mirror of
https://github.com/Dispatcharr/Dispatcharr.git
synced 2026-07-18 00:55:50 +00:00
Merge branch 'dev' of https://github.com/Dispatcharr/Dispatcharr into pr/Goldenfreddy0703/1254
This commit is contained in:
commit
0f0e855507
167 changed files with 22718 additions and 4539 deletions
31
.github/workflows/issue-template-check.yml
vendored
Normal file
31
.github/workflows/issue-template-check.yml
vendored
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
on:
|
||||
issues:
|
||||
types: [opened, reopened]
|
||||
|
||||
jobs:
|
||||
check:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
|
||||
# Request a bot user token
|
||||
- uses: actions/create-github-app-token@v1
|
||||
id: app-token
|
||||
with:
|
||||
app-id: ${{ secrets.BOT_APP_ID }}
|
||||
private-key: ${{ secrets.BOT_PRIVATE_KEY }}
|
||||
|
||||
# Do the actual check
|
||||
- uses: Dispatcharr/repo-bot/actions/template-enforcer@v1
|
||||
with:
|
||||
github-token: ${{ steps.app-token.outputs.token }}
|
||||
event-type: issue
|
||||
required-type: "Bug, Feature"
|
||||
enforcement: close-and-lock
|
||||
bypass-for-members: true
|
||||
close-comment: |
|
||||
## Issue not opened from a template
|
||||
|
||||
|
||||
This issue was closed because it was not opened using one of the available issue templates.
|
||||
|
||||
Please [open a new issue]({new-issue-url}) and select the appropriate template. This helps us triage and address issues efficiently.
|
||||
65
.github/workflows/pr-compliance-check.yml
vendored
Normal file
65
.github/workflows/pr-compliance-check.yml
vendored
Normal file
|
|
@ -0,0 +1,65 @@
|
|||
on:
|
||||
pull_request_target:
|
||||
types: [opened, reopened, edited]
|
||||
|
||||
jobs:
|
||||
check:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
|
||||
# Request a bot user token
|
||||
- uses: actions/create-github-app-token@v1
|
||||
id: app-token
|
||||
with:
|
||||
app-id: ${{ secrets.BOT_APP_ID }}
|
||||
private-key: ${{ secrets.BOT_PRIVATE_KEY }}
|
||||
|
||||
# Delete old bot comments before posting fresh ones
|
||||
- uses: Dispatcharr/repo-bot/actions/comment-collapse@v1
|
||||
with:
|
||||
github-token: ${{ steps.app-token.outputs.token }}
|
||||
mode: delete
|
||||
|
||||
# Template + Agreement Check
|
||||
- uses: Dispatcharr/repo-bot/actions/template-enforcer@v1
|
||||
with:
|
||||
github-token: ${{ steps.app-token.outputs.token }}
|
||||
event-type: pull_request
|
||||
required-markers: "## How was it tested?, ## Checklist, - [x] I agree to the [Contributor License Agreement](../blob/dev/CONTRIBUTING.md#contributor-license-agreement)"
|
||||
enforcement: comment-only
|
||||
bypass-for-members: true
|
||||
close-comment: |
|
||||
## PR requirements not met
|
||||
|
||||
|
||||
Your PR description is missing one or more required sections. Please ensure all of the following are present and filled out exactly as they appear in the [PR template](../blob/dev/.github/pull_request_template.md).:
|
||||
|
||||
- **How was it tested?** Heading (describe how you verified your changes)
|
||||
- **Checklist** Heading (completed from the [pull request template](../blob/dev/.github/pull_request_template.md))
|
||||
- **Contributor License Agreement** Checklist Item (the following item must appear checked in your description):
|
||||
|
||||
> - [x] I agree to the [Contributor License Agreement](../blob/dev/CONTRIBUTING.md#contributor-license-agreement)
|
||||
|
||||
|
||||
Edit your PR description to add any missing items. See [CONTRIBUTING.md](../blob/dev/CONTRIBUTING.md) for full contribution guidelines. Pull requests that do not follow the template, or that are wholly AI-generated or CLI-created, will be closed.
|
||||
|
||||
# Target Check
|
||||
- uses: Dispatcharr/repo-bot/actions/branch-guard@v1
|
||||
with:
|
||||
github-token: ${{ steps.app-token.outputs.token }}
|
||||
allowed-targets: "dev"
|
||||
enforcement: comment-only
|
||||
bypass-for-members: true
|
||||
comment: |
|
||||
## Wrong target branch
|
||||
|
||||
|
||||
This PR targets `{target-branch}`, but all contributions must target the `dev` branch.
|
||||
|
||||
> **To fix this:**
|
||||
> 1. Open the PR and click **Edit** next to the title
|
||||
> 2. Change the base branch from `{target-branch}` to `dev`
|
||||
> 3. Save the change
|
||||
|
||||
|
||||
Unsure about our contribution guidelines? See [CONTRIBUTING.md](../blob/dev/CONTRIBUTING.md).
|
||||
131
CHANGELOG.md
131
CHANGELOG.md
|
|
@ -7,6 +7,118 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||
|
||||
## [Unreleased]
|
||||
|
||||
### Changed
|
||||
|
||||
- **EPG auto-match overhaul** — matching logic moved to `apps/channels/epg_matching.py`; Celery tasks in `tasks.py` are thin wrappers.
|
||||
- Single-channel auto-match is now asynchronous: the API returns `202 Accepted` and pushes the result over WebSocket (`single_channel_epg_match`), so large EPG libraries no longer hit the previous 30-second HTTP timeout.
|
||||
- Progress, bulk completion, and single-channel results use `send_websocket_update` instead of `async_to_sync(channel_layer.group_send)`, so notifications work reliably under gevent-patched uWSGI and Celery workers.
|
||||
- Single-channel and selected-channel auto-match always run, even when the channel already has EPG assigned; match-all (no channel IDs) still only processes channels without EPG.
|
||||
- Rematching to the same EPG no longer re-saves the channel or queues program-parse tasks; only assignments that actually change are written and refreshed.
|
||||
|
||||
### Performance
|
||||
|
||||
- **EPG auto-match memory and throughput improvements.**
|
||||
- Single-channel matching streams active EPG rows and keeps only the best match plus the top 20 candidates in memory; ML validates at most 21 names per channel instead of embedding the full catalog.
|
||||
- Strong fuzzy matches (≥75% single channel, ≥80% bulk) skip ML entirely, avoiding a ~500MB PyTorch load when the fuzzy result is already reliable.
|
||||
- Bulk matching uses a single fuzzy pass per channel instead of scanning the full catalog twice for best match and top candidates.
|
||||
- Bulk exact `tvg_id` / Gracenote matching uses an in-memory index built alongside the EPG catalog (`build_epg_matching_catalog()`), giving O(1) lookups with no extra database queries.
|
||||
- Bulk match apply uses batched queries (two fetches plus `bulk_update`) instead of one `EPGData.objects.get()` per matched channel.
|
||||
- EPG normalization settings are cached once per matching run, avoiding repeated `CoreSettings` reads when normalizing thousands of names.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **EPG auto-match reliability fixes.**
|
||||
- Memory could spike to multiple GB on large EPG sources when building a full in-memory catalog before fuzzy matching; single-channel matching now streams rows and bounds ML work to a small candidate set.
|
||||
- Wrong channel assignments from global ML similarity; ML validation now checks the fuzzy best match (or top fuzzy candidates as a last resort) instead of scoring the entire catalog.
|
||||
- Channel form auto-match spinner could stick after errors or early task exits; all single-channel outcomes now push a WebSocket result, and the UI clears loading state after a 3-minute timeout.
|
||||
- Bulk auto-match completion no longer calls `batch-set-epg` from the WebSocket handler, which had been re-applying every match and queueing redundant `parse_programs_for_tvg_id` tasks even when assignments were unchanged.
|
||||
|
||||
## [0.26.0] - 2026-06-07
|
||||
|
||||
### Added
|
||||
|
||||
- **EPG logo auto-apply for XMLTV and Schedules Direct.** Channel logos are applied from `EPGData.icon_url` through a shared helper used by bulk "Set Logos from EPG", optional per-source auto-apply on refresh (`auto_apply_epg_logos` in source `custom_properties`), and a new `epg_source_id` option on the set-logos-from-epg API. Large libraries are processed in 500-channel chunks without loading every mapped row into memory.
|
||||
- **Schedules Direct EPG integration.** Dispatcharr now supports Schedules Direct as a first-class EPG source type alongside XMLTV and dummy EPG, with credential auth, lineup management, and guide refresh through the existing EPG pipeline and WebSocket progress. (Closes #1246) — Thanks [@Shokkstokk](https://github.com/Shokkstokk)
|
||||
- Lineup manager in EPG source settings: search by postal code, add/remove up to four active lineups, with SD's six-adds-per-24-hours limit and midnight-UTC reset surfaced in the UI.
|
||||
- MD5 delta refresh skips unchanged schedule and program downloads; a two-hour minimum interval between full refreshes is enforced (bypassable via force refresh).
|
||||
- Station logos in dark, light, gray, or white variants (`logo_style`), stored in `EPGData.icon_url` alongside XMLTV channel icons.
|
||||
- Optional program poster fetch (off by default): configurable style preference (SD Recommended via Gracenote's `primary` flag, or portrait/landscape banner/iconic variants with fallbacks), served through a backend proxy cached by nginx on first view.
|
||||
- XMLTV-compatible cast output (`role` for character names, `guest` for guest stars).
|
||||
- Lightweight stations-only fetch on source creation so EPG entries exist for auto-matching before the first full schedule pull.
|
||||
- **Live EPG program preview in the channel create/edit modal.** When selecting an EPG channel in the channel form, a "Current Program" card appears showing the currently-airing program title, description, and a progress bar. The backend builds a byte-offset index over the raw XMLTV file after each EPG refresh so lookups seek directly to the relevant file positions rather than parsing the full file - returning results in 1-10ms regardless of source size. If the index has not yet been built for a source, the API dispatches an async background build and the frontend retries up to 20 times over a 3-minute window. The `ProgramPreview` component was extracted into a shared component reused by the Stats page. The `programme_index` field on `EPGSource` is excluded from all API list responses to avoid returning multi-MB blobs. — Thanks [@FiveBoroughs](https://github.com/FiveBoroughs)
|
||||
- **Public IP display in the sidebar is now blurred by default and reveals on click.** Prevents accidental exposure in screenshots and screen shares. A new toggle in Settings > System Settings lets users disable IP and geolocation fetching entirely. A `DISPATCHARR_ENABLE_IP_LOOKUP` environment variable provides a container-level override; when set to `false` the toggle is hidden from the UI and cannot be changed. (Closes #1302) — Thanks [@sethwv](https://github.com/sethwv)
|
||||
- **Configurable per-page count and sticky pagination footer in Plugin Browse.** The pagination controls now live in a fixed footer bar at the bottom of the page. A page-size selector (9 / 18 / 27 / 36) sits alongside the pagination widget and an item range readout (`X to Y of Z`). The selected page size is persisted in `localStorage` so it survives page navigations. — Thanks [@sethwv](https://github.com/sethwv)
|
||||
- **VOD basic sync now stores additional metadata from the provider's stream list.** When a provider includes `director`, `cast`, `release_date`, or `trailer`/`youtube_trailer` in its `get_vod_streams` response, Dispatcharr now captures and stores those fields in `custom_properties` during the initial sync pass. Previously, this data was discarded and only populated if an advanced per-movie refresh was triggered. - Thanks [@nemesbak](https://github.com/nemesbak)
|
||||
|
||||
### Changed
|
||||
|
||||
- **`send_websocket_update` now detects Celery worker context when gevent is monkey-patched.** EPG refresh progress (including Schedules Direct) uses the shared `send_epg_update` helper instead of a duplicate synchronous Redis sender. In Celery prefork workers that inherit gevent patching, WebSocket messages are delivered synchronously via the existing `_gevent_ws_send` Redis path; uWSGI continues to use `gevent.spawn` as before.
|
||||
- **Schedules Direct EPG delete confirmation shows username only.** The delete dialog no longer surfaces password or API key fields for credential-based SD sources.
|
||||
- **Channel edit form reorganized into three semantic columns.** Fields are now grouped as Identity (name, number, group, logo), Guide Data (TVG-ID, Gracenote StationId, EPG picker, current program preview), and Behavior/Access (stream profile, user level, mature content, hidden). The EPG section having its own column gives the `ProgramPreview` card enough width to truncate long titles correctly rather than expanding the column.
|
||||
- **IP lookup result delivered via WebSocket push.** When the background lookup completes, an `ip_lookup_complete` event is pushed to all connected clients so the sidebar IP field populates without polling. A `Skeleton` placeholder is shown while the lookup is in progress.
|
||||
- **`get_host_and_port` and `build_absolute_uri_with_port` moved from `apps/output/views.py` to `core/utils.py`.** Both helpers have no dependencies on anything in `apps/output` and are now used in `apps/channels/serializers.py` as well. Moving them to `core/utils` eliminates the need for a local import inside `LogoSerializer` and makes them available to the rest of the codebase without circular-import risk. `LogoSerializer.get_cache_url()` was also updated to use `build_absolute_uri_with_port` instead of `request.build_absolute_uri()`, so logo cache URLs now correctly include non-standard ports (fixing port-stripping for logo URLs behind reverse proxies, matching the existing fix applied to M3U and EPG URLs).
|
||||
- **nginx logo/poster proxy cache moved to `/data/logo_cache`.** `proxy_cache_path` now uses the persistent data volume instead of `/app/logo_cache`, and the init script ensures the directory exists with correct ownership on container start.
|
||||
- **`debian_install.sh` switched from Gunicorn to uWSGI with gevent workers.** The Debian/LXC bare-metal installer now deploys Dispatcharr under the same uWSGI + gevent stack used by the Docker image, eliminating a class of compatibility differences between the two deployment paths. The installer writes a `uwsgi-debian.ini` next to the app and manages uWSGI via a systemd service. The nginx site config now uses `uwsgi_pass` + `include uwsgi_params` instead of `proxy_pass`, which correctly populates `SERVER_PORT` in the WSGI environ so M3U playlist URLs include the configured port number (fixing the port-stripping bug from #1267 for bare-metal installs). Python 3.13 is now provisioned through uv's managed runtime so the install works on Debian 12 and Ubuntu 24.04 LTS regardless of the system Python version.
|
||||
- **`get_vod_streams` XC API response was missing metadata fields available from basic sync.** When a provider includes `director`, `cast`, `release_date`, `plot`, `genre`, or `year` in its `get_vod_streams` response, Dispatcharr stores those fields during the basic sync pass but was not including them in its own `get_vod_streams` XC output. The endpoint now outputs all six fields. The `trailer` key in the response also mapped to the wrong internal key (`trailer` instead of `youtube_trailer`) following the storage key rename, so the trailer field was always empty until an advanced refresh ran. Both issues are corrected. Users with existing libraries should trigger a VOD provider refresh to populate the missing fields. (Fixes #1228)
|
||||
- **Docker base image now uses a multi-stage build.** The builder stage installs compilers and dev headers (`gcc`, `g++`, `gfortran`, `build-essential`, `libopenblas-dev`, `libpcre3-dev`, `python3.13-dev`, `ninja-build`) to create the virtual environment and compile the legacy NumPy wheel (cpu-baseline=none for old hardware). The final runtime image starts from the same ffmpeg base, copies only the prebuilt venv and wheel from the builder, and installs only the runtime libraries needed at runtime - eliminating compiler binaries from production containers and reducing final image size. — Thanks [@kensac](https://github.com/kensac)
|
||||
- **`libpq-dev` removed from both build and runtime stages.** The project uses `psycopg[binary]` (psycopg3), which bundles its own statically linked copy of libpq inside the wheel. No system libpq headers or shared library are needed at build or runtime.
|
||||
- **Database driver upgraded from `psycopg2` to `psycopg3` (`psycopg[binary]`).** psycopg3 rewrote its network I/O layer in Python, so `gevent`'s `monkey.patch_all()` makes it gevent-cooperative without any additional patching. The `psycogreen` dependency and its driver-patching block in `gevent_patch.py` have been removed. Django's native psycopg3 connection pool is explicitly disabled (`pool: False`) so `django-db-geventpool` remains in sole control of connection lifecycle. The `dropdb` management command was updated to the `psycopg` API (`psycopg.connect`, `psycopg.sql`).
|
||||
- **Frontend unit tests extended to additional form components and the Guide page.** `ProgramRecordingModal`, `Recording`, `RecordingDetailsModal`, `RecurringRuleModal`, `ScheduleInput`, `SeriesRecordingModal`, `SeriesRuleEditorModal`, `Stream`, `StreamProfile`, `SuperuserForm`, `User`, `UserAgent`, and `VODCategoryFilter` now have Vitest + Testing Library test suites. Business logic was extracted from these components into corresponding utility modules and is exercised through the new tests. The Guide page was similarly refactored with extracted utils, and `dateTimeUtils.js` was extended to support the new test coverage. — Thanks [@nick4810](https://github.com/nick4810)
|
||||
- **Official plugin repository manifest URL moved to GitHub Pages.** The default `OFFICIAL_REPO_URL` now points to `https://dispatcharr.github.io/Plugins/manifest.json` instead of the raw GitHub content URL. GitHub Pages avoids opaque caching on raw content that could serve stale manifests without indication. A data migration updates existing `PluginRepo` rows marked `is_official=True` in place; fresh installs pick up the new URL from the model default after the seed migration runs. — Thanks [@sethwv](https://github.com/sethwv)
|
||||
|
||||
### Performance
|
||||
|
||||
- **M3U and EPG channel logo URLs no longer call `build_absolute_uri_with_port()` per channel.** The host/port/scheme base URL and logo path prefix/suffix are computed once per request; each row appends only the logo ID. M3U proxy stream URLs use the same pattern for channel UUIDs.
|
||||
- **`get_vod_streams` and `get_series` XC API response times reduced significantly for large libraries.** Both endpoints previously loaded all active relations per title (one row per account that carries the same movie/series), then discarded duplicates in Python. With large libraries across multiple active accounts this fetched a multiple of N rows. Both queries now use a single `DISTINCT ON (movie_id/series_id)` pass over the relation table ordered by account priority, returning exactly one row per title. Logo URL construction (`reverse()` + `build_absolute_uri_with_port()`) is also computed once and reused via prefix/suffix string concatenation, matching the existing pattern in `get_live_streams`. The `m3u_account` JOIN in `get_series` was also removed (it was fetched but never read in the loop). Additionally, `get_series` previously had no `order_by` on `M3USeriesRelation`, so output order was non-deterministic (physical storage order). Both endpoints now sort alphabetically by title. Observed improvements: 18s → 12s for 48k movies; 9.5s → 3.5s for 11k series.
|
||||
- **Database connections are now managed by a persistent per-worker pool.** Previously each request opened a fresh TCP connection to PostgreSQL, paid a full authentication handshake, and closed the connection at request end. `django-db-geventpool` now maintains a pool of warm connections per uWSGI worker; requests borrow a connection and return it when done, eliminating connection-setup overhead on every request. Pool size is bounded (`MAX_CONNS=8` per worker, `REUSE_CONNS=3` warm connections kept idle) to stay comfortably within PostgreSQL's default `max_connections=100` across all uWSGI workers, Celery workers, and Daphne. — Thanks [@JCBird1012](https://github.com/JCBird1012)
|
||||
- **Reduced Redis round-trips on the Stats page channel status endpoint.** `get_basic_channel_info` was making up to 6 individual `HGET` calls per connected client plus a redundant `HGET` for `TOTAL_BYTES` (already present in the preceding `HGETALL` result). Client metadata is now fetched with a single `HMGET` per client, and `TOTAL_BYTES` is read from the already-fetched hash. Under load with many active streams this significantly reduces the time each uWSGI worker holds the GIL servicing the stats endpoint, reducing the chance of concurrent requests from other pages timing out with a 503. The same `HGET`-to-`HMGET` consolidation was applied to `stream_ts` and `get_user_active_connections`. The Stats page frontend was also fixed to fire the initial fetch only once on mount (previously two `useEffect` hooks both triggered an immediate fetch on load). — Thanks [@JCBird1012](https://github.com/JCBird1012)
|
||||
|
||||
### Security
|
||||
|
||||
- **M3U endpoint no longer reflects POST body content in error responses.** The error message for disallowed POST requests previously echoed the raw request body back to the caller in a `text/html` response, which could be used for reflected XSS. The body is no longer included in the response. - Thanks [@sebastiondev](https://github.com/sebastiondev)
|
||||
- Updated frontend npm dependencies to resolve 5 audit vulnerabilities (2 moderate, 2 high, 1 critical):
|
||||
- Updated `react-router` and `react-router-dom` 7.13.0 → 7.17.0, resolving **high** unauthenticated RCE via turbo-stream TYPE_ERROR deserialization ([GHSA-49rj-9fvp-4h2h](https://github.com/advisories/GHSA-49rj-9fvp-4h2h)), **high** open redirect via protocol-relative `//` paths ([GHSA-2j2x-hqr9-3h42](https://github.com/advisories/GHSA-2j2x-hqr9-3h42)), **high** XSS in unstable RSC redirect handling via `javascript:` targets ([GHSA-8646-j5j9-6r62](https://github.com/advisories/GHSA-8646-j5j9-6r62)), **high** stored XSS via unescaped `Location` header in prerendered redirect HTML ([GHSA-f22v-gfqf-p8f3](https://github.com/advisories/GHSA-f22v-gfqf-p8f3)), **high** DoS via unbounded path expansion in the `__manifest` endpoint ([GHSA-8x6r-g9mw-2r78](https://github.com/advisories/GHSA-8x6r-g9mw-2r78)), and **high** DoS via reflected user input in single-fetch ([GHSA-rxv8-25v2-qmq8](https://github.com/advisories/GHSA-rxv8-25v2-qmq8))
|
||||
- Updated `vitest` 3.2.4 → 4.1.8, resolving **critical** arbitrary file read and execution when the Vitest UI server is listening ([GHSA-5xrq-8626-4rwp](https://github.com/advisories/GHSA-5xrq-8626-4rwp))
|
||||
- Updated `brace-expansion` 5.0.5 → 5.0.6, resolving **moderate** DoS when large numeric ranges defeat the documented `max` limit ([GHSA-jxxr-4gwj-5jf2](https://github.com/advisories/GHSA-jxxr-4gwj-5jf2))
|
||||
- Updated `ws` 8.19.0 → 8.21.0, resolving **moderate** uninitialized memory disclosure ([GHSA-58qx-3vcg-4xpx](https://github.com/advisories/GHSA-58qx-3vcg-4xpx))
|
||||
|
||||
### Fixed
|
||||
|
||||
- **HDHR lineup and discovery URLs now use the shared port-aware URI builder.** `lineup.json`, `discover.json`, and `device.xml` previously called Django's `request.build_absolute_uri()`, which drops non-standard ports behind reverse proxies and on dev installs. They now use `build_absolute_uri_with_port()` from `core/utils.py`, matching M3U, EPG, XC, and logo cache URLs.
|
||||
- **EPG channel list did not refresh after a source finished parsing channels.** The `epg_refresh` WebSocket handler closed over a stale `epgs` snapshot from when the socket connected. When a newly created source was not found in that snapshot, the handler updated progress and returned early without calling `fetchEPGData()`, so the channel picker and EPG assignment UI stayed empty until a full page reload. The handler now reads the current store via `getState()` and still calls `fetchEPGData()` when `parsing_channels` completes.
|
||||
- **DVR recordings no longer stop immediately when FFmpeg exits mid-stream.** If FFmpeg crashes, stalls, or loses the source before the scheduled end time, `run_recording` now restarts it in-process instead of going straight to HLS→MKV concat and marking the recording complete. Each restart reuses the same HLS working directory and continues segment numbering (`append_list`, `-start_number`) so the final MKV is a single continuous file. Retries are bounded by a per-outage time window matching live-proxy client tolerance (`STREAM_TIMEOUT` + `FAILOVER_GRACE_PERIOD`, default 80s); the window resets whenever new segments resume, so a long recording can survive multiple separate outages. Reconnect pacing between attempts follows the same `min(0.25 × attempt, 3s)` backoff used by the live-proxy `StreamManager`. Input demuxing also uses `-err_detect ignore_err` to tolerate minor TS corruption without aborting the process. If the outage window expires without recovery, the recording is saved as `interrupted` with `ffmpeg_outage_window_exhausted` rather than falsely reported as `completed`. (Closes #1170)
|
||||
- **DVR HLS→MKV concat now tolerates timestamp splices and corrupt segments.** The finalize step (and startup recovery concat) previously used a bare `ffmpeg -f concat -c copy`, which could fail on truncated tail segments or PTS discontinuities from FFmpeg restarts mid-recording. Concat now uses a shared `_dvr_build_hls_concat_cmd` helper with `-fflags +genpts+igndts+discardcorrupt`, `-err_detect ignore_err`, and `-avoid_negative_ts make_zero`; the existing MP4-intermediate fallback path uses the same tolerant input flags.
|
||||
- **DVR FFmpeg restart no longer skips HLS segment numbers.** On restart, `-start_number` was set to the existing segment count while `append_list` also incremented the internal sequence for each segment reloaded from `index.m3u8`, so the first post-restart segment could land at roughly double the expected index (e.g. `seg_00028.ts` after 14 segments). Restarts now pass `-start_number 0` when the playlist already lists segments and let `append_list` continue numbering; loose `.ts` files without a playlist still seed from the highest filename index + 1.
|
||||
- **Null TS keepalive packets during channel initialization caused Emby (and Jellyfin) to force a deinterlace transcode or fail playback entirely.** While waiting for a channel to become ready, the generator sent a null TS packet (PID `0x1FFF`) every 0.5 s to keep the HTTP connection alive. Emby probes the initial bytes of the response via ffprobe; receiving a null packet instead of real stream data produced incorrect codec metadata and triggered a forced transcode. The null-packet yield has been replaced with a plain `gevent.sleep(0.1)`. (Fixes #1280)
|
||||
- **VOD proxy stream URLs survive M3U import refresh cycles.** When `process_movie_batch` / `process_series_batch` create duplicate content records during a refresh, existing `M3UMovieRelation` / `M3UEpisodeRelation` rows are repointed at the new records, orphaning the old UUIDs. External players (Emby, Jellyfin, ChannelsDVR) that cached `.strm` URLs with the dead UUID would then 404 even though the same request carried a stable `stream_id` that maps to an active relation. The proxy now falls back to stream_id resolution when the UUID lookup misses, using strictest-match-first logic (account-scoped relation preferred, then highest-priority account). A `[STREAMID-FALLBACK]` WARNING log keeps the underlying import churn. — Thanks [@R3XCHRIS](https://github.com/R3XCHRIS)
|
||||
- **IP lookup no longer blocks the settings page load.** The `environment` endpoint previously made up to three sequential HTTP calls (ipify, ipapi.co, ip-api.com) with 5s timeouts each, blocking the page for up to 15s if any were unreachable. The lookups now run in a background thread on first request and results are cached in Redis for 1 hour, so the endpoint returns immediately on every call. — Thanks [@sethwv](https://github.com/sethwv)
|
||||
- **Authenticated users were not identified in VOD connection cards and stream events when streaming via the web player.** The VOD proxy now accepts a JWT via a `?token=` query parameter so browser `<video>` elements (which cannot send `Authorization` headers) can authenticate. The token is read on the initial request, the resolved user ID is stored in Redis, then retrieved on the actual streaming request after the session redirect. Previously the redirect discarded auth context and all JWT-authenticated VOD sessions were tracked as anonymous. (Fixes #1224)
|
||||
- **Deleting the last playlist (or any playlist) crashed the entire UI.** `removePlaylists()` in the playlists store filtered the `playlists` array but never removed the corresponding entries from the `profiles` map. After deletion, components reading `profiles[deletedId]` received `undefined` and crashed with `undefined is not an object (evaluating 'P[R].name')`, replacing the entire page with an error screen. The profiles map is now cleaned up atomically in the same state update as the playlists array. (Fixes #1269) - Thanks [@nemesbak](https://github.com/nemesbak)
|
||||
- **Switching providers in the VOD or Series detail modal had no effect.** The `provider-info` endpoints for movies and series always fetched data from the highest-priority provider, ignoring the `relation_id` the frontend sent when the user selected a different provider from the dropdown. The endpoints now accept an optional `relation_id` query parameter and fetch from that specific relation. The VOD modal also now shows the "Loading additional details..." indicator while the provider switch is in flight, matching the existing behaviour in the Series modal. (Fixes #1285) - Thanks [@nemesbak](https://github.com/nemesbak)
|
||||
- **Per-channel stream profile override was ignored during streaming.** `Channel.get_stream_profile()` read `self.stream_profile` directly, bypassing any `ChannelOverride.stream_profile` set by the user on auto-synced channels. The method now resolves through `effective_stream_profile_obj`, which checks the channel's override record first and falls back to the channel's own field. Channels without an override continue to behave identically to before. (Fixes #1268) - Thanks [@nemesbak](https://github.com/nemesbak)
|
||||
- **Web-player output profile was ignored for live streams started outside the Channels page.** `getShowVideoUrl` in `RecordingCardUtils.js` returned a raw proxy URL without calling `buildLiveStreamUrl`, so the output profile preference stored in `localStorage` was not applied when launching a stream from the TV Guide, Program Detail modal, DVR page, Recording Details modal, or Recording Card. Only the Channels page (StreamsTable) was calling `buildLiveStreamUrl` correctly. One additional import and one changed return value fix all affected entry points. (Fixes #1304) - Thanks [@nemesbak](https://github.com/nemesbak)
|
||||
- **Plugins with available updates were not sorting to the top of the Plugin Browse list.** The sort weight function previously treated `update_available` plugins the same as any other installed plugin, leaving them buried. They now receive the highest sort priority (below the search/filter results header) so users can spot pending updates immediately. — Thanks [@sethwv](https://github.com/sethwv)
|
||||
- **Disabled state on the size-labeled install button rendered with a warm tint instead of appearing clearly disabled.** The CSS filter was `brightness(0.65) saturate(0.7)`, which left a faint color cast. It is now `grayscale(1) brightness(0.55)`, matching the standard disabled appearance. — Thanks [@sethwv](https://github.com/sethwv)
|
||||
- **Restoring a backup from an older version left the database with missing schema.** The restore task ran `pg_restore` which replaced the entire database (including the `django_migrations` table) but did not run migrations afterward. If the backup predated a schema migration, the restored database was missing tables and columns added by those migrations, causing 500 errors on every API call. `migrate --noinput` now runs automatically after every restore. The success notification also now recommends a restart to clear stale service state.
|
||||
- **Cast and actors lists were silently truncated to the first name.** When a provider returns `cast` or `actors` as a JSON array, the helper used during both movie and series basic sync and movie advanced refresh only extracted the first element. All names in the array are now joined into a comma-separated string. Providers that return cast as a plain string are unaffected.
|
||||
- **Channel Group Override interactions in compact numbering and override display.** Two related bugs surfaced when Channel Group Override was used with auto-synced channels. First, compact numbering silently failed: with an override on the source `ChannelGroupM3UAccount`, sync stores channels under the override target group's id (not the source group id), so hide/unhide/repack operations all missed their channels and slot accounting broke silently (hidden channels kept their numbers, unhides got none, repack saw zero channels). The compact paths now include an override-aware fallback to match channels under both source and override-target group ids. Second, the clear-override reset button disappeared when an override's stored value coincidentally matched the provider value. The frontend `isFormFieldOverridden` function was value-based only; it now also checks for a persisted override row regardless of value, so the reset button remains available to clear any override. (Fixes #1263, Fixes #1276) — Thanks [@CodeBormen](https://github.com/CodeBormen)
|
||||
- **Compact numbering repack is now idempotent.** Auto-synced channel numbers reshuffled within their configured range on every sync, even when the provider returned no changes. The compact repack queried channels with no `ORDER BY`, so packing followed PostgreSQL's physical row order, which drifts after each repack's UPDATEs and autovacuum. The channel query now uses `.order_by("id")` (creation order tracks provider stream order for the default "provider" sort), and explicit name / tvg_id / updated_at sorts carry `c.id` as a secondary tiebreaker so equal values keep a stable relative order instead of churning. (Fixes #1321) — Thanks [@CodeBormen](https://github.com/CodeBormen)
|
||||
|
||||
## [0.25.1] - 2026-05-23
|
||||
|
||||
### Fixed
|
||||
|
||||
- **`SynchronousOnlyOperation` and permanent loading state in series rules save.** `_evaluate_series_rules_locked` and `reschedule_upcoming_recordings_for_offset_change_impl` called `async_to_sync(channel_layer.group_send)` directly from WSGI views. In a uWSGI/gevent worker this has two effects: (1) it sets Python 3.10+'s C-level OS-thread-local running-loop flag, causing Django's `@async_unsafe` guard to raise `SynchronousOnlyOperation` on any ORM call made by another greenlet on the same thread; (2) the asyncio event loop it creates cannot make progress because the gevent hub is blocked waiting for the request greenlet to complete, so the request hangs and the frontend spinner never clears. Both functions now use `send_websocket_update` - the same gevent-aware helper used elsewhere - which takes a direct Redis path (no asyncio) in gevent workers. (Fixes #1260)
|
||||
- **Migration 0037 fails on PostgreSQL when channels have been orphaned from their M3U account.** Channels created by auto-sync retain `auto_created=True` but get `auto_created_by=NULL` when the originating M3U account is deleted (`on_delete=SET_NULL`). The data migration step that re-attributes or demotes those channels updates the FK column via ORM `.save()` calls, which queues deferred constraint trigger events in PostgreSQL. The subsequent `ALTER TABLE ... DROP NOT NULL` on the same table then fails with `ObjectInUse: cannot ALTER TABLE because it has pending trigger events`. Fixed by issuing `SET CONSTRAINTS ALL IMMEDIATE` at the end of the data migration function to flush those pending events before the DDL runs. (Fixes #1259)
|
||||
- **XC stream URLs with no file extension now respect output format defaults.** `stream_xc` previously treated any extension other than `.mp4` as a forced `mpegts` request, including the empty-extension URLs that XC-compatible M3U playlists produce via `get.php`. Requests with no extension now pass `force_format=None` so the standard resolution chain (request param, user default, server default) applies correctly.
|
||||
- **XC M3U stream URLs now carry output profile and output format parameters.** The `get.php` M3U playlist previously emitted bare `/live/user/pass/id` URLs with no query string, causing per-user and server-wide output profile and output format settings to be silently ignored for XC clients. When `output_profile` or `output_format` parameters are present on the playlist request, they are now appended to every stream URL in the playlist so the proxy honours them on playback.
|
||||
|
||||
### Performance
|
||||
|
||||
- **M3U playlist URL building moved outside the channel loop.** `generate_m3u` previously rebuilt the query-string suffix (and for XC requests, called `build_absolute_uri_with_port`) on every channel iteration even though both inputs are request-level constants. The XC base URL and both query-string suffixes (`xc_qs_suffix`, `proxy_qs_suffix`) are now computed once before the channel loop; per-channel URL assembly is a single f-string interpolation.
|
||||
|
||||
## [0.25.0] - 2026-05-21
|
||||
|
||||
### Security
|
||||
|
||||
- Updated `Django` 6.0.4 → 6.0.5, resolving the following CVEs:
|
||||
|
|
@ -16,6 +128,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||
|
||||
### Added
|
||||
|
||||
- **About modal.** A `?` button in the sidebar footer opens an About dialog showing the current version, links to Documentation, Discord, GitHub, and Open Collective, a contributors acknowledgment, and a memorial note for Jesse Mann. The button is visible in both expanded and collapsed sidebar states.
|
||||
- **Comskip mode setting.** DVR Settings now includes a "Comskip mode" option:
|
||||
- **Cut** (default): FFmpeg permanently removes commercial segments from the recording file in place. The EDL file is deleted after a successful cut.
|
||||
- **Mark**: comskip analysis runs as normal but the recording file is left untouched. The EDL file is kept alongside the recording so players that support EDL-based commercial skipping (e.g. Kodi) can use it. The recording's `custom_properties` record the EDL filename, commercial count, and mode so the UI can surface this.
|
||||
|
|
@ -41,9 +154,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||
- **`POST /api/channels/series-rules/preview/`** returns up to 25 (configurable, max 100) upcoming programs that a candidate rule would match within the standard 7-day evaluation horizon, without persisting anything. Used by the new rule editor to give live feedback as the user types.
|
||||
- **`GET /api/epg/programs/search/?tvg_id=`** filter parameter for exact `epg.tvg_id` matches.
|
||||
- **Series rule editor modal** with form (title + match mode, description + match mode, episodes mode, pinned channel) and a debounced (500ms) preview pane backed by an `AbortController` so per-keystroke calls don't pile up. A "Customize rule..." link in the program record-choice modal opens the editor pre-filled with the program's `tvg_id` and title; the series rules modal gains an "Add rule" button and an "Edit" button per existing rule.
|
||||
- **Shift+click and Ctrl+click row selection in tables.** Clicking anywhere on a non-interactive area of a row now participates in selection:
|
||||
- **Shift+click**: extends selection from the last-clicked row to the current row (range select), identical to shift+clicking the checkbox.
|
||||
- **Ctrl+click** (Cmd+click on Mac): toggles the clicked row in or out of the current selection without disturbing other selected rows.
|
||||
- Plain clicks on action buttons, checkboxes, inputs, links, and menus are unaffected. The expand chevron uses `stopPropagation` so expanding a row does not also trigger selection.
|
||||
|
||||
### Changed
|
||||
|
||||
- **Custom SVG icons extracted to `icons.jsx`.** `DiscordIcon` and `GitHubIcon` were moved from `PluginDetailPanel.jsx` into a new shared `frontend/src/components/icons.jsx` module so they can be reused across components without cross-importing from an unrelated file.
|
||||
|
||||
- **Comskip `.ini` overhauled.** The shipped `docker/comskip.ini` was replaced with a fully documented configuration covering all tunable sections: Main Settings, Output, Commercial Break Timing, Black Frame Detection, Logo Detection, Silence Detection, and Live TV. Key defaults: `detect_method=127` (all seven detection methods, up from the comskip default of 107), `min_commercialbreak=25` (slightly stricter floor for US broadcast TV), `output_default=0` (suppresses the `.txt` stats file comskip writes by default), `edl_skip_field=3` (Kodi commercial-break action code). All values include inline source references and plain-language explanations.
|
||||
- **Comskip enable switch label updated.** The DVR settings switch was relabeled from "Enable Comskip (remove commercials after recording)" to "Enable Comskip (commercial detection after recording)" to remain accurate when mark mode is selected.
|
||||
- **Settings reorganization: Preferred Region and Auto-Import Mapped Files moved to System Settings.** These two settings were previously stored in the `stream_settings` database group and shown under Stream Settings in the UI. They are now stored in `system_settings` and displayed under System Settings, which better reflects that they are server-wide behavior settings rather than stream delivery settings. A data migration (0025) moves existing values from the old group to the new one for all existing installs.
|
||||
|
|
@ -98,6 +217,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||
- Dependency updates:
|
||||
- `Django` 6.0.4 → 6.0.5 (security patch; see Security section)
|
||||
|
||||
### Removed
|
||||
|
||||
- **`python-gnupg` dependency dropped.** GPG manifest signature verification now calls the `gpg` binary directly via `os.posix_spawn` (see Fixed below). The `python-gnupg` Python library was the only consumer and has been removed from `pyproject.toml`. The `gpg` binary itself is still required on the host (it was always required since `python-gnupg` is just a wrapper around it).
|
||||
|
||||
### Fixed
|
||||
|
||||
- **DVR settings form no longer flashes back to old values during save.** The comskip mode and hardware acceleration selects briefly showed stale values while the save was in flight because the Zustand settings store update (triggered by the API response) fired the `useEffect([settings])` re-hydration hook mid-save. An `isSavingRef` guard now suppresses the reactive re-hydration while a save is in progress; after a successful save the form is explicitly synced from the freshly-updated store state instead.
|
||||
|
|
@ -109,6 +232,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||
- `input/http_streamer.py`: set `O_NONBLOCK` on the HTTP-to-pipe relay write-end with an EAGAIN retry loop for the same reason.
|
||||
- `core/views.py` (`stream_view`): replaced `subprocess.Popen` with `os.posix_spawn`; also fixed a pre-existing indentation bug where the `return StreamingHttpResponse(...)` was accidentally nested inside `stream_generator` (making every successful response return `None` and raise a Django error). Also corrected two `NameError` references to the undefined `stream_id` variable in log messages.
|
||||
- `apps/connect/handlers/script.py` (`ScriptHandler`): replaced `subprocess.run` with a `_posix_run` helper that uses `os.posix_spawn` + cooperative `select.select` reads + non-blocking `waitpid` polling. Without this fix, any script-type connect integration configured for events fired from a uWSGI worker (e.g. `client_connect`) would deadlock the serving greenlet. Note: `cwd` is no longer set to the script's directory during execution (it inherits the worker's cwd); this was a minor convenience, not a documented guarantee.
|
||||
- **`POST /api/plugins/repos/plugin-detail/` hung for up to 105 seconds under gevent+uWSGI.** The plugin detail endpoint called GPG via `subprocess.Popen` to verify per-plugin manifest signatures, triggering the same `fork()` atfork deadlock described above. Replaced with a `_gpg_run()` helper that uses `os.posix_spawn`, matching the pattern used by the ffmpeg and script-handler fixes. `select.select()` drains stdout/stderr cooperatively (gevent-patched) and `os.waitpid(WNOHANG)` with `time.sleep(0.01)` reaps the child without blocking the hub. Results are cached in Redis for 5 minutes per manifest URL so repeat detail fetches skip GPG entirely. The cache is also invalidated per-plugin when the owning repo's hub manifest is refreshed, so a newly released version is visible immediately after a manual hub refresh.
|
||||
- **XC server sub-path URLs now work correctly.** When a provider serves its XC API from a sub-path (e.g. `http://server/Pluto/gb/player_api.php`), Dispatcharr was stripping the path entirely and hitting the root (`/player_api.php`) instead. `_normalize_url` now preserves sub-path components and only strips any trailing `.php` segment (covering `player_api.php`, `get.php`, `xmltv.php`, and any future endpoint without a maintained list). The same fix is applied to `get_transformed_credentials` in the M3U profile transformation path. (Fixes #1218)
|
||||
- **M3U filter delete confirmation showed wrong field name and had a typo.** The confirmation dialog for deleting an M3U filter read `filter.type` (always `undefined`) instead of `filter.filter_type`, leaving the "Type:" line blank, and displayed "Patter:" instead of "Pattern:". Both are corrected. — Thanks [@nick4810](https://github.com/nick4810)
|
||||
- **M3U form FileInput expanded the modal width on long filenames.** Uploading a local M3U file with a long name caused the `FileInput` to expand beyond the modal's layout bounds. The input now clips overflow with `textOverflow: ellipsis`. — Thanks [@nick4810](https://github.com/nick4810)
|
||||
|
|
@ -125,6 +249,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||
- **Duplicate `stop_channel` calls on every channel shutdown.** `StreamGenerator._cleanup` triggered channel shutdown via two parallel paths: `client_manager.remove_client()` (which fires `handle_client_disconnect` on the owning worker, the correct path) and `_schedule_channel_shutdown_if_needed` (which independently spawned a delayed `stop_channel` greenlet). The duplicate call was suppressed by the `_stopping_channels` guard but produced a redundant log entry and unnecessary greenlet on every shutdown. `_schedule_channel_shutdown_if_needed` has been removed; `handle_client_disconnect` is the sole shutdown trigger.
|
||||
- **Concurrent greenlets could re-enter `_close_socket` during `proc.wait()`.** `self.transcode_process` was set to `None` at the end of the `if proc:` block rather than immediately after capturing the reference. Under gevent, `proc.wait(timeout=0.5)` yields the hub, allowing a second greenlet to enter `_close_socket`, find `self.transcode_process` still set, and attempt a second kill+close. `self.transcode_process = None` is now assigned immediately after `proc = self.transcode_process` so concurrent callers see `None` and skip the block.
|
||||
- **Channel card "started at" tooltip jumping by 1 second on every stats poll.** The Stats page channel card tooltip showed the channel start time by computing `Date.now() - uptime * 1000` on every render. Because `uptime` is a server-side elapsed-seconds value recomputed each response, this reconstruction drifted by up to 1 second per tick. The basic channel info path now emits `started_at` (the raw Unix timestamp from Redis) alongside `uptime`, matching what the detailed stats path already sent. The frontend `getStartDate` helper now accepts the stable `started_at` timestamp directly, so the displayed wall-clock time is fixed from the first poll and never changes.
|
||||
- **Shift+click range selection in the channels table broken after row memoization.** After `MemoizedTableRow` was introduced, `handleShiftSelect` captured `lastClickedId` from its render-time closure. Because the memo comparator intentionally excludes callback function references, unselected rows retained the stale closure where `lastClickedId === null`, so every shift+click from a previously unchecked row fell through to a plain toggle instead of selecting the range. Added `lastClickedIdRef` and `allRowIdsRef` alongside the existing `selectedTableIdsRef`; `handleShiftSelect` now reads from those refs so every row uses the current anchor ID and full ID list regardless of which render produced the closure.
|
||||
- **Selected rows in the channels table did not show the teal highlight.** `MemoizedTableRow` applied `backgroundColor: '#163632'` based on `row.getIsSelected()` from TanStack Table's API. Because `state.rowSelection` was never wired into `useReactTable`, `row.getIsSelected()` always returned `false` and selected rows remained unstyled. Changed to use the `isSelected` prop, which is correctly derived from `selectedTableIdsSet` and already tracked by the memo comparator.
|
||||
- **`blur` event listener in `useTable` leaked on component unmount.** The `useEffect` cleanup function called `window.removeEventListener('blur', ...)` with a newly created anonymous function literal that never matched the handler registered at setup time, so the listener was never removed. Extracted to a named `handleBlur` constant so setup and cleanup reference the same function.
|
||||
|
||||
### Performance
|
||||
|
||||
|
|
@ -143,6 +270,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||
- **`JsonResponse` for ID list and channel summary endpoints.** The `stream_ids`, `channel_ids`, and channel `summary` endpoints now use `django.http.JsonResponse` instead of DRF's `Response`, bypassing the DRF renderer pipeline (content-type negotiation, serializer dispatch) for responses that are already plain Python structures. Removes overhead on every channel-table load and stream-table load.
|
||||
- **Logo queryset annotates `channel_count` to eliminate N+1 in `LogoSerializer`.** `LogoViewSet.get_queryset()` now annotates each row with `Count('channels')`. `LogoSerializer.get_channel_count()` and `get_is_used()` read the annotation directly instead of issuing a separate `COUNT(*)` per logo. The `used=true` and `used=false` list filters use the annotation for their conditions, removing the `DISTINCT` that was previously required.
|
||||
- **`ChannelProfileSerializer` reads prefetched memberships.** `ChannelProfileViewSet.get_queryset()` now prefetches enabled `ChannelProfileMembership` rows into `enabled_memberships`. `ChannelProfileSerializer.get_channels()` uses the prefetched set when available, eliminating one query per profile in any response that lists multiple profiles.
|
||||
- **Channel table selection re-render reduction.**
|
||||
- Removed `isShiftKeyDown` React state from `useTable`. Setting it on every `keydown`/`keyup` event triggered a re-render of any table consumer (`ChannelsTable`, `CustomTableBody`, ~50 memoized row comparisons) each time shift was pressed or released. The visual shift-key effect is handled entirely by `document.body.classList` manipulation, so the React state served no purpose.
|
||||
- Removed the `selectedChannelIds` reactive Zustand store subscription from `ChannelsTable`. Each checkbox click wrote to both local `selectedTableIds` state and the store via `onRowSelectionChange`, causing two sequential re-renders of `ChannelsTable` per click. The two consumers of this subscription (`deleteChannel` and `ChannelBatchForm`) now read from `table.selectedTableIds` directly.
|
||||
- Removed the dead `rowSelection` useMemo that built a TanStack row-selection map from `selectedTableIds`. The map was placed in `tableInstance` but `state.rowSelection` was never passed to `useReactTable`, so TanStack never consumed it. Eliminated a full page-row iteration on every selection change.
|
||||
|
||||
## [0.24.0] - 2026-05-03
|
||||
|
||||
|
|
|
|||
126
Plugin_repo.md
126
Plugin_repo.md
|
|
@ -81,6 +81,7 @@ This is the simplest valid repo manifest - one plugin with enough info to show i
|
|||
Dispatcharr accepts two top-level shapes:
|
||||
|
||||
**Wrapped (supports signing):**
|
||||
|
||||
```json
|
||||
{
|
||||
"manifest": { "plugins": [...], ... },
|
||||
|
|
@ -89,6 +90,7 @@ Dispatcharr accepts two top-level shapes:
|
|||
```
|
||||
|
||||
**Flat (no signing):**
|
||||
|
||||
```json
|
||||
{
|
||||
"plugins": [...],
|
||||
|
|
@ -118,36 +120,36 @@ If the name contains any of these, the repo will be rejected on add and skipped
|
|||
|
||||
### Top-Level Metadata
|
||||
|
||||
| Field | Required | Description |
|
||||
|-------|----------|-------------|
|
||||
| `registry_name` | **Yes** | Display name for the repo. Must not contain words like "official" or "dispatcharr" that could be mistaken for an official repo (see [Name Restrictions](#name-restrictions)). |
|
||||
| `registry_url` | No | URL to the repo's home page (e.g. GitHub). Used as a fallback for generating icon URLs. |
|
||||
| `root_url` | No | Base URL for resolving relative URLs in plugin entries. Trailing slashes are stripped. |
|
||||
| `plugins` | **Yes** | Array of plugin entry objects. |
|
||||
| Field | Required | Description |
|
||||
| --------------- | -------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `registry_name` | **Yes** | Display name for the repo. Must not contain words like "official" or "dispatcharr" that could be mistaken for an official repo (see [Name Restrictions](#name-restrictions)). |
|
||||
| `registry_url` | No | URL to the repo's home page (e.g. GitHub). Used as a fallback for generating icon URLs. |
|
||||
| `root_url` | No | Base URL for resolving relative URLs in plugin entries. Trailing slashes are stripped. |
|
||||
| `plugins` | **Yes** | Array of plugin entry objects. |
|
||||
|
||||
### Plugin Entry Fields
|
||||
|
||||
| Field | Required | Description |
|
||||
|-------|----------|-------------|
|
||||
| `slug` | **Yes** | Unique identifier. Alphanumeric, dashes, and underscores. Used as the install directory name (lowercased, dashes converted to underscores). |
|
||||
| `name` | **Yes** | Human-readable display name. |
|
||||
| `description` | No | Short description shown on the plugin card. |
|
||||
| `author` | No | Author or organization name. |
|
||||
| `maintainers` | No | Array of maintainer GitHub usernames (e.g. `["alice", "bob"]`). Shown in the detail view. |
|
||||
| `license` | No | SPDX license identifier (e.g. `MIT`, `GPL-3.0`). Displayed as a link to the SPDX license page. |
|
||||
| `deprecated` | No | Boolean. When `true`, marks the plugin as deprecated in the store UI. Omit or set to `false` for active plugins. |
|
||||
| `repo_url` | No | URL to the plugin's source code repository (e.g. GitHub). |
|
||||
| `discord_thread` | No | URL to a Discord thread or channel for plugin support. Must start with `http://` or `https://`. |
|
||||
| `latest_version` | No | Current latest version string (semver: `1.2.3` or `v1.2.3`). Drives update detection. |
|
||||
| `last_updated` | No | ISO 8601 timestamp of the latest release. Shown as "Built" date in the detail view. |
|
||||
| `manifest_url` | No | URL (or relative path) to the per-plugin manifest with full version history. See [Per-Plugin Manifest](#per-plugin-manifest). |
|
||||
| `latest_url` | No | Direct download URL (or relative path) to the latest release zip. |
|
||||
| `latest_sha256` | No | SHA256 checksum of the latest release zip (lowercase hex, 64 chars). |
|
||||
| `latest_md5` | No | MD5 checksum of the latest release zip. Informational only - not validated by Dispatcharr. |
|
||||
| `latest_size` | No | Size of the latest release zip in kilobytes. Informational only. |
|
||||
| `icon_url` | No | URL (or relative path) to a logo image (PNG recommended). |
|
||||
| `min_dispatcharr_version` | No | Minimum Dispatcharr version required. Install is blocked if the running version is older. |
|
||||
| `max_dispatcharr_version` | No | Maximum Dispatcharr version supported. Install is blocked if the running version is newer. |
|
||||
| Field | Required | Description |
|
||||
| ------------------------- | -------- | ------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `slug` | **Yes** | Unique identifier. Alphanumeric, dashes, and underscores. Used as the install directory name (lowercased, dashes converted to underscores). |
|
||||
| `name` | **Yes** | Human-readable display name. |
|
||||
| `description` | No | Short description shown on the plugin card. |
|
||||
| `author` | No | Author or organization name. |
|
||||
| `maintainers` | No | Array of maintainer GitHub usernames (e.g. `["alice", "bob"]`). Shown in the detail view. |
|
||||
| `license` | No | SPDX license identifier (e.g. `MIT`, `GPL-3.0`). Displayed as a link to the SPDX license page. |
|
||||
| `deprecated` | No | Boolean. When `true`, marks the plugin as deprecated in the store UI. Omit or set to `false` for active plugins. |
|
||||
| `repo_url` | No | URL to the plugin's source code repository (e.g. GitHub). |
|
||||
| `discord_thread` | No | URL to a Discord thread or channel for plugin support. Must start with `http://` or `https://`. |
|
||||
| `latest_version` | No | Current latest version string (semver: `1.2.3` or `v1.2.3`). Drives update detection. |
|
||||
| `last_updated` | No | ISO 8601 timestamp of the latest release. Shown as "Built" date in the detail view. |
|
||||
| `manifest_url` | No | URL (or relative path) to the per-plugin manifest with full version history. See [Per-Plugin Manifest](#per-plugin-manifest). |
|
||||
| `latest_url` | No | Direct download URL (or relative path) to the latest release zip. |
|
||||
| `latest_sha256` | No | SHA256 checksum of the latest release zip (lowercase hex, 64 chars). |
|
||||
| `latest_md5` | No | MD5 checksum of the latest release zip. Informational only - not validated by Dispatcharr. |
|
||||
| `latest_size` | No | Size of the latest release zip in kilobytes. Informational only. |
|
||||
| `icon_url` | No | URL (or relative path) to a logo image (PNG recommended). |
|
||||
| `min_dispatcharr_version` | No | Minimum Dispatcharr version required. Install is blocked if the running version is older. |
|
||||
| `max_dispatcharr_version` | No | Maximum Dispatcharr version supported. Install is blocked if the running version is newer. |
|
||||
|
||||
Extra fields in a plugin entry are passed through to the frontend as-is, so you can include custom metadata (e.g. `homepage`, `tags`) without breaking anything.
|
||||
|
||||
|
|
@ -160,6 +162,7 @@ If `root_url` is set and a URL field (`manifest_url`, `latest_url`, `icon_url`)
|
|||
```
|
||||
|
||||
This lets you keep plugin entries compact:
|
||||
|
||||
```json
|
||||
{
|
||||
"root_url": "https://raw.githubusercontent.com/myorg/my-plugins/releases",
|
||||
|
|
@ -175,6 +178,7 @@ This lets you keep plugin entries compact:
|
|||
```
|
||||
|
||||
**Icon fallback:** If `icon_url` is missing and `registry_url` is set, Dispatcharr generates a fallback URL by converting the GitHub URL to a raw content URL:
|
||||
|
||||
```
|
||||
{registry_url => raw.githubusercontent.com}/refs/heads/main/plugins/{slug}/logo.png
|
||||
```
|
||||
|
|
@ -186,6 +190,7 @@ This lets you keep plugin entries compact:
|
|||
The per-plugin manifest provides full version history. It is fetched on-demand when a user clicks "More Info" on a plugin card. It is **not required** - if `manifest_url` is absent, the UI builds a detail view from the repo-level fields instead.
|
||||
|
||||
Include a per-plugin manifest if you want to:
|
||||
|
||||
- Offer multiple downloadable versions
|
||||
- Show per-version compatibility ranges
|
||||
- Display build timestamps and commit links for each version
|
||||
|
|
@ -196,6 +201,7 @@ Include a per-plugin manifest if you want to:
|
|||
Same as the root manifest - both flat and wrapped formats are accepted:
|
||||
|
||||
**Flat (no signing):**
|
||||
|
||||
```json
|
||||
{
|
||||
"slug": "...",
|
||||
|
|
@ -204,6 +210,7 @@ Same as the root manifest - both flat and wrapped formats are accepted:
|
|||
```
|
||||
|
||||
**Wrapped (supports signing):**
|
||||
|
||||
```json
|
||||
{
|
||||
"manifest": {
|
||||
|
|
@ -272,33 +279,33 @@ Use the wrapped format if you want to GPG-sign the per-plugin manifest.
|
|||
|
||||
### Per-Plugin Manifest Fields
|
||||
|
||||
| Field | Required | Description |
|
||||
|-------|----------|-------------|
|
||||
| `slug` | No | Plugin identifier (should match the repo entry). |
|
||||
| `name` | No | Display name. |
|
||||
| `description` | No | Full description shown in the detail modal. |
|
||||
| `author` | No | Author/org name shown in the detail modal. |
|
||||
| `license` | No | SPDX license identifier. |
|
||||
| `latest_version` | No | Latest version string. |
|
||||
| `registry_name` | No | Registry name inherited from the parent repo manifest. Injected automatically by the official publish tooling. |
|
||||
| `registry_url` | No | Registry URL inherited from the parent repo manifest. Used by the store to build commit links. Injected automatically by the official publish tooling. |
|
||||
| `versions` | No | Array of version objects (newest first recommended). |
|
||||
| `latest` | No | Object mirroring the latest version entry for quick access. Accepts all the same fields as a version object. Additionally, `latest_url` may appear here pointing to a stable symlink (e.g. `plugin-latest.zip`) that always resolves to the newest release. |
|
||||
| Field | Required | Description |
|
||||
| ---------------- | -------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `slug` | No | Plugin identifier (should match the repo entry). |
|
||||
| `name` | No | Display name. |
|
||||
| `description` | No | Full description shown in the detail modal. |
|
||||
| `author` | No | Author/org name shown in the detail modal. |
|
||||
| `license` | No | SPDX license identifier. |
|
||||
| `latest_version` | No | Latest version string. |
|
||||
| `registry_name` | No | Registry name inherited from the parent repo manifest. Injected automatically by the official publish tooling. |
|
||||
| `registry_url` | No | Registry URL inherited from the parent repo manifest. Used by the store to build commit links. Injected automatically by the official publish tooling. |
|
||||
| `versions` | No | Array of version objects (newest first recommended). |
|
||||
| `latest` | No | Object mirroring the latest version entry for quick access. Accepts all the same fields as a version object. Additionally, `latest_url` may appear here pointing to a stable symlink (e.g. `plugin-latest.zip`) that always resolves to the newest release. |
|
||||
|
||||
### Version Object Fields
|
||||
|
||||
| Field | Required | Description |
|
||||
|-------|----------|-------------|
|
||||
| `version` | **Yes** | Version string (`1.2.3` or `v1.2.3`). |
|
||||
| `url` | **Yes** | Download URL for the zip. Relative URLs are resolved against the repo's `root_url`. |
|
||||
| `checksum_sha256` | No | SHA256 hex checksum. **Strongly recommended.** Validated on install - mismatch blocks the install. |
|
||||
| `prerelease` | No | Boolean. When `true`, marks this version as a pre-release (alpha, beta, RC, etc.). If the installed version is a prerelease, Dispatcharr will not suggest updating to the latest stable version - the user must install a new version manually. The latest version in the root manifest is always assumed to be stable, so this field only needs to appear in the per-plugin manifest. Omit or set to `false` for stable releases. |
|
||||
| `build_timestamp` | No | ISO 8601 build timestamp. Shown as "Built" in the version detail. |
|
||||
| `commit_sha` | No | Full Git commit SHA. Used to build a commit link if `registry_url` is set. |
|
||||
| `commit_sha_short` | No | Abbreviated commit SHA. Displayed in the version detail table as a clickable link. |
|
||||
| `size` | No | Size of this version's zip in kilobytes. Informational only. |
|
||||
| `min_dispatcharr_version` | No | Minimum compatible Dispatcharr version. |
|
||||
| `max_dispatcharr_version` | No | Maximum compatible Dispatcharr version. |
|
||||
| Field | Required | Description |
|
||||
| ------------------------- | -------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `version` | **Yes** | Version string (`1.2.3` or `v1.2.3`). |
|
||||
| `url` | **Yes** | Download URL for the zip. Relative URLs are resolved against the repo's `root_url`. |
|
||||
| `checksum_sha256` | No | SHA256 hex checksum. **Strongly recommended.** Validated on install - mismatch blocks the install. |
|
||||
| `prerelease` | No | Boolean. When `true`, marks this version as a pre-release (alpha, beta, RC, etc.). If the installed version is a prerelease, Dispatcharr will not suggest updating to the latest stable version - the user must install a new version manually. The latest version in the root manifest is always assumed to be stable, so this field only needs to appear in the per-plugin manifest. Omit or set to `false` for stable releases. |
|
||||
| `build_timestamp` | No | ISO 8601 build timestamp. Shown as "Built" in the version detail. |
|
||||
| `commit_sha` | No | Full Git commit SHA. Used to build a commit link if `registry_url` is set. |
|
||||
| `commit_sha_short` | No | Abbreviated commit SHA. Displayed in the version detail table as a clickable link. |
|
||||
| `size` | No | Size of this version's zip in kilobytes. Informational only. |
|
||||
| `min_dispatcharr_version` | No | Minimum compatible Dispatcharr version. |
|
||||
| `max_dispatcharr_version` | No | Maximum compatible Dispatcharr version. |
|
||||
|
||||
Relative `url` values in versions are resolved the same way as repo-level URLs: `{root_url}/{url}`.
|
||||
|
||||
|
|
@ -348,6 +355,7 @@ jq -c '.manifest' manifest.json | gpg --armor --detach-sign
|
|||
```
|
||||
|
||||
In code terms:
|
||||
|
||||
```python
|
||||
import json
|
||||
canonical = json.dumps(manifest_obj, separators=(",", ":")) + "\n"
|
||||
|
|
@ -371,11 +379,11 @@ Use the wrapped format so the signature sits alongside the manifest:
|
|||
|
||||
### Verification Results
|
||||
|
||||
| Result | Meaning | UI Badge |
|
||||
|--------|---------|----------|
|
||||
| `true` | Valid signature | Green checkmark |
|
||||
| `false` | Invalid signature or verification error | Red X |
|
||||
| `null` | Not attempted (no signature, no key, or `python-gnupg` not installed) | Gray/neutral |
|
||||
| Result | Meaning | UI Badge |
|
||||
| ------- | ------------------------------------------------------------------- | --------------- |
|
||||
| `true` | Valid signature | Green checkmark |
|
||||
| `false` | Invalid signature or verification error | Red X |
|
||||
| `null` | Not attempted (no signature, no key, or `gpg` binary not installed) | Gray/neutral |
|
||||
|
||||
### Signing Workflow Example
|
||||
|
||||
|
|
@ -446,6 +454,7 @@ my_plugin-1.0.0.zip
|
|||
```
|
||||
|
||||
Or with a subdirectory:
|
||||
|
||||
```
|
||||
my_plugin-1.0.0.zip
|
||||
my_plugin/
|
||||
|
|
@ -477,6 +486,7 @@ The plugin is installed **disabled** by default. The user can enable it from the
|
|||
Dispatcharr detects updates by comparing `installed_version` (stored in the database) against `latest_version` from the repo manifest. This uses repo-level fields only - per-plugin manifests are not needed for update detection.
|
||||
|
||||
A plugin shows "Update Available" when:
|
||||
|
||||
- It is managed (installed from a repo)
|
||||
- Its `installed_version` differs from `latest_version`
|
||||
- It was installed from the same repo
|
||||
|
|
@ -488,7 +498,9 @@ A plugin shows "Update Available" when:
|
|||
A plugin repo manifest is just a JSON file served over HTTPS. Some options:
|
||||
|
||||
### GitHub Pages / Raw Content
|
||||
|
||||
Host your manifest and release zips in a GitHub repo. Use raw.githubusercontent.com URLs:
|
||||
|
||||
```
|
||||
https://raw.githubusercontent.com/myorg/my-plugins/main/manifest.json
|
||||
```
|
||||
|
|
@ -496,9 +508,11 @@ https://raw.githubusercontent.com/myorg/my-plugins/main/manifest.json
|
|||
Use `root_url` pointing to your releases branch/path so version URLs stay relative.
|
||||
|
||||
### Static File Server
|
||||
|
||||
Any web server that serves JSON works. Dispatcharr fetches manifests server-side, so CORS is not needed.
|
||||
|
||||
### GitHub Releases
|
||||
|
||||
You can host release zips as GitHub Release assets and reference them with absolute URLs in your manifest. The manifest itself can live in the repo's default branch.
|
||||
|
||||
---
|
||||
|
|
|
|||
|
|
@ -1,5 +1,7 @@
|
|||
from rest_framework import authentication
|
||||
from rest_framework import exceptions
|
||||
from rest_framework_simplejwt.authentication import JWTAuthentication
|
||||
from rest_framework_simplejwt.exceptions import InvalidToken, TokenError
|
||||
from django.conf import settings
|
||||
from drf_spectacular.extensions import OpenApiAuthenticationExtension
|
||||
from .models import User
|
||||
|
|
@ -84,3 +86,18 @@ class ApiKeyAuthentication(authentication.BaseAuthentication):
|
|||
|
||||
def authenticate_header(self, request):
|
||||
return self.keyword
|
||||
|
||||
|
||||
class QueryParamJWTAuthentication(JWTAuthentication):
|
||||
"""Reads a JWT from the `token` query parameter. Used for media endpoints
|
||||
where the browser cannot set Authorization headers (e.g. <video src>)."""
|
||||
|
||||
def authenticate(self, request):
|
||||
raw_token = request.GET.get('token')
|
||||
if not raw_token:
|
||||
return None
|
||||
try:
|
||||
validated_token = self.get_validated_token(raw_token)
|
||||
return self.get_user(validated_token), validated_token
|
||||
except (InvalidToken, TokenError):
|
||||
return None
|
||||
|
|
|
|||
|
|
@ -23,8 +23,7 @@ def get_backup_dir() -> Path:
|
|||
|
||||
|
||||
def _is_postgresql() -> bool:
|
||||
"""Check if we're using PostgreSQL."""
|
||||
return settings.DATABASES["default"]["ENGINE"] == "django.db.backends.postgresql"
|
||||
return "postgresql" in settings.DATABASES["default"]["ENGINE"]
|
||||
|
||||
|
||||
def _get_pg_env() -> dict:
|
||||
|
|
@ -171,30 +170,25 @@ def _restore_postgresql(dump_file: Path) -> None:
|
|||
|
||||
|
||||
def _dump_sqlite(output_file: Path) -> None:
|
||||
"""Dump SQLite database using sqlite3 .backup command."""
|
||||
logger.info("Dumping SQLite database with sqlite3 .backup...")
|
||||
import sqlite3 as _sqlite3
|
||||
logger.info("Dumping SQLite database...")
|
||||
db_path = Path(settings.DATABASES["default"]["NAME"])
|
||||
|
||||
if not db_path.exists():
|
||||
raise FileNotFoundError(f"SQLite database not found: {db_path}")
|
||||
|
||||
# Use sqlite3 .backup command via stdin for reliable execution
|
||||
result = subprocess.run(
|
||||
["sqlite3", str(db_path)],
|
||||
input=f".backup '{output_file}'\n",
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
src = _sqlite3.connect(str(db_path))
|
||||
dst = _sqlite3.connect(str(output_file))
|
||||
try:
|
||||
src.backup(dst)
|
||||
finally:
|
||||
dst.close()
|
||||
src.close()
|
||||
|
||||
if result.returncode != 0:
|
||||
logger.error(f"sqlite3 backup failed: {result.stderr}")
|
||||
raise RuntimeError(f"sqlite3 backup failed: {result.stderr}")
|
||||
|
||||
# Verify the backup file was created
|
||||
if not output_file.exists():
|
||||
raise RuntimeError("sqlite3 backup failed: output file not created")
|
||||
raise RuntimeError("SQLite backup failed: output file not created")
|
||||
|
||||
logger.info(f"sqlite3 backup completed successfully: {output_file}")
|
||||
logger.info(f"SQLite backup completed successfully: {output_file}")
|
||||
|
||||
|
||||
def _restore_sqlite(dump_file: Path) -> None:
|
||||
|
|
@ -216,23 +210,20 @@ def _restore_sqlite(dump_file: Path) -> None:
|
|||
# We can simply copy it over the existing database
|
||||
shutil.copy2(dump_file, db_path)
|
||||
|
||||
# Verify the restore worked by checking if sqlite3 can read it
|
||||
result = subprocess.run(
|
||||
["sqlite3", str(db_path)],
|
||||
input=".tables\n",
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
|
||||
if result.returncode != 0:
|
||||
logger.error(f"sqlite3 verification failed: {result.stderr}")
|
||||
# Try to restore from backup
|
||||
# Verify the restore worked by checking if the file is a readable SQLite database
|
||||
import sqlite3 as _sqlite3
|
||||
try:
|
||||
conn = _sqlite3.connect(str(db_path))
|
||||
conn.execute("SELECT name FROM sqlite_master WHERE type='table'").fetchall()
|
||||
conn.close()
|
||||
except _sqlite3.DatabaseError as exc:
|
||||
logger.error(f"SQLite verification failed: {exc}")
|
||||
if backup_current and backup_current.exists():
|
||||
shutil.copy2(backup_current, db_path)
|
||||
logger.info("Restored original database from backup")
|
||||
raise RuntimeError(f"sqlite3 restore verification failed: {result.stderr}")
|
||||
raise RuntimeError(f"SQLite restore verification failed: {exc}") from exc
|
||||
|
||||
logger.info("sqlite3 restore completed successfully")
|
||||
logger.info("SQLite restore completed successfully")
|
||||
|
||||
|
||||
def create_backup() -> Path:
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import logging
|
||||
import traceback
|
||||
from celery import shared_task
|
||||
from django.core.management import call_command
|
||||
|
||||
from . import services
|
||||
|
||||
|
|
@ -61,6 +62,8 @@ def restore_backup_task(self, filename: str):
|
|||
backup_file = backup_dir / filename
|
||||
logger.info(f"[RESTORE] Backup file path: {backup_file}")
|
||||
services.restore_backup(backup_file)
|
||||
logger.info(f"[RESTORE] Running migrations after restore...")
|
||||
call_command('migrate', '--noinput', verbosity=1)
|
||||
logger.info(f"[RESTORE] Task {self.request.id} completed successfully")
|
||||
return {
|
||||
"status": "completed",
|
||||
|
|
|
|||
|
|
@ -1525,32 +1525,52 @@ class ChannelViewSet(viewsets.ModelViewSet):
|
|||
@action(detail=False, methods=["post"], url_path="set-logos-from-epg")
|
||||
def set_logos_from_epg(self, request):
|
||||
"""
|
||||
Trigger a Celery task to set channel logos from EPG data
|
||||
Trigger a Celery task to set channel logos from EPG data.
|
||||
Provide channel_ids or epg_source_id (not both).
|
||||
"""
|
||||
from .tasks import set_channels_logos_from_epg
|
||||
|
||||
data = request.data
|
||||
channel_ids = data.get("channel_ids", [])
|
||||
channel_ids = data.get("channel_ids")
|
||||
epg_source_id = data.get("epg_source_id")
|
||||
|
||||
if not channel_ids:
|
||||
if channel_ids and epg_source_id:
|
||||
return Response(
|
||||
{"error": "channel_ids is required"},
|
||||
{"error": "Provide either channel_ids or epg_source_id, not both"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
if not isinstance(channel_ids, list):
|
||||
if not channel_ids and not epg_source_id:
|
||||
return Response(
|
||||
{"error": "channel_ids must be a list"},
|
||||
{"error": "channel_ids or epg_source_id is required"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
# Start the Celery task
|
||||
task = set_channels_logos_from_epg.delay(channel_ids)
|
||||
if channel_ids is not None:
|
||||
if not isinstance(channel_ids, list):
|
||||
return Response(
|
||||
{"error": "channel_ids must be a list"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
if not channel_ids:
|
||||
return Response(
|
||||
{"error": "channel_ids cannot be empty"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
task = set_channels_logos_from_epg.delay(channel_ids=channel_ids)
|
||||
channel_count = len(channel_ids)
|
||||
else:
|
||||
from .utils import channels_with_epg_icon_queryset
|
||||
|
||||
task = set_channels_logos_from_epg.delay(epg_source_id=epg_source_id)
|
||||
channel_count = channels_with_epg_icon_queryset(
|
||||
epg_source_id=epg_source_id,
|
||||
).count()
|
||||
|
||||
return Response({
|
||||
"message": f"Started EPG logo setting task for {len(channel_ids)} channels",
|
||||
"message": f"Started EPG logo setting task for {channel_count} channels",
|
||||
"task_id": task.id,
|
||||
"channel_count": len(channel_ids)
|
||||
"channel_count": channel_count,
|
||||
})
|
||||
|
||||
@action(detail=False, methods=["post"], url_path="set-tvg-ids-from-epg")
|
||||
|
|
@ -2067,7 +2087,7 @@ class ChannelViewSet(viewsets.ModelViewSet):
|
|||
fields={
|
||||
'channel_ids': serializers.ListField(
|
||||
child=serializers.IntegerField(),
|
||||
help_text='List of channel IDs to process. If empty or not provided, all channels without EPG will be processed.',
|
||||
help_text='List of channel IDs to process (includes channels that already have EPG). If empty or not provided, only channels without EPG are processed.',
|
||||
required=False,
|
||||
)
|
||||
}
|
||||
|
|
@ -2100,23 +2120,15 @@ class ChannelViewSet(viewsets.ModelViewSet):
|
|||
def match_channel_epg(self, request, pk=None):
|
||||
channel = self.get_object()
|
||||
|
||||
# Import the matching logic
|
||||
from apps.channels.tasks import match_single_channel_epg
|
||||
|
||||
try:
|
||||
# Try to match this specific channel - call synchronously for immediate response
|
||||
result = match_single_channel_epg.apply_async(args=[channel.id]).get(timeout=30)
|
||||
|
||||
# Refresh the channel from DB to get any updates
|
||||
channel.refresh_from_db()
|
||||
|
||||
return Response({
|
||||
"message": result.get("message", "Channel matching completed"),
|
||||
"matched": result.get("matched", False),
|
||||
"channel": self.get_serializer(channel).data
|
||||
})
|
||||
except Exception as e:
|
||||
return Response({"error": str(e)}, status=400)
|
||||
match_single_channel_epg.delay(channel.id)
|
||||
return Response(
|
||||
{
|
||||
"message": f"EPG matching started for channel '{channel.name}'",
|
||||
"accepted": True,
|
||||
"channel_id": channel.id,
|
||||
},
|
||||
status=status.HTTP_202_ACCEPTED,
|
||||
)
|
||||
|
||||
# ─────────────────────────────────────────────────────────
|
||||
# 7) Set EPG and Refresh
|
||||
|
|
@ -2301,7 +2313,6 @@ class ChannelViewSet(viewsets.ModelViewSet):
|
|||
|
||||
# Extract channel IDs upfront
|
||||
channel_updates = {}
|
||||
unique_epg_ids = set()
|
||||
|
||||
for assoc in associations:
|
||||
channel_id = assoc.get("channel_id")
|
||||
|
|
@ -2311,24 +2322,28 @@ class ChannelViewSet(viewsets.ModelViewSet):
|
|||
continue
|
||||
|
||||
channel_updates[channel_id] = epg_data_id
|
||||
if epg_data_id:
|
||||
unique_epg_ids.add(epg_data_id)
|
||||
|
||||
# Batch fetch all channels (single query)
|
||||
channels_dict = {
|
||||
c.id: c for c in Channel.objects.filter(id__in=channel_updates.keys())
|
||||
}
|
||||
|
||||
# Collect channels to update
|
||||
# Collect channels whose EPG assignment actually changes
|
||||
channels_to_update = []
|
||||
changed_epg_ids = set()
|
||||
for channel_id, epg_data_id in channel_updates.items():
|
||||
if channel_id not in channels_dict:
|
||||
logger.error(f"Channel with ID {channel_id} not found")
|
||||
continue
|
||||
|
||||
channel = channels_dict[channel_id]
|
||||
if channel.epg_data_id == epg_data_id:
|
||||
continue
|
||||
|
||||
channel.epg_data_id = epg_data_id
|
||||
channels_to_update.append(channel)
|
||||
if epg_data_id:
|
||||
changed_epg_ids.add(epg_data_id)
|
||||
|
||||
# Bulk update all channels (single query)
|
||||
if channels_to_update:
|
||||
|
|
@ -2341,25 +2356,25 @@ class ChannelViewSet(viewsets.ModelViewSet):
|
|||
|
||||
channels_updated = len(channels_to_update)
|
||||
|
||||
# Trigger program refresh for unique EPG data IDs (skip dummy EPGs)
|
||||
# Trigger program refresh only for EPG ids newly assigned (skip dummy/SD)
|
||||
from apps.epg.tasks import parse_programs_for_tvg_id
|
||||
from apps.epg.models import EPGData
|
||||
|
||||
# Batch fetch EPG data (single query)
|
||||
epg_data_dict = {
|
||||
epg.id: epg
|
||||
for epg in EPGData.objects.filter(id__in=unique_epg_ids).select_related('epg_source')
|
||||
for epg in EPGData.objects.filter(id__in=changed_epg_ids).select_related('epg_source')
|
||||
}
|
||||
|
||||
programs_refreshed = 0
|
||||
for epg_id in unique_epg_ids:
|
||||
for epg_id in changed_epg_ids:
|
||||
epg_data = epg_data_dict.get(epg_id)
|
||||
if not epg_data:
|
||||
logger.error(f"EPGData with ID {epg_id} not found")
|
||||
continue
|
||||
|
||||
# Only refresh non-dummy EPG sources
|
||||
if epg_data.epg_source.source_type != 'dummy':
|
||||
source_type = epg_data.epg_source.source_type if epg_data.epg_source else None
|
||||
if source_type not in ('dummy', 'schedules_direct'):
|
||||
parse_programs_for_tvg_id.delay(epg_id)
|
||||
programs_refreshed += 1
|
||||
|
||||
|
|
|
|||
|
|
@ -44,20 +44,38 @@ def is_compact_group(group_relation):
|
|||
def get_group_relation_for_channel(channel):
|
||||
"""Resolve the ChannelGroupM3UAccount that owns this auto-created
|
||||
channel. Returns None for manual channels, or when the relation has
|
||||
been deleted."""
|
||||
if (
|
||||
not channel.auto_created
|
||||
or not channel.auto_created_by_id
|
||||
or not channel.channel_group_id
|
||||
):
|
||||
been deleted.
|
||||
|
||||
With a Channel Group Override active, sync stores the channel under
|
||||
the override target group's id, not the source group's id recorded on
|
||||
the relation. The direct lookup then misses, so fall back to scanning
|
||||
the account's relations for one whose group_override points at the
|
||||
channel's current group. The fallback runs only on a direct miss, so
|
||||
the common no-override path keeps its single SELECT.
|
||||
"""
|
||||
if not channel.auto_created or not channel.auto_created_by_id:
|
||||
return None
|
||||
if not channel.channel_group_id:
|
||||
return None
|
||||
|
||||
try:
|
||||
return ChannelGroupM3UAccount.objects.get(
|
||||
m3u_account_id=channel.auto_created_by_id,
|
||||
channel_group_id=channel.channel_group_id,
|
||||
)
|
||||
except ChannelGroupM3UAccount.DoesNotExist:
|
||||
return None
|
||||
pass
|
||||
|
||||
# group_override may be stored as int or str; compare as strings so
|
||||
# the match is type-agnostic.
|
||||
target = str(channel.channel_group_id)
|
||||
for rel in ChannelGroupM3UAccount.objects.filter(
|
||||
m3u_account_id=channel.auto_created_by_id
|
||||
):
|
||||
cp = _custom_properties_as_dict(rel.custom_properties)
|
||||
if str(cp.get("group_override", "")) == target:
|
||||
return rel
|
||||
return None
|
||||
|
||||
|
||||
def build_reserved_set(exclude_channel_ids=None, range_start=None, range_end=None):
|
||||
|
|
@ -151,6 +169,29 @@ def assign_compact_numbers_for_channels(channel_ids):
|
|||
for rel in relations_qs:
|
||||
relations_by_pair[(rel.m3u_account_id, rel.channel_group_id)] = rel
|
||||
|
||||
# Override fallback: pairs the direct lookup missed carry an override-
|
||||
# target channel_group_id. Resolve them with one extra query over the
|
||||
# unresolved accounts (not one per pair), so the common path keeps its
|
||||
# single narrow query.
|
||||
unresolved = [k for k in pair_keys if k not in relations_by_pair]
|
||||
if unresolved:
|
||||
override_relations = {}
|
||||
for rel in ChannelGroupM3UAccount.objects.filter(
|
||||
m3u_account_id__in={k[0] for k in unresolved}
|
||||
):
|
||||
cp = _custom_properties_as_dict(rel.custom_properties)
|
||||
target = cp.get("group_override")
|
||||
if not target:
|
||||
continue
|
||||
try:
|
||||
override_relations[(rel.m3u_account_id, int(target))] = rel
|
||||
except (TypeError, ValueError):
|
||||
continue
|
||||
for key in unresolved:
|
||||
rel = override_relations.get(key)
|
||||
if rel is not None:
|
||||
relations_by_pair[key] = rel
|
||||
|
||||
by_relation = {}
|
||||
for key, group_channels in by_pair.items():
|
||||
rel = relations_by_pair.get(key)
|
||||
|
|
@ -265,12 +306,35 @@ def _repack_inner(group_relation):
|
|||
else None
|
||||
)
|
||||
|
||||
# Match the override target group too: channels created under an
|
||||
# override live under the target's id, not the source group's.
|
||||
group_ids = {group_id}
|
||||
override_group_id = cp.get("group_override")
|
||||
if override_group_id:
|
||||
try:
|
||||
group_ids.add(int(override_group_id))
|
||||
except (TypeError, ValueError):
|
||||
logger.warning(
|
||||
"Ignoring non-numeric group_override %r on relation %s",
|
||||
override_group_id,
|
||||
group_relation.id,
|
||||
)
|
||||
|
||||
# Known limitation: if two source groups on the same account override
|
||||
# into the SAME target group, their channels are indistinguishable
|
||||
# here (channels carry no source-group back-reference), so each repack
|
||||
# renumbers the shared target's channels into its own range.
|
||||
# order_by("id") makes the pack deterministic. Without it the query
|
||||
# returns rows in unspecified physical order, which shifts after the
|
||||
# renumber's own UPDATEs and autovacuum, so the default "provider" sort
|
||||
# below would repack channels into different numbers on every sync.
|
||||
# id order is creation order, which tracks the provider stream order.
|
||||
channels = list(
|
||||
Channel.objects.filter(
|
||||
auto_created=True,
|
||||
auto_created_by_id=account_id,
|
||||
channel_group_id=group_id,
|
||||
).select_related("override")
|
||||
channel_group_id__in=group_ids,
|
||||
).select_related("override").order_by("id")
|
||||
)
|
||||
|
||||
visible = []
|
||||
|
|
@ -285,21 +349,22 @@ def _repack_inner(group_relation):
|
|||
visible.append(ch)
|
||||
|
||||
# Sort the visible set by the group's configured channel_sort_order.
|
||||
# Provider order (the default) preserves DB-iteration order which is
|
||||
# roughly creation order; treat unrecognized values the same way.
|
||||
# Provider order (the default) keeps the id order from the query above.
|
||||
# Each explicit sort carries c.id as a secondary key so equal values
|
||||
# (e.g. blank tvg_id) break ties deterministically instead of churning.
|
||||
if sort_order == "name":
|
||||
visible.sort(
|
||||
key=lambda c: natural_sort_key(c.name or ""),
|
||||
key=lambda c: (natural_sort_key(c.name or ""), c.id),
|
||||
reverse=sort_reverse,
|
||||
)
|
||||
elif sort_order == "tvg_id":
|
||||
visible.sort(
|
||||
key=lambda c: c.tvg_id or "",
|
||||
key=lambda c: (c.tvg_id or "", c.id),
|
||||
reverse=sort_reverse,
|
||||
)
|
||||
elif sort_order == "updated_at":
|
||||
visible.sort(
|
||||
key=lambda c: c.updated_at,
|
||||
key=lambda c: (c.updated_at, c.id),
|
||||
reverse=sort_reverse,
|
||||
)
|
||||
|
||||
|
|
|
|||
937
apps/channels/epg_matching.py
Normal file
937
apps/channels/epg_matching.py
Normal file
|
|
@ -0,0 +1,937 @@
|
|||
"""
|
||||
EPG channel matching: fuzzy scoring, optional ML validation, and UI notifications.
|
||||
|
||||
Celery tasks in tasks.py call into this module; keep orchestration here and
|
||||
task wiring thin so matching logic stays testable without a worker.
|
||||
"""
|
||||
import gc
|
||||
import heapq
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
|
||||
from rapidfuzz import fuzz
|
||||
|
||||
from apps.epg.models import EPGData
|
||||
from core.models import CoreSettings
|
||||
from core.utils import send_websocket_update
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_ml_model_cache = {'sentence_transformer': None}
|
||||
_normalize_settings_cache = None
|
||||
|
||||
ML_CANDIDATE_LIMIT = 20
|
||||
SINGLE_CHANNEL_MATCH_TIMEOUT_MS = 180_000
|
||||
|
||||
COMMON_EXTRANEOUS_WORDS = [
|
||||
"tv", "channel", "network", "television",
|
||||
"east", "west", "hd", "uhd", "24/7",
|
||||
"1080p", "720p", "540p", "480p",
|
||||
"film", "movie", "movies",
|
||||
]
|
||||
|
||||
|
||||
def release_ml_models():
|
||||
"""Unload sentence transformer and encourage PyTorch to release memory."""
|
||||
if _ml_model_cache['sentence_transformer'] is None:
|
||||
return
|
||||
logger.info("Cleaning up ML models from memory")
|
||||
model = _ml_model_cache['sentence_transformer']
|
||||
_ml_model_cache['sentence_transformer'] = None
|
||||
del model
|
||||
try:
|
||||
import torch
|
||||
if hasattr(torch, 'cuda') and torch.cuda.is_available():
|
||||
torch.cuda.empty_cache()
|
||||
except ImportError:
|
||||
pass
|
||||
gc.collect()
|
||||
|
||||
|
||||
def clear_normalize_settings_cache():
|
||||
"""Reset cached normalization settings after a matching run."""
|
||||
global _normalize_settings_cache
|
||||
_normalize_settings_cache = None
|
||||
|
||||
|
||||
def cleanup_after_matching():
|
||||
"""Release ML models and normalization cache after a matching run."""
|
||||
release_ml_models()
|
||||
clear_normalize_settings_cache()
|
||||
|
||||
|
||||
def get_sentence_transformer():
|
||||
"""Lazy load the sentence transformer model only when needed."""
|
||||
if _ml_model_cache['sentence_transformer'] is None:
|
||||
try:
|
||||
from sentence_transformers import SentenceTransformer
|
||||
from sentence_transformers import util
|
||||
|
||||
model_name = "sentence-transformers/all-MiniLM-L6-v2"
|
||||
cache_dir = "/data/models"
|
||||
disable_downloads = os.environ.get('DISABLE_ML_DOWNLOADS', 'false').lower() == 'true'
|
||||
|
||||
if disable_downloads:
|
||||
hf_model_path = os.path.join(cache_dir, f"models--{model_name.replace('/', '--')}")
|
||||
if not os.path.exists(hf_model_path):
|
||||
logger.warning(
|
||||
"ML model not found and downloads disabled (DISABLE_ML_DOWNLOADS=true). "
|
||||
"Skipping ML matching."
|
||||
)
|
||||
return None, None
|
||||
|
||||
os.makedirs(cache_dir, exist_ok=True)
|
||||
logger.info(f"Loading sentence transformer model (cache: {cache_dir})")
|
||||
_ml_model_cache['sentence_transformer'] = SentenceTransformer(
|
||||
model_name,
|
||||
cache_folder=cache_dir,
|
||||
)
|
||||
return _ml_model_cache['sentence_transformer'], util
|
||||
except ImportError:
|
||||
logger.warning("sentence-transformers not available - ML-enhanced matching disabled")
|
||||
return None, None
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to load sentence transformer: {e}")
|
||||
return None, None
|
||||
|
||||
from sentence_transformers import util
|
||||
return _ml_model_cache['sentence_transformer'], util
|
||||
|
||||
|
||||
def normalize_name(name: str) -> str:
|
||||
"""Normalize a channel/EPG name for fuzzy matching."""
|
||||
if not name:
|
||||
return ""
|
||||
|
||||
global _normalize_settings_cache
|
||||
if _normalize_settings_cache is None:
|
||||
prefixes = []
|
||||
suffixes = []
|
||||
custom_strings = []
|
||||
try:
|
||||
settings = CoreSettings.get_epg_settings()
|
||||
mode = settings.get("epg_match_mode", "default")
|
||||
if mode == "advanced":
|
||||
prefixes = settings.get("epg_match_ignore_prefixes", [])
|
||||
suffixes = settings.get("epg_match_ignore_suffixes", [])
|
||||
custom_strings = settings.get("epg_match_ignore_custom", [])
|
||||
if not isinstance(prefixes, list):
|
||||
prefixes = []
|
||||
if not isinstance(suffixes, list):
|
||||
suffixes = []
|
||||
if not isinstance(custom_strings, list):
|
||||
custom_strings = []
|
||||
except Exception as e:
|
||||
logger.debug(f"Could not load EPG matching settings: {e}")
|
||||
_normalize_settings_cache = (prefixes, suffixes, custom_strings)
|
||||
|
||||
prefixes, suffixes, custom_strings = _normalize_settings_cache
|
||||
result = name
|
||||
|
||||
for prefix in prefixes:
|
||||
if not prefix or not isinstance(prefix, str):
|
||||
continue
|
||||
if result.startswith(prefix):
|
||||
result = result[len(prefix):]
|
||||
break
|
||||
|
||||
for suffix in suffixes:
|
||||
if not suffix or not isinstance(suffix, str):
|
||||
continue
|
||||
if result.endswith(suffix):
|
||||
result = result[:-len(suffix)]
|
||||
break
|
||||
|
||||
for custom in custom_strings:
|
||||
if not custom or not isinstance(custom, str):
|
||||
continue
|
||||
try:
|
||||
result = result.replace(custom, "")
|
||||
except Exception as e:
|
||||
logger.debug(f"Failed to remove custom string '{custom}': {e}")
|
||||
|
||||
norm = result.lower()
|
||||
norm = re.sub(r"\[.*?\]", "", norm)
|
||||
|
||||
call_sign_match = re.search(r"\(([A-Z]{3,5})\)", name)
|
||||
preserved_call_sign = ""
|
||||
if call_sign_match:
|
||||
preserved_call_sign = " " + call_sign_match.group(1).lower()
|
||||
|
||||
norm = re.sub(r"\(.*?\)", "", norm)
|
||||
norm = norm + preserved_call_sign
|
||||
norm = re.sub(r"[^\w\s]", "", norm)
|
||||
tokens = [t for t in norm.split() if t not in COMMON_EXTRANEOUS_WORDS]
|
||||
return " ".join(tokens).strip()
|
||||
|
||||
|
||||
def send_epg_matching_progress(total_channels, matched_channels, current_channel_name="", stage="matching"):
|
||||
"""Send bulk EPG matching progress via WebSocket."""
|
||||
matched_count = (
|
||||
len(matched_channels) if isinstance(matched_channels, list) else matched_channels
|
||||
)
|
||||
send_websocket_update(
|
||||
'updates',
|
||||
'update',
|
||||
{
|
||||
'type': 'epg_matching_progress',
|
||||
'total': total_channels,
|
||||
'matched': matched_count,
|
||||
'remaining': total_channels - matched_count,
|
||||
'current_channel': current_channel_name,
|
||||
'stage': stage,
|
||||
'progress_percent': round(matched_count / total_channels * 100, 1) if total_channels > 0 else 0,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def send_single_channel_epg_match_result(channel_id, matched, message, channel=None, epg_data=None):
|
||||
"""Notify the UI that a single-channel EPG match attempt has finished."""
|
||||
try:
|
||||
from apps.channels.serializers import ChannelSerializer
|
||||
|
||||
payload = {
|
||||
"type": "single_channel_epg_match",
|
||||
"channel_id": channel_id,
|
||||
"matched": matched,
|
||||
"message": message,
|
||||
}
|
||||
if channel is not None:
|
||||
payload["channel"] = ChannelSerializer(channel).data
|
||||
if epg_data is not None:
|
||||
payload["epg_id"] = epg_data.id
|
||||
payload["epg_name"] = epg_data.name
|
||||
|
||||
send_websocket_update('updates', 'update', payload)
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to send single channel EPG match result: {e}")
|
||||
|
||||
|
||||
def _compute_fuzzy_score(chan_norm, row, region_code=None):
|
||||
"""Compute fuzzy match score with optional region bonus/penalty."""
|
||||
if not row.get("norm_name"):
|
||||
return 0
|
||||
base_score = fuzz.ratio(chan_norm, row["norm_name"])
|
||||
bonus = 0
|
||||
if region_code and row.get("tvg_id"):
|
||||
combined_text = row["tvg_id"].lower() + " " + row["name"].lower()
|
||||
dot_regions = re.findall(r'\.([a-z]{2})', combined_text)
|
||||
if dot_regions:
|
||||
bonus = 15 if region_code in dot_regions else -15
|
||||
elif region_code in combined_text:
|
||||
bonus = 10
|
||||
return base_score + bonus
|
||||
|
||||
|
||||
def _ml_cosine_similarities(st_model, util, query_text, candidate_texts):
|
||||
"""Encode only the query plus candidate texts (not the full EPG database)."""
|
||||
if not candidate_texts:
|
||||
return []
|
||||
texts = [query_text] + list(candidate_texts)
|
||||
embeddings = st_model.encode(texts, convert_to_tensor=True, show_progress_bar=False)
|
||||
sim_scores = util.cos_sim(embeddings[0:1], embeddings[1:])[0]
|
||||
return [float(s) for s in sim_scores]
|
||||
|
||||
|
||||
def _active_epg_lookup_queryset():
|
||||
"""Lightweight queryset for exact EPG lookups (includes nameless entries)."""
|
||||
return (
|
||||
EPGData.objects
|
||||
.filter(epg_source__is_active=True)
|
||||
.values('id', 'tvg_id', 'name', 'epg_source_id', 'epg_source__priority')
|
||||
)
|
||||
|
||||
|
||||
def _active_epg_fuzzy_queryset():
|
||||
"""Lightweight queryset for fuzzy EPG matching (requires a display name)."""
|
||||
return (
|
||||
_active_epg_lookup_queryset()
|
||||
.filter(name__isnull=False)
|
||||
.exclude(name='')
|
||||
)
|
||||
|
||||
|
||||
def _row_from_epg_values(values_row):
|
||||
tvg_id = values_row.get('tvg_id') or ''
|
||||
normalized_tvg_id = tvg_id.strip().lower() if tvg_id else ''
|
||||
return {
|
||||
'id': values_row['id'],
|
||||
'tvg_id': normalized_tvg_id,
|
||||
'original_tvg_id': tvg_id,
|
||||
'name': values_row['name'],
|
||||
'epg_source_id': values_row['epg_source_id'],
|
||||
'epg_source_priority': values_row.get('epg_source__priority') or 0,
|
||||
}
|
||||
|
||||
|
||||
def lookup_epg_by_tvg_id(tvg_id):
|
||||
"""Exact tvg_id lookup without loading the full EPG catalog into memory."""
|
||||
if not tvg_id:
|
||||
return None
|
||||
values_row = _active_epg_lookup_queryset().filter(tvg_id__iexact=tvg_id.strip()).first()
|
||||
return _row_from_epg_values(values_row) if values_row else None
|
||||
|
||||
|
||||
def build_epg_matching_catalog():
|
||||
"""
|
||||
Build the in-memory EPG catalog for bulk matching using a streaming DB cursor.
|
||||
|
||||
Returns (epg_data, tvg_id_index): the full catalog plus an O(1) in-memory
|
||||
tvg_id lookup table (no extra DB queries). The index prefers the first entry
|
||||
per tvg_id after priority sorting.
|
||||
"""
|
||||
epg_data = []
|
||||
for values_row in _active_epg_fuzzy_queryset().iterator(chunk_size=500):
|
||||
row = _row_from_epg_values(values_row)
|
||||
row['norm_name'] = normalize_name(row['name'])
|
||||
epg_data.append(row)
|
||||
epg_data.sort(key=lambda x: x['epg_source_priority'], reverse=True)
|
||||
return epg_data, build_epg_tvg_id_index(epg_data)
|
||||
|
||||
|
||||
def build_epg_tvg_id_index(epg_data):
|
||||
"""
|
||||
Build an in-memory tvg_id -> row index from an EPG catalog (no DB queries).
|
||||
epg_data must be sorted by source priority (highest first) so the first
|
||||
entry wins when multiple sources share the same tvg_id.
|
||||
"""
|
||||
index = {}
|
||||
for row in epg_data:
|
||||
tvg_id = row.get("tvg_id")
|
||||
if tvg_id and tvg_id not in index:
|
||||
index[tvg_id] = row
|
||||
return index
|
||||
|
||||
|
||||
def _dispatch_program_parse_for_epg_assignments(changed_associations):
|
||||
"""
|
||||
Queue parse_programs once per unique EPG id newly assigned to a channel.
|
||||
|
||||
bulk_update bypasses post_save, so callers must invoke this when epg_data
|
||||
actually changes (mirrors the M3U sync path).
|
||||
"""
|
||||
if not changed_associations:
|
||||
return 0
|
||||
|
||||
from apps.epg.tasks import parse_programs_for_tvg_id
|
||||
|
||||
epg_ids = {
|
||||
assoc["epg_data_id"]
|
||||
for assoc in changed_associations
|
||||
if assoc.get("epg_data_id")
|
||||
}
|
||||
if not epg_ids:
|
||||
return 0
|
||||
|
||||
dispatched = 0
|
||||
for epg in EPGData.objects.filter(id__in=epg_ids).select_related("epg_source"):
|
||||
source_type = epg.epg_source.source_type if epg.epg_source else None
|
||||
if source_type in ("dummy", "schedules_direct"):
|
||||
continue
|
||||
parse_programs_for_tvg_id.delay(epg.id)
|
||||
dispatched += 1
|
||||
return dispatched
|
||||
|
||||
|
||||
def _log_unchanged_epg_assignment(chan, epg_id, epg_name, epg_tvg_id, match_method):
|
||||
chan_name = chan.get("name") or f"id={chan['id']}"
|
||||
chan_tvg = chan.get("original_tvg_id") or chan.get("tvg_id") or ""
|
||||
logger.debug(
|
||||
f"Channel '{chan_name}' (id={chan['id']}, tvg_id={chan_tvg!r}) "
|
||||
f"unchanged - already on EPG '{epg_name or '?'}' "
|
||||
f"(id={epg_id}, tvg_id={(epg_tvg_id or '?')!r}, via {match_method})"
|
||||
)
|
||||
|
||||
|
||||
def _record_epg_match(
|
||||
chan,
|
||||
epg_id,
|
||||
*,
|
||||
epg_name,
|
||||
epg_tvg_id,
|
||||
match_method,
|
||||
channels_to_update,
|
||||
matched_channels,
|
||||
unchanged_channels,
|
||||
):
|
||||
"""Record a match result; skip channels_to_update when assignment is already correct."""
|
||||
if chan.get("current_epg_data_id") == epg_id:
|
||||
unchanged_channels.append((chan["id"], chan.get("name") or "", epg_tvg_id or ""))
|
||||
_log_unchanged_epg_assignment(chan, epg_id, epg_name, epg_tvg_id, match_method)
|
||||
return
|
||||
|
||||
chan_name = chan.get("name") or f"id={chan['id']}"
|
||||
chan_tvg = chan.get("original_tvg_id") or chan.get("tvg_id") or ""
|
||||
fallback_name = chan.get("fallback_name") or chan_name
|
||||
chan["epg_data_id"] = epg_id
|
||||
channels_to_update.append(chan)
|
||||
matched_channels.append((chan["id"], fallback_name, epg_tvg_id or ""))
|
||||
logger.info(
|
||||
f"Channel '{chan_name}' (id={chan['id']}, tvg_id={chan_tvg!r}) "
|
||||
f"=> EPG '{epg_name or '?'}' (id={epg_id}, tvg_id={(epg_tvg_id or '?')!r}, via {match_method})"
|
||||
)
|
||||
|
||||
|
||||
def apply_matched_epg_to_channels(channels_to_update_dicts):
|
||||
"""
|
||||
Assign matched EPG rows to channels using two DB queries (channels + EPG).
|
||||
|
||||
Skips channels that already have the matched EPG. Returns association dicts
|
||||
for channels whose epg_data assignment actually changed, and dispatches
|
||||
program-parse tasks only for those new assignments.
|
||||
"""
|
||||
from apps.channels.models import Channel
|
||||
|
||||
if not channels_to_update_dicts:
|
||||
return []
|
||||
|
||||
channel_ids = [d["id"] for d in channels_to_update_dicts]
|
||||
epg_mapping = {d["id"]: d["epg_data_id"] for d in channels_to_update_dicts}
|
||||
epg_ids = {epg_id for epg_id in epg_mapping.values() if epg_id}
|
||||
|
||||
epg_by_id = {epg.id: epg for epg in EPGData.objects.filter(id__in=epg_ids)}
|
||||
channels_list = list(Channel.objects.filter(id__in=channel_ids))
|
||||
|
||||
changed_associations = []
|
||||
channels_to_bulk = []
|
||||
for channel_obj in channels_list:
|
||||
epg_data_id = epg_mapping.get(channel_obj.id)
|
||||
if not epg_data_id:
|
||||
continue
|
||||
if channel_obj.epg_data_id == epg_data_id:
|
||||
epg_row = epg_by_id.get(epg_data_id)
|
||||
_log_unchanged_epg_assignment(
|
||||
{
|
||||
"id": channel_obj.id,
|
||||
"name": channel_obj.name,
|
||||
"original_tvg_id": channel_obj.tvg_id,
|
||||
},
|
||||
epg_data_id,
|
||||
epg_row.name if epg_row else None,
|
||||
epg_row.tvg_id if epg_row else None,
|
||||
"apply",
|
||||
)
|
||||
continue
|
||||
epg_data_obj = epg_by_id.get(epg_data_id)
|
||||
if epg_data_obj:
|
||||
channel_obj.epg_data = epg_data_obj
|
||||
channels_to_bulk.append(channel_obj)
|
||||
changed_associations.append(
|
||||
{"channel_id": channel_obj.id, "epg_data_id": epg_data_id}
|
||||
)
|
||||
else:
|
||||
logger.error(f"EPG data {epg_data_id} not found for channel {channel_obj.id}")
|
||||
|
||||
if channels_to_bulk:
|
||||
Channel.objects.bulk_update(channels_to_bulk, ["epg_data"])
|
||||
|
||||
parse_dispatched = _dispatch_program_parse_for_epg_assignments(changed_associations)
|
||||
if parse_dispatched:
|
||||
logger.info(
|
||||
f"Dispatched {parse_dispatched} EPG program parse task(s) for changed assignments"
|
||||
)
|
||||
|
||||
return changed_associations
|
||||
|
||||
|
||||
def get_preferred_region_code():
|
||||
try:
|
||||
region_obj = CoreSettings.objects.get(key="preferred-region")
|
||||
return region_obj.value.strip().lower()
|
||||
except CoreSettings.DoesNotExist:
|
||||
return None
|
||||
|
||||
|
||||
def _fuzzy_scan_core(chan_norm, rows, region_code=None, candidate_limit=ML_CANDIDATE_LIMIT):
|
||||
"""
|
||||
Single-pass fuzzy scan: track best match and top-K candidates.
|
||||
Rows must already include norm_name when scanning an in-memory catalog.
|
||||
"""
|
||||
best_score = 0
|
||||
best_epg = None
|
||||
top_heap = []
|
||||
seq = 0
|
||||
scanned = 0
|
||||
|
||||
for row in rows:
|
||||
if not row.get("norm_name"):
|
||||
continue
|
||||
|
||||
scanned += 1
|
||||
score = _compute_fuzzy_score(chan_norm, row, region_code)
|
||||
if score <= 0:
|
||||
continue
|
||||
|
||||
if score > 50:
|
||||
logger.debug(f" EPG '{row['name']}' (norm: '{row['norm_name']}') => score: {score}")
|
||||
|
||||
priority = row['epg_source_priority']
|
||||
if score > best_score or (
|
||||
score == best_score
|
||||
and priority > (best_epg.get('epg_source_priority', 0) if best_epg else -1)
|
||||
):
|
||||
best_score = score
|
||||
best_epg = row
|
||||
|
||||
seq += 1
|
||||
if len(top_heap) < candidate_limit:
|
||||
heapq.heappush(top_heap, (score, priority, seq, row))
|
||||
else:
|
||||
smallest_score, smallest_priority, _, _ = top_heap[0]
|
||||
if score > smallest_score or (score == smallest_score and priority > smallest_priority):
|
||||
heapq.heapreplace(top_heap, (score, priority, seq, row))
|
||||
|
||||
top_candidates = sorted(top_heap, key=lambda item: (item[0], item[1]), reverse=True)
|
||||
return best_score, best_epg, [(score, row) for score, _, _, row in top_candidates], scanned
|
||||
|
||||
|
||||
def fuzzy_scan_epg_list(chan_norm, epg_data, region_code=None, candidate_limit=ML_CANDIDATE_LIMIT):
|
||||
"""Fuzzy scan over a pre-built in-memory EPG catalog (bulk matching)."""
|
||||
logger.debug(f"Fuzzy matching '{chan_norm}' against EPG entries...")
|
||||
return _fuzzy_scan_core(chan_norm, epg_data, region_code, candidate_limit)
|
||||
|
||||
|
||||
def stream_fuzzy_epg_scan(chan_norm, region_code=None, candidate_limit=ML_CANDIDATE_LIMIT):
|
||||
"""Stream fuzzy scan over active EPG entries (single-channel matching)."""
|
||||
|
||||
def row_iterator():
|
||||
for values_row in _active_epg_fuzzy_queryset().iterator(chunk_size=500):
|
||||
row = _row_from_epg_values(values_row)
|
||||
row['norm_name'] = normalize_name(row['name'])
|
||||
yield row
|
||||
|
||||
logger.debug(f"Fuzzy matching '{chan_norm}' against EPG entries...")
|
||||
return _fuzzy_scan_core(chan_norm, row_iterator(), region_code, candidate_limit)
|
||||
|
||||
|
||||
def _get_epg_match_thresholds(is_bulk_matching):
|
||||
if is_bulk_matching:
|
||||
return {
|
||||
'FUZZY_HIGH_CONFIDENCE': 90,
|
||||
'FUZZY_SKIP_ML': 80,
|
||||
'FUZZY_MEDIUM_CONFIDENCE': 70,
|
||||
'ML_HIGH_CONFIDENCE': 0.75,
|
||||
'ML_LAST_RESORT': 0.65,
|
||||
'FUZZY_LAST_RESORT_MIN': 50,
|
||||
}
|
||||
return {
|
||||
'FUZZY_HIGH_CONFIDENCE': 85,
|
||||
'FUZZY_SKIP_ML': 75,
|
||||
'FUZZY_MEDIUM_CONFIDENCE': 40,
|
||||
'ML_HIGH_CONFIDENCE': 0.65,
|
||||
'ML_LAST_RESORT': 0.50,
|
||||
'FUZZY_LAST_RESORT_MIN': 20,
|
||||
}
|
||||
|
||||
|
||||
def try_epg_name_match(chan, best_score, best_epg, top_candidates, is_bulk_matching,
|
||||
use_ml=True, ml_state=None):
|
||||
"""
|
||||
Apply fuzzy/ML thresholds to a channel's best fuzzy result.
|
||||
Returns the matched EPG row dict, or None.
|
||||
"""
|
||||
if not best_epg:
|
||||
return None
|
||||
|
||||
thresholds = _get_epg_match_thresholds(is_bulk_matching)
|
||||
fuzzy_high = thresholds['FUZZY_HIGH_CONFIDENCE']
|
||||
fuzzy_skip_ml = thresholds['FUZZY_SKIP_ML']
|
||||
fuzzy_medium = thresholds['FUZZY_MEDIUM_CONFIDENCE']
|
||||
ml_high = thresholds['ML_HIGH_CONFIDENCE']
|
||||
ml_last_resort = thresholds['ML_LAST_RESORT']
|
||||
fuzzy_last_resort_min = thresholds['FUZZY_LAST_RESORT_MIN']
|
||||
|
||||
if best_score >= fuzzy_high:
|
||||
logger.info(
|
||||
f"Channel {chan['id']} '{chan['name']}' => matched tvg_id={best_epg['tvg_id']} "
|
||||
f"(score={best_score})"
|
||||
)
|
||||
return best_epg
|
||||
|
||||
if best_score >= fuzzy_skip_ml:
|
||||
logger.info(
|
||||
f"Channel {chan['id']} '{chan['name']}' => matched tvg_id={best_epg['tvg_id']} "
|
||||
f"(fuzzy={best_score}, ML skipped)"
|
||||
)
|
||||
return best_epg
|
||||
|
||||
if ml_state is None:
|
||||
ml_state = {}
|
||||
|
||||
st_model = ml_state.get('st_model')
|
||||
util = ml_state.get('util')
|
||||
|
||||
if best_score >= fuzzy_medium and use_ml:
|
||||
if st_model is None:
|
||||
st_model, util = get_sentence_transformer()
|
||||
ml_state['st_model'] = st_model
|
||||
ml_state['util'] = util
|
||||
|
||||
if st_model:
|
||||
try:
|
||||
logger.info("Validating fuzzy best match with ML model (single candidate)")
|
||||
sims = _ml_cosine_similarities(st_model, util, chan["norm_chan"], [best_epg["norm_name"]])
|
||||
top_value = sims[0] if sims else 0.0
|
||||
|
||||
if top_value >= ml_high - 1e-9:
|
||||
logger.info(
|
||||
f"Channel {chan['id']} '{chan['name']}' => matched EPG tvg_id={best_epg['tvg_id']} "
|
||||
f"(fuzzy={best_score}, ML-sim={top_value:.2f})"
|
||||
)
|
||||
return best_epg
|
||||
if top_value >= ml_last_resort - 1e-9:
|
||||
logger.info(
|
||||
f"Channel {chan['id']} '{chan['name']}' => LAST RESORT match EPG "
|
||||
f"tvg_id={best_epg['tvg_id']} (fuzzy={best_score}, ML-sim={top_value:.2f})"
|
||||
)
|
||||
return best_epg
|
||||
logger.info(
|
||||
f"Channel {chan['id']} '{chan['name']}' => fuzzy={best_score}, "
|
||||
f"ML-sim={top_value:.2f} < {ml_last_resort}, skipping"
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning(f"ML matching failed for channel {chan['id']}: {e}")
|
||||
logger.info(
|
||||
f"Channel {chan['id']} '{chan['name']}' => fuzzy score {best_score} below threshold, skipping"
|
||||
)
|
||||
return None
|
||||
|
||||
if best_score >= fuzzy_last_resort_min and use_ml:
|
||||
if st_model is None:
|
||||
st_model, util = get_sentence_transformer()
|
||||
ml_state['st_model'] = st_model
|
||||
ml_state['util'] = util
|
||||
|
||||
if st_model and top_candidates:
|
||||
try:
|
||||
logger.info(
|
||||
f"Channel {chan['id']} '{chan['name']}' => trying ML last resort against "
|
||||
f"top {len(top_candidates)} fuzzy candidates (fuzzy={best_score})"
|
||||
)
|
||||
candidate_rows = [row for _, row in top_candidates]
|
||||
sims = _ml_cosine_similarities(
|
||||
st_model,
|
||||
util,
|
||||
chan["norm_chan"],
|
||||
[row["norm_name"] for row in candidate_rows],
|
||||
)
|
||||
top_index = max(range(len(sims)), key=lambda i: sims[i])
|
||||
top_value = sims[top_index]
|
||||
matched_epg = candidate_rows[top_index]
|
||||
|
||||
if top_value >= ml_last_resort - 1e-9:
|
||||
logger.info(
|
||||
f"Channel {chan['id']} '{chan['name']}' => DESPERATE LAST RESORT match "
|
||||
f"EPG tvg_id={matched_epg['tvg_id']} (fuzzy={best_score}, ML-sim={top_value:.2f})"
|
||||
)
|
||||
return matched_epg
|
||||
logger.info(
|
||||
f"Channel {chan['id']} '{chan['name']}' => desperate last resort "
|
||||
f"ML-sim {top_value:.2f} < {ml_last_resort}, giving up"
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning(f"Last resort ML matching failed for channel {chan['id']}: {e}")
|
||||
logger.info(
|
||||
f"Channel {chan['id']} '{chan['name']}' => best fuzzy score={best_score} "
|
||||
f"< {fuzzy_medium}, giving up"
|
||||
)
|
||||
return None
|
||||
|
||||
logger.info(
|
||||
f"Channel {chan['id']} '{chan['name']}' => best fuzzy score={best_score} "
|
||||
f"< {fuzzy_medium}, no ML fallback available"
|
||||
)
|
||||
return None
|
||||
|
||||
|
||||
def prepare_channel_match_data(channel):
|
||||
"""Build the channel dict used by matching logic."""
|
||||
normalized_tvg_id = channel.tvg_id.strip().lower() if channel.tvg_id else ""
|
||||
normalized_gracenote_id = (
|
||||
channel.tvc_guide_stationid.strip().lower() if channel.tvc_guide_stationid else ""
|
||||
)
|
||||
return {
|
||||
"id": channel.id,
|
||||
"name": channel.name,
|
||||
"tvg_id": normalized_tvg_id,
|
||||
"original_tvg_id": channel.tvg_id,
|
||||
"gracenote_id": normalized_gracenote_id,
|
||||
"original_gracenote_id": channel.tvc_guide_stationid,
|
||||
"fallback_name": normalized_tvg_id if normalized_tvg_id else channel.name,
|
||||
"norm_chan": normalize_name(channel.name),
|
||||
"current_epg_data_id": channel.epg_data_id,
|
||||
}
|
||||
|
||||
|
||||
def match_channels_to_epg(
|
||||
channels_data,
|
||||
epg_data,
|
||||
region_code=None,
|
||||
use_ml=True,
|
||||
send_progress=True,
|
||||
epg_tvg_id_index=None,
|
||||
):
|
||||
"""
|
||||
Match channels to EPG rows using exact ID, fuzzy, and optional ML strategies.
|
||||
|
||||
epg_tvg_id_index: optional pre-built tvg_id -> row map from build_epg_matching_catalog().
|
||||
"""
|
||||
channels_to_update = []
|
||||
matched_channels = []
|
||||
unchanged_channels = []
|
||||
total_channels = len(channels_data)
|
||||
|
||||
if send_progress:
|
||||
send_epg_matching_progress(total_channels, 0, stage="starting")
|
||||
|
||||
is_bulk_matching = len(channels_data) > 1
|
||||
ml_state = {}
|
||||
epg_by_tvg_id = epg_tvg_id_index if epg_tvg_id_index is not None else build_epg_tvg_id_index(epg_data)
|
||||
|
||||
if is_bulk_matching:
|
||||
logger.info(f"Using conservative thresholds for bulk matching ({total_channels} channels)")
|
||||
else:
|
||||
logger.info("Using aggressive thresholds for single channel matching")
|
||||
|
||||
for index, chan in enumerate(channels_data):
|
||||
normalized_tvg_id = chan.get("tvg_id", "")
|
||||
fallback_name = chan["tvg_id"].strip() if chan["tvg_id"] else chan["name"]
|
||||
|
||||
resolved_count = len(matched_channels) + len(unchanged_channels)
|
||||
if send_progress and (index < 5 or index % 5 == 0 or index == total_channels - 1):
|
||||
send_epg_matching_progress(
|
||||
total_channels,
|
||||
resolved_count,
|
||||
current_channel_name=chan["name"][:50],
|
||||
stage="matching",
|
||||
)
|
||||
|
||||
if normalized_tvg_id:
|
||||
epg_row = epg_by_tvg_id.get(normalized_tvg_id)
|
||||
if epg_row:
|
||||
_record_epg_match(
|
||||
chan,
|
||||
epg_row["id"],
|
||||
epg_name=epg_row.get("name"),
|
||||
epg_tvg_id=epg_row.get("original_tvg_id") or epg_row.get("tvg_id"),
|
||||
match_method="exact tvg_id",
|
||||
channels_to_update=channels_to_update,
|
||||
matched_channels=matched_channels,
|
||||
unchanged_channels=unchanged_channels,
|
||||
)
|
||||
continue
|
||||
|
||||
normalized_gracenote_id = chan.get("gracenote_id", "")
|
||||
if normalized_gracenote_id:
|
||||
epg_by_gracenote_id = epg_by_tvg_id.get(normalized_gracenote_id)
|
||||
if epg_by_gracenote_id:
|
||||
_record_epg_match(
|
||||
chan,
|
||||
epg_by_gracenote_id["id"],
|
||||
epg_name=epg_by_gracenote_id.get("name"),
|
||||
epg_tvg_id=epg_by_gracenote_id.get("original_tvg_id")
|
||||
or epg_by_gracenote_id.get("tvg_id"),
|
||||
match_method="exact gracenote_id",
|
||||
channels_to_update=channels_to_update,
|
||||
matched_channels=matched_channels,
|
||||
unchanged_channels=unchanged_channels,
|
||||
)
|
||||
continue
|
||||
|
||||
if not chan["norm_chan"]:
|
||||
logger.debug(f"Channel {chan['id']} '{chan['name']}' => empty after normalization, skipping")
|
||||
continue
|
||||
|
||||
best_score, best_epg, top_candidates, _scanned = fuzzy_scan_epg_list(
|
||||
chan["norm_chan"], epg_data, region_code
|
||||
)
|
||||
if not best_epg:
|
||||
logger.debug(f"Channel {chan['id']} '{chan['name']}' => no EPG entries with valid norm_name found")
|
||||
continue
|
||||
|
||||
matched_epg = try_epg_name_match(
|
||||
chan,
|
||||
best_score,
|
||||
best_epg,
|
||||
top_candidates,
|
||||
is_bulk_matching,
|
||||
use_ml=use_ml,
|
||||
ml_state=ml_state,
|
||||
)
|
||||
if matched_epg:
|
||||
_record_epg_match(
|
||||
chan,
|
||||
matched_epg["id"],
|
||||
epg_name=matched_epg.get("name"),
|
||||
epg_tvg_id=matched_epg.get("original_tvg_id") or matched_epg.get("tvg_id"),
|
||||
match_method=f"fuzzy (score={best_score})",
|
||||
channels_to_update=channels_to_update,
|
||||
matched_channels=matched_channels,
|
||||
unchanged_channels=unchanged_channels,
|
||||
)
|
||||
|
||||
if send_progress:
|
||||
send_epg_matching_progress(
|
||||
total_channels,
|
||||
len(matched_channels) + len(unchanged_channels),
|
||||
stage="completed",
|
||||
)
|
||||
|
||||
return {
|
||||
"channels_to_update": channels_to_update,
|
||||
"matched_channels": matched_channels,
|
||||
"unchanged_channels": unchanged_channels,
|
||||
}
|
||||
|
||||
|
||||
def run_single_channel_epg_match(channel_id):
|
||||
"""
|
||||
Match one channel to EPG data. Always notifies the UI via WebSocket before returning.
|
||||
"""
|
||||
from apps.channels.models import Channel
|
||||
|
||||
channel = None
|
||||
try:
|
||||
logger.info(f"Starting integrated single channel EPG matching for channel ID {channel_id}")
|
||||
|
||||
try:
|
||||
channel = Channel.objects.get(id=channel_id)
|
||||
except Channel.DoesNotExist:
|
||||
message = "Channel not found"
|
||||
send_single_channel_epg_match_result(channel_id, False, message)
|
||||
return {"matched": False, "message": message}
|
||||
|
||||
channel_data = prepare_channel_match_data(channel)
|
||||
logger.info(
|
||||
f"Channel data prepared: name='{channel.name}', tvg_id='{channel_data['tvg_id']}', "
|
||||
f"gracenote_id='{channel_data['gracenote_id']}', norm_chan='{channel_data['norm_chan']}'"
|
||||
)
|
||||
|
||||
send_epg_matching_progress(1, 0, current_channel_name=channel.name, stage="matching")
|
||||
region_code = get_preferred_region_code()
|
||||
|
||||
fallback_name = channel_data["tvg_id"] if channel_data["tvg_id"] else channel.name
|
||||
matched_epg_row = None
|
||||
match_via = None
|
||||
|
||||
if channel_data["tvg_id"]:
|
||||
matched_epg_row = lookup_epg_by_tvg_id(channel_data["tvg_id"])
|
||||
if matched_epg_row:
|
||||
match_via = matched_epg_row["tvg_id"]
|
||||
logger.info(
|
||||
f"Channel {channel.id} '{fallback_name}' => EPG found by exact tvg_id={match_via}"
|
||||
)
|
||||
|
||||
if not matched_epg_row and channel_data["gracenote_id"]:
|
||||
matched_epg_row = lookup_epg_by_tvg_id(channel_data["gracenote_id"])
|
||||
if matched_epg_row:
|
||||
match_via = f"gracenote:{matched_epg_row['tvg_id']}"
|
||||
logger.info(
|
||||
f"Channel {channel.id} '{fallback_name}' => EPG found by exact "
|
||||
f"gracenote_id={channel_data['gracenote_id']}"
|
||||
)
|
||||
|
||||
if not matched_epg_row and channel_data["norm_chan"]:
|
||||
best_score, best_epg, top_candidates, scanned = stream_fuzzy_epg_scan(
|
||||
channel_data["norm_chan"], region_code
|
||||
)
|
||||
logger.info(
|
||||
f"Matching single channel '{channel.name}' against {scanned} EPG entries"
|
||||
)
|
||||
if best_epg:
|
||||
logger.info(
|
||||
f"Channel {channel.id} '{channel.name}' => best match: '{best_epg['name']}' "
|
||||
f"(score: {best_score})"
|
||||
)
|
||||
matched_epg_row = try_epg_name_match(
|
||||
channel_data,
|
||||
best_score,
|
||||
best_epg,
|
||||
top_candidates,
|
||||
is_bulk_matching=False,
|
||||
use_ml=True,
|
||||
)
|
||||
if matched_epg_row:
|
||||
match_via = matched_epg_row["tvg_id"]
|
||||
elif not channel_data["norm_chan"]:
|
||||
logger.debug(f"Channel {channel.id} '{channel.name}' => empty after normalization, skipping")
|
||||
|
||||
if not matched_epg_row:
|
||||
has_fuzzy_epg = _active_epg_fuzzy_queryset().exists()
|
||||
if not has_fuzzy_epg and not channel_data["tvg_id"] and not channel_data["gracenote_id"]:
|
||||
message = "No EPG data available for matching (from active sources)"
|
||||
send_epg_matching_progress(1, 0, current_channel_name=channel.name, stage="completed")
|
||||
send_single_channel_epg_match_result(channel.id, False, message, channel=channel)
|
||||
return {"matched": False, "message": message}
|
||||
|
||||
if matched_epg_row:
|
||||
try:
|
||||
matched_epg_id = matched_epg_row["id"]
|
||||
epg_data = (
|
||||
channel.epg_data
|
||||
if channel.epg_data_id == matched_epg_id
|
||||
else EPGData.objects.get(id=matched_epg_id)
|
||||
)
|
||||
|
||||
if channel.epg_data_id == matched_epg_id:
|
||||
success_msg = (
|
||||
f"Channel '{channel.name}' already matched with EPG '{epg_data.name}'"
|
||||
)
|
||||
if match_via:
|
||||
success_msg += f" (matched via: {match_via})"
|
||||
logger.info(success_msg)
|
||||
send_epg_matching_progress(1, 1, current_channel_name=channel.name, stage="completed")
|
||||
send_single_channel_epg_match_result(
|
||||
channel.id, True, success_msg, channel=channel, epg_data=epg_data
|
||||
)
|
||||
return {
|
||||
"matched": True,
|
||||
"unchanged": True,
|
||||
"message": success_msg,
|
||||
"epg_name": epg_data.name,
|
||||
"epg_id": epg_data.id,
|
||||
}
|
||||
|
||||
channel.epg_data = epg_data
|
||||
channel.save(update_fields=["epg_data"])
|
||||
|
||||
success_msg = f"Channel '{channel.name}' matched with EPG '{epg_data.name}'"
|
||||
if match_via:
|
||||
success_msg += f" (matched via: {match_via})"
|
||||
|
||||
logger.info(success_msg)
|
||||
send_epg_matching_progress(1, 1, current_channel_name=channel.name, stage="completed")
|
||||
channel.refresh_from_db()
|
||||
send_single_channel_epg_match_result(
|
||||
channel.id, True, success_msg, channel=channel, epg_data=epg_data
|
||||
)
|
||||
return {
|
||||
"matched": True,
|
||||
"message": success_msg,
|
||||
"epg_name": epg_data.name,
|
||||
"epg_id": epg_data.id,
|
||||
}
|
||||
except EPGData.DoesNotExist:
|
||||
message = "Matched EPG data not found"
|
||||
send_single_channel_epg_match_result(channel.id, False, message, channel=channel)
|
||||
return {"matched": False, "message": message}
|
||||
|
||||
send_epg_matching_progress(1, 0, current_channel_name=channel.name, stage="completed")
|
||||
message = f"No suitable EPG match found for channel '{channel.name}'"
|
||||
send_single_channel_epg_match_result(channel.id, False, message, channel=channel)
|
||||
return {"matched": False, "message": message}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error in integrated single channel EPG matching: {e}", exc_info=True)
|
||||
message = f"Error during matching: {str(e)}"
|
||||
send_single_channel_epg_match_result(
|
||||
channel_id,
|
||||
False,
|
||||
message,
|
||||
channel=channel,
|
||||
)
|
||||
return {"matched": False, "message": message}
|
||||
|
||||
finally:
|
||||
cleanup_after_matching()
|
||||
|
|
@ -62,6 +62,9 @@ def backfill_auto_created_by_null(apps, schema_editor):
|
|||
f"(ambiguous/no streams): {demoted}"
|
||||
)
|
||||
|
||||
with schema_editor.connection.cursor() as cursor:
|
||||
cursor.execute("SET CONSTRAINTS ALL IMMEDIATE")
|
||||
|
||||
|
||||
def reverse_auto_created_by_null(apps, schema_editor):
|
||||
# Forward decisions cannot be cleanly reverted (no record of the
|
||||
|
|
@ -101,6 +104,9 @@ def reverse_backfill_channel_number_nulls(apps, schema_editor):
|
|||
ch.save(update_fields=["channel_number"])
|
||||
next_num += 1.0
|
||||
|
||||
with schema_editor.connection.cursor() as cursor:
|
||||
cursor.execute("SET CONSTRAINTS ALL IMMEDIATE")
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
|
|
|
|||
|
|
@ -443,7 +443,7 @@ class Channel(models.Model):
|
|||
|
||||
# @TODO: honor stream's stream profile
|
||||
def get_stream_profile(self):
|
||||
stream_profile = self.stream_profile
|
||||
stream_profile = self.effective_stream_profile_obj
|
||||
if not stream_profile:
|
||||
stream_profile = StreamProfile.objects.get(
|
||||
id=CoreSettings.get_default_stream_profile_id()
|
||||
|
|
|
|||
|
|
@ -22,7 +22,7 @@ from django.db import connection, transaction
|
|||
from django.urls import reverse
|
||||
from rest_framework import serializers
|
||||
from django.utils import timezone
|
||||
from core.utils import validate_flexible_url
|
||||
from core.utils import validate_flexible_url, build_absolute_uri_with_port
|
||||
|
||||
|
||||
class LogoSerializer(serializers.ModelSerializer):
|
||||
|
|
@ -57,13 +57,18 @@ class LogoSerializer(serializers.ModelSerializer):
|
|||
return instance
|
||||
|
||||
def get_cache_url(self, obj):
|
||||
# return f"/api/channels/logos/{obj.id}/cache/"
|
||||
# Cache-busting: append a short hash of the logo's source URL so the browser
|
||||
# fetches fresh when the logo changes (e.g., M3U logo replaced by SD logo).
|
||||
# The backend ignores the 'v' parameter — it's purely for browser cache invalidation.
|
||||
# See SD integration PR notes for context on why this was added.
|
||||
import hashlib
|
||||
url_hash = hashlib.md5((obj.url or '').encode()).hexdigest()[:8]
|
||||
base_path = reverse("api:channels:logo-cache", args=[obj.id])
|
||||
cache_url = f"{base_path}?v={url_hash}"
|
||||
request = self.context.get("request")
|
||||
if request:
|
||||
return request.build_absolute_uri(
|
||||
reverse("api:channels:logo-cache", args=[obj.id])
|
||||
)
|
||||
return reverse("api:channels:logo-cache", args=[obj.id])
|
||||
return build_absolute_uri_with_port(request, cache_url)
|
||||
return cache_url
|
||||
|
||||
def get_channel_count(self, obj):
|
||||
"""Get the number of channels using this logo"""
|
||||
|
|
|
|||
|
|
@ -201,10 +201,19 @@ def refresh_epg_programs(sender, instance, created, **kwargs):
|
|||
if not created and kwargs.get('update_fields') and 'epg_data' in kwargs['update_fields']:
|
||||
logger.info(f"Channel {instance.id} ({instance.name}) EPG data updated, refreshing program data")
|
||||
if instance.epg_data:
|
||||
# Skip XMLTV program parser for SD sources — program data is handled
|
||||
# by fetch_schedules_direct() directly.
|
||||
if instance.epg_data.epg_source and instance.epg_data.epg_source.source_type == 'schedules_direct':
|
||||
logger.info(f"Skipping XMLTV parse for SD source {instance.epg_data.tvg_id}")
|
||||
return
|
||||
logger.info(f"Triggering EPG program refresh for {instance.epg_data.tvg_id}")
|
||||
parse_programs_for_tvg_id.delay(instance.epg_data.id)
|
||||
# For new channels with EPG data, also refresh
|
||||
elif created and instance.epg_data:
|
||||
# Skip XMLTV program parser for SD sources
|
||||
if instance.epg_data.epg_source and instance.epg_data.epg_source.source_type == 'schedules_direct':
|
||||
logger.info(f"Skipping XMLTV parse for SD source {instance.epg_data.tvg_id}")
|
||||
return
|
||||
logger.info(f"New channel {instance.id} ({instance.name}) created with EPG data, refreshing program data")
|
||||
parse_programs_for_tvg_id.delay(instance.epg_data.id)
|
||||
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -196,15 +196,17 @@ class InitialConnectionRetryTests(TestCase):
|
|||
base URL before falling back to the next candidate."""
|
||||
|
||||
def test_reconnect_max_constant_exists_in_run_recording(self):
|
||||
"""run_recording must define a max-reconnect limit to prevent
|
||||
infinite retries on the same broken base URL."""
|
||||
"""run_recording must use a time-bounded FFmpeg outage window to prevent
|
||||
infinite restarts when the source stream is permanently down."""
|
||||
import inspect
|
||||
from apps.channels.tasks import run_recording
|
||||
from apps.channels.tasks import run_recording, _dvr_ffmpeg_retry_window_seconds
|
||||
source = inspect.getsource(run_recording)
|
||||
|
||||
# The reconnection counter pattern must be present
|
||||
self.assertGreater(_dvr_ffmpeg_retry_window_seconds(), 0)
|
||||
self.assertIn("reconnect", source.lower(),
|
||||
"run_recording must contain reconnection logic")
|
||||
"run_recording must contain input reconnection flags")
|
||||
self.assertIn("_ffmpeg_outage_started", source)
|
||||
self.assertIn("_ffmpeg_retry_window", source)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
|
|
|
|||
272
apps/channels/tests/test_epg_logo_apply.py
Normal file
272
apps/channels/tests/test_epg_logo_apply.py
Normal file
|
|
@ -0,0 +1,272 @@
|
|||
from unittest.mock import patch
|
||||
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.test import TestCase
|
||||
from rest_framework import status
|
||||
from rest_framework.test import APIClient
|
||||
|
||||
from apps.channels.models import Channel, Logo
|
||||
from apps.channels.utils import (
|
||||
apply_logos_from_epg_icon_url,
|
||||
apply_logos_from_epg_for_source,
|
||||
auto_apply_epg_logos_enabled,
|
||||
maybe_auto_apply_epg_logos,
|
||||
)
|
||||
from apps.epg.models import EPGData, EPGSource
|
||||
|
||||
User = get_user_model()
|
||||
|
||||
|
||||
class AutoApplyEpgLogosEnabledTests(TestCase):
|
||||
def test_enabled_when_flag_true(self):
|
||||
self.assertTrue(
|
||||
auto_apply_epg_logos_enabled({'auto_apply_epg_logos': True})
|
||||
)
|
||||
|
||||
def test_disabled_when_flag_false_or_missing(self):
|
||||
self.assertFalse(
|
||||
auto_apply_epg_logos_enabled({'auto_apply_epg_logos': False})
|
||||
)
|
||||
self.assertFalse(auto_apply_epg_logos_enabled({}))
|
||||
self.assertFalse(auto_apply_epg_logos_enabled(None))
|
||||
|
||||
|
||||
class ApplyLogosFromEpgIconUrlTests(TestCase):
|
||||
def setUp(self):
|
||||
self.source = EPGSource.objects.create(
|
||||
name='XML EPG',
|
||||
source_type='xmltv',
|
||||
url='http://example.com/epg.xml',
|
||||
)
|
||||
self.epg_one = EPGData.objects.create(
|
||||
tvg_id='ch.one',
|
||||
name='Channel One',
|
||||
icon_url='https://example.com/one.png',
|
||||
epg_source=self.source,
|
||||
)
|
||||
self.epg_two = EPGData.objects.create(
|
||||
tvg_id='ch.two',
|
||||
name='Channel Two',
|
||||
icon_url='https://example.com/one.png',
|
||||
epg_source=self.source,
|
||||
)
|
||||
self.channel_one = Channel.objects.create(
|
||||
channel_number=1,
|
||||
name='Channel One',
|
||||
tvg_id='ch.one',
|
||||
epg_data=self.epg_one,
|
||||
)
|
||||
self.channel_two = Channel.objects.create(
|
||||
channel_number=2,
|
||||
name='Channel Two',
|
||||
tvg_id='ch.two',
|
||||
epg_data=self.epg_two,
|
||||
)
|
||||
|
||||
def test_creates_logo_and_updates_channels(self):
|
||||
channels = Channel.objects.filter(
|
||||
id__in=[self.channel_one.id, self.channel_two.id],
|
||||
).select_related('epg_data', 'logo')
|
||||
|
||||
stats = apply_logos_from_epg_icon_url(channels)
|
||||
|
||||
self.assertEqual(stats['updated_count'], 2)
|
||||
self.assertEqual(stats['created_logos_count'], 1)
|
||||
self.assertEqual(Logo.objects.count(), 1)
|
||||
|
||||
self.channel_one.refresh_from_db()
|
||||
self.channel_two.refresh_from_db()
|
||||
self.assertEqual(self.channel_one.logo.url, 'https://example.com/one.png')
|
||||
self.assertEqual(self.channel_two.logo_id, self.channel_one.logo_id)
|
||||
|
||||
def test_skips_channels_already_using_icon_url(self):
|
||||
existing_logo = Logo.objects.create(
|
||||
name='Existing',
|
||||
url='https://example.com/one.png',
|
||||
)
|
||||
self.channel_one.logo = existing_logo
|
||||
self.channel_one.save(update_fields=['logo'])
|
||||
|
||||
channels = Channel.objects.filter(
|
||||
id__in=[self.channel_one.id, self.channel_two.id],
|
||||
).select_related('epg_data', 'logo')
|
||||
|
||||
stats = apply_logos_from_epg_icon_url(channels)
|
||||
|
||||
self.assertEqual(stats['updated_count'], 1)
|
||||
self.assertEqual(stats['created_logos_count'], 0)
|
||||
|
||||
def test_skips_channels_without_icon_url(self):
|
||||
self.epg_one.icon_url = None
|
||||
self.epg_one.save(update_fields=['icon_url'])
|
||||
|
||||
channels = Channel.objects.filter(
|
||||
id__in=[self.channel_one.id, self.channel_two.id],
|
||||
).select_related('epg_data', 'logo')
|
||||
|
||||
stats = apply_logos_from_epg_icon_url(channels)
|
||||
|
||||
self.assertEqual(stats['updated_count'], 1)
|
||||
self.channel_one.refresh_from_db()
|
||||
self.assertIsNone(self.channel_one.logo_id)
|
||||
|
||||
|
||||
class ApplyLogosForSourceTests(TestCase):
|
||||
def setUp(self):
|
||||
self.source = EPGSource.objects.create(
|
||||
name='XML EPG',
|
||||
source_type='xmltv',
|
||||
url='http://example.com/epg.xml',
|
||||
)
|
||||
self.other_source = EPGSource.objects.create(
|
||||
name='Other EPG',
|
||||
source_type='xmltv',
|
||||
url='http://example.com/other.xml',
|
||||
)
|
||||
self.epg = EPGData.objects.create(
|
||||
tvg_id='mapped',
|
||||
name='Mapped',
|
||||
icon_url='https://example.com/mapped.png',
|
||||
epg_source=self.source,
|
||||
)
|
||||
self.other_epg = EPGData.objects.create(
|
||||
tvg_id='other',
|
||||
name='Other',
|
||||
icon_url='https://example.com/other.png',
|
||||
epg_source=self.other_source,
|
||||
)
|
||||
self.mapped_channel = Channel.objects.create(
|
||||
channel_number=1,
|
||||
name='Mapped',
|
||||
tvg_id='mapped',
|
||||
epg_data=self.epg,
|
||||
)
|
||||
Channel.objects.create(
|
||||
channel_number=2,
|
||||
name='Other',
|
||||
tvg_id='other',
|
||||
epg_data=self.other_epg,
|
||||
)
|
||||
|
||||
def test_only_updates_channels_mapped_to_source(self):
|
||||
stats = apply_logos_from_epg_for_source(self.source)
|
||||
|
||||
self.assertEqual(stats['updated_count'], 1)
|
||||
self.mapped_channel.refresh_from_db()
|
||||
self.assertEqual(
|
||||
self.mapped_channel.logo.url,
|
||||
'https://example.com/mapped.png',
|
||||
)
|
||||
|
||||
def test_processes_source_in_batches(self):
|
||||
stats = apply_logos_from_epg_for_source(self.source, batch_size=1)
|
||||
|
||||
self.assertEqual(stats['updated_count'], 1)
|
||||
self.mapped_channel.refresh_from_db()
|
||||
self.assertEqual(
|
||||
self.mapped_channel.logo.url,
|
||||
'https://example.com/mapped.png',
|
||||
)
|
||||
|
||||
|
||||
class MaybeAutoApplyEpgLogosTests(TestCase):
|
||||
def setUp(self):
|
||||
self.source = EPGSource.objects.create(
|
||||
name='XML EPG',
|
||||
source_type='xmltv',
|
||||
url='http://example.com/epg.xml',
|
||||
custom_properties={'auto_apply_epg_logos': True},
|
||||
)
|
||||
self.epg = EPGData.objects.create(
|
||||
tvg_id='mapped',
|
||||
name='Mapped',
|
||||
icon_url='https://example.com/mapped.png',
|
||||
epg_source=self.source,
|
||||
)
|
||||
self.channel = Channel.objects.create(
|
||||
channel_number=1,
|
||||
name='Mapped',
|
||||
tvg_id='mapped',
|
||||
epg_data=self.epg,
|
||||
)
|
||||
|
||||
def test_runs_when_enabled(self):
|
||||
stats = maybe_auto_apply_epg_logos(self.source)
|
||||
self.assertEqual(stats['updated_count'], 1)
|
||||
self.channel.refresh_from_db()
|
||||
self.assertIsNotNone(self.channel.logo_id)
|
||||
|
||||
def test_skips_when_disabled(self):
|
||||
self.source.custom_properties = {'auto_apply_epg_logos': False}
|
||||
self.source.save(update_fields=['custom_properties'])
|
||||
|
||||
stats = maybe_auto_apply_epg_logos(self.source)
|
||||
self.assertIsNone(stats)
|
||||
self.channel.refresh_from_db()
|
||||
self.assertIsNone(self.channel.logo_id)
|
||||
|
||||
class SetLogosFromEpgApiTests(TestCase):
|
||||
def setUp(self):
|
||||
self.user = User.objects.create_user(username='testuser', password='testpass123')
|
||||
self.user.user_level = 10
|
||||
self.user.save()
|
||||
self.client = APIClient()
|
||||
self.client.force_authenticate(user=self.user)
|
||||
self.url = '/api/channels/channels/set-logos-from-epg/'
|
||||
|
||||
self.source = EPGSource.objects.create(
|
||||
name='XML EPG',
|
||||
source_type='xmltv',
|
||||
url='http://example.com/epg.xml',
|
||||
)
|
||||
self.epg = EPGData.objects.create(
|
||||
tvg_id='mapped',
|
||||
name='Mapped',
|
||||
icon_url='https://example.com/mapped.png',
|
||||
epg_source=self.source,
|
||||
)
|
||||
self.channel = Channel.objects.create(
|
||||
channel_number=1,
|
||||
name='Mapped',
|
||||
tvg_id='mapped',
|
||||
epg_data=self.epg,
|
||||
)
|
||||
|
||||
@patch('apps.channels.tasks.set_channels_logos_from_epg.delay')
|
||||
def test_accepts_channel_ids(self, mock_delay):
|
||||
mock_delay.return_value.id = 'task-1'
|
||||
|
||||
response = self.client.post(
|
||||
self.url,
|
||||
{'channel_ids': [self.channel.id]},
|
||||
format='json',
|
||||
)
|
||||
|
||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||
mock_delay.assert_called_once_with(channel_ids=[self.channel.id])
|
||||
|
||||
@patch('apps.channels.tasks.set_channels_logos_from_epg.delay')
|
||||
def test_accepts_epg_source_id(self, mock_delay):
|
||||
mock_delay.return_value.id = 'task-2'
|
||||
|
||||
response = self.client.post(
|
||||
self.url,
|
||||
{'epg_source_id': self.source.id},
|
||||
format='json',
|
||||
)
|
||||
|
||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||
mock_delay.assert_called_once_with(epg_source_id=self.source.id)
|
||||
self.assertEqual(response.data['channel_count'], 1)
|
||||
|
||||
def test_rejects_both_parameters(self):
|
||||
response = self.client.post(
|
||||
self.url,
|
||||
{
|
||||
'channel_ids': [self.channel.id],
|
||||
'epg_source_id': self.source.id,
|
||||
},
|
||||
format='json',
|
||||
)
|
||||
|
||||
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
|
||||
58
apps/channels/tests/test_epg_match_apply.py
Normal file
58
apps/channels/tests/test_epg_match_apply.py
Normal file
|
|
@ -0,0 +1,58 @@
|
|||
"""Tests for applying EPG auto-match results to channels."""
|
||||
from unittest.mock import patch
|
||||
|
||||
from django.test import TestCase
|
||||
|
||||
from apps.channels.epg_matching import apply_matched_epg_to_channels
|
||||
from apps.channels.models import Channel
|
||||
from apps.epg.models import EPGData, EPGSource
|
||||
|
||||
|
||||
class ApplyMatchedEpgToChannelsTests(TestCase):
|
||||
def setUp(self):
|
||||
self.source = EPGSource.objects.create(
|
||||
name="XML EPG",
|
||||
source_type="xmltv",
|
||||
url="http://example.com/epg.xml",
|
||||
)
|
||||
self.epg_one = EPGData.objects.create(
|
||||
tvg_id="ch.one",
|
||||
name="Channel One",
|
||||
epg_source=self.source,
|
||||
)
|
||||
self.epg_two = EPGData.objects.create(
|
||||
tvg_id="ch.two",
|
||||
name="Channel Two",
|
||||
epg_source=self.source,
|
||||
)
|
||||
self.channel = Channel.objects.create(
|
||||
channel_number=1,
|
||||
name="Channel One",
|
||||
tvg_id="ch.one",
|
||||
epg_data=self.epg_one,
|
||||
)
|
||||
|
||||
@patch("apps.epg.tasks.parse_programs_for_tvg_id.delay")
|
||||
def test_skips_unchanged_assignment(self, mock_delay):
|
||||
changed = apply_matched_epg_to_channels(
|
||||
[{"id": self.channel.id, "epg_data_id": self.epg_one.id}]
|
||||
)
|
||||
|
||||
self.assertEqual(changed, [])
|
||||
mock_delay.assert_not_called()
|
||||
self.channel.refresh_from_db()
|
||||
self.assertEqual(self.channel.epg_data_id, self.epg_one.id)
|
||||
|
||||
@patch("apps.epg.tasks.parse_programs_for_tvg_id.delay")
|
||||
def test_updates_changed_assignment_and_dispatches_parse(self, mock_delay):
|
||||
changed = apply_matched_epg_to_channels(
|
||||
[{"id": self.channel.id, "epg_data_id": self.epg_two.id}]
|
||||
)
|
||||
|
||||
self.assertEqual(
|
||||
changed,
|
||||
[{"channel_id": self.channel.id, "epg_data_id": self.epg_two.id}],
|
||||
)
|
||||
mock_delay.assert_called_once_with(self.epg_two.id)
|
||||
self.channel.refresh_from_db()
|
||||
self.assertEqual(self.channel.epg_data_id, self.epg_two.id)
|
||||
117
apps/channels/tests/test_epg_name_normalize.py
Normal file
117
apps/channels/tests/test_epg_name_normalize.py
Normal file
|
|
@ -0,0 +1,117 @@
|
|||
"""Tests for EPG channel name normalization (prefix/suffix/custom ignore rules)."""
|
||||
from django.test import TestCase
|
||||
|
||||
from apps.channels.epg_matching import (
|
||||
build_epg_tvg_id_index,
|
||||
clear_normalize_settings_cache,
|
||||
normalize_name,
|
||||
)
|
||||
from core.models import CoreSettings, EPG_SETTINGS_KEY
|
||||
|
||||
|
||||
class NormalizeNameSettingsTest(TestCase):
|
||||
def _set_epg_settings(self, **kwargs):
|
||||
obj, _ = CoreSettings.objects.get_or_create(
|
||||
key=EPG_SETTINGS_KEY,
|
||||
defaults={"name": "EPG Settings", "value": {}},
|
||||
)
|
||||
current = obj.value if isinstance(obj.value, dict) else {}
|
||||
current.update(kwargs)
|
||||
obj.value = current
|
||||
obj.save()
|
||||
clear_normalize_settings_cache()
|
||||
|
||||
def test_default_mode_does_not_apply_ignore_lists(self):
|
||||
self._set_epg_settings(
|
||||
epg_match_mode="default",
|
||||
epg_match_ignore_prefixes=["HD:"],
|
||||
epg_match_ignore_suffixes=[" 4K"],
|
||||
epg_match_ignore_custom=["Plus"],
|
||||
)
|
||||
result_default = normalize_name("HD:HBO Plus East 4K")
|
||||
|
||||
self._set_epg_settings(
|
||||
epg_match_mode="advanced",
|
||||
epg_match_ignore_prefixes=["HD:"],
|
||||
epg_match_ignore_suffixes=[" 4K"],
|
||||
epg_match_ignore_custom=["Plus"],
|
||||
)
|
||||
result_advanced = normalize_name("HD:HBO Plus East 4K")
|
||||
|
||||
self.assertNotEqual(result_default, result_advanced)
|
||||
self.assertEqual(result_advanced, "hbo east")
|
||||
|
||||
def test_advanced_mode_strips_prefix(self):
|
||||
self._set_epg_settings(
|
||||
epg_match_mode="advanced",
|
||||
epg_match_ignore_prefixes=["HD:"],
|
||||
)
|
||||
self.assertEqual(
|
||||
normalize_name("HD:ABC 7 (WXYZ) - Springfield"),
|
||||
"abc 7 springfield wxyz",
|
||||
)
|
||||
|
||||
def test_advanced_mode_strips_suffix(self):
|
||||
self._set_epg_settings(
|
||||
epg_match_mode="advanced",
|
||||
epg_match_ignore_suffixes=[" 4K"],
|
||||
)
|
||||
self.assertEqual(
|
||||
normalize_name("NBC 5 (KABC) - Metro 4K"),
|
||||
"nbc 5 metro kabc",
|
||||
)
|
||||
|
||||
def test_advanced_mode_removes_custom_strings(self):
|
||||
self._set_epg_settings(
|
||||
epg_match_mode="advanced",
|
||||
epg_match_ignore_custom=["Plus"],
|
||||
)
|
||||
self.assertEqual(
|
||||
normalize_name("HBO Plus East"),
|
||||
"hbo east",
|
||||
)
|
||||
|
||||
def test_advanced_mode_applies_prefix_suffix_and_custom_in_order(self):
|
||||
self._set_epg_settings(
|
||||
epg_match_mode="advanced",
|
||||
epg_match_ignore_prefixes=["Sling:"],
|
||||
epg_match_ignore_suffixes=[" HD"],
|
||||
epg_match_ignore_custom=["Plus"],
|
||||
)
|
||||
self.assertEqual(
|
||||
normalize_name("Sling:HBO Plus East HD"),
|
||||
"hbo east",
|
||||
)
|
||||
|
||||
def test_only_first_matching_prefix_is_removed(self):
|
||||
self._set_epg_settings(
|
||||
epg_match_mode="advanced",
|
||||
epg_match_ignore_prefixes=["HD:", "SD:"],
|
||||
)
|
||||
self.assertEqual(normalize_name("HD:SD:Channel 5"), "sd channel 5")
|
||||
|
||||
def test_call_sign_preserved_from_original_name(self):
|
||||
self._set_epg_settings(epg_match_mode="default")
|
||||
self.assertEqual(
|
||||
normalize_name("NBC 5 (KABC) - Metro"),
|
||||
"nbc 5 metro kabc",
|
||||
)
|
||||
|
||||
def test_tvg_id_index_prefers_first_entry_when_catalog_sorted_by_priority(self):
|
||||
# Catalog from build_epg_matching_catalog() is highest-priority first.
|
||||
epg_data = [
|
||||
{"id": 2, "tvg_id": "abc.us", "epg_source_priority": 50, "name": "High"},
|
||||
{"id": 1, "tvg_id": "abc.us", "epg_source_priority": 10, "name": "Low"},
|
||||
]
|
||||
index = build_epg_tvg_id_index(epg_data)
|
||||
self.assertEqual(index["abc.us"]["id"], 2)
|
||||
|
||||
def test_settings_cache_refresh_picks_up_new_rules(self):
|
||||
self._set_epg_settings(epg_match_mode="default")
|
||||
self.assertEqual(normalize_name("HD:ABC"), "hdabc")
|
||||
|
||||
self._set_epg_settings(
|
||||
epg_match_mode="advanced",
|
||||
epg_match_ignore_prefixes=["HD:"],
|
||||
)
|
||||
self.assertEqual(normalize_name("HD:ABC"), "abc")
|
||||
|
|
@ -365,46 +365,44 @@ class RecordingStatusLifecycleTests(TestCase):
|
|||
# =========================================================================
|
||||
|
||||
class ConcatFlagsTests(TestCase):
|
||||
"""Verify that the finalize phase uses error-tolerant ffmpeg flags
|
||||
when concatenating pre-restart segments."""
|
||||
"""Verify error-tolerant FFmpeg flags on the HLS segment concat command."""
|
||||
|
||||
def test_concat_command_includes_error_tolerant_flags(self):
|
||||
"""Inspect the source code to confirm error-tolerant flags are present.
|
||||
This is a static analysis test — no ffmpeg execution needed."""
|
||||
def test_hls_concat_cmd_includes_error_tolerant_flags(self):
|
||||
from apps.channels.tasks import _dvr_build_hls_concat_cmd
|
||||
|
||||
cmd = _dvr_build_hls_concat_cmd("/data/concat.txt", "/data/out.mkv")
|
||||
self.assertIn("+genpts+igndts+discardcorrupt", cmd)
|
||||
self.assertIn("-err_detect", cmd)
|
||||
self.assertEqual(cmd[cmd.index("-err_detect") + 1], "ignore_err")
|
||||
self.assertIn("-avoid_negative_ts", cmd)
|
||||
self.assertEqual(cmd[cmd.index("-avoid_negative_ts") + 1], "make_zero")
|
||||
self.assertIn("concat", cmd)
|
||||
self.assertEqual(cmd[-1], "/data/out.mkv")
|
||||
|
||||
def test_hls_concat_cmd_supports_mp4_fallback_extra_args(self):
|
||||
from apps.channels.tasks import _dvr_build_hls_concat_cmd
|
||||
|
||||
cmd = _dvr_build_hls_concat_cmd(
|
||||
"/data/concat.txt",
|
||||
"/data/intermediate.mp4",
|
||||
extra_args=["-bsf:a", "aac_adtstoasc"],
|
||||
)
|
||||
self.assertIn("aac_adtstoasc", cmd)
|
||||
self.assertEqual(cmd[-1], "/data/intermediate.mp4")
|
||||
|
||||
def test_run_recording_uses_hls_concat_helper(self):
|
||||
import inspect
|
||||
from apps.channels.tasks import run_recording
|
||||
|
||||
source = inspect.getsource(run_recording)
|
||||
self.assertIn("_dvr_build_hls_concat_cmd", source)
|
||||
|
||||
# The concat subprocess.run call must include these flags
|
||||
self.assertIn("+genpts+igndts+discardcorrupt", source,
|
||||
"Concat must use +genpts+igndts+discardcorrupt fflags")
|
||||
self.assertIn("ignore_err", source,
|
||||
"Concat must use -err_detect ignore_err")
|
||||
self.assertIn("-f", source)
|
||||
self.assertIn("concat", source)
|
||||
|
||||
def test_concat_goes_directly_to_mkv(self):
|
||||
"""Concat must produce MKV directly (not intermediate .ts) to
|
||||
preserve timestamp boundaries and avoid playback freeze at splice."""
|
||||
def test_recover_recordings_uses_hls_concat_helper(self):
|
||||
import inspect
|
||||
from apps.channels.tasks import run_recording
|
||||
source = inspect.getsource(run_recording)
|
||||
from apps.channels.tasks import recover_recordings_on_startup
|
||||
|
||||
# Must contain reset_timestamps for proper segment boundary handling
|
||||
self.assertIn("reset_timestamps", source,
|
||||
"Concat must use -reset_timestamps 1 for seamless seeking")
|
||||
# Must write directly to final_path (MKV), not an intermediate .ts
|
||||
self.assertIn("_concat_did_remux", source,
|
||||
"Concat path must set flag to skip separate remux step")
|
||||
|
||||
def test_segment_time_metadata_present(self):
|
||||
"""Verify concat uses -segment_time_metadata for boundary awareness."""
|
||||
import inspect
|
||||
from apps.channels.tasks import run_recording
|
||||
source = inspect.getsource(run_recording)
|
||||
|
||||
self.assertIn("segment_time_metadata", source,
|
||||
"Concat must use -segment_time_metadata 1 for segment boundary handling")
|
||||
source = inspect.getsource(recover_recordings_on_startup)
|
||||
self.assertIn("_dvr_build_hls_concat_cmd", source)
|
||||
|
||||
|
||||
# =========================================================================
|
||||
|
|
@ -486,6 +484,92 @@ class RecoverySkipListTests(TestCase):
|
|||
mock_run.apply_async.assert_not_called()
|
||||
|
||||
|
||||
# =========================================================================
|
||||
# 7. FFmpeg in-process retry loop
|
||||
# =========================================================================
|
||||
|
||||
class FfmpegRetryTests(TestCase):
|
||||
"""Verify FFmpeg restart logic for mid-recording crashes and stalls."""
|
||||
|
||||
def test_ffmpeg_retry_constants_and_helpers_exist(self):
|
||||
from apps.channels import tasks as dvr_tasks
|
||||
|
||||
self.assertGreater(dvr_tasks._dvr_ffmpeg_retry_window_seconds(), 0)
|
||||
self.assertEqual(dvr_tasks._dvr_count_hls_segments(None), 0)
|
||||
self.assertEqual(dvr_tasks._dvr_count_hls_segments("/nonexistent"), 0)
|
||||
self.assertEqual(dvr_tasks._dvr_ffmpeg_retry_backoff_seconds(1), 0.25)
|
||||
self.assertEqual(dvr_tasks._dvr_ffmpeg_retry_backoff_seconds(12), 3.0)
|
||||
|
||||
@patch("apps.proxy.live_proxy.config_helper.ConfigHelper.stream_timeout", return_value=60)
|
||||
@patch("apps.proxy.live_proxy.config_helper.ConfigHelper.failover_grace_period", return_value=20)
|
||||
def test_retry_window_matches_live_proxy_timeouts(self, _grace, _stream):
|
||||
from apps.channels.tasks import _dvr_ffmpeg_retry_window_seconds
|
||||
|
||||
self.assertEqual(_dvr_ffmpeg_retry_window_seconds(), 80.0)
|
||||
|
||||
def test_hls_start_number_zero_when_playlist_exists(self):
|
||||
import tempfile
|
||||
from apps.channels.tasks import _dvr_hls_start_number
|
||||
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
m3u8 = os.path.join(tmp, "index.m3u8")
|
||||
open(os.path.join(tmp, "seg_00000.ts"), "wb").write(b"\x00")
|
||||
open(os.path.join(tmp, "seg_00013.ts"), "wb").write(b"\x00")
|
||||
with open(m3u8, "w") as f:
|
||||
f.write("#EXTM3U\n#EXT-X-TARGETDURATION:4\n")
|
||||
f.write("seg_00000.ts\nseg_00013.ts\n")
|
||||
# append_list reloads playlist entries; start_number must stay 0.
|
||||
self.assertEqual(_dvr_hls_start_number(tmp, m3u8), 0)
|
||||
|
||||
def test_hls_start_number_from_max_index_without_playlist(self):
|
||||
import tempfile
|
||||
from apps.channels.tasks import _dvr_hls_start_number
|
||||
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
open(os.path.join(tmp, "seg_00000.ts"), "wb").write(b"\x00")
|
||||
open(os.path.join(tmp, "seg_00013.ts"), "wb").write(b"\x00")
|
||||
self.assertEqual(_dvr_hls_start_number(tmp, os.path.join(tmp, "index.m3u8")), 14)
|
||||
|
||||
def test_hls_start_number_zero_on_fresh_dir(self):
|
||||
import tempfile
|
||||
from apps.channels.tasks import _dvr_hls_start_number
|
||||
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
self.assertEqual(_dvr_hls_start_number(tmp, os.path.join(tmp, "index.m3u8")), 0)
|
||||
|
||||
def test_build_ffmpeg_cmd_continues_hls_numbering(self):
|
||||
from apps.channels.tasks import _dvr_build_ffmpeg_cmd
|
||||
|
||||
cmd = _dvr_build_ffmpeg_cmd(
|
||||
"http://127.0.0.1:5656/proxy/ts/stream/uuid",
|
||||
71,
|
||||
"/data/recordings/.dvr_71_hls/index.m3u8",
|
||||
"/data/recordings/.dvr_71_hls/seg_%05d.ts",
|
||||
42,
|
||||
)
|
||||
self.assertIn("-start_number", cmd)
|
||||
self.assertEqual(cmd[cmd.index("-start_number") + 1], "42")
|
||||
hls_flags = cmd[cmd.index("-hls_flags") + 1]
|
||||
self.assertIn("append_list", hls_flags)
|
||||
self.assertIn("omit_endlist", hls_flags)
|
||||
self.assertIn("-err_detect", cmd)
|
||||
self.assertEqual(cmd[cmd.index("-err_detect") + 1], "ignore_err")
|
||||
|
||||
def test_run_recording_has_retry_loop(self):
|
||||
import inspect
|
||||
from apps.channels.tasks import run_recording
|
||||
|
||||
source = inspect.getsource(run_recording)
|
||||
self.assertIn("_ffmpeg_retry_count", source)
|
||||
self.assertIn("_ffmpeg_outage_started", source)
|
||||
self.assertIn("_ffmpeg_retry_window", source)
|
||||
self.assertIn("_break_reason", source)
|
||||
self.assertIn("ffmpeg_outage_window_exhausted", source)
|
||||
self.assertIn("_dvr_build_ffmpeg_cmd", source)
|
||||
self.assertIn("_dvr_hls_start_number", source)
|
||||
self.assertIn("_ffmpeg_retry_count = 0", source)
|
||||
|
||||
|
||||
# =========================================================================
|
||||
# 6. Frontend red-dot filter (guideUtils.mapRecordingsByProgramId)
|
||||
# =========================================================================
|
||||
|
|
|
|||
|
|
@ -1,5 +1,12 @@
|
|||
import logging
|
||||
import threading
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Bound memory/DB work per chunk for large libraries (20k+ channels).
|
||||
EPG_LOGO_APPLY_BATCH_SIZE = 500
|
||||
EPG_LOGO_APPLY_MAX_ERRORS = 100
|
||||
|
||||
lock = threading.Lock()
|
||||
# Dictionary to track usage: {account_id: current_usage}
|
||||
active_streams_map = {}
|
||||
|
|
@ -35,3 +42,186 @@ def decrement_stream_count(account):
|
|||
active_streams_map[account.id] = current_usage
|
||||
account.active_streams = current_usage
|
||||
account.save(update_fields=['active_streams'])
|
||||
|
||||
|
||||
def auto_apply_epg_logos_enabled(custom_properties):
|
||||
"""Return whether channel logos should be auto-applied after EPG refresh."""
|
||||
return bool((custom_properties or {}).get('auto_apply_epg_logos', False))
|
||||
|
||||
|
||||
def _empty_logo_apply_stats():
|
||||
return {
|
||||
'updated_count': 0,
|
||||
'created_logos_count': 0,
|
||||
'error_count': 0,
|
||||
'errors': [],
|
||||
}
|
||||
|
||||
|
||||
def _merge_logo_apply_stats(accumulated, batch_stats):
|
||||
accumulated['updated_count'] += batch_stats['updated_count']
|
||||
accumulated['created_logos_count'] += batch_stats['created_logos_count']
|
||||
accumulated['error_count'] += batch_stats['error_count']
|
||||
remaining = EPG_LOGO_APPLY_MAX_ERRORS - len(accumulated['errors'])
|
||||
if remaining > 0:
|
||||
accumulated['errors'].extend(batch_stats['errors'][:remaining])
|
||||
return accumulated
|
||||
|
||||
|
||||
def apply_logos_from_epg_icon_url(channels):
|
||||
"""
|
||||
Set channel.logo from epg_data.icon_url for the given channels.
|
||||
|
||||
Expects channels to be pre-filtered with select_related('epg_data', 'logo').
|
||||
Uses bulk logo lookup/create and a single channel bulk_update for efficiency.
|
||||
"""
|
||||
from .models import Channel, Logo
|
||||
|
||||
work = []
|
||||
url_to_meta = {}
|
||||
|
||||
for channel in channels:
|
||||
if not channel.epg_data:
|
||||
continue
|
||||
icon_url = (channel.epg_data.icon_url or '').strip()
|
||||
if not icon_url:
|
||||
continue
|
||||
if channel.logo and channel.logo.url == icon_url:
|
||||
continue
|
||||
work.append((channel, icon_url))
|
||||
if icon_url not in url_to_meta:
|
||||
url_to_meta[icon_url] = (
|
||||
channel.epg_data.name,
|
||||
channel.epg_data.tvg_id,
|
||||
)
|
||||
|
||||
if not work:
|
||||
return _empty_logo_apply_stats()
|
||||
|
||||
unique_urls = list(url_to_meta.keys())
|
||||
logo_by_url = {
|
||||
logo.url: logo
|
||||
for logo in Logo.objects.filter(url__in=unique_urls)
|
||||
}
|
||||
|
||||
missing_urls = [url for url in unique_urls if url not in logo_by_url]
|
||||
created_logos_count = 0
|
||||
if missing_urls:
|
||||
logos_to_create = [
|
||||
Logo(
|
||||
name=(url_to_meta[url][0] or f"Logo for {url_to_meta[url][1]}"),
|
||||
url=url,
|
||||
)
|
||||
for url in missing_urls
|
||||
]
|
||||
created_logos_count = len(logos_to_create)
|
||||
Logo.objects.bulk_create(logos_to_create, ignore_conflicts=True)
|
||||
for logo in Logo.objects.filter(url__in=unique_urls):
|
||||
logo_by_url[logo.url] = logo
|
||||
|
||||
channels_to_update = []
|
||||
errors = []
|
||||
for channel, icon_url in work:
|
||||
logo = logo_by_url.get(icon_url)
|
||||
if not logo:
|
||||
errors.append(f"Channel {channel.id}: Logo not found for {icon_url}")
|
||||
continue
|
||||
if channel.logo_id != logo.id:
|
||||
channel.logo = logo
|
||||
channels_to_update.append(channel)
|
||||
|
||||
if channels_to_update:
|
||||
Channel.objects.bulk_update(channels_to_update, ['logo'], batch_size=500)
|
||||
|
||||
return {
|
||||
'updated_count': len(channels_to_update),
|
||||
'created_logos_count': created_logos_count,
|
||||
'error_count': len(errors),
|
||||
'errors': errors,
|
||||
}
|
||||
|
||||
|
||||
def channels_with_epg_icon_queryset(*, epg_source=None, epg_source_id=None):
|
||||
"""Channels mapped to a source that have a non-empty EPG icon URL."""
|
||||
from .models import Channel
|
||||
|
||||
qs = Channel.objects.filter(epg_data__isnull=False)
|
||||
if epg_source is not None:
|
||||
qs = qs.filter(epg_data__epg_source=epg_source)
|
||||
elif epg_source_id is not None:
|
||||
qs = qs.filter(epg_data__epg_source_id=epg_source_id)
|
||||
else:
|
||||
raise ValueError("epg_source or epg_source_id is required")
|
||||
|
||||
return qs.exclude(
|
||||
epg_data__icon_url__isnull=True,
|
||||
).exclude(
|
||||
epg_data__icon_url='',
|
||||
)
|
||||
|
||||
|
||||
def apply_logos_from_epg_queryset(channels_qs, *, batch_size=EPG_LOGO_APPLY_BATCH_SIZE):
|
||||
"""
|
||||
Apply logos for a potentially large queryset without loading every row at once.
|
||||
Streams channel IDs from the database and processes fixed-size chunks.
|
||||
"""
|
||||
from .models import Channel
|
||||
|
||||
stats = _empty_logo_apply_stats()
|
||||
batch_ids = []
|
||||
|
||||
id_stream = channels_qs.order_by('id').values_list('id', flat=True).iterator(
|
||||
chunk_size=batch_size,
|
||||
)
|
||||
for channel_id in id_stream:
|
||||
batch_ids.append(channel_id)
|
||||
if len(batch_ids) >= batch_size:
|
||||
batch = Channel.objects.filter(
|
||||
id__in=batch_ids,
|
||||
).select_related('epg_data', 'logo')
|
||||
_merge_logo_apply_stats(stats, apply_logos_from_epg_icon_url(batch))
|
||||
batch_ids = []
|
||||
|
||||
if batch_ids:
|
||||
batch = Channel.objects.filter(
|
||||
id__in=batch_ids,
|
||||
).select_related('epg_data', 'logo')
|
||||
_merge_logo_apply_stats(stats, apply_logos_from_epg_icon_url(batch))
|
||||
|
||||
return stats
|
||||
|
||||
|
||||
def apply_logos_from_epg_for_source(epg_source, *, batch_size=EPG_LOGO_APPLY_BATCH_SIZE):
|
||||
"""Apply EPG icon URLs to all channels mapped to the given EPG source."""
|
||||
channels_qs = channels_with_epg_icon_queryset(epg_source=epg_source)
|
||||
return apply_logos_from_epg_queryset(channels_qs, batch_size=batch_size)
|
||||
|
||||
|
||||
def maybe_auto_apply_epg_logos(epg_source):
|
||||
"""Auto-apply logos after refresh when enabled on the source. Non-fatal on error."""
|
||||
if not auto_apply_epg_logos_enabled(epg_source.custom_properties):
|
||||
return None
|
||||
try:
|
||||
stats = apply_logos_from_epg_for_source(epg_source)
|
||||
if stats['updated_count'] or stats['created_logos_count']:
|
||||
logger.info(
|
||||
"Auto-applied EPG logos for source %s: updated %s channels, "
|
||||
"created %s logos.",
|
||||
epg_source.name,
|
||||
stats['updated_count'],
|
||||
stats['created_logos_count'],
|
||||
)
|
||||
else:
|
||||
logger.info(
|
||||
"Auto-apply EPG logos for source %s: all matched channels already current.",
|
||||
epg_source.name,
|
||||
)
|
||||
return stats
|
||||
except Exception as logo_error:
|
||||
logger.warning(
|
||||
"EPG logo auto-apply failed for source %s (non-fatal): %s",
|
||||
epg_source.name,
|
||||
logo_error,
|
||||
exc_info=True,
|
||||
)
|
||||
return None
|
||||
|
|
|
|||
|
|
@ -1,6 +1,11 @@
|
|||
import logging, os, re
|
||||
import hashlib
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
import time
|
||||
from rest_framework import viewsets, status, serializers
|
||||
from rest_framework.pagination import PageNumberPagination
|
||||
from rest_framework.permissions import AllowAny
|
||||
from rest_framework.response import Response
|
||||
from rest_framework.views import APIView
|
||||
from rest_framework.decorators import action
|
||||
|
|
@ -18,10 +23,11 @@ from .serializers import (
|
|||
EPGDataSerializer,
|
||||
ProgramSearchResultSerializer,
|
||||
)
|
||||
from .tasks import refresh_epg_data
|
||||
from .tasks import refresh_epg_data, find_current_program_for_tvg_id
|
||||
from .query_utils import parse_text_query
|
||||
from apps.accounts.permissions import (
|
||||
Authenticated,
|
||||
IsAdmin,
|
||||
IsStandardUser,
|
||||
permission_classes_by_action,
|
||||
permission_classes_by_method,
|
||||
|
|
@ -47,6 +53,10 @@ class EPGSourceViewSet(viewsets.ModelViewSet):
|
|||
try:
|
||||
return [perm() for perm in permission_classes_by_action[self.action]]
|
||||
except KeyError:
|
||||
if self.action in ('sd_lineups', 'sd_lineups_search'):
|
||||
if self.request.method == 'GET':
|
||||
return [IsStandardUser()]
|
||||
return [IsAdmin()]
|
||||
return [Authenticated()]
|
||||
|
||||
def get_queryset(self):
|
||||
|
|
@ -54,6 +64,8 @@ class EPGSourceViewSet(viewsets.ModelViewSet):
|
|||
from apps.channels.models import Channel
|
||||
return EPGSource.objects.select_related(
|
||||
"refresh_task__crontab", "refresh_task__interval"
|
||||
).defer(
|
||||
'programme_index'
|
||||
).annotate(
|
||||
has_channels=Exists(
|
||||
Channel.objects.filter(epg_data__epg_source_id=OuterRef('pk'))
|
||||
|
|
@ -108,6 +120,337 @@ class EPGSourceViewSet(viewsets.ModelViewSet):
|
|||
return super().partial_update(request, *args, **kwargs)
|
||||
|
||||
|
||||
def _sd_authenticate(self, source):
|
||||
"""
|
||||
Authenticate with Schedules Direct using stored credentials.
|
||||
Returns (token, None) on success or (None, Response) on failure.
|
||||
"""
|
||||
import hashlib
|
||||
import requests as http_requests
|
||||
from apps.epg.tasks import SD_BASE_URL
|
||||
from version import __version__ as dispatcharr_version
|
||||
|
||||
username = (source.username or '').strip()
|
||||
password = (source.password or '').strip()
|
||||
if not username or not password:
|
||||
return None, Response(
|
||||
{"error": "Username and password are required."},
|
||||
status=status.HTTP_400_BAD_REQUEST
|
||||
)
|
||||
|
||||
sha1_password = hashlib.sha1(password.encode('utf-8')).hexdigest()
|
||||
try:
|
||||
auth_response = http_requests.post(
|
||||
f"{SD_BASE_URL}/token",
|
||||
json={'username': username, 'password': sha1_password},
|
||||
headers={'Content-Type': 'application/json', 'User-Agent': f'Dispatcharr/{dispatcharr_version}'},
|
||||
timeout=15,
|
||||
)
|
||||
auth_response.raise_for_status()
|
||||
token = auth_response.json().get('token')
|
||||
if not token:
|
||||
return None, Response(
|
||||
{"error": "Authentication failed. Check your credentials."},
|
||||
status=status.HTTP_401_UNAUTHORIZED
|
||||
)
|
||||
return token, None
|
||||
except http_requests.exceptions.RequestException as e:
|
||||
return None, Response(
|
||||
{"error": f"Authentication failed: {str(e)}"},
|
||||
status=status.HTTP_502_BAD_GATEWAY
|
||||
)
|
||||
|
||||
def _get_sd_reset_at(self, source):
|
||||
"""Retrieve stored reset timestamp from EPGSource model field."""
|
||||
reset_at_str = (source.custom_properties or {}).get('sd_changes_reset_at')
|
||||
return reset_at_str
|
||||
|
||||
def _get_sd_changes_remaining(self, source):
|
||||
"""
|
||||
Retrieve stored changesRemaining from EPGSource model field.
|
||||
If a reset timestamp exists and has passed (midnight UTC), clears the
|
||||
lockout automatically so the user can make adds again.
|
||||
"""
|
||||
from django.utils import timezone
|
||||
|
||||
cp = source.custom_properties or {}
|
||||
changes_remaining = cp.get('sd_changes_remaining')
|
||||
reset_at_str = cp.get('sd_changes_reset_at')
|
||||
from django.utils.dateparse import parse_datetime
|
||||
reset_at = parse_datetime(reset_at_str) if reset_at_str else None
|
||||
|
||||
# If we have a reset timestamp and it has passed, clear the lockout
|
||||
if changes_remaining == 0 and reset_at:
|
||||
if timezone.now() >= reset_at:
|
||||
cp = source.custom_properties or {}
|
||||
cp.pop('sd_changes_remaining', None)
|
||||
cp.pop('sd_changes_reset_at', None)
|
||||
source.custom_properties = cp
|
||||
source.save(update_fields=['custom_properties'])
|
||||
return None
|
||||
|
||||
return changes_remaining
|
||||
|
||||
def _save_sd_changes_remaining(self, source, changes_remaining):
|
||||
"""Persist changesRemaining to EPGSource model field."""
|
||||
cp = source.custom_properties or {}
|
||||
cp['sd_changes_remaining'] = changes_remaining
|
||||
source.custom_properties = cp
|
||||
source.save(update_fields=['custom_properties'])
|
||||
|
||||
def _save_sd_lockout(self, source):
|
||||
"""
|
||||
Persist a hard lockout to EPGSource custom_properties when SD returns
|
||||
4100 MAX_LINEUP_CHANGES_REACHED. SD lineup change counters reset at
|
||||
00:00Z (midnight UTC) per SD's documented behavior — error 4100 states
|
||||
"lineup changes for today" and all SD rate counters reset at midnight UTC.
|
||||
Lockout clears automatically when the next midnight UTC passes.
|
||||
"""
|
||||
from django.utils import timezone
|
||||
from datetime import datetime, timedelta, timezone as dt_timezone
|
||||
|
||||
now = timezone.now()
|
||||
# Calculate next midnight UTC — SD resets at 00:00Z not on a rolling window
|
||||
tomorrow = (now + timedelta(days=1)).replace(
|
||||
hour=0, minute=0, second=0, microsecond=0,
|
||||
tzinfo=dt_timezone.utc
|
||||
)
|
||||
reset_at = tomorrow
|
||||
|
||||
cp = source.custom_properties or {}
|
||||
cp['sd_changes_remaining'] = 0
|
||||
cp['sd_changes_reset_at'] = reset_at.isoformat()
|
||||
source.custom_properties = cp
|
||||
source.save(update_fields=['custom_properties'])
|
||||
logger.warning(
|
||||
f"SD source {source.id}: daily add limit reached (4100). "
|
||||
f"Lockout set until {reset_at.isoformat()}."
|
||||
)
|
||||
|
||||
@action(detail=True, methods=["get", "post", "delete"], url_path="sd-lineups")
|
||||
def sd_lineups(self, request, pk=None):
|
||||
"""
|
||||
GET — list lineups currently on the SD account
|
||||
POST — add a lineup (body: {"lineup": "USA-NJ29486-X"})
|
||||
DELETE — remove a lineup (body: {"lineup": "USA-NJ29486-X"})
|
||||
"""
|
||||
import requests as http_requests
|
||||
from apps.epg.tasks import SD_BASE_URL
|
||||
from version import __version__ as dispatcharr_version
|
||||
|
||||
source = self.get_object()
|
||||
if source.source_type != 'schedules_direct':
|
||||
return Response(
|
||||
{"error": "This action is only available for Schedules Direct sources."},
|
||||
status=status.HTTP_400_BAD_REQUEST
|
||||
)
|
||||
|
||||
token, error = self._sd_authenticate(source)
|
||||
if error:
|
||||
return error
|
||||
|
||||
headers = {
|
||||
'Content-Type': 'application/json',
|
||||
'User-Agent': f'Dispatcharr/{dispatcharr_version}',
|
||||
'token': token,
|
||||
}
|
||||
|
||||
if request.method == "GET":
|
||||
try:
|
||||
resp = http_requests.get(
|
||||
f"{SD_BASE_URL}/lineups",
|
||||
headers=headers,
|
||||
timeout=15,
|
||||
)
|
||||
if resp.status_code == 400:
|
||||
sd_data = resp.json()
|
||||
sd_code = sd_data.get('code')
|
||||
if sd_code == 4102:
|
||||
return Response({
|
||||
"lineups": [],
|
||||
"max_lineups": 4,
|
||||
"changes_remaining": self._get_sd_changes_remaining(source),
|
||||
"changes_reset_at": self._get_sd_reset_at(source),
|
||||
"notice": "No lineups are currently configured on this Schedules Direct account. Use the search below to add one.",
|
||||
})
|
||||
resp.raise_for_status()
|
||||
data = resp.json()
|
||||
lineups = [l for l in data.get('lineups', []) if not l.get('isDeleted', False)]
|
||||
return Response({
|
||||
"lineups": lineups,
|
||||
"max_lineups": 4,
|
||||
"changes_remaining": self._get_sd_changes_remaining(source),
|
||||
"changes_reset_at": self._get_sd_reset_at(source),
|
||||
})
|
||||
except http_requests.exceptions.RequestException as e:
|
||||
return Response(
|
||||
{"error": f"Failed to fetch lineups: {str(e)}"},
|
||||
status=status.HTTP_502_BAD_GATEWAY
|
||||
)
|
||||
|
||||
elif request.method == "POST":
|
||||
lineup_id = request.data.get('lineup')
|
||||
if not lineup_id:
|
||||
return Response({"error": "lineup field is required."}, status=status.HTTP_400_BAD_REQUEST)
|
||||
try:
|
||||
resp = http_requests.put(
|
||||
f"{SD_BASE_URL}/lineups/{lineup_id}",
|
||||
headers=headers,
|
||||
timeout=15,
|
||||
)
|
||||
sd_data = resp.json()
|
||||
sd_code = sd_data.get('code')
|
||||
|
||||
if resp.status_code == 400 or resp.status_code == 403:
|
||||
if sd_code == 4100:
|
||||
self._save_sd_lockout(source)
|
||||
return Response({
|
||||
"error": "daily_limit_reached",
|
||||
"message": "You have reached your daily Schedules Direct lineup addition limit. SD allows 6 adds per 24-hour period. Resets at midnight UTC.",
|
||||
"changes_remaining": 0,
|
||||
"docs_url": "https://github.com/SchedulesDirect/JSON-Service/wiki/API-20141201#tasks-your-client-must-perform",
|
||||
}, status=status.HTTP_200_OK)
|
||||
if sd_code == 4101:
|
||||
return Response({
|
||||
"error": "max_lineups_reached",
|
||||
"message": "Your Schedules Direct account has reached the maximum of 4 lineups. Remove one before adding another.",
|
||||
"changes_remaining": self._get_sd_changes_remaining(source),
|
||||
}, status=status.HTTP_200_OK)
|
||||
if sd_code == 2100:
|
||||
return Response({
|
||||
"error": "duplicate_lineup",
|
||||
"message": "This lineup is already on your Schedules Direct account.",
|
||||
"changes_remaining": self._get_sd_changes_remaining(source),
|
||||
}, status=status.HTTP_200_OK)
|
||||
return Response({
|
||||
"error": sd_data.get('message', 'Failed to add lineup.'),
|
||||
"changes_remaining": self._get_sd_changes_remaining(source),
|
||||
}, status=status.HTTP_200_OK)
|
||||
|
||||
resp.raise_for_status()
|
||||
|
||||
# Persist changesRemaining to custom_properties
|
||||
changes_remaining = sd_data.get('changesRemaining')
|
||||
if changes_remaining is not None:
|
||||
self._save_sd_changes_remaining(source, changes_remaining)
|
||||
|
||||
logger.info(
|
||||
f"SD lineup added for source {source.id}: {lineup_id}. "
|
||||
f"changesRemaining: {changes_remaining}"
|
||||
)
|
||||
|
||||
# Re-fetch stations so the new lineup's stations are available for matching
|
||||
from apps.epg.tasks import fetch_schedules_direct_stations
|
||||
fetch_schedules_direct_stations.delay(source.id)
|
||||
|
||||
return Response({
|
||||
**sd_data,
|
||||
"changes_remaining": changes_remaining,
|
||||
})
|
||||
except http_requests.exceptions.RequestException as e:
|
||||
return Response(
|
||||
{"error": f"Failed to add lineup: {str(e)}"},
|
||||
status=status.HTTP_502_BAD_GATEWAY
|
||||
)
|
||||
|
||||
elif request.method == "DELETE":
|
||||
lineup_id = request.data.get('lineup')
|
||||
if not lineup_id:
|
||||
return Response({"error": "lineup field is required."}, status=status.HTTP_400_BAD_REQUEST)
|
||||
try:
|
||||
resp = http_requests.delete(
|
||||
f"{SD_BASE_URL}/lineups/{lineup_id}",
|
||||
headers=headers,
|
||||
timeout=15,
|
||||
)
|
||||
if resp.status_code == 400:
|
||||
sd_data = resp.json()
|
||||
sd_code = sd_data.get('code')
|
||||
if sd_code == 2103:
|
||||
return Response({
|
||||
"response": "OK",
|
||||
"code": 0,
|
||||
"message": "Lineup not found on account — already removed.",
|
||||
"changes_remaining": self._get_sd_changes_remaining(source),
|
||||
})
|
||||
resp.raise_for_status()
|
||||
sd_data = resp.json()
|
||||
# SD returns changesRemaining on deletes — persist it
|
||||
changes_remaining = sd_data.get('changesRemaining')
|
||||
if changes_remaining is not None:
|
||||
self._save_sd_changes_remaining(source, changes_remaining)
|
||||
logger.info(f"SD lineup deleted for source {source.id}: {lineup_id}")
|
||||
return Response({
|
||||
**sd_data,
|
||||
"changes_remaining": self._get_sd_changes_remaining(source),
|
||||
})
|
||||
except http_requests.exceptions.RequestException as e:
|
||||
return Response(
|
||||
{"error": f"Failed to remove lineup: {str(e)}"},
|
||||
status=status.HTTP_502_BAD_GATEWAY
|
||||
)
|
||||
|
||||
@action(detail=True, methods=["post"], url_path="sd-lineups/search")
|
||||
def sd_lineups_search(self, request, pk=None):
|
||||
"""
|
||||
Search available headends/lineups by country and postal code.
|
||||
Body: {"country": "USA", "postalcode": "07030"}
|
||||
Returns a flat list of lineups across all matching headends.
|
||||
"""
|
||||
import requests as http_requests
|
||||
from apps.epg.tasks import SD_BASE_URL
|
||||
from version import __version__ as dispatcharr_version
|
||||
|
||||
source = self.get_object()
|
||||
if source.source_type != 'schedules_direct':
|
||||
return Response(
|
||||
{"error": "This action is only available for Schedules Direct sources."},
|
||||
status=status.HTTP_400_BAD_REQUEST
|
||||
)
|
||||
|
||||
country = request.data.get('country', '').strip()
|
||||
postalcode = request.data.get('postalcode', '').strip()
|
||||
if not country or not postalcode:
|
||||
return Response(
|
||||
{"error": "country and postalcode are required."},
|
||||
status=status.HTTP_400_BAD_REQUEST
|
||||
)
|
||||
|
||||
token, error = self._sd_authenticate(source)
|
||||
if error:
|
||||
return error
|
||||
|
||||
headers = {
|
||||
'Content-Type': 'application/json',
|
||||
'User-Agent': f'Dispatcharr/{dispatcharr_version}',
|
||||
'token': token,
|
||||
}
|
||||
|
||||
try:
|
||||
resp = http_requests.get(
|
||||
f"{SD_BASE_URL}/headends",
|
||||
params={'country': country, 'postalcode': postalcode},
|
||||
headers=headers,
|
||||
timeout=15,
|
||||
)
|
||||
resp.raise_for_status()
|
||||
headends = resp.json()
|
||||
lineups = []
|
||||
for headend in headends:
|
||||
for lineup in headend.get('lineups', []):
|
||||
lineups.append({
|
||||
'lineup': lineup.get('lineup'),
|
||||
'name': lineup.get('name'),
|
||||
'transport': headend.get('transport'),
|
||||
'location': headend.get('location'),
|
||||
'headend': headend.get('headend'),
|
||||
})
|
||||
return Response({"lineups": lineups})
|
||||
except http_requests.exceptions.RequestException as e:
|
||||
return Response(
|
||||
{"error": f"Failed to search headends: {str(e)}"},
|
||||
status=status.HTTP_502_BAD_GATEWAY
|
||||
)
|
||||
# ─────────────────────────────
|
||||
# 2) Program API (CRUD)
|
||||
# ─────────────────────────────
|
||||
|
|
@ -123,7 +466,13 @@ class ProgramViewSet(viewsets.ModelViewSet):
|
|||
queryset = ProgramData.objects.select_related("epg").all()
|
||||
serializer_class = ProgramDataSerializer
|
||||
|
||||
# Per-source in-memory caches (token and error state)
|
||||
_sd_poster_token_cache: dict = {}
|
||||
_sd_poster_error_cache: dict = {}
|
||||
|
||||
def get_permissions(self):
|
||||
if self.action == 'poster':
|
||||
return [AllowAny()]
|
||||
try:
|
||||
return [perm() for perm in permission_classes_by_action[self.action]]
|
||||
except KeyError:
|
||||
|
|
@ -139,6 +488,101 @@ class ProgramViewSet(viewsets.ModelViewSet):
|
|||
serializer = self.get_serializer(instance)
|
||||
return Response(serializer.data)
|
||||
|
||||
@action(detail=True, methods=['get'], url_path='poster', permission_classes=[AllowAny])
|
||||
def poster(self, request, pk=None):
|
||||
"""Proxy endpoint for SD program poster images. Nginx caches the response."""
|
||||
import requests as http_requests
|
||||
from apps.epg.tasks import SD_BASE_URL
|
||||
|
||||
program = self.get_object()
|
||||
poster_sd_url = (program.custom_properties or {}).get('sd_icon')
|
||||
if not poster_sd_url:
|
||||
return Response(status=status.HTTP_404_NOT_FOUND)
|
||||
|
||||
source = program.epg.epg_source if program.epg else None
|
||||
if not source or source.source_type != 'schedules_direct':
|
||||
return Response(status=status.HTTP_404_NOT_FOUND)
|
||||
|
||||
error_cache = ProgramViewSet._sd_poster_error_cache.get(source.id)
|
||||
if error_cache and time.time() < error_cache['until']:
|
||||
return Response(
|
||||
{'error': f"SD temporarily unavailable: {error_cache['reason']}"},
|
||||
status=status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||
)
|
||||
|
||||
cached = ProgramViewSet._sd_poster_token_cache.get(source.id)
|
||||
token = cached['token'] if cached and time.time() < cached['expires'] else None
|
||||
|
||||
if not token:
|
||||
sha1_password = hashlib.sha1(source.password.encode('utf-8')).hexdigest()
|
||||
try:
|
||||
from version import __version__ as dispatcharr_version
|
||||
auth_resp = http_requests.post(
|
||||
f"{SD_BASE_URL}/token",
|
||||
json={'username': source.username, 'password': sha1_password},
|
||||
headers={
|
||||
'Content-Type': 'application/json',
|
||||
'User-Agent': f'Dispatcharr/{dispatcharr_version}',
|
||||
},
|
||||
timeout=10,
|
||||
)
|
||||
auth_data = auth_resp.json()
|
||||
token = auth_data.get('token')
|
||||
if not token:
|
||||
ProgramViewSet._sd_poster_error_cache[source.id] = {
|
||||
'until': time.time() + 3600,
|
||||
'reason': auth_data.get('message', 'Authentication failed'),
|
||||
}
|
||||
return Response(status=status.HTTP_502_BAD_GATEWAY)
|
||||
token_expires = auth_data.get('tokenExpires', time.time() + 86400)
|
||||
ProgramViewSet._sd_poster_token_cache[source.id] = {
|
||||
'token': token,
|
||||
'expires': token_expires,
|
||||
}
|
||||
except http_requests.exceptions.RequestException:
|
||||
ProgramViewSet._sd_poster_error_cache[source.id] = {
|
||||
'until': time.time() + 300,
|
||||
'reason': 'Network error reaching Schedules Direct',
|
||||
}
|
||||
return Response(status=status.HTTP_502_BAD_GATEWAY)
|
||||
|
||||
try:
|
||||
img_resp = http_requests.get(
|
||||
poster_sd_url,
|
||||
headers={'token': token},
|
||||
timeout=15,
|
||||
allow_redirects=True,
|
||||
)
|
||||
if img_resp.status_code in (401, 403):
|
||||
ProgramViewSet._sd_poster_token_cache.pop(source.id, None)
|
||||
ProgramViewSet._sd_poster_error_cache[source.id] = {
|
||||
'until': time.time() + 3600,
|
||||
'reason': f'SD returned {img_resp.status_code}',
|
||||
}
|
||||
return Response(status=status.HTTP_502_BAD_GATEWAY)
|
||||
if img_resp.status_code == 400:
|
||||
try:
|
||||
err_code = img_resp.json().get('code')
|
||||
except Exception:
|
||||
err_code = None
|
||||
if err_code == 5002:
|
||||
ProgramViewSet._sd_poster_error_cache[source.id] = {
|
||||
'until': time.time() + 3600,
|
||||
'reason': 'Daily image download limit reached (SD error 5002)',
|
||||
}
|
||||
return Response(status=status.HTTP_429_TOO_MANY_REQUESTS)
|
||||
if img_resp.status_code != 200:
|
||||
return Response(status=status.HTTP_502_BAD_GATEWAY)
|
||||
|
||||
from django.http import HttpResponse
|
||||
content_type = img_resp.headers.get('Content-Type', 'image/jpeg')
|
||||
response = HttpResponse(img_resp.content, content_type=content_type)
|
||||
response['Cache-Control'] = 'public, max-age=86400'
|
||||
return response
|
||||
|
||||
except http_requests.exceptions.RequestException:
|
||||
return Response(status=status.HTTP_502_BAD_GATEWAY)
|
||||
|
||||
def list(self, request, *args, **kwargs):
|
||||
logger.debug("Listing all EPG programs.")
|
||||
return super().list(request, *args, **kwargs)
|
||||
|
|
@ -663,6 +1107,7 @@ class EPGImportAPIView(APIView):
|
|||
def post(self, request, format=None):
|
||||
logger.info("EPGImportAPIView: Received request to import EPG data.")
|
||||
epg_id = request.data.get("id", None)
|
||||
force = bool(request.data.get("force", False))
|
||||
|
||||
# Check if this is a dummy EPG source
|
||||
try:
|
||||
|
|
@ -677,7 +1122,7 @@ class EPGImportAPIView(APIView):
|
|||
except EPGSource.DoesNotExist:
|
||||
pass # Let the task handle the missing source
|
||||
|
||||
refresh_epg_data.delay(epg_id) # Trigger Celery task
|
||||
refresh_epg_data.delay(epg_id, force=force) # Trigger Celery task
|
||||
logger.info("EPGImportAPIView: Task dispatched to refresh EPG data.")
|
||||
return Response(
|
||||
{"success": True, "message": "EPG data refresh initiated."},
|
||||
|
|
@ -731,19 +1176,101 @@ class CurrentProgramsAPIView(APIView):
|
|||
allow_null=True,
|
||||
help_text="Array of channel UUIDs. If null or omitted, returns all channels with current programs.",
|
||||
),
|
||||
"epg_data_ids": serializers.ListField(
|
||||
child=serializers.IntegerField(),
|
||||
required=False,
|
||||
allow_null=True,
|
||||
help_text="Array of EPG data IDs. Can be used instead of channel_ids.",
|
||||
),
|
||||
},
|
||||
),
|
||||
responses={200: ProgramDataSerializer(many=True)},
|
||||
)
|
||||
def post(self, request, format=None):
|
||||
# Get IDs from request body
|
||||
channel_uuids = request.data.get('channel_uuids', None)
|
||||
epg_data_ids = request.data.get('epg_data_ids', None)
|
||||
|
||||
# Validate that at most one type of ID is provided
|
||||
if channel_uuids is not None and epg_data_ids is not None:
|
||||
return Response(
|
||||
{"error": "Provide either channel_uuids or epg_data_ids, not both"},
|
||||
status=status.HTTP_400_BAD_REQUEST
|
||||
)
|
||||
|
||||
# Get current time
|
||||
now = timezone.now()
|
||||
|
||||
# If epg_data_ids are provided, query directly by EPG data
|
||||
if epg_data_ids is not None:
|
||||
if not isinstance(epg_data_ids, list):
|
||||
return Response(
|
||||
{"error": "epg_data_ids must be an array of integers or null"},
|
||||
status=status.HTTP_400_BAD_REQUEST
|
||||
)
|
||||
|
||||
try:
|
||||
epg_data_ids = [int(eid) for eid in epg_data_ids]
|
||||
except (ValueError, TypeError):
|
||||
return Response(
|
||||
{"error": "epg_data_ids must contain valid integers"},
|
||||
status=status.HTTP_400_BAD_REQUEST
|
||||
)
|
||||
|
||||
# Limit to 50 IDs per request
|
||||
epg_data_ids = epg_data_ids[:50]
|
||||
|
||||
# Defer the multi-MB programme_index the JOIN would pull once per row. The lookup reads it via a targeted refresh_from_db
|
||||
epg_data_entries = EPGData.objects.select_related('epg_source').defer(
|
||||
'epg_source__programme_index'
|
||||
).filter(id__in=epg_data_ids)
|
||||
|
||||
# Batch-fetch current programs for all requested EPG entries in one query
|
||||
db_programs = ProgramData.objects.filter(
|
||||
epg__in=epg_data_entries, start_time__lte=now, end_time__gt=now
|
||||
).select_related('epg')
|
||||
# Map epg_data id -> first matching program
|
||||
programs_by_epg = {}
|
||||
for prog in db_programs:
|
||||
if prog.epg_id not in programs_by_epg:
|
||||
programs_by_epg[prog.epg_id] = prog
|
||||
|
||||
current_programs = []
|
||||
for epg_data in epg_data_entries:
|
||||
# Check batch-fetched DB results first
|
||||
program = programs_by_epg.get(epg_data.id)
|
||||
|
||||
if program:
|
||||
program_data = ProgramDataSerializer(program).data
|
||||
program_data['epg_data_id'] = epg_data.id
|
||||
current_programs.append(program_data)
|
||||
continue
|
||||
|
||||
# Skip dummy sources
|
||||
if epg_data.epg_source and epg_data.epg_source.source_type == 'dummy':
|
||||
continue
|
||||
|
||||
# Fall back to byte-offset index lookup, pass the object to avoid re-fetch
|
||||
result = find_current_program_for_tvg_id(epg_data)
|
||||
|
||||
if result == "timeout":
|
||||
current_programs.append({
|
||||
"epg_data_id": epg_data.id,
|
||||
"parsing": True,
|
||||
})
|
||||
elif result is not None:
|
||||
result['epg_data_id'] = epg_data.id
|
||||
current_programs.append(result)
|
||||
|
||||
return Response(current_programs, status=status.HTTP_200_OK)
|
||||
|
||||
# Otherwise, use channel-based query
|
||||
# Import Channel model
|
||||
from apps.channels.models import Channel
|
||||
|
||||
# Build query for channels with EPG data
|
||||
query = Channel.objects.filter(epg_data__isnull=False)
|
||||
|
||||
channel_uuids = request.data.get('channel_uuids', None)
|
||||
|
||||
if channel_uuids is not None:
|
||||
if not isinstance(channel_uuids, list):
|
||||
return Response(
|
||||
|
|
@ -755,9 +1282,6 @@ class CurrentProgramsAPIView(APIView):
|
|||
# Get channels with EPG data
|
||||
channels = query.select_related('epg_data')
|
||||
|
||||
# Get current time
|
||||
now = timezone.now()
|
||||
|
||||
# Build list of current programs
|
||||
current_programs = []
|
||||
|
||||
|
|
@ -776,4 +1300,3 @@ class CurrentProgramsAPIView(APIView):
|
|||
|
||||
|
||||
return Response(current_programs, status=status.HTTP_200_OK)
|
||||
|
||||
|
|
|
|||
21
apps/epg/migrations/0023_epgsource_programme_index.py
Normal file
21
apps/epg/migrations/0023_epgsource_programme_index.py
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('epg', '0022_alter_epgdata_name'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='epgsource',
|
||||
name='programme_index',
|
||||
field=models.JSONField(
|
||||
blank=True,
|
||||
default=None,
|
||||
help_text='Byte-offset index mapping tvg_id to file positions, built after each EPG refresh',
|
||||
null=True,
|
||||
),
|
||||
),
|
||||
]
|
||||
|
|
@ -0,0 +1,65 @@
|
|||
# Generated by Django 6.0.5 on 2026-05-31 16:48
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('epg', '0023_epgsource_programme_index'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RemoveField(
|
||||
model_name='epgsource',
|
||||
name='api_key',
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='epgsource',
|
||||
name='password',
|
||||
field=models.CharField(blank=True, help_text='Password for credential-based EPG sources (e.g. Schedules Direct)', max_length=255, null=True),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='epgsource',
|
||||
name='username',
|
||||
field=models.CharField(blank=True, help_text='Username for credential-based EPG sources (e.g. Schedules Direct)', max_length=255, null=True),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='programdata',
|
||||
name='program_id',
|
||||
field=models.CharField(blank=True, help_text='Schedules Direct programID (e.g. EP123456789). Null for XMLTV sources.', max_length=64, null=True),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='epgsource',
|
||||
name='custom_properties',
|
||||
field=models.JSONField(blank=True, default=dict, help_text='Custom properties for source-specific configuration', null=True),
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='SDProgramMD5',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('program_id', models.CharField(help_text='Schedules Direct programID (e.g. EP123456789)', max_length=64)),
|
||||
('md5', models.CharField(help_text='MD5 hash of the program metadata from Schedules Direct', max_length=22)),
|
||||
('epg_source', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='sd_program_md5s', to='epg.epgsource')),
|
||||
],
|
||||
options={
|
||||
'unique_together': {('epg_source', 'program_id')},
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='SDScheduleMD5',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('station_id', models.CharField(help_text='Schedules Direct stationID', max_length=20)),
|
||||
('date', models.DateField(help_text='Schedule date (UTC)')),
|
||||
('md5', models.CharField(help_text='MD5 hash of the schedule for this station/date from Schedules Direct', max_length=22)),
|
||||
('last_modified', models.DateTimeField(help_text='Last modified timestamp from Schedules Direct')),
|
||||
('epg_source', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='sd_schedule_md5s', to='epg.epgsource')),
|
||||
],
|
||||
options={
|
||||
'indexes': [models.Index(fields=['epg_source', 'station_id'], name='epg_sdsched_epg_sou_0d700e_idx')],
|
||||
'unique_together': {('epg_source', 'station_id', 'date')},
|
||||
},
|
||||
),
|
||||
]
|
||||
|
|
@ -30,7 +30,10 @@ class EPGSource(models.Model):
|
|||
name = models.CharField(max_length=255, unique=True)
|
||||
source_type = models.CharField(max_length=20, choices=SOURCE_TYPE_CHOICES)
|
||||
url = models.URLField(max_length=1000, blank=True, null=True) # For XMLTV
|
||||
api_key = models.CharField(max_length=255, blank=True, null=True) # For Schedules Direct
|
||||
username = models.CharField(max_length=255, blank=True, null=True,
|
||||
help_text='Username for credential-based EPG sources (e.g. Schedules Direct)')
|
||||
password = models.CharField(max_length=255, blank=True, null=True,
|
||||
help_text='Password for credential-based EPG sources (e.g. Schedules Direct)')
|
||||
is_active = models.BooleanField(default=True)
|
||||
file_path = models.CharField(max_length=1024, blank=True, null=True)
|
||||
extracted_file_path = models.CharField(max_length=1024, blank=True, null=True,
|
||||
|
|
@ -43,7 +46,7 @@ class EPGSource(models.Model):
|
|||
default=dict,
|
||||
blank=True,
|
||||
null=True,
|
||||
help_text="Custom properties for dummy EPG configuration (regex patterns, timezone, duration, etc.)"
|
||||
help_text="Custom properties for source-specific configuration"
|
||||
)
|
||||
priority = models.PositiveIntegerField(
|
||||
default=0,
|
||||
|
|
@ -59,6 +62,12 @@ class EPGSource(models.Model):
|
|||
blank=True,
|
||||
help_text="Last status message, including success results or error information"
|
||||
)
|
||||
programme_index = models.JSONField(
|
||||
null=True,
|
||||
blank=True,
|
||||
default=None,
|
||||
help_text="Byte-offset index mapping tvg_id to file positions, built after each EPG refresh"
|
||||
)
|
||||
created_at = models.DateTimeField(
|
||||
auto_now_add=True,
|
||||
help_text="Time when this source was created"
|
||||
|
|
@ -74,18 +83,13 @@ class EPGSource(models.Model):
|
|||
def get_cache_file(self):
|
||||
import mimetypes
|
||||
|
||||
# Use a temporary extension for initial download
|
||||
# The actual extension will be determined after content inspection
|
||||
file_ext = ".tmp"
|
||||
|
||||
# If file_path is already set and contains an extension, use that
|
||||
# This handles cases where we've already detected the proper type
|
||||
if self.file_path and os.path.exists(self.file_path):
|
||||
_, existing_ext = os.path.splitext(self.file_path)
|
||||
if existing_ext:
|
||||
file_ext = existing_ext
|
||||
else:
|
||||
# Try to detect the MIME type and map to extension
|
||||
mime_type, _ = mimetypes.guess_type(self.file_path)
|
||||
if mime_type:
|
||||
if mime_type == 'application/gzip' or mime_type == 'application/x-gzip':
|
||||
|
|
@ -94,48 +98,33 @@ class EPGSource(models.Model):
|
|||
file_ext = '.zip'
|
||||
elif mime_type == 'application/xml' or mime_type == 'text/xml':
|
||||
file_ext = '.xml'
|
||||
# For files without mime type detection, try peeking at content
|
||||
else:
|
||||
try:
|
||||
with open(self.file_path, 'rb') as f:
|
||||
header = f.read(4)
|
||||
# Check for gzip magic number (1f 8b)
|
||||
if header[:2] == b'\x1f\x8b':
|
||||
file_ext = '.gz'
|
||||
# Check for zip magic number (PK..)
|
||||
elif header[:2] == b'PK':
|
||||
file_ext = '.zip'
|
||||
# Check for XML
|
||||
elif header[:5] == b'<?xml' or header[:5] == b'<tv>':
|
||||
file_ext = '.xml'
|
||||
except Exception as e:
|
||||
# If we can't read the file, just keep the default extension
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
filename = f"{self.id}{file_ext}"
|
||||
|
||||
# Build full path in MEDIA_ROOT/cached_epg
|
||||
cache_dir = os.path.join(settings.MEDIA_ROOT, "cached_epg")
|
||||
|
||||
# Create directory if it doesn't exist
|
||||
os.makedirs(cache_dir, exist_ok=True)
|
||||
|
||||
cache = os.path.join(cache_dir, filename)
|
||||
|
||||
return cache
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
# Prevent auto_now behavior by handling updated_at manually
|
||||
if 'update_fields' in kwargs and 'updated_at' not in kwargs['update_fields']:
|
||||
# Don't modify updated_at for regular updates
|
||||
kwargs.setdefault('update_fields', [])
|
||||
if 'updated_at' in kwargs['update_fields']:
|
||||
kwargs['update_fields'].remove('updated_at')
|
||||
super().save(*args, **kwargs)
|
||||
|
||||
class EPGData(models.Model):
|
||||
# Removed the Channel foreign key. We now just store the original tvg_id
|
||||
# and a name (which might simply be the tvg_id if no real channel exists).
|
||||
tvg_id = models.CharField(max_length=255, null=True, blank=True, db_index=True)
|
||||
name = models.CharField(max_length=512)
|
||||
icon_url = models.URLField(max_length=500, null=True, blank=True)
|
||||
|
|
@ -154,7 +143,6 @@ class EPGData(models.Model):
|
|||
return f"EPG Data for {self.name}"
|
||||
|
||||
class ProgramData(models.Model):
|
||||
# Each programme is associated with an EPGData record.
|
||||
epg = models.ForeignKey(EPGData, on_delete=models.CASCADE, related_name="programs")
|
||||
start_time = models.DateTimeField()
|
||||
end_time = models.DateTimeField()
|
||||
|
|
@ -162,7 +150,71 @@ class ProgramData(models.Model):
|
|||
sub_title = models.TextField(blank=True, null=True)
|
||||
description = models.TextField(blank=True, null=True)
|
||||
tvg_id = models.CharField(max_length=255, null=True, blank=True)
|
||||
program_id = models.CharField(max_length=64, null=True, blank=True, help_text='Schedules Direct programID (e.g. EP123456789). Null for XMLTV sources.')
|
||||
custom_properties = models.JSONField(default=dict, blank=True, null=True)
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.title} ({self.start_time} - {self.end_time})"
|
||||
|
||||
class SDScheduleMD5(models.Model):
|
||||
"""
|
||||
Caches per-station per-date MD5 hashes from Schedules Direct.
|
||||
Used to detect schedule changes and avoid unnecessary re-downloads,
|
||||
minimizing API calls against SD's rate-limited endpoints.
|
||||
"""
|
||||
epg_source = models.ForeignKey(
|
||||
EPGSource,
|
||||
on_delete=models.CASCADE,
|
||||
related_name="sd_schedule_md5s",
|
||||
)
|
||||
station_id = models.CharField(
|
||||
max_length=20,
|
||||
help_text="Schedules Direct stationID"
|
||||
)
|
||||
date = models.DateField(
|
||||
help_text="Schedule date (UTC)"
|
||||
)
|
||||
md5 = models.CharField(
|
||||
max_length=22,
|
||||
help_text="MD5 hash of the schedule for this station/date from Schedules Direct"
|
||||
)
|
||||
last_modified = models.DateTimeField(
|
||||
help_text="Last modified timestamp from Schedules Direct"
|
||||
)
|
||||
|
||||
class Meta:
|
||||
unique_together = ('epg_source', 'station_id', 'date')
|
||||
indexes = [
|
||||
models.Index(fields=['epg_source', 'station_id']),
|
||||
]
|
||||
|
||||
def __str__(self):
|
||||
return f"SDScheduleMD5: {self.station_id} / {self.date} ({self.epg_source.name})"
|
||||
|
||||
|
||||
class SDProgramMD5(models.Model):
|
||||
"""
|
||||
Caches per-program MD5 hashes from Schedules Direct.
|
||||
Keyed by epg_source + program_id (SD's programID e.g. EP123456789).
|
||||
Used for program-level delta detection to avoid re-downloading unchanged
|
||||
program metadata, minimizing API calls against SD's rate-limited endpoints.
|
||||
"""
|
||||
epg_source = models.ForeignKey(
|
||||
EPGSource,
|
||||
on_delete=models.CASCADE,
|
||||
related_name="sd_program_md5s",
|
||||
)
|
||||
program_id = models.CharField(
|
||||
max_length=64,
|
||||
help_text="Schedules Direct programID (e.g. EP123456789)"
|
||||
)
|
||||
md5 = models.CharField(
|
||||
max_length=22,
|
||||
help_text="MD5 hash of the program metadata from Schedules Direct"
|
||||
)
|
||||
|
||||
class Meta:
|
||||
unique_together = ('epg_source', 'program_id')
|
||||
|
||||
def __str__(self):
|
||||
return f"SDProgramMD5: {self.program_id} ({self.epg_source.name})"
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
from core.utils import validate_flexible_url
|
||||
from core.utils import validate_flexible_url, build_absolute_uri_with_port
|
||||
from rest_framework import serializers
|
||||
from .models import EPGSource, EPGData, ProgramData
|
||||
from apps.channels.models import Channel, Stream
|
||||
|
|
@ -22,7 +22,8 @@ class EPGSourceSerializer(serializers.ModelSerializer):
|
|||
'name',
|
||||
'source_type',
|
||||
'url',
|
||||
'api_key',
|
||||
'username',
|
||||
'password',
|
||||
'is_active',
|
||||
'file_path',
|
||||
'refresh_interval',
|
||||
|
|
@ -36,6 +37,7 @@ class EPGSourceSerializer(serializers.ModelSerializer):
|
|||
'epg_data_count',
|
||||
'has_channels',
|
||||
]
|
||||
extra_kwargs = {'password': {'write_only': True}}
|
||||
|
||||
def get_epg_data_count(self, obj):
|
||||
"""Return the count of EPG data entries instead of all IDs to prevent large payloads"""
|
||||
|
|
@ -66,6 +68,8 @@ class EPGSourceSerializer(serializers.ModelSerializer):
|
|||
cron_expr = f'{ct.minute} {ct.hour} {ct.day_of_month} {ct.month_of_year} {ct.day_of_week}'
|
||||
instance._cron_expression = cron_expr
|
||||
for attr, value in validated_data.items():
|
||||
if attr == 'password' and not value:
|
||||
continue
|
||||
setattr(instance, attr, value)
|
||||
instance.save()
|
||||
return instance
|
||||
|
|
@ -142,6 +146,20 @@ class ProgramDetailSerializer(ProgramDataSerializer):
|
|||
previously_shown = cp.get('previously_shown_details') or {}
|
||||
data['original_air_date'] = previously_shown.get('start')
|
||||
|
||||
# Content advisory (SD)
|
||||
data['content_advisory'] = cp.get('content_advisory') or []
|
||||
|
||||
# Full content ratings array (SD — all regional ratings)
|
||||
data['content_ratings'] = cp.get('content_ratings') or []
|
||||
|
||||
# Sports event details (SD)
|
||||
data['event_details'] = cp.get('event_details')
|
||||
|
||||
# Runtime (duration without commercials)
|
||||
length = cp.get('length') or {}
|
||||
data['runtime'] = length.get('value') if length else None
|
||||
data['runtime_units'] = length.get('units') if length else None
|
||||
|
||||
# External IDs
|
||||
data['imdb_id'] = cp.get('imdb.com_id')
|
||||
data['tmdb_id'] = cp.get('themoviedb.org_id')
|
||||
|
|
@ -151,6 +169,17 @@ class ProgramDetailSerializer(ProgramDataSerializer):
|
|||
data['icon'] = cp.get('icon')
|
||||
data['images'] = cp.get('images') or []
|
||||
|
||||
# SD poster: expose as absolute proxy URL so frontend/img tags never need SD auth
|
||||
if cp.get('sd_icon'):
|
||||
poster_path = f"/api/epg/programs/{obj.id}/poster/"
|
||||
request = self.context.get('request')
|
||||
if request:
|
||||
data['poster_url'] = build_absolute_uri_with_port(request, poster_path)
|
||||
else:
|
||||
data['poster_url'] = poster_path
|
||||
else:
|
||||
data['poster_url'] = None
|
||||
|
||||
return data
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
from django.db.models.signals import post_save, post_delete, pre_save
|
||||
from django.dispatch import receiver
|
||||
from .models import EPGSource, EPGData
|
||||
from .tasks import refresh_epg_data, delete_epg_refresh_task_by_id
|
||||
from .tasks import refresh_epg_data, delete_epg_refresh_task_by_id, fetch_schedules_direct_stations
|
||||
from core.scheduling import create_or_update_periodic_task, delete_periodic_task
|
||||
from core.utils import is_protected_path, send_websocket_update
|
||||
import json
|
||||
|
|
@ -12,9 +12,16 @@ logger = logging.getLogger(__name__)
|
|||
|
||||
@receiver(post_save, sender=EPGSource)
|
||||
def trigger_refresh_on_new_epg_source(sender, instance, created, **kwargs):
|
||||
# Trigger refresh only if the source is newly created, active, and not a dummy EPG
|
||||
# Trigger refresh only if the source is newly created, active, and not a dummy EPG.
|
||||
# For Schedules Direct sources, run a stations-only fetch on creation so the user
|
||||
# can run Auto-match EPG before committing to a full schedule/program fetch.
|
||||
# A short countdown gives the frontend time to receive the API response and
|
||||
# populate the store before WebSocket updates arrive.
|
||||
if created and instance.is_active and instance.source_type != 'dummy':
|
||||
refresh_epg_data.delay(instance.id)
|
||||
if instance.source_type == 'schedules_direct':
|
||||
fetch_schedules_direct_stations.apply_async((instance.id,), countdown=3)
|
||||
else:
|
||||
refresh_epg_data.delay(instance.id)
|
||||
|
||||
@receiver(post_save, sender=EPGSource)
|
||||
def create_dummy_epg_data(sender, instance, created, **kwargs):
|
||||
|
|
|
|||
1870
apps/epg/tasks.py
1870
apps/epg/tasks.py
File diff suppressed because it is too large
Load diff
29
apps/epg/tests/fixtures/test_epg.xml
vendored
Normal file
29
apps/epg/tests/fixtures/test_epg.xml
vendored
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<tv generator-info-name="test">
|
||||
<channel id="channel.current">
|
||||
<display-name>Current Channel</display-name>
|
||||
</channel>
|
||||
<channel id="channel.past">
|
||||
<display-name>Past Channel</display-name>
|
||||
</channel>
|
||||
<channel id="channel.empty">
|
||||
<display-name>Empty Channel</display-name>
|
||||
</channel>
|
||||
<programme start="20200101000000 +0000" stop="20200101060000 +0000" channel="channel.past">
|
||||
<title>Past Show</title>
|
||||
<desc>A show that already ended</desc>
|
||||
</programme>
|
||||
<programme start="20200101060000 +0000" stop="20200101120000 +0000" channel="channel.past">
|
||||
<title>Another Past Show</title>
|
||||
<desc>Another show that ended</desc>
|
||||
</programme>
|
||||
<programme start="20000101000000 +0000" stop="20991231235959 +0000" channel="channel.current">
|
||||
<title>Always On Show</title>
|
||||
<sub-title>The eternal broadcast</sub-title>
|
||||
<desc>This programme spans a very long time for testing</desc>
|
||||
</programme>
|
||||
<programme start="20200101000000 +0000" stop="20200101060000 +0000" channel="channel.current">
|
||||
<title>Old Current Show</title>
|
||||
<desc>An old show on current channel</desc>
|
||||
</programme>
|
||||
</tv>
|
||||
194
apps/epg/tests/test_current_programs_api.py
Normal file
194
apps/epg/tests/test_current_programs_api.py
Normal file
|
|
@ -0,0 +1,194 @@
|
|||
from unittest.mock import patch
|
||||
|
||||
from django.test import TestCase
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.utils import timezone
|
||||
from rest_framework.test import APIClient
|
||||
from rest_framework import status
|
||||
|
||||
from apps.epg.models import EPGSource, EPGData, ProgramData
|
||||
|
||||
User = get_user_model()
|
||||
|
||||
CURRENT_PROGRAMS_URL = "/api/epg/current-programs/"
|
||||
|
||||
|
||||
class CurrentProgramsAPITests(TestCase):
|
||||
def setUp(self):
|
||||
self.user = User.objects.create_user(
|
||||
username="testuser", password="testpass123"
|
||||
)
|
||||
self.user.user_level = 10
|
||||
self.user.save()
|
||||
self.client = APIClient()
|
||||
self.client.force_authenticate(user=self.user)
|
||||
|
||||
self.now = timezone.now()
|
||||
|
||||
# Create an XMLTV source with programmes
|
||||
self.source = EPGSource.objects.create(
|
||||
name="Test XMLTV",
|
||||
source_type="xmltv",
|
||||
url="http://example.com/epg.xml",
|
||||
)
|
||||
self.epg_data = EPGData.objects.create(
|
||||
tvg_id="test.channel",
|
||||
name="Test Channel",
|
||||
epg_source=self.source,
|
||||
)
|
||||
self.program = ProgramData.objects.create(
|
||||
epg=self.epg_data,
|
||||
start_time=self.now - timezone.timedelta(hours=1),
|
||||
end_time=self.now + timezone.timedelta(hours=1),
|
||||
title="Current Show",
|
||||
description="A show currently airing",
|
||||
tvg_id="test.channel",
|
||||
)
|
||||
|
||||
# Dummy EPG source
|
||||
self.dummy_source = EPGSource.objects.create(
|
||||
name="Dummy EPG",
|
||||
source_type="dummy",
|
||||
)
|
||||
self.dummy_epg = EPGData.objects.create(
|
||||
tvg_id="dummy.channel",
|
||||
name="Dummy Channel",
|
||||
epg_source=self.dummy_source,
|
||||
)
|
||||
|
||||
def test_returns_program_for_current_time_window(self):
|
||||
response = self.client.post(
|
||||
CURRENT_PROGRAMS_URL,
|
||||
{"epg_data_ids": [self.epg_data.id]},
|
||||
format="json",
|
||||
)
|
||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||
self.assertEqual(len(response.data), 1)
|
||||
self.assertEqual(response.data[0]["title"], "Current Show")
|
||||
self.assertEqual(response.data[0]["epg_data_id"], self.epg_data.id)
|
||||
|
||||
def test_program_payload_has_expected_fields(self):
|
||||
response = self.client.post(
|
||||
CURRENT_PROGRAMS_URL,
|
||||
{"epg_data_ids": [self.epg_data.id]},
|
||||
format="json",
|
||||
)
|
||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||
self.assertEqual(len(response.data), 1)
|
||||
|
||||
payload = response.data[0]
|
||||
expected_keys = {
|
||||
"id",
|
||||
"start_time",
|
||||
"end_time",
|
||||
"title",
|
||||
"description",
|
||||
"sub_title",
|
||||
"tvg_id",
|
||||
"epg_data_id",
|
||||
}
|
||||
self.assertTrue(expected_keys.issubset(set(payload.keys())))
|
||||
self.assertEqual(payload["epg_data_id"], self.epg_data.id)
|
||||
|
||||
@patch("apps.epg.api_views.find_current_program_for_tvg_id", return_value=None)
|
||||
def test_returns_empty_when_no_program_matches(self, mock_find):
|
||||
# Create EPG data with no DB programme and fallback returns None
|
||||
epg_no_prog = EPGData.objects.create(
|
||||
tvg_id="no.programme",
|
||||
name="No Programme Channel",
|
||||
epg_source=self.source,
|
||||
)
|
||||
response = self.client.post(
|
||||
CURRENT_PROGRAMS_URL,
|
||||
{"epg_data_ids": [epg_no_prog.id]},
|
||||
format="json",
|
||||
)
|
||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||
self.assertEqual(len(response.data), 0)
|
||||
|
||||
@patch(
|
||||
"apps.epg.api_views.find_current_program_for_tvg_id",
|
||||
return_value="timeout",
|
||||
)
|
||||
def test_returns_parsing_sentinel_on_timeout(self, mock_find):
|
||||
epg_no_prog = EPGData.objects.create(
|
||||
tvg_id="timeout.channel",
|
||||
name="Timeout Channel",
|
||||
epg_source=self.source,
|
||||
)
|
||||
response = self.client.post(
|
||||
CURRENT_PROGRAMS_URL,
|
||||
{"epg_data_ids": [epg_no_prog.id]},
|
||||
format="json",
|
||||
)
|
||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||
self.assertEqual(len(response.data), 1)
|
||||
self.assertTrue(response.data[0]["parsing"])
|
||||
self.assertEqual(response.data[0]["epg_data_id"], epg_no_prog.id)
|
||||
|
||||
def test_400_when_both_channel_uuids_and_epg_data_ids(self):
|
||||
response = self.client.post(
|
||||
CURRENT_PROGRAMS_URL,
|
||||
{"channel_uuids": ["abc"], "epg_data_ids": [1]},
|
||||
format="json",
|
||||
)
|
||||
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
|
||||
self.assertIn("not both", response.data["error"])
|
||||
|
||||
def test_skips_dummy_epg_sources(self):
|
||||
response = self.client.post(
|
||||
CURRENT_PROGRAMS_URL,
|
||||
{"epg_data_ids": [self.dummy_epg.id]},
|
||||
format="json",
|
||||
)
|
||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||
self.assertEqual(len(response.data), 0)
|
||||
|
||||
def test_enforces_50_id_limit(self):
|
||||
# Create 55 EPG entries, each with a current programme so DB lookup
|
||||
# handles them all (no fallback to find_current_program_for_tvg_id).
|
||||
ids = []
|
||||
for i in range(55):
|
||||
epg = EPGData.objects.create(
|
||||
tvg_id=f"limit.{i}",
|
||||
name=f"Limit Channel {i}",
|
||||
epg_source=self.source,
|
||||
)
|
||||
ProgramData.objects.create(
|
||||
epg=epg,
|
||||
start_time=self.now - timezone.timedelta(hours=1),
|
||||
end_time=self.now + timezone.timedelta(hours=1),
|
||||
title=f"Show {i}",
|
||||
tvg_id=f"limit.{i}",
|
||||
)
|
||||
ids.append(epg.id)
|
||||
|
||||
response = self.client.post(
|
||||
CURRENT_PROGRAMS_URL,
|
||||
{"epg_data_ids": ids},
|
||||
format="json",
|
||||
)
|
||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||
# The view truncates to 50 IDs, so at most 50 results
|
||||
self.assertLessEqual(len(response.data), 50)
|
||||
|
||||
def test_400_for_non_integer_epg_data_ids(self):
|
||||
response = self.client.post(
|
||||
CURRENT_PROGRAMS_URL,
|
||||
{"epg_data_ids": ["abc", "def"]},
|
||||
format="json",
|
||||
)
|
||||
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
|
||||
self.assertIn("valid integers", response.data["error"])
|
||||
|
||||
def test_auth_required(self):
|
||||
anon_client = APIClient()
|
||||
response = anon_client.post(
|
||||
CURRENT_PROGRAMS_URL,
|
||||
{"epg_data_ids": [self.epg_data.id]},
|
||||
format="json",
|
||||
)
|
||||
self.assertIn(
|
||||
response.status_code,
|
||||
[status.HTTP_401_UNAUTHORIZED, status.HTTP_403_FORBIDDEN],
|
||||
)
|
||||
754
apps/epg/tests/test_programme_index.py
Normal file
754
apps/epg/tests/test_programme_index.py
Normal file
|
|
@ -0,0 +1,754 @@
|
|||
import os
|
||||
import gzip
|
||||
import tempfile
|
||||
from unittest.mock import patch, MagicMock
|
||||
|
||||
from django.test import TestCase
|
||||
from django.utils import timezone
|
||||
|
||||
from apps.epg.models import EPGSource, EPGData
|
||||
from apps.epg.tasks import (
|
||||
find_current_program_for_tvg_id,
|
||||
build_programme_index,
|
||||
build_programme_index_task,
|
||||
)
|
||||
|
||||
FIXTURE_DIR = os.path.join(os.path.dirname(__file__), "fixtures")
|
||||
FIXTURE_XML = os.path.join(FIXTURE_DIR, "test_epg.xml")
|
||||
|
||||
|
||||
class FindCurrentProgramTests(TestCase):
|
||||
def setUp(self):
|
||||
self.now = timezone.now()
|
||||
self.source = EPGSource.objects.create(
|
||||
name="Test Source",
|
||||
source_type="xmltv",
|
||||
url="http://example.com/epg.xml",
|
||||
file_path=FIXTURE_XML,
|
||||
)
|
||||
self.epg = EPGData.objects.create(
|
||||
tvg_id="channel.current",
|
||||
name="Current Channel",
|
||||
epg_source=self.source,
|
||||
)
|
||||
|
||||
def test_returns_none_for_dummy_source(self):
|
||||
dummy = EPGSource.objects.create(name="Dummy", source_type="dummy")
|
||||
epg = EPGData.objects.create(
|
||||
tvg_id="x", name="X", epg_source=dummy
|
||||
)
|
||||
self.assertIsNone(find_current_program_for_tvg_id(epg))
|
||||
|
||||
def test_returns_none_for_schedules_direct_source(self):
|
||||
sd = EPGSource.objects.create(
|
||||
name="SD", source_type="schedules_direct"
|
||||
)
|
||||
epg = EPGData.objects.create(
|
||||
tvg_id="x", name="X", epg_source=sd
|
||||
)
|
||||
self.assertIsNone(find_current_program_for_tvg_id(epg))
|
||||
|
||||
def test_returns_none_when_tvg_id_empty(self):
|
||||
epg = EPGData.objects.create(
|
||||
tvg_id="", name="Empty", epg_source=self.source
|
||||
)
|
||||
self.assertIsNone(find_current_program_for_tvg_id(epg))
|
||||
|
||||
def test_returns_none_when_tvg_id_none(self):
|
||||
epg = EPGData.objects.create(
|
||||
tvg_id=None, name="None", epg_source=self.source
|
||||
)
|
||||
self.assertIsNone(find_current_program_for_tvg_id(epg))
|
||||
|
||||
def test_byte_offset_index_hit(self):
|
||||
# Build the index from the fixture
|
||||
build_programme_index(self.source.id)
|
||||
self.source.refresh_from_db()
|
||||
self.assertIsNotNone(self.source.programme_index)
|
||||
|
||||
# "Always On Show" spans 2000-2099, so should always be current
|
||||
result = find_current_program_for_tvg_id(self.epg)
|
||||
self.assertIsNotNone(result)
|
||||
self.assertEqual(result["title"], "Always On Show")
|
||||
self.assertEqual(result["sub_title"], "The eternal broadcast")
|
||||
self.assertEqual(
|
||||
result["description"],
|
||||
"This programme spans a very long time for testing",
|
||||
)
|
||||
self.assertIn("start_time", result)
|
||||
self.assertIn("end_time", result)
|
||||
|
||||
def test_byte_offset_index_miss(self):
|
||||
# Build index, then query for a tvg_id that exists in the index
|
||||
# but has no programme airing now
|
||||
build_programme_index(self.source.id)
|
||||
self.source.refresh_from_db()
|
||||
|
||||
epg_past = EPGData.objects.create(
|
||||
tvg_id="channel.past",
|
||||
name="Past Channel",
|
||||
epg_source=self.source,
|
||||
)
|
||||
result = find_current_program_for_tvg_id(epg_past)
|
||||
self.assertIsNone(result)
|
||||
|
||||
def test_index_miss_tvg_id_not_in_index(self):
|
||||
# tvg_id not in index at all
|
||||
build_programme_index(self.source.id)
|
||||
self.source.refresh_from_db()
|
||||
|
||||
epg_unknown = EPGData.objects.create(
|
||||
tvg_id="channel.nonexistent",
|
||||
name="Nonexistent",
|
||||
epg_source=self.source,
|
||||
)
|
||||
result = find_current_program_for_tvg_id(epg_unknown)
|
||||
self.assertIsNone(result)
|
||||
|
||||
def test_accepts_integer_id(self):
|
||||
# find_current_program_for_tvg_id accepts an int (EPGData PK)
|
||||
build_programme_index(self.source.id)
|
||||
result = find_current_program_for_tvg_id(self.epg.id)
|
||||
self.assertIsNotNone(result)
|
||||
self.assertEqual(result["title"], "Always On Show")
|
||||
|
||||
def test_returns_none_for_nonexistent_id(self):
|
||||
result = find_current_program_for_tvg_id(99999)
|
||||
self.assertIsNone(result)
|
||||
|
||||
def test_multi_block_file(self):
|
||||
# Create an XML where programmes for the same channel appear in
|
||||
# multiple non-contiguous blocks (A, B, A, B pattern).
|
||||
# The index records multiple offsets per channel so the lookup
|
||||
# scans all blocks.
|
||||
xml = (
|
||||
'<?xml version="1.0" encoding="UTF-8"?>\n'
|
||||
"<tv>\n"
|
||||
' <channel id="A"/>\n'
|
||||
' <channel id="B"/>\n'
|
||||
' <programme start="20000101000000 +0000" stop="20000101060000 +0000" channel="A">\n'
|
||||
" <title>A Morning</title>\n"
|
||||
" </programme>\n"
|
||||
' <programme start="20000101000000 +0000" stop="20000101060000 +0000" channel="B">\n'
|
||||
" <title>B Morning</title>\n"
|
||||
" </programme>\n"
|
||||
# Second block for A — current programme lives here
|
||||
' <programme start="20000101060000 +0000" stop="20991231235959 +0000" channel="A">\n'
|
||||
" <title>A Current</title>\n"
|
||||
" </programme>\n"
|
||||
' <programme start="20000101060000 +0000" stop="20991231235959 +0000" channel="B">\n'
|
||||
" <title>B Current</title>\n"
|
||||
" </programme>\n"
|
||||
"</tv>\n"
|
||||
)
|
||||
with tempfile.NamedTemporaryFile(
|
||||
mode="w", suffix=".xml", delete=False
|
||||
) as f:
|
||||
f.write(xml)
|
||||
tmp_path = f.name
|
||||
|
||||
try:
|
||||
src = EPGSource.objects.create(
|
||||
name="MultiBlock",
|
||||
source_type="xmltv",
|
||||
file_path=tmp_path,
|
||||
)
|
||||
build_programme_index(src.id)
|
||||
src.refresh_from_db()
|
||||
self.assertIsNotNone(src.programme_index)
|
||||
|
||||
epg_a = EPGData.objects.create(
|
||||
tvg_id="A", name="A", epg_source=src
|
||||
)
|
||||
result = find_current_program_for_tvg_id(epg_a)
|
||||
self.assertIsNotNone(result)
|
||||
self.assertEqual(result["title"], "A Current")
|
||||
finally:
|
||||
os.unlink(tmp_path)
|
||||
|
||||
def test_channel_id_entities_and_whitespace_match_tvg_id(self):
|
||||
# programme@channel carries an XML entity and surrounding whitespace;
|
||||
# EPGData.tvg_id holds the lxml-decoded, stripped form. The index key
|
||||
# and lookup must canonicalize to the same value.
|
||||
xml = (
|
||||
'<?xml version="1.0" encoding="UTF-8"?>\n'
|
||||
"<tv>\n"
|
||||
' <channel id="A&E.us"/>\n'
|
||||
' <programme start="20000101000000 +0000" '
|
||||
'stop="20991231235959 +0000" channel=" A&E.us ">\n'
|
||||
" <title>A and E Now</title>\n"
|
||||
" </programme>\n"
|
||||
"</tv>\n"
|
||||
)
|
||||
with tempfile.NamedTemporaryFile(
|
||||
mode="w", suffix=".xml", delete=False
|
||||
) as f:
|
||||
f.write(xml)
|
||||
tmp_path = f.name
|
||||
|
||||
try:
|
||||
src = EPGSource.objects.create(
|
||||
name="Entities", source_type="xmltv", file_path=tmp_path
|
||||
)
|
||||
build_programme_index(src.id)
|
||||
src.refresh_from_db()
|
||||
self.assertIn("A&E.us", src.programme_index["channels"])
|
||||
|
||||
epg = EPGData.objects.create(
|
||||
tvg_id="A&E.us", name="A&E", epg_source=src
|
||||
)
|
||||
result = find_current_program_for_tvg_id(epg)
|
||||
self.assertIsNotNone(result)
|
||||
self.assertEqual(result["title"], "A and E Now")
|
||||
finally:
|
||||
os.unlink(tmp_path)
|
||||
|
||||
def test_offset_lookup_resolves_named_html_entities_in_programme_text(self):
|
||||
xml = (
|
||||
'<?xml version="1.0" encoding="UTF-8"?>\n'
|
||||
"<tv>\n"
|
||||
' <channel id="entity.channel"/>\n'
|
||||
' <programme start="20000101000000 +0000" '
|
||||
'stop="20991231235959 +0000" channel="entity.channel">\n'
|
||||
" <title>Café Live</title>\n"
|
||||
" </programme>\n"
|
||||
"</tv>\n"
|
||||
)
|
||||
with tempfile.NamedTemporaryFile(
|
||||
mode="w", suffix=".xml", delete=False
|
||||
) as f:
|
||||
f.write(xml)
|
||||
tmp_path = f.name
|
||||
|
||||
try:
|
||||
src = EPGSource.objects.create(
|
||||
name="Named Entities", source_type="xmltv", file_path=tmp_path
|
||||
)
|
||||
build_programme_index(src.id)
|
||||
|
||||
epg = EPGData.objects.create(
|
||||
tvg_id="entity.channel", name="Entity Channel", epg_source=src
|
||||
)
|
||||
result = find_current_program_for_tvg_id(epg)
|
||||
|
||||
self.assertIsNotNone(result)
|
||||
self.assertEqual(result["title"], "Caf\u00e9 Live")
|
||||
finally:
|
||||
os.unlink(tmp_path)
|
||||
|
||||
def test_epgshare_fr_style_programme_with_channel_first_and_apostrophe_entities(self):
|
||||
# Based on epgshare01 FR feeds: channel is the first programme attr,
|
||||
# ids/text include non-ASCII plus XML entities, and sub-title is common.
|
||||
tvg_id = "France.3.-.C\u00f4te.d'Azur.fr"
|
||||
xml = (
|
||||
'<?xml version="1.0" encoding="UTF-8" ?>\n'
|
||||
'<tv generator-info-name="none" generator-info-url="none">\n'
|
||||
' <channel id="France.3.-.C\u00f4te.d'Azur.fr">\n'
|
||||
' <display-name lang="fr">France 3 - C\u00f4te d'Azur</display-name>\n'
|
||||
" </channel>\n"
|
||||
' <programme channel="France.3.-.C\u00f4te.d'Azur.fr" '
|
||||
'start="20000101000000 +0000" stop="20991231235959 +0000">\n'
|
||||
" <title lang=\"fr\">La p'tite librairie</title>\n"
|
||||
" <sub-title lang=\"fr\">Le Lys de Brooklyn, de Betty Smith</sub-title>\n"
|
||||
" </programme>\n"
|
||||
"</tv>\n"
|
||||
)
|
||||
with tempfile.NamedTemporaryFile(
|
||||
mode="w", suffix=".xml", encoding="utf-8", delete=False
|
||||
) as f:
|
||||
f.write(xml)
|
||||
tmp_path = f.name
|
||||
|
||||
try:
|
||||
src = EPGSource.objects.create(
|
||||
name="EPGShare FR", source_type="xmltv", file_path=tmp_path
|
||||
)
|
||||
build_programme_index(src.id)
|
||||
src.refresh_from_db()
|
||||
self.assertIn(tvg_id, src.programme_index["channels"])
|
||||
|
||||
epg = EPGData.objects.create(
|
||||
tvg_id=tvg_id, name="France 3 Cote d'Azur", epg_source=src
|
||||
)
|
||||
result = find_current_program_for_tvg_id(epg)
|
||||
|
||||
self.assertIsNotNone(result)
|
||||
self.assertEqual(result["title"], "La p'tite librairie")
|
||||
self.assertEqual(
|
||||
result["sub_title"], "Le Lys de Brooklyn, de Betty Smith"
|
||||
)
|
||||
finally:
|
||||
os.unlink(tmp_path)
|
||||
|
||||
def test_epgshare_fr_style_description_decodes_predefined_entities(self):
|
||||
xml = (
|
||||
'<?xml version="1.0" encoding="UTF-8" ?>\n'
|
||||
"<tv>\n"
|
||||
' <channel id="Chasse.et.P\u00eache.fr"/>\n'
|
||||
' <programme channel="Chasse.et.P\u00eache.fr" '
|
||||
'start="20000101000000 +0000" stop="20991231235959 +0000">\n'
|
||||
" <title lang=\"fr\">Chasse & p\u00eache, le mag</title>\n"
|
||||
" <desc lang=\"fr\">Au sommaire : "La r\u00e9gion" <HD> & bonus.</desc>\n"
|
||||
" </programme>\n"
|
||||
"</tv>\n"
|
||||
)
|
||||
with tempfile.NamedTemporaryFile(
|
||||
mode="w", suffix=".xml", encoding="utf-8", delete=False
|
||||
) as f:
|
||||
f.write(xml)
|
||||
tmp_path = f.name
|
||||
|
||||
try:
|
||||
src = EPGSource.objects.create(
|
||||
name="EPGShare FR Entities", source_type="xmltv", file_path=tmp_path
|
||||
)
|
||||
build_programme_index(src.id)
|
||||
|
||||
epg = EPGData.objects.create(
|
||||
tvg_id="Chasse.et.P\u00eache.fr",
|
||||
name="Chasse et Peche",
|
||||
epg_source=src,
|
||||
)
|
||||
result = find_current_program_for_tvg_id(epg)
|
||||
|
||||
self.assertIsNotNone(result)
|
||||
self.assertEqual(result["title"], "Chasse & p\u00eache, le mag")
|
||||
self.assertEqual(
|
||||
result["description"],
|
||||
'Au sommaire : "La r\u00e9gion" <HD> & bonus.',
|
||||
)
|
||||
finally:
|
||||
os.unlink(tmp_path)
|
||||
|
||||
def test_epgshare_all_sources_style_start_stop_before_channel(self):
|
||||
# The all-sources EPG contains both programme attr orders:
|
||||
# channel/start/stop and start/stop/channel.
|
||||
tvg_id = "Atfal.&.Mawaheb.ae"
|
||||
xml = (
|
||||
'<?xml version="1.0" encoding="UTF-8" ?>\n'
|
||||
"<tv>\n"
|
||||
' <channel id="Atfal.&.Mawaheb.ae"/>\n'
|
||||
' <programme start="20000101000000 +0000" '
|
||||
'stop="20991231235959 +0000" channel="Atfal.&.Mawaheb.ae">\n'
|
||||
" <title lang=\"en\">Kids & Talent</title>\n"
|
||||
" </programme>\n"
|
||||
"</tv>\n"
|
||||
)
|
||||
with tempfile.NamedTemporaryFile(
|
||||
mode="w", suffix=".xml", encoding="utf-8", delete=False
|
||||
) as f:
|
||||
f.write(xml)
|
||||
tmp_path = f.name
|
||||
|
||||
try:
|
||||
src = EPGSource.objects.create(
|
||||
name="EPGShare All Sources", source_type="xmltv", file_path=tmp_path
|
||||
)
|
||||
build_programme_index(src.id)
|
||||
src.refresh_from_db()
|
||||
self.assertIn(tvg_id, src.programme_index["channels"])
|
||||
|
||||
epg = EPGData.objects.create(
|
||||
tvg_id=tvg_id, name="Atfal and Mawaheb", epg_source=src
|
||||
)
|
||||
result = find_current_program_for_tvg_id(epg)
|
||||
|
||||
self.assertIsNotNone(result)
|
||||
self.assertEqual(result["title"], "Kids & Talent")
|
||||
finally:
|
||||
os.unlink(tmp_path)
|
||||
|
||||
def test_jesmann_fullguide_style_numeric_channel_id(self):
|
||||
# FullGuide.xml.gz uses numeric channel ids and consistently orders
|
||||
# programme attrs as start/stop/channel.
|
||||
xml = (
|
||||
'<?xml version="1.0" encoding="UTF-8" ?>\n'
|
||||
"<tv>\n"
|
||||
' <channel id="123958">\n'
|
||||
" <display-name>Sample Numeric Channel</display-name>\n"
|
||||
" </channel>\n"
|
||||
' <programme start="20000101000000 +0000" '
|
||||
'stop="20991231235959 +0000" channel="123958">\n'
|
||||
" <title>Breaking Basics</title>\n"
|
||||
" <desc>Tobi visits the "Flying Steps" in Berlin.</desc>\n"
|
||||
" </programme>\n"
|
||||
"</tv>\n"
|
||||
)
|
||||
with tempfile.NamedTemporaryFile(
|
||||
mode="w", suffix=".xml", encoding="utf-8", delete=False
|
||||
) as f:
|
||||
f.write(xml)
|
||||
tmp_path = f.name
|
||||
|
||||
try:
|
||||
src = EPGSource.objects.create(
|
||||
name="Jesmann FullGuide", source_type="xmltv", file_path=tmp_path
|
||||
)
|
||||
build_programme_index(src.id)
|
||||
src.refresh_from_db()
|
||||
self.assertIn("123958", src.programme_index["channels"])
|
||||
|
||||
epg = EPGData.objects.create(
|
||||
tvg_id="123958", name="Numeric Channel", epg_source=src
|
||||
)
|
||||
result = find_current_program_for_tvg_id(epg)
|
||||
|
||||
self.assertIsNotNone(result)
|
||||
self.assertEqual(result["title"], "Breaking Basics")
|
||||
self.assertEqual(
|
||||
result["description"],
|
||||
'Tobi visits the "Flying Steps" in Berlin.',
|
||||
)
|
||||
finally:
|
||||
os.unlink(tmp_path)
|
||||
|
||||
def test_offset_lookup_accepts_single_quoted_channel_attribute(self):
|
||||
xml = (
|
||||
'<?xml version="1.0" encoding="UTF-8"?>\n'
|
||||
"<tv>\n"
|
||||
" <channel id='single.quote.channel'/>\n"
|
||||
" <programme start='20000101000000 +0000' "
|
||||
"stop='20991231235959 +0000' channel='single.quote.channel'>\n"
|
||||
" <title>Single Quote Current</title>\n"
|
||||
" </programme>\n"
|
||||
"</tv>\n"
|
||||
)
|
||||
with tempfile.NamedTemporaryFile(
|
||||
mode="w", suffix=".xml", encoding="utf-8", delete=False
|
||||
) as f:
|
||||
f.write(xml)
|
||||
tmp_path = f.name
|
||||
|
||||
try:
|
||||
src = EPGSource.objects.create(
|
||||
name="Single Quotes", source_type="xmltv", file_path=tmp_path
|
||||
)
|
||||
build_programme_index(src.id)
|
||||
src.refresh_from_db()
|
||||
self.assertIn("single.quote.channel", src.programme_index["channels"])
|
||||
|
||||
epg = EPGData.objects.create(
|
||||
tvg_id="single.quote.channel",
|
||||
name="Single Quote Channel",
|
||||
epg_source=src,
|
||||
)
|
||||
result = find_current_program_for_tvg_id(epg)
|
||||
|
||||
self.assertIsNotNone(result)
|
||||
self.assertEqual(result["title"], "Single Quote Current")
|
||||
finally:
|
||||
os.unlink(tmp_path)
|
||||
|
||||
def test_offset_lookup_resolves_html_named_entity_not_predefined_by_xml(self):
|
||||
xml = (
|
||||
'<?xml version="1.0" encoding="UTF-8"?>\n'
|
||||
"<tv>\n"
|
||||
' <channel id="html.entity.channel"/>\n'
|
||||
' <programme start="20000101000000 +0000" '
|
||||
'stop="20991231235959 +0000" channel="html.entity.channel">\n'
|
||||
" <title>Café Society</title>\n"
|
||||
" </programme>\n"
|
||||
"</tv>\n"
|
||||
)
|
||||
with tempfile.NamedTemporaryFile(
|
||||
mode="w", suffix=".xml", encoding="utf-8", delete=False
|
||||
) as f:
|
||||
f.write(xml)
|
||||
tmp_path = f.name
|
||||
|
||||
try:
|
||||
src = EPGSource.objects.create(
|
||||
name="HTML Entities", source_type="xmltv", file_path=tmp_path
|
||||
)
|
||||
build_programme_index(src.id)
|
||||
|
||||
epg = EPGData.objects.create(
|
||||
tvg_id="html.entity.channel",
|
||||
name="HTML Entity Channel",
|
||||
epg_source=src,
|
||||
)
|
||||
result = find_current_program_for_tvg_id(epg)
|
||||
|
||||
self.assertIsNotNone(result)
|
||||
self.assertEqual(result["title"], "Caf\u00e9\u00a0Society")
|
||||
finally:
|
||||
os.unlink(tmp_path)
|
||||
|
||||
def test_offset_lookup_handles_programme_element_larger_than_read_chunk(self):
|
||||
long_desc = "x" * (2 * 1024 * 1024 + 1024)
|
||||
xml = (
|
||||
'<?xml version="1.0" encoding="UTF-8"?>\n'
|
||||
"<tv>\n"
|
||||
' <channel id="large.programme.channel"/>\n'
|
||||
' <programme start="20000101000000 +0000" '
|
||||
'stop="20991231235959 +0000" channel="large.programme.channel">\n'
|
||||
" <title>Large Programme Current</title>\n"
|
||||
f" <desc>{long_desc}</desc>\n"
|
||||
" </programme>\n"
|
||||
"</tv>\n"
|
||||
)
|
||||
with tempfile.NamedTemporaryFile(
|
||||
mode="w", suffix=".xml", encoding="utf-8", delete=False
|
||||
) as f:
|
||||
f.write(xml)
|
||||
tmp_path = f.name
|
||||
|
||||
try:
|
||||
src = EPGSource.objects.create(
|
||||
name="Large Programme", source_type="xmltv", file_path=tmp_path
|
||||
)
|
||||
build_programme_index(src.id)
|
||||
|
||||
epg = EPGData.objects.create(
|
||||
tvg_id="large.programme.channel",
|
||||
name="Large Programme Channel",
|
||||
epg_source=src,
|
||||
)
|
||||
result = find_current_program_for_tvg_id(epg)
|
||||
|
||||
self.assertIsNotNone(result)
|
||||
self.assertEqual(result["title"], "Large Programme Current")
|
||||
self.assertEqual(len(result["description"]), len(long_desc))
|
||||
finally:
|
||||
os.unlink(tmp_path)
|
||||
|
||||
@patch("apps.epg.tasks.build_programme_index_task")
|
||||
def test_no_index_dispatches_build_and_returns_timeout(self, mock_build_task):
|
||||
# Source with no index and file on disk
|
||||
src = EPGSource.objects.create(
|
||||
name="No Index",
|
||||
source_type="xmltv",
|
||||
file_path=FIXTURE_XML,
|
||||
programme_index=None,
|
||||
)
|
||||
epg = EPGData.objects.create(
|
||||
tvg_id="channel.current",
|
||||
name="Current",
|
||||
epg_source=src,
|
||||
)
|
||||
|
||||
result = find_current_program_for_tvg_id(epg)
|
||||
|
||||
self.assertEqual(result, "timeout")
|
||||
mock_build_task.delay.assert_called_once_with(src.id)
|
||||
|
||||
|
||||
class BuildProgrammeIndexTests(TestCase):
|
||||
def test_builds_index_from_fixture(self):
|
||||
source = EPGSource.objects.create(
|
||||
name="Index Test",
|
||||
source_type="xmltv",
|
||||
file_path=FIXTURE_XML,
|
||||
)
|
||||
build_programme_index(source.id)
|
||||
source.refresh_from_db()
|
||||
|
||||
index = source.programme_index
|
||||
self.assertIsNotNone(index)
|
||||
channels = index["channels"]
|
||||
self.assertIn("channel.current", channels)
|
||||
self.assertIn("channel.past", channels)
|
||||
# channel.empty has no programmes
|
||||
self.assertNotIn("channel.empty", channels)
|
||||
# Small fixture has no interleaved channels
|
||||
self.assertEqual(index["interleaved_channels"], [])
|
||||
|
||||
def test_builds_index_when_channel_attribute_has_valid_xml_spacing(self):
|
||||
xml = (
|
||||
'<?xml version="1.0" encoding="UTF-8"?>\n'
|
||||
"<tv>\n"
|
||||
' <channel id="spaced.channel"/>\n'
|
||||
' <programme start="20000101000000 +0000" '
|
||||
'stop="20991231235959 +0000" channel = "spaced.channel">\n'
|
||||
" <title>Spaced Attribute Current</title>\n"
|
||||
" </programme>\n"
|
||||
"</tv>\n"
|
||||
)
|
||||
with tempfile.NamedTemporaryFile(
|
||||
mode="w", suffix=".xml", delete=False
|
||||
) as f:
|
||||
f.write(xml)
|
||||
tmp_path = f.name
|
||||
|
||||
try:
|
||||
src = EPGSource.objects.create(
|
||||
name="Spaced Attribute", source_type="xmltv", file_path=tmp_path
|
||||
)
|
||||
build_programme_index(src.id)
|
||||
src.refresh_from_db()
|
||||
|
||||
self.assertIn(
|
||||
"spaced.channel", src.programme_index["channels"]
|
||||
)
|
||||
finally:
|
||||
os.unlink(tmp_path)
|
||||
|
||||
def test_builds_index_from_extracted_file_path_for_gz_source(self):
|
||||
xml = (
|
||||
'<?xml version="1.0" encoding="UTF-8"?>\n'
|
||||
"<tv>\n"
|
||||
' <channel id="gz.channel"/>\n'
|
||||
' <programme channel="gz.channel" '
|
||||
'start="20000101000000 +0000" stop="20991231235959 +0000">\n'
|
||||
" <title>GZ Current</title>\n"
|
||||
" </programme>\n"
|
||||
"</tv>\n"
|
||||
)
|
||||
gz_path = None
|
||||
xml_path = None
|
||||
try:
|
||||
with tempfile.NamedTemporaryFile(
|
||||
mode="wb", suffix=".xml.gz", delete=False
|
||||
) as gz_file:
|
||||
gz_path = gz_file.name
|
||||
with gzip.GzipFile(fileobj=gz_file, mode="wb") as compressed:
|
||||
compressed.write(b"not the file the index should scan")
|
||||
|
||||
with tempfile.NamedTemporaryFile(
|
||||
mode="w", suffix=".xml", encoding="utf-8", delete=False
|
||||
) as xml_file:
|
||||
xml_file.write(xml)
|
||||
xml_path = xml_file.name
|
||||
|
||||
src = EPGSource.objects.create(
|
||||
name="Extracted GZ",
|
||||
source_type="xmltv",
|
||||
file_path=gz_path,
|
||||
extracted_file_path=xml_path,
|
||||
)
|
||||
build_programme_index(src.id)
|
||||
src.refresh_from_db()
|
||||
|
||||
self.assertIn("gz.channel", src.programme_index["channels"])
|
||||
finally:
|
||||
if gz_path:
|
||||
os.unlink(gz_path)
|
||||
if xml_path:
|
||||
os.unlink(xml_path)
|
||||
|
||||
def test_builds_index_ignores_elements_whose_name_only_starts_with_programme(self):
|
||||
xml = (
|
||||
'<?xml version="1.0" encoding="UTF-8"?>\n'
|
||||
"<tv>\n"
|
||||
' <channel id="real.channel"/>\n'
|
||||
' <programme-extra channel="not.a.programme" '
|
||||
'start="20000101000000 +0000" stop="20991231235959 +0000">\n'
|
||||
" <title>Not a Programme</title>\n"
|
||||
" </programme-extra>\n"
|
||||
' <programme channel="real.channel" '
|
||||
'start="20000101000000 +0000" stop="20991231235959 +0000">\n'
|
||||
" <title>Real Programme</title>\n"
|
||||
" </programme>\n"
|
||||
"</tv>\n"
|
||||
)
|
||||
with tempfile.NamedTemporaryFile(
|
||||
mode="w", suffix=".xml", encoding="utf-8", delete=False
|
||||
) as f:
|
||||
f.write(xml)
|
||||
tmp_path = f.name
|
||||
|
||||
try:
|
||||
src = EPGSource.objects.create(
|
||||
name="Programme Prefix", source_type="xmltv", file_path=tmp_path
|
||||
)
|
||||
build_programme_index(src.id)
|
||||
src.refresh_from_db()
|
||||
|
||||
self.assertIn("real.channel", src.programme_index["channels"])
|
||||
self.assertNotIn("not.a.programme", src.programme_index["channels"])
|
||||
finally:
|
||||
os.unlink(tmp_path)
|
||||
|
||||
def test_nonexistent_source_does_not_raise(self):
|
||||
# Should log error but not raise
|
||||
build_programme_index(99999)
|
||||
|
||||
@patch("apps.epg.tasks.build_programme_index")
|
||||
def test_task_builds_and_releases_lock_when_free(self, mock_build):
|
||||
mock_redis = MagicMock()
|
||||
mock_redis.set.return_value = True # lock acquired
|
||||
with patch("core.utils.RedisClient.get_client", return_value=mock_redis):
|
||||
build_programme_index_task(42)
|
||||
|
||||
mock_build.assert_called_once_with(42)
|
||||
mock_redis.set.assert_called_once()
|
||||
self.assertEqual(
|
||||
mock_redis.set.call_args.args[0], "building_programme_index_42"
|
||||
)
|
||||
mock_redis.delete.assert_called_once_with("building_programme_index_42")
|
||||
|
||||
@patch("apps.epg.tasks.build_programme_index")
|
||||
def test_task_skips_when_lock_held(self, mock_build):
|
||||
mock_redis = MagicMock()
|
||||
mock_redis.set.return_value = False # another build in flight
|
||||
with patch("core.utils.RedisClient.get_client", return_value=mock_redis):
|
||||
build_programme_index_task(42)
|
||||
|
||||
mock_build.assert_not_called()
|
||||
mock_redis.delete.assert_not_called()
|
||||
|
||||
@patch("apps.epg.tasks.build_programme_index", side_effect=RuntimeError("boom"))
|
||||
def test_task_releases_lock_on_failure(self, mock_build):
|
||||
mock_redis = MagicMock()
|
||||
mock_redis.set.return_value = True
|
||||
with patch("core.utils.RedisClient.get_client", return_value=mock_redis):
|
||||
with self.assertRaises(RuntimeError):
|
||||
build_programme_index_task(42)
|
||||
|
||||
mock_redis.delete.assert_called_once_with("building_programme_index_42")
|
||||
|
||||
def test_per_channel_interleaved_marking(self):
|
||||
xml = (
|
||||
'<?xml version="1.0" encoding="UTF-8"?>\n'
|
||||
"<tv>\n"
|
||||
' <channel id="A"/>\n'
|
||||
' <channel id="B"/>\n'
|
||||
' <programme start="20000101000000 +0000" '
|
||||
'stop="20991231235959 +0000" channel="A">\n'
|
||||
" <title>A Current</title>\n"
|
||||
" </programme>\n"
|
||||
' <programme start="20000101000000 +0000" '
|
||||
'stop="20991231235959 +0000" channel="B">\n'
|
||||
" <title>B Current</title>\n"
|
||||
" </programme>\n"
|
||||
' <programme start="19990101000000 +0000" '
|
||||
'stop="19990102000000 +0000" channel="A">\n'
|
||||
" <title>A Old</title>\n"
|
||||
" </programme>\n"
|
||||
"</tv>\n"
|
||||
)
|
||||
with tempfile.NamedTemporaryFile(
|
||||
mode="w", suffix=".xml", delete=False
|
||||
) as f:
|
||||
f.write(xml)
|
||||
tmp_path = f.name
|
||||
|
||||
try:
|
||||
src = EPGSource.objects.create(
|
||||
name="Interleaved", source_type="xmltv", file_path=tmp_path
|
||||
)
|
||||
with patch("apps.epg.tasks._OFFSET_CAP", 1):
|
||||
build_programme_index(src.id)
|
||||
src.refresh_from_db()
|
||||
index = src.programme_index
|
||||
self.assertEqual(index["interleaved_channels"], ["A"])
|
||||
|
||||
epg_b = EPGData.objects.create(
|
||||
tvg_id="B", name="B", epg_source=src
|
||||
)
|
||||
with patch(
|
||||
"apps.epg.tasks._scan_from_offset_for_tvg_id"
|
||||
) as mock_scan:
|
||||
result_b = find_current_program_for_tvg_id(epg_b)
|
||||
self.assertIsNotNone(result_b)
|
||||
self.assertEqual(result_b["title"], "B Current")
|
||||
mock_scan.assert_not_called()
|
||||
|
||||
epg_a = EPGData.objects.create(
|
||||
tvg_id="A", name="A", epg_source=src
|
||||
)
|
||||
result_a = find_current_program_for_tvg_id(epg_a)
|
||||
self.assertIsNotNone(result_a)
|
||||
self.assertEqual(result_a["title"], "A Current")
|
||||
finally:
|
||||
os.unlink(tmp_path)
|
||||
503
apps/epg/tests/test_schedules_direct.py
Normal file
503
apps/epg/tests/test_schedules_direct.py
Normal file
|
|
@ -0,0 +1,503 @@
|
|||
"""
|
||||
Tests for the Schedules Direct EPG integration.
|
||||
|
||||
Covers:
|
||||
- EPGSource model: username field presence and help text
|
||||
- EPGSource serializer: username field included in output
|
||||
- fetch_schedules_direct: credential validation
|
||||
- fetch_schedules_direct: SHA1 password hashing and token exchange
|
||||
- fetch_schedules_direct: graceful error handling on auth failure
|
||||
- parse_schedules_direct_time: correct UTC parsing
|
||||
- EPG signals: SD sources skip the XMLTV program parser
|
||||
"""
|
||||
|
||||
import hashlib
|
||||
from datetime import datetime
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
from django.test import TestCase
|
||||
from django.utils import timezone
|
||||
|
||||
from apps.epg.models import EPGSource, EPGData
|
||||
from apps.epg.serializers import EPGSourceSerializer
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Model tests
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class EPGSourceUsernameFieldTests(TestCase):
|
||||
"""EPGSource.username must exist, be nullable, and carry help text."""
|
||||
|
||||
def test_username_field_exists(self):
|
||||
source = EPGSource.objects.create(
|
||||
name='SD Test',
|
||||
source_type='schedules_direct',
|
||||
username='testuser',
|
||||
password='testpass',
|
||||
)
|
||||
source.refresh_from_db()
|
||||
self.assertEqual(source.username, 'testuser')
|
||||
|
||||
def test_username_nullable(self):
|
||||
source = EPGSource.objects.create(
|
||||
name='SD Nullable',
|
||||
source_type='schedules_direct',
|
||||
)
|
||||
source.refresh_from_db()
|
||||
self.assertIsNone(source.username)
|
||||
|
||||
def test_username_help_text(self):
|
||||
field = EPGSource._meta.get_field('username')
|
||||
self.assertIn('Schedules Direct', field.help_text)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Serializer tests
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class EPGSourceSerializerSDTests(TestCase):
|
||||
"""EPGSourceSerializer must include the username field."""
|
||||
|
||||
def test_username_in_serializer_fields(self):
|
||||
source = EPGSource.objects.create(
|
||||
name='SD Serializer Test',
|
||||
source_type='schedules_direct',
|
||||
username='sduser',
|
||||
password='sdpass',
|
||||
)
|
||||
data = EPGSourceSerializer(source).data
|
||||
self.assertIn('username', data)
|
||||
self.assertEqual(data['username'], 'sduser')
|
||||
|
||||
def test_password_not_in_serializer_output(self):
|
||||
source = EPGSource.objects.create(
|
||||
name='SD API Key Test',
|
||||
source_type='schedules_direct',
|
||||
password='secret',
|
||||
)
|
||||
data = EPGSourceSerializer(source).data
|
||||
self.assertNotIn('password', data)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# fetch_schedules_direct tests
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class FetchSchedulesDirectCredentialTests(TestCase):
|
||||
"""fetch_schedules_direct must reject sources missing credentials."""
|
||||
|
||||
def _make_source(self, username=None, password=None):
|
||||
return EPGSource.objects.create(
|
||||
name='SD Cred Test',
|
||||
source_type='schedules_direct',
|
||||
username=username,
|
||||
password=password,
|
||||
)
|
||||
|
||||
def test_missing_username_sets_error_status(self):
|
||||
from apps.epg.tasks import fetch_schedules_direct
|
||||
source = self._make_source(username=None, password='pass')
|
||||
fetch_schedules_direct(source)
|
||||
source.refresh_from_db()
|
||||
self.assertEqual(source.status, EPGSource.STATUS_ERROR)
|
||||
|
||||
def test_missing_password_sets_error_status(self):
|
||||
from apps.epg.tasks import fetch_schedules_direct
|
||||
source = self._make_source(username='user', password=None)
|
||||
fetch_schedules_direct(source)
|
||||
source.refresh_from_db()
|
||||
self.assertEqual(source.status, EPGSource.STATUS_ERROR)
|
||||
|
||||
def test_empty_username_sets_error_status(self):
|
||||
from apps.epg.tasks import fetch_schedules_direct
|
||||
source = self._make_source(username=' ', password='pass')
|
||||
fetch_schedules_direct(source)
|
||||
source.refresh_from_db()
|
||||
self.assertEqual(source.status, EPGSource.STATUS_ERROR)
|
||||
|
||||
|
||||
class FetchSchedulesDirectAuthTests(TestCase):
|
||||
"""fetch_schedules_direct must SHA1-hash the password before sending."""
|
||||
|
||||
@patch('apps.epg.tasks.requests.post')
|
||||
@patch('apps.epg.tasks.requests.get')
|
||||
def test_password_sha1_hashed_in_token_request(self, mock_get, mock_post):
|
||||
"""The token POST body must contain the SHA1 hash of the plaintext password."""
|
||||
plaintext = 'mysecretpassword'
|
||||
expected_hash = hashlib.sha1(plaintext.encode('utf-8')).hexdigest()
|
||||
|
||||
# Auth succeeds, status check returns empty data, lineups returns empty
|
||||
mock_post.return_value = MagicMock(
|
||||
status_code=200,
|
||||
json=MagicMock(return_value={'code': 0, 'token': 'tok123'}),
|
||||
)
|
||||
mock_get.return_value = MagicMock(
|
||||
status_code=200,
|
||||
json=MagicMock(return_value={}),
|
||||
)
|
||||
|
||||
from apps.epg.tasks import fetch_schedules_direct
|
||||
source = EPGSource.objects.create(
|
||||
name='SD Hash Test',
|
||||
source_type='schedules_direct',
|
||||
username='sduser',
|
||||
password=plaintext,
|
||||
)
|
||||
|
||||
with patch('apps.epg.tasks.send_epg_update'):
|
||||
fetch_schedules_direct(source)
|
||||
|
||||
# Verify the POST was called and the body contained the hash
|
||||
self.assertTrue(mock_post.called)
|
||||
call_kwargs = mock_post.call_args
|
||||
posted_json = call_kwargs[1].get('json') or call_kwargs[0][1]
|
||||
self.assertEqual(posted_json.get('password'), expected_hash)
|
||||
self.assertEqual(posted_json.get('username'), 'sduser')
|
||||
|
||||
@patch('apps.epg.tasks.requests.post')
|
||||
def test_auth_failure_sets_error_status(self, mock_post):
|
||||
"""A non-zero SD response code must set STATUS_ERROR on the source."""
|
||||
mock_post.return_value = MagicMock(
|
||||
status_code=200,
|
||||
json=MagicMock(return_value={
|
||||
'code': 3000,
|
||||
'message': 'Invalid credentials',
|
||||
}),
|
||||
)
|
||||
|
||||
from apps.epg.tasks import fetch_schedules_direct
|
||||
source = EPGSource.objects.create(
|
||||
name='SD Auth Fail',
|
||||
source_type='schedules_direct',
|
||||
username='baduser',
|
||||
password='badpass',
|
||||
)
|
||||
|
||||
with patch('apps.epg.tasks.send_epg_update'):
|
||||
fetch_schedules_direct(source)
|
||||
|
||||
source.refresh_from_db()
|
||||
self.assertEqual(source.status, EPGSource.STATUS_ERROR)
|
||||
|
||||
@patch('apps.epg.tasks.requests.post')
|
||||
def test_network_error_sets_error_status(self, mock_post):
|
||||
"""A network-level exception must set STATUS_ERROR and not crash."""
|
||||
import requests as req_lib
|
||||
mock_post.side_effect = req_lib.exceptions.ConnectionError('timeout')
|
||||
|
||||
from apps.epg.tasks import fetch_schedules_direct
|
||||
source = EPGSource.objects.create(
|
||||
name='SD Network Error',
|
||||
source_type='schedules_direct',
|
||||
username='user',
|
||||
password='pass',
|
||||
)
|
||||
|
||||
with patch('apps.epg.tasks.send_epg_update'):
|
||||
fetch_schedules_direct(source) # Must not raise
|
||||
|
||||
source.refresh_from_db()
|
||||
self.assertEqual(source.status, EPGSource.STATUS_ERROR)
|
||||
|
||||
|
||||
class FetchSchedulesDirectStationsOnlyTests(TestCase):
|
||||
"""stations_only fetch must signal channel parsing completion to the frontend."""
|
||||
|
||||
@patch('apps.epg.tasks.send_epg_update')
|
||||
@patch('apps.epg.tasks.requests.get')
|
||||
@patch('apps.epg.tasks.requests.post')
|
||||
def test_stations_only_sends_parsing_channels_complete(
|
||||
self, mock_post, mock_get, mock_send_epg_update
|
||||
):
|
||||
from apps.epg.tasks import fetch_schedules_direct
|
||||
|
||||
mock_post.return_value = MagicMock(
|
||||
status_code=200,
|
||||
json=MagicMock(return_value={'code': 0, 'token': 'tok123'}),
|
||||
)
|
||||
|
||||
def get_side_effect(url, **kwargs):
|
||||
if url.endswith('/status'):
|
||||
return MagicMock(
|
||||
status_code=200,
|
||||
json=MagicMock(return_value={'systemStatus': [{'status': 'Online'}]}),
|
||||
)
|
||||
if url.endswith('/lineups'):
|
||||
return MagicMock(
|
||||
status_code=200,
|
||||
json=MagicMock(return_value={
|
||||
'lineups': [{'lineupID': 'USA-TEST-X'}],
|
||||
}),
|
||||
)
|
||||
if '/lineups/USA-TEST-X' in url:
|
||||
return MagicMock(
|
||||
status_code=200,
|
||||
json=MagicMock(return_value={
|
||||
'stations': [{
|
||||
'stationID': '10001',
|
||||
'name': 'Test Station',
|
||||
'callsign': 'TEST',
|
||||
}],
|
||||
}),
|
||||
)
|
||||
raise AssertionError(f'Unexpected GET URL: {url}')
|
||||
|
||||
mock_get.side_effect = get_side_effect
|
||||
|
||||
source = EPGSource.objects.create(
|
||||
name='SD Stations Only',
|
||||
source_type='schedules_direct',
|
||||
username='sduser',
|
||||
password='sdpass',
|
||||
)
|
||||
|
||||
fetch_schedules_direct(source, stations_only=True)
|
||||
|
||||
source.refresh_from_db()
|
||||
self.assertEqual(source.status, EPGSource.STATUS_SUCCESS)
|
||||
self.assertEqual(EPGData.objects.filter(epg_source=source).count(), 1)
|
||||
|
||||
parsing_channel_complete = [
|
||||
c
|
||||
for c in mock_send_epg_update.call_args_list
|
||||
if c[0][1] == 'parsing_channels' and c[0][2] == 100
|
||||
]
|
||||
self.assertEqual(len(parsing_channel_complete), 1)
|
||||
complete_call = parsing_channel_complete[0]
|
||||
self.assertEqual(complete_call[0][0], source.id)
|
||||
self.assertEqual(complete_call[1]['status'], 'success')
|
||||
self.assertEqual(complete_call[1]['channels_count'], 1)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# parse_schedules_direct_time tests
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class ParseSchedulesDirectTimeTests(TestCase):
|
||||
"""parse_schedules_direct_time must parse SD ISO timestamps to UTC-aware datetimes."""
|
||||
|
||||
def test_parses_valid_timestamp(self):
|
||||
from apps.epg.tasks import parse_schedules_direct_time
|
||||
result = parse_schedules_direct_time('2026-05-16T20:00:00Z')
|
||||
self.assertEqual(result.year, 2026)
|
||||
self.assertEqual(result.month, 5)
|
||||
self.assertEqual(result.day, 16)
|
||||
self.assertEqual(result.hour, 20)
|
||||
self.assertIsNotNone(result.tzinfo)
|
||||
|
||||
def test_result_is_utc_aware(self):
|
||||
from apps.epg.tasks import parse_schedules_direct_time
|
||||
result = parse_schedules_direct_time('2026-01-01T00:00:00Z')
|
||||
# Should be timezone-aware
|
||||
self.assertIsNotNone(result.tzinfo)
|
||||
|
||||
def test_raises_on_invalid_format(self):
|
||||
from apps.epg.tasks import parse_schedules_direct_time
|
||||
with self.assertRaises(Exception):
|
||||
parse_schedules_direct_time('not-a-timestamp')
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Signal tests
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class SDSourceSignalTests(TestCase):
|
||||
"""SD EPG sources must skip the XMLTV program parser signal."""
|
||||
|
||||
@patch('apps.channels.signals.parse_programs_for_tvg_id')
|
||||
def test_sd_source_skips_xmltv_parse_on_channel_create(self, mock_parse):
|
||||
"""Creating a channel linked to an SD EPG source must not trigger
|
||||
the XMLTV program parser — SD data is handled by fetch_schedules_direct."""
|
||||
from apps.epg.models import EPGData
|
||||
from apps.channels.models import Channel
|
||||
|
||||
sd_source = EPGSource.objects.create(
|
||||
name='SD Signal Test',
|
||||
source_type='schedules_direct',
|
||||
username='u',
|
||||
password='p',
|
||||
)
|
||||
epg_data = EPGData.objects.create(
|
||||
tvg_id='sd-test-station',
|
||||
name='SD Test Station',
|
||||
epg_source=sd_source,
|
||||
)
|
||||
|
||||
Channel.objects.create(
|
||||
name='SD Channel',
|
||||
epg_data=epg_data,
|
||||
)
|
||||
|
||||
mock_parse.delay.assert_not_called()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Poster selection tests
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class SDPosterSelectionTests(TestCase):
|
||||
"""_sd_pick_poster_url must honour style preference with sensible fallbacks."""
|
||||
|
||||
def _images(self):
|
||||
return [
|
||||
{
|
||||
'uri': 'assets/iconic_portrait.jpg',
|
||||
'width': '960',
|
||||
'aspect': '2x3',
|
||||
'category': 'Iconic',
|
||||
},
|
||||
{
|
||||
'uri': 'assets/banner_portrait.jpg',
|
||||
'width': '360',
|
||||
'aspect': '2x3',
|
||||
'category': 'Banner-L1',
|
||||
},
|
||||
{
|
||||
'uri': 'assets/iconic_landscape.jpg',
|
||||
'width': '1920',
|
||||
'aspect': '16x9',
|
||||
'category': 'Iconic',
|
||||
},
|
||||
{
|
||||
'uri': 'assets/banner_landscape.jpg',
|
||||
'width': '1280',
|
||||
'aspect': '16x9',
|
||||
'category': 'Banner-L1',
|
||||
},
|
||||
]
|
||||
|
||||
def test_portrait_iconic_prefers_iconic_over_banner(self):
|
||||
from apps.epg.tasks import _sd_pick_poster_url
|
||||
|
||||
self.assertEqual(
|
||||
_sd_pick_poster_url(self._images(), 'portrait_iconic'),
|
||||
'assets/iconic_portrait.jpg',
|
||||
)
|
||||
|
||||
def test_portrait_banner_prefers_banner(self):
|
||||
from apps.epg.tasks import _sd_pick_poster_url
|
||||
|
||||
self.assertEqual(
|
||||
_sd_pick_poster_url(self._images(), 'portrait_banner'),
|
||||
'assets/banner_portrait.jpg',
|
||||
)
|
||||
|
||||
def test_landscape_iconic_prefers_landscape_iconic(self):
|
||||
from apps.epg.tasks import _sd_pick_poster_url
|
||||
|
||||
self.assertEqual(
|
||||
_sd_pick_poster_url(self._images(), 'landscape_iconic'),
|
||||
'assets/iconic_landscape.jpg',
|
||||
)
|
||||
|
||||
def test_landscape_falls_back_to_portrait_when_unavailable(self):
|
||||
from apps.epg.tasks import _sd_pick_poster_url
|
||||
|
||||
images = [img for img in self._images() if img['aspect'] in ('2x3', '3x4')]
|
||||
self.assertEqual(
|
||||
_sd_pick_poster_url(images, 'landscape_iconic'),
|
||||
'assets/iconic_portrait.jpg',
|
||||
)
|
||||
|
||||
def test_unknown_style_defaults_to_sd_recommended(self):
|
||||
from apps.epg.tasks import _sd_pick_poster_url
|
||||
|
||||
self.assertEqual(
|
||||
_sd_pick_poster_url(self._images(), 'not_a_real_style'),
|
||||
'assets/iconic_portrait.jpg',
|
||||
)
|
||||
|
||||
def test_prefers_primary_when_category_and_aspect_match(self):
|
||||
from apps.epg.tasks import _sd_pick_poster_url
|
||||
|
||||
images = [
|
||||
{
|
||||
'uri': 'assets/banner_small.jpg',
|
||||
'width': '120',
|
||||
'aspect': '2x3',
|
||||
'category': 'Banner-L1',
|
||||
},
|
||||
{
|
||||
'uri': 'assets/banner_primary.jpg',
|
||||
'width': '360',
|
||||
'aspect': '2x3',
|
||||
'category': 'Banner-L1',
|
||||
'primary': 'true',
|
||||
},
|
||||
]
|
||||
self.assertEqual(
|
||||
_sd_pick_poster_url(images, 'portrait_banner'),
|
||||
'assets/banner_primary.jpg',
|
||||
)
|
||||
|
||||
def test_sd_recommended_uses_primary_poster_category(self):
|
||||
from apps.epg.tasks import _sd_pick_poster_url
|
||||
|
||||
images = [
|
||||
{
|
||||
'uri': 'assets/cast_primary.jpg',
|
||||
'width': '500',
|
||||
'aspect': '3x4',
|
||||
'category': 'Cast in Character',
|
||||
'primary': 'true',
|
||||
},
|
||||
{
|
||||
'uri': 'assets/iconic_primary.jpg',
|
||||
'width': '300',
|
||||
'aspect': '16x9',
|
||||
'category': 'Iconic',
|
||||
'primary': 'true',
|
||||
},
|
||||
]
|
||||
self.assertEqual(
|
||||
_sd_pick_poster_url(images, 'sd_recommended'),
|
||||
'assets/iconic_primary.jpg',
|
||||
)
|
||||
|
||||
def test_sd_recommended_falls_back_to_portrait_iconic(self):
|
||||
from apps.epg.tasks import _sd_pick_poster_url
|
||||
|
||||
self.assertEqual(
|
||||
_sd_pick_poster_url(self._images(), 'sd_recommended'),
|
||||
'assets/iconic_portrait.jpg',
|
||||
)
|
||||
|
||||
def test_default_style_is_sd_recommended(self):
|
||||
from apps.epg.tasks import _sd_pick_poster_url, SD_POSTER_STYLE_DEFAULT
|
||||
|
||||
self.assertEqual(SD_POSTER_STYLE_DEFAULT, 'sd_recommended')
|
||||
images = [
|
||||
{
|
||||
'uri': 'assets/primary.jpg',
|
||||
'width': '960',
|
||||
'aspect': '16x9',
|
||||
'category': 'Iconic',
|
||||
'primary': 'true',
|
||||
},
|
||||
]
|
||||
self.assertEqual(_sd_pick_poster_url(images), 'assets/primary.jpg')
|
||||
|
||||
def test_style_fallback_uses_primary_before_cross_orientation(self):
|
||||
from apps.epg.tasks import _sd_pick_poster_url
|
||||
|
||||
images = [
|
||||
{
|
||||
'uri': 'assets/iconic_portrait.jpg',
|
||||
'width': '960',
|
||||
'aspect': '2x3',
|
||||
'category': 'Iconic',
|
||||
},
|
||||
{
|
||||
'uri': 'assets/landscape_primary.jpg',
|
||||
'width': '1920',
|
||||
'aspect': '16x9',
|
||||
'category': 'Iconic',
|
||||
'primary': 'true',
|
||||
},
|
||||
]
|
||||
# square_iconic has no 1x1 images; should pick SD primary before portrait iconic fallback
|
||||
self.assertEqual(
|
||||
_sd_pick_poster_url(images, 'square_iconic'),
|
||||
'assets/landscape_primary.jpg',
|
||||
)
|
||||
|
|
@ -19,6 +19,7 @@ from django.utils.decorators import method_decorator
|
|||
from django.contrib.auth.decorators import login_required
|
||||
from django.views.decorators.csrf import csrf_exempt
|
||||
from dispatcharr.utils import network_access_allowed
|
||||
from core.utils import build_absolute_uri_with_port
|
||||
|
||||
# Configure logger
|
||||
logger = logging.getLogger(__name__)
|
||||
|
|
@ -75,7 +76,7 @@ class DiscoverAPIView(APIView):
|
|||
uri_parts.append("output_profile")
|
||||
uri_parts.append(str(output_profile_id))
|
||||
|
||||
base_url = request.build_absolute_uri(f'/{"/".join(uri_parts)}/').rstrip("/")
|
||||
base_url = build_absolute_uri_with_port(request, f'/{"/".join(uri_parts)}/').rstrip("/")
|
||||
device = HDHRDevice.objects.first()
|
||||
|
||||
from apps.m3u.utils import calculate_tuner_count
|
||||
|
|
@ -166,6 +167,13 @@ class LineupAPIView(APIView):
|
|||
|
||||
resolved_output_profile_id = _resolve_hdhr_output_profile_id(output_profile_id)
|
||||
|
||||
_stream_url_prefix = build_absolute_uri_with_port(request, "/proxy/ts/stream/")
|
||||
_output_profile_qs = (
|
||||
f"?output_profile={resolved_output_profile_id}"
|
||||
if resolved_output_profile_id is not None
|
||||
else ""
|
||||
)
|
||||
|
||||
lineup = []
|
||||
for ch in channels:
|
||||
formatted = format_channel_number(ch.effective_channel_number, empty=None)
|
||||
|
|
@ -173,9 +181,7 @@ class LineupAPIView(APIView):
|
|||
continue
|
||||
formatted_channel_number = str(formatted)
|
||||
|
||||
stream_url = request.build_absolute_uri(f"/proxy/ts/stream/{ch.uuid}")
|
||||
if resolved_output_profile_id is not None:
|
||||
stream_url += f"?output_profile={resolved_output_profile_id}"
|
||||
stream_url = f"{_stream_url_prefix}{ch.uuid}{_output_profile_qs}"
|
||||
|
||||
lineup.append(
|
||||
{
|
||||
|
|
@ -224,7 +230,7 @@ class HDHRDeviceXMLAPIView(APIView):
|
|||
if blocked is not None:
|
||||
return blocked
|
||||
|
||||
base_url = request.build_absolute_uri("/hdhr/").rstrip("/")
|
||||
base_url = build_absolute_uri_with_port(request, "/hdhr/").rstrip("/")
|
||||
|
||||
xml_response = f"""<?xml version="1.0" encoding="utf-8"?>
|
||||
<root>
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@ from django.views import View
|
|||
from django.utils.decorators import method_decorator
|
||||
from django.contrib.auth.decorators import login_required
|
||||
from django.views.decorators.csrf import csrf_exempt
|
||||
from core.utils import build_absolute_uri_with_port
|
||||
|
||||
|
||||
@login_required
|
||||
|
|
@ -46,7 +47,7 @@ class DiscoverAPIView(APIView):
|
|||
description="Retrieve HDHomeRun device discovery information",
|
||||
)
|
||||
def get(self, request):
|
||||
base_url = request.build_absolute_uri("/hdhr/").rstrip("/")
|
||||
base_url = build_absolute_uri_with_port(request, "/hdhr/").rstrip("/")
|
||||
device = HDHRDevice.objects.first()
|
||||
|
||||
if not device:
|
||||
|
|
@ -92,6 +93,8 @@ class LineupAPIView(APIView):
|
|||
.exclude(hidden_from_output=True)
|
||||
.order_by("effective_channel_number")
|
||||
)
|
||||
_stream_url_prefix = build_absolute_uri_with_port(request, "/proxy/ts/stream/")
|
||||
|
||||
lineup = []
|
||||
for ch in channels:
|
||||
formatted = format_channel_number(ch.effective_channel_number, empty=None)
|
||||
|
|
@ -102,7 +105,7 @@ class LineupAPIView(APIView):
|
|||
{
|
||||
"GuideNumber": formatted_channel_number,
|
||||
"GuideName": ch.effective_name,
|
||||
"URL": request.build_absolute_uri(f"/proxy/ts/stream/{ch.uuid}"),
|
||||
"URL": f"{_stream_url_prefix}{ch.uuid}",
|
||||
}
|
||||
)
|
||||
return JsonResponse(lineup, safe=False)
|
||||
|
|
@ -133,7 +136,7 @@ class HDHRDeviceXMLAPIView(APIView):
|
|||
description="Retrieve the HDHomeRun device XML configuration",
|
||||
)
|
||||
def get(self, request):
|
||||
base_url = request.build_absolute_uri("/hdhr/").rstrip("/")
|
||||
base_url = build_absolute_uri_with_port(request, "/hdhr/").rstrip("/")
|
||||
|
||||
xml_response = f"""<?xml version="1.0" encoding="utf-8"?>
|
||||
<root>
|
||||
|
|
|
|||
|
|
@ -978,7 +978,7 @@ def process_xc_category_direct(account_id, batch, groups, hash_keys):
|
|||
existing_streams = {
|
||||
s.stream_hash: s
|
||||
for s in Stream.objects.filter(stream_hash__in=stream_hashes.keys()).select_related('m3u_account').only(
|
||||
'id', 'stream_hash', 'name', 'url', 'logo_url', 'tvg_id', 'custom_properties', 'last_seen', 'updated_at', 'm3u_account', 'stream_id', 'stream_chno'
|
||||
'id', 'stream_hash', 'name', 'url', 'logo_url', 'tvg_id', 'custom_properties', 'last_seen', 'updated_at', 'm3u_account', 'stream_id', 'stream_chno', 'channel_group_id'
|
||||
)
|
||||
}
|
||||
|
||||
|
|
@ -994,7 +994,8 @@ def process_xc_category_direct(account_id, batch, groups, hash_keys):
|
|||
obj.custom_properties != stream_props["custom_properties"] or
|
||||
obj.is_adult != stream_props["is_adult"] or
|
||||
obj.stream_id != stream_props["stream_id"] or
|
||||
obj.stream_chno != stream_props["stream_chno"]
|
||||
obj.stream_chno != stream_props["stream_chno"] or
|
||||
obj.channel_group_id != stream_props["channel_group_id"]
|
||||
)
|
||||
|
||||
if changed:
|
||||
|
|
@ -1030,7 +1031,7 @@ def process_xc_category_direct(account_id, batch, groups, hash_keys):
|
|||
# Simplified bulk update for better performance
|
||||
Stream.objects.bulk_update(
|
||||
streams_to_update,
|
||||
['name', 'url', 'logo_url', 'tvg_id', 'custom_properties', 'is_adult', 'last_seen', 'updated_at', 'is_stale', 'stream_id', 'stream_chno'],
|
||||
['name', 'url', 'logo_url', 'tvg_id', 'custom_properties', 'is_adult', 'last_seen', 'updated_at', 'is_stale', 'stream_id', 'stream_chno', 'channel_group_id'],
|
||||
batch_size=150 # Smaller batch size for XC processing
|
||||
)
|
||||
|
||||
|
|
@ -1200,7 +1201,7 @@ def process_m3u_batch_direct(account_id, batch, groups, hash_keys):
|
|||
existing_streams = {
|
||||
s.stream_hash: s
|
||||
for s in Stream.objects.filter(stream_hash__in=stream_hashes.keys()).select_related('m3u_account').only(
|
||||
'id', 'stream_hash', 'name', 'url', 'logo_url', 'tvg_id', 'custom_properties', 'last_seen', 'updated_at', 'm3u_account', 'stream_id', 'stream_chno'
|
||||
'id', 'stream_hash', 'name', 'url', 'logo_url', 'tvg_id', 'custom_properties', 'last_seen', 'updated_at', 'm3u_account', 'stream_id', 'stream_chno', 'channel_group_id'
|
||||
)
|
||||
}
|
||||
|
||||
|
|
@ -1216,7 +1217,8 @@ def process_m3u_batch_direct(account_id, batch, groups, hash_keys):
|
|||
obj.custom_properties != stream_props["custom_properties"] or
|
||||
obj.is_adult != stream_props["is_adult"] or
|
||||
obj.stream_id != stream_props["stream_id"] or
|
||||
obj.stream_chno != stream_props["stream_chno"]
|
||||
obj.stream_chno != stream_props["stream_chno"] or
|
||||
obj.channel_group_id != stream_props["channel_group_id"]
|
||||
)
|
||||
|
||||
# Always update last_seen
|
||||
|
|
@ -1232,6 +1234,7 @@ def process_m3u_batch_direct(account_id, batch, groups, hash_keys):
|
|||
obj.is_adult = stream_props["is_adult"]
|
||||
obj.stream_id = stream_props["stream_id"]
|
||||
obj.stream_chno = stream_props["stream_chno"]
|
||||
obj.channel_group_id = stream_props["channel_group_id"]
|
||||
obj.updated_at = timezone.now()
|
||||
|
||||
# Always mark as not stale since we saw it in this refresh
|
||||
|
|
@ -1254,7 +1257,7 @@ def process_m3u_batch_direct(account_id, batch, groups, hash_keys):
|
|||
# Update all streams in a single bulk operation
|
||||
Stream.objects.bulk_update(
|
||||
streams_to_update,
|
||||
['name', 'url', 'logo_url', 'tvg_id', 'custom_properties', 'is_adult', 'last_seen', 'updated_at', 'is_stale', 'stream_id', 'stream_chno'],
|
||||
['name', 'url', 'logo_url', 'tvg_id', 'custom_properties', 'is_adult', 'last_seen', 'updated_at', 'is_stale', 'stream_id', 'stream_chno', 'channel_group_id'],
|
||||
batch_size=200
|
||||
)
|
||||
except Exception as e:
|
||||
|
|
@ -2955,7 +2958,7 @@ def refresh_account_profiles(account_id):
|
|||
existing_props = profile.custom_properties or {}
|
||||
existing_props.update(profile_account_info)
|
||||
profile.custom_properties = existing_props
|
||||
profile.save(update_fields=['custom_properties', 'exp_date'])
|
||||
profile.save(update_fields=['custom_properties'])
|
||||
|
||||
profiles_updated += 1
|
||||
logger.info(f"Updated account information for profile '{profile.name}' ({profiles_updated}/{profiles.count()})")
|
||||
|
|
|
|||
|
|
@ -6,7 +6,10 @@ first (fails on HEAD prior to the Tier 2 patch), then is flipped to assert
|
|||
the correct post-fix behavior. Comments call out the failure mode and the
|
||||
fix location.
|
||||
"""
|
||||
from django.test import TestCase
|
||||
from unittest import skipUnless
|
||||
|
||||
from django.db import connection
|
||||
from django.test import TestCase, TransactionTestCase
|
||||
from django.utils import timezone
|
||||
|
||||
from apps.channels.models import (
|
||||
|
|
@ -2049,3 +2052,251 @@ class Migration0037DemoteOrphansTests(TestCase):
|
|||
"auto_created=True or deleted",
|
||||
)
|
||||
self.assertIsNone(ch.auto_created_by)
|
||||
|
||||
|
||||
class CompactNumberingWithGroupOverrideTests(TestCase):
|
||||
"""
|
||||
Compact numbering must keep working when a Channel Group Override is
|
||||
configured on the source ChannelGroupM3UAccount. With an override,
|
||||
sync stores auto-created channels under the OVERRIDE TARGET group's id
|
||||
rather than the source group's id recorded on the relation. The
|
||||
compact paths resolve the relation from the channel's group id, so
|
||||
without the override-aware fallback they all miss and slot accounting
|
||||
silently breaks (hidden channels keep their numbers, unhides get none,
|
||||
repack sees zero channels).
|
||||
|
||||
Fix location: apps/channels/compact_numbering.py
|
||||
(get_group_relation_for_channel fallback, _repack_inner group_ids,
|
||||
assign_compact_numbers_for_channels bulk fallback).
|
||||
"""
|
||||
|
||||
def _override_setup(self, start=100, end=110):
|
||||
account = _make_account()
|
||||
source_group = _make_group(name="SourcePPV")
|
||||
target_group = _make_group(name="TargetAll")
|
||||
rel = _attach_group_to_account(
|
||||
account,
|
||||
source_group,
|
||||
custom_properties={
|
||||
"compact_numbering": True,
|
||||
"group_override": target_group.id,
|
||||
},
|
||||
)
|
||||
rel.auto_sync_channel_start = start
|
||||
rel.auto_sync_channel_end = end
|
||||
rel.save()
|
||||
return account, source_group, target_group, rel
|
||||
|
||||
def _auto_channel(self, account, group, number=None, hidden=False, name="PPV"):
|
||||
return Channel.objects.create(
|
||||
name=name,
|
||||
channel_number=number,
|
||||
channel_group=group,
|
||||
auto_created=True,
|
||||
auto_created_by=account,
|
||||
hidden_from_output=hidden,
|
||||
)
|
||||
|
||||
def test_hide_releases_slot_under_group_override(self):
|
||||
# Fail signature: channel_number stays populated after hide =
|
||||
# release_compact_number_on_hide bailed because
|
||||
# get_group_relation_for_channel returned None for the override
|
||||
# target group.
|
||||
account, source, target, rel = self._override_setup()
|
||||
ch = self._auto_channel(account, target, number=100)
|
||||
|
||||
ch.hidden_from_output = True
|
||||
ch.save()
|
||||
ch.refresh_from_db()
|
||||
|
||||
self.assertIsNone(
|
||||
ch.channel_number,
|
||||
"Hiding an auto channel under a Channel Group Override must "
|
||||
"release its compact slot (channel_number=None)",
|
||||
)
|
||||
|
||||
def test_unhide_assigns_slot_under_group_override(self):
|
||||
# Fail signature: channel_number stays None after unhide =
|
||||
# assign_compact_number_on_unhide bailed on the override target.
|
||||
account, source, target, rel = self._override_setup()
|
||||
ch = self._auto_channel(account, target, number=None, hidden=True)
|
||||
|
||||
ch.hidden_from_output = False
|
||||
ch.save()
|
||||
ch.refresh_from_db()
|
||||
|
||||
self.assertEqual(
|
||||
ch.channel_number,
|
||||
100,
|
||||
"Unhiding an auto channel under a Channel Group Override must "
|
||||
"assign a number from the compact range",
|
||||
)
|
||||
|
||||
def test_repack_sees_channels_under_override_target(self):
|
||||
# Fail signature: assigned=0 = _repack_inner filtered on the source
|
||||
# group id and found none of the channels stored under the target.
|
||||
from apps.channels.compact_numbering import repack_group
|
||||
|
||||
account, source, target, rel = self._override_setup()
|
||||
channels = [
|
||||
self._auto_channel(account, target, number=900 + i, name=f"C{i}")
|
||||
for i in range(3)
|
||||
]
|
||||
|
||||
result = repack_group(rel)
|
||||
|
||||
self.assertEqual(result["assigned"], 3)
|
||||
self.assertEqual(result["failed"], 0)
|
||||
nums = sorted(
|
||||
Channel.objects.filter(
|
||||
id__in=[c.id for c in channels]
|
||||
).values_list("channel_number", flat=True)
|
||||
)
|
||||
self.assertEqual(nums, [100, 101, 102])
|
||||
|
||||
def test_no_override_fast_path_still_resolves(self):
|
||||
# Regression guard: the common no-override case must still resolve
|
||||
# the relation via the direct lookup (channel.channel_group_id ==
|
||||
# source group id), unaffected by the override fallback.
|
||||
from apps.channels.compact_numbering import (
|
||||
get_group_relation_for_channel,
|
||||
)
|
||||
|
||||
account = _make_account()
|
||||
group = _make_group(name="PlainSports")
|
||||
rel = _attach_group_to_account(
|
||||
account, group, custom_properties={"compact_numbering": True}
|
||||
)
|
||||
ch = self._auto_channel(account, group, number=100)
|
||||
|
||||
resolved = get_group_relation_for_channel(ch)
|
||||
self.assertIsNotNone(resolved)
|
||||
self.assertEqual(resolved.id, rel.id)
|
||||
|
||||
def test_repack_under_override_query_count_does_not_scale(self):
|
||||
# Perf guard: the override-aware repack widens the channel lookup's
|
||||
# IN clause; it must not add a query per channel. Query count must
|
||||
# be identical for N and 3*N channels.
|
||||
from django.db import connection
|
||||
from django.test.utils import CaptureQueriesContext
|
||||
from apps.channels.compact_numbering import repack_group
|
||||
|
||||
account, source, target, rel = self._override_setup(start=100, end=300)
|
||||
|
||||
def measure(n):
|
||||
Channel.objects.filter(auto_created_by=account).delete()
|
||||
for i in range(n):
|
||||
self._auto_channel(account, target, number=900 + i, name=f"C{i}")
|
||||
with CaptureQueriesContext(connection) as ctx:
|
||||
repack_group(rel)
|
||||
return len(ctx.captured_queries)
|
||||
|
||||
small = measure(5)
|
||||
large = measure(15)
|
||||
self.assertEqual(
|
||||
small,
|
||||
large,
|
||||
f"repack query count scaled with channel count: {small} -> {large}",
|
||||
)
|
||||
|
||||
|
||||
@skipUnless(
|
||||
connection.vendor == "postgresql",
|
||||
"Idempotency repro forces a physical heap reorder via CLUSTER, which is "
|
||||
"PostgreSQL-specific (the suite's target DB).",
|
||||
)
|
||||
class CompactNumberingIdempotencyTests(TransactionTestCase):
|
||||
"""
|
||||
A compact repack must be idempotent: with no change to hide state or
|
||||
overrides, repacking again must leave every channel on the same number.
|
||||
|
||||
The unpatched _repack_inner read its channels with no ORDER BY, so the
|
||||
pack followed PostgreSQL's physical row order. That order drifts after
|
||||
the UPDATEs each repack issues (and after autovacuum), so successive
|
||||
syncs packed the same channels into different numbers. That is the daily
|
||||
channel-number churn users reported.
|
||||
|
||||
This test forces the divergence deterministically. After the first pack
|
||||
it rewrites every channel_number to the reverse of id order, then
|
||||
physically clusters the table on that column so the heap order becomes
|
||||
the reverse of id order. An unordered SELECT then returns the rows in the
|
||||
opposite order from the first pass. Unpatched, the second pack assigns
|
||||
numbers in that reversed order and the channel->number mapping flips;
|
||||
patched, .order_by("id") keeps both packs identical.
|
||||
|
||||
Fail signature: channel->number mapping differs between the two repacks
|
||||
= _repack_inner is following physical row order instead of id order.
|
||||
|
||||
Fix location: apps/channels/compact_numbering.py (_repack_inner channel
|
||||
query .order_by("id")).
|
||||
"""
|
||||
|
||||
# TransactionTestCase commits its rows (TestCase's savepoint rollback
|
||||
# would hide them from CLUSTER, which also cannot run inside the
|
||||
# transaction block TestCase wraps each test in).
|
||||
|
||||
def _mapping(self, account):
|
||||
return {
|
||||
c.id: c.channel_number
|
||||
for c in Channel.objects.filter(
|
||||
auto_created=True, auto_created_by=account
|
||||
)
|
||||
}
|
||||
|
||||
def test_repack_is_idempotent_under_physical_reorder(self):
|
||||
from apps.channels.compact_numbering import repack_group
|
||||
|
||||
account = _make_account()
|
||||
group = _make_group(name="Sports")
|
||||
rel = _attach_group_to_account(
|
||||
account, group, custom_properties={"compact_numbering": True}
|
||||
)
|
||||
rel.auto_sync_channel_start = 8000
|
||||
rel.auto_sync_channel_end = 8099
|
||||
rel.save()
|
||||
|
||||
# Eight visible auto channels; ascending id is creation order.
|
||||
channels = [
|
||||
Channel.objects.create(
|
||||
name=f"C{i}",
|
||||
channel_group=group,
|
||||
auto_created=True,
|
||||
auto_created_by=account,
|
||||
)
|
||||
for i in range(8)
|
||||
]
|
||||
|
||||
repack_group(rel)
|
||||
first = self._mapping(account)
|
||||
# Provider-order pack (the default) assigns by id, so the lowest id
|
||||
# takes the range start.
|
||||
lowest_id = min(c.id for c in channels)
|
||||
self.assertEqual(first[lowest_id], 8000)
|
||||
|
||||
# Set channel_number to the reverse of id order, then cluster the
|
||||
# heap on that column so physical order becomes reverse-id order.
|
||||
# Values sit above the range so they cannot collide with the pack.
|
||||
table = Channel._meta.db_table
|
||||
with connection.cursor() as cur:
|
||||
for pos, ch in enumerate(channels):
|
||||
cur.execute(
|
||||
f"UPDATE {table} SET channel_number = %s WHERE id = %s",
|
||||
[9000 - pos, ch.id],
|
||||
)
|
||||
cur.execute(
|
||||
f"CREATE INDEX IF NOT EXISTS churn_cn_idx "
|
||||
f"ON {table} (channel_number)"
|
||||
)
|
||||
cur.execute(f"CLUSTER {table} USING churn_cn_idx")
|
||||
cur.execute("DROP INDEX IF EXISTS churn_cn_idx")
|
||||
|
||||
repack_group(rel)
|
||||
second = self._mapping(account)
|
||||
|
||||
self.assertEqual(
|
||||
first,
|
||||
second,
|
||||
"Repack is not idempotent: channel numbers changed on a second "
|
||||
"pass with no hide or override change. _repack_inner is following "
|
||||
"physical row order instead of id order.",
|
||||
)
|
||||
|
|
|
|||
|
|
@ -1,6 +1,5 @@
|
|||
from django.http import HttpResponse, JsonResponse, Http404, HttpResponseForbidden, StreamingHttpResponse
|
||||
import json
|
||||
from rest_framework.response import Response
|
||||
from django.urls import reverse
|
||||
from apps.channels.models import Channel, ChannelProfile, ChannelGroup, Stream
|
||||
from apps.channels.utils import format_channel_number
|
||||
|
|
@ -16,7 +15,7 @@ from datetime import datetime, timedelta
|
|||
import html
|
||||
import time
|
||||
from tzlocal import get_localzone
|
||||
from urllib.parse import urlparse
|
||||
from urllib.parse import urlencode
|
||||
import base64
|
||||
import logging
|
||||
from django.db.models.functions import Lower
|
||||
|
|
@ -24,7 +23,7 @@ import os
|
|||
from apps.m3u.utils import calculate_tuner_count
|
||||
from apps.proxy.utils import get_user_active_connections
|
||||
import regex
|
||||
from core.utils import log_system_event
|
||||
from core.utils import log_system_event, build_absolute_uri_with_port
|
||||
import hashlib
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
|
@ -127,7 +126,7 @@ def generate_m3u(request, profile_name=None, user=None):
|
|||
# Check if this is a POST request with data (which we don't want to allow)
|
||||
if request.method == "POST" and request.body:
|
||||
if request.body.decode() != '{}':
|
||||
return HttpResponseForbidden("POST requests with body are not allowed, body is: {}".format(request.body.decode()))
|
||||
return HttpResponseForbidden("POST requests with body are not allowed.")
|
||||
|
||||
if user is not None:
|
||||
if user.user_level < 10:
|
||||
|
|
@ -206,12 +205,26 @@ def generate_m3u(request, profile_name=None, user=None):
|
|||
xc_username = request.GET.get('username')
|
||||
xc_password = request.GET.get('password')
|
||||
is_xc_request = user is not None and xc_username and xc_password
|
||||
_base_url = build_absolute_uri_with_port(request, '')
|
||||
|
||||
if is_xc_request:
|
||||
# This is an XC API request - use XC-style EPG URL
|
||||
base_url = build_absolute_uri_with_port(request, '')
|
||||
epg_url = f"{base_url}/xmltv.php?username={xc_username}&password={xc_password}"
|
||||
epg_url = f"{_base_url}/xmltv.php?username={xc_username}&password={xc_password}"
|
||||
# Build the query-string suffix for stream URLs once - it's the same for every channel
|
||||
xc_qs = {}
|
||||
if output_profile_id:
|
||||
xc_qs['output_profile'] = output_profile_id
|
||||
if output_format_param:
|
||||
xc_qs['output_format'] = output_format_param
|
||||
xc_qs_suffix = f"?{urlencode(xc_qs)}" if xc_qs else ""
|
||||
else:
|
||||
# Pre-compute proxy query-string suffix (same for every channel in this request)
|
||||
proxy_qs = {}
|
||||
if output_profile_id:
|
||||
proxy_qs['output_profile'] = output_profile_id
|
||||
if output_format_param:
|
||||
proxy_qs['output_format'] = output_format_param
|
||||
proxy_qs_suffix = f"?{urlencode(proxy_qs)}" if proxy_qs else ""
|
||||
# Regular request - use standard EPG endpoint
|
||||
epg_base_url = build_absolute_uri_with_port(request, reverse('output:epg_endpoint', args=[profile_name]) if profile_name else reverse('output:epg_endpoint'))
|
||||
|
||||
|
|
@ -219,7 +232,6 @@ def generate_m3u(request, profile_name=None, user=None):
|
|||
preserved_params = ['tvg_id_source', 'cachedlogos', 'days', 'prev_days']
|
||||
query_params = {k: v for k, v in request.GET.items() if k in preserved_params}
|
||||
if query_params:
|
||||
from urllib.parse import urlencode
|
||||
epg_url = f"{epg_base_url}?{urlencode(query_params)}"
|
||||
else:
|
||||
epg_url = epg_base_url
|
||||
|
|
@ -227,6 +239,13 @@ def generate_m3u(request, profile_name=None, user=None):
|
|||
# Add x-tvg-url and url-tvg attribute for EPG URL
|
||||
m3u_content = f'#EXTM3U x-tvg-url="{epg_url}" url-tvg="{epg_url}"\n'
|
||||
|
||||
# Host/port/scheme are constant per request; precompute URL prefixes once.
|
||||
_stream_url_prefix = None if is_xc_request else f"{_base_url}/proxy/ts/stream/"
|
||||
_sample_logo_path = reverse("api:channels:logo-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
|
||||
|
||||
# Start building M3U content
|
||||
channel_count = 0
|
||||
for channel in channels:
|
||||
|
|
@ -256,8 +275,7 @@ def generate_m3u(request, profile_name=None, user=None):
|
|||
tvg_logo = ""
|
||||
if effective_logo:
|
||||
if use_cached_logos:
|
||||
# Use cached logo as before
|
||||
tvg_logo = build_absolute_uri_with_port(request, reverse('api:channels:logo-cache', args=[effective_logo.id]))
|
||||
tvg_logo = f"{_logo_url_prefix}{effective_logo.id}{_logo_url_suffix}"
|
||||
else:
|
||||
# Try to find direct logo URL from channel's streams
|
||||
direct_logo = effective_logo.url if effective_logo.url.startswith(('http://', 'https://')) else None
|
||||
|
|
@ -265,7 +283,7 @@ def generate_m3u(request, profile_name=None, user=None):
|
|||
if direct_logo:
|
||||
tvg_logo = direct_logo
|
||||
else:
|
||||
tvg_logo = build_absolute_uri_with_port(request, reverse('api:channels:logo-cache', args=[effective_logo.id]))
|
||||
tvg_logo = f"{_logo_url_prefix}{effective_logo.id}{_logo_url_suffix}"
|
||||
|
||||
# create possible gracenote id insertion
|
||||
tvc_guide_stationid = ""
|
||||
|
|
@ -281,9 +299,7 @@ def generate_m3u(request, profile_name=None, user=None):
|
|||
|
||||
# Determine the stream URL based on request type
|
||||
if is_xc_request:
|
||||
# XC API request - use XC-style stream URL format
|
||||
base_url = build_absolute_uri_with_port(request, '')
|
||||
stream_url = f"{base_url}/live/{xc_username}/{xc_password}/{channel.id}"
|
||||
stream_url = f"{_base_url}/live/{xc_username}/{xc_password}/{channel.id}{xc_qs_suffix}"
|
||||
elif use_direct_urls:
|
||||
# Try to get the first stream's direct URL
|
||||
all_streams = channel.streams.all()
|
||||
|
|
@ -293,20 +309,10 @@ def generate_m3u(request, profile_name=None, user=None):
|
|||
stream_url = first_stream.url
|
||||
else:
|
||||
# Fall back to proxy URL if no direct URL available
|
||||
stream_url = build_absolute_uri_with_port(request, f"/proxy/ts/stream/{channel.uuid}")
|
||||
stream_url = f"{_stream_url_prefix}{channel.uuid}"
|
||||
else:
|
||||
# Standard behavior - use proxy URL
|
||||
base_stream_url = build_absolute_uri_with_port(request, f"/proxy/ts/stream/{channel.uuid}")
|
||||
qs_parts = {}
|
||||
if output_profile_id:
|
||||
qs_parts['output_profile'] = output_profile_id
|
||||
if output_format_param:
|
||||
qs_parts['output_format'] = output_format_param
|
||||
if qs_parts:
|
||||
from urllib.parse import urlencode
|
||||
stream_url = f"{base_stream_url}?{urlencode(qs_parts)}"
|
||||
else:
|
||||
stream_url = base_stream_url
|
||||
stream_url = f"{_stream_url_prefix}{channel.uuid}{proxy_qs_suffix}"
|
||||
|
||||
m3u_content += extinf_line + stream_url + "\n"
|
||||
|
||||
|
|
@ -1427,6 +1433,13 @@ def generate_epg(request, profile_name=None, user=None):
|
|||
channel_num_map[channel.id] = candidate
|
||||
used_numbers.add(candidate)
|
||||
|
||||
# Host/port/scheme are constant per request; precompute logo URL prefix once.
|
||||
_base_url = build_absolute_uri_with_port(request, "")
|
||||
_sample_logo_path = reverse("api:channels:logo-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
|
||||
|
||||
# Process channels for the <channel> section
|
||||
for channel in channels:
|
||||
effective_name = channel.effective_name
|
||||
|
|
@ -1506,14 +1519,14 @@ def generate_epg(request, profile_name=None, user=None):
|
|||
# If no custom dummy logo, use regular logo logic
|
||||
if not tvg_logo and effective_logo:
|
||||
if use_cached_logos:
|
||||
tvg_logo = build_absolute_uri_with_port(request, reverse('api:channels:logo-cache', args=[effective_logo.id]))
|
||||
tvg_logo = f"{_logo_url_prefix}{effective_logo.id}{_logo_url_suffix}"
|
||||
else:
|
||||
# Use direct URL if available, otherwise fall back to cached version
|
||||
direct_logo = effective_logo.url if effective_logo.url.startswith(('http://', 'https://')) else None
|
||||
if direct_logo:
|
||||
tvg_logo = direct_logo
|
||||
else:
|
||||
tvg_logo = build_absolute_uri_with_port(request, reverse('api:channels:logo-cache', args=[effective_logo.id]))
|
||||
tvg_logo = f"{_logo_url_prefix}{effective_logo.id}{_logo_url_suffix}"
|
||||
display_name = effective_name
|
||||
xml_lines.append(f' <channel id="{html.escape(channel_id)}">')
|
||||
xml_lines.append(f' <display-name>{html.escape(display_name)}</display-name>')
|
||||
|
|
@ -1645,6 +1658,7 @@ def generate_epg(request, profile_name=None, user=None):
|
|||
# to avoid skipping/duplicating rows if the table changes mid-stream.
|
||||
last_epg_id = 0
|
||||
last_id = 0
|
||||
_poster_url_base = build_absolute_uri_with_port(request, "/api/epg/programs/")
|
||||
|
||||
while True:
|
||||
program_chunk = list(
|
||||
|
|
@ -1848,6 +1862,8 @@ def generate_epg(request, profile_name=None, user=None):
|
|||
|
||||
if "icon" in custom_data:
|
||||
program_xml.append(f' <icon src="{html.escape(custom_data["icon"])}" />')
|
||||
elif "sd_icon" in custom_data:
|
||||
program_xml.append(f' <icon src="{html.escape(_poster_url_base)}{prog["id"]}/poster/" />')
|
||||
|
||||
# Add special flags as proper tags with enhanced handling
|
||||
if custom_data.get("previously_shown", False):
|
||||
|
|
@ -2548,60 +2564,79 @@ 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 Movie, M3UMovieRelation
|
||||
from django.db.models import Prefetch
|
||||
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')
|
||||
)
|
||||
|
||||
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, "")
|
||||
_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
|
||||
|
||||
# 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
|
||||
|
||||
# All authenticated users get access to VOD from all active M3U accounts
|
||||
filters = {"m3u_relations__m3u_account__is_active": True}
|
||||
|
||||
if category_id:
|
||||
filters["m3u_relations__category_id"] = category_id
|
||||
|
||||
# Optimize with prefetch_related to eliminate N+1 queries
|
||||
# This loads all relations in a single query instead of one per movie
|
||||
movies = Movie.objects.filter(**filters).select_related('logo').prefetch_related(
|
||||
Prefetch(
|
||||
'm3u_relations',
|
||||
queryset=M3UMovieRelation.objects.filter(
|
||||
m3u_account__is_active=True
|
||||
).select_related('m3u_account', 'category').order_by('-m3u_account__priority', 'id'),
|
||||
to_attr='active_relations'
|
||||
)
|
||||
).distinct()
|
||||
|
||||
for movie in movies:
|
||||
# Get the first (highest priority) relation from prefetched data
|
||||
# This avoids the N+1 query problem entirely
|
||||
if hasattr(movie, 'active_relations') and movie.active_relations:
|
||||
relation = movie.active_relations[0]
|
||||
else:
|
||||
# Fallback - should rarely be needed with proper prefetching
|
||||
continue
|
||||
|
||||
streams.append({
|
||||
"num": movie.id,
|
||||
append({
|
||||
"num": num,
|
||||
"name": movie.name,
|
||||
"stream_type": "movie",
|
||||
"stream_id": movie.id,
|
||||
"stream_icon": (
|
||||
None if not movie.logo
|
||||
else build_absolute_uri_with_port(
|
||||
request,
|
||||
reverse("api:vod:vodlogo-cache", args=[movie.logo.id])
|
||||
)
|
||||
f"{_logo_url_prefix}{logo.id}{_logo_url_suffix}" if logo else None
|
||||
),
|
||||
#'stream_icon': movie.logo.url if movie.logo else '',
|
||||
"rating": movie.rating or "0",
|
||||
"rating_5based": round(float(movie.rating or 0) / 2, 2) if movie.rating else 0,
|
||||
"rating": rating or "0",
|
||||
"rating_5based": round(float(rating or 0) / 2, 2) if rating else 0,
|
||||
"added": str(int(movie.created_at.timestamp())),
|
||||
"is_adult": 0,
|
||||
"tmdb_id": movie.tmdb_id or "",
|
||||
"imdb_id": movie.imdb_id or "",
|
||||
"trailer": (movie.custom_properties or {}).get('trailer') or "",
|
||||
"category_id": str(relation.category.id) if relation.category else "0",
|
||||
"category_ids": [int(relation.category.id)] if relation.category else [],
|
||||
"trailer": custom_props.get('youtube_trailer') or "",
|
||||
"plot": movie.description or "",
|
||||
"genre": movie.genre or "",
|
||||
"year": 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",
|
||||
"custom_sid": None,
|
||||
"direct_source": "",
|
||||
|
|
@ -2635,47 +2670,70 @@ 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
|
||||
|
||||
series_list = []
|
||||
|
||||
# All authenticated users get access to series from all active M3U accounts
|
||||
filters = {"m3u_account__is_active": True}
|
||||
|
||||
rel_filters = {"m3u_account__is_active": True}
|
||||
if category_id:
|
||||
filters["category_id"] = category_id
|
||||
rel_filters["category_id"] = category_id
|
||||
|
||||
# Get series relations instead of series directly
|
||||
series_relations = M3USeriesRelation.objects.filter(**filters).select_related(
|
||||
'series', 'series__logo', 'category', 'm3u_account'
|
||||
base_qs = (
|
||||
M3USeriesRelation.objects
|
||||
.filter(**rel_filters)
|
||||
.select_related('series', 'series__logo', 'category')
|
||||
)
|
||||
|
||||
for relation in series_relations:
|
||||
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
|
||||
series_list.append({
|
||||
"num": relation.id, # Use relation ID
|
||||
custom_props = series.custom_properties or {}
|
||||
category = relation.category
|
||||
rating = series.rating
|
||||
logo = series.logo
|
||||
year_str = str(series.year) if series.year else ""
|
||||
release_date = custom_props.get('release_date', year_str)
|
||||
|
||||
append({
|
||||
"num": num,
|
||||
"name": series.name,
|
||||
"series_id": relation.id, # Use relation ID
|
||||
"series_id": relation.id,
|
||||
"cover": (
|
||||
None if not series.logo
|
||||
else build_absolute_uri_with_port(
|
||||
request,
|
||||
reverse("api:vod:vodlogo-cache", args=[series.logo.id])
|
||||
)
|
||||
f"{_logo_url_prefix}{logo.id}{_logo_url_suffix}" if logo else None
|
||||
),
|
||||
"plot": series.description or "",
|
||||
"cast": series.custom_properties.get('cast', '') if series.custom_properties else "",
|
||||
"director": series.custom_properties.get('director', '') if series.custom_properties else "",
|
||||
"cast": custom_props.get('cast', ''),
|
||||
"director": custom_props.get('director', ''),
|
||||
"genre": series.genre or "",
|
||||
"release_date": series.custom_properties.get('release_date', str(series.year) if series.year else "") if series.custom_properties else (str(series.year) if series.year else ""),
|
||||
"releaseDate": series.custom_properties.get('release_date', str(series.year) if series.year else "") if series.custom_properties else (str(series.year) if series.year else ""),
|
||||
"release_date": release_date,
|
||||
"releaseDate": release_date,
|
||||
"last_modified": str(int(relation.updated_at.timestamp())),
|
||||
"rating": str(series.rating or "0"),
|
||||
"rating_5based": str(round(float(series.rating or 0) / 2, 2)) if series.rating else "0",
|
||||
"backdrop_path": series.custom_properties.get('backdrop_path', []) if series.custom_properties else [],
|
||||
"youtube_trailer": series.custom_properties.get('youtube_trailer', '') if series.custom_properties else "",
|
||||
"episode_run_time": series.custom_properties.get('episode_run_time', '') if series.custom_properties else "",
|
||||
"category_id": str(relation.category.id) if relation.category else "0",
|
||||
"category_ids": [int(relation.category.id)] if relation.category else [],
|
||||
"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 "",
|
||||
})
|
||||
|
|
@ -3108,87 +3166,6 @@ def xc_series_stream(request, username, password, stream_id, extension):
|
|||
return HttpResponseRedirect(vod_url)
|
||||
|
||||
|
||||
def get_host_and_port(request):
|
||||
"""
|
||||
Returns (host, port) for building absolute URIs.
|
||||
- Prefers X-Forwarded-Host/X-Forwarded-Port (nginx).
|
||||
- Falls back to Host header.
|
||||
- Returns None for port if using standard ports (80/443) to omit from URLs.
|
||||
- In dev, uses 5656 as a guess if port cannot be determined.
|
||||
"""
|
||||
# Determine the scheme first - needed for standard port detection
|
||||
scheme = request.META.get("HTTP_X_FORWARDED_PROTO", request.scheme)
|
||||
standard_port = "443" if scheme == "https" else "80"
|
||||
|
||||
# 1. Try X-Forwarded-Host (may include port) - set by our nginx
|
||||
xfh = request.META.get("HTTP_X_FORWARDED_HOST")
|
||||
if xfh:
|
||||
if ":" in xfh:
|
||||
host, port = xfh.split(":", 1)
|
||||
# Omit standard ports from URLs
|
||||
if port == standard_port:
|
||||
return host, None
|
||||
# Non-standard port in X-Forwarded-Host - return it
|
||||
# This handles reverse proxies on non-standard ports (e.g., https://example.com:8443)
|
||||
return host, port
|
||||
else:
|
||||
host = xfh
|
||||
|
||||
# Check for X-Forwarded-Port header (if we didn't find a port in X-Forwarded-Host)
|
||||
port = request.META.get("HTTP_X_FORWARDED_PORT")
|
||||
if port:
|
||||
# Omit standard ports from URLs
|
||||
return host, None if port == standard_port else port
|
||||
# If X-Forwarded-Proto is set but no valid port, assume standard
|
||||
if request.META.get("HTTP_X_FORWARDED_PROTO"):
|
||||
return host, None
|
||||
|
||||
# 2. Try Host header
|
||||
raw_host = request.get_host()
|
||||
if ":" in raw_host:
|
||||
host, port = raw_host.split(":", 1)
|
||||
# Omit standard ports from URLs
|
||||
return host, None if port == standard_port else port
|
||||
else:
|
||||
host = raw_host
|
||||
|
||||
# 3. Check for X-Forwarded-Port (when Host header has no port but we're behind a reverse proxy)
|
||||
port = request.META.get("HTTP_X_FORWARDED_PORT")
|
||||
if port:
|
||||
# Omit standard ports from URLs
|
||||
return host, None if port == standard_port else port
|
||||
|
||||
# 4. Check if we're behind a reverse proxy (X-Forwarded-Proto or X-Forwarded-For present)
|
||||
# If so, assume standard port for the scheme (don't trust SERVER_PORT in this case)
|
||||
if request.META.get("HTTP_X_FORWARDED_PROTO") or request.META.get("HTTP_X_FORWARDED_FOR"):
|
||||
return host, None
|
||||
|
||||
# 5. Try SERVER_PORT from META (only if NOT behind reverse proxy)
|
||||
port = request.META.get("SERVER_PORT")
|
||||
if port:
|
||||
# Omit standard ports from URLs
|
||||
return host, None if port == standard_port else port
|
||||
|
||||
# 6. Dev fallback: guess port 5656
|
||||
if os.environ.get("DISPATCHARR_ENV") == "dev" or host in ("localhost", "127.0.0.1"):
|
||||
return host, "5656"
|
||||
|
||||
# 7. Final fallback: assume standard port for scheme (omit from URL)
|
||||
return host, None
|
||||
|
||||
def build_absolute_uri_with_port(request, path):
|
||||
"""
|
||||
Build an absolute URI with optional port.
|
||||
Port is omitted from URL if None (standard port for scheme).
|
||||
"""
|
||||
host, port = get_host_and_port(request)
|
||||
scheme = request.META.get("HTTP_X_FORWARDED_PROTO", request.scheme)
|
||||
|
||||
if port:
|
||||
return f"{scheme}://{host}:{port}{path}"
|
||||
else:
|
||||
return f"{scheme}://{host}{path}"
|
||||
|
||||
def format_duration_hms(seconds):
|
||||
"""
|
||||
Format a duration in seconds as HH:MM:SS zero-padded string.
|
||||
|
|
|
|||
|
|
@ -1,7 +1,6 @@
|
|||
import hashlib
|
||||
import ipaddress
|
||||
import logging
|
||||
import io
|
||||
import json
|
||||
import re
|
||||
import socket
|
||||
|
|
@ -9,6 +8,7 @@ from rest_framework.views import APIView
|
|||
from rest_framework.response import Response
|
||||
from rest_framework import status, serializers
|
||||
from drf_spectacular.utils import extend_schema, inline_serializer
|
||||
from django.core.cache import cache
|
||||
from django.conf import settings
|
||||
from django.core.files.uploadedfile import UploadedFile
|
||||
from django.http import FileResponse
|
||||
|
|
@ -335,6 +335,21 @@ def _save_fetched_manifest_to_repo(repo, data, verified):
|
|||
return None
|
||||
|
||||
|
||||
def _invalidate_plugin_detail_cache(repo_id, manifest_data):
|
||||
manifest = manifest_data.get("manifest", manifest_data)
|
||||
root_url = manifest.get("root_url", "").rstrip("/")
|
||||
keys = []
|
||||
for p in manifest.get("plugins", []):
|
||||
url = p.get("manifest_url", "")
|
||||
if not url:
|
||||
continue
|
||||
if root_url and not url.startswith(("http://", "https://")):
|
||||
url = f"{root_url}/{url}"
|
||||
keys.append(f"plugin_detail:{repo_id}:{hashlib.md5(url.encode()).hexdigest()}")
|
||||
if keys:
|
||||
cache.delete_many(keys)
|
||||
|
||||
|
||||
def _unmanage_dropped_slugs(repo, new_manifest_data):
|
||||
"""After a manifest refresh, clear source_repo on any installed plugins
|
||||
whose slug is no longer listed in the repo's manifest. Also syncs the
|
||||
|
|
@ -571,6 +586,7 @@ class PluginDeleteAPIView(PluginAuthMixin, APIView):
|
|||
# ---------------------------------------------------------------------------
|
||||
|
||||
MANIFEST_FETCH_TIMEOUT = 15
|
||||
PLUGIN_DETAIL_CACHE_TTL = 300 # seconds
|
||||
|
||||
OFFICIAL_KEY_PATH = os.path.join(
|
||||
os.path.dirname(__file__), "keys", "dispatcharr-plugins.pub"
|
||||
|
|
@ -589,6 +605,101 @@ def _normalize_pgp_key(text):
|
|||
return text
|
||||
|
||||
|
||||
def _gpg_run(cmd, input_data=None, timeout=30):
|
||||
"""
|
||||
Run a GPG command using os.posix_spawn.
|
||||
|
||||
os.posix_spawn skips pthread_atfork handlers, avoiding the indefinite hang
|
||||
that fork()-based approaches suffer under gevent+uWSGI. select.select()
|
||||
and time.sleep() are gevent-patched so reads and the waitpid poll yield to
|
||||
the hub cooperatively.
|
||||
|
||||
Returns (returncode, stdout_bytes, stderr_bytes).
|
||||
"""
|
||||
import select as _select
|
||||
import signal as _signal
|
||||
import time as _time
|
||||
|
||||
stdin_r, stdin_w = os.pipe()
|
||||
stdout_r, stdout_w = os.pipe()
|
||||
stderr_r, stderr_w = os.pipe()
|
||||
|
||||
try:
|
||||
executable = shutil.which(cmd[0]) or cmd[0]
|
||||
pid = os.posix_spawn(
|
||||
executable, cmd, os.environ,
|
||||
file_actions=[
|
||||
(os.POSIX_SPAWN_DUP2, stdin_r, 0),
|
||||
(os.POSIX_SPAWN_DUP2, stdout_w, 1),
|
||||
(os.POSIX_SPAWN_DUP2, stderr_w, 2),
|
||||
(os.POSIX_SPAWN_CLOSE, stdin_r),
|
||||
(os.POSIX_SPAWN_CLOSE, stdin_w),
|
||||
(os.POSIX_SPAWN_CLOSE, stdout_w),
|
||||
(os.POSIX_SPAWN_CLOSE, stderr_w),
|
||||
(os.POSIX_SPAWN_CLOSE, stdout_r),
|
||||
(os.POSIX_SPAWN_CLOSE, stderr_r),
|
||||
],
|
||||
)
|
||||
except Exception:
|
||||
for fd in (stdin_r, stdin_w, stdout_r, stdout_w, stderr_r, stderr_w):
|
||||
try:
|
||||
os.close(fd)
|
||||
except OSError:
|
||||
pass
|
||||
raise
|
||||
|
||||
for fd in (stdin_r, stdout_w, stderr_w):
|
||||
os.close(fd)
|
||||
|
||||
try:
|
||||
if input_data:
|
||||
os.write(stdin_w, input_data)
|
||||
finally:
|
||||
os.close(stdin_w)
|
||||
|
||||
out, err = [], []
|
||||
done = set()
|
||||
deadline = _time.monotonic() + timeout
|
||||
try:
|
||||
while len(done) < 2:
|
||||
remaining = deadline - _time.monotonic()
|
||||
if remaining <= 0:
|
||||
try:
|
||||
os.kill(pid, _signal.SIGKILL)
|
||||
except ProcessLookupError:
|
||||
pass
|
||||
break
|
||||
fds = [fd for fd in (stdout_r, stderr_r) if fd not in done]
|
||||
readable, _, _ = _select.select(fds, [], [], min(remaining, 0.5))
|
||||
for fd in readable:
|
||||
data = os.read(fd, 8192)
|
||||
if data:
|
||||
(out if fd == stdout_r else err).append(data)
|
||||
else:
|
||||
done.add(fd)
|
||||
finally:
|
||||
for fd in (stdout_r, stderr_r):
|
||||
try:
|
||||
os.close(fd)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
deadline = _time.monotonic() + 5.0
|
||||
while _time.monotonic() < deadline:
|
||||
try:
|
||||
wpid, st = os.waitpid(pid, os.WNOHANG)
|
||||
except ChildProcessError:
|
||||
return -1, b"".join(out), b"".join(err)
|
||||
if wpid == pid:
|
||||
if os.WIFEXITED(st):
|
||||
return os.WEXITSTATUS(st), b"".join(out), b"".join(err)
|
||||
if os.WIFSIGNALED(st):
|
||||
return -os.WTERMSIG(st), b"".join(out), b"".join(err)
|
||||
return -1, b"".join(out), b"".join(err)
|
||||
_time.sleep(0.01)
|
||||
return -1, b"".join(out), b"".join(err)
|
||||
|
||||
|
||||
def _verify_manifest_signature(manifest_obj, signature_armored, public_key_text=None):
|
||||
"""Verify a detached GPG signature over the canonical manifest JSON.
|
||||
|
||||
|
|
@ -597,7 +708,7 @@ def _verify_manifest_signature(manifest_obj, signature_armored, public_key_text=
|
|||
repos). When *None* the bundled official key is used instead.
|
||||
|
||||
Returns True if valid, False if invalid/error, None if verification
|
||||
could not be attempted (no signature, no key, gnupg missing, etc.).
|
||||
could not be attempted (no signature, no key, gpg binary missing, etc.).
|
||||
"""
|
||||
if not signature_armored:
|
||||
return None
|
||||
|
|
@ -614,18 +725,19 @@ def _verify_manifest_signature(manifest_obj, signature_armored, public_key_text=
|
|||
logger.debug("No GPG public key available; skipping verification")
|
||||
return None
|
||||
|
||||
try:
|
||||
import gnupg
|
||||
except ImportError:
|
||||
logger.debug("python-gnupg not installed; skipping signature verification")
|
||||
return None
|
||||
|
||||
tmp_home = tempfile.mkdtemp(prefix="gpg_verify_")
|
||||
try:
|
||||
gpg = gnupg.GPG(gnupghome=tmp_home)
|
||||
import_result = gpg.import_keys(key_text)
|
||||
if not import_result.fingerprints:
|
||||
logger.warning("Failed to import GPG public key")
|
||||
key_bytes = key_text.encode("utf-8") if isinstance(key_text, str) else key_text
|
||||
rc, _, import_stderr = _gpg_run(
|
||||
["gpg", "--batch", "--no-tty", "--status-fd", "2",
|
||||
"--homedir", tmp_home, "--import"],
|
||||
input_data=key_bytes,
|
||||
)
|
||||
if logger.isEnabledFor(logging.DEBUG):
|
||||
for line in import_stderr.decode("utf-8", errors="replace").splitlines():
|
||||
logger.debug("gpg import: %s", line)
|
||||
if rc != 0:
|
||||
logger.warning("GPG key import failed (rc=%d)", rc)
|
||||
return None
|
||||
|
||||
# Must match what the signing script produces: jq -c '.manifest'
|
||||
|
|
@ -639,8 +751,18 @@ def _verify_manifest_signature(manifest_obj, signature_armored, public_key_text=
|
|||
with open(sig_path, "w") as sf:
|
||||
sf.write(signature_armored)
|
||||
|
||||
verified = gpg.verify_data(sig_path, manifest_bytes)
|
||||
return bool(verified)
|
||||
rc, _, verify_stderr = _gpg_run(
|
||||
["gpg", "--batch", "--no-tty", "--status-fd", "2",
|
||||
"--homedir", tmp_home, "--verify", sig_path, "-"],
|
||||
input_data=manifest_bytes,
|
||||
)
|
||||
if logger.isEnabledFor(logging.DEBUG):
|
||||
for line in verify_stderr.decode("utf-8", errors="replace").splitlines():
|
||||
logger.debug("gpg verify: %s", line)
|
||||
return rc == 0 and b"VALIDSIG" in verify_stderr
|
||||
except FileNotFoundError:
|
||||
logger.debug("gpg binary not found; skipping signature verification")
|
||||
return None
|
||||
except Exception:
|
||||
logger.exception("GPG signature verification error")
|
||||
return False
|
||||
|
|
@ -881,6 +1003,7 @@ class PluginRepoRefreshAPIView(PluginAuthMixin, APIView):
|
|||
if err:
|
||||
return Response({"error": err}, status=status.HTTP_400_BAD_REQUEST)
|
||||
_unmanage_dropped_slugs(repo, data)
|
||||
_invalidate_plugin_detail_cache(repo.id, data)
|
||||
return Response(PluginRepoSerializer(repo).data)
|
||||
|
||||
|
||||
|
|
@ -1017,6 +1140,12 @@ class PluginDetailManifestAPIView(PluginAuthMixin, APIView):
|
|||
_validate_fetch_url(manifest_url)
|
||||
except ValueError as e:
|
||||
return Response({"error": str(e)}, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
cache_key = f"plugin_detail:{repo_id}:{hashlib.md5(manifest_url.encode()).hexdigest()}"
|
||||
cached = cache.get(cache_key)
|
||||
if cached is not None:
|
||||
return Response(cached)
|
||||
|
||||
try:
|
||||
resp = http_requests.get(manifest_url, timeout=MANIFEST_FETCH_TIMEOUT)
|
||||
resp.raise_for_status()
|
||||
|
|
@ -1045,10 +1174,12 @@ class PluginDetailManifestAPIView(PluginAuthMixin, APIView):
|
|||
if url_val and not url_val.startswith(("http://", "https://")):
|
||||
manifest_obj["latest"][url_field] = f"{root_url}/{url_val}"
|
||||
|
||||
return Response({
|
||||
result = {
|
||||
"manifest": manifest_obj,
|
||||
"signature_verified": verified,
|
||||
})
|
||||
}
|
||||
cache.set(cache_key, result, PLUGIN_DETAIL_CACHE_TTL)
|
||||
return Response(result)
|
||||
except Exception as e:
|
||||
logger.exception("Failed to fetch plugin manifest from %s", manifest_url)
|
||||
return Response(
|
||||
|
|
|
|||
25
apps/plugins/migrations/0003_update_official_repo_url.py
Normal file
25
apps/plugins/migrations/0003_update_official_repo_url.py
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
from django.db import migrations
|
||||
|
||||
def update_url(apps, schema_editor):
|
||||
PluginRepo = apps.get_model("plugins", "PluginRepo")
|
||||
PluginRepo.objects.filter(is_official=True).update(
|
||||
url="https://dispatcharr.github.io/Plugins/manifest.json"
|
||||
)
|
||||
|
||||
|
||||
def revert_url(apps, schema_editor):
|
||||
PluginRepo = apps.get_model("plugins", "PluginRepo")
|
||||
PluginRepo.objects.filter(is_official=True).update(
|
||||
url="https://raw.githubusercontent.com/Dispatcharr/Plugins/releases/manifest.json"
|
||||
)
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("plugins", "0002_pluginrepo"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RunPython(update_url, revert_url),
|
||||
]
|
||||
|
|
@ -36,9 +36,7 @@ class PluginConfig(models.Model):
|
|||
return f"{self.name} ({self.key})"
|
||||
|
||||
|
||||
OFFICIAL_REPO_URL = (
|
||||
"https://raw.githubusercontent.com/Dispatcharr/Plugins/releases/manifest.json"
|
||||
)
|
||||
OFFICIAL_REPO_URL = "https://dispatcharr.github.io/Plugins/manifest.json"
|
||||
|
||||
|
||||
class PluginRepo(models.Model):
|
||||
|
|
|
|||
|
|
@ -429,7 +429,8 @@ class ChannelStatus:
|
|||
info['stream_name'] = stream_name
|
||||
|
||||
# Add data throughput information to basic info
|
||||
total_bytes_bytes = proxy_server.redis_client.hget(metadata_key, ChannelMetadataField.TOTAL_BYTES)
|
||||
# TOTAL_BYTES is already present in the hgetall result — avoid a redundant round-trip
|
||||
total_bytes_bytes = metadata.get(ChannelMetadataField.TOTAL_BYTES)
|
||||
if total_bytes_bytes:
|
||||
total_bytes = int(total_bytes_bytes)
|
||||
info['total_bytes'] = total_bytes
|
||||
|
|
@ -470,29 +471,31 @@ class ChannelStatus:
|
|||
|
||||
client_key = RedisKeys.client_metadata(channel_id, client_id)
|
||||
|
||||
# Fetch only the fields we need in one round-trip (hmget returns a list
|
||||
# in the same order as the requested keys; values are None if absent)
|
||||
ua, ip, connected_at, user_id, output_format, raw_profile_id = (
|
||||
proxy_server.redis_client.hmget(
|
||||
client_key,
|
||||
'user_agent', 'ip_address', 'connected_at', 'user_id',
|
||||
'output_format', 'output_profile_id',
|
||||
)
|
||||
)
|
||||
|
||||
client_info = {
|
||||
'client_id': client_id,
|
||||
'user_agent': ua,
|
||||
'output_format': output_format or 'mpegts',
|
||||
}
|
||||
|
||||
user_agent_bytes = proxy_server.redis_client.hget(client_key, 'user_agent')
|
||||
client_info['user_agent'] = user_agent_bytes
|
||||
if ip:
|
||||
client_info['ip_address'] = ip
|
||||
|
||||
ip_address_bytes = proxy_server.redis_client.hget(client_key, 'ip_address')
|
||||
if ip_address_bytes:
|
||||
client_info['ip_address'] = ip_address_bytes
|
||||
if connected_at:
|
||||
client_info['connected_at'] = float(connected_at)
|
||||
|
||||
connected_at_bytes = proxy_server.redis_client.hget(client_key, 'connected_at')
|
||||
if connected_at_bytes:
|
||||
client_info['connected_at'] = float(connected_at_bytes)
|
||||
if user_id:
|
||||
client_info['user_id'] = user_id
|
||||
|
||||
user_id_bytes = proxy_server.redis_client.hget(client_key, 'user_id')
|
||||
if user_id_bytes:
|
||||
client_info['user_id'] = user_id_bytes
|
||||
|
||||
output_format = proxy_server.redis_client.hget(client_key, 'output_format')
|
||||
client_info['output_format'] = output_format or 'mpegts'
|
||||
|
||||
raw_profile_id = proxy_server.redis_client.hget(client_key, 'output_profile_id')
|
||||
if raw_profile_id and raw_profile_id not in ('None', '0', ''):
|
||||
client_info['output_profile_id'] = int(raw_profile_id)
|
||||
else:
|
||||
|
|
|
|||
|
|
@ -140,11 +140,8 @@ class StreamGenerator:
|
|||
self._cleanup()
|
||||
|
||||
def _wait_for_initialization(self):
|
||||
"""Wait for channel initialization to complete, sending keepalive packets."""
|
||||
initialization_start = time.time()
|
||||
max_init_wait = ConfigHelper.client_wait_timeout()
|
||||
keepalive_interval = 0.5
|
||||
last_keepalive = 0
|
||||
proxy_server = ProxyServer.get_instance()
|
||||
|
||||
while time.time() - initialization_start < max_init_wait:
|
||||
|
|
@ -169,16 +166,7 @@ class StreamGenerator:
|
|||
yield create_ts_packet('error', "Error: Channel is stopping")
|
||||
return False
|
||||
|
||||
# Send PAT+PMT+null so clients recognise a valid TS program and keep
|
||||
# buffering instead of timing out from missing program info.
|
||||
if time.time() - last_keepalive >= keepalive_interval:
|
||||
logger.debug(f"[{self.client_id}] Sending keepalive during initialization")
|
||||
keepalive_data = create_ts_packet('null')
|
||||
yield keepalive_data
|
||||
self.bytes_sent += len(keepalive_data)
|
||||
last_keepalive = time.time()
|
||||
else:
|
||||
gevent.sleep(0.1)
|
||||
gevent.sleep(0.1)
|
||||
|
||||
logger.warning(f"[{self.client_id}] Timed out waiting for initialization")
|
||||
yield create_ts_packet('error', "Error: Initialization timeout")
|
||||
|
|
|
|||
|
|
@ -1,6 +1,5 @@
|
|||
import logging
|
||||
import re
|
||||
import struct
|
||||
from urllib.parse import urlparse
|
||||
import inspect
|
||||
|
||||
|
|
@ -58,53 +57,6 @@ def get_client_ip(request):
|
|||
ip = request.META.get('REMOTE_ADDR')
|
||||
return ip
|
||||
|
||||
def _mpeg_crc32(data):
|
||||
crc = 0xFFFFFFFF
|
||||
for b in data:
|
||||
crc ^= b << 24
|
||||
for _ in range(8):
|
||||
if crc & 0x80000000:
|
||||
crc = ((crc << 1) ^ 0x04C11DB7) & 0xFFFFFFFF
|
||||
else:
|
||||
crc = (crc << 1) & 0xFFFFFFFF
|
||||
return crc
|
||||
|
||||
|
||||
def create_ts_pat_pmt_packets():
|
||||
"""
|
||||
Return two valid TS packets: PAT (PID 0x0000) and PMT (PID 0x0100).
|
||||
|
||||
Declares program 1 with an H.264 video track at PID 0x0101.
|
||||
TS clients like VLC need PAT/PMT to recognise a stream as valid; without
|
||||
them they time out waiting for program info even while receiving null packets.
|
||||
Returns exactly 376 bytes (2 x 188-byte TS packets).
|
||||
"""
|
||||
# PAT section: program 1 mapped to PMT at PID 0x0100
|
||||
pat_body = bytes([
|
||||
0x00, 0xB0, 0x0D, # table_id=PAT, section_length=13
|
||||
0x00, 0x01, # transport_stream_id=1
|
||||
0xC1, 0x00, 0x00, # version=0, current=1, section 0/0
|
||||
0x00, 0x01, # program_number=1
|
||||
0xE1, 0x00, # PMT PID=0x0100 (reserved 0b111 | PID)
|
||||
])
|
||||
pat_body += struct.pack('>I', _mpeg_crc32(pat_body))
|
||||
pat_packet = bytes([0x47, 0x40, 0x00, 0x10, 0x00]) + pat_body + bytes([0xFF] * (183 - len(pat_body)))
|
||||
|
||||
# PMT section: program 1, H.264 video at PID 0x0101
|
||||
pmt_body = bytes([
|
||||
0x02, 0xB0, 0x12, # table_id=PMT, section_length=18
|
||||
0x00, 0x01, # program_number=1
|
||||
0xC1, 0x00, 0x00, # version=0, current=1, section 0/0
|
||||
0xE1, 0x01, # PCR_PID=0x0101
|
||||
0xF0, 0x00, # program_info_length=0
|
||||
0x1B, 0xE1, 0x01, 0xF0, 0x00, # stream_type=H.264, PID=0x0101
|
||||
])
|
||||
pmt_body += struct.pack('>I', _mpeg_crc32(pmt_body))
|
||||
pmt_packet = bytes([0x47, 0x41, 0x00, 0x10, 0x00]) + pmt_body + bytes([0xFF] * (183 - len(pmt_body)))
|
||||
|
||||
return pat_packet + pmt_packet
|
||||
|
||||
|
||||
def create_ts_packet(packet_type='null', message=None):
|
||||
"""
|
||||
Create a Transport Stream (TS) packet for various purposes.
|
||||
|
|
|
|||
|
|
@ -467,14 +467,11 @@ def stream_ts(request, channel_id, user=None, force_output_format=None):
|
|||
|
||||
if proxy_server.redis_client:
|
||||
metadata_key = RedisKeys.channel_metadata(channel_id)
|
||||
url_bytes = proxy_server.redis_client.hget(
|
||||
metadata_key, ChannelMetadataField.URL
|
||||
)
|
||||
ua_bytes = proxy_server.redis_client.hget(
|
||||
metadata_key, ChannelMetadataField.USER_AGENT
|
||||
)
|
||||
profile_bytes = proxy_server.redis_client.hget(
|
||||
metadata_key, ChannelMetadataField.STREAM_PROFILE
|
||||
url_bytes, ua_bytes, profile_bytes = proxy_server.redis_client.hmget(
|
||||
metadata_key,
|
||||
ChannelMetadataField.URL,
|
||||
ChannelMetadataField.USER_AGENT,
|
||||
ChannelMetadataField.STREAM_PROFILE,
|
||||
)
|
||||
|
||||
if url_bytes:
|
||||
|
|
@ -629,7 +626,12 @@ def stream_xc(request, username, password, channel_id):
|
|||
else:
|
||||
channel = get_object_or_404(Channel, id=channel_id)
|
||||
|
||||
force_format = 'fmp4' if extension.lower() == '.mp4' else 'mpegts'
|
||||
if extension.lower() == '.mp4':
|
||||
force_format = 'fmp4'
|
||||
elif extension.lower() == '.ts':
|
||||
force_format = 'mpegts'
|
||||
else:
|
||||
force_format = None
|
||||
return stream_ts(request._request, str(channel.uuid), user, force_output_format=force_format)
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -98,8 +98,7 @@ def get_user_active_connections(user_id):
|
|||
channel_id = parts[2]
|
||||
client_id = parts[4]
|
||||
|
||||
client_user_id = redis_client.hget(key, 'user_id')
|
||||
connected_at = redis_client.hget(key, 'connected_at')
|
||||
client_user_id, connected_at = redis_client.hmget(key, 'user_id', 'connected_at')
|
||||
|
||||
logger.debug(f"[stream limits] user_id = {user_id}")
|
||||
logger.debug(f"[stream limits] channel_id = {channel_id}")
|
||||
|
|
@ -124,9 +123,9 @@ def get_user_active_connections(user_id):
|
|||
if len(parts) >= 2:
|
||||
client_id = parts[1]
|
||||
|
||||
client_user_id = redis_client.hget(key, 'user_id')
|
||||
connected_at = redis_client.hget(key, 'created_at')
|
||||
content_uuid = redis_client.hget(key, 'content_uuid')
|
||||
client_user_id, connected_at, content_uuid = redis_client.hmget(
|
||||
key, 'user_id', 'created_at', 'content_uuid'
|
||||
)
|
||||
|
||||
logger.debug(f"[stream limits] user_id = {user_id}")
|
||||
logger.debug(f"[stream limits] client_id = {client_id}")
|
||||
|
|
|
|||
225
apps/proxy/vod_proxy/tests/test_streamid_fallback.py
Normal file
225
apps/proxy/vod_proxy/tests/test_streamid_fallback.py
Normal file
|
|
@ -0,0 +1,225 @@
|
|||
"""
|
||||
Tests for `_get_content_and_relation`'s graceful stream_id fallback when the
|
||||
VOD content UUID has been orphaned by an import-time refresh.
|
||||
|
||||
Context (see #961 / closed #973): `process_movie_batch` and `process_series_batch`
|
||||
can create duplicate `vod_movie` / `vod_episode` records during a refresh and
|
||||
repoint existing `M3U*Relation` rows at the new records. The old UUIDs that
|
||||
external players (Emby / Jellyfin / ChannelsDVR) cached in `.strm` URLs are
|
||||
left orphaned, and the proxy then 404s — even though the same request carries
|
||||
a stable `stream_id` that uniquely identifies a live relation.
|
||||
|
||||
These tests cover the read-side fallback that resolves content via that
|
||||
stream_id when the UUID lookup misses, leaving the existing UUID-first path
|
||||
unchanged for the happy case. Both branches (movie + episode) exercise:
|
||||
|
||||
* UUID hit (no fallback fires) — happy path unchanged
|
||||
* UUID miss + stream_id present → resolved via stream_id, [STREAMID-FALLBACK]
|
||||
logged at WARNING
|
||||
* UUID miss + stream_id present + preferred_m3u_account_id → strictest-first
|
||||
account match preferred
|
||||
* UUID miss + stream_id present but no relation matches → Http404 with both
|
||||
identifiers in the message
|
||||
* UUID miss + no stream_id → Http404 (no fallback attempt)
|
||||
"""
|
||||
|
||||
import logging
|
||||
from unittest.mock import MagicMock, patch
|
||||
from django.test import SimpleTestCase
|
||||
from django.http import Http404
|
||||
|
||||
|
||||
# ---------- Movie branch --------------------------------------------------
|
||||
|
||||
class TestStreamIdFallbackMovie(SimpleTestCase):
|
||||
"""Movie UUID dead -> fall back to M3UMovieRelation.stream_id."""
|
||||
|
||||
def _call(self, **kwargs):
|
||||
# Imported inside each test so the module-level Movie / Episode /
|
||||
# M3U*Relation references can be patched per test without leaking.
|
||||
from apps.proxy.vod_proxy.views import _get_content_and_relation
|
||||
return _get_content_and_relation(
|
||||
kwargs.pop('content_type', 'movie'),
|
||||
kwargs.pop('content_id', 'dead-uuid'),
|
||||
preferred_m3u_account_id=kwargs.pop('preferred_m3u_account_id', None),
|
||||
preferred_stream_id=kwargs.pop('preferred_stream_id', None),
|
||||
)
|
||||
|
||||
def test_uuid_hit_no_fallback_attempted(self):
|
||||
"""When the UUID resolves, the M3UMovieRelation table is never queried
|
||||
for fallback purposes — the existing happy-path behaviour is preserved
|
||||
and only the stream_id-specific relation selection runs."""
|
||||
live_movie = MagicMock(name='Movie', uuid='live-uuid', id=42)
|
||||
live_movie.name = 'Live Movie'
|
||||
# The existing relation-selection logic walks
|
||||
# content_obj.m3u_relations; give it a relation matching the requested
|
||||
# stream_id so we exit cleanly.
|
||||
live_relation = MagicMock(stream_id='S1')
|
||||
live_relation.m3u_account.name = 'AcmeProvider'
|
||||
live_movie.m3u_relations.filter.return_value.filter.return_value.first.return_value = live_relation
|
||||
|
||||
with patch('apps.proxy.vod_proxy.views.Movie') as MovieMock, \
|
||||
patch('apps.proxy.vod_proxy.views.M3UMovieRelation') as RelMock:
|
||||
MovieMock.objects.filter.return_value.first.return_value = live_movie
|
||||
content, relation = self._call(
|
||||
content_type='movie', content_id='live-uuid', preferred_stream_id='S1',
|
||||
)
|
||||
self.assertIs(content, live_movie)
|
||||
self.assertIs(relation, live_relation)
|
||||
# Fallback path must not have queried the relation table directly
|
||||
# — happy path is unchanged.
|
||||
RelMock.objects.filter.assert_not_called()
|
||||
|
||||
def test_uuid_miss_resolves_via_stream_id(self):
|
||||
"""UUID lookup returns None; stream_id finds an active relation; the
|
||||
recovered movie is returned and the fallback line is logged."""
|
||||
recovered_movie = MagicMock(name='Movie', uuid='new-uuid', id=99)
|
||||
recovered_movie.name = 'Recovered Movie'
|
||||
fallback_rel = MagicMock(movie=recovered_movie)
|
||||
fallback_rel.m3u_account.name = 'AcmeProvider'
|
||||
# The fallback only sets content_obj; the existing relation-selection
|
||||
# logic then re-discovers the same relation via the reverse FK.
|
||||
recovered_movie.m3u_relations.filter.return_value.filter.return_value.first.return_value = fallback_rel
|
||||
|
||||
with patch('apps.proxy.vod_proxy.views.Movie') as MovieMock, \
|
||||
patch('apps.proxy.vod_proxy.views.M3UMovieRelation') as RelMock, \
|
||||
self.assertLogs('apps.proxy.vod_proxy.views', level='WARNING') as logs:
|
||||
MovieMock.objects.filter.return_value.first.return_value = None
|
||||
# The non-account-scoped fallback chain returns our rel.
|
||||
RelMock.objects.filter.return_value.select_related.return_value.order_by.return_value.first.return_value = fallback_rel
|
||||
content, _ = self._call(
|
||||
content_type='movie', content_id='dead-uuid', preferred_stream_id='S1',
|
||||
)
|
||||
self.assertIs(content, recovered_movie)
|
||||
self.assertTrue(
|
||||
any('[STREAMID-FALLBACK]' in m for m in logs.output),
|
||||
f"expected [STREAMID-FALLBACK] in warnings, got: {logs.output}",
|
||||
)
|
||||
|
||||
def test_uuid_miss_prefers_requested_account_first(self):
|
||||
"""When preferred_m3u_account_id is set AND a matching relation exists
|
||||
on that account, it must be chosen ahead of the unrestricted ordered
|
||||
fallback. This is the strictest-match-first contract."""
|
||||
preferred_movie = MagicMock(name='PreferredMovie', uuid='preferred-uuid', id=1)
|
||||
preferred_movie.name = 'Preferred'
|
||||
preferred_rel = MagicMock(movie=preferred_movie)
|
||||
preferred_rel.m3u_account.name = 'Preferred'
|
||||
preferred_movie.m3u_relations.filter.return_value.filter.return_value.first.return_value = preferred_rel
|
||||
|
||||
with patch('apps.proxy.vod_proxy.views.Movie') as MovieMock, \
|
||||
patch('apps.proxy.vod_proxy.views.M3UMovieRelation') as RelMock:
|
||||
MovieMock.objects.filter.return_value.first.return_value = None
|
||||
|
||||
# Two distinct fallback chains share the same RelMock — we
|
||||
# distinguish them by which `.filter(...)` call they emerged from.
|
||||
# The account-scoped query is the FIRST .filter() call (with the
|
||||
# m3u_account_id kw); the unrestricted ordered query is the SECOND.
|
||||
unrestricted_movie = MagicMock(uuid='other-uuid', id=2)
|
||||
unrestricted_movie.name = 'OtherMovie'
|
||||
unrestricted_rel = MagicMock(movie=unrestricted_movie)
|
||||
|
||||
def filter_router(**kwargs):
|
||||
# First chain: scoped to m3u_account_id -> returns preferred_rel
|
||||
if 'm3u_account_id' in kwargs:
|
||||
chain = MagicMock()
|
||||
chain.select_related.return_value.first.return_value = preferred_rel
|
||||
return chain
|
||||
# Second chain: no account scope -> returns unrestricted_rel
|
||||
chain = MagicMock()
|
||||
chain.select_related.return_value.order_by.return_value.first.return_value = unrestricted_rel
|
||||
return chain
|
||||
RelMock.objects.filter.side_effect = filter_router
|
||||
|
||||
content, _ = self._call(
|
||||
content_type='movie',
|
||||
content_id='dead-uuid',
|
||||
preferred_stream_id='S1',
|
||||
preferred_m3u_account_id=7,
|
||||
)
|
||||
# The account-scoped relation wins; the unrestricted-ordered one
|
||||
# is never consulted because the strict match succeeded.
|
||||
self.assertIs(content, preferred_movie)
|
||||
|
||||
def test_uuid_miss_with_no_stream_id_raises_404(self):
|
||||
with patch('apps.proxy.vod_proxy.views.Movie') as MovieMock, \
|
||||
patch('apps.proxy.vod_proxy.views.M3UMovieRelation') as RelMock:
|
||||
MovieMock.objects.filter.return_value.first.return_value = None
|
||||
content, relation = self._call(
|
||||
content_type='movie', content_id='dead-uuid', preferred_stream_id=None,
|
||||
)
|
||||
# _get_content_and_relation swallows exceptions and returns
|
||||
# (None, None) for any error including Http404 — caller checks for
|
||||
# that. Verify the fallback was NEVER attempted.
|
||||
self.assertIsNone(content)
|
||||
self.assertIsNone(relation)
|
||||
RelMock.objects.filter.assert_not_called()
|
||||
|
||||
def test_uuid_miss_with_no_matching_relation_raises_404(self):
|
||||
with patch('apps.proxy.vod_proxy.views.Movie') as MovieMock, \
|
||||
patch('apps.proxy.vod_proxy.views.M3UMovieRelation') as RelMock:
|
||||
MovieMock.objects.filter.return_value.first.return_value = None
|
||||
# Both the account-scoped and unrestricted chains return None.
|
||||
RelMock.objects.filter.return_value.select_related.return_value.order_by.return_value.first.return_value = None
|
||||
RelMock.objects.filter.return_value.select_related.return_value.first.return_value = None
|
||||
content, relation = self._call(
|
||||
content_type='movie',
|
||||
content_id='dead-uuid',
|
||||
preferred_stream_id='ghost-stream',
|
||||
)
|
||||
self.assertIsNone(content)
|
||||
self.assertIsNone(relation)
|
||||
|
||||
|
||||
# ---------- Episode branch ------------------------------------------------
|
||||
|
||||
class TestStreamIdFallbackEpisode(SimpleTestCase):
|
||||
"""Episode UUID dead -> fall back to M3UEpisodeRelation.stream_id.
|
||||
|
||||
Same contract as the movie branch; less duplication of edge cases since
|
||||
the code paths are intentionally symmetric.
|
||||
"""
|
||||
|
||||
def _call(self, **kwargs):
|
||||
from apps.proxy.vod_proxy.views import _get_content_and_relation
|
||||
return _get_content_and_relation(
|
||||
'episode',
|
||||
kwargs.pop('content_id', 'dead-uuid'),
|
||||
preferred_m3u_account_id=kwargs.pop('preferred_m3u_account_id', None),
|
||||
preferred_stream_id=kwargs.pop('preferred_stream_id', None),
|
||||
)
|
||||
|
||||
def test_uuid_miss_resolves_via_stream_id(self):
|
||||
recovered_episode = MagicMock(uuid='new-uuid', id=77)
|
||||
recovered_episode.name = 'Recovered S01E01'
|
||||
recovered_episode.series.name = 'Recovered Show'
|
||||
fallback_rel = MagicMock(episode=recovered_episode)
|
||||
fallback_rel.m3u_account.name = 'AcmeProvider'
|
||||
recovered_episode.m3u_relations.filter.return_value.filter.return_value.first.return_value = fallback_rel
|
||||
|
||||
with patch('apps.proxy.vod_proxy.views.Episode') as EpisodeMock, \
|
||||
patch('apps.proxy.vod_proxy.views.M3UEpisodeRelation') as RelMock, \
|
||||
self.assertLogs('apps.proxy.vod_proxy.views', level='WARNING') as logs:
|
||||
EpisodeMock.objects.filter.return_value.first.return_value = None
|
||||
RelMock.objects.filter.return_value.select_related.return_value.order_by.return_value.first.return_value = fallback_rel
|
||||
content, _ = self._call(content_id='dead-uuid', preferred_stream_id='S99')
|
||||
self.assertIs(content, recovered_episode)
|
||||
self.assertTrue(
|
||||
any('[STREAMID-FALLBACK]' in m and 'Episode' in m for m in logs.output),
|
||||
f"expected episode-flavoured [STREAMID-FALLBACK] warning, got: {logs.output}",
|
||||
)
|
||||
|
||||
def test_uuid_hit_no_fallback_attempted(self):
|
||||
live_episode = MagicMock(uuid='live-uuid', id=42)
|
||||
live_episode.name = 'Live S01E02'
|
||||
live_episode.series.name = 'Live Show'
|
||||
live_relation = MagicMock(stream_id='S2')
|
||||
live_relation.m3u_account.name = 'AcmeProvider'
|
||||
live_episode.m3u_relations.filter.return_value.filter.return_value.first.return_value = live_relation
|
||||
|
||||
with patch('apps.proxy.vod_proxy.views.Episode') as EpisodeMock, \
|
||||
patch('apps.proxy.vod_proxy.views.M3UEpisodeRelation') as RelMock:
|
||||
EpisodeMock.objects.filter.return_value.first.return_value = live_episode
|
||||
content, relation = self._call(content_id='live-uuid', preferred_stream_id='S2')
|
||||
self.assertIs(content, live_episode)
|
||||
self.assertIs(relation, live_relation)
|
||||
RelMock.objects.filter.assert_not_called()
|
||||
|
|
@ -7,17 +7,20 @@ import time
|
|||
import random
|
||||
import logging
|
||||
import requests
|
||||
from urllib.parse import urlencode
|
||||
from django.http import JsonResponse, Http404, HttpResponse
|
||||
from django.shortcuts import get_object_or_404
|
||||
from django.views.decorators.csrf import csrf_exempt
|
||||
from apps.vod.models import Movie, Series, Episode
|
||||
from apps.vod.models import Movie, Series, Episode, M3UMovieRelation, M3UEpisodeRelation
|
||||
from apps.m3u.models import M3UAccountProfile
|
||||
from apps.proxy.vod_proxy.multi_worker_connection_manager import MultiWorkerVODConnectionManager, infer_content_type_from_url, get_vod_client_stop_key
|
||||
from .utils import get_client_info
|
||||
from rest_framework.decorators import api_view, permission_classes
|
||||
from rest_framework.decorators import api_view, authentication_classes, permission_classes
|
||||
from rest_framework.permissions import AllowAny
|
||||
from apps.accounts.models import User
|
||||
from apps.accounts.permissions import IsAdmin
|
||||
from rest_framework_simplejwt.authentication import JWTAuthentication
|
||||
from apps.accounts.authentication import ApiKeyAuthentication, QueryParamJWTAuthentication
|
||||
from apps.proxy.utils import check_user_stream_limits
|
||||
from dispatcharr.utils import network_access_allowed
|
||||
|
||||
|
|
@ -36,7 +39,49 @@ def _get_content_and_relation(content_type, content_id, preferred_m3u_account_id
|
|||
logger.info(f"[CONTENT-LOOKUP] Preferred stream ID: {preferred_stream_id}")
|
||||
|
||||
if content_type == 'movie':
|
||||
content_obj = get_object_or_404(Movie, uuid=content_id)
|
||||
content_obj = Movie.objects.filter(uuid=content_id).first()
|
||||
if content_obj is None and preferred_stream_id:
|
||||
# UUIDs are regenerated when process_movie_batch
|
||||
# (apps/vod/tasks.py) creates duplicate vod_movie records
|
||||
# during refresh — see #961 / #973. stream_id is stable
|
||||
# (unique per (m3u_account, stream_id)) so it's a safe
|
||||
# fallback for previously-cached external player URLs.
|
||||
# Strictest-match first: prefer the requested account, then
|
||||
# any active account by priority (matches the existing
|
||||
# relation-selection ordering below).
|
||||
rel = None
|
||||
if preferred_m3u_account_id:
|
||||
rel = (
|
||||
M3UMovieRelation.objects
|
||||
.filter(stream_id=preferred_stream_id,
|
||||
m3u_account_id=preferred_m3u_account_id,
|
||||
m3u_account__is_active=True)
|
||||
.select_related('movie', 'm3u_account')
|
||||
.first()
|
||||
)
|
||||
if rel is None:
|
||||
rel = (
|
||||
M3UMovieRelation.objects
|
||||
.filter(stream_id=preferred_stream_id,
|
||||
m3u_account__is_active=True)
|
||||
.select_related('movie', 'm3u_account')
|
||||
.order_by('-m3u_account__priority', 'id')
|
||||
.first()
|
||||
)
|
||||
if rel is not None:
|
||||
content_obj = rel.movie
|
||||
logger.warning(
|
||||
f"[STREAMID-FALLBACK] Movie UUID {content_id} not "
|
||||
f"found; resolved via stream_id "
|
||||
f"{preferred_stream_id} -> movie uuid "
|
||||
f"{content_obj.uuid} (provider: "
|
||||
f"{rel.m3u_account.name})"
|
||||
)
|
||||
if content_obj is None:
|
||||
raise Http404(
|
||||
f"Movie not found by uuid {content_id} "
|
||||
f"or stream_id {preferred_stream_id}"
|
||||
)
|
||||
logger.info(f"[CONTENT-FOUND] Movie: {content_obj.name} (ID: {content_obj.id})")
|
||||
|
||||
# Filter by preferred stream ID first (most specific)
|
||||
|
|
@ -67,7 +112,44 @@ def _get_content_and_relation(content_type, content_id, preferred_m3u_account_id
|
|||
return content_obj, relation
|
||||
|
||||
elif content_type == 'episode':
|
||||
content_obj = get_object_or_404(Episode, uuid=content_id)
|
||||
content_obj = Episode.objects.filter(uuid=content_id).first()
|
||||
if content_obj is None and preferred_stream_id:
|
||||
# Same rationale as the movie branch above — episode UUIDs
|
||||
# are regenerated when process_series_batch creates
|
||||
# duplicate vod_episode records during refresh.
|
||||
rel = None
|
||||
if preferred_m3u_account_id:
|
||||
rel = (
|
||||
M3UEpisodeRelation.objects
|
||||
.filter(stream_id=preferred_stream_id,
|
||||
m3u_account_id=preferred_m3u_account_id,
|
||||
m3u_account__is_active=True)
|
||||
.select_related('episode', 'm3u_account')
|
||||
.first()
|
||||
)
|
||||
if rel is None:
|
||||
rel = (
|
||||
M3UEpisodeRelation.objects
|
||||
.filter(stream_id=preferred_stream_id,
|
||||
m3u_account__is_active=True)
|
||||
.select_related('episode', 'm3u_account')
|
||||
.order_by('-m3u_account__priority', 'id')
|
||||
.first()
|
||||
)
|
||||
if rel is not None:
|
||||
content_obj = rel.episode
|
||||
logger.warning(
|
||||
f"[STREAMID-FALLBACK] Episode UUID {content_id} not "
|
||||
f"found; resolved via stream_id "
|
||||
f"{preferred_stream_id} -> episode uuid "
|
||||
f"{content_obj.uuid} (provider: "
|
||||
f"{rel.m3u_account.name})"
|
||||
)
|
||||
if content_obj is None:
|
||||
raise Http404(
|
||||
f"Episode not found by uuid {content_id} "
|
||||
f"or stream_id {preferred_stream_id}"
|
||||
)
|
||||
logger.info(f"[CONTENT-FOUND] Episode: {content_obj.name} (ID: {content_obj.id}, Series: {content_obj.series.name})")
|
||||
|
||||
# Filter by preferred stream ID first (most specific)
|
||||
|
|
@ -298,6 +380,7 @@ def _transform_url(original_url, m3u_profile):
|
|||
return original_url
|
||||
|
||||
@api_view(["GET"])
|
||||
@authentication_classes([JWTAuthentication, ApiKeyAuthentication, QueryParamJWTAuthentication])
|
||||
@permission_classes([AllowAny])
|
||||
def stream_vod(request, content_type, content_id, session_id=None, profile_id=None, user=None):
|
||||
"""
|
||||
|
|
@ -311,7 +394,8 @@ def stream_vod(request, content_type, content_id, session_id=None, profile_id=No
|
|||
"""
|
||||
if not network_access_allowed(request, "STREAMS"):
|
||||
return JsonResponse({"error": "Forbidden"}, status=403)
|
||||
|
||||
if user is None and hasattr(request, "user") and request.user.is_authenticated:
|
||||
user = request.user
|
||||
logger.info(f"[VOD-REQUEST] Starting VOD stream request: {content_type}/{content_id}, session: {session_id}, profile: {profile_id}")
|
||||
logger.info(f"[VOD-REQUEST] Full request path: {request.get_full_path()}")
|
||||
logger.info(f"[VOD-REQUEST] Request method: {request.method}")
|
||||
|
|
@ -389,39 +473,66 @@ def stream_vod(request, content_type, content_id, session_id=None, profile_id=No
|
|||
new_session_id = f"vod_{int(time.time() * 1000)}_{random.randint(1000, 9999)}"
|
||||
logger.info(f"[VOD-SESSION] Creating new session: {new_session_id}")
|
||||
|
||||
# Preserve any query parameters (except session_id)
|
||||
# Preserve any query parameters (except session_id and token)
|
||||
query_params = dict(request.GET)
|
||||
query_params.pop('session_id', None) # Remove if present
|
||||
query_params.pop('session_id', None)
|
||||
query_params.pop('token', None) # Token not needed after session is established
|
||||
|
||||
if user:
|
||||
redirect_url = f"{request.path}?session_id={new_session_id}"
|
||||
if query_params:
|
||||
query_string = urlencode(query_params, doseq=True)
|
||||
redirect_url = f"{redirect_url}&{query_string}"
|
||||
else:
|
||||
# Build redirect URL with session ID in path, preserve query parameters
|
||||
# The VOD proxy URL patterns accept session_id in the path, so we redirect
|
||||
# to a path-based URL. XC endpoints (/movie/<user>/<pass>/<id>.<ext>) have
|
||||
# a fixed shape and instead read session_id from a query parameter.
|
||||
is_vod_proxy_path = request.path.startswith('/proxy/vod/')
|
||||
|
||||
if is_vod_proxy_path:
|
||||
path_parts = request.path.rstrip('/').split('/')
|
||||
|
||||
# Construct new path: /vod/movie/UUID/SESSION_ID or /vod/movie/UUID/SESSION_ID/PROFILE_ID/
|
||||
if profile_id:
|
||||
new_path = f"{'/'.join(path_parts)}/{new_session_id}/{profile_id}/"
|
||||
else:
|
||||
new_path = f"{'/'.join(path_parts)}/{new_session_id}"
|
||||
|
||||
if query_params:
|
||||
from urllib.parse import urlencode
|
||||
query_string = urlencode(query_params, doseq=True)
|
||||
redirect_url = f"{new_path}?{query_string}"
|
||||
else:
|
||||
redirect_url = new_path
|
||||
else:
|
||||
# XC path: keep the original path, put session_id in the query string
|
||||
query_params['session_id'] = new_session_id
|
||||
query_string = urlencode(query_params, doseq=True)
|
||||
redirect_url = f"{request.path}?{query_string}"
|
||||
|
||||
logger.info(f"[VOD-SESSION] Redirecting to path-based URL: {redirect_url}")
|
||||
|
||||
# Persist the authenticated user to Redis so the streaming request
|
||||
# (which arrives without the token after the redirect) can resolve it.
|
||||
if user:
|
||||
try:
|
||||
from core.utils import RedisClient
|
||||
_r = RedisClient.get_client()
|
||||
if _r:
|
||||
_r.set(f"vod_session_user:{new_session_id}", user.id, ex=300)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return HttpResponse(
|
||||
status=301,
|
||||
headers={'Location': redirect_url}
|
||||
)
|
||||
|
||||
# Resolve user from Redis session mapping when the streaming request
|
||||
# arrives without auth credentials (token was stripped from redirect URL).
|
||||
# Only needed on the first streaming request - skip if connection already exists.
|
||||
if user is None:
|
||||
try:
|
||||
from core.utils import RedisClient
|
||||
_r = RedisClient.get_client()
|
||||
if _r and not _r.exists(f"vod_persistent_connection:{session_id}"):
|
||||
stored_uid = _r.get(f"vod_session_user:{session_id}")
|
||||
if stored_uid:
|
||||
user = User.objects.filter(id=int(stored_uid)).first()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
if user:
|
||||
if not check_user_stream_limits(user, session_id, media_id=content_id):
|
||||
return JsonResponse(
|
||||
|
|
@ -511,6 +622,7 @@ def stream_vod(request, content_type, content_id, session_id=None, profile_id=No
|
|||
return HttpResponse(f"Streaming error: {str(e)}", status=500)
|
||||
|
||||
@api_view(["HEAD"])
|
||||
@authentication_classes([JWTAuthentication, ApiKeyAuthentication, QueryParamJWTAuthentication])
|
||||
@permission_classes([AllowAny])
|
||||
def head_vod(request, content_type, content_id, session_id=None, profile_id=None):
|
||||
"""
|
||||
|
|
|
|||
|
|
@ -120,11 +120,30 @@ class MovieViewSet(viewsets.ReadOnlyModelViewSet):
|
|||
"""Get detailed movie information from the original provider, throttled to 24h."""
|
||||
movie = self.get_object()
|
||||
|
||||
# Get the highest priority active relation
|
||||
relation = M3UMovieRelation.objects.filter(
|
||||
relation_id = request.query_params.get('relation_id')
|
||||
if relation_id is not None:
|
||||
try:
|
||||
relation_id = int(relation_id)
|
||||
except (TypeError, ValueError):
|
||||
return Response(
|
||||
{'error': 'Invalid relation_id'},
|
||||
status=status.HTTP_400_BAD_REQUEST
|
||||
)
|
||||
|
||||
qs = M3UMovieRelation.objects.filter(
|
||||
movie=movie,
|
||||
m3u_account__is_active=True
|
||||
).select_related('m3u_account').order_by('-m3u_account__priority', 'id').first()
|
||||
).select_related('m3u_account')
|
||||
|
||||
if relation_id is not None:
|
||||
relation = qs.filter(id=relation_id).first()
|
||||
if not relation:
|
||||
return Response(
|
||||
{'error': 'Relation not found or not active'},
|
||||
status=status.HTTP_404_NOT_FOUND
|
||||
)
|
||||
else:
|
||||
relation = qs.order_by('-m3u_account__priority', 'id').first()
|
||||
|
||||
if not relation:
|
||||
return Response(
|
||||
|
|
@ -326,11 +345,30 @@ class SeriesViewSet(viewsets.ReadOnlyModelViewSet):
|
|||
series = self.get_object()
|
||||
logger.debug(f"Retrieved series: {series.name} (ID: {series.id})")
|
||||
|
||||
# Get the highest priority active relation
|
||||
relation = M3USeriesRelation.objects.filter(
|
||||
relation_id = request.query_params.get('relation_id')
|
||||
if relation_id is not None:
|
||||
try:
|
||||
relation_id = int(relation_id)
|
||||
except (TypeError, ValueError):
|
||||
return Response(
|
||||
{'error': 'Invalid relation_id'},
|
||||
status=status.HTTP_400_BAD_REQUEST
|
||||
)
|
||||
|
||||
qs = M3USeriesRelation.objects.filter(
|
||||
series=series,
|
||||
m3u_account__is_active=True
|
||||
).select_related('m3u_account').order_by('-m3u_account__priority', 'id').first()
|
||||
).select_related('m3u_account')
|
||||
|
||||
if relation_id is not None:
|
||||
relation = qs.filter(id=relation_id).first()
|
||||
if not relation:
|
||||
return Response(
|
||||
{'error': 'Relation not found or not active'},
|
||||
status=status.HTTP_404_NOT_FOUND
|
||||
)
|
||||
else:
|
||||
relation = qs.order_by('-m3u_account__priority', 'id').first()
|
||||
|
||||
if not relation:
|
||||
return Response(
|
||||
|
|
|
|||
|
|
@ -439,6 +439,26 @@ def process_movie_batch(account, batch, categories, relations, scan_start_time=N
|
|||
trailer = extract_string_from_array_or_string(trailer_raw) if trailer_raw else None
|
||||
logo_url = movie_data.get('stream_icon') or ''
|
||||
|
||||
director = extract_string_from_array_or_string(
|
||||
movie_data.get('director') or ''
|
||||
)
|
||||
actors_raw = movie_data.get('actors') or movie_data.get('cast') or ''
|
||||
if isinstance(actors_raw, list):
|
||||
actors = ', '.join(s.strip() for s in actors_raw if s and str(s).strip()) or None
|
||||
else:
|
||||
actors = actors_raw.strip() if actors_raw else None
|
||||
release_date = movie_data.get('release_date') or movie_data.get('releasedate') or ''
|
||||
|
||||
custom_props = {}
|
||||
if trailer:
|
||||
custom_props['youtube_trailer'] = trailer
|
||||
if director:
|
||||
custom_props['director'] = director
|
||||
if actors:
|
||||
custom_props['actors'] = actors
|
||||
if release_date:
|
||||
custom_props['release_date'] = release_date
|
||||
|
||||
movie_props = {
|
||||
'name': name,
|
||||
'year': year,
|
||||
|
|
@ -448,7 +468,7 @@ def process_movie_batch(account, batch, categories, relations, scan_start_time=N
|
|||
'rating': rating,
|
||||
'genre': genre,
|
||||
'duration_secs': duration_secs,
|
||||
'custom_properties': {'trailer': trailer} if trailer else None,
|
||||
'custom_properties': custom_props or None,
|
||||
}
|
||||
|
||||
movie_keys[movie_key] = {
|
||||
|
|
@ -548,8 +568,18 @@ def process_movie_batch(account, batch, categories, relations, scan_start_time=N
|
|||
|
||||
for field, value in movie_props.items():
|
||||
if field == 'custom_properties':
|
||||
if value != movie.custom_properties:
|
||||
movie.custom_properties = value
|
||||
# Merge: preserve advanced-refresh keys; don't overwrite director/actors/release_date if already set.
|
||||
existing_cp = movie.custom_properties or {}
|
||||
incoming_cp = value or {}
|
||||
merged = dict(existing_cp)
|
||||
for k, v in incoming_cp.items():
|
||||
if k in ('director', 'actors', 'release_date'):
|
||||
if not existing_cp.get(k):
|
||||
merged[k] = v
|
||||
else:
|
||||
merged[k] = v
|
||||
if merged != existing_cp:
|
||||
movie.custom_properties = merged
|
||||
updated = True
|
||||
elif getattr(movie, field) != value:
|
||||
setattr(movie, field, value)
|
||||
|
|
@ -564,8 +594,6 @@ def process_movie_batch(account, batch, categories, relations, scan_start_time=N
|
|||
movie._logo_to_update = new_logo
|
||||
logo_updated = True
|
||||
elif movie.logo_id:
|
||||
# Logo URL exists but logo creation failed or logo not found
|
||||
# Clear the orphaned logo reference
|
||||
logger.warning(f"Logo URL provided but logo not found in database for movie '{movie.name}', clearing logo reference")
|
||||
movie._logo_to_update = None
|
||||
logo_updated = True
|
||||
|
|
@ -784,7 +812,14 @@ def process_series_batch(account, batch, categories, relations, scan_start_time=
|
|||
value = series_data.get(key)
|
||||
if value:
|
||||
# For string-like fields that might be arrays, extract clean strings
|
||||
if key in ['poster_path', 'youtube_trailer', 'cast', 'director']:
|
||||
if key == 'cast':
|
||||
if isinstance(value, list):
|
||||
clean_value = ', '.join(s.strip() for s in value if s and str(s).strip()) or None
|
||||
else:
|
||||
clean_value = extract_string_from_array_or_string(value)
|
||||
if clean_value:
|
||||
additional_metadata[key] = clean_value
|
||||
elif key in ['poster_path', 'youtube_trailer', 'director']:
|
||||
clean_value = extract_string_from_array_or_string(value)
|
||||
if clean_value:
|
||||
additional_metadata[key] = clean_value
|
||||
|
|
@ -2156,26 +2191,25 @@ def refresh_movie_advanced_data(m3u_movie_relation_id, force_refresh=False):
|
|||
movie.imdb_id = imdb_id_to_set
|
||||
updated = True
|
||||
logger.debug(f"Set imdb_id {imdb_id_to_set} on movie {movie.id}")
|
||||
# Only update trailer if we have a non-empty value and either no existing value or existing value is empty
|
||||
if should_update_field(custom_props.get('youtube_trailer'), info.get('trailer')):
|
||||
custom_props['youtube_trailer'] = extract_string_from_array_or_string(info.get('trailer'))
|
||||
updated = True
|
||||
if should_update_field(custom_props.get('youtube_trailer'), info.get('youtube_trailer')):
|
||||
custom_props['youtube_trailer'] = extract_string_from_array_or_string(info.get('youtube_trailer'))
|
||||
updated = True
|
||||
# Only update backdrop_path if we have a non-empty value and either no existing value or existing value is empty
|
||||
if should_update_field(custom_props.get('backdrop_path'), info.get('backdrop_path')):
|
||||
backdrop_url = extract_string_from_array_or_string(info.get('backdrop_path'))
|
||||
custom_props['backdrop_path'] = [backdrop_url] if backdrop_url else None
|
||||
updated = True
|
||||
# Only update actors if we have a non-empty value and either no existing value or existing value is empty
|
||||
if should_update_field(custom_props.get('actors'), info.get('actors')):
|
||||
custom_props['actors'] = extract_string_from_array_or_string(info.get('actors'))
|
||||
updated = True
|
||||
if should_update_field(custom_props.get('actors'), info.get('cast')):
|
||||
custom_props['actors'] = extract_string_from_array_or_string(info.get('cast'))
|
||||
updated = True
|
||||
# Only update director if we have a non-empty value and either no existing value or existing value is empty
|
||||
for actors_key in ('actors', 'cast'):
|
||||
actors_raw = info.get(actors_key)
|
||||
if should_update_field(custom_props.get('actors'), actors_raw):
|
||||
if isinstance(actors_raw, list):
|
||||
custom_props['actors'] = ', '.join(s.strip() for s in actors_raw if s and str(s).strip()) or None
|
||||
else:
|
||||
custom_props['actors'] = extract_string_from_array_or_string(actors_raw)
|
||||
updated = True
|
||||
break
|
||||
if should_update_field(custom_props.get('director'), info.get('director')):
|
||||
custom_props['director'] = extract_string_from_array_or_string(info.get('director'))
|
||||
updated = True
|
||||
|
|
|
|||
|
|
@ -1,6 +1,5 @@
|
|||
# core/api_views.py
|
||||
|
||||
import json
|
||||
import ipaddress
|
||||
import logging
|
||||
from django.conf import settings as django_settings
|
||||
|
|
@ -8,11 +7,9 @@ from django.db import models
|
|||
from rest_framework import viewsets, status
|
||||
from rest_framework.response import Response
|
||||
from rest_framework.views import APIView
|
||||
from django.shortcuts import get_object_or_404
|
||||
from rest_framework.permissions import IsAuthenticated, AllowAny
|
||||
from rest_framework.decorators import api_view, permission_classes, action
|
||||
from drf_spectacular.utils import extend_schema, OpenApiParameter
|
||||
from drf_spectacular.types import OpenApiTypes
|
||||
from drf_spectacular.utils import extend_schema
|
||||
from .models import (
|
||||
UserAgent,
|
||||
StreamProfile,
|
||||
|
|
@ -32,8 +29,10 @@ from .serializers import (
|
|||
)
|
||||
|
||||
import socket
|
||||
import threading
|
||||
import requests
|
||||
import os
|
||||
from django.core.cache import cache
|
||||
from core.tasks import rehash_streams
|
||||
from apps.accounts.permissions import (
|
||||
Authenticated,
|
||||
|
|
@ -287,6 +286,73 @@ class ProxySettingsViewSet(viewsets.ViewSet):
|
|||
|
||||
|
||||
|
||||
_IP_CACHE_KEY = "dispatcharr:ip_lookup_result"
|
||||
_IP_CACHE_TTL = 3600 # 1 hour
|
||||
_IP_LOCK_KEY = "dispatcharr:ip_lookup_lock"
|
||||
|
||||
|
||||
def _perform_ip_lookup():
|
||||
"""Run IP and geolocation lookups in a background thread and cache the result."""
|
||||
public_ip = None
|
||||
local_ip = None
|
||||
country_code = None
|
||||
country_name = None
|
||||
city = None
|
||||
|
||||
try:
|
||||
r = requests.get("https://api64.ipify.org?format=json", timeout=5)
|
||||
r.raise_for_status()
|
||||
public_ip = r.json().get("ip")
|
||||
except requests.RequestException:
|
||||
pass
|
||||
|
||||
try:
|
||||
s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
||||
try:
|
||||
s.connect(("203.0.113.1", 80))
|
||||
local_ip = s.getsockname()[0]
|
||||
finally:
|
||||
s.close()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
try:
|
||||
ipaddress.ip_address(public_ip)
|
||||
except (ValueError, TypeError):
|
||||
public_ip = None
|
||||
|
||||
if public_ip:
|
||||
try:
|
||||
r = requests.get(f"https://ipapi.co/{public_ip}/json/", timeout=5)
|
||||
if r.status_code == requests.codes.ok:
|
||||
geo = r.json()
|
||||
country_code = geo.get("country_code")
|
||||
country_name = geo.get("country_name")
|
||||
city = geo.get("city")
|
||||
else:
|
||||
r = requests.get("http://ip-api.com/json/", timeout=5)
|
||||
if r.status_code == requests.codes.ok:
|
||||
geo = r.json()
|
||||
country_code = geo.get("countryCode")
|
||||
country_name = geo.get("country")
|
||||
city = geo.get("city")
|
||||
except Exception as e:
|
||||
logger.error(f"Error during geo lookup: {e}")
|
||||
|
||||
result = {
|
||||
"public_ip": public_ip,
|
||||
"local_ip": local_ip,
|
||||
"country_code": country_code,
|
||||
"country_name": country_name,
|
||||
"city": city,
|
||||
}
|
||||
cache.set(_IP_CACHE_KEY, result, _IP_CACHE_TTL)
|
||||
cache.delete(_IP_LOCK_KEY)
|
||||
|
||||
from core.utils import send_websocket_update
|
||||
send_websocket_update("updates", "update", {"type": "ip_lookup_complete", **result})
|
||||
|
||||
|
||||
@extend_schema(
|
||||
description="Endpoint for environment details",
|
||||
)
|
||||
|
|
@ -297,55 +363,27 @@ def environment(request):
|
|||
local_ip = None
|
||||
country_code = None
|
||||
country_name = None
|
||||
city = None
|
||||
ip_lookup_pending = False
|
||||
|
||||
# 1) Get the public IP from ipify.org API
|
||||
try:
|
||||
r = requests.get("https://api64.ipify.org?format=json", timeout=5)
|
||||
r.raise_for_status()
|
||||
public_ip = r.json().get("ip")
|
||||
except requests.RequestException as e:
|
||||
public_ip = f"Error: {e}"
|
||||
ip_lookup_env_disabled = not getattr(django_settings, "ENABLE_IP_LOOKUP", True)
|
||||
ip_lookup_db_enabled = CoreSettings.get_system_settings().get("enable_ip_lookup", True)
|
||||
ip_lookup_enabled = not ip_lookup_env_disabled and ip_lookup_db_enabled
|
||||
|
||||
# 2) Get the local IP by connecting to a public DNS server
|
||||
try:
|
||||
s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
||||
# connect to a "public" address so the OS can determine our local interface
|
||||
s.connect(("8.8.8.8", 80))
|
||||
local_ip = s.getsockname()[0]
|
||||
s.close()
|
||||
except Exception as e:
|
||||
local_ip = f"Error: {e}"
|
||||
if ip_lookup_enabled:
|
||||
cached = cache.get(_IP_CACHE_KEY)
|
||||
if cached is not None:
|
||||
public_ip = cached.get("public_ip")
|
||||
local_ip = cached.get("local_ip")
|
||||
country_code = cached.get("country_code")
|
||||
country_name = cached.get("country_name")
|
||||
city = cached.get("city")
|
||||
else:
|
||||
if cache.add(_IP_LOCK_KEY, True, 30):
|
||||
threading.Thread(target=_perform_ip_lookup, daemon=True).start()
|
||||
ip_lookup_pending = True
|
||||
|
||||
# 3) Get geolocation data from ipapi.co or ip-api.com
|
||||
if public_ip and "Error" not in public_ip:
|
||||
try:
|
||||
# Attempt to get geo information from ipapi.co first
|
||||
r = requests.get(f"https://ipapi.co/{public_ip}/json/", timeout=5)
|
||||
|
||||
if r.status_code == requests.codes.ok:
|
||||
geo = r.json()
|
||||
country_code = geo.get("country_code") # e.g. "US"
|
||||
country_name = geo.get("country_name") # e.g. "United States"
|
||||
|
||||
else:
|
||||
# If ipapi.co fails, fallback to ip-api.com
|
||||
# only supports http requests for free tier
|
||||
r = requests.get("http://ip-api.com/json/", timeout=5)
|
||||
|
||||
if r.status_code == requests.codes.ok:
|
||||
geo = r.json()
|
||||
country_code = geo.get("countryCode") # e.g. "US"
|
||||
country_name = geo.get("country") # e.g. "United States"
|
||||
|
||||
else:
|
||||
raise Exception("Geo lookup failed with both services")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error during geo lookup: {e}")
|
||||
country_code = None
|
||||
country_name = None
|
||||
|
||||
# 4) Get environment mode and TLS status from settings
|
||||
# Get environment mode and TLS status from settings
|
||||
postgres_ssl = getattr(django_settings, "POSTGRES_SSL", False)
|
||||
|
||||
return Response(
|
||||
|
|
@ -355,6 +393,10 @@ def environment(request):
|
|||
"local_ip": local_ip,
|
||||
"country_code": country_code,
|
||||
"country_name": country_name,
|
||||
"city": city,
|
||||
"ip_lookup_enabled": ip_lookup_enabled,
|
||||
"ip_lookup_env_disabled": ip_lookup_env_disabled,
|
||||
"ip_lookup_pending": ip_lookup_pending,
|
||||
"env_mode": os.getenv("DISPATCHARR_ENV", "aio"),
|
||||
"redis_tls": {
|
||||
"enabled": getattr(django_settings, "REDIS_SSL", False),
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import sys
|
||||
import psycopg2
|
||||
from psycopg2 import sql
|
||||
import psycopg
|
||||
from psycopg import sql
|
||||
from django.core.management.base import BaseCommand
|
||||
from django.conf import settings
|
||||
from django.db import connection
|
||||
|
|
@ -38,8 +38,7 @@ class Command(BaseCommand):
|
|||
maintenance_db = 'postgres'
|
||||
try:
|
||||
self.stdout.write("Connecting to maintenance database...")
|
||||
conn = psycopg2.connect(dbname=maintenance_db, user=user, password=password, host=host, port=port, **ssl_kwargs)
|
||||
conn.autocommit = True
|
||||
conn = psycopg.connect(dbname=maintenance_db, user=user, password=password, host=host, port=port, autocommit=True, **ssl_kwargs)
|
||||
cur = conn.cursor()
|
||||
self.stdout.write(f"Dropping database '{db_name}'...")
|
||||
cur.execute(sql.SQL("DROP DATABASE IF EXISTS {}").format(sql.Identifier(db_name)))
|
||||
|
|
|
|||
|
|
@ -407,6 +407,7 @@ class CoreSettings(models.Model):
|
|||
"max_system_events": 100,
|
||||
"preferred_region": None,
|
||||
"auto_import_mapped_files": True,
|
||||
"enable_ip_lookup": True,
|
||||
})
|
||||
|
||||
@classmethod
|
||||
|
|
|
|||
|
|
@ -388,9 +388,35 @@ def scan_and_process_files():
|
|||
}
|
||||
)
|
||||
|
||||
# Rebuild EPG programme indices on first run if missing (e.g. after migration or container restart)
|
||||
if not _first_scan_completed:
|
||||
_rebuild_programme_indices()
|
||||
|
||||
# Mark that the first scan is complete
|
||||
_first_scan_completed = True
|
||||
|
||||
def _rebuild_programme_indices():
|
||||
"""Queue index builds for active EPG sources that are missing their DB index."""
|
||||
try:
|
||||
from apps.epg.tasks import build_programme_index_task
|
||||
|
||||
sources = EPGSource.objects.filter(
|
||||
is_active=True,
|
||||
programme_index__isnull=True,
|
||||
).exclude(source_type__in=('dummy', 'schedules_direct'))
|
||||
|
||||
count = 0
|
||||
for source in sources:
|
||||
# The task acquires its own build lock; taking it here would make the task no-op.
|
||||
build_programme_index_task.delay(source.id)
|
||||
count += 1
|
||||
|
||||
if count:
|
||||
logger.info(f"Queued programme index rebuild for {count} EPG source(s)")
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to queue programme index rebuilds: {e}")
|
||||
|
||||
|
||||
def fetch_channel_stats():
|
||||
redis_client = RedisClient.get_client()
|
||||
|
||||
|
|
|
|||
|
|
@ -2,9 +2,58 @@ from unittest.mock import patch, MagicMock
|
|||
|
||||
from django.test import TestCase
|
||||
|
||||
from apps.epg.models import EPGSource
|
||||
from core.models import CoreSettings, DVR_SETTINGS_KEY, EPG_SETTINGS_KEY
|
||||
|
||||
|
||||
class ProgrammeIndexRebuildTests(TestCase):
|
||||
def test_startup_rebuild_does_not_lock_out_queued_build_task(self):
|
||||
EPGSource.objects.update(
|
||||
programme_index={"channels": {}, "interleaved_channels": []}
|
||||
)
|
||||
source = EPGSource.objects.create(
|
||||
name="Missing Index",
|
||||
source_type="xmltv",
|
||||
is_active=True,
|
||||
programme_index=None,
|
||||
)
|
||||
|
||||
class FakeRedis:
|
||||
def __init__(self):
|
||||
self.keys = set()
|
||||
|
||||
def set(self, key, value, nx=False, ex=None):
|
||||
if nx and key in self.keys:
|
||||
return False
|
||||
self.keys.add(key)
|
||||
return True
|
||||
|
||||
def delete(self, key):
|
||||
self.keys.discard(key)
|
||||
|
||||
fake_redis = FakeRedis()
|
||||
|
||||
from apps.epg.tasks import build_programme_index_task
|
||||
from core.tasks import _rebuild_programme_indices
|
||||
|
||||
def run_task_immediately(source_id):
|
||||
build_programme_index_task(source_id)
|
||||
|
||||
with patch(
|
||||
"core.tasks.RedisClient.get_client", return_value=fake_redis
|
||||
), patch(
|
||||
"core.utils.RedisClient.get_client", return_value=fake_redis
|
||||
), patch(
|
||||
"apps.epg.tasks.build_programme_index"
|
||||
) as mock_build, patch(
|
||||
"apps.epg.tasks.build_programme_index_task.delay",
|
||||
side_effect=run_task_immediately,
|
||||
):
|
||||
_rebuild_programme_indices()
|
||||
|
||||
mock_build.assert_called_once_with(source.id)
|
||||
|
||||
|
||||
class GetDvrSeriesRulesTest(TestCase):
|
||||
"""Verify get_dvr_series_rules handles corrupted stored data."""
|
||||
|
||||
|
|
@ -153,7 +202,7 @@ class EpgIgnoreListsTest(TestCase):
|
|||
|
||||
|
||||
class DropDBCommandTlsTest(TestCase):
|
||||
"""Verify dropdb management command passes TLS parameters to psycopg2."""
|
||||
"""Verify dropdb management command passes TLS parameters to psycopg."""
|
||||
databases = []
|
||||
|
||||
_DB_WITH_TLS = {
|
||||
|
|
@ -184,7 +233,7 @@ class DropDBCommandTlsTest(TestCase):
|
|||
}
|
||||
}
|
||||
|
||||
@patch('core.management.commands.dropdb.psycopg2.connect')
|
||||
@patch('core.management.commands.dropdb.psycopg.connect')
|
||||
@patch('core.management.commands.dropdb.connection')
|
||||
@patch('builtins.input', return_value='yes')
|
||||
def test_dropdb_passes_ssl_kwargs_when_tls_enabled(self, _inp, _conn, mock_connect):
|
||||
|
|
@ -199,13 +248,14 @@ class DropDBCommandTlsTest(TestCase):
|
|||
mock_connect.assert_called_once_with(
|
||||
dbname='postgres', user='testuser', password='testpass',
|
||||
host='localhost', port=5432,
|
||||
autocommit=True,
|
||||
sslmode='verify-full',
|
||||
sslrootcert='/certs/ca.crt',
|
||||
sslcert='/certs/client.crt',
|
||||
sslkey='/certs/client.key',
|
||||
)
|
||||
|
||||
@patch('core.management.commands.dropdb.psycopg2.connect')
|
||||
@patch('core.management.commands.dropdb.psycopg.connect')
|
||||
@patch('core.management.commands.dropdb.connection')
|
||||
@patch('builtins.input', return_value='yes')
|
||||
def test_dropdb_no_ssl_kwargs_when_tls_disabled(self, _inp, _conn, mock_connect):
|
||||
|
|
@ -220,4 +270,5 @@ class DropDBCommandTlsTest(TestCase):
|
|||
mock_connect.assert_called_once_with(
|
||||
dbname='postgres', user='testuser', password='testpass',
|
||||
host='localhost', port=5432,
|
||||
autocommit=True,
|
||||
)
|
||||
|
|
|
|||
131
core/utils.py
131
core/utils.py
|
|
@ -312,6 +312,34 @@ class TaskLockRenewer:
|
|||
return False
|
||||
|
||||
|
||||
def _is_gevent_monkey_patched():
|
||||
try:
|
||||
import gevent.monkey
|
||||
return gevent.monkey.is_module_patched('threading')
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
def _is_celery_worker_context():
|
||||
"""True when executing inside an active Celery task (prefork worker)."""
|
||||
try:
|
||||
from celery import current_task
|
||||
request = getattr(current_task, 'request', None)
|
||||
return bool(request and getattr(request, 'id', None))
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
def _should_use_sync_websocket_send():
|
||||
"""
|
||||
Use synchronous Redis delivery when gevent is monkey-patched but no gevent
|
||||
hub is driving the process — e.g. Celery prefork workers that inherit
|
||||
gevent patching from uWSGI imports. gevent.spawn in that context schedules
|
||||
coroutines that never run.
|
||||
"""
|
||||
return _is_gevent_monkey_patched() and _is_celery_worker_context()
|
||||
|
||||
|
||||
def _gevent_ws_send(group_name, message):
|
||||
"""
|
||||
Publishes a WebSocket group message synchronously through Redis.
|
||||
|
|
@ -363,6 +391,12 @@ def _gevent_ws_send(group_name, message):
|
|||
logger.warning(f"Failed to send WebSocket update: {e}")
|
||||
|
||||
|
||||
def send_websocket_update_sync(group_name, event_type, data):
|
||||
"""Send a WebSocket group message synchronously via Redis (channels_redis wire format)."""
|
||||
message = {'type': event_type, 'data': data}
|
||||
_gevent_ws_send(group_name, message)
|
||||
|
||||
|
||||
def send_websocket_update(group_name, event_type, data, collect_garbage=False):
|
||||
"""
|
||||
Sends a WebSocket group message.
|
||||
|
|
@ -370,28 +404,25 @@ def send_websocket_update(group_name, event_type, data, collect_garbage=False):
|
|||
In gevent-patched uWSGI workers, asyncio event loop creation fails because
|
||||
monkey-patching removes select.epoll. For those contexts a synchronous Redis
|
||||
path is used instead, matching the channels_redis 4.x wire format.
|
||||
|
||||
Celery prefork workers may inherit gevent monkey-patching without a running
|
||||
gevent hub; in that case gevent.spawn would never execute, so delivery is
|
||||
synchronous via Redis instead.
|
||||
"""
|
||||
channel_layer = get_channel_layer()
|
||||
message = {'type': event_type, 'data': data}
|
||||
|
||||
def _do_send():
|
||||
if _should_use_sync_websocket_send():
|
||||
_gevent_ws_send(group_name, message)
|
||||
elif _is_gevent_monkey_patched():
|
||||
import gevent
|
||||
gevent.spawn(_gevent_ws_send, group_name, message)
|
||||
else:
|
||||
# Not gevent-patched (plain Celery, tests) — use asyncio channel layer
|
||||
try:
|
||||
async_to_sync(channel_layer.group_send)(group_name, message)
|
||||
async_to_sync(get_channel_layer().group_send)(group_name, message)
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to send WebSocket update: {e}")
|
||||
|
||||
try:
|
||||
import gevent.monkey
|
||||
if gevent.monkey.is_module_patched('threading'):
|
||||
import gevent
|
||||
gevent.spawn(_gevent_ws_send, group_name, message)
|
||||
return
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Not in a gevent-patched environment (Celery, tests) — use asyncio path
|
||||
_do_send()
|
||||
|
||||
if collect_garbage:
|
||||
gc.collect()
|
||||
|
||||
|
|
@ -752,6 +783,76 @@ def send_websocket_notification(notification):
|
|||
logger.error(f"Failed to send WebSocket notification: {e}")
|
||||
|
||||
|
||||
def get_host_and_port(request):
|
||||
"""
|
||||
Returns (host, port) for building absolute URIs.
|
||||
- Prefers X-Forwarded-Host/X-Forwarded-Port (nginx).
|
||||
- Falls back to Host header.
|
||||
- Returns None for port if using standard ports (80/443) to omit from URLs.
|
||||
- In dev, uses 5656 as a guess if port cannot be determined.
|
||||
"""
|
||||
scheme = request.META.get("HTTP_X_FORWARDED_PROTO", request.scheme)
|
||||
standard_port = "443" if scheme == "https" else "80"
|
||||
|
||||
# 1. Try X-Forwarded-Host (may include port) - set by our nginx
|
||||
xfh = request.META.get("HTTP_X_FORWARDED_HOST")
|
||||
if xfh:
|
||||
if ":" in xfh:
|
||||
host, port = xfh.split(":", 1)
|
||||
if port == standard_port:
|
||||
return host, None
|
||||
return host, port
|
||||
else:
|
||||
host = xfh
|
||||
|
||||
port = request.META.get("HTTP_X_FORWARDED_PORT")
|
||||
if port:
|
||||
return host, None if port == standard_port else port
|
||||
if request.META.get("HTTP_X_FORWARDED_PROTO"):
|
||||
return host, None
|
||||
|
||||
# 2. Try Host header
|
||||
raw_host = request.get_host()
|
||||
if ":" in raw_host:
|
||||
host, port = raw_host.split(":", 1)
|
||||
return host, None if port == standard_port else port
|
||||
else:
|
||||
host = raw_host
|
||||
|
||||
# 3. Check for X-Forwarded-Port (when Host header has no port but we're behind a reverse proxy)
|
||||
port = request.META.get("HTTP_X_FORWARDED_PORT")
|
||||
if port:
|
||||
return host, None if port == standard_port else port
|
||||
|
||||
# 4. Behind a reverse proxy with no port info - assume standard port
|
||||
if request.META.get("HTTP_X_FORWARDED_PROTO") or request.META.get("HTTP_X_FORWARDED_FOR"):
|
||||
return host, None
|
||||
|
||||
# 5. Try SERVER_PORT from META (only if NOT behind reverse proxy)
|
||||
port = request.META.get("SERVER_PORT")
|
||||
if port:
|
||||
return host, None if port == standard_port else port
|
||||
|
||||
# 6. Dev fallback
|
||||
if os.environ.get("DISPATCHARR_ENV") == "dev" or host in ("localhost", "127.0.0.1"):
|
||||
return host, "5656"
|
||||
|
||||
# 7. Final fallback: assume standard port for scheme
|
||||
return host, None
|
||||
|
||||
|
||||
def build_absolute_uri_with_port(request, path):
|
||||
"""
|
||||
Build an absolute URI with optional port.
|
||||
Port is omitted from URL if None (standard port for scheme).
|
||||
"""
|
||||
host, port = get_host_and_port(request)
|
||||
scheme = request.META.get("HTTP_X_FORWARDED_PROTO", request.scheme)
|
||||
if port:
|
||||
return f"{scheme}://{host}:{port}{path}"
|
||||
return f"{scheme}://{host}{path}"
|
||||
|
||||
|
||||
def send_notification_dismissed(notification_key):
|
||||
"""
|
||||
Notify all connected clients that a notification was dismissed.
|
||||
|
|
|
|||
|
|
@ -64,11 +64,42 @@ configure_variables() {
|
|||
POSTGRES_PASSWORD="secret"
|
||||
NGINX_HTTP_PORT="9191"
|
||||
WEBSOCKET_PORT="8001"
|
||||
GUNICORN_RUNTIME_DIR="dispatcharr"
|
||||
GUNICORN_SOCKET="/run/${GUNICORN_RUNTIME_DIR}/dispatcharr.sock"
|
||||
PYTHON_BIN=$(command -v python3)
|
||||
UWSGI_RUNTIME_DIR="dispatcharr"
|
||||
UWSGI_SOCKET="/run/${UWSGI_RUNTIME_DIR}/dispatcharr.sock"
|
||||
SYSTEMD_DIR="/etc/systemd/system"
|
||||
NGINX_SITE="/etc/nginx/sites-available/dispatcharr"
|
||||
}
|
||||
|
||||
# Helper: pick the first candidate package that exists in apt repos
|
||||
pick_candidate() {
|
||||
local cand info candidate
|
||||
for cand in "$@"; do
|
||||
info=$(apt-cache policy "$cand" 2>/dev/null || true)
|
||||
candidate=$(printf '%s' "$info" | awk '/Candidate:/ {print $2; exit}')
|
||||
if [ -n "$candidate" ] && [ "$candidate" != "(none)" ]; then
|
||||
printf '%s' "$cand"
|
||||
return 0
|
||||
fi
|
||||
done
|
||||
return 1
|
||||
}
|
||||
|
||||
# Resolve a list of package candidate entries into installable package names.
|
||||
# Each entry may contain alternatives separated by '|', e.g. 'libpcre3-dev|libpcre2-dev'
|
||||
resolve_packages() {
|
||||
local entry
|
||||
local alts
|
||||
local alt
|
||||
local resolved=()
|
||||
for entry in "$@"; do
|
||||
IFS='|' read -r -a alts <<<"$entry"
|
||||
alt=$(pick_candidate "${alts[@]}" 2>/dev/null || true)
|
||||
if [ -n "$alt" ]; then
|
||||
resolved+=("$alt")
|
||||
else
|
||||
echo "[WARN] No available candidate for package group: $entry" >&2
|
||||
fi
|
||||
done
|
||||
printf '%s\n' "${resolved[@]}"
|
||||
}
|
||||
|
||||
##############################################################################
|
||||
|
|
@ -77,13 +108,39 @@ configure_variables() {
|
|||
|
||||
install_packages() {
|
||||
echo ">>> Installing system packages..."
|
||||
# Refresh package lists before probing availability
|
||||
apt-get update
|
||||
declare -a packages=(
|
||||
git curl wget build-essential gcc libpq-dev
|
||||
python3-dev python3-venv python3-pip nginx redis-server
|
||||
postgresql postgresql-contrib ffmpeg procps streamlink
|
||||
sudo
|
||||
|
||||
# Candidate package groups (use '|' to separate alternatives)
|
||||
package_candidates=(
|
||||
'git'
|
||||
'curl'
|
||||
'wget'
|
||||
'build-essential'
|
||||
'gcc'
|
||||
'libpq-dev'
|
||||
'libpcre3-dev|libpcre2-dev|pcre3-dev'
|
||||
'python3-dev|python3.13-dev'
|
||||
'libssl-dev'
|
||||
'pkg-config'
|
||||
'nginx'
|
||||
'redis-server'
|
||||
'postgresql'
|
||||
'postgresql-contrib'
|
||||
'ffmpeg'
|
||||
'procps'
|
||||
'streamlink'
|
||||
'sudo'
|
||||
)
|
||||
|
||||
# Resolve candidates to actual installable package names
|
||||
mapfile -t packages < <(resolve_packages "${package_candidates[@]}")
|
||||
|
||||
if [ "${#packages[@]}" -eq 0 ]; then
|
||||
echo "[ERROR] No installable packages found. Aborting." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
apt-get install -y --no-install-recommends "${packages[@]}"
|
||||
|
||||
if ! command -v node >/dev/null 2>&1; then
|
||||
|
|
@ -113,6 +170,11 @@ create_dispatcharr_user() {
|
|||
##############################################################################
|
||||
|
||||
setup_postgresql() {
|
||||
echo ">>> Waiting for PostgreSQL to accept connections..."
|
||||
until pg_isready -h /var/run/postgresql >/dev/null 2>&1; do
|
||||
sleep 1
|
||||
done
|
||||
|
||||
echo ">>> Checking PostgreSQL database and user..."
|
||||
|
||||
db_exists=$(sudo -u postgres psql -tAc "SELECT 1 FROM pg_database WHERE datname='$POSTGRES_DB'")
|
||||
|
|
@ -143,7 +205,7 @@ setup_postgresql() {
|
|||
|
||||
clone_dispatcharr_repo() {
|
||||
echo ">>> Installing or updating Dispatcharr in ${APP_DIR} ..."
|
||||
|
||||
|
||||
if [ ! -d "$APP_DIR" ]; then
|
||||
mkdir -p "$APP_DIR"
|
||||
chown "$DISPATCH_USER:$DISPATCH_GROUP" "$APP_DIR"
|
||||
|
|
@ -171,14 +233,8 @@ EOSU
|
|||
# 6) Setup Python Environment
|
||||
##############################################################################
|
||||
|
||||
install_uv() {
|
||||
echo ">>> Installing UV package manager..."
|
||||
curl -LsSf https://astral.sh/uv/install.sh | sh
|
||||
export PATH="$HOME/.local/bin:$PATH"
|
||||
}
|
||||
|
||||
setup_python_env() {
|
||||
echo ">>> Setting up Python virtual environment with UV..."
|
||||
echo ">>> Setting up Python virtual environment with UV (Python 3.13)..."
|
||||
|
||||
su - "$DISPATCH_USER" <<EOSU
|
||||
set -euo pipefail
|
||||
|
|
@ -188,13 +244,12 @@ setup_python_env() {
|
|||
command -v uv >/dev/null 2>&1 || curl -LsSf https://astral.sh/uv/install.sh | sh
|
||||
|
||||
rm -rf env
|
||||
$PYTHON_BIN -m venv env
|
||||
env/bin/python -m ensurepip --upgrade
|
||||
# uv creates the venv with a managed Python 3.13 (auto-downloads if missing),
|
||||
# avoiding system Python version mismatches on Debian 12 / Ubuntu 24.04.
|
||||
uv venv --python 3.13 env
|
||||
|
||||
export UV_PROJECT_ENVIRONMENT="$APP_DIR/env"
|
||||
uv sync --no-dev
|
||||
|
||||
env/bin/python -m pip install -q gunicorn
|
||||
EOSU
|
||||
|
||||
ln -sf /usr/bin/ffmpeg "$APP_DIR/env/bin/ffmpeg"
|
||||
|
|
@ -291,17 +346,40 @@ EOSU
|
|||
configure_services() {
|
||||
echo ">>> Creating systemd service files..."
|
||||
|
||||
# Gunicorn
|
||||
# uWSGI config
|
||||
cat <<EOF >${APP_DIR}/uwsgi-debian.ini
|
||||
[uwsgi]
|
||||
chdir = ${APP_DIR}
|
||||
module = dispatcharr.wsgi:application
|
||||
virtualenv = ${APP_DIR}/env
|
||||
master = true
|
||||
workers = 4
|
||||
socket = ${UWSGI_SOCKET}
|
||||
chmod-socket = 666
|
||||
vacuum = true
|
||||
die-on-term = true
|
||||
gevent = 100
|
||||
gevent-early-monkey-patch = true
|
||||
import = dispatcharr.gevent_patch
|
||||
lazy-apps = true
|
||||
buffer-size = 65536
|
||||
socket-timeout = 600
|
||||
thunder-lock = true
|
||||
EOF
|
||||
|
||||
chown ${DISPATCH_USER}:${DISPATCH_GROUP} ${APP_DIR}/uwsgi-debian.ini
|
||||
|
||||
# uWSGI
|
||||
cat <<EOF >${SYSTEMD_DIR}/dispatcharr.service
|
||||
[Unit]
|
||||
Description=Gunicorn for Dispatcharr
|
||||
Description=uWSGI for Dispatcharr
|
||||
After=network.target postgresql.service redis-server.service
|
||||
|
||||
[Service]
|
||||
User=${DISPATCH_USER}
|
||||
Group=${DISPATCH_GROUP}
|
||||
WorkingDirectory=${APP_DIR}
|
||||
RuntimeDirectory=${GUNICORN_RUNTIME_DIR}
|
||||
RuntimeDirectory=${UWSGI_RUNTIME_DIR}
|
||||
RuntimeDirectoryMode=0775
|
||||
EnvironmentFile=/opt/dispatcharr/.env
|
||||
Environment="PATH=${APP_DIR}/env/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin"
|
||||
|
|
@ -310,12 +388,7 @@ Environment="POSTGRES_USER=${POSTGRES_USER}"
|
|||
Environment="POSTGRES_PASSWORD=${POSTGRES_PASSWORD}"
|
||||
Environment="POSTGRES_HOST=localhost"
|
||||
ExecStartPre=/usr/bin/bash -c 'until pg_isready -h localhost -U ${POSTGRES_USER}; do sleep 1; done'
|
||||
ExecStart=${APP_DIR}/env/bin/gunicorn \\
|
||||
--workers=4 \\
|
||||
--worker-class=gevent \\
|
||||
--timeout=300 \\
|
||||
--bind unix:${GUNICORN_SOCKET} \\
|
||||
dispatcharr.wsgi:application
|
||||
ExecStart=${APP_DIR}/env/bin/uwsgi --ini ${APP_DIR}/uwsgi-debian.ini
|
||||
Restart=always
|
||||
KillMode=mixed
|
||||
SyslogIdentifier=dispatcharr
|
||||
|
|
@ -412,9 +485,14 @@ EOF
|
|||
cat <<EOF >/etc/nginx/sites-available/dispatcharr.conf
|
||||
server {
|
||||
listen ${NGINX_HTTP_PORT};
|
||||
client_max_body_size 0;
|
||||
|
||||
location / {
|
||||
include proxy_params;
|
||||
proxy_pass http://unix:${GUNICORN_SOCKET};
|
||||
include uwsgi_params;
|
||||
uwsgi_param HTTP_X_REAL_IP \$remote_addr;
|
||||
uwsgi_read_timeout 600;
|
||||
uwsgi_send_timeout 600;
|
||||
uwsgi_pass unix:${UWSGI_SOCKET};
|
||||
}
|
||||
location /static/ {
|
||||
alias ${APP_DIR}/static/;
|
||||
|
|
@ -465,7 +543,7 @@ show_summary() {
|
|||
=================================================
|
||||
Dispatcharr installation (or update) complete!
|
||||
Nginx is listening on port ${NGINX_HTTP_PORT}.
|
||||
Gunicorn socket: ${GUNICORN_SOCKET}.
|
||||
uWSGI socket: ${UWSGI_SOCKET}.
|
||||
WebSockets on port ${WEBSOCKET_PORT} (path /ws/).
|
||||
|
||||
You can check logs via:
|
||||
|
|
|
|||
|
|
@ -95,6 +95,8 @@ def cleanup_task_memory(**kwargs):
|
|||
'apps.epg.tasks.parse_programs_for_source',
|
||||
'apps.epg.tasks.parse_programs_for_tvg_id',
|
||||
'apps.channels.tasks.match_epg_channels',
|
||||
'apps.channels.tasks.match_selected_channels_epg',
|
||||
'apps.channels.tasks.match_single_channel_epg',
|
||||
'core.tasks.rehash_streams'
|
||||
]
|
||||
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import json
|
||||
from channels.generic.websocket import AsyncWebsocketConsumer
|
||||
from asgiref.sync import sync_to_async
|
||||
import regex, logging
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
|
@ -16,7 +17,6 @@ class MyWebSocketConsumer(AsyncWebsocketConsumer):
|
|||
try:
|
||||
await self.accept()
|
||||
await self.channel_layer.group_add(self.room_name, self.channel_name)
|
||||
# Send a connection confirmation to the client with consistent format
|
||||
await self.send(text_data=json.dumps({
|
||||
'type': 'connection_established',
|
||||
'data': {
|
||||
|
|
@ -24,13 +24,22 @@ class MyWebSocketConsumer(AsyncWebsocketConsumer):
|
|||
'message': 'WebSocket connection established successfully'
|
||||
}
|
||||
}))
|
||||
except Exception as e:
|
||||
import logging
|
||||
logger = logging.getLogger(__name__)
|
||||
logger.error(f"Error in WebSocket connect: {str(e)}")
|
||||
# If an error occurs during connection, attempt to close
|
||||
# If the IP lookup already completed before the client connected,
|
||||
# push the cached result immediately so the Skeleton resolves.
|
||||
try:
|
||||
await self.close(code=1011) # Internal server error
|
||||
from django.core.cache import cache
|
||||
from core.api_views import _IP_CACHE_KEY
|
||||
cached_ip = await sync_to_async(cache.get)(_IP_CACHE_KEY)
|
||||
if cached_ip:
|
||||
await self.send(text_data=json.dumps({
|
||||
'data': {'type': 'ip_lookup_complete', **cached_ip}
|
||||
}))
|
||||
except Exception as e:
|
||||
logger.warning(f"Could not push cached IP result on connect: {e}")
|
||||
except Exception as e:
|
||||
logger.error(f"Error in WebSocket connect: {str(e)}")
|
||||
try:
|
||||
await self.close(code=1011)
|
||||
except:
|
||||
pass
|
||||
|
||||
|
|
|
|||
|
|
@ -1,24 +1,20 @@
|
|||
"""
|
||||
Loaded via uWSGI's `import = dispatcharr.gevent_patch` directive.
|
||||
|
||||
Two things happen here:
|
||||
gevent stdlib monkey-patching - replaces blocking socket/threading/os
|
||||
primitives with gevent-cooperative versions. `gevent-early-monkey-patch = true`
|
||||
in the ini should already have done this; calling it again is a safe no-op if
|
||||
it did, and a necessary fallback if it didn't (e.g. older uWSGI build).
|
||||
|
||||
1. gevent stdlib monkey-patching - replaces blocking socket/threading/os
|
||||
primitives with gevent-cooperative versions. `gevent-early-monkey-patch = true`
|
||||
in the ini should already have done this; calling it again is a safe no-op if
|
||||
it did, and a necessary fallback if it didn't (e.g. older uWSGI build).
|
||||
|
||||
2. psycogreen - installs a wait callback on psycopg2 so libpq yields to the
|
||||
gevent hub during I/O instead of blocking the OS thread.
|
||||
|
||||
Without (1), `async_to_sync(channel_layer.group_send)` in send_websocket_update
|
||||
Without this, `async_to_sync(channel_layer.group_send)` in send_websocket_update
|
||||
calls epoll_wait() directly, which blocks the OS thread and freezes all greenlets
|
||||
on the worker until the call returns. With (1), select.epoll is replaced by
|
||||
monkey-patching, which breaks asyncio event loop creation in threadpool threads.
|
||||
on the worker until the call returns. With monkey-patching, select.epoll is
|
||||
replaced, which breaks asyncio event loop creation in threadpool threads.
|
||||
send_websocket_update therefore uses a synchronous Redis path in gevent workers
|
||||
instead of asyncio - see _gevent_ws_send() in core/utils.py.
|
||||
|
||||
Without (2), psycopg2 network calls pin the worker during slow/stalled queries.
|
||||
psycopg3 uses Python's socket layer for I/O, so monkey.patch_all() provides
|
||||
gevent compatibility without any additional driver patching.
|
||||
|
||||
Celery and Daphne run in separate daemon processes and do not load this module.
|
||||
|
||||
|
|
@ -40,15 +36,4 @@ if not monkey.is_module_patched("socket"):
|
|||
else:
|
||||
print("[gevent_patch] gevent stdlib monkey-patching already active.", flush=True)
|
||||
|
||||
try:
|
||||
from psycogreen.gevent import patch_psycopg
|
||||
patch_psycopg()
|
||||
print("[gevent_patch] psycogreen: psycopg2 patched for gevent.", flush=True)
|
||||
except ImportError:
|
||||
print(
|
||||
"[gevent_patch] WARNING: psycogreen not installed - "
|
||||
"psycopg2 will block the gevent hub during DB I/O. "
|
||||
"Run: uv pip install psycogreen",
|
||||
file=sys.stderr,
|
||||
flush=True,
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -59,6 +59,8 @@ if REDIS_SSL:
|
|||
else:
|
||||
print("Redis TLS: disabled")
|
||||
|
||||
ENABLE_IP_LOOKUP = os.environ.get("DISPATCHARR_ENABLE_IP_LOOKUP", "true").lower() == "true"
|
||||
|
||||
# Set DEBUG to True for development, False for production
|
||||
if os.environ.get("DISPATCHARR_DEBUG", "False").lower() == "true":
|
||||
DEBUG = True
|
||||
|
|
@ -111,7 +113,7 @@ XC_PROFILE_REFRESH_DELAY = float(os.environ.get('XC_PROFILE_REFRESH_DELAY', '2.5
|
|||
|
||||
# Database optimization settings
|
||||
DATABASE_STATEMENT_TIMEOUT = 300 # Seconds before timing out long-running queries
|
||||
DATABASE_CONN_MAX_AGE = 0 # Close after each request; gevent makes per-greenlet connections
|
||||
DATABASE_CONN_MAX_AGE = 0 # geventpool intercepts close(); pool handles reuse
|
||||
|
||||
# Disable atomic requests for performance-sensitive views
|
||||
ATOMIC_REQUESTS = False
|
||||
|
|
@ -221,13 +223,18 @@ if os.getenv("DB_ENGINE", None) == "sqlite":
|
|||
else:
|
||||
DATABASES = {
|
||||
"default": {
|
||||
"ENGINE": "django.db.backends.postgresql",
|
||||
"ENGINE": "django_db_geventpool.backends.postgresql_psycopg3",
|
||||
"NAME": os.environ.get("POSTGRES_DB", "dispatcharr"),
|
||||
"USER": os.environ.get("POSTGRES_USER", "dispatch"),
|
||||
"PASSWORD": os.environ.get("POSTGRES_PASSWORD", "secret"),
|
||||
"HOST": os.environ.get("POSTGRES_HOST", "localhost"),
|
||||
"PORT": int(os.environ.get("POSTGRES_PORT", 5432)),
|
||||
"CONN_MAX_AGE": DATABASE_CONN_MAX_AGE,
|
||||
"OPTIONS": {
|
||||
"MAX_CONNS": 8, # Per-worker pool size; 4 workers × 8 = 32 total < pg max_connections=100
|
||||
"REUSE_CONNS": 3, # Connections to keep warm between requests
|
||||
"pool": False, # Disable Django's native psycopg3 pool; geventpool manages connections
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -238,9 +245,9 @@ else:
|
|||
("POSTGRES_SSL_KEY", POSTGRES_SSL_KEY),
|
||||
], "PostgreSQL")
|
||||
|
||||
DATABASES["default"]["OPTIONS"] = {
|
||||
DATABASES["default"]["OPTIONS"].update({
|
||||
"sslmode": POSTGRES_SSL_MODE,
|
||||
}
|
||||
})
|
||||
if POSTGRES_SSL_CA_CERT:
|
||||
DATABASES["default"]["OPTIONS"]["sslrootcert"] = POSTGRES_SSL_CA_CERT
|
||||
if POSTGRES_SSL_CERT:
|
||||
|
|
|
|||
|
|
@ -1,4 +1,10 @@
|
|||
FROM lscr.io/linuxserver/ffmpeg:latest
|
||||
# ==============================================================================
|
||||
# Stage 1: builder
|
||||
# Installs the Python toolchain + compilers, creates the virtual environment and
|
||||
# builds the legacy (CPU-baseline=none) NumPy wheel. None of these compilers end
|
||||
# up in the final image — only the artifacts below are copied forward.
|
||||
# ==============================================================================
|
||||
FROM lscr.io/linuxserver/ffmpeg:latest AS builder
|
||||
|
||||
ENV DEBIAN_FRONTEND=noninteractive
|
||||
ENV UV_PROJECT_ENVIRONMENT=/dispatcharrpy
|
||||
|
|
@ -15,10 +21,9 @@ RUN apt-get update && apt-get install --no-install-recommends -y \
|
|||
&& apt-get update \
|
||||
&& apt-get install --no-install-recommends -y \
|
||||
python3.13 python3.13-dev python3.13-venv libpython3.13 \
|
||||
libpcre3 libpcre3-dev libpq-dev procps pciutils \
|
||||
nginx comskip \
|
||||
vlc-bin vlc-plugin-base \
|
||||
build-essential gcc g++ gfortran libopenblas-dev libopenblas0 ninja-build
|
||||
libpcre3 libpcre3-dev \
|
||||
build-essential gcc g++ gfortran libopenblas-dev libopenblas0 ninja-build \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# --- Install UV ---
|
||||
COPY --from=ghcr.io/astral-sh/uv:latest /uv /uvx /bin/
|
||||
|
|
@ -28,11 +33,12 @@ WORKDIR /tmp/build
|
|||
COPY pyproject.toml /tmp/build/
|
||||
COPY version.py /tmp/build/
|
||||
COPY README.md /tmp/build/
|
||||
RUN uv sync --python 3.13 --no-cache --no-install-project --no-dev && \
|
||||
rm -rf /tmp/build
|
||||
RUN uv sync --python 3.13 --no-cache --no-install-project --no-dev
|
||||
WORKDIR /
|
||||
|
||||
# --- Build legacy NumPy wheel for old hardware (store for runtime switching) ---
|
||||
# build/pip are installed into the venv only long enough to produce the wheel,
|
||||
# then uninstalled so they are not carried into the final image's venv.
|
||||
RUN uv pip install --python $UV_PROJECT_ENVIRONMENT/bin/python --no-cache build pip && \
|
||||
cd /tmp && \
|
||||
$UV_PROJECT_ENVIRONMENT/bin/python -m pip download --no-binary numpy --no-deps numpy && \
|
||||
|
|
@ -40,14 +46,44 @@ RUN uv pip install --python $UV_PROJECT_ENVIRONMENT/bin/python --no-cache build
|
|||
cd numpy-*/ && \
|
||||
$UV_PROJECT_ENVIRONMENT/bin/python -m build --wheel -Csetup-args=-Dcpu-baseline="none" -Csetup-args=-Dcpu-dispatch="none" && \
|
||||
mv dist/*.whl /opt/ && \
|
||||
cd / && rm -rf /tmp/numpy-* /tmp/*.tar.gz && \
|
||||
cd / && rm -rf /tmp/numpy-* /tmp/*.tar.gz /tmp/build && \
|
||||
uv pip uninstall --python $UV_PROJECT_ENVIRONMENT/bin/python build pip
|
||||
|
||||
# --- Clean up build dependencies to reduce image size ---
|
||||
RUN apt-get remove -y build-essential gcc g++ gfortran libopenblas-dev libpcre3-dev python3.13-dev ninja-build && \
|
||||
apt-get autoremove -y --purge && \
|
||||
apt-get clean && \
|
||||
rm -rf /var/lib/apt/lists/* /root/.cache /tmp/*
|
||||
# ==============================================================================
|
||||
# Stage 2: final runtime image
|
||||
# Same ffmpeg base, but installs only the runtime system libraries (no compilers)
|
||||
# and copies the prebuilt virtual environment + NumPy wheel from the builder.
|
||||
# ==============================================================================
|
||||
FROM lscr.io/linuxserver/ffmpeg:latest AS final
|
||||
|
||||
ENV DEBIAN_FRONTEND=noninteractive
|
||||
ENV UV_PROJECT_ENVIRONMENT=/dispatcharrpy
|
||||
ENV VIRTUAL_ENV=/dispatcharrpy
|
||||
ENV PATH="$VIRTUAL_ENV/bin:$PATH"
|
||||
ENV UV_COMPILE_BYTECODE=1
|
||||
ENV UV_LINK_MODE=copy
|
||||
|
||||
# --- Install runtime system dependencies (no build toolchain) ---
|
||||
# python3.13 + libpython3.13 back the copied venv; libpcre3 backs uWSGI;
|
||||
# libopenblas0 backs the legacy NumPy wheel; ca-certificates/gnupg2/curl/wget
|
||||
# and software-properties-common are kept for TLS and the AIO runtime apt step.
|
||||
RUN apt-get update && apt-get install --no-install-recommends -y \
|
||||
ca-certificates software-properties-common gnupg2 curl wget \
|
||||
&& add-apt-repository ppa:deadsnakes/ppa \
|
||||
&& apt-get update \
|
||||
&& apt-get install --no-install-recommends -y \
|
||||
python3.13 python3.13-venv libpython3.13 \
|
||||
libpcre3 libopenblas0 procps pciutils \
|
||||
nginx comskip \
|
||||
vlc-bin vlc-plugin-base \
|
||||
&& apt-get clean && rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# --- Install UV (used at runtime to swap in the legacy NumPy wheel) ---
|
||||
COPY --from=ghcr.io/astral-sh/uv:latest /uv /uvx /bin/
|
||||
|
||||
# --- Copy prebuilt virtual environment and legacy NumPy wheel from the builder ---
|
||||
COPY --from=builder /dispatcharrpy /dispatcharrpy
|
||||
COPY --from=builder /opt/numpy-*.whl /opt/
|
||||
|
||||
# --- Set up Redis 7.x ---
|
||||
RUN curl -fsSL https://packages.redis.io/gpg | gpg --dearmor -o /usr/share/keyrings/redis-archive-keyring.gpg && \
|
||||
|
|
|
|||
|
|
@ -177,7 +177,7 @@ export POSTGRES_DIR=/data/db
|
|||
variables=(
|
||||
PATH VIRTUAL_ENV DJANGO_SETTINGS_MODULE PYTHONUNBUFFERED PYTHONDONTWRITEBYTECODE
|
||||
POSTGRES_DB POSTGRES_USER POSTGRES_PASSWORD POSTGRES_HOST POSTGRES_PORT
|
||||
DISPATCHARR_ENV DISPATCHARR_DEBUG DISPATCHARR_LOG_LEVEL
|
||||
DISPATCHARR_ENV DISPATCHARR_DEBUG DISPATCHARR_LOG_LEVEL DISPATCHARR_ENABLE_IP_LOOKUP
|
||||
REDIS_HOST REDIS_PORT REDIS_DB REDIS_PASSWORD REDIS_USER POSTGRES_DIR DISPATCHARR_PORT
|
||||
DISPATCHARR_VERSION DISPATCHARR_TIMESTAMP LIBVA_DRIVERS_PATH LIBVA_DRIVER_NAME LD_LIBRARY_PATH
|
||||
CELERY_NICE_LEVEL UWSGI_NICE_LEVEL DJANGO_SECRET_KEY
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@
|
|||
DATA_DIRS=(
|
||||
"/data/backups"
|
||||
"/data/logos"
|
||||
"/data/logo_cache"
|
||||
"/data/recordings"
|
||||
"/data/uploads/m3us"
|
||||
"/data/uploads/epgs"
|
||||
|
|
@ -19,7 +20,6 @@ DATA_DIRS=(
|
|||
|
||||
# APP_DIRS live on the image layer and are always locally writable.
|
||||
APP_DIRS=(
|
||||
"/app/logo_cache"
|
||||
"/app/media"
|
||||
"/app/static"
|
||||
)
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
proxy_cache_path /app/logo_cache levels=1:2 keys_zone=logo_cache:10m
|
||||
proxy_cache_path /data/logo_cache levels=1:2 keys_zone=logo_cache:10m
|
||||
inactive=24h use_temp_path=off;
|
||||
|
||||
server {
|
||||
|
|
@ -58,6 +58,14 @@ server {
|
|||
proxy_cache_use_stale error timeout updating; # Serve stale if Django is slow
|
||||
}
|
||||
|
||||
location ~ ^/api/epg/programs/(?<prog_id>\d+)/poster/ {
|
||||
proxy_pass http://127.0.0.1:5656;
|
||||
proxy_cache logo_cache;
|
||||
proxy_cache_key "$scheme$request_uri";
|
||||
proxy_cache_valid 200 24h;
|
||||
proxy_cache_use_stale error timeout updating;
|
||||
}
|
||||
|
||||
# admin disabled when not in dev mode
|
||||
location ~ ^/admin/?$ {
|
||||
return 301 /login;
|
||||
|
|
|
|||
|
|
@ -42,8 +42,8 @@ gevent = 100
|
|||
|
||||
# Patch the stdlib (socket, threading, time, ...) before any app code
|
||||
# loads so blocking calls yield to the gevent hub. Without this, a single
|
||||
# blocking psycopg2 / requests / DNS call freezes every greenlet on the
|
||||
# worker. The companion module greens psycopg2 specifically.
|
||||
# blocking requests / DNS call freezes every greenlet on the
|
||||
# worker. psycopg3 uses Python's socket layer, so no additional patching is needed.
|
||||
gevent-early-monkey-patch = true
|
||||
import = dispatcharr.gevent_patch
|
||||
|
||||
|
|
|
|||
|
|
@ -45,8 +45,8 @@ gevent = 100
|
|||
|
||||
# Patch the stdlib (socket, threading, time, ...) before any app code
|
||||
# loads so blocking calls yield to the gevent hub. Without this, a single
|
||||
# blocking psycopg2 / requests / DNS call freezes every greenlet on the
|
||||
# worker. The companion module greens psycopg2 specifically.
|
||||
# blocking requests / DNS call freezes every greenlet on the
|
||||
# worker. psycopg3 uses Python's socket layer, so no additional patching is needed.
|
||||
gevent-early-monkey-patch = true
|
||||
import = dispatcharr.gevent_patch
|
||||
|
||||
|
|
|
|||
|
|
@ -48,8 +48,8 @@ gevent = 400 # Each unused greenlet costs ~2-4KB of memory
|
|||
|
||||
# Patch the stdlib (socket, threading, time, ...) before any app code
|
||||
# loads so blocking calls yield to the gevent hub. Without this, a single
|
||||
# blocking psycopg2 / requests / DNS call freezes every greenlet on the
|
||||
# worker. The companion module greens psycopg2 specifically.
|
||||
# blocking requests / DNS call freezes every greenlet on the
|
||||
# worker. psycopg3 uses Python's socket layer, so no additional patching is needed.
|
||||
gevent-early-monkey-patch = true
|
||||
import = dispatcharr.gevent_patch
|
||||
|
||||
|
|
|
|||
|
|
@ -44,8 +44,8 @@ gevent = 400 # Each unused greenlet costs ~2-4KB of memory
|
|||
|
||||
# Patch the stdlib (socket, threading, time, ...) before any app code
|
||||
# loads so blocking calls yield to the gevent hub. Without this, a single
|
||||
# blocking psycopg2 / requests / DNS call freezes every greenlet on the
|
||||
# worker. The companion module greens psycopg2 specifically.
|
||||
# blocking requests / DNS call freezes every greenlet on the
|
||||
# worker. psycopg3 uses Python's socket layer, so no additional patching is needed.
|
||||
gevent-early-monkey-patch = true
|
||||
import = dispatcharr.gevent_patch
|
||||
|
||||
|
|
|
|||
365
frontend/package-lock.json
generated
365
frontend/package-lock.json
generated
|
|
@ -58,7 +58,7 @@
|
|||
"jsdom": "^27.0.0",
|
||||
"prettier": "^3.5.3",
|
||||
"vite": "^7.1.7",
|
||||
"vitest": "^3.2.4"
|
||||
"vitest": "^4.1.8"
|
||||
}
|
||||
},
|
||||
"node_modules/@acemir/cssom": {
|
||||
|
|
@ -1840,6 +1840,13 @@
|
|||
"win32"
|
||||
]
|
||||
},
|
||||
"node_modules/@standard-schema/spec": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz",
|
||||
"integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@standard-schema/utils": {
|
||||
"version": "0.3.0",
|
||||
"resolved": "https://registry.npmjs.org/@standard-schema/utils/-/utils-0.3.0.tgz",
|
||||
|
|
@ -2403,39 +2410,40 @@
|
|||
}
|
||||
},
|
||||
"node_modules/@vitest/expect": {
|
||||
"version": "3.2.4",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-3.2.4.tgz",
|
||||
"integrity": "sha512-Io0yyORnB6sikFlt8QW5K7slY4OjqNX9jmJQ02QDda8lyM6B5oNgVWoSoKPac8/kgnCUzuHQKrSLtu/uOqqrig==",
|
||||
"version": "4.1.8",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.8.tgz",
|
||||
"integrity": "sha512-h3nDO677RDLEGlBxyQ5CW8RlMThSKSRLUePLOx09gNIWRL40edgA1GCZSZgf1W55MFAG6/Sw14KeaAnqv0NKdQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@standard-schema/spec": "^1.1.0",
|
||||
"@types/chai": "^5.2.2",
|
||||
"@vitest/spy": "3.2.4",
|
||||
"@vitest/utils": "3.2.4",
|
||||
"chai": "^5.2.0",
|
||||
"tinyrainbow": "^2.0.0"
|
||||
"@vitest/spy": "4.1.8",
|
||||
"@vitest/utils": "4.1.8",
|
||||
"chai": "^6.2.2",
|
||||
"tinyrainbow": "^3.1.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/vitest"
|
||||
}
|
||||
},
|
||||
"node_modules/@vitest/mocker": {
|
||||
"version": "3.2.4",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-3.2.4.tgz",
|
||||
"integrity": "sha512-46ryTE9RZO/rfDd7pEqFl7etuyzekzEhUbTW3BvmeO/BcCMEgq59BKhek3dXDWgAj4oMK6OZi+vRr1wPW6qjEQ==",
|
||||
"version": "4.1.8",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.8.tgz",
|
||||
"integrity": "sha512-LEiN/xe4OSIbKe9HQIp5OC24agGD9J5CnmMgsLohVVoOPWL9a2sBoR6VBx43jQZb7Kr1l4RCuyCJzcAa0+dojw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@vitest/spy": "3.2.4",
|
||||
"@vitest/spy": "4.1.8",
|
||||
"estree-walker": "^3.0.3",
|
||||
"magic-string": "^0.30.17"
|
||||
"magic-string": "^0.30.21"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/vitest"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"msw": "^2.4.9",
|
||||
"vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0"
|
||||
"vite": "^6.0.0 || ^7.0.0 || ^8.0.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"msw": {
|
||||
|
|
@ -2447,42 +2455,42 @@
|
|||
}
|
||||
},
|
||||
"node_modules/@vitest/pretty-format": {
|
||||
"version": "3.2.4",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-3.2.4.tgz",
|
||||
"integrity": "sha512-IVNZik8IVRJRTr9fxlitMKeJeXFFFN0JaB9PHPGQ8NKQbGpfjlTx9zO4RefN8gp7eqjNy8nyK3NZmBzOPeIxtA==",
|
||||
"version": "4.1.8",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.8.tgz",
|
||||
"integrity": "sha512-9GasEBxpZ1VYIpqHf/0+YGg121uSNwCKOJqIrTwWP/TB7DmFCiaBpNl3aPZzoLWfWkuqhbH8vJIVobZkvdo2cA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"tinyrainbow": "^2.0.0"
|
||||
"tinyrainbow": "^3.1.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/vitest"
|
||||
}
|
||||
},
|
||||
"node_modules/@vitest/runner": {
|
||||
"version": "3.2.4",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-3.2.4.tgz",
|
||||
"integrity": "sha512-oukfKT9Mk41LreEW09vt45f8wx7DordoWUZMYdY/cyAk7w5TWkTRCNZYF7sX7n2wB7jyGAl74OxgwhPgKaqDMQ==",
|
||||
"version": "4.1.8",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.8.tgz",
|
||||
"integrity": "sha512-EmVxeBAfMJvycdjd6Hm+RbFBbA9fKvo0Kx37hNpBYoYeavH3RNsBXWDooR1mgD52dCrxIIuP7UotpfiwOikvcg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@vitest/utils": "3.2.4",
|
||||
"pathe": "^2.0.3",
|
||||
"strip-literal": "^3.0.0"
|
||||
"@vitest/utils": "4.1.8",
|
||||
"pathe": "^2.0.3"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/vitest"
|
||||
}
|
||||
},
|
||||
"node_modules/@vitest/snapshot": {
|
||||
"version": "3.2.4",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-3.2.4.tgz",
|
||||
"integrity": "sha512-dEYtS7qQP2CjU27QBC5oUOxLE/v5eLkGqPE0ZKEIDGMs4vKWe7IjgLOeauHsR0D5YuuycGRO5oSRXnwnmA78fQ==",
|
||||
"version": "4.1.8",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.8.tgz",
|
||||
"integrity": "sha512-acfZboRmAIf05DEKcBQy33VXojFJjtUdLyo7oOmV9kebb2xdU01UknNiPuPZoJZQyO7DF0gZdTGTpeAzET9QPQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@vitest/pretty-format": "3.2.4",
|
||||
"magic-string": "^0.30.17",
|
||||
"@vitest/pretty-format": "4.1.8",
|
||||
"@vitest/utils": "4.1.8",
|
||||
"magic-string": "^0.30.21",
|
||||
"pathe": "^2.0.3"
|
||||
},
|
||||
"funding": {
|
||||
|
|
@ -2490,33 +2498,37 @@
|
|||
}
|
||||
},
|
||||
"node_modules/@vitest/spy": {
|
||||
"version": "3.2.4",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-3.2.4.tgz",
|
||||
"integrity": "sha512-vAfasCOe6AIK70iP5UD11Ac4siNUNJ9i/9PZ3NKx07sG6sUxeag1LWdNrMWeKKYBLlzuK+Gn65Yd5nyL6ds+nw==",
|
||||
"version": "4.1.8",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.8.tgz",
|
||||
"integrity": "sha512-6EevtBp6OZOPF7bmz36HrGMeP3txgVSrgebWxHOafDXGkhIzfXK14f8KF6MuFfgXXUeHxmpD3BQxkV00/3s5mA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"tinyspy": "^4.0.3"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/vitest"
|
||||
}
|
||||
},
|
||||
"node_modules/@vitest/utils": {
|
||||
"version": "3.2.4",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-3.2.4.tgz",
|
||||
"integrity": "sha512-fB2V0JFrQSMsCo9HiSq3Ezpdv4iYaXRG1Sx8edX3MwxfyNn83mKiGzOcH+Fkxt4MHxr3y42fQi1oeAInqgX2QA==",
|
||||
"version": "4.1.8",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.8.tgz",
|
||||
"integrity": "sha512-uOJamYALNhfJ6iolExyQM40yIQwDqYnkKtQ5VCiSe17E33H0aQ/u+1GlRuz4LZBk6Mm3sg90G9hEbmEt37C1Zg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@vitest/pretty-format": "3.2.4",
|
||||
"loupe": "^3.1.4",
|
||||
"tinyrainbow": "^2.0.0"
|
||||
"@vitest/pretty-format": "4.1.8",
|
||||
"convert-source-map": "^2.0.0",
|
||||
"tinyrainbow": "^3.1.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/vitest"
|
||||
}
|
||||
},
|
||||
"node_modules/@vitest/utils/node_modules/convert-source-map": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz",
|
||||
"integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@xmldom/xmldom": {
|
||||
"version": "0.8.13",
|
||||
"resolved": "https://registry.npmjs.org/@xmldom/xmldom/-/xmldom-0.8.13.tgz",
|
||||
|
|
@ -2704,9 +2716,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/brace-expansion": {
|
||||
"version": "5.0.5",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.5.tgz",
|
||||
"integrity": "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==",
|
||||
"version": "5.0.6",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.6.tgz",
|
||||
"integrity": "sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
|
|
@ -2716,16 +2728,6 @@
|
|||
"node": "18 || 20 || >=22"
|
||||
}
|
||||
},
|
||||
"node_modules/cac": {
|
||||
"version": "6.7.14",
|
||||
"resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz",
|
||||
"integrity": "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/callsites": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz",
|
||||
|
|
@ -2736,18 +2738,11 @@
|
|||
}
|
||||
},
|
||||
"node_modules/chai": {
|
||||
"version": "5.3.3",
|
||||
"resolved": "https://registry.npmjs.org/chai/-/chai-5.3.3.tgz",
|
||||
"integrity": "sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==",
|
||||
"version": "6.2.2",
|
||||
"resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz",
|
||||
"integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"assertion-error": "^2.0.1",
|
||||
"check-error": "^2.1.1",
|
||||
"deep-eql": "^5.0.1",
|
||||
"loupe": "^3.1.0",
|
||||
"pathval": "^2.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
|
|
@ -2769,16 +2764,6 @@
|
|||
"url": "https://github.com/chalk/chalk?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/check-error": {
|
||||
"version": "2.1.3",
|
||||
"resolved": "https://registry.npmjs.org/check-error/-/check-error-2.1.3.tgz",
|
||||
"integrity": "sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 16"
|
||||
}
|
||||
},
|
||||
"node_modules/classnames": {
|
||||
"version": "2.5.1",
|
||||
"resolved": "https://registry.npmjs.org/classnames/-/classnames-2.5.1.tgz",
|
||||
|
|
@ -3097,16 +3082,6 @@
|
|||
"integrity": "sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/deep-eql": {
|
||||
"version": "5.0.2",
|
||||
"resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-5.0.2.tgz",
|
||||
"integrity": "sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/deep-is": {
|
||||
"version": "0.1.4",
|
||||
"resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz",
|
||||
|
|
@ -3175,9 +3150,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/es-module-lexer": {
|
||||
"version": "1.7.0",
|
||||
"resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz",
|
||||
"integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==",
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.1.0.tgz",
|
||||
"integrity": "sha512-n27zTYMjYu1aj4MjCWzSP7G9r75utsaoc8m61weK+W8JMBGGQybd43GstCXZ3WNmSFtGT9wi59qQTW6mhTR5LQ==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
|
|
@ -4022,13 +3997,6 @@
|
|||
"loose-envify": "cli.js"
|
||||
}
|
||||
},
|
||||
"node_modules/loupe": {
|
||||
"version": "3.2.1",
|
||||
"resolved": "https://registry.npmjs.org/loupe/-/loupe-3.2.1.tgz",
|
||||
"integrity": "sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/lru-cache": {
|
||||
"version": "11.2.6",
|
||||
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.6.tgz",
|
||||
|
|
@ -4210,6 +4178,20 @@
|
|||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/obug": {
|
||||
"version": "2.1.2",
|
||||
"resolved": "https://registry.npmjs.org/obug/-/obug-2.1.2.tgz",
|
||||
"integrity": "sha512-AWGB9WFcRXOQs48Z/udjI5ZcZMHXwX8XPByNpOydgcGsDLIzjGizhoMWJyKAWze7AVW/2W1i+/gPX4YtKe5cyg==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
"https://github.com/sponsors/sxzz",
|
||||
"https://opencollective.com/debug"
|
||||
],
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=12.20.0"
|
||||
}
|
||||
},
|
||||
"node_modules/optionator": {
|
||||
"version": "0.9.4",
|
||||
"resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz",
|
||||
|
|
@ -4345,16 +4327,6 @@
|
|||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/pathval": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/pathval/-/pathval-2.0.1.tgz",
|
||||
"integrity": "sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 14.16"
|
||||
}
|
||||
},
|
||||
"node_modules/picocolors": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
|
||||
|
|
@ -4666,9 +4638,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/react-router": {
|
||||
"version": "7.13.0",
|
||||
"resolved": "https://registry.npmjs.org/react-router/-/react-router-7.13.0.tgz",
|
||||
"integrity": "sha512-PZgus8ETambRT17BUm/LL8lX3Of+oiLaPuVTRH3l1eLvSPpKO3AvhAEb5N7ihAFZQrYDqkvvWfFh9p0z9VsjLw==",
|
||||
"version": "7.17.0",
|
||||
"resolved": "https://registry.npmjs.org/react-router/-/react-router-7.17.0.tgz",
|
||||
"integrity": "sha512-FDELK7rTMlCHO5+reyXsPlmfr7N1F91lPHsWYfMEGQm/KQ+F4JFM8jGoeQDmDvdTs93Fw9aSilH+uKRb4/jXvQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"cookie": "^1.0.1",
|
||||
|
|
@ -4688,12 +4660,12 @@
|
|||
}
|
||||
},
|
||||
"node_modules/react-router-dom": {
|
||||
"version": "7.13.0",
|
||||
"resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.13.0.tgz",
|
||||
"integrity": "sha512-5CO/l5Yahi2SKC6rGZ+HDEjpjkGaG/ncEP7eWFTvFxbHP8yeeI0PxTDjimtpXYlR3b3i9/WIL4VJttPrESIf2g==",
|
||||
"version": "7.17.0",
|
||||
"resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.17.0.tgz",
|
||||
"integrity": "sha512-fyU2yjGups/hE6Xz0I5ZYbVL8Gx29eCjgpHaRaTaVU+OOAdfRX05KsvyRm0GO8YQwOkhpU3MurW1jyMUJn+zSw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"react-router": "7.13.0"
|
||||
"react-router": "7.17.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.0.0"
|
||||
|
|
@ -5051,9 +5023,9 @@
|
|||
"license": "MIT"
|
||||
},
|
||||
"node_modules/std-env": {
|
||||
"version": "3.10.0",
|
||||
"resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz",
|
||||
"integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==",
|
||||
"version": "4.1.0",
|
||||
"resolved": "https://registry.npmjs.org/std-env/-/std-env-4.1.0.tgz",
|
||||
"integrity": "sha512-Rq7ybcX2RuC55r9oaPVEW7/xu3tj8u4GeBYHBWCychFtzMIr86A7e3PPEBPT37sHStKX3+TiX/Fr/ACmJLVlLQ==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
|
|
@ -5083,26 +5055,6 @@
|
|||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/strip-literal": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/strip-literal/-/strip-literal-3.1.0.tgz",
|
||||
"integrity": "sha512-8r3mkIM/2+PpjHoOtiAW8Rg3jJLHaV7xPwG+YRGrv6FP0wwk/toTpATxWYOW0BKdWwl82VT2tFYi5DlROa0Mxg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"js-tokens": "^9.0.1"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/antfu"
|
||||
}
|
||||
},
|
||||
"node_modules/strip-literal/node_modules/js-tokens": {
|
||||
"version": "9.0.1",
|
||||
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-9.0.1.tgz",
|
||||
"integrity": "sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/stylis": {
|
||||
"version": "4.2.0",
|
||||
"resolved": "https://registry.npmjs.org/stylis/-/stylis-4.2.0.tgz",
|
||||
|
|
@ -5167,11 +5119,14 @@
|
|||
"license": "MIT"
|
||||
},
|
||||
"node_modules/tinyexec": {
|
||||
"version": "0.3.2",
|
||||
"resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-0.3.2.tgz",
|
||||
"integrity": "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==",
|
||||
"version": "1.2.4",
|
||||
"resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.2.4.tgz",
|
||||
"integrity": "sha512-SHf/r48b7vOrjve9PxJo3MN5v5yuyjHvdUcrQffT3WXMUfnGmHDVbC4k3sHJaJTgZCwpUplIaAo5ANtMyp3YHg==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/tinyglobby": {
|
||||
"version": "0.2.15",
|
||||
|
|
@ -5190,30 +5145,10 @@
|
|||
"url": "https://github.com/sponsors/SuperchupuDev"
|
||||
}
|
||||
},
|
||||
"node_modules/tinypool": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/tinypool/-/tinypool-1.1.1.tgz",
|
||||
"integrity": "sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": "^18.0.0 || >=20.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/tinyrainbow": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-2.0.0.tgz",
|
||||
"integrity": "sha512-op4nsTR47R6p0vMUUoYl/a+ljLFVtlfaXkLQmqfLR1qHma1h/ysYk4hEXZ880bf2CYgTskvTa/e196Vd5dDQXw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=14.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/tinyspy": {
|
||||
"version": "4.0.4",
|
||||
"resolved": "https://registry.npmjs.org/tinyspy/-/tinyspy-4.0.4.tgz",
|
||||
"integrity": "sha512-azl+t0z7pw/z958Gy9svOTuzqIk6xq+NSheJzn5MMWtWTFywIacg2wUlzKFGtt3cthx0r2SxMK0yzJOR0IES7Q==",
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.0.tgz",
|
||||
"integrity": "sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
|
|
@ -5564,89 +5499,80 @@
|
|||
}
|
||||
}
|
||||
},
|
||||
"node_modules/vite-node": {
|
||||
"version": "3.2.4",
|
||||
"resolved": "https://registry.npmjs.org/vite-node/-/vite-node-3.2.4.tgz",
|
||||
"integrity": "sha512-EbKSKh+bh1E1IFxeO0pg1n4dvoOTt0UDiXMd/qn++r98+jPO1xtJilvXldeuQ8giIB5IkpjCgMleHMNEsGH6pg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"cac": "^6.7.14",
|
||||
"debug": "^4.4.1",
|
||||
"es-module-lexer": "^1.7.0",
|
||||
"pathe": "^2.0.3",
|
||||
"vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0"
|
||||
},
|
||||
"bin": {
|
||||
"vite-node": "vite-node.mjs"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^18.0.0 || ^20.0.0 || >=22.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/vitest"
|
||||
}
|
||||
},
|
||||
"node_modules/vitest": {
|
||||
"version": "3.2.4",
|
||||
"resolved": "https://registry.npmjs.org/vitest/-/vitest-3.2.4.tgz",
|
||||
"integrity": "sha512-LUCP5ev3GURDysTWiP47wRRUpLKMOfPh+yKTx3kVIEiu5KOMeqzpnYNsKyOoVrULivR8tLcks4+lga33Whn90A==",
|
||||
"version": "4.1.8",
|
||||
"resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.8.tgz",
|
||||
"integrity": "sha512-flY6ScbCIt9HThs+C5HS7jvGOB560DJtk/Z15IQROTA6zEy49Nh8T/dofWTQL+n3vswqn87sbJNiuqw1SDp5Ig==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/chai": "^5.2.2",
|
||||
"@vitest/expect": "3.2.4",
|
||||
"@vitest/mocker": "3.2.4",
|
||||
"@vitest/pretty-format": "^3.2.4",
|
||||
"@vitest/runner": "3.2.4",
|
||||
"@vitest/snapshot": "3.2.4",
|
||||
"@vitest/spy": "3.2.4",
|
||||
"@vitest/utils": "3.2.4",
|
||||
"chai": "^5.2.0",
|
||||
"debug": "^4.4.1",
|
||||
"expect-type": "^1.2.1",
|
||||
"magic-string": "^0.30.17",
|
||||
"@vitest/expect": "4.1.8",
|
||||
"@vitest/mocker": "4.1.8",
|
||||
"@vitest/pretty-format": "4.1.8",
|
||||
"@vitest/runner": "4.1.8",
|
||||
"@vitest/snapshot": "4.1.8",
|
||||
"@vitest/spy": "4.1.8",
|
||||
"@vitest/utils": "4.1.8",
|
||||
"es-module-lexer": "^2.0.0",
|
||||
"expect-type": "^1.3.0",
|
||||
"magic-string": "^0.30.21",
|
||||
"obug": "^2.1.1",
|
||||
"pathe": "^2.0.3",
|
||||
"picomatch": "^4.0.2",
|
||||
"std-env": "^3.9.0",
|
||||
"picomatch": "^4.0.3",
|
||||
"std-env": "^4.0.0-rc.1",
|
||||
"tinybench": "^2.9.0",
|
||||
"tinyexec": "^0.3.2",
|
||||
"tinyglobby": "^0.2.14",
|
||||
"tinypool": "^1.1.1",
|
||||
"tinyrainbow": "^2.0.0",
|
||||
"vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0",
|
||||
"vite-node": "3.2.4",
|
||||
"tinyexec": "^1.0.2",
|
||||
"tinyglobby": "^0.2.15",
|
||||
"tinyrainbow": "^3.1.0",
|
||||
"vite": "^6.0.0 || ^7.0.0 || ^8.0.0",
|
||||
"why-is-node-running": "^2.3.0"
|
||||
},
|
||||
"bin": {
|
||||
"vitest": "vitest.mjs"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^18.0.0 || ^20.0.0 || >=22.0.0"
|
||||
"node": "^20.0.0 || ^22.0.0 || >=24.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/vitest"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@edge-runtime/vm": "*",
|
||||
"@types/debug": "^4.1.12",
|
||||
"@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0",
|
||||
"@vitest/browser": "3.2.4",
|
||||
"@vitest/ui": "3.2.4",
|
||||
"@opentelemetry/api": "^1.9.0",
|
||||
"@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0",
|
||||
"@vitest/browser-playwright": "4.1.8",
|
||||
"@vitest/browser-preview": "4.1.8",
|
||||
"@vitest/browser-webdriverio": "4.1.8",
|
||||
"@vitest/coverage-istanbul": "4.1.8",
|
||||
"@vitest/coverage-v8": "4.1.8",
|
||||
"@vitest/ui": "4.1.8",
|
||||
"happy-dom": "*",
|
||||
"jsdom": "*"
|
||||
"jsdom": "*",
|
||||
"vite": "^6.0.0 || ^7.0.0 || ^8.0.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@edge-runtime/vm": {
|
||||
"optional": true
|
||||
},
|
||||
"@types/debug": {
|
||||
"@opentelemetry/api": {
|
||||
"optional": true
|
||||
},
|
||||
"@types/node": {
|
||||
"optional": true
|
||||
},
|
||||
"@vitest/browser": {
|
||||
"@vitest/browser-playwright": {
|
||||
"optional": true
|
||||
},
|
||||
"@vitest/browser-preview": {
|
||||
"optional": true
|
||||
},
|
||||
"@vitest/browser-webdriverio": {
|
||||
"optional": true
|
||||
},
|
||||
"@vitest/coverage-istanbul": {
|
||||
"optional": true
|
||||
},
|
||||
"@vitest/coverage-v8": {
|
||||
"optional": true
|
||||
},
|
||||
"@vitest/ui": {
|
||||
|
|
@ -5657,6 +5583,9 @@
|
|||
},
|
||||
"jsdom": {
|
||||
"optional": true
|
||||
},
|
||||
"vite": {
|
||||
"optional": false
|
||||
}
|
||||
}
|
||||
},
|
||||
|
|
@ -5756,9 +5685,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/ws": {
|
||||
"version": "8.19.0",
|
||||
"resolved": "https://registry.npmjs.org/ws/-/ws-8.19.0.tgz",
|
||||
"integrity": "sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg==",
|
||||
"version": "8.21.0",
|
||||
"resolved": "https://registry.npmjs.org/ws/-/ws-8.21.0.tgz",
|
||||
"integrity": "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@
|
|||
"@dnd-kit/modifiers": "^9.0.0",
|
||||
"@dnd-kit/sortable": "^10.0.0",
|
||||
"@dnd-kit/utilities": "^3.2.2",
|
||||
"@hookform/resolvers": "^5.2.2",
|
||||
"@mantine/charts": "~8.0.1",
|
||||
"@mantine/core": "~8.0.1",
|
||||
"@mantine/dates": "~8.0.1",
|
||||
|
|
@ -23,17 +24,16 @@
|
|||
"@mantine/form": "~8.0.1",
|
||||
"@mantine/hooks": "~8.0.1",
|
||||
"@mantine/notifications": "~8.0.1",
|
||||
"@hookform/resolvers": "^5.2.2",
|
||||
"@tanstack/react-table": "^8.21.2",
|
||||
"allotment": "^1.20.4",
|
||||
"dayjs": "^1.11.13",
|
||||
"hls.js": "^1.5.20",
|
||||
"react-hook-form": "^7.70.0",
|
||||
"lucide-react": "^0.511.0",
|
||||
"mpegts.js": "^1.8.0",
|
||||
"react": "^19.1.0",
|
||||
"react-dom": "^19.1.0",
|
||||
"react-draggable": "^4.4.6",
|
||||
"react-hook-form": "^7.70.0",
|
||||
"react-pro-sidebar": "^1.1.0",
|
||||
"react-router-dom": "^7.3.0",
|
||||
"react-virtualized": "^9.22.6",
|
||||
|
|
@ -62,7 +62,7 @@
|
|||
"jsdom": "^27.0.0",
|
||||
"prettier": "^3.5.3",
|
||||
"vite": "^7.1.7",
|
||||
"vitest": "^3.2.4"
|
||||
"vitest": "^4.1.8"
|
||||
},
|
||||
"resolutions": {
|
||||
"vite": "7.1.7",
|
||||
|
|
|
|||
|
|
@ -50,7 +50,6 @@ export const WebsocketProvider = ({ children }) => {
|
|||
|
||||
const epgs = useEPGsStore((s) => s.epgs);
|
||||
const updateEPG = useEPGsStore((s) => s.updateEPG);
|
||||
const updateEPGProgress = useEPGsStore((s) => s.updateEPGProgress);
|
||||
|
||||
const updatePlaylist = usePlaylistsStore((s) => s.updatePlaylist);
|
||||
|
||||
|
|
@ -367,19 +366,28 @@ export const WebsocketProvider = ({ children }) => {
|
|||
fetchEPGData();
|
||||
break;
|
||||
|
||||
case 'single_channel_epg_match': {
|
||||
const matchResult = parsedEvent.data;
|
||||
if (matchResult.channel) {
|
||||
useChannelsStore.getState().updateChannel(matchResult.channel);
|
||||
}
|
||||
window.dispatchEvent(
|
||||
new CustomEvent('single-channel-epg-match', {
|
||||
detail: matchResult,
|
||||
})
|
||||
);
|
||||
break;
|
||||
}
|
||||
|
||||
case 'epg_match':
|
||||
notifications.show({
|
||||
message: parsedEvent.data.message || 'EPG match is complete!',
|
||||
color: 'green.5',
|
||||
});
|
||||
|
||||
// Check if we have associations data and use the more efficient batch API
|
||||
if (
|
||||
parsedEvent.data.associations &&
|
||||
parsedEvent.data.associations.length > 0
|
||||
) {
|
||||
API.batchSetEPG(parsedEvent.data.associations);
|
||||
}
|
||||
// Celery already applied assignments server-side; refresh local state.
|
||||
fetchEPGData();
|
||||
API.requeryChannels();
|
||||
break;
|
||||
|
||||
case 'epg_matching_progress': {
|
||||
|
|
@ -635,81 +643,91 @@ export const WebsocketProvider = ({ children }) => {
|
|||
}
|
||||
break;
|
||||
|
||||
case 'epg_refresh':
|
||||
// If we have source/account info, check if EPG exists before processing
|
||||
if (parsedEvent.data.source || parsedEvent.data.account) {
|
||||
const sourceId =
|
||||
parsedEvent.data.source || parsedEvent.data.account;
|
||||
const epg = epgs[sourceId];
|
||||
case 'epg_refresh': {
|
||||
const sourceId =
|
||||
parsedEvent.data.source || parsedEvent.data.account;
|
||||
if (!sourceId) break;
|
||||
|
||||
// Only update progress if the EPG still exists in the store
|
||||
// This prevents crashes when receiving updates for deleted EPGs
|
||||
if (epg) {
|
||||
// Update the store with progress information
|
||||
updateEPGProgress(parsedEvent.data);
|
||||
} else {
|
||||
// EPG was deleted, ignore this update
|
||||
console.debug(
|
||||
`Ignoring EPG refresh update for deleted EPG ${sourceId}`
|
||||
// Read from the store directly. connectWebSocket closes over a stale
|
||||
// epgs snapshot, so a newly created source is missed and the old early-
|
||||
// return path never reached fetchEPGData on parsing_channels completion.
|
||||
let {
|
||||
epgs: epgsState,
|
||||
updateEPG,
|
||||
updateEPGProgress,
|
||||
fetchEPGs,
|
||||
fetchEPGData,
|
||||
} = useEPGsStore.getState();
|
||||
|
||||
if (!epgsState[sourceId]) {
|
||||
try {
|
||||
await fetchEPGs();
|
||||
} catch (e) {
|
||||
console.warn(
|
||||
'Failed to refresh EPG sources for progress update:',
|
||||
e
|
||||
);
|
||||
break;
|
||||
}
|
||||
epgsState = useEPGsStore.getState().epgs;
|
||||
}
|
||||
|
||||
if (epg) {
|
||||
// Check for any indication of an error (either via status or error field)
|
||||
const hasError =
|
||||
parsedEvent.data.status === 'error' ||
|
||||
!!parsedEvent.data.error ||
|
||||
(parsedEvent.data.message &&
|
||||
parsedEvent.data.message.toLowerCase().includes('error'));
|
||||
updateEPGProgress(parsedEvent.data);
|
||||
|
||||
if (hasError) {
|
||||
// Handle error state
|
||||
const errorMessage =
|
||||
parsedEvent.data.error ||
|
||||
parsedEvent.data.message ||
|
||||
'Unknown error occurred';
|
||||
const epg = epgsState[sourceId];
|
||||
if (!epg) break;
|
||||
|
||||
updateEPG({
|
||||
...epg,
|
||||
status: 'error',
|
||||
last_message: errorMessage,
|
||||
});
|
||||
const hasError =
|
||||
parsedEvent.data.status === 'error' ||
|
||||
!!parsedEvent.data.error ||
|
||||
(parsedEvent.data.message &&
|
||||
parsedEvent.data.message.toLowerCase().includes('error'));
|
||||
|
||||
// Show notification for the error
|
||||
notifications.show({
|
||||
title: 'EPG Refresh Error',
|
||||
message: errorMessage,
|
||||
color: 'red.5',
|
||||
});
|
||||
}
|
||||
// Update status on completion only if no errors
|
||||
else if (parsedEvent.data.progress === 100) {
|
||||
updateEPG({
|
||||
...epg,
|
||||
status: parsedEvent.data.status || 'success',
|
||||
last_message:
|
||||
parsedEvent.data.message || epg.last_message,
|
||||
// Use the timestamp from the backend if provided
|
||||
...(parsedEvent.data.updated_at && {
|
||||
updated_at: parsedEvent.data.updated_at,
|
||||
}),
|
||||
});
|
||||
if (hasError) {
|
||||
const errorMessage =
|
||||
parsedEvent.data.error ||
|
||||
parsedEvent.data.message ||
|
||||
'Unknown error occurred';
|
||||
|
||||
// Only show success notification if we've finished parsing programs and had no errors
|
||||
if (parsedEvent.data.action === 'parsing_programs') {
|
||||
notifications.show({
|
||||
title: 'EPG Processing Complete',
|
||||
message: 'EPG data has been updated successfully',
|
||||
color: 'green.5',
|
||||
});
|
||||
updateEPG({
|
||||
...epg,
|
||||
status: 'error',
|
||||
last_message: errorMessage,
|
||||
});
|
||||
|
||||
fetchEPGData();
|
||||
}
|
||||
}
|
||||
notifications.show({
|
||||
title: 'EPG Refresh Error',
|
||||
message: errorMessage,
|
||||
color: 'red.5',
|
||||
});
|
||||
} else if (parsedEvent.data.progress === 100) {
|
||||
updateEPG({
|
||||
...epg,
|
||||
status: parsedEvent.data.status || 'success',
|
||||
last_message: parsedEvent.data.message || epg.last_message,
|
||||
...(parsedEvent.data.updated_at && {
|
||||
updated_at: parsedEvent.data.updated_at,
|
||||
}),
|
||||
});
|
||||
|
||||
if (parsedEvent.data.action === 'parsing_channels') {
|
||||
notifications.show({
|
||||
message: 'EPG channels updated!',
|
||||
color: 'green.5',
|
||||
});
|
||||
|
||||
await fetchEPGData();
|
||||
} else if (parsedEvent.data.action === 'parsing_programs') {
|
||||
notifications.show({
|
||||
title: 'EPG Processing Complete',
|
||||
message: 'EPG data has been updated successfully',
|
||||
color: 'green.5',
|
||||
});
|
||||
|
||||
await fetchEPGData();
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case 'epg_sources_changed':
|
||||
// A plugin or backend process signaled that the EPG sources changed
|
||||
|
|
@ -965,6 +983,12 @@ export const WebsocketProvider = ({ children }) => {
|
|||
break;
|
||||
}
|
||||
|
||||
case 'ip_lookup_complete': {
|
||||
const { type: _t, ...ipData } = parsedEvent.data;
|
||||
useSettingsStore.getState().setEnvironmentFields(ipData);
|
||||
break;
|
||||
}
|
||||
|
||||
default:
|
||||
console.error(
|
||||
`Unknown websocket event type: ${parsedEvent.data?.type}`
|
||||
|
|
|
|||
|
|
@ -1583,6 +1583,21 @@ export default class API {
|
|||
}
|
||||
}
|
||||
|
||||
static async getCurrentProgramForEpg(epgId) {
|
||||
const response = await request(`${host}/api/epg/current-programs/`, {
|
||||
method: 'POST',
|
||||
body: { epg_data_ids: [epgId] },
|
||||
});
|
||||
|
||||
if (response && response.length > 0) {
|
||||
if (response[0].parsing) {
|
||||
return { parsing: true };
|
||||
}
|
||||
return response[0];
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// Notice there's a duplicated "refreshPlaylist" method above;
|
||||
// you might want to rename or remove one if it's not needed.
|
||||
|
||||
|
|
@ -1686,11 +1701,11 @@ export default class API {
|
|||
}
|
||||
}
|
||||
|
||||
static async refreshEPG(id) {
|
||||
static async refreshEPG(id, force = false) {
|
||||
try {
|
||||
const response = await request(`${host}/api/epg/import/`, {
|
||||
method: 'POST',
|
||||
body: { id },
|
||||
body: { id, force },
|
||||
});
|
||||
|
||||
return response;
|
||||
|
|
@ -3580,10 +3595,11 @@ export default class API {
|
|||
}
|
||||
}
|
||||
|
||||
static async getMovieProviderInfo(movieId) {
|
||||
static async getMovieProviderInfo(movieId, relationId = null) {
|
||||
try {
|
||||
const params = relationId ? `?relation_id=${relationId}` : '';
|
||||
const response = await request(
|
||||
`${host}/api/vod/movies/${movieId}/provider-info/`
|
||||
`${host}/api/vod/movies/${movieId}/provider-info/${params}`
|
||||
);
|
||||
return response;
|
||||
} catch (e) {
|
||||
|
|
@ -3622,11 +3638,12 @@ export default class API {
|
|||
}
|
||||
}
|
||||
|
||||
static async getSeriesInfo(seriesId) {
|
||||
static async getSeriesInfo(seriesId, relationId = null) {
|
||||
try {
|
||||
// Call the provider-info endpoint that includes episodes
|
||||
const params = new URLSearchParams({ include_episodes: 'true' });
|
||||
if (relationId) params.set('relation_id', relationId);
|
||||
const response = await request(
|
||||
`${host}/api/vod/series/${seriesId}/provider-info/?include_episodes=true`
|
||||
`${host}/api/vod/series/${seriesId}/provider-info/?${params}`
|
||||
);
|
||||
return response;
|
||||
} catch (e) {
|
||||
|
|
@ -3871,4 +3888,83 @@ export default class API {
|
|||
errorNotification('Failed to fetch connect logs', e);
|
||||
}
|
||||
}
|
||||
|
||||
static async getSDLineups(sourceId) {
|
||||
try {
|
||||
const response = await request(
|
||||
`${host}/api/epg/sources/${sourceId}/sd-lineups/`
|
||||
);
|
||||
return response;
|
||||
} catch (e) {
|
||||
errorNotification('Failed to retrieve Schedules Direct lineups', e);
|
||||
}
|
||||
}
|
||||
|
||||
static async addSDLineup(sourceId, lineup) {
|
||||
try {
|
||||
const response = await request(
|
||||
`${host}/api/epg/sources/${sourceId}/sd-lineups/`,
|
||||
{
|
||||
method: 'POST',
|
||||
body: { lineup },
|
||||
}
|
||||
);
|
||||
return response;
|
||||
} catch (e) {
|
||||
errorNotification(`Failed to add lineup ${lineup}`, e);
|
||||
}
|
||||
}
|
||||
|
||||
static async deleteSDLineup(sourceId, lineup) {
|
||||
try {
|
||||
const response = await request(
|
||||
`${host}/api/epg/sources/${sourceId}/sd-lineups/`,
|
||||
{
|
||||
method: 'DELETE',
|
||||
body: { lineup },
|
||||
}
|
||||
);
|
||||
return response;
|
||||
} catch (e) {
|
||||
errorNotification(`Failed to remove lineup ${lineup}`, e);
|
||||
}
|
||||
}
|
||||
|
||||
static async updateEpgSourceSettings(sourceId, settings) {
|
||||
try {
|
||||
// Read current custom_properties from the store to merge, not replace
|
||||
const epgs = useEPGsStore.getState().epgs;
|
||||
const source = epgs[sourceId];
|
||||
const cp = { ...(source?.custom_properties || {}), ...settings };
|
||||
|
||||
const response = await request(`${host}/api/epg/sources/${sourceId}/`, {
|
||||
method: 'PATCH',
|
||||
body: { custom_properties: cp },
|
||||
});
|
||||
|
||||
useEPGsStore.getState().updateEPG(response);
|
||||
return response;
|
||||
} catch (e) {
|
||||
errorNotification('Failed to update EPG source settings', e);
|
||||
}
|
||||
}
|
||||
|
||||
static async updateSDSettings(sourceId, settings) {
|
||||
return API.updateEpgSourceSettings(sourceId, settings);
|
||||
}
|
||||
|
||||
static async searchSDLineups(sourceId, country, postalcode) {
|
||||
try {
|
||||
const response = await request(
|
||||
`${host}/api/epg/sources/${sourceId}/sd-lineups/search/`,
|
||||
{
|
||||
method: 'POST',
|
||||
body: { country, postalcode },
|
||||
}
|
||||
);
|
||||
return response;
|
||||
} catch (e) {
|
||||
errorNotification('Failed to search Schedules Direct lineups', e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
144
frontend/src/components/AboutModal.jsx
Normal file
144
frontend/src/components/AboutModal.jsx
Normal file
|
|
@ -0,0 +1,144 @@
|
|||
import React from 'react';
|
||||
import {
|
||||
Box,
|
||||
Button,
|
||||
Divider,
|
||||
Group,
|
||||
Modal,
|
||||
SimpleGrid,
|
||||
Stack,
|
||||
Text,
|
||||
Tooltip,
|
||||
} from '@mantine/core';
|
||||
import { BookOpen, Github, Heart, Users } from 'lucide-react';
|
||||
import { DiscordIcon } from './icons.jsx';
|
||||
import logo from '../images/logo.png';
|
||||
import useSettingsStore from '../store/settings';
|
||||
|
||||
const AboutModal = ({ isOpen, onClose }) => {
|
||||
const appVersion = useSettingsStore((s) => s.version);
|
||||
const versionString = `v${appVersion?.version || '0.0.0'}${appVersion?.timestamp ? `-${appVersion.timestamp}` : ''}`;
|
||||
|
||||
return (
|
||||
<Modal
|
||||
opened={isOpen}
|
||||
onClose={onClose}
|
||||
title="About Dispatcharr"
|
||||
centered
|
||||
size="md"
|
||||
>
|
||||
<Stack gap="lg">
|
||||
<Group justify="center" gap="md">
|
||||
<img src={logo} alt="Dispatcharr" width={56} />
|
||||
<Stack gap={2}>
|
||||
<Text fw={700} size="xl">
|
||||
Dispatcharr
|
||||
</Text>
|
||||
<Text size="sm" c="dimmed">
|
||||
{versionString}
|
||||
</Text>
|
||||
</Stack>
|
||||
</Group>
|
||||
|
||||
<Divider />
|
||||
|
||||
<SimpleGrid cols={2} spacing="sm">
|
||||
<Tooltip label="Visit the Dispatcharr documentation" position="top">
|
||||
<Button
|
||||
component="a"
|
||||
href="https://dispatcharr.github.io/Dispatcharr-Docs/"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
variant="default"
|
||||
leftSection={<BookOpen size={15} />}
|
||||
fullWidth
|
||||
>
|
||||
Documentation
|
||||
</Button>
|
||||
</Tooltip>
|
||||
<Tooltip label="Join our Discord community" position="top">
|
||||
<Button
|
||||
component="a"
|
||||
href="https://discord.gg/Sp45V5BcxU"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
variant="default"
|
||||
leftSection={<DiscordIcon size={15} />}
|
||||
fullWidth
|
||||
>
|
||||
Discord
|
||||
</Button>
|
||||
</Tooltip>
|
||||
<Tooltip label="View source on GitHub" position="top">
|
||||
<Button
|
||||
component="a"
|
||||
href="https://github.com/Dispatcharr/Dispatcharr"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
variant="default"
|
||||
leftSection={<Github size={15} />}
|
||||
fullWidth
|
||||
>
|
||||
GitHub
|
||||
</Button>
|
||||
</Tooltip>
|
||||
<Tooltip
|
||||
label="Support Dispatcharr on Open Collective"
|
||||
position="top"
|
||||
>
|
||||
<Button
|
||||
component="a"
|
||||
href="https://opencollective.com/dispatcharr/contribute"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
variant="default"
|
||||
color="pink"
|
||||
leftSection={<Heart size={15} />}
|
||||
fullWidth
|
||||
>
|
||||
Donate
|
||||
</Button>
|
||||
</Tooltip>
|
||||
</SimpleGrid>
|
||||
|
||||
<Divider />
|
||||
|
||||
<Stack gap="xs">
|
||||
<Group gap="xs">
|
||||
<Users size={16} />
|
||||
<Text size="sm" fw={500}>
|
||||
Contributors
|
||||
</Text>
|
||||
</Group>
|
||||
<Text size="sm" c="dimmed">
|
||||
Dispatcharr is built by the community, for the community. Thank you
|
||||
to every contributor, tester, and supporter who has helped make this
|
||||
project what it is.
|
||||
</Text>
|
||||
</Stack>
|
||||
|
||||
<Tooltip label="Remembering Jesse Mann" position="top" withArrow>
|
||||
<Box
|
||||
style={{
|
||||
background: 'var(--mantine-color-dark-6)',
|
||||
borderRadius: 'var(--mantine-radius-sm)',
|
||||
borderLeft: '3px solid var(--mantine-color-pink-5)',
|
||||
padding: '10px 14px',
|
||||
cursor: 'default',
|
||||
}}
|
||||
>
|
||||
<Text size="sm" c="dimmed">
|
||||
In memory of{' '}
|
||||
<Text span fw={600} c="gray.3">
|
||||
Jesse Mann
|
||||
</Text>
|
||||
.
|
||||
</Text>
|
||||
</Box>
|
||||
</Tooltip>
|
||||
</Stack>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
export default AboutModal;
|
||||
|
|
@ -23,18 +23,7 @@ import {
|
|||
} from 'lucide-react';
|
||||
import { compareVersions } from './pluginUtils.js';
|
||||
import { formatKB } from '../utils/networkUtils.js';
|
||||
|
||||
export const GitHubIcon = ({ size = 16 }) => (
|
||||
<svg width={size} height={size} viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M12 0C5.37 0 0 5.37 0 12c0 5.31 3.435 9.795 8.205 11.385.6.105.825-.255.825-.57 0-.285-.015-1.23-.015-2.235-3.015.555-3.795-.735-4.035-1.41-.135-.345-.72-1.41-1.23-1.695-.42-.225-1.02-.78-.015-.795.945-.015 1.62.87 1.845 1.23 1.08 1.815 2.805 1.305 3.495.99.105-.78.42-1.305.765-1.605-2.67-.3-5.46-1.335-5.46-5.925 0-1.305.465-2.385 1.23-3.225-.12-.3-.54-1.53.12-3.18 0 0 1.005-.315 3.3 1.23.96-.27 1.98-.405 3-.405s2.04.135 3 .405c2.295-1.56 3.3-1.23 3.3-1.23.66 1.65.24 2.88.12 3.18.765.84 1.23 1.905 1.23 3.225 0 4.605-2.805 5.625-5.475 5.925.435.375.81 1.095.81 2.22 0 1.605-.015 2.895-.015 3.3 0 .315.225.69.825.57A12.02 12.02 0 0024 12c0-6.63-5.37-12-12-12z" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
export const DiscordIcon = ({ size = 16 }) => (
|
||||
<svg width={size} height={size} viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M20.317 4.37a19.791 19.791 0 00-4.885-1.515.074.074 0 00-.079.037c-.21.375-.444.864-.608 1.25a18.27 18.27 0 00-5.487 0 12.64 12.64 0 00-.617-1.25.077.077 0 00-.079-.037A19.736 19.736 0 003.677 4.37a.07.07 0 00-.032.027C.533 9.046-.32 13.58.099 18.057a.082.082 0 00.031.057 19.9 19.9 0 005.993 3.03.078.078 0 00.084-.028c.462-.63.874-1.295 1.226-1.994a.076.076 0 00-.041-.106 13.107 13.107 0 01-1.872-.892.077.077 0 01-.008-.128 10.2 10.2 0 00.372-.292.074.074 0 01.077-.01c3.928 1.793 8.18 1.793 12.062 0a.074.074 0 01.078.01c.12.098.246.198.373.292a.077.077 0 01-.006.127 12.299 12.299 0 01-1.873.892.077.077 0 00-.041.107c.36.698.772 1.362 1.225 1.993a.076.076 0 00.084.028 19.839 19.839 0 006.002-3.03.077.077 0 00.032-.054c.5-5.177-.838-9.674-3.549-13.66a.061.061 0 00-.031-.03zM8.02 15.33c-1.183 0-2.157-1.085-2.157-2.419 0-1.333.956-2.419 2.157-2.419 1.21 0 2.176 1.096 2.157 2.42 0 1.333-.956 2.418-2.157 2.418zm7.975 0c-1.183 0-2.157-1.085-2.157-2.419 0-1.333.956-2.419 2.157-2.419 1.21 0 2.176 1.096 2.157 2.42 0 1.333-.947 2.418-2.157 2.418z" />
|
||||
</svg>
|
||||
);
|
||||
import { DiscordIcon, GitHubIcon } from './icons.jsx';
|
||||
|
||||
/**
|
||||
* Shared plugin detail panel used in both PluginCard and AvailablePluginCard modals.
|
||||
|
|
|
|||
|
|
@ -40,11 +40,19 @@ function formatDurationMinutes(startTime, endTime) {
|
|||
return `${minutes}m`;
|
||||
}
|
||||
|
||||
function resolveApiUrl(url) {
|
||||
if (!url || url.startsWith('http')) return url;
|
||||
const apiHost = import.meta.env.DEV
|
||||
? `http://${window.location.hostname}:5656`
|
||||
: '';
|
||||
return `${apiHost}${url}`;
|
||||
}
|
||||
|
||||
function resolveImageUrl(detail) {
|
||||
if (detail?.tmdb_poster_url) return detail.tmdb_poster_url;
|
||||
if (detail?.poster_url) return detail.poster_url;
|
||||
if (detail?.images?.length > 0) return detail.images[0].url;
|
||||
if (detail?.icon) return detail.icon;
|
||||
if (detail?.poster_url) return resolveApiUrl(detail.poster_url);
|
||||
if (detail?.images?.length > 0) return resolveApiUrl(detail.images[0].url);
|
||||
if (detail?.icon) return resolveApiUrl(detail.icon);
|
||||
return null;
|
||||
}
|
||||
|
||||
|
|
@ -298,6 +306,37 @@ export default function ProgramDetailModal({
|
|||
</>
|
||||
)}
|
||||
|
||||
{d.content_advisory?.length > 0 && (
|
||||
<Text size="xs" c="orange" fs="italic">
|
||||
{d.content_advisory.join(', ')}
|
||||
</Text>
|
||||
)}
|
||||
|
||||
{d.event_details && (
|
||||
<>
|
||||
<Divider color="#333" />
|
||||
<Stack gap={4}>
|
||||
{d.event_details.venue100 && (
|
||||
<Text size="sm" c="dimmed">
|
||||
<Text span fw={600}>Venue: </Text>
|
||||
{d.event_details.venue100}
|
||||
</Text>
|
||||
)}
|
||||
{d.event_details.teams?.length > 0 && (
|
||||
<Text size="sm" c="dimmed">
|
||||
<Text span fw={600}>Teams: </Text>
|
||||
{d.event_details.teams.map((t, i) => (
|
||||
<Text span key={i}>
|
||||
{i > 0 ? ' vs ' : ''}
|
||||
{t.name}{t.isHome ? ' (Home)' : ''}
|
||||
</Text>
|
||||
))}
|
||||
</Text>
|
||||
)}
|
||||
</Stack>
|
||||
</>
|
||||
)}
|
||||
|
||||
{hasCredits && (
|
||||
<>
|
||||
<Divider color="#333" />
|
||||
|
|
@ -330,21 +369,13 @@ export default function ProgramDetailModal({
|
|||
</>
|
||||
)}
|
||||
|
||||
{(d.country ||
|
||||
d.language ||
|
||||
{(d.language ||
|
||||
d.original_air_date ||
|
||||
(d.production_date && d.is_previously_shown) ||
|
||||
starRatings.length > 0) && (
|
||||
<>
|
||||
<Divider color="#333" />
|
||||
<Group gap="md" wrap="wrap">
|
||||
{d.country && (
|
||||
<Text size="sm" c="dimmed">
|
||||
<Text span fw={600}>
|
||||
Country:{' '}
|
||||
</Text>
|
||||
{d.country}
|
||||
</Text>
|
||||
)}
|
||||
{d.language && (
|
||||
<Text size="sm" c="dimmed">
|
||||
<Text span fw={600}>
|
||||
|
|
@ -353,6 +384,14 @@ export default function ProgramDetailModal({
|
|||
{d.language}
|
||||
</Text>
|
||||
)}
|
||||
{d.production_date && d.is_previously_shown && (
|
||||
<Text size="sm" c="dimmed">
|
||||
<Text span fw={600}>
|
||||
First aired:{' '}
|
||||
</Text>
|
||||
{d.production_date}
|
||||
</Text>
|
||||
)}
|
||||
{d.original_air_date && (
|
||||
<Text size="sm" c="dimmed">
|
||||
<Text span fw={600}>
|
||||
|
|
|
|||
119
frontend/src/components/ProgramPreview.jsx
Normal file
119
frontend/src/components/ProgramPreview.jsx
Normal file
|
|
@ -0,0 +1,119 @@
|
|||
import React, { useState } from 'react';
|
||||
import {
|
||||
ActionIcon,
|
||||
Box,
|
||||
Group,
|
||||
Progress,
|
||||
Stack,
|
||||
Text,
|
||||
Tooltip,
|
||||
} from '@mantine/core';
|
||||
import { ChevronDown, ChevronRight, Radio } from 'lucide-react';
|
||||
|
||||
const formatProgramTime = (seconds) => {
|
||||
const absSeconds = Math.abs(seconds);
|
||||
const hours = Math.floor(absSeconds / 3600);
|
||||
const minutes = Math.floor((absSeconds % 3600) / 60);
|
||||
const secs = Math.floor(absSeconds % 60);
|
||||
if (hours > 0) {
|
||||
return `${hours}:${minutes.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}`;
|
||||
}
|
||||
return `${minutes}:${secs.toString().padStart(2, '0')}`;
|
||||
};
|
||||
|
||||
const ProgramPreview = ({ program, loading, fetched, label = 'Now Playing:' }) => {
|
||||
const [isExpanded, setIsExpanded] = useState(false);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<Group gap={5}>
|
||||
<Radio size="14" style={{ color: '#22c55e', flexShrink: 0 }} />
|
||||
<Text size="xs" c="dimmed">Loading EPG data...</Text>
|
||||
</Group>
|
||||
);
|
||||
}
|
||||
|
||||
if (fetched && !program) {
|
||||
return (
|
||||
<Group gap={5}>
|
||||
<Radio size="14" style={{ color: '#6b7280', flexShrink: 0 }} />
|
||||
<Text size="xs" c="dimmed">No current program (EPG may need refresh)</Text>
|
||||
</Group>
|
||||
);
|
||||
}
|
||||
|
||||
if (!program) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const now = new Date();
|
||||
const startTime = program.start_time ? new Date(program.start_time) : null;
|
||||
const endTime = program.end_time ? new Date(program.end_time) : null;
|
||||
|
||||
let elapsed = 0, remaining = 0, percentage = 0;
|
||||
let hasValidTime = false;
|
||||
if (startTime && endTime) {
|
||||
const totalDuration = (endTime - startTime) / 1000;
|
||||
if (totalDuration > 0) {
|
||||
hasValidTime = true;
|
||||
elapsed = (now - startTime) / 1000;
|
||||
remaining = (endTime - now) / 1000;
|
||||
percentage = Math.min(100, Math.max(0, (elapsed / totalDuration) * 100));
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Group gap={5} wrap="nowrap">
|
||||
<Radio size="14" style={{ color: '#22c55e', flexShrink: 0 }} />
|
||||
<Text size="xs" fw={500} c="green.5" style={{ flexShrink: 0 }}>
|
||||
{label}
|
||||
</Text>
|
||||
<Tooltip label={program.title}>
|
||||
<Text size="xs" c="dimmed" truncate>
|
||||
{program.title}
|
||||
</Text>
|
||||
</Tooltip>
|
||||
<ActionIcon
|
||||
size="xs"
|
||||
variant="subtle"
|
||||
onClick={() => setIsExpanded(!isExpanded)}
|
||||
style={{ flexShrink: 0 }}
|
||||
>
|
||||
{isExpanded ? <ChevronDown size="14" /> : <ChevronRight size="14" />}
|
||||
</ActionIcon>
|
||||
</Group>
|
||||
|
||||
{isExpanded && program.description && (
|
||||
<Box mt={4} ml={24}>
|
||||
<Text size="xs" c="dimmed" style={{ fontStyle: 'italic' }}>
|
||||
{program.description}
|
||||
</Text>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{isExpanded && hasValidTime && (
|
||||
<Stack gap="xs" mt={4} ml={24}>
|
||||
<Group justify="space-between" align="center">
|
||||
<Text size="xs" c="dimmed">
|
||||
{formatProgramTime(elapsed)} elapsed
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed">
|
||||
{formatProgramTime(remaining)} remaining
|
||||
</Text>
|
||||
</Group>
|
||||
<Progress
|
||||
value={percentage}
|
||||
size="sm"
|
||||
color="#3BA882"
|
||||
style={{
|
||||
backgroundColor: 'rgba(255, 255, 255, 0.1)',
|
||||
}}
|
||||
/>
|
||||
</Stack>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default ProgramPreview;
|
||||
|
|
@ -470,6 +470,13 @@ const SeriesModal = ({ series, opened, onClose }) => {
|
|||
const onChangeSelectedProvider = (value) => {
|
||||
const provider = providers.find((p) => p.id.toString() === value);
|
||||
setSelectedProvider(provider);
|
||||
if (provider) {
|
||||
setLoadingDetails(true);
|
||||
fetchSeriesInfo(series.id, provider.id)
|
||||
.then((details) => setDetailedSeries(details))
|
||||
.catch(() => {})
|
||||
.finally(() => setLoadingDetails(false));
|
||||
}
|
||||
};
|
||||
|
||||
if (!series) return null;
|
||||
|
|
|
|||
|
|
@ -1,7 +1,15 @@
|
|||
import React, { useRef, useState, useMemo } from 'react';
|
||||
import React, { useState, useMemo } from 'react';
|
||||
import { Link, useLocation } from 'react-router-dom';
|
||||
import { copyToClipboard } from '../utils';
|
||||
import { Copy, LogOut, ChevronDown, ChevronRight, Heart } from 'lucide-react';
|
||||
import {
|
||||
Copy,
|
||||
LogOut,
|
||||
ChevronDown,
|
||||
ChevronRight,
|
||||
Heart,
|
||||
HelpCircle,
|
||||
} from 'lucide-react';
|
||||
import AboutModal from './AboutModal';
|
||||
import { getOrderedNavItems } from '../config/navigation';
|
||||
import {
|
||||
Avatar,
|
||||
|
|
@ -10,10 +18,10 @@ import {
|
|||
Box,
|
||||
Text,
|
||||
UnstyledButton,
|
||||
TextInput,
|
||||
ActionIcon,
|
||||
AppShellNavbar,
|
||||
ScrollArea,
|
||||
Skeleton,
|
||||
Tooltip,
|
||||
} from '@mantine/core';
|
||||
import logo from '../images/logo.png';
|
||||
|
|
@ -153,9 +161,9 @@ const Sidebar = ({ collapsed, toggleDrawer, drawerWidth, miniDrawerWidth }) => {
|
|||
const getNavOrder = useAuthStore((s) => s.getNavOrder);
|
||||
const getHiddenNav = useAuthStore((s) => s.getHiddenNav);
|
||||
|
||||
const publicIPRef = useRef(null);
|
||||
|
||||
const [userFormOpen, setUserFormOpen] = useState(false);
|
||||
const [aboutOpen, setAboutOpen] = useState(false);
|
||||
const [ipRevealed, setIpRevealed] = useState(false);
|
||||
|
||||
const closeUserForm = () => setUserFormOpen(false);
|
||||
|
||||
|
|
@ -280,35 +288,106 @@ const Sidebar = ({ collapsed, toggleDrawer, drawerWidth, miniDrawerWidth }) => {
|
|||
}}
|
||||
>
|
||||
{isAuthenticated && (
|
||||
<Stack gap="sm">
|
||||
{!collapsed && (
|
||||
<TextInput
|
||||
label="Public IP"
|
||||
ref={publicIPRef}
|
||||
value={environment.public_ip}
|
||||
readOnly={true}
|
||||
leftSection={
|
||||
environment.country_code && (
|
||||
<img
|
||||
src={`https://flagcdn.com/16x12/${environment.country_code.toLowerCase()}.png`}
|
||||
alt={environment.country_name || environment.country_code}
|
||||
title={
|
||||
environment.country_name || environment.country_code
|
||||
}
|
||||
/>
|
||||
)
|
||||
}
|
||||
rightSection={
|
||||
<ActionIcon
|
||||
variant="transparent"
|
||||
color="gray.9"
|
||||
onClick={copyPublicIP}
|
||||
<Stack gap="sm" style={{ width: '100%' }}>
|
||||
{!collapsed &&
|
||||
environment.ip_lookup_enabled !== false &&
|
||||
environment.ip_lookup_pending && (
|
||||
<Box>
|
||||
<Text size="sm" fw={500} mb={4}>
|
||||
Public IP
|
||||
</Text>
|
||||
<Skeleton height={36} radius="sm" />
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{!collapsed &&
|
||||
environment.ip_lookup_enabled !== false &&
|
||||
!environment.ip_lookup_pending &&
|
||||
environment.public_ip &&
|
||||
!environment.public_ip.startsWith('Error') && (
|
||||
<Box
|
||||
onClick={() => setIpRevealed((v) => !v)}
|
||||
style={{ cursor: 'pointer' }}
|
||||
>
|
||||
<Text size="sm" fw={500} mb={4}>
|
||||
Public IP
|
||||
</Text>
|
||||
<Box
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
border: '1px solid var(--mantine-color-default-border)',
|
||||
borderRadius: 'var(--mantine-radius-sm)',
|
||||
backgroundColor: 'var(--mantine-color-dark-6)',
|
||||
height: '36px',
|
||||
paddingLeft: '10px',
|
||||
gap: '8px',
|
||||
}}
|
||||
>
|
||||
<Copy />
|
||||
</ActionIcon>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
{environment.country_code && (
|
||||
<img
|
||||
src={`https://flagcdn.com/16x12/${environment.country_code.toLowerCase()}.png`}
|
||||
alt={
|
||||
environment.country_name || environment.country_code
|
||||
}
|
||||
title={[
|
||||
environment.country_name || environment.country_code,
|
||||
environment.city,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(', ')}
|
||||
style={{ flexShrink: 0 }}
|
||||
/>
|
||||
)}
|
||||
<Box style={{ flex: 1, overflow: 'hidden' }}>
|
||||
<span
|
||||
style={{
|
||||
display: 'block',
|
||||
whiteSpace: 'nowrap',
|
||||
fontSize: 'var(--mantine-font-size-sm)',
|
||||
color: 'var(--mantine-color-text)',
|
||||
}}
|
||||
>
|
||||
{(() => {
|
||||
const ip = environment.public_ip;
|
||||
const isIPv6 = ip.includes(':');
|
||||
const sep = isIPv6 ? ':' : '.';
|
||||
const parts = ip.split(sep);
|
||||
const splitAt = isIPv6 ? 4 : 2;
|
||||
const visible =
|
||||
parts.slice(0, splitAt).join(sep) + sep;
|
||||
const hidden = parts.slice(splitAt).join(sep);
|
||||
return (
|
||||
<>
|
||||
{visible}
|
||||
<span
|
||||
style={{
|
||||
filter: ipRevealed ? 'none' : 'blur(5px)',
|
||||
transition: 'filter 0.15s',
|
||||
userSelect: ipRevealed ? 'text' : 'none',
|
||||
}}
|
||||
>
|
||||
{hidden}
|
||||
</span>
|
||||
</>
|
||||
);
|
||||
})()}
|
||||
</span>
|
||||
</Box>
|
||||
<ActionIcon
|
||||
variant="transparent"
|
||||
color="gray.9"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
copyPublicIP();
|
||||
}}
|
||||
style={{ flexShrink: 0 }}
|
||||
>
|
||||
<Copy />
|
||||
</ActionIcon>
|
||||
</Box>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{!collapsed && authUser && (
|
||||
<Group
|
||||
|
|
@ -327,7 +406,7 @@ const Sidebar = ({ collapsed, toggleDrawer, drawerWidth, miniDrawerWidth }) => {
|
|||
</Group>
|
||||
)}
|
||||
{collapsed && (
|
||||
<Group gap="xs">
|
||||
<Group justify="center" style={{ width: '100%' }}>
|
||||
<Avatar src="" radius="xl" />
|
||||
</Group>
|
||||
)}
|
||||
|
|
@ -372,6 +451,15 @@ const Sidebar = ({ collapsed, toggleDrawer, drawerWidth, miniDrawerWidth }) => {
|
|||
</Text>
|
||||
</Tooltip>
|
||||
<Group gap="xs" wrap="nowrap">
|
||||
<Tooltip label="About" position="top">
|
||||
<ActionIcon
|
||||
variant="transparent"
|
||||
color="gray"
|
||||
onClick={() => setAboutOpen(true)}
|
||||
>
|
||||
<HelpCircle size={20} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
<DonateButton />
|
||||
{isAuthenticated && <NotificationCenter />}
|
||||
</Group>
|
||||
|
|
@ -389,10 +477,20 @@ const Sidebar = ({ collapsed, toggleDrawer, drawerWidth, miniDrawerWidth }) => {
|
|||
>
|
||||
{isAuthenticated && <NotificationCenter />}
|
||||
<DonateButton tooltipPosition="right" />
|
||||
<Tooltip label="About" position="right">
|
||||
<ActionIcon
|
||||
variant="transparent"
|
||||
color="gray"
|
||||
onClick={() => setAboutOpen(true)}
|
||||
>
|
||||
<HelpCircle size={20} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
<UserForm user={authUser} isOpen={userFormOpen} onClose={closeUserForm} />
|
||||
<AboutModal isOpen={aboutOpen} onClose={() => setAboutOpen(false)} />
|
||||
</AppShellNavbar>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -23,7 +23,7 @@ import {
|
|||
formatStreamLabel,
|
||||
getYouTubeEmbedUrl,
|
||||
imdbUrl,
|
||||
tmdbUrl
|
||||
tmdbUrl,
|
||||
} from '../utils/components/SeriesModalUtils.js';
|
||||
import { YouTubeTrailerModal } from './modals/YouTubeTrailerModal.jsx';
|
||||
import {
|
||||
|
|
@ -38,7 +38,7 @@ const Movie = ({
|
|||
hasMultipleProviders,
|
||||
selectedProvider,
|
||||
detailedVOD,
|
||||
vod
|
||||
vod,
|
||||
}) => {
|
||||
const showVideo = useVideoStore((s) => s.showVideo);
|
||||
const env_mode = useSettingsStore((s) => s.environment.env_mode);
|
||||
|
|
@ -71,28 +71,19 @@ const Movie = ({
|
|||
<Title order={3}>{displayVOD.name}</Title>
|
||||
|
||||
{/* Original name if different */}
|
||||
{displayVOD.o_name &&
|
||||
displayVOD.o_name !== displayVOD.name && (
|
||||
<Text size="sm" c="dimmed" fs="italic">
|
||||
Original: {displayVOD.o_name}
|
||||
</Text>
|
||||
)}
|
||||
{displayVOD.o_name && displayVOD.o_name !== displayVOD.name && (
|
||||
<Text size="sm" c="dimmed" fs="italic">
|
||||
Original: {displayVOD.o_name}
|
||||
</Text>
|
||||
)}
|
||||
|
||||
<Group spacing="md">
|
||||
{displayVOD.year && (
|
||||
<Badge color="blue">{displayVOD.year}</Badge>
|
||||
)}
|
||||
{displayVOD.year && <Badge color="blue">{displayVOD.year}</Badge>}
|
||||
{displayVOD.duration_secs && (
|
||||
<Badge color="gray">
|
||||
{formatDuration(displayVOD.duration_secs)}
|
||||
</Badge>
|
||||
)}
|
||||
{displayVOD.rating && (
|
||||
<Badge color="yellow">{displayVOD.rating}</Badge>
|
||||
)}
|
||||
{displayVOD.age && (
|
||||
<Badge color="orange">{displayVOD.age}</Badge>
|
||||
<Badge color="gray">{formatDuration(displayVOD.duration_secs)}</Badge>
|
||||
)}
|
||||
{displayVOD.rating && <Badge color="yellow">{displayVOD.rating}</Badge>}
|
||||
{displayVOD.age && <Badge color="orange">{displayVOD.age}</Badge>}
|
||||
<Badge color="green">Movie</Badge>
|
||||
{/* imdb_id and tmdb_id badges */}
|
||||
{displayVOD.imdb_id && (
|
||||
|
|
@ -203,12 +194,15 @@ const Movie = ({
|
|||
|
||||
const MovieTechnicalDetails = ({ selectedProvider, displayVOD }) => {
|
||||
const techDetails = getTechnicalDetails(selectedProvider, displayVOD);
|
||||
const hasDetails = techDetails.bitrate || techDetails.video || techDetails.audio;
|
||||
const hasDetails =
|
||||
techDetails.bitrate || techDetails.video || techDetails.audio;
|
||||
|
||||
if (!hasDetails) return null;
|
||||
|
||||
const hasVideo = techDetails.video && Object.keys(techDetails.video).length > 0;
|
||||
const hasAudio = techDetails.audio && Object.keys(techDetails.audio).length > 0;
|
||||
const hasVideo =
|
||||
techDetails.video && Object.keys(techDetails.video).length > 0;
|
||||
const hasAudio =
|
||||
techDetails.audio && Object.keys(techDetails.audio).length > 0;
|
||||
|
||||
return (
|
||||
<Stack spacing={4} mt="xs">
|
||||
|
|
@ -217,7 +211,9 @@ const MovieTechnicalDetails = ({ selectedProvider, displayVOD }) => {
|
|||
{selectedProvider && (
|
||||
<Text size="xs" c="dimmed" weight="normal" span ml={8}>
|
||||
(from {selectedProvider.m3u_account.name}
|
||||
{selectedProvider.stream_id && ` - Stream ${selectedProvider.stream_id}`})
|
||||
{selectedProvider.stream_id &&
|
||||
` - Stream ${selectedProvider.stream_id}`}
|
||||
)
|
||||
</Text>
|
||||
)}
|
||||
</Text>
|
||||
|
|
@ -315,16 +311,21 @@ const VODModal = ({ vod, opened, onClose }) => {
|
|||
}, [opened]);
|
||||
|
||||
const onClickYouTubeTrailer = () => {
|
||||
setTrailerUrl(
|
||||
getYouTubeEmbedUrl(displayVOD.youtube_trailer)
|
||||
);
|
||||
setTrailerUrl(getYouTubeEmbedUrl(displayVOD.youtube_trailer));
|
||||
setTrailerModalOpened(true);
|
||||
}
|
||||
};
|
||||
|
||||
const onChangeSelectedProvider = (value) => {
|
||||
const provider = providers.find((p) => p.id.toString() === value);
|
||||
setSelectedProvider(provider);
|
||||
}
|
||||
if (provider) {
|
||||
setLoadingDetails(true);
|
||||
fetchMovieDetailsFromProvider(vod.id, provider.id)
|
||||
.then((details) => setDetailedVOD(details))
|
||||
.catch(() => {})
|
||||
.finally(() => setLoadingDetails(false));
|
||||
}
|
||||
};
|
||||
|
||||
if (!vod) return null;
|
||||
|
||||
|
|
|
|||
231
frontend/src/components/__tests__/ProgramPreview.test.jsx
Normal file
231
frontend/src/components/__tests__/ProgramPreview.test.jsx
Normal file
|
|
@ -0,0 +1,231 @@
|
|||
import { describe, it, expect, vi, afterEach } from 'vitest';
|
||||
import { render, screen, fireEvent } from '@testing-library/react';
|
||||
import ProgramPreview from '../ProgramPreview';
|
||||
|
||||
// Mock Mantine components as lightweight stubs
|
||||
vi.mock('@mantine/core', () => {
|
||||
return {
|
||||
ActionIcon: ({ children, onClick, ...props }) => (
|
||||
<button onClick={onClick} {...props}>
|
||||
{children}
|
||||
</button>
|
||||
),
|
||||
Box: ({ children, ...props }) => <div {...props}>{children}</div>,
|
||||
Group: ({ children }) => <div>{children}</div>,
|
||||
Progress: ({ value }) => (
|
||||
<div data-testid="progress" data-value={value} />
|
||||
),
|
||||
Stack: ({ children }) => <div>{children}</div>,
|
||||
Text: ({ children }) => <span>{children}</span>,
|
||||
Tooltip: ({ children }) => <>{children}</>,
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock('lucide-react', () => ({
|
||||
ChevronDown: () => <span data-testid="chevron-down" />,
|
||||
ChevronRight: () => <span data-testid="chevron-right" />,
|
||||
Radio: () => <span data-testid="radio-icon" />,
|
||||
}));
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
describe('ProgramPreview', () => {
|
||||
it('renders nothing when loading=false, fetched=false, program=null', () => {
|
||||
const { container } = render(
|
||||
<ProgramPreview loading={false} fetched={false} program={null} />
|
||||
);
|
||||
expect(container.innerHTML).toBe('');
|
||||
});
|
||||
|
||||
it('shows loading text when loading=true', () => {
|
||||
render(<ProgramPreview loading={true} fetched={false} program={null} />);
|
||||
expect(screen.getByText('Loading EPG data...')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('shows no current program message when fetched=true and program=null', () => {
|
||||
render(<ProgramPreview loading={false} fetched={true} program={null} />);
|
||||
expect(
|
||||
screen.getByText('No current program (EPG may need refresh)')
|
||||
).toBeTruthy();
|
||||
});
|
||||
|
||||
it('shows program title when program is provided', () => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date('2026-01-01T12:00:00.000Z'));
|
||||
|
||||
const now = Date.now();
|
||||
const program = {
|
||||
title: 'Test Show',
|
||||
start_time: new Date(now - 1800000).toISOString(),
|
||||
end_time: new Date(now + 1800000).toISOString(),
|
||||
};
|
||||
render(
|
||||
<ProgramPreview
|
||||
loading={false}
|
||||
fetched={true}
|
||||
program={program}
|
||||
/>
|
||||
);
|
||||
expect(screen.getByText('Test Show')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('shows custom label when label prop is overridden', () => {
|
||||
const program = { title: 'Show', start_time: null, end_time: null };
|
||||
render(
|
||||
<ProgramPreview
|
||||
loading={false}
|
||||
fetched={true}
|
||||
program={program}
|
||||
label="Currently Airing:"
|
||||
/>
|
||||
);
|
||||
expect(screen.getByText('Currently Airing:')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('shows default label "Now Playing:"', () => {
|
||||
const program = { title: 'Show', start_time: null, end_time: null };
|
||||
render(
|
||||
<ProgramPreview
|
||||
loading={false}
|
||||
fetched={true}
|
||||
program={program}
|
||||
/>
|
||||
);
|
||||
expect(screen.getByText('Now Playing:')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('expand/collapse reveals description and time details', () => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date('2026-01-01T12:00:00.000Z'));
|
||||
|
||||
const now = Date.now();
|
||||
const program = {
|
||||
title: 'Expandable Show',
|
||||
description: 'A detailed description',
|
||||
start_time: new Date(now - 3600000).toISOString(),
|
||||
end_time: new Date(now + 3600000).toISOString(),
|
||||
};
|
||||
render(
|
||||
<ProgramPreview
|
||||
loading={false}
|
||||
fetched={true}
|
||||
program={program}
|
||||
/>
|
||||
);
|
||||
|
||||
// Description not visible initially
|
||||
expect(screen.queryByText('A detailed description')).toBeNull();
|
||||
|
||||
// Click chevron to expand
|
||||
const expandButton = screen.getByTestId('chevron-right').closest('button');
|
||||
fireEvent.click(expandButton);
|
||||
|
||||
// Description should now be visible
|
||||
expect(screen.getByText('A detailed description')).toBeTruthy();
|
||||
|
||||
// Time info visible
|
||||
expect(screen.getByText(/elapsed/)).toBeTruthy();
|
||||
expect(screen.getByText(/remaining/)).toBeTruthy();
|
||||
|
||||
// Click again to collapse
|
||||
const collapseButton = screen.getByTestId('chevron-down').closest('button');
|
||||
fireEvent.click(collapseButton);
|
||||
|
||||
// Description and time should be hidden again
|
||||
expect(screen.queryByText('A detailed description')).toBeNull();
|
||||
expect(screen.queryByText(/elapsed/)).toBeNull();
|
||||
});
|
||||
|
||||
it('does not render description block when program has no description', () => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date('2026-01-01T12:00:00.000Z'));
|
||||
|
||||
const now = Date.now();
|
||||
const program = {
|
||||
title: 'No Desc Show',
|
||||
description: null,
|
||||
start_time: new Date(now - 3600000).toISOString(),
|
||||
end_time: new Date(now + 3600000).toISOString(),
|
||||
};
|
||||
render(
|
||||
<ProgramPreview
|
||||
loading={false}
|
||||
fetched={true}
|
||||
program={program}
|
||||
/>
|
||||
);
|
||||
|
||||
// Expand
|
||||
const expandButton = screen.getByTestId('chevron-right').closest('button');
|
||||
fireEvent.click(expandButton);
|
||||
|
||||
// Time info should be visible, but no italic description block
|
||||
expect(screen.getByText(/elapsed/)).toBeTruthy();
|
||||
expect(screen.queryByText('null')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('formatProgramTime', () => {
|
||||
// We need to test the exported helper; import it via the module
|
||||
// Since it's not exported, we test it indirectly through rendered output
|
||||
it('formats time with hours correctly', () => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date('2026-01-01T12:00:00.000Z'));
|
||||
|
||||
const now = Date.now();
|
||||
const program = {
|
||||
title: 'Long Show',
|
||||
description: 'desc',
|
||||
start_time: new Date(now - 2 * 3600000 - 30 * 60000).toISOString(), // 2h30m ago
|
||||
end_time: new Date(now + 30 * 60000).toISOString(), // 30m from now
|
||||
};
|
||||
render(
|
||||
<ProgramPreview
|
||||
loading={false}
|
||||
fetched={true}
|
||||
program={program}
|
||||
/>
|
||||
);
|
||||
|
||||
// Expand to see time
|
||||
const expandButton = screen.getByTestId('chevron-right').closest('button');
|
||||
fireEvent.click(expandButton);
|
||||
|
||||
// Elapsed should show ~2:30:xx format (with hours)
|
||||
const elapsedEl = screen.getByText(/elapsed/);
|
||||
expect(elapsedEl.textContent).toMatch(/2:30:\d{2} elapsed/);
|
||||
});
|
||||
|
||||
it('formats time without hours correctly', () => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date('2026-01-01T12:00:00.000Z'));
|
||||
|
||||
const now = Date.now();
|
||||
const program = {
|
||||
title: 'Short Show',
|
||||
description: 'desc',
|
||||
start_time: new Date(now - 5 * 60000).toISOString(), // 5m ago
|
||||
end_time: new Date(now + 25 * 60000).toISOString(), // 25m from now
|
||||
};
|
||||
render(
|
||||
<ProgramPreview
|
||||
loading={false}
|
||||
fetched={true}
|
||||
program={program}
|
||||
/>
|
||||
);
|
||||
|
||||
const expandButton = screen.getByTestId('chevron-right').closest('button');
|
||||
fireEvent.click(expandButton);
|
||||
|
||||
// Elapsed should show ~5:xx format (no hours)
|
||||
const elapsedEl = screen.getByText(/elapsed/);
|
||||
expect(elapsedEl.textContent).toMatch(/5:\d{2} elapsed/);
|
||||
|
||||
// With pinned time, remaining should be exactly 25:00
|
||||
const remainingEl = screen.getByText(/remaining/);
|
||||
expect(remainingEl.textContent).toBe('25:00 remaining');
|
||||
});
|
||||
});
|
||||
|
|
@ -7,6 +7,12 @@ import useSettingsStore from '../../store/settings';
|
|||
import { copyToClipboard } from '../../utils';
|
||||
|
||||
// Mock stores
|
||||
vi.mock('../../store/auth', () => ({
|
||||
default: {
|
||||
getState: () => ({ accessToken: null }),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('../../store/useVODStore', () => ({
|
||||
default: vi.fn(),
|
||||
}));
|
||||
|
|
|
|||
|
|
@ -22,6 +22,10 @@ vi.mock('../NotificationCenter', () => ({
|
|||
),
|
||||
}));
|
||||
|
||||
vi.mock('../AboutModal', () => ({
|
||||
default: () => null,
|
||||
}));
|
||||
|
||||
// Mock lucide-react icons
|
||||
vi.mock('lucide-react', () => ({
|
||||
ListOrdered: ({ onClick }) => (
|
||||
|
|
@ -59,6 +63,7 @@ vi.mock('lucide-react', () => ({
|
|||
Heart: () => <div data-testid="heart-icon" />,
|
||||
Package: () => <div data-testid="package-icon" />,
|
||||
Download: () => <div data-testid="download-icon" />,
|
||||
HelpCircle: () => <div data-testid="help-circle-icon" />,
|
||||
}));
|
||||
|
||||
// Mock UserForm component
|
||||
|
|
@ -91,14 +96,6 @@ vi.mock('@mantine/core', async () => {
|
|||
</Component>
|
||||
);
|
||||
},
|
||||
TextInput: ({ value, onChange, leftSection, rightSection, label }) => (
|
||||
<div>
|
||||
{label && <label>{label}</label>}
|
||||
{leftSection}
|
||||
<input value={value} onChange={onChange} />
|
||||
{rightSection}
|
||||
</div>
|
||||
),
|
||||
ActionIcon: ({ children, onClick, ...props }) => (
|
||||
<button onClick={onClick} {...props}>
|
||||
{children}
|
||||
|
|
@ -117,6 +114,7 @@ vi.mock('@mantine/core', async () => {
|
|||
</nav>
|
||||
),
|
||||
ScrollArea: ({ children }) => <div>{children}</div>,
|
||||
Skeleton: ({ height }) => <div data-testid="skeleton" style={{ height }} />,
|
||||
Tooltip: ({ children }) => <>{children}</>,
|
||||
};
|
||||
});
|
||||
|
|
@ -285,8 +283,7 @@ describe('Sidebar', () => {
|
|||
it('should render public IP with country flag', () => {
|
||||
renderSidebar();
|
||||
|
||||
const ipInput = screen.getByDisplayValue('192.168.1.1');
|
||||
expect(ipInput).toBeInTheDocument();
|
||||
expect(screen.getByText('Public IP')).toBeInTheDocument();
|
||||
|
||||
const flag = screen.getByAltText('United States');
|
||||
expect(flag).toHaveAttribute('src', 'https://flagcdn.com/16x12/us.png');
|
||||
|
|
@ -487,7 +484,7 @@ describe('Sidebar', () => {
|
|||
});
|
||||
|
||||
renderSidebar();
|
||||
expect(screen.getByDisplayValue('192.168.1.1')).toBeInTheDocument();
|
||||
expect(screen.getByText('Public IP')).toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByRole('img', { name: /flag/i })
|
||||
).not.toBeInTheDocument();
|
||||
|
|
|
|||
|
|
@ -439,12 +439,12 @@ export default function BackupManager() {
|
|||
try {
|
||||
await API.restoreBackup(selectedBackup.name);
|
||||
notifications.show({
|
||||
title: 'Success',
|
||||
title: 'Restore Complete',
|
||||
message:
|
||||
'Backup restored successfully. You may need to refresh the page.',
|
||||
'Backup restored successfully. A restart is recommended to ensure all services are running against the restored data.',
|
||||
color: 'green',
|
||||
});
|
||||
setTimeout(() => window.location.reload(), 2000);
|
||||
setTimeout(() => window.location.reload(), 4000);
|
||||
} catch (error) {
|
||||
notifications.show({
|
||||
title: 'Error',
|
||||
|
|
|
|||
|
|
@ -11,7 +11,6 @@ import {
|
|||
Card,
|
||||
Center,
|
||||
Group,
|
||||
Progress,
|
||||
Select,
|
||||
Stack,
|
||||
Text,
|
||||
|
|
@ -19,18 +18,16 @@ import {
|
|||
useMantineTheme,
|
||||
} from '@mantine/core';
|
||||
import {
|
||||
ChevronDown,
|
||||
ChevronRight,
|
||||
CirclePlay,
|
||||
Gauge,
|
||||
HardDriveDownload,
|
||||
HardDriveUpload,
|
||||
Radio,
|
||||
SquareX,
|
||||
Timer,
|
||||
Users,
|
||||
Video,
|
||||
} from 'lucide-react';
|
||||
import ProgramPreview from '../ProgramPreview';
|
||||
import {
|
||||
toFriendlyDuration,
|
||||
formatExactDuration,
|
||||
|
|
@ -57,50 +54,6 @@ import {
|
|||
import useVideoStore from '../../store/useVideoStore';
|
||||
import { buildLiveStreamUrl } from '../../utils/components/FloatingVideoUtils.js';
|
||||
|
||||
const formatProgramTime = (seconds) => {
|
||||
const absSeconds = Math.abs(seconds);
|
||||
const hours = Math.floor(absSeconds / 3600);
|
||||
const minutes = Math.floor((absSeconds % 3600) / 60);
|
||||
const secs = Math.floor(absSeconds % 60);
|
||||
if (hours > 0) {
|
||||
return `${hours}:${minutes.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}`;
|
||||
}
|
||||
return `${minutes}:${secs.toString().padStart(2, '0')}`;
|
||||
};
|
||||
|
||||
const ProgramProgress = ({ currentProgram }) => {
|
||||
const now = new Date();
|
||||
const startTime = new Date(currentProgram.start_time);
|
||||
const endTime = new Date(currentProgram.end_time);
|
||||
const totalDuration = (endTime - startTime) / 1000; // in seconds
|
||||
const elapsed = (now - startTime) / 1000; // in seconds
|
||||
const remaining = (endTime - now) / 1000; // in seconds
|
||||
const percentage = Math.min(
|
||||
100,
|
||||
Math.max(0, (elapsed / totalDuration) * 100)
|
||||
);
|
||||
|
||||
return (
|
||||
<Stack gap="xs" mt={4}>
|
||||
<Group justify="space-between" align="center">
|
||||
<Text size="xs" c="dimmed">
|
||||
{formatProgramTime(elapsed)} elapsed
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed">
|
||||
{formatProgramTime(remaining)} remaining
|
||||
</Text>
|
||||
</Group>
|
||||
<Progress
|
||||
value={percentage}
|
||||
size="sm"
|
||||
color="#3BA882"
|
||||
style={{
|
||||
backgroundColor: 'rgba(255, 255, 255, 0.1)',
|
||||
}}
|
||||
/>
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
// Create a separate component for each channel card to properly handle the hook
|
||||
const StreamConnectionCard = ({
|
||||
|
|
@ -120,7 +73,7 @@ const StreamConnectionCard = ({
|
|||
const [currentM3UProfile, setCurrentM3UProfile] = useState(null); // Add state for current M3U profile
|
||||
const [data, setData] = useState([]);
|
||||
const [previewedStream, setPreviewedStream] = useState(null);
|
||||
const [isProgramDescExpanded, setIsProgramDescExpanded] = useState(false);
|
||||
|
||||
|
||||
const theme = useMantineTheme();
|
||||
|
||||
|
|
@ -673,48 +626,11 @@ const StreamConnectionCard = ({
|
|||
|
||||
{/* Display current program on its own line */}
|
||||
{currentProgram && (
|
||||
<Group gap={5} mt={-9} wrap="nowrap">
|
||||
<Radio size="14" style={{ color: '#22c55e', flexShrink: 0 }} />
|
||||
<Text size="xs" fw={500} c="green.5" style={{ flexShrink: 0 }}>
|
||||
Now Playing:
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed" truncate>
|
||||
{currentProgram.title}
|
||||
</Text>
|
||||
<ActionIcon
|
||||
size="xs"
|
||||
variant="subtle"
|
||||
onClick={() => setIsProgramDescExpanded(!isProgramDescExpanded)}
|
||||
style={{ flexShrink: 0 }}
|
||||
>
|
||||
{isProgramDescExpanded ? (
|
||||
<ChevronDown size="14" />
|
||||
) : (
|
||||
<ChevronRight size="14" />
|
||||
)}
|
||||
</ActionIcon>
|
||||
</Group>
|
||||
<Box mt={-9}>
|
||||
<ProgramPreview program={currentProgram} />
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* Expandable program description */}
|
||||
{currentProgram &&
|
||||
isProgramDescExpanded &&
|
||||
currentProgram.description && (
|
||||
<Box mt={4} ml={24}>
|
||||
<Text size="xs" c="dimmed" style={{ fontStyle: 'italic' }}>
|
||||
{currentProgram.description}
|
||||
</Text>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* Program progress bar */}
|
||||
{currentProgram &&
|
||||
isProgramDescExpanded &&
|
||||
currentProgram.start_time &&
|
||||
currentProgram.end_time && (
|
||||
<ProgramProgress currentProgram={currentProgram} />
|
||||
)}
|
||||
|
||||
{/* Add stream selection dropdown and preview button */}
|
||||
{availableStreams.length > 0 && (
|
||||
<Box mt={-10}>
|
||||
|
|
|
|||
|
|
@ -3,10 +3,12 @@ import { useForm } from 'react-hook-form';
|
|||
import { yupResolver } from '@hookform/resolvers/yup';
|
||||
import * as Yup from 'yup';
|
||||
import useChannelsStore from '../../store/channels';
|
||||
import API from '../../api';
|
||||
import useStreamProfilesStore from '../../store/streamProfiles';
|
||||
import ChannelGroupForm from './ChannelGroup';
|
||||
import logo from '../../images/logo.png';
|
||||
import { useChannelLogoSelection } from '../../hooks/useSmartLogos';
|
||||
import { useEpgPreview } from '../../hooks/useEpgPreview';
|
||||
import useLogosStore from '../../store/logos';
|
||||
import LazyLogo from '../LazyLogo';
|
||||
import LogoForm from './Logo';
|
||||
|
|
@ -34,6 +36,7 @@ import {
|
|||
useMantineTheme,
|
||||
} from '@mantine/core';
|
||||
import { ListOrdered, SquarePlus, Undo2, X, Zap } from 'lucide-react';
|
||||
import ProgramPreview from '../ProgramPreview';
|
||||
import useEPGsStore from '../../store/epgs';
|
||||
import { FixedSizeList as List } from 'react-window';
|
||||
import { USER_LEVEL_LABELS, USER_LEVELS } from '../../constants';
|
||||
|
|
@ -160,12 +163,24 @@ const ChannelForm = ({ channel: channelProp = null, isOpen, onClose }) => {
|
|||
}
|
||||
|
||||
setAutoMatchLoading(true);
|
||||
let accepted = false;
|
||||
try {
|
||||
const response = await matchChannelEpg(channel);
|
||||
|
||||
if (response?.accepted) {
|
||||
accepted = true;
|
||||
showNotification({
|
||||
title: 'Matching in Progress',
|
||||
message:
|
||||
response.message ||
|
||||
'EPG auto-match is running. Results will appear when complete.',
|
||||
color: 'blue',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (response.matched) {
|
||||
// Update the form with the new EPG data
|
||||
if (response.channel && response.channel.epg_data_id) {
|
||||
if (response.channel?.epg_data_id) {
|
||||
setValue('epg_data_id', response.channel.epg_data_id);
|
||||
}
|
||||
|
||||
|
|
@ -189,7 +204,9 @@ const ChannelForm = ({ channel: channelProp = null, isOpen, onClose }) => {
|
|||
});
|
||||
console.error('Auto-match error:', error);
|
||||
} finally {
|
||||
setAutoMatchLoading(false);
|
||||
if (!accepted) {
|
||||
setAutoMatchLoading(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
|
|
@ -351,6 +368,48 @@ const ChannelForm = ({ channel: channelProp = null, isOpen, onClose }) => {
|
|||
resolver: yupResolver(validationSchema),
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
const onMatchResult = (event) => {
|
||||
const data = event.detail;
|
||||
if (!channel?.id || String(data.channel_id) !== String(channel.id)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (data.matched && data.channel?.epg_data_id) {
|
||||
setValue('epg_data_id', data.channel.epg_data_id);
|
||||
}
|
||||
|
||||
showNotification({
|
||||
title: data.matched ? 'Success' : 'No Match Found',
|
||||
message: data.message,
|
||||
color: data.matched ? 'green' : 'orange',
|
||||
});
|
||||
setAutoMatchLoading(false);
|
||||
};
|
||||
|
||||
window.addEventListener('single-channel-epg-match', onMatchResult);
|
||||
return () =>
|
||||
window.removeEventListener('single-channel-epg-match', onMatchResult);
|
||||
}, [channel?.id, setValue]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!autoMatchLoading) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const timeoutId = window.setTimeout(() => {
|
||||
setAutoMatchLoading(false);
|
||||
showNotification({
|
||||
title: 'Matching Timed Out',
|
||||
message:
|
||||
'EPG auto-match is taking longer than expected. Check back shortly or try again.',
|
||||
color: 'orange',
|
||||
});
|
||||
}, 180_000);
|
||||
|
||||
return () => window.clearTimeout(timeoutId);
|
||||
}, [autoMatchLoading]);
|
||||
|
||||
const clearOverrides = async () => {
|
||||
if (!channel) return;
|
||||
try {
|
||||
|
|
@ -455,6 +514,10 @@ const ChannelForm = ({ channel: channelProp = null, isOpen, onClose }) => {
|
|||
}
|
||||
}, [defaultValues, channel, reset, epgs, tvgsById]);
|
||||
|
||||
const epgDataId = watch('epg_data_id');
|
||||
const { currentProgram, isLoadingProgram, hasFetchedProgram } =
|
||||
useEpgPreview(epgDataId);
|
||||
|
||||
// Memoize logo options to prevent infinite re-renders during background loading
|
||||
const logoOptions = useMemo(() => {
|
||||
const options = [{ id: '0', name: 'Default' }].concat(
|
||||
|
|
@ -521,7 +584,8 @@ const ChannelForm = ({ channel: channelProp = null, isOpen, onClose }) => {
|
|||
</Text>
|
||||
)}
|
||||
<Group justify="space-between" align="top">
|
||||
<Stack gap="5" style={{ flex: 1 }}>
|
||||
{/* Col 1: Identity - Channel Name, Number, Group, Logo */}
|
||||
<Stack gap="5" style={{ flex: 1, minWidth: 0 }}>
|
||||
<TextInput
|
||||
id="name"
|
||||
name="name"
|
||||
|
|
@ -561,6 +625,33 @@ const ChannelForm = ({ channel: channelProp = null, isOpen, onClose }) => {
|
|||
style={{ flex: 1 }}
|
||||
/>
|
||||
|
||||
<NumberInput
|
||||
id="channel_number"
|
||||
name="channel_number"
|
||||
label="Channel # (blank to auto-assign)"
|
||||
description={
|
||||
<ProviderHintRow
|
||||
channel={channel}
|
||||
field="channel_number"
|
||||
formValue={watch('channel_number')}
|
||||
hintText={getProviderHint(channel, 'channel_number')}
|
||||
onReset={() =>
|
||||
setValue(
|
||||
'channel_number',
|
||||
getProviderFormValue(channel, 'channel_number'),
|
||||
{ shouldDirty: true }
|
||||
)
|
||||
}
|
||||
/>
|
||||
}
|
||||
value={watch('channel_number')}
|
||||
onChange={(value) => setValue('channel_number', value)}
|
||||
error={errors.channel_number?.message}
|
||||
size="xs"
|
||||
step={0.1}
|
||||
precision={1}
|
||||
/>
|
||||
|
||||
<Flex gap="sm">
|
||||
<Popover
|
||||
opened={groupPopoverOpened}
|
||||
|
|
@ -674,64 +765,6 @@ const ChannelForm = ({ channel: channelProp = null, isOpen, onClose }) => {
|
|||
</Flex>
|
||||
</Flex>
|
||||
|
||||
<Select
|
||||
id="stream_profile_id"
|
||||
label="Stream Profile"
|
||||
name="stream_profile_id"
|
||||
description={
|
||||
<ProviderHintRow
|
||||
channel={channel}
|
||||
field="stream_profile_id"
|
||||
formValue={watch('stream_profile_id')}
|
||||
hintText={getFkProviderHint(
|
||||
channel,
|
||||
'stream_profile_id',
|
||||
streamProfiles.reduce((acc, p) => {
|
||||
acc[p.id] = p;
|
||||
return acc;
|
||||
}, {})
|
||||
)}
|
||||
onReset={() =>
|
||||
setValue(
|
||||
'stream_profile_id',
|
||||
getProviderFormValue(channel, 'stream_profile_id')
|
||||
)
|
||||
}
|
||||
/>
|
||||
}
|
||||
value={watch('stream_profile_id')}
|
||||
onChange={(value) => {
|
||||
setValue('stream_profile_id', value);
|
||||
}}
|
||||
error={errors.stream_profile_id?.message}
|
||||
data={[{ value: '0', label: '(use default)' }].concat(
|
||||
streamProfiles.map((option) => ({
|
||||
value: `${option.id}`,
|
||||
label: option.name,
|
||||
}))
|
||||
)}
|
||||
size="xs"
|
||||
/>
|
||||
|
||||
<Select
|
||||
label="User Level Access"
|
||||
data={Object.entries(USER_LEVELS).map(([, value]) => {
|
||||
return {
|
||||
label: USER_LEVEL_LABELS[value],
|
||||
value: `${value}`,
|
||||
};
|
||||
})}
|
||||
value={watch('user_level')}
|
||||
onChange={(value) => {
|
||||
setValue('user_level', value);
|
||||
}}
|
||||
error={errors.user_level?.message}
|
||||
/>
|
||||
</Stack>
|
||||
|
||||
<Divider size="sm" orientation="vertical" />
|
||||
|
||||
<Stack justify="flex-start" style={{ flex: 1 }}>
|
||||
<Group justify="space-between">
|
||||
<Popover
|
||||
opened={logoPopoverOpened}
|
||||
|
|
@ -908,85 +941,16 @@ const ChannelForm = ({ channel: channelProp = null, isOpen, onClose }) => {
|
|||
>
|
||||
Upload or Create Logo
|
||||
</Button>
|
||||
<Tooltip label="Mark as mature/adult content (18+)" withArrow>
|
||||
<Box>
|
||||
<Switch
|
||||
label="Mature Content"
|
||||
checked={watch('is_adult')}
|
||||
onChange={(event) =>
|
||||
setValue('is_adult', event.currentTarget.checked)
|
||||
}
|
||||
size="md"
|
||||
/>
|
||||
</Box>
|
||||
</Tooltip>
|
||||
<Tooltip
|
||||
label="Hides this channel from HDHR, M3U, EPG, and XC client output and preserves it from auto-cleanup. To hide channels per-user, use channel profiles instead."
|
||||
withArrow
|
||||
multiline
|
||||
w={320}
|
||||
>
|
||||
<Box>
|
||||
<Switch
|
||||
label="Hidden"
|
||||
checked={watch('hidden_from_output')}
|
||||
onChange={(event) =>
|
||||
setValue(
|
||||
'hidden_from_output',
|
||||
event.currentTarget.checked
|
||||
)
|
||||
}
|
||||
size="md"
|
||||
/>
|
||||
</Box>
|
||||
</Tooltip>
|
||||
{channel?.auto_created && hasAnyOverride && (
|
||||
<Tooltip
|
||||
label={`Currently overriding: ${overriddenFieldLabels.join(', ')}. Clear all overrides to follow the provider values again on the next refresh.`}
|
||||
withArrow
|
||||
>
|
||||
<Button
|
||||
variant="light"
|
||||
color="orange"
|
||||
size="xs"
|
||||
onClick={clearOverrides}
|
||||
>
|
||||
Clear All Overrides ({overriddenFieldLabels.length})
|
||||
</Button>
|
||||
</Tooltip>
|
||||
)}
|
||||
</Stack>
|
||||
|
||||
<Divider size="sm" orientation="vertical" />
|
||||
|
||||
<Stack gap="5" style={{ flex: 1 }} justify="flex-start">
|
||||
<NumberInput
|
||||
id="channel_number"
|
||||
name="channel_number"
|
||||
label="Channel # (blank to auto-assign)"
|
||||
description={
|
||||
<ProviderHintRow
|
||||
channel={channel}
|
||||
field="channel_number"
|
||||
formValue={watch('channel_number')}
|
||||
hintText={getProviderHint(channel, 'channel_number')}
|
||||
onReset={() =>
|
||||
setValue(
|
||||
'channel_number',
|
||||
getProviderFormValue(channel, 'channel_number'),
|
||||
{ shouldDirty: true }
|
||||
)
|
||||
}
|
||||
/>
|
||||
}
|
||||
value={watch('channel_number')}
|
||||
onChange={(value) => setValue('channel_number', value)}
|
||||
error={errors.channel_number?.message}
|
||||
size="xs"
|
||||
step={0.1} // Add step prop to allow decimal inputs
|
||||
precision={1} // Specify decimal precision
|
||||
/>
|
||||
|
||||
{/* Col 2: Guide Data - TVG-ID, Gracenote StationId, EPG, Program Preview */}
|
||||
<Stack
|
||||
gap="5"
|
||||
style={{ flex: 1, minWidth: 0 }}
|
||||
justify="flex-start"
|
||||
>
|
||||
<TextInput
|
||||
id="tvg_id"
|
||||
name="tvg_id"
|
||||
|
|
@ -1218,6 +1182,124 @@ const ChannelForm = ({ channel: channelProp = null, isOpen, onClose }) => {
|
|||
</ScrollArea>
|
||||
</PopoverDropdown>
|
||||
</Popover>
|
||||
|
||||
{(isLoadingProgram || hasFetchedProgram || currentProgram) && (
|
||||
<Box mt="xs" p="xs">
|
||||
<ProgramPreview
|
||||
program={currentProgram}
|
||||
loading={isLoadingProgram}
|
||||
fetched={hasFetchedProgram}
|
||||
label="Current Program:"
|
||||
/>
|
||||
</Box>
|
||||
)}
|
||||
</Stack>
|
||||
|
||||
<Divider size="sm" orientation="vertical" />
|
||||
|
||||
{/* Col 3: Behavior/Access - Stream Profile, User Level, Mature Content, Hidden */}
|
||||
<Stack justify="flex-start" style={{ flex: 1, minWidth: 0 }}>
|
||||
<Select
|
||||
id="stream_profile_id"
|
||||
label="Stream Profile"
|
||||
name="stream_profile_id"
|
||||
description={
|
||||
<ProviderHintRow
|
||||
channel={channel}
|
||||
field="stream_profile_id"
|
||||
formValue={watch('stream_profile_id')}
|
||||
hintText={getFkProviderHint(
|
||||
channel,
|
||||
'stream_profile_id',
|
||||
streamProfiles.reduce((acc, p) => {
|
||||
acc[p.id] = p;
|
||||
return acc;
|
||||
}, {})
|
||||
)}
|
||||
onReset={() =>
|
||||
setValue(
|
||||
'stream_profile_id',
|
||||
getProviderFormValue(channel, 'stream_profile_id')
|
||||
)
|
||||
}
|
||||
/>
|
||||
}
|
||||
value={watch('stream_profile_id')}
|
||||
onChange={(value) => {
|
||||
setValue('stream_profile_id', value);
|
||||
}}
|
||||
error={errors.stream_profile_id?.message}
|
||||
data={[{ value: '0', label: '(use default)' }].concat(
|
||||
streamProfiles.map((option) => ({
|
||||
value: `${option.id}`,
|
||||
label: option.name,
|
||||
}))
|
||||
)}
|
||||
size="xs"
|
||||
/>
|
||||
|
||||
<Select
|
||||
label="User Level Access"
|
||||
data={Object.entries(USER_LEVELS).map(([, value]) => {
|
||||
return {
|
||||
label: USER_LEVEL_LABELS[value],
|
||||
value: `${value}`,
|
||||
};
|
||||
})}
|
||||
value={watch('user_level')}
|
||||
onChange={(value) => {
|
||||
setValue('user_level', value);
|
||||
}}
|
||||
error={errors.user_level?.message}
|
||||
/>
|
||||
|
||||
<Tooltip label="Mark as mature/adult content (18+)" withArrow>
|
||||
<Box>
|
||||
<Switch
|
||||
label="Mature Content"
|
||||
checked={watch('is_adult')}
|
||||
onChange={(event) =>
|
||||
setValue('is_adult', event.currentTarget.checked)
|
||||
}
|
||||
size="md"
|
||||
/>
|
||||
</Box>
|
||||
</Tooltip>
|
||||
<Tooltip
|
||||
label="Hides this channel from HDHR, M3U, EPG, and XC client output and preserves it from auto-cleanup. To hide channels per-user, use channel profiles instead."
|
||||
withArrow
|
||||
multiline
|
||||
w={320}
|
||||
>
|
||||
<Box>
|
||||
<Switch
|
||||
label="Hidden"
|
||||
checked={watch('hidden_from_output')}
|
||||
onChange={(event) =>
|
||||
setValue(
|
||||
'hidden_from_output',
|
||||
event.currentTarget.checked
|
||||
)
|
||||
}
|
||||
size="md"
|
||||
/>
|
||||
</Box>
|
||||
</Tooltip>
|
||||
{channel?.auto_created && hasAnyOverride && (
|
||||
<Tooltip
|
||||
label={`Currently overriding: ${overriddenFieldLabels.join(', ')}. Clear all overrides to follow the provider values again on the next refresh.`}
|
||||
withArrow
|
||||
>
|
||||
<Button
|
||||
variant="light"
|
||||
color="orange"
|
||||
size="xs"
|
||||
onClick={clearOverrides}
|
||||
>
|
||||
Clear All Overrides ({overriddenFieldLabels.length})
|
||||
</Button>
|
||||
</Tooltip>
|
||||
)}
|
||||
</Stack>
|
||||
</Group>
|
||||
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -1,86 +1,40 @@
|
|||
import React, { useEffect, useMemo, useState } from 'react';
|
||||
import dayjs from 'dayjs';
|
||||
import API from '../../api';
|
||||
import {
|
||||
Alert,
|
||||
Button,
|
||||
Group,
|
||||
Loader,
|
||||
Modal,
|
||||
MultiSelect,
|
||||
SegmentedControl,
|
||||
Select,
|
||||
Stack,
|
||||
SegmentedControl,
|
||||
MultiSelect,
|
||||
Group,
|
||||
TextInput,
|
||||
Loader,
|
||||
} from '@mantine/core';
|
||||
import { DateTimePicker, TimeInput, DatePickerInput } from '@mantine/dates';
|
||||
import { DatePickerInput, DateTimePicker, TimeInput } from '@mantine/dates';
|
||||
import { CircleAlert } from 'lucide-react';
|
||||
import { isNotEmpty, useForm } from '@mantine/form';
|
||||
import { useForm } from '@mantine/form';
|
||||
import useChannelsStore from '../../store/channels';
|
||||
import { notifications } from '@mantine/notifications';
|
||||
|
||||
const DAY_OPTIONS = [
|
||||
{ value: '6', label: 'Sun' },
|
||||
{ value: '0', label: 'Mon' },
|
||||
{ value: '1', label: 'Tue' },
|
||||
{ value: '2', label: 'Wed' },
|
||||
{ value: '3', label: 'Thu' },
|
||||
{ value: '4', label: 'Fri' },
|
||||
{ value: '5', label: 'Sat' },
|
||||
];
|
||||
|
||||
const asDate = (value) => {
|
||||
if (!value) return null;
|
||||
if (value instanceof Date) return value;
|
||||
const parsed = new Date(value);
|
||||
return Number.isNaN(parsed.getTime()) ? null : parsed;
|
||||
};
|
||||
|
||||
const toIsoIfDate = (value) => {
|
||||
const dt = asDate(value);
|
||||
return dt ? dt.toISOString() : value;
|
||||
};
|
||||
|
||||
// Accepts "h:mm A"/"hh:mm A"/"HH:mm"/Date, returns "HH:mm"
|
||||
const toTimeString = (value) => {
|
||||
if (!value) return '00:00';
|
||||
if (typeof value === 'string') {
|
||||
const parsed = dayjs(
|
||||
value,
|
||||
['HH:mm', 'hh:mm A', 'h:mm A', 'HH:mm:ss'],
|
||||
true
|
||||
);
|
||||
if (parsed.isValid()) return parsed.format('HH:mm');
|
||||
return value;
|
||||
}
|
||||
const dt = asDate(value);
|
||||
if (!dt) return '00:00';
|
||||
return dayjs(dt).format('HH:mm');
|
||||
};
|
||||
|
||||
const toDateString = (value) => {
|
||||
const dt = asDate(value);
|
||||
if (!dt) return null;
|
||||
const year = dt.getFullYear();
|
||||
const month = String(dt.getMonth() + 1).padStart(2, '0');
|
||||
const day = String(dt.getDate()).padStart(2, '0');
|
||||
return `${year}-${month}-${day}`;
|
||||
};
|
||||
|
||||
const createRoundedDate = (minutesAhead = 0) => {
|
||||
const dt = new Date();
|
||||
dt.setSeconds(0);
|
||||
dt.setMilliseconds(0);
|
||||
dt.setMinutes(Math.ceil(dt.getMinutes() / 30) * 30);
|
||||
if (minutesAhead) dt.setMinutes(dt.getMinutes() + minutesAhead);
|
||||
return dt;
|
||||
};
|
||||
|
||||
// robust onChange for TimeInput (string or event)
|
||||
const timeChange = (setter) => (valOrEvent) => {
|
||||
if (typeof valOrEvent === 'string') setter(valOrEvent);
|
||||
else if (valOrEvent?.currentTarget) setter(valOrEvent.currentTarget.value);
|
||||
};
|
||||
import {
|
||||
RECURRING_DAY_OPTIONS,
|
||||
toTimeString,
|
||||
} from '../../utils/dateTimeUtils.js';
|
||||
import { showNotification } from '../../utils/notificationUtils.js';
|
||||
import {
|
||||
buildRecurringPayload,
|
||||
buildSinglePayload,
|
||||
createRecording,
|
||||
createRecurringRule,
|
||||
getChannelsSummary,
|
||||
getRecurringFormDefaults,
|
||||
getSingleFormDefaults,
|
||||
numberedChannelLabel,
|
||||
recurringFormValidators,
|
||||
singleFormValidators,
|
||||
sortedChannelOptions,
|
||||
timeChange,
|
||||
updateRecording,
|
||||
} from '../../utils/forms/RecordingUtils.js';
|
||||
|
||||
const RecordingModal = ({
|
||||
recording = null,
|
||||
|
|
@ -98,117 +52,29 @@ const RecordingModal = ({
|
|||
const [mode, setMode] = useState('single');
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
|
||||
const defaultStart = createRoundedDate();
|
||||
const defaultEnd = createRoundedDate(60);
|
||||
const defaultDate = new Date();
|
||||
|
||||
// One-time form
|
||||
const singleForm = useForm({
|
||||
mode: 'controlled',
|
||||
initialValues: {
|
||||
channel_id: recording
|
||||
? `${recording.channel}`
|
||||
: channel
|
||||
? `${channel.id}`
|
||||
: '',
|
||||
start_time: recording
|
||||
? asDate(recording.start_time) || defaultStart
|
||||
: defaultStart,
|
||||
end_time: recording
|
||||
? asDate(recording.end_time) || defaultEnd
|
||||
: defaultEnd,
|
||||
},
|
||||
validate: {
|
||||
channel_id: isNotEmpty('Select a channel'),
|
||||
start_time: isNotEmpty('Select a start time'),
|
||||
end_time: (value, values) => {
|
||||
const start = asDate(values.start_time);
|
||||
const end = asDate(value);
|
||||
if (!end) return 'Select an end time';
|
||||
if (start && end <= start) return 'End time must be after start time';
|
||||
return null;
|
||||
},
|
||||
},
|
||||
initialValues: getSingleFormDefaults(recording, channel),
|
||||
validate: singleFormValidators,
|
||||
});
|
||||
|
||||
// Recurring form stores times as "HH:mm" strings for stable editing
|
||||
const recurringForm = useForm({
|
||||
mode: 'controlled',
|
||||
validateInputOnChange: false,
|
||||
validateInputOnBlur: true,
|
||||
initialValues: {
|
||||
channel_id: channel ? `${channel.id}` : '',
|
||||
days_of_week: [],
|
||||
start_time: dayjs(defaultStart).format('HH:mm'),
|
||||
end_time: dayjs(defaultEnd).format('HH:mm'),
|
||||
rule_name: '',
|
||||
start_date: defaultDate,
|
||||
end_date: defaultDate,
|
||||
},
|
||||
validate: {
|
||||
channel_id: isNotEmpty('Select a channel'),
|
||||
days_of_week: (value) =>
|
||||
value && value.length ? null : 'Pick at least one day',
|
||||
start_time: (value) => (value ? null : 'Select a start time'),
|
||||
end_time: (value, values) => {
|
||||
if (!value) return 'Select an end time';
|
||||
const start = dayjs(
|
||||
values.start_time,
|
||||
['HH:mm', 'hh:mm A', 'h:mm A'],
|
||||
true
|
||||
);
|
||||
const end = dayjs(value, ['HH:mm', 'hh:mm A', 'h:mm A'], true);
|
||||
if (
|
||||
start.isValid() &&
|
||||
end.isValid() &&
|
||||
end.diff(start, 'minute') === 0
|
||||
) {
|
||||
return 'End time must differ from start time';
|
||||
}
|
||||
return null;
|
||||
},
|
||||
end_date: (value, values) => {
|
||||
const end = asDate(value);
|
||||
const start = asDate(values.start_date);
|
||||
if (!end) return 'Select an end date';
|
||||
if (start && end < start) return 'End date cannot be before start date';
|
||||
return null;
|
||||
},
|
||||
},
|
||||
initialValues: getRecurringFormDefaults(channel),
|
||||
validate: recurringFormValidators,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOpen) return;
|
||||
|
||||
const freshStart = createRoundedDate();
|
||||
const freshEnd = createRoundedDate(60);
|
||||
const freshDate = new Date();
|
||||
|
||||
if (recording && recording.id) {
|
||||
if (recording?.id) {
|
||||
setMode('single');
|
||||
singleForm.setValues({
|
||||
channel_id: `${recording.channel}`,
|
||||
start_time: asDate(recording.start_time) || defaultStart,
|
||||
end_time: asDate(recording.end_time) || defaultEnd,
|
||||
});
|
||||
singleForm.setValues(getSingleFormDefaults(recording, channel));
|
||||
} else {
|
||||
// Reset forms for fresh open
|
||||
singleForm.setValues({
|
||||
channel_id: channel ? `${channel.id}` : '',
|
||||
start_time: freshStart,
|
||||
end_time: freshEnd,
|
||||
});
|
||||
|
||||
const startStr = dayjs(freshStart).format('HH:mm');
|
||||
recurringForm.setValues({
|
||||
channel_id: channel ? `${channel.id}` : '',
|
||||
days_of_week: [],
|
||||
start_time: startStr,
|
||||
end_time: dayjs(freshEnd).format('HH:mm'),
|
||||
rule_name: channel?.name || '',
|
||||
start_date: freshDate,
|
||||
end_date: freshDate,
|
||||
});
|
||||
singleForm.setValues(getSingleFormDefaults(null, channel));
|
||||
recurringForm.setValues(getRecurringFormDefaults(channel));
|
||||
setMode('single');
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
|
|
@ -221,7 +87,7 @@ const RecordingModal = ({
|
|||
if (!isOpen) return;
|
||||
try {
|
||||
setIsChannelsLoading(true);
|
||||
const chans = await API.getChannelsSummary();
|
||||
const chans = await getChannelsSummary();
|
||||
if (cancelled) return;
|
||||
setAllChannels(Array.isArray(chans) ? chans : []);
|
||||
} catch (e) {
|
||||
|
|
@ -238,19 +104,7 @@ const RecordingModal = ({
|
|||
}, [isOpen]);
|
||||
|
||||
const channelOptions = useMemo(() => {
|
||||
const list = Array.isArray(allChannels) ? [...allChannels] : [];
|
||||
list.sort((a, b) => {
|
||||
const aNum = Number(a.channel_number) || 0;
|
||||
const bNum = Number(b.channel_number) || 0;
|
||||
if (aNum === bNum) return (a.name || '').localeCompare(b.name || '');
|
||||
return aNum - bNum;
|
||||
});
|
||||
return list.map((item) => ({
|
||||
value: `${item.id}`,
|
||||
label: item.channel_number
|
||||
? `${item.channel_number} - ${item.name || `Channel ${item.id}`}`
|
||||
: item.name || `Channel ${item.id}`,
|
||||
}));
|
||||
return sortedChannelOptions(allChannels, numberedChannelLabel);
|
||||
}, [allChannels]);
|
||||
|
||||
const resetForms = () => {
|
||||
|
|
@ -267,25 +121,18 @@ const RecordingModal = ({
|
|||
const handleSingleSubmit = async (values) => {
|
||||
try {
|
||||
setSubmitting(true);
|
||||
const payload = buildSinglePayload(values);
|
||||
if (recording && recording.id) {
|
||||
await API.updateRecording(recording.id, {
|
||||
channel: values.channel_id,
|
||||
start_time: toIsoIfDate(values.start_time),
|
||||
end_time: toIsoIfDate(values.end_time),
|
||||
});
|
||||
notifications.show({
|
||||
await updateRecording(recording.id, payload);
|
||||
showNotification({
|
||||
title: 'Recording updated',
|
||||
message: 'Recording schedule updated successfully',
|
||||
color: 'green',
|
||||
autoClose: 2500,
|
||||
});
|
||||
} else {
|
||||
await API.createRecording({
|
||||
channel: values.channel_id,
|
||||
start_time: toIsoIfDate(values.start_time),
|
||||
end_time: toIsoIfDate(values.end_time),
|
||||
});
|
||||
notifications.show({
|
||||
await createRecording(payload);
|
||||
showNotification({
|
||||
title: 'Recording scheduled',
|
||||
message: 'One-time recording added to DVR queue',
|
||||
color: 'green',
|
||||
|
|
@ -304,18 +151,10 @@ const RecordingModal = ({
|
|||
const handleRecurringSubmit = async (values) => {
|
||||
try {
|
||||
setSubmitting(true);
|
||||
await API.createRecurringRule({
|
||||
channel: values.channel_id,
|
||||
days_of_week: (values.days_of_week || []).map((d) => Number(d)),
|
||||
start_time: toTimeString(values.start_time),
|
||||
end_time: toTimeString(values.end_time),
|
||||
start_date: toDateString(values.start_date),
|
||||
end_date: toDateString(values.end_date),
|
||||
name: values.rule_name?.trim() || '',
|
||||
});
|
||||
await createRecurringRule(buildRecurringPayload(values));
|
||||
|
||||
await Promise.all([fetchRecurringRules(), fetchRecordings()]);
|
||||
notifications.show({
|
||||
showNotification({
|
||||
title: 'Recurring rule saved',
|
||||
message: 'Future slots will be scheduled automatically',
|
||||
color: 'green',
|
||||
|
|
@ -427,7 +266,10 @@ const RecordingModal = ({
|
|||
key={recurringForm.key('days_of_week')}
|
||||
label="Every"
|
||||
placeholder="Select days"
|
||||
data={DAY_OPTIONS}
|
||||
data={RECURRING_DAY_OPTIONS.map((opt) => ({
|
||||
value: String(opt.value),
|
||||
label: opt.label,
|
||||
}))}
|
||||
searchable
|
||||
clearable
|
||||
nothingFoundMessage="No match"
|
||||
|
|
|
|||
|
|
@ -1,11 +1,13 @@
|
|||
import useChannelsStore from '../../store/channels.jsx';
|
||||
import API from '../../api';
|
||||
import {
|
||||
format,
|
||||
isAfter,
|
||||
isBefore,
|
||||
useDateTimeFormat,
|
||||
useTimeHelpers,
|
||||
} from '../../utils/dateTimeUtils.js';
|
||||
import React from 'react';
|
||||
import { Pencil, RefreshCcw, Check, X } from 'lucide-react';
|
||||
import React, { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { Check, Pencil, RefreshCcw, X } from 'lucide-react';
|
||||
import {
|
||||
ActionIcon,
|
||||
Badge,
|
||||
|
|
@ -21,7 +23,6 @@ import {
|
|||
TextInput,
|
||||
} from '@mantine/core';
|
||||
import useVideoStore from '../../store/useVideoStore.jsx';
|
||||
import { notifications } from '@mantine/notifications';
|
||||
import defaultLogo from '../../images/logo.png';
|
||||
import {
|
||||
deleteRecordingById,
|
||||
|
|
@ -33,10 +34,98 @@ import {
|
|||
runComSkip,
|
||||
} from '../../utils/cards/RecordingCardUtils.js';
|
||||
import {
|
||||
getChannel,
|
||||
getRating,
|
||||
getStatRows,
|
||||
getUpcomingEpisodes,
|
||||
refreshArtwork,
|
||||
updateRecordingMetadata,
|
||||
} from '../../utils/forms/RecordingDetailsModalUtils.js';
|
||||
import { showNotification } from '../../utils/notificationUtils.js';
|
||||
|
||||
const EpisodeRow = ({
|
||||
rec,
|
||||
recording,
|
||||
channel,
|
||||
channelsById,
|
||||
livePosterUrl,
|
||||
toUserTime,
|
||||
dateformat,
|
||||
timeformat,
|
||||
onOpenChild,
|
||||
}) => {
|
||||
const cp = rec.custom_properties || {};
|
||||
const pr = cp.program || {};
|
||||
const start = toUserTime(rec.start_time);
|
||||
const end = toUserTime(rec.end_time);
|
||||
const season = cp.season ?? pr?.custom_properties?.season;
|
||||
const episode = cp.episode ?? pr?.custom_properties?.episode;
|
||||
const onscreen =
|
||||
cp.onscreen_episode ?? pr?.custom_properties?.onscreen_episode;
|
||||
const se = getSeasonLabel(season, episode, onscreen);
|
||||
const posterLogoId = cp.poster_logo_id;
|
||||
const purl = getPosterUrl(posterLogoId, cp, livePosterUrl);
|
||||
const epChannel =
|
||||
channelsById[rec.channel] ||
|
||||
(rec.channel === recording?.channel ? channel : null);
|
||||
|
||||
const onRemove = async (e) => {
|
||||
e?.stopPropagation?.();
|
||||
try {
|
||||
await deleteRecordingById(rec.id);
|
||||
} catch (error) {
|
||||
console.error('Failed to delete upcoming recording', error);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Card
|
||||
withBorder
|
||||
radius="md"
|
||||
padding="sm"
|
||||
style={{ backgroundColor: '#27272A', cursor: 'pointer' }}
|
||||
onClick={() => onOpenChild(rec)}
|
||||
>
|
||||
<Flex gap="sm" align="center">
|
||||
<Image
|
||||
src={purl}
|
||||
w={64}
|
||||
h={64}
|
||||
fit="contain"
|
||||
radius="sm"
|
||||
alt={pr.title}
|
||||
fallbackSrc={getChannelLogoUrl(epChannel) || defaultLogo}
|
||||
/>
|
||||
<Stack gap={4} flex={1}>
|
||||
<Group justify="space-between">
|
||||
<Text
|
||||
fw={600}
|
||||
size="sm"
|
||||
lineClamp={1}
|
||||
title={pr.sub_title || pr.title}
|
||||
>
|
||||
{pr.sub_title || pr.title}
|
||||
</Text>
|
||||
{se && (
|
||||
<Badge color="gray" variant="light">
|
||||
{se}
|
||||
</Badge>
|
||||
)}
|
||||
</Group>
|
||||
<Text size="xs">
|
||||
{format(start, `${dateformat}, YYYY ${timeformat}`)} –{' '}
|
||||
{format(end, timeformat)}
|
||||
</Text>
|
||||
</Stack>
|
||||
<Group gap={6}>
|
||||
<Button size="xs" color="red" variant="light" onClick={onRemove}>
|
||||
Remove
|
||||
</Button>
|
||||
</Group>
|
||||
</Flex>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
const RecordingDetailsModal = ({
|
||||
opened,
|
||||
|
|
@ -51,21 +140,21 @@ const RecordingDetailsModal = ({
|
|||
}) => {
|
||||
const allRecordings = useChannelsStore((s) => s.recordings);
|
||||
// Local channel cache to avoid the global channels map
|
||||
const [channelsById, setChannelsById] = React.useState({});
|
||||
const [channelsById, setChannelsById] = useState({});
|
||||
const { toUserTime, userNow } = useTimeHelpers();
|
||||
const [childOpen, setChildOpen] = React.useState(false);
|
||||
const [childRec, setChildRec] = React.useState(null);
|
||||
const [childOpen, setChildOpen] = useState(false);
|
||||
const [childRec, setChildRec] = useState(null);
|
||||
const { timeFormat: timeformat, dateFormat: dateformat } =
|
||||
useDateTimeFormat();
|
||||
|
||||
const [editing, setEditing] = React.useState(false);
|
||||
const [editing, setEditing] = useState(false);
|
||||
|
||||
// Prefer the store version of the recording for live updates
|
||||
// (e.g., after artwork refresh or metadata edit via WebSocket).
|
||||
// Preserve _group_count from the categorized prop — the store version
|
||||
// doesn't carry this client-side field, so without merging it back
|
||||
// isSeriesGroup would always be false and the episode list hidden.
|
||||
const safeRecording = React.useMemo(() => {
|
||||
const safeRecording = useMemo(() => {
|
||||
if (recording?.id && Array.isArray(allRecordings)) {
|
||||
const found = allRecordings.find((r) => r.id === recording.id);
|
||||
if (found) {
|
||||
|
|
@ -81,7 +170,7 @@ const RecordingDetailsModal = ({
|
|||
const program = customProps.program || {};
|
||||
|
||||
// Derive poster URL from live store data instead of the stale prop snapshot.
|
||||
const livePosterUrl = React.useMemo(
|
||||
const livePosterUrl = useMemo(
|
||||
() =>
|
||||
getPosterUrl(
|
||||
customProps.poster_logo_id,
|
||||
|
|
@ -93,17 +182,17 @@ const RecordingDetailsModal = ({
|
|||
|
||||
// Optimistic overrides — show saved values immediately without waiting
|
||||
// for the WebSocket round-trip to refresh the store.
|
||||
const [savedTitle, setSavedTitle] = React.useState(null);
|
||||
const [savedDescription, setSavedDescription] = React.useState(null);
|
||||
const [savedTitle, setSavedTitle] = useState(null);
|
||||
const [savedDescription, setSavedDescription] = useState(null);
|
||||
const recordingName = savedTitle ?? (program.title || 'Custom Recording');
|
||||
const description =
|
||||
savedDescription ?? (program.description || customProps.description || '');
|
||||
|
||||
const [editTitle, setEditTitle] = React.useState('');
|
||||
const [editDescription, setEditDescription] = React.useState('');
|
||||
const [editTitle, setEditTitle] = useState('');
|
||||
const [editDescription, setEditDescription] = useState('');
|
||||
|
||||
// Reset optimistic state when the recording changes
|
||||
React.useEffect(() => {
|
||||
useEffect(() => {
|
||||
setSavedTitle(null);
|
||||
setSavedDescription(null);
|
||||
setEditing(false);
|
||||
|
|
@ -129,7 +218,7 @@ const RecordingDetailsModal = ({
|
|||
const isSeriesGroup = Boolean(
|
||||
safeRecording._group_count && safeRecording._group_count > 1
|
||||
);
|
||||
const upcomingEpisodes = React.useMemo(() => {
|
||||
const upcomingEpisodes = useMemo(() => {
|
||||
return getUpcomingEpisodes(
|
||||
isSeriesGroup,
|
||||
allRecordings,
|
||||
|
|
@ -147,7 +236,7 @@ const RecordingDetailsModal = ({
|
|||
]);
|
||||
|
||||
// Ensure channel is available for a given id
|
||||
const loadChannel = React.useCallback(
|
||||
const loadChannel = useCallback(
|
||||
async (id) => {
|
||||
if (!id) {
|
||||
return null;
|
||||
|
|
@ -159,7 +248,7 @@ const RecordingDetailsModal = ({
|
|||
}
|
||||
|
||||
try {
|
||||
const ch = await API.getChannel(id);
|
||||
const ch = await getChannel(id);
|
||||
if (ch && ch.id === id) {
|
||||
setChannelsById((prev) => ({ ...prev, [id]: ch }));
|
||||
return ch;
|
||||
|
|
@ -177,7 +266,7 @@ const RecordingDetailsModal = ({
|
|||
);
|
||||
|
||||
// When opening a child episode, fetch that episode's channel
|
||||
React.useEffect(() => {
|
||||
useEffect(() => {
|
||||
if (!childOpen || !childRec) return;
|
||||
loadChannel(childRec.channel);
|
||||
}, [childOpen, childRec, loadChannel]);
|
||||
|
|
@ -188,7 +277,7 @@ const RecordingDetailsModal = ({
|
|||
const s = toUserTime(rec.start_time);
|
||||
const e = toUserTime(rec.end_time);
|
||||
|
||||
if (now.isAfter(s) && now.isBefore(e)) {
|
||||
if (isAfter(now, s) && isBefore(now, e)) {
|
||||
const ch =
|
||||
channelsById[rec.channel] ||
|
||||
(rec.channel === recording?.channel ? channel : null);
|
||||
|
|
@ -228,14 +317,11 @@ const RecordingDetailsModal = ({
|
|||
|
||||
const saveMetadata = async () => {
|
||||
try {
|
||||
await API.updateRecordingMetadata(recording.id, {
|
||||
title: editTitle || 'Custom Recording',
|
||||
description: editDescription,
|
||||
});
|
||||
await updateRecordingMetadata(recording, editTitle, editDescription);
|
||||
setSavedTitle(editTitle || 'Custom Recording');
|
||||
setSavedDescription(editDescription);
|
||||
setEditing(false);
|
||||
notifications.show({
|
||||
showNotification({
|
||||
title: 'Saved',
|
||||
message: 'Recording metadata updated',
|
||||
color: 'green',
|
||||
|
|
@ -249,8 +335,8 @@ const RecordingDetailsModal = ({
|
|||
const handleRefreshArtwork = async (e) => {
|
||||
e.stopPropagation?.();
|
||||
try {
|
||||
await API.refreshArtwork(recording.id);
|
||||
notifications.show({
|
||||
await refreshArtwork(recording.id);
|
||||
showNotification({
|
||||
title: 'Refreshing artwork',
|
||||
message: 'Poster resolution started',
|
||||
color: 'blue.5',
|
||||
|
|
@ -265,7 +351,7 @@ const RecordingDetailsModal = ({
|
|||
e.stopPropagation?.();
|
||||
try {
|
||||
await runComSkip(recording);
|
||||
notifications.show({
|
||||
showNotification({
|
||||
title: 'Removing commercials',
|
||||
message: 'Queued comskip for this recording',
|
||||
color: 'blue.5',
|
||||
|
|
@ -278,86 +364,6 @@ const RecordingDetailsModal = ({
|
|||
|
||||
if (!recording) return null;
|
||||
|
||||
const EpisodeRow = ({ rec }) => {
|
||||
const cp = rec.custom_properties || {};
|
||||
const pr = cp.program || {};
|
||||
const start = toUserTime(rec.start_time);
|
||||
const end = toUserTime(rec.end_time);
|
||||
const season = cp.season ?? pr?.custom_properties?.season;
|
||||
const episode = cp.episode ?? pr?.custom_properties?.episode;
|
||||
const onscreen =
|
||||
cp.onscreen_episode ?? pr?.custom_properties?.onscreen_episode;
|
||||
const se = getSeasonLabel(season, episode, onscreen);
|
||||
const posterLogoId = cp.poster_logo_id;
|
||||
const purl = getPosterUrl(posterLogoId, cp, livePosterUrl);
|
||||
const epChannel =
|
||||
channelsById[rec.channel] ||
|
||||
(rec.channel === recording?.channel ? channel : null);
|
||||
|
||||
const onRemove = async (e) => {
|
||||
e?.stopPropagation?.();
|
||||
try {
|
||||
await deleteRecordingById(rec.id);
|
||||
} catch (error) {
|
||||
console.error('Failed to delete upcoming recording', error);
|
||||
}
|
||||
// recording_cancelled WS event triggers the debounced fetchRecordings()
|
||||
};
|
||||
|
||||
const handleOnMainCardClick = () => {
|
||||
setChildRec(rec);
|
||||
setChildOpen(true);
|
||||
};
|
||||
|
||||
return (
|
||||
<Card
|
||||
withBorder
|
||||
radius="md"
|
||||
padding="sm"
|
||||
style={{ backgroundColor: '#27272A', cursor: 'pointer' }}
|
||||
onClick={handleOnMainCardClick}
|
||||
>
|
||||
<Flex gap="sm" align="center">
|
||||
<Image
|
||||
src={purl}
|
||||
w={64}
|
||||
h={64}
|
||||
fit="contain"
|
||||
radius="sm"
|
||||
alt={pr.title || recordingName}
|
||||
fallbackSrc={getChannelLogoUrl(epChannel) || defaultLogo}
|
||||
/>
|
||||
<Stack gap={4} flex={1}>
|
||||
<Group justify="space-between">
|
||||
<Text
|
||||
fw={600}
|
||||
size="sm"
|
||||
lineClamp={1}
|
||||
title={pr.sub_title || pr.title}
|
||||
>
|
||||
{pr.sub_title || pr.title}
|
||||
</Text>
|
||||
{se && (
|
||||
<Badge color="gray" variant="light">
|
||||
{se}
|
||||
</Badge>
|
||||
)}
|
||||
</Group>
|
||||
<Text size="xs">
|
||||
{start.format(`${dateformat}, YYYY ${timeformat}`)} –{' '}
|
||||
{end.format(timeformat)}
|
||||
</Text>
|
||||
</Stack>
|
||||
<Group gap={6}>
|
||||
<Button size="xs" color="red" variant="light" onClick={onRemove}>
|
||||
Remove
|
||||
</Button>
|
||||
</Group>
|
||||
</Flex>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
const WatchLive = () => {
|
||||
return (
|
||||
<Button
|
||||
|
|
@ -414,7 +420,21 @@ const RecordingDetailsModal = ({
|
|||
</Text>
|
||||
)}
|
||||
{upcomingEpisodes.map((ep) => (
|
||||
<EpisodeRow key={`ep-${ep.id}`} rec={ep} />
|
||||
<EpisodeRow
|
||||
key={`ep-${ep.id}`}
|
||||
rec={ep}
|
||||
recording={recording}
|
||||
channel={channel}
|
||||
channelsById={channelsById}
|
||||
livePosterUrl={livePosterUrl}
|
||||
toUserTime={toUserTime}
|
||||
dateformat={dateformat}
|
||||
timeformat={timeformat}
|
||||
onOpenChild={(rec) => {
|
||||
setChildRec(rec);
|
||||
setChildOpen(true);
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
{childOpen && childRec && (
|
||||
<RecordingDetailsModal
|
||||
|
|
@ -468,7 +488,7 @@ const RecordingDetailsModal = ({
|
|||
<Group gap={8}>
|
||||
{onWatchLive && <WatchLive />}
|
||||
{onWatchRecording && <WatchRecording />}
|
||||
{onEdit && start.isAfter(userNow()) && <Edit />}
|
||||
{onEdit && isAfter(start, userNow()) && <Edit />}
|
||||
{(customProps.status === 'completed' ||
|
||||
customProps.status === 'stopped' ||
|
||||
customProps.status === 'interrupted') &&
|
||||
|
|
@ -486,8 +506,8 @@ const RecordingDetailsModal = ({
|
|||
</Group>
|
||||
</Group>
|
||||
<Text size="sm">
|
||||
{start.format(`${dateformat}, YYYY ${timeformat}`)} –{' '}
|
||||
{end.format(timeformat)}
|
||||
{format(start, `${dateformat}, YYYY ${timeformat}`)} –{' '}
|
||||
{format(end, timeformat)}
|
||||
</Text>
|
||||
{rating && (
|
||||
<Group gap={8}>
|
||||
|
|
|
|||
|
|
@ -1,16 +1,15 @@
|
|||
import useChannelsStore from '../../store/channels.jsx';
|
||||
import API from '../../api.js';
|
||||
import {
|
||||
parseDate,
|
||||
format,
|
||||
getNow,
|
||||
RECURRING_DAY_OPTIONS,
|
||||
toDate,
|
||||
toTimeString,
|
||||
useDateTimeFormat,
|
||||
useTimeHelpers,
|
||||
} from '../../utils/dateTimeUtils.js';
|
||||
import React, { useEffect, useMemo, useState } from 'react';
|
||||
import { useForm } from '@mantine/form';
|
||||
import dayjs from 'dayjs';
|
||||
import { notifications } from '@mantine/notifications';
|
||||
import {
|
||||
Badge,
|
||||
Button,
|
||||
|
|
@ -28,11 +27,18 @@ import { DatePickerInput, TimeInput } from '@mantine/dates';
|
|||
import { deleteRecordingById } from '../../utils/cards/RecordingCardUtils.js';
|
||||
import {
|
||||
deleteRecurringRuleById,
|
||||
getChannelOptions,
|
||||
getFormDefaults,
|
||||
getUpcomingOccurrences,
|
||||
updateRecurringRule,
|
||||
updateRecurringRuleEnabled,
|
||||
} from '../../utils/forms/RecurringRuleModalUtils.js';
|
||||
import { showNotification } from '../../utils/notificationUtils.js';
|
||||
import {
|
||||
getChannelsSummary,
|
||||
getRecurringFormDefaults,
|
||||
recurringFormValidators,
|
||||
sortedChannelOptions,
|
||||
} from '../../utils/forms/RecordingUtils.js';
|
||||
|
||||
const RecurringRuleModal = ({
|
||||
opened,
|
||||
|
|
@ -56,66 +62,18 @@ const RecurringRuleModal = ({
|
|||
const rule = recurringRules.find((r) => r.id === ruleId);
|
||||
|
||||
const channelOptions = useMemo(() => {
|
||||
return getChannelOptions(allChannels);
|
||||
return sortedChannelOptions(allChannels);
|
||||
}, [allChannels]);
|
||||
|
||||
const form = useForm({
|
||||
mode: 'controlled',
|
||||
initialValues: {
|
||||
channel_id: '',
|
||||
days_of_week: [],
|
||||
rule_name: '',
|
||||
start_time: dayjs().startOf('hour').format('HH:mm'),
|
||||
end_time: dayjs().startOf('hour').add(1, 'hour').format('HH:mm'),
|
||||
start_date: dayjs().toDate(),
|
||||
end_date: dayjs().toDate(),
|
||||
enabled: true,
|
||||
},
|
||||
validate: {
|
||||
channel_id: (value) => (value ? null : 'Select a channel'),
|
||||
days_of_week: (value) =>
|
||||
value && value.length ? null : 'Pick at least one day',
|
||||
end_time: (value, values) => {
|
||||
if (!value) return 'Select an end time';
|
||||
const startValue = dayjs(
|
||||
values.start_time,
|
||||
['HH:mm', 'hh:mm A', 'h:mm A'],
|
||||
true
|
||||
);
|
||||
const endValue = dayjs(value, ['HH:mm', 'hh:mm A', 'h:mm A'], true);
|
||||
if (
|
||||
startValue.isValid() &&
|
||||
endValue.isValid() &&
|
||||
endValue.diff(startValue, 'minute') === 0
|
||||
) {
|
||||
return 'End time must differ from start time';
|
||||
}
|
||||
return null;
|
||||
},
|
||||
end_date: (value, values) => {
|
||||
const endDate = dayjs(value);
|
||||
const startDate = dayjs(values.start_date);
|
||||
if (!value) return 'Select an end date';
|
||||
if (startDate.isValid() && endDate.isBefore(startDate, 'day')) {
|
||||
return 'End date cannot be before start date';
|
||||
}
|
||||
return null;
|
||||
},
|
||||
},
|
||||
initialValues: { ...getRecurringFormDefaults(), enabled: true },
|
||||
validate: recurringFormValidators,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (opened && rule) {
|
||||
form.setValues({
|
||||
channel_id: `${rule.channel}`,
|
||||
days_of_week: (rule.days_of_week || []).map((d) => String(d)),
|
||||
rule_name: rule.name || '',
|
||||
start_time: toTimeString(rule.start_time),
|
||||
end_time: toTimeString(rule.end_time),
|
||||
start_date: parseDate(rule.start_date) || dayjs().toDate(),
|
||||
end_date: parseDate(rule.end_date),
|
||||
enabled: Boolean(rule.enabled),
|
||||
});
|
||||
form.setValues(getFormDefaults(rule));
|
||||
} else {
|
||||
form.reset();
|
||||
}
|
||||
|
|
@ -127,7 +85,7 @@ const RecurringRuleModal = ({
|
|||
let cancelled = false;
|
||||
(async () => {
|
||||
try {
|
||||
const chans = await API.getChannelsSummary();
|
||||
const chans = await getChannelsSummary();
|
||||
if (!cancelled) setAllChannels(Array.isArray(chans) ? chans : []);
|
||||
} catch (e) {
|
||||
console.warn('Failed to load channels for recurring rule modal', e);
|
||||
|
|
@ -149,7 +107,7 @@ const RecurringRuleModal = ({
|
|||
try {
|
||||
await updateRecurringRule(ruleId, values);
|
||||
await fetchRecurringRules(); // recordings_refreshed WS event handles recording list update
|
||||
notifications.show({
|
||||
showNotification({
|
||||
title: 'Recurring rule updated',
|
||||
message: 'Schedule adjustments saved',
|
||||
color: 'green',
|
||||
|
|
@ -169,7 +127,7 @@ const RecurringRuleModal = ({
|
|||
try {
|
||||
await deleteRecurringRuleById(ruleId);
|
||||
await fetchRecurringRules(); // recordings_refreshed WS event handles recording list update
|
||||
notifications.show({
|
||||
showNotification({
|
||||
title: 'Recurring rule removed',
|
||||
message: 'All future occurrences were cancelled',
|
||||
color: 'red',
|
||||
|
|
@ -189,7 +147,7 @@ const RecurringRuleModal = ({
|
|||
try {
|
||||
await updateRecurringRuleEnabled(ruleId, checked);
|
||||
await fetchRecurringRules(); // recordings_refreshed WS event handles recording list update
|
||||
notifications.show({
|
||||
showNotification({
|
||||
title: checked ? 'Recurring rule enabled' : 'Recurring rule paused',
|
||||
message: checked
|
||||
? 'Future occurrences will resume'
|
||||
|
|
@ -210,7 +168,7 @@ const RecurringRuleModal = ({
|
|||
try {
|
||||
await deleteRecordingById(occurrence.id);
|
||||
// recording_cancelled WS event handles recording list update
|
||||
notifications.show({
|
||||
showNotification({
|
||||
title: 'Occurrence cancelled',
|
||||
message: 'The selected airing was removed',
|
||||
color: 'yellow',
|
||||
|
|
@ -246,7 +204,7 @@ const RecurringRuleModal = ({
|
|||
setDeleting(true);
|
||||
try {
|
||||
await deleteRecordingById(sourceRecording.id);
|
||||
notifications.show({
|
||||
showNotification({
|
||||
title: 'Recording deleted',
|
||||
color: 'green',
|
||||
autoClose: 2500,
|
||||
|
|
@ -275,7 +233,7 @@ const RecurringRuleModal = ({
|
|||
};
|
||||
|
||||
const handleStartDateChange = (value) => {
|
||||
form.setFieldValue('start_date', value || dayjs().toDate());
|
||||
form.setFieldValue('start_date', value || toDate(getNow()));
|
||||
};
|
||||
|
||||
const handleEndDateChange = (value) => {
|
||||
|
|
@ -302,10 +260,11 @@ const RecurringRuleModal = ({
|
|||
<Group justify="space-between" align="center">
|
||||
<Stack gap={2} flex={1}>
|
||||
<Text fw={600} size="sm">
|
||||
{occStart.format(`${dateformat}, YYYY`)}
|
||||
{format(occStart, `${dateformat}, YYYY`)}
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed">
|
||||
{occStart.format(timeformat)} – {occEnd.format(timeformat)}
|
||||
{format(occStart, timeformat)} –{' '}
|
||||
{format(occEnd, timeformat)}
|
||||
</Text>
|
||||
</Stack>
|
||||
<Group gap={6}>
|
||||
|
|
|
|||
|
|
@ -38,8 +38,8 @@ import {
|
|||
Popover,
|
||||
ActionIcon,
|
||||
Group,
|
||||
Divider,
|
||||
SimpleGrid,
|
||||
PopoverTarget,
|
||||
PopoverDropdown,
|
||||
} from '@mantine/core';
|
||||
import { Info } from 'lucide-react';
|
||||
import { validateCronExpression } from '../../utils/cronUtils';
|
||||
|
|
@ -118,12 +118,12 @@ export default function ScheduleInput({
|
|||
<Group gap="xs">
|
||||
Cron Expression
|
||||
<Popover width={320} position="top" withArrow shadow="md">
|
||||
<Popover.Target>
|
||||
<PopoverTarget>
|
||||
<ActionIcon variant="subtle" size="xs" color="gray">
|
||||
<Info size={14} />
|
||||
</ActionIcon>
|
||||
</Popover.Target>
|
||||
<Popover.Dropdown p="sm">
|
||||
</PopoverTarget>
|
||||
<PopoverDropdown p="sm">
|
||||
<Text size="xs" fw={600} mb="xs" c="dimmed">
|
||||
COMMON EXAMPLES
|
||||
</Text>
|
||||
|
|
@ -169,7 +169,7 @@ export default function ScheduleInput({
|
|||
<Code size="xs">30 14 1 * *</Code>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Popover.Dropdown>
|
||||
</PopoverDropdown>
|
||||
</Popover>
|
||||
</Group>
|
||||
}
|
||||
|
|
|
|||
|
|
@ -16,6 +16,19 @@ const TITLE_MODE_LABEL = {
|
|||
regex: 'Title regex',
|
||||
};
|
||||
|
||||
const renderRuleSummary = (r) => {
|
||||
const titleMode = (r.title_mode || 'exact').toLowerCase();
|
||||
const parts = [];
|
||||
parts.push(r.mode === 'new' ? 'New episodes' : 'Every episode');
|
||||
if (r.title) {
|
||||
parts.push(`${TITLE_MODE_LABEL[titleMode] || titleMode}: "${r.title}"`);
|
||||
}
|
||||
if (r.description) {
|
||||
parts.push(`Description: "${r.description}"`);
|
||||
}
|
||||
return parts.join(' | ');
|
||||
};
|
||||
|
||||
export default function SeriesRecordingModal({
|
||||
opened,
|
||||
onClose,
|
||||
|
|
@ -59,19 +72,6 @@ export default function SeriesRecordingModal({
|
|||
onRulesUpdate(updated);
|
||||
};
|
||||
|
||||
const renderRuleSummary = (r) => {
|
||||
const titleMode = (r.title_mode || 'exact').toLowerCase();
|
||||
const parts = [];
|
||||
parts.push(r.mode === 'new' ? 'New episodes' : 'Every episode');
|
||||
if (r.title) {
|
||||
parts.push(`${TITLE_MODE_LABEL[titleMode] || titleMode}: "${r.title}"`);
|
||||
}
|
||||
if (r.description) {
|
||||
parts.push(`Description: "${r.description}"`);
|
||||
}
|
||||
return parts.join(' | ');
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Modal
|
||||
|
|
|
|||
|
|
@ -1,64 +1,37 @@
|
|||
import React, { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import {
|
||||
Alert,
|
||||
Badge,
|
||||
Button,
|
||||
Divider,
|
||||
Group,
|
||||
Modal,
|
||||
ScrollAreaAutosize,
|
||||
SegmentedControl,
|
||||
Select,
|
||||
Stack,
|
||||
Text,
|
||||
TextInput,
|
||||
Textarea,
|
||||
SegmentedControl,
|
||||
Group,
|
||||
Button,
|
||||
Select,
|
||||
Badge,
|
||||
Divider,
|
||||
ScrollArea,
|
||||
Alert,
|
||||
TextInput,
|
||||
} from '@mantine/core';
|
||||
import API from '../../api.js';
|
||||
import useChannelsStore from '../../store/channels.jsx';
|
||||
import useEPGsStore from '../../store/epgs.jsx';
|
||||
import { useDebounce } from '../../utils.js';
|
||||
import { showNotification } from '../../utils/notificationUtils.js';
|
||||
|
||||
const TITLE_MODES = [
|
||||
{ label: 'Exact', value: 'exact' },
|
||||
{ label: 'Contains', value: 'contains' },
|
||||
{ label: 'Whole word', value: 'search' },
|
||||
{ label: 'Regex', value: 'regex' },
|
||||
];
|
||||
|
||||
const DESCRIPTION_MODES = [
|
||||
{ label: 'Contains', value: 'contains' },
|
||||
{ label: 'Whole word', value: 'search' },
|
||||
{ label: 'Regex', value: 'regex' },
|
||||
];
|
||||
|
||||
const EPISODE_MODES = [
|
||||
{ label: 'All episodes', value: 'all' },
|
||||
{ label: 'New only', value: 'new' },
|
||||
];
|
||||
|
||||
function formatRange(start, end) {
|
||||
try {
|
||||
const s = new Date(start);
|
||||
const e = new Date(end);
|
||||
const sameDay = s.toDateString() === e.toDateString();
|
||||
const dateStr = s.toLocaleDateString();
|
||||
const startStr = s.toLocaleTimeString([], {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
});
|
||||
const endStr = e.toLocaleTimeString([], {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
});
|
||||
return sameDay
|
||||
? `${dateStr} ${startStr} - ${endStr}`
|
||||
: `${dateStr} ${startStr} -> ${e.toLocaleString()}`;
|
||||
} catch {
|
||||
return `${start} - ${end}`;
|
||||
}
|
||||
}
|
||||
import { getChannelsSummary } from '../../utils/forms/RecordingUtils.js';
|
||||
import {
|
||||
createSeriesRule,
|
||||
evaluateSeriesRulesByTvgId,
|
||||
} from '../../utils/guideUtils.js';
|
||||
import {
|
||||
DESCRIPTION_MODES,
|
||||
EPISODE_MODES,
|
||||
formatRange,
|
||||
getChannelOptions,
|
||||
getTvgOptions,
|
||||
previewSeriesRule,
|
||||
TITLE_MODES,
|
||||
} from '../../utils/forms/SeriesRuleEditorModalUtils.js';
|
||||
|
||||
export default function SeriesRuleEditorModal({
|
||||
opened,
|
||||
|
|
@ -106,7 +79,7 @@ export default function SeriesRuleEditorModal({
|
|||
useEffect(() => {
|
||||
if (!opened) return;
|
||||
let cancelled = false;
|
||||
API.getChannelsSummary()
|
||||
getChannelsSummary()
|
||||
.then((chans) => {
|
||||
if (!cancelled) setAllChannels(Array.isArray(chans) ? chans : []);
|
||||
})
|
||||
|
|
@ -152,7 +125,7 @@ export default function SeriesRuleEditorModal({
|
|||
|
||||
setPreviewLoading(true);
|
||||
setPreviewError(null);
|
||||
API.previewSeriesRule(debouncedPreviewKey, { signal: controller.signal })
|
||||
previewSeriesRule(debouncedPreviewKey, controller)
|
||||
.then((resp) => {
|
||||
if (controller.signal.aborted) return;
|
||||
setPreview(resp || { matches: [], total: 0 });
|
||||
|
|
@ -173,44 +146,11 @@ export default function SeriesRuleEditorModal({
|
|||
// EPG channel options for the tvg_id selector. Deduplicate by tvg_id value
|
||||
// since the same channel can appear across multiple EPG sources.
|
||||
const tvgOptions = useMemo(() => {
|
||||
const seen = new Set();
|
||||
const options = [];
|
||||
for (const t of tvgs || []) {
|
||||
if (!t.tvg_id || seen.has(t.tvg_id)) continue;
|
||||
seen.add(t.tvg_id);
|
||||
options.push({
|
||||
value: t.tvg_id,
|
||||
label: t.name ? `${t.name} (${t.tvg_id})` : t.tvg_id,
|
||||
});
|
||||
}
|
||||
return options.sort((a, b) => a.label.localeCompare(b.label));
|
||||
return getTvgOptions(tvgs);
|
||||
}, [tvgs]);
|
||||
|
||||
// Channel select options: prefer channels matching the selected tvg_id.
|
||||
const channelOptions = useMemo(() => {
|
||||
const sorted = [...allChannels].sort((a, b) => {
|
||||
const aNum = Number(a.channel_number) || 0;
|
||||
const bNum = Number(b.channel_number) || 0;
|
||||
if (aNum !== bNum) return aNum - bNum;
|
||||
return (a.name || '').localeCompare(b.name || '');
|
||||
});
|
||||
const matching = [];
|
||||
const others = [];
|
||||
for (const c of sorted) {
|
||||
const item = {
|
||||
value: String(c.id),
|
||||
label: c.channel_number
|
||||
? `${c.channel_number} - ${c.name || `Channel ${c.id}`}`
|
||||
: c.name || `Channel ${c.id}`,
|
||||
};
|
||||
const cTvg = c.epg_data_id ? tvgsById?.[c.epg_data_id]?.tvg_id : null;
|
||||
if (tvgId && cTvg && String(cTvg) === String(tvgId)) {
|
||||
matching.push(item);
|
||||
} else {
|
||||
others.push(item);
|
||||
}
|
||||
}
|
||||
return [...matching, ...others];
|
||||
return getChannelOptions(allChannels, tvgsById, tvgId);
|
||||
}, [allChannels, tvgsById, tvgId]);
|
||||
|
||||
const canSave = !!(payload.title || payload.description);
|
||||
|
|
@ -218,10 +158,10 @@ export default function SeriesRuleEditorModal({
|
|||
const handleSave = async () => {
|
||||
setSaving(true);
|
||||
try {
|
||||
await API.createSeriesRule(payload);
|
||||
await createSeriesRule(payload);
|
||||
// Trigger evaluation so matching upcoming programs get scheduled.
|
||||
try {
|
||||
await API.evaluateSeriesRules(payload.tvg_id);
|
||||
await evaluateSeriesRulesByTvgId(payload.tvg_id);
|
||||
await useChannelsStore.getState().fetchRecordings();
|
||||
} catch (e) {
|
||||
console.warn('Failed to evaluate after save', e);
|
||||
|
|
@ -229,6 +169,8 @@ export default function SeriesRuleEditorModal({
|
|||
showNotification({ title: 'Series rule saved' });
|
||||
if (onSaved) await onSaved();
|
||||
onClose();
|
||||
} catch (e) {
|
||||
console.error('Failed to save series rule', e);
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
|
|
@ -370,7 +312,7 @@ export default function SeriesRuleEditorModal({
|
|||
</Alert>
|
||||
)}
|
||||
|
||||
<ScrollArea.Autosize mah={240}>
|
||||
<ScrollAreaAutosize mah={240}>
|
||||
<Stack gap={4}>
|
||||
{(preview.matches || []).map((p) => (
|
||||
<Group key={p.id} gap="xs" wrap="nowrap" align="flex-start">
|
||||
|
|
@ -407,7 +349,7 @@ export default function SeriesRuleEditorModal({
|
|||
</Text>
|
||||
)}
|
||||
</Stack>
|
||||
</ScrollArea.Autosize>
|
||||
</ScrollAreaAutosize>
|
||||
|
||||
<Group justify="flex-end" gap="xs">
|
||||
<Button variant="subtle" onClick={onClose}>
|
||||
|
|
|
|||
|
|
@ -1,17 +1,14 @@
|
|||
// Modal.js
|
||||
import React, { useEffect, useMemo } from 'react';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { yupResolver } from '@hookform/resolvers/yup';
|
||||
import * as Yup from 'yup';
|
||||
import API from '../../api';
|
||||
import useStreamProfilesStore from '../../store/streamProfiles';
|
||||
import { Modal, TextInput, Select, Button, Flex } from '@mantine/core';
|
||||
import { Button, Flex, Modal, Select, TextInput } from '@mantine/core';
|
||||
import useChannelsStore from '../../store/channels';
|
||||
|
||||
const schema = Yup.object({
|
||||
name: Yup.string().required('Name is required'),
|
||||
url: Yup.string().required('URL is required').min(0),
|
||||
});
|
||||
import {
|
||||
addStream,
|
||||
getResolver,
|
||||
updateStream,
|
||||
} from '../../utils/forms/StreamUtils.js';
|
||||
|
||||
const Stream = ({ stream = null, isOpen, onClose }) => {
|
||||
const streamProfiles = useStreamProfilesStore((state) => state.profiles);
|
||||
|
|
@ -40,7 +37,7 @@ const Stream = ({ stream = null, isOpen, onClose }) => {
|
|||
watch,
|
||||
} = useForm({
|
||||
defaultValues,
|
||||
resolver: yupResolver(schema),
|
||||
resolver: getResolver(),
|
||||
});
|
||||
|
||||
const onSubmit = async (values) => {
|
||||
|
|
@ -58,9 +55,9 @@ const Stream = ({ stream = null, isOpen, onClose }) => {
|
|||
};
|
||||
|
||||
if (stream?.id) {
|
||||
await API.updateStream({ id: stream.id, ...payload });
|
||||
await updateStream(stream.id, payload);
|
||||
} else {
|
||||
await API.addStream(payload);
|
||||
await addStream(payload);
|
||||
}
|
||||
|
||||
reset();
|
||||
|
|
|
|||
|
|
@ -1,50 +1,25 @@
|
|||
// StreamProfile form
|
||||
import React, { useEffect, useMemo, useState } from 'react';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { yupResolver } from '@hookform/resolvers/yup';
|
||||
import * as Yup from 'yup';
|
||||
import API from '../../api';
|
||||
import useUserAgentsStore from '../../store/userAgents';
|
||||
import {
|
||||
Modal,
|
||||
TextInput,
|
||||
Textarea,
|
||||
Select,
|
||||
Button,
|
||||
Flex,
|
||||
Stack,
|
||||
Checkbox,
|
||||
Flex,
|
||||
Modal,
|
||||
Select,
|
||||
Stack,
|
||||
Textarea,
|
||||
TextInput,
|
||||
} from '@mantine/core';
|
||||
|
||||
// Built-in commands supported by Dispatcharr out of the box.
|
||||
const BUILT_IN_COMMANDS = [
|
||||
{ value: 'ffmpeg', label: 'FFmpeg' },
|
||||
{ value: 'streamlink', label: 'Streamlink' },
|
||||
{ value: 'cvlc', label: 'VLC' },
|
||||
{ value: 'yt-dlp', label: 'yt-dlp' },
|
||||
{ value: '__custom__', label: 'Custom…' },
|
||||
];
|
||||
|
||||
// Default parameter examples for each built-in command.
|
||||
const COMMAND_EXAMPLES = {
|
||||
ffmpeg: '-user_agent {userAgent} -i {streamUrl} -c copy -f mpegts pipe:1',
|
||||
streamlink: '{streamUrl} --http-header User-Agent={userAgent} best --stdout',
|
||||
cvlc: '-vv -I dummy --no-video-title-show --http-user-agent {userAgent} {streamUrl} --sout #standard{access=file,mux=ts,dst=-}',
|
||||
'yt-dlp': '--hls-use-mpegts -f best -o - {streamUrl}',
|
||||
};
|
||||
|
||||
// Returns '__custom__' when the command isn't one of the built-ins,
|
||||
// otherwise returns the command value itself.
|
||||
const toCommandSelection = (command) =>
|
||||
BUILT_IN_COMMANDS.find((o) => o.value === command && o.value !== '__custom__')
|
||||
? command
|
||||
: '__custom__';
|
||||
|
||||
const schema = Yup.object({
|
||||
name: Yup.string().required('Name is required'),
|
||||
command: Yup.string().required('Command is required'),
|
||||
parameters: Yup.string(),
|
||||
});
|
||||
import {
|
||||
addStreamProfile,
|
||||
BUILT_IN_COMMANDS,
|
||||
COMMAND_EXAMPLES,
|
||||
getResolver,
|
||||
toCommandSelection,
|
||||
updateStreamProfile,
|
||||
} from '../../utils/forms/StreamProfileUtils.js';
|
||||
|
||||
const StreamProfile = ({ profile = null, isOpen, onClose }) => {
|
||||
const userAgents = useUserAgentsStore((state) => state.userAgents);
|
||||
|
|
@ -73,7 +48,7 @@ const StreamProfile = ({ profile = null, isOpen, onClose }) => {
|
|||
watch,
|
||||
} = useForm({
|
||||
defaultValues,
|
||||
resolver: yupResolver(schema),
|
||||
resolver: getResolver(),
|
||||
});
|
||||
|
||||
// Sync form + dropdown selection whenever the target profile or modal state changes
|
||||
|
|
@ -84,9 +59,9 @@ const StreamProfile = ({ profile = null, isOpen, onClose }) => {
|
|||
|
||||
const onSubmit = async (values) => {
|
||||
if (profile?.id) {
|
||||
await API.updateStreamProfile({ id: profile.id, ...values });
|
||||
await updateStreamProfile(profile.id, values);
|
||||
} else {
|
||||
await API.addStreamProfile(values);
|
||||
await addStreamProfile(values);
|
||||
}
|
||||
|
||||
reset();
|
||||
|
|
@ -102,6 +77,17 @@ const StreamProfile = ({ profile = null, isOpen, onClose }) => {
|
|||
const userAgentValue = watch('user_agent');
|
||||
const isActiveValue = watch('is_active');
|
||||
|
||||
const handleOnChangeCommand = (val) => {
|
||||
setCommandSelection(val);
|
||||
// For built-in selections, write the real command value immediately
|
||||
if (val !== '__custom__') {
|
||||
setValue('command', val, { shouldValidate: true });
|
||||
} else {
|
||||
// Clear so the user enters their own value
|
||||
setValue('command', '', { shouldValidate: false });
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal opened={isOpen} onClose={onClose} title="Stream Profile">
|
||||
<form onSubmit={handleSubmit(onSubmit)}>
|
||||
|
|
@ -127,16 +113,7 @@ const StreamProfile = ({ profile = null, isOpen, onClose }) => {
|
|||
data={BUILT_IN_COMMANDS}
|
||||
disabled={isLocked}
|
||||
value={commandSelection}
|
||||
onChange={(val) => {
|
||||
setCommandSelection(val);
|
||||
// For built-in selections, write the real command value immediately
|
||||
if (val !== '__custom__') {
|
||||
setValue('command', val, { shouldValidate: true });
|
||||
} else {
|
||||
// Clear so the user enters their own value
|
||||
setValue('command', '', { shouldValidate: false });
|
||||
}
|
||||
}}
|
||||
onChange={handleOnChangeCommand}
|
||||
error={isCustom ? undefined : errors.command?.message}
|
||||
/>
|
||||
|
||||
|
|
|
|||
|
|
@ -16,13 +16,21 @@ import useAuthStore from '../../store/auth';
|
|||
import useSettingsStore from '../../store/settings';
|
||||
import logo from '../../assets/logo.png';
|
||||
|
||||
const createSuperUser = (formData) => {
|
||||
return API.createSuperUser({
|
||||
username: formData.username,
|
||||
password: formData.password,
|
||||
email: formData.email,
|
||||
});
|
||||
};
|
||||
|
||||
function SuperuserForm() {
|
||||
const [formData, setFormData] = useState({
|
||||
username: '',
|
||||
password: '',
|
||||
email: '',
|
||||
});
|
||||
const [error, setError] = useState('');
|
||||
const [_error, setError] = useState('');
|
||||
const setSuperuserExists = useAuthStore((s) => s.setSuperuserExists);
|
||||
const fetchVersion = useSettingsStore((s) => s.fetchVersion);
|
||||
const storedVersion = useSettingsStore((s) => s.version);
|
||||
|
|
@ -43,11 +51,7 @@ function SuperuserForm() {
|
|||
e.preventDefault();
|
||||
try {
|
||||
console.log(formData);
|
||||
const response = await API.createSuperUser({
|
||||
username: formData.username,
|
||||
password: formData.password,
|
||||
email: formData.email,
|
||||
});
|
||||
const response = await createSuperUser(formData);
|
||||
if (response.superuser_exists) {
|
||||
setSuperuserExists(true);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,43 +1,41 @@
|
|||
import React, { useState, useEffect } from 'react';
|
||||
import API from '../../api';
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import {
|
||||
TextInput,
|
||||
Button,
|
||||
Modal,
|
||||
Select,
|
||||
PasswordInput,
|
||||
Group,
|
||||
Stack,
|
||||
MultiSelect,
|
||||
ActionIcon,
|
||||
Switch,
|
||||
Button,
|
||||
Group,
|
||||
Modal,
|
||||
MultiSelect,
|
||||
NumberInput,
|
||||
PasswordInput,
|
||||
Select,
|
||||
Stack,
|
||||
Switch,
|
||||
Tabs,
|
||||
TabsList,
|
||||
TabsPanel,
|
||||
TabsTab,
|
||||
TagsInput,
|
||||
Text,
|
||||
TextInput,
|
||||
useMantineTheme,
|
||||
} from '@mantine/core';
|
||||
import { RotateCcwKey, X } from 'lucide-react';
|
||||
import { Copy, Key } from 'lucide-react';
|
||||
import { Copy, Key, RotateCcwKey, X } from 'lucide-react';
|
||||
import { useForm } from '@mantine/form';
|
||||
import useChannelsStore from '../../store/channels';
|
||||
import useOutputProfilesStore from '../../store/outputProfiles';
|
||||
import {
|
||||
USER_LEVELS,
|
||||
USER_LEVEL_LABELS,
|
||||
NETWORK_ACCESS_OPTIONS,
|
||||
} from '../../constants';
|
||||
import { USER_LEVEL_LABELS, USER_LEVELS } from '../../constants';
|
||||
import useAuthStore from '../../store/auth';
|
||||
import { copyToClipboard } from '../../utils';
|
||||
import { IPV4_CIDR_REGEX, IPV6_CIDR_REGEX } from '../../utils/networkUtils';
|
||||
|
||||
const isValidNetworkEntry = (entry) =>
|
||||
entry.match(IPV4_CIDR_REGEX) ||
|
||||
entry.match(IPV6_CIDR_REGEX) ||
|
||||
(entry + '/32').match(IPV4_CIDR_REGEX) ||
|
||||
(entry + '/128').match(IPV6_CIDR_REGEX);
|
||||
|
||||
const NETWORK_KEYS = Object.keys(NETWORK_ACCESS_OPTIONS);
|
||||
import {
|
||||
createUser,
|
||||
formValuesToPayload,
|
||||
generateApiKey,
|
||||
getFormInitialValues,
|
||||
getFormValidators,
|
||||
revokeApiKey,
|
||||
updateUser,
|
||||
userToFormValues,
|
||||
} from '../../utils/forms/UserUtils.js';
|
||||
|
||||
const User = ({ user = null, isOpen, onClose }) => {
|
||||
const profiles = useChannelsStore((s) => s.profiles);
|
||||
|
|
@ -48,52 +46,15 @@ const User = ({ user = null, isOpen, onClose }) => {
|
|||
const [, setEnableXC] = useState(false);
|
||||
const [selectedProfiles, setSelectedProfiles] = useState(new Set());
|
||||
const [generating, setGenerating] = useState(false);
|
||||
const [generatedKey, setGeneratedKey] = useState(null);
|
||||
const [_generatedKey, setGeneratedKey] = useState(null);
|
||||
const [userAPIKey, setUserAPIKey] = useState(user?.api_key || null);
|
||||
|
||||
const theme = useMantineTheme();
|
||||
|
||||
const form = useForm({
|
||||
mode: 'uncontrolled',
|
||||
initialValues: {
|
||||
username: '',
|
||||
first_name: '',
|
||||
last_name: '',
|
||||
email: '',
|
||||
user_level: '0',
|
||||
stream_limit: 0,
|
||||
password: '',
|
||||
xc_password: '',
|
||||
output_format: '',
|
||||
output_profile: '',
|
||||
channel_profiles: [],
|
||||
hide_adult_content: false,
|
||||
epg_days: 0,
|
||||
epg_prev_days: 0,
|
||||
allowed_ips: [],
|
||||
},
|
||||
|
||||
validate: (values) => ({
|
||||
username: !values.username
|
||||
? 'Username is required'
|
||||
: values.user_level == USER_LEVELS.STREAMER &&
|
||||
!values.username.match(/^[a-z0-9]+$/i)
|
||||
? 'Streamer username must be alphanumeric'
|
||||
: null,
|
||||
password:
|
||||
!user && !values.password && values.user_level != USER_LEVELS.STREAMER
|
||||
? 'Password is required'
|
||||
: null,
|
||||
xc_password:
|
||||
values.xc_password && !values.xc_password.match(/^[a-z0-9]+$/i)
|
||||
? 'XC password must be alphanumeric'
|
||||
: null,
|
||||
allowed_ips: (values.allowed_ips || []).some(
|
||||
(t) => !isValidNetworkEntry(t)
|
||||
)
|
||||
? 'Invalid IP address or CIDR range'
|
||||
: null,
|
||||
}),
|
||||
initialValues: getFormInitialValues(),
|
||||
validate: getFormValidators(user),
|
||||
});
|
||||
|
||||
const onChannelProfilesChange = (values) => {
|
||||
|
|
@ -110,65 +71,18 @@ const User = ({ user = null, isOpen, onClose }) => {
|
|||
};
|
||||
|
||||
const onSubmit = async () => {
|
||||
const values = form.getValues();
|
||||
const payload = formValuesToPayload(form.getValues(), user);
|
||||
|
||||
const customProps = user?.custom_properties || {};
|
||||
|
||||
customProps.xc_password = values.xc_password || '';
|
||||
delete values.xc_password;
|
||||
|
||||
customProps.output_format = values.output_format || null;
|
||||
delete values.output_format;
|
||||
|
||||
customProps.output_profile = values.output_profile
|
||||
? parseInt(values.output_profile, 10)
|
||||
: null;
|
||||
delete values.output_profile;
|
||||
|
||||
customProps.hide_adult_content = values.hide_adult_content || false;
|
||||
delete values.hide_adult_content;
|
||||
|
||||
customProps.epg_days = values.epg_days || 0;
|
||||
delete values.epg_days;
|
||||
customProps.epg_prev_days = values.epg_prev_days || 0;
|
||||
delete values.epg_prev_days;
|
||||
|
||||
values.custom_properties = customProps;
|
||||
|
||||
// Serialize per-user network restrictions into custom_properties (same list for all types)
|
||||
const joined = (values.allowed_ips || []).join(',');
|
||||
delete values.allowed_ips;
|
||||
const allowed_networks = {};
|
||||
if (joined)
|
||||
NETWORK_KEYS.forEach((key) => {
|
||||
allowed_networks[key] = joined;
|
||||
});
|
||||
customProps.allowed_networks = allowed_networks;
|
||||
|
||||
if (values.channel_profiles.includes('0')) {
|
||||
values.channel_profiles = [];
|
||||
}
|
||||
|
||||
if (!user && values.user_level == USER_LEVELS.STREAMER) {
|
||||
values.password = Math.random().toString(36).slice(2);
|
||||
if (!user && payload.user_level == USER_LEVELS.STREAMER) {
|
||||
payload.password = Math.random().toString(36).slice(2);
|
||||
}
|
||||
|
||||
if (!user) {
|
||||
await API.createUser(values);
|
||||
await createUser(payload);
|
||||
} else {
|
||||
if (!values.password) {
|
||||
delete values.password;
|
||||
}
|
||||
|
||||
const response = await API.updateUser(
|
||||
user.id,
|
||||
values,
|
||||
isAdmin ? false : authUser.id === user.id
|
||||
);
|
||||
|
||||
if (user.id == authUser.id) {
|
||||
setUser(response);
|
||||
}
|
||||
if (!payload.password) delete payload.password;
|
||||
const response = await updateUser(user.id, payload, isAdmin, authUser);
|
||||
if (user.id == authUser.id) setUser(response);
|
||||
}
|
||||
|
||||
form.reset();
|
||||
|
|
@ -178,38 +92,9 @@ const User = ({ user = null, isOpen, onClose }) => {
|
|||
|
||||
useEffect(() => {
|
||||
if (user?.id) {
|
||||
const customProps = user.custom_properties || {};
|
||||
const networks = customProps.allowed_networks || {};
|
||||
form.setValues(userToFormValues(user));
|
||||
|
||||
form.setValues({
|
||||
username: user.username,
|
||||
first_name: user.first_name || '',
|
||||
last_name: user.last_name || '',
|
||||
email: user.email,
|
||||
user_level: `${user.user_level}`,
|
||||
stream_limit: user.stream_limit || 0,
|
||||
channel_profiles:
|
||||
user.channel_profiles.length > 0
|
||||
? user.channel_profiles.map((id) => `${id}`)
|
||||
: ['0'],
|
||||
xc_password: customProps.xc_password || '',
|
||||
output_format: customProps.output_format || '',
|
||||
output_profile: customProps.output_profile
|
||||
? `${customProps.output_profile}`
|
||||
: '',
|
||||
hide_adult_content: customProps.hide_adult_content || false,
|
||||
epg_days: customProps.epg_days || 0,
|
||||
epg_prev_days: customProps.epg_prev_days || 0,
|
||||
allowed_ips: [
|
||||
...new Set(
|
||||
NETWORK_KEYS.flatMap((key) =>
|
||||
networks[key] ? networks[key].split(',').filter(Boolean) : []
|
||||
)
|
||||
),
|
||||
],
|
||||
});
|
||||
|
||||
if (customProps.xc_password) {
|
||||
if (user.custom_properties?.xc_password) {
|
||||
setEnableXC(true);
|
||||
}
|
||||
|
||||
|
|
@ -248,13 +133,13 @@ const User = ({ user = null, isOpen, onClose }) => {
|
|||
payload.user_id = user.id;
|
||||
}
|
||||
|
||||
const resp = await API.generateApiKey(payload);
|
||||
const resp = await generateApiKey(payload);
|
||||
const newKey = resp && (resp.key || resp.raw_key);
|
||||
if (newKey) {
|
||||
setGeneratedKey(newKey);
|
||||
setUserAPIKey(newKey);
|
||||
}
|
||||
} catch (e) {
|
||||
} catch {
|
||||
// API shows notifications
|
||||
} finally {
|
||||
setGenerating(false);
|
||||
|
|
@ -271,7 +156,8 @@ const User = ({ user = null, isOpen, onClose }) => {
|
|||
payload.user_id = user.id;
|
||||
}
|
||||
|
||||
const resp = await API.revokeApiKey(payload);
|
||||
const resp = await revokeApiKey(payload);
|
||||
// backend returns { success: true } - clear local state
|
||||
if (resp && resp.success) {
|
||||
setGeneratedKey(null);
|
||||
setUserAPIKey(null);
|
||||
|
|
@ -280,7 +166,7 @@ const User = ({ user = null, isOpen, onClose }) => {
|
|||
setUser({ ...authUser, api_key: null });
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
} catch {
|
||||
// API shows notifications
|
||||
} finally {
|
||||
setGenerating(false);
|
||||
|
|
@ -291,16 +177,16 @@ const User = ({ user = null, isOpen, onClose }) => {
|
|||
<Modal opened={isOpen} onClose={onClose} title="User" size="xl">
|
||||
<form onSubmit={form.onSubmit(onSubmit)}>
|
||||
<Tabs defaultValue="account">
|
||||
<Tabs.List mb="md">
|
||||
<Tabs.Tab value="account">Account</Tabs.Tab>
|
||||
<TabsList mb="md">
|
||||
<TabsTab value="account">Account</TabsTab>
|
||||
{showPermissions && (
|
||||
<Tabs.Tab value="permissions">Permissions</Tabs.Tab>
|
||||
<TabsTab value="permissions">Permissions</TabsTab>
|
||||
)}
|
||||
<Tabs.Tab value="epg">EPG Defaults</Tabs.Tab>
|
||||
<Tabs.Tab value="api">API & XC</Tabs.Tab>
|
||||
</Tabs.List>
|
||||
<TabsTab value="epg">EPG Defaults</TabsTab>
|
||||
<TabsTab value="api">API & XC</TabsTab>
|
||||
</TabsList>
|
||||
|
||||
<Tabs.Panel value="account">
|
||||
<TabsPanel value="account">
|
||||
<Stack gap="sm">
|
||||
<Group grow align="flex-start">
|
||||
<TextInput
|
||||
|
|
@ -335,10 +221,10 @@ const User = ({ user = null, isOpen, onClose }) => {
|
|||
disabled={form.getValues().user_level == USER_LEVELS.STREAMER}
|
||||
/>
|
||||
</Stack>
|
||||
</Tabs.Panel>
|
||||
</TabsPanel>
|
||||
|
||||
{showPermissions && (
|
||||
<Tabs.Panel value="permissions">
|
||||
<TabsPanel value="permissions">
|
||||
<Stack gap="sm">
|
||||
<Group grow align="flex-start">
|
||||
<Select
|
||||
|
|
@ -375,10 +261,10 @@ const User = ({ user = null, isOpen, onClose }) => {
|
|||
key={form.key('hide_adult_content')}
|
||||
/>
|
||||
</Stack>
|
||||
</Tabs.Panel>
|
||||
</TabsPanel>
|
||||
)}
|
||||
|
||||
<Tabs.Panel value="epg">
|
||||
<TabsPanel value="epg">
|
||||
<Stack gap="sm">
|
||||
<Text size="sm" c="dimmed">
|
||||
These defaults apply when no URL parameters are specified and
|
||||
|
|
@ -404,9 +290,9 @@ const User = ({ user = null, isOpen, onClose }) => {
|
|||
/>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Tabs.Panel>
|
||||
</TabsPanel>
|
||||
|
||||
<Tabs.Panel value="api">
|
||||
<TabsPanel value="api">
|
||||
<Stack gap="sm">
|
||||
<TextInput
|
||||
label="XC Password"
|
||||
|
|
@ -524,7 +410,7 @@ const User = ({ user = null, isOpen, onClose }) => {
|
|||
</Stack>
|
||||
)}
|
||||
</Stack>
|
||||
</Tabs.Panel>
|
||||
</TabsPanel>
|
||||
</Tabs>
|
||||
|
||||
<Group justify="flex-end" mt="md">
|
||||
|
|
|
|||
|
|
@ -1,26 +1,12 @@
|
|||
// Modal.js
|
||||
import React, { useEffect, useMemo } from 'react';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { yupResolver } from '@hookform/resolvers/yup';
|
||||
import * as Yup from 'yup';
|
||||
import API from '../../api';
|
||||
import { Button, Checkbox, Flex, Modal, Space, TextInput } from '@mantine/core';
|
||||
import {
|
||||
LoadingOverlay,
|
||||
TextInput,
|
||||
Button,
|
||||
Checkbox,
|
||||
Modal,
|
||||
Flex,
|
||||
NativeSelect,
|
||||
FileInput,
|
||||
Space,
|
||||
} from '@mantine/core';
|
||||
import { NETWORK_ACCESS_OPTIONS } from '../../constants';
|
||||
|
||||
const schema = Yup.object({
|
||||
name: Yup.string().required('Name is required'),
|
||||
user_agent: Yup.string().required('User-Agent is required'),
|
||||
});
|
||||
addUserAgent,
|
||||
getResolver,
|
||||
updateUserAgent,
|
||||
} from '../../utils/forms/UserAgentUtils.js';
|
||||
|
||||
const UserAgent = ({ userAgent = null, isOpen, onClose }) => {
|
||||
const defaultValues = useMemo(
|
||||
|
|
@ -42,14 +28,14 @@ const UserAgent = ({ userAgent = null, isOpen, onClose }) => {
|
|||
watch,
|
||||
} = useForm({
|
||||
defaultValues,
|
||||
resolver: yupResolver(schema),
|
||||
resolver: getResolver(),
|
||||
});
|
||||
|
||||
const onSubmit = async (values) => {
|
||||
if (userAgent?.id) {
|
||||
await API.updateUserAgent({ id: userAgent.id, ...values });
|
||||
await updateUserAgent(userAgent.id, values);
|
||||
} else {
|
||||
await API.addUserAgent(values);
|
||||
await addUserAgent(values);
|
||||
}
|
||||
|
||||
reset();
|
||||
|
|
|
|||
|
|
@ -18,6 +18,12 @@ vi.mock('../../../utils/notificationUtils.js', () => ({
|
|||
updateNotification: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../../../api', () => ({
|
||||
default: {
|
||||
getCurrentProgramForEpg: vi.fn().mockResolvedValue(null),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('../../../utils/forms/ChannelUtils.js', () => ({
|
||||
addChannel: vi.fn(),
|
||||
clearChannelOverrides: vi.fn(),
|
||||
|
|
@ -117,8 +123,25 @@ vi.mock('@hookform/resolvers/yup', () => ({
|
|||
|
||||
// ── lucide-react mock ──────────────────────────────────────────────────────────
|
||||
vi.mock('lucide-react', () => ({
|
||||
Blocks: () => <svg data-testid="icon-blocks" />,
|
||||
ChartLine: () => <svg data-testid="icon-chart-line" />,
|
||||
Database: () => <svg data-testid="icon-database" />,
|
||||
Download: () => <svg data-testid="icon-download" />,
|
||||
FileImage: () => <svg data-testid="icon-file-image" />,
|
||||
LayoutGrid: () => <svg data-testid="icon-layout-grid" />,
|
||||
ListOrdered: () => <svg data-testid="icon-list-ordered" />,
|
||||
Logs: () => <svg data-testid="icon-logs" />,
|
||||
MonitorCog: () => <svg data-testid="icon-monitor-cog" />,
|
||||
Package: () => <svg data-testid="icon-package" />,
|
||||
Play: () => <svg data-testid="icon-play" />,
|
||||
PlugZap: () => <svg data-testid="icon-plug-zap" />,
|
||||
Radio: () => <svg data-testid="icon-radio" />,
|
||||
Settings: () => <svg data-testid="icon-settings" />,
|
||||
SquarePlus: () => <svg data-testid="icon-square-plus" />,
|
||||
Undo2: () => <svg data-testid="icon-undo" />,
|
||||
User: () => <svg data-testid="icon-user" />,
|
||||
Video: () => <svg data-testid="icon-video" />,
|
||||
Webhook: () => <svg data-testid="icon-webhook" />,
|
||||
X: () => <svg data-testid="icon-x" />,
|
||||
Zap: () => <svg data-testid="icon-zap" />,
|
||||
}));
|
||||
|
|
@ -680,6 +703,25 @@ describe('ChannelForm', () => {
|
|||
expect(autoMatch).not.toBeDisabled();
|
||||
});
|
||||
|
||||
it('shows in-progress notification when matchChannelEpg returns accepted', async () => {
|
||||
const channel = makeChannel();
|
||||
vi.mocked(ChannelUtils.matchChannelEpg).mockResolvedValue({
|
||||
accepted: true,
|
||||
message: 'EPG matching started',
|
||||
});
|
||||
setupMocks({ channel });
|
||||
render(<ChannelForm {...defaultProps({ channel })} />);
|
||||
fireEvent.click(screen.getByText('Auto Match'));
|
||||
await waitFor(() => {
|
||||
expect(showNotification).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
title: 'Matching in Progress',
|
||||
color: 'blue',
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it('calls matchChannelEpg with the channel on click', async () => {
|
||||
const channel = makeChannel();
|
||||
vi.mocked(ChannelUtils.matchChannelEpg).mockResolvedValue({
|
||||
|
|
|
|||
|
|
@ -0,0 +1,213 @@
|
|||
/**
|
||||
* Tests for the EPG preview fetch logic used in Channel.jsx.
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import { renderHook, act, waitFor } from '@testing-library/react';
|
||||
|
||||
vi.mock('../../../api', () => ({
|
||||
default: {
|
||||
getCurrentProgramForEpg: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
import API from '../../../api';
|
||||
import { useEpgPreview } from '../../../hooks/useEpgPreview';
|
||||
|
||||
describe('Channel EPG preview hook', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it('does not call API when epg_data_id is empty', () => {
|
||||
const { result } = renderHook(() => useEpgPreview(''));
|
||||
expect(API.getCurrentProgramForEpg).not.toHaveBeenCalled();
|
||||
expect(result.current.isLoadingProgram).toBe(false);
|
||||
expect(result.current.hasFetchedProgram).toBe(false);
|
||||
expect(result.current.currentProgram).toBeNull();
|
||||
});
|
||||
|
||||
it('does not call API when epg_data_id is "0"', () => {
|
||||
renderHook(() => useEpgPreview('0'));
|
||||
expect(API.getCurrentProgramForEpg).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('calls API when epg_data_id is set', async () => {
|
||||
const program = { title: 'Live News', epg_data_id: 10 };
|
||||
API.getCurrentProgramForEpg.mockResolvedValue(program);
|
||||
|
||||
const { result } = renderHook(() => useEpgPreview(10));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(API.getCurrentProgramForEpg).toHaveBeenCalledWith(10);
|
||||
expect(result.current.currentProgram).toEqual(program);
|
||||
expect(result.current.isLoadingProgram).toBe(false);
|
||||
expect(result.current.hasFetchedProgram).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
it('shows loading state while API call is in flight', () => {
|
||||
API.getCurrentProgramForEpg.mockReturnValue(new Promise(() => {}));
|
||||
|
||||
const { result } = renderHook(() => useEpgPreview(10));
|
||||
|
||||
expect(result.current.isLoadingProgram).toBe(true);
|
||||
expect(result.current.hasFetchedProgram).toBe(false);
|
||||
});
|
||||
|
||||
it('shows null program after successful fetch with null response', async () => {
|
||||
API.getCurrentProgramForEpg.mockResolvedValue(null);
|
||||
|
||||
const { result } = renderHook(() => useEpgPreview(10));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.currentProgram).toBeNull();
|
||||
expect(result.current.hasFetchedProgram).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
it('retries when response has parsing=true', async () => {
|
||||
vi.useFakeTimers();
|
||||
|
||||
let callCount = 0;
|
||||
API.getCurrentProgramForEpg.mockImplementation(() => {
|
||||
callCount++;
|
||||
if (callCount === 1) return Promise.resolve({ parsing: true });
|
||||
return Promise.resolve({ title: 'Parsed Show', epg_data_id: 10 });
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useEpgPreview(10));
|
||||
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
});
|
||||
expect(callCount).toBe(1);
|
||||
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(3100);
|
||||
});
|
||||
|
||||
expect(callCount).toBe(2);
|
||||
expect(result.current.currentProgram).toEqual({
|
||||
title: 'Parsed Show',
|
||||
epg_data_id: 10,
|
||||
});
|
||||
expect(result.current.isLoadingProgram).toBe(false);
|
||||
expect(result.current.hasFetchedProgram).toBe(true);
|
||||
});
|
||||
|
||||
it('retries on API error and eventually succeeds', async () => {
|
||||
vi.useFakeTimers();
|
||||
vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||
|
||||
let callCount = 0;
|
||||
API.getCurrentProgramForEpg.mockImplementation(() => {
|
||||
callCount++;
|
||||
if (callCount <= 2) return Promise.reject(new Error('Network error'));
|
||||
return Promise.resolve({ title: 'Recovered Show', epg_data_id: 10 });
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useEpgPreview(10));
|
||||
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
});
|
||||
expect(callCount).toBe(1);
|
||||
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(3100);
|
||||
});
|
||||
expect(callCount).toBe(2);
|
||||
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(4600);
|
||||
});
|
||||
expect(callCount).toBe(3);
|
||||
expect(result.current.currentProgram).toEqual({
|
||||
title: 'Recovered Show',
|
||||
epg_data_id: 10,
|
||||
});
|
||||
expect(result.current.isLoadingProgram).toBe(false);
|
||||
expect(result.current.hasFetchedProgram).toBe(true);
|
||||
});
|
||||
|
||||
it('sets null program after exhausting all retries on error', async () => {
|
||||
vi.useFakeTimers();
|
||||
vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||
|
||||
API.getCurrentProgramForEpg.mockRejectedValue(new Error('Persistent error'));
|
||||
|
||||
const { result } = renderHook(() => useEpgPreview(10));
|
||||
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
});
|
||||
// Backoff ramps to a 15s cap; advance past the 180s deadline that bounds the loop.
|
||||
for (let elapsed = 0; elapsed <= 190000; elapsed += 15100) {
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(15100);
|
||||
});
|
||||
}
|
||||
|
||||
expect(result.current.currentProgram).toBeNull();
|
||||
expect(result.current.isLoadingProgram).toBe(false);
|
||||
expect(result.current.hasFetchedProgram).toBe(true);
|
||||
});
|
||||
|
||||
it('cancels fetch on unmount', async () => {
|
||||
let resolvePromise;
|
||||
API.getCurrentProgramForEpg.mockReturnValue(
|
||||
new Promise((resolve) => {
|
||||
resolvePromise = resolve;
|
||||
})
|
||||
);
|
||||
|
||||
const { unmount, result } = renderHook(() => useEpgPreview(10));
|
||||
|
||||
expect(result.current.isLoadingProgram).toBe(true);
|
||||
|
||||
unmount();
|
||||
|
||||
await act(async () => {
|
||||
resolvePromise({ title: 'Late Show', epg_data_id: 10 });
|
||||
});
|
||||
});
|
||||
|
||||
it('changing epg_data_id cancels previous fetch and starts new one', async () => {
|
||||
let resolve1;
|
||||
const promise1 = new Promise((resolve) => {
|
||||
resolve1 = resolve;
|
||||
});
|
||||
|
||||
API.getCurrentProgramForEpg
|
||||
.mockReturnValueOnce(promise1)
|
||||
.mockResolvedValueOnce({ title: 'New Show', epg_data_id: 20 });
|
||||
|
||||
const { result, rerender } = renderHook(({ id }) => useEpgPreview(id), {
|
||||
initialProps: { id: 10 },
|
||||
});
|
||||
|
||||
expect(result.current.isLoadingProgram).toBe(true);
|
||||
|
||||
rerender({ id: 20 });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.currentProgram).toEqual({
|
||||
title: 'New Show',
|
||||
epg_data_id: 20,
|
||||
});
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
resolve1({ title: 'Old Show', epg_data_id: 10 });
|
||||
});
|
||||
|
||||
expect(result.current.currentProgram).toEqual({
|
||||
title: 'New Show',
|
||||
epg_data_id: 20,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -2,6 +2,20 @@ import { render, screen, fireEvent, waitFor } from '@testing-library/react';
|
|||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
|
||||
// ── Utility mocks ──────────────────────────────────────────────────────────────
|
||||
global.fetch = vi.fn().mockResolvedValue({
|
||||
json: () => Promise.resolve({}),
|
||||
});
|
||||
|
||||
vi.mock('../../../api.js', () => ({
|
||||
default: {
|
||||
getSDLineups: vi.fn().mockResolvedValue([]),
|
||||
addSDLineup: vi.fn().mockResolvedValue({ success: true }),
|
||||
deleteSDLineup: vi.fn().mockResolvedValue({ success: true }),
|
||||
searchSDLineups: vi.fn().mockResolvedValue([]),
|
||||
updateSDSettings: vi.fn().mockResolvedValue({}),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('../../../utils/notificationUtils.js', () => ({
|
||||
showNotification: vi.fn(),
|
||||
}));
|
||||
|
|
@ -184,6 +198,62 @@ vi.mock('@mantine/core', async () => ({
|
|||
{error && <span data-testid="input-error">{error}</span>}
|
||||
</div>
|
||||
),
|
||||
Alert: ({ children, title, color, icon }) => (
|
||||
<div data-testid="alert" data-color={color}>
|
||||
{title && <div data-testid="alert-title">{title}</div>}
|
||||
{children}
|
||||
</div>
|
||||
),
|
||||
Select: ({ label, value, onChange, data, placeholder }) => (
|
||||
<select
|
||||
aria-label={label}
|
||||
data-testid={`select-${label?.toString().toLowerCase().replace(/\s+/g, '-')}`}
|
||||
value={value ?? ''}
|
||||
onChange={(e) => onChange?.(e.target.value)}
|
||||
>
|
||||
<option value="">{placeholder}</option>
|
||||
{(data ?? []).map((opt) => {
|
||||
const val = typeof opt === 'string' ? opt : opt.value;
|
||||
const lbl = typeof opt === 'string' ? opt : opt.label;
|
||||
return <option key={val} value={val}>{lbl}</option>;
|
||||
})}
|
||||
</select>
|
||||
),
|
||||
Loader: ({ size }) => <div data-testid="loader" data-size={size} />,
|
||||
Badge: ({ children, color }) => <span data-testid="badge" data-color={color}>{children}</span>,
|
||||
ScrollArea: ({ children }) => <div data-testid="scroll-area">{children}</div>,
|
||||
Table: ({ children }) => <table>{children}</table>,
|
||||
Tooltip: ({ children }) => <div>{children}</div>,
|
||||
Switch: ({ label, checked, onChange, disabled, description }) => (
|
||||
<label>
|
||||
<input
|
||||
type="checkbox"
|
||||
data-testid={`switch-${label?.toString().toLowerCase().replace(/\s+/g, '-')}`}
|
||||
checked={checked ?? false}
|
||||
onChange={(e) => onChange?.(e)}
|
||||
disabled={disabled}
|
||||
/>
|
||||
{label}
|
||||
</label>
|
||||
),
|
||||
UnstyledButton: ({ children, onClick, ...props }) => (
|
||||
<button type="button" onClick={onClick} {...props}>{children}</button>
|
||||
),
|
||||
Alert: ({ children, title, color, icon }) => (
|
||||
<div data-testid="alert" data-color={color}><strong>{title}</strong>{children}</div>
|
||||
),
|
||||
Stack: ({ children, gap }) => <div data-testid="stack">{children}</div>,
|
||||
Text: ({ children, ...props }) => <span>{children}</span>,
|
||||
TextInput: ({ label, value, onChange, placeholder, ...props }) => (
|
||||
<input
|
||||
type="text"
|
||||
aria-label={label}
|
||||
data-testid={`input-${label?.toString().toLowerCase().replace(/\s+/g, '-')}`}
|
||||
value={value || ''}
|
||||
onChange={onChange}
|
||||
placeholder={placeholder}
|
||||
/>
|
||||
),
|
||||
}));
|
||||
|
||||
// ── Imports after mocks ────────────────────────────────────────────────────────
|
||||
|
|
@ -373,10 +443,11 @@ describe('EPG', () => {
|
|||
expect(screen.queryByTestId('input-api-key')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows API key input when source type requires it', () => {
|
||||
it('shows username and password inputs when source type is schedules_direct', () => {
|
||||
const epg = makeEPG({ source_type: 'schedules_direct' });
|
||||
render(<EPG {...defaultProps({ epg })} />);
|
||||
expect(screen.getByTestId('input-api-key')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('input-username')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('input-password')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue