diff --git a/Dockerfile b/Dockerfile index 5e565bc..125468a 100644 --- a/Dockerfile +++ b/Dockerfile @@ -14,4 +14,4 @@ RUN pip install --no-cache-dir -r requirements.txt EXPOSE 4000 # Run the application with Gunicorn -CMD ["gunicorn", "--worker-class", "eventlet", "-w", "1", "-b", "0.0.0.0:4000", "movie_selector:app"] +CMD ["gunicorn", "-k", "eventlet", "-w", "1", "-b", "0.0.0.0:4000", "movie_selector:app"] diff --git a/movie_selector.py b/movie_selector.py index 975ff31..d1df113 100644 --- a/movie_selector.py +++ b/movie_selector.py @@ -6,9 +6,15 @@ import traceback import threading import time +from datetime import datetime, timedelta +import pytz from flask import Flask, jsonify, render_template, send_from_directory, request, session from flask_socketio import SocketIO, emit from utils.cache_manager import CacheManager +from utils.poster_view import set_current_movie, poster_bp, init_socket +from utils.default_poster_manager import init_default_poster_manager, default_poster_manager +from utils.playback_monitor import PlaybackMonitor +from utils.fetch_movie_links import fetch_movie_links logging.basicConfig(level=logging.DEBUG) logger = logging.getLogger(__name__) @@ -16,9 +22,16 @@ app = Flask(__name__, static_folder='static', template_folder='web') app.secret_key = 'your_secret_key_here' # Replace with a real secret key socketio = SocketIO(app) +init_socket(socketio) + +# Initialize the default poster manager +default_poster_manager = init_default_poster_manager(socketio) + +# Add the default_poster_manager to the app config +app.config['DEFAULT_POSTER_MANAGER'] = default_poster_manager # Check which services are available -PLEX_AVAILABLE = all([os.getenv('PLEX_URL'), os.getenv('PLEX_TOKEN'), os.getenv('MOVIES_LIBRARY_NAME')]) +PLEX_AVAILABLE = all([os.getenv('PLEX_URL'), os.getenv('PLEX_TOKEN'), os.getenv('PLEX_MOVIE_LIBRARIES')]) JELLYFIN_AVAILABLE = all([os.getenv('JELLYFIN_URL'), os.getenv('JELLYFIN_API_KEY')]) HOMEPAGE_MODE = os.getenv('HOMEPAGE_MODE', 'FALSE').upper() == 'TRUE' @@ -33,6 +46,7 @@ plex = PlexService() cache_manager = CacheManager(plex, cache_file_path) cache_manager.start() + app.config['PLEX_SERVICE'] = plex else: plex = None cache_manager = None @@ -40,6 +54,7 @@ if JELLYFIN_AVAILABLE: from utils.jellyfin_service import JellyfinService jellyfin = JellyfinService() + app.config['JELLYFIN_SERVICE'] = jellyfin else: jellyfin = None @@ -51,6 +66,13 @@ movies_loaded_from_cache = False loading_in_progress = False +# Register the poster blueprint +app.register_blueprint(poster_bp) + +# Start the PlaybackMonitor +playback_monitor = PlaybackMonitor(app, interval=10) +playback_monitor.start() + def get_available_service(): if PLEX_AVAILABLE: return 'plex' @@ -121,12 +143,12 @@ def get_available_services(): def get_current_service(): if 'current_service' not in session or session['current_service'] not in ['plex', 'jellyfin']: session['current_service'] = get_available_service() - + # Check if the current service is still available if (session['current_service'] == 'plex' and not PLEX_AVAILABLE) or \ (session['current_service'] == 'jellyfin' and not JELLYFIN_AVAILABLE): session['current_service'] = get_available_service() - + return jsonify({"service": session['current_service']}) @app.route('/switch_service') @@ -156,12 +178,12 @@ def random_movie(): movie_data = jellyfin.get_random_movie() else: return jsonify({"error": "No available media service"}), 400 - + if movie_data: movie_data = enrich_movie_data(movie_data) return jsonify({ - "service": current_service, - "movie": movie_data, + "service": current_service, + "movie": movie_data, "cache_loaded": movies_loaded_from_cache, "loading_in_progress": loading_in_progress }) @@ -187,7 +209,7 @@ def filter_movies(): movie_data = jellyfin.filter_movies(genre, year, pg_rating) else: return jsonify({"error": "No available media service"}), 400 - + if movie_data: logger.debug(f"Filtered movie: {movie_data['title']} ({movie_data['year']}) - PG Rating: {movie_data.get('contentRating', 'N/A')}") movie_data = enrich_movie_data(movie_data) @@ -230,7 +252,7 @@ def next_movie(): movie_data = jellyfin.filter_movies(genre, year, pg_rating) else: return jsonify({"error": "No available media service"}), 400 - + if movie_data: logger.debug(f"Next movie selected: {movie_data['title']} ({movie_data['year']}) - PG Rating: {movie_data.get('contentRating', 'N/A')}") movie_data = enrich_movie_data(movie_data) @@ -246,14 +268,14 @@ def enrich_movie_data(movie_data): current_service = session.get('current_service', get_available_service()) tmdb_url, trakt_url, imdb_url = fetch_movie_links(movie_data, current_service) trailer_url = search_youtube_trailer(movie_data['title'], movie_data['year']) - + movie_data.update({ "tmdb_url": tmdb_url, "trakt_url": trakt_url, "imdb_url": imdb_url, "trailer_url": trailer_url }) - + return movie_data @app.route('/get_genres') @@ -335,10 +357,18 @@ def play_movie(client_id): try: if current_service == 'plex' and PLEX_AVAILABLE: result = plex.play_movie(movie_id, client_id) + if result.get("status") == "playing": + movie_data = plex.get_movie_by_id(movie_id) elif current_service == 'jellyfin' and JELLYFIN_AVAILABLE: result = jellyfin.play_movie(movie_id, client_id) + if result.get("status") == "playing": + movie_data = jellyfin.get_movie_by_id(movie_id) else: return jsonify({"error": "No available media service"}), 400 + + if result.get("status") == "playing" and movie_data: + set_current_movie(movie_data, current_service) + logger.debug(f"Play movie result for {current_service}: {result}") return jsonify(result) except Exception as e: @@ -418,6 +448,14 @@ def trigger_resync(): resync_cache() return jsonify({"status": "Cache resync completed"}) +@socketio.on('connect', namespace='/poster') +def poster_connect(): + print('Client connected to poster namespace') + +@socketio.on('disconnect', namespace='/poster') +def poster_disconnect(): + print('Client disconnected from poster namespace') + if __name__ == '__main__': logger.info("Application starting") logger.info("Application setup complete") diff --git a/requirements.txt b/requirements.txt index 3e23acf..739ea48 100644 --- a/requirements.txt +++ b/requirements.txt @@ -8,3 +8,4 @@ gunicorn pywebostv flask-socketio eventlet +pytz diff --git a/static/images/default_poster.png b/static/images/default_poster.png new file mode 100644 index 0000000..4d0d3f1 Binary files /dev/null and b/static/images/default_poster.png differ diff --git a/static/js/poster.js b/static/js/poster.js new file mode 100644 index 0000000..662826f --- /dev/null +++ b/static/js/poster.js @@ -0,0 +1,206 @@ +const posterContainer = document.getElementById('posterContainer'); +const progressBar = document.getElementById('progress-bar'); +const startTimeElement = document.getElementById('start-time'); +const endTimeElement = document.getElementById('end-time'); +const playbackStatusElement = document.getElementById('playback-status'); +const posterImage = document.getElementById('poster-image'); +const contentRatingElement = document.getElementById('content-rating'); +const videoFormatElement = document.getElementById('video-format'); +const audioFormatElement = document.getElementById('audio-format'); +const customTextContainer = document.getElementById('custom-text-container'); +const customText = document.getElementById('custom-text'); + +let startTime; +let currentStatus = 'UNKNOWN'; // Track current status +let playbackInterval; + +// Socket.IO connection +const socket = io('/poster'); + +socket.on('connect', function() { + console.log('Connected to WebSocket'); +}); + +socket.on('movie_changed', function(data) { + console.log('Movie changed:', data); + isDefaultPoster = false; + updatePoster(data); + updatePosterDisplay(); +}); + +socket.on('set_default_poster', function(data) { + console.log('Setting default poster:', data); + posterImage.src = data.poster; + isDefaultPoster = true; + updatePosterDisplay(); + clearMovieInfo(); +}); + +function updatePosterDisplay() { + if (isDefaultPoster) { + posterContainer.classList.add('default-poster'); + customTextContainer.style.display = 'flex'; + adjustCustomText(); + } else { + posterContainer.classList.remove('default-poster'); + customTextContainer.style.display = 'none'; + } +} + +function adjustCustomText() { + const container = customTextContainer; + const textElement = customText; + let fontSize = 100; // Start with a large font size + + textElement.style.fontSize = fontSize + 'px'; + + // Reduce font size until text fits within the container + while ((textElement.scrollWidth > container.clientWidth || textElement.scrollHeight > container.clientHeight) && fontSize > 10) { + fontSize -= 1; + textElement.style.fontSize = fontSize + 'px'; + } +} + +function updatePoster(movieData) { + document.title = `Now Playing - ${movieData.movie.title}`; + movieId = movieData.movie.id; + movieDuration = movieData.duration_hours * 3600 + movieData.duration_minutes * 60; + posterImage.src = movieData.movie.poster; + contentRatingElement.textContent = movieData.movie.contentRating; + videoFormatElement.textContent = movieData.movie.videoFormat; + audioFormatElement.textContent = movieData.movie.audioFormat; + startTime = new Date(movieData.start_time); + updateTimes(movieData.start_time, 0, 'PLAYING'); // Assume status is PLAYING initially + updatePlaybackStatus('playing'); + clearInterval(playbackInterval); + playbackInterval = setInterval(fetchPlaybackState, 2000); +} + +function clearMovieInfo() { + document.title = 'Now Playing'; + movieId = null; + movieDuration = 0; + contentRatingElement.textContent = ''; + videoFormatElement.textContent = ''; + audioFormatElement.textContent = ''; + startTimeElement.textContent = '--:--'; + endTimeElement.textContent = '--:--'; + progressBar.style.width = '0%'; + clearInterval(playbackInterval); +} + +function updateProgress(position) { + if (movieDuration > 0) { + const progress = (position / movieDuration) * 100; + progressBar.style.width = `${progress}%`; + } else { + progressBar.style.width = '0%'; + } +} + +function updateTimes(start, position, status) { + if (status === 'STOPPED') { + startTimeElement.textContent = '--:--'; + endTimeElement.textContent = '--:--'; + return; + } + + const formatTime = (time) => new Date(time).toLocaleTimeString([], {hour: 'numeric', minute:'2-digit', hour12: true}).replace('am', 'AM').replace('pm', 'PM'); + + if (!startTime) { + startTime = new Date(start); + } + startTimeElement.textContent = formatTime(startTime); + + const currentTime = new Date(); + const remainingDuration = movieDuration - position; + const newEndTime = new Date(currentTime.getTime() + remainingDuration * 1000); + endTimeElement.textContent = formatTime(newEndTime); +} + +function updatePlaybackStatus(status) { + playbackStatusElement.classList.remove('paused', 'ended', 'stopped'); + + switch(status.toLowerCase()) { + case 'playing': + playbackStatusElement.textContent = "NOW PLAYING"; + playbackStatusElement.classList.remove('paused', 'ended', 'stopped'); + currentStatus = 'PLAYING'; + break; + case 'paused': + playbackStatusElement.textContent = "PAUSED"; + playbackStatusElement.classList.add('paused'); + playbackStatusElement.classList.remove('ended', 'stopped'); + currentStatus = 'PAUSED'; + break; + case 'ended': + playbackStatusElement.textContent = "ENDED"; + playbackStatusElement.classList.add('ended'); + playbackStatusElement.classList.remove('paused', 'stopped'); + currentStatus = 'ENDED'; + break; + case 'stopped': + playbackStatusElement.textContent = "STOPPED"; + playbackStatusElement.classList.add('stopped'); + playbackStatusElement.classList.remove('paused', 'ended'); + currentStatus = 'STOPPED'; + break; + default: + playbackStatusElement.textContent = status.toUpperCase(); + playbackStatusElement.classList.remove('paused', 'ended', 'stopped'); + currentStatus = 'UNKNOWN'; + } + + // If status is STOPPED, set times to --:-- + if (currentStatus === 'STOPPED') { + startTimeElement.textContent = '--:--'; + endTimeElement.textContent = '--:--'; + } +} + +function fetchPlaybackState() { + if (!movieId) { + return; + } + fetch(`/playback_state/${movieId}`) + .then(response => response.json()) + .then(data => { + if (data.error) { + console.error('Playback state error:', data.error); + return; + } + updateProgress(data.position); + updatePlaybackStatus(data.status); + + if (data.status.toUpperCase() === 'STOPPED') { + // Set times to --:-- + startTimeElement.textContent = '--:--'; + endTimeElement.textContent = '--:--'; + } else { + updateTimes(data.start_time, data.position, data.status.toUpperCase()); + } + }) + .catch(error => { + console.error('Error:', error); + fetch('/current_poster') + .then(response => response.json()) + .then(data => { + posterImage.src = data.poster; + isDefaultPoster = data.poster === defaultPosterUrl; + updatePosterDisplay(); + clearMovieInfo(); + }) + .catch(error => console.error('Error fetching current poster:', error)); + }); +} + +function initialize() { + updatePosterDisplay(); + if (!isDefaultPoster && movieId) { + fetchPlaybackState(); + playbackInterval = setInterval(fetchPlaybackState, 2000); + } +} + +window.addEventListener('load', initialize); +window.addEventListener('resize', adjustCustomText); diff --git a/static/style/poster.css b/static/style/poster.css new file mode 100644 index 0000000..ce9f683 --- /dev/null +++ b/static/style/poster.css @@ -0,0 +1,147 @@ +body, html { + margin: 0; + padding: 0; + height: 100vh; + background-color: #000; + color: #FFD700; + font-family: Arial, sans-serif; + display: flex; + justify-content: center; + align-items: center; + overflow: hidden; +} +.poster-container { + position: relative; + width: 100%; + height: 100vh; + display: flex; + flex-direction: column; +} +.info-bar { + display: flex; + justify-content: space-between; + align-items: center; + background-color: rgba(0, 0, 0, 0.7); + padding: 10px; + width: 100%; + box-sizing: border-box; +} +.time-info { + display: flex; + flex-direction: column; + align-items: flex-start; +} +.time-info.end { + align-items: flex-end; +} +.time-label { + font-size: 0.8em; +} +.time { + font-size: 1.2em; +} +.playback-status { + font-size: 1.5em; + font-weight: bold; + text-align: center; + flex-grow: 1; +} +.paused { color: #FFA500; } +.ended { color: #FF4500; } +.stopped { color: #DC143C; } +.progress-container { + width: 100%; + background-color: #333; + height: 5px; +} +.progress-bar { + width: 0%; + height: 100%; + background-color: #FFD700; + transition: width 0.5s ease; +} +.poster-image { + max-width: 100%; + max-height: calc(100vh - 120px); + object-fit: contain; +} +.bottom-info { + display: flex; + justify-content: space-between; + padding: 10px; + background-color: rgba(0, 0, 0, 0.7); + width: 100%; + box-sizing: border-box; +} +.info-item { + text-align: center; +} +.info-label { + font-size: 0.8em; + display: block; +} +.info-value { + font-size: 1.2em; +} + +/* Custom text styles */ +.custom-text-container { + position: absolute; + top: 24.475%; /* Adjusted top% */ + left: 30.62%; /* Adjusted left% */ + width: 38.96%; /* Adjusted width% */ + height: 17.04%; /* Adjusted height% */ + display: none; /* Hidden by default; will be shown when needed */ + justify-content: center; + align-items: center; + text-align: center; + overflow: hidden; +} +.custom-text { + color: white; + line-height: 1.2; + white-space: pre-wrap; + text-shadow: 2px 2px 4px rgba(0,0,0,0.5); + word-break: break-word; + margin: 0; +} +/* Media queries for font size adjustments */ +@media (orientation: portrait) { + .custom-text { + font-size: 3vw; + } +} +@media (orientation: landscape) { + .custom-text { + font-size: 2vw; + } +} + +/* New media query for portrait orientation to maintain same dimensions as landscape */ +@media (orientation: portrait) { + .custom-text-container { + top: 24.475%; /* Same as landscape */ + left: 30.62%; /* Same as landscape */ + width: 38.96%; /* Same as landscape */ + height: 17.04%; /* Same as landscape */ + } +} + +/* Additional media queries for smaller screens (e.g., phones) */ +@media (orientation: portrait) and (max-width: 600px) { + .custom-text-container { + top: 20%; /* Adjusted for smaller screens */ + left: 25%; /* Adjusted for smaller screens */ + width: 50%; /* Adjusted for smaller screens */ + height: 20%; /* Adjusted for smaller screens */ + } +} + +.default-poster .info-bar, +.default-poster .progress-container, +.default-poster .bottom-info { + display: none; +} +.default-poster .poster-image { + max-height: 100vh; +} diff --git a/utils/cache_manager.py b/utils/cache_manager.py index 83d2793..ce4bd79 100644 --- a/utils/cache_manager.py +++ b/utils/cache_manager.py @@ -28,7 +28,7 @@ def _update_loop(self): def update_cache(self): try: - new_unwatched_movies = list(self.plex_service.get_all_unwatched_movies()) + new_unwatched_movies = self.plex_service.get_all_unwatched_movies() # Read the current cache if os.path.exists(self.cache_file_path): diff --git a/utils/default_poster_manager.py b/utils/default_poster_manager.py new file mode 100644 index 0000000..127095f --- /dev/null +++ b/utils/default_poster_manager.py @@ -0,0 +1,97 @@ +import time +import threading +import json +import os +import logging +from flask_socketio import SocketIO + +logging.basicConfig(level=logging.DEBUG) +logger = logging.getLogger(__name__) + +class DefaultPosterManager: + def __init__(self, socketio): + self.socketio = socketio + self.default_poster_timer = None + self.last_state = None + self.state_change_time = None + self.lock = threading.Lock() + self.current_movie_file = '/app/data/current_movie.json' + self.default_poster = '/static/images/default_poster.png' + self.is_default_poster_active = False + logger.info("DefaultPosterManager initialized") + + def start_default_poster_timer(self, state): + with self.lock: + if self.last_state == state: + logger.debug(f"State '{state}' unchanged, ignoring update") + return + + if self.default_poster_timer: + self.default_poster_timer.cancel() + + self.last_state = state + self.state_change_time = time.time() + self.default_poster_timer = threading.Timer(300, self.set_default_poster) + self.default_poster_timer.start() + + def cancel_default_poster_timer(self): + with self.lock: + if self.default_poster_timer: + self.default_poster_timer.cancel() + self.default_poster_timer = None + self.last_state = None + self.state_change_time = None + self.is_default_poster_active = False + + def set_default_poster(self): + logger.debug("Setting default poster") + with self.lock: + if self.last_state in ['STOPPED', 'ENDED'] and time.time() - self.state_change_time >= 300: + self.clear_current_movie() + self.is_default_poster_active = True + self.socketio.emit('set_default_poster', {'poster': self.default_poster}, namespace='/poster') + logger.info("Default poster set and emitted") + + def clear_current_movie(self): + logger.debug("Clearing current movie") + if os.path.exists(self.current_movie_file): + os.remove(self.current_movie_file) + logger.info(f"Removed current movie file: {self.current_movie_file}") + + def handle_playback_state(self, state): + if state in ['STOPPED', 'ENDED']: + self.start_default_poster_timer(state) + else: + if self.last_state != state: + self.cancel_default_poster_timer() + + def get_current_poster(self): + logger.debug("Getting current poster") + with self.lock: + if self.is_default_poster_active: + logger.info("Returning default poster") + return self.default_poster + if os.path.exists(self.current_movie_file): + with open(self.current_movie_file, 'r') as f: + current_movie = json.load(f) + logger.info(f"Returning movie poster: {current_movie['movie']['poster']}") + return current_movie['movie']['poster'] + logger.info("No current movie, returning default poster") + return self.default_poster + + def reset_state(self): + logger.debug("Resetting DefaultPosterManager state") + self.cancel_default_poster_timer() + self.is_default_poster_active = False + self.last_state = None + self.state_change_time = None + logger.info("DefaultPosterManager state reset") + +default_poster_manager = None + +def init_default_poster_manager(socketio): + global default_poster_manager + if default_poster_manager is None: + default_poster_manager = DefaultPosterManager(socketio) + logger.info("DefaultPosterManager initialized") + return default_poster_manager diff --git a/utils/fetch_movie_links.py b/utils/fetch_movie_links.py index e7f2c36..5b147c1 100644 --- a/utils/fetch_movie_links.py +++ b/utils/fetch_movie_links.py @@ -1,8 +1,9 @@ import os import requests +from plexapi.server import PlexServer # Check which services are available -PLEX_AVAILABLE = all([os.getenv('PLEX_URL'), os.getenv('PLEX_TOKEN'), os.getenv('MOVIES_LIBRARY_NAME')]) +PLEX_AVAILABLE = all([os.getenv('PLEX_URL'), os.getenv('PLEX_TOKEN'), os.getenv('PLEX_MOVIE_LIBRARIES')]) JELLYFIN_AVAILABLE = all([os.getenv('JELLYFIN_URL'), os.getenv('JELLYFIN_API_KEY')]) if not (PLEX_AVAILABLE or JELLYFIN_AVAILABLE): @@ -10,45 +11,37 @@ # Initialize Plex if available if PLEX_AVAILABLE: - from plexapi.server import PlexServer PLEX_URL = os.getenv('PLEX_URL') PLEX_TOKEN = os.getenv('PLEX_TOKEN') - MOVIES_LIBRARY_NAME = os.getenv('MOVIES_LIBRARY_NAME', 'Movies') # Default to 'Movies' if not set + PLEX_MOVIE_LIBRARIES = os.getenv('PLEX_MOVIE_LIBRARIES', 'Movies').split(',') plex = PlexServer(PLEX_URL, PLEX_TOKEN) def get_tmdb_id_from_plex(movie_data): if not PLEX_AVAILABLE: return None try: - movies_library = plex.library.section(MOVIES_LIBRARY_NAME) - movie = movies_library.get(movie_data['title']) - for guid in movie.guids: - if 'tmdb://' in guid.id: - return guid.id.split('//')[1] + for library_name in PLEX_MOVIE_LIBRARIES: + try: + library = plex.library.section(library_name.strip()) + movie = library.get(movie_data['title']) + for guid in movie.guids: + if 'tmdb://' in guid.id: + return guid.id.split('//')[1] + except Exception: + continue # If movie not found in this library, try the next one except Exception as e: print(f"Error getting TMDB ID from Plex: {e}") return None -def fetch_movie_links_plex(movie_data): - if not PLEX_AVAILABLE: - return None, None, None - tmdb_id = get_tmdb_id_from_plex(movie_data) - return fetch_movie_links_from_tmdb_id(tmdb_id) - -def fetch_movie_links_jellyfin(movie_data): - if not JELLYFIN_AVAILABLE: - return None, None, None +def get_tmdb_id_from_jellyfin(movie_data): provider_ids = movie_data.get('ProviderIds', {}) - tmdb_id = provider_ids.get('Tmdb') - return fetch_movie_links_from_tmdb_id(tmdb_id) + return provider_ids.get('Tmdb') def fetch_movie_links_from_tmdb_id(tmdb_id): if not tmdb_id: return None, None, None - tmdb_url = f"https://www.themoviedb.org/movie/{tmdb_id}" trakt_api_url = f"https://api.trakt.tv/search/tmdb/{tmdb_id}?type=movie" - try: response = requests.get(trakt_api_url) if response.status_code == 200: @@ -61,14 +54,16 @@ def fetch_movie_links_from_tmdb_id(tmdb_id): return tmdb_url, trakt_url, imdb_url except requests.RequestException as e: print(f"Error fetching Trakt data: {e}") - return tmdb_url, None, None def fetch_movie_links(movie_data, service): + tmdb_id = None if service == 'plex' and PLEX_AVAILABLE: - return fetch_movie_links_plex(movie_data) + tmdb_id = get_tmdb_id_from_plex(movie_data) elif service == 'jellyfin' and JELLYFIN_AVAILABLE: - return fetch_movie_links_jellyfin(movie_data) + tmdb_id = get_tmdb_id_from_jellyfin(movie_data) else: print(f"Unsupported or unavailable service: {service}") return None, None, None + + return fetch_movie_links_from_tmdb_id(tmdb_id) diff --git a/utils/jellyfin_service.py b/utils/jellyfin_service.py index ee1cc44..425a656 100644 --- a/utils/jellyfin_service.py +++ b/utils/jellyfin_service.py @@ -2,6 +2,7 @@ import requests import json import logging +from datetime import datetime, timedelta logging.basicConfig(level=logging.DEBUG) logger = logging.getLogger(__name__) @@ -15,6 +16,7 @@ def __init__(self): 'X-Emby-Token': self.api_key, 'Content-Type': 'application/json' } + self.playback_start_times = {} def get_random_movie(self): try: @@ -30,7 +32,7 @@ def get_random_movie(self): response = requests.get(movies_url, headers=self.headers, params=params) response.raise_for_status() movies = response.json() - + if movies.get('Items'): movie_data = self.get_movie_data(movies['Items'][0]) logger.debug(f"Fetched unwatched movie data: {movie_data}") @@ -60,16 +62,16 @@ def filter_movies(self, genre=None, year=None, pg_rating=None): params['OfficialRatings'] = pg_rating logger.debug(f"Jellyfin API request params: {params}") - + response = requests.get(movies_url, headers=self.headers, params=params) response.raise_for_status() movies = response.json().get('Items', []) - + logger.debug(f"Jellyfin API returned {len(movies)} movies") - + if movies: return self.get_movie_data(movies[0]) - + logger.warning("No unwatched movies found matching the criteria") return None except Exception as e: @@ -82,7 +84,91 @@ def get_movie_data(self, movie): hours = total_minutes // 60 minutes = total_minutes % 60 + # Extract video format information + video_format = "Unknown" + audio_format = "Unknown" + + if 'MediaSources' in movie and movie['MediaSources']: + media_sources = movie['MediaSources'] + if media_sources and isinstance(media_sources, list): + media_source = media_sources[0] + + if 'MediaStreams' in media_source: + # Video format extraction + video_streams = [s for s in media_source['MediaStreams'] if s['Type'] == 'Video'] + if video_streams: + video_stream = video_streams[0] + height = video_stream.get('Height', 0) + + if height <= 480: + resolution = "SD" + elif height <= 720: + resolution = "HD" + elif height <= 1080: + resolution = "FHD" + elif height > 1080: + resolution = "4K" + else: + resolution = "Unknown" + + hdr_types = [] + if video_stream.get('VideoRange') == 'HDR': + if 'DV' in video_stream.get('Title', ''): + hdr_types.append('DV') + if video_stream.get('VideoRangeType') == 'HDR10': + hdr_types.append('HDR10') + elif video_stream.get('VideoRangeType') == 'HDR10+': + hdr_types.append('HDR10+') + elif not hdr_types: # If no specific type is identified, just add HDR + hdr_types.append('HDR') + + video_format_parts = [resolution] + if hdr_types: + video_format_parts.append('/'.join(hdr_types)) + + video_format = ' '.join(video_format_parts) + + # Audio format extraction + audio_streams = [s for s in media_source['MediaStreams'] if s['Type'] == 'Audio'] + if audio_streams: + audio_stream = audio_streams[0] + + # Start with the Profile information + profile = audio_stream.get('Profile', '') + if profile: + # Split the profile into its components + profile_parts = [part.strip() for part in profile.split('+')] + + # Remove redundant "Dolby" from TrueHD if Atmos is present + if "Dolby TrueHD" in profile_parts and "Dolby Atmos" in profile_parts: + profile_parts = ["TrueHD" if part == "Dolby TrueHD" else part for part in profile_parts] + + audio_format = ' + '.join(profile_parts) + else: + # Fallback to Codec if Profile is not available + codec = audio_stream.get('Codec', '').upper() + codec_map = { + 'AC3': 'Dolby Digital', + 'EAC3': 'Dolby Digital Plus', + 'TRUEHD': 'Dolby TrueHD', + 'DTS': 'DTS', + 'DTSHD': 'DTS-HD', + 'AAC': 'AAC', + 'FLAC': 'FLAC' + } + audio_format = codec_map.get(codec, codec) + + # Add layout if available and not already included + layout = audio_stream.get('Layout', '') + if layout and layout not in audio_format: + audio_format += f" {layout}" + + # Remove any duplicate words + parts = audio_format.split() + audio_format = ' '.join(dict.fromkeys(parts)) + return { + "id": movie.get('Id', ''), "title": movie.get('Name', ''), "year": movie.get('ProductionYear', ''), "duration_hours": hours, @@ -94,9 +180,10 @@ def get_movie_data(self, movie): "genres": movie.get('Genres', []), "poster": f"{self.server_url}/Items/{movie['Id']}/Images/Primary?api_key={self.api_key}", "background": f"{self.server_url}/Items/{movie['Id']}/Images/Backdrop?api_key={self.api_key}", - "id": movie.get('Id', ''), "ProviderIds": movie.get('ProviderIds', {}), - "contentRating": movie.get('OfficialRating', '') + "contentRating": movie.get('OfficialRating', ''), + "videoFormat": video_format, + "audioFormat": audio_format, } def get_genres(self): @@ -107,15 +194,15 @@ def get_genres(self): 'Fields': 'Genres', 'IncludeItemTypes': 'Movie' } - + response = requests.get(items_url, headers=self.headers, params=params) response.raise_for_status() data = response.json() - + all_genres = set() for item in data.get('Items', []): all_genres.update(item.get('Genres', [])) - + genre_list = sorted(list(all_genres)) logger.debug(f"Extracted genre list: {genre_list}") return genre_list @@ -168,15 +255,63 @@ def get_pg_ratings(self): logger.error(f"Error fetching PG ratings: {e}") return [] + def get_playback_info(self, item_id): + try: + sessions_url = f"{self.server_url}/Sessions" + response = requests.get(sessions_url, headers=self.headers) + response.raise_for_status() + sessions = response.json() + for session in sessions: + if session.get('NowPlayingItem', {}).get('Id') == item_id: + playstate = session.get('PlayState', {}) + position_ticks = playstate.get('PositionTicks', 0) + is_paused = playstate.get('IsPaused', False) + + position_seconds = position_ticks / 10_000_000 + total_duration = session['NowPlayingItem']['RunTimeTicks'] / 10_000_000 + + # Use stored start time or current time if not available + if item_id not in self.playback_start_times: + self.playback_start_times[item_id] = datetime.now() + + start_time = self.playback_start_times[item_id] + end_time = start_time + timedelta(seconds=total_duration) + + # Check if the session is inactive or if NowPlayingItem is None + is_stopped = session.get('PlayState', {}).get('PlayMethod') is None or session.get('NowPlayingItem') is None + + return { + 'is_playing': not is_paused and not is_stopped, + 'IsPaused': is_paused, + 'IsStopped': is_stopped, + 'position': position_seconds, + 'start_time': start_time.isoformat(), + 'end_time': end_time.isoformat(), + 'duration': total_duration + } + # If we didn't find a matching session, the movie is stopped + return { + 'is_playing': False, + 'IsPaused': False, + 'IsStopped': True, + 'position': 0, + 'start_time': None, + 'end_time': None, + 'duration': 0 + } + except requests.RequestException as e: + logger.error(f"Error fetching playback info: {e}") + return None + def get_clients(self): try: sessions_url = f"{self.server_url}/Sessions" response = requests.get(sessions_url, headers=self.headers) response.raise_for_status() sessions = response.json() - + logger.debug(f"Raw sessions data: {json.dumps(sessions, indent=2)}") - + castable_clients = [] for session in sessions: if session.get('SupportsRemoteControl', False) and session.get('DeviceName') != 'Jellyfin Server': @@ -189,18 +324,65 @@ def get_clients(self): "supports_media_control": session.get('SupportsMediaControl', False), } castable_clients.append(client) - + if not castable_clients: logger.warning("No castable clients found.") else: logger.info(f"Found {len(castable_clients)} castable clients") - + logger.debug(f"Fetched castable clients: {json.dumps(castable_clients, indent=2)}") return castable_clients except Exception as e: logger.error(f"Error fetching clients: {e}") return [] + def get_movie_by_id(self, movie_id): + try: + item_url = f"{self.server_url}/Users/{self.user_id}/Items/{movie_id}" + params = { + 'Fields': 'Overview,People,Genres,RunTimeTicks,ProviderIds,UserData,OfficialRating' + } + response = requests.get(item_url, headers=self.headers, params=params) + response.raise_for_status() + movie = response.json() + return self.get_movie_data(movie) + except Exception as e: + logger.error(f"Error fetching movie by ID: {e}") + return None + + def get_current_playback(self): + try: + sessions_url = f"{self.server_url}/Sessions" + response = requests.get(sessions_url, headers=self.headers) + response.raise_for_status() + sessions_json = response.json() + logger.debug(f"Sessions JSON Response: {json.dumps(sessions_json, indent=2)}") + + # Check if the response is a list + if isinstance(sessions_json, list): + sessions = sessions_json + elif isinstance(sessions_json, dict): + sessions = sessions_json.get('Items', []) + else: + logger.error("Unexpected JSON structure for sessions.") + return None + + for session in sessions: + if not isinstance(session, dict): + logger.warning("Session item is not a dictionary. Skipping.") + continue + + now_playing = session.get('NowPlayingItem') + if now_playing: + return { + 'id': now_playing.get('Id'), + 'position': session.get('PlayState', {}).get('PositionTicks', 0) / 10_000_000 # Convert ticks to seconds + } + return None + except Exception as e: + logger.error(f"Error fetching current playback: {e}") + return None + def play_movie(self, movie_id, session_id): try: playback_url = f"{self.server_url}/Sessions/{session_id}/Playing" @@ -212,47 +394,62 @@ def play_movie(self, movie_id, session_id): response.raise_for_status() logger.debug(f"Playing movie {movie_id} on session {session_id}") logger.debug(f"Response: {response.text}") - return {"status": "playing", "response": response.text} + + # Set the start time for the movie + self.playback_start_times[movie_id] = datetime.now() + + # Fetch movie data + movie_data = self.get_movie_by_id(movie_id) + if movie_data: + start_time = self.playback_start_times[movie_id] + end_time = start_time + timedelta(hours=movie_data['duration_hours'], minutes=movie_data['duration_minutes']) + + from flask import session + session['current_movie'] = movie_data + session['movie_start_time'] = start_time.isoformat() + session['movie_end_time'] = end_time.isoformat() + session['current_service'] = 'jellyfin' + + return { + "status": "playing", + "response": response.text, + "movie_id": movie_id, + "session_id": session_id, + "start_time": self.playback_start_times[movie_id].isoformat(), + "movie_data": movie_data + } except Exception as e: logger.error(f"Error playing movie: {e}") return {"error": str(e)} -# For testing purposes -if __name__ == "__main__": - jellyfin = JellyfinService() - - print("\nFetching random unwatched movie:") - movie = jellyfin.get_random_movie() - if movie: - print(f"Title: {movie['title']}") - print(f"Year: {movie['year']}") - print(f"Directors: {', '.join(movie['directors'])}") - print(f"Writers: {', '.join(movie['writers'])}") - print(f"Actors: {', '.join(movie['actors'])}") - print(f"Genres: {', '.join(movie['genres'])}") - else: - print("No unwatched movie found") - - # Test clients - print("\nFetching castable clients:") - clients = jellyfin.get_clients() - print(json.dumps(clients, indent=2)) - - # Test playing a movie - if clients and movie: - print("\nAvailable castable clients:") - for i, client in enumerate(clients): - print(f"{i+1}. {client['title']} ({client['client']}) - ID: {client['id']}") - - choice = int(input("Choose a client number to play the movie (or 0 to skip): ")) - 1 - if 0 <= choice < len(clients): - selected_client = clients[choice] - print(f"\nAttempting to play movie {movie['title']} on {selected_client['title']} (Session ID: {selected_client['id']})") - result = jellyfin.play_movie(movie['id'], selected_client['id']) - print(result) - elif choice != -1: - print("Invalid client selection.") - elif not clients: - print("No castable clients available to play on.") - elif not movie: - print("No movie available to play.") + def get_active_sessions(self): + try: + sessions_url = f"{self.server_url}/Sessions" + response = requests.get(sessions_url, headers=self.headers) + response.raise_for_status() + sessions = response.json() + + active_sessions = [] + for session in sessions: + if session.get('NowPlayingItem'): + active_sessions.append(session) + + logger.debug(f"Found {len(active_sessions)} active Jellyfin sessions") + return active_sessions + except Exception as e: + logger.error(f"Error fetching active Jellyfin sessions: {e}") + return [] + + def get_current_username(self): + try: + sessions_url = f"{self.server_url}/Sessions" + response = requests.get(sessions_url, headers=self.headers) + response.raise_for_status() + sessions = response.json() + for session in sessions: + if session.get('NowPlayingItem'): + return session.get('UserName') + return None + except Exception as e: + logger.error(f"Error getting current Jellyfin username: {e}") + return None diff --git a/utils/playback_monitor.py b/utils/playback_monitor.py new file mode 100644 index 0000000..9189c15 --- /dev/null +++ b/utils/playback_monitor.py @@ -0,0 +1,110 @@ +# utils/playback_monitor.py +import threading +import time +import logging +import os +from flask import current_app +from utils.jellyfin_service import JellyfinService +from utils.plex_service import PlexService +from utils.poster_view import set_current_movie + +logging.basicConfig(level=logging.DEBUG) +logger = logging.getLogger(__name__) + +class PlaybackMonitor(threading.Thread): + def __init__(self, app, interval=10): + super().__init__() + self.interval = interval + self.jellyfin_service = JellyfinService() + self.plex_service = None + if 'PLEX_SERVICE' in app.config: + self.plex_service = app.config['PLEX_SERVICE'] + self.current_movie_id = None + self.running = True + self.app = app + self.plex_poster_users = os.getenv('PLEX_POSTER_USERS', '').split(',') + self.jellyfin_poster_users = os.getenv('JELLYFIN_POSTER_USERS', '').split(',') + logger.info(f"Initialized PlaybackMonitor with Plex poster users: {self.plex_poster_users}") + logger.info(f"Initialized PlaybackMonitor with Jellyfin poster users: {self.jellyfin_poster_users}") + + def is_poster_user(self, username, service): + if service == 'plex': + is_authorized = username in self.plex_poster_users + elif service == 'jellyfin': + is_authorized = username in self.jellyfin_poster_users + else: + is_authorized = False + logger.debug(f"Checking if {username} is a {service} poster user: {is_authorized}") + return is_authorized + + def run(self): + while self.running: + try: + with self.app.app_context(): + playback_info = None + service = None + username = None + + # Check Jellyfin + jellyfin_sessions = self.jellyfin_service.get_active_sessions() + for session in jellyfin_sessions: + now_playing = session.get('NowPlayingItem', {}) + if now_playing.get('Type') == 'Movie': + username = session.get('UserName') + if self.is_poster_user(username, 'jellyfin'): + playback_info = { + 'id': now_playing.get('Id'), + 'position': session.get('PlayState', {}).get('PositionTicks', 0) / 10_000_000 + } + service = 'jellyfin' + break + else: + logger.info(f"Jellyfin user {username} is playing a movie but not authorized for poster updates.") + else: + logger.debug(f"Jellyfin user is playing non-movie content. Ignoring.") + + # If no authorized Jellyfin playback, check Plex + if not playback_info and self.plex_service: + sessions = self.plex_service.plex.sessions() + for session in sessions: + if session.type == 'movie': # Only consider movie sessions + username = session.usernames[0] if session.usernames else None + if self.is_poster_user(username, 'plex'): + playback_info = self.plex_service.get_playback_info(session.ratingKey) + service = 'plex' + break + else: + logger.info(f"Plex user {username} is playing a movie but not authorized for poster updates.") + else: + logger.debug(f"Plex user {session.usernames[0] if session.usernames else 'Unknown'} is playing non-movie content. Ignoring.") + + if playback_info: + movie_id = playback_info.get('id') + if movie_id and movie_id != self.current_movie_id: + logger.info(f"Detected new movie playback: {movie_id}") + # Fetch detailed movie data + if service == 'jellyfin': + movie_data = self.jellyfin_service.get_movie_by_id(movie_id) + elif service == 'plex': + movie_data = self.plex_service.get_movie_by_id(movie_id) + + if movie_data: + # Update the current movie in the poster view + set_current_movie(movie_data, service=service, resume_position=playback_info.get('position', 0)) + self.current_movie_id = movie_id + else: + # No movie is currently playing by authorized users + if self.current_movie_id is not None: + logger.info("Playback stopped or no authorized users playing movies.") + self.current_movie_id = None + # Emit event to set the default poster + default_poster_manager = self.app.config.get('DEFAULT_POSTER_MANAGER') + if default_poster_manager: + default_poster_manager.set_default_poster() + time.sleep(self.interval) + except Exception as e: + logger.error(f"Error in PlaybackMonitor: {e}") + time.sleep(self.interval) + + def stop(self): + self.running = False diff --git a/utils/plex_service.py b/utils/plex_service.py index 0b02822..4378b4e 100644 --- a/utils/plex_service.py +++ b/utils/plex_service.py @@ -1,17 +1,29 @@ import os import random +import logging +import requests from plexapi.server import PlexServer +from datetime import datetime, timedelta +from utils.poster_view import set_current_movie + +logging.basicConfig(level=logging.DEBUG) +logger = logging.getLogger(__name__) class PlexService: def __init__(self): self.PLEX_URL = os.getenv('PLEX_URL') self.PLEX_TOKEN = os.getenv('PLEX_TOKEN') - self.MOVIES_LIBRARY_NAME = os.getenv('MOVIES_LIBRARY_NAME', 'Movies') + self.PLEX_MOVIE_LIBRARIES = os.getenv('PLEX_MOVIE_LIBRARIES', 'Movies').split(',') self.plex = PlexServer(self.PLEX_URL, self.PLEX_TOKEN) - self.movies = self.plex.library.section(self.MOVIES_LIBRARY_NAME) + self.libraries = [self.plex.library.section(lib.strip()) for lib in self.PLEX_MOVIE_LIBRARIES] + self.playback_start_times = {} def get_random_movie(self): - all_unwatched = self.movies.search(unwatched=True) + all_unwatched = [] + for library in self.libraries: + all_unwatched.extend(library.search(unwatched=True)) + if not all_unwatched: + return None chosen_movie = random.choice(all_unwatched) return self.get_movie_data(chosen_movie) @@ -24,7 +36,9 @@ def filter_movies(self, genre=None, year=None, pg_rating=None): if pg_rating: filters['contentRating'] = pg_rating - filtered_movies = self.movies.search(**filters) + filtered_movies = [] + for library in self.libraries: + filtered_movies.extend(library.search(**filters)) if filtered_movies: chosen_movie = random.choice(filtered_movies) @@ -34,6 +48,74 @@ def filter_movies(self, genre=None, year=None, pg_rating=None): def get_movie_data(self, movie): movie_duration_hours = (movie.duration / (1000 * 60 * 60)) % 24 movie_duration_minutes = (movie.duration / (1000 * 60)) % 60 + + # Extract video format information + video_format = "Unknown" + audio_format = "Unknown" + + # Make an additional API call to get extended metadata + metadata_url = f"{self.PLEX_URL}/library/metadata/{movie.ratingKey}?includeChildren=1" + headers = {"X-Plex-Token": self.PLEX_TOKEN, "Accept": "application/json"} + response = requests.get(metadata_url, headers=headers) + if response.status_code == 200: + metadata = response.json() + media_info = metadata['MediaContainer']['Metadata'][0]['Media'][0]['Part'][0]['Stream'] + + # Video format extraction + video_stream = next((s for s in media_info if s['streamType'] == 1), None) + if video_stream: + # Determine resolution + height = video_stream.get('height', 0) + if height <= 480: + resolution = "SD" + elif height <= 720: + resolution = "HD" + elif height <= 1080: + resolution = "FHD" + elif height > 1080: + resolution = "4K" + else: + resolution = "Unknown" + + # Check for HDR and Dolby Vision + hdr_types = [] + if video_stream.get('DOVIPresent'): + hdr_types.append("DV") + if video_stream.get('colorTrc') == 'smpte2084' and video_stream.get('colorSpace') == 'bt2020nc': + hdr_types.append("HDR10") + + # Combine resolution and HDR info + video_format = f"{resolution} {'/'.join(hdr_types)}".strip() + + # Audio format extraction + audio_stream = next((s for s in media_info if s['streamType'] == 2), None) + if audio_stream: + codec = audio_stream.get('codec', '').lower() + channels = audio_stream.get('channels', 0) + + codec_map = { + 'ac3': 'Dolby Digital', + 'eac3': 'Dolby Digital Plus', + 'truehd': 'Dolby TrueHD', + 'dca': 'DTS', + 'dts': 'DTS', + 'aac': 'AAC', + 'flac': 'FLAC' + } + + audio_format = codec_map.get(codec, codec.upper()) + + if audio_stream.get('audioChannelLayout'): + channel_layout = audio_stream['audioChannelLayout'].split('(')[0] # Remove (side) or similar + audio_format += f" {channel_layout}" + elif channels: + if channels == 8: + audio_format += ' 7.1' + elif channels == 6: + audio_format += ' 5.1' + elif channels == 2: + audio_format += ' 2.0' + return { "id": movie.ratingKey, "title": movie.title, @@ -45,28 +127,33 @@ def get_movie_data(self, movie): "writers": [writer.tag for writer in movie.writers][:3], # Limit to first 3 writers "actors": [actor.tag for actor in movie.actors][:3], # Limit to first 3 actors "genres": [genre.tag for genre in movie.genres], - "poster": movie.posterUrl, + "poster": movie.thumbUrl, "background": movie.artUrl, - "contentRating": movie.contentRating + "contentRating": movie.contentRating, + "videoFormat": video_format, + "audioFormat": audio_format, } def get_genres(self): all_genres = set() - for movie in self.movies.search(unwatched=True): - all_genres.update([genre.tag for genre in movie.genres]) + for library in self.libraries: + for movie in library.search(unwatched=True): + all_genres.update([genre.tag for genre in movie.genres]) return sorted(list(all_genres)) def get_years(self): all_years = set() - for movie in self.movies.search(unwatched=True): - all_years.add(movie.year) + for library in self.libraries: + for movie in library.search(unwatched=True): + all_years.add(movie.year) return sorted(list(all_years), reverse=True) def get_pg_ratings(self): ratings = set() - for movie in self.movies.search(unwatched=True): - if movie.contentRating: - ratings.add(movie.contentRating) + for library in self.libraries: + for movie in library.search(unwatched=True): + if movie.contentRating: + ratings.add(movie.contentRating) return sorted(list(ratings)) def get_clients(self): @@ -74,23 +161,171 @@ def get_clients(self): def play_movie(self, movie_id, client_id): try: - movie = self.movies.fetchItem(int(movie_id)) + movie = None + for library in self.libraries: + try: + movie = library.fetchItem(int(movie_id)) + if movie: + break + except: + continue + + if not movie: + raise ValueError(f"Movie with id {movie_id} not found in any library") + client = next((c for c in self.plex.clients() if c.machineIdentifier == client_id), None) if not client: raise ValueError(f"Unknown client id: {client_id}") + client.proxyThroughServer() client.playMedia(movie) + + # Set the start time for the movie + self.playback_start_times[movie_id] = datetime.now() + + # Fetch movie data + movie_data = self.get_movie_data(movie) + if movie_data: + # Set current movie + set_current_movie(movie_data, service='plex', resume_position=0) return {"status": "playing"} except Exception as e: + logger.error(f"Error playing movie: {e}") return {"error": str(e)} - # Methods to support caching def get_total_unwatched_movies(self): - return len(self.movies.search(unwatched=True)) + return sum(len(library.search(unwatched=True)) for library in self.libraries) def get_all_unwatched_movies(self): - return [self.get_movie_data(movie) for movie in self.movies.search(unwatched=True)] + all_unwatched = [] + for library in self.libraries: + all_unwatched.extend(library.search(unwatched=True)) + return [self.get_movie_data(movie) for movie in all_unwatched] def get_movie_by_id(self, movie_id): - movie = self.movies.fetchItem(int(movie_id)) - return self.get_movie_data(movie) + for library in self.libraries: + try: + movie = library.fetchItem(int(movie_id)) + return self.get_movie_data(movie) + except: + continue + logger.error(f"Movie with id {movie_id} not found in any library") + return None + + def get_playback_info(self, item_id): + try: + for session in self.plex.sessions(): + if str(session.ratingKey) == str(item_id): + position_ms = session.viewOffset or 0 + duration_ms = session.duration or 0 + position_seconds = position_ms / 1000 + total_duration_seconds = duration_ms / 1000 + + # Correctly access the playback state + session_state = session.player.state.lower() + is_paused = session_state == 'paused' + is_playing = session_state == 'playing' + is_buffering = session_state == 'buffering' + + # Handle buffering state if necessary + if is_buffering: + is_playing = True + is_paused = False + + # Use stored start time or current time if not available + if item_id not in self.playback_start_times: + self.playback_start_times[item_id] = datetime.now() + + start_time = self.playback_start_times[item_id] + end_time = start_time + timedelta(seconds=total_duration_seconds) + + return { + 'id': str(item_id), + 'is_playing': is_playing, + 'IsPaused': is_paused, + 'IsStopped': False, + 'position': position_seconds, + 'start_time': start_time.isoformat(), + 'end_time': end_time.isoformat(), + 'duration': total_duration_seconds + } + # If no matching session found, the movie is stopped + return { + 'id': str(item_id), + 'is_playing': False, + 'IsPaused': False, + 'IsStopped': True, + 'position': 0, + 'start_time': None, + 'end_time': None, + 'duration': 0 + } + except Exception as e: + logger.error(f"Error fetching playback info: {e}") + return None + +# You might want to keep this function outside the class if it's used elsewhere +def get_playback_state(movie_id): + from flask import current_app + default_poster_manager = current_app.config.get('DEFAULT_POSTER_MANAGER') + current_movie = load_current_movie() + service = current_movie['service'] if current_movie else None + + playback_info = None + + if service == 'jellyfin': + jellyfin_service = current_app.config.get('JELLYFIN_SERVICE') + if jellyfin_service: + playback_info = jellyfin_service.get_playback_info(movie_id) + elif service == 'plex': + plex_service = current_app.config.get('PLEX_SERVICE') + if plex_service: + playback_info = plex_service.get_playback_info(movie_id) + else: + playback_info = None + + if playback_info: + current_position = playback_info.get('position', 0) + total_duration = playback_info.get('duration', 0) + is_playing = playback_info.get('is_playing', False) + is_paused = playback_info.get('IsPaused', False) + is_stopped = playback_info.get('IsStopped', False) + # Determine the current state + if is_stopped: + current_state = 'STOPPED' + elif total_duration > 0 and (total_duration - current_position) <= 10: + current_state = 'ENDED' + elif is_paused: + current_state = 'PAUSED' + elif is_playing: + current_state = 'PLAYING' + else: + current_state = 'UNKNOWN' + if default_poster_manager: + default_poster_manager.handle_playback_state(current_state) + playback_info['status'] = current_state + return playback_info + else: + # Fallback to the current movie data if no real-time info is available + if current_movie and current_movie['movie']['id'] == movie_id: + start_time = datetime.fromisoformat(current_movie['start_time']) + duration = timedelta(hours=current_movie['duration_hours'], minutes=current_movie['duration_minutes']) + current_time = datetime.now() + resume_position = current_movie.get('resume_position', 0) + elapsed_time = (current_time - start_time).total_seconds() + current_position = min(elapsed_time + resume_position, duration.total_seconds()) + if current_position >= duration.total_seconds() - 10: + current_state = 'ENDED' + elif elapsed_time >= 0: + current_state = 'PLAYING' + else: + current_state = 'STOPPED' + if default_poster_manager: + default_poster_manager.handle_playback_state(current_state) + return { + 'status': current_state, + 'position': current_position, + 'start_time': start_time.isoformat(), + 'duration': duration.total_seconds() + } + return None diff --git a/utils/poster_view.py b/utils/poster_view.py new file mode 100644 index 0000000..16ae723 --- /dev/null +++ b/utils/poster_view.py @@ -0,0 +1,213 @@ +# utils/poster_view.py +from flask import render_template, session, jsonify, Blueprint, current_app +from flask_socketio import emit +from datetime import datetime, timedelta +import pytz +import os +import json +import time +import logging + +logging.basicConfig(level=logging.DEBUG) +logger = logging.getLogger(__name__) + +poster_bp = Blueprint('poster', __name__) +socketio = None + +# Get the timezone from environment variable +TZ = os.environ.get('TZ', 'UTC') +timezone = pytz.timezone(TZ) + +# File to store current movie data +CURRENT_MOVIE_FILE = '/app/data/current_movie.json' + +def init_socket(socket): + global socketio + socketio = socket + +def save_current_movie(movie_data): + with open(CURRENT_MOVIE_FILE, 'w') as f: + json.dump(movie_data, f) + +def load_current_movie(): + if os.path.exists(CURRENT_MOVIE_FILE): + with open(CURRENT_MOVIE_FILE, 'r') as f: + return json.load(f) + return None + +def get_poster_data(): + current_movie = load_current_movie() + if not current_movie: + return None + + start_time = datetime.fromisoformat(current_movie['start_time']) + + # Convert to the desired timezone + if start_time.tzinfo is None: + start_time = timezone.localize(start_time) + else: + start_time = start_time.astimezone(timezone) + + duration = timedelta(hours=current_movie['duration_hours'], minutes=current_movie['duration_minutes']) + end_time = start_time + duration + + movie_data = current_movie['movie'] + # Ensure these fields exist, using defaults if not present + movie_data.update({ + 'contentRating': movie_data.get('contentRating', 'Not Rated'), + 'videoFormat': movie_data.get('videoFormat', 'Unknown'), + 'audioFormat': movie_data.get('audioFormat', 'Unknown') + }) + + return { + 'movie': movie_data, + 'start_time': start_time.isoformat(), + 'end_time': end_time.isoformat(), + 'service': current_movie['service'], + } + +def set_current_movie(movie_data, service, resume_position=0): + current_time = datetime.now(timezone) + total_duration = timedelta(hours=movie_data['duration_hours'], minutes=movie_data['duration_minutes']) + + if resume_position > 0: + elapsed = timedelta(seconds=resume_position) + start_time = current_time - elapsed + else: + start_time = current_time + + current_movie = { + 'movie': movie_data, + 'start_time': start_time.isoformat(), + 'duration_hours': movie_data['duration_hours'], + 'duration_minutes': movie_data['duration_minutes'], + 'service': service, + 'resume_position': resume_position + } + save_current_movie(current_movie) + + if socketio: + socketio.emit('movie_changed', current_movie, namespace='/poster') + else: + logger.warning("SocketIO not initialized in poster_view") + + default_poster_manager = current_app.config.get('DEFAULT_POSTER_MANAGER') + if default_poster_manager: + default_poster_manager.cancel_default_poster_timer() + else: + logger.warning("DEFAULT_POSTER_MANAGER not found in app config") + +def get_playback_state(movie_id): + default_poster_manager = current_app.config.get('DEFAULT_POSTER_MANAGER') + current_movie = load_current_movie() + service = current_movie['service'] if current_movie else None + + playback_info = None + + if service == 'jellyfin': + jellyfin_service = current_app.config.get('JELLYFIN_SERVICE') + if jellyfin_service: + playback_info = jellyfin_service.get_playback_info(movie_id) + elif service == 'plex': + plex_service = current_app.config.get('PLEX_SERVICE') + if plex_service: + playback_info = plex_service.get_playback_info(movie_id) + else: + playback_info = None + + if playback_info: + current_position = playback_info.get('position', 0) + total_duration = playback_info.get('duration', 0) + is_playing = playback_info.get('is_playing', False) + is_paused = playback_info.get('IsPaused', False) + is_stopped = playback_info.get('IsStopped', False) + # Determine the current state + if is_stopped: + current_state = 'STOPPED' + elif total_duration > 0 and (total_duration - current_position) <= 10: + current_state = 'ENDED' + elif is_paused: + current_state = 'PAUSED' + elif is_playing: + current_state = 'PLAYING' + else: + current_state = 'UNKNOWN' + if default_poster_manager: + default_poster_manager.handle_playback_state(current_state) + playback_info['status'] = current_state + return playback_info + else: + # Fallback to the current movie data if no real-time info is available + if current_movie and current_movie['movie']['id'] == movie_id: + start_time = datetime.fromisoformat(current_movie['start_time']) + duration = timedelta(hours=current_movie['duration_hours'], minutes=current_movie['duration_minutes']) + current_time = datetime.now(timezone) + resume_position = current_movie.get('resume_position', 0) + elapsed_time = (current_time - start_time).total_seconds() + current_position = min(elapsed_time + resume_position, duration.total_seconds()) + if current_position >= duration.total_seconds() - 10: + current_state = 'ENDED' + elif elapsed_time >= 0: + current_state = 'PLAYING' + else: + current_state = 'STOPPED' + if default_poster_manager: + default_poster_manager.handle_playback_state(current_state) + return { + 'status': current_state, + 'position': current_position, + 'start_time': start_time.isoformat(), + 'duration': duration.total_seconds() + } + return None + +@poster_bp.route('/playback_state/') +def playback_state(movie_id): + try: + state = get_playback_state(movie_id) + if state: + return jsonify(state) + else: + return jsonify({"error": "No playback information available"}), 404 + except Exception as e: + logger.error(f"Error in playback_state: {str(e)}") + return jsonify({"error": "Internal server error"}), 500 + +@poster_bp.route('/poster') +def poster(): + logger.debug("Poster route called") + default_poster_manager = current_app.config.get('DEFAULT_POSTER_MANAGER') + custom_text = os.environ.get('DEFAULT_POSTER_TEXT', '') + logger.debug(f"Custom text from environment: '{custom_text}'") + + current_poster = default_poster_manager.get_current_poster() + logger.debug(f"Current poster: {current_poster}") + + if current_poster == default_poster_manager.default_poster: + logger.debug("Rendering default poster with custom text") + return render_template('poster.html', + current_poster=current_poster, + custom_text=custom_text) + else: + poster_data = get_poster_data() + if poster_data: + logger.debug("Rendering movie poster") + return render_template('poster.html', + movie=poster_data['movie'], + start_time=poster_data['start_time'], + end_time=poster_data['end_time'], + service=poster_data['service'], + current_poster=current_poster, + custom_text=custom_text) + else: + logger.debug("No poster data, rendering default poster with custom text") + return render_template('poster.html', + current_poster=default_poster_manager.default_poster, + custom_text=custom_text) + +@poster_bp.route('/current_poster') +def current_poster(): + default_poster_manager = current_app.config.get('DEFAULT_POSTER_MANAGER') + current_poster = default_poster_manager.get_current_poster() + logger.debug(f"Current poster route called, returning: {current_poster}") + return jsonify({'poster': current_poster}) diff --git a/web/index.html b/web/index.html index 2206ff5..17799b5 100644 --- a/web/index.html +++ b/web/index.html @@ -144,7 +144,7 @@

Loading Unwatched Movies

{% if not homepage_mode %} -
v1.6
+
v2.0
{% endif %} diff --git a/web/poster.html b/web/poster.html new file mode 100644 index 0000000..28d6bff --- /dev/null +++ b/web/poster.html @@ -0,0 +1,59 @@ + + + + + + + Now Playing + + + + + + + + + + +
+
+
+ Start Time + +
+
NOW PLAYING
+
+ End Time + +
+
+
+
+
+ {{ movie.title if movie else 'Default' }} Poster + +
+
{{ custom_text | safe }}
+
+
+
+ Rating + {{ movie.contentRating if movie else '' }} +
+
+ Video + {{ movie.videoFormat if movie else '' }} +
+
+ Audio + {{ movie.audioFormat if movie else '' }} +
+
+
+ +