mirror of
https://github.com/Dispatcharr/Dispatcharr.git
synced 2026-01-23 02:35:14 +00:00
Added youtube trailers
Added youtube trailers for local media.
This commit is contained in:
parent
9a15075a6d
commit
7afd5b8ef2
5 changed files with 313 additions and 341 deletions
|
|
@ -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)
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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')},
|
||||
),
|
||||
]
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue