enhancement(vod): optimize VOD stream retrieval and performance

- Improved `xc_get_vod_streams` and `xc_get_series` functions to utilize a new helper method for fetching distinct relations based on account priority, significantly reducing memory usage and response times for large libraries.
- Updated the handling of VOD streams to avoid unnecessary ORM instantiation, enhancing performance for endpoints dealing with extensive movie and series data.
- Added comprehensive tests to ensure the correctness of the new logic and verify that the highest priority relations are correctly selected.
This commit is contained in:
SergeantPanda 2026-06-23 20:15:29 -05:00
parent d2e764316d
commit 7b6adf62d7
3 changed files with 595 additions and 78 deletions

View file

@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Performance
- **`get_vod_streams` and `get_series` XC API endpoints are faster and no longer exhaust Docker `/dev/shm`.** Large libraries (e.g. 125k movies) previously ran one wide `DISTINCT ON` query with parallel workers, which could fail with `could not resize shared memory segment … No space left on device` on the default 64MB container shm. Both endpoints now dedupe on narrow relation rows first (`SET LOCAL max_parallel_workers_per_gather = 0`), then fetch display columns via `.values()` (no ORM model instantiation per row). Redundant `category` and `logo` joins were dropped in favor of FK ids; alphabetical sort runs in SQL. Typical full-library response time drops from ~2328s to ~810s with stable shm usage.
- **Lighter EPG export: fewer escape calls and a slimmer channel prefetch.** The primary channel id is escaped once per `epg_id` group instead of once per programme (saves ~750k `html.escape` calls on a large guide). The per-channel `streams` prefetch now loads only `id`/`name` (the only fields the export reads) instead of full `Stream` rows, reducing worker RSS during the channel phase.
- **XMLTV EPG export streams without holding the full guide in the worker.** `generate_epg()` streams incrementally; on a cache miss each chunk is pushed to a Redis list as it is yielded (no `''.join()` in the worker). Repeat requests within 300s stream chunks back one at a time from Redis. Post-export `malloc_trim` runs after cold builds. Real programmes use fast `(epg_id, id)` keyset pagination with a per-source `start_time` sort before emit.
- **EPG export no longer fetches the multi-MB `programme_index` per channel.** The channel query `select_related`s `epg_data__epg_source`, which pulled and JSON-parsed each source's byte-offset `programme_index` blob once per channel (~13s of the request on a ~2000-channel guide). The index now lives in its own `EPGSourceIndex` table, so the JOIN never pulls it.

View file

@ -1,9 +1,23 @@
from django.test import TestCase, Client, SimpleTestCase
from django.test import TestCase, Client, SimpleTestCase, RequestFactory
from django.urls import reverse
from unittest import skipUnless
from unittest.mock import patch
from uuid import uuid4
from django.db import connection
from django.test.utils import CaptureQueriesContext
from apps.channels.models import Channel, ChannelGroup, ChannelProfile, ChannelProfileMembership
from apps.epg.models import EPGData, EPGSource
from apps.accounts.models import User
from apps.m3u.models import M3UAccount
from apps.output.views import xc_get_series, xc_get_vod_streams
from apps.vod.models import (
M3UMovieRelation,
M3USeriesRelation,
Movie,
Series,
VODCategory,
VODLogo,
)
import xml.etree.ElementTree as ET
from datetime import timedelta
@ -415,3 +429,470 @@ class OutputEPGHelperTest(SimpleTestCase):
self.assertEqual(aligned.minute, 30)
self.assertEqual(aligned.second, 0)
self.assertGreaterEqual(aligned, dt.replace(microsecond=0))
class XcVodSeriesDistinctTests(TestCase):
def setUp(self):
self.factory = RequestFactory()
self.user = User.objects.create_user(
username=f"xc-{uuid4().hex[:8]}",
password="pass",
custom_properties={"xc_password": "xcpass"},
)
self.request = self.factory.get("/player_api.php")
def _account(self, name, *, priority=0, is_active=True):
return M3UAccount.objects.create(
name=name,
server_url="http://example.com",
priority=priority,
is_active=is_active,
)
def test_vod_streams_picks_highest_priority_relation(self):
low = self._account(f"low-{uuid4().hex[:6]}", priority=1)
high = self._account(f"high-{uuid4().hex[:6]}", priority=10)
movie = Movie.objects.create(name="Shared Movie", year=2020)
M3UMovieRelation.objects.create(
m3u_account=low,
movie=movie,
stream_id="low-stream",
container_extension="mkv",
)
M3UMovieRelation.objects.create(
m3u_account=high,
movie=movie,
stream_id="high-stream",
container_extension="mp4",
)
streams = xc_get_vod_streams(self.request, self.user)
self.assertEqual(len(streams), 1)
self.assertEqual(streams[0]["name"], "Shared Movie")
self.assertEqual(streams[0]["container_extension"], "mp4")
def test_vod_streams_excludes_inactive_accounts(self):
active = self._account(f"active-{uuid4().hex[:6]}", priority=1)
inactive = self._account(
f"inactive-{uuid4().hex[:6]}", priority=99, is_active=False
)
active_movie = Movie.objects.create(name="Active Movie")
inactive_movie = Movie.objects.create(name="Inactive Only Movie")
M3UMovieRelation.objects.create(
m3u_account=active,
movie=active_movie,
stream_id="active-1",
)
M3UMovieRelation.objects.create(
m3u_account=inactive,
movie=inactive_movie,
stream_id="inactive-1",
)
streams = xc_get_vod_streams(self.request, self.user)
names = {s["name"] for s in streams}
self.assertEqual(names, {"Active Movie"})
def test_vod_streams_category_filter(self):
account = self._account(f"acct-{uuid4().hex[:6]}")
action = VODCategory.objects.create(name="Action", category_type="movie")
comedy = VODCategory.objects.create(name="Comedy", category_type="movie")
action_movie = Movie.objects.create(name="Action Movie")
comedy_movie = Movie.objects.create(name="Comedy Movie")
M3UMovieRelation.objects.create(
m3u_account=account,
movie=action_movie,
category=action,
stream_id="action-1",
)
M3UMovieRelation.objects.create(
m3u_account=account,
movie=comedy_movie,
category=comedy,
stream_id="comedy-1",
)
streams = xc_get_vod_streams(self.request, self.user, category_id=action.id)
self.assertEqual(len(streams), 1)
self.assertEqual(streams[0]["name"], "Action Movie")
self.assertEqual(streams[0]["category_id"], str(action.id))
def test_vod_streams_sorted_alphabetically_by_name(self):
account = self._account(f"acct-{uuid4().hex[:6]}")
zebra = Movie.objects.create(name="Zebra Film")
apple = Movie.objects.create(name="Apple Film")
M3UMovieRelation.objects.create(
m3u_account=account, movie=zebra, stream_id="z-1"
)
M3UMovieRelation.objects.create(
m3u_account=account, movie=apple, stream_id="a-1"
)
streams = xc_get_vod_streams(self.request, self.user)
self.assertEqual([s["name"] for s in streams], ["Apple Film", "Zebra Film"])
def test_vod_streams_includes_metadata_fields(self):
account = self._account(f"acct-{uuid4().hex[:6]}")
movie = Movie.objects.create(
name="Rich Movie",
description="A plot",
genre="Drama",
year=2021,
rating="8",
custom_properties={
"director": "Dir",
"actors": "Cast",
"release_date": "2021-01-01",
"youtube_trailer": "yt123",
},
)
M3UMovieRelation.objects.create(
m3u_account=account,
movie=movie,
stream_id="rich-1",
container_extension="avi",
)
stream = xc_get_vod_streams(self.request, self.user)[0]
self.assertEqual(stream["plot"], "A plot")
self.assertEqual(stream["genre"], "Drama")
self.assertEqual(stream["year"], 2021)
self.assertEqual(stream["director"], "Dir")
self.assertEqual(stream["cast"], "Cast")
self.assertEqual(stream["release_date"], "2021-01-01")
self.assertEqual(stream["trailer"], "yt123")
self.assertEqual(stream["container_extension"], "avi")
def test_vod_streams_stream_icon_uses_logo_id_without_logo_join(self):
account = self._account(f"acct-{uuid4().hex[:6]}")
logo = VODLogo.objects.create(name="Poster", url="http://example.com/poster.png")
movie = Movie.objects.create(name="Logo Movie", logo=logo)
M3UMovieRelation.objects.create(
m3u_account=account,
movie=movie,
stream_id="logo-1",
)
stream = xc_get_vod_streams(self.request, self.user)[0]
self.assertIn(f"/{logo.id}/", stream["stream_icon"])
def test_series_picks_highest_priority_relation(self):
low = self._account(f"low-{uuid4().hex[:6]}", priority=1)
high = self._account(f"high-{uuid4().hex[:6]}", priority=10)
series = Series.objects.create(name="Shared Series", year=2019)
M3USeriesRelation.objects.create(
m3u_account=low,
series=series,
external_series_id="low-series",
)
high_rel = M3USeriesRelation.objects.create(
m3u_account=high,
series=series,
external_series_id="high-series",
)
results = xc_get_series(self.request, self.user)
self.assertEqual(len(results), 1)
self.assertEqual(results[0]["name"], "Shared Series")
self.assertEqual(results[0]["series_id"], high_rel.id)
def test_series_excludes_inactive_accounts(self):
active = self._account(f"active-{uuid4().hex[:6]}")
inactive = self._account(f"inactive-{uuid4().hex[:6]}", is_active=False)
active_series = Series.objects.create(name="Active Series")
inactive_series = Series.objects.create(name="Inactive Only Series")
M3USeriesRelation.objects.create(
m3u_account=active,
series=active_series,
external_series_id="active-s",
)
M3USeriesRelation.objects.create(
m3u_account=inactive,
series=inactive_series,
external_series_id="inactive-s",
)
results = xc_get_series(self.request, self.user)
self.assertEqual({r["name"] for r in results}, {"Active Series"})
def test_series_sorted_alphabetically_by_name(self):
account = self._account(f"acct-{uuid4().hex[:6]}")
z = Series.objects.create(name="Zulu Show")
a = Series.objects.create(name="Alpha Show")
M3USeriesRelation.objects.create(
m3u_account=account, series=z, external_series_id="z"
)
M3USeriesRelation.objects.create(
m3u_account=account, series=a, external_series_id="a"
)
results = xc_get_series(self.request, self.user)
self.assertEqual([r["name"] for r in results], ["Alpha Show", "Zulu Show"])
@skipUnless(connection.vendor == "postgresql", "PostgreSQL-specific query shape")
def test_vod_streams_dedupe_query_avoids_movie_join(self):
account = self._account(f"acct-{uuid4().hex[:6]}")
movie = Movie.objects.create(name="Query Shape Movie")
M3UMovieRelation.objects.create(
m3u_account=account, movie=movie, stream_id="qs-1"
)
with CaptureQueriesContext(connection) as ctx:
xc_get_vod_streams(self.request, self.user)
distinct_queries = [q for q in ctx.captured_queries if "DISTINCT" in q["sql"]]
self.assertEqual(len(distinct_queries), 1)
self.assertNotIn('"vod_movie"', distinct_queries[0]["sql"])
self.assertNotIn('"vod_vodlogo"', distinct_queries[0]["sql"])
fetch_queries = [
q
for q in ctx.captured_queries
if '"vod_movie"' in q["sql"] and "DISTINCT" not in q["sql"]
]
self.assertGreaterEqual(len(fetch_queries), 1)
fetch_sql = fetch_queries[0]["sql"]
self.assertNotIn('"vod_vodlogo"', fetch_sql)
self.assertNotIn('"vod_vodcategory"', fetch_sql)
@skipUnless(connection.vendor == "postgresql", "PostgreSQL-specific query shape")
def test_series_dedupe_query_avoids_series_join(self):
account = self._account(f"acct-{uuid4().hex[:6]}")
series = Series.objects.create(name="Query Shape Series")
M3USeriesRelation.objects.create(
m3u_account=account, series=series, external_series_id="qs-s"
)
with CaptureQueriesContext(connection) as ctx:
xc_get_series(self.request, self.user)
distinct_queries = [q for q in ctx.captured_queries if "DISTINCT" in q["sql"]]
self.assertEqual(len(distinct_queries), 1)
self.assertNotIn('"vod_series"', distinct_queries[0]["sql"])
fetch_queries = [
q
for q in ctx.captured_queries
if '"vod_series"' in q["sql"] and "DISTINCT" not in q["sql"]
]
self.assertGreaterEqual(len(fetch_queries), 1)
fetch_sql = fetch_queries[0]["sql"]
self.assertNotIn('"vod_vodlogo"', fetch_sql)
self.assertNotIn('"vod_vodcategory"', fetch_sql)
XC_VOD_STREAM_KEYS = frozenset({
"num", "name", "stream_type", "stream_id", "stream_icon", "rating",
"rating_5based", "added", "is_adult", "tmdb_id", "imdb_id", "trailer",
"plot", "genre", "year", "director", "cast", "release_date", "category_id",
"category_ids", "container_extension", "custom_sid", "direct_source",
})
XC_SERIES_KEYS = frozenset({
"num", "name", "series_id", "cover", "plot", "cast", "director", "genre",
"release_date", "releaseDate", "last_modified", "rating", "rating_5based",
"backdrop_path", "youtube_trailer", "episode_run_time", "category_id",
"category_ids", "tmdb_id", "imdb_id",
})
class XcVodSeriesRegressionTests(TestCase):
"""Full output-shape and edge-case regressions for XC list endpoints."""
def setUp(self):
self.factory = RequestFactory()
self.user = User.objects.create_user(
username=f"xc-reg-{uuid4().hex[:8]}",
password="pass",
custom_properties={"xc_password": "xcpass"},
)
self.request = self.factory.get("/player_api.php")
def _account(self, name, *, priority=0):
return M3UAccount.objects.create(
name=name,
server_url="http://example.com",
priority=priority,
)
def test_vod_streams_empty_library(self):
self.assertEqual(xc_get_vod_streams(self.request, self.user), [])
def test_series_empty_library(self):
self.assertEqual(xc_get_series(self.request, self.user), [])
def test_vod_streams_response_keys(self):
account = self._account(f"acct-{uuid4().hex[:6]}")
movie = Movie.objects.create(name="Schema Movie", rating="10")
M3UMovieRelation.objects.create(
m3u_account=account, movie=movie, stream_id="schema-1"
)
stream = xc_get_vod_streams(self.request, self.user)[0]
self.assertEqual(set(stream.keys()), XC_VOD_STREAM_KEYS)
self.assertEqual(stream["stream_type"], "movie")
self.assertEqual(stream["stream_id"], movie.id)
self.assertEqual(stream["rating_5based"], 5.0)
self.assertEqual(stream["custom_sid"], None)
self.assertEqual(stream["direct_source"], "")
def test_vod_streams_null_optional_fields(self):
account = self._account(f"acct-{uuid4().hex[:6]}")
movie = Movie.objects.create(name="Sparse Movie")
M3UMovieRelation.objects.create(
m3u_account=account,
movie=movie,
stream_id="sparse-1",
container_extension=None,
)
stream = xc_get_vod_streams(self.request, self.user)[0]
self.assertIsNone(stream["stream_icon"])
self.assertEqual(stream["category_id"], "0")
self.assertEqual(stream["category_ids"], [])
self.assertEqual(stream["container_extension"], "mp4")
self.assertEqual(stream["plot"], "")
self.assertEqual(stream["trailer"], "")
self.assertEqual(stream["tmdb_id"], "")
self.assertEqual(stream["imdb_id"], "")
def test_vod_streams_category_from_winning_relation(self):
"""Category must come from the highest-priority relation, not any relation."""
low = self._account(f"low-{uuid4().hex[:6]}", priority=1)
high = self._account(f"high-{uuid4().hex[:6]}", priority=10)
action = VODCategory.objects.create(name="Action", category_type="movie")
comedy = VODCategory.objects.create(name="Comedy", category_type="movie")
movie = Movie.objects.create(name="Dual Category Movie")
M3UMovieRelation.objects.create(
m3u_account=low,
movie=movie,
category=action,
stream_id="low-cat",
)
M3UMovieRelation.objects.create(
m3u_account=high,
movie=movie,
category=comedy,
stream_id="high-cat",
)
stream = xc_get_vod_streams(self.request, self.user)[0]
self.assertEqual(stream["category_id"], str(comedy.id))
self.assertEqual(stream["category_ids"], [comedy.id])
def test_series_response_keys_and_metadata(self):
account = self._account(f"acct-{uuid4().hex[:6]}")
logo = VODLogo.objects.create(name="Cover", url="http://example.com/cover.png")
category = VODCategory.objects.create(name="Drama", category_type="series")
series = Series.objects.create(
name="Schema Series",
description="Series plot",
genre="Sci-Fi",
year=2022,
rating="8",
tmdb_id="tm123",
imdb_id="tt123",
logo=logo,
custom_properties={
"cast": "Actor A",
"director": "Director B",
"release_date": "2022-06-01",
"backdrop_path": ["/img1.jpg"],
"youtube_trailer": "yt-series",
"episode_run_time": "45",
},
)
relation = M3USeriesRelation.objects.create(
m3u_account=account,
series=series,
category=category,
external_series_id="schema-s",
)
row = xc_get_series(self.request, self.user)[0]
self.assertEqual(set(row.keys()), XC_SERIES_KEYS)
self.assertEqual(row["series_id"], relation.id)
self.assertIn(f"/{logo.id}/", row["cover"])
self.assertEqual(row["plot"], "Series plot")
self.assertEqual(row["cast"], "Actor A")
self.assertEqual(row["director"], "Director B")
self.assertEqual(row["genre"], "Sci-Fi")
self.assertEqual(row["release_date"], "2022-06-01")
self.assertEqual(row["releaseDate"], "2022-06-01")
self.assertEqual(row["backdrop_path"], ["/img1.jpg"])
self.assertEqual(row["youtube_trailer"], "yt-series")
self.assertEqual(row["episode_run_time"], "45")
self.assertEqual(row["tmdb_id"], "tm123")
self.assertEqual(row["imdb_id"], "tt123")
self.assertEqual(row["category_id"], str(category.id))
self.assertEqual(row["category_ids"], [category.id])
self.assertEqual(row["last_modified"], str(int(relation.updated_at.timestamp())))
def test_series_null_optional_fields(self):
account = self._account(f"acct-{uuid4().hex[:6]}")
series = Series.objects.create(name="Sparse Series")
M3USeriesRelation.objects.create(
m3u_account=account,
series=series,
external_series_id="sparse-s",
)
row = xc_get_series(self.request, self.user)[0]
self.assertIsNone(row["cover"])
self.assertEqual(row["category_id"], "0")
self.assertEqual(row["category_ids"], [])
self.assertEqual(row["release_date"], "")
self.assertEqual(row["releaseDate"], "")
self.assertEqual(row["backdrop_path"], [])
self.assertEqual(row["youtube_trailer"], "")
self.assertEqual(row["episode_run_time"], "")
def test_series_release_date_falls_back_to_year(self):
account = self._account(f"acct-{uuid4().hex[:6]}")
series = Series.objects.create(name="Year Only", year=2018)
M3USeriesRelation.objects.create(
m3u_account=account,
series=series,
external_series_id="year-s",
)
row = xc_get_series(self.request, self.user)[0]
self.assertEqual(row["release_date"], "2018")
self.assertEqual(row["releaseDate"], "2018")
def test_priority_tiebreaker_uses_lower_relation_id(self):
"""Same priority: DISTINCT ON tie-breaks on relation id ascending."""
a1 = self._account(f"a1-{uuid4().hex[:6]}", priority=5)
a2 = self._account(f"a2-{uuid4().hex[:6]}", priority=5)
movie = Movie.objects.create(name="Tie Movie")
first = M3UMovieRelation.objects.create(
m3u_account=a1,
movie=movie,
stream_id="first",
container_extension="mkv",
)
M3UMovieRelation.objects.create(
m3u_account=a2,
movie=movie,
stream_id="second",
container_extension="mp4",
)
stream = xc_get_vod_streams(self.request, self.user)[0]
self.assertEqual(stream["container_extension"], first.container_extension)

View file

@ -918,6 +918,73 @@ def xc_get_epg(request, user, short=False):
return output
XC_MOVIE_VALUE_FIELDS = (
'id', 'movie_id', 'category_id', 'container_extension',
'movie__id', 'movie__name', 'movie__rating', 'movie__created_at',
'movie__tmdb_id', 'movie__imdb_id', 'movie__description', 'movie__genre',
'movie__year', 'movie__custom_properties', 'movie__logo_id',
)
XC_SERIES_VALUE_FIELDS = (
'id', 'series_id', 'category_id', 'updated_at',
'series__id', 'series__name', 'series__description', 'series__genre',
'series__year', 'series__rating', 'series__custom_properties', 'series__logo_id',
'series__tmdb_id', 'series__imdb_id',
)
def _xc_fetch_priority_distinct_relations(
*,
manager,
rel_filters,
distinct_field,
value_fields,
order_by_name_field,
):
"""
Return one row dict per distinct content ID (highest account priority wins).
On PostgreSQL, dedupe on narrow relation rows first, then fetch display
columns via values() (no ORM model instantiation). That avoids sorting
wide joined rows during DISTINCT ON and reduces parallel worker /dev/shm
pressure in Docker.
"""
from django.db import connection, transaction
narrow_qs = manager.filter(**rel_filters)
def _fetch_by_ids(ids):
return list(
manager.filter(pk__in=ids)
.values(*value_fields)
.order_by(Lower(order_by_name_field))
)
if connection.vendor == 'postgresql':
winning_ids_qs = (
narrow_qs
.order_by(distinct_field, '-m3u_account__priority', 'id')
.distinct(distinct_field)
.values('pk')
)
with transaction.atomic():
#with connection.cursor() as cursor:
#cursor.execute("SET LOCAL max_parallel_workers_per_gather = 0")
winning_ids = list(winning_ids_qs.values_list('pk', flat=True))
if not winning_ids:
return []
return _fetch_by_ids(winning_ids)
seen = {}
for row in narrow_qs.values(*value_fields).order_by('-m3u_account__priority', 'id'):
key = row[distinct_field]
if key not in seen:
seen[key] = row
rows = list(seen.values())
rows.sort(key=lambda r: (r[order_by_name_field] or '').lower())
return rows
def xc_get_vod_categories(user):
"""Get VOD categories for XtreamCodes API"""
from apps.vod.models import VODCategory, M3UMovieRelation
@ -943,33 +1010,19 @@ def xc_get_vod_categories(user):
def xc_get_vod_streams(request, user, category_id=None):
"""Get VOD streams (movies) for XtreamCodes API"""
from apps.vod.models import M3UMovieRelation
from django.db import connection
rel_filters = {"m3u_account__is_active": True}
if category_id:
rel_filters["category_id"] = category_id
base_qs = (
M3UMovieRelation.objects
.filter(**rel_filters)
.select_related('movie', 'movie__logo', 'category')
relations = _xc_fetch_priority_distinct_relations(
manager=M3UMovieRelation.objects,
rel_filters=rel_filters,
distinct_field='movie_id',
value_fields=XC_MOVIE_VALUE_FIELDS,
order_by_name_field='movie__name',
)
if connection.vendor == 'postgresql':
# DISTINCT ON returns one row per movie (highest-priority active relation)
# in a single query. ORDER BY must lead with the DISTINCT field.
relations = list(
base_qs.order_by('movie_id', '-m3u_account__priority', 'id')
.distinct('movie_id')
)
else:
# SQLite fallback: fetch all matching relations, deduplicate in Python.
seen: dict = {}
for rel in base_qs.order_by('-m3u_account__priority', 'id'):
if rel.movie_id not in seen:
seen[rel.movie_id] = rel
relations = list(seen.values())
# Precompute logo URL prefix/suffix once (mirrors _xc_live_streams_setup)
# so each row only needs a string concat instead of reverse() + URI build.
_base_url = build_absolute_uri_with_port(request, "")
@ -978,44 +1031,40 @@ def xc_get_vod_streams(request, user, category_id=None):
_logo_url_prefix = _base_url + _logo_prefix_raw + "/"
_logo_url_suffix = "/" + _logo_suffix_raw
# Sort by name (DISTINCT ON forces ORDER BY movie_id; SQLite path is unsorted).
relations.sort(key=lambda r: (r.movie.name or "").lower())
streams = []
append = streams.append
for num, relation in enumerate(relations, 1):
movie = relation.movie
custom_props = movie.custom_properties or {}
category = relation.category
category_id_str = str(category.id) if category else "0"
category_id_list = [category.id] if category else []
rating = movie.rating
logo = movie.logo
for num, row in enumerate(relations, 1):
custom_props = row['movie__custom_properties'] or {}
category_id = row['category_id']
category_id_str = str(category_id) if category_id else "0"
category_id_list = [category_id] if category_id else []
rating = row['movie__rating']
logo_id = row['movie__logo_id']
append({
"num": num,
"name": movie.name,
"name": row['movie__name'],
"stream_type": "movie",
"stream_id": movie.id,
"stream_id": row['movie__id'],
"stream_icon": (
f"{_logo_url_prefix}{logo.id}{_logo_url_suffix}" if logo else None
f"{_logo_url_prefix}{logo_id}{_logo_url_suffix}" if logo_id else None
),
"rating": rating or "0",
"rating_5based": round(float(rating or 0) / 2, 2) if rating else 0,
"added": str(int(movie.created_at.timestamp())),
"added": str(int(row['movie__created_at'].timestamp())),
"is_adult": 0,
"tmdb_id": movie.tmdb_id or "",
"imdb_id": movie.imdb_id or "",
"tmdb_id": row['movie__tmdb_id'] or "",
"imdb_id": row['movie__imdb_id'] or "",
"trailer": custom_props.get('youtube_trailer') or "",
"plot": movie.description or "",
"genre": movie.genre or "",
"year": movie.year or "",
"plot": row['movie__description'] or "",
"genre": row['movie__genre'] or "",
"year": row['movie__year'] or "",
"director": custom_props.get('director', ''),
"cast": custom_props.get('actors', ''),
"release_date": custom_props.get('release_date', ''),
"category_id": category_id_str,
"category_ids": category_id_list,
"container_extension": relation.container_extension or "mp4",
"container_extension": row['container_extension'] or "mp4",
"custom_sid": None,
"direct_source": "",
})
@ -1048,72 +1097,58 @@ def xc_get_series_categories(user):
def xc_get_series(request, user, category_id=None):
"""Get series list for XtreamCodes API"""
from apps.vod.models import M3USeriesRelation
from django.db import connection
rel_filters = {"m3u_account__is_active": True}
if category_id:
rel_filters["category_id"] = category_id
base_qs = (
M3USeriesRelation.objects
.filter(**rel_filters)
.select_related('series', 'series__logo', 'category')
relations = _xc_fetch_priority_distinct_relations(
manager=M3USeriesRelation.objects,
rel_filters=rel_filters,
distinct_field='series_id',
value_fields=XC_SERIES_VALUE_FIELDS,
order_by_name_field='series__name',
)
if connection.vendor == 'postgresql':
relations = list(
base_qs.order_by('series_id', '-m3u_account__priority', 'id')
.distinct('series_id')
)
else:
seen: dict = {}
for rel in base_qs.order_by('-m3u_account__priority', 'id'):
if rel.series_id not in seen:
seen[rel.series_id] = rel
relations = list(seen.values())
_base_url = build_absolute_uri_with_port(request, "")
_sample_logo_path = reverse("api:vod:vodlogo-cache", args=[0])
_logo_prefix_raw, _, _logo_suffix_raw = _sample_logo_path.partition("/0/")
_logo_url_prefix = _base_url + _logo_prefix_raw + "/"
_logo_url_suffix = "/" + _logo_suffix_raw
relations.sort(key=lambda r: (r.series.name or "").lower())
series_list = []
append = series_list.append
for num, relation in enumerate(relations, 1):
series = relation.series
custom_props = series.custom_properties or {}
category = relation.category
rating = series.rating
logo = series.logo
year_str = str(series.year) if series.year else ""
for num, row in enumerate(relations, 1):
custom_props = row['series__custom_properties'] or {}
category_id = row['category_id']
rating = row['series__rating']
logo_id = row['series__logo_id']
year_str = str(row['series__year']) if row['series__year'] else ""
release_date = custom_props.get('release_date', year_str)
append({
"num": num,
"name": series.name,
"series_id": relation.id,
"name": row['series__name'],
"series_id": row['id'],
"cover": (
f"{_logo_url_prefix}{logo.id}{_logo_url_suffix}" if logo else None
f"{_logo_url_prefix}{logo_id}{_logo_url_suffix}" if logo_id else None
),
"plot": series.description or "",
"plot": row['series__description'] or "",
"cast": custom_props.get('cast', ''),
"director": custom_props.get('director', ''),
"genre": series.genre or "",
"genre": row['series__genre'] or "",
"release_date": release_date,
"releaseDate": release_date,
"last_modified": str(int(relation.updated_at.timestamp())),
"last_modified": str(int(row['updated_at'].timestamp())),
"rating": str(rating or "0"),
"rating_5based": str(round(float(rating or 0) / 2, 2)) if rating else "0",
"backdrop_path": custom_props.get('backdrop_path', []),
"youtube_trailer": custom_props.get('youtube_trailer', ''),
"episode_run_time": custom_props.get('episode_run_time', ''),
"category_id": str(category.id) if category else "0",
"category_ids": [category.id] if category else [],
"tmdb_id": series.tmdb_id or "",
"imdb_id": series.imdb_id or "",
"category_id": str(category_id) if category_id else "0",
"category_ids": [category_id] if category_id else [],
"tmdb_id": row['series__tmdb_id'] or "",
"imdb_id": row['series__imdb_id'] or "",
})
return series_list