diff --git a/apps/media_library/metadata.py b/apps/media_library/metadata.py index 2ac99251..6fbd0b74 100644 --- a/apps/media_library/metadata.py +++ b/apps/media_library/metadata.py @@ -1,5 +1,6 @@ import logging import os +import re import xml.etree.ElementTree as ET from typing import Any, Dict, Optional, Tuple @@ -67,6 +68,10 @@ GENRE_ID_MAP: dict[int, str] = { 10770: "TV Movie", } +_YOUTUBE_ID_RE = re.compile( + r"(?:video_id=|v=|youtu\.be/|youtube\.com/embed/)([A-Za-z0-9_-]{6,})" +) + def _get_library_metadata_prefs(media_item: MediaItem) -> dict[str, Any]: # Merge library language/region fields with optional metadata overrides. @@ -383,6 +388,27 @@ def _nfo_runtime_minutes(root: ET.Element) -> Optional[int]: return None +def _extract_youtube_id(value: str | None) -> Optional[str]: + if not value: + return None + text = str(value).strip() + if not text: + return None + match = _YOUTUBE_ID_RE.search(text) + if match: + return match.group(1) + if "://" not in text: + return text + return None + + +def _nfo_trailer(root: ET.Element) -> Optional[str]: + trailer = _safe_xml_text(root.find("trailer")) + if not trailer: + return None + return _extract_youtube_id(trailer) or trailer + + def _find_library_base_path(file_path: str, library) -> Optional[str]: # Identify which library location contains the file path. if not file_path: @@ -612,6 +638,7 @@ def fetch_local_nfo_metadata( sort_title = _safe_xml_text(root.find("sorttitle")) synopsis = _safe_xml_text(root.find("plot")) or _safe_xml_text(root.find("outline")) tagline = _safe_xml_text(root.find("tagline")) + youtube_trailer = _nfo_trailer(root) premiered = _safe_xml_text(root.find("premiered")) or _safe_xml_text(root.find("aired")) release_year = _parse_xml_int(_safe_xml_text(root.find("year"))) or _parse_release_year(premiered) runtime_minutes = _nfo_runtime_minutes(root) @@ -643,6 +670,7 @@ def fetch_local_nfo_metadata( rating, poster_url, backdrop_url, + youtube_trailer, imdb_id, tmdb_id, cast, @@ -658,6 +686,7 @@ def fetch_local_nfo_metadata( "sort_title": sort_title, "synopsis": synopsis, "tagline": tagline, + "youtube_trailer": youtube_trailer, "release_year": release_year, "runtime_minutes": runtime_minutes, "genres": genres, @@ -674,6 +703,7 @@ def fetch_local_nfo_metadata( "sort_title": sort_title, "synopsis": synopsis, "tagline": tagline, + "youtube_trailer": youtube_trailer, "release_year": release_year, "runtime_minutes": runtime_minutes, "genres": genres, @@ -1046,6 +1076,39 @@ def _tmdb_candidate_year(payload: dict) -> Optional[int]: ) +def _tmdb_trailer_key(payload: dict | None) -> Optional[str]: + if not isinstance(payload, dict): + return None + videos = payload.get("videos") + if not isinstance(videos, dict): + return None + results = videos.get("results") or [] + if not isinstance(results, list): + return None + candidates = [ + entry + for entry in results + if isinstance(entry, dict) + and entry.get("site") == "YouTube" + and entry.get("key") + ] + if not candidates: + return None + + def score(entry: dict) -> tuple[int, int]: + score_value = 0 + if entry.get("type") == "Trailer": + score_value += 3 + elif entry.get("type") == "Teaser": + score_value += 1 + if entry.get("official"): + score_value += 2 + return score_value, 0 + + best = max(candidates, key=score) + return best.get("key") + + def _select_tmdb_candidate( results: list[dict], title: str, @@ -1182,7 +1245,7 @@ def _tmdb_fetch_details( if normalized_key in _TMDB_DETAIL_CACHE: return _TMDB_DETAIL_CACHE[normalized_key] - params = {"api_key": api_key, "append_to_response": "credits"} + params = {"api_key": api_key, "append_to_response": "credits,videos"} if prefs and prefs.get("language"): params["language"] = prefs["language"] @@ -1270,6 +1333,7 @@ def fetch_tmdb_metadata( cast, crew = _normalize_credits(credits_payload) genres = [entry.get("name") for entry in details.get("genres", []) if entry.get("name")] runtime = details.get("runtime") + trailer_key = _tmdb_trailer_key(details) release_year = _parse_release_year(details.get("release_date")) or media_item.release_year metadata = _to_serializable( { @@ -1282,6 +1346,7 @@ def fetch_tmdb_metadata( "poster": build_image_url(details.get("poster_path")), "backdrop": build_image_url(details.get("backdrop_path")), "runtime_minutes": runtime, + "youtube_trailer": trailer_key, "genres": genres, "studios": [ entry.get("name") @@ -1334,6 +1399,7 @@ def fetch_tmdb_metadata( genres = [entry.get("name") for entry in details.get("genres", []) if entry.get("name")] runtime_list = details.get("episode_run_time") or [] runtime = runtime_list[0] if runtime_list else None + trailer_key = _tmdb_trailer_key(details) release_year = _parse_release_year(details.get("first_air_date")) or media_item.release_year metadata = _to_serializable( { @@ -1345,6 +1411,7 @@ def fetch_tmdb_metadata( "poster": build_image_url(details.get("poster_path")), "backdrop": build_image_url(details.get("backdrop_path")), "runtime_minutes": runtime, + "youtube_trailer": trailer_key, "genres": genres, "studios": [ entry.get("name") @@ -1775,6 +1842,16 @@ def apply_metadata( update_fields.append("tagline") changed = True + youtube_trailer = metadata.get("youtube_trailer") or metadata.get("trailer") + if ( + youtube_trailer + and youtube_trailer != media_item.youtube_trailer + and can_update(media_item.youtube_trailer) + ): + media_item.youtube_trailer = youtube_trailer + update_fields.append("youtube_trailer") + changed = True + rating = metadata.get("rating") if rating and rating != media_item.rating and can_update(media_item.rating): media_item.rating = rating @@ -1898,6 +1975,8 @@ def _needs_remote_metadata(media_item: MediaItem) -> bool: media_item.crew, media_item.rating, ] + if media_item.item_type in {MediaItem.TYPE_MOVIE, MediaItem.TYPE_SHOW}: + required_fields.append(media_item.youtube_trailer) return any(_is_empty_value(value) for value in required_fields) diff --git a/apps/media_library/migrations/0001_initial.py b/apps/media_library/migrations/0001_initial.py index cdd20bea..9a0ef6b4 100644 --- a/apps/media_library/migrations/0001_initial.py +++ b/apps/media_library/migrations/0001_initial.py @@ -1,8 +1,8 @@ -# Generated by Django 5.2.4 on 2025-12-20 00:00 +# Generated by Django 5.2.9 on 2025-12-26 21:08 +import django.db.models.deletion from django.conf import settings from django.db import migrations, models -import django.db.models.deletion class Migration(migrations.Migration): @@ -10,399 +10,235 @@ class Migration(migrations.Migration): initial = True dependencies = [ - ("m3u", "0018_add_profile_custom_properties"), - ("vod", "0003_vodlogo_alter_movie_logo_alter_series_logo"), + ('m3u', '0018_add_profile_custom_properties'), + ('vod', '0003_vodlogo_alter_movie_logo_alter_series_logo'), migrations.swappable_dependency(settings.AUTH_USER_MODEL), ] operations = [ migrations.CreateModel( - name="Library", + name='Library', fields=[ - ("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")), - ("name", models.CharField(max_length=255)), - ("description", models.TextField(blank=True, default="")), - ( - "library_type", - models.CharField( - choices=[("movies", "Movies"), ("shows", "TV Shows")], - max_length=20, - ), - ), - ("metadata_language", models.CharField(blank=True, default="", max_length=12)), - ("metadata_country", models.CharField(blank=True, default="", max_length=12)), - ("metadata_options", models.JSONField(blank=True, default=dict, null=True)), - ("scan_interval_minutes", models.PositiveIntegerField(default=1440)), - ("auto_scan_enabled", models.BooleanField(default=True)), - ("add_to_vod", models.BooleanField(default=False)), - ("last_scan_at", models.DateTimeField(blank=True, null=True)), - ("last_successful_scan_at", models.DateTimeField(blank=True, null=True)), - ("created_at", models.DateTimeField(auto_now_add=True)), - ("updated_at", models.DateTimeField(auto_now=True)), - ( - "vod_account", - models.ForeignKey( - blank=True, - null=True, - on_delete=django.db.models.deletion.SET_NULL, - related_name="media_libraries", - to="m3u.m3uaccount", - ), - ), + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('name', models.CharField(max_length=255)), + ('description', models.TextField(blank=True, default='')), + ('library_type', models.CharField(choices=[('movies', 'Movies'), ('shows', 'TV Shows')], max_length=20)), + ('metadata_language', models.CharField(blank=True, default='', max_length=12)), + ('metadata_country', models.CharField(blank=True, default='', max_length=12)), + ('metadata_options', models.JSONField(blank=True, default=dict, null=True)), + ('scan_interval_minutes', models.PositiveIntegerField(default=1440)), + ('auto_scan_enabled', models.BooleanField(default=True)), + ('add_to_vod', models.BooleanField(default=False)), + ('last_scan_at', models.DateTimeField(blank=True, null=True)), + ('last_successful_scan_at', models.DateTimeField(blank=True, null=True)), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('vod_account', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='media_libraries', to='m3u.m3uaccount')), ], options={ - "ordering": ["name"], + 'ordering': ['name'], }, ), migrations.CreateModel( - name="MediaItem", + name='MediaItem', fields=[ - ("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")), - ( - "item_type", - models.CharField( - choices=[ - ("movie", "Movie"), - ("show", "Show"), - ("episode", "Episode"), - ("other", "Other"), - ], - max_length=16, - ), - ), - ("title", models.CharField(max_length=512)), - ("sort_title", models.CharField(blank=True, default="", max_length=512)), - ("normalized_title", models.CharField(blank=True, default="", max_length=512)), - ("synopsis", models.TextField(blank=True, default="")), - ("tagline", models.TextField(blank=True, default="")), - ("release_year", models.IntegerField(blank=True, null=True)), - ("rating", models.CharField(blank=True, default="", max_length=10)), - ("runtime_ms", models.PositiveBigIntegerField(blank=True, null=True)), - ("season_number", models.IntegerField(blank=True, null=True)), - ("episode_number", models.IntegerField(blank=True, null=True)), - ("genres", models.JSONField(blank=True, default=list, null=True)), - ("tags", models.JSONField(blank=True, default=list, null=True)), - ("studios", models.JSONField(blank=True, default=list, null=True)), - ("cast", models.JSONField(blank=True, default=list, null=True)), - ("crew", models.JSONField(blank=True, default=list, null=True)), - ("poster_url", models.TextField(blank=True, default="")), - ("backdrop_url", models.TextField(blank=True, default="")), - ("movie_db_id", models.CharField(blank=True, default="", max_length=64)), - ("imdb_id", models.CharField(blank=True, default="", max_length=32)), - ("metadata", models.JSONField(blank=True, null=True)), - ("metadata_source", models.CharField(blank=True, default="", max_length=32)), - ("metadata_last_synced_at", models.DateTimeField(blank=True, null=True)), - ("first_imported_at", models.DateTimeField(auto_now_add=True)), - ("updated_at", models.DateTimeField(auto_now=True)), - ( - "library", - models.ForeignKey( - on_delete=django.db.models.deletion.CASCADE, - related_name="items", - to="media_library.library", - ), - ), - ( - "parent", - models.ForeignKey( - blank=True, - null=True, - on_delete=django.db.models.deletion.CASCADE, - related_name="children", - to="media_library.mediaitem", - ), - ), + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('item_type', models.CharField(choices=[('movie', 'Movie'), ('show', 'Show'), ('episode', 'Episode'), ('other', 'Other')], max_length=16)), + ('title', models.CharField(max_length=512)), + ('sort_title', models.CharField(blank=True, default='', max_length=512)), + ('normalized_title', models.CharField(blank=True, default='', max_length=512)), + ('synopsis', models.TextField(blank=True, default='')), + ('tagline', models.TextField(blank=True, default='')), + ('release_year', models.IntegerField(blank=True, null=True)), + ('rating', models.CharField(blank=True, default='', max_length=10)), + ('runtime_ms', models.PositiveBigIntegerField(blank=True, null=True)), + ('season_number', models.IntegerField(blank=True, null=True)), + ('episode_number', models.IntegerField(blank=True, null=True)), + ('genres', models.JSONField(blank=True, default=list, null=True)), + ('tags', models.JSONField(blank=True, default=list, null=True)), + ('studios', models.JSONField(blank=True, default=list, null=True)), + ('cast', models.JSONField(blank=True, default=list, null=True)), + ('crew', models.JSONField(blank=True, default=list, null=True)), + ('poster_url', models.TextField(blank=True, default='')), + ('backdrop_url', models.TextField(blank=True, default='')), + ('movie_db_id', models.CharField(blank=True, default='', max_length=64)), + ('imdb_id', models.CharField(blank=True, default='', max_length=32)), + ('youtube_trailer', models.TextField(blank=True, default='')), + ('metadata', models.JSONField(blank=True, null=True)), + ('metadata_source', models.CharField(blank=True, default='', max_length=32)), + ('metadata_last_synced_at', models.DateTimeField(blank=True, null=True)), + ('first_imported_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('library', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='items', to='media_library.library')), + ('parent', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='children', to='media_library.mediaitem')), ], options={ - "ordering": ["sort_title", "title"], + 'ordering': ['sort_title', 'title'], }, ), migrations.CreateModel( - name="LibraryLocation", + name='MediaFile', fields=[ - ("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")), - ("path", models.CharField(max_length=1024)), - ("include_subdirectories", models.BooleanField(default=True)), - ("is_primary", models.BooleanField(default=False)), - ("created_at", models.DateTimeField(auto_now_add=True)), - ("updated_at", models.DateTimeField(auto_now=True)), - ( - "library", - models.ForeignKey( - on_delete=django.db.models.deletion.CASCADE, - related_name="locations", - to="media_library.library", - ), - ), + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('path', models.CharField(max_length=1024)), + ('relative_path', models.CharField(blank=True, default='', max_length=1024)), + ('file_name', models.CharField(max_length=512)), + ('size_bytes', models.BigIntegerField(blank=True, null=True)), + ('modified_at', models.DateTimeField(blank=True, null=True)), + ('duration_ms', models.PositiveBigIntegerField(blank=True, null=True)), + ('is_primary', models.BooleanField(default=False)), + ('last_seen_at', models.DateTimeField(blank=True, null=True)), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('library', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='files', to='media_library.library')), + ('media_item', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='files', to='media_library.mediaitem')), + ], + ), + migrations.CreateModel( + name='ArtworkAsset', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('asset_type', models.CharField(choices=[('poster', 'Poster'), ('backdrop', 'Backdrop')], max_length=16)), + ('source', models.CharField(blank=True, default='', max_length=32)), + ('external_url', models.TextField()), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('media_item', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='artwork_assets', to='media_library.mediaitem')), + ], + ), + migrations.CreateModel( + name='MediaItemVODLink', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('media_item', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='vod_link', to='media_library.mediaitem')), + ('vod_episode', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='media_library_links', to='vod.episode')), + ('vod_movie', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='media_library_links', to='vod.movie')), + ('vod_series', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='media_library_links', to='vod.series')), + ], + ), + migrations.CreateModel( + name='WatchProgress', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('position_ms', models.PositiveBigIntegerField(default=0)), + ('duration_ms', models.PositiveBigIntegerField(blank=True, null=True)), + ('completed', models.BooleanField(default=False)), + ('last_watched_at', models.DateTimeField(blank=True, null=True)), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('file', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='progress_entries', to='media_library.mediafile')), + ('media_item', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='progress_entries', to='media_library.mediaitem')), + ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='media_progress', to=settings.AUTH_USER_MODEL)), + ], + ), + migrations.CreateModel( + name='LibraryLocation', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('path', models.CharField(max_length=1024)), + ('include_subdirectories', models.BooleanField(default=True)), + ('is_primary', models.BooleanField(default=False)), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('library', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='locations', to='media_library.library')), ], options={ - "unique_together": {("library", "path")}, + 'indexes': [models.Index(fields=['library', 'is_primary'], name='media_libra_library_68a40b_idx')], + 'unique_together': {('library', 'path')}, }, ), migrations.CreateModel( - name="MediaFile", + name='LibraryScan', fields=[ - ("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")), - ("path", models.CharField(max_length=1024)), - ("relative_path", models.CharField(blank=True, default="", max_length=1024)), - ("file_name", models.CharField(max_length=512)), - ("size_bytes", models.BigIntegerField(blank=True, null=True)), - ("modified_at", models.DateTimeField(blank=True, null=True)), - ("duration_ms", models.PositiveBigIntegerField(blank=True, null=True)), - ("is_primary", models.BooleanField(default=False)), - ("last_seen_at", models.DateTimeField(blank=True, null=True)), - ("created_at", models.DateTimeField(auto_now_add=True)), - ("updated_at", models.DateTimeField(auto_now=True)), - ( - "library", - models.ForeignKey( - on_delete=django.db.models.deletion.CASCADE, - related_name="files", - to="media_library.library", - ), - ), - ( - "media_item", - models.ForeignKey( - on_delete=django.db.models.deletion.CASCADE, - related_name="files", - to="media_library.mediaitem", - ), - ), + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('scan_type', models.CharField(choices=[('quick', 'Quick'), ('full', 'Full')], max_length=16)), + ('status', models.CharField(choices=[('pending', 'Pending'), ('queued', 'Queued'), ('running', 'Running'), ('completed', 'Completed'), ('failed', 'Failed'), ('cancelled', 'Cancelled')], max_length=16)), + ('summary', models.CharField(blank=True, default='', max_length=255)), + ('stages', models.JSONField(blank=True, default=dict, null=True)), + ('processed_files', models.PositiveIntegerField(default=0)), + ('total_files', models.PositiveIntegerField(default=0)), + ('new_files', models.PositiveIntegerField(default=0)), + ('updated_files', models.PositiveIntegerField(default=0)), + ('removed_files', models.PositiveIntegerField(default=0)), + ('unmatched_files', models.PositiveIntegerField(default=0)), + ('log', models.TextField(blank=True, default='')), + ('extra', models.JSONField(blank=True, null=True)), + ('task_id', models.CharField(blank=True, default='', max_length=255)), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('started_at', models.DateTimeField(blank=True, null=True)), + ('finished_at', models.DateTimeField(blank=True, null=True)), + ('library', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='scans', to='media_library.library')), ], options={ - "unique_together": {("library", "path")}, - }, - ), - migrations.CreateModel( - name="MediaItemVODLink", - fields=[ - ("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")), - ("created_at", models.DateTimeField(auto_now_add=True)), - ("updated_at", models.DateTimeField(auto_now=True)), - ( - "media_item", - models.OneToOneField( - on_delete=django.db.models.deletion.CASCADE, - related_name="vod_link", - to="media_library.mediaitem", - ), - ), - ( - "vod_episode", - models.ForeignKey( - blank=True, - null=True, - on_delete=django.db.models.deletion.SET_NULL, - related_name="media_library_links", - to="vod.episode", - ), - ), - ( - "vod_movie", - models.ForeignKey( - blank=True, - null=True, - on_delete=django.db.models.deletion.SET_NULL, - related_name="media_library_links", - to="vod.movie", - ), - ), - ( - "vod_series", - models.ForeignKey( - blank=True, - null=True, - on_delete=django.db.models.deletion.SET_NULL, - related_name="media_library_links", - to="vod.series", - ), - ), - ], - ), - migrations.CreateModel( - name="ArtworkAsset", - fields=[ - ("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")), - ("asset_type", models.CharField(choices=[("poster", "Poster"), ("backdrop", "Backdrop")], max_length=16)), - ("source", models.CharField(blank=True, default="", max_length=32)), - ("external_url", models.TextField()), - ("created_at", models.DateTimeField(auto_now_add=True)), - ("updated_at", models.DateTimeField(auto_now=True)), - ( - "media_item", - models.ForeignKey( - on_delete=django.db.models.deletion.CASCADE, - related_name="artwork_assets", - to="media_library.mediaitem", - ), - ), - ], - ), - migrations.CreateModel( - name="WatchProgress", - fields=[ - ("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")), - ("position_ms", models.PositiveBigIntegerField(default=0)), - ("duration_ms", models.PositiveBigIntegerField(blank=True, null=True)), - ("completed", models.BooleanField(default=False)), - ("last_watched_at", models.DateTimeField(blank=True, null=True)), - ("created_at", models.DateTimeField(auto_now_add=True)), - ("updated_at", models.DateTimeField(auto_now=True)), - ( - "file", - models.ForeignKey( - blank=True, - null=True, - on_delete=django.db.models.deletion.SET_NULL, - related_name="progress_entries", - to="media_library.mediafile", - ), - ), - ( - "media_item", - models.ForeignKey( - on_delete=django.db.models.deletion.CASCADE, - related_name="progress_entries", - to="media_library.mediaitem", - ), - ), - ( - "user", - models.ForeignKey( - on_delete=django.db.models.deletion.CASCADE, - related_name="media_progress", - to=settings.AUTH_USER_MODEL, - ), - ), - ], - options={ - "unique_together": {("user", "media_item")}, - }, - ), - migrations.CreateModel( - name="LibraryScan", - fields=[ - ("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")), - ( - "scan_type", - models.CharField( - choices=[("quick", "Quick"), ("full", "Full")], - max_length=16, - ), - ), - ( - "status", - models.CharField( - choices=[ - ("pending", "Pending"), - ("queued", "Queued"), - ("running", "Running"), - ("completed", "Completed"), - ("failed", "Failed"), - ("cancelled", "Cancelled"), - ], - max_length=16, - ), - ), - ("summary", models.CharField(blank=True, default="", max_length=255)), - ("stages", models.JSONField(blank=True, default=dict, null=True)), - ("processed_files", models.PositiveIntegerField(default=0)), - ("total_files", models.PositiveIntegerField(default=0)), - ("new_files", models.PositiveIntegerField(default=0)), - ("updated_files", models.PositiveIntegerField(default=0)), - ("removed_files", models.PositiveIntegerField(default=0)), - ("unmatched_files", models.PositiveIntegerField(default=0)), - ("log", models.TextField(blank=True, default="")), - ("extra", models.JSONField(blank=True, null=True)), - ("task_id", models.CharField(blank=True, default="", max_length=255)), - ("created_at", models.DateTimeField(auto_now_add=True)), - ("updated_at", models.DateTimeField(auto_now=True)), - ("started_at", models.DateTimeField(blank=True, null=True)), - ("finished_at", models.DateTimeField(blank=True, null=True)), - ( - "library", - models.ForeignKey( - on_delete=django.db.models.deletion.CASCADE, - related_name="scans", - to="media_library.library", - ), - ), - ], - options={ - "ordering": ["-created_at"], + 'ordering': ['-created_at'], + 'indexes': [models.Index(fields=['library', 'created_at'], name='media_libra_library_30a866_idx'), models.Index(fields=['library', 'status'], name='media_libra_library_1d0dfb_idx')], }, ), migrations.AddIndex( - model_name="librarylocation", - index=models.Index(fields=["library", "is_primary"], name="media_libr_library_b13f17_idx"), + model_name='mediaitem', + index=models.Index(fields=['library', 'item_type'], name='media_libra_library_61aacb_idx'), ), migrations.AddIndex( - model_name="mediaitem", - index=models.Index(fields=["library", "item_type"], name="media_libr_library_a7ec3e_idx"), + model_name='mediaitem', + index=models.Index(fields=['library', 'sort_title'], name='media_libra_library_a56dfe_idx'), ), migrations.AddIndex( - model_name="mediaitem", - index=models.Index(fields=["library", "sort_title"], name="media_libr_library_2dc3a5_idx"), + model_name='mediaitem', + index=models.Index(fields=['library', 'updated_at'], name='media_libra_library_e2043d_idx'), ), migrations.AddIndex( - model_name="mediaitem", - index=models.Index(fields=["library", "updated_at"], name="media_libr_library_0f7e9b_idx"), - ), - migrations.AddIndex( - model_name="mediaitem", - index=models.Index(fields=["parent", "season_number", "episode_number"], name="media_libr_parent__f58b2b_idx"), + model_name='mediaitem', + index=models.Index(fields=['parent', 'season_number', 'episode_number'], name='media_libra_parent__c35480_idx'), ), migrations.AddConstraint( - model_name="mediaitem", - constraint=models.UniqueConstraint( - condition=models.Q(item_type="episode"), - fields=("parent", "season_number", "episode_number"), - name="unique_episode_per_season", - ), + model_name='mediaitem', + constraint=models.UniqueConstraint(condition=models.Q(('item_type', 'episode')), fields=('parent', 'season_number', 'episode_number'), name='unique_episode_per_season'), ), migrations.AddIndex( - model_name="mediafile", - index=models.Index(fields=["media_item"], name="media_libr_media_i_4bf7b4_idx"), + model_name='mediafile', + index=models.Index(fields=['media_item'], name='media_libra_media_i_a41535_idx'), ), migrations.AddIndex( - model_name="mediafile", - index=models.Index(fields=["library", "is_primary"], name="media_libr_library_0b3ae5_idx"), + model_name='mediafile', + index=models.Index(fields=['library', 'is_primary'], name='media_libra_library_b8a250_idx'), ), migrations.AddIndex( - model_name="mediafile", - index=models.Index(fields=["library", "last_seen_at"], name="media_libr_library_31c4a1_idx"), + model_name='mediafile', + index=models.Index(fields=['library', 'last_seen_at'], name='media_libra_library_92b66d_idx'), + ), + migrations.AlterUniqueTogether( + name='mediafile', + unique_together={('library', 'path')}, ), migrations.AddIndex( - model_name="mediaitemvodlink", - index=models.Index(fields=["media_item"], name="media_libr_media_i_f9b327_idx"), + model_name='artworkasset', + index=models.Index(fields=['media_item', 'asset_type'], name='media_libra_media_i_1eb3d5_idx'), ), migrations.AddIndex( - model_name="mediaitemvodlink", - index=models.Index(fields=["vod_movie"], name="media_libr_vod_mo_6d43ad_idx"), + model_name='mediaitemvodlink', + index=models.Index(fields=['media_item'], name='media_libra_media_i_ca9ee9_idx'), ), migrations.AddIndex( - model_name="mediaitemvodlink", - index=models.Index(fields=["vod_series"], name="media_libr_vod_se_05e51a_idx"), + model_name='mediaitemvodlink', + index=models.Index(fields=['vod_movie'], name='media_libra_vod_mov_e27a4e_idx'), ), migrations.AddIndex( - model_name="mediaitemvodlink", - index=models.Index(fields=["vod_episode"], name="media_libr_vod_ep_c0b0c7_idx"), + model_name='mediaitemvodlink', + index=models.Index(fields=['vod_series'], name='media_libra_vod_ser_5b93c7_idx'), ), migrations.AddIndex( - model_name="artworkasset", - index=models.Index(fields=["media_item", "asset_type"], name="media_libr_media_i_6fe152_idx"), + model_name='mediaitemvodlink', + index=models.Index(fields=['vod_episode'], name='media_libra_vod_epi_fe3f81_idx'), ), migrations.AddIndex( - model_name="watchprogress", - index=models.Index(fields=["user", "media_item"], name="media_libr_user_id_5f9dd5_idx"), + model_name='watchprogress', + index=models.Index(fields=['user', 'media_item'], name='media_libra_user_id_cc5175_idx'), ), - migrations.AddIndex( - model_name="libraryscan", - index=models.Index(fields=["library", "created_at"], name="media_libr_library_1f1b2e_idx"), - ), - migrations.AddIndex( - model_name="libraryscan", - index=models.Index(fields=["library", "status"], name="media_libr_library_0f3a70_idx"), + migrations.AlterUniqueTogether( + name='watchprogress', + unique_together={('user', 'media_item')}, ), ] diff --git a/apps/media_library/models.py b/apps/media_library/models.py index 1753b075..ee9cdec5 100644 --- a/apps/media_library/models.py +++ b/apps/media_library/models.py @@ -102,6 +102,7 @@ class MediaItem(models.Model): backdrop_url = models.TextField(blank=True, default="") movie_db_id = models.CharField(max_length=64, blank=True, default="") imdb_id = models.CharField(max_length=32, blank=True, default="") + youtube_trailer = models.TextField(blank=True, default="") metadata = models.JSONField(blank=True, null=True) metadata_source = models.CharField(max_length=32, blank=True, default="") metadata_last_synced_at = models.DateTimeField(null=True, blank=True) diff --git a/apps/media_library/serializers.py b/apps/media_library/serializers.py index 4853dbe7..15372067 100644 --- a/apps/media_library/serializers.py +++ b/apps/media_library/serializers.py @@ -137,6 +137,7 @@ class MediaItemSerializer(serializers.ModelSerializer): "backdrop_url", "movie_db_id", "imdb_id", + "youtube_trailer", "metadata_source", "metadata_last_synced_at", "first_imported_at", diff --git a/frontend/src/components/library/MediaDetailModal.jsx b/frontend/src/components/library/MediaDetailModal.jsx index 3d496eb1..f18e097f 100644 --- a/frontend/src/components/library/MediaDetailModal.jsx +++ b/frontend/src/components/library/MediaDetailModal.jsx @@ -70,6 +70,15 @@ const resolveArtworkUrl = (url, envMode) => { return url; }; +const getEmbedUrl = (url) => { + if (!url) return ''; + const match = url.match( + /(?:youtube\.com\/watch\?v=|youtu\.be\/|video_id=)([\w-]+)/ + ); + const videoId = match ? match[1] : url; + return `https://www.youtube.com/embed/${videoId}`; +}; + const MediaDetailModal = ({ opened, onClose }) => { const activeItem = useMediaLibraryStore((s) => s.activeItem); const activeItemLoading = useMediaLibraryStore((s) => s.activeItemLoading); @@ -89,6 +98,8 @@ const MediaDetailModal = ({ opened, onClose }) => { const [resumeModalOpen, setResumeModalOpen] = useState(false); const [resumeMode, setResumeMode] = useState('start'); const [editModalOpen, setEditModalOpen] = useState(false); + const [trailerModalOpen, setTrailerModalOpen] = useState(false); + const [trailerUrl, setTrailerUrl] = useState(''); const [episodes, setEpisodes] = useState([]); const [episodesLoading, setEpisodesLoading] = useState(false); @@ -292,6 +303,8 @@ const MediaDetailModal = ({ opened, onClose }) => { setEpisodesLoading(false); setEpisodePlayLoadingId(null); setEditModalOpen(false); + setTrailerModalOpen(false); + setTrailerUrl(''); return; } if (activeItem?.item_type === 'show') { @@ -921,6 +934,18 @@ const MediaDetailModal = ({ opened, onClose }) => { > {primaryButtonLabel} + {activeItem?.youtube_trailer && ( + + )} {canResume && activeItem?.item_type !== 'show' && ( Resume at{' '} @@ -1314,6 +1339,36 @@ const MediaDetailModal = ({ opened, onClose }) => { + + setTrailerModalOpen(false)} + title="Trailer" + size="xl" + centered + withCloseButton + > + + {trailerUrl && ( +