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 && (
+
+ )}
+
+
>
);
};