Added youtube trailers

Added youtube trailers for local media.
This commit is contained in:
Dispatcharr 2025-12-26 15:37:01 -06:00
parent 9a15075a6d
commit 7afd5b8ef2
5 changed files with 313 additions and 341 deletions

View file

@ -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)

View file

@ -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')},
),
]

View file

@ -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)

View file

@ -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",

View file

@ -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}
</Button>
{activeItem?.youtube_trailer && (
<Button
variant="outline"
color="red"
onClick={() => {
setTrailerUrl(getEmbedUrl(activeItem.youtube_trailer));
setTrailerModalOpen(true);
}}
>
Watch Trailer
</Button>
)}
{canResume && activeItem?.item_type !== 'show' && (
<Text size="sm" c="dimmed">
Resume at{' '}
@ -1314,6 +1339,36 @@ const MediaDetailModal = ({ opened, onClose }) => {
</Group>
</Stack>
</Modal>
<Modal
opened={trailerModalOpen}
onClose={() => setTrailerModalOpen(false)}
title="Trailer"
size="xl"
centered
withCloseButton
>
<Box style={{ position: 'relative', paddingBottom: '56.25%', height: 0 }}>
{trailerUrl && (
<iframe
src={trailerUrl}
title="YouTube Trailer"
frameBorder="0"
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share"
referrerPolicy="strict-origin-when-cross-origin"
allowFullScreen
style={{
position: 'absolute',
top: 0,
left: 0,
width: '100%',
height: '100%',
borderRadius: 8,
}}
/>
)}
</Box>
</Modal>
</>
);
};