Compare commits

...

318 commits

Author SHA1 Message Date
GitHub Actions
9989b41d1a Release v0.28.2
Some checks failed
Backend Tests / Plan test groups (push) Has been cancelled
CI Pipeline / prepare (push) Has been cancelled
Build and Push Multi-Arch Docker Image / build-and-push (push) Has been cancelled
Frontend Tests / test (push) Has been cancelled
Backend Tests / (push) Has been cancelled
CI Pipeline / docker (amd64, ubuntu-24.04) (push) Has been cancelled
CI Pipeline / docker (arm64, ubuntu-24.04-arm) (push) Has been cancelled
CI Pipeline / create-manifest (push) Has been cancelled
2026-07-23 21:34:58 +00:00
SergeantPanda
9dc7b241b8
Merge pull request #1469 from Dispatcharr/dev
Dispatcharr - v0.28.2
2026-07-23 16:34:05 -05:00
SergeantPanda
6bf126d432 feat(epg): Enhance Schedules Direct token management and request handling
This commit introduces a new `sd_authorized_request` function to streamline authenticated API requests to Schedules Direct. It handles token expiration by clearing the cached token and retrying the request with a fresh token when necessary. Additionally, the `sd_obtain_token` function has been updated to reuse cached tokens when valid, improving efficiency. The changes are accompanied by unit tests to ensure correct behavior during token reuse and request retries, enhancing the overall reliability of the Schedules Direct integration.
2026-07-23 19:42:12 +00:00
SergeantPanda
7c34fbb7e0 feat(epg): Refine Schedules Direct poster fetching logic and improve query handling
Some checks failed
Backend Tests / Plan test groups (push) Has been cancelled
CI Pipeline / prepare (push) Has been cancelled
Build and Push Multi-Arch Docker Image / build-and-push (push) Has been cancelled
Frontend Tests / test (push) Has been cancelled
Backend Tests / (push) Has been cancelled
CI Pipeline / docker (amd64, ubuntu-24.04) (push) Has been cancelled
CI Pipeline / docker (arm64, ubuntu-24.04-arm) (push) Has been cancelled
CI Pipeline / create-manifest (push) Has been cancelled
This commit introduces a new function to identify programs needing artwork while ensuring that those marked as missing for a specific style are excluded from the selection. The logic for fetching poster IDs has been streamlined, enhancing efficiency and clarity. Additionally, unit tests have been added to verify the correct behavior of the new functionality, ensuring that programs without an SD icon are correctly selected and that style changes allow for retries of previously missing artwork.
2026-07-21 23:50:04 +00:00
SergeantPanda
6b2ed0754a
Merge pull request #1465 from Dispatcharr/sd-refactor
Enhance Schedules Direct integration with debugging, caching, and refactoring
2026-07-21 14:19:23 -05:00
SergeantPanda
5c9221e755 feat(epg): Enhance Schedules Direct poster handling with caching and proxy improvements
This commit improves the Schedules Direct poster fetching mechanism by implementing a caching strategy for SD program posters using nginx, with a 14-day cache duration. It introduces a cache-busting feature that appends a hash of the SD icon URI to the poster URL, ensuring clients receive updated artwork without stale images. Additionally, the code structure has been refined to separate poster proxy logic into dedicated utility functions, enhancing maintainability. Tests have been added to verify the correct behavior of the new caching and proxy functionalities.
2026-07-21 18:37:57 +00:00
SergeantPanda
5b90ccff28 feat(epg): Enhance Schedules Direct token management with credential fingerprinting
This commit improves the Schedules Direct token management by introducing a credential fingerprinting mechanism. The fingerprint is used to ensure that cached tokens are only reused when the username and password match, preventing token misuse across different accounts. Additionally, the token caching functions have been updated to store and validate the fingerprint, and tests have been added to verify the correct behavior when changing credentials. This enhancement increases security and reliability in handling Schedules Direct authentication.
2026-07-21 17:18:58 +00:00
SergeantPanda
2a5ab5c268 refactor(epg): Centralize WebSocket progress updates in utils and clean up imports
This commit refactors the Schedules Direct integration by centralizing the WebSocket progress update functionality into the `utils.py` module. The `send_epg_update` function is now defined in `utils.py`, allowing for cleaner imports across the codebase and reducing circular dependencies. Additionally, references to the old `send_epg_update` function in `sd_tasks.py` and tests have been updated accordingly to ensure consistent functionality.
2026-07-21 14:59:02 +00:00
SergeantPanda
726e0faa4c refactor(epg): Separate Schedules Direct functionality into dedicated modules
This commit refactors the Schedules Direct integration by splitting related functionality into distinct modules. The refresh pipeline and Celery tasks are now located in `sd_tasks.py`, while API mixins are moved to `sd_api.py`. This restructuring enhances code organization and maintainability, allowing for cleaner imports and better separation of concerns. Existing imports and task names remain unchanged to ensure backward compatibility.
2026-07-21 14:56:31 +00:00
SergeantPanda
ddef24a8fc feat(epg): Enhance Schedules Direct authentication with lockout handling and refactor token retrieval
This commit improves the Schedules Direct authentication process by implementing a lockout mechanism for specific error codes (4002/4003), preventing repeated token requests during lockout periods. The token retrieval logic has been centralized in the `sd_obtain_token` function, streamlining the authentication process across various components. Additionally, comprehensive tests have been added to ensure the correct handling of lockouts and authentication failures, enhancing overall reliability and user experience.
2026-07-21 14:35:04 +00:00
SergeantPanda
dc59469b21 feat(epg): Implement Redis caching for Schedules Direct tokens to optimize authentication
This commit introduces a caching mechanism for Schedules Direct authentication tokens using Django's Redis cache. The new implementation allows concurrent requests to reuse tokens across uWSGI workers, reducing the number of authentication calls. Additionally, helper functions for setting, getting, and clearing cached tokens have been added, along with tests to ensure proper functionality and handling of token expiration. This enhancement improves performance and efficiency in managing Schedules Direct API interactions.
2026-07-20 23:09:39 +00:00
SergeantPanda
18b41a57e2 fix(epg): Improve handling of Schedules Direct lineup changes and error messages
This commit enhances the handling of lineup changes by implementing a daily limit check, ensuring that users receive clear messaging when they reach their limit of 6 add/delete operations per 24 hours. It also updates the error handling for specific Schedules Direct authentication codes, providing more informative responses for offline and busy states. Additionally, frontend components have been adjusted to reflect these changes in user notifications and UI behavior.
2026-07-20 22:26:06 +00:00
SergeantPanda
3449469ece fix(epg): Update descriptions for poster fetching and debugging options in SDSettings 2026-07-20 22:04:24 +00:00
SergeantPanda
1a429b2cc6 Merge branch 'dev' of https://github.com/Dispatcharr/Dispatcharr into sd-refactor 2026-07-20 21:55:49 +00:00
SergeantPanda
6b675f814a refactor(epg): Remove AutoApplyEpgLogosSwitch from SDSettings and adjust condition for rendering in EPG
This commit removes the AutoApplyEpgLogosSwitch component from the SDSettings section and updates the condition for its rendering in the EPG component to only display when savedEpgId is present. This change simplifies the UI and improves the logical flow of the component.
2026-07-20 21:48:11 +00:00
SergeantPanda
ac38adcfce feat(epg): Add Extra Schedules Direct Debugging option and improve error handling
This commit introduces an Extra Schedules Direct Debugging toggle in the EPG source settings, allowing users to enable a `RouteTo: debug` header for troubleshooting with Schedules Direct support. The implementation includes automatic disabling of this option if Schedules Direct returns code 2055. Additionally, error handling for image download limits has been enhanced to prevent account blocking, with appropriate responses for various error codes. Tests have been added to ensure the correct behavior of these features.
2026-07-20 21:38:34 +00:00
GitHub Actions
c1a28863c6 Release v0.28.1
Some checks failed
CI Pipeline / prepare (push) Waiting to run
CI Pipeline / docker (amd64, ubuntu-24.04) (push) Blocked by required conditions
CI Pipeline / docker (arm64, ubuntu-24.04-arm) (push) Blocked by required conditions
CI Pipeline / create-manifest (push) Blocked by required conditions
Backend Tests / Plan test groups (push) Has been cancelled
Build and Push Multi-Arch Docker Image / build-and-push (push) Has been cancelled
Backend Tests / (push) Has been cancelled
2026-07-20 16:22:53 +00:00
SergeantPanda
67ccbd70bc
Merge pull request #1461 from Dispatcharr/dev
Dev
2026-07-20 11:21:40 -05:00
SergeantPanda
e94a1acc1c refactor(core): Remove redundant cache imports in CoreSettings model
Some checks are pending
Backend Tests / Plan test groups (push) Waiting to run
Backend Tests / (push) Blocked by required conditions
CI Pipeline / prepare (push) Waiting to run
CI Pipeline / docker (amd64, ubuntu-24.04) (push) Blocked by required conditions
CI Pipeline / docker (arm64, ubuntu-24.04-arm) (push) Blocked by required conditions
CI Pipeline / create-manifest (push) Blocked by required conditions
Build and Push Multi-Arch Docker Image / build-and-push (push) Waiting to run
2026-07-20 15:59:44 +00:00
SergeantPanda
524ee6d479 tests: Fix backend test. 2026-07-20 15:44:41 +00:00
SergeantPanda
172967c2fd fix(core): Enhance CoreSettings caching with Redis error handling
This update introduces robust error handling for Redis connectivity issues in the CoreSettings model. It adds methods to gracefully fall back to Postgres when Redis is unavailable, ensuring that settings reads and invalidations do not fail. New logging functionality has been implemented to warn when the cache degrades to Postgres, and tests have been added to verify the behavior under various failure scenarios. This enhancement improves the resilience of the caching mechanism and maintains application stability during Redis outages.
2026-07-20 15:35:52 +00:00
SergeantPanda
0bb58542a5 Merge branch 'main' of https://github.com/Dispatcharr/Dispatcharr into dev
Some checks failed
Backend Tests / Plan test groups (push) Waiting to run
Backend Tests / (push) Blocked by required conditions
CI Pipeline / prepare (push) Waiting to run
CI Pipeline / docker (amd64, ubuntu-24.04) (push) Blocked by required conditions
CI Pipeline / docker (arm64, ubuntu-24.04-arm) (push) Blocked by required conditions
CI Pipeline / create-manifest (push) Blocked by required conditions
Build and Push Multi-Arch Docker Image / build-and-push (push) Waiting to run
Frontend Tests / test (push) Has been cancelled
2026-07-19 22:02:32 +00:00
GitHub Actions
df53439789 Release v0.28.0
Some checks failed
Backend Tests / Plan test groups (push) Waiting to run
Backend Tests / (push) Blocked by required conditions
Build and Push Multi-Arch Docker Image / build-and-push (push) Waiting to run
Frontend Tests / test (push) Has been cancelled
2026-07-19 21:58:10 +00:00
SergeantPanda
532ac1ccfd test: Fix timeshift test for is_catchup_enabled 2026-07-19 21:57:01 +00:00
SergeantPanda
5e4441804c test: Fix timeshift test for is_catchup_enabled 2026-07-19 21:56:13 +00:00
SergeantPanda
ce6b0099a1
Merge pull request #1457 from Dispatcharr/dev
# Dispatcharr - v0.28.0
2026-07-19 16:47:29 -05:00
SergeantPanda
019e38fa70
Merge branch 'main' into dev 2026-07-19 16:46:17 -05:00
SergeantPanda
b1801cb92a tests: Fix test failing from other tests running. 2026-07-19 19:34:02 +00:00
SergeantPanda
5f603ac7b7 tests: Fix confirmationdialog test ESlint error. 2026-07-19 19:28:11 +00:00
SergeantPanda
7e2765733b fix(channel-deletion): implement optional stream stopping during channel deletion (Fixes #870)
This update introduces functionality to stop active proxy sessions for channels before their deletion, addressing issues where streams could hang without a corresponding channel. The `stop_stream` query parameter allows users to choose whether to stop the stream during manual deletes, while automated sync deletes will always stop streams. The implementation includes updates to the API views, service methods, and tests to ensure proper functionality. Additionally, the confirmation dialog and related components have been updated to reflect this new behavior, enhancing user experience during channel management.
2026-07-19 19:25:32 +00:00
SergeantPanda
3749953c2c feat(confirmation-dialog): add stop stream option to confirmation dialog
This update enhances the ConfirmationDialog component by introducing an option to stop the active stream when confirming actions. The new checkbox allows users to choose whether to stop the stream if it is playing, with the preference being remembered for future confirmations. The implementation includes updates to the state management and effects to handle the new option, as well as corresponding tests to ensure functionality. Additionally, the ChannelsTable component has been updated to utilize this new feature, improving user experience during channel deletions.
2026-07-19 18:52:34 +00:00
SergeantPanda
3011946c56 fix(cron): enhance hourly cron presets with step intervals (Fixes #1320)
This update introduces a new feature in the CronBuilder component, allowing users to select step-based intervals (e.g., every 6/12 hours) for hourly cron expressions. The changes include the addition of an Interval dropdown for hourly frequency, which loads presets and maintains the step pattern during edits. The buildCron utility has been updated to accommodate the new hours parameter, and tests have been added to ensure proper functionality and validation of the new presets. This enhancement improves user experience and flexibility in scheduling tasks.
2026-07-19 18:04:38 +00:00
SergeantPanda
b30e953483 changelog: Link issue. 2026-07-19 17:22:38 +00:00
SergeantPanda
559151585e fix(redis): extend TTL for fMP4 and profile output keys during long sessions
Some checks are pending
Backend Tests / Plan test groups (push) Waiting to run
Backend Tests / (push) Blocked by required conditions
CI Pipeline / prepare (push) Waiting to run
CI Pipeline / docker (amd64, ubuntu-24.04) (push) Blocked by required conditions
CI Pipeline / docker (arm64, ubuntu-24.04-arm) (push) Blocked by required conditions
CI Pipeline / create-manifest (push) Blocked by required conditions
Build and Push Multi-Arch Docker Image / build-and-push (push) Waiting to run
This update addresses the issue of Redis keys for fMP4 and profile outputs expiring during extended sessions. The TTL for these keys is now refreshed approximately every minute while the remux or transcode is active, preventing loss of coordination keys for long-running sessions. Additionally, the initialization process for both fMP4 and profile managers has been updated to ensure that TTLs are managed effectively, enhancing reliability and stability during playback. Tests have been updated to validate these improvements.
2026-07-19 16:39:22 +00:00
SergeantPanda
6c9cb30a90
Merge pull request #1456 from Dispatcharr/fix/ghost-initializing-root-cause
fix(live): prevent ghost channel sessions from blocking playback indefinitely
2026-07-19 11:11:54 -05:00
SergeantPanda
820959e390 fix(live): prevent ghost channel sessions from blocking playback indefinitely
This update resolves an issue where ghost channel sessions stuck in the `initializing` state could block playback requests indefinitely. The initialization process now ensures that the `initializing` state is only written to Redis after acquiring the channel ownership lock, preventing race conditions that could leave sessions without an owner. Additionally, failed initializations are cleaned up immediately, allowing subsequent play requests to retry without hitting a stopping gate. The changes enhance the reliability of channel initialization and playback handling. Tests have been updated to validate these improvements.
2026-07-19 16:08:24 +00:00
SergeantPanda
21901498de fix(connections): ensure proper cleanup of geventpool checkouts across various components
This update addresses issues related to geventpool database connection leaks by consistently invoking `close_old_connections()` in `finally` blocks within the backup scheduler, plugin repository refresh, and core notification synchronization processes. These changes ensure that idle connections are properly released, preventing resource exhaustion. HLS proxy server has been disabled (hasn't been used). Additionally, the proxy server cleanup logic has been enhanced to safely handle the absence of proxy instances. Tests have been updated to validate these improvements and ensure stability in high-load scenarios.
2026-07-19 14:32:51 +00:00
SergeantPanda
64912c6164 fix(live): prevent geventpool DB connection leaks and enhance channel name resolution (Fixes #1418)
This update addresses a critical issue where geventpool database connections were not properly released during stream setup and teardown, potentially leading to resource exhaustion. The `close_old_connections()` method is now consistently called in `finally` blocks across various components, ensuring that connections are released even in the event of exceptions. Additionally, the logic for resolving channel and stream names has been improved to prioritize caller-supplied names and Redis values, reducing reliance on ORM during initialization. This change enhances performance and stability, particularly in high-load scenarios. Tests have been updated to validate these improvements.
2026-07-19 14:06:14 +00:00
SergeantPanda
cdba6dac84 tests: Fix logo tests.
Some checks are pending
Backend Tests / Plan test groups (push) Waiting to run
Backend Tests / (push) Blocked by required conditions
CI Pipeline / prepare (push) Waiting to run
CI Pipeline / docker (amd64, ubuntu-24.04) (push) Blocked by required conditions
CI Pipeline / docker (arm64, ubuntu-24.04-arm) (push) Blocked by required conditions
CI Pipeline / create-manifest (push) Blocked by required conditions
Build and Push Multi-Arch Docker Image / build-and-push (push) Waiting to run
Frontend Tests / test (push) Waiting to run
2026-07-18 20:20:20 +00:00
SergeantPanda
66089f14ee refactor(permissions): enhance permission handling across various viewsets
This update modifies permission classes in multiple viewsets to restrict access based on user roles. The `IsAdmin` permission is now enforced for several actions, including group management and permission listing, ensuring that only administrators can perform sensitive operations. Additionally, a new utility function, `resolve_safe_local_data_path`, is introduced to enhance security when accessing local file paths. The changes improve overall security and maintainability of the codebase.
2026-07-18 20:03:03 +00:00
SergeantPanda
b6442e6421 feat(catchup): enhance catchup functionality with user and system settings
This update introduces a new `is_catchup_enabled` function to determine if catch-up is allowed for users based on their custom properties and system settings. The `UserViewSet` is modified to restrict admin-managed properties, including catch-up access. Additionally, various views and tests are updated to incorporate catch-up checks, ensuring that users without access receive appropriate error responses. The frontend is enhanced with a catch-up toggle in user and system settings forms, allowing for better management of catch-up capabilities.
2026-07-18 18:55:51 +00:00
SergeantPanda
2b62928b42 feat(settings): implement Redis caching for CoreSettings and enhance network access handling
This update introduces Redis caching for grouped settings in the CoreSettings model, improving performance by reducing database queries. A new method, `get_network_access_settings`, is added to streamline access to network settings. Additionally, the `setting_flag_enabled` function is introduced to handle boolean settings more robustly. The cache is invalidated on save and delete operations, ensuring data consistency. Tests have been added to validate the caching behavior and the new settings functionality.
2026-07-18 17:33:02 +00:00
SergeantPanda
f9a2968ec5 feat(range): add full restart range detection and enhance plain reconnect logic
Some checks are pending
Backend Tests / Plan test groups (push) Waiting to run
Backend Tests / (push) Blocked by required conditions
CI Pipeline / prepare (push) Waiting to run
CI Pipeline / docker (amd64, ubuntu-24.04) (push) Blocked by required conditions
CI Pipeline / docker (arm64, ubuntu-24.04-arm) (push) Blocked by required conditions
CI Pipeline / create-manifest (push) Blocked by required conditions
Build and Push Multi-Arch Docker Image / build-and-push (push) Waiting to run
Frontend Tests / test (push) Waiting to run
This update introduces a new helper function `_is_full_restart_range` to determine when a request indicates a full restart from byte 0. The existing `_should_preempt_plain_reconnect` function has been modified to utilize this new logic, ensuring that plain GET requests and open-ended byte ranges are handled correctly. Additionally, new tests have been added to validate the behavior of these functions, confirming that they correctly identify restart scenarios and preempt playback as expected.
2026-07-18 00:43:09 +00:00
SergeantPanda
0aed30253b refactor(stats): simplify channel logo handling in stats data
This update refactors the logic for building timeshift statistics by removing unnecessary `select_related` calls and the `logo_url` field from the connections. The changes streamline the data retrieval process and ensure that only the `logo_id` is exposed in the session data. Corresponding tests have been updated to reflect these modifications.
2026-07-17 23:21:44 +00:00
SergeantPanda
b24f39d9f8 fix(timestamps): enhance timestamp parsing to support mixed separators
This update modifies the timestamp parsing logic to accommodate various formats, including those using both dash and colon as separators for hours, minutes, and seconds. The changes ensure that timestamps like "HH-MM-SS" and mixed formats such as "HH-MM:SS" are correctly parsed and normalized. Additional tests have been added to validate these new parsing capabilities.
2026-07-17 22:53:21 +00:00
SergeantPanda
a40ac78ffd fix(live): resolve Redis state latching issue during buffering-timeout failover (Fixes #1449)
This update ensures that the Redis state for live channels is correctly cleared when a buffering-timeout failover occurs. Previously, the in-memory buffering flag could be cleared without updating Redis, leading to incorrect state reporting. The fix modifies the logic to clear the Redis state to 'active' when the buffering flag is reset, preventing stale 'buffering' states from persisting after recovery. Tests have been updated to validate this behavior.
2026-07-17 22:31:42 +00:00
SergeantPanda
8303ce27ee fix(plugins): improve plugin discovery in multi-worker setups (Fixes #1452)
This change ensures that plugin discovery does not force-reload on every connect event in multi-worker environments. By allowing local reloads in response to a newer `.reload_token`, the update prevents stale token issues that previously led to degraded worker performance. The explicit `force_reload=True` option is retained for installations, updates, or reloads, ensuring better resource management and stability. Tests have been updated to reflect this behavior.
2026-07-17 22:16:47 +00:00
SergeantPanda
692f2d27c1 fix(channels): update "Copy URL" functionality to exclude web player parameters
This change modifies the "Copy URL" action in the Channels table to generate a plain proxy URL without including web player output profile parameters. The update ensures that copied links are suitable for external players, enhancing usability. Additionally, tests have been updated to verify the new behavior of the "Copy URL" feature.
2026-07-17 21:52:47 +00:00
SergeantPanda
84d2aedae5 fix(vod): prevent stale profile connection reservations in Redis-backed VOD sessions (Fixes #1426)
This update introduces atomic mutations for active_streams using Redis Lua scripts, ensuring that concurrent byte-range requests do not leave stale session metadata. The changes also include improvements to the session state saving mechanism, preventing the recreation of zombie sessions after idle cleanup. Additionally, tests have been added to cover the new atomic behavior and ensure reliability across various scenarios.
2026-07-17 21:35:32 +00:00
SergeantPanda
a78bc7aa48 changelog: Link issue. 2026-07-17 18:56:42 +00:00
SergeantPanda
02268bbd9e
Merge pull request #1445 from nagelm/fix/db-session-timezone-startup-packet
fix(db): pin session timezone to UTC via libpq startup packet (#651)
2026-07-17 13:34:15 -05:00
SergeantPanda
31f448c673 changelog: add clarity. 2026-07-17 18:33:42 +00:00
SergeantPanda
fc8b955b36 changelog: Update for pr 1445. 2026-07-17 16:46:39 +00:00
SergeantPanda
5714cf23cf Merge branch 'dev' of https://github.com/Dispatcharr/Dispatcharr into pr/nagelm/1445 2026-07-17 16:00:11 +00:00
SergeantPanda
097e7118ef Merge branch 'dev' of https://github.com/Dispatcharr/Dispatcharr into dev
Some checks are pending
Backend Tests / Plan test groups (push) Waiting to run
Backend Tests / (push) Blocked by required conditions
CI Pipeline / prepare (push) Waiting to run
CI Pipeline / docker (amd64, ubuntu-24.04) (push) Blocked by required conditions
CI Pipeline / docker (arm64, ubuntu-24.04-arm) (push) Blocked by required conditions
CI Pipeline / create-manifest (push) Blocked by required conditions
Build and Push Multi-Arch Docker Image / build-and-push (push) Waiting to run
Frontend Tests / test (push) Waiting to run
2026-07-17 15:45:34 +00:00
SergeantPanda
52c97120fb
Merge pull request #1442 from nagelm/fix/machine-independent-date-tests
test(frontend): make date assertions machine-independent
2026-07-17 10:45:02 -05:00
SergeantPanda
9da83d76fa changelog: Update for pr 1442 2026-07-17 15:44:25 +00:00
SergeantPanda
b91385aacb Merge branch 'dev' of https://github.com/Dispatcharr/Dispatcharr into pr/nagelm/1442 2026-07-17 15:40:28 +00:00
SergeantPanda
1f30e2076d
Merge pull request #1441 from nagelm/fix/toast-html-error-bodies
fix(ui): stop rendering raw HTML error pages in toast notifications (#1261, item 1)
2026-07-17 10:33:36 -05:00
SergeantPanda
5263e18ed9 fix(api): improve error handling for API responses
- Enhanced the `formatApiError` function to better format failed API responses, preventing raw HTML error pages from being displayed in toasts. JSON error bodies are now prioritized for user-friendly messages, while HTML and empty bodies are collapsed to a concise status line. Long plain-text bodies are truncated for readability.
- Updated tests to cover new error formatting behavior, ensuring accurate extraction of error messages from various JSON structures.
2026-07-17 15:31:56 +00:00
SergeantPanda
9ee5ba2535 Merge branch 'dev' of https://github.com/Dispatcharr/Dispatcharr into pr/nagelm/1441 2026-07-17 15:19:35 +00:00
SergeantPanda
6157bc1177 changelog: update for url validation pr. 2026-07-17 14:04:03 +00:00
SergeantPanda
42248743fa
Merge pull request #1381 from recurst/patch-1
Allow underscores in non-FQDN hostnames
2026-07-17 09:01:13 -05:00
SergeantPanda
540f3d9bed Merge branch 'dev' of https://github.com/Dispatcharr/Dispatcharr into dev 2026-07-17 13:57:34 +00:00
SergeantPanda
61df45c895 changelog: Update for frontend test/refactor pr. 2026-07-17 13:56:26 +00:00
SergeantPanda
1b99f105c4
Merge pull request #1444 from nick4810/tests/frontend-unit-tests
Tests/frontend unit tests
2026-07-17 08:52:42 -05:00
SergeantPanda
8bcc7c9ebe feat(timeshift): enhance catch-up programme handling and UI updates
Some checks are pending
Backend Tests / Plan test groups (push) Waiting to run
Backend Tests / (push) Blocked by required conditions
CI Pipeline / prepare (push) Waiting to run
CI Pipeline / docker (amd64, ubuntu-24.04) (push) Blocked by required conditions
CI Pipeline / docker (arm64, ubuntu-24.04-arm) (push) Blocked by required conditions
CI Pipeline / create-manifest (push) Blocked by required conditions
Build and Push Multi-Arch Docker Image / build-and-push (push) Waiting to run
Frontend Tests / test (push) Waiting to run
- Updated the catch-up stats UI to advance to the next EPG programme when playback continues past the original programme's end, improving user experience.
- Enhanced the `get_programme_info` function to resolve the guide entry based on the playhead position, ensuring accurate display of the current programme.
- Introduced new utility functions for computing catch-up playback position and checking if the playhead is outside the displayed programme window, optimizing session management.
- Added tests to validate the new functionality and ensure correct behaviour of the catch-up features.
2026-07-16 23:10:06 +00:00
SergeantPanda
6f62d807f4 feat(timeshift): add position reporting for catch-up sessions
- Introduced a new endpoint `POST /api/catchup/sessions/<session_id>/position/` for native clients to report their playhead position and pause state during catch-up sessions.
- Updated the catch-up session handling to include a `paused` flag, allowing accurate tracking of playback state without seeking the provider stream.
- Enhanced the Redis storage mechanism to accommodate the new position and pause data, ensuring real-time updates for admin stats.
- Added tests to validate the new position reporting functionality and its integration with existing catch-up features.
2026-07-16 22:06:27 +00:00
SergeantPanda
14bfd25d9a feat(timeshift): enhance catch-up session handling with optional duration support
- Introduced an optional `duration` parameter for catch-up sessions, allowing clients to specify the programme length in minutes. This value is preferred over EPG-derived durations and includes a buffer for provider lag.
- Updated the API views and serializers to accept and process the new `duration` field.
- Enhanced the catch-up proxy implementation to utilize the client-supplied duration, improving playback accuracy.
- Added tests to validate the new duration handling and ensure proper integration with existing functionality.
2026-07-16 21:26:06 +00:00
SergeantPanda
d3d51780dd fix(schema): enhance Swagger/OpenAPI schema generation for concurrent requests 2026-07-16 19:24:46 +00:00
SergeantPanda
cdfe16ae5d fix(schema): resolve Swagger/OpenAPI schema generation issues under concurrent requests
- Implemented a new `LockedSpectacularAPIView` to handle schema generation, ensuring it no longer fails due to concurrent gevent requests. The schema builds are now single-flight per process and cached in Django's cache, allowing all workers to share the result.
- Updated the `urls.py` to use the new view for schema generation, enhancing stability and performance.
2026-07-16 18:57:57 +00:00
SergeantPanda
388c4bd28d Enhancement: update catch-up features and enhance stream ordering logic
Some checks are pending
Backend Tests / Plan test groups (push) Waiting to run
Backend Tests / (push) Blocked by required conditions
CI Pipeline / prepare (push) Waiting to run
CI Pipeline / docker (amd64, ubuntu-24.04) (push) Blocked by required conditions
CI Pipeline / docker (arm64, ubuntu-24.04-arm) (push) Blocked by required conditions
CI Pipeline / create-manifest (push) Blocked by required conditions
Build and Push Multi-Arch Docker Image / build-and-push (push) Waiting to run
Frontend Tests / test (push) Waiting to run
- Improved multi-provider failover by preferring streams with sufficient catch-up days.
- Added new helper functions for calculating programme age and ordering catch-up streams.
- Removed deprecated XMLTV settings from the frontend and backend.
- Updated tests to validate new stream ordering logic and catch-up functionality.
2026-07-16 15:33:34 +00:00
SergeantPanda
6bd42c83f4
Merge pull request #1440 from dillardblom/fix/catchup-incoming-query-url-style
Some checks are pending
Backend Tests / Plan test groups (push) Waiting to run
Backend Tests / (push) Blocked by required conditions
CI Pipeline / prepare (push) Waiting to run
CI Pipeline / docker (amd64, ubuntu-24.04) (push) Blocked by required conditions
CI Pipeline / docker (arm64, ubuntu-24.04-arm) (push) Blocked by required conditions
CI Pipeline / create-manifest (push) Blocked by required conditions
Build and Push Multi-Arch Docker Image / build-and-push (push) Waiting to run
fix(timeshift): accept QUERY-style incoming catch-up requests (streaming/timeshift.php)
2026-07-15 17:22:51 -05:00
SergeantPanda
e6244adc0b docs: Remove mentions of specific clients as they could change and isn't fully inclusive anyway. 2026-07-15 22:16:00 +00:00
SergeantPanda
b475bbc07b changelog: add support for QUERY-style catch-up requests 2026-07-15 22:08:35 +00:00
SergeantPanda
23dc7c0a92 Merge branch 'dev' of https://github.com/Dispatcharr/Dispatcharr into fix/catchup-incoming-query-url-style 2026-07-15 22:05:35 +00:00
SergeantPanda
33ada59dba fix(timeshift): refine catch-up timestamp handling and enhance EOF probe logic
Updated the catch-up timestamp normalization to reflect XC client specifications. Introduced a new mechanism to handle near-EOF duration probes, ensuring that playback statistics are preserved without reanchoring to the end of the file. Additionally, improved the handling of presentation lengths in the timeshift proxy to align with XC behavior. Updated tests to validate these changes and ensure robust playback functionality.
2026-07-15 21:26:25 +00:00
nagelm
49189465d2 fix(db): pin session timezone to UTC via libpq startup packet
DB sessions were never actually pinned to UTC. Three stacked failures:

1. The connection_created receiver (_force_utc0 in core/apps.py) was a
   nested closure connected with Django's default weak reference. It was
   garbage-collected as soon as CoreConfig.ready() returned, so
   SET TIME ZONE 'UTC0' fired into a dead weakref and never executed -
   in any version since it landed (verified: the signal's receivers list
   on a live 0.27.2 shows a dead weakref).

2. Sessions were UTC on older stacks anyway because Django's own
   init_connection_state configured the timezone. Since native
   psycopg-pool support (Django 5.1+), that path is gated on
   `not self.pool` - and the geventpool mixin's `pool` property is
   always truthy, so Django silently skips timezone (and role)
   configuration for this backend on every connection. This is what
   actually regressed at the psycopg2->3 / Django upgrade: the masking
   layer disappeared, not the (already dead) signal.

3. psycopg3 logs "unknown PostgreSQL timezone: 'UTC0'" because the
   POSIX spec is unresolvable in Python zoneinfo (cosmetic, but it
   means 'UTC0' buys nothing on psycopg3).

Net effect: every session ran at the server-default timezone (verified
live: SHOW TimeZone through the pool returns 'Etc/UTC'). Deployments
whose Postgres default is non-UTC (e.g. /etc/localtime bind-mounts,
the original issue 651 report) get the EPG offset corruption back.

Fix: pin the GUC in the libpq startup packet in the pool backend's
get_connection_params():

- covers every connection the gevent pool creates; no signal, GC, or
  Django-flow dependency (same pattern as the client_encoding pin the
  pool already applies)
- startup-packet GUCs are the session default: they survive ROLLBACK,
  and RESET TimeZone returns to UTC rather than the server default
- 'UTC' resolves cleanly in psycopg3's zoneinfo lookup, and PostgreSQL
  resolves it against its own bundled tzdata, not host-mounted files

The dead signal is removed. Regression tests exercise the session
timezone through the real pool backend explicitly (the test runner's
default engine is the vanilla Django backend), and assert the
RESET-returns-to-UTC session-default property.

Tested: live 0.27.2 AIO (psycopg 3.3.4, PostgreSQL 17) - unpatched
backend+pool session shows 'Etc/UTC'; patched shows 'UTC' incl. after
RESET and ROLLBACK. Full A/B against the dev image with the server
default forced to Europe/Zurich: stock renders a 12:00Z source
programme as start="20260715140000 +0000" in /output/epg; patched
renders it correctly; the regression tests fail on stock and pass
when patched.
2026-07-15 10:20:38 +12:00
Nick Sandstrom
817ebf21a9 Added tests for components 2026-07-14 14:21:54 -07:00
Nick Sandstrom
07d17a5ffc Extracted ManageReposModal from PluginBrowse 2026-07-14 14:21:53 -07:00
Nick Sandstrom
99fc71de99 Slight refactoring of components 2026-07-14 14:21:53 -07:00
Nick Sandstrom
f6ad115a1f Added tests for utils 2026-07-14 14:21:53 -07:00
Nick Sandstrom
6d5a5a549a Extracted utils 2026-07-14 14:21:53 -07:00
nagelm
8cb1dab5ce test(frontend): make date assertions machine-independent
Three tests encoded the machine's timezone or locale into their
expectations and fail on any box east of UTC (observed on UTC+12/13;
CI's UTC and US-locale dev machines never see it):

- dateTimeUtils isSame: compared 10:00Z/11:00Z on the 15th as 'same
  day', but isSame works on local calendar days and no UTC instant
  falls on the same date in every timezone (at UTC+13 those are 23:00
  on the 15th and 00:00 on the 16th). Use timezone-less datetimes,
  which mean the same calendar day everywhere.
- RecordingUtils toDateString: formatted a UTC-noon instant and
  expected the UTC date, but toDateString renders local time (UTC+12
  renders the next day). Construct the Date with local components.
- SeriesModalUtils getEpisodeAirdate: the code renders the viewer's
  locale via toLocaleDateString() by design, but the test hardcoded
  the en-US shape (it tolerated the timezone day-shift with /1[4|5]/
  yet not the day/month order). Compute the expectation with the same
  API so the test asserts the wiring, not a specific locale.

No production code changes.
2026-07-14 23:00:04 +12:00
nagelm
ed1c449234 fix(ui): stop rendering raw HTML error pages in toast notifications (#1261)
Failed API responses whose body is not JSON - Django's HTML "Server
Error (500)" page, nginx's HTML 502/504 pages when the backend is down
or timing out - were interpolated verbatim into the error toast,
showing users a wall of markup.

request() deliberately keeps the raw text when JSON.parse fails, and
errorNotification() rendered `${status} - ${body}` unconditionally.
Route the body through a new formatApiError() helper (in utils.js so it
is unit-testable without importing the store-heavy api module):

- JSON object bodies keep their existing pretty-printed formatting
- markup and empty bodies collapse to the response's own status line:
  the declared Content-Type decides what counts as markup (body
  sniffing only as fallback when the header is missing), and the label
  comes from the fetch Response's statusText - the protocol's reason
  phrase, defined for every status code, rather than a hardcoded status
  map. HTTP/2+ transmits no reason phrase, so an empty statusText falls
  back to a generic label. request() already attaches the Response to
  the error, so no call-site changes are needed.
- the full suppressed body goes to console.debug so the markup stays
  available for troubleshooting (deliberate, not a leftover debug
  statement)
- plain-text bodies are truncated at 200 chars as a backstop
- errors without a status keep the error.message fallback
2026-07-14 20:37:58 +12:00
SergeantPanda
d1344adb4f chore(uwsgi): update configuration for worker reload mechanism
Added a new file entry to .gitignore for .uwsgi-reload and modified uwsgi.debug.ini to implement a touch-based worker reload mechanism, replacing the previous py-autoreload setting. This change enhances the management of API worker reloads during development.
2026-07-14 00:53:03 +00:00
SergeantPanda
ab8e3f93c6 tests: Fix backend timeshift test.
Some checks failed
Backend Tests / Plan test groups (push) Has been cancelled
CI Pipeline / prepare (push) Has been cancelled
Build and Push Multi-Arch Docker Image / build-and-push (push) Has been cancelled
Backend Tests / (push) Has been cancelled
CI Pipeline / docker (amd64, ubuntu-24.04) (push) Has been cancelled
CI Pipeline / docker (arm64, ubuntu-24.04-arm) (push) Has been cancelled
CI Pipeline / create-manifest (push) Has been cancelled
2026-07-13 23:31:31 +00:00
Dillard Blom
e5d33861e6 Accept QUERY-style incoming catch-up requests (streaming/timeshift.php)
Clients that build catch-up requests in QUERY layout (e.g. Open-TV /
Fred TV: /streaming/timeshift.php?username=...&stream=...&start=...)
had no matching urlpattern, so the request silently fell through to
the frontend's <path:unused_path> catch-all and got served index.html
(200 OK, wrong content) instead of reaching the proxy — no error, no
log line, the client just fails to play.

The PATH-style layout (timeshift/<user>/<pass>/<stream_id>/<ts>/<dur>)
already worked; QUERY-style autodetection already existed for outgoing
provider requests (helpers.build_timeshift_url_format_a/_b) but was
never mirrored to the incoming route.

Split timeshift_proxy into a shared _timeshift_proxy_impl plus two thin
entry points (PATH-style timeshift_proxy, new QUERY-style
timeshift_proxy_query) so both incoming layouts are recognized.

Reported against the predecessor plugin as dispatcharr_timeshift#10;
reproduced identically against dispatcharr:dev (confirmed via nginx
access log: a QUERY-style request returned 200 with a response body
exactly matching the size of frontend/dist/index.html).
2026-07-13 22:13:42 +02:00
SergeantPanda
ea9eaf4d0c fix(timeshift): enhance session handling and playback logic
Some checks failed
Backend Tests / Plan test groups (push) Has been cancelled
CI Pipeline / prepare (push) Has been cancelled
Build and Push Multi-Arch Docker Image / build-and-push (push) Has been cancelled
Frontend Tests / test (push) Has been cancelled
Backend Tests / (push) Has been cancelled
CI Pipeline / docker (amd64, ubuntu-24.04) (push) Has been cancelled
CI Pipeline / docker (arm64, ubuntu-24.04-arm) (push) Has been cancelled
CI Pipeline / create-manifest (push) Has been cancelled
Updated the session handling mechanism to improve user experience during reconnects. The first request without a `session_id` now receives a `301` redirect with a minted `session_id`, while reconnects that omit `session_id` but match an existing pool entry are served immediately without a redirect. Additionally, refined playback logic for plain GET requests to restart from byte 0, aligning with provider behavior. Updated tests to reflect these changes and ensure proper session reuse and playback anchoring.
2026-07-12 21:09:40 +00:00
SergeantPanda
58d60bf733 chore(changelog): update CHANGELOG.md to reflect recent enhancements in unit tests and Backup Manager functionality
- Added entry for extended frontend unit tests covering plugin, backup, and settings components.
- Updated Backup Manager to ensure the "Created" column refreshes when date/time format preferences change.
2026-07-12 14:35:05 +00:00
SergeantPanda
d56680cad8
Merge pull request #1424 from nick4810:tests/frontend-unit-tests
Tests/frontend unit tests
2026-07-12 09:27:26 -05:00
SergeantPanda
2f60dc91ae fix(proxy): enhance stream switching logic and error handling
Some checks failed
Backend Tests / Plan test groups (push) Has been cancelled
CI Pipeline / prepare (push) Has been cancelled
Build and Push Multi-Arch Docker Image / build-and-push (push) Has been cancelled
Frontend Tests / test (push) Has been cancelled
Backend Tests / (push) Has been cancelled
CI Pipeline / docker (amd64, ubuntu-24.04) (push) Has been cancelled
CI Pipeline / docker (arm64, ubuntu-24.04-arm) (push) Has been cancelled
CI Pipeline / create-manifest (push) Has been cancelled
Updated the stream switching mechanism in the proxy server to ensure that the `stream_id` is persisted correctly and that the requesting worker waits for confirmation from the owning worker in multi-worker deployments. Improved error handling for stream switch failures, returning appropriate HTTP status codes (502/504) based on the confirmation status. Additionally, refined the metadata update process to handle cases where the requested URL is already in use, ensuring a successful response without unnecessary operations. This change addresses issues with stale metadata and enhances the robustness of the stream switching feature. (Fixes #1412)
2026-07-11 18:21:48 +00:00
SergeantPanda
c6e3f57e26 fix(timeshift): enhance session matching logic for fresh sessions
Updated the session matching logic to improve handling of fresh sessions. Introduced a new parameter, `fresh_session`, to skip adopting idle exact-media pools when a new session ID is provided. This prevents unnecessary reconnections to previously abandoned programme slots. Added tests to verify that fresh sessions correctly skip idle matches while retaining busy exact-media sessions and allowing channel hops. This change enhances the robustness of the timeshift feature and improves user experience during session transitions.
2026-07-11 17:32:07 +00:00
SergeantPanda
e887b9e1e5
Merge pull request #1432 from Dispatcharr/timeshift-api
feat(timeshift): add catch-up playback, stats, and admin APIs
2026-07-11 12:18:38 -05:00
SergeantPanda
223dff33ed feat(timeshift): add catch-up playback, stats, and admin APIs
Add end-to-end catch-up support for XC clients and native apps: provider
proxy with failover and per-viewer session pooling, REST session minting
for tokenless playback URLs, catch-up admin stats, combined connection
stats, and Stats UI with dedicated cards plus websocket updates.

Includes Redis namespace consolidation under timeshift:* (dropping legacy
timeshift_ id prefixes), dedicated catch-up stop by session_id, and
cleaner channel/client metadata split for stats keys.

Closes #133
2026-07-11 17:14:31 +00:00
SergeantPanda
3c2c42ce66 fix(epg): prevent crashes for channels with null channel numbers in XMLTV export
Some checks failed
Backend Tests / Plan test groups (push) Has been cancelled
CI Pipeline / prepare (push) Has been cancelled
Build and Push Multi-Arch Docker Image / build-and-push (push) Has been cancelled
Backend Tests / (push) Has been cancelled
CI Pipeline / docker (amd64, ubuntu-24.04) (push) Has been cancelled
CI Pipeline / docker (arm64, ubuntu-24.04-arm) (push) Has been cancelled
CI Pipeline / create-manifest (push) Has been cancelled
Updated the EPG generation logic to ensure that channels without a channel number do not cause crashes in the XMLTV export. Implemented a deferred assignment mechanism for channels with null numbers, allowing them to receive valid integers during processing. Added tests to verify that the XMLTV output remains stable and valid even when channels are unnumbered.
2026-07-10 02:42:50 +00:00
SergeantPanda
5c5b866bcf fix(vod): prevent group selection loss on empty category fetch
Implemented logic to abort the VOD refresh process when the provider returns no categories, preserving existing group selections. This change prevents unintended deletions of category relations and ensures that accounts retain their current settings during transient provider outages or API issues. Updated logging to reflect the abort condition for better traceability.
2026-07-09 21:26:36 +00:00
SergeantPanda
9ad198beb3 fix(live-streams): prevent crashes for channels with null channel numbers
Updated the handling of channels in the live streams setup to ensure that channels without a channel number do not cause crashes. Implemented logic to assign a valid number to these channels during processing. Added tests to verify that the system correctly assigns numbers to channels with null values, ensuring stability in the live streams feature.
2026-07-09 20:02:57 +00:00
SergeantPanda
c1c32686bd fix(timeshift): ensure database connections are closed before returning responses
Some checks are pending
Backend Tests / Plan test groups (push) Waiting to run
Backend Tests / (push) Blocked by required conditions
CI Pipeline / prepare (push) Waiting to run
CI Pipeline / docker (amd64, ubuntu-24.04) (push) Blocked by required conditions
CI Pipeline / docker (arm64, ubuntu-24.04-arm) (push) Blocked by required conditions
CI Pipeline / create-manifest (push) Blocked by required conditions
Build and Push Multi-Arch Docker Image / build-and-push (push) Waiting to run
Added a helper function to close old database connections in the `timeshift_proxy` view before returning any HTTP responses. This change prevents potential database connection leaks and ensures that connections are properly managed during the request lifecycle. Updated tests to verify that connections are closed appropriately in various response scenarios.

Also stores provider_tz_name with session pool in redis to avoid an extra database call.
2026-07-09 17:03:53 +00:00
SergeantPanda
6f2e99f47d
Merge pull request #1425 from cedric-marcoux:fix/timeshift-seek-position
fix(timeshift): serve the requested position when reusing a session (FF/RW snaps back to rewind anchor)
2026-07-09 11:51:57 -05:00
SergeantPanda
27deef9ad6 feat(epg): Improve performance during cold rebuilds by yielding to gevent hub
Enhanced the `/output/epg` cold rebuild process to prevent freezing of the gevent uWSGI worker. The `_stream_build` function now yields control to the gevent hub after processing each cached chunk, allowing other requests to be handled concurrently. This change improves responsiveness during CPU-bound XMLTV generation. Additionally, introduced a new utility function, `_cooperative_yield`, to facilitate yielding in CPU-bound loops.
2026-07-09 15:32:51 +00:00
Nick Sandstrom
3c658a6fc6 Fixed mock path 2026-07-09 01:44:14 -07:00
Cédric Marcoux
35dce2b7f8 fix(timeshift): drop stale byte state and harden descriptor update on seek
Self-review follow-ups on the position fix:

- When the position actually moves, drop content_length/serving_range from
  the pool entry — they describe the PREVIOUS position's file, and keeping
  them would feed the near-EOF/displacement heuristics another programme's
  size (a metadata probe near the new file's EOF could displace live
  playback, or a genuine scrub could be misjudged as a probe). The next
  successful open repopulates both.
- Guard the update under the pool lock and skip it when the entry has
  vanished (Redis restart/eviction) — a bare HSET would resurrect a
  partial, TTL-less hash that answers every later request for that
  session_id with 503.
- Align the reuse-path timezone lookup with the fresh path
  (is_active=True on the default-profile filter).

Three more tests: byte state dropped on move / kept on same-position
update, vanished entry never resurrected, tz fallback to the reserved
profile when no active default exists.
2026-07-09 08:40:31 +02:00
Cédric Marcoux
7655060149 fix(timeshift): serve the requested position when reusing a session
Clients (TiviMate) keep the ?session_id= query when they rebuild the seek
URL with a new start timestamp, so every timestamp-jump FF/RW landed on
the reused session's STORED provider_timestamp — playback snapped back to
the position the session was created for (the rewind anchor), 100%
reproducible: two requests with different start values on one session
returned byte-identical streams.

The reuse path now always recomputes the provider timestamp from the
REQUESTED one (the provider zone is a property of the account — read from
the default profile's server_info, same as the fresh path) and moves the
pool descriptor (media_id + provider_timestamp) to the position actually
served, so fingerprint matching and same-channel displacement keep
comparing against reality. Slot continuity is unchanged.

Regression tests: reused session serves the requested timestamp (unit),
descriptor follows the seek (unit), and an end-to-end timestamp-jump with
the same session_id reaches the new position through timeshift_proxy.
2026-07-09 07:42:19 +02:00
Nick Sandstrom
65fcaa885e Syntax formatting changes 2026-07-08 15:03:03 -07:00
Nick Sandstrom
b228d1cab1 Added tests for components 2026-07-08 15:00:35 -07:00
Nick Sandstrom
71f1c7d5e5 Slight component refactoring 2026-07-08 15:00:35 -07:00
Nick Sandstrom
18f7e8584a Added tests for utils 2026-07-08 15:00:34 -07:00
Nick Sandstrom
92946057c6 Extracted utils 2026-07-08 15:00:34 -07:00
Nick Sandstrom
5849082650 Moved pluginUtils to utils folder 2026-07-08 15:00:34 -07:00
SergeantPanda
080a2bbd74 refactor: Update test label mapping to load test discovery dynamically
Some checks are pending
Backend Tests / Plan test groups (push) Waiting to run
Backend Tests / (push) Blocked by required conditions
CI Pipeline / prepare (push) Waiting to run
CI Pipeline / docker (amd64, ubuntu-24.04) (push) Blocked by required conditions
CI Pipeline / docker (arm64, ubuntu-24.04-arm) (push) Blocked by required conditions
CI Pipeline / create-manifest (push) Blocked by required conditions
Build and Push Multi-Arch Docker Image / build-and-push (push) Waiting to run
Revised the import mechanism for the test discovery module to load it dynamically by file path, avoiding eager loading of the `dispatcharr` package. This change ensures that the script can run in a bare Python environment before the application virtual environment is set up, improving compatibility with CI processes.
2026-07-08 13:16:21 +00:00
SergeantPanda
d93cb6265f refactor: Eagerly import celery_app to ensure proper configuration in production
Updated the import mechanism for celery_app to avoid issues with lazy-loading, ensuring the default Celery app is correctly configured in production environments. Removed the __getattr__ function for celery_app, simplifying the module structure.
2026-07-08 13:11:17 +00:00
SergeantPanda
44cf9c4b90
Merge pull request #1421 from MotWakorb/feature/xz-epg-support
Some checks are pending
Backend Tests / Plan test groups (push) Waiting to run
Backend Tests / (push) Blocked by required conditions
CI Pipeline / prepare (push) Waiting to run
CI Pipeline / docker (amd64, ubuntu-24.04) (push) Blocked by required conditions
CI Pipeline / docker (arm64, ubuntu-24.04-arm) (push) Blocked by required conditions
CI Pipeline / create-manifest (push) Blocked by required conditions
Build and Push Multi-Arch Docker Image / build-and-push (push) Waiting to run
Add native xz decompression support for EPG sources and uploaded M3U playlists
2026-07-07 20:26:18 -05:00
SergeantPanda
0aff1e1d9c changelog: Update for pr. 2026-07-08 01:24:29 +00:00
SergeantPanda
15d0b8f824 fix(tasks): update file type checks to include .xz extension for skipping logic
Modified the file type validation in scan_and_process_files() to recognize .xz files alongside .xml, .gz, and .zip. Updated logging messages to reflect the inclusion of .xz in the skipped file notifications.
2026-07-08 01:21:05 +00:00
Curt LeCaptain
c0d6e951a3 feat(m3u): add native xz decompression support for uploaded M3U playlists
Extends _open_m3u_text_source() and fetch_m3u_lines() to treat an
uploaded .xz playlist the same way as the existing .m3u.gz path: it is
streamed lazily via lzma.open() rather than loaded fully into memory
(unlike the .zip path, which must read archive members). Uses stdlib
lzma, no new dependency.

Companion to the EPG xz support added for Dispatcharr/Dispatcharr#1414 -
the M3U upload path has the same gzip/zip dispatch structure and would
otherwise hit the same gap for an xz-compressed playlist.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-07 19:48:44 -05:00
Curt LeCaptain
408c3c6bea feat(epg): add native xz decompression support for EPG sources
Adds .xz to the set of compressed formats detect_file_format(),
extract_compressed_file(), fetch_xmltv(), and EPGSource.get_cache_file()
recognize, mirroring the existing gzip handling. Uses stdlib lzma, no new
dependency. Detection works via LZMA magic bytes (fd 37 7a 58 5a 00),
the .xz extension (including compound extensions like .xml.xz), and the
application/x-xz mimetype fallback.

Widened the magic-byte read in get_cache_file() from 4 to 6 bytes since
the xz signature is 6 bytes (the previous 4-byte read was sufficient
only for the 2-byte gzip/zip signatures it originally supported). That
widening silently broke the bare-<tv> raw-XML detection: the
fixed-length slice comparison header[:5] == b'<tv>' can never match a
5-byte slice of a 6-byte header against a 4-byte literal. Replaced the
fixed-length slice comparisons with header.startswith(...), which is
correct regardless of how many bytes are read - this also fixes the
adjacent <?xml declaration check, which was silently dead before this
change (a 4-byte read could never equal the 5-byte b'<?xml' literal
either).

Fixes Dispatcharr/Dispatcharr#1414

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-07 19:48:44 -05:00
SergeantPanda
b8f657250d tests: Add CELERY_BROKER_URL and CELERY_RESULT_BACKEND environment variables to ci_bootstrap_backend.sh for improved Celery configuration. 2026-07-08 00:16:54 +00:00
SergeantPanda
8b22f1c63a refactor: Lazy load celery_app in __getattr__ to improve module attribute access and reduce initial import overhead. 2026-07-07 21:33:49 +00:00
SergeantPanda
b26916dc7c refactor: Replace custom test label iteration with centralized test discovery functions for improved maintainability and consistency in test suite management. 2026-07-07 21:30:20 +00:00
SergeantPanda
a347e50e48 test: Implement github workflow for backend tests.
Some checks failed
Backend Tests / Plan test groups (push) Has been cancelled
Build and Push Multi-Arch Docker Image / build-and-push (push) Has been cancelled
Backend Tests / (push) Has been cancelled
2026-07-07 21:14:14 +00:00
SergeantPanda
35ac9ea987 test: Refactor collision avoidance tests to use a fixed timestamp and improve suffix collision checks. Introduced helper function for clearer logic in assertions regarding MKV file naming conventions. 2026-07-07 21:08:45 +00:00
SergeantPanda
53e6d9874a test: Implement github workflow for backend tests. 2026-07-07 20:51:49 +00:00
SergeantPanda
c6dfce167b test: Add custom test runner for improved test execution in settings_test.py 2026-07-07 18:58:18 +00:00
SergeantPanda
af71dc929f tests: Move tests to under tests folder. 2026-07-07 18:51:35 +00:00
SergeantPanda
2a09ce49da Enhancement: Update changelog to reflect changes in ChannelPagination, ensuring pagination works correctly with only page_size provided. Refactor tests to improve reliability and consistency, including adjustments to database connection management in various test cases. Modify EPG name normalization tests for accuracy and clarity, and enhance program data serializer tests to utilize precomputed season and episode values. 2026-07-07 18:30:06 +00:00
SergeantPanda
cf421b7b39 Enhancement: Refine database connection management in event dispatching and logging functions by conditionally closing old connections based on gevent monkey patching status, improving resource handling during asynchronous operations. 2026-07-07 18:27:35 +00:00
SergeantPanda
ae58fdb980 Enhancement: Add release_connections parameter to PluginManager, allowing for conditional database connection closure during plugin discovery, improving resource management. 2026-07-07 18:27:15 +00:00
SergeantPanda
dff97d9067 Enhancement: Ensure proper DB connection management in Celery tasks by checking for worker context before releasing connections, preventing potential ORM errors. 2026-07-07 18:26:09 +00:00
SergeantPanda
810a7a93a1 Enhancement: Update plugin discovery logic to include release_connections parameter, improving resource management during event triggering. 2026-07-07 18:25:52 +00:00
SergeantPanda
553bfc135c Enhancement: Modify pagination logic in ChannelPagination to disable pagination when both page and page size parameters are absent, ensuring full queryset is returned in such cases. 2026-07-07 18:23:46 +00:00
SergeantPanda
7649bfc88a Enhancement: Prevent 500 errors in live proxy preview when joining active channels on non-owner workers by ensuring proper client registration and cleanup. Update changelog to reflect this fix.
Some checks failed
CI Pipeline / prepare (push) Has been cancelled
Build and Push Multi-Arch Docker Image / build-and-push (push) Has been cancelled
CI Pipeline / docker (amd64, ubuntu-24.04) (push) Has been cancelled
CI Pipeline / docker (arm64, ubuntu-24.04-arm) (push) Has been cancelled
CI Pipeline / create-manifest (push) Has been cancelled
2026-07-06 21:05:09 -05:00
SergeantPanda
a90872079b Enhancement: Update session management details and improve stream limit handling in timeshift proxy. Enhance tests for decisive account failover and session probe logic. 2026-07-06 19:49:19 -05:00
SergeantPanda
4f798e9a28 changelog: update to link issue. 2026-07-06 19:29:25 -05:00
SergeantPanda
4d4cc92bc9 Enhancement: Improve live proxy failover logic by resetting connection retries after a specified period of stability and ensuring backup streams are rotated in channel order. Update changelog to reflect these changes. 2026-07-06 19:11:32 -05:00
SergeantPanda
ebbc2a7b54 Enhancement: Enable GEVENT support in uWSGI configuration for improved concurrency handling. Update debugging environment variables accordingly. 2026-07-06 17:15:35 -05:00
SergeantPanda
96db4f92c7 Enhancement: Implement live proxy failover for VLC stream profile, ensuring proper handling of upstream URL failures. Update changelog to reflect changes in stream management and VLC profile parameters. 2026-07-06 16:45:56 -05:00
SergeantPanda
9c3ace6146 Enhancement: Introduce 'Only Catch-up' filter in Channels and Streams tables, allowing users to easily narrow down to catch-up entries. Update changelog to reflect this new feature.
Some checks failed
CI Pipeline / prepare (push) Has been cancelled
Build and Push Multi-Arch Docker Image / build-and-push (push) Has been cancelled
Frontend Tests / test (push) Has been cancelled
CI Pipeline / docker (amd64, ubuntu-24.04) (push) Has been cancelled
CI Pipeline / docker (arm64, ubuntu-24.04-arm) (push) Has been cancelled
CI Pipeline / create-manifest (push) Has been cancelled
2026-07-03 09:09:59 -05:00
SergeantPanda
4b0066f6e7 Enhancement: Add catch-up indicators to Channels and Streams tables, enhancing UI with new serializers for catch-up data. 2026-07-03 09:03:28 -05:00
SergeantPanda
54d97fd630 changelog: slight cleanup.
Some checks are pending
CI Pipeline / prepare (push) Waiting to run
CI Pipeline / docker (amd64, ubuntu-24.04) (push) Blocked by required conditions
CI Pipeline / docker (arm64, ubuntu-24.04-arm) (push) Blocked by required conditions
CI Pipeline / create-manifest (push) Blocked by required conditions
Build and Push Multi-Arch Docker Image / build-and-push (push) Waiting to run
Frontend Tests / test (push) Waiting to run
2026-07-02 14:52:30 -05:00
SergeantPanda
6fe67f4044 changelog: Update for pr 1403 2026-07-02 14:42:49 -05:00
SergeantPanda
9bd05580e7
Merge pull request #1403 from nick4810:fix/node25+-test-compatibility
Node25+ Test Compatibility
2026-07-02 14:40:34 -05:00
SergeantPanda
27fc812df9 Add comment explaining the Node 25 issue with localStorage. Also patch globalThis. 2026-07-02 14:39:26 -05:00
SergeantPanda
aa60275a2a
Merge pull request #1398 from francescodg89-crypto:main
Add VOD failover logic for M3U relations
2026-07-02 14:19:02 -05:00
SergeantPanda
259db1196d Update tests and changelog. 2026-07-02 14:03:29 -05:00
SergeantPanda
2dcae97d01 Merge branch 'dev' of https://github.com/Dispatcharr/Dispatcharr into pr/francescodg89-crypto/1398 2026-07-02 13:55:27 -05:00
SergeantPanda
3a81a34e4a fix(epg): Enhance EPG data refresh and parsing logic to prevent data loss and improve concurrency handling. Implement deferred processing for ongoing tasks, ensuring stability during refresh operations. Update changelog with significant fixes and improvements. 2026-07-02 13:34:53 -05:00
SergeantPanda
de3a0a0713 refactor(celery): Replace worker_ready signal with worker_process_init for plugin discovery and close inherited DB connections. Use djangos PostgrSQL backend for celery tasks. 2026-07-02 13:33:04 -05:00
francescodg89-crypto
72c0a4e69a
Refactor VOD failover tests and enhance coverage
Refactor tests for VOD provider failover logic to improve clarity and remove unnecessary components. Introduce new tests for order candidates and ensure no database access occurs during processing.
2026-07-02 00:17:13 +02:00
francescodg89-crypto
c824f7a17f
Refactor content relation handling for efficiency
Refactor content relation retrieval to use a single DB query for active relations, improving efficiency. Update return values to include candidates for failover handling.
2026-07-02 00:16:25 +02:00
SergeantPanda
12f094cbc0 feat(epg): Implement channel parsing progress tracking and update progress reporting logic in parse_channels_only function
Some checks are pending
CI Pipeline / prepare (push) Waiting to run
CI Pipeline / docker (amd64, ubuntu-24.04) (push) Blocked by required conditions
CI Pipeline / docker (arm64, ubuntu-24.04-arm) (push) Blocked by required conditions
CI Pipeline / create-manifest (push) Blocked by required conditions
Build and Push Multi-Arch Docker Image / build-and-push (push) Waiting to run
2026-07-01 17:07:47 -05:00
Nick Sandstrom
e8eec56783 Added localStorage polyfill 2026-06-30 16:27:50 -07:00
SergeantPanda
692765c199
Merge pull request #1393 from nick4810:tests/frontend-unit-tests
Some checks failed
CI Pipeline / prepare (push) Waiting to run
CI Pipeline / docker (amd64, ubuntu-24.04) (push) Blocked by required conditions
CI Pipeline / docker (arm64, ubuntu-24.04-arm) (push) Blocked by required conditions
CI Pipeline / create-manifest (push) Blocked by required conditions
Build and Push Multi-Arch Docker Image / build-and-push (push) Waiting to run
Frontend Tests / test (push) Has been cancelled
Tests/frontend unit tests
2026-06-30 16:57:24 -05:00
SergeantPanda
15fbf3d3d6 changelog: Update for pr. 2026-06-30 16:56:51 -05:00
SergeantPanda
dafdb1a19b refactor(tests): Improve formatting and structure in ChannelsTable tests; streamline requeryChannels function in ChannelUtils 2026-06-30 16:49:30 -05:00
SergeantPanda
14ab2a0317 Enhance VodConnectionCard to use human-readable duration format and update tests accordingly. Refactor formatDuration function to support 'human' precision and add related unit tests for various duration scenarios. 2026-06-30 16:48:36 -05:00
SergeantPanda
570adc2fe0 Merge branch 'dev' of https://github.com/Dispatcharr/Dispatcharr into pr/nick4810/1393 2026-06-30 12:55:20 -05:00
SergeantPanda
e6630d52c3
Merge pull request #1378 from Jacob-Lasky:fix/xc-empty-fetch-channel-wipe
fix(m3u): abort XC refresh on empty provider fetch to prevent channel wipe
2026-06-30 12:23:50 -05:00
SergeantPanda
617eeae42a docs: Update changelog and reduce code comment length. 2026-06-30 12:22:55 -05:00
SergeantPanda
49a40ba7ed Merge branch 'dev' of https://github.com/Dispatcharr/Dispatcharr into pr/Jacob-Lasky/1378 2026-06-30 12:08:17 -05:00
SergeantPanda
4dc8e27cfd
Merge pull request #1367 from CodeBormen/fix/1332-rename-preview
fix(m3u): align auto-sync rename preview with the live rename
2026-06-30 11:58:08 -05:00
SergeantPanda
eac3849486 changelog: add thanks to submitter. 2026-06-30 11:57:38 -05:00
SergeantPanda
827e18a3fd Merge remote-tracking branch 'origin/dev' into pr/CodeBormen/1367 2026-06-30 11:54:31 -05:00
SergeantPanda
a71667436e Merge branch 'main' of https://github.com/Dispatcharr/Dispatcharr into dev 2026-06-30 11:37:19 -05:00
GitHub Actions
22b957aee4 Release v0.27.2
Some checks failed
Build and Push Multi-Arch Docker Image / build-and-push (push) Has been cancelled
2026-06-30 16:28:27 +00:00
SergeantPanda
68ab03d95f
Merge pull request #1399 from Dispatcharr/refactor-channel-initialization
Some checks failed
Build and Push Multi-Arch Docker Image / build-and-push (push) Waiting to run
Frontend Tests / test (push) Has been cancelled
Refactor channel initialization
2026-06-30 10:47:15 -05:00
SergeantPanda
8d5c13bdc1 tests: fix proxy setting test
Some checks are pending
CI Pipeline / prepare (push) Waiting to run
CI Pipeline / docker (amd64, ubuntu-24.04) (push) Blocked by required conditions
CI Pipeline / docker (arm64, ubuntu-24.04-arm) (push) Blocked by required conditions
CI Pipeline / create-manifest (push) Blocked by required conditions
Build and Push Multi-Arch Docker Image / build-and-push (push) Waiting to run
Frontend Tests / test (push) Waiting to run
2026-06-30 10:35:03 -05:00
SergeantPanda
97515c4d05 tests: fix proxy setting test 2026-06-30 10:34:40 -05:00
SergeantPanda
968862cad3 refactor(proxysettings): Enhance proxy settings UI by categorizing advanced options and improving documentation. The channel_client_wait_period and channel_init_grace_period are now marked as advanced settings, and the UI has been updated to allow users to expand and view these options. Additionally, the changelog has been updated to reflect these changes and clarify the behavior of grace periods. 2026-06-30 10:08:02 -05:00
SergeantPanda
ac6d43af8c refactor(proxysettings): Enhance proxy settings UI by categorizing advanced options and improving documentation. The channel_client_wait_period and channel_init_grace_period are now marked as advanced settings, and the UI has been updated to allow users to expand and view these options. Additionally, the changelog has been updated to reflect these changes and clarify the behavior of grace periods. 2026-06-30 10:00:22 -05:00
francescodg89-crypto
6ddbf2ccd4
Rename test vod failover.py to test_vod_failover.py 2026-06-30 13:34:20 +02:00
francescodg89-crypto
5c43f14c19
Add files via upload 2026-06-30 13:32:40 +02:00
francescodg89-crypto
502ca5bfc8
Add VOD failover logic for M3U relations
Implemented VOD failover logic to iterate over all active M3U relations, selecting the first account with spare capacity instead of relying on a single highest-priority relation. This change enhances reliability by allowing for provider failover in case of account saturation.
2026-06-29 13:15:52 +02:00
SergeantPanda
5c5a79962d refactor(proxy): Add new client connect grace period setting and update channel initialization grace period defaults. The channel_client_wait_period is introduced to manage channel lifetimes before client connections, while the channel_init_grace_period default is increased to improve failover handling.
Some checks failed
CI Pipeline / prepare (push) Has been cancelled
Build and Push Multi-Arch Docker Image / build-and-push (push) Has been cancelled
Frontend Tests / test (push) Has been cancelled
CI Pipeline / docker (amd64, ubuntu-24.04) (push) Has been cancelled
CI Pipeline / docker (arm64, ubuntu-24.04-arm) (push) Has been cancelled
CI Pipeline / create-manifest (push) Has been cancelled
2026-06-28 11:34:44 -05:00
SergeantPanda
391c3412d2 refactor(proxy): Add new client connect grace period setting and update channel initialization grace period defaults. The channel_client_wait_period is introduced to manage channel lifetimes before client connections, while the channel_init_grace_period default is increased to improve failover handling. 2026-06-28 11:31:21 -05:00
SergeantPanda
fc4ced9043 feat(m3u): Add stream count summary and parsing result handling to M3U refresh process. Introduce helper functions for better readability and maintainability. Update frontend notification to display detailed stream processing outcomes, including counts for created, updated, stale, and removed streams.
Some checks are pending
CI Pipeline / prepare (push) Waiting to run
CI Pipeline / docker (amd64, ubuntu-24.04) (push) Blocked by required conditions
CI Pipeline / docker (arm64, ubuntu-24.04-arm) (push) Blocked by required conditions
CI Pipeline / create-manifest (push) Blocked by required conditions
Build and Push Multi-Arch Docker Image / build-and-push (push) Waiting to run
Frontend Tests / test (push) Waiting to run
2026-06-28 09:54:09 -05:00
None
40ec6b6339 fix(channels): align the auto-sync rename preview and live rename on the regex module
The Find and Replace preview did not correctly reflect the rename the sync performs, and the rename engine differed from the preview engine.

- The preview rendered the literal $1 instead of the substituted capture group, because the replacement was passed straight into the regex engine, which honors \1, not the JS-style $1 the field accepts.
- The preview compiled patterns with the regex module while the live rename used stdlib re, so patterns valid in regex but not re (for example ^*) previewed a transform the sync silently skipped.
- A rename that expanded a name past the Channel.name column length aborted the whole bulk_create sync, while the preview showed the full name.
- Convert JS-style $1 backreferences to \1 via a shared helper used by both the preview and the live rename.
- Switch the live rename from re.sub to regex.sub, matching the preview engine and the sync's own include/exclude filters, with a timeout to bound catastrophic backtracking on user patterns.
- Cap the rename result at the Channel.name column length in both paths, so an over-length result cannot abort the sync.
- Add unit, integration, and differential parity tests covering the above.
2026-06-27 17:45:42 -05:00
SergeantPanda
b4aa11c921
Merge pull request #1242 from cedric-marcoux/feat/timeshift-native-v2
Some checks are pending
CI Pipeline / prepare (push) Waiting to run
CI Pipeline / docker (amd64, ubuntu-24.04) (push) Blocked by required conditions
CI Pipeline / docker (arm64, ubuntu-24.04-arm) (push) Blocked by required conditions
CI Pipeline / create-manifest (push) Blocked by required conditions
Build and Push Multi-Arch Docker Image / build-and-push (push) Waiting to run
Frontend Tests / test (push) Waiting to run
feat: built-in XC catch-up (timeshift) support
2026-06-27 16:52:10 -05:00
SergeantPanda
269acc2dda refactor(timeshift): Replace random session ID generation with secure secrets for timeshift sessions. Introduce helper functions for session management and enhance session validation to prevent foreign session reuse. Update tests to cover new session handling logic and ensure proper user ownership checks. 2026-06-27 16:50:44 -05:00
SergeantPanda
6d17663f84 refactor(tasks): Optimize XC stream collection by avoiding redundant work during catalog filtering. Introduce a shared URL prefix for stream URLs and enhance memory management by releasing per-group caches after processing. Update changelog to reflect these improvements. 2026-06-27 13:33:26 -05:00
SergeantPanda
6456d11795 refactor(tasks): Enhance M3U refresh process by introducing a line-by-line parsing method for on-disk files, improving memory efficiency. Update error handling to return None for failures, and streamline the fetching logic for better clarity and performance. 2026-06-27 12:48:02 -05:00
SergeantPanda
f01c6563c1 refactor(tasks): Optimize M3U stream processing by pre-compiling filters for batch workers, reducing redundant regex evaluations. Update related functions to improve performance and memory management during account refreshes. 2026-06-27 11:52:00 -05:00
SergeantPanda
96a39ce5d2 refactor(tasks): Improve stream processing logic by adding bulk update functionality for unchanged streams. Enhance memory management and error handling in database queries, and update cleanup functions to optimize memory usage. 2026-06-27 11:13:17 -05:00
SergeantPanda
ec8594cd0a refactor(tasks): Add is_catchup and catchup_days fields to Stream processing logic. Update existing stream queries and conditions to handle new catch-up properties, ensuring accurate updates and memory management for bulk operations. 2026-06-27 10:11:49 -05:00
SergeantPanda
931f4dc50f refactor(tasks): Enhance rollup_channel_catchup_fields function to limit updates to channels linked to the specified account. Update SQL queries for clarity and efficiency, and improve docstrings for better understanding of the self-heal logic related to catch-up flags. 2026-06-27 09:28:24 -05:00
Nick Sandstrom
fe44af7d38 Updated to use datetime util 2026-06-27 01:55:28 -07:00
Nick Sandstrom
4eb4b9c221 Added tests for components 2026-06-27 01:55:27 -07:00
Nick Sandstrom
68fcbe3e2d Extracted shared component 2026-06-27 01:55:27 -07:00
Nick Sandstrom
28cff8e87b Refactored components 2026-06-27 01:55:27 -07:00
Nick Sandstrom
1a46ca4be4 Added tests for utils 2026-06-27 01:55:10 -07:00
Nick Sandstrom
7a4db31460 Moved functionality to shared util 2026-06-27 01:55:09 -07:00
Nick Sandstrom
f1dde066c0 Extracted utils 2026-06-27 01:55:09 -07:00
SergeantPanda
88de2d9bf0 changelog: update for accuracy 2026-06-26 17:51:06 -05:00
SergeantPanda
5a552cd565 refactor(epg): Update xmltv_prev_days_override setting to EPG settings and adjust related logic. Refactor CoreSettings to include a dedicated method for retrieving the override value. Update tests and frontend components to reflect the new structure and ensure consistent behavior across settings management. 2026-06-26 17:48:06 -05:00
SergeantPanda
ca3d0eb9e1 feat(timeshift): Implement session-based media ID handling and improve stream limit checks for timeshift proxy. Enhance session management by allowing distinct client/session identification for timeshift requests, ensuring better resource allocation and user experience. Update tests to validate new session handling logic. 2026-06-26 17:36:23 -05:00
SergeantPanda
7289e60aa4 changelog: move to unreleased. 2026-06-26 13:34:55 -05:00
SergeantPanda
8e0a7b44f5 feat(timestamps): Introduce comprehensive timestamp normalization and parsing for catch-up functionality. Support various input formats including colon-dash, underscore, and Unix epoch. Enhance related tests to ensure robust handling of timestamp shapes in timeshift operations. 2026-06-26 13:33:50 -05:00
SergeantPanda
9e941c7011 refactor(proxy): Consolidate MPEG-TS sync detection logic into utils.py and streamline comments in timeshift views. Update network access checks for consistency across XC API endpoints. Enhance clarity in timeshift proxy documentation and tests. 2026-06-26 12:53:22 -05:00
SergeantPanda
fa39a594be feat(epg): Enhance generate_epg function to support xc_catchup_prev_days parameter for improved catch-up day resolution. Update related views and tests to ensure consistent behavior across EPG generation and catch-up functionality. 2026-06-26 12:53:02 -05:00
SergeantPanda
a8f48a1cbb refactor(tasks): Remove outdated comments and simplify the rollup_channel_catchup_fields function. Enhance clarity by focusing on active account streams and self-healing logic for catch-up flags. 2026-06-26 12:47:38 -05:00
SergeantPanda
f2dfe6deb6 feat(channels): Add caching and resolution logic for provider archive days in XC catch-up streams. Implemented compute_provider_archive_days_capped and resolve_xc_epg_prev_days functions to enhance performance and flexibility in determining catch-up days. Updated docstrings for clarity. 2026-06-26 12:45:13 -05:00
SergeantPanda
8d990ebf93 refactor(signals): Simplify catch-up field update logic in ChannelStream signal, enhancing clarity and performance by filtering active streams directly. Updated docstring for improved understanding of the functionality. 2026-06-26 12:40:57 -05:00
SergeantPanda
e596a508c7 refactor(models): Simplify comments for catch-up fields in Stream and Channel models, clarifying their population process. Update migration docstring for consistency. Remove outdated comments in ProxyServer and URLs related to timeshift handling. 2026-06-26 12:39:54 -05:00
SergeantPanda
293745d568 Merge branch 'dev' of https://github.com/Dispatcharr/Dispatcharr into pr/cedric-marcoux/1242 2026-06-25 21:38:35 -05:00
GitHub Actions
136fb81d41 Release v0.27.1
Some checks failed
CI Pipeline / prepare (push) Has been cancelled
Build and Push Multi-Arch Docker Image / build-and-push (push) Has been cancelled
Frontend Tests / test (push) Has been cancelled
CI Pipeline / docker (amd64, ubuntu-24.04) (push) Has been cancelled
CI Pipeline / docker (arm64, ubuntu-24.04-arm) (push) Has been cancelled
CI Pipeline / create-manifest (push) Has been cancelled
2026-06-25 23:18:47 +00:00
SergeantPanda
633b12e2d3
Merge pull request #1388 from Dispatcharr/dev
Dispatcharr - v0.27.1
2026-06-25 18:17:52 -05:00
SergeantPanda
fd93c0dc3d fix(channel management): Implement channel initialization grace period handling to ensure proper state transitions during live stream startup. Channels now honor the configured grace period for buffer filling, promoting to active state when conditions are met. Updated related tests and documentation for clarity. (Fixes #1380)
Some checks failed
CI Pipeline / prepare (push) Waiting to run
CI Pipeline / docker (amd64, ubuntu-24.04) (push) Blocked by required conditions
CI Pipeline / docker (arm64, ubuntu-24.04-arm) (push) Blocked by required conditions
CI Pipeline / create-manifest (push) Blocked by required conditions
Build and Push Multi-Arch Docker Image / build-and-push (push) Waiting to run
Frontend Tests / test (push) Has been cancelled
2026-06-25 14:24:30 -05:00
SergeantPanda
8f3dc83543 fix(api): Correct OpenAPI schema paths for nested M3U profiles and filters by removing escaped slashes, ensuring proper serialization and compatibility with client generators. Updated related API URL patterns for consistency. (Fixes #1384) 2026-06-25 13:37:29 -05:00
SergeantPanda
c44cac2e9a fix(recording playback): Complete authentication handling for DVR recordings by allowing JWT tokens via query parameters for both native video and HLS segments. Updated API views to support token propagation in redirects and playlist rewrites. Enhanced tests to validate new authentication behavior across endpoints. 2026-06-25 11:27:57 -05:00
SergeantPanda
78d1f70eda documentation: Add comment to disable parallel gather for DISTINCT ON query to prevent OOM issues in large VOD libraries. 2026-06-25 11:06:35 -05:00
SergeantPanda
b7ee17ffba fix(tasks): Remove unnecessary raise statement in refresh_single_m3u_account function to improve error handling and maintain task flow. 2026-06-25 11:06:07 -05:00
SergeantPanda
562393b77e fix(recording playback): Enhance completed DVR recording playback by allowing JWT token via query parameter for native <video src> support. Updated authentication handling in API views and modified FloatingVideo component to append token dynamically. Updated tests to reflect new API URL structure.
Some checks are pending
CI Pipeline / prepare (push) Waiting to run
CI Pipeline / docker (amd64, ubuntu-24.04) (push) Blocked by required conditions
CI Pipeline / docker (arm64, ubuntu-24.04-arm) (push) Blocked by required conditions
CI Pipeline / create-manifest (push) Blocked by required conditions
Build and Push Multi-Arch Docker Image / build-and-push (push) Waiting to run
Frontend Tests / test (push) Waiting to run
2026-06-24 17:32:48 -05:00
SergeantPanda
c5e5016728 fix(dvr): Fix in-progress DVR playback to prevent jumping to live edge and update FloatingVideo component for improved error handling. Added tests to verify HLS configuration behavior. (Fixes #1329) 2026-06-24 17:02:31 -05:00
SergeantPanda
e2ceef5217 changelog: refactor xmltv changes and link issue. 2026-06-24 16:46:37 -05:00
SergeantPanda
6b6eb11cc0 chore(dependencies): update Vite, esbuild and js-yaml to latest versions in package.json 2026-06-24 16:31:15 -05:00
SergeantPanda
e483fc203b chore(dependencies): update Django, requests, gevent, torch, sentence-transformers, and lxml to latest versions
Some checks failed
Base Image Build / prepare (push) Has been cancelled
Build and Push Multi-Arch Docker Image / build-and-push (push) Has been cancelled
Base Image Build / docker (amd64, ubuntu-24.04) (push) Has been cancelled
Base Image Build / docker (arm64, ubuntu-24.04-arm) (push) Has been cancelled
Base Image Build / create-manifest (push) Has been cancelled
2026-06-24 16:19:44 -05:00
SergeantPanda
107246ff0b changelog: Update for dependency updates.
Some checks failed
CI Pipeline / prepare (push) Waiting to run
CI Pipeline / docker (amd64, ubuntu-24.04) (push) Blocked by required conditions
CI Pipeline / docker (arm64, ubuntu-24.04-arm) (push) Blocked by required conditions
CI Pipeline / create-manifest (push) Blocked by required conditions
Build and Push Multi-Arch Docker Image / build-and-push (push) Waiting to run
Base Image Build / prepare (push) Has been cancelled
Base Image Build / docker (amd64, ubuntu-24.04) (push) Has been cancelled
Base Image Build / docker (arm64, ubuntu-24.04-arm) (push) Has been cancelled
Base Image Build / create-manifest (push) Has been cancelled
2026-06-24 16:17:38 -05:00
SergeantPanda
971065b8a8 chore(dependencies): update Django, requests, gevent, torch, sentence-transformers, and lxml to latest versions 2026-06-24 16:14:00 -05:00
SergeantPanda
578c50ea96 changelog: corrected
Some checks are pending
Build and Push Multi-Arch Docker Image / build-and-push (push) Waiting to run
2026-06-24 08:01:13 -05:00
SergeantPanda
1b7840b715 changelog: Update for pr 1331 2026-06-24 07:52:13 -05:00
SergeantPanda
6b4453e9ca fix: Readd prev_days logic after dev merge. 2026-06-23 21:51:24 -05:00
SergeantPanda
3b93f0a08d Merge branch 'dev' into pr/cedric-marcoux/1242 2026-06-23 21:45:57 -05:00
SergeantPanda
ab8fd29434 Merge branch 'dev' of https://github.com/Dispatcharr/Dispatcharr into dev
Some checks are pending
CI Pipeline / prepare (push) Waiting to run
CI Pipeline / docker (amd64, ubuntu-24.04) (push) Blocked by required conditions
CI Pipeline / docker (arm64, ubuntu-24.04-arm) (push) Blocked by required conditions
CI Pipeline / create-manifest (push) Blocked by required conditions
Build and Push Multi-Arch Docker Image / build-and-push (push) Waiting to run
Frontend Tests / test (push) Waiting to run
2026-06-23 20:54:18 -05:00
SergeantPanda
53fa1e42a0 enhancement(tests): introduce isolated backend test settings and improve test documentation
- Added `dispatcharr.settings_test` for isolated testing, automatically creating an empty PostgreSQL test database and ensuring transaction isolation during tests.
- Updated `manage.py` to switch to the test settings when running tests.
- Enhanced the CONTRIBUTING.md file with detailed instructions on running the backend test suite and handling Celery tasks in tests.
- Refactored test cases to use static methods for `worker_id` in `test_process_label.py` and adjusted assertions in `test_user_preferences.py` for better clarity and correctness.
2026-06-23 20:54:12 -05:00
SergeantPanda
46d36fbbc6
Merge pull request #1368 from CodeBormen/fix/1331-range-conflict-group-override
fix(channels): account for group_override in the auto-sync range-conflict check
2026-06-23 20:32:43 -05:00
SergeantPanda
7b6adf62d7 enhancement(vod): optimize VOD stream retrieval and performance
- Improved `xc_get_vod_streams` and `xc_get_series` functions to utilize a new helper method for fetching distinct relations based on account priority, significantly reducing memory usage and response times for large libraries.
- Updated the handling of VOD streams to avoid unnecessary ORM instantiation, enhancing performance for endpoints dealing with extensive movie and series data.
- Added comprehensive tests to ensure the correctness of the new logic and verify that the highest priority relations are correctly selected.
2026-06-23 20:15:29 -05:00
SergeantPanda
d2e764316d
Merge pull request #1383 from Dispatcharr/epg-output-refactor
Some checks are pending
CI Pipeline / prepare (push) Waiting to run
CI Pipeline / docker (amd64, ubuntu-24.04) (push) Blocked by required conditions
CI Pipeline / docker (arm64, ubuntu-24.04-arm) (push) Blocked by required conditions
CI Pipeline / create-manifest (push) Blocked by required conditions
Build and Push Multi-Arch Docker Image / build-and-push (push) Waiting to run
Epg output refactor
2026-06-23 15:44:37 -05:00
SergeantPanda
e984b234e3 enhancement(epg): add export lookback and cutoff parameters to EPG generation
- Updated the `generate_dummy_programs` function to include `export_lookback` and `export_cutoff` parameters, allowing for more precise control over the EPG data generation window.
- Added a new test case to verify that future events are correctly represented in the grid window, ensuring upcoming fillers are displayed as expected.
2026-06-23 15:27:15 -05:00
SergeantPanda
0dc8898e8b refactor(epg): separate programme index into dedicated table for optimized data handling
- Moved the `programme_index` from the `EPGSource` model to a new `EPGSourceIndex` table, ensuring that the large JSON blob is only loaded when explicitly accessed, thus improving query performance and memory efficiency.
- Updated related queries and API views to utilize the new structure, including adjustments to EPG generation and import logic to prevent unnecessary data loading.
- Enhanced memory management in the EPG grid endpoint to reduce worker RSS during response handling.
2026-06-23 15:10:27 -05:00
SergeantPanda
107a891359 enhancement(epg): optimize XMLTV escaping and channel prefetch for improved performance 2026-06-23 13:20:17 -05:00
SergeantPanda
bccee9ebc1 refactor(epg): extract EPG generation logic into dedicated module
- Moved all XMLTV output logic from `apps/output/views.py` to `apps/output/epg.py`, streamlining the codebase and maintaining the existing HTTP endpoint functionality.
- Updated related tests and references to ensure proper integration with the new module structure.
2026-06-23 11:50:52 -05:00
SergeantPanda
3868f02c45 enhancement(epg): optimize EPG export performance and database interactions
- Streamlined `generate_epg()` to incrementally stream EPG data without loading the entire guide, improving memory efficiency.
- Reduced database load by deferring the fetching of large `programme_index` blobs, enhancing response times for EPG generation.
- Introduced a composite index on `(epg_id, id)` in `ProgramData` to optimize query performance during EPG exports.
- Updated tests to ensure proper functionality and performance of new features.
2026-06-23 10:57:06 -05:00
recurst
820c61caa0
Allow underscores in non-FQDN hostnames
This PR updates the non_fqdn_pattern regex to allow underscores in non-FQDN hostnames. Such hostnames are commonly used in Docker networks, but Dispatcharr currently rejects them due to the overly strict regex.
2026-06-23 14:04:01 +02:00
Jacob Lasky
808fadfef2 fix(m3u): abort XC refresh on empty provider fetch to prevent channel wipe
When an Xtream Codes provider returns no live streams on a routine refresh
(a transient upstream failure, a fetch exception, or no enabled category
matching), collect_xc_streams() returns an empty list. The refresh then fell
through to stale-marking and sync_auto_channels, which -- with nothing seen
this refresh -- deletes the account's entire auto-created channel lineup via
its per-group "no streams remaining" branch.

Guard the XC branch of _refresh_single_m3u_account_impl: on an empty result,
set the account to ERROR and return before stale-marking and auto-sync,
mirroring the standard-path empty/failed-download guards already in the
function. A transient empty fetch can no longer destroy channels.

Adds apps/m3u/tests/test_xc_empty_fetch_guard.py covering both the abort
(sync not called, channels preserved, streams not marked stale, status ERROR)
and the healthy path (non-empty fetch still runs sync).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 12:02:01 -04:00
None
a29704af55 fix(channels): account for group_override in the auto-sync range-conflict check
The range-conflict warning classified each channel in the configured range as either this config's own auto-sync output or a real conflict, comparing each occupant against the source group. When a group sets a group_override, auto-sync creates its channels in the override target group, so the config's own channels failed that comparison and were misclassified as a conflict.

- Add effectiveSyncGroupId to resolve the group the sync's channels actually land in (the group_override target when set, otherwise the source group).
- Compare occupants against that effective target, so the config's own output is recognized while genuine conflicts (manual channels, channels from another account, channels in a different group, user-pinned numbers) still warn.
- Add Vitest coverage for the helper, the override case, and an over-suppression guard, plus a backend test asserting the numbers-in-range endpoint reports the override target group.
2026-06-19 23:40:10 -05:00
SergeantPanda
7139c88c35 enhancement(epg): improve custom dummy EPG generation performance
Some checks failed
CI Pipeline / prepare (push) Has been cancelled
Build and Push Multi-Arch Docker Image / build-and-push (push) Has been cancelled
CI Pipeline / docker (amd64, ubuntu-24.04) (push) Has been cancelled
CI Pipeline / docker (arm64, ubuntu-24.04-arm) (push) Has been cancelled
CI Pipeline / create-manifest (push) Has been cancelled
- Optimized `generate_epg()` to reduce database queries by prefetching ordered channel streams and implementing a new method for custom dummy epg name matching.
- Enhanced handling of dummy EPG sources to minimize SQL round-trips, improving performance for large guides with numerous custom-dummy channels.
2026-06-18 17:12:43 -05:00
SergeantPanda
04394b7eac fix(epg): enhance DB connection management and error handling during EPG refreshes
- Implemented robust DB connection management in `refresh_epg_data` to handle transient errors, ensuring reliable status updates for EPG sources.
- Added retry logic for database queries and improved error handling to prevent task failures from unhandled exceptions.
- Updated EPG source status handling to ensure accurate reporting of errors and terminal states during refresh operations.
2026-06-18 16:39:52 -05:00
SergeantPanda
c2ac08fdfd enhancement(epg): Performance improvements and API enhancements for EPG refreshes.
- Updated EPG import logic to reject dummy sources without loading the large `programme_index`, optimizing memory usage during API calls.
2026-06-18 15:53:55 -05:00
SergeantPanda
460677aeea refactor(channels): optimize stream retrieval in ChannelSerializer and add tests for query efficiency
- Updated `get_streams` method in `ChannelSerializer` to use prefetched `channelstream_set`, improving performance by reducing database queries.
- Added `ChannelListIncludeStreamsQueryTests` to ensure that the number of queries remains stable as channels grow, verifying efficient stream inclusion in API responses.
2026-06-18 15:24:23 -05:00
SergeantPanda
46695588f7 fix(m3u): enhance custom properties handling and DB connection management
- Implemented `ensure_custom_properties_dict()` to normalize custom properties across various models and serializers, addressing issues with legacy JSON-encoded strings.
- Updated M3U account and channel group models to ensure custom properties are consistently stored as dictionaries during save operations.
- Enhanced Celery task management by ensuring old DB connections are closed before and after tasks, improving reliability and preventing errors during account refresh operations. (Fixes #1338)
2026-06-18 14:59:09 -05:00
SergeantPanda
cfe58db222 fix(xc): normalize server URLs for live playback and stream URLs (Fixes #1363)
Some checks are pending
CI Pipeline / prepare (push) Waiting to run
CI Pipeline / docker (amd64, ubuntu-24.04) (push) Blocked by required conditions
CI Pipeline / docker (arm64, ubuntu-24.04-arm) (push) Blocked by required conditions
CI Pipeline / create-manifest (push) Blocked by required conditions
Build and Push Multi-Arch Docker Image / build-and-push (push) Waiting to run
- Implemented `normalize_server_url()` to standardize account server URLs, ensuring that on-demand live URLs are built correctly without including API endpoints or query parameters.
- Updated `get_transformed_credentials()` and stream URL generation in `M3UMovieRelation` and `M3UEpisodeRelation` to utilize the new normalization function, improving URL handling for Xtream Codes accounts.
2026-06-18 10:12:12 -05:00
SergeantPanda
f991338376 fix(proxy): update for improved DB connection management
Some checks are pending
CI Pipeline / prepare (push) Waiting to run
CI Pipeline / docker (amd64, ubuntu-24.04) (push) Blocked by required conditions
CI Pipeline / docker (arm64, ubuntu-24.04-arm) (push) Blocked by required conditions
CI Pipeline / create-manifest (push) Blocked by required conditions
Build and Push Multi-Arch Docker Image / build-and-push (push) Waiting to run
- Enhanced the live proxy to release geventpool DB connections more effectively across various paths.
- Implemented `close_old_connections()` in `stream_ts()`, `generate_stream_url()`, `get_stream_info_for_switch()`, `get_alternate_streams()`, `get_connections_left()`, and the cleanup process in `StreamGenerator`, ensuring that clients do not hold onto pool slots unnecessarily during streaming operations.
2026-06-17 17:22:28 -05:00
GitHub Actions
704fb1f529 Release v0.27.0
Some checks failed
CI Pipeline / prepare (push) Waiting to run
CI Pipeline / docker (amd64, ubuntu-24.04) (push) Blocked by required conditions
CI Pipeline / docker (arm64, ubuntu-24.04-arm) (push) Blocked by required conditions
CI Pipeline / create-manifest (push) Blocked by required conditions
Build and Push Multi-Arch Docker Image / build-and-push (push) Has been cancelled
Frontend Tests / test (push) Has been cancelled
2026-06-16 22:04:01 +00:00
SergeantPanda
1918b7d633
Merge pull request #1356 from Dispatcharr/dev
Dispatcharr - v0.27.0
2026-06-16 17:03:36 -05:00
SergeantPanda
a7b4db83fa feat(process_label): enhance uWSGI process role detection
- Added a new function `_is_uwsgi_worker` to accurately identify when the application is running within a uWSGI worker.
- Updated `get_process_role` to label processes as "uwsgi" based on the new function or command-line arguments, improving process role accuracy.
- Expanded unit tests to cover various scenarios for uWSGI role detection, ensuring robust validation of the new logic.
2026-06-16 16:21:22 -05:00
SergeantPanda
5d424adbe8 perf(vod): optimize VOD batch processing and memory management
- Introduced `lookup_by_name_year` function to limit database scans for movies and series without TMDB/IMDB IDs, enhancing performance by scoping lookups to current batch names.
- Updated `process_movie_batch` and `process_series_batch` to utilize the new lookup function, reducing memory usage and improving efficiency.
- Registered `refresh_vod_content` and `batch_refresh_series_episodes` for post-task memory cleanup in Celery, ensuring better resource management during VOD content refreshes.
2026-06-16 14:45:24 -05:00
SergeantPanda
56199aef81 perf(m3u): Enhance memory management and performance during XC stream processing
- Improved memory cleanup by invoking `gc.collect()` after batch processing in `process_m3u_batch_direct` and `collect_xc_streams`, ensuring timely release of resources.
- Added tests to verify that garbage collection is triggered appropriately after batch operations.
2026-06-16 14:24:26 -05:00
SergeantPanda
fe8309fd45 changelog: update.
Some checks are pending
CI Pipeline / prepare (push) Waiting to run
CI Pipeline / docker (amd64, ubuntu-24.04) (push) Blocked by required conditions
CI Pipeline / docker (arm64, ubuntu-24.04-arm) (push) Blocked by required conditions
CI Pipeline / create-manifest (push) Blocked by required conditions
Build and Push Multi-Arch Docker Image / build-and-push (push) Waiting to run
2026-06-16 11:28:43 -05:00
SergeantPanda
c1ff5e35aa feat(process_label): add process role labeling for PostgreSQL connections
- Introduced a new module to determine the process role based on command-line arguments, enhancing PostgreSQL connection labeling for better monitoring.
- Updated the database connection parameters to include the application name derived from the process role.
- Added unit tests to validate the process role detection logic for different scenarios, including Celery and uWSGI.
2026-06-16 11:26:12 -05:00
SergeantPanda
b2496dc4e7 refactor(trigger_event): streamline event dispatching and improve plugin action handling
- Replaced the plugin listing mechanism with a more efficient handler iteration for event actions.
- Enhanced logging to provide clearer insights into the dispatching process and plugin action execution.
- Added error handling to ensure that failures in one plugin action do not block subsequent actions.
- Updated tests to cover new behavior and ensure proper handling of enabled and disabled plugins.
2026-06-16 11:25:17 -05:00
SergeantPanda
c4bf21141c changelog: Add for plugin pr and link issue.
Some checks are pending
CI Pipeline / prepare (push) Waiting to run
CI Pipeline / docker (amd64, ubuntu-24.04) (push) Blocked by required conditions
CI Pipeline / docker (arm64, ubuntu-24.04-arm) (push) Blocked by required conditions
CI Pipeline / create-manifest (push) Blocked by required conditions
Build and Push Multi-Arch Docker Image / build-and-push (push) Waiting to run
2026-06-15 20:48:59 -05:00
SergeantPanda
84e4623a2c
Merge pull request #1352 from sv-dispatcharr/plugins/base-url-split
Plugins: split manifest root_url into download_base_url and metadata_base_url
2026-06-15 20:32:48 -05:00
SergeantPanda
e9f38169c2
Merge pull request #1355 from Dispatcharr/custom-psycopg3-pool
Custom-psycopg3-pool
2026-06-15 20:31:39 -05:00
SergeantPanda
8fec2060ca changelog: Update for last two prs. 2026-06-15 20:28:07 -05:00
SergeantPanda
d06455d3c8 Merge branch 'dev' of https://github.com/Dispatcharr/Dispatcharr into custom-psycopg3-pool 2026-06-15 20:25:27 -05:00
SergeantPanda
2b8bed704d
Merge pull request #1354 from Dispatcharr/epg-parse-refactor
feat(epg): implement staging and batch processing for EPG program updates
2026-06-15 20:17:13 -05:00
SergeantPanda
34c938b1ce feat(epg): implement staging and batch processing for EPG program updates
- Introduced a temporary staging table for efficient batch processing of EPG program inserts, reducing memory and I/O contention during updates.
- Enhanced the `parse_programs_for_source` function to stream parsed rows into the staging table before swapping them atomically into the main program data.
- Added unit tests to validate the new staging and swapping logic, ensuring existing programs are preserved during failures and that batch processing works as intended.
2026-06-15 19:50:50 -05:00
SergeantPanda
32442e0b64 fix(proxy): enhance channel shutdown management and client reconnection handling
Some checks are pending
CI Pipeline / prepare (push) Waiting to run
CI Pipeline / docker (amd64, ubuntu-24.04) (push) Blocked by required conditions
CI Pipeline / docker (arm64, ubuntu-24.04-arm) (push) Blocked by required conditions
CI Pipeline / create-manifest (push) Blocked by required conditions
Build and Push Multi-Arch Docker Image / build-and-push (push) Waiting to run
- Improved the handling of channel shutdown delays to ensure they reset correctly upon client reconnections, preventing premature shutdowns.
- Implemented logic to cancel pending shutdowns when clients reconnect, ensuring proper resource management and preventing client disconnect issues across uWSGI workers.
- Enhanced logging for shutdown processes and client management to provide clearer insights during channel operations.
2026-06-15 18:53:49 -05:00
SergeantPanda
7989fa3673 fix(proxy): enhance database connection management during VOD playback and stats updates
- Added calls to `close_old_connections()` in `stream_vod()` and `build_vod_stats_data()` to prevent connection leaks during long-lived streaming responses and background stats refreshes.
- Improved connection handling in various components to ensure proper resource cleanup and prevent blocking issues.
2026-06-15 17:32:38 -05:00
SergeantPanda
c389462dde fix(proxy): enhance connection management and event handling during streaming operations
- Improved database connection management by ensuring `close_old_connections()` is called in various methods to prevent connection leaks.
- Updated event dispatching to run asynchronously in gevent, preventing blocking during live-proxy and streaming paths.
2026-06-15 17:14:48 -05:00
SergeantPanda
db40421faa fix(plugins): ensure proper database connection management in plugin actions
- Added calls to `close_old_connections()` in `run_action()` and `stop_plugin()` methods to prevent connection leaks after plugin execution.
- Updated documentation to clarify connection handling for plugins, emphasizing the importance of cleanup in long-running tasks and event hooks.
2026-06-15 16:50:08 -05:00
SergeantPanda
8f40f6065f fix(proxy): improve resource cleanup and connection management
Some checks are pending
CI Pipeline / prepare (push) Waiting to run
CI Pipeline / docker (amd64, ubuntu-24.04) (push) Blocked by required conditions
CI Pipeline / docker (arm64, ubuntu-24.04-arm) (push) Blocked by required conditions
CI Pipeline / create-manifest (push) Blocked by required conditions
Build and Push Multi-Arch Docker Image / build-and-push (push) Waiting to run
- Added calls to close_old_connections in various components to ensure proper resource cleanup and prevent connection leaks.
2026-06-14 21:55:57 -05:00
SergeantPanda
67239d921d fix(proxy): enhance channel teardown and client management
- Improved channel teardown process to prevent lingering upstream connections and ensure proper resource cleanup.
- Enhanced client management by implementing checks for ghost clients and ensuring accurate client disconnection handling.
- Updated logging to provide clearer insights during channel initialization and teardown, including handling of unavailable channels.
- Refined stream manager behavior to manage ownership transitions more effectively and prevent blocking during shutdown.
2026-06-14 21:41:50 -05:00
SergeantPanda
9393f72cc1 fix(proxy): enhance channel teardown and client management
- Improved handling of channel teardown to prevent client reconnect issues across uWSGI workers, ensuring proper resource cleanup and ownership management.
- Added functionality to clear all client entries from Redis for a channel, enhancing client management during shutdown.
- Updated logging and response mechanisms to inform clients of channel availability during teardown,.
- Enhanced stream manager behavior to ensure upstream connections are properly managed during ownership transitions.
2026-06-14 18:59:59 -05:00
SergeantPanda
99c2b3b4d7 fix(proxy): improve channel stop handling and logging
- Enhanced the `stop_channel` method to prevent blocking during teardown by logging stop events asynchronously.
- Updated stream buffer behavior to ignore late writes during shutdown, improving resource management.
2026-06-14 11:50:50 -05:00
Seth Van Niekerk
092ac2c735
Split manifest base URL 2026-06-14 09:43:46 -04:00
SergeantPanda
38389a81e6 feat(settings): add DATABASE_POOL_CONN_MAX_LIFETIME for connection management and update database engine path (Fixes #1343)
- Introduced DATABASE_POOL_CONN_MAX_LIFETIME to manage pooled connection lifecycle, improving performance and resource management.
- Updated database engine path to use 'dispatcharr.db.backends.postgresql_psycopg3' for consistency.
- Enhanced logging configuration for geventpool connection lifecycle tracking.
2026-06-12 11:31:09 -05:00
Cédric Marcoux
c91f4f9a6e feat(timeshift): provider pool accounting, per-channel session takeover, rollup self-heal
Aligns catch-up with how live and VOD manage provider capacity:

- Reserve a provider profile slot (connection_pool) before every upstream
  connect, walking the account's active profiles default-first when the
  default is at capacity (profile_full/credential_full are transient and
  never mark the account decisive). Credentials for the reserved profile
  are resolved via get_transformed_credentials — the same credential
  extraction live playback uses — so pool accounting and real upstream
  usage always agree. All eligible streams blocked on capacity alone
  returns 503 (the VOD pool-exhausted precedent).

- Release exactly once via a one-shot Redis ownership token consumed with
  a transactional GET+DEL: the generator finally, the response-close
  wrapper, failed failover attempts and session takeover all share it, so
  no path can double-decrement and a client disconnecting before the
  first chunk still releases (Django registers the iterator's close() as
  a resource closer). Failures between reservation and the streaming
  response owning the slot release before propagating; an ownership-token
  write failure releases directly and reports transient unavailability.

- One catch-up session per user and channel: a new request (programme
  jump or seek) displaces the user's previous session on that channel —
  its slot is released synchronously, its stats are unregistered, and its
  generator is stopped through the standard stop-key mechanism — so rapid
  seeking cannot stack upstream provider connections.

- rollup_channel_catchup_fields self-heals channels left flagged
  is_catchup with no remaining catch-up stream (outside the
  account-scoped CTE), covering bulk removals on manual/multi-provider
  channels regardless of how the link rows disappeared. A regression test
  locks the ChannelStream post_delete signal firing on queryset bulk
  deletes.

Backend test suite grows to 94 (slot reservation/release on every
failover outcome, profile walk, mixed capacity-vs-upstream precedence,
exception-path release, token exactly-once semantics, takeover scoping
and ordering, never-started-generator release, rollup self-heal).
2026-06-12 16:36:02 +02:00
Cédric Marcoux
5223d5430b Merge remote-tracking branch 'upstream/dev' into feat/timeshift-native-v2 2026-06-12 07:40:22 +02:00
SergeantPanda
a8a52f3251
Merge pull request #1319 from FiveBoroughs:feature/epg-filter-multiword-search
Some checks failed
CI Pipeline / prepare (push) Waiting to run
CI Pipeline / docker (amd64, ubuntu-24.04) (push) Blocked by required conditions
CI Pipeline / docker (arm64, ubuntu-24.04-arm) (push) Blocked by required conditions
CI Pipeline / create-manifest (push) Blocked by required conditions
Build and Push Multi-Arch Docker Image / build-and-push (push) Waiting to run
Base Image Build / prepare (push) Has been cancelled
Frontend Tests / test (push) Has been cancelled
Base Image Build / docker (amd64, ubuntu-24.04) (push) Has been cancelled
Base Image Build / docker (arm64, ubuntu-24.04-arm) (push) Has been cancelled
Base Image Build / create-manifest (push) Has been cancelled
feat(frontend): multi-word, accent-insensitive EPG channel filter
2026-06-11 18:27:54 -05:00
SergeantPanda
f9336a8115 changelog: Update changelog for pr. 2026-06-11 18:26:14 -05:00
SergeantPanda
c27f28c0c1 Merge branch 'dev' of https://github.com/Dispatcharr/Dispatcharr into pr/FiveBoroughs/1319 2026-06-11 18:09:12 -05:00
SergeantPanda
546a05ff0b changelog: Update for auto sync pr. 2026-06-11 17:04:46 -05:00
SergeantPanda
5744a55b06
Merge pull request #1328 from CodeBormen:fix/1273-provider-numbering
Fix/1273 provider numbering
2026-06-11 17:03:17 -05:00
SergeantPanda
703926bca5 fix(m3u): improve RANGE_EXHAUSTED error reporting for provider mode
- Introduced a new helper function to generate user-facing error messages for RANGE_EXHAUSTED failures, ensuring the fallback range is correctly displayed.
- Updated tests to verify that the error message reflects the fallback range instead of the hidden auto_sync_channel_start, enhancing clarity for users.
2026-06-11 16:58:43 -05:00
SergeantPanda
7ed9b11a89 Refactor Schedules Direct EPG handling and enhance guide fetch logic
- Replaced individual EPG program parse tasks with a centralized dispatch function to streamline guide refresh for newly assigned EPG IDs.
- Implemented batching for guide fetches when multiple EPGs are mapped, reducing redundant API calls and improving efficiency.
- Updated related utility functions to support the new fetching strategy and added tests to ensure correct behavior under various scenarios.
2026-06-11 16:41:30 -05:00
SergeantPanda
c274bd7fbb Enhance Schedules Direct integration with per-channel guide fetch and improved EPG handling
- Implemented a targeted guide fetch for Schedules Direct when mapping a channel, ensuring immediate data availability without redundant API calls.
- Updated EPG signals to queue guide fetches for newly mapped channels, streamlining the process and improving user experience.
- Refactored existing logic to skip unnecessary program refreshes for dummy sources and ensure proper handling of Schedules Direct data.
- Added unit tests to validate the new fetching behavior and ensure robustness of the integration.
2026-06-11 13:54:19 -05:00
SergeantPanda
bbff67899f
Merge pull request #1336 from sv-dispatcharr:fix/sd-gaps
force-fetch SD schedules for newly mapped stations with no existing P…
2026-06-11 13:15:05 -05:00
Cédric Marcoux
1703d6a703 feat(timeshift): address review — network gate, catch-up failover, live_proxy revert, setting moved to proxy settings
Implements all four points from the latest review, plus hardening from a
pre-submission audit pass.

1. Access control: timeshift_proxy now enforces
   network_access_allowed(request, "STREAMS", user) — same key and placement
   as the live XC stream endpoint.

2. Catch-up failover: the proxy walks the channel's catch-up streams in
   channelstream order (get_channel_catchup_streams), mirroring live
   playback. Each attempt carries its own provider context: account
   credentials, provider stream id, reported server_info timezone (the
   UTC->provider conversion is recomputed per attempt), user-agent, and the
   per-account URL-format cache. The first streamable response wins; if all
   providers fail the last failure is returned.
   Ban-safety is per account: a decisive auth/ban-class failure (401/403/406)
   marks the account and skips its remaining streams (e.g. FHD/HD variants of
   the same channel) instead of hammering a banning provider, while other
   accounts — different hosts — are still tried. Streams from disabled M3U
   accounts are excluded, same as live dispatch. Redirects stay enabled on
   purpose (XC providers legitimately 302 to load-balanced streaming nodes);
   the 3xx decisive branch is kept as defense-in-depth and documented as such.

3. apps/proxy/live_proxy/views.py restored byte-identical to upstream — the
   leftover channel-id wrapper from the removed provider-stream-id fallback
   is gone (zero-line diff).

4. Single remaining setting relocated: xmltv_prev_days_override now lives in
   proxy_settings (backend default in get_proxy_settings, consumed by the
   XMLTV prev_days resolution). The timeshift_settings group,
   TIMESHIFT_DEFAULTS, get_timeshift_settings, the Settings → Timeshift form
   and tab are all removed; the field appears under Settings → Proxy Settings
   (0 = auto-detect, capped at 30).

Audit hardening in the same pass:
- Updated the proxy-settings defaults unit test for the new key (would have
  failed CI otherwise).
- Migration backfills use schema_editor.connection instead of the global
  connection (multi-database correctness).
- CHANGELOG and module docstring brought in line with the final architecture
  (PATH-first cascade, failover, setting under Proxy Settings).
- Tests grown to 69 backend tests: failover success/exhaustion/skip
  semantics, decisive-account skip vs soft-failure retry, per-stream
  timezone conversion (different zones per provider), 406/connection-error
  cascade paths, stream-limit and no-eligible-stream outcomes,
  network-gate 403, server_info strict-UTC guarantee, EPG duration window
  resolution, and DB-backed coverage of xc_password auth, user_level access
  and the failover stream ordering (catch-up-only, active accounts,
  channelstream order). The format-cache test now runs on an isolated
  locmem cache.
2026-06-11 20:08:16 +02:00
SergeantPanda
8d59b0b27b changelog: Update for SD PR. 2026-06-11 12:43:16 -05:00
SergeantPanda
37cc72a040 Merge branch 'dev' of https://github.com/Dispatcharr/Dispatcharr into pr/sethwv/1336 2026-06-11 12:31:39 -05:00
SergeantPanda
205deb150b Implement schedule MD5 delta detection and backfill logic for improved cache management
- Added functions to compute schedule changes based on MD5 hashes and backfill missing dates for mapped stations.
- Enhanced handling of orphaned ProgramData records for unmapped EPG entries during schedule fetch.
- Introduced unit tests to validate MD5 comparison, backfill logic, and metadata fetching requirements.
2026-06-11 12:31:30 -05:00
Cédric Marcoux
fefa0abdc6 Merge remote-tracking branch 'upstream/dev' into feat/timeshift-native-v2 2026-06-11 18:20:36 +02:00
SergeantPanda
843940f552 refactor(http headers): centralize User-Agent construction and streamline API requests
- Introduced `dispatcharr_user_agent`, `dispatcharr_dvr_user_agent`, and `dispatcharr_http_headers` functions in `core.utils` to standardize User-Agent strings and HTTP headers for outbound requests.
- Updated various components, including `LogoViewSet`, `EPGSourceViewSet`, and VOD proxy views, to utilize the new header functions, ensuring consistent User-Agent usage across the application.
- Enhanced the handling of Schedules Direct API requests by including proper User-Agent headers, addressing previous API compliance issues.
2026-06-11 09:45:35 -05:00
SergeantPanda
5f9fa4c11f
Merge pull request #1334 from sv-dispatcharr:fix/sd-countries-list
proxy SD country list fetch through backend to fix CORS failure
2026-06-11 09:04:11 -05:00
SergeantPanda
ed364a26c9 refactor(EPGSourceViewSet, frontend): streamline SD countries fetching and remove proxy endpoint
- Introduced a new method `_fetch_sd_countries` in `EPGSourceViewSet` to fetch SD countries directly from the backend, eliminating the need for a separate proxy endpoint.
- Updated the `sd_lineups` action to include the fetched countries in the response.
- Removed the `getSDCountries` method from the API and adjusted the frontend to fetch countries alongside lineups, improving efficiency
- Updated related components to handle the new data structure for countries.
2026-06-11 09:00:35 -05:00
Cédric Marcoux
2909c9b339 chore(timeshift): pre-review hardening and dead-code cleanup
- http_streamer.py: restore HTTPStreamReader to its upstream form and keep
  only the find_ts_sync() addition. The response=/extra_headers=/
  strip_ts_preamble= extensions had no remaining callers since the timeshift
  view moved to direct iter_content streaming (delta shrinks from +235/-83
  lines to +28).
- Unify the catchup_days semantics everywhere: a channel's archive depth is
  MAX(catchup_days) over its CATCH-UP streams only. The SQL rollup now uses
  a FILTER (WHERE s.is_catchup) aggregate and the ChannelStream signal uses
  the same MAX aggregation (it previously took the first stream by order,
  and the rollup aggregated over all streams including non-catchup ones).
- Migration backfill: also accept the lowercase 'true' that ->> extraction
  yields for JSON booleans.
- get_channel_catchup_info(): drop the tv_archive_duration key — its only
  caller never used it.
- Document why timeshift termination fails closed when Redis is unavailable
  (denying the new stream is what protects the provider connection limit),
  and update the stale stop-key comment (5 s cadence, not 100 chunks).
2026-06-10 22:05:01 +02:00
Cédric Marcoux
b6bbd39469 fix(timeshift): PATH-first catch-up + strict-UTC API surface with proxy-time provider timezone conversion
Two coupled fixes that make catch-up play the requested programme:

1. Try the PATH catch-up form (/timeshift/.../{start}/{id}.ts) BEFORE the
   timeshift.php query form. Empirical testing showed some XC providers
   return the LIVE stream on the query form (HTTP 200, silently ignoring
   `start`) — a valid MPEG-TS indistinguishable from a real archive, so the
   cascade accepted it and the user always got live content. The PATH form
   actually seeks. Candidate ordering now lives in
   build_timeshift_candidate_urls() with the full rationale.

2. Strict-UTC XC API surface + single proxy-time timezone conversion:
   - xc_get_epg start/end always UTC, server_info.timezone always "UTC",
     time_now UTC — the timezone triple is consistently UTC.
   - Removed the global default_timezone setting and the
     _convert_xmltv_to_local_timezone rewrite entirely.
   - The proxy converts the client's UTC timestamp to the SERVING provider's
     own zone (server_info.timezone captured on account refresh, read from
     the account's default profile) right before building the upstream URL —
     DST-correct via ZoneInfo, no-op for UTC/unknown zones. Verified against
     a real provider: it interprets the URL timestamp as its LOCAL wall
     clock, so pure UTC pass-through seeks 1-2h off.
   - EPG duration lookup keeps the ORIGINAL UTC timestamp (programmes are
     stored in UTC) — exactly one conversion in the whole chain.

Also per review feedback:
- Removed the debug_logging toggle — verbose timeshift logging now follows
  the standard logger DEBUG level (DISPATCHARR_LOG_LEVEL).
- Removed the dead default_language setting (never read anywhere; the EPG
  lang field is hardcoded). TIMESHIFT_DEFAULTS is down to
  xmltv_prev_days_override, and the Settings UI shows that single field
  with accurate help text.
- Validate the timestamp up front (400 on malformed input) instead of
  forwarding it verbatim into the upstream URL.
- Redact the upstream URL in the connection-error log (requests exceptions
  embed the full URL, which carries XC credentials).
- URL-encode XC credentials in both URL builders.
- Request identity encoding upstream (the TS-sync peek reads raw bytes).
- Poll the stop key on the 5-second heartbeat cadence instead of every 100
  chunks (~25 MB), so stream-limit terminations free the provider slot fast.
- New tests: proxy timestamp wiring (converted value reaches the URL
  builder, original UTC value reaches the duration lookup), _redact_url,
  decisive 3xx break (anti ban), invalid timestamp rejection; deflaked the
  format-cache promotion test (django cache persists across runs).
2026-06-10 22:04:44 +02:00
Seth Van Niekerk
bfa7019324
force-fetch SD schedules for newly mapped stations with no existing ProgramData 2026-06-10 10:53:09 -04:00
Seth Van Niekerk
887a177581
proxy SD country list fetch through backend to fix CORS failure 2026-06-10 08:46:02 -04:00
Cédric Marcoux
19acdcc29d Merge remote-tracking branch 'upstream/dev' into feat/timeshift-native-v2
# Conflicts:
#	apps/m3u/tasks.py
2026-06-10 08:42:33 +02:00
SergeantPanda
d3ecefb7d6
Merge pull request #1254 from Goldenfreddy0703/feature/1137-credential-pools
Enforce shared credential connection pools across M3U accounts (#1137)
2026-06-09 18:48:14 -05:00
SergeantPanda
95975d5756 refactor(connection_pool): streamline credential slot management
- Removed redundant logic for releasing server group slots in `move_credential_slot_on_profile_switch` and `release_profile_slot`.
- Simplified the `_release_server_group_slot_for_profile` function by eliminating it, as its functionality was integrated into other methods.
2026-06-09 18:38:52 -05:00
SergeantPanda
c6b23690f4 changelog: enhance M3U connection management and server group features 2026-06-09 18:09:55 -05:00
SergeantPanda
4fc12bca4a refactor(m3u, channels, proxy): enhance stream assignment logic and error handling
- Improved logging in the Stream model for better debugging of profile evaluations.
- Introduced a new method `_stream_assignment_is_reusable` in the Channel model to determine if existing stream assignments can be reused, enhancing efficiency.
- Updated the release logic in `release_profile_slot` to utilize stored credential keys, reducing unnecessary database lookups.
- Simplified error handling in the `get_stream_info_for_switch` function to ensure proper stream release on exceptions.
- Enhanced tests for connection pool management and error handling in the ServerGroupsTable component to improve reliability and user feedback.
2026-06-09 17:59:12 -05:00
SergeantPanda
647c1316ba refactor(M3U): enhance form layout and streamline input handling
- Increased modal size for better visibility and user experience.
- Adjusted form layout for improved alignment and spacing between elements.
- Removed unused Checkbox component and simplified the account type selection logic.
- Consolidated VOD-related input fields under the Xtream Codes account type for clarity.
- Enhanced overall readability and maintainability of the component by reducing complexity in the form structure.
2026-06-09 16:34:27 -05:00
SergeantPanda
2dae24a02b refactor(ServerGroupsTable, ServerGroupsManagerModal): adjust layout and component structure
- Changed the size of the ServerGroupsManagerModal from "lg" to "md" for improved UI consistency.
- Refactored ServerGroupsTable to enhance the layout, including adjustments to column sizes and the addition of minimum sizes for better responsiveness.
- Simplified the RowActions component for better readability and alignment.
- Updated the loading and empty state handling in the table to provide clearer user feedback.
- Removed unused local storage hook from the component, streamlining the codebase.
2026-06-09 16:18:09 -05:00
SergeantPanda
705041ea81 refactor(channel_status, vod_proxy): remove stream URL credential extraction
- Eliminated redundant extraction of provider usernames from stream URLs in both ChannelStatus and VOD stats data functions, simplifying the codebase.
- Updated frontend components to reflect the removal of provider username display, streamlining the UI.
- Enhanced overall readability and maintainability of the code by reducing complexity in handling stream URLs.
2026-06-09 14:46:26 -05:00
SergeantPanda
bc046b3c92 refactor(m3u, channels): streamline stream assignment and server group management
- Renamed `_clear_stream_assignment_keys` to `_release_stale_stream_assignment` for clarity and updated its logic to release pool counters.
- Introduced new functions for managing credential slots during profile switches, enhancing the handling of shared connection limits across server groups.
- Removed the `max_streams` field from the `ServerGroup` model and updated related components to reflect this change, simplifying the server group management.
- Updated frontend components to integrate server group management, allowing for dynamic creation and editing of server groups.
- Enhanced error handling in stream URL generation to provide more informative feedback on connection issues.
- Added tests for stale assignment release and credential management during profile switches.
2026-06-09 13:40:16 -05:00
SergeantPanda
0f0e855507 Merge branch 'dev' of https://github.com/Dispatcharr/Dispatcharr into pr/Goldenfreddy0703/1254 2026-06-09 11:16:39 -05:00
None
abf16fd104 test(m3u): cover provider-number collisions in auto-sync numbering
- A duplicate provider number keeps one channel and falls the collider back to a free number.
- A provider number matching an existing channel falls back instead of overwriting it.
2026-06-08 21:22:16 -05:00
SergeantPanda
7086f41d64 feat(epg): overhaul EPG auto-matching logic and improve performance
- Moved matching logic to a dedicated module for better organization and testability.
- Made single-channel auto-matching asynchronous, allowing for larger EPG libraries without hitting HTTP timeouts.
- Enhanced memory management and throughput during EPG matching, including optimizations for fuzzy matching and bulk processing.
- Fixed various reliability issues in the auto-matching process, ensuring accurate channel assignments and improved UI feedback.
- Updated API views and frontend components to reflect changes in the matching process and provide real-time notifications.
- Added tests for EPG matching functionality and name normalization.
- 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.
2026-06-08 18:12:29 -05:00
None
57cae0e4be fix(m3u): correct auto-sync numbering field interactions from the auto-sync overhaul
The auto-sync overhaul added a [start, end] range and a shared numbering picker, but each mode's UI exposes only a subset of the persisted fields (Provider's "Start #" writes channel_numbering_fallback; Next Available exposes no Start/End) while the backend read auto_sync_channel_start and auto_sync_channel_end in every mode. Switching modes never resets the others, so a stale or auto-computed value silently changed numbering.

- Provider mode honors the provider-supplied number (stream_chno) verbatim; the group's Start (channel_numbering_fallback) and End bound only the fallback for streams the provider did not number. auto_sync_channel_start is no longer applied in provider mode.
- Next Available ignores End, since its UI exposes no range.
- Range enforcement (the overflow-delete) runs in fixed mode only, the one mode with a user-set [start, end] range.
- Provider mode gains the Start>End guard fixed mode already had, so an inverted fallback range cannot fail every numberless stream.

Includes backend and frontend regression tests.
2026-06-07 22:02:23 -05:00
Goldenfreddy0703
abdf2d7864 Fix credential release on deleted profile and round 3 review items.
Store credential Redis keys at reserve so release works when the profile row is deleted. Return reserve failure reasons to avoid fingerprint DB queries on logging paths. Document unlimited profile bypass in pool logic and Server Group UI.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-04 19:51:20 -04:00
Five Boroughs
d204d6ad64
feat(frontend): multi-word, accent-insensitive EPG channel filter 2026-06-03 20:31:52 +02:00
Cédric Marcoux
f0e38a8d41 Merge remote-tracking branch 'upstream/dev' into feat/timeshift-native-v2
# Conflicts:
#	frontend/src/components/cards/StreamConnectionCard.jsx
2026-06-03 09:09:35 +02:00
Cédric Marcoux
ea09fd0a84 fix(timeshift): keep cascading on upstream 5xx
Some XC servers run PHP with display_errors off, so a timestamp shape
their parser rejects comes back as a hard HTTP 500 instead of a 200 with
inline PHP warning text. The candidate-format cascade treated any 5xx as
decisive and stopped, so catch-up never reached the timestamp shape that
works on those servers and failed outright on them.

Only short-circuit on genuinely decisive, ban-sensitive statuses
(401/403/406 and 3xx redirects — a 302 is the first sign of an XC ban);
treat 5xx like 400/404 and try the next candidate shape. Add regression
tests for "500 then success" and "all candidates 500".
2026-06-03 09:05:09 +02:00
SergeantPanda
e154e4e696 fix: Ensure catch-up streams are ordered by channelstream order in get_channel_catchup_info 2026-06-02 12:16:02 -05:00
SergeantPanda
09f4ec91f1 refactor: Remove catchup_provider_stream_id field and related logic from Channel model and migrations 2026-06-02 11:59:09 -05:00
SergeantPanda
7e8b546ee0 fix: Move catchup rollup out of auto sync function. It will now run after every account refresh and target all channels with at least 1 stream from the current provider that has catchup enabled. 2026-06-02 11:00:24 -05:00
Cédric Marcoux
97038624fe migration: sync catchup_provider_stream_id help_text
The Channel.catchup_provider_stream_id model field carries a help_text that
was not reflected in migration 0038, leaving makemigrations --check dirty.
Add the AlterField migration so model and migration state match (no schema
change — help_text only).
2026-06-01 13:43:56 +02:00
Cédric Marcoux
341c133c74 Merge remote-tracking branch 'upstream/dev' into rewrite-feature
# Conflicts:
#	apps/output/views.py
#	frontend/src/utils/pages/SettingsUtils.js
2026-06-01 13:43:56 +02:00
Cédric Marcoux
4f45249067 fix(timeshift): resolve catch-up channel by Channel.id only + cleanup
- timeshift_proxy(): drop the resolve_channel_by_provider_stream_id
  fallback. The client only ever sends Dispatcharr's internal Channel.id,
  so resolve by id and return 404 on miss. Remove the dead `channel = None`
  sentinel and the now-unused import.
- Delete the now-orphaned resolve_channel_by_provider_stream_id helper and
  its local Stream import from apps/channels/utils.py (zero remaining refs).
- Import django.core.cache.cache at module top-level instead of inside
  _get_cached_format_index / _set_cached_format_index.
- Fix the stale dispatcharr/urls.py comment: the "duration" slot carries
  Channel.id, not the provider stream_id.
- Genericize provider host references in code comments.
2026-06-01 12:51:14 +02:00
Goldenfreddy0703
6e07dce3f1 Fix maintainer review items without breaking multi-login rotation.
Cap credential-scoped group counters at profile.max_streams (not group.max_streams),
skip credential counters when fingerprint resolution fails, and always swap profile
counters on update_stream_profile. Restore per-profile-only selection for VOD/live
switches so a second stream can use a different login without invalid HTTP errors.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-29 17:07:52 -04:00
Cédric Marcoux
3b8717c4ce fix: timeshift stream management, channel resolution, and streaming throughput
- Channel resolution: resolve by Channel.id first (what the XC API emits
  to clients), fall back to provider stream_id for backward compatibility.
  Fixes 404 on all timeshift requests from XC clients.

- Stream management: timeshift is now a first-class citizen alongside live
  and VOD in the stream-limits system.  get_user_active_connections()
  detects type='timeshift', attempt_stream_termination() sets a Redis
  stop key (same pattern as VOD), and the timeshift view calls
  check_user_stream_limits() before connecting upstream.  The stream
  generator checks the stop key every 100 chunks.  Fixes infinite retry
  loops on max_connections=1 providers for live→timeshift and
  timeshift→timeshift transitions.

- Streaming: replaced HTTPStreamReader thread+pipe with direct
  iter_content+yield (same pattern as VOD proxy).  The pipe approach
  deadlocked under gevent because the reader thread's select() competed
  with the greenlet's pipe read for hub scheduling.  Throughput went
  from ~74 B/s to 13+ MB/s.

- TS preamble: peek now strips pre-sync bytes (PHP warnings, BOM) before
  prepending to the stream, preventing corrupt TS output.

- Credential safety: _redact_url() now truncates to scheme://host/...
  to avoid leaking XC path-based credentials in logs.

- Tests: updated 5 tests that referenced the removed HTTPStreamReader.
  21/21 timeshift tests pass.

- Migration: fixed SyntaxWarning for unescaped regex in 0038.
2026-05-27 23:32:57 +02:00
Cédric Marcoux
2781b21a89 feat: built-in XC catch-up (timeshift) support
Adds native catch-up/timeshift replay for Xtream Codes providers through
the same HTTPStreamReader transport pipeline as live TV.

Timeshift proxy (apps/timeshift/):
- URL cascade: 3 candidate timestamp formats per provider, per-account
  format cache for fast-forward seek performance
- MPEG-TS preamble stripping (shared with HTTPStreamReader)
- Stats integration: timeshift viewers appear on /stats with TIMESHIFT badge
- Auth via hmac.compare_digest on XC password

Catchup detection — denormalized for zero-cost output queries:
- Stream.is_catchup + Stream.catchup_days populated at XC import time
- Channel.has_catchup + Channel.catchup_days + Channel.catchup_provider_stream_id
  rolled up via ChannelStream post_save signal (UI path) and explicit SQL
  after bulk_create (import path)
- _xc_channel_entry() reads denormalized fields instead of per-channel
  custom_properties JSON introspection (eliminates N+1 queries)
- Migration 0038 backfills existing data via raw SQL

XC API enhancements:
- server_info.timezone + start/end + time_now use configured timezone
  (triple consistency rule — fixes wrong-programme-plays bug)
- Dynamic has_archive flag + auto prev_days for catch-up channels
- XMLTV timestamps rewritten to local timezone for catch-up clients

HTTPStreamReader extended (apps/proxy/live_proxy/input/http_streamer.py):
- 1 MB pipe buffer via fcntl F_SETPIPE_SZ (eliminates producer/consumer
  ping-pong that halved throughput)
- Pre-opened response= for URL cascade workflows
- strip_ts_preamble= for XC servers emitting PHP warnings before TS
- find_ts_sync() as shared utility
- Builds on upstream O_NONBLOCK + select() write loop

Provider stream_id lookup order:
- stream_xc() and xc_get_epg() try internal Channel.id first, fall back
  to provider stream_id only when needed (avoids unconditional query on
  every request)

Also includes:
- VOD provider cascade in stream_vod() — iterates all M3U relations by
  priority when first provider is at capacity
- Defensive null-safety: custom_sid: None → "" in get_live_streams,
  get_vod_streams, get_vod_info, get_series_info (fixes iPlayTV crash on
  JSON null for string fields)
- Timeshift settings UI (timezone selector, debug toggle)
- StreamConnectionCard violet TIMESHIFT badge
- Orphan cleanup skips timeshift_* virtual channels
2026-05-27 13:13:20 +02:00
Goldenfreddy0703
bb9b95b4a4 Refactor connection pools to manual ServerGroups with profile rotation.
Drop auto-fingerprint migration and restore per-profile selection for live/VOD.
Enforce shared limits on reserve using login-scoped group counters, and add
Server Groups UI for manual account assignment per maintainer feedback (#1137).

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-23 20:18:45 -04:00
Goldenfreddy0703
d89f634213 Enforce shared credential connection pools across M3U accounts (#1137)
Group M3U/XC accounts and profiles that share the same provider login into auto-assigned ServerGroups keyed by credential fingerprint. Enforce combined Redis limits for live TV and VOD via apps/m3u/connection_pool.py.

- Per-profile fingerprinting (XC transforms and STD stream URLs)

- VOD profile selection tries alternates when default credential pool is full (fixes live then VOD failure)

- Stats UI shows provider login from active stream URL

- Tests: apps/m3u/tests/test_connection_pool.py (11 tests, all passing)

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-20 03:30:48 -04:00
396 changed files with 75314 additions and 12071 deletions

161
.github/workflows/backend-tests.yml vendored Normal file
View file

@ -0,0 +1,161 @@
name: Backend Tests
on:
push:
branches: [main, dev]
paths:
- 'apps/**'
- 'core/**'
- 'tests/**'
- 'dispatcharr/**'
- 'pyproject.toml'
- 'version.py'
- 'manage.py'
- 'scripts/ci_backend_test_labels.py'
- 'scripts/ci_bootstrap_backend.sh'
- '.github/workflows/backend-tests.yml'
pull_request:
branches: [main, dev]
paths:
- 'apps/**'
- 'core/**'
- 'tests/**'
- 'dispatcharr/**'
- 'pyproject.toml'
- 'version.py'
- 'manage.py'
- 'scripts/ci_backend_test_labels.py'
- 'scripts/ci_bootstrap_backend.sh'
- '.github/workflows/backend-tests.yml'
workflow_dispatch:
inputs:
full_suite:
description: Run the full backend test suite
type: boolean
default: true
permissions:
contents: read
packages: read
concurrency:
group: backend-tests-${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
jobs:
plan:
name: Plan test groups
runs-on: ubuntu-latest
outputs:
labels: ${{ steps.resolve.outputs.labels }}
has_tests: ${{ steps.resolve.outputs.has_tests }}
base_image: ${{ steps.base_image.outputs.image }}
sync_python_deps: ${{ steps.base_image.outputs.sync_python_deps }}
steps:
- name: Checkout code
uses: actions/checkout@v6
with:
fetch-depth: 0
- name: Collect changed paths
id: changed
shell: bash
run: |
set -euo pipefail
: > /tmp/changed_paths.txt
if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
if [ "${{ inputs.full_suite }}" = "true" ]; then
: > /tmp/changed_paths.txt
echo "mode=full" >> "$GITHUB_OUTPUT"
else
git diff --name-only HEAD~1 HEAD > /tmp/changed_paths.txt || true
echo "mode=diff" >> "$GITHUB_OUTPUT"
fi
elif [ "${{ github.event_name }}" = "pull_request" ]; then
git fetch origin "${{ github.base_ref }}"
git diff --name-only "origin/${{ github.base_ref }}...HEAD" > /tmp/changed_paths.txt
echo "mode=pr" >> "$GITHUB_OUTPUT"
else
if [ "${{ github.event.before }}" = "0000000000000000000000000000000000000000" ]; then
git ls-files > /tmp/changed_paths.txt
echo "mode=initial-push" >> "$GITHUB_OUTPUT"
else
git diff --name-only "${{ github.event.before }}" "${{ github.sha }}" > /tmp/changed_paths.txt
echo "mode=push" >> "$GITHUB_OUTPUT"
fi
fi
- name: Select base image
id: base_image
shell: bash
run: |
set -euo pipefail
if [ "${{ github.event_name }}" = "pull_request" ]; then
TARGET_BRANCH="${{ github.base_ref }}"
else
TARGET_BRANCH="${{ github.ref_name }}"
fi
if [ "$TARGET_BRANCH" = "main" ]; then
TAG="base"
else
TAG="base-dev"
fi
REPO_OWNER="$(echo "${{ github.repository_owner }}" | tr '[:upper:]' '[:lower:]')"
REPO_NAME="$(echo "${{ github.event.repository.name }}" | tr '[:upper:]' '[:lower:]')"
echo "image=ghcr.io/${REPO_OWNER}/${REPO_NAME}:${TAG}" >> "$GITHUB_OUTPUT"
if grep -qx 'pyproject.toml' /tmp/changed_paths.txt || [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
echo "sync_python_deps=true" >> "$GITHUB_OUTPUT"
else
echo "sync_python_deps=false" >> "$GITHUB_OUTPUT"
fi
echo "Using base image: ghcr.io/${REPO_OWNER}/${REPO_NAME}:${TAG}"
- name: Resolve Django test labels
id: resolve
env:
FULL_SUITE: ${{ github.event_name == 'workflow_dispatch' && inputs.full_suite == true && 'true' || 'false' }}
run: |
set -euo pipefail
LABELS=$(python scripts/ci_backend_test_labels.py < /tmp/changed_paths.txt)
echo "labels=${LABELS}" >> "$GITHUB_OUTPUT"
if [ "${LABELS}" = "[]" ]; then
echo "has_tests=false" >> "$GITHUB_OUTPUT"
else
echo "has_tests=true" >> "$GITHUB_OUTPUT"
fi
echo "Selected labels: ${LABELS}"
test:
name: ${{ matrix.label }}
needs: plan
if: needs.plan.outputs.has_tests == 'true'
runs-on: ubuntu-latest
container:
image: ${{ needs.plan.outputs.base_image }}
credentials:
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
options: --entrypoint ""
strategy:
max-parallel: 6
fail-fast: false
matrix:
label: ${{ fromJSON(needs.plan.outputs.labels) }}
env:
DISPATCHARR_ENV: aio
DJANGO_SECRET_KEY: ci-test-secret-key
POSTGRES_DB: dispatcharr
POSTGRES_USER: dispatch
POSTGRES_PASSWORD: secret
DISPATCHARR_LOG_LEVEL: WARNING
SYNC_PYTHON_DEPS: ${{ needs.plan.outputs.sync_python_deps }}
steps:
- name: Checkout code
uses: actions/checkout@v6
- name: Run tests in base image
env:
GITHUB_WORKSPACE: ${{ github.workspace }}
run: bash scripts/ci_bootstrap_backend.sh "${{ matrix.label }}" -v2

1
.gitignore vendored
View file

@ -17,6 +17,7 @@ celerybeat-schedule*
dump.rdb dump.rdb
debugpy* debugpy*
uwsgi.sock uwsgi.sock
.uwsgi-reload
package-lock.json package-lock.json
models models
.idea .idea

View file

@ -7,6 +7,267 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased] ## [Unreleased]
## [0.28.2] - 2026-07-23
### Added
- **Schedules Direct Extra Debugging option.** EPG source settings include an **Extra Schedules Direct Debugging** toggle that adds a `RouteTo: debug` header so Schedules Direct support can steer traffic to their debug server. The tooltip states it should only be enabled when SD support asks. If SD returns code 2055 (unexpected debug connection), the toggle is turned off automatically.
### Changed
- **EPG source form places Auto-Apply EPG Logos in the shared middle column for XMLTV and Schedules Direct.** The toggle previously lived in the SD-only right panel when editing a Schedules Direct source; it now uses the same middle-column control as XMLTV so SD-specific options (logo style, posters, debug) stay in the right panel.
- **Schedules Direct poster proxy shares auth tokens across uWSGI workers via Redis.** Tokens are stored in Django's cache until near `tokenExpires` (24h fallback), so concurrent `/poster/` requests on different workers reuse one SD session instead of each process hitting `/token` separately. Redis failures fall back to re-authentication.
### Fixed
- **Schedules Direct poster proxy no longer ignores daily image download limits or missing-image errors.** SD returns codes 5002/5003 as HTTP 200 with a JSON body; the proxy previously only inspected HTTP 400 and kept requesting images after the limit, which can get accounts blocked. It now detects 5002/5003 (subscriber and trial), stops further image fetches for that source until the next midnight UTC (persisted on the EPG source so all workers honor it), and on explicit IMAGE_NOT_FOUND (code 5000) clears that program's `sd_icon` so the same dead URI is not retried. Transient bare HTTP 404s do not blacklist the URI. Successful SD posters use a dedicated nginx cache under `/data/cache/sd_posters` (14 days); channel/VOD logos remain at `/data/cache/logos` (24 hours). On startup, an existing `/data/logo_cache` directory is moved to `/data/cache/logos` once so warm logo entries are kept. API and XMLTV icon URLs include a `?v=` hash of the stored SD image URI so a new artwork link after refresh uses a new cache key without waiting for TTL expiry. Selecting programs that still need artwork no longer uses a negated JSON `AND` that dropped every row when `sd_icon_missing` / `sd_poster_style` keys were absent (Postgres NULL), which had prevented poster links from being saved after refresh.
- **Schedules Direct auth and lineup change handling better match SD guidelines.** Token codes 3000/3001 (offline/busy) stop as idle with clear "do not retry" messaging instead of looking like credential failures; 4010 (too many unique IPs) and related account codes get explicit messages. Lineup add/delete respects a known daily change lockout before calling SD, handles 4100 on deletes, and the UI blocks remove when no changes remain (adds and deletes both count toward the 6/day limit).
- **Schedules Direct stops retrying `/token` on auth failures that will not clear by themselves.** Codes 4002/4003 (bad credentials), 4001/4005/4007/4008 (account state), 4009/4010 (login/IP limits), and 4004 (15-minute account lock) persist a cooldown on the EPG source. Soft codes 3000/3001 use a 1-hour idle cooldown. Lockouts clear when username/password change or the cooldown expires. Token auth is centralized in `sd_obtain_token` for refresh, lineup/form, and poster paths. That helper reuses a Redis-cached token until near `tokenExpires` (bound to a credential fingerprint, and cleared when the EPG source username/password change) so opening the SD form, refreshing EPG, and serving posters do not mint a new token on every call. When an authenticated SD call returns HTTP 401/403 (SD documents TOKEN_EXPIRED as 403), the cached token is cleared and the request is retried once with a fresh `/token`.
- **Schedules Direct code split out of general EPG modules.** Refresh pipeline and Celery tasks live in `apps/epg/sd_tasks.py`; lineup/poster API mixins in `apps/epg/sd_api.py`; shared protocol helpers remain in `sd_utils.py`. Progress WebSocket updates (`send_epg_update`) live in `apps/epg/utils.py` so XMLTV and SD tasks can import cleanly. `tasks.py` / `api_views.py` re-export or inherit so existing imports and Celery task names are unchanged.
## [0.28.1] - 2026-07-20
### Fixed
- **Fresh AIO installs no longer crash during first-boot migrate when Redis is not up yet.** In AIO, `manage.py migrate` runs in the entrypoint before uWSGI starts Redis via `attach-daemon`. After CoreSettings group caching landed in 0.28.0, data migrations such as `m3u.0003` called live `CoreSettings.get_default_user_agent_id()` → Redis and failed with connection refused, leaving a partially migrated DB and a boot loop. Settings group cache reads/writes now fall back to Postgres on connectivity/timeout errors (with a throttled warning), skip cache fill on backend failure so a flapping Redis cannot re-poison entries after invalidate, and leave auth/protocol Redis errors loud. A regression test migrates a throwaway empty database with Redis unreachable. (Fixes #1459)
## [0.28.0] - 2026-07-19
### Added
- **Catch-up enable/disable controls.** System Settings includes an **Enable Catchup** toggle (default on) that blocks timeshift and catch-up playback for everyone and clears XC `tv_archive` / `tv_archive_duration` advertising when off. Per-user **Enable Catchup** on the user Permissions tab does the same for that user only (admin-managed; not writable via `PATCH /me/`). Channel/stream catch-up indicators remain visible in the web UI.
- **Native XZ decompression for EPG sources and uploaded M3U playlists.** `.xz`-compressed XMLTV and M3U files are now decompressed using Python's stdlib `lzma` module. EPG ingestion detects XZ via magic bytes, file extension (including compound names like `.xml.xz`), or MIME type; uploaded M3U `.xz` playlists stream line-by-line like `.gz`. The `/data/epgs` auto-import scanner now includes `.xz` files alongside `.xml`, `.gz`, and `.zip`. (Closes #1414) — Thanks [@MotWakorb](https://github.com/MotWakorb)
- **XC catch-up (timeshift) support**. Adds a native `/timeshift/{user}/{pass}/{duration}/{timestamp}/{channel_id}.ts` endpoint that proxies replay sessions from an Xtream Codes provider to IPTV clients. Auth reuses the same `xc_password` custom-property the live `/live/.../{id}.ts` endpoint already uses. Catch-up sessions appear on the Stats page in dedicated connection cards (separate from live and VOD) and respect per-channel access rules. (Closes #133) — Thanks [@cedric-marcoux](https://github.com/cedric-marcoux)
- **Query-style catch-up compatibility.** Accepts `/streaming/timeshift.php` query-string catch-up requests in addition to the path-style endpoint. Path and query requests now use client-supplied duration hints when present, add a short provider-lag buffer, and fall back to EPG duration, then 120 minutes. Thanks [@dillardblom](https://github.com/dillardblom)
- **REST catch-up API for native apps.** `POST /api/catchup/sessions/` (JWT or API key) mints a server-side playback session and returns a tokenless `playback_url` at `/proxy/catchup/<channel_uuid>?session_id=...` for headerless video players. Sessions accept an optional programme `duration` in minutes, using the same buffered duration handling as XC clients. `POST /api/catchup/sessions/<session_id>/position/` lets native apps report local playhead / pause state for accurate admin stats without seeking the provider. `DELETE /api/catchup/sessions/<session_id>/` ends the session. OpenAPI docs cover handshake and idle TTL behaviour.
- **Catch-up admin APIs.** `GET /proxy/catchup/stats/` lists active catch-up viewers; `POST /proxy/catchup/programs/` returns batch EPG metadata for those sessions; `POST /proxy/catchup/stop_client/` stops a viewer from the admin UI.
- **Catch-up Stats UI.** Dedicated catch-up connection cards show channel logo, programme preview (watched/remaining timeline), playback position, bitrate, and a stop button. When playback continues past the original programme into the catch-up buffer, the card advances to the next EPG programme. Stats refresh in real time via WebSocket (`timeshift_stats`) with desktop notifications when catch-up sessions start or end.
- **Catch-up flags** (`tv_archive`, `tv_archive_duration`) denormalized at import time for zero-cost output queries; end-of-refresh SQL rollup updates channels linked to the refreshed account and self-heals stale flags on those channels only (e.g. after catch-up streams are removed or an account is deactivated).
- **Strictly-UTC API surface, automatic per-provider timezone.** `server_info.timezone` is always `UTC` and the XC EPG `start`/`end` strings are emitted in UTC; the proxy converts the requested instant to the serving provider's own reported timezone (`server_info.timezone` captured on account refresh) at request time. No timezone to configure.
- **Multi-provider failover.** Catch-up walks the channel's catch-up streams in order (like live playback): if one provider cannot serve the archive the next is tried, each attempt with its own account context (credentials, reported timezone, user-agent). Accounts that fail with an auth/ban-class status are not retried via their other streams. Attempts prefer streams whose `catchup_days` cover the requested programme age, then fall back to shorter archives in channel order.
- **Provider pool accounting.** Catch-up reserves a provider profile slot (`connection_pool`) before connecting upstream — same contract as live and VOD — and releases it when the session ends. When the default profile is at capacity it walks the account's alternate profiles with their own resolved credentials, and answers `503` when every profile is full.
- **Per-client session pool.** The first request without `session_id` and no fingerprint match receives a `301` redirect to the same URL with a stable `?session_id=` query parameter; that ID becomes the client identity for stats, stop keys, and pool coordination. Reconnects that omit `session_id` but match an existing pool entry (same user, IP, and user-agent) are served immediately without a redirect, matching IPTV client fast-forward behaviour. Each viewer gets their own Redis-backed pool entry even when watching the same programme; idle sessions can be reclaimed via fingerprint matching. Plain GET restarts stream from byte 0 with upstream `Content-Length` when known (provider-faithful). Real scrubs within a session stop the in-flight stream through the standard stop-key mechanism; parallel startup probes (full-file, open-ended, and EOF tail `Range` requests) are deferred with `503` instead of opening extra upstream connections. Parallel HTTP probes from the same `session_id` for the same programme do not count as extra streams toward the user limit; distinct sessions still each consume a slot.
- Verbose timeshift logging follows the standard logger DEBUG level.
- **EPG XMLTV `prev_days` auto-detection** from the provider's largest `tv_archive_duration` (capped at 30 days), overridable via per-user `epg_prev_days` or `?prev_days=` URL parameter.
- **Byte path streams directly via `iter_content` + generator yield** (same pattern as the VOD proxy), with an upfront MPEG-TS sync peek that strips any PHP-warning preamble before bytes reach strict demuxers. Throughput stays comfortably above the typical 5 Mbps FHD bitrate so clients don't buffer.
- **Catch-up indicators in Channels and Streams tables.** Channels and streams with catch-up enabled show a grey history icon beside the name (tooltip includes archive days when known). Expanded channel stream rows show a catch-up badge. Channel and stream API serializers expose `is_catchup` and `catchup_days` for the UI. The Channels and Streams table filter menus include **Only Catch-up** to narrow each list to catch-up entries.
- **Combined connection stats API.** `GET /proxy/stats/` (admin) returns live, VOD, and catch-up connection stats in one JSON response (`live`, `vod`, `catchup`, `timestamp`). The Stats page polls this endpoint instead of separate live and VOD stats requests.
- **Frontend unit tests extended to table components.** `ChannelsTable`, `StreamsTable`, `M3UsTable`, `EPGsTable`, `LogosTable`, `CustomTable`, and related header/editable subcomponents now have Vitest + Testing Library suites. Table business logic was extracted into `frontend/src/utils/tables/*` (with shared `M3uTableUtils` for M3U/EPG sort headers) and covered by dedicated util tests. `dateTimeUtils.formatDuration` gained a `human` precision mode for readable content lengths. — Thanks [@nick4810](https://github.com/nick4810)
- **Frontend unit tests extended to plugin, backup, and settings components.** `AboutModal`, `PluginDetailPanel`, `BackupManager`, `AvailablePluginCard`, `ConnectionSecurityPanel`, and `UserLimitsForm` were refactored with business logic moved into `frontend/src/utils/components/*` and `PluginsUtils`; `pluginUtils` (version comparison, compatibility labels, install/downgrade detection) now lives under `utils/components/`. Vitest + Testing Library suites were added for those components plus `PluginWarnings`. — Thanks [@nick4810](https://github.com/nick4810)
- **Frontend unit tests extended to forms, modals, Connect, and Plugin Browse.** `OutputProfile`, `ServerGroup`, `CreateChannelModal`, `ProfileModal`, `Connect`, `ConnectLogs`, `PluginBrowse`, and `useEpgPreview` now have Vitest + Testing Library suites. Repo-management UI was extracted from `PluginBrowse` into `ManageReposModal`; form/schema helpers moved into `OutputProfileUtils` and `ServerGroupUtils`, with plugin-repo settings helpers added to `PluginsUtils`. — Thanks [@nick4810](https://github.com/nick4810)
### Changed
- **Channel list API paginates when only `page_size` is provided.** `ChannelPagination` previously required an explicit `page` query parameter; sending `page_size` alone returned the full unpaginated queryset. Requests with `page_size` but no `page` now paginate normally (page 1). Omitting both `page` and `page_size` still returns the full queryset for legacy clients and plugins.
- **VOD proxy now fails over across M3U accounts when the selected account is at capacity.** `stream_vod()` and `head_vod()` previously picked the highest-priority relation and returned HTTP 503 if that account's profile pool was full, without trying other accounts carrying the same title. The proxy now materialises active relations in a single query, walks them in priority order (preferred stream/account first), and streams from the first account with spare capacity—matching live-channel failover. (Closes #1385) — Thanks [@francescodg89-crypto](https://github.com/francescodg89-crypto)
- **URL validation now accepts underscores in non-FQDN hostnames.** Stream, M3U server, and EPG source URLs with Docker-style host labels (e.g. `http://my_service/`, `http://dispatcharr_web:8000/`) were rejected because the flexible-URL fallback did not allow underscores in hostname characters. — Thanks [@recurst](https://github.com/recurst)
### Performance
- **CoreSettings JSON groups are cached in Redis.** `CoreSettings._get_group()` caches stream, system, proxy, DVR, EPG, user-limit, and network-access settings so hot paths (including `network_access_allowed` on every stream/XC/catchup request and catch-up enable checks) no longer hit Postgres on each call. Cache entries invalidate on `CoreSettings` save or delete; proxy workers also clear their short process-local proxy-settings copy when `proxy_settings` changes.
- **M3U/XC stream refresh is faster on large accounts.** Steady-state refreshes split `bulk_update` into a lightweight touch pass (`last_seen` / `is_stale` only) for unchanged streams and a full column update only when provider metadata or catch-up fields actually change.
- **M3U stream filters compile once per refresh.** Account filters are regex-compiled before batch workers start; accounts with no filters skip per-stream filter checks entirely.
- **M3U refresh releases parse catalogs sooner.** Standard accounts stream-parse the on-disk M3U instead of loading the full file into RAM; `extinf_data` is dropped immediately after batch DB work, before stale cleanup and auto-sync.
- **XC live refresh avoids redundant work during catalog filtering.** `collect_xc_streams` skips disabled categories before building entries and uses a shared URL prefix instead of formatting each stream URL separately. Auto-sync releases per-group logo/EPG caches after each group iteration.
- **Celery workers return RSS after memory-intensive tasks.** `cleanup_memory()` accepts an optional `trim_heap` flag (glibc `malloc_trim`); Celery `task_postrun` enables it after `close_old_connections()` for M3U account/group refresh, EPG, VOD, and channel-matching tasks so worker memory drops back toward baseline instead of ratcheting across successive large jobs.
### Security
- **Authentication boundaries and local logo serving.**
- Logo and VOD logo `cache` endpoints no longer serve arbitrary filesystem paths: local URLs must resolve under `/data/logos` (blocks `/data/../…` traversal). Empty logo URLs return 404 instead of attempting a remote fetch.
- EPG source file upload API (`POST /api/epg/sources/upload/`) is admin-only and uses `safe_upload_path` so uploaded filenames cannot escape `/data/uploads/epgs`. The web UI does not expose this endpoint (EPG sources are added via URL, Schedules Direct, or dummy); the fix hardens the API-only path.
- M3U account passwords are omitted from API responses for non-admins; admins still receive them (the M3U edit form already blanks the password field on load).
- Channel bulk-regex rename, EPG name/logo/tvg-id apply, channel reorder, channel-group cleanup, recording stop/extend/comskip/metadata actions, and Connect integration APIs require admin. Guide-facing channel read helpers (`summary`, `ids`, etc.) remain available to standard users.
- System notifications: any authenticated user can still list and dismiss notifications visible to them; create/update/delete require admin (previously any authenticated user could create notifications).
- Django `auth.Group` CRUD (`/api/accounts/groups/`) and the permissions list endpoint require admin. Dispatcharr authorization uses `user_level`, not these groups; the React UI does not call these endpoints.
- Removed unused unauthenticated routes: `/proxy/hls/` (HLS proxy package retained for possible future use) and legacy `/output/stream/<uuid>/` (including the nginx location). Live playback continues via `/proxy/ts/stream/`.
### Fixed
- **Deleting or sync-removing a channel while it is playing no longer leaves a hung proxy session.** Manual deletes no longer force-stop playback (optional `stop_stream` on the API / "Also stop active channel if playing" in the UI). M3U auto-sync and account cleanup still stop proxy sessions before removing channel rows. If a stream is left playing after a delete, later Stats stop / disconnect still releases the M3U profile connection slot from Redis metadata even when the Channel row is gone. (Fixes #870)
- **Cron builder quick presets with step intervals (e.g. every 6/12 hours) no longer apply the wrong expression.** Selecting `0 */6 * * *` or `0 */12 * * *` previously rebuilt through Simple-mode controls that could not express hour steps, so Apply saved `0 6 * * *` / `0 12 * * *` instead. Hourly frequency now includes an Interval dropdown (every hour, every 2/3/4/6/8/12 hours), and those presets load into it so editing keeps the step pattern. (Fixes #1320)
- **Live proxy fMP4/profile output Redis keys no longer expire during long sessions.** `output:{fmt}:owner`, `state`, and fMP4 `init` were written once with a 3600s TTL and never refreshed, so sessions longer than an hour lost coordination keys even while the remux/transcode was still running (late joiners and cross-worker `ensure_output_format` could fail). Those TTLs are now extended about once per minute from the manager reader loop while alive; graceful stop still deletes the keys immediately.
- **Ghost channel sessions stuck in `initializing` no longer block playback forever.** Failed live-proxy initialization previously wrote `state=initializing` to Redis _before_ acquiring the channel ownership lock. If init died in that window (exception, worker race), metadata was left behind with no owner lock, no URL, and no TTL. Later play requests treated the live worker heartbeat as proof the channel was healthy, attached to the dead session, and got `Error: Connection stalled` forever; failover never ran. The M3U profile connection slot from the failed init also leaked. Initialization now writes `initializing` only after ownership is held, tears down Redis/local leftovers immediately on failure, and gives temp/init metadata a TTL backstop. Play setup claims ownership (plus a same-worker in-progress flag) under a short gevent lock _before_ `generate_stream_url`, then releases that lock so followers are not blocked for the URL/init work.
- **Live proxy no longer leaks geventpool DB connections across stream setup / teardown.** `stream_ts()` released its checkout only on the happy path before `StreamingHttpResponse`, so early JSON failures, exceptions, 404s, and aborted channel init left slots counted against `MAX_CONNS` until restart (XC/`player_api` hung while `/api/core/version/` stayed fast). Setup now always calls `close_old_connections()` in a `finally` (including re-raised `Http404`); `ProxyServer.initialize_channel`, `ChannelService.initialize_channel`, XC auth, `next_stream`, channel status, and the ffmpeg stderr reader do the same. Channel/stream display names are taken from the caller or Redis during `StreamManager` / TS / fMP4 generator construction so those paths no longer check out the pool just to resolve a name. (Fixes #1418)
- **uWSGI/Daphne boot no longer leaves a permanent geventpool checkout on `core_coresettings`.** `AppConfig.ready` paths that sync the backup scheduler (cron timezone via `system_settings`), developer notifications, and plugin repo refresh now call `close_old_connections()` in `finally`, so a stuck idle `system_settings` query no longer burns one of the 8 pool slots from process start.
- **Live channel Redis `state` no longer stays latched at `buffering` after a buffering-timeout failover.** When FFmpeg speed dipped below the buffering threshold and the timeout triggered a stream switch, the in-memory buffering flag was cleared without rewriting Redis, and the same stats sample could re-write `buffering`. After the new stream recovered, the speed-good path only cleared Redis when that flag was still set, so a healthy session could report `buffering` until restart or retune. A successful buffering-timeout switch now writes `active` and skips the fallthrough `buffering` write. (Fixes #1449)
- **Plugin discovery no longer force-reloads on every connect event in multi-worker setups.** Reacting to a newer `.reload_token` (after install/update/reload) previously re-touched the token, so each uWSGI worker's next discovery re-staled every other worker and never converged. Streaming connect/disconnect events then re-imported every enabled plugin on the request path, leaking plugin background threads and degrading workers until restart. Stale-token reactions now reload locally without bumping the shared token; only an explicit `force_reload=True` (install/update/reload API) broadcasts. (Fixes #1452)
- **Channels table "Copy URL" no longer includes the web player's output profile.** The kebab-menu action was reusing the in-app preview URL builder, so copied links could include `output_format=mpegts` and `output_profile=...` from web-player prefs. Copy now emits a plain `/proxy/ts/stream/{uuid}` URL suitable for external players; Watch still applies player prefs.
- **Redis-backed VOD sessions no longer leave stale profile connection reservations after multi-worker range-request teardown.** Jellyfin/FFmpeg clients that issue concurrent byte-range requests for one VOD session could finish teardown while another worker held the session metadata lock (`vod_connection_lock:*`). `decrement_active_streams_and_check()` then failed with `DECR-AS-CHECK failed: could not acquire lock`, skipped the profile-slot release, and left `profile_connections:*` and the session hash occupied until restart or manual Redis cleanup. Per-session `active_streams` is now mutated with Redis Lua (independent of the metadata lock), metadata saves omit and never clobber that counter, idle cleanup deletes the session hash only when still idle, and late metadata writes cannot recreate a deleted session. (Fixes #1426)
- **EPG programme times no longer shift when PostgreSQL's server default timezone is non-UTC.** After the psycopg3 / Django upgrade, geventpool sessions were no longer pinned to UTC: Django's connection timezone setup is skipped whenever `self.pool` is truthy, and the older `connection_created` `SET TIME ZONE 'UTC0'` receiver was a nested closure registered with a weak reference, so it did not reliably stay registered (in production with `DEBUG=False` it was garbage-collected and never ran; `DEBUG=True` could keep it alive via Django's inspect LRU cache). Sessions that lacked a live pin therefore inherited the server default; on deployments whose default is non-UTC (for example from `/etc/localtime` bind-mounts), psycopg3 decoded `timestamptz` values under that zone and XMLTV output labeled local wall-clock times as `+0000`. The pool backend now sets `TimeZone=UTC` in the libpq startup packet so every pooled connection starts in UTC (and `RESET TimeZone` returns to UTC), and the fragile signal is removed. Stored data was never corrupted, only reads were wrong. (Fixes #651) — Thanks [@nagelm](https://github.com/nagelm)
- **Frontend date/time unit tests no longer fail outside UTC / en-US.** Three tests encoded the machine timezone or locale into their expectations (`dateTimeUtils.isSame`, `RecordingUtils.toDateString`, `SeriesModalUtils.getEpisodeAirdate`), so the suite failed on boxes east of UTC or non-US locales. Fixtures now use local calendar datetimes and locale-computed expectations so the suite passes in every environment. — Thanks [@nagelm](https://github.com/nagelm)
- **API error toasts no longer dump raw HTML error pages.** Failed responses that return Django or nginx HTML (for example 500/502/504 when the backend is down) were interpolated into the toast body as markup. Errors are now formatted for display: JSON bodies prefer `detail` / `error` / field messages, HTML and empty bodies collapse to a short status line, and long plain-text bodies are truncated. (Fixes #1261) — Thanks [@nagelm](https://github.com/nagelm)
- **Swagger/OpenAPI schema generation no longer fails under concurrent gevent requests.** Concurrent hits to `/api/schema/` (for example Swagger UI loading) could race on DRF's shared `AutoSchema` state and raise `AssertionError: Schema generation REQUIRES a view instance`, leaving Swagger empty. Cold builds could also hang the gevent uWSGI worker. Schema introspection now runs in a real OS thread, builds are single-flight per process, and the result is cached in Django's cache (Redis) so all workers share it.
- **`/proxy/ts/change_stream` now persists `stream_id` and waits for the real switch outcome in multi-worker deployments.** When the request landed on a worker that didn't own the channel, the owning worker's `STREAM_SWITCH` pubsub handler performed the switch but wrote only `url`/`user_agent` back to the channel's Redis metadata, so `GET /proxy/ts/status` kept reporting the old `stream_id` indefinitely; the handler now persists the full switch metadata (`stream_id`, `m3u_profile`, stream name) via the same `_update_channel_metadata()` helper the direct owner path uses. `stream_name` from the initial `get_stream_info_for_switch()` lookup is now forwarded through the pubsub payload (and from `change_stream` / `next_stream` on the direct owner path) so the owner no longer re-queries the database when writing metadata. The endpoint also no longer returns an optimistic 200 on the non-owner path: the requesting worker waits (up to 15s) for the owner to confirm via the `switch_status` Redis key and answers 502 if the owner reports failure or 504 if no confirmation arrives (e.g. owner worker gone); `next_stream` gets the same treatment. Requesting a switch to the URL the channel is already playing is now treated as success (with a metadata refresh) instead of a silent no-op, and the `switch_status` key now expires (60s TTL) instead of persisting forever. (Fixes #1412)
- **XC live streams, `/panel_api.php`, and `/xmltv.php` no longer crash when a visible channel has no channel number.** Since `channel_number` became nullable, auto-sync and compact numbering can leave a channel with `effective_channel_number=None` while it remains visible in output (`hidden_from_output=False`). The XC live-stream number map skipped those rows but still tried to emit them, causing `KeyError` on `get_live_streams` and a 500 on `/panel_api.php` (`xc_get_info`). Authenticated XMLTV export (`/xmltv.php`, profile EPG with a logged-in user) had the same gap and could fail chunk-cache rebuilds with `KeyError` on `channel_num_map[channel.id]`. Null-number channels are now deferred into the existing collision-free integer assignment pass (same second loop as fractional numbers) and receive the next available integer; `_xc_channel_entry` also falls back to `channel.id` if a row is still unmapped. HDHR lineup behaviour is unchanged (null-number channels remain omitted there).
- **System events and Connect dispatch no longer close the DB connection mid-call outside worker contexts.** `log_system_event()` and `dispatch_event_system()` always called `close_old_connections()` in `finally` blocks, which broke Django `TestCase` transactions and could interrupt in-flight ORM work when Connect/plugin handlers ran synchronously. Closes are now limited to gevent uWSGI workers (and the Celery sync-dispatch path where gevent is patched but no hub runs). Celery workers still reset connections via existing `task_postrun` / `task_prerun` hooks on the standard PostgreSQL backend (no geventpool).
- **Connect `trigger_event` no longer releases DB connections during plugin discovery.** Event dispatch calls `discover_plugins(use_cache=True)` to resolve plugin handlers; its unconditional `close_old_connections()` could tear down the caller's connection before `log_system_event()` or Schedules Direct refresh finished. Discovery from event dispatch now passes `release_connections=False`; boot-time discovery (`worker_process_init`, `post_migrate`, admin reload) still releases connections by default.
- **EPG Celery tasks no longer reset the DB connection when invoked outside a worker.** `_release_task_db_connection()` is gated on `_is_celery_worker_context()` so synchronous test runs and in-process task calls do not close the test database connection; active Celery tasks still reset poisoned connections after transient ORM errors.
- **Celery workers no longer use django-db-geventpool (fixes Postgres protocol errors under concurrent tasks).** Celery's default queue runs prefork (`--autoscale=6,1`), not gevent, but it was sharing the same `django-db-geventpool` backend as uWSGI. That pool is a process-wide singleton of warm connections; `fork()` (including autoscale spawning new children on demand) duplicated those sockets across processes and corrupted Postgres session state (`the last operation didn't produce records (command status: SET/BEGIN/ROLLBACK)`, `connection ... in transaction status INTRANS`, and matching server-side `there is already a transaction in progress` on one backend). Celery workers and beat now use Django's standard PostgreSQL backend (`CONN_MAX_AGE=0`, no warm pool); uWSGI keeps geventpool. Plugin discovery moved from `worker_ready` (arbiter) to `worker_process_init` (each child after `connections.close_all()`), so the prefork parent no longer opens DB handles that children would inherit. (Fixes #1404)
- **EPG channel parsing progress no longer exceeds 100%.** During `parsing_channels`, progress used the pre-parse database channel count as the denominator while the numerator counted channels found in the XML. When the guide grew (or on sources where the file had more channels than the DB), the UI could show values well above 100% (e.g. ~300%). The estimate now bumps when the XML exceeds the DB baseline, progress is capped at 98% until final cleanup, and update frequency adapts to the known total (~20 ticks on steady-state refreshes; coarse every-100-channel updates for first import or when the file outgrows the estimate). A final in-loop update reports the true parsed count before completion.
- **EPG refresh and per-channel parses no longer race.** Full-source refresh holds an `epg_source_file` lock through download, channel parse, and bulk programme swap; programme index builds acquire the same lock separately (after refresh releases it). Per-channel `parse_programs_for_tvg_id` tasks run concurrently for different tvg-ids (no file lock between them) but defer while a source refresh is in progress. Refresh waits until in-flight per-channel parses finish (Redis counter) before downloading. Channels matched mid-refresh are excluded from the bulk-parse snapshot but receive a follow-up per-channel parse when refresh finishes; orphan cleanup at swap time uses live channel assignments so mid-refresh matches are not wiped. Duplicate tasks for the same `epg_id` are deduped via `parse_epg_programs` lock with a log noting how many channels map to that EPG. Busy file/index deferrals retry for up to two hours (15s interval).
- **Per-channel EPG parse no longer wipes guide data on failure.** `parse_programs_for_tvg_id` used to delete existing programmes before streaming the XML and flush new rows in batches as it parsed, so any failure partway through left a mapped channel with no guide data. It also re-fetched the `EPGData` row mid-task instead of reusing the instance loaded at task start. The task now reuses the initial row and replaces programmes in one transaction at the end (delete old, insert new), so a parse failure leaves the previous guide intact.
- **Cold `/output/epg` rebuild no longer freezes the gevent uWSGI worker.** CPU-bound XMLTV generation in the chunk-cache leader loop ran without yielding to the gevent hub, so login, API, and HDHomeRun requests on the same worker hung for the full rebuild duration. `_stream_build` now calls `_cooperative_yield()` (`gevent.sleep(0)`) after each cached chunk so other greenlets can run between programme batches. (Fixes #1396)
- **M3U refresh completion counts now reflect actual stream changes.** The "updated" count previously included every existing stream because the summary treated `last_seen` touch-only rows as updates; it now counts only streams whose provider metadata changed. "Total processed" includes unchanged streams separately. The completion message, WebSocket payload, and parsing notification now also report how many streams were **marked stale** (missing from this refresh, pending retention-gated deletion) versus **removed** (deleted this run).
- **M3U group processing retries on poisoned DB connections.** `_db_query_with_retry` now treats psycopg desync errors (`DatabaseError`, e.g. `lost synchronization with server`) as transient; `process_groups` relationship loading uses it so a stale Celery worker connection resets once instead of failing the whole refresh.
- **Auto Channel Sync's Find and Replace preview now matches the rename it performs.** The preview rendered the literal `$1` instead of substituting numbered capture groups, and compiled patterns with a stricter engine than the rename, so patterns like `^*` previewed a change the sync silently skipped. The live rename now uses the same `regex` engine as the preview (with a substitution timeout to bound catastrophic backtracking), both apply the `$1``\1` conversion through one shared helper, and a rename whose result exceeds the channel-name column length is truncated instead of aborting the whole sync. (Fixes #1332) — Thanks [@CodeBormen](https://github.com/CodeBormen)
- **XC refresh no longer wipes auto-created channels on an empty provider fetch.** When `collect_xc_streams()` returned no live streams (transient upstream failure, fetch exception, or no enabled category matched), the refresh used to continue into stale-marking and auto-sync, which deleted the account's entire auto-created channel lineup. An empty XC result now aborts before those steps, sets the account to `ERROR`, and preserves the existing lineup—matching the standard M3U path's empty/failed-download guards. (Fixes #1377) — Thanks [@Jacob-Lasky](https://github.com/Jacob-Lasky)
- **VOD refresh no longer wipes group selections on an empty category fetch.** When `get_vod_categories` or `get_series_categories` returned `[]` (transient provider outage, rate limiting, or VOD API hiccup during a scheduled refresh), `batch_create_categories` treated every existing relation as orphaned, deleted category links and often the `VODCategory` rows themselves, and the follow-up cleanup removed thousands of movie relations. On the next successful refresh, groups were recreated with `enabled=False` when `auto_enable_new_groups_vod` was off—matching reports of all VOD groups appearing unselected after routine M3U refreshes. An empty category list now aborts the VOD refresh before any relation deletion or content cleanup when the account already has category selections (new accounts with no existing relations are unchanged).
- **`ChannelUtils.requeryChannels()` returns the API promise again** so bulk channel delete, drag reorder, and inline channel-number saves await the table refetch before clearing selection or continuing. — Thanks [@nick4810](https://github.com/nick4810)
- **VOD connection card Content Duration badge** shows human-readable lengths (`45m`, `2h 0m`) again on the Stats page instead of bare minute integers. — Thanks [@nick4810](https://github.com/nick4810)
- **Frontend unit tests pass on Node 25+ again.** Node 25+ exposes a native `localStorage` stub on `globalThis` that lacks Storage API methods (`getItem`, `clear`, etc.), which shadows jsdom's implementation and breaks tests that touch `localStorage` (e.g. `TypeError: localStorage.clear is not a function`). `setupTests.js` now installs an in-memory shim on `globalThis` and `window` when those methods are missing; the shim is skipped automatically once Node provides a working implementation. — Thanks [@nick4810](https://github.com/nick4810)
- **Live proxy failover works with the default VLC stream profile.** When `cvlc` could not open an upstream URL it often stayed running in dummy mode without writing TS data, so the stream manager never left its read loop and never exhausted retries to switch streams. The locked VLC profile now passes `--play-and-exit` (migration 0027 for existing installs), and `VLCLogParser` treats `unable to open the mrl` as a fatal input error so the connection is closed and the normal retry → failover cycle can proceed. (Fixes #1415)
- **Live proxy no longer fails over after isolated provider disconnects.** Connection retries on the same URL used to accumulate indefinitely, so three brief drops spread across many hours of otherwise stable playback could exhaust retries and switch streams even when each reconnect succeeded. Retries now reset after 30 minutes without a failure (`RETRY_WINDOW_SECONDS`), so periodic provider drops (for example every few hours) each get a full retry budget on the current stream. Failover still triggers when three failures occur within that window.
- **Live proxy failover now walks backup streams in channel order.** After a stable session on a backup stream, `tried_stream_ids` is cleared so rotation continues from the current position (stream 2 → 3 → 4 → 1) instead of jumping back to stream 1. `get_alternate_streams()` returns alternates in that rotated order, matching the manual next-stream API.
- **Live proxy preview no longer 500s when joining an active channel on a non-owner worker.** `stream_ts()` now pre-registers the client before `ensure_output_profile()` (the same watchdog protection owners already had), so the non-owner cleanup thread no longer tears down `client_manager` while output-profile transcode is still starting. Failed setup paths remove the client again and return 503 instead of an unhandled `KeyError`.
- **Backend test suite reliability.** `dispatcharr.settings_test` creates `test_dispatcharr` as UTF-8 (`template0`) so EPG programme indexes and Unicode XMLTV data round-trip correctly. Tests were updated for DOCTYPE-based HTML entity resolution, EPG name-normalization expectations, migration 0037 unit invocation, Schedules Direct mocks, and other areas; full app test modules are discoverable when listed explicitly (bare `manage.py test` still only runs `core.tests` and top-level `tests/`).
- **Backup Manager "Created" column now updates when date/time format preferences change.** The table column definitions were memoized with an empty dependency array while closing over `fullDateTimeFormat`, so changing display preferences did not refresh backup timestamps until the page was reloaded.
## [0.27.2] - 2026-06-30
### Added
- **New proxy setting: Client Connect Grace Period (`channel_client_wait_period`, default 5s).** Adds a dedicated timeout for channels that have filled their buffer but still have no viewers (`waiting_for_clients`). Previously that window reused `channel_shutdown_delay` (default 0s), so those channels were torn down almost immediately.
### Changed
- **Proxy grace-period settings are now split into three distinct timeouts.** The cleanup watchdog already applied `channel_init_grace_period` while a channel was still connecting (buffer not ready) and reused `channel_shutdown_delay` once `connection_ready_time` was set, including for `waiting_for_clients` with zero viewers. With the default `channel_shutdown_delay` of 0s, a buffered channel waiting for its first viewer was stopped almost immediately; raising shutdown delay was the only workaround, but that also delayed teardown after real disconnects. Behaviour is now:
- **`channel_init_grace_period` (default 60s, max 300s):** how long the proxy may spend connecting and cycling failover streams before giving up on startup.
- **`channel_client_wait_period` (default 5s):** how long a ready channel with no viewers stays up waiting for the first client (the original grace-period use case).
- **`channel_shutdown_delay` (default 0s):** delay after the last client disconnects only; no longer applies when the buffer is ready but no viewer has connected yet.
- **Migration 0026 bumps `channel_init_grace_period` to 60s when the stored value is below 60.** Existing installs on the old 5s default (or any custom value under 60) are raised automatically. Values already at 60s or higher are unchanged. If you previously raised `channel_shutdown_delay` to keep buffered channels alive with no viewers, set `channel_client_wait_period` instead (Settings → Proxy → Advanced).
- **Proxy settings UI: less-used options moved under Advanced.** Settings → Proxy now shows the day-to-day tuning fields by default (`buffering_timeout`, `buffering_speed`, `channel_shutdown_delay`, `new_client_behind_seconds`). **Buffer Chunk TTL**, **Channel Initialization Timeout**, and **Client Connect Grace Period** are tucked under **Show Advanced Settings**.
## [0.27.1] - 2026-06-25
### Security
- Updated `Django` 6.0.5 → 6.0.6, resolving the following CVEs:
- **CVE-2026-6873**: Signed cookie salt namespace collision in `HttpRequest.get_signed_cookie()`.
- **CVE-2026-7666**: Potential unencrypted email transmission via STARTTLS in the SMTP backend.
- **CVE-2026-8404**: Potential private data exposure via case-sensitive `Cache-Control` directives in `UpdateCacheMiddleware`.
- **CVE-2026-35193**: Potential private data exposure via missing `Vary: Authorization` in `UpdateCacheMiddleware`.
- **CVE-2026-48587**: Potential private data exposure via whitespace padding in the `Vary` header.
- Updated frontend npm dependencies to resolve 4 audit vulnerabilities (1 low, 2 moderate, 1 high):
- Updated `vite` 7.3.2 → 7.3.5, resolving **moderate** NTLMv2 hash disclosure via UNC path handling on Windows ([GHSA-v6wh-96g9-6wx3](https://github.com/advisories/GHSA-v6wh-96g9-6wx3)) and **high** `server.fs.deny` bypass on Windows alternate paths ([GHSA-fx2h-pf6j-xcff](https://github.com/advisories/GHSA-fx2h-pf6j-xcff))
- Updated `js-yaml` 4.1.1 → 5.1.0, resolving **moderate** quadratic-complexity DoS in merge key handling via repeated aliases ([GHSA-h67p-54hq-rp68](https://github.com/advisories/GHSA-h67p-54hq-rp68))
- Updated `esbuild` 0.27.3 → 0.28.1, resolving **low** arbitrary file read when running the development server on Windows ([GHSA-g7r4-m6w7-qqqr](https://github.com/advisories/GHSA-g7r4-m6w7-qqqr))
### Added
- **Isolated backend test settings (`dispatcharr.settings_test`).** `python manage.py test` now switches to this module automatically (via `manage.py`). It creates an empty PostgreSQL `test_<dbname>` database (same engine as production), uses the standard Postgres backend instead of geventpool so `TestCase` transactions isolate correctly, and leaves Celery tasks queued (no eager `post_save` signal runs during tests). Set `TEST_USE_SQLITE=1` for an in-memory SQLite fallback when Postgres is unavailable.
### Performance
- **`get_vod_streams` and `get_series` XC API endpoints are faster and no longer exhaust Docker `/dev/shm`.** Large libraries (e.g. 125k movies) previously ran one wide `DISTINCT ON` query with parallel workers, which could fail with `could not resize shared memory segment … No space left on device` on the default 64MB container shm. Both endpoints now fetch display columns via `.values()` (no ORM model instantiation per row). Redundant `category` and `logo` joins were dropped in favor of FK ids; alphabetical sort runs in SQL. Typical full-library response time drops from ~2328s to ~810s with stable shm usage.
- **XMLTV EPG export is faster and no longer balloons worker memory.** `generate_epg()` was reworked end-to-end for large guides. (Fixes #1366)
- Streams incrementally: on a cache miss each chunk is pushed to a Redis list as it is yielded (no `''.join()` in the worker); repeat requests within 300s stream chunks back from Redis. `malloc_trim` runs after cold builds.
- Channel streams are prefetched once (only `id`/`name`) instead of one query per custom-dummy channel; dummy `EPGData` programme existence is bulk-checked in a single query.
- The primary channel id is escaped once per `epg_id` group instead of once per programme (~750k fewer `html.escape` calls on a large guide).
- The channel query no longer JOINs multi-MB `programme_index` blobs per channel (~13s saved on a ~2000-channel guide; indices live in `EPGSourceIndex`).
- Programme export uses `(epg_id, id)` keyset pagination with a per-source `start_time` sort; a matching composite index on `ProgramData` (created `CONCURRENTLY` on PostgreSQL) lets each chunk use an ordered index range scan instead of re-sorting every chunk.
- **EPG grid endpoint releases its payload memory back to the OS.** `/api/epg/grid/` drops the redundant full-list copy when appending dummy programmes and runs `malloc_trim` once the response is sent, so worker RSS no longer ratchets up ~20MB per request.
### Changed
- **`programme_index` moved off `EPGSource` into a dedicated `EPGSourceIndex` table.** The multi-MB byte-offset index was repeatedly pulled into web and Celery workers by ordinary `EPGSource` queries and `select_related` JOINs (which ignore manager-level `defer()`). It now lives in a one-to-one `EPGSourceIndex` row, read only when explicitly accessed through the `EPGSource.programme_index` property, so no list, detail, or JOIN query can load it by accident. Migration `0026` copies existing indices across. No API or index-build behavior change.
- **EPG generation extracted into `apps/output/epg.py`.** All XMLTV output logic (`generate_epg`, `generate_dummy_programs`, `generate_custom_dummy_programs`, `generate_dummy_epg`, and supporting helpers) moved from `apps/output/views.py` into a dedicated module. `views.py` retains the thin HTTP endpoint wrappers and auth checks; `epg.py` handles all content generation. No behavior change.
- **Channel list with nested streams loads faster.** `GET /api/channels/channels/?include_streams=true` (Channels UI and single-channel fetch) now builds nested stream payloads from the prefetched `channelstream_set` instead of issuing one extra streams M2M query per channel.
- Dependency updates:
- `Django` 6.0.5 → 6.0.6 (security patch; see Security section)
- `requests` 2.33.1 → 2.34.2
- `gevent` 26.4.0 → 26.5.0
- `torch` 2.11.0+cpu → 2.12.1+cpu
- `sentence-transformers` 5.4.1 → 5.6.0
- `lxml` 6.1.0 → 6.1.1
### Fixed
- **Channel Initialization Grace Period is honoured during live stream startup.** Preview and playback no longer abort after a hardcoded 10s while the channel is still connecting with an empty buffer; the TS generator init-wait stall check and upstream health monitor now use the configured `channel_init_grace_period` (same as the server cleanup watchdog) instead of `CONNECTION_TIMEOUT`. (Fixes #1380)
- **Channels are marked `active` as soon as the buffer threshold is met and a client is streaming.** Once the initial buffer fills, state is set to `active` immediately when viewers are attached, or `waiting_for_clients` when the buffer is ready but no client is connected yet (e.g. proxy API warmup). The cleanup watchdog no longer waits an extra grace period before promoting to `active`. Proxy settings copy now describes `channel_init_grace_period` as an initialization buffer timeout.
- **DVR recording playback auth is complete for native video, HLS segments, and redirects.** Completed recordings use `/file/` with native `<video src>`, which cannot send `Authorization` headers. Playback accepts `?token=` query-param JWT (matching VOD streams), and the floating video player appends the token when assigning native URLs. The explicit HLS URL route (`/api/channels/recordings/.../hls/...`) now registers the same authenticators as the ViewSet `@action` handlers—previously only header JWT applied on that path, so `?token=` failed for native HLS clients. When the request used `?token=`, rewritten playlist segment URLs and `/file/``/hls/` redirects preserve the token; hls.js clients that authenticate via `Authorization` are unchanged. `QueryParamJWTAuthentication` reads `request.query_params` on DRF requests.
- **In-progress DVR playback no longer jumps to the live edge.** The floating video player still opens in-progress recordings at the start of the seekable range, but hls.js was configured with `liveMaxLatencyDurationCount: 10`, which forced the playhead forward to "now" shortly after playback began. Live-edge sync is now disabled for recording HLS so users can watch, pause, and scrub from the beginning while the recording continues. (Fixes #1329)
- **`refresh_single_m3u_account` no longer re-raises after setting account ERROR.** Matches `refresh_epg_data`: the account status and UI already reflect the failure; Celery no longer marks the task FAILED after the terminal error state is persisted.
- **M3U refresh recovers DB connections and account status after failures.** Celery workers now call `close_old_connections()` before and after every task; `refresh_single_m3u_account` also resets its DB connection at task start and retries account loads once after a connection reset (avoids opaque `list index out of range` errors from poisoned worker connections). Accounts leave `fetching`/`parsing` for a terminal `error` or `success` state when refresh aborts. Error-path status updates use `QuerySet.update()` on a clean connection instead of `save()` on a connection that may already be INTRANS after `refresh_m3u_groups` or batch ORM failures. Failed group downloads now set `error` instead of returning silently. Refresh start replaces stale `last_message` text. `refresh_account_profiles` releases DB connections after each profile failure. (Fixes #1338)
- **EPG refresh recovers DB connections and source status after failures.** `refresh_epg_data` now mirrors the M3U hardening: connection reset at task start/end, retried source load after a poisoned-worker connection reset, `QuerySet.update()` for error status on a clean connection, and a `finally` guard that moves sources stuck in `fetching`/`parsing` to `error`. Programme index builds are skipped when programme parsing fails. `parse_channels_only` now persists the error status when a missing file has no URL to refetch.
- **M3U `custom_properties` JSON normalization.** Legacy rows and some API writes could store a JSON-encoded string instead of an object in `custom_properties`, causing refresh, profile sync, and auto-sync to fail with `'str' object has no attribute 'get'`. M3U account, profile, and group-relation models normalize on `save()`; group settings bulk writes and read/merge paths use shared helpers in `core.utils` without re-parsing dict values.
- **`POST /api/epg/import/` no longer loads the full EPG source row.** The dummy-source guard uses a narrow existence query instead of `objects.get()`, keeping the import trigger lightweight. Request/response shape is unchanged for the UI, plugins, and external importers.
- **XC live playback URL building now normalizes account server URLs.** On-demand live URLs (introduced for Server Group profile rotation in 0.27.0) rebuild `/live/{user}/{pass}/{stream_id}.ts` from current credentials instead of reusing the synced `stream.url`. That path now runs `normalize_server_url()` so pasted API URLs (e.g. `/player_api.php` with query params) are stripped while sub-paths like `/server1` are preserved. `get_transformed_credentials()` normalizes the base URL at the source; VOD movie and episode relations use the same helper instead of constructing a throwaway `XCClient` (which also avoided per-request HTTP session setup). (Fixes #1363)
- **Live proxy now releases geventpool DB connections on more paths.** `stream_ts()` calls `close_old_connections()` before `StreamingHttpResponse` so clients waiting on channel init do not keep a pool slot while the generator polls Redis. `generate_stream_url()`, `get_stream_info_for_switch()`, `get_alternate_streams()`, and `get_connections_left()` release in `finally` blocks after ORM work on stream-manager failover paths that run outside Django's request cycle. TS client disconnect cleanup matches fMP4, and `ChannelService` metadata helpers release after their ORM lookups.
- **OpenAPI schema paths for nested M3U profiles and filters no longer contain escaped slashes.** Router regex prefixes used unnecessary `\/`, which drf-spectacular serialized literally into the schema and broke client generators (e.g. oapi-codegen). Runtime URL matching is unchanged. (Fixes #1384)
- **Auto-sync range conflict warning no longer flags a group's own channels when using a channel-group override.** The M3U group settings "Range conflict" check compared occupant channel groups against the source group being configured. Auto-sync with `group_override` creates channels in the override target group, so those channels were misclassified as conflicts. The frontend now resolves the effective target group (override target when set, otherwise the source group) for classification. Genuine conflicts—manual channels, other accounts, different groups, or user-pinned numbers—still surface. (Fixes #1331) — Thanks [@CodeBormen](https://github.com/CodeBormen)
## [0.27.0] - 2026-06-16
### Added
- **Manual Server Groups for shared M3U connection limits.** The existing `ServerGroup` model and account FK are now wired into live and VOD playback. Accounts assigned to the same group share a credential-scoped Redis counter when their provider logins match (hashed fingerprint); unrelated logins in the same group keep separate counters. Enforcement uses each profile's `max_streams` - not a group-wide cap - and profiles with `max_streams=0` skip credential pooling for that profile while still rotating on their own per-profile counter. New `apps/m3u/connection_pool.py` centralizes reserve/release, profile rotation, and credential moves on profile switch. (Closes #1137) — Thanks [@Goldenfreddy0703](https://github.com/Goldenfreddy0703)
- **Server Groups manager UI** on the M3U Accounts page: create, rename, and delete groups with account counts and delete confirmation. Groups load on login via a new Zustand store and REST helpers (`/api/m3u/server-groups/`).
- **M3U account form** adds a Server Group picker (including inline “add group”), reorganized into three columns (source/auth, connection limits, sync/content), and a Manage server groups shortcut.
- **VOD profile selection** uses `pool_has_capacity_for_profile()` so grouped accounts respect shared credential limits before a profile is chosen (non-grouped accounts behave as before).
- **Live profile switches** move the shared credential counter when the new profile uses a different provider login; same-login switches leave the credential counter unchanged.
- **Credential release keys** stored at reserve time allow counters to be released even if the M3U profile row is deleted afterward.
- **PostgreSQL `application_name` tagging by process role.** Pool connections now set `application_name` at connect time (e.g. `Dispatcharr-uwsgi-{pid}`, `Dispatcharr-celery-worker-{pid}`, `Dispatcharr-celery-dvr-{pid}`) so `pg_stat_activity` shows which Dispatcharr process owns each backend instead of a generic client label.
### Changed
- **Plugin repo manifests support split download and metadata base URLs.** `download_base_url` and `metadata_base_url` are optional alternatives to `root_url` in the plugin repository manifest, so repo authors can serve metadata (manifests, icons) and release zips from different origins without absolute URLs everywhere. Manifest and icon URLs resolve via `metadata_base_url` then `root_url`; latest/download URLs resolve via `download_base_url` then `root_url`. When `metadata_base_url` is set and a plugin entry has `manifest_url` but no `icon_url`, the icon defaults to `logo.png` in the same directory as the per-plugin manifest. Manifests using only `root_url` behave identically to before. — Thanks [@sethwv](https://github.com/sethwv)
- **Live and VOD connection slot logic now routes through `connection_pool`.** `Channel.get_stream()`, direct `Stream.get_stream()`, VOD profile selection, and the multi-worker VOD connection manager all call `reserve_profile_slot()` / `release_profile_slot()` instead of inline Redis INCR/DECR. VOD profile selection also checks `pool_has_capacity_for_profile()` before choosing a profile.
- **Live XC upstream URLs use current credentials.** `_resolve_live_stream_url()` builds `/live/{user}/{pass}/{stream_id}.ts` from transformed account credentials and the stream's provider `stream_id`, so playback stays aligned with the active login after credential or profile changes instead of reusing a stale `stream.url` from sync.
- **Channel stream switches check pooled capacity.** `get_stream_info_for_switch()` uses `profile_available_for_channel_switch()` when targeting a specific stream, and releases a reserved slot if URL assembly fails after `get_stream()` allocated one.
- **Live proxy init reads Redis assignment once.** After `generate_stream_url()` reserves a slot, the stream handler reads `channel_stream` / `stream_profile` from Redis instead of calling `get_stream()` again, avoiding double INCR under concurrent release.
- **Centralized Dispatcharr User-Agent construction in `core.utils`.** Outbound HTTP calls (Schedules Direct API, update checks, logo/VOD fallbacks, DVR recording clients) now use `dispatcharr_user_agent()`, `dispatcharr_dvr_user_agent()`, and `dispatcharr_http_headers()` instead of ad-hoc `Dispatcharr/{version}` strings and stale `Dispatcharr/1.0` fallbacks.
- **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.
- **Easier EPG search when editing a channel.** The filter in the EPG picker now works more like a normal search box. You can type several words at once (e.g. `sky uk`) and it finds channels where every word appears somewhere in the name or TVG-ID, the order doesn't matter, and a word in the name can pair with one in the ID. Accents are ignored too, so `decale` matches `Décalé` and the other way around. — Thanks [@FiveBoroughs](https://github.com/FiveBoroughs)
### Performance
- **XC live refresh releases bulk catalog data sooner during batch processing.** After filtering the single `get_all_live_streams` response, the full provider catalog list is dropped before batch DB work. `process_m3u_batch_direct` (the path XC refreshes use) now runs `gc.collect()` after each batch and clears batch slice references as thread futures complete. Large structures are still `del`'d in the refresh `finally` block before Celery's `task_postrun` runs `cleanup_memory()`.
- **VOD movie/series batch matching no longer scans the full no-ID catalog.** `process_movie_batch` and `process_series_batch` previously loaded every `Movie`/`Series` row without TMDB or IMDB IDs on each 1000-item chunk to resolve name+year duplicates. Lookup is now scoped to the names in the current batch via `lookup_by_name_year()`, which reduces memory and DB time per chunk. `refresh_vod_content` and `batch_refresh_series_episodes` are registered for Celery post-task memory cleanup (one GC pass at task end, not per chunk).
- **EPG programme parse now streams through PostgreSQL staging instead of holding the full catalogue in Python.** `parse_programs_for_source` writes parsed rows into a session-scoped temp table in batches (`_EPG_PARSE_BATCH_SIZE=2500`), then atomically swaps them into `ProgramData` with batched `DELETE ... RETURNING` + `INSERT` (`_EPG_SWAP_BATCH_SIZE=5000`) so Postgres never materializes the entire guide in one statement. Peak Celery memory during large XMLTV refreshes drops sharply compared with building a monolithic in-memory list before bulk insert. The byte-offset programme index build is deferred until after the swap completes so index construction no longer competes with parse for memory and I/O. `refresh_epg_data` closes its DB connection in `finally`, and `build_programme_index_task` is registered as a memory-intensive Celery task. SQLite/dev installs keep an in-memory fallback path.
- **Pooled PostgreSQL connections now rotate on a bounded lifetime.** psycopg3 client-side cache grows on handles kept open indefinitely by `django-db-geventpool`; recycling uWSGI workers would interrupt live streams. A thin custom backend (`dispatcharr.db.backends.postgresql_psycopg3`) closes and replaces pool connections after `DATABASE_POOL_CONN_MAX_LIFETIME` seconds (default 600, env-overridable; set `0` to disable) on checkout and return while preserving warm-pool reuse within the window. (Fixes #1343)
- **Schedules Direct refresh only fetches guide data for mapped channels.** Schedule MD5 checks and schedule downloads now target mapped lineup stations instead of the entire lineup, and schedule MD5 cache for unmapped stations is pruned each refresh. Unmapped lineup entries no longer trigger wasted schedule API calls when their MD5 changes.
- **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
- **Live proxy channels could remain running with no clients after disconnect.** `stop_channel` called `log_system_event('channel_stop')` synchronously before local cleanup and Redis key deletion; Connect integrations or DB event writes on that path could block teardown indefinitely while the cleanup thread kept refreshing metadata TTL for a stale `stream_buffers` entry. Teardown now signals shutdown (`buffer.stopping`), runs model `release_stream()` and Redis key deletion, releases ownership, stops ffmpeg/output managers and local buffers, and only then logs the stop event asynchronously. Metadata TTL refresh skips channels mid-shutdown and non-owned channels; a stuck-stop watchdog forces cleanup if `stop_channel` does not return within ~10s (or 2× `channel_shutdown_delay`, whichever is greater). Stream buffers ignore late `add_chunk()` writes once shutdown begins (`buffer.stopping` set at teardown start).
- **Client reconnect during channel teardown could wedge the channel across uWSGI workers.** The disconnect path called `proxy_server.stop_channel()` directly, so other workers were not notified and reconnecting clients could attach while ownership was released on the owner worker. The cleanup thread then re-acquired expired ownership and looped on orphaned clients while the upstream connection stayed open. All lifecycle stops now go through coordinated Redis teardown (`channel_stopping` flag, metadata `stopping` state, and `CHANNEL_STOP` pubsub). New stream requests are rejected with `503 Retry-After` only during active teardown (not during the post-disconnect shutdown delay grace period); `initialize_channel()` and ownership re-acquisition refuse to proceed in those states; non-owner workers clean local resources only on pubsub; and orphaned-client sweeps force cleanup when teardown is already active. (Fixes #1342)
- **Orphaned upstream threads kept writing Redis after teardown.** Partial cleanup could remove a channel from `stream_managers` while the `stream-{uuid}` OS thread kept running, so orphan metadata sweeps only deleted Redis keys and chunks/metadata were immediately recreated (bare `total_bytes` hash with TTL -1). Stream managers now stop upstream when ownership is lost, orphan cleanup stops local ffmpeg/stream threads even when registry entries are gone (`_live_stream_managers` tracks managers until the thread exits), and forced recovery stops processes before Redis deletion instead of dropping dict entries without calling `stop()`.
- **Rapid channel switching could leave bare `buffer:index` keys in Redis.** `buffer.stop()` flushed a final partial chunk via `INCR` during teardown, creating a persistent index key (TTL -1) after chunk keys expired; under load overlapping disconnect and cleanup-thread stops could race on `_stop_local_stream_activity`, blocking on ffmpeg stderr join before `_clean_redis_keys` ever ran. Teardown no longer writes to Redis from `buffer.stop()`, disconnect uses coordinated stop only (no duplicate upstream stop), model `release_stream()` runs before Redis keys are scanned/deleted (so `profile_connections` counters are not left stuck), ownership is released after Redis cleanup but before the blocking local ffmpeg stop, and a `finally` block guarantees Redis cleanup if local teardown raises.
- **Live proxy could leak geventpool DB checkouts outside HTTP requests.** With `django-db-geventpool`, `connection.close()` returns handles to the per-worker pool (`MAX_CONNS=8`); without it, greenlets and OS threads keep connections checked out until the pool blocks on `pool.get()`. Proxy paths that touch the ORM outside Django's request cycle now call `close_old_connections()` after work completes: `_clean_redis_keys()`, profile lookup in `_establish_transcode_connection()` before spawning ffmpeg, and fMP4 client disconnect cleanup when releasing streams (TS disconnect relies on `log_system_event()`). (Fixes #1345)
- **System events could block callers on Connect and plugin handlers.** `log_system_event()` ran Connect subscriptions and plugin `"events"` hooks synchronously after the DB write, so slow integrations could stall live-proxy and streaming paths even when inserting the event row was fast. Connect/plugin dispatch now runs on a separate gevent when the hub is available (synchronously in Celery workers without a hub). `log_system_event()` and `dispatch_event_system()` also call `close_old_connections()` in `finally` blocks so integration work does not leave pool slots checked out on the caller or dispatch greenlet.
- **Plugins could leak geventpool DB checkouts after UI or Connect event runs.** Third-party plugins run inline on uWSGI greenlets (manual actions and Connect `"events"` hooks) with no guaranteed connection cleanup at the plugin boundary. `PluginManager.run_action()` and `stop_plugin()` now call `close_old_connections()` in a `finally` block so each action returns its pool slot whether the plugin succeeds or raises.
- **Connect events repeatedly queried the full plugin catalog during streaming.** `trigger_event()` called `list_plugins()` on every `client_connect` / `client_disconnect`, loading all `PluginConfig` rows and sometimes hitting plugin-repo work even when no plugin subscribed to the event. Dispatch now walks the in-memory registry via `iter_actions_for_event()`, returns immediately when no handlers exist, and runs a single batched `enabled` lookup for matching plugins only. Failed plugin actions are logged per handler instead of aborting the rest.
- **Plugin discovery left idle Postgres backends after worker boot.** `discover_plugins()` runs outside Django's request cycle during uWSGI and Celery startup; it now calls `close_old_connections()` in a `finally` block so bootstrap `PluginConfig` queries return their pool slot instead of appearing as long-lived idle connections in `pg_stat_activity`.
- **VOD proxy could leak geventpool DB checkouts during playback and stats updates.** `stream_vod()` ran ORM lookups for content and M3U profiles, then returned a long-lived `StreamingHttpResponse` without releasing the checkout, so each movie/episode stream could hold a pool slot for its full duration. Background VOD stats refresh (`build_vod_stats_data()`, triggered on start/stop and by the admin stats API) also queried movie, episode, and profile rows from daemon threads with no cleanup. `stream_vod()` now calls `close_old_connections()` before handing off to the streaming generator, and `build_vod_stats_data()` releases its checkout in a `finally` block.
- **Channel shutdown delay did not reset after a reconnect within the grace period.** `handle_client_disconnect()` used a fixed `gevent.sleep(shutdown_delay)` from the first last-client disconnect. If a client reconnected and disconnected again during the delay, an earlier disconnect handler could still stop the channel on the original timer instead of waiting the full delay from the latest disconnect. Shutdown now polls Redis (`last_client_disconnect` timestamp and client count) so concurrent disconnect handlers and multi-worker reconnects always honour the latest disconnect time.
- **Zombie ffmpeg could survive owner-lock expiry and orphan Redis sweeps.** When the 30s owner lock lapsed under single-worker load, disconnect handling treated the worker as non-owner so coordinated stop never ran, while ffmpeg kept writing and the orphan sweeper only deleted Redis keys (recreating them immediately). Disconnect now re-acquires ownership when local upstream is still active, stops locally when the last client leaves without a lock, orphan cleanup stops local ffmpeg before Redis deletion via `_has_local_upstream_activity`, re-init stops lingering upstream before starting a duplicate thread, and `is_channel_teardown_active` includes channels mid-`stop_channel` on this worker so rapid reconnect gets 503 during teardown.
- **Stale `channel_stream` Redis keys after a channel stopped could skip connection accounting on retune.** On `dev`, `get_stream()` reused any existing `channel_stream` / `stream_profile` assignment without reserving a new slot. If those keys were left behind after a stop, the next tune-in could reach the provider without incrementing Redis counters. `get_stream()` now releases stale assignments when proxy metadata shows the channel is inactive, then reserves fresh slots.
- **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.
- **Schedules Direct lineup search country dropdown.** The country list was fetched directly from the SD API in the browser, which failed due to CORS and silently fell back to a 14-country hardcoded list. Countries are now included in the `GET sd-lineups` response (server-side fetch with proper User-Agent) so the full SD country list populates correctly. — Thanks [@sethwv](https://github.com/sethwv)
- **Schedules Direct program poster proxy omitted User-Agent on image requests.** The poster endpoint authenticated with SD correctly but fetched images with only the `token` header, which violates SD's API requirements (error 1003). Image requests now include the standard `Dispatcharr/{version}` User-Agent via `dispatcharr_http_headers()`.
- **Schedules Direct guide data missing after mapping a channel.** Lineup refreshes cached schedule MD5s for all stations, but `ProgramData` was only written for mapped channels. Mapping a channel later could leave MD5s looking unchanged so schedules were skipped and the channel had no guide until dates rolled outside the cached window. Refreshes now backfill fetch-window dates that lack `ProgramData` (including newly mapped stations with stale cache), fetch program metadata when no local `ProgramData` exists for a `programID`, and clean up orphaned `ProgramData` on unmapped `EPGData` entries. — Thanks [@sethwv](https://github.com/sethwv)
- **Schedules Direct guide fetch on channel map.** Mapping a channel (or bulk-assigning EPG) now triggers a targeted guide fetch for that `EPGData` entry—mirroring XMLTV's `parse_programs_for_tvg_id` flow—without waiting for the next full source refresh. Skips the fetch when `ProgramData` already exists so additional channels sharing the same `tvg-id` do not trigger redundant API calls. Bulk assignment of three or more SD stations without guide data on the same source queues one batched mapped-station fetch instead of separate per-station API sessions. Concurrent batch and single-EPG fetches coordinate via source-level locks with deferred retries so mappings are not dropped and overlapping SD API sessions are avoided when possible.
- **Auto-sync numbering modes now read only the fields each mode's UI exposes.** After the auto-sync overhaul, switching between Provider, Next Available, and Fixed modes left stale `auto_sync_channel_start` / `auto_sync_channel_end` values in the database while each mode's UI only edits a subset of those fields. Sync treated the hidden values as authoritative in every mode, which discarded valid provider numbers (floored at an auto-computed start), capped Next Available at a stale End, and ran range-enforcement deletes against provider- and next-available-numbered channels. Provider mode now honors `stream_chno` verbatim when free (Start/End bound only the fallback for numberless streams); Next Available ignores End; overflow-delete runs in Fixed mode only. Duplicate or already-taken provider numbers fall back to a free slot instead of being dropped or overwriting an existing channel. Provider mode's Start # field now drops End when it would invert the fallback range (matching Fixed mode). (Closes #1273) — Thanks [@CodeBormen](https://github.com/CodeBormen)
## [0.26.0] - 2026-06-07 ## [0.26.0] - 2026-06-07
### Added ### Added

View file

@ -144,7 +144,19 @@ Untested code is significantly less likely to be merged.
- Use Django's `TestCase` for unit/integration tests. - Use Django's `TestCase` for unit/integration tests.
- Test files live at `apps/<app>/tests/`. - Test files live at `apps/<app>/tests/`.
- Run the test suite with: `uv run python manage.py test` - Run the backend test suite with:
```bash
python manage.py test
```
`manage.py` automatically uses `dispatcharr.settings_test`, which creates an empty PostgreSQL database `test_<dbname>` (same engine as production), runs migrations, and rolls back each test in a transaction. Your live VOD/channels data is not used.
Optional: `TEST_USE_SQLITE=1` for machines without Postgres (some PostgreSQL-only tests skip automatically).
Tests that exercise Celery task bodies should use `@override_settings(CELERY_TASK_ALWAYS_EAGER=True)` locally. Global eager mode is off because `post_save` signals on M3U/EPG models call `.delay()` and would break `TestCase` transaction isolation.
- Do **not** override with `--settings=dispatcharr.settings` on a live instance.
### Frontend ### Frontend

View file

@ -120,12 +120,14 @@ If the name contains any of these, the repo will be rejected on add and skipped
### Top-Level Metadata ### Top-Level Metadata
| Field | Required | Description | | 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_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. | | `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. | | `root_url` | No | Generic base URL for resolving all relative URLs in plugin entries. Trailing slashes are stripped. Used as the fallback when neither `download_base_url` nor `metadata_base_url` is set. |
| `plugins` | **Yes** | Array of plugin entry objects. | | `download_base_url` | No | Base URL for resolving relative download URLs (`latest_url` in plugin entries; `url` and `latest_url` inside per-plugin manifest `versions`/`latest`). Overrides `root_url` for download assets when set. |
| `metadata_base_url` | No | Base URL for resolving relative metadata URLs (`manifest_url` and `icon_url` in plugin entries). Overrides `root_url` for metadata assets when set. |
| `plugins` | **Yes** | Array of plugin entry objects. |
### Plugin Entry Fields ### Plugin Entry Fields
@ -155,13 +157,18 @@ Extra fields in a plugin entry are passed through to the frontend as-is, so you
### URL Resolution ### URL Resolution
If `root_url` is set and a URL field (`manifest_url`, `latest_url`, `icon_url`) does not start with `http://` or `https://`, it is treated as relative and resolved as: Relative URL fields are resolved against a base URL. Dispatcharr uses two separate base URLs (one for metadata assets and one for download assets) so you can serve them from different origins (e.g., manifests and icons on GitHub Pages, release zips on a CDN).
``` **Resolution priority:**
{root_url}/{field_value}
```
This lets you keep plugin entries compact: | Field(s) | Priority |
| --------- | -------- |
| `manifest_url`, `icon_url` | `metadata_base_url``root_url` |
| `latest_url` (plugin entries); `url`, `latest_url` (per-plugin manifest versions/latest) | `download_base_url``root_url` |
A field value is treated as relative if it does not start with `http://` or `https://`. Relative values are resolved as `{base_url}/{field_value}`. All base URL fields are optional; if none are set, URL fields must be absolute.
**Single base URL (simplest):** use `root_url` for everything:
```json ```json
{ {
@ -177,12 +184,45 @@ 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: **Split base URLs:** use `metadata_base_url` and `download_base_url` when assets are served from different origins:
```json
{
"metadata_base_url": "https://raw.githubusercontent.com/myorg/my-plugins/main",
"download_base_url": "https://cdn.example.com/releases",
"plugins": [
{
"slug": "my_plugin",
"manifest_url": "plugins/my_plugin/manifest.json",
"icon_url": "plugins/my_plugin/logo.png",
"latest_url": "my_plugin/my_plugin-1.0.0.zip"
}
]
}
``` ```
{registry_url => raw.githubusercontent.com}/refs/heads/main/plugins/{slug}/logo.png
You can also combine `root_url` with one specific field. The specific field overrides for its consumers, and `root_url` covers the rest:
```json
{
"root_url": "https://raw.githubusercontent.com/myorg/my-plugins/main",
"download_base_url": "https://cdn.example.com/releases"
}
``` ```
**Icon fallback:** If `icon_url` is missing, Dispatcharr tries two fallbacks in order:
1. **Manifest-directory fallback**: if a base URL is set (`root_url`, `metadata_base_url`, etc.) and `manifest_url` is present, the logo is assumed to live in the same directory as the per-plugin manifest:
```
{directory of resolved manifest_url}/logo.png
```
For example, if `manifest_url` resolves to `https://example.com/plugins/my_plugin/manifest.json`, the fallback icon URL is `https://example.com/plugins/my_plugin/logo.png`.
2. **GitHub fallback**: if `registry_url` is a GitHub URL, Dispatcharr converts it to a raw content URL:
```
{registry_url => raw.githubusercontent.com}/refs/heads/main/plugins/{slug}/logo.png
```
--- ---
## Per-Plugin Manifest (Optional) ## Per-Plugin Manifest (Optional)
@ -563,7 +603,9 @@ You can host release zips as GitHub Release assets and reference them with absol
"manifest": { "manifest": {
"registry_name": "string (required)", "registry_name": "string (required)",
"registry_url": "string (optional)", "registry_url": "string (optional)",
"root_url": "string (optional)", "root_url": "string (optional, generic base URL fallback)",
"download_base_url": "string (optional, overrides root_url for zip download URLs)",
"metadata_base_url": "string (optional, overrides root_url for manifest_url and icon_url)",
"plugins": [ "plugins": [
{ {
"slug": "string (required)", "slug": "string (required)",

View file

@ -307,6 +307,18 @@ Plugins are server-side Python code running within the Django application. You c
Prefer Celery tasks (`.delay()`) to keep `run` fast and non-blocking. Prefer Celery tasks (`.delay()`) to keep `run` fast and non-blocking.
### Database connections
Dispatcharr uses `django-db-geventpool` with a bounded per-uWSGI-worker pool (`MAX_CONNS=8`). Each greenlet or OS thread that runs ORM code checks out a connection until Django closes it.
`PluginManager.run_action()` and `stop_plugin()` always call `close_old_connections()` in a `finally` block after your plugin returns (success or error). That returns the current greenlet's checkout to the pool. **You do not need to call `close_old_connections()` yourself for normal inline ORM inside `run()` or `stop()`.**
Still follow these rules:
- **Heavy or long work:** dispatch a Celery task (`.delay()`) and return quickly from `run()`. Celery workers close connections after each task; blocking the uWSGI gevent hub with `time.sleep`, sync HTTP, or large CPU work can freeze the whole worker regardless of DB cleanup.
- **Background threads or greenlets you spawn:** each thread/greenlet that uses the ORM must call `close_old_connections()` (or `connection.close()`) in its own `finally` block when done. The wrapper only covers the thread/greenlet that called `run_action()`.
- **Connect event hooks:** actions with an `"events"` list are dispatched from `log_system_event()` on a separate gevent when uWSGI has an active hub (otherwise synchronously, e.g. Celery). Keep handlers short or defer heavy work to Celery.
### Important: Dont Ask Users for URL/User/Password ### Important: Dont Ask Users for URL/User/Password
Dispatcharr plugins run **inside** the Dispatcharr backend process. That means they already have direct access to the apps models, tasks, and internal utilities. Dispatcharr plugins run **inside** the Dispatcharr backend process. That means they already have direct access to the apps models, tasks, and internal utilities.
Plugins **should not** ask users for “Dispatcharr URL”, “Admin Username”, or “Admin Password” just to call the API. That is unnecessary and unsafe because: Plugins **should not** ask users for “Dispatcharr URL”, “Admin Username”, or “Admin Password” just to call the API. That is unnecessary and unsafe because:

View file

@ -252,8 +252,9 @@ class UserViewSet(viewsets.ModelViewSet):
request.data.pop(key, None) request.data.pop(key, None)
# Strip admin-managed keys from custom_properties so users cannot # Strip admin-managed keys from custom_properties so users cannot
# set their own XC credentials or network rules via this endpoint. # set their own XC credentials, network rules, or catchup access
ADMIN_ONLY_PROPS = {"xc_password", "allowed_networks"} # via this endpoint.
ADMIN_ONLY_PROPS = {"xc_password", "allowed_networks", "catchup_enabled"}
cp = request.data.get("custom_properties") cp = request.data.get("custom_properties")
if isinstance(cp, dict): if isinstance(cp, dict):
for key in ADMIN_ONLY_PROPS: for key in ADMIN_ONLY_PROPS:
@ -267,13 +268,18 @@ class UserViewSet(viewsets.ModelViewSet):
return Response(serializer.data) return Response(serializer.data)
# 🔹 3) Group Management APIs # 🔹 3) Group Management APIs (Django auth.Group; unused by the React UI)
class GroupViewSet(viewsets.ModelViewSet): class GroupViewSet(viewsets.ModelViewSet):
"""Handles CRUD operations for Groups""" """CRUD for Django auth groups and their permissions.
Dispatcharr authorization uses ``user_level``, not these groups. The
endpoint is kept for compatibility but restricted to admins so
non-admins cannot invent auth groups or attach Django permissions.
"""
queryset = Group.objects.all() queryset = Group.objects.all()
serializer_class = GroupSerializer serializer_class = GroupSerializer
permission_classes = [Authenticated] permission_classes = [IsAdmin]
@extend_schema( @extend_schema(
description="Retrieve a list of groups", description="Retrieve a list of groups",
@ -358,9 +364,9 @@ class APIKeyViewSet(viewsets.ViewSet):
responses={200: PermissionSerializer(many=True)}, responses={200: PermissionSerializer(many=True)},
) )
@api_view(["GET"]) @api_view(["GET"])
@permission_classes([Authenticated]) @permission_classes([IsAdmin])
def list_permissions(request): def list_permissions(request):
"""Returns a list of all available permissions""" """Returns a list of all available Django permissions (admin only)."""
permissions = Permission.objects.all() permissions = Permission.objects.all()
serializer = PermissionSerializer(permissions, many=True) serializer = PermissionSerializer(permissions, many=True)
return Response(serializer.data) return Response(serializer.data)

View file

@ -93,7 +93,8 @@ class QueryParamJWTAuthentication(JWTAuthentication):
where the browser cannot set Authorization headers (e.g. <video src>).""" where the browser cannot set Authorization headers (e.g. <video src>)."""
def authenticate(self, request): def authenticate(self, request):
raw_token = request.GET.get('token') params = getattr(request, "query_params", request.GET)
raw_token = params.get("token")
if not raw_token: if not raw_token:
return None return None
try: try:

View file

View file

@ -0,0 +1,44 @@
"""Django auth.Group API is admin-only (unused by the React UI)."""
from django.contrib.auth import get_user_model
from django.contrib.auth.models import Group
from django.test import TestCase
from rest_framework.test import APIRequestFactory, force_authenticate
from apps.accounts.api_views import GroupViewSet, list_permissions
class AuthGroupPermissionTests(TestCase):
def setUp(self):
User = get_user_model()
self.admin = User.objects.create_user(
username="auth_group_admin",
password="x",
user_level=User.UserLevel.ADMIN,
)
self.standard = User.objects.create_user(
username="auth_group_user",
password="x",
user_level=User.UserLevel.STANDARD,
)
self.factory = APIRequestFactory()
self.group = Group.objects.create(name="unused-role")
def test_standard_user_cannot_list_groups(self):
request = self.factory.get("/api/accounts/groups/")
force_authenticate(request, user=self.standard)
view = GroupViewSet.as_view({"get": "list"})
response = view(request)
self.assertEqual(response.status_code, 403)
def test_admin_can_list_groups(self):
request = self.factory.get("/api/accounts/groups/")
force_authenticate(request, user=self.admin)
view = GroupViewSet.as_view({"get": "list"})
response = view(request)
self.assertEqual(response.status_code, 200)
def test_standard_user_cannot_list_permissions(self):
request = self.factory.get("/api/accounts/permissions/")
force_authenticate(request, user=self.standard)
response = list_permissions(request)
self.assertEqual(response.status_code, 403)

200
apps/api/schema_views.py Normal file
View file

@ -0,0 +1,200 @@
"""OpenAPI schema views that are safe under gevent concurrency.
DRF's AutoSchema descriptor keeps mutable per-class state (``self.view``).
When uWSGI serves requests with gevent, concurrent ``/api/schema/`` builds
interleave on that state and raise:
AssertionError: Schema generation REQUIRES a view instance
Cold builds also frequently hang the gevent hub when introspection runs in a
greenlet. Generation therefore runs in a real OS thread (gevent ThreadPool),
with per-process single-flight coordination and a Django/Redis cache shared
by all workers.
"""
from __future__ import annotations
import copy
import logging
import threading
from typing import Callable, Dict, Optional
from django.conf import settings
from django.core.cache import cache
from rest_framework.response import Response
from drf_spectacular.views import SpectacularAPIView
logger = logging.getLogger(__name__)
try:
# Real OS threads; .get() waits without blocking the gevent hub.
from gevent import monkey
from gevent.threadpool import ThreadPool
_SCHEMA_POOL: Optional[ThreadPool] = ThreadPool(maxsize=1)
_GEVENT_PATCHED = monkey.is_module_patched("threading")
except ImportError: # pragma: no cover - non-gevent runtimes
_SCHEMA_POOL = None
_GEVENT_PATCHED = False
# Greenlet-safe after gevent monkey-patching.
_guard = threading.Lock()
_in_progress: Dict[str, threading.Event] = {}
_flight_results: Dict[str, dict] = {}
_SCHEMA_CACHE_PREFIX = "openapi:schema:"
_SCHEMA_CACHE_VER_KEY = f"{_SCHEMA_CACHE_PREFIX}cache_ver"
_BUILD_TIMEOUT_SECONDS = 120
def _run_schema_build(build: Callable[[], dict]) -> dict:
"""Run schema introspection off the gevent hub when monkey-patched."""
if _SCHEMA_POOL is not None and _GEVENT_PATCHED:
return _SCHEMA_POOL.spawn(build).get(timeout=_BUILD_TIMEOUT_SECONDS)
return build()
def _cache_supports_delete_pattern() -> bool:
return callable(getattr(cache, "delete_pattern", None))
def clear_schema_cache() -> None:
"""Invalidate cached schemas (tests / forced refresh after deploy)."""
with _guard:
_flight_results.clear()
try:
if _cache_supports_delete_pattern():
cache.delete_pattern(f"{_SCHEMA_CACHE_PREFIX}*")
return
ver = cache.get(_SCHEMA_CACHE_VER_KEY) or 0
cache.set(_SCHEMA_CACHE_VER_KEY, int(ver) + 1, timeout=None)
except Exception:
logger.warning("Failed to clear OpenAPI schema cache", exc_info=True)
def _key_namespace() -> str:
"""Stable on Redis (pattern delete); versioned on LocMem for test clears."""
if _cache_supports_delete_pattern():
return "shared"
try:
ver = cache.get(_SCHEMA_CACHE_VER_KEY)
if ver is None:
cache.add(_SCHEMA_CACHE_VER_KEY, 1, timeout=None)
ver = cache.get(_SCHEMA_CACHE_VER_KEY) or 1
return f"v{int(ver)}"
except Exception:
return "v1"
class LockedSpectacularAPIView(SpectacularAPIView):
"""SpectacularAPIView with single-flight generation and Django cache."""
def _resolve_version(self, request):
return self.api_version or request.version or self._get_version_parameter(
request
)
def _cache_key(self, request) -> str:
version = self._resolve_version(request)
lang = request.GET.get("lang") if settings.USE_I18N else None
urlconf = self.urlconf
if urlconf is None:
urlconf_part = "default"
else:
urlconf_part = getattr(urlconf, "__name__", None) or type(urlconf).__name__
patterns_part = "custom" if self.patterns is not None else "default"
return (
f"{_SCHEMA_CACHE_PREFIX}{_key_namespace()}:"
f"{version}:{lang}:{self.serve_public}:{urlconf_part}:{patterns_part}"
)
def _cache_get(self, key: str) -> Optional[dict]:
try:
return cache.get(key)
except Exception:
logger.warning("OpenAPI schema cache get failed", exc_info=True)
return None
def _cache_set(self, key: str, schema: dict) -> None:
try:
# Use CACHES['default'] TIMEOUT (3600s in settings).
cache.set(key, schema)
except Exception:
logger.warning("OpenAPI schema cache set failed", exc_info=True)
def _build_schema(self, request, version: Optional[str]) -> dict:
generator = self.generator_class(
urlconf=self.urlconf,
api_version=version,
patterns=self.patterns,
)
return generator.get_schema(request=request, public=self.serve_public)
def _get_schema_response(self, request):
version = self._resolve_version(request)
cache_key = self._cache_key(request)
cached = self._cache_get(cache_key)
if cached is not None:
return self._schema_response(request, version, cached)
with _guard:
cached = self._cache_get(cache_key)
if cached is not None:
return self._schema_response(request, version, cached)
event = _in_progress.get(cache_key)
if event is None:
event = threading.Event()
_in_progress[cache_key] = event
is_builder = True
else:
is_builder = False
if not is_builder:
if not event.wait(timeout=_BUILD_TIMEOUT_SECONDS):
return Response(
{"detail": "Timed out waiting for OpenAPI schema generation."},
status=503,
)
cached = self._cache_get(cache_key)
if cached is None:
with _guard:
cached = _flight_results.get(cache_key)
if cached is None:
return Response(
{"detail": "OpenAPI schema generation failed."},
status=500,
)
return self._schema_response(request, version, cached)
try:
schema = _run_schema_build(
lambda: self._build_schema(request, version)
)
with _guard:
_flight_results[cache_key] = schema
self._cache_set(cache_key, schema)
return self._schema_response(request, version, schema)
except Exception:
logger.exception("OpenAPI schema generation failed")
return Response(
{"detail": "OpenAPI schema generation failed."},
status=500,
)
finally:
with _guard:
_in_progress.pop(cache_key, None)
event.set()
def _schema_response(self, request, version, schema: dict) -> Response:
# LocMem returns the same object; deepcopy keeps responses isolated.
return Response(
data=copy.deepcopy(schema),
headers={
"Content-Disposition": (
f'inline; filename="{self._get_filename(request, version)}"'
)
},
)

View file

@ -1,5 +1,7 @@
from django.urls import path, include, re_path from django.urls import path, include, re_path
from drf_spectacular.views import SpectacularAPIView, SpectacularSwaggerView, SpectacularRedocView from drf_spectacular.views import SpectacularSwaggerView, SpectacularRedocView
from apps.api.schema_views import LockedSpectacularAPIView
app_name = 'api' app_name = 'api'
@ -12,6 +14,7 @@ urlpatterns = [
path('core/', include(('core.api_urls', 'core'), namespace='core')), path('core/', include(('core.api_urls', 'core'), namespace='core')),
path('plugins/', include(('apps.plugins.api_urls', 'plugins'), namespace='plugins')), path('plugins/', include(('apps.plugins.api_urls', 'plugins'), namespace='plugins')),
path('vod/', include(('apps.vod.api_urls', 'vod'), namespace='vod')), path('vod/', include(('apps.vod.api_urls', 'vod'), namespace='vod')),
path('catchup/', include(('apps.timeshift.api_urls', 'catchup'), namespace='catchup')),
path('backups/', include(('apps.backups.api_urls', 'backups'), namespace='backups')), path('backups/', include(('apps.backups.api_urls', 'backups'), namespace='backups')),
path('connect/', include(('apps.connect.api_urls', 'connect'), namespace='connect')), path('connect/', include(('apps.connect.api_urls', 'connect'), namespace='connect')),
# path('output/', include(('apps.output.api_urls', 'output'), namespace='output')), # path('output/', include(('apps.output.api_urls', 'output'), namespace='output')),
@ -21,9 +24,9 @@ urlpatterns = [
# OpenAPI Schema and Documentation (drf-spectacular) # OpenAPI schema (single-flight + Django cache; see apps.api.schema_views)
path('schema/', SpectacularAPIView.as_view(), name='schema'), path('schema/', LockedSpectacularAPIView.as_view(), name='schema'),
re_path(r'^swagger/?$', SpectacularSwaggerView.as_view(url_name='api:schema'), name='swagger-ui'), re_path(r'^swagger/?$', SpectacularSwaggerView.as_view(url_name='api:schema'), name='swagger-ui'),
path('redoc/', SpectacularRedocView.as_view(url_name='api:schema'), name='redoc'), path('redoc/', SpectacularRedocView.as_view(url_name='api:schema'), name='redoc'),
path('swagger.json', SpectacularAPIView.as_view(), name='schema-json'), path('swagger.json', LockedSpectacularAPIView.as_view(), name='schema-json'),
] ]

View file

@ -23,6 +23,8 @@ class BackupsConfig(AppConfig):
def _sync_backup_scheduler(self): def _sync_backup_scheduler(self):
"""Sync backup scheduler task to database.""" """Sync backup scheduler task to database."""
from django.db import close_old_connections
from core.models import CoreSettings from core.models import CoreSettings
from .scheduler import _sync_periodic_task, DEFAULTS from .scheduler import _sync_periodic_task, DEFAULTS
try: try:
@ -38,3 +40,6 @@ class BackupsConfig(AppConfig):
except Exception as e: except Exception as e:
# Log but don't fail startup if there's an issue # Log but don't fail startup if there's an issue
logger.warning(f"Failed to initialize backup scheduler: {e}") logger.warning(f"Failed to initialize backup scheduler: {e}")
finally:
# Cron sync reads system_settings for timezone; return geventpool checkouts.
close_old_connections()

View file

View file

@ -10,7 +10,7 @@ from django.contrib.auth import get_user_model
from rest_framework.test import APIClient from rest_framework.test import APIClient
from rest_framework_simplejwt.tokens import RefreshToken from rest_framework_simplejwt.tokens import RefreshToken
from . import services from apps.backups import services
User = get_user_model() User = get_user_model()
@ -939,7 +939,7 @@ class BackupSchedulerTestCase(TestCase):
def test_get_schedule_settings_defaults(self): def test_get_schedule_settings_defaults(self):
"""Test that get_schedule_settings returns defaults when no settings exist""" """Test that get_schedule_settings returns defaults when no settings exist"""
from . import scheduler from apps.backups import scheduler
settings = scheduler.get_schedule_settings() settings = scheduler.get_schedule_settings()
@ -953,7 +953,7 @@ class BackupSchedulerTestCase(TestCase):
def test_update_schedule_settings_stores_values(self): def test_update_schedule_settings_stores_values(self):
"""Test that update_schedule_settings stores values correctly""" """Test that update_schedule_settings stores values correctly"""
from . import scheduler from apps.backups import scheduler
result = scheduler.update_schedule_settings({ result = scheduler.update_schedule_settings({
'enabled': True, 'enabled': True,
@ -976,7 +976,7 @@ class BackupSchedulerTestCase(TestCase):
def test_update_schedule_settings_invalid_frequency(self): def test_update_schedule_settings_invalid_frequency(self):
"""Test that invalid frequency raises ValueError""" """Test that invalid frequency raises ValueError"""
from . import scheduler from apps.backups import scheduler
with self.assertRaises(ValueError) as context: with self.assertRaises(ValueError) as context:
scheduler.update_schedule_settings({'frequency': 'monthly'}) scheduler.update_schedule_settings({'frequency': 'monthly'})
@ -985,7 +985,7 @@ class BackupSchedulerTestCase(TestCase):
def test_update_schedule_settings_invalid_time(self): def test_update_schedule_settings_invalid_time(self):
"""Test that invalid time raises ValueError""" """Test that invalid time raises ValueError"""
from . import scheduler from apps.backups import scheduler
with self.assertRaises(ValueError) as context: with self.assertRaises(ValueError) as context:
scheduler.update_schedule_settings({'time': 'invalid'}) scheduler.update_schedule_settings({'time': 'invalid'})
@ -994,7 +994,7 @@ class BackupSchedulerTestCase(TestCase):
def test_update_schedule_settings_invalid_day_of_week(self): def test_update_schedule_settings_invalid_day_of_week(self):
"""Test that invalid day_of_week raises ValueError""" """Test that invalid day_of_week raises ValueError"""
from . import scheduler from apps.backups import scheduler
with self.assertRaises(ValueError) as context: with self.assertRaises(ValueError) as context:
scheduler.update_schedule_settings({'day_of_week': 7}) scheduler.update_schedule_settings({'day_of_week': 7})
@ -1003,7 +1003,7 @@ class BackupSchedulerTestCase(TestCase):
def test_update_schedule_settings_invalid_retention(self): def test_update_schedule_settings_invalid_retention(self):
"""Test that negative retention_count raises ValueError""" """Test that negative retention_count raises ValueError"""
from . import scheduler from apps.backups import scheduler
with self.assertRaises(ValueError) as context: with self.assertRaises(ValueError) as context:
scheduler.update_schedule_settings({'retention_count': -1}) scheduler.update_schedule_settings({'retention_count': -1})
@ -1012,7 +1012,7 @@ class BackupSchedulerTestCase(TestCase):
def test_sync_creates_periodic_task_when_enabled(self): def test_sync_creates_periodic_task_when_enabled(self):
"""Test that enabling schedule creates a PeriodicTask""" """Test that enabling schedule creates a PeriodicTask"""
from . import scheduler from apps.backups import scheduler
from django_celery_beat.models import PeriodicTask from django_celery_beat.models import PeriodicTask
scheduler.update_schedule_settings({ scheduler.update_schedule_settings({
@ -1028,7 +1028,7 @@ class BackupSchedulerTestCase(TestCase):
def test_sync_deletes_periodic_task_when_disabled(self): def test_sync_deletes_periodic_task_when_disabled(self):
"""Test that disabling schedule removes PeriodicTask""" """Test that disabling schedule removes PeriodicTask"""
from . import scheduler from apps.backups import scheduler
from django_celery_beat.models import PeriodicTask from django_celery_beat.models import PeriodicTask
# First enable # First enable
@ -1047,7 +1047,7 @@ class BackupSchedulerTestCase(TestCase):
def test_weekly_schedule_sets_day_of_week(self): def test_weekly_schedule_sets_day_of_week(self):
"""Test that weekly schedule sets correct day_of_week in crontab""" """Test that weekly schedule sets correct day_of_week in crontab"""
from . import scheduler from apps.backups import scheduler
from django_celery_beat.models import PeriodicTask from django_celery_beat.models import PeriodicTask
scheduler.update_schedule_settings({ scheduler.update_schedule_settings({
@ -1062,7 +1062,7 @@ class BackupSchedulerTestCase(TestCase):
def test_cron_expression_stores_value(self): def test_cron_expression_stores_value(self):
"""Test that cron_expression is stored and retrieved correctly""" """Test that cron_expression is stored and retrieved correctly"""
from . import scheduler from apps.backups import scheduler
result = scheduler.update_schedule_settings({ result = scheduler.update_schedule_settings({
'enabled': True, 'enabled': True,
@ -1077,7 +1077,7 @@ class BackupSchedulerTestCase(TestCase):
def test_cron_expression_creates_correct_schedule(self): def test_cron_expression_creates_correct_schedule(self):
"""Test that cron expression creates correct CrontabSchedule""" """Test that cron expression creates correct CrontabSchedule"""
from . import scheduler from apps.backups import scheduler
from django_celery_beat.models import PeriodicTask from django_celery_beat.models import PeriodicTask
scheduler.update_schedule_settings({ scheduler.update_schedule_settings({
@ -1094,7 +1094,7 @@ class BackupSchedulerTestCase(TestCase):
def test_cron_expression_invalid_format(self): def test_cron_expression_invalid_format(self):
"""Test that invalid cron expression raises ValueError""" """Test that invalid cron expression raises ValueError"""
from . import scheduler from apps.backups import scheduler
# Too few parts # Too few parts
with self.assertRaises(ValueError) as context: with self.assertRaises(ValueError) as context:
@ -1106,7 +1106,7 @@ class BackupSchedulerTestCase(TestCase):
def test_cron_expression_empty_uses_simple_mode(self): def test_cron_expression_empty_uses_simple_mode(self):
"""Test that empty cron_expression falls back to simple frequency mode""" """Test that empty cron_expression falls back to simple frequency mode"""
from . import scheduler from apps.backups import scheduler
from django_celery_beat.models import PeriodicTask from django_celery_beat.models import PeriodicTask
scheduler.update_schedule_settings({ scheduler.update_schedule_settings({
@ -1123,7 +1123,7 @@ class BackupSchedulerTestCase(TestCase):
def test_cron_expression_overrides_simple_settings(self): def test_cron_expression_overrides_simple_settings(self):
"""Test that cron_expression takes precedence over frequency/time""" """Test that cron_expression takes precedence over frequency/time"""
from . import scheduler from apps.backups import scheduler
from django_celery_beat.models import PeriodicTask from django_celery_beat.models import PeriodicTask
scheduler.update_schedule_settings({ scheduler.update_schedule_settings({
@ -1140,7 +1140,7 @@ class BackupSchedulerTestCase(TestCase):
def test_periodic_task_uses_system_timezone(self): def test_periodic_task_uses_system_timezone(self):
"""Test that CrontabSchedule is created with the system timezone""" """Test that CrontabSchedule is created with the system timezone"""
from . import scheduler from apps.backups import scheduler
from django_celery_beat.models import PeriodicTask from django_celery_beat.models import PeriodicTask
from core.models import CoreSettings from core.models import CoreSettings
@ -1164,7 +1164,7 @@ class BackupSchedulerTestCase(TestCase):
def test_periodic_task_timezone_updates_with_schedule(self): def test_periodic_task_timezone_updates_with_schedule(self):
"""Test that CrontabSchedule timezone is updated when schedule is modified""" """Test that CrontabSchedule timezone is updated when schedule is modified"""
from . import scheduler from apps.backups import scheduler
from django_celery_beat.models import PeriodicTask from django_celery_beat.models import PeriodicTask
from core.models import CoreSettings from core.models import CoreSettings
@ -1197,7 +1197,7 @@ class BackupSchedulerTestCase(TestCase):
def test_orphaned_crontab_cleanup(self): def test_orphaned_crontab_cleanup(self):
"""Test that old CrontabSchedule is deleted when schedule changes""" """Test that old CrontabSchedule is deleted when schedule changes"""
from . import scheduler from apps.backups import scheduler
from django_celery_beat.models import PeriodicTask, CrontabSchedule from django_celery_beat.models import PeriodicTask, CrontabSchedule
# Create initial daily schedule # Create initial daily schedule
@ -1246,7 +1246,7 @@ class BackupTasksTestCase(TestCase):
@patch('apps.backups.tasks.services.delete_backup') @patch('apps.backups.tasks.services.delete_backup')
def test_cleanup_old_backups_keeps_recent(self, mock_delete, mock_list): def test_cleanup_old_backups_keeps_recent(self, mock_delete, mock_list):
"""Test that cleanup keeps the most recent backups""" """Test that cleanup keeps the most recent backups"""
from .tasks import _cleanup_old_backups from apps.backups.tasks import _cleanup_old_backups
mock_list.return_value = [ mock_list.return_value = [
{'name': 'backup-3.zip'}, # newest {'name': 'backup-3.zip'}, # newest
@ -1263,7 +1263,7 @@ class BackupTasksTestCase(TestCase):
@patch('apps.backups.tasks.services.delete_backup') @patch('apps.backups.tasks.services.delete_backup')
def test_cleanup_old_backups_does_nothing_when_under_limit(self, mock_delete, mock_list): def test_cleanup_old_backups_does_nothing_when_under_limit(self, mock_delete, mock_list):
"""Test that cleanup does nothing when under retention limit""" """Test that cleanup does nothing when under retention limit"""
from .tasks import _cleanup_old_backups from apps.backups.tasks import _cleanup_old_backups
mock_list.return_value = [ mock_list.return_value = [
{'name': 'backup-2.zip'}, {'name': 'backup-2.zip'},
@ -1279,7 +1279,7 @@ class BackupTasksTestCase(TestCase):
@patch('apps.backups.tasks.services.delete_backup') @patch('apps.backups.tasks.services.delete_backup')
def test_cleanup_old_backups_zero_retention_keeps_all(self, mock_delete, mock_list): def test_cleanup_old_backups_zero_retention_keeps_all(self, mock_delete, mock_list):
"""Test that retention_count=0 keeps all backups""" """Test that retention_count=0 keeps all backups"""
from .tasks import _cleanup_old_backups from apps.backups.tasks import _cleanup_old_backups
mock_list.return_value = [ mock_list.return_value = [
{'name': 'backup-3.zip'}, {'name': 'backup-3.zip'},
@ -1296,7 +1296,7 @@ class BackupTasksTestCase(TestCase):
@patch('apps.backups.tasks._cleanup_old_backups') @patch('apps.backups.tasks._cleanup_old_backups')
def test_scheduled_backup_task_success(self, mock_cleanup, mock_create): def test_scheduled_backup_task_success(self, mock_cleanup, mock_create):
"""Test scheduled backup task success""" """Test scheduled backup task success"""
from .tasks import scheduled_backup_task from apps.backups.tasks import scheduled_backup_task
mock_backup_file = MagicMock() mock_backup_file = MagicMock()
mock_backup_file.name = 'scheduled-backup.zip' mock_backup_file.name = 'scheduled-backup.zip'
@ -1316,7 +1316,7 @@ class BackupTasksTestCase(TestCase):
@patch('apps.backups.tasks._cleanup_old_backups') @patch('apps.backups.tasks._cleanup_old_backups')
def test_scheduled_backup_task_no_cleanup_when_retention_zero(self, mock_cleanup, mock_create): def test_scheduled_backup_task_no_cleanup_when_retention_zero(self, mock_cleanup, mock_create):
"""Test scheduled backup skips cleanup when retention is 0""" """Test scheduled backup skips cleanup when retention is 0"""
from .tasks import scheduled_backup_task from apps.backups.tasks import scheduled_backup_task
mock_backup_file = MagicMock() mock_backup_file = MagicMock()
mock_backup_file.name = 'scheduled-backup.zip' mock_backup_file.name = 'scheduled-backup.zip'
@ -1332,7 +1332,7 @@ class BackupTasksTestCase(TestCase):
@patch('apps.backups.tasks.services.create_backup') @patch('apps.backups.tasks.services.create_backup')
def test_scheduled_backup_task_failure(self, mock_create): def test_scheduled_backup_task_failure(self, mock_create):
"""Test scheduled backup task handles failure""" """Test scheduled backup task handles failure"""
from .tasks import scheduled_backup_task from apps.backups.tasks import scheduled_backup_task
mock_create.side_effect = Exception("Backup failed") mock_create.side_effect = Exception("Backup failed")

View file

@ -13,6 +13,7 @@ from .api_views import (
UpdateChannelMembershipAPIView, UpdateChannelMembershipAPIView,
BulkUpdateChannelMembershipAPIView, BulkUpdateChannelMembershipAPIView,
RecordingViewSet, RecordingViewSet,
RECORDING_PLAYBACK_AUTHENTICATORS,
RecurringRecordingRuleViewSet, RecurringRecordingRuleViewSet,
GetChannelStreamsAPIView, GetChannelStreamsAPIView,
GetChannelStreamStatsAPIView, GetChannelStreamStatsAPIView,
@ -53,7 +54,10 @@ urlpatterns = [
path('recordings/bulk-delete-upcoming/', BulkDeleteUpcomingRecordingsAPIView.as_view(), name='bulk_delete_upcoming_recordings'), path('recordings/bulk-delete-upcoming/', BulkDeleteUpcomingRecordingsAPIView.as_view(), name='bulk_delete_upcoming_recordings'),
path( path(
'recordings/<int:pk>/hls/<path:seg_path>', 'recordings/<int:pk>/hls/<path:seg_path>',
RecordingViewSet.as_view({'get': 'hls'}), RecordingViewSet.as_view(
{'get': 'hls'},
authentication_classes=RECORDING_PLAYBACK_AUTHENTICATORS,
),
name='recording-hls', name='recording-hls',
), ),
path('dvr/comskip-config/', ComskipConfigAPIView.as_view(), name='comskip_config'), path('dvr/comskip-config/', ComskipConfigAPIView.as_view(), name='comskip_config'),

View file

@ -3,6 +3,8 @@ from rest_framework.response import Response
from rest_framework.views import APIView from rest_framework.views import APIView
from rest_framework.permissions import AllowAny from rest_framework.permissions import AllowAny
from rest_framework.decorators import action from rest_framework.decorators import action
from rest_framework_simplejwt.authentication import JWTAuthentication
from apps.accounts.authentication import ApiKeyAuthentication, QueryParamJWTAuthentication
from rest_framework.parsers import MultiPartParser, FormParser, JSONParser from rest_framework.parsers import MultiPartParser, FormParser, JSONParser
from drf_spectacular.utils import extend_schema, OpenApiParameter, inline_serializer from drf_spectacular.utils import extend_schema, OpenApiParameter, inline_serializer
from drf_spectacular.types import OpenApiTypes from drf_spectacular.types import OpenApiTypes
@ -12,18 +14,21 @@ from django.db import connection, transaction
from django.db.models import Count, F, Prefetch from django.db.models import Count, F, Prefetch
from django.db.models import Q from django.db.models import Q
import os, json, requests, logging, mimetypes, threading, time import os, json, requests, logging, mimetypes, threading, time
from urllib.parse import urlencode
from datetime import timedelta from datetime import timedelta
from django.utils.http import http_date from django.utils.http import http_date
from apps.accounts.permissions import ( from apps.accounts.permissions import (
Authenticated, Authenticated,
IsAdmin, IsAdmin,
IsOwnerOfObject, IsOwnerOfObject,
IsStandardUser,
permission_classes_by_action, permission_classes_by_action,
permission_classes_by_method, permission_classes_by_method,
) )
from core.models import UserAgent, CoreSettings from core.models import UserAgent, CoreSettings
from core.utils import RedisClient, safe_upload_path from core.utils import RedisClient, safe_upload_path, resolve_safe_local_data_path
from apps.m3u.utils import convert_js_numbered_backreferences
from .models import ( from .models import (
Stream, Stream,
@ -71,6 +76,33 @@ from rest_framework.pagination import PageNumberPagination
from dispatcharr.utils import network_access_allowed from dispatcharr.utils import network_access_allowed
def _parse_request_bool(value, default=False):
"""Parse a query/body boolean. Missing values use *default*."""
if value is None:
return default
if isinstance(value, bool):
return value
if isinstance(value, (int, float)):
return bool(value)
return str(value).strip().lower() in ("1", "true", "yes", "on")
def _stop_proxy_sessions_for_channel_ids(channel_ids):
"""Stop live proxy sessions for channels before deleting their DB rows.
Runs outside Django's delete atomic so geventpool connection release during
teardown cannot poison the delete transaction.
"""
if not channel_ids:
return
from apps.proxy.live_proxy.services.channel_service import ChannelService
uuids = list(
Channel.objects.filter(id__in=channel_ids).values_list("uuid", flat=True)
)
ChannelService.stop_channels(uuids)
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
# Negative cache for remote logo URLs that failed to fetch. # Negative cache for remote logo URLs that failed to fetch.
@ -173,6 +205,10 @@ class StreamViewSet(viewsets.ModelViewSet):
if hide_stale and str(hide_stale).lower() in ("1", "true", "yes", "on"): if hide_stale and str(hide_stale).lower() in ("1", "true", "yes", "on"):
qs = qs.filter(is_stale=False) qs = qs.filter(is_stale=False)
is_catchup = self.request.query_params.get("is_catchup")
if is_catchup and str(is_catchup).lower() in ("1", "true", "yes", "on"):
qs = qs.filter(is_catchup=True)
return qs return qs
def list(self, request, *args, **kwargs): def list(self, request, *args, **kwargs):
@ -365,6 +401,17 @@ class StreamViewSet(viewsets.ModelViewSet):
except re.error as e: except re.error as e:
exclude_error = str(e) exclude_error = str(e)
# The replace field accepts JS-style $1 backreferences, but the regex
# engine honors \1. Convert once so the preview's "after" matches the
# name the live rename produces (apps/m3u/tasks.py sync_auto_channels
# applies the same conversion on the same engine).
replace_repl = convert_js_numbered_backreferences(replace_pat)
# The live rename caps the result at Channel.name's column length
# before bulk_create; mirror that cap so the preview never shows a
# name the sync would truncate.
name_max_len = Channel._meta.get_field("name").max_length
# Capped at SCAN_CAP to bound memory on huge groups; the # Capped at SCAN_CAP to bound memory on huge groups; the
# separate COUNT lets the client surface scan_limit_hit when # separate COUNT lets the client surface scan_limit_hit when
# the preview covers only a sample. # the preview covers only a sample.
@ -387,11 +434,12 @@ class StreamViewSet(viewsets.ModelViewSet):
total_scanned += 1 total_scanned += 1
if find_re is not None: if find_re is not None:
try: try:
new_name = find_re.sub(replace_pat, name, timeout=REGEX_TIMEOUT) new_name = find_re.sub(replace_repl, name, timeout=REGEX_TIMEOUT)
except (TimeoutError, re.error) as e: except (TimeoutError, re.error) as e:
find_error = find_error or f"Pattern timed out: {e}" find_error = find_error or f"Pattern timed out: {e}"
find_re = None find_re = None
continue continue
new_name = new_name[:name_max_len]
if new_name != name: if new_name != name:
find_match_count += 1 find_match_count += 1
if len(find_matches) < limit: if len(find_matches) < limit:
@ -591,10 +639,12 @@ class ChannelGroupViewSet(viewsets.ModelViewSet):
serializer_class = ChannelGroupSerializer serializer_class = ChannelGroupSerializer
def get_permissions(self): def get_permissions(self):
if self.action == "cleanup_unused_groups":
return [IsAdmin()]
try: try:
return [perm() for perm in permission_classes_by_action[self.action]] return [perm() for perm in permission_classes_by_action[self.action]]
except KeyError: except KeyError:
return [Authenticated()] return [IsAdmin()]
def get_queryset(self): def get_queryset(self):
# Annotate both counts at the SQL level so the serializer methods # Annotate both counts at the SQL level so the serializer methods
@ -739,7 +789,9 @@ class ChannelPagination(PageNumberPagination):
max_page_size = 10000 # Prevent excessive page sizes max_page_size = 10000 # Prevent excessive page sizes
def paginate_queryset(self, queryset, request, view=None): def paginate_queryset(self, queryset, request, view=None):
if not request.query_params.get(self.page_query_param): if not request.query_params.get(self.page_query_param) and not request.query_params.get(
self.page_size_query_param
):
return None # disables pagination, returns full queryset return None # disables pagination, returns full queryset
return super().paginate_queryset(queryset, request, view) return super().paginate_queryset(queryset, request, view)
@ -868,6 +920,22 @@ class ChannelViewSet(viewsets.ModelViewSet):
headers = self.get_success_headers(serializer.data) headers = self.get_success_headers(serializer.data)
return Response(serializer.data, status=status.HTTP_201_CREATED, headers=headers) return Response(serializer.data, status=status.HTTP_201_CREATED, headers=headers)
def destroy(self, request, *args, **kwargs):
"""Delete a channel. Optionally stop an active proxy session first.
Query param ``stop_stream`` (default false): when true, stop the live
proxy session *before* the DB delete (outside the delete atomic) so
playback ends with the channel row. When false, an in-progress stream
keeps playing until stopped from Stats or the client disconnects.
"""
instance = self.get_object()
stop_stream = _parse_request_bool(request.query_params.get("stop_stream"))
if stop_stream:
# Stop before super().destroy() so teardown is not inside the
# collector's transaction.atomic.
_stop_proxy_sessions_for_channel_ids([instance.id])
return super().destroy(request, *args, **kwargs)
def get_permissions(self): def get_permissions(self):
if self.action in [ if self.action in [
"edit_bulk", "edit_bulk",
@ -877,13 +945,26 @@ class ChannelViewSet(viewsets.ModelViewSet):
"match_epg", "match_epg",
"set_epg", "set_epg",
"batch_set_epg", "batch_set_epg",
"bulk_regex_rename",
"set_names_from_epg",
"set_logos_from_epg",
"set_tvg_ids_from_epg",
"reorder",
]: ]:
return [IsAdmin()] return [IsAdmin()]
if self.action in (
"get_ids",
"summary",
"numbers_in_range",
"by_uuids",
):
return [IsStandardUser()]
try: try:
return [perm() for perm in permission_classes_by_action[self.action]] return [perm() for perm in permission_classes_by_action[self.action]]
except KeyError: except KeyError:
return [Authenticated()] return [IsAdmin()]
def get_queryset(self): def get_queryset(self):
# get_ids and summary only need the filter conditions, not the full # get_ids and summary only need the filter conditions, not the full
@ -925,6 +1006,7 @@ class ChannelViewSet(viewsets.ModelViewSet):
only_streamless = self.request.query_params.get("only_streamless", None) only_streamless = self.request.query_params.get("only_streamless", None)
only_stale = self.request.query_params.get("only_stale", None) only_stale = self.request.query_params.get("only_stale", None)
only_has_overrides = self.request.query_params.get("only_has_overrides", None) only_has_overrides = self.request.query_params.get("only_has_overrides", None)
only_catchup = self.request.query_params.get("only_catchup", None)
visibility_filter = self.request.query_params.get("visibility_filter", "active") visibility_filter = self.request.query_params.get("visibility_filter", "active")
if channel_profile_id: if channel_profile_id:
@ -950,6 +1032,8 @@ class ChannelViewSet(viewsets.ModelViewSet):
q_filters &= Q(streams__is_stale=True) q_filters &= Q(streams__is_stale=True)
if only_has_overrides: if only_has_overrides:
q_filters &= Q(override__isnull=False) q_filters &= Q(override__isnull=False)
if only_catchup:
q_filters &= Q(is_catchup=True)
# Visibility filter applies to list-style reads only; retrieve / # Visibility filter applies to list-style reads only; retrieve /
# update / delete must still reach a hidden channel by id so the # update / delete must still reach a hidden channel by id so the
@ -2087,7 +2171,7 @@ class ChannelViewSet(viewsets.ModelViewSet):
fields={ fields={
'channel_ids': serializers.ListField( 'channel_ids': serializers.ListField(
child=serializers.IntegerField(), 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, required=False,
) )
} }
@ -2120,23 +2204,15 @@ class ChannelViewSet(viewsets.ModelViewSet):
def match_channel_epg(self, request, pk=None): def match_channel_epg(self, request, pk=None):
channel = self.get_object() channel = self.get_object()
# Import the matching logic match_single_channel_epg.delay(channel.id)
from apps.channels.tasks import match_single_channel_epg return Response(
{
try: "message": f"EPG matching started for channel '{channel.name}'",
# Try to match this specific channel - call synchronously for immediate response "accepted": True,
result = match_single_channel_epg.apply_async(args=[channel.id]).get(timeout=30) "channel_id": channel.id,
},
# Refresh the channel from DB to get any updates status=status.HTTP_202_ACCEPTED,
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)
# ───────────────────────────────────────────────────────── # ─────────────────────────────────────────────────────────
# 7) Set EPG and Refresh # 7) Set EPG and Refresh
@ -2171,22 +2247,14 @@ class ChannelViewSet(viewsets.ModelViewSet):
epg_data = EPGData.objects.get(pk=epg_data_id) epg_data = EPGData.objects.get(pk=epg_data_id)
# Set the EPG data and save # Set the EPG data and save. refresh_epg_programs (post_save) queues
# parse_programs_for_tvg_id for non-dummy sources — no second dispatch here.
channel.epg_data = epg_data channel.epg_data = epg_data
channel.save(update_fields=["epg_data"]) channel.save(update_fields=["epg_data"])
# Only trigger program refresh for non-dummy EPG sources
status_message = None status_message = None
if epg_data.epg_source.source_type != 'dummy': if epg_data.epg_source.source_type != 'dummy':
# Explicitly trigger program refresh for this EPG
from apps.epg.tasks import parse_programs_for_tvg_id
task_result = parse_programs_for_tvg_id.delay(epg_data.id)
# Prepare response with task status info
status_message = "EPG refresh queued" status_message = "EPG refresh queued"
if task_result.result == "Task already running":
status_message = "EPG refresh already in progress"
# Build response message # Build response message
message = f"EPG data set to {epg_data.tvg_id} for channel {channel.name}" message = f"EPG data set to {epg_data.tvg_id} for channel {channel.name}"
@ -2321,7 +2389,6 @@ class ChannelViewSet(viewsets.ModelViewSet):
# Extract channel IDs upfront # Extract channel IDs upfront
channel_updates = {} channel_updates = {}
unique_epg_ids = set()
for assoc in associations: for assoc in associations:
channel_id = assoc.get("channel_id") channel_id = assoc.get("channel_id")
@ -2331,24 +2398,28 @@ class ChannelViewSet(viewsets.ModelViewSet):
continue continue
channel_updates[channel_id] = epg_data_id channel_updates[channel_id] = epg_data_id
if epg_data_id:
unique_epg_ids.add(epg_data_id)
# Batch fetch all channels (single query) # Batch fetch all channels (single query)
channels_dict = { channels_dict = {
c.id: c for c in Channel.objects.filter(id__in=channel_updates.keys()) 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 = [] channels_to_update = []
changed_epg_ids = set()
for channel_id, epg_data_id in channel_updates.items(): for channel_id, epg_data_id in channel_updates.items():
if channel_id not in channels_dict: if channel_id not in channels_dict:
logger.error(f"Channel with ID {channel_id} not found") logger.error(f"Channel with ID {channel_id} not found")
continue continue
channel = channels_dict[channel_id] channel = channels_dict[channel_id]
if channel.epg_data_id == epg_data_id:
continue
channel.epg_data_id = epg_data_id channel.epg_data_id = epg_data_id
channels_to_update.append(channel) channels_to_update.append(channel)
if epg_data_id:
changed_epg_ids.add(epg_data_id)
# Bulk update all channels (single query) # Bulk update all channels (single query)
if channels_to_update: if channels_to_update:
@ -2361,27 +2432,9 @@ class ChannelViewSet(viewsets.ModelViewSet):
channels_updated = len(channels_to_update) channels_updated = len(channels_to_update)
# Trigger program refresh for unique EPG data IDs (skip dummy EPGs) from apps.epg.tasks import dispatch_program_refresh_for_epg_ids
from apps.epg.tasks import parse_programs_for_tvg_id
from apps.epg.models import EPGData
# Batch fetch EPG data (single query) programs_refreshed = dispatch_program_refresh_for_epg_ids(changed_epg_ids)
epg_data_dict = {
epg.id: epg
for epg in EPGData.objects.filter(id__in=unique_epg_ids).select_related('epg_source')
}
programs_refreshed = 0
for epg_id in unique_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':
parse_programs_for_tvg_id.delay(epg_id)
programs_refreshed += 1
return Response( return Response(
{ {
@ -2438,19 +2491,34 @@ class BulkDeleteChannelsAPIView(APIView):
return [Authenticated()] return [Authenticated()]
@extend_schema( @extend_schema(
description="Bulk delete channels by ID", description=(
"Bulk delete channels by ID. Optional ``stop_stream`` (default false) "
"stops active proxy sessions for those channels before deleting."
),
request=inline_serializer( request=inline_serializer(
name="BulkDeleteChannelsRequest", name="BulkDeleteChannelsRequest",
fields={ fields={
"channel_ids": serializers.ListField( "channel_ids": serializers.ListField(
child=serializers.IntegerField(), child=serializers.IntegerField(),
help_text="Channel IDs to delete", help_text="Channel IDs to delete",
) ),
"stop_stream": serializers.BooleanField(
required=False,
default=False,
help_text=(
"When true, stop active live proxy sessions for these "
"channels before deleting. Default false leaves streams "
"playing until stopped separately."
),
),
}, },
), ),
) )
def delete(self, request): def delete(self, request):
channel_ids = request.data.get("channel_ids", []) channel_ids = request.data.get("channel_ids", [])
stop_stream = _parse_request_bool(request.data.get("stop_stream"), default=False)
if stop_stream:
_stop_proxy_sessions_for_channel_ids(channel_ids)
Channel.objects.filter(id__in=channel_ids).delete() Channel.objects.filter(id__in=channel_ids).delete()
return Response( return Response(
{"message": "Channels deleted"}, status=status.HTTP_204_NO_CONTENT {"message": "Channels deleted"}, status=status.HTTP_204_NO_CONTENT
@ -2771,23 +2839,26 @@ class LogoViewSet(viewsets.ModelViewSet):
"""Streams the logo file, whether it's local or remote.""" """Streams the logo file, whether it's local or remote."""
logo = self.get_object() logo = self.get_object()
logo_url = logo.url logo_url = logo.url
if not logo_url:
raise Http404("Image not found")
if logo_url.startswith("/data"): # Local file if logo_url.startswith("/data"): # Local file
if not os.path.exists(logo_url): safe_path = resolve_safe_local_data_path(logo_url)
if safe_path is None or not os.path.exists(safe_path):
raise Http404("Image not found") raise Http404("Image not found")
stat = os.stat(logo_url) stat = os.stat(safe_path)
# Get proper mime type (first item of the tuple) # Get proper mime type (first item of the tuple)
content_type, _ = mimetypes.guess_type(logo_url) content_type, _ = mimetypes.guess_type(safe_path)
if not content_type: if not content_type:
content_type = "image/jpeg" # Default to a common image type content_type = "image/jpeg" # Default to a common image type
# Use context manager and set Content-Disposition to inline # StreamingHttpResponse closes the file when the response finishes.
response = StreamingHttpResponse( response = StreamingHttpResponse(
open(logo_url, "rb"), content_type=content_type open(safe_path, "rb"), content_type=content_type
) )
response["Cache-Control"] = "public, max-age=14400" # Cache in browser for 4 hours response["Cache-Control"] = "public, max-age=14400" # Cache in browser for 4 hours
response["Last-Modified"] = http_date(stat.st_mtime) response["Last-Modified"] = http_date(stat.st_mtime)
response["Content-Disposition"] = 'inline; filename="{}"'.format( response["Content-Disposition"] = 'inline; filename="{}"'.format(
os.path.basename(logo_url) os.path.basename(safe_path)
) )
return response return response
@ -2805,8 +2876,9 @@ class LogoViewSet(viewsets.ModelViewSet):
user_agent_obj = UserAgent.objects.get(id=int(default_user_agent_id)) user_agent_obj = UserAgent.objects.get(id=int(default_user_agent_id))
user_agent = user_agent_obj.user_agent user_agent = user_agent_obj.user_agent
except (CoreSettings.DoesNotExist, UserAgent.DoesNotExist, ValueError): except (CoreSettings.DoesNotExist, UserAgent.DoesNotExist, ValueError):
# Fallback to hardcoded if default not found # Fallback if default not found
user_agent = 'Dispatcharr/1.0' from core.utils import dispatcharr_user_agent
user_agent = dispatcharr_user_agent()
# Hard total timeout (connect + full download) prevents a slow # Hard total timeout (connect + full download) prevents a slow
# server dripping bytes from holding a greenlet indefinitely. # server dripping bytes from holding a greenlet indefinitely.
@ -3266,18 +3338,55 @@ def _stop_dvr_clients(channel_uuid, recording_id=None):
return stopped return stopped
# QueryParamJWTAuthentication supports native <video src> clients that cannot
# send Authorization headers. Authorization still requires an authenticated
# user via _user_can_play_recording; these classes only populate request.user.
RECORDING_PLAYBACK_AUTHENTICATORS = [
JWTAuthentication,
ApiKeyAuthentication,
QueryParamJWTAuthentication,
]
def _recording_auth_query_suffix(request):
"""Suffix for rewritten recording URLs when auth used ?token= (native <video>).
hls.js clients authenticate via Authorization on each XHR and do not need
tokens embedded in playlist segment lines.
"""
from rest_framework.request import Request as DRFRequest
if isinstance(request, DRFRequest):
params = request.query_params
else:
params = request.GET
token = params.get("token")
if not token:
return ""
return "?" + urlencode({"token": token})
class RecordingViewSet(viewsets.ModelViewSet): class RecordingViewSet(viewsets.ModelViewSet):
queryset = Recording.objects.all() queryset = Recording.objects.all()
serializer_class = RecordingSerializer serializer_class = RecordingSerializer
def get_permissions(self): def get_permissions(self):
# Allow unauthenticated playback of recording files (like other streaming endpoints) # file/hls use AllowAny so DRF does not reject requests before auth
# classes run; _user_can_play_recording enforces authenticated access.
if self.action in ('file', 'hls'): if self.action in ('file', 'hls'):
return [AllowAny()] return [AllowAny()]
if self.action in (
'stop',
'extend',
'comskip',
'refresh_artwork',
'update_metadata',
):
return [IsAdmin()]
try: try:
return [perm() for perm in permission_classes_by_action[self.action]] return [perm() for perm in permission_classes_by_action[self.action]]
except KeyError: except KeyError:
return [Authenticated()] return [IsAdmin()]
def _user_can_play_recording(self, request, recording): def _user_can_play_recording(self, request, recording):
"""Authorization gate for recording playback (file/hls actions). """Authorization gate for recording playback (file/hls actions).
@ -3335,7 +3444,12 @@ class RecordingViewSet(viewsets.ModelViewSet):
except Exception as e: except Exception as e:
return Response({"success": False, "error": str(e)}, status=400) return Response({"success": False, "error": str(e)}, status=400)
@action(detail=True, methods=["get"], url_path="file") @action(
detail=True,
methods=["get"],
url_path="file",
authentication_classes=RECORDING_PLAYBACK_AUTHENTICATORS,
)
def file(self, request, pk=None): def file(self, request, pk=None):
"""Stream a completed recording file with HTTP Range support for seeking. """Stream a completed recording file with HTTP Range support for seeking.
@ -3359,7 +3473,7 @@ class RecordingViewSet(viewsets.ModelViewSet):
if hls_dir and os.path.isdir(hls_dir): if hls_dir and os.path.isdir(hls_dir):
hls_url = request.build_absolute_uri( hls_url = request.build_absolute_uri(
f"/api/channels/recordings/{pk}/hls/index.m3u8" f"/api/channels/recordings/{pk}/hls/index.m3u8"
) ) + _recording_auth_query_suffix(request)
return HttpResponseRedirect(hls_url) return HttpResponseRedirect(hls_url)
if not file_path or not os.path.exists(file_path): if not file_path or not os.path.exists(file_path):
raise Http404("Recording file not found") raise Http404("Recording file not found")
@ -3423,7 +3537,12 @@ class RecordingViewSet(viewsets.ModelViewSet):
response["Content-Disposition"] = f"inline; filename=\"{file_name}\"" response["Content-Disposition"] = f"inline; filename=\"{file_name}\""
return response return response
@action(detail=True, methods=["get"], url_path="hls/(?P<seg_path>.+)") @action(
detail=True,
methods=["get"],
url_path="hls/(?P<seg_path>.+)",
authentication_classes=RECORDING_PLAYBACK_AUTHENTICATORS,
)
def hls(self, request, pk=None, seg_path=None): def hls(self, request, pk=None, seg_path=None):
"""Serve HLS playlist and segment files for an in-progress (or completed) recording. """Serve HLS playlist and segment files for an in-progress (or completed) recording.
@ -3447,9 +3566,10 @@ class RecordingViewSet(viewsets.ModelViewSet):
cp = recording.custom_properties or {} cp = recording.custom_properties or {}
file_path = cp.get("file_path") file_path = cp.get("file_path")
if seg_path.endswith(".m3u8") and file_path and os.path.exists(file_path) and os.path.getsize(file_path) > 0: if seg_path.endswith(".m3u8") and file_path and os.path.exists(file_path) and os.path.getsize(file_path) > 0:
return HttpResponseRedirect( file_url = request.build_absolute_uri(
request.build_absolute_uri(f"/api/channels/recordings/{pk}/file/") f"/api/channels/recordings/{pk}/file/"
) ) + _recording_auth_query_suffix(request)
return HttpResponseRedirect(file_url)
raise Http404("HLS content not available for this recording") raise Http404("HLS content not available for this recording")
# Security: prevent path traversal outside the HLS directory # Security: prevent path traversal outside the HLS directory
@ -3462,16 +3582,18 @@ class RecordingViewSet(viewsets.ModelViewSet):
raise Http404(f"HLS file not found: {seg_path}") raise Http404(f"HLS file not found: {seg_path}")
if seg_path.endswith(".m3u8"): if seg_path.endswith(".m3u8"):
# Rewrite relative segment lines to absolute URLs through this API # Rewrite relative segment lines to absolute URLs through this API.
# Propagate ?token= only for native <video> clients (see helper).
base_url = request.build_absolute_uri( base_url = request.build_absolute_uri(
f"/api/channels/recordings/{pk}/hls/" f"/api/channels/recordings/{pk}/hls/"
) )
auth_suffix = _recording_auth_query_suffix(request)
lines = [] lines = []
with open(requested) as _f: with open(requested) as _f:
for line in _f: for line in _f:
stripped = line.strip() stripped = line.strip()
if stripped and not stripped.startswith("#"): if stripped and not stripped.startswith("#"):
lines.append(f"{base_url}{stripped}\n") lines.append(f"{base_url}{stripped}{auth_suffix}\n")
else: else:
lines.append(line) lines.append(line)
return HttpResponse("".join(lines), content_type="application/x-mpegURL") return HttpResponse("".join(lines), content_type="application/x-mpegURL")

View file

@ -25,7 +25,8 @@ import logging
from django.db import transaction from django.db import transaction
from .models import Channel, ChannelOverride, ChannelGroupM3UAccount from .models import Channel, ChannelOverride, ChannelGroupM3UAccount
from apps.m3u.tasks import _custom_properties_as_dict, _next_available_number from apps.m3u.tasks import _next_available_number
from core.utils import ensure_custom_properties_dict
from core.utils import ( from core.utils import (
acquire_task_lock, acquire_task_lock,
natural_sort_key, natural_sort_key,
@ -37,7 +38,7 @@ logger = logging.getLogger(__name__)
def is_compact_group(group_relation): def is_compact_group(group_relation):
"""Return True if the given ChannelGroupM3UAccount is in compact mode.""" """Return True if the given ChannelGroupM3UAccount is in compact mode."""
cp = _custom_properties_as_dict(group_relation.custom_properties) cp = ensure_custom_properties_dict(group_relation.custom_properties)
return bool(cp.get("compact_numbering")) return bool(cp.get("compact_numbering"))
@ -72,7 +73,7 @@ def get_group_relation_for_channel(channel):
for rel in ChannelGroupM3UAccount.objects.filter( for rel in ChannelGroupM3UAccount.objects.filter(
m3u_account_id=channel.auto_created_by_id m3u_account_id=channel.auto_created_by_id
): ):
cp = _custom_properties_as_dict(rel.custom_properties) cp = ensure_custom_properties_dict(rel.custom_properties)
if str(cp.get("group_override", "")) == target: if str(cp.get("group_override", "")) == target:
return rel return rel
return None return None
@ -179,7 +180,7 @@ def assign_compact_numbers_for_channels(channel_ids):
for rel in ChannelGroupM3UAccount.objects.filter( for rel in ChannelGroupM3UAccount.objects.filter(
m3u_account_id__in={k[0] for k in unresolved} m3u_account_id__in={k[0] for k in unresolved}
): ):
cp = _custom_properties_as_dict(rel.custom_properties) cp = ensure_custom_properties_dict(rel.custom_properties)
target = cp.get("group_override") target = cp.get("group_override")
if not target: if not target:
continue continue
@ -295,7 +296,7 @@ def _repack_inner(group_relation):
account_id = group_relation.m3u_account_id account_id = group_relation.m3u_account_id
group_id = group_relation.channel_group_id group_id = group_relation.channel_group_id
cp = _custom_properties_as_dict(group_relation.custom_properties) cp = ensure_custom_properties_dict(group_relation.custom_properties)
sort_order = cp.get("channel_sort_order") or "" sort_order = cp.get("channel_sort_order") or ""
sort_reverse = bool(cp.get("channel_sort_reverse")) sort_reverse = bool(cp.get("channel_sort_reverse"))

View file

@ -0,0 +1,927 @@
"""
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 guide/program refresh for newly assigned EPG ids.
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 dispatch_program_refresh_for_epg_ids
epg_ids = {
assoc["epg_data_id"]
for assoc in changed_associations
if assoc.get("epg_data_id")
}
return dispatch_program_refresh_for_epg_ids(epg_ids)
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()

View file

@ -0,0 +1,101 @@
"""Add denormalized catch-up fields to Stream and Channel."""
from django.db import migrations, models
def backfill_stream_catchup(apps, schema_editor):
"""Derive is_catchup/catchup_days from Stream.custom_properties JSON."""
with schema_editor.connection.cursor() as cursor:
cursor.execute("""
UPDATE dispatcharr_channels_stream
SET is_catchup = TRUE,
catchup_days = COALESCE(
CASE WHEN (custom_properties->>'tv_archive_duration') ~ '^\\d+$'
THEN (custom_properties->>'tv_archive_duration')::int
ELSE NULL
END, 7
)
WHERE custom_properties IS NOT NULL
AND custom_properties != 'null'::jsonb
AND (
custom_properties->>'tv_archive' = '1'
-- JSON booleans extract as lowercase 'true' via ->>; the
-- 'True' spelling covers Python-str values stored by
-- older import code.
OR custom_properties->>'tv_archive' = 'true'
OR custom_properties->>'tv_archive' = 'True'
)
""")
def backfill_channel_catchup(apps, schema_editor):
"""Roll up catch-up fields from streams to channels."""
with schema_editor.connection.cursor() as cursor:
cursor.execute("""
UPDATE dispatcharr_channels_channel c SET
is_catchup = EXISTS (
SELECT 1 FROM dispatcharr_channels_channelstream cs
JOIN dispatcharr_channels_stream s ON s.id = cs.stream_id
WHERE cs.channel_id = c.id AND s.is_catchup = TRUE
),
catchup_days = COALESCE((
SELECT MAX(s.catchup_days) FROM dispatcharr_channels_channelstream cs
JOIN dispatcharr_channels_stream s ON s.id = cs.stream_id
WHERE cs.channel_id = c.id AND s.is_catchup = TRUE
), 0)
""")
class Migration(migrations.Migration):
dependencies = [
("dispatcharr_channels", "0037_auto_sync_overhaul"),
]
operations = [
# Stream fields
migrations.AddField(
model_name="stream",
name="is_catchup",
field=models.BooleanField(
default=False,
db_index=True,
help_text="Whether this stream supports catch-up/timeshift (tv_archive=1)",
),
),
migrations.AddField(
model_name="stream",
name="catchup_days",
field=models.PositiveIntegerField(
default=0,
help_text="Number of days of catch-up archive available (tv_archive_duration)",
),
),
# Channel fields
migrations.AddField(
model_name="channel",
name="is_catchup",
field=models.BooleanField(
default=False,
db_index=True,
help_text="Whether any stream on this channel supports catch-up (tv_archive=1)",
),
),
migrations.AddField(
model_name="channel",
name="catchup_days",
field=models.PositiveIntegerField(
default=0,
help_text="Max catch-up archive days across all streams on this channel",
),
),
# Backfill existing data
migrations.RunPython(
backfill_stream_catchup,
reverse_code=migrations.RunPython.noop,
),
migrations.RunPython(
backfill_channel_catchup,
reverse_code=migrations.RunPython.noop,
),
]

View file

@ -2,9 +2,9 @@ from django.db import models
from django.core.exceptions import ValidationError from django.core.exceptions import ValidationError
from django.conf import settings from django.conf import settings
from core.models import StreamProfile, CoreSettings from core.models import StreamProfile, CoreSettings
from core.utils import RedisClient from core.utils import RedisClient, custom_properties_as_dict
from apps.proxy.live_proxy.redis_keys import RedisKeys from apps.proxy.live_proxy.redis_keys import RedisKeys
from apps.proxy.live_proxy.constants import ChannelMetadataField from apps.proxy.live_proxy.constants import ChannelMetadataField, ChannelState
import logging import logging
import uuid import uuid
from django.utils import timezone from django.utils import timezone
@ -17,6 +17,7 @@ logger = logging.getLogger(__name__)
# If you have an M3UAccount model in apps.m3u, you can still import it: # If you have an M3UAccount model in apps.m3u, you can still import it:
from apps.m3u.models import M3UAccount from apps.m3u.models import M3UAccount
from apps.m3u.connection_pool import reserve_profile_slot, release_profile_slot
# Add fallback functions if Redis isn't available # Add fallback functions if Redis isn't available
@ -134,6 +135,17 @@ class Stream(models.Model):
db_index=True db_index=True
) )
# Populated at import from tv_archive / tv_archive_duration.
is_catchup = models.BooleanField(
default=False,
db_index=True,
help_text="Whether this stream supports catch-up/timeshift (tv_archive=1)",
)
catchup_days = models.PositiveIntegerField(
default=0,
help_text="Number of days of catch-up archive available (tv_archive_duration)",
)
class Meta: class Meta:
# If you use m3u_account, you might do unique_together = ('name','url','m3u_account') # If you use m3u_account, you might do unique_together = ('name','url','m3u_account')
verbose_name = "Stream" verbose_name = "Stream"
@ -209,13 +221,14 @@ class Stream(models.Model):
Finds an available profile for this stream and reserves a connection slot. Finds an available profile for this stream and reserves a connection slot.
Returns: Returns:
Tuple[Optional[int], Optional[int], Optional[str]]: (stream_id, profile_id, error_reason) Tuple[Optional[int], Optional[int], Optional[str], bool]:
(stream_id, profile_id, error_reason, slot_reserved)
""" """
redis_client = RedisClient.get_client() redis_client = RedisClient.get_client()
profile_id = redis_client.get(f"stream_profile:{self.id}") profile_id = redis_client.get(f"stream_profile:{self.id}")
if profile_id: if profile_id:
profile_id = int(profile_id) profile_id = int(profile_id)
return self.id, profile_id, None return self.id, profile_id, None, False
# Retrieve the M3U account associated with the stream. # Retrieve the M3U account associated with the stream.
m3u_account = self.m3u_account m3u_account = self.m3u_account
@ -226,29 +239,22 @@ class Stream(models.Model):
] ]
for profile in profiles: for profile in profiles:
logger.info(profile) logger.debug("Evaluating profile %s for stream %s", profile.id, self.id)
# Skip inactive profiles # Skip inactive profiles
if profile.is_active == False: if profile.is_active == False:
continue continue
# Atomic slot reservation: INCR first, check, rollback if over capacity # Atomic slot reservation via shared connection pool helper
if profile.max_streams == 0: reserved, _count, _failure_reason = reserve_profile_slot(
reserved = True profile, redis_client
else: )
profile_connections_key = f"profile_connections:{profile.id}"
new_count = redis_client.incr(profile_connections_key)
if new_count <= profile.max_streams:
reserved = True
else:
redis_client.decr(profile_connections_key)
reserved = False
if reserved: if reserved:
redis_client.set(f"channel_stream:{self.id}", self.id) redis_client.set(f"channel_stream:{self.id}", self.id)
redis_client.set(f"stream_profile:{self.id}", profile.id) redis_client.set(f"stream_profile:{self.id}", profile.id)
return self.id, profile.id, None return self.id, profile.id, None, True
return None, None, "All active M3U profiles have reached maximum connection limits" return None, None, "All active M3U profiles have reached maximum connection limits", False
def release_stream(self): def release_stream(self):
""" """
@ -277,12 +283,7 @@ class Stream(models.Model):
f"Stream {stream_id}: found profile_id={profile_id}" f"Stream {stream_id}: found profile_id={profile_id}"
) )
profile_connections_key = f"profile_connections:{profile_id}" release_profile_slot(profile_id, redis_client)
# Only decrement if the profile had a max_connections limit
current_count = int(redis_client.get(profile_connections_key) or 0)
if current_count > 0:
redis_client.decr(profile_connections_key)
return True return True
@ -374,6 +375,17 @@ class Channel(models.Model):
help_text="The M3U account that auto-created this channel" help_text="The M3U account that auto-created this channel"
) )
# Populated at import; rolled up via ChannelStream signal / m3u refresh.
is_catchup = models.BooleanField(
default=False,
db_index=True,
help_text="Whether any stream on this channel supports catch-up (tv_archive=1)",
)
catchup_days = models.PositiveIntegerField(
default=0,
help_text="Max catch-up archive days across all streams on this channel",
)
# Hidden channels are excluded from HDHR, M3U, EPG, and XC output queries. # Hidden channels are excluded from HDHR, M3U, EPG, and XC output queries.
# Auto-sync still recognizes them so they are not recreated when their # Auto-sync still recognizes them so they are not recreated when their
# underlying provider stream persists; this is an output-layer concern, not # underlying provider stream persists; this is an output-layer concern, not
@ -599,44 +611,91 @@ class Channel(models.Model):
return victim_id return victim_id
def _check_and_reserve_profile_slot(self, profile, redis_client): def _channel_proxy_is_active(self, redis_client) -> bool:
"""True when live proxy metadata shows this channel is still running."""
metadata_key = RedisKeys.channel_metadata(str(self.uuid))
if not redis_client.exists(metadata_key):
return False
state = redis_client.hget(metadata_key, ChannelMetadataField.STATE)
if state is None:
return False
if isinstance(state, bytes):
state = state.decode()
return state in (
ChannelState.ACTIVE,
ChannelState.WAITING_FOR_CLIENTS,
ChannelState.BUFFERING,
ChannelState.INITIALIZING,
ChannelState.CONNECTING,
)
def _stream_assignment_is_reusable(self, redis_client, stream_id: int) -> bool:
""" """
Atomically check and reserve a connection slot for the given profile. Return True when an existing channel_stream assignment should be reused.
Uses an INCR-first-then-check pattern to eliminate the TOCTOU race Reuse when the proxy is active, or when metadata is not written yet
condition where separate GET + check + INCR operations could allow (between get_stream() reserving slots and initialize_channel() starting).
concurrent requests to both pass the capacity check. When metadata exists but the proxy is inactive, the assignment is stale.
For profiles with max_streams=0 (unlimited), no reservation is needed.
Args:
profile: M3UAccountProfile instance
redis_client: Redis client instance
Returns:
tuple: (reserved: bool, current_count: int)
""" """
if profile.max_streams == 0: if self._channel_proxy_is_active(redis_client):
return (True, 0) return True
profile_connections_key = f"profile_connections:{profile.id}" metadata_key = RedisKeys.channel_metadata(str(self.uuid))
if not redis_client.exists(metadata_key):
return redis_client.get(f"stream_profile:{stream_id}") is not None
# Atomically increment first — this is a single Redis command return False
new_count = redis_client.incr(profile_connections_key)
if new_count <= profile.max_streams: def _release_stale_stream_assignment(self, redis_client, stream_id: int) -> None:
return (True, new_count) """Release pool counters and remove stale channel/stream assignment keys."""
profile_id = None
profile_id_bytes = redis_client.get(f"stream_profile:{stream_id}")
if profile_id_bytes:
try:
profile_id = int(profile_id_bytes)
except (ValueError, TypeError):
logger.debug(
"Invalid profile ID for stale assignment on stream %s: %s",
stream_id,
profile_id_bytes,
)
# Over capacity — roll back the increment if profile_id is None:
redis_client.decr(profile_connections_key) metadata_key = RedisKeys.channel_metadata(str(self.uuid))
return (False, new_count - 1) meta_profile_id = redis_client.hget(
metadata_key, ChannelMetadataField.M3U_PROFILE
)
if meta_profile_id:
try:
profile_id = int(meta_profile_id)
except (ValueError, TypeError):
logger.debug(
"Invalid profile ID in metadata for stale assignment on "
"channel %s: %s",
self.uuid,
meta_profile_id,
)
if profile_id is not None:
release_profile_slot(profile_id, redis_client)
else:
logger.warning(
"Channel %s: releasing stale stream %s assignment without profile "
"info - profile_connections may leak",
self.uuid,
stream_id,
)
redis_client.delete(f"channel_stream:{self.id}")
redis_client.delete(f"stream_profile:{stream_id}")
def get_stream(self, requester=None): def get_stream(self, requester=None):
""" """
Finds an available stream for the requested channel and returns the selected stream and profile. Finds an available stream for the requested channel and returns the selected stream and profile.
Returns: Returns:
Tuple[Optional[int], Optional[int], Optional[str]]: (stream_id, profile_id, error_reason) Tuple[Optional[int], Optional[int], Optional[str], bool]:
(stream_id, profile_id, error_reason, slot_reserved)
""" """
redis_client = RedisClient.get_client() redis_client = RedisClient.get_client()
error_reason = None error_reason = None
@ -644,26 +703,42 @@ class Channel(models.Model):
# Check if this channel has any streams # Check if this channel has any streams
if not self.streams.exists(): if not self.streams.exists():
error_reason = "No streams assigned to channel" error_reason = "No streams assigned to channel"
return None, None, error_reason return None, None, error_reason, False
# Check if a stream is already active for this channel # Reuse assignment only when this channel is still active in the proxy.
# Stale channel_stream keys after stop/disconnect skip INCR and break pool
# accounting, which lets a second stream reach the provider and fail validation.
stream_id_bytes = redis_client.get(f"channel_stream:{self.id}") stream_id_bytes = redis_client.get(f"channel_stream:{self.id}")
if stream_id_bytes: if stream_id_bytes:
try: try:
stream_id = int(stream_id_bytes) stream_id = int(stream_id_bytes)
profile_id_bytes = redis_client.get(f"stream_profile:{stream_id}")
if profile_id_bytes:
try:
profile_id = int(profile_id_bytes)
return stream_id, profile_id, None
except (ValueError, TypeError):
logger.debug(
f"Invalid profile ID retrieved from Redis: {profile_id_bytes}"
)
except (ValueError, TypeError): except (ValueError, TypeError):
logger.debug( logger.debug(
f"Invalid stream ID retrieved from Redis: {stream_id_bytes}" f"Invalid stream ID retrieved from Redis: {stream_id_bytes}"
) )
stream_id = None
if stream_id is not None:
if self._stream_assignment_is_reusable(redis_client, stream_id):
profile_id_bytes = redis_client.get(f"stream_profile:{stream_id}")
if profile_id_bytes:
try:
profile_id = int(profile_id_bytes)
logger.debug(
f"Channel {self.uuid}: reusing stream assignment "
f"stream={stream_id} profile={profile_id}"
)
return stream_id, profile_id, None, False
except (ValueError, TypeError):
logger.debug(
f"Invalid profile ID retrieved from Redis: {profile_id_bytes}"
)
else:
logger.info(
f"Channel {self.uuid}: releasing stale stream assignment "
f"(stream={stream_id}, proxy not active)"
)
self._release_stale_stream_assignment(redis_client, stream_id)
# No existing active stream, attempt to assign a new one # No existing active stream, attempt to assign a new one
has_streams_but_maxed_out = False has_streams_but_maxed_out = False
@ -697,7 +772,7 @@ class Channel(models.Model):
has_active_profiles = True has_active_profiles = True
# Atomically check and reserve a slot (INCR-first pattern) # Atomically check and reserve a slot (INCR-first pattern)
reserved, current_count = self._check_and_reserve_profile_slot( reserved, current_count, failure_reason = reserve_profile_slot(
profile, redis_client profile, redis_client
) )
@ -705,11 +780,16 @@ class Channel(models.Model):
# Slot reserved — assign stream to this channel # Slot reserved — assign stream to this channel
redis_client.set(f"channel_stream:{self.id}", stream.id) redis_client.set(f"channel_stream:{self.id}", stream.id)
redis_client.set(f"stream_profile:{stream.id}", profile.id) redis_client.set(f"stream_profile:{stream.id}", profile.id)
logger.info(
f"Channel {self.uuid}: assigned stream {stream.id} "
f"profile {profile.id} ({profile.name})"
)
return ( return (
stream.id, stream.id,
profile.id, profile.id,
None, None,
True,
) # Return newly assigned stream and matched profile ) # Return newly assigned stream and matched profile
else: else:
# At capacity: try to preempt a lower-impact channel on this profile # At capacity: try to preempt a lower-impact channel on this profile
@ -723,12 +803,21 @@ class Channel(models.Model):
logger.info(f"Preempting channel {victim_channel_id} for new stream on profile {profile.id}") logger.info(f"Preempting channel {victim_channel_id} for new stream on profile {profile.id}")
# return self.id, profile.id, victim_channel_id # return self.id, profile.id, victim_channel_id
# This profile is at max connections
has_streams_but_maxed_out = True has_streams_but_maxed_out = True
logger.debug( if failure_reason == "profile_full":
f"Profile {profile.id} at max connections: " logger.info(
f"{current_count}/{profile.max_streams}" f"Profile {profile.id} at max connections: "
) f"{current_count}/{profile.max_streams}, trying next profile"
)
elif failure_reason == "credential_full":
logger.info(
f"Profile {profile.id} shared login pool full, trying next profile"
)
else:
logger.debug(
f"Profile {profile.id} reservation failed: "
f"{current_count}/{profile.max_streams}"
)
# No available streams - determine specific reason # No available streams - determine specific reason
if has_streams_but_maxed_out: if has_streams_but_maxed_out:
@ -738,7 +827,7 @@ class Channel(models.Model):
else: else:
error_reason = "No active profiles found for any assigned stream" error_reason = "No active profiles found for any assigned stream"
return None, None, error_reason return None, None, error_reason, False
def release_stream(self): def release_stream(self):
""" """
@ -782,12 +871,7 @@ class Channel(models.Model):
ChannelMetadataField.M3U_PROFILE, ChannelMetadataField.M3U_PROFILE,
) )
profile_connections_key = f"profile_connections:{profile_id}" release_profile_slot(profile_id, redis_client)
current_count = int(
redis_client.get(profile_connections_key) or 0
)
if current_count > 0:
redis_client.decr(profile_connections_key)
return True return True
logger.debug( logger.debug(
@ -832,12 +916,7 @@ class Channel(models.Model):
f"stream {stream_id}" f"stream {stream_id}"
) )
profile_connections_key = f"profile_connections:{profile_id}" release_profile_slot(profile_id, redis_client)
# Only decrement if the profile had a max_connections limit
current_count = int(redis_client.get(profile_connections_key) or 0)
if current_count > 0:
redis_client.decr(profile_connections_key)
# Clear metadata fields so duplicate release_stream() calls # Clear metadata fields so duplicate release_stream() calls
# (e.g. from _clean_redis_keys or ChannelService.stop_channel) # (e.g. from _clean_redis_keys or ChannelService.stop_channel)
@ -883,10 +962,31 @@ class Channel(models.Model):
if current_profile_id == new_profile_id: if current_profile_id == new_profile_id:
return True return True
# Use pipeline for atomic profile switch to prevent counter drift from apps.m3u.connection_pool import (
# if an exception occurs between DECR and INCR move_credential_slot_on_profile_switch,
old_profile_connections_key = f"profile_connections:{current_profile_id}" profile_connections_key,
new_profile_connections_key = f"profile_connections:{new_profile_id}" )
from apps.m3u.models import M3UAccountProfile
old_profile = M3UAccountProfile.objects.select_related(
"m3u_account__server_group"
).get(id=current_profile_id)
new_profile = M3UAccountProfile.objects.select_related(
"m3u_account__server_group"
).get(id=new_profile_id)
if not move_credential_slot_on_profile_switch(
old_profile, new_profile, redis_client
):
logger.warning(
"Shared login pool full for profile %s during stream profile switch",
new_profile_id,
)
return False
# Profile counters always move on switch; credential totals move only when login changes.
old_profile_connections_key = profile_connections_key(current_profile_id)
new_profile_connections_key = profile_connections_key(new_profile_id)
old_count = int(redis_client.get(old_profile_connections_key) or 0) old_count = int(redis_client.get(old_profile_connections_key) or 0)
pipe = redis_client.pipeline() pipe = redis_client.pipeline()
@ -1041,6 +1141,13 @@ class ChannelGroupM3UAccount(models.Model):
def __str__(self): def __str__(self):
return f"{self.channel_group.name} - {self.m3u_account.name} (Enabled: {self.enabled})" return f"{self.channel_group.name} - {self.m3u_account.name} (Enabled: {self.enabled})"
def save(self, *args, **kwargs):
if self.custom_properties is not None and not isinstance(
self.custom_properties, dict
):
self.custom_properties = custom_properties_as_dict(self.custom_properties)
super().save(*args, **kwargs)
class Logo(models.Model): class Logo(models.Model):
name = models.CharField(max_length=255) name = models.CharField(max_length=255)

View file

@ -142,6 +142,8 @@ class StreamSerializer(serializers.ModelSerializer):
"stream_stats_updated_at", "stream_stats_updated_at",
"stream_id", "stream_id",
"stream_chno", "stream_chno",
"is_catchup",
"catchup_days",
] ]
def get_fields(self): def get_fields(self):
@ -221,17 +223,6 @@ class ChannelGroupM3UAccountSerializer(serializers.ModelSerializer):
return data return data
def to_internal_value(self, data):
# Accept both dict and JSON string for custom_properties (for backward compatibility)
val = data.get("custom_properties")
if isinstance(val, str):
try:
data["custom_properties"] = json.loads(val)
except Exception:
pass
return super().to_internal_value(data)
def validate(self, attrs): def validate(self, attrs):
# Partial PATCHes only carry submitted fields; fill missing # Partial PATCHes only carry submitted fields; fill missing
# start/end from the instance so the validator catches a PATCH # start/end from the instance so the validator catches a PATCH
@ -459,6 +450,8 @@ class ChannelSerializer(serializers.ModelSerializer):
"logo_id", "logo_id",
"user_level", "user_level",
"is_adult", "is_adult",
"is_catchup",
"catchup_days",
"hidden_from_output", "hidden_from_output",
"auto_created", "auto_created",
"auto_created_by", "auto_created_by",
@ -562,10 +555,13 @@ class ChannelSerializer(serializers.ModelSerializer):
return LogoSerializer(obj.logo).data return LogoSerializer(obj.logo).data
def get_streams(self, obj): def get_streams(self, obj):
"""Retrieve ordered stream IDs for GET requests.""" """Retrieve ordered streams for GET requests using prefetched channelstream_set."""
return StreamSerializer( ordered_streams = [
obj.streams.all().order_by("channelstream__order"), many=True cs.stream
).data for cs in obj.channelstream_set.all()
if cs.stream_id is not None
]
return StreamSerializer(ordered_streams, many=True).data
def create(self, validated_data): def create(self, validated_data):
streams = validated_data.pop("streams", []) streams = validated_data.pop("streams", [])

View file

@ -1,11 +1,11 @@
# apps/channels/signals.py # apps/channels/signals.py
from django.db.models.signals import m2m_changed, pre_save, post_save, post_delete, pre_delete from django.db.models.signals import m2m_changed, pre_save, post_save, post_delete
from django.dispatch import receiver from django.dispatch import receiver
from django.utils.timezone import now, is_aware, make_aware from django.utils.timezone import now, is_aware, make_aware
from celery.result import AsyncResult from celery.result import AsyncResult
from django_celery_beat.models import ClockedSchedule, PeriodicTask from django_celery_beat.models import ClockedSchedule, PeriodicTask
from .models import Channel, Stream, ChannelProfile, ChannelProfileMembership, Recording from .models import Channel, Stream, ChannelStream, ChannelProfile, ChannelProfileMembership, Recording
from apps.m3u.models import M3UAccount from apps.m3u.models import M3UAccount
from apps.epg.tasks import parse_programs_for_tvg_id from apps.epg.tasks import parse_programs_for_tvg_id
import json import json
@ -60,38 +60,6 @@ def generate_custom_stream_hash(sender, instance, created, **kwargs):
# Use update to avoid triggering signals again # Use update to avoid triggering signals again
Stream.objects.filter(id=instance.id).update(stream_hash=instance.stream_hash) Stream.objects.filter(id=instance.id).update(stream_hash=instance.stream_hash)
@receiver(pre_delete, sender=Channel)
def stop_proxy_session_before_channel_delete(sender, instance, **kwargs):
"""
When a Channel is deleted, stop any active TS proxy session for it first.
Without this, the proxy's Redis state (live:channel:{uuid}:*) survives
the Channel row and the connected clients' "Stop" button hits
`ChannelService.stop_channel(uuid)`, which calls `Channel.objects.get(uuid=...)`
and crashes with DoesNotExist (reported as 'Channel not found' in the UI).
Users then cannot close the stream; source-side connection limits stay
consumed. Covers manual deletes, bulk deletes, and sync-driven deletes
via the same signal path.
"""
try:
from apps.proxy.live_proxy.services.channel_service import ChannelService
channel_uuid = str(instance.uuid) if instance.uuid else None
if not channel_uuid:
return
# Best-effort: if the channel has no active session the service
# returns a benign 'Channel not found' result, which is ignored.
ChannelService.stop_channel(channel_uuid)
except Exception as e:
# Never block a channel delete on proxy cleanup failure. Log and
# continue so at least the DB row is removed.
logger.warning(
"Failed to stop proxy session before deleting channel %s: %s",
getattr(instance, "id", "<unknown>"),
e,
)
@receiver(post_save, sender=Channel) @receiver(post_save, sender=Channel)
def assign_compact_number_on_unhide(sender, instance, created, **kwargs): def assign_compact_number_on_unhide(sender, instance, created, **kwargs):
"""When a channel transitions from hidden to visible under compact """When a channel transitions from hidden to visible under compact
@ -201,18 +169,13 @@ def refresh_epg_programs(sender, instance, created, **kwargs):
if not created and kwargs.get('update_fields') and 'epg_data' in kwargs['update_fields']: 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") logger.info(f"Channel {instance.id} ({instance.name}) EPG data updated, refreshing program data")
if instance.epg_data: if instance.epg_data:
# Skip XMLTV program parser for SD sources — program data is handled if instance.epg_data.epg_source and instance.epg_data.epg_source.source_type == 'dummy':
# 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 return
logger.info(f"Triggering EPG program refresh for {instance.epg_data.tvg_id}") logger.info(f"Triggering EPG program refresh for {instance.epg_data.tvg_id}")
parse_programs_for_tvg_id.delay(instance.epg_data.id) parse_programs_for_tvg_id.delay(instance.epg_data.id)
# For new channels with EPG data, also refresh # For new channels with EPG data, also refresh
elif created and instance.epg_data: 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 == 'dummy':
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 return
logger.info(f"New channel {instance.id} ({instance.name}) created with EPG data, refreshing program data") 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) parse_programs_for_tvg_id.delay(instance.epg_data.id)
@ -374,3 +337,20 @@ def schedule_task_on_save(sender, instance, created, **kwargs):
@receiver(post_delete, sender=Recording) @receiver(post_delete, sender=Recording)
def revoke_task_on_delete(sender, instance, **kwargs): def revoke_task_on_delete(sender, instance, **kwargs):
revoke_task(instance.task_id) revoke_task(instance.task_id)
@receiver([post_save, post_delete], sender=ChannelStream)
def update_channel_catchup_fields(sender, instance, **kwargs):
"""Roll up catch-up flags from active streams (UI path; import uses SQL rollup)."""
from django.db.models import Max
channel = instance.channel
catchup_qs = channel.streams.filter(
is_catchup=True,
m3u_account__is_active=True,
)
max_days = catchup_qs.aggregate(max_days=Max("catchup_days"))["max_days"]
Channel.objects.filter(pk=channel.pk).update(
is_catchup=catchup_qs.exists(),
catchup_days=max_days or 0,
)

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,158 @@
from unittest.mock import patch
from django.core.cache import cache
from django.test import RequestFactory, TestCase
from apps.accounts.models import User
from apps.channels.models import Channel, ChannelStream, Stream
from apps.channels.utils import is_catchup_enabled, resolve_xc_epg_prev_days
from apps.m3u.models import M3UAccount
from core.models import SYSTEM_SETTINGS_KEY, CoreSettings
class IsCatchupEnabledTests(TestCase):
def setUp(self):
cache.clear()
self.user = User(username="catchup-gate", custom_properties={})
# Drop any leftover catchup_enabled so default-on tests see a clean slate.
obj = CoreSettings.objects.filter(key=SYSTEM_SETTINGS_KEY).first()
if obj is not None:
value = dict(obj.value or {})
if "catchup_enabled" in value:
value.pop("catchup_enabled", None)
obj.value = value
obj.save()
def tearDown(self):
# CoreSettings writes populate Redis; DB rollback does not clear it.
cache.clear()
def _set_system_catchup(self, enabled):
obj, _ = CoreSettings.objects.get_or_create(
key=SYSTEM_SETTINGS_KEY,
defaults={"name": "System Settings", "value": {}},
)
value = dict(obj.value or {})
value["catchup_enabled"] = enabled
obj.value = value
obj.save()
def test_defaults_to_enabled(self):
self.assertTrue(is_catchup_enabled())
self.assertTrue(is_catchup_enabled(user=self.user))
def test_system_disable_blocks_everyone(self):
self._set_system_catchup(False)
self.assertFalse(is_catchup_enabled())
self.assertFalse(is_catchup_enabled(user=self.user))
def test_user_disable_only_affects_that_user(self):
self._set_system_catchup(True)
self.user.custom_properties = {"catchup_enabled": False}
other = User(username="other", custom_properties={})
self.assertFalse(is_catchup_enabled(user=self.user))
self.assertTrue(is_catchup_enabled(user=other))
def test_user_disable_skips_system_settings_lookup(self):
self.user.custom_properties = {"catchup_enabled": False}
with patch.object(CoreSettings, "get_catchup_enabled") as system_mock:
self.assertFalse(is_catchup_enabled(user=self.user))
system_mock.assert_not_called()
def test_system_disable_overrides_user_enable(self):
self._set_system_catchup(False)
self.user.custom_properties = {"catchup_enabled": True}
self.assertFalse(is_catchup_enabled(user=self.user))
class ResolveXcEpgPrevDaysTests(TestCase):
def setUp(self):
self.factory = RequestFactory()
self.user = User(username="xc-prev", custom_properties={})
def test_url_prev_days_zero_is_explicit(self):
request = self.factory.get("/xmltv.php?prev_days=0")
self.assertEqual(resolve_xc_epg_prev_days(request, self.user), 0)
def test_user_epg_prev_days_used_when_url_omitted(self):
self.user.custom_properties = {"epg_prev_days": 5}
request = self.factory.get("/xmltv.php")
self.assertEqual(resolve_xc_epg_prev_days(request, self.user), 5)
@patch("apps.channels.utils.compute_provider_archive_days_capped", return_value=14)
def test_auto_detect_only_when_no_url_or_user_default(self, mock_compute):
request = self.factory.get("/xmltv.php")
self.assertEqual(resolve_xc_epg_prev_days(request, self.user), 14)
mock_compute.assert_called_once()
@patch("apps.channels.utils.compute_provider_archive_days_capped", return_value=14)
def test_per_channel_epg_skips_global_auto_detect(self, mock_compute):
request = self.factory.get("/player_api.php")
self.assertEqual(
resolve_xc_epg_prev_days(request, self.user, auto_detect_fallback=False),
0,
)
mock_compute.assert_not_called()
@patch("apps.channels.utils.compute_provider_archive_days_capped", return_value=14)
def test_user_default_prevents_auto_detect(self, mock_compute):
self.user.custom_properties = {"epg_prev_days": 3}
request = self.factory.get("/xmltv.php")
self.assertEqual(resolve_xc_epg_prev_days(request, self.user), 3)
mock_compute.assert_not_called()
@patch("apps.channels.utils.compute_provider_archive_days_capped", return_value=14)
def test_auto_detect_still_runs_when_catchup_disabled(self, mock_compute):
"""Catchup disable must not suppress historical EPG lookback."""
self.user.custom_properties = {"catchup_enabled": False}
request = self.factory.get("/xmltv.php")
self.assertEqual(resolve_xc_epg_prev_days(request, self.user), 14)
mock_compute.assert_called_once()
class CatchupRollupActiveAccountTests(TestCase):
"""Denormalized catch-up flags ignore disabled M3U accounts."""
@classmethod
def setUpTestData(cls):
cls.inactive = M3UAccount.objects.create(
name="catchup-inactive",
server_url="http://example.test",
account_type="XC",
is_active=False,
)
def test_channelstream_signal_ignores_inactive_catchup_stream(self):
channel = Channel.objects.create(name="inactive-only")
stream = Stream.objects.create(
name="inactive-catchup",
url="http://example.test/inactive",
m3u_account=self.inactive,
is_catchup=True,
catchup_days=9,
)
ChannelStream.objects.create(channel=channel, stream=stream, order=0)
channel.refresh_from_db()
self.assertFalse(channel.is_catchup)
self.assertEqual(channel.catchup_days, 0)
def test_rollup_ignores_inactive_catchup_stream(self):
from apps.m3u.tasks import rollup_channel_catchup_fields
channel = Channel.objects.create(name="rollup-inactive-only")
stream = Stream.objects.create(
name="rollup-inactive-catchup",
url="http://example.test/rollup-inactive",
m3u_account=self.inactive,
is_catchup=True,
catchup_days=9,
)
ChannelStream.objects.create(channel=channel, stream=stream, order=0)
Channel.objects.filter(pk=channel.pk).update(is_catchup=True, catchup_days=9)
rollup_channel_catchup_fields(self.inactive.id)
channel.refresh_from_db()
self.assertFalse(channel.is_catchup)
self.assertEqual(channel.catchup_days, 0)

View file

@ -293,7 +293,8 @@ class ChannelSummaryEffectiveValuesTests(TestCase):
def test_summary_returns_effective_values(self): def test_summary_returns_effective_values(self):
response = self.client.get("/api/channels/channels/summary/") response = self.client.get("/api/channels/channels/summary/")
self.assertEqual(response.status_code, 200) self.assertEqual(response.status_code, 200)
row = next(r for r in response.data if r["id"] == self.channel.id) data = response.json()
row = next(r for r in data if r["id"] == self.channel.id)
self.assertEqual(row["name"], "Override Name") self.assertEqual(row["name"], "Override Name")
self.assertEqual(row["channel_number"], 99.0) self.assertEqual(row["channel_number"], 99.0)
self.assertEqual(row["channel_group_id"], self.other_group.id) self.assertEqual(row["channel_group_id"], self.other_group.id)
@ -504,3 +505,117 @@ class SeriesRuleAPITests(TestCase):
def test_bulk_remove_requires_tvg_id_or_title(self): def test_bulk_remove_requires_tvg_id_or_title(self):
resp = self.client.post(self.bulk_remove_url, {}, format="json") resp = self.client.post(self.bulk_remove_url, {}, format="json")
self.assertEqual(resp.status_code, status.HTTP_400_BAD_REQUEST) self.assertEqual(resp.status_code, status.HTTP_400_BAD_REQUEST)
class ChannelListIncludeStreamsQueryTests(TestCase):
"""include_streams=true must not issue one stream query per channel."""
def setUp(self):
from apps.channels.models import ChannelStream, Stream
from apps.m3u.models import M3UAccount
self.user = User.objects.create_user(username="list_admin", password="x")
self.user.user_level = 10
self.user.save()
self.client = APIClient()
self.client.force_authenticate(user=self.user)
self.account = M3UAccount.objects.create(
name="list-test-account",
account_type="XC",
username="user",
password="pass",
)
self.group = ChannelGroup.objects.create(name=f"List Group {self.id}")
def _add_channel_with_stream(self, number):
from apps.channels.models import ChannelStream, Stream
channel = Channel.objects.create(
channel_number=float(number),
name=f"Channel {number}",
channel_group=self.group,
)
stream = Stream.objects.create(
name=f"Stream {number}",
url=f"http://example.com/{number}.ts",
m3u_account=self.account,
)
ChannelStream.objects.create(channel=channel, stream=stream, order=0)
return channel
def _query_count_for_list(self):
from django.db import connection
from django.test.utils import CaptureQueriesContext
with CaptureQueriesContext(connection) as ctx:
response = self.client.get(
"/api/channels/channels/",
{"page": 1, "page_size": 50, "include_streams": "true"},
)
self.assertEqual(response.status_code, status.HTTP_200_OK)
return len(ctx.captured_queries)
def test_include_streams_query_count_stable_as_channels_grow(self):
self._add_channel_with_stream(1)
self._add_channel_with_stream(2)
self._add_channel_with_stream(3)
q_small = self._query_count_for_list()
self._add_channel_with_stream(4)
self._add_channel_with_stream(5)
self._add_channel_with_stream(6)
self._add_channel_with_stream(7)
q_large = self._query_count_for_list()
self.assertEqual(
q_small,
q_large,
"include_streams list should use prefetched channelstream_set, "
"not one streams M2M query per channel",
)
class ChannelListOnlyCatchupFilterTests(TestCase):
def setUp(self):
self.user = User.objects.create_user(username="catchup_filter", password="x")
self.user.user_level = 10
self.user.save()
self.client = APIClient()
self.client.force_authenticate(user=self.user)
self.catchup_channel = Channel.objects.create(
channel_number=1.0,
name="Catch-up Channel",
is_catchup=True,
catchup_days=7,
)
self.live_channel = Channel.objects.create(
channel_number=2.0,
name="Live Channel",
is_catchup=False,
)
def test_only_catchup_returns_catchup_channels(self):
response = self.client.get(
"/api/channels/channels/",
{"only_catchup": "true", "page": 1, "page_size": 50},
)
self.assertEqual(response.status_code, status.HTTP_200_OK)
ids = {row["id"] for row in response.data["results"]}
self.assertEqual(ids, {self.catchup_channel.id})
def test_only_catchup_does_not_force_distinct(self):
from django.db import connection
from django.test.utils import CaptureQueriesContext
with CaptureQueriesContext(connection) as ctx:
response = self.client.get(
"/api/channels/channels/",
{"only_catchup": "true", "page": 1, "page_size": 50},
)
self.assertEqual(response.status_code, status.HTTP_200_OK)
sql = " ".join(q["sql"] for q in ctx.captured_queries).upper()
self.assertNotIn("DISTINCT", sql)

View file

@ -0,0 +1,95 @@
"""Channel delete optionally stops live proxy sessions (default: leave playing)."""
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, ChannelGroup
User = get_user_model()
class ChannelDeleteStopStreamAPITests(TestCase):
def setUp(self):
self.user = User.objects.create_user(username="deleter", password="testpass123")
self.user.user_level = 10
self.user.save()
self.client = APIClient()
self.client.force_authenticate(user=self.user)
self.group = ChannelGroup.objects.create(name="Delete API Group")
self.channel = Channel.objects.create(
channel_number=42.0,
name="Playing Channel",
channel_group=self.group,
)
@patch(
"apps.proxy.live_proxy.services.channel_service.ChannelService.stop_channels"
)
def test_delete_without_stop_stream_leaves_proxy_running(self, mock_stop_channels):
url = f"/api/channels/channels/{self.channel.id}/"
response = self.client.delete(url)
self.assertEqual(response.status_code, status.HTTP_204_NO_CONTENT)
self.assertFalse(Channel.objects.filter(pk=self.channel.pk).exists())
mock_stop_channels.assert_not_called()
@patch(
"apps.proxy.live_proxy.services.channel_service.ChannelService.stop_channels"
)
def test_delete_with_stop_stream_stops_before_delete(self, mock_stop_channels):
url = f"/api/channels/channels/{self.channel.id}/?stop_stream=true"
channel_uuid = self.channel.uuid
response = self.client.delete(url)
self.assertEqual(response.status_code, status.HTTP_204_NO_CONTENT)
self.assertFalse(Channel.objects.filter(pk=self.channel.pk).exists())
mock_stop_channels.assert_called_once()
stopped = list(mock_stop_channels.call_args[0][0])
self.assertEqual(stopped, [channel_uuid])
@patch(
"apps.proxy.live_proxy.services.channel_service.ChannelService.stop_channels"
)
def test_bulk_delete_without_stop_stream(self, mock_stop_channels):
other = Channel.objects.create(
channel_number=43.0,
name="Other",
channel_group=self.group,
)
response = self.client.delete(
"/api/channels/channels/bulk-delete/",
{"channel_ids": [self.channel.id, other.id]},
format="json",
)
self.assertEqual(response.status_code, status.HTTP_204_NO_CONTENT)
self.assertFalse(Channel.objects.filter(pk=self.channel.pk).exists())
mock_stop_channels.assert_not_called()
@patch(
"apps.proxy.live_proxy.services.channel_service.ChannelService.stop_channels"
)
def test_bulk_delete_with_stop_stream(self, mock_stop_channels):
other = Channel.objects.create(
channel_number=43.0,
name="Other",
channel_group=self.group,
)
response = self.client.delete(
"/api/channels/channels/bulk-delete/",
{
"channel_ids": [self.channel.id, other.id],
"stop_stream": True,
},
format="json",
)
self.assertEqual(response.status_code, status.HTTP_204_NO_CONTENT)
mock_stop_channels.assert_called_once()
stopped = set(mock_stop_channels.call_args[0][0])
self.assertEqual(stopped, {self.channel.uuid, other.uuid})

View file

@ -203,10 +203,9 @@ class InitialConnectionRetryTests(TestCase):
source = inspect.getsource(run_recording) source = inspect.getsource(run_recording)
self.assertGreater(_dvr_ffmpeg_retry_window_seconds(), 0) self.assertGreater(_dvr_ffmpeg_retry_window_seconds(), 0)
self.assertIn("reconnect", source.lower(),
"run_recording must contain input reconnection flags")
self.assertIn("_ffmpeg_outage_started", source) self.assertIn("_ffmpeg_outage_started", source)
self.assertIn("_ffmpeg_retry_window", source) self.assertIn("_ffmpeg_retry_window", source)
self.assertIn("_dvr_build_ffmpeg_cmd", source)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------

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

View 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")
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",
)
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",
)
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"), "sdchannel 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")

View file

@ -0,0 +1,173 @@
"""Tests for Channel.get_stream() assignment reuse and stale cleanup."""
from unittest.mock import patch
from django.test import TestCase
from apps.channels.models import Channel, ChannelStream, Stream
from apps.m3u.models import M3UAccount, M3UAccountProfile
from apps.proxy.live_proxy.constants import ChannelMetadataField, ChannelState
from apps.proxy.live_proxy.redis_keys import RedisKeys
class FakeAssignmentRedis:
"""In-memory Redis for channel_stream assignment tests."""
def __init__(self):
self._strings = {}
self._hashes = {}
def _decode(self, value):
if isinstance(value, bytes):
return value.decode()
return value
def get(self, key):
value = self._strings.get(key)
if value is None:
return None
if isinstance(value, int):
return str(value).encode()
return str(value).encode()
def set(self, key, value):
self._strings[key] = value
def delete(self, key):
self._strings.pop(key, None)
self._hashes.pop(key, None)
def exists(self, key):
return key in self._strings or key in self._hashes
def hget(self, key, field):
return self._hashes.get(key, {}).get(field)
def hset(self, key, mapping=None, **kwargs):
bucket = self._hashes.setdefault(key, {})
if mapping:
bucket.update(mapping)
bucket.update(kwargs)
def incr(self, key):
current = int(self._decode(self.get(key)) or 0)
current += 1
self._strings[key] = current
return current
def decr(self, key):
current = int(self._decode(self.get(key)) or 0)
current -= 1
self._strings[key] = current
return current
class ChannelGetStreamAssignmentTests(TestCase):
def setUp(self):
self.redis = FakeAssignmentRedis()
self.account = M3UAccount.objects.create(
name="assignment-test",
account_type="XC",
username="user",
password="pass",
max_streams=5,
)
self.profile = M3UAccountProfile.objects.get(
m3u_account=self.account, is_default=True
)
self.profile.max_streams = 2
self.profile.save()
self.stream = Stream.objects.create(
name="Test Stream",
url="http://example.com/live/user/pass/1.ts",
m3u_account=self.account,
)
self.channel = Channel.objects.create(channel_number=501, name="Assignment Ch")
ChannelStream.objects.create(channel=self.channel, stream=self.stream, order=0)
self.metadata_key = RedisKeys.channel_metadata(str(self.channel.uuid))
def _seed_assignment(self):
self.redis.set(f"channel_stream:{self.channel.id}", self.stream.id)
self.redis.set(f"stream_profile:{self.stream.id}", self.profile.id)
@patch("apps.channels.models.RedisClient.get_client")
@patch("apps.channels.models.reserve_profile_slot")
def test_reuses_assignment_when_proxy_active(
self, mock_reserve, mock_get_client
):
mock_get_client.return_value = self.redis
self._seed_assignment()
self.redis.hset(
self.metadata_key,
{ChannelMetadataField.STATE: ChannelState.ACTIVE},
)
stream_id, profile_id, error, slot_reserved = self.channel.get_stream()
self.assertEqual(stream_id, self.stream.id)
self.assertEqual(profile_id, self.profile.id)
self.assertIsNone(error)
self.assertFalse(slot_reserved)
mock_reserve.assert_not_called()
@patch("apps.channels.models.RedisClient.get_client")
@patch("apps.channels.models.reserve_profile_slot")
def test_reuses_assignment_during_init_before_metadata(
self, mock_reserve, mock_get_client
):
mock_get_client.return_value = self.redis
self._seed_assignment()
stream_id, profile_id, error, slot_reserved = self.channel.get_stream()
self.assertEqual(stream_id, self.stream.id)
self.assertEqual(profile_id, self.profile.id)
self.assertIsNone(error)
self.assertFalse(slot_reserved)
mock_reserve.assert_not_called()
@patch("apps.channels.models.RedisClient.get_client")
@patch("apps.channels.models.release_profile_slot")
@patch("apps.channels.models.reserve_profile_slot")
def test_releases_stale_assignment_when_proxy_stopped(
self, mock_reserve, mock_release, mock_get_client
):
mock_get_client.return_value = self.redis
mock_reserve.return_value = (True, 1, None)
self._seed_assignment()
self.redis.hset(
self.metadata_key,
{ChannelMetadataField.STATE: ChannelState.STOPPED},
)
stream_id, profile_id, error, slot_reserved = self.channel.get_stream()
mock_release.assert_called_once_with(self.profile.id, self.redis)
mock_reserve.assert_called_once()
self.assertEqual(stream_id, self.stream.id)
self.assertEqual(profile_id, self.profile.id)
self.assertTrue(slot_reserved)
@patch("apps.channels.models.RedisClient.get_client")
def test_stream_assignment_is_reusable_during_init_pending(self, mock_get_client):
mock_get_client.return_value = self.redis
self._seed_assignment()
self.assertTrue(
self.channel._stream_assignment_is_reusable(self.redis, self.stream.id)
)
@patch("apps.channels.models.RedisClient.get_client")
def test_stream_assignment_not_reusable_when_stopped(self, mock_get_client):
mock_get_client.return_value = self.redis
self._seed_assignment()
self.redis.hset(
self.metadata_key,
{ChannelMetadataField.STATE: ChannelState.STOPPED},
)
self.assertFalse(
self.channel._stream_assignment_is_reusable(self.redis, self.stream.id)
)

View file

@ -0,0 +1,81 @@
"""Security-focused tests for local logo path jailing and related helpers."""
import tempfile
import uuid
from pathlib import Path
from django.contrib.auth import get_user_model
from django.test import TestCase
from rest_framework.test import APIRequestFactory, force_authenticate
from apps.channels.api_views import LogoViewSet
from apps.channels.models import Logo
from core.utils import resolve_safe_local_data_path, safe_upload_path
class ResolveSafeLocalDataPathTests(TestCase):
def test_accepts_file_under_logos_root(self):
logos_root = Path("/data/logos")
logos_root.mkdir(parents=True, exist_ok=True)
name = f"_jail_test_{uuid.uuid4().hex}.png"
target = logos_root / name
target.write_bytes(b"x")
try:
resolved = resolve_safe_local_data_path(
str(target), allowed_roots=("/data/logos",)
)
self.assertEqual(resolved, str(target.resolve()))
finally:
target.unlink(missing_ok=True)
def test_rejects_path_traversal_outside_root(self):
self.assertIsNone(
resolve_safe_local_data_path(
"/data/../etc/passwd", allowed_roots=("/data/logos",)
)
)
def test_rejects_non_data_prefix(self):
self.assertIsNone(resolve_safe_local_data_path("/etc/passwd"))
def test_safe_upload_path_strips_directory_components(self):
with tempfile.TemporaryDirectory() as tmp:
path = safe_upload_path("../../evil.xml", tmp)
self.assertEqual(Path(path).name, "evil.xml")
self.assertTrue(Path(path).is_relative_to(Path(tmp).resolve()))
class LogoCachePathJailTests(TestCase):
def setUp(self):
self.factory = APIRequestFactory()
User = get_user_model()
self.user, _ = User.objects.get_or_create(
username="logo_cache_admin",
defaults={"user_level": User.UserLevel.ADMIN},
)
def test_traversal_url_returns_404(self):
logo = Logo.objects.create(
name="Traversal",
url="/data/../etc/passwd",
)
request = self.factory.get(f"/api/channels/logos/{logo.id}/cache/")
force_authenticate(request, user=self.user)
view = LogoViewSet.as_view({"get": "cache"})
response = view(request, pk=logo.id)
self.assertEqual(response.status_code, 404)
def test_valid_local_logo_is_served(self):
logos_root = Path("/data/logos")
logos_root.mkdir(parents=True, exist_ok=True)
name = f"_jail_serve_{uuid.uuid4().hex}.png"
file_path = logos_root / name
file_path.write_bytes(b"\x89PNG\r\n\x1a\n")
logo = Logo.objects.create(name="Ok", url=str(file_path))
try:
request = self.factory.get(f"/api/channels/logos/{logo.id}/cache/")
view = LogoViewSet.as_view({"get": "cache"})
response = view(request, pk=logo.id)
self.assertEqual(response.status_code, 200)
b"".join(response.streaming_content)
finally:
file_path.unlink(missing_ok=True)

View file

@ -8,6 +8,7 @@ Covers:
5. Recovery skip-list: "recording" status NOT in terminal skip list 5. Recovery skip-list: "recording" status NOT in terminal skip list
""" """
import os import os
import datetime as dt
from datetime import timedelta from datetime import timedelta
from unittest.mock import MagicMock, patch from unittest.mock import MagicMock, patch
@ -17,6 +18,15 @@ from rest_framework.test import APIRequestFactory, force_authenticate
from apps.channels.models import Channel, Recording from apps.channels.models import Channel, Recording
# Fixed wall time for collision tests: 10:30 avoids _2 appearing inside
# %Y%m%d_%H%M%S timestamps (e.g. hour 20 produces ..._205331 which contains "_2").
COLLISION_TEST_START = timezone.make_aware(dt.datetime(2026, 1, 15, 10, 30, 0))
def _path_has_collision_suffix(path, counter):
"""True when the MKV basename ends with _<counter>.mkv (not timestamp digits)."""
return path.endswith(f"_{counter}.mkv")
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Helpers # Helpers
@ -58,9 +68,9 @@ class CollisionAvoidanceTests(TestCase):
"""_build_output_paths must increment the filename counter when """_build_output_paths must increment the filename counter when
EITHER the .mkv OR the .ts file already exists with size > 0.""" EITHER the .mkv OR the .ts file already exists with size > 0."""
def _call(self, channel, program, start, end): def _call(self, channel, program, start, end, recording_id=1):
from apps.channels.tasks import _build_output_paths from apps.channels.tasks import _build_output_paths
return _build_output_paths(channel, program, start, end) return _build_output_paths(channel, program, start, end, recording_id)
@patch("apps.channels.tasks.CoreSettings.get_dvr_tv_fallback_template", @patch("apps.channels.tasks.CoreSettings.get_dvr_tv_fallback_template",
return_value="TV/{show}/{start}.mkv") return_value="TV/{show}/{start}.mkv")
@ -71,7 +81,7 @@ class CollisionAvoidanceTests(TestCase):
ch = MagicMock(name="TestCh") ch = MagicMock(name="TestCh")
ch.name = "TestCh" ch.name = "TestCh"
program = {"title": "My Show"} program = {"title": "My Show"}
now = timezone.now() now = COLLISION_TEST_START
def mock_stat(path): def mock_stat(path):
raise OSError("No such file") raise OSError("No such file")
@ -80,8 +90,7 @@ class CollisionAvoidanceTests(TestCase):
patch("os.makedirs"): patch("os.makedirs"):
final, ts, fname = self._call(ch, program, now, now + timedelta(hours=1)) final, ts, fname = self._call(ch, program, now, now + timedelta(hours=1))
# Should NOT have a _2 suffix self.assertFalse(_path_has_collision_suffix(final, 2))
self.assertNotIn("_2", final)
self.assertTrue(final.endswith(".mkv")) self.assertTrue(final.endswith(".mkv"))
@patch("apps.channels.tasks.CoreSettings.get_dvr_tv_fallback_template", @patch("apps.channels.tasks.CoreSettings.get_dvr_tv_fallback_template",
@ -89,32 +98,31 @@ class CollisionAvoidanceTests(TestCase):
@patch("apps.channels.tasks.CoreSettings.get_dvr_tv_template", @patch("apps.channels.tasks.CoreSettings.get_dvr_tv_template",
return_value="TV/{show}/S{season:02d}E{episode:02d}.mkv") return_value="TV/{show}/S{season:02d}E{episode:02d}.mkv")
def test_collision_when_ts_exists_but_mkv_is_zero_bytes(self, _tv, _fb): def test_collision_when_ts_exists_but_mkv_is_zero_bytes(self, _tv, _fb):
"""Pre-restart scenario: MKV is 0-byte placeholder, TS has real data. """With the HLS pipeline, collision avoidance keys off the final MKV only.
The old code only checked MKV size, so it would reuse the path. A 0-byte MKV placeholder is treated as unoccupied even if legacy TS
The fix also checks TS, so it must increment.""" segments exist elsewhere on disk."""
ch = MagicMock(name="TestCh") ch = MagicMock(name="TestCh")
ch.name = "TestCh" ch.name = "TestCh"
program = {"title": "My Show"} program = {"title": "My Show"}
now = timezone.now() now = COLLISION_TEST_START
def mock_stat(path): def mock_stat(path):
if "_2" in path: if _path_has_collision_suffix(path, 2):
raise OSError("No such file") raise OSError("No such file")
result = MagicMock() result = MagicMock()
if path.endswith('.mkv'): if path.endswith('.mkv'):
result.st_size = 0 # MKV is 0-byte placeholder result.st_size = 0 # MKV is 0-byte placeholder
elif path.endswith('.ts'): elif path.endswith('.ts'):
result.st_size = 5000000 # TS has real data from pre-restart result.st_size = 5000000 # legacy TS data is ignored for collision
else: else:
result.st_size = 0 result.st_size = 0
return result return result
with patch("os.stat", side_effect=mock_stat), \ with patch("os.stat", side_effect=mock_stat), \
patch("os.makedirs"): patch("os.makedirs"):
final, ts, fname = self._call(ch, program, now, now + timedelta(hours=1)) final, hls_dir, fname = self._call(ch, program, now, now + timedelta(hours=1))
# Must have incremented to _2 self.assertFalse(_path_has_collision_suffix(final, 2), "HLS path builder ignores legacy TS when MKV is empty")
self.assertIn("_2", final, "Should increment counter when TS file has data")
@patch("apps.channels.tasks.CoreSettings.get_dvr_tv_fallback_template", @patch("apps.channels.tasks.CoreSettings.get_dvr_tv_fallback_template",
return_value="TV/{show}/{start}.mkv") return_value="TV/{show}/{start}.mkv")
@ -125,10 +133,10 @@ class CollisionAvoidanceTests(TestCase):
ch = MagicMock(name="TestCh") ch = MagicMock(name="TestCh")
ch.name = "TestCh" ch.name = "TestCh"
program = {"title": "My Show"} program = {"title": "My Show"}
now = timezone.now() now = COLLISION_TEST_START
def mock_stat(path): def mock_stat(path):
if "_2" in path: if _path_has_collision_suffix(path, 2):
raise OSError("No such file") raise OSError("No such file")
result = MagicMock() result = MagicMock()
if path.endswith('.mkv'): if path.endswith('.mkv'):
@ -141,7 +149,7 @@ class CollisionAvoidanceTests(TestCase):
patch("os.makedirs"): patch("os.makedirs"):
final, ts, fname = self._call(ch, program, now, now + timedelta(hours=1)) final, ts, fname = self._call(ch, program, now, now + timedelta(hours=1))
self.assertIn("_2", final, "Should increment counter when MKV file has data") self.assertTrue(_path_has_collision_suffix(final, 2), "Should increment counter when MKV file has data")
@patch("apps.channels.tasks.CoreSettings.get_dvr_tv_fallback_template", @patch("apps.channels.tasks.CoreSettings.get_dvr_tv_fallback_template",
return_value="TV/{show}/{start}.mkv") return_value="TV/{show}/{start}.mkv")
@ -152,7 +160,7 @@ class CollisionAvoidanceTests(TestCase):
ch = MagicMock(name="TestCh") ch = MagicMock(name="TestCh")
ch.name = "TestCh" ch.name = "TestCh"
program = {"title": "My Show"} program = {"title": "My Show"}
now = timezone.now() now = COLLISION_TEST_START
def mock_stat(path): def mock_stat(path):
result = MagicMock() result = MagicMock()
@ -163,7 +171,7 @@ class CollisionAvoidanceTests(TestCase):
patch("os.makedirs"): patch("os.makedirs"):
final, ts, fname = self._call(ch, program, now, now + timedelta(hours=1)) final, ts, fname = self._call(ch, program, now, now + timedelta(hours=1))
self.assertNotIn("_2", final, "Should NOT increment when all files are empty") self.assertFalse(_path_has_collision_suffix(final, 2), "Should NOT increment when all files are empty")
@patch("apps.channels.tasks.CoreSettings.get_dvr_tv_fallback_template", @patch("apps.channels.tasks.CoreSettings.get_dvr_tv_fallback_template",
return_value="TV/{show}/{start}.mkv") return_value="TV/{show}/{start}.mkv")
@ -174,23 +182,23 @@ class CollisionAvoidanceTests(TestCase):
ch = MagicMock(name="TestCh") ch = MagicMock(name="TestCh")
ch.name = "TestCh" ch.name = "TestCh"
program = {"title": "My Show"} program = {"title": "My Show"}
now = timezone.now() now = COLLISION_TEST_START
def mock_stat(path): def mock_stat(path):
if "_3" in path: if _path_has_collision_suffix(path, 3):
raise OSError("No such file") raise OSError("No such file")
result = MagicMock() result = MagicMock()
if path.endswith('.ts'): if path.endswith('.mkv'):
result.st_size = 5000000 result.st_size = 1000000 # occupied MKV at base and _2
else: else:
result.st_size = 0 result.st_size = 0
return result return result
with patch("os.stat", side_effect=mock_stat), \ with patch("os.stat", side_effect=mock_stat), \
patch("os.makedirs"): patch("os.makedirs"):
final, ts, fname = self._call(ch, program, now, now + timedelta(hours=1)) final, hls_dir, fname = self._call(ch, program, now, now + timedelta(hours=1))
self.assertIn("_3", final, "Should increment to _3 when base and _2 are occupied") self.assertTrue(_path_has_collision_suffix(final, 3), "Should increment to _3 when base and _2 MKVs are occupied")
# ========================================================================= # =========================================================================
@ -442,6 +450,7 @@ class RecoverySkipListTests(TestCase):
"""A recording with status='recording' should be recovered, not skipped.""" """A recording with status='recording' should be recovered, not skipped."""
mock_redis_conn = MagicMock() mock_redis_conn = MagicMock()
mock_redis_conn.set.return_value = True # Acquire lock mock_redis_conn.set.return_value = True # Acquire lock
mock_redis_conn.exists.return_value = False # No active-recording lock
mock_redis_cls.get_client.return_value = mock_redis_conn mock_redis_cls.get_client.return_value = mock_redis_conn
channel = _make_channel("Recovery Test", 300) channel = _make_channel("Recovery Test", 300)

View file

@ -0,0 +1,193 @@
"""Tests for DVR recording playback authentication (file/hls endpoints)."""
import os
import shutil
import tempfile
from types import SimpleNamespace
from unittest.mock import patch
from django.test import TestCase, override_settings
from django.utils import timezone
from rest_framework.test import APIClient
from rest_framework_simplejwt.tokens import RefreshToken
from apps.channels.api_views import _recording_auth_query_suffix
from apps.channels.models import Channel, Recording
def _make_admin():
from django.contrib.auth import get_user_model
User = get_user_model()
user, _ = User.objects.get_or_create(
username="recording_playback_admin",
defaults={"user_level": User.UserLevel.ADMIN},
)
user.user_level = User.UserLevel.ADMIN
user.set_password("pass")
user.save()
return user
@override_settings(ALLOWED_HOSTS=["testserver"])
@patch("apps.channels.api_views.network_access_allowed", return_value=True)
class RecordingPlaybackAuthTests(TestCase):
def setUp(self):
self.channel = Channel.objects.create(channel_number=42, name="Playback Auth Channel")
self.user = _make_admin()
self.client = APIClient()
self.tmp = tempfile.NamedTemporaryFile(suffix=".mkv", delete=False)
self.tmp.write(b"\x00" * 1024)
self.tmp.close()
self.hls_dir = tempfile.mkdtemp(prefix="dvr_playback_auth_hls_")
with open(os.path.join(self.hls_dir, "index.m3u8"), "w", encoding="utf-8") as playlist:
playlist.write("#EXTM3U\n#EXTINF:4.0,\nseg_00001.ts\n")
with open(os.path.join(self.hls_dir, "seg_00001.ts"), "wb") as segment:
segment.write(b"\x00" * 188)
now = timezone.now()
self.recording = Recording.objects.create(
channel=self.channel,
start_time=now,
end_time=now,
custom_properties={
"status": "completed",
"file_path": self.tmp.name,
"file_name": "test.mkv",
},
)
def tearDown(self):
if os.path.exists(self.tmp.name):
os.unlink(self.tmp.name)
if os.path.isdir(self.hls_dir):
shutil.rmtree(self.hls_dir, ignore_errors=True)
@staticmethod
def _jwt_for(user):
return str(RefreshToken.for_user(user).access_token)
def test_file_requires_authentication(self, _mock_network):
response = self.client.get(
f"/api/channels/recordings/{self.recording.id}/file/"
)
self.assertEqual(response.status_code, 403)
def test_file_accepts_jwt_query_param(self, _mock_network):
token = self._jwt_for(self.user)
response = self.client.get(
f"/api/channels/recordings/{self.recording.id}/file/",
{"token": token},
)
self.assertEqual(response.status_code, 200)
def test_file_redirect_to_hls_preserves_token(self, _mock_network):
pending = os.path.join(self.hls_dir, "pending.mkv")
now = timezone.now()
in_progress = Recording.objects.create(
channel=self.channel,
start_time=now,
end_time=now,
custom_properties={
"status": "recording",
"_hls_dir": self.hls_dir,
"file_path": pending,
},
)
token = self._jwt_for(self.user)
response = self.client.get(
f"/api/channels/recordings/{in_progress.id}/file/",
{"token": token},
)
self.assertEqual(response.status_code, 302)
self.assertIn("token=", response["Location"])
self.assertIn("/hls/index.m3u8", response["Location"])
def test_hls_playlist_rewrites_segments_with_token_when_present(self, _mock_network):
now = timezone.now()
hls_rec = Recording.objects.create(
channel=self.channel,
start_time=now,
end_time=now,
custom_properties={
"status": "recording",
"_hls_dir": self.hls_dir,
},
)
token = self._jwt_for(self.user)
response = self.client.get(
f"/api/channels/recordings/{hls_rec.id}/hls/index.m3u8",
{"token": token},
)
self.assertEqual(response.status_code, 200)
body = response.content.decode("utf-8")
self.assertIn("token=", body)
self.assertIn("seg_00001.ts", body)
def test_hls_playlist_omits_token_when_not_in_request(self, _mock_network):
now = timezone.now()
hls_rec = Recording.objects.create(
channel=self.channel,
start_time=now,
end_time=now,
custom_properties={
"status": "recording",
"_hls_dir": self.hls_dir,
},
)
token = self._jwt_for(self.user)
response = self.client.get(
f"/api/channels/recordings/{hls_rec.id}/hls/index.m3u8",
HTTP_AUTHORIZATION=f"Bearer {token}",
)
self.assertEqual(response.status_code, 200)
body = response.content.decode("utf-8")
self.assertIn("seg_00001.ts", body)
self.assertNotIn("token=", body)
def test_hls_segment_accepts_jwt_query_param(self, _mock_network):
now = timezone.now()
hls_rec = Recording.objects.create(
channel=self.channel,
start_time=now,
end_time=now,
custom_properties={
"status": "recording",
"_hls_dir": self.hls_dir,
},
)
token = self._jwt_for(self.user)
response = self.client.get(
f"/api/channels/recordings/{hls_rec.id}/hls/seg_00001.ts",
{"token": token},
)
self.assertEqual(response.status_code, 200)
def test_hls_redirect_to_file_preserves_token(self, _mock_network):
now = timezone.now()
hls_rec = Recording.objects.create(
channel=self.channel,
start_time=now,
end_time=now,
custom_properties={
"status": "completed",
"file_path": self.tmp.name,
"file_name": "test.mkv",
},
)
token = self._jwt_for(self.user)
response = self.client.get(
f"/api/channels/recordings/{hls_rec.id}/hls/index.m3u8",
{"token": token},
)
self.assertEqual(response.status_code, 302)
self.assertIn("token=", response["Location"])
self.assertIn("/file/", response["Location"])
class RecordingAuthQuerySuffixTests(TestCase):
def test_empty_when_no_token(self):
request = SimpleNamespace(GET={})
self.assertEqual(_recording_auth_query_suffix(request), "")
def test_includes_token_when_present(self):
request = SimpleNamespace(GET={"token": "abc123"})
self.assertEqual(_recording_auth_query_suffix(request), "?token=abc123")

View file

@ -247,6 +247,14 @@ class BasicStatsGhostClientTests(TestCase):
redis.scard.return_value = len(client_ids) redis.scard.return_value = len(client_ids)
redis.smembers.return_value = client_ids redis.smembers.return_value = client_ids
redis.hget.return_value = None # individual field lookups redis.hget.return_value = None # individual field lookups
redis.hmget.return_value = [
b'VLC/3.0',
b'127.0.0.1',
b'1773500000.0',
None,
b'mpegts',
None,
]
# Pipeline for remove_ghost_clients # Pipeline for remove_ghost_clients
pipe = MagicMock() pipe = MagicMock()

View file

@ -214,11 +214,12 @@ class DoStatsUpdateTests(TestCase):
"""_do_stats_update must call send_websocket_update with channel_stats.""" """_do_stats_update must call send_websocket_update with channel_stats."""
cm = self._make_client_manager() cm = self._make_client_manager()
mock_redis = MagicMock()
mock_redis.scan.return_value = (0, [])
with patch("apps.proxy.live_proxy.client_manager.send_websocket_update") as mock_ws, \ with patch("apps.proxy.live_proxy.client_manager.send_websocket_update") as mock_ws, \
patch("redis.Redis.from_url", return_value=mock_redis): patch(
"apps.proxy.live_proxy.channel_status.build_live_channel_stats_data",
return_value={"channels": [], "count": 0},
), \
patch("core.utils.RedisClient.get_client", return_value=MagicMock()):
cm._do_stats_update() cm._do_stats_update()
mock_ws.assert_called_once() mock_ws.assert_called_once()
@ -231,25 +232,26 @@ class DoStatsUpdateTests(TestCase):
"""Redis failure must be swallowed (logged), not propagated.""" """Redis failure must be swallowed (logged), not propagated."""
cm = self._make_client_manager() cm = self._make_client_manager()
with patch("redis.Redis.from_url", side_effect=Exception("Redis down")): with patch("core.utils.RedisClient.get_client", side_effect=Exception("Redis down")):
try: try:
cm._do_stats_update() cm._do_stats_update()
except Exception as e: except Exception as e:
self.fail(f"_do_stats_update raised an exception: {e}") self.fail(f"_do_stats_update raised an exception: {e}")
def test_do_stats_update_scans_channel_client_keys(self): def test_do_stats_update_uses_build_live_channel_stats_data(self):
"""Must scan for live:channel:*:clients pattern.""" """Must build live stats via the shared builder."""
cm = self._make_client_manager() cm = self._make_client_manager()
mock_redis = MagicMock() mock_redis = MagicMock()
mock_redis.scan.return_value = (0, [])
with patch("apps.proxy.live_proxy.client_manager.send_websocket_update"), \ with patch("apps.proxy.live_proxy.client_manager.send_websocket_update"), \
patch("redis.Redis.from_url", return_value=mock_redis): patch(
"apps.proxy.live_proxy.channel_status.build_live_channel_stats_data",
return_value={"channels": [{"channel_id": "ch-1"}], "count": 1},
) as mock_build, \
patch("core.utils.RedisClient.get_client", return_value=mock_redis):
cm._do_stats_update() cm._do_stats_update()
scan_call = mock_redis.scan.call_args mock_build.assert_called_once_with(mock_redis)
self.assertIn("live:channel:*:clients", str(scan_call))
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------

File diff suppressed because it is too large Load diff

View file

@ -1,8 +1,13 @@
import logging import logging
import threading import threading
from django.core.cache import cache
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
PROVIDER_ARCHIVE_CACHE_TTL_SECONDS = 300
MAX_AUTO_PREV_DAYS = 30
# Bound memory/DB work per chunk for large libraries (20k+ channels). # Bound memory/DB work per chunk for large libraries (20k+ channels).
EPG_LOGO_APPLY_BATCH_SIZE = 500 EPG_LOGO_APPLY_BATCH_SIZE = 500
EPG_LOGO_APPLY_MAX_ERRORS = 100 EPG_LOGO_APPLY_MAX_ERRORS = 100
@ -23,6 +28,100 @@ def format_channel_number(value, empty=""):
return int(value) return int(value)
return value return value
def compute_provider_archive_days_capped():
"""Max ``catchup_days`` across active XC catch-up streams (capped, cached).
Cached briefly so XC XMLTV exports without an explicit ``prev_days`` do not
repeat the aggregate query on every request.
"""
def _scan():
from django.db.models import Max
from apps.channels.models import Stream
result = Stream.objects.filter(
m3u_account__account_type="XC",
m3u_account__is_active=True,
is_catchup=True,
).aggregate(max_days=Max("catchup_days"))
return min(result["max_days"] or 0, MAX_AUTO_PREV_DAYS)
return cache.get_or_set(
"channels:provider_archive_days_capped",
_scan,
PROVIDER_ARCHIVE_CACHE_TTL_SECONDS,
)
def resolve_xc_epg_prev_days(request, user, *, auto_detect_fallback=True):
"""Resolve ``prev_days`` for XC XMLTV and player_api EPG.
Args:
request: HTTP request (reads ``?prev_days=``).
user: Authenticated user (reads ``custom_properties.epg_prev_days``).
auto_detect_fallback: When True (XC XMLTV), fall back to the largest
provider archive depth. When False (per-channel EPG), return 0 so
``xc_get_epg`` can expand to each channel's ``catchup_days``.
Resolution order:
1. URL ``?prev_days=`` (explicit; 0 means no past programmes)
2. ``user.custom_properties.epg_prev_days``
3. Auto-detect (only when *auto_detect_fallback* is True)
"""
user_custom = (user.custom_properties or {}) if user else {}
url_prev = request.GET.get("prev_days")
user_prev = user_custom.get("epg_prev_days") if user_custom else None
if url_prev is not None:
try:
return max(0, min(int(url_prev), MAX_AUTO_PREV_DAYS))
except (ValueError, TypeError):
return 0
if user_prev not in (None, ""):
try:
return max(0, min(int(user_prev), MAX_AUTO_PREV_DAYS))
except (ValueError, TypeError):
return 0
if auto_detect_fallback:
return compute_provider_archive_days_capped()
return 0
def is_catchup_enabled(*, user=None):
"""Return whether catch-up / timeshift is allowed.
When a *user* is supplied, their ``custom_properties.catchup_enabled``
(default True) is checked first (no DB). System
``system_settings.catchup_enabled`` (default True, Redis-cached) can
disable catch-up for everyone. Both flags are JSON booleans.
"""
from core.models import CoreSettings
if user is not None:
props = getattr(user, "custom_properties", None) or {}
if props.get("catchup_enabled") is False:
return False
return CoreSettings.get_catchup_enabled()
def get_channel_catchup_streams(channel):
"""Active catch-up streams for a channel, in ``channelstream`` order.
Inactive M3U accounts are excluded, matching live dispatch.
"""
if not getattr(channel, "is_catchup", False):
return []
return list(
channel.streams.filter(is_catchup=True, m3u_account__is_active=True)
.order_by("channelstream__order")
.select_related("m3u_account")
)
def increment_stream_count(account): def increment_stream_count(account):
with lock: with lock:
current_usage = active_streams_map.get(account.id, 0) current_usage = active_streams_map.get(account.id, 0)

View file

@ -12,8 +12,6 @@ from .serializers import (
DeliveryLogSerializer, DeliveryLogSerializer,
) )
from apps.accounts.permissions import ( from apps.accounts.permissions import (
Authenticated,
permission_classes_by_action,
IsAdmin, IsAdmin,
) )
from .handlers.webhook import WebhookHandler from .handlers.webhook import WebhookHandler
@ -25,12 +23,8 @@ class IntegrationViewSet(viewsets.ModelViewSet):
serializer_class = IntegrationSerializer serializer_class = IntegrationSerializer
def get_permissions(self): def get_permissions(self):
try: # Integrations expose webhook URLs / script paths in config; admin only.
perms = permission_classes_by_action[self.action] return [IsAdmin()]
except KeyError:
# Respect view/action-specific permission_classes if provided; fallback to Authenticated
perms = getattr(self, "permission_classes", [Authenticated])
return [perm() for perm in perms]
@action(detail=True, methods=["get"], url_path="subscriptions") @action(detail=True, methods=["get"], url_path="subscriptions")
def list_subscriptions(self, request, pk=None): def list_subscriptions(self, request, pk=None):

View file

@ -35,43 +35,56 @@ def _empty_subscription_chain():
class TriggerEventDispatchTests(SimpleTestCase): class TriggerEventDispatchTests(SimpleTestCase):
def _run_trigger_event(self, plugins, event_name, payload): def _run_trigger_event(self, handlers, event_name, payload, enabled_keys=None):
pm = MagicMock() pm = MagicMock()
pm.list_plugins.return_value = plugins pm.iter_actions_for_event.return_value = handlers
if enabled_keys is None:
enabled_keys = [key for key, _ in handlers]
enabled_qs = MagicMock()
enabled_qs.values_list.return_value = enabled_keys
with patch( with patch(
"apps.connect.utils.PluginManager.get", return_value=pm "apps.connect.utils.PluginManager.get", return_value=pm
), patch( ), patch(
"apps.connect.utils.EventSubscription.objects.filter", "apps.connect.utils.EventSubscription.objects.filter",
return_value=_empty_subscription_chain(), return_value=_empty_subscription_chain(),
): ), patch(
"apps.plugins.models.PluginConfig"
) as mock_cfg:
mock_cfg.objects.filter.return_value = enabled_qs
from apps.connect.utils import trigger_event from apps.connect.utils import trigger_event
trigger_event(event_name, payload) trigger_event(event_name, payload)
return pm return pm
def test_disabled_plugin_does_not_abort_dispatch_for_later_enabled_plugin(self): def test_disabled_plugin_does_not_abort_dispatch_for_later_enabled_plugin(self):
"""The original bug: AttributeError on a disabled plugin's debug log """Enabled handlers still run when other plugins are disabled in DB."""
aborted the loop, so any plugin iterated AFTER a disabled one never handlers = [
received events.""" ("enabled-plugin", "on_event"),
plugins = [
{
"key": "disabled-plugin",
"name": "Disabled Plugin",
"enabled": False,
"actions": [],
},
{
"key": "enabled-plugin",
"name": "Enabled Plugin",
"enabled": True,
"actions": [
{"id": "on_event", "events": ["channel_start"]},
],
},
] ]
pm = self._run_trigger_event( pm = self._run_trigger_event(
plugins, "channel_start", {"channel_name": "TEST"} handlers, "channel_start", {"channel_name": "TEST"}
)
pm.run_action.assert_called_once_with(
"enabled-plugin",
"on_event",
{"event": "channel_start", "payload": {"channel_name": "TEST"}},
)
def test_skips_handlers_for_disabled_plugins(self):
handlers = [
("disabled-plugin", "on_event"),
("enabled-plugin", "on_event"),
]
pm = self._run_trigger_event(
handlers,
"channel_start",
{"channel_name": "TEST"},
enabled_keys=["enabled-plugin"],
) )
pm.run_action.assert_called_once_with( pm.run_action.assert_called_once_with(
@ -81,22 +94,64 @@ class TriggerEventDispatchTests(SimpleTestCase):
) )
def test_action_without_matching_event_is_not_dispatched(self): def test_action_without_matching_event_is_not_dispatched(self):
"""Sanity check: actions whose `events` list doesn't include the """When no handlers are registered for the event, run_action is not called."""
fired event, or which have no `events` key at all, are skipped."""
plugins = [
{
"key": "plugin",
"name": "Plugin",
"enabled": True,
"actions": [
{"id": "on_event", "events": ["channel_stop"]},
{"id": "manual_button"}, # no events key
],
},
]
pm = self._run_trigger_event( pm = self._run_trigger_event(
plugins, "channel_start", {"channel_name": "TEST"} [], "channel_start", {"channel_name": "TEST"}
) )
pm.run_action.assert_not_called() pm.run_action.assert_not_called()
def test_no_plugin_config_query_when_no_handlers(self):
pm = MagicMock()
pm.iter_actions_for_event.return_value = []
with patch(
"apps.connect.utils.PluginManager.get", return_value=pm
), patch(
"apps.connect.utils.EventSubscription.objects.filter",
return_value=_empty_subscription_chain(),
), patch(
"apps.plugins.models.PluginConfig"
) as mock_cfg:
from apps.connect.utils import trigger_event
trigger_event("channel_start", {"channel_name": "TEST"})
mock_cfg.objects.filter.assert_not_called()
def test_plugin_action_failure_does_not_block_sibling_handlers(self):
handlers = [
("failing-plugin", "on_event"),
("working-plugin", "on_event"),
]
pm = MagicMock()
pm.iter_actions_for_event.return_value = handlers
pm.run_action.side_effect = [RuntimeError("boom"), {"status": "ok"}]
enabled_qs = MagicMock()
enabled_qs.values_list.return_value = ["failing-plugin", "working-plugin"]
with patch(
"apps.connect.utils.PluginManager.get", return_value=pm
), patch(
"apps.connect.utils.EventSubscription.objects.filter",
return_value=_empty_subscription_chain(),
), patch(
"apps.plugins.models.PluginConfig"
) as mock_cfg:
mock_cfg.objects.filter.return_value = enabled_qs
from apps.connect.utils import trigger_event
trigger_event("channel_start", {"channel_name": "TEST"})
self.assertEqual(pm.run_action.call_count, 2)
pm.run_action.assert_any_call(
"failing-plugin",
"on_event",
{"event": "channel_start", "payload": {"channel_name": "TEST"}},
)
pm.run_action.assert_any_call(
"working-plugin",
"on_event",
{"event": "channel_start", "payload": {"channel_name": "TEST"}},
)

View file

@ -1,5 +1,5 @@
# connect/utils.py # connect/utils.py
import logging, json import logging
from django.template import Template, Context from django.template import Template, Context
from .models import EventSubscription, DeliveryLog, SUPPORTED_EVENTS from .models import EventSubscription, DeliveryLog, SUPPORTED_EVENTS
from .handlers.webhook import WebhookHandler from .handlers.webhook import WebhookHandler
@ -93,24 +93,45 @@ def trigger_event(event_name, payload):
) )
pm = PluginManager.get() pm = PluginManager.get()
pm.discover_plugins(sync_db=False, use_cache=True) pm.discover_plugins(sync_db=False, use_cache=True, release_connections=False)
plugins = pm.list_plugins() handlers = list(pm.iter_actions_for_event(event_name))
if not handlers:
return
logger.debug(f"Checking {len(plugins)} plugins for event '{event_name}'") from apps.plugins.models import PluginConfig
for plugin in plugins:
if not plugin["enabled"]: handler_keys = {key for key, _ in handlers}
logger.debug(f"Skipping disabled plugin id={plugin['key']} name={plugin['name']}") enabled_keys = set(
PluginConfig.objects.filter(enabled=True, key__in=handler_keys).values_list(
"key", flat=True
)
)
logger.debug(
"Dispatching event '%s' to %d plugin action(s) (%d enabled)",
event_name,
len(handlers),
len(enabled_keys),
)
params = {"event": event_name, "payload": payload}
for key, action_id in handlers:
if key not in enabled_keys:
logger.debug(
"Skipping disabled plugin id=%s for event '%s'", key, event_name
)
continue continue
logger.debug(
logger.debug(json.dumps(plugin)) "Triggering plugin action for event '%s' on plugin id=%s action=%s",
for action in plugin["actions"]: event_name,
if "events" in action and event_name in action["events"]: key,
key = plugin["key"] action_id,
params = {"event": event_name, "payload": payload} )
action_name = action.get("label") or action.get("id") try:
action_id = action.get("id") pm.run_action(key, action_id, params)
logger.debug( except Exception:
f"Triggering plugin action for event '{event_name}' on plugin id={key} action={action_name}" logger.exception(
) "Plugin action failed for event '%s' on plugin id=%s action=%s",
if action_id: event_name,
pm.run_action(key, action_id, params) key,
action_id,
)

View file

View file

@ -1,4 +1,3 @@
import hashlib
import logging import logging
import os import os
import re import re
@ -8,6 +7,10 @@ from rest_framework.pagination import PageNumberPagination
from rest_framework.permissions import AllowAny from rest_framework.permissions import AllowAny
from rest_framework.response import Response from rest_framework.response import Response
from rest_framework.views import APIView from rest_framework.views import APIView
from apps.epg.sd_api import (
SchedulesDirectPosterMixin,
SchedulesDirectSourceMixin,
)
from rest_framework.decorators import action from rest_framework.decorators import action
from drf_spectacular.utils import extend_schema, OpenApiParameter, inline_serializer from drf_spectacular.utils import extend_schema, OpenApiParameter, inline_serializer
from drf_spectacular.types import OpenApiTypes from drf_spectacular.types import OpenApiTypes
@ -32,6 +35,7 @@ from apps.accounts.permissions import (
permission_classes_by_action, permission_classes_by_action,
permission_classes_by_method, permission_classes_by_method,
) )
from core.utils import safe_upload_path
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -39,7 +43,7 @@ logger = logging.getLogger(__name__)
# ───────────────────────────── # ─────────────────────────────
# 1) EPG Source API (CRUD) # 1) EPG Source API (CRUD)
# ───────────────────────────── # ─────────────────────────────
class EPGSourceViewSet(viewsets.ModelViewSet): class EPGSourceViewSet(SchedulesDirectSourceMixin, viewsets.ModelViewSet):
""" """
API endpoint that allows EPG sources to be viewed or edited. API endpoint that allows EPG sources to be viewed or edited.
""" """
@ -50,6 +54,8 @@ class EPGSourceViewSet(viewsets.ModelViewSet):
serializer_class = EPGSourceSerializer serializer_class = EPGSourceSerializer
def get_permissions(self): def get_permissions(self):
if self.action == "upload":
return [IsAdmin()]
try: try:
return [perm() for perm in permission_classes_by_action[self.action]] return [perm() for perm in permission_classes_by_action[self.action]]
except KeyError: except KeyError:
@ -57,15 +63,13 @@ class EPGSourceViewSet(viewsets.ModelViewSet):
if self.request.method == 'GET': if self.request.method == 'GET':
return [IsStandardUser()] return [IsStandardUser()]
return [IsAdmin()] return [IsAdmin()]
return [Authenticated()] return [IsAdmin()]
def get_queryset(self): def get_queryset(self):
from django.db.models import Exists, OuterRef from django.db.models import Exists, OuterRef
from apps.channels.models import Channel from apps.channels.models import Channel
return EPGSource.objects.select_related( return EPGSource.objects.select_related(
"refresh_task__crontab", "refresh_task__interval" "refresh_task__crontab", "refresh_task__interval"
).defer(
'programme_index'
).annotate( ).annotate(
has_channels=Exists( has_channels=Exists(
Channel.objects.filter(epg_data__epg_source_id=OuterRef('pk')) Channel.objects.filter(epg_data__epg_source_id=OuterRef('pk'))
@ -84,8 +88,12 @@ class EPGSourceViewSet(viewsets.ModelViewSet):
) )
file = request.FILES["file"] file = request.FILES["file"]
file_name = file.name try:
file_path = os.path.join("/data/uploads/epgs", file_name) file_path = safe_upload_path(file.name, "/data/uploads/epgs")
except ValueError:
return Response(
{"error": "Invalid filename"}, status=status.HTTP_400_BAD_REQUEST
)
os.makedirs(os.path.dirname(file_path), exist_ok=True) os.makedirs(os.path.dirname(file_path), exist_ok=True)
with open(file_path, "wb+") as destination: with open(file_path, "wb+") as destination:
@ -120,355 +128,20 @@ class EPGSourceViewSet(viewsets.ModelViewSet):
return super().partial_update(request, *args, **kwargs) 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)
# ─────────────────────────────
class ProgramSearchPagination(PageNumberPagination): class ProgramSearchPagination(PageNumberPagination):
page_size = 50 page_size = 50
page_size_query_param = 'page_size' page_size_query_param = 'page_size'
max_page_size = 500 max_page_size = 500
class ProgramViewSet(viewsets.ModelViewSet): class ProgramViewSet(SchedulesDirectPosterMixin, viewsets.ModelViewSet):
"""Handles CRUD operations for EPG programs""" """Handles CRUD operations for EPG programs"""
queryset = ProgramData.objects.select_related("epg").all() queryset = ProgramData.objects.select_related("epg").all()
serializer_class = ProgramDataSerializer serializer_class = ProgramDataSerializer
# Per-source in-memory caches (token and error state) # Short process-local cooldown for transient poster errors (auth/network).
_sd_poster_token_cache: dict = {} # Image download limits are persisted on the EPG source (shared across workers).
_sd_poster_error_cache: dict = {}
def get_permissions(self): def get_permissions(self):
if self.action == 'poster': if self.action == 'poster':
@ -488,101 +161,6 @@ class ProgramViewSet(viewsets.ModelViewSet):
serializer = self.get_serializer(instance) serializer = self.get_serializer(instance)
return Response(serializer.data) 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): def list(self, request, *args, **kwargs):
logger.debug("Listing all EPG programs.") logger.debug("Listing all EPG programs.")
return super().list(request, *args, **kwargs) return super().list(request, *args, **kwargs)
@ -976,7 +554,9 @@ class EPGGridAPIView(APIView):
channel_name=name_to_parse, channel_name=name_to_parse,
num_days=1, num_days=1,
program_length_hours=4, program_length_hours=4,
epg_source=epg_source epg_source=epg_source,
export_lookback=one_hour_ago,
export_cutoff=twenty_four_hours_later,
) )
# Custom dummy should always return data (either from patterns or fallback) # Custom dummy should always return data (either from patterns or fallback)
@ -1072,13 +652,19 @@ class EPGGridAPIView(APIView):
f"Error creating standard dummy programs for channel {channel.name} (ID: {channel.id}): {str(e)}" f"Error creating standard dummy programs for channel {channel.name} (ID: {channel.id}): {str(e)}"
) )
# Combine regular and dummy programs # Combine regular and dummy programs in place to avoid copying the large list
all_programs = list(serialized_programs) + dummy_programs serialized_programs.extend(dummy_programs)
logger.debug( logger.debug(
f"EPGGridAPIView: Returning {len(all_programs)} total programs (including {len(dummy_programs)} dummy programs)." f"EPGGridAPIView: Returning {len(serialized_programs)} total programs (including {len(dummy_programs)} dummy programs)."
) )
return Response({"data": all_programs}, status=status.HTTP_200_OK) # The grid materializes tens of thousands of program dicts plus the
# rendered JSON; trim once the response is sent so worker RSS does not
# ratchet up per request.
from core.utils import spawn_memory_trim
response = Response({"data": serialized_programs}, status=status.HTTP_200_OK)
response._resource_closers.append(spawn_memory_trim)
return response
# ───────────────────────────── # ─────────────────────────────
@ -1109,18 +695,24 @@ class EPGImportAPIView(APIView):
epg_id = request.data.get("id", None) epg_id = request.data.get("id", None)
force = bool(request.data.get("force", False)) force = bool(request.data.get("force", False))
# Check if this is a dummy EPG source # Reject dummy sources with a narrow existence query, no full row load.
try: if epg_id is not None:
from .models import EPGSource from .models import EPGSource
epg_source = EPGSource.objects.get(id=epg_id)
if epg_source.source_type == 'dummy': if EPGSource.objects.filter(
logger.info(f"EPGImportAPIView: Skipping refresh for dummy EPG source {epg_id}") id=epg_id, source_type="dummy"
).exists():
logger.info(
"EPGImportAPIView: Skipping refresh for dummy EPG source %s",
epg_id,
)
return Response( return Response(
{"success": False, "message": "Dummy EPG sources do not require refreshing."}, {
"success": False,
"message": "Dummy EPG sources do not require refreshing.",
},
status=status.HTTP_400_BAD_REQUEST, status=status.HTTP_400_BAD_REQUEST,
) )
except EPGSource.DoesNotExist:
pass # Let the task handle the missing source
refresh_epg_data.delay(epg_id, force=force) # Trigger Celery task refresh_epg_data.delay(epg_id, force=force) # Trigger Celery task
logger.info("EPGImportAPIView: Task dispatched to refresh EPG data.") logger.info("EPGImportAPIView: Task dispatched to refresh EPG data.")
@ -1220,10 +812,9 @@ class CurrentProgramsAPIView(APIView):
# Limit to 50 IDs per request # Limit to 50 IDs per request
epg_data_ids = epg_data_ids[:50] 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').filter(
epg_data_entries = EPGData.objects.select_related('epg_source').defer( id__in=epg_data_ids
'epg_source__programme_index' )
).filter(id__in=epg_data_ids)
# Batch-fetch current programs for all requested EPG entries in one query # Batch-fetch current programs for all requested EPG entries in one query
db_programs = ProgramData.objects.filter( db_programs = ProgramData.objects.filter(

View file

@ -0,0 +1,40 @@
from django.contrib.postgres.operations import AddIndexConcurrently
from django.db import migrations, models
class AddIndexConcurrentlyIfPostgres(AddIndexConcurrently):
"""Create the index CONCURRENTLY on PostgreSQL (no table lock on large
tables), falling back to a normal blocking AddIndex on other backends
such as the sqlite dev/test fallback."""
def database_forwards(self, app_label, schema_editor, from_state, to_state):
if schema_editor.connection.vendor == 'postgresql':
super().database_forwards(app_label, schema_editor, from_state, to_state)
else:
migrations.AddIndex.database_forwards(
self, app_label, schema_editor, from_state, to_state
)
def database_backwards(self, app_label, schema_editor, from_state, to_state):
if schema_editor.connection.vendor == 'postgresql':
super().database_backwards(app_label, schema_editor, from_state, to_state)
else:
migrations.AddIndex.database_backwards(
self, app_label, schema_editor, from_state, to_state
)
class Migration(migrations.Migration):
# CREATE INDEX CONCURRENTLY cannot run inside a transaction.
atomic = False
dependencies = [
('epg', '0024_remove_epgsource_api_key_epgsource_password_and_more'),
]
operations = [
AddIndexConcurrentlyIfPostgres(
model_name='programdata',
index=models.Index(fields=['epg', 'id'], name='epg_prog_epg_id_idx'),
),
]

View file

@ -0,0 +1,52 @@
import django.db.models.deletion
from django.db import migrations, models
def copy_index_forward(apps, schema_editor):
EPGSource = apps.get_model('epg', 'EPGSource')
EPGSourceIndex = apps.get_model('epg', 'EPGSourceIndex')
rows = list(
EPGSource.objects.exclude(programme_index__isnull=True).values_list(
'id', 'programme_index'
)
)
for source_id, data in rows:
EPGSourceIndex.objects.update_or_create(
source_id=source_id, defaults={'data': data}
)
def copy_index_backward(apps, schema_editor):
EPGSource = apps.get_model('epg', 'EPGSource')
EPGSourceIndex = apps.get_model('epg', 'EPGSourceIndex')
for source_id, data in EPGSourceIndex.objects.values_list('source_id', 'data'):
EPGSource.objects.filter(id=source_id).update(programme_index=data)
class Migration(migrations.Migration):
dependencies = [
('epg', '0025_programdata_epg_id_index'),
]
operations = [
migrations.CreateModel(
name='EPGSourceIndex',
fields=[
('source', models.OneToOneField(
on_delete=django.db.models.deletion.CASCADE,
primary_key=True,
related_name='index_record',
serialize=False,
to='epg.epgsource',
)),
('data', models.JSONField(blank=True, default=None, null=True)),
('updated_at', models.DateTimeField(auto_now=True)),
],
),
migrations.RunPython(copy_index_forward, copy_index_backward),
migrations.RemoveField(
model_name='epgsource',
name='programme_index',
),
]

View file

@ -62,12 +62,6 @@ class EPGSource(models.Model):
blank=True, blank=True,
help_text="Last status message, including success results or error information" 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( created_at = models.DateTimeField(
auto_now_add=True, auto_now_add=True,
help_text="Time when this source was created" help_text="Time when this source was created"
@ -96,17 +90,21 @@ class EPGSource(models.Model):
file_ext = '.gz' file_ext = '.gz'
elif mime_type == 'application/zip': elif mime_type == 'application/zip':
file_ext = '.zip' file_ext = '.zip'
elif mime_type == 'application/x-xz':
file_ext = '.xz'
elif mime_type == 'application/xml' or mime_type == 'text/xml': elif mime_type == 'application/xml' or mime_type == 'text/xml':
file_ext = '.xml' file_ext = '.xml'
else: else:
try: try:
with open(self.file_path, 'rb') as f: with open(self.file_path, 'rb') as f:
header = f.read(4) header = f.read(6)
if header[:2] == b'\x1f\x8b': if header.startswith(b'\x1f\x8b'):
file_ext = '.gz' file_ext = '.gz'
elif header[:2] == b'PK': elif header.startswith(b'PK'):
file_ext = '.zip' file_ext = '.zip'
elif header[:5] == b'<?xml' or header[:5] == b'<tv>': elif header.startswith(b'\xfd7zXZ\x00'):
file_ext = '.xz'
elif header.startswith(b'<?xml') or header.startswith(b'<tv>'):
file_ext = '.xml' file_ext = '.xml'
except Exception: except Exception:
pass pass
@ -124,6 +122,37 @@ class EPGSource(models.Model):
kwargs['update_fields'].remove('updated_at') kwargs['update_fields'].remove('updated_at')
super().save(*args, **kwargs) super().save(*args, **kwargs)
@property
def programme_index(self):
"""Byte-offset index for this source, read on demand from the separate
EPGSourceIndex table so the multi-MB blob is never pulled into EPGSource
queries or select_related JOINs. Returns the stored dict or None."""
return (
EPGSourceIndex.objects.filter(source_id=self.pk)
.values_list('data', flat=True)
.first()
)
class EPGSourceIndex(models.Model):
"""Byte-offset programme index for an EPGSource, stored in its own table.
Kept out of EPGSource so the multi-MB JSON blob is only loaded when read
explicitly, never when querying or joining EPGSource rows.
"""
source = models.OneToOneField(
EPGSource,
on_delete=models.CASCADE,
related_name='index_record',
primary_key=True,
)
data = models.JSONField(null=True, blank=True, default=None)
updated_at = models.DateTimeField(auto_now=True)
def __str__(self):
return f"Programme index for source {self.source_id}"
class EPGData(models.Model): class EPGData(models.Model):
tvg_id = models.CharField(max_length=255, null=True, blank=True, db_index=True) tvg_id = models.CharField(max_length=255, null=True, blank=True, db_index=True)
name = models.CharField(max_length=512) name = models.CharField(max_length=512)
@ -153,6 +182,11 @@ class ProgramData(models.Model):
program_id = models.CharField(max_length=64, null=True, blank=True, help_text='Schedules Direct programID (e.g. EP123456789). Null for XMLTV sources.') 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) custom_properties = models.JSONField(default=dict, blank=True, null=True)
class Meta:
indexes = [
models.Index(fields=['epg', 'id'], name='epg_prog_epg_id_idx'),
]
def __str__(self): def __str__(self):
return f"{self.title} ({self.start_time} - {self.end_time})" return f"{self.title} ({self.start_time} - {self.end_time})"

571
apps/epg/sd_api.py Normal file
View file

@ -0,0 +1,571 @@
"""
Schedules Direct HTTP API helpers and ViewSet mixins.
Keeps lineup management and poster proxy out of the general EPG view modules.
Protocol/auth helpers live in ``sd_utils``; the refresh pipeline in ``sd_tasks``.
"""
from __future__ import annotations
import logging
import time
import requests as http_requests
from django.http import HttpResponse
from django.utils import timezone
from django.utils.dateparse import parse_datetime
from rest_framework import status
from rest_framework.decorators import action
from rest_framework.permissions import AllowAny
from rest_framework.response import Response
from apps.epg.sd_utils import (
SD_AUTH_CREDENTIAL_LOCKOUT_CODES,
SD_BASE_URL,
SD_CODE_IMAGE_NOT_FOUND,
SD_IMAGE_LIMIT_CODES,
sd_auth_lockout_active,
sd_authorized_request,
sd_handle_2055,
sd_image_limit_active,
sd_mark_icon_missing,
sd_next_midnight_utc,
sd_obtain_token,
sd_parse_response_payload,
sd_save_image_limit_lockout,
)
from core.utils import dispatcharr_http_headers
logger = logging.getLogger(__name__)
class SchedulesDirectSourceMixin:
"""Lineup management actions and helpers for Schedules Direct EPG sources."""
def _sd_authenticate(self, source):
"""
Authenticate with Schedules Direct using stored credentials.
Returns (token, None) on success or (None, Response) on failure.
"""
result = sd_obtain_token(source, timeout=15)
if result.ok:
return result.token, None
if result.debug_rejected:
return None, Response(
{"error": result.message},
status=status.HTTP_400_BAD_REQUEST,
)
if result.message == 'Username and password are required.':
http_status = status.HTTP_400_BAD_REQUEST
elif result.code in SD_AUTH_CREDENTIAL_LOCKOUT_CODES:
http_status = status.HTTP_401_UNAUTHORIZED
elif result.lockout or result.soft or result.code:
http_status = status.HTTP_503_SERVICE_UNAVAILABLE
else:
http_status = status.HTTP_502_BAD_GATEWAY
return None, Response({"error": result.message}, status=http_status)
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.
"""
cp = source.custom_properties or {}
changes_remaining = cp.get('sd_changes_remaining')
reset_at_str = cp.get('sd_changes_reset_at')
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 on the EPG source.
When remaining hits 0, also store sd_changes_reset_at (next midnight UTC)
so the lockout can clear automatically. A positive remaining clears any
prior reset timestamp.
"""
cp = dict(source.custom_properties or {})
cp['sd_changes_remaining'] = changes_remaining
if changes_remaining == 0:
cp['sd_changes_reset_at'] = sd_next_midnight_utc().isoformat()
else:
cp.pop('sd_changes_reset_at', None)
source.custom_properties = cp
source.save(update_fields=['custom_properties'])
def _save_sd_lockout(self, source):
"""
Persist a hard lockout when SD returns 4100 MAX_LINEUP_CHANGES_REACHED.
SD lineup change counters reset at 00:00Z (midnight UTC). Lockout clears
automatically when that reset time passes.
"""
self._save_sd_changes_remaining(source, 0)
reset_at = (source.custom_properties or {}).get('sd_changes_reset_at')
logger.warning(
f"SD source {source.id}: daily lineup change limit reached (4100). "
f"Lockout set until {reset_at}."
)
def _fetch_sd_countries(self):
"""Fetch the SD country list (token not required; User-Agent is)."""
try:
resp = http_requests.get(
f"{SD_BASE_URL}/available/countries",
headers=dispatcharr_http_headers(content_type=None),
timeout=15,
)
resp.raise_for_status()
return resp.json()
except http_requests.exceptions.RequestException as e:
logger.warning(f"Failed to fetch SD countries: {e}")
return None
@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"})
"""
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
if request.method == "GET":
countries = self._fetch_sd_countries()
try:
resp, token = sd_authorized_request(
'GET',
f"{SD_BASE_URL}/lineups",
source=source,
token=token,
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.",
"countries": countries,
})
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),
"countries": countries,
})
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)
# Honor a known daily change lockout without calling SD again.
if self._get_sd_changes_remaining(source) == 0:
return Response({
"error": "daily_limit_reached",
"message": (
"You have reached your daily Schedules Direct lineup "
"change limit (6 add/delete operations per 24 hours). "
"Resets at midnight UTC."
),
"changes_remaining": 0,
"changes_reset_at": self._get_sd_reset_at(source),
"docs_url": "https://github.com/SchedulesDirect/JSON-Service/wiki/API-20141201#tasks-your-client-must-perform",
}, status=status.HTTP_200_OK)
try:
resp, token = sd_authorized_request(
'PUT',
f"{SD_BASE_URL}/lineups/{lineup_id}",
source=source,
token=token,
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 "
"change limit (6 add/delete operations per 24 hours). "
"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)
if self._get_sd_changes_remaining(source) == 0:
return Response({
"error": "daily_limit_reached",
"message": (
"You have reached your daily Schedules Direct lineup "
"change limit (6 add/delete operations per 24 hours). "
"Resets at midnight UTC."
),
"changes_remaining": 0,
"changes_reset_at": self._get_sd_reset_at(source),
"docs_url": "https://github.com/SchedulesDirect/JSON-Service/wiki/API-20141201#tasks-your-client-must-perform",
}, status=status.HTTP_200_OK)
try:
resp, token = sd_authorized_request(
'DELETE',
f"{SD_BASE_URL}/lineups/{lineup_id}",
source=source,
token=token,
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),
})
if sd_code == 4100:
self._save_sd_lockout(source)
return Response({
"error": "daily_limit_reached",
"message": (
"You have reached your daily Schedules Direct lineup "
"change limit (6 add/delete operations per 24 hours). "
"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)
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.
"""
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
try:
resp, token = sd_authorized_request(
'GET',
f"{SD_BASE_URL}/headends",
source=source,
token=token,
timeout=15,
params={'country': country, 'postalcode': postalcode},
)
try:
headends = resp.json()
except ValueError:
headends = None
if isinstance(headends, dict) and sd_handle_2055(source, headends):
return Response(
{
"error": (
"Schedules Direct rejected the debug routing header "
"(code 2055). Extra Schedules Direct Debugging has "
"been turned off."
),
},
status=status.HTTP_400_BAD_REQUEST,
)
resp.raise_for_status()
if not isinstance(headends, list):
headends = []
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)
# ─────────────────────────────
class SchedulesDirectPosterMixin:
"""Program poster proxy for Schedules Direct artwork URIs."""
_sd_poster_error_cache: dict = {}
@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."""
program = self.get_object()
cp = program.custom_properties or {}
if cp.get('sd_icon_missing'):
return Response(status=status.HTTP_404_NOT_FOUND)
poster_sd_url = cp.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)
# Persisted lockout (shared across workers) until next midnight UTC.
limit_active, limit_reason = sd_image_limit_active(source)
if limit_active:
self._sd_poster_error_cache[source.id] = {
'until': time.time() + 300,
'reason': limit_reason,
}
return Response(
{'error': f"SD temporarily unavailable: {limit_reason}"},
status=status.HTTP_503_SERVICE_UNAVAILABLE,
)
auth_lockout_active, auth_lockout_reason, _lockout_code = (
sd_auth_lockout_active(source)
)
if auth_lockout_active:
# Rely on the persisted lockout only. Do not seed the process-local
# poster error cache here: that cache outlives credential changes and
# cooldown expiry and would keep returning 503 afterward.
return Response(
{'error': auth_lockout_reason},
status=status.HTTP_503_SERVICE_UNAVAILABLE,
)
error_cache = self._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,
)
auth = sd_obtain_token(source, timeout=10)
if auth.debug_rejected:
return Response(
{'error': auth.message},
status=status.HTTP_400_BAD_REQUEST,
)
if not auth.ok:
# Lockout codes are persisted by sd_obtain_token. Other failures
# use a short process-local cache so workers do not hammer /token.
if not auth.lockout:
self._sd_poster_error_cache[source.id] = {
'until': time.time() + (3600 if auth.code else 300),
'reason': auth.message or 'Authentication failed',
}
return Response(
{'error': auth.message},
status=status.HTTP_502_BAD_GATEWAY,
)
token = auth.token
self._sd_poster_error_cache.pop(source.id, None)
try:
img_resp, token = sd_authorized_request(
'GET',
poster_sd_url,
source=source,
token=token,
timeout=15,
content_type=None,
allow_redirects=True,
)
err_code, err_data = sd_parse_response_payload(img_resp)
if err_data is not None and sd_handle_2055(source, err_data):
return Response(
{
'error': (
'Schedules Direct rejected the debug routing header '
'(code 2055). Extra Schedules Direct Debugging has '
'been turned off.'
),
},
status=status.HTTP_400_BAD_REQUEST,
)
# SD documents 5002/5003 as HTTP 200 + JSON. Also accept 4xx.
if err_code in SD_IMAGE_LIMIT_CODES:
sd_save_image_limit_lockout(source, err_code)
self._sd_poster_error_cache[source.id] = {
'until': time.time() + 300,
'reason': (
f'Daily image download limit reached (SD error {err_code})'
),
}
return Response(status=status.HTTP_429_TOO_MANY_REQUESTS)
# Only blacklist on explicit IMAGE_NOT_FOUND (5000). Bare HTTP 404 can
# be a transient CDN/S3 miss for ephemeral URIs and must not permanently
# clear sd_icon (SD docs: code 5000 + HTTP 404).
if err_code == SD_CODE_IMAGE_NOT_FOUND:
sd_mark_icon_missing(program)
return Response(status=status.HTTP_404_NOT_FOUND)
if img_resp.status_code == 404:
return Response(status=status.HTTP_404_NOT_FOUND)
if img_resp.status_code in (401, 403):
# Clear already happened inside sd_authorized_request; seed a short
# process cache only when the fresh-token retry still failed.
self._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)
# JSON error body (even on HTTP 200) is never a valid image.
if err_data is not None and err_code not in (None, 0):
logger.warning(
"SD poster proxy: unexpected JSON error code %s for program %s",
err_code,
program.id,
)
return Response(status=status.HTTP_502_BAD_GATEWAY)
if img_resp.status_code != 200:
return Response(status=status.HTTP_502_BAD_GATEWAY)
content_type = img_resp.headers.get('Content-Type', 'image/jpeg')
if 'json' in (content_type or '').lower():
return Response(status=status.HTTP_502_BAD_GATEWAY)
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)

1840
apps/epg/sd_tasks.py Normal file

File diff suppressed because it is too large Load diff

771
apps/epg/sd_utils.py Normal file
View file

@ -0,0 +1,771 @@
"""
Schedules Direct API helpers: headers, error codes, and rate-limit lockouts.
See https://github.com/SchedulesDirect/JSON-Service/wiki/API-20141201
"""
from __future__ import annotations
import hashlib
import json
import logging
import time
from dataclasses import dataclass
from datetime import timedelta, timezone as dt_timezone
import requests
from django.core.cache import cache
from django.utils import timezone
from django.utils.dateparse import parse_datetime
from core.utils import dispatcharr_http_headers
logger = logging.getLogger(__name__)
SD_BASE_URL = 'https://json.schedulesdirect.org/20141201'
# SD JSON error codes we must honor to avoid account blocks.
SD_CODE_INVALID_DEBUG = 2055
SD_CODE_SERVICE_OFFLINE = 3000
SD_CODE_SERVICE_BUSY = 3001
SD_CODE_ACCOUNT_EXPIRED = 4001
SD_CODE_INVALID_PASSWORD_HASH = 4002
SD_CODE_INVALID_USER_OR_PASSWORD = 4003
SD_CODE_ACCOUNT_LOCKED = 4004
SD_CODE_JSON_DISABLED = 4005
SD_CODE_APP_NOT_AUTHORIZED = 4007
SD_CODE_ACCOUNT_INACTIVE = 4008
SD_CODE_TOO_MANY_LOGINS = 4009
SD_CODE_TOO_MANY_UNIQUE_IPS = 4010
SD_CODE_IMAGE_NOT_FOUND = 5000
SD_CODE_MAX_IMAGE_DOWNLOADS = 5002
SD_CODE_MAX_IMAGE_DOWNLOADS_TRIAL = 5003
SD_IMAGE_LIMIT_CODES = frozenset({
SD_CODE_MAX_IMAGE_DOWNLOADS,
SD_CODE_MAX_IMAGE_DOWNLOADS_TRIAL,
})
# Soft /token failures: stop and retry later (idle), not a hard account error.
SD_AUTH_SOFT_CODES = frozenset({
SD_CODE_SERVICE_OFFLINE,
SD_CODE_SERVICE_BUSY,
})
# Wrong username/password (or hash): clear early when credentials change.
SD_AUTH_CREDENTIAL_LOCKOUT_CODES = frozenset({
SD_CODE_INVALID_PASSWORD_HASH,
SD_CODE_INVALID_USER_OR_PASSWORD,
})
# All /token codes we must not hammer. Includes soft codes (shorter cooldown).
SD_AUTH_LOCKOUT_CODES = frozenset({
SD_CODE_SERVICE_OFFLINE,
SD_CODE_SERVICE_BUSY,
SD_CODE_ACCOUNT_EXPIRED,
SD_CODE_INVALID_PASSWORD_HASH,
SD_CODE_INVALID_USER_OR_PASSWORD,
SD_CODE_ACCOUNT_LOCKED,
SD_CODE_JSON_DISABLED,
SD_CODE_APP_NOT_AUTHORIZED,
SD_CODE_ACCOUNT_INACTIVE,
SD_CODE_TOO_MANY_LOGINS,
SD_CODE_TOO_MANY_UNIQUE_IPS,
})
SD_AUTH_LOCKOUT_SECONDS = 24 * 3600
SD_AUTH_LOCKOUT_SECONDS_SOFT = 3600
SD_AUTH_LOCKOUT_SECONDS_ACCOUNT_LOCK = 15 * 60
def sd_auth_lockout_seconds_for_code(code):
"""Cooldown length for a /token error code."""
if code == SD_CODE_ACCOUNT_LOCKED:
return SD_AUTH_LOCKOUT_SECONDS_ACCOUNT_LOCK
if code in SD_AUTH_SOFT_CODES:
return SD_AUTH_LOCKOUT_SECONDS_SOFT
return SD_AUTH_LOCKOUT_SECONDS
def sd_auth_lockout_retry_message():
"""Suffix explaining how to clear an auth lockout."""
return (
"Not retrying Schedules Direct authentication until the username or "
"password is updated, or the cooldown expires."
)
# Shared across uWSGI workers via Django's Redis cache.
_SD_TOKEN_CACHE_PREFIX = 'sd:token:'
# Expire a bit early so we re-auth before SD rejects an almost-expired token.
_SD_TOKEN_CACHE_SKEW_SECONDS = 60
_SD_TOKEN_DEFAULT_TTL_SECONDS = 86400
def sd_token_cache_key(source_id):
return f'{_SD_TOKEN_CACHE_PREFIX}{source_id}'
def sd_credential_fingerprint(username, password):
"""
Stable fingerprint of SD credentials for lockout and token-cache matching.
Used so a persisted auth lockout or cached token clears automatically when
the user changes username or password.
"""
raw = f'{(username or "").strip()}\0{(password or "").strip()}'
return hashlib.sha256(raw.encode('utf-8')).hexdigest()
def sd_get_cached_token(source_id, username=None, password=None):
"""
Return a cached SD token string for this source, or None.
Requires ``username`` / ``password`` so a cached session is only reused when
credentials still match (avoids using account A's token after switching to B).
On Redis failure, returns None so the caller re-authenticates.
"""
if source_id is None:
return None
current_fp = sd_credential_fingerprint(username, password)
try:
payload = cache.get(sd_token_cache_key(source_id))
except Exception as exc:
logger.warning(
"SD token cache get failed for source %s (%s); re-authenticating",
source_id,
type(exc).__name__,
)
return None
if not isinstance(payload, dict):
return None
token = payload.get('token')
expires = payload.get('expires')
if not token or not isinstance(expires, (int, float)):
return None
if payload.get('fp') != current_fp:
sd_clear_cached_token(source_id)
return None
if time.time() >= float(expires) - _SD_TOKEN_CACHE_SKEW_SECONDS:
return None
return token
def sd_set_cached_token(source_id, token, expires=None, username=None, password=None):
"""
Cache an SD token for this source until near tokenExpires.
``expires`` is a UNIX epoch seconds value from SD's tokenExpires field.
Stores a credential fingerprint so callers must still match to reuse it.
"""
if source_id is None or not token:
return False
now = time.time()
if expires is None:
expires = now + _SD_TOKEN_DEFAULT_TTL_SECONDS
try:
expires = float(expires)
except (TypeError, ValueError):
expires = now + _SD_TOKEN_DEFAULT_TTL_SECONDS
ttl = int(expires - now - _SD_TOKEN_CACHE_SKEW_SECONDS)
if ttl < 1:
return False
try:
cache.set(
sd_token_cache_key(source_id),
{
'token': token,
'expires': expires,
'fp': sd_credential_fingerprint(username, password),
},
timeout=ttl,
)
return True
except Exception as exc:
logger.warning(
"SD token cache set failed for source %s (%s)",
source_id,
type(exc).__name__,
)
return False
def sd_clear_cached_token(source_id):
"""Drop a cached SD token (e.g. after 401/403 from an image request)."""
if source_id is None:
return False
try:
cache.delete(sd_token_cache_key(source_id))
return True
except Exception as exc:
logger.warning(
"SD token cache delete failed for source %s (%s)",
source_id,
type(exc).__name__,
)
return False
def sd_auth_failure_message(code, sd_message=None):
"""Human-readable message for a Schedules Direct /token error code."""
if code == SD_CODE_SERVICE_OFFLINE:
return (
"Schedules Direct is offline for maintenance. "
"Do not retry for at least 1 hour."
)
if code == SD_CODE_SERVICE_BUSY:
return "Schedules Direct is busy. Stop requesting and retry later."
if code == SD_CODE_ACCOUNT_EXPIRED:
return (
"Schedules Direct: account has expired. Please renew your "
"subscription at schedulesdirect.org."
)
if code == SD_CODE_INVALID_PASSWORD_HASH:
return (
"Schedules Direct: invalid password hash. Check that credentials "
"are stored correctly."
)
if code == SD_CODE_INVALID_USER_OR_PASSWORD:
return "Schedules Direct: invalid username or password."
if code == SD_CODE_ACCOUNT_LOCKED:
return (
"Schedules Direct: account locked due to too many failed login "
"attempts. Try again in 15 minutes."
)
if code == SD_CODE_JSON_DISABLED:
return (
"Schedules Direct: JSON API access is disabled for this account. "
"Contact Schedules Direct support."
)
if code == SD_CODE_APP_NOT_AUTHORIZED:
return (
"Schedules Direct: this application is not authorized. Please "
"contact the Dispatcharr maintainers."
)
if code == SD_CODE_ACCOUNT_INACTIVE:
return (
"Schedules Direct: account is inactive. Please log in to "
"schedulesdirect.org to reactivate."
)
if code == SD_CODE_TOO_MANY_LOGINS:
return (
"Schedules Direct: too many login attempts in 24 hours. Token is "
"valid for 24 hours. Check for misconfiguration."
)
if code == SD_CODE_TOO_MANY_UNIQUE_IPS:
return (
"Schedules Direct: too many unique IP addresses in 24 hours. "
"Avoid VPNs or multiple locations, or contact SD support to raise "
"the limit."
)
if sd_message:
return f"Schedules Direct authentication failed (code {code}): {sd_message}"
return f"Schedules Direct authentication failed (code {code})."
def sd_token_response_code(auth_data):
"""Return integer SD ``code`` from a /token JSON body, or 0."""
if not isinstance(auth_data, dict):
return 0
code = auth_data.get('code', 0)
return code if isinstance(code, int) else 0
def sd_auth_lockout_active(source, username=None, password=None):
"""
Return (active, reason, code) for a persisted auth lockout.
Cleared when username/password changed, or sd_auth_lockout_until has passed.
"""
if source is None:
return False, None, None
cp = source.custom_properties or {}
if not cp.get('sd_auth_lockout'):
return False, None, None
until_str = cp.get('sd_auth_lockout_until')
until = parse_datetime(until_str) if until_str else None
if until is None or timezone.now() >= until:
sd_clear_auth_lockout(source)
return False, None, None
if username is None:
username = source.username
if password is None:
password = source.password
stored_fp = cp.get('sd_auth_lockout_credential_fp')
current_fp = sd_credential_fingerprint(username, password)
if not stored_fp or stored_fp != current_fp:
sd_clear_auth_lockout(source)
return False, None, None
code = cp.get('sd_auth_lockout_code')
reason = cp.get('sd_auth_lockout_reason') or (
'Schedules Direct authentication is temporarily blocked.'
)
return True, reason, code if isinstance(code, int) else None
def sd_save_auth_lockout(source, code, username=None, password=None, reason=None):
"""
Persist an auth lockout so we stop calling /token for a cooldown period.
Duration depends on the code (15m for 4004, 1h for offline/busy, else 24h).
Cleared early if username/password change.
"""
if source is None:
return
if code not in SD_AUTH_LOCKOUT_CODES:
return
if username is None:
username = source.username
if password is None:
password = source.password
if reason is None:
reason = sd_auth_failure_message(code)
seconds = sd_auth_lockout_seconds_for_code(code)
until = timezone.now() + timedelta(seconds=seconds)
cp = dict(source.custom_properties or {})
cp['sd_auth_lockout'] = True
cp['sd_auth_lockout_code'] = code
cp['sd_auth_lockout_reason'] = reason
cp['sd_auth_lockout_until'] = until.isoformat()
cp['sd_auth_lockout_credential_fp'] = sd_credential_fingerprint(
username, password
)
source.custom_properties = cp
source.save(update_fields=['custom_properties'])
sd_clear_cached_token(source.id)
logger.warning(
"SD source %s: auth lockout (code %s). Not calling /token until "
"username or password changes, or after %s.",
source.id,
code,
until.isoformat(),
)
def sd_clear_auth_lockout(source):
"""Clear a persisted auth lockout (success, credentials changed, or expired)."""
if source is None:
return
cp = dict(source.custom_properties or {})
changed = False
for key in (
'sd_auth_lockout',
'sd_auth_lockout_code',
'sd_auth_lockout_reason',
'sd_auth_lockout_until',
'sd_auth_lockout_credential_fp',
):
if key in cp:
cp.pop(key, None)
changed = True
if changed:
source.custom_properties = cp
source.save(update_fields=['custom_properties'])
@dataclass(frozen=True)
class SDTokenAuthResult:
"""Outcome of POST /token (or a short-circuit from a persisted lockout)."""
ok: bool
token: str | None = None
token_expires: float | None = None
code: int | None = None
message: str = ''
soft: bool = False
debug_rejected: bool = False
lockout: bool = False
_DEBUG_REJECTED_MESSAGE = (
"Schedules Direct rejected the debug routing header (code 2055). "
"Extra Schedules Direct Debugging has been turned off. Retry without it "
"unless Schedules Direct support asked you to enable it."
)
def sd_obtain_token(source, username=None, password=None, *, timeout=30):
"""
Return a Schedules Direct session token, reusing a cached one when valid.
Shared by refresh, lineup/form auth, and the poster proxy. Checks Redis for
an unexpired token bound to the current credentials before POSTing /token.
Honors persisted lockouts, reads JSON ``code`` before HTTP status, and
persists cooldowns for codes that must not be retried until cleared.
"""
if source is None:
return SDTokenAuthResult(
ok=False,
message='Schedules Direct source is required.',
)
if username is None:
username = (source.username or '').strip()
else:
username = (username or '').strip()
if password is None:
password = (source.password or '').strip()
else:
password = (password or '').strip()
if not username or not password:
return SDTokenAuthResult(
ok=False,
message='Username and password are required.',
)
active, reason, lockout_code = sd_auth_lockout_active(
source, username, password
)
if active:
msg = f"{reason} {sd_auth_lockout_retry_message()}"
return SDTokenAuthResult(
ok=False,
code=lockout_code,
message=msg,
soft=lockout_code in SD_AUTH_SOFT_CODES if lockout_code else False,
lockout=True,
)
cached = sd_get_cached_token(source.id, username=username, password=password)
if cached:
return SDTokenAuthResult(
ok=True,
token=cached,
code=0,
)
sha1_password = hashlib.sha1(password.encode('utf-8')).hexdigest()
try:
response = requests.post(
f"{SD_BASE_URL}/token",
json={'username': username, 'password': sha1_password},
headers=sd_headers_for_source(source),
timeout=timeout,
)
except requests.exceptions.RequestException as exc:
return SDTokenAuthResult(
ok=False,
message=f'Network error authenticating with Schedules Direct: {exc}',
)
try:
auth_data = response.json()
except ValueError:
auth_data = {}
if sd_handle_2055(source, auth_data):
return SDTokenAuthResult(
ok=False,
code=SD_CODE_INVALID_DEBUG,
message=_DEBUG_REJECTED_MESSAGE,
debug_rejected=True,
)
# Honor JSON ``code`` before raise_for_status. SD error semantics are in
# the body; HTTP status alone is not reliable for auth failures.
auth_code = sd_token_response_code(auth_data)
if auth_code != 0:
msg = sd_auth_failure_message(
auth_code, auth_data.get('message', 'Unknown error')
)
soft = auth_code in SD_AUTH_SOFT_CODES
locked = False
if auth_code in SD_AUTH_LOCKOUT_CODES:
sd_save_auth_lockout(
source, auth_code, username, password, reason=msg
)
msg = f"{msg} {sd_auth_lockout_retry_message()}"
locked = True
return SDTokenAuthResult(
ok=False,
code=auth_code,
message=msg,
soft=soft,
lockout=locked,
)
try:
response.raise_for_status()
except requests.exceptions.RequestException as exc:
return SDTokenAuthResult(
ok=False,
message=f'Network error authenticating with Schedules Direct: {exc}',
)
token = auth_data.get('token') if isinstance(auth_data, dict) else None
if not token:
return SDTokenAuthResult(
ok=False,
message='Schedules Direct returned no token.',
)
sd_clear_auth_lockout(source)
expires = None
if isinstance(auth_data, dict):
expires = auth_data.get('tokenExpires')
if not isinstance(expires, (int, float)):
expires = time.time() + _SD_TOKEN_DEFAULT_TTL_SECONDS
expires = float(expires)
sd_set_cached_token(
source.id, token, expires, username=username, password=password
)
return SDTokenAuthResult(
ok=True,
token=token,
token_expires=expires,
code=0,
)
def sd_authorized_request(
method,
url,
*,
source,
token,
username=None,
password=None,
timeout=30,
content_type='application/json',
**kwargs,
):
"""
Perform an authenticated Schedules Direct HTTP request.
On HTTP 401/403 (SD documents TOKEN_EXPIRED as 403 + code 4006), clears the
Redis token cache, obtains a fresh token, and retries the request once.
Returns ``(response, token)`` where ``token`` may have been refreshed.
"""
method_upper = (method or 'GET').upper()
http_fn = {
'GET': requests.get,
'POST': requests.post,
'PUT': requests.put,
'DELETE': requests.delete,
'HEAD': requests.head,
'PATCH': requests.patch,
}.get(method_upper)
def _once(current_token):
headers = sd_headers_for_source(
source,
token=current_token,
content_type=content_type,
)
if http_fn is not None:
return http_fn(url, headers=headers, timeout=timeout, **kwargs)
return requests.request(
method_upper, url, headers=headers, timeout=timeout, **kwargs
)
response = _once(token)
if response.status_code not in (401, 403):
return response, token
logger.warning(
"SD source %s: %s %s returned %s; clearing cached token and retrying once",
getattr(source, 'id', None),
method_upper,
url,
response.status_code,
)
sd_clear_cached_token(getattr(source, 'id', None))
auth_timeout = timeout if isinstance(timeout, (int, float)) else 30
auth = sd_obtain_token(
source,
username=username,
password=password,
timeout=min(int(auth_timeout), 30) if auth_timeout else 30,
)
if not auth.ok or not auth.token:
return response, token
retry_response = _once(auth.token)
return retry_response, auth.token
def sd_next_midnight_utc():
"""Return the next Schedules Direct counter reset (00:00Z)."""
now = timezone.now()
return (now + timedelta(days=1)).replace(
hour=0, minute=0, second=0, microsecond=0,
tzinfo=dt_timezone.utc,
)
def sd_headers_for_source(source, *, token=None, content_type='application/json'):
"""
Build outbound headers for Schedules Direct requests for this source.
When Extra Schedules Direct Debugging is enabled, adds RouteTo: debug so the
SD load balancer can steer traffic to their debug server (support-coordinated).
"""
cp = source.custom_properties or {} if source is not None else {}
route_to = 'debug' if cp.get('sd_extra_debugging') else None
return dispatcharr_http_headers(
token=token,
content_type=content_type,
route_to=route_to,
)
def sd_disable_extra_debugging(source):
"""Clear the user-facing debug toggle after SD returns code 2055."""
if source is None:
return False
cp = dict(source.custom_properties or {})
if not cp.get('sd_extra_debugging'):
return False
cp['sd_extra_debugging'] = False
source.custom_properties = cp
source.save(update_fields=['custom_properties'])
logger.warning(
"SD source %s: received code 2055 (unexpected debug connection). "
"Disabled Extra Schedules Direct Debugging.",
source.id,
)
return True
def sd_handle_2055(source, data):
"""
If data is an SD JSON error with code 2055, disable debug routing.
Returns True when 2055 was handled.
"""
if not isinstance(data, dict):
return False
if data.get('code') != SD_CODE_INVALID_DEBUG:
return False
sd_disable_extra_debugging(source)
return True
def sd_parse_response_payload(response):
"""Return (code, data_dict_or_None) for an SD HTTP response body."""
if response is None:
return None, None
content_type = (response.headers.get('Content-Type') or '').lower()
body = response.content or b''
looks_json = (
'json' in content_type
or body.lstrip()[:1] in (b'{', b'[')
)
if not looks_json:
return None, None
try:
data = response.json()
except (ValueError, json.JSONDecodeError):
return None, None
if not isinstance(data, dict):
return None, None
code = data.get('code')
return (code if isinstance(code, int) else None), data
def sd_image_limit_active(source):
"""
Return (active: bool, reason: str|None) for a persisted image download lockout.
Clears the lockout automatically once the next midnight UTC has passed.
"""
if source is None:
return False, None
cp = source.custom_properties or {}
if not cp.get('sd_image_limit_hit'):
return False, None
reset_at_str = cp.get('sd_image_limit_reset_at')
reset_at = parse_datetime(reset_at_str) if reset_at_str else None
if reset_at and timezone.now() >= reset_at:
sd_clear_image_limit_lockout(source)
return False, None
reason = cp.get('sd_image_limit_reason') or (
'Daily image download limit reached'
)
return True, reason
def sd_save_image_limit_lockout(source, code):
"""
Persist a source-wide image download lockout until next 00:00Z.
Used for SD codes 5002 (subscriber) and 5003 (trial).
"""
if source is None:
return
reset_at = sd_next_midnight_utc()
reason = (
f'Daily image download limit reached (SD error {code})'
if code
else 'Daily image download limit reached'
)
cp = dict(source.custom_properties or {})
cp['sd_image_limit_hit'] = True
cp['sd_image_limit_reset_at'] = reset_at.isoformat()
cp['sd_image_limit_reason'] = reason
source.custom_properties = cp
source.save(update_fields=['custom_properties'])
logger.warning(
"SD source %s: image download limit (code %s). Lockout until %s.",
source.id,
code,
reset_at.isoformat(),
)
def sd_clear_image_limit_lockout(source):
"""Clear a persisted image download lockout after midnight UTC."""
if source is None:
return
cp = dict(source.custom_properties or {})
changed = False
for key in (
'sd_image_limit_hit',
'sd_image_limit_reset_at',
'sd_image_limit_reason',
):
if key in cp:
cp.pop(key, None)
changed = True
if changed:
source.custom_properties = cp
source.save(update_fields=['custom_properties'])
def sd_mark_icon_missing(program):
"""
Clear a bad image URI so we never re-request it (SD code 5000).
Continues requesting the same missing URI can accumulate toward code 5004
and get the account blocked.
"""
if program is None:
return
cp = dict(program.custom_properties or {})
if 'sd_icon' not in cp and cp.get('sd_icon_missing'):
return
cp.pop('sd_icon', None)
cp['sd_icon_missing'] = True
program.custom_properties = cp
program.save(update_fields=['custom_properties'])
logger.info(
"SD program %s: IMAGE_NOT_FOUND (5000). Cleared sd_icon to avoid retries.",
program.id,
)

View file

@ -1,3 +1,8 @@
from apps.epg.sd_utils import (
sd_clear_cached_token,
sd_credential_fingerprint,
)
from apps.epg.utils import sd_poster_proxy_path
from core.utils import validate_flexible_url, build_absolute_uri_with_port from core.utils import validate_flexible_url, build_absolute_uri_with_port
from rest_framework import serializers from rest_framework import serializers
from .models import EPGSource, EPGData, ProgramData from .models import EPGSource, EPGData, ProgramData
@ -67,11 +72,16 @@ class EPGSourceSerializer(serializers.ModelSerializer):
ct = instance.refresh_task.crontab ct = instance.refresh_task.crontab
cron_expr = f'{ct.minute} {ct.hour} {ct.day_of_month} {ct.month_of_year} {ct.day_of_week}' cron_expr = f'{ct.minute} {ct.hour} {ct.day_of_month} {ct.month_of_year} {ct.day_of_week}'
instance._cron_expression = cron_expr instance._cron_expression = cron_expr
prior_fp = sd_credential_fingerprint(instance.username, instance.password)
for attr, value in validated_data.items(): for attr, value in validated_data.items():
if attr == 'password' and not value: if attr == 'password' and not value:
continue continue
setattr(instance, attr, value) setattr(instance, attr, value)
instance.save() instance.save()
# Drop any Redis SD session tied to the previous username/password so
# poster traffic cannot keep using another account's token.
if prior_fp != sd_credential_fingerprint(instance.username, instance.password):
sd_clear_cached_token(instance.id)
return instance return instance
def create(self, validated_data): def create(self, validated_data):
@ -169,9 +179,11 @@ class ProgramDetailSerializer(ProgramDataSerializer):
data['icon'] = cp.get('icon') data['icon'] = cp.get('icon')
data['images'] = cp.get('images') or [] data['images'] = cp.get('images') or []
# SD poster: expose as absolute proxy URL so frontend/img tags never need SD auth # SD poster: expose as absolute proxy URL so frontend/img tags never need SD auth.
if cp.get('sd_icon'): # ``?v=`` tracks the sd_icon URI so nginx/browser caches bust when artwork changes.
poster_path = f"/api/epg/programs/{obj.id}/poster/" sd_icon = cp.get('sd_icon')
if sd_icon:
poster_path = sd_poster_proxy_path(obj.id, sd_icon)
request = self.context.get('request') request = self.context.get('request')
if request: if request:
data['poster_url'] = build_absolute_uri_with_port(request, poster_path) data['poster_url'] = build_absolute_uri_with_port(request, poster_path)

File diff suppressed because it is too large Load diff

View file

@ -1,195 +1,149 @@
"""Tests for HTML entity resolution in XMLTV parsing (DOCTYPE injection path)."""
import os import os
import tempfile import tempfile
from django.test import TestCase from django.test import TestCase
from lxml import etree
from apps.epg.tasks import ( from apps.epg.tasks import _open_xmltv_file, _parse_programme_element
_NAMED_ENTITY_RE,
_detect_xml_encoding,
_replace_html_entity,
_resolve_html_entities,
)
class ReplaceHtmlEntityTests(TestCase): def _parse_title(entity_text: str) -> str:
"""Tests for the regex callback that resolves individual HTML entities.""" """Resolve entities the same way production programme parsing does."""
xml = (
f'<programme start="20000101000000 +0000" '
f'stop="20000101000000 +0000" channel="test.ch">'
f"<title>{entity_text}</title></programme>"
).encode("utf-8")
root = _parse_programme_element(xml)
return root.findtext("title")
def _sub(self, text):
return _NAMED_ENTITY_RE.sub(_replace_html_entity, text) def _read_display_name_from_file(path: str) -> str:
with _open_xmltv_file(path) as handle:
for _event, elem in etree.iterparse(handle, events=("end",), tag="display-name"):
text = (elem.text or "").strip()
elem.clear()
while elem.getprevious() is not None:
del elem.getparent()[0]
return text
return ""
class ParseProgrammeElementEntityTests(TestCase):
"""Entity resolution via _parse_programme_element + injected DOCTYPE."""
def test_french_accented(self): def test_french_accented(self):
self.assertEqual(self._sub("Cha&icirc;ne T&eacute;l&eacute;"), "Chaîne Télé") self.assertEqual(_parse_title("Cha&icirc;ne T&eacute;l&eacute;"), "Chaîne Télé")
def test_german_umlauts(self): def test_german_umlauts(self):
self.assertEqual(self._sub("M&uuml;nchen &Uuml;bersicht &szlig;"), "München Übersicht ß") self.assertEqual(
_parse_title("M&uuml;nchen &Uuml;bersicht &szlig;"),
"München Übersicht ß",
)
def test_spanish(self): def test_spanish(self):
self.assertEqual(self._sub("Espa&ntilde;a &iquest;Qu&eacute;?"), "España ¿Qué?") self.assertEqual(_parse_title("Espa&ntilde;a &iquest;Qu&eacute;?"), "España ¿Qué?")
def test_portuguese(self): def test_portuguese(self):
self.assertEqual(self._sub("Comunica&ccedil;&atilde;o"), "Comunicação") self.assertEqual(_parse_title("Comunica&ccedil;&atilde;o"), "Comunicação")
def test_scandinavian(self): def test_scandinavian(self):
self.assertEqual(self._sub("Norsk &oslash; &aring; &aelig;"), "Norsk ø å æ") self.assertEqual(_parse_title("Norsk &oslash; &aring; &aelig;"), "Norsk ø å æ")
def test_greek_letters(self): def test_greek_letters(self):
self.assertEqual(self._sub("&alpha;&beta;&gamma;"), "αβγ") self.assertEqual(_parse_title("&alpha;&beta;&gamma;"), "αβγ")
def test_currency_and_symbols(self): def test_currency_and_symbols(self):
self.assertEqual(self._sub("&copy; &euro; &pound; &yen;"), "© € £ ¥") self.assertEqual(_parse_title("&copy; &euro; &pound; &yen;"), "© € £ ¥")
def test_preserves_xml_amp(self): def test_preserves_xml_amp(self):
self.assertEqual(self._sub("A &amp; B"), "A &amp; B") self.assertEqual(_parse_title("A &amp; B"), "A & B")
def test_preserves_xml_lt_gt(self): def test_preserves_xml_lt_gt(self):
self.assertEqual(self._sub("&lt;tag&gt;"), "&lt;tag&gt;") self.assertEqual(_parse_title("&lt;tag&gt;"), "<tag>")
def test_preserves_xml_quot_apos(self): def test_preserves_xml_quot_apos(self):
self.assertEqual(self._sub("&quot;hello&apos;"), "&quot;hello&apos;") self.assertEqual(_parse_title("&quot;hello&apos;"), '"hello\'')
def test_preserves_uppercase_xml_entities(self):
"""&AMP;, &LT;, &GT;, &QUOT; resolve to XML-special chars; must not be replaced."""
self.assertEqual(self._sub("&AMP;"), "&AMP;")
self.assertEqual(self._sub("&LT;"), "&LT;")
self.assertEqual(self._sub("&GT;"), "&GT;")
self.assertEqual(self._sub("&QUOT;"), "&QUOT;")
def test_partial_entity_match_preserved(self):
"""html.unescape can partially match &amp inside &ampersand; — must not corrupt."""
self.assertEqual(self._sub("&ampersand;"), "&ampersand;")
def test_mixed_html_and_xml_entities(self): def test_mixed_html_and_xml_entities(self):
self.assertEqual( self.assertEqual(
self._sub("R&eacute;sum&eacute; &amp; Co &lt;test&gt;"), _parse_title("R&eacute;sum&eacute; &amp; Co &lt;test&gt;"),
"Résumé &amp; Co &lt;test&gt;", "Résumé & Co <test>",
) )
def test_plain_ascii_unchanged(self): def test_plain_ascii_unchanged(self):
self.assertEqual(self._sub("Plain ASCII text"), "Plain ASCII text") self.assertEqual(_parse_title("Plain ASCII text"), "Plain ASCII text")
def test_direct_utf8_unchanged(self): def test_direct_utf8_unchanged(self):
self.assertEqual(self._sub("日本語テレビ"), "日本語テレビ") self.assertEqual(_parse_title("日本語テレビ"), "日本語テレビ")
def test_unknown_entity_preserved(self):
self.assertEqual(self._sub("&zzfakeentity;"), "&zzfakeentity;")
class ResolveHtmlEntitiesFileTests(TestCase): class OpenXmltvFileEntityTests(TestCase):
"""Tests for the file-level preprocessing function.""" """Entity resolution via _open_xmltv_file streaming (no disk rewrite)."""
def _make_file(self, content): def _make_file(self, content, *, binary=False):
fd, path = tempfile.mkstemp(suffix=".xml") fd, path = tempfile.mkstemp(suffix=".xml")
with os.fdopen(fd, "w", encoding="utf-8") as f: with os.fdopen(fd, "wb" if binary else "w", encoding=None if binary else "utf-8") as f:
f.write(content) f.write(content)
self.addCleanup(lambda: os.unlink(path) if os.path.exists(path) else None) self.addCleanup(lambda: os.unlink(path) if os.path.exists(path) else None)
return path return path
def test_resolves_entities_in_file(self): def test_resolves_entities_in_file(self):
path = self._make_file( path = self._make_file(
'<?xml version="1.0"?>\n<tv><channel><display-name>T&eacute;l&eacute;</display-name></channel></tv>' '<?xml version="1.0"?>\n'
"<tv><channel><display-name>T&eacute;l&eacute;</display-name></channel></tv>"
) )
_resolve_html_entities(path) self.assertEqual(_read_display_name_from_file(path), "Télé")
with open(path, "r", encoding="utf-8") as f: with open(path, "r", encoding="utf-8") as f:
content = f.read() self.assertIn("&eacute;", f.read(), "Source file must not be rewritten on disk")
self.assertIn("Télé", content)
self.assertNotIn("&eacute;", content)
def test_preserves_xml_entities_in_file(self): def test_preserves_xml_entities_in_file(self):
path = self._make_file("<tv><desc>A &amp; B &lt;C&gt;</desc></tv>") path = self._make_file("<tv><channel><display-name>A &amp; B</display-name></channel></tv>")
_resolve_html_entities(path) self.assertEqual(_read_display_name_from_file(path), "A & B")
with open(path, "r", encoding="utf-8") as f:
content = f.read()
self.assertIn("&amp;", content)
self.assertIn("&lt;", content)
self.assertIn("&gt;", content)
def test_no_temp_file_left_on_success(self): def test_plain_file_unchanged_on_disk(self):
path = self._make_file("<tv>test</tv>") original = (
_resolve_html_entities(path) '<?xml version="1.0"?>\n'
self.assertFalse(os.path.exists(path + ".entity_tmp")) "<tv><channel><display-name>Plain</display-name></channel></tv>"
)
def test_plain_file_unchanged(self):
original = '<?xml version="1.0"?>\n<tv><channel><display-name>Plain</display-name></channel></tv>'
path = self._make_file(original) path = self._make_file(original)
_resolve_html_entities(path) _read_display_name_from_file(path)
with open(path, "r", encoding="utf-8") as f: with open(path, "r", encoding="utf-8") as f:
content = f.read() self.assertEqual(f.read(), original)
self.assertEqual(content, original)
def test_utf8_content_preserved(self): def test_utf8_content_preserved(self):
original = "<tv><channel><display-name>日本語テレビ</display-name></channel></tv>" path = self._make_file(
path = self._make_file(original) "<tv><channel><display-name>日本語テレビ</display-name></channel></tv>"
_resolve_html_entities(path) )
with open(path, "r", encoding="utf-8") as f: self.assertEqual(_read_display_name_from_file(path), "日本語テレビ")
content = f.read()
self.assertIn("日本語テレビ", content)
def test_iso_8859_1_encoding(self): def test_iso_8859_1_encoding(self):
"""Files declaring ISO-8859-1 should be read in that encoding.""" xml = (
xml = '<?xml version="1.0" encoding="ISO-8859-1"?>\n<tv><channel><display-name>Cha&icirc;ne</display-name></channel></tv>' '<?xml version="1.0" encoding="ISO-8859-1"?>\n'
fd, path = tempfile.mkstemp(suffix=".xml") "<tv><channel><display-name>Cha&icirc;ne</display-name></channel></tv>"
with os.fdopen(fd, "wb") as f:
f.write(xml.encode("iso-8859-1"))
self.addCleanup(lambda: os.unlink(path) if os.path.exists(path) else None)
_resolve_html_entities(path)
with open(path, "r", encoding="iso-8859-1") as f:
content = f.read()
self.assertIn("Cha\u00eene", content)
self.assertNotIn("&icirc;", content)
def test_detect_encoding_utf8_default(self):
"""Headers without an encoding declaration default to UTF-8."""
self.assertEqual(_detect_xml_encoding(b'<?xml version="1.0"?>'), "utf-8")
def test_detect_encoding_iso_8859_1(self):
"""Encoding is read from the XML declaration."""
self.assertEqual(
_detect_xml_encoding(b'<?xml version="1.0" encoding="ISO-8859-1"?>'),
"ISO-8859-1",
)
def test_detect_encoding_single_quotes(self):
"""Encoding detection works with single-quoted attributes."""
self.assertEqual(
_detect_xml_encoding(b"<?xml version='1.0' encoding='windows-1252'?>"),
"windows-1252",
)
def test_detect_encoding_unknown_falls_back(self):
"""Unrecognized encoding falls back to UTF-8."""
self.assertEqual(
_detect_xml_encoding(b'<?xml version="1.0" encoding="x-fake-codec"?>'),
"utf-8",
) )
path = self._make_file(xml.encode("iso-8859-1"), binary=True)
self.assertEqual(_read_display_name_from_file(path), "Cha\u00eene")
def test_iso_8859_1_with_entities_roundtrip(self): def test_iso_8859_1_with_entities_roundtrip(self):
"""ISO-8859-1 file with entities: resolved without corrupting existing accented chars.""" xml_str = (
# Mix of direct ISO-8859-1 chars and HTML entities '<?xml version="1.0" encoding="ISO-8859-1"?>\n'
xml_str = '<?xml version="1.0" encoding="ISO-8859-1"?>\n<tv><channel><display-name>D\xe9j\xe0 &eacute;mission</display-name></channel></tv>' "<tv><channel><display-name>D\xe9j\xe0 &eacute;mission</display-name></channel></tv>"
fd, path = tempfile.mkstemp(suffix=".xml") )
with os.fdopen(fd, "wb") as f: path = self._make_file(xml_str.encode("iso-8859-1"), binary=True)
f.write(xml_str.encode("iso-8859-1")) name = _read_display_name_from_file(path)
self.addCleanup(lambda: os.unlink(path) if os.path.exists(path) else None) self.assertIn("D\xe9j\xe0", name)
self.assertIn("\xe9mission", name)
_resolve_html_entities(path) def test_existing_doctype_not_doubled(self):
with open(path, "r", encoding="iso-8859-1") as f: """Files that already declare DOCTYPE are opened without injecting another."""
content = f.read() path = self._make_file(
self.assertIn("D\xe9j\xe0", content, "Existing accented chars should be preserved") '<!DOCTYPE tv SYSTEM "xmltv.dtd">\n'
self.assertIn("\xe9mission", content, "Entity should be resolved") "<tv><channel><display-name>Has Doctype</display-name></channel></tv>"
self.assertNotIn("&eacute;", content) )
def test_mismatched_encoding_leaves_file_untouched(self):
"""File declaring UTF-8 but containing Latin-1 bytes is left alone."""
# \xe9 is valid ISO-8859-1 but invalid as a standalone UTF-8 byte
raw = b'<?xml version="1.0" encoding="UTF-8"?>\n<tv><channel><display-name>\xe9</display-name></channel></tv>'
fd, path = tempfile.mkstemp(suffix=".xml")
with os.fdopen(fd, "wb") as f:
f.write(raw)
self.addCleanup(lambda: os.unlink(path) if os.path.exists(path) else None)
original_bytes = raw # save for comparison
_resolve_html_entities(path)
with open(path, "rb") as f: with open(path, "rb") as f:
result_bytes = f.read() original = f.read()
self.assertEqual(result_bytes, original_bytes, "File should be untouched on decode error") with _open_xmltv_file(path) as handle:
self.assertEqual(handle.read(len(original)), original)

View file

@ -0,0 +1,98 @@
from unittest.mock import patch
from django.contrib.auth import get_user_model
from django.db import connection
from django.test.utils import CaptureQueriesContext
from django.test import TestCase
from rest_framework import status
from rest_framework.test import APIClient
from apps.epg.models import EPGSource, EPGSourceIndex
User = get_user_model()
IMPORT_URL = "/api/epg/import/"
class EPGImportAPITests(TestCase):
def setUp(self):
self.user = User.objects.create_user(
username="epg_import_admin", password="testpass123"
)
self.user.user_level = 10
self.user.save()
self.client = APIClient()
self.client.force_authenticate(user=self.user)
@patch("apps.epg.api_views.refresh_epg_data.delay")
def test_import_dummy_source_rejected_without_dispatch(self, mock_delay):
source = EPGSource.objects.create(
name="Dummy EPG",
source_type="dummy",
)
response = self.client.post(
IMPORT_URL, {"id": source.id}, format="json"
)
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
self.assertFalse(response.data["success"])
mock_delay.assert_not_called()
@patch("apps.epg.api_views.refresh_epg_data.delay")
def test_import_xmltv_dispatches_without_loading_programme_index(
self, mock_delay
):
source = EPGSource.objects.create(
name="Large Index XMLTV",
source_type="xmltv",
url="http://example.com/epg.xml",
)
EPGSourceIndex.objects.create(
source=source,
data={
"channels": {f"ch.{i}": {"offsets": [0, 100]} for i in range(200)},
"interleaved_channels": [],
},
)
mock_delay.reset_mock()
with CaptureQueriesContext(connection) as ctx:
response = self.client.post(
IMPORT_URL, {"id": source.id}, format="json"
)
self.assertEqual(response.status_code, status.HTTP_202_ACCEPTED)
self.assertTrue(response.data["success"])
mock_delay.assert_called_once_with(source.id, force=False)
for query in ctx.captured_queries:
self.assertNotIn(
"programme_index",
query["sql"].lower(),
"import trigger should not read programme_index",
)
@patch("apps.epg.api_views.refresh_epg_data.delay")
def test_import_missing_source_still_dispatches(self, mock_delay):
response = self.client.post(IMPORT_URL, {"id": 99999}, format="json")
self.assertEqual(response.status_code, status.HTTP_202_ACCEPTED)
mock_delay.assert_called_once_with(99999, force=False)
@patch("apps.epg.api_views.refresh_epg_data.delay")
def test_import_honours_force_flag(self, mock_delay):
source = EPGSource.objects.create(
name="Force XMLTV",
source_type="xmltv",
url="http://example.com/epg.xml",
)
mock_delay.reset_mock()
response = self.client.post(
IMPORT_URL,
{"id": source.id, "force": True},
format="json",
)
self.assertEqual(response.status_code, status.HTTP_202_ACCEPTED)
mock_delay.assert_called_once_with(source.id, force=True)

View file

@ -7,6 +7,7 @@ from rest_framework import status
from rest_framework.test import APIClient from rest_framework.test import APIClient
from apps.epg.models import EPGData, EPGSource, ProgramData from apps.epg.models import EPGData, EPGSource, ProgramData
from apps.channels.models import Channel
User = get_user_model() User = get_user_model()
@ -63,6 +64,13 @@ class ProgramSearchAPIViewTests(TestCase):
cls.now = now cls.now = now
cls.user = User.objects.create_user(username="testuser", password="pass", user_level=1) cls.user = User.objects.create_user(username="testuser", password="pass", user_level=1)
# Standard users only see programs for EPG entries linked to accessible channels.
Channel.objects.create(
name="Test Channel",
channel_number=1,
epg_data=cls.epg,
user_level=1,
)
def setUp(self): def setUp(self):
self.client = APIClient(REMOTE_ADDR="127.0.0.1") self.client = APIClient(REMOTE_ADDR="127.0.0.1")

View file

@ -0,0 +1,95 @@
from unittest.mock import patch
from django.test import SimpleTestCase, TestCase
from apps.epg.models import EPGSource, EPGData
from apps.epg.tasks import (
_EPG_PARSE_DEFER_MAX,
_EPG_REFRESH_DEFER_SECONDS,
_defer_parse_programs_for_tvg_id,
_refresh_epg_data_impl,
parse_programs_for_tvg_id,
)
class DeferParseProgramsTests(SimpleTestCase):
@patch("apps.epg.tasks.parse_programs_for_tvg_id.apply_async")
def test_defer_schedules_retry_for_refresh(self, mock_apply_async):
result = _defer_parse_programs_for_tvg_id(5, False, 0, "source refresh in progress")
self.assertEqual(result, "Deferred")
mock_apply_async.assert_called_once_with(
args=[5],
kwargs={"force": False, "_defer_retry": 1},
countdown=_EPG_REFRESH_DEFER_SECONDS,
)
@patch("apps.epg.tasks.parse_programs_for_tvg_id.apply_async")
def test_defer_gives_up_after_max_retries(self, mock_apply_async):
result = _defer_parse_programs_for_tvg_id(
5, False, _EPG_PARSE_DEFER_MAX, "source refresh in progress"
)
self.assertIn("Deferred too many times", result)
mock_apply_async.assert_not_called()
class ParseProgramsForTvgIdLockTests(TestCase):
def setUp(self):
self.source = EPGSource.objects.create(
name="Lock Test",
source_type="xmltv",
file_path="/tmp/unused.xml",
)
self.epg = EPGData.objects.create(
tvg_id="test.channel",
name="Test",
epg_source=self.source,
)
@patch("apps.epg.tasks.parse_programs_for_tvg_id.apply_async")
@patch("apps.epg.tasks.is_task_lock_held", return_value=True)
def test_defers_while_source_refresh_running(self, mock_held, mock_apply_async):
result = parse_programs_for_tvg_id(self.epg.id)
self.assertEqual(result, "Deferred")
mock_held.assert_called_once_with("refresh_epg_data", self.source.id)
mock_apply_async.assert_called_once()
@patch("apps.epg.tasks._decr_source_tvg_parse_count")
@patch("apps.epg.tasks._incr_source_tvg_parse_count")
@patch("apps.epg.tasks.acquire_task_lock", return_value=False)
@patch("apps.epg.tasks.is_task_lock_held", return_value=False)
def test_skips_when_duplicate_epg_parse_running(
self, mock_held, mock_acquire, mock_incr, mock_decr
):
result = parse_programs_for_tvg_id(self.epg.id)
self.assertEqual(result, "Task already running")
mock_acquire.assert_called_once_with("parse_epg_programs", self.epg.id)
mock_incr.assert_called_once_with(self.source.id)
mock_decr.assert_called_once_with(self.source.id)
class RefreshEpgDataDeferTests(TestCase):
def setUp(self):
self.source = EPGSource.objects.create(
name="Refresh Defer Test",
source_type="xmltv",
file_path="/tmp/unused.xml",
)
@patch("apps.epg.tasks.refresh_epg_data.apply_async")
@patch("apps.epg.tasks.fetch_xmltv")
@patch("apps.epg.tasks._source_tvg_parse_count", return_value=1)
def test_refresh_defers_while_per_channel_parses_running(
self, mock_count, mock_fetch, mock_apply_async
):
_refresh_epg_data_impl(self.source.id)
mock_fetch.assert_not_called()
mock_apply_async.assert_called_once_with(
args=[self.source.id],
kwargs={"force": False, "_file_defer_retry": 1},
countdown=_EPG_REFRESH_DEFER_SECONDS,
)

View file

@ -0,0 +1,73 @@
"""End-to-end coverage for fetch_xmltv() against a local xz-compressed
XMLTV file (no URL), mirroring FetchM3uLinesXzUploadTests in
apps/m3u/tests/test_xz_playlist.py.
fetch_xmltv is mocked everywhere else in the suite, so this exercises the
local-file-with-no-url branch (apps/epg/tasks.py fetch_xmltv) end to end:
detection, extraction via extract_compressed_file(), and the
extracted_file_path bookkeeping - with no mocking beyond what's needed to
keep the test hermetic (Celery Beat periodic task scheduling touches the
django_celery_beat tables in ways unrelated to this behavior).
"""
import lzma
import os
import tempfile
from django.test import TestCase
from apps.epg.models import EPGSource
from apps.epg.tasks import fetch_xmltv
SAMPLE_XML = (
'<?xml version="1.0" encoding="UTF-8"?>\n'
"<tv>\n"
' <channel id="xz.channel"/>\n'
' <programme channel="xz.channel" '
'start="20000101000000 +0000" stop="20991231235959 +0000">\n'
" <title>XZ Title</title>\n"
" </programme>\n"
"</tv>\n"
)
class FetchXmltvXzLocalFileTests(TestCase):
def setUp(self):
self.xz_path = None
self.source = None
def tearDown(self):
if self.xz_path and os.path.exists(self.xz_path):
os.unlink(self.xz_path)
if (
self.source
and self.source.extracted_file_path
and os.path.exists(self.source.extracted_file_path)
):
os.unlink(self.source.extracted_file_path)
def test_fetch_xmltv_extracts_local_xz_file(self):
with tempfile.NamedTemporaryFile(suffix=".xz", delete=False) as xz_file:
self.xz_path = xz_file.name
xz_file.write(lzma.compress(SAMPLE_XML.encode("utf-8")))
self.source = EPGSource.objects.create(
name="XZ local file source",
source_type="xmltv",
file_path=self.xz_path,
)
result = fetch_xmltv(self.source)
self.assertTrue(result)
self.source.refresh_from_db()
self.assertEqual(self.source.status, EPGSource.STATUS_SUCCESS)
self.assertTrue(self.source.extracted_file_path)
self.assertTrue(os.path.exists(self.source.extracted_file_path))
with open(self.source.extracted_file_path, "r", encoding="utf-8") as f:
self.assertEqual(f.read(), SAMPLE_XML)
# The original compressed file is kept - fetch_xmltv extracts
# without deleting the source upload.
self.assertTrue(os.path.exists(self.xz_path))

View file

@ -0,0 +1,274 @@
import os
import tempfile
from datetime import timedelta
from unittest.mock import patch
from django.db import connection, transaction
from django.test import TestCase
from django.utils import timezone
from apps.channels.models import Channel
from apps.epg.models import EPGSource, EPGData, ProgramData
from apps.epg.tasks import (
parse_programs_for_source,
_flush_epg_program_staging_batch,
_swap_staged_epg_programs,
_delete_orphaned_epg_programs,
_dispatch_late_mapped_epg_parses,
_EPG_PARSE_BATCH_SIZE,
)
def _programme_xml(channel_id, title, start, stop):
return (
f' <programme start="{start}" stop="{stop}" channel="{channel_id}">\n'
f' <title>{title}</title>\n'
f' </programme>\n'
)
def _xmltv_file(programmes):
body = (
'<?xml version="1.0" encoding="UTF-8"?>\n'
'<tv generator-info-name="test">\n'
f'{programmes}'
'</tv>\n'
)
handle = tempfile.NamedTemporaryFile(
mode='w',
suffix='.xml',
delete=False,
encoding='utf-8',
)
handle.write(body)
handle.close()
return handle.name
class ParseProgramsForSourceTests(TestCase):
def setUp(self):
self.source = EPGSource.objects.create(
name='XMLTV Parse Test',
source_type='xmltv',
)
self.mapped_epg = EPGData.objects.create(
epg_source=self.source,
tvg_id='mapped.channel',
name='Mapped Channel',
)
self.unmapped_epg = EPGData.objects.create(
epg_source=self.source,
tvg_id='unmapped.channel',
name='Unmapped Channel',
)
Channel.objects.create(
channel_number=1,
name='Mapped Channel',
epg_data=self.mapped_epg,
)
self.base_time = timezone.now().replace(minute=0, second=0, microsecond=0)
self.start = self.base_time.strftime('%Y%m%d%H%M%S +0000')
self.stop = (self.base_time + timedelta(hours=1)).strftime('%Y%m%d%H%M%S +0000')
def tearDown(self):
if getattr(self, 'xml_path', None) and os.path.exists(self.xml_path):
os.unlink(self.xml_path)
def _configure_source_file(self, programmes):
self.xml_path = _xmltv_file(programmes)
self.source.file_path = self.xml_path
self.source.save(update_fields=['file_path'])
@patch('apps.epg.tasks.log_system_event')
@patch('apps.epg.tasks.send_epg_update')
def test_replaces_programs_for_mapped_channels(self, _send_update, _log_event):
old_start = self.base_time - timedelta(days=1)
ProgramData.objects.create(
epg=self.mapped_epg,
start_time=old_start,
end_time=old_start + timedelta(hours=1),
title='Old Programme',
tvg_id=self.mapped_epg.tvg_id,
)
orphan_start = self.base_time - timedelta(days=1)
ProgramData.objects.create(
epg=self.unmapped_epg,
start_time=orphan_start,
end_time=orphan_start + timedelta(hours=1),
title='Orphan Programme',
tvg_id=self.unmapped_epg.tvg_id,
)
programmes = (
_programme_xml('mapped.channel', 'New Show', self.start, self.stop)
+ _programme_xml('unmapped.channel', 'Skipped Show', self.start, self.stop)
)
self._configure_source_file(programmes)
result = parse_programs_for_source(self.source)
self.assertTrue(result)
mapped_programs = ProgramData.objects.filter(epg=self.mapped_epg)
self.assertEqual(mapped_programs.count(), 1)
self.assertEqual(mapped_programs.get().title, 'New Show')
self.assertFalse(ProgramData.objects.filter(epg=self.unmapped_epg).exists())
@patch('apps.epg.tasks.log_system_event')
@patch('apps.epg.tasks.send_epg_update')
def test_atomic_failure_rolls_back_and_preserves_existing_programs(self, _send_update, _log_event):
old_start = self.base_time - timedelta(days=1)
ProgramData.objects.create(
epg=self.mapped_epg,
start_time=old_start,
end_time=old_start + timedelta(hours=1),
title='Keep Me',
tvg_id=self.mapped_epg.tvg_id,
)
self._configure_source_file(
_programme_xml('mapped.channel', 'Replacement', self.start, self.stop)
)
swap_path = (
'apps.epg.tasks._swap_staged_epg_programs'
if connection.vendor == 'postgresql'
else 'apps.epg.tasks._swap_parsed_epg_programs'
)
with patch(swap_path, side_effect=RuntimeError('simulated insert failure')):
result = parse_programs_for_source(self.source)
self.assertFalse(result)
self.assertEqual(ProgramData.objects.filter(epg=self.mapped_epg).count(), 1)
self.assertEqual(
ProgramData.objects.get(epg=self.mapped_epg).title,
'Keep Me',
)
@patch('apps.epg.tasks.log_system_event')
@patch('apps.epg.tasks.send_epg_update')
def test_streams_batches_without_holding_full_program_list(self, _send_update, _log_event):
if connection.vendor != 'postgresql':
self.skipTest('PostgreSQL staging batches are required for this assertion')
programme_count = _EPG_PARSE_BATCH_SIZE * 2
programmes = ''.join(
_programme_xml(
'mapped.channel',
f'Show {idx}',
self.start,
self.stop,
)
for idx in range(programme_count)
)
self._configure_source_file(programmes)
flush_sizes = []
original_flush = _flush_epg_program_staging_batch
def tracking_flush(batch):
flush_sizes.append(len(batch))
return original_flush(batch)
with patch('apps.epg.tasks._flush_epg_program_staging_batch', side_effect=tracking_flush):
result = parse_programs_for_source(self.source)
self.assertTrue(result)
self.assertEqual(ProgramData.objects.filter(epg=self.mapped_epg).count(), programme_count)
self.assertEqual(sum(flush_sizes), programme_count)
self.assertTrue(all(size <= _EPG_PARSE_BATCH_SIZE for size in flush_sizes))
self.assertGreater(len(flush_sizes), 1)
@patch('apps.epg.tasks.log_system_event')
@patch('apps.epg.tasks.send_epg_update')
def test_live_programs_remain_until_swap_commits(self, _send_update, _log_event):
if connection.vendor != 'postgresql':
self.skipTest('PostgreSQL staging swap is required for this assertion')
old_start = self.base_time - timedelta(days=1)
ProgramData.objects.create(
epg=self.mapped_epg,
start_time=old_start,
end_time=old_start + timedelta(hours=1),
title='Old Programme',
tvg_id=self.mapped_epg.tvg_id,
)
self._configure_source_file(
_programme_xml('mapped.channel', 'New Show', self.start, self.stop)
)
observed_titles_at_swap = []
def swap_with_visibility_check(mapped_epg_ids, epg_source, *args, **kwargs):
observed_titles_at_swap.append(
ProgramData.objects.get(epg=self.mapped_epg).title
)
return _swap_staged_epg_programs(mapped_epg_ids, epg_source, *args, **kwargs)
with patch('apps.epg.tasks._swap_staged_epg_programs', side_effect=swap_with_visibility_check):
result = parse_programs_for_source(self.source)
self.assertTrue(result)
self.assertEqual(observed_titles_at_swap, ['Old Programme'])
self.assertEqual(ProgramData.objects.get(epg=self.mapped_epg).title, 'New Show')
@patch('apps.epg.tasks.log_system_event')
@patch('apps.epg.tasks.send_epg_update')
def test_swap_delete_is_rolled_back_when_insert_fails(self, _send_update, _log_event):
if connection.vendor != 'postgresql':
self.skipTest('PostgreSQL staging swap is required for this assertion')
old_start = self.base_time - timedelta(days=1)
ProgramData.objects.create(
epg=self.mapped_epg,
start_time=old_start,
end_time=old_start + timedelta(hours=1),
title='Keep Me',
tvg_id=self.mapped_epg.tvg_id,
)
self._configure_source_file(
_programme_xml('mapped.channel', 'Replacement', self.start, self.stop)
)
def failing_swap(mapped_epg_ids, epg_source, *args, **kwargs):
with transaction.atomic():
ProgramData.objects.filter(epg_id__in=mapped_epg_ids).delete()
raise RuntimeError('simulated insert failure')
with patch('apps.epg.tasks._swap_staged_epg_programs', side_effect=failing_swap):
result = parse_programs_for_source(self.source)
self.assertFalse(result)
self.assertEqual(ProgramData.objects.get(epg=self.mapped_epg).title, 'Keep Me')
def test_orphan_cleanup_respects_channels_mapped_during_bulk_parse(self):
late_start = self.base_time - timedelta(days=1)
ProgramData.objects.create(
epg=self.unmapped_epg,
start_time=late_start,
end_time=late_start + timedelta(hours=1),
title='Late Match Programme',
tvg_id=self.unmapped_epg.tvg_id,
)
Channel.objects.create(
channel_number=2,
name='Late Mapped Channel',
epg_data=self.unmapped_epg,
)
deleted = _delete_orphaned_epg_programs(self.source)
self.assertEqual(deleted, 0)
self.assertEqual(ProgramData.objects.filter(epg=self.unmapped_epg).count(), 1)
@patch('apps.epg.tasks.dispatch_program_refresh_for_epg_ids', return_value=1)
def test_late_mapped_dispatches_per_channel_parse(self, mock_dispatch):
Channel.objects.create(
channel_number=2,
name='Late Mapped Channel',
epg_data=self.unmapped_epg,
)
bulk_snapshot = {self.mapped_epg.id}
dispatched = _dispatch_late_mapped_epg_parses(self.source, bulk_snapshot)
self.assertEqual(dispatched, 1)
mock_dispatch.assert_called_once_with({self.unmapped_epg.id})

View file

@ -0,0 +1,138 @@
import os
import tempfile
from datetime import timedelta
from unittest.mock import patch
from django.test import TestCase
from django.utils import timezone
from apps.channels.models import Channel
from apps.epg.models import EPGSource, EPGData, ProgramData
from apps.epg.tasks import parse_programs_for_tvg_id
def _programme_xml(channel_id, title, start, stop):
return (
f' <programme start="{start}" stop="{stop}" channel="{channel_id}">\n'
f' <title>{title}</title>\n'
f' </programme>\n'
)
def _xmltv_file(programmes):
body = (
'<?xml version="1.0" encoding="UTF-8"?>\n'
'<tv generator-info-name="test">\n'
f'{programmes}'
'</tv>\n'
)
handle = tempfile.NamedTemporaryFile(
mode='w',
suffix='.xml',
delete=False,
encoding='utf-8',
)
handle.write(body)
handle.close()
return handle.name
class ParseProgramsForTvgIdSwapTests(TestCase):
def setUp(self):
self._lock_patches = [
patch('apps.epg.tasks.is_task_lock_held', return_value=False),
patch('apps.epg.tasks.acquire_task_lock', return_value=True),
patch('apps.epg.tasks.release_task_lock'),
]
for p in self._lock_patches:
p.start()
self.source = EPGSource.objects.create(
name='Per-Channel Parse Test',
source_type='xmltv',
)
self.epg = EPGData.objects.create(
epg_source=self.source,
tvg_id='test.channel',
name='Test Channel',
)
self.channel = Channel.objects.create(
channel_number=1,
name='Test Channel',
epg_data=self.epg,
)
self.base_time = timezone.now().replace(minute=0, second=0, microsecond=0)
self.start = self.base_time.strftime('%Y%m%d%H%M%S +0000')
self.stop = (self.base_time + timedelta(hours=1)).strftime('%Y%m%d%H%M%S +0000')
def tearDown(self):
for p in self._lock_patches:
p.stop()
if getattr(self, 'xml_path', None) and os.path.exists(self.xml_path):
os.unlink(self.xml_path)
def _configure_source_file(self, programmes):
self.xml_path = _xmltv_file(programmes)
self.source.file_path = self.xml_path
self.source.save(update_fields=['file_path'])
def test_replaces_programs_for_channel(self):
old_start = self.base_time - timedelta(days=1)
ProgramData.objects.create(
epg=self.epg,
start_time=old_start,
end_time=old_start + timedelta(hours=1),
title='Old Programme',
tvg_id=self.epg.tvg_id,
)
self._configure_source_file(
_programme_xml('test.channel', 'New Show', self.start, self.stop)
)
parse_programs_for_tvg_id(self.epg.id)
programs = ProgramData.objects.filter(epg=self.epg)
self.assertEqual(programs.count(), 1)
self.assertEqual(programs.get().title, 'New Show')
def test_failed_insert_preserves_existing_programs(self):
"""A failed atomic swap must not leave the channel with no guide data."""
old_start = self.base_time - timedelta(days=1)
ProgramData.objects.create(
epg=self.epg,
start_time=old_start,
end_time=old_start + timedelta(hours=1),
title='Keep Me',
tvg_id=self.epg.tvg_id,
)
self._configure_source_file(
_programme_xml('test.channel', 'Replacement', self.start, self.stop)
)
with patch(
'apps.epg.tasks.ProgramData.objects.bulk_create',
side_effect=RuntimeError('simulated insert failure'),
):
with self.assertRaises(RuntimeError):
parse_programs_for_tvg_id(self.epg.id)
remaining = ProgramData.objects.filter(epg=self.epg)
self.assertEqual(remaining.count(), 1)
self.assertEqual(remaining.get().title, 'Keep Me')
def test_does_not_refetch_epg_data_mid_task(self):
"""The task must reuse the EPGData row loaded at task start."""
self._configure_source_file(
_programme_xml('test.channel', 'New Show', self.start, self.stop)
)
with patch(
'apps.epg.tasks.EPGData.objects.get',
side_effect=AssertionError('should not re-fetch EPGData mid-task'),
) as mock_get:
parse_programs_for_tvg_id(self.epg.id)
mock_get.assert_not_called()
self.assertEqual(
ProgramData.objects.filter(epg=self.epg).get().title, 'New Show'
)

View file

@ -0,0 +1,44 @@
from django.test import SimpleTestCase
from apps.epg.tasks import (
_CHANNEL_PARSE_PROGRESS_CAP,
_CHANNEL_PARSE_PROGRESS_START,
_channel_parse_progress,
)
class ChannelParseProgressTests(SimpleTestCase):
def test_exceeding_estimate_recalculates_instead_of_going_over_cap(self):
"""101/100 used to yield ~90%+; now bumps estimate to 101 so ratio stays <= 1."""
progress, estimate = _channel_parse_progress(100, 100, had_db_baseline=True)
self.assertEqual(estimate, 100)
self.assertLessEqual(progress, _CHANNEL_PARSE_PROGRESS_CAP)
progress, estimate = _channel_parse_progress(101, 100, had_db_baseline=True)
self.assertEqual(estimate, 101)
self.assertLessEqual(progress, _CHANNEL_PARSE_PROGRESS_CAP)
self.assertGreater(progress, 90)
def test_large_xml_growth_never_exceeds_cap(self):
"""Previously 425 channels vs DB estimate of 100 could show ~300%."""
estimate = 100
for processed in (100, 200, 300, 425):
progress, estimate = _channel_parse_progress(
processed, estimate, had_db_baseline=True
)
self.assertLessEqual(progress, _CHANNEL_PARSE_PROGRESS_CAP)
def test_first_import_crawls_instead_of_flat_ninety(self):
progress, estimate = _channel_parse_progress(100, 0, had_db_baseline=False)
self.assertEqual(estimate, 0)
self.assertEqual(progress, _CHANNEL_PARSE_PROGRESS_START + 1)
self.assertLess(progress, 90)
progress, _ = _channel_parse_progress(5000, 0, had_db_baseline=False)
self.assertLess(progress, _CHANNEL_PARSE_PROGRESS_CAP)
def test_partial_progress_when_below_estimate(self):
progress, estimate = _channel_parse_progress(50, 100, had_db_baseline=True)
self.assertEqual(estimate, 100)
self.assertGreater(progress, _CHANNEL_PARSE_PROGRESS_START)
self.assertLess(progress, _CHANNEL_PARSE_PROGRESS_CAP)

View file

@ -1,7 +1,7 @@
import os import os
import gzip import gzip
import tempfile import tempfile
from unittest.mock import patch, MagicMock from unittest.mock import patch
from django.test import TestCase from django.test import TestCase
from django.utils import timezone from django.utils import timezone
@ -11,6 +11,7 @@ from apps.epg.tasks import (
find_current_program_for_tvg_id, find_current_program_for_tvg_id,
build_programme_index, build_programme_index,
build_programme_index_task, build_programme_index_task,
_EPG_TVG_PARSE_DEFER_SECONDS,
) )
FIXTURE_DIR = os.path.join(os.path.dirname(__file__), "fixtures") FIXTURE_DIR = os.path.join(os.path.dirname(__file__), "fixtures")
@ -519,7 +520,6 @@ class FindCurrentProgramTests(TestCase):
name="No Index", name="No Index",
source_type="xmltv", source_type="xmltv",
file_path=FIXTURE_XML, file_path=FIXTURE_XML,
programme_index=None,
) )
epg = EPGData.objects.create( epg = EPGData.objects.create(
tvg_id="channel.current", tvg_id="channel.current",
@ -664,38 +664,40 @@ class BuildProgrammeIndexTests(TestCase):
build_programme_index(99999) build_programme_index(99999)
@patch("apps.epg.tasks.build_programme_index") @patch("apps.epg.tasks.build_programme_index")
def test_task_builds_and_releases_lock_when_free(self, mock_build): @patch("apps.epg.tasks.release_task_lock")
mock_redis = MagicMock() @patch("apps.epg.tasks.acquire_task_lock", return_value=True)
mock_redis.set.return_value = True # lock acquired def test_task_builds_and_releases_lock_when_free(
with patch("core.utils.RedisClient.get_client", return_value=mock_redis): self, mock_acquire, mock_release, mock_build
build_programme_index_task(42) ):
build_programme_index_task(42)
mock_build.assert_called_once_with(42) mock_build.assert_called_once_with(42)
mock_redis.set.assert_called_once() mock_acquire.assert_called_once_with("epg_source_file", 42)
self.assertEqual( mock_release.assert_called_once_with("epg_source_file", 42)
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") @patch("apps.epg.tasks.build_programme_index")
def test_task_skips_when_lock_held(self, mock_build): @patch("apps.epg.tasks.acquire_task_lock", return_value=False)
mock_redis = MagicMock() def test_task_defers_when_lock_held(self, mock_acquire, mock_build):
mock_redis.set.return_value = False # another build in flight with patch("apps.epg.tasks.build_programme_index_task.apply_async") as mock_apply:
with patch("core.utils.RedisClient.get_client", return_value=mock_redis):
build_programme_index_task(42) build_programme_index_task(42)
mock_build.assert_not_called() mock_build.assert_not_called()
mock_redis.delete.assert_not_called() mock_apply.assert_called_once_with(
args=[42],
kwargs={'_defer_retry': 1},
countdown=_EPG_TVG_PARSE_DEFER_SECONDS,
)
@patch("apps.epg.tasks.build_programme_index", side_effect=RuntimeError("boom")) @patch("apps.epg.tasks.build_programme_index", side_effect=RuntimeError("boom"))
def test_task_releases_lock_on_failure(self, mock_build): @patch("apps.epg.tasks.release_task_lock")
mock_redis = MagicMock() @patch("apps.epg.tasks.acquire_task_lock", return_value=True)
mock_redis.set.return_value = True def test_task_releases_lock_on_failure(
with patch("core.utils.RedisClient.get_client", return_value=mock_redis): self, mock_acquire, mock_release, mock_build
with self.assertRaises(RuntimeError): ):
build_programme_index_task(42) with self.assertRaises(RuntimeError):
build_programme_index_task(42)
mock_redis.delete.assert_called_once_with("building_programme_index_42") mock_release.assert_called_once_with("epg_source_file", 42)
def test_per_channel_interleaved_marking(self): def test_per_channel_interleaved_marking(self):
xml = ( xml = (

View file

@ -0,0 +1,106 @@
from unittest.mock import MagicMock, patch
from django.test import SimpleTestCase
from apps.epg.tasks import (
_db_query_with_retry,
_ensure_epg_refresh_terminal_status,
_get_epg_source,
_release_task_db_connection,
refresh_epg_data,
)
class DbQueryWithRetryTests(SimpleTestCase):
def test_retries_after_index_error_from_poisoned_connection(self):
fn = MagicMock(side_effect=[IndexError("list index out of range"), "ok"])
with patch(
"apps.epg.tasks._release_task_db_connection"
) as mock_release:
result = _db_query_with_retry(fn, label="test query")
self.assertEqual(result, "ok")
self.assertEqual(fn.call_count, 2)
mock_release.assert_called_once()
def test_raises_after_exhausting_retries(self):
fn = MagicMock(side_effect=IndexError("list index out of range"))
with patch("apps.epg.tasks._release_task_db_connection"):
with self.assertRaises(IndexError):
_db_query_with_retry(fn, label="test query", max_retries=2)
self.assertEqual(fn.call_count, 2)
class RefreshTaskDbStartupTests(SimpleTestCase):
@patch("apps.epg.tasks._ensure_epg_refresh_terminal_status")
@patch("apps.epg.tasks._refresh_epg_data_impl")
@patch("apps.epg.tasks.release_task_lock")
@patch("apps.epg.tasks.acquire_task_lock", return_value=True)
@patch("apps.epg.tasks.TaskLockRenewer")
@patch("apps.epg.tasks._release_task_db_connection")
def test_refresh_releases_db_connection_before_impl(
self,
mock_release,
_mock_renewer,
_mock_acquire,
_mock_release_lock,
mock_impl,
_mock_ensure_terminal,
):
call_order = []
def track_release():
call_order.append("release")
mock_release.side_effect = track_release
mock_impl.side_effect = lambda *_a, **_k: call_order.append("impl") or "done"
result = refresh_epg_data(42)
self.assertEqual(result, "done")
self.assertEqual(call_order[:2], ["release", "impl"])
@patch("apps.epg.tasks.EPGSource")
def test_get_epg_source_uses_retry_helper(self, mock_model):
mock_source = MagicMock(id=42)
mock_model.objects.get.return_value = mock_source
with patch("apps.epg.tasks._db_query_with_retry") as mock_retry:
mock_retry.side_effect = lambda fn, **_: fn()
source = _get_epg_source(42)
mock_retry.assert_called_once()
mock_model.objects.get.assert_called_once_with(id=42)
self.assertIs(source, mock_source)
class EnsureEpgTerminalStatusTests(SimpleTestCase):
@patch("apps.epg.tasks.send_epg_update")
@patch("apps.epg.tasks._release_task_db_connection")
def test_marks_stuck_fetching_as_error(self, _mock_release, mock_ws):
with patch("apps.epg.tasks.EPGSource") as mock_model:
mock_model.STATUS_ERROR = "error"
qs = MagicMock()
mock_model.objects.filter.return_value = qs
qs.values_list.return_value.first.return_value = "fetching"
_ensure_epg_refresh_terminal_status(7)
qs.update.assert_called_once()
mock_ws.assert_called_once()
@patch("apps.epg.tasks.send_epg_update")
@patch("apps.epg.tasks._release_task_db_connection")
def test_leaves_success_unchanged(self, _mock_release, mock_ws):
with patch("apps.epg.tasks.EPGSource") as mock_model:
qs = MagicMock()
mock_model.objects.filter.return_value = qs
qs.values_list.return_value.first.return_value = "success"
_ensure_epg_refresh_terminal_status(7)
qs.update.assert_not_called()
mock_ws.assert_not_called()

File diff suppressed because it is too large Load diff

View file

@ -100,27 +100,38 @@ class ProgramDataSerializerTests(TestCase):
self.assertEqual(set(data.keys()), expected_fields) self.assertEqual(set(data.keys()), expected_fields)
def test_season_episode_from_onscreen_episode(self): def test_season_episode_from_onscreen_episode(self):
"""Season and episode should be parsed from onscreen_episode string.""" """Serializer reads precomputed season/episode from custom_properties (set at import)."""
program = self._create_program( program = self._create_program(
custom_properties={"onscreen_episode": "S12 E6"} custom_properties={
"onscreen_episode": "S12 E6",
"season": 12,
"episode": 6,
}
) )
data = ProgramDataSerializer(program).data data = ProgramDataSerializer(program).data
self.assertEqual(data["season"], 12) self.assertEqual(data["season"], 12)
self.assertEqual(data["episode"], 6) self.assertEqual(data["episode"], 6)
def test_onscreen_episode_no_space(self): def test_onscreen_episode_no_space(self):
"""Should parse onscreen_episode without space between S and E.""" """Precomputed S/E in custom_properties is exposed by the serializer."""
program = self._create_program( program = self._create_program(
custom_properties={"onscreen_episode": "S3E21"} custom_properties={
"onscreen_episode": "S3E21",
"season": 3,
"episode": 21,
}
) )
data = ProgramDataSerializer(program).data data = ProgramDataSerializer(program).data
self.assertEqual(data["season"], 3) self.assertEqual(data["season"], 3)
self.assertEqual(data["episode"], 21) self.assertEqual(data["episode"], 21)
def test_onscreen_episode_with_part(self): def test_onscreen_episode_with_part(self):
"""Should parse season/episode even when part info follows."""
program = self._create_program( program = self._create_program(
custom_properties={"onscreen_episode": "S8 E8 P2/2"} custom_properties={
"onscreen_episode": "S8 E8 P2/2",
"season": 8,
"episode": 8,
}
) )
data = ProgramDataSerializer(program).data data = ProgramDataSerializer(program).data
self.assertEqual(data["season"], 8) self.assertEqual(data["season"], 8)
@ -139,7 +150,7 @@ class ProgramDataSerializerTests(TestCase):
self.assertEqual(data["episode"], 2) self.assertEqual(data["episode"], 2)
def test_onscreen_episode_invalid_format(self): def test_onscreen_episode_invalid_format(self):
"""Should return None for onscreen_episode that doesn't match S/E pattern.""" """Without precomputed S/E keys, serializer returns None."""
program = self._create_program( program = self._create_program(
custom_properties={"onscreen_episode": "Episode 5"} custom_properties={"onscreen_episode": "Episode 5"}
) )
@ -433,7 +444,7 @@ class ExtractSeasonEpisodeFromDescriptionTests(TestCase):
class ProgramDataSerializerDescriptionFallbackTests(TestCase): class ProgramDataSerializerDescriptionFallbackTests(TestCase):
"""Integration tests: serializer uses description fallback for S/E.""" """Serializer does not parse description at request time; import precomputes S/E."""
def setUp(self): def setUp(self):
self.epg_source = EPGSource.objects.create( self.epg_source = EPGSource.objects.create(
@ -460,8 +471,8 @@ class ProgramDataSerializerDescriptionFallbackTests(TestCase):
description="S2 E5 The Episode Title", description="S2 E5 The Episode Title",
) )
data = ProgramDataSerializer(program).data data = ProgramDataSerializer(program).data
self.assertEqual(data["season"], 2) self.assertIsNone(data["season"])
self.assertEqual(data["episode"], 5) self.assertIsNone(data["episode"])
def test_se_from_description_not_used_when_cp_has_values(self): def test_se_from_description_not_used_when_cp_has_values(self):
program = self._create_program( program = self._create_program(
@ -473,6 +484,34 @@ class ProgramDataSerializerDescriptionFallbackTests(TestCase):
self.assertEqual(data["episode"], 1) self.assertEqual(data["episode"], 1)
class SDPosterProxyPathTests(TestCase):
"""Cache-bust query on SD poster proxy URLs."""
def test_stable_uri_stable_bust(self):
from apps.epg.utils import sd_poster_cache_bust, sd_poster_proxy_path
uri = 'https://json.schedulesdirect.org/20141201/image/assets/a.jpg'
self.assertEqual(sd_poster_cache_bust(uri), sd_poster_cache_bust(uri))
path = sd_poster_proxy_path(42, uri)
self.assertTrue(path.startswith('/api/epg/programs/42/poster/?v='))
self.assertIn(sd_poster_cache_bust(uri), path)
def test_uri_change_changes_bust(self):
from apps.epg.utils import sd_poster_cache_bust
a = 'https://json.schedulesdirect.org/20141201/image/assets/a.jpg'
b = 'https://json.schedulesdirect.org/20141201/image/assets/b.jpg'
self.assertNotEqual(sd_poster_cache_bust(a), sd_poster_cache_bust(b))
def test_empty_uri_has_no_query(self):
from apps.epg.utils import sd_poster_proxy_path
self.assertEqual(
sd_poster_proxy_path(7, None),
'/api/epg/programs/7/poster/',
)
class ProgramDetailSerializerTests(TestCase): class ProgramDetailSerializerTests(TestCase):
"""Tests for ProgramDetailSerializer — rich field extraction from custom_properties.""" """Tests for ProgramDetailSerializer — rich field extraction from custom_properties."""
@ -506,6 +545,8 @@ class ProgramDetailSerializerTests(TestCase):
"credits", "video_quality", "aspect_ratio", "stereo", "is_previously_shown", "credits", "video_quality", "aspect_ratio", "stereo", "is_previously_shown",
"country", "language", "production_date", "original_air_date", "country", "language", "production_date", "original_air_date",
"imdb_id", "tmdb_id", "tvdb_id", "icon", "images", "imdb_id", "tmdb_id", "tvdb_id", "icon", "images",
"poster_url", "content_advisory", "content_ratings", "event_details",
"runtime", "runtime_units",
} }
self.assertEqual(set(data.keys()), expected_fields) self.assertEqual(set(data.keys()), expected_fields)
@ -631,9 +672,13 @@ class ProgramDetailSerializerTests(TestCase):
self.assertEqual(data["images"], []) self.assertEqual(data["images"], [])
def test_season_episode_uses_shared_helper(self): def test_season_episode_uses_shared_helper(self):
"""Detail serializer should use the same onscreen_episode fallback.""" """Detail serializer reads precomputed S/E from custom_properties."""
program = self._create_program( program = self._create_program(
custom_properties={"onscreen_episode": "S5E14"} custom_properties={
"onscreen_episode": "S5E14",
"season": 5,
"episode": 14,
}
) )
data = ProgramDetailSerializer(program).data data = ProgramDetailSerializer(program).data
self.assertEqual(data["season"], 5) self.assertEqual(data["season"], 5)
@ -653,3 +698,16 @@ class ProgramDetailSerializerTests(TestCase):
self.assertEqual(slim["is_live"], detail["is_live"]) self.assertEqual(slim["is_live"], detail["is_live"])
self.assertEqual(slim["is_premiere"], detail["is_premiere"]) self.assertEqual(slim["is_premiere"], detail["is_premiere"])
self.assertEqual(slim["is_finale"], detail["is_finale"]) self.assertEqual(slim["is_finale"], detail["is_finale"])
def test_poster_url_includes_cache_bust_from_sd_icon(self):
from apps.epg.utils import sd_poster_proxy_path
uri = 'https://json.schedulesdirect.org/20141201/image/assets/p.jpg'
program = self._create_program(custom_properties={"sd_icon": uri})
data = ProgramDetailSerializer(program).data
self.assertEqual(data["poster_url"], sd_poster_proxy_path(program.id, uri))
def test_poster_url_none_without_sd_icon(self):
program = self._create_program(custom_properties={})
data = ProgramDetailSerializer(program).data
self.assertIsNone(data["poster_url"])

View file

@ -0,0 +1,49 @@
"""EPG upload path safety and admin-only permission."""
from io import BytesIO
from unittest.mock import patch
from django.contrib.auth import get_user_model
from django.core.files.uploadedfile import SimpleUploadedFile
from django.test import TestCase
from rest_framework.test import APIRequestFactory, force_authenticate
from apps.epg.api_views import EPGSourceViewSet
class EPGUploadSecurityTests(TestCase):
def setUp(self):
User = get_user_model()
self.admin = User.objects.create_user(
username="epg_upload_admin",
password="x",
user_level=User.UserLevel.ADMIN,
)
self.standard = User.objects.create_user(
username="epg_upload_user",
password="x",
user_level=User.UserLevel.STANDARD,
)
self.factory = APIRequestFactory()
def _upload(self, user, filename="guide.xml"):
content = SimpleUploadedFile(
filename, b"<?xml version='1.0'?><tv></tv>", content_type="application/xml"
)
request = self.factory.post(
"/api/epg/sources/upload/",
{"file": content, "name": "Uploaded", "source_type": "xmltv"},
format="multipart",
)
force_authenticate(request, user=user)
view = EPGSourceViewSet.as_view({"post": "upload"})
return view(request)
def test_standard_user_forbidden(self):
response = self._upload(self.standard)
self.assertEqual(response.status_code, 403)
@patch("apps.epg.api_views.safe_upload_path", side_effect=ValueError("bad"))
def test_traversal_filename_rejected(self, _mock):
response = self._upload(self.admin, filename="../../evil.xml")
self.assertEqual(response.status_code, 400)
self.assertIn("Invalid filename", response.data.get("error", ""))

View file

@ -0,0 +1,291 @@
"""Tests for native xz (.xz) support in EPG source ingestion.
Covers the three dispatch points that previously only recognized gzip/zip:
- detect_file_format() format sniffing (magic bytes, extension, mimetype)
- extract_compressed_file() decompression
- EPGSource.get_cache_file() extension inference for uploaded files
"""
import lzma
import os
import tempfile
from unittest.mock import patch
from django.test import SimpleTestCase
from apps.epg.models import EPGSource
from apps.epg.tasks import detect_file_format, extract_compressed_file
SAMPLE_XML = (
'<?xml version="1.0" encoding="UTF-8"?>\n'
"<tv>\n"
' <channel id="xz.channel"/>\n'
' <programme channel="xz.channel" '
'start="20000101000000 +0000" stop="20991231235959 +0000">\n'
" <title>XZ Title</title>\n"
" </programme>\n"
"</tv>\n"
)
class DetectFileFormatXzTests(SimpleTestCase):
def test_detects_xz_by_magic_bytes(self):
compressed = lzma.compress(SAMPLE_XML.encode("utf-8"))
format_type, is_compressed, file_extension = detect_file_format(
content=compressed[:64]
)
self.assertEqual(format_type, "xz")
self.assertTrue(is_compressed)
self.assertEqual(file_extension, ".xz")
def test_detects_xz_by_extension(self):
format_type, is_compressed, file_extension = detect_file_format(
file_path="/tmp/epg_source.xz"
)
self.assertEqual(format_type, "xz")
self.assertTrue(is_compressed)
self.assertEqual(file_extension, ".xz")
def test_detects_xz_with_compound_extension(self):
# Compound extensions like .xml.xz must prioritize the compression
# extension, matching the existing .xml.gz behavior.
format_type, is_compressed, file_extension = detect_file_format(
file_path="/tmp/epg_source.xml.xz"
)
self.assertEqual(format_type, "xz")
self.assertTrue(is_compressed)
self.assertEqual(file_extension, ".xz")
def test_detects_xz_by_mimetype_fallback(self):
# Python's stdlib mimetypes module treats .xz as an *encoding*
# suffix rather than a full MIME type (mirroring .gz), so the
# mimetype branch is only reachable when guess_type is coerced to
# return the MIME type directly (e.g. a customized mimetypes
# configuration). Exercise that fallback branch directly, matching
# how the existing gzip/zip mimetype fallback branches are tested.
with patch("mimetypes.guess_type", return_value=("application/x-xz", None)):
format_type, is_compressed, file_extension = detect_file_format(
file_path="/tmp/epg_source_with_no_known_suffix"
)
self.assertEqual(format_type, "xz")
self.assertTrue(is_compressed)
self.assertEqual(file_extension, ".xz")
def test_content_magic_bytes_take_priority_over_extension(self):
compressed = lzma.compress(SAMPLE_XML.encode("utf-8"))
# Mismatched extension should not override a confident content match.
format_type, is_compressed, file_extension = detect_file_format(
file_path="/tmp/misleading_name.gz", content=compressed[:64]
)
self.assertEqual(format_type, "xz")
self.assertTrue(is_compressed)
self.assertEqual(file_extension, ".xz")
class ExtractCompressedFileXzTests(SimpleTestCase):
def test_round_trips_lzma_compressed_xmltv_to_identical_xml(self):
xz_path = None
xml_path = None
try:
with tempfile.NamedTemporaryFile(
suffix=".xml.xz", delete=False
) as xz_file:
xz_path = xz_file.name
xz_file.write(lzma.compress(SAMPLE_XML.encode("utf-8")))
xml_path = f"{os.path.splitext(os.path.splitext(xz_path)[0])[0]}.xml"
extracted_path = extract_compressed_file(xz_path, xml_path)
self.assertEqual(extracted_path, xml_path)
with open(extracted_path, "r", encoding="utf-8") as f:
self.assertEqual(f.read(), SAMPLE_XML)
finally:
if xz_path and os.path.exists(xz_path):
os.unlink(xz_path)
if xml_path and os.path.exists(xml_path):
os.unlink(xml_path)
def test_extracts_xz_file_detected_purely_by_magic_bytes(self):
# No .xz suffix at all - detection must rely on the LZMA magic
# number, not the filename.
xz_path = None
xml_path = None
try:
with tempfile.NamedTemporaryFile(
suffix=".bin", delete=False
) as xz_file:
xz_path = xz_file.name
xz_file.write(lzma.compress(SAMPLE_XML.encode("utf-8")))
xml_path = f"{os.path.splitext(xz_path)[0]}.xml"
extracted_path = extract_compressed_file(xz_path, xml_path)
self.assertEqual(extracted_path, xml_path)
with open(extracted_path, "r", encoding="utf-8") as f:
self.assertEqual(f.read(), SAMPLE_XML)
finally:
if xz_path and os.path.exists(xz_path):
os.unlink(xz_path)
if xml_path and os.path.exists(xml_path):
os.unlink(xml_path)
def test_deletes_original_when_requested(self):
xz_path = None
xml_path = None
try:
with tempfile.NamedTemporaryFile(
suffix=".xml.xz", delete=False
) as xz_file:
xz_path = xz_file.name
xz_file.write(lzma.compress(SAMPLE_XML.encode("utf-8")))
xml_path = f"{os.path.splitext(os.path.splitext(xz_path)[0])[0]}.xml"
extracted_path = extract_compressed_file(
xz_path, xml_path, delete_original=True
)
self.assertEqual(extracted_path, xml_path)
self.assertFalse(os.path.exists(xz_path))
finally:
xz_path = None # already deleted by extract_compressed_file
if xml_path and os.path.exists(xml_path):
os.unlink(xml_path)
class ExtractCompressedFileCorruptXzTests(SimpleTestCase):
def test_corrupt_xz_fails_closed_with_no_partial_output(self):
# Valid LZMA magic bytes (fd 37 7a 58 5a 00) followed by garbage: the
# format sniff reports 'xz' (magic bytes match), but decompression
# itself must fail. extract_compressed_file() wraps this in a
# blanket except Exception so a corrupt/truncated upload can never
# raise out of the Celery worker - it must return None instead.
xz_path = None
xml_path = None
try:
with tempfile.NamedTemporaryFile(
suffix=".xz", delete=False
) as xz_file:
xz_path = xz_file.name
xz_file.write(b"\xfd7zXZ\x00" + b"not actually lzma data" * 20)
xml_path = f"{os.path.splitext(xz_path)[0]}.xml"
extracted_path = extract_compressed_file(xz_path, xml_path)
self.assertIsNone(extracted_path)
self.assertFalse(
os.path.exists(xml_path),
"extract_compressed_file must not leave a partial output file "
"behind when decompression fails",
)
finally:
if xz_path and os.path.exists(xz_path):
os.unlink(xz_path)
if xml_path and os.path.exists(xml_path):
os.unlink(xml_path)
class EPGSourceGetCacheFileXzTests(SimpleTestCase):
def test_infers_xz_extension_from_mimetype(self):
xz_path = None
try:
with tempfile.NamedTemporaryFile(
suffix="", delete=False
) as xz_file:
xz_path = xz_file.name
xz_file.write(lzma.compress(SAMPLE_XML.encode("utf-8")))
source = EPGSource(
name="XZ mimetype source",
source_type="xmltv",
file_path=xz_path,
)
with patch(
"mimetypes.guess_type", return_value=("application/x-xz", None)
):
cache_file = source.get_cache_file()
self.assertTrue(cache_file.endswith(".xz"))
finally:
if xz_path and os.path.exists(xz_path):
os.unlink(xz_path)
def test_infers_xz_extension_from_magic_bytes(self):
xz_path = None
try:
with tempfile.NamedTemporaryFile(
suffix="", delete=False
) as xz_file:
xz_path = xz_file.name
xz_file.write(lzma.compress(SAMPLE_XML.encode("utf-8")))
source = EPGSource(
name="XZ magic bytes source",
source_type="xmltv",
file_path=xz_path,
)
# No mimetype guess available and no extension on disk - must
# fall back to sniffing the LZMA magic number.
with patch("mimetypes.guess_type", return_value=(None, None)):
cache_file = source.get_cache_file()
self.assertTrue(cache_file.endswith(".xz"))
finally:
if xz_path and os.path.exists(xz_path):
os.unlink(xz_path)
class EPGSourceGetCacheFileRawXmlTests(SimpleTestCase):
"""Regression coverage for the bare-<tv> raw-XML magic-byte detection.
get_cache_file()'s magic-byte read was widened from f.read(4) to
f.read(6) to fit the 6-byte xz signature. That widening broke the
fixed-length slice comparison `header[:5] == b'<tv>'`: a 5-byte slice
of a 6-byte header can never equal the 4-byte literal b'<tv>', so a
raw (uncompressed) XMLTV file with no filename extension and no
resolvable mimetype was silently misdetected as '.tmp' instead of
'.xml'. Using header.startswith(...) instead of a fixed-length slice
comparison is correct regardless of how many bytes are read.
"""
def _cache_file_for_content(self, content):
raw_path = None
try:
with tempfile.NamedTemporaryFile(suffix="", delete=False) as raw_file:
raw_path = raw_file.name
raw_file.write(content)
source = EPGSource(
name="Raw XML source",
source_type="xmltv",
file_path=raw_path,
)
# No extension on disk and no resolvable mimetype - detection
# must fall back to sniffing the magic bytes.
with patch("mimetypes.guess_type", return_value=(None, None)):
return source.get_cache_file()
finally:
if raw_path and os.path.exists(raw_path):
os.unlink(raw_path)
def test_infers_xml_extension_from_bare_tv_tag(self):
cache_file = self._cache_file_for_content(SAMPLE_XML.split("\n", 1)[1].encode("utf-8"))
self.assertTrue(cache_file.endswith(".xml"))
def test_infers_xml_extension_from_xml_declaration(self):
cache_file = self._cache_file_for_content(SAMPLE_XML.encode("utf-8"))
self.assertTrue(cache_file.endswith(".xml"))

View file

@ -1,12 +1,17 @@
""" """
Shared EPG utilities season/episode extraction. Shared EPG utilities.
These live here (rather than in serializers.py or tasks.py) to avoid circular imports: Season/episode extraction, WebSocket progress updates, and SD poster proxy URL
serializers tasks and channels/tasks serializers both need these functions. helpers live here so serializers, XMLTV output, and tasks can import without
circular dependencies.
""" """
import gc
import hashlib
import re import re
from core.utils import send_websocket_update
# Matches patterns like "S12 E6", "S3E21", "S8 E8 P2/2" # Matches patterns like "S12 E6", "S3E21", "S8 E8 P2/2"
_ONSCREEN_RE = re.compile(r'S(\d+)\s*E(\d+)', re.IGNORECASE) _ONSCREEN_RE = re.compile(r'S(\d+)\s*E(\d+)', re.IGNORECASE)
@ -21,6 +26,34 @@ _DESC_SE_PATTERNS = [
re.compile(r'^[\s\-:]*(\d+)x(\d{2,})[\s\-:.]*'), re.compile(r'^[\s\-:]*(\d+)x(\d{2,})[\s\-:.]*'),
] ]
_SD_POSTER_CACHE_BUST_LEN = 12
def sd_poster_cache_bust(sd_icon_url):
"""
Short content hash of an SD poster URI for nginx cache busting.
Same URI keeps the same ``v`` (long-lived nginx cache). A new artwork URI
after refresh gets a new ``v`` so clients do not keep stale bytes.
"""
if not sd_icon_url:
return ''
return hashlib.sha256(sd_icon_url.encode('utf-8')).hexdigest()[:_SD_POSTER_CACHE_BUST_LEN]
def sd_poster_proxy_path(program_id, sd_icon_url):
"""
Relative proxy path for a program poster.
Includes ``?v=`` when ``sd_icon_url`` is set so nginx cache keys change with
the upstream SD URI. The poster endpoint ignores ``v``; nginx keys on full URI.
"""
path = f'/api/epg/programs/{program_id}/poster/'
bust = sd_poster_cache_bust(sd_icon_url)
if not bust:
return path
return f'{path}?v={bust}'
def extract_season_episode_from_description(desc): def extract_season_episode_from_description(desc):
""" """
@ -59,3 +92,23 @@ def extract_season_episode(cp, description=None):
if episode is None: if episode is None:
episode = d_episode episode = d_episode
return season, episode return season, episode
def send_epg_update(source_id, action, progress, **kwargs):
"""Send WebSocket update about EPG download/parsing progress."""
data = {
"progress": progress,
"type": "epg_refresh",
"source": source_id,
"action": action,
}
data.update(kwargs)
# High-frequency program parsing needs more aggressive memory management
collect_garbage = action == "parsing_programs" and progress % 10 == 0
send_websocket_update('updates', 'update', data, collect_garbage=collect_garbage)
data = None
if action == "parsing_programs" and progress % 50 == 0:
gc.collect()

View file

@ -16,12 +16,12 @@ app_name = "m3u"
router = DefaultRouter() router = DefaultRouter()
router.register(r"accounts", M3UAccountViewSet, basename="m3u-account") router.register(r"accounts", M3UAccountViewSet, basename="m3u-account")
router.register( router.register(
r"accounts\/(?P<account_id>\d+)\/profiles", r"accounts/(?P<account_id>\d+)/profiles",
M3UAccountProfileViewSet, M3UAccountProfileViewSet,
basename="m3u-account-profiles", basename="m3u-account-profiles",
) )
router.register( router.register(
r"accounts\/(?P<account_id>\d+)\/filters", r"accounts/(?P<account_id>\d+)/filters",
M3UFilterViewSet, M3UFilterViewSet,
basename="m3u-filters", basename="m3u-filters",
) )

View file

@ -23,7 +23,7 @@ logger = logging.getLogger(__name__)
from .models import M3UAccount, M3UFilter, ServerGroup, M3UAccountProfile from .models import M3UAccount, M3UFilter, ServerGroup, M3UAccountProfile
from core.models import UserAgent from core.models import UserAgent
from core.utils import safe_upload_path from core.utils import safe_upload_path, ensure_custom_properties_dict
from apps.channels.models import ChannelGroupM3UAccount from apps.channels.models import ChannelGroupM3UAccount
from core.serializers import UserAgentSerializer from core.serializers import UserAgentSerializer
from apps.vod.models import M3UVODCategoryRelation from apps.vod.models import M3UVODCategoryRelation
@ -283,36 +283,21 @@ class M3UAccountViewSet(viewsets.ModelViewSet):
) )
# Snapshot channels so proxy sessions can be stopped outside # Snapshot channels so proxy sessions can be stopped outside
# the DB transaction. The pre_delete signal would otherwise # the DB transaction. Teardown must not run inside the atomic
# fire ChannelService.stop_channel (Redis pub / hgetall / # delete (geventpool connection release would poison the TX).
# setex) per channel inside the atomic, holding the DB
# connection across thousands of blocking RPCs and gumming up
# the connection pool.
channels_to_delete = list( channels_to_delete = list(
Channel.objects.filter( Channel.objects.filter(
auto_created=True, auto_created=True,
auto_created_by=instance, auto_created_by=instance,
).values_list("id", "uuid") ).values_list("id", "uuid")
) )
for _, channel_uuid in channels_to_delete: ChannelService.stop_channels(
if not channel_uuid: channel_uuid for _, channel_uuid in channels_to_delete
continue )
try:
ChannelService.stop_channel(str(channel_uuid))
except Exception as e:
logger.warning(
"Failed to stop proxy session for channel %s "
"during account cleanup: %s",
channel_uuid,
e,
)
channel_ids = [cid for cid, _ in channels_to_delete] channel_ids = [cid for cid, _ in channels_to_delete]
# Channel + account writes share an atomic so an account # Channel + account writes share an atomic so an account
# delete failure rolls back the channel deletes too. The # delete failure rolls back the channel deletes too.
# pre_delete signal will fire again here but its proxy stop
# is fast on already-stopped channels (a single Redis check
# returns "not found" immediately).
with transaction.atomic(): with transaction.atomic():
if channel_ids: if channel_ids:
_, per_model = Channel.objects.filter( _, per_model = Channel.objects.filter(
@ -536,7 +521,9 @@ class M3UAccountViewSet(viewsets.ModelViewSet):
auto_channel_sync=setting.get("auto_channel_sync", False), auto_channel_sync=setting.get("auto_channel_sync", False),
auto_sync_channel_start=setting.get("auto_sync_channel_start"), auto_sync_channel_start=setting.get("auto_sync_channel_start"),
auto_sync_channel_end=setting.get("auto_sync_channel_end"), auto_sync_channel_end=setting.get("auto_sync_channel_end"),
custom_properties=setting.get("custom_properties", {}), custom_properties=ensure_custom_properties_dict(
setting.get("custom_properties")
),
) )
for setting in group_settings for setting in group_settings
if setting.get("channel_group") if setting.get("channel_group")
@ -561,7 +548,9 @@ class M3UAccountViewSet(viewsets.ModelViewSet):
category_id=setting["id"], category_id=setting["id"],
m3u_account=account, m3u_account=account,
enabled=setting.get("enabled", True), enabled=setting.get("enabled", True),
custom_properties=setting.get("custom_properties", {}), custom_properties=ensure_custom_properties_dict(
setting.get("custom_properties")
),
) )
for setting in category_settings for setting in category_settings
if setting.get("id") if setting.get("id")

323
apps/m3u/connection_pool.py Normal file
View file

@ -0,0 +1,323 @@
"""
Shared connection pool enforcement for M3U accounts in the same ServerGroup.
Profile selection rotates across M3UAccountProfile rows using each profile's own
Redis counter (the pre-pool behavior). When an account belongs to a ServerGroup, a credential-scoped counter is checked on reserve/release
so accounts sharing the same provider login share one limit without blocking
unrelated logins on the same group. Account profiles with max_streams=0 skip
credential enforcement for that profile.
"""
from __future__ import annotations
import hashlib
import logging
import re
from typing import Literal, Optional, Tuple
logger = logging.getLogger(__name__)
ReserveFailureReason = Literal["profile_full", "credential_full"]
PROFILE_CONNECTIONS_KEY = "profile_connections:{profile_id}"
PROFILE_CREDENTIAL_RELEASE_KEY = "profile_credential_release:{profile_id}"
SERVER_GROUP_CONNECTIONS_KEY = "server_group_connections:{group_id}:{fingerprint}"
_XC_URL_CREDENTIALS_RE = re.compile(
r"/(?:live|movie|series)/([^/]+)/([^/]+)/",
re.IGNORECASE,
)
def profile_connections_key(profile_id: int) -> str:
return PROFILE_CONNECTIONS_KEY.format(profile_id=profile_id)
def profile_credential_release_key(profile_id: int) -> str:
"""Redis key storing the credential counter to release when the profile row is gone."""
return PROFILE_CREDENTIAL_RELEASE_KEY.format(profile_id=profile_id)
def server_group_connections_key(group_id: int, fingerprint: str) -> str:
"""Redis key for per-credential usage within a ServerGroup."""
return SERVER_GROUP_CONNECTIONS_KEY.format(
group_id=group_id,
fingerprint=fingerprint[:16],
)
def compute_credential_fingerprint(username: str, password: str) -> Optional[str]:
"""Return a stable hash for grouping accounts with the same IPTV login."""
if not username or not password:
return None
normalized = f"{username.strip().lower()}\0{password.strip()}"
return hashlib.sha256(normalized.encode("utf-8")).hexdigest()
def extract_credentials_from_stream_url(url: str) -> Tuple[Optional[str], Optional[str]]:
"""Parse username/password embedded in an Xtream-style stream URL."""
if not url:
return None, None
match = _XC_URL_CREDENTIALS_RE.search(url)
if not match:
return None, None
return match.group(1), match.group(2)
def _fingerprint_from_profile_stream_url(profile) -> Optional[str]:
"""STD/M3U: fingerprint from a sample stream URL after profile rewrite."""
from apps.channels.models import Stream
sample_url = (
Stream.objects.filter(m3u_account=profile.m3u_account)
.exclude(url="")
.values_list("url", flat=True)
.first()
)
if not sample_url:
return None
try:
from apps.proxy.live_proxy.url_utils import transform_url
transformed = transform_url(
sample_url,
profile.search_pattern or "",
profile.replace_pattern or "",
)
url_user, url_pass = extract_credentials_from_stream_url(
transformed or sample_url
)
return compute_credential_fingerprint(url_user or "", url_pass or "")
except Exception as exc:
logger.debug(
"Could not derive profile %s fingerprint from stream URL: %s",
profile.pk,
exc,
)
return None
def get_profile_credential_fingerprint(profile) -> Optional[str]:
"""Fingerprint for credentials this profile uses at playback time."""
m3u_account = profile.m3u_account
if m3u_account.account_type == "XC":
try:
from apps.m3u.tasks import get_transformed_credentials
_url, username, password = get_transformed_credentials(m3u_account, profile)
fingerprint = compute_credential_fingerprint(username or "", password or "")
if fingerprint:
return fingerprint
except Exception as exc:
logger.debug(
"Could not resolve transformed credentials for profile %s: %s",
profile.pk,
exc,
)
fingerprint = _fingerprint_from_profile_stream_url(profile)
if fingerprint:
return fingerprint
return compute_credential_fingerprint(
m3u_account.username or "",
m3u_account.password or "",
)
def get_enforced_server_group_for_profile(profile):
"""Return the ServerGroup for credential pooling when the account is assigned to one."""
group = profile.m3u_account.server_group
if group:
return group
return None
def _credential_counter_key(profile, group) -> Optional[str]:
fingerprint = get_profile_credential_fingerprint(profile)
if not fingerprint:
return None
return server_group_connections_key(group.id, fingerprint)
def get_profile_connection_count(profile, redis_client) -> int:
return int(redis_client.get(profile_connections_key(profile.id)) or 0)
def get_credential_connection_count(profile, redis_client) -> int:
group = get_enforced_server_group_for_profile(profile)
if not group:
return 0
cred_key = _credential_counter_key(profile, group)
if not cred_key:
return 0
return int(redis_client.get(cred_key) or 0)
def profile_has_capacity_for_selection(profile, redis_client) -> bool:
"""Per-profile capacity check used when rotating across profiles on one account."""
if profile.max_streams == 0:
return True
return get_profile_connection_count(profile, redis_client) < profile.max_streams
def group_has_capacity_for_profile(profile, redis_client) -> bool:
# Profiles with max_streams=0 skip credential enforcement entirely. An unlimited
# profile in a pooled group can still stream while other accounts share the login.
group = get_enforced_server_group_for_profile(profile)
if not group or profile.max_streams == 0:
return True
cred_key = _credential_counter_key(profile, group)
if not cred_key:
return True
return int(redis_client.get(cred_key) or 0) < profile.max_streams
def pool_has_capacity_for_profile(profile, redis_client) -> bool:
"""Non-mutating check before reserve: profile slot and credential slot if applicable."""
return profile_has_capacity_for_selection(profile, redis_client) and group_has_capacity_for_profile(
profile, redis_client
)
def profile_available_for_channel_switch(
profile, redis_client, *, channel_already_on_profile: bool
) -> bool:
"""
Non-mutating capacity check when selecting a profile for an in-flight channel.
If the channel already holds this profile's slots, skip re-checking capacity.
"""
if channel_already_on_profile:
return True
return pool_has_capacity_for_profile(profile, redis_client)
def move_credential_slot_on_profile_switch(
old_profile, new_profile, redis_client
) -> bool:
"""
Move the shared credential counter when switching to a different provider login.
Profile counters are managed separately by Channel.update_stream_profile().
Returns False when the new profile's credential pool is full.
"""
old_fp = get_profile_credential_fingerprint(old_profile)
new_fp = get_profile_credential_fingerprint(new_profile)
if old_fp == new_fp:
return True
_release_credential_slot_by_profile_id(old_profile.id, redis_client)
cred_reserved, cred_key = _reserve_server_group_slot_for_profile(
new_profile, redis_client
)
if not cred_reserved:
restore_reserved, restore_key = _reserve_server_group_slot_for_profile(
old_profile, redis_client
)
if restore_reserved and restore_key:
_remember_credential_release_key(
old_profile.id, restore_key, redis_client
)
return False
if cred_key:
_remember_credential_release_key(new_profile.id, cred_key, redis_client)
return True
def _safe_decr(redis_client, key: str) -> None:
current = int(redis_client.get(key) or 0)
if current <= 0:
return
new_count = redis_client.decr(key)
if new_count < 0:
redis_client.set(key, 0)
def _remember_credential_release_key(
profile_id: int, cred_key: str, redis_client
) -> None:
redis_client.set(profile_credential_release_key(profile_id), cred_key)
def _release_credential_slot_by_profile_id(profile_id: int, redis_client) -> bool:
"""Release a reserved credential counter using the key stored at reserve time."""
release_key = profile_credential_release_key(profile_id)
cred_key = redis_client.get(release_key)
if not cred_key:
return False
if isinstance(cred_key, bytes):
cred_key = cred_key.decode()
_safe_decr(redis_client, cred_key)
redis_client.delete(release_key)
return True
def _reserve_server_group_slot_for_profile(
profile, redis_client
) -> Tuple[bool, Optional[str]]:
group = get_enforced_server_group_for_profile(profile)
if not group or profile.max_streams == 0:
return True, None
cred_key = _credential_counter_key(profile, group)
if not cred_key:
return True, None
cred_count = redis_client.incr(cred_key)
if cred_count <= profile.max_streams:
return True, cred_key
redis_client.decr(cred_key)
return False, None
def reserve_profile_slot(
profile, redis_client
) -> Tuple[bool, int, Optional[ReserveFailureReason]]:
"""
Atomically reserve profile + optional credential slots (INCR-first).
Returns (reserved, profile_count_after_attempt, failure_reason).
failure_reason is set when reserved is False.
"""
profile_key = profile_connections_key(profile.id)
profile_count = 0
if profile.max_streams > 0:
profile_count = redis_client.incr(profile_key)
if profile_count > profile.max_streams:
redis_client.decr(profile_key)
return False, profile_count - 1, "profile_full"
cred_reserved, cred_key = _reserve_server_group_slot_for_profile(
profile, redis_client
)
if not cred_reserved:
if profile.max_streams > 0:
redis_client.decr(profile_key)
return (
False,
profile_count - 1 if profile.max_streams > 0 else 0,
"credential_full",
)
if cred_key:
_remember_credential_release_key(profile.id, cred_key, redis_client)
return True, profile_count, None
def release_profile_slot(profile_id: int, redis_client) -> None:
"""Release profile and shared credential slots after a stream end."""
_release_credential_slot_by_profile_id(profile_id, redis_client)
profile_key = profile_connections_key(profile_id)
current = int(redis_client.get(profile_key) or 0)
if current > 0:
redis_client.decr(profile_key)

View file

@ -1,6 +1,7 @@
# apps/m3u/forms.py # apps/m3u/forms.py
from django import forms from django import forms
from .models import M3UAccount, M3UFilter from .models import M3UAccount, M3UFilter
from core.utils import ensure_custom_properties_dict
import re import re
class M3UAccountForm(forms.ModelForm): class M3UAccountForm(forms.ModelForm):
@ -28,7 +29,9 @@ class M3UAccountForm(forms.ModelForm):
# Set initial value for enable_vod from custom_properties # Set initial value for enable_vod from custom_properties
if self.instance and self.instance.custom_properties: if self.instance and self.instance.custom_properties:
custom_props = self.instance.custom_properties or {} custom_props = self.instance.custom_properties
if not isinstance(custom_props, dict):
custom_props = ensure_custom_properties_dict(custom_props)
self.fields['enable_vod'].initial = custom_props.get('enable_vod', False) self.fields['enable_vod'].initial = custom_props.get('enable_vod', False)
def save(self, commit=True): def save(self, commit=True):
@ -37,8 +40,9 @@ class M3UAccountForm(forms.ModelForm):
# Handle enable_vod field # Handle enable_vod field
enable_vod = self.cleaned_data.get('enable_vod', False) enable_vod = self.cleaned_data.get('enable_vod', False)
# Parse existing custom_properties
custom_props = instance.custom_properties or {} custom_props = instance.custom_properties or {}
if not isinstance(custom_props, dict):
custom_props = ensure_custom_properties_dict(custom_props)
# Update VOD preference # Update VOD preference
custom_props['enable_vod'] = enable_vod custom_props['enable_vod'] = enable_vod

View file

@ -7,6 +7,7 @@ from django.dispatch import receiver
from apps.channels.models import StreamProfile from apps.channels.models import StreamProfile
from django_celery_beat.models import PeriodicTask from django_celery_beat.models import PeriodicTask
from core.models import CoreSettings, UserAgent from core.models import CoreSettings, UserAgent
from core.utils import custom_properties_as_dict
CUSTOM_M3U_ACCOUNT_NAME = "custom" CUSTOM_M3U_ACCOUNT_NAME = "custom"
@ -124,6 +125,11 @@ class M3UAccount(models.Model):
return user_agent return user_agent
def save(self, *args, **kwargs): def save(self, *args, **kwargs):
if self.custom_properties is not None and not isinstance(
self.custom_properties, dict
):
self.custom_properties = custom_properties_as_dict(self.custom_properties)
# Prevent auto_now behavior by handling updated_at manually # Prevent auto_now behavior by handling updated_at manually
if "update_fields" in kwargs and "updated_at" not in kwargs["update_fields"]: if "update_fields" in kwargs and "updated_at" not in kwargs["update_fields"]:
# Don't modify updated_at for regular updates # Don't modify updated_at for regular updates
@ -194,7 +200,13 @@ class M3UFilter(models.Model):
class ServerGroup(models.Model): class ServerGroup(models.Model):
"""Represents a logical grouping of servers or channels.""" """
Groups M3U accounts that share provider credentials.
Accounts assigned to the same server group share credential-scoped connection
counters when their logins match. Limits come from each account profile's
max_streams, not from the group itself.
"""
name = models.CharField( name = models.CharField(
max_length=100, unique=True, help_text="Unique name for this server group." max_length=100, unique=True, help_text="Unique name for this server group."
@ -257,6 +269,11 @@ class M3UAccountProfile(models.Model):
def save(self, *args, **kwargs): def save(self, *args, **kwargs):
"""Auto-sync exp_date from custom_properties for XC accounts on every save. """Auto-sync exp_date from custom_properties for XC accounts on every save.
For non-XC accounts, exp_date is set directly and left untouched here.""" For non-XC accounts, exp_date is set directly and left untouched here."""
if self.custom_properties is not None and not isinstance(
self.custom_properties, dict
):
self.custom_properties = custom_properties_as_dict(self.custom_properties)
parsed = self._parse_exp_date_from_custom_properties() parsed = self._parse_exp_date_from_custom_properties()
if parsed is not None: if parsed is not None:
# XC account with exp_date in custom_properties — always sync # XC account with exp_date in custom_properties — always sync

View file

@ -1,4 +1,4 @@
from core.utils import validate_flexible_url from core.utils import validate_flexible_url, ensure_custom_properties_dict
from rest_framework import serializers, status from rest_framework import serializers, status
from rest_framework.response import Response from rest_framework.response import Response
from .models import M3UAccount, M3UFilter, ServerGroup, M3UAccountProfile from .models import M3UAccount, M3UFilter, ServerGroup, M3UAccountProfile
@ -189,6 +189,7 @@ class M3UAccountSerializer(serializers.ModelSerializer):
"password": { "password": {
"required": False, "required": False,
"allow_blank": True, "allow_blank": True,
"write_only": True,
}, },
} }
@ -213,6 +214,13 @@ class M3UAccountSerializer(serializers.ModelSerializer):
data = super().to_representation(instance) data = super().to_representation(instance)
# write_only strips password for everyone; re-add only for admins so
# operator tooling / profile regex helpers still see credentials.
request = self.context.get("request")
user = getattr(request, "user", None) if request else None
if user is not None and getattr(user, "user_level", 0) >= 10:
data["password"] = instance.password or ""
# Parse custom_properties to get VOD preference and auto_enable_new_groups settings # Parse custom_properties to get VOD preference and auto_enable_new_groups settings
custom_props = instance.custom_properties or {} custom_props = instance.custom_properties or {}
@ -270,11 +278,15 @@ class M3UAccountSerializer(serializers.ModelSerializer):
# overwrite their corresponding keys; clients should set those via # overwrite their corresponding keys; clients should set those via
# the typed top-level fields rather than the custom_properties # the typed top-level fields rather than the custom_properties
# payload. # payload.
incoming_custom = validated_data.get("custom_properties") or {} incoming_custom = {}
custom_props = { if "custom_properties" in validated_data:
**(instance.custom_properties or {}), incoming_custom = validated_data["custom_properties"] or {}
**incoming_custom, if not isinstance(incoming_custom, dict):
} incoming_custom = ensure_custom_properties_dict(incoming_custom)
existing_custom = instance.custom_properties or {}
if not isinstance(existing_custom, dict):
existing_custom = ensure_custom_properties_dict(existing_custom)
custom_props = {**existing_custom, **incoming_custom}
if enable_vod is not None: if enable_vod is not None:
custom_props["enable_vod"] = enable_vod custom_props["enable_vod"] = enable_vod
@ -346,7 +358,9 @@ class M3UAccountSerializer(serializers.ModelSerializer):
auto_enable_new_groups_series = validated_data.pop("auto_enable_new_groups_series", True) auto_enable_new_groups_series = validated_data.pop("auto_enable_new_groups_series", True)
# Parse existing custom_properties or create new # Parse existing custom_properties or create new
custom_props = validated_data.get("custom_properties", {}) custom_props = validated_data.get("custom_properties") or {}
if not isinstance(custom_props, dict):
custom_props = ensure_custom_properties_dict(custom_props)
# Set preferences (default to True for auto_enable_new_groups) # Set preferences (default to True for auto_enable_new_groups)
custom_props["enable_vod"] = enable_vod custom_props["enable_vod"] = enable_vod

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,594 @@
"""Tests for shared ServerGroup connection pools (#1137)."""
from django.test import TestCase
from unittest.mock import patch
from apps.m3u.connection_pool import (
extract_credentials_from_stream_url,
get_credential_connection_count,
get_enforced_server_group_for_profile,
get_profile_connection_count,
get_profile_credential_fingerprint,
group_has_capacity_for_profile,
pool_has_capacity_for_profile,
profile_has_capacity_for_selection,
profile_connections_key,
profile_credential_release_key,
release_profile_slot,
reserve_profile_slot,
server_group_connections_key,
)
from apps.m3u.models import M3UAccount, M3UAccountProfile, ServerGroup
class FakeRedis:
"""Minimal in-memory Redis stand-in for counter tests."""
def __init__(self):
self._data = {}
def get(self, key):
val = self._data.get(key)
if val is None:
return None
if isinstance(val, str):
return val.encode()
return str(val).encode()
def set(self, key, value, ex=None):
try:
self._data[key] = int(value)
except (ValueError, TypeError):
self._data[key] = value
def incr(self, key):
self._data[key] = self._data.get(key, 0) + 1
return self._data[key]
def decr(self, key):
self._data[key] = self._data.get(key, 0) - 1
return self._data[key]
def delete(self, key):
self._data.pop(key, None)
def pipeline(self):
return FakeRedisPipeline(self)
class FakeRedisPipeline:
def __init__(self, redis):
self.redis = redis
self._ops = []
def decr(self, key):
self._ops.append(("decr", key))
return self
def incr(self, key):
self._ops.append(("incr", key))
return self
def set(self, key, value):
self._ops.append(("set", key, value))
return self
def execute(self):
for op in self._ops:
if op[0] == "decr":
self.redis.decr(op[1])
elif op[0] == "incr":
self.redis.incr(op[1])
elif op[0] == "set":
self.redis.set(op[1], op[2])
self._ops = []
class ExtractCredentialsTests(TestCase):
def test_extract_credentials_from_xc_style_url(self):
url = "http://example.com/live/alice/secret123/99999.ts"
user, password = extract_credentials_from_stream_url(url)
self.assertEqual(user, "alice")
self.assertEqual(password, "secret123")
class ManualServerGroupTests(TestCase):
def test_group_enforced_when_account_assigned(self):
group = ServerGroup.objects.create(name="provider-a")
account = M3UAccount.objects.create(
name="Account A",
account_type="XC",
username="user",
password="pass",
server_group=group,
)
profile = M3UAccountProfile.objects.get(m3u_account=account, is_default=True)
self.assertEqual(get_enforced_server_group_for_profile(profile), group)
def test_accounts_in_same_group_share_credential_counter(self):
group = ServerGroup.objects.create(name="shared")
account1 = M3UAccount.objects.create(
name="XC Account",
account_type="XC",
username="user",
password="pass",
server_url="http://xc.example.com",
server_group=group,
max_streams=5,
)
account2 = M3UAccount.objects.create(
name="M3U Account",
account_type="STD",
username="user",
password="pass",
server_group=group,
max_streams=5,
)
profile1 = M3UAccountProfile.objects.get(m3u_account=account1, is_default=True)
profile2 = M3UAccountProfile.objects.get(m3u_account=account2, is_default=True)
profile1.max_streams = 1
profile1.save()
profile2.max_streams = 1
profile2.save()
redis = FakeRedis()
reserved1, _, _ = reserve_profile_slot(profile1, redis)
self.assertTrue(reserved1)
reserved2, _, _ = reserve_profile_slot(profile2, redis)
self.assertFalse(reserved2)
self.assertFalse(group_has_capacity_for_profile(profile2, redis))
def test_profile_rotation_when_default_profile_full(self):
account = M3UAccount.objects.create(
name="Multi-profile",
account_type="XC",
max_streams=1,
)
default = M3UAccountProfile.objects.get(m3u_account=account, is_default=True)
default.max_streams = 1
default.save()
alt = M3UAccountProfile.objects.create(
m3u_account=account,
name="alt_profile",
is_default=False,
is_active=True,
max_streams=1,
search_pattern="",
replace_pattern="",
)
redis = FakeRedis()
reserved, _, _ = reserve_profile_slot(default, redis)
self.assertTrue(reserved)
self.assertFalse(profile_has_capacity_for_selection(default, redis))
self.assertTrue(profile_has_capacity_for_selection(alt, redis))
class PoolEnforcementTests(TestCase):
def setUp(self):
self.redis = FakeRedis()
self.group = ServerGroup.objects.create(name="test-pool")
self.account = M3UAccount.objects.create(
name="Test Account",
account_type="XC",
username="user",
password="pass",
server_url="http://xc.example.com",
server_group=self.group,
max_streams=5,
)
self.profile = M3UAccountProfile.objects.get(
m3u_account=self.account, is_default=True
)
self.profile.max_streams = 1
self.profile.save()
def test_group_has_capacity_reads_credential_counter_directly(self):
cred_key = server_group_connections_key(
self.group.id,
get_profile_credential_fingerprint(self.profile),
)
self.redis.set(cred_key, 1)
self.assertFalse(group_has_capacity_for_profile(self.profile, self.redis))
self.redis.set(cred_key, 0)
self.assertTrue(group_has_capacity_for_profile(self.profile, self.redis))
def test_reserve_and_release_both_counters(self):
reserved, count, _ = reserve_profile_slot(self.profile, self.redis)
self.assertTrue(reserved)
self.assertEqual(count, 1)
cred_key = server_group_connections_key(
self.group.id,
get_profile_credential_fingerprint(self.profile),
)
profile_key = profile_connections_key(self.profile.id)
self.assertEqual(self.redis._data[cred_key], 1)
self.assertEqual(self.redis._data[profile_key], 1)
release_profile_slot(self.profile.id, self.redis)
self.assertEqual(self.redis._data[cred_key], 0)
self.assertEqual(self.redis._data[profile_key], 0)
def test_same_credential_capped_at_profile_max(self):
"""Shared credential counter is capped by each profile's max_streams."""
account2 = M3UAccount.objects.create(
name="Second Account",
account_type="XC",
username="user",
password="pass",
server_url="http://xc.example.com",
server_group=self.group,
max_streams=5,
)
profile2 = M3UAccountProfile.objects.get(m3u_account=account2, is_default=True)
profile2.max_streams = 1
profile2.save()
self.assertTrue(reserve_profile_slot(self.profile, self.redis)[0])
self.assertFalse(reserve_profile_slot(profile2, self.redis)[0])
fp = get_profile_credential_fingerprint(self.profile)
cred_key = server_group_connections_key(self.group.id, fp)
self.assertEqual(self.redis._data[cred_key], 1)
def test_different_logins_both_stream_in_same_group(self):
"""Different provider logins keep separate credential counters in one group."""
account = M3UAccount.objects.create(
name="Grouped multi-login",
account_type="XC",
username="login_a",
password="pass_a",
server_group=self.group,
max_streams=5,
)
default = M3UAccountProfile.objects.get(m3u_account=account, is_default=True)
default.max_streams = 1
default.save()
alt = M3UAccountProfile.objects.create(
m3u_account=account,
name="alt_login",
is_default=False,
is_active=True,
max_streams=1,
search_pattern="",
replace_pattern="",
)
fp_a = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
fp_b = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"
with patch(
"apps.m3u.connection_pool.get_profile_credential_fingerprint",
side_effect=lambda profile: fp_a if profile.id == default.id else fp_b,
):
self.assertTrue(reserve_profile_slot(default, self.redis)[0])
self.assertTrue(reserve_profile_slot(alt, self.redis)[0])
key_a = server_group_connections_key(self.group.id, fp_a)
key_b = server_group_connections_key(self.group.id, fp_b)
self.assertEqual(self.redis._data[key_a], 1)
self.assertEqual(self.redis._data[key_b], 1)
def test_no_fingerprint_skips_credential_counter(self):
account = M3UAccount.objects.create(
name="No creds",
account_type="STD",
server_group=self.group,
max_streams=5,
)
profile = M3UAccountProfile.objects.get(m3u_account=account, is_default=True)
profile.max_streams = 1
profile.save()
with patch(
"apps.m3u.connection_pool.get_profile_credential_fingerprint",
return_value=None,
):
self.assertTrue(reserve_profile_slot(profile, self.redis)[0])
self.assertEqual(get_credential_connection_count(profile, self.redis), 0)
self.assertEqual(get_credential_connection_count(profile, self.redis), 0)
def test_release_when_profile_row_deleted(self):
profile_id = self.profile.id
fp = get_profile_credential_fingerprint(self.profile)
cred_key = server_group_connections_key(self.group.id, fp)
reserved, _, failure_reason = reserve_profile_slot(
self.profile, self.redis
)
self.assertTrue(reserved)
self.assertIsNone(failure_reason)
self.profile.delete()
release_profile_slot(profile_id, self.redis)
self.assertEqual(self.redis._data[profile_connections_key(profile_id)], 0)
self.assertEqual(self.redis._data[cred_key], 0)
self.assertNotIn(profile_credential_release_key(profile_id), self.redis._data)
def test_release_uses_stored_credential_key_without_db_lookup(self):
profile_id = self.profile.id
cred_key = server_group_connections_key(
self.group.id,
get_profile_credential_fingerprint(self.profile),
)
self.assertTrue(reserve_profile_slot(self.profile, self.redis)[0])
with patch("apps.m3u.models.M3UAccountProfile.objects.get") as mock_get:
release_profile_slot(profile_id, self.redis)
mock_get.assert_not_called()
self.assertEqual(self.redis._data[profile_connections_key(profile_id)], 0)
self.assertEqual(self.redis._data[cred_key], 0)
def test_reserve_returns_failure_reason_without_extra_checks(self):
account2 = M3UAccount.objects.create(
name="Reason Account",
account_type="XC",
username="user",
password="pass",
server_url="http://xc.example.com",
server_group=self.group,
max_streams=5,
)
profile2 = M3UAccountProfile.objects.get(m3u_account=account2, is_default=True)
profile2.max_streams = 1
profile2.save()
self.assertTrue(reserve_profile_slot(self.profile, self.redis)[0])
reserved, _count, reason = reserve_profile_slot(profile2, self.redis)
self.assertFalse(reserved)
self.assertEqual(reason, "credential_full")
class StaleAssignmentTests(TestCase):
def test_stale_assignment_releases_counters(self):
from apps.channels.models import Channel, Stream
redis = FakeRedis()
group = ServerGroup.objects.create(name="stale-group")
account = M3UAccount.objects.create(
name="Stale Account",
account_type="XC",
username="user",
password="pass",
server_url="http://xc.example.com",
server_group=group,
max_streams=5,
)
profile = M3UAccountProfile.objects.get(m3u_account=account, is_default=True)
profile.max_streams = 1
profile.save()
stream = Stream.objects.create(name="Stale Stream", m3u_account=account)
channel = Channel.objects.create(channel_number=502, name="Stale Channel")
channel.streams.add(stream)
reserve_profile_slot(profile, redis)
redis.set(f"channel_stream:{channel.id}", stream.id)
redis.set(f"stream_profile:{stream.id}", profile.id)
profile_key = profile_connections_key(profile.id)
cred_key = server_group_connections_key(
group.id, get_profile_credential_fingerprint(profile)
)
self.assertEqual(redis._data[profile_key], 1)
self.assertEqual(redis._data[cred_key], 1)
with patch("core.utils.RedisClient.get_client", return_value=redis):
channel._release_stale_stream_assignment(redis, stream.id)
self.assertNotIn(f"channel_stream:{channel.id}", redis._data)
self.assertNotIn(f"stream_profile:{stream.id}", redis._data)
self.assertEqual(redis._data[profile_key], 0)
self.assertEqual(redis._data[cred_key], 0)
class UpdateStreamProfileTests(TestCase):
def test_switch_updates_profile_counters_when_group_assigned(self):
from apps.channels.models import Channel, Stream
redis = FakeRedis()
group = ServerGroup.objects.create(name="switch-group")
account = M3UAccount.objects.create(
name="Switch Account",
account_type="XC",
username="user",
password="pass",
server_url="http://xc.example.com",
server_group=group,
max_streams=5,
)
profile_a = M3UAccountProfile.objects.get(m3u_account=account, is_default=True)
profile_a.max_streams = 1
profile_a.save()
profile_b = M3UAccountProfile.objects.create(
m3u_account=account,
name="alt",
is_default=False,
is_active=True,
max_streams=1,
search_pattern="",
replace_pattern="",
)
stream = Stream.objects.create(name="Test Stream", m3u_account=account)
channel = Channel.objects.create(channel_number=501, name="Switch Channel")
channel.streams.add(stream)
reserve_profile_slot(profile_a, redis)
redis.set(f"channel_stream:{channel.id}", stream.id)
redis.set(f"stream_profile:{stream.id}", profile_a.id)
cred_key = server_group_connections_key(
group.id, get_profile_credential_fingerprint(profile_a)
)
cred_before = redis._data[cred_key]
with patch("core.utils.RedisClient.get_client", return_value=redis):
self.assertTrue(channel.update_stream_profile(profile_b.id))
self.assertEqual(int(redis.get(f"stream_profile:{stream.id}")), profile_b.id)
self.assertEqual(redis._data[profile_connections_key(profile_a.id)], 0)
self.assertEqual(redis._data[profile_connections_key(profile_b.id)], 1)
self.assertEqual(redis._data[cred_key], cred_before)
def test_switch_moves_credential_counter_when_login_changes(self):
from apps.channels.models import Channel, Stream
redis = FakeRedis()
group = ServerGroup.objects.create(name="cred-switch-group")
account = M3UAccount.objects.create(
name="Cred Switch Account",
account_type="XC",
username="login_a",
password="pass_a",
server_url="http://xc.example.com",
server_group=group,
max_streams=5,
)
profile_a = M3UAccountProfile.objects.get(m3u_account=account, is_default=True)
profile_a.max_streams = 1
profile_a.save()
profile_b = M3UAccountProfile.objects.create(
m3u_account=account,
name="alt_login",
is_default=False,
is_active=True,
max_streams=1,
search_pattern="",
replace_pattern="",
)
fp_a = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
fp_b = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"
key_a = server_group_connections_key(group.id, fp_a)
key_b = server_group_connections_key(group.id, fp_b)
stream = Stream.objects.create(name="Cred Switch Stream", m3u_account=account)
channel = Channel.objects.create(channel_number=503, name="Cred Switch Channel")
channel.streams.add(stream)
with patch(
"apps.m3u.connection_pool.get_profile_credential_fingerprint",
side_effect=lambda profile: fp_a if profile.id == profile_a.id else fp_b,
):
reserve_profile_slot(profile_a, redis)
redis.set(f"channel_stream:{channel.id}", stream.id)
redis.set(f"stream_profile:{stream.id}", profile_a.id)
self.assertEqual(redis._data[key_a], 1)
self.assertNotIn(key_b, redis._data)
with patch("core.utils.RedisClient.get_client", return_value=redis):
self.assertTrue(channel.update_stream_profile(profile_b.id))
self.assertEqual(redis._data[key_a], 0)
self.assertEqual(redis._data[key_b], 1)
class VodProfileSelectionTests(TestCase):
def test_get_m3u_profile_skips_default_when_profile_full(self):
from apps.proxy.vod_proxy.views import _get_m3u_profile
account = M3UAccount.objects.create(
name="VOD multi-profile",
account_type="XC",
username="xc_user_a",
password="xc_pass_a",
server_url="http://xc.example.com",
max_streams=1,
)
default = M3UAccountProfile.objects.get(m3u_account=account, is_default=True)
default.max_streams = 1
default.save()
alt = M3UAccountProfile.objects.create(
m3u_account=account,
name="alt_profile",
is_default=False,
is_active=True,
max_streams=1,
search_pattern="",
replace_pattern="",
)
redis = FakeRedis()
self.assertTrue(reserve_profile_slot(default, redis)[0])
with patch("core.utils.RedisClient.get_client", return_value=redis):
result = _get_m3u_profile(account, None, None)
self.assertIsNotNone(result)
selected, _connections = result
self.assertEqual(selected.id, alt.id)
def test_get_m3u_profile_skips_default_when_credential_pool_full(self):
from apps.proxy.vod_proxy.views import _get_m3u_profile
group = ServerGroup.objects.create(name="vod-cred-pool")
account = M3UAccount.objects.create(
name="VOD pooled",
account_type="XC",
username="shared_user",
password="shared_pass",
server_url="http://xc.example.com",
server_group=group,
max_streams=5,
)
default = M3UAccountProfile.objects.get(m3u_account=account, is_default=True)
default.max_streams = 1
default.save()
alt = M3UAccountProfile.objects.create(
m3u_account=account,
name="alt_login",
is_default=False,
is_active=True,
max_streams=1,
search_pattern="",
replace_pattern="",
)
other_account = M3UAccount.objects.create(
name="Other pooled account",
account_type="XC",
username="shared_user",
password="shared_pass",
server_url="http://xc.example.com",
server_group=group,
max_streams=5,
)
other_profile = M3UAccountProfile.objects.get(
m3u_account=other_account, is_default=True
)
other_profile.max_streams = 1
other_profile.save()
fp_shared = "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc"
fp_alt = "dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd"
redis = FakeRedis()
with patch(
"apps.m3u.connection_pool.get_profile_credential_fingerprint",
side_effect=lambda profile: (
fp_shared
if profile.id in (default.id, other_profile.id)
else fp_alt
),
):
self.assertTrue(reserve_profile_slot(other_profile, redis)[0])
with patch("core.utils.RedisClient.get_client", return_value=redis):
result = _get_m3u_profile(account, None, None)
self.assertIsNotNone(result)
selected, _connections = result
self.assertEqual(selected.id, alt.id)

View file

@ -30,9 +30,47 @@ class ProcessM3UBatchCleanupTests(SimpleTestCase):
mock_stream_cls.generate_hash_key = MagicMock(return_value="hash123") mock_stream_cls.generate_hash_key = MagicMock(return_value="hash123")
with patch("django.db.connections") as mock_connections: with patch("django.db.connections") as mock_connections:
process_m3u_batch_direct(1, [], {}, ["name", "url"]) process_m3u_batch_direct(1, [], {}, ["name", "url"], compiled_filters=[])
mock_connections.close_all.assert_called() mock_connections.close_all.assert_called()
@patch("apps.m3u.tasks.Stream")
@patch("apps.m3u.tasks.M3UAccount")
def test_batch_calls_gc_collect(self, mock_account_cls, mock_stream_cls):
"""gc.collect() must run after each batch so XC refresh threads release promptly."""
from apps.m3u.tasks import process_m3u_batch_direct
mock_account = MagicMock()
mock_account.filters.order_by.return_value = []
mock_account_cls.objects.get.return_value = mock_account
mock_stream_cls.objects.filter.return_value.select_related.return_value.only.return_value = (
[]
)
mock_stream_cls.generate_hash_key = MagicMock(return_value="hash123")
with patch("gc.collect") as mock_gc, patch("django.db.connections"):
process_m3u_batch_direct(1, [], {}, ["name", "url"], compiled_filters=[])
mock_gc.assert_called()
@patch("apps.m3u.tasks.Stream")
@patch("apps.m3u.tasks.M3UAccount")
def test_precompiled_empty_filters_skip_db_lookup(
self, mock_account_cls, mock_stream_cls,
):
"""When filters are precompiled as empty, batch workers must not query filters."""
from apps.m3u.tasks import process_m3u_batch_direct
mock_account = MagicMock()
mock_account_cls.objects.get.return_value = mock_account
mock_stream_cls.objects.filter.return_value.select_related.return_value.only.return_value = (
[]
)
mock_stream_cls.generate_hash_key = MagicMock(return_value="hash123")
with patch("django.db.connections"):
process_m3u_batch_direct(1, [], {}, ["name", "url"], compiled_filters=[])
mock_account.filters.order_by.assert_not_called()
class LockReleaseTests(SimpleTestCase): class LockReleaseTests(SimpleTestCase):
"""Verify task lock is released on all exit paths.""" """Verify task lock is released on all exit paths."""

View file

@ -0,0 +1,46 @@
"""M3U account password visibility for admin vs standard users."""
from django.contrib.auth import get_user_model
from django.test import TestCase
from rest_framework.test import APIRequestFactory
from apps.m3u.models import M3UAccount
from apps.m3u.serializers import M3UAccountSerializer
class M3UPasswordVisibilityTests(TestCase):
def setUp(self):
User = get_user_model()
self.admin = User.objects.create_user(
username="m3u_pwd_admin",
password="x",
user_level=User.UserLevel.ADMIN,
)
self.standard = User.objects.create_user(
username="m3u_pwd_user",
password="x",
user_level=User.UserLevel.STANDARD,
)
self.account = M3UAccount.objects.create(
name="XC Acc",
server_url="http://example.test",
account_type="XC",
username="xcuser",
password="super-secret",
)
self.factory = APIRequestFactory()
def test_admin_sees_password(self):
request = self.factory.get("/api/m3u/accounts/")
request.user = self.admin
data = M3UAccountSerializer(
self.account, context={"request": request}
).data
self.assertEqual(data.get("password"), "super-secret")
def test_standard_user_does_not_see_password(self):
request = self.factory.get("/api/m3u/accounts/")
request.user = self.standard
data = M3UAccountSerializer(
self.account, context={"request": request}
).data
self.assertNotIn("password", data)

View file

@ -0,0 +1,74 @@
from unittest.mock import MagicMock, patch
from django.test import SimpleTestCase
from apps.m3u.tasks import (
_db_query_with_retry,
_get_active_m3u_account,
_release_task_db_connection,
refresh_single_m3u_account,
)
class DbQueryWithRetryTests(SimpleTestCase):
def test_retries_after_index_error_from_poisoned_connection(self):
fn = MagicMock(side_effect=[IndexError("list index out of range"), "ok"])
with patch(
"apps.m3u.tasks._release_task_db_connection"
) as mock_release:
result = _db_query_with_retry(fn, label="test query")
self.assertEqual(result, "ok")
self.assertEqual(fn.call_count, 2)
mock_release.assert_called_once()
def test_raises_after_exhausting_retries(self):
fn = MagicMock(side_effect=IndexError("list index out of range"))
with patch("apps.m3u.tasks._release_task_db_connection"):
with self.assertRaises(IndexError):
_db_query_with_retry(fn, label="test query", max_retries=2)
self.assertEqual(fn.call_count, 2)
class RefreshTaskDbStartupTests(SimpleTestCase):
@patch("apps.m3u.tasks._ensure_m3u_refresh_terminal_status")
@patch("apps.m3u.tasks._refresh_single_m3u_account_impl")
@patch("apps.m3u.tasks.release_task_lock")
@patch("apps.m3u.tasks.acquire_task_lock", return_value=True)
@patch("apps.m3u.tasks.TaskLockRenewer")
@patch("apps.m3u.tasks._release_task_db_connection")
def test_refresh_releases_db_connection_before_impl(
self,
mock_release,
_mock_renewer,
_mock_acquire,
_mock_release_lock,
mock_impl,
_mock_ensure_terminal,
):
call_order = []
def track_release():
call_order.append("release")
mock_release.side_effect = track_release
mock_impl.side_effect = lambda *_a, **_k: call_order.append("impl") or "done"
result = refresh_single_m3u_account(140)
self.assertEqual(result, "done")
self.assertEqual(call_order[:2], ["release", "impl"])
@patch("apps.m3u.tasks.M3UAccount")
def test_get_active_m3u_account_uses_retry_helper(self, mock_model):
mock_model.objects.get.return_value = MagicMock(is_active=True)
with patch("apps.m3u.tasks._db_query_with_retry") as mock_retry:
mock_retry.side_effect = lambda fn, **_: fn()
account = _get_active_m3u_account(140)
mock_retry.assert_called_once()
self.assertTrue(account.is_active)

View file

@ -0,0 +1,212 @@
"""
Differential parity tests: the auto-sync rename preview must predict the exact
name the live rename produces, across a broad matrix of regex strategies.
Both the preview and the live rename compile with the third-party `regex`
module (for its JS-aligned syntax and per-call timeout). They can only be
trusted together if, for every find/replace a user might author, the preview's
predicted `after` equals the channel name the sync actually writes.
Each case is run end-to-end: real streams, the real sync_auto_channels rename,
and the real regex-preview endpoint, compared per original stream name.
"""
from django.test import TestCase
from django.utils import timezone
from apps.channels.models import Channel, ChannelGroup, ChannelStream, Stream
from apps.m3u.models import M3UAccount
from apps.m3u.tasks import sync_auto_channels
# Diverse names: distinct word-prefixes (collision-resistant), with quality
# tags, brackets, pipes, ampersands, extra whitespace, underscores, dots, CJK,
# and emoji, to exercise anchors, classes, boundaries, and Unicode handling.
NAMES = [
"Alpha Channel 11",
"Bravo Sports HD",
"Charlie News FHD",
"Delta Movie (2024)",
"Echo [UK] 4K",
"Foxtrot spaced",
"Golf|Pipe|Name",
"Hotel & Inn <x>",
"India 日本語 77",
"Juliet 📺 88",
"Kilo_under_9",
"Lima.dot.name",
]
# (find, replace) pairs spanning common user strategies and edge cases.
STRATEGIES = [
# --- capture groups ---
(r"(.+) Channel (\d+)", r"$1 #$2"),
(r"(\w+) (\w+)", r"$2 $1"),
(r"(.+)", r"$1 - $1"),
(r"(.+)", r"[$1]"),
(r"(.+) (\d+)$", r"$2 $1"),
# --- strip / delete ---
(r" (HD|FHD|4K|SD)\b", r""),
(r"\s+", r" "),
(r"[\[\(].*?[\]\)]", r""),
(r"\d+", r""),
(r"[_.]", r" "),
# --- anchors / inserts ---
(r"^", r"NEW "),
(r"$", r" LIVE"),
(r"^(\w+)", r"<$1>"),
# --- char classes / Unicode (divergence hunters) ---
(r"\w+", r"W"),
(r"\b\w", r"_"),
(r"[A-Z]", r"*"),
(r"\s", r"_"),
(r"[^\x00-\x7F]+", r"?"),
# --- non-capturing / lookaround / in-pattern backref ---
(r"(?:Channel|Movie|News) ", r""),
(r"(\w)\1", r"$1"),
(r"\w+(?= )", r"X"),
(r"(?<=\d)\d", r"#"),
# --- literal $ and odd replacements ---
(r" ", r" $ "),
(r"o", r"0"),
# --- invalid group references (rejected by both engines) ---
(r"(.+)", r"$2"),
(r"(.+)", r"$10"),
# --- rename that expands past Channel.name's column length ---
(r"(.+)", r"$1" * 40),
# --- regex-module syntax: quantified anchor, JS-style and duplicate
# named groups (these transform; both paths use the regex module) ---
(r"^*", r"$"),
(r"(?<season>\d+)", r"S$1"),
(r"(?P<n>x)(?P<n>y)", r"z"),
]
class RenamePreviewParityTests(TestCase):
@classmethod
def setUpClass(cls):
super().setUpClass()
from rest_framework.test import APIClient
from apps.accounts.models import User
admin = User.objects.create_superuser(
username="admin_rename_parity", password="pw", user_level=10
)
cls.client_api = APIClient()
cls.client_api.force_authenticate(user=admin)
cls.account = M3UAccount.objects.create(
name="Rename Parity Provider",
server_url="http://example.com/test.m3u",
)
def _sync(self):
return sync_auto_channels(
self.account.id,
scan_start_time=(
timezone.now() - timezone.timedelta(minutes=1)
).isoformat(),
)
def _run_case(self, group_name, find, replace):
"""Returns a list of human-readable mismatch strings (empty == parity)."""
group = ChannelGroup.objects.create(name=group_name)
from apps.channels.models import ChannelGroupM3UAccount
ChannelGroupM3UAccount.objects.create(
m3u_account=self.account,
channel_group=group,
enabled=True,
auto_channel_sync=True,
auto_sync_channel_start=1000,
custom_properties={
"name_regex_pattern": find,
"name_replace_pattern": replace,
},
)
for i, name in enumerate(NAMES):
Stream.objects.create(
name=name,
url=f"http://example.com/{group_name}_{i}.m3u8",
m3u_account=self.account,
channel_group=group,
tvg_id=f"{group_name}-{i}",
last_seen=timezone.now(),
)
# --- live rename via sync ---
result = self._sync()
if result.get("status") != "ok":
return [f"[{find!r} -> {replace!r}] sync status={result.get('status')}"]
channels = Channel.objects.filter(
auto_created_by=self.account, channel_group=group
)
cs_rows = ChannelStream.objects.filter(
channel__in=channels
).select_related("channel", "stream")
sync_map = {row.stream.name: row.channel.name for row in cs_rows}
# --- preview endpoint ---
response = self.client_api.get(
"/api/channels/streams/regex-preview/",
{
"channel_group": group_name,
"find": find,
"replace": replace,
"limit": 50,
},
)
if response.status_code != 200:
return [f"[{find!r} -> {replace!r}] preview HTTP {response.status_code}"]
data = response.data
# When the preview reports find_error it predicts no rename at all.
preview_map = {name: name for name in NAMES}
if "find_error" not in data:
for m in data.get("find_matches", []):
preview_map[m["before"]] = m["after"]
mismatches = []
for name in NAMES:
sync_after = sync_map.get(name)
if sync_after is None:
mismatches.append(
f"[{find!r} -> {replace!r}] {name!r}: no channel created"
)
continue
preview_after = preview_map[name]
if preview_after != sync_after:
mismatches.append(
f"[{find!r} -> {replace!r}] {name!r}: "
f"preview={preview_after!r} sync={sync_after!r} "
f"(find_error={data.get('find_error')!r})"
)
return mismatches
def test_preview_predicts_rename_across_strategies(self):
all_mismatches = []
for idx, (find, replace) in enumerate(STRATEGIES):
all_mismatches.extend(
self._run_case(f"ParityG{idx}", find, replace)
)
self.assertEqual(
all_mismatches,
[],
"Preview diverged from the live rename:\n"
+ "\n".join(all_mismatches),
)
def test_overlong_rename_is_bounded_not_aborting_sync(self):
# A rename that expands past Channel.name's column length must not
# abort the bulk_create sync. Both sync and preview cap at the column
# length so the channel is created (truncated) and the preview shows
# the same bounded name.
max_len = Channel._meta.get_field("name").max_length
mismatches = self._run_case("OverlongG", r"(.+)", "$1" * 40)
self.assertEqual(mismatches, [], "\n".join(mismatches))
group = ChannelGroup.objects.get(name="OverlongG")
channels = Channel.objects.filter(
auto_created_by=self.account, channel_group=group
)
self.assertEqual(channels.count(), len(NAMES))
self.assertTrue(all(len(c.name) <= max_len for c in channels))
self.assertTrue(any(len(c.name) == max_len for c in channels))

View file

@ -0,0 +1,156 @@
"""Tests for M3U stream filter compilation and batch application."""
from unittest.mock import MagicMock, patch
from django.test import SimpleTestCase, TestCase
from apps.m3u.tasks import (
_compile_m3u_stream_filters,
_stream_passes_m3u_filters,
process_m3u_batch_direct,
)
class CompileM3UStreamFiltersTests(SimpleTestCase):
def test_compiles_case_insensitive_when_configured(self):
filter_obj = MagicMock()
filter_obj.regex_pattern = "news"
filter_obj.custom_properties = {"case_sensitive": False}
compiled = _compile_m3u_stream_filters([filter_obj])
self.assertEqual(len(compiled), 1)
pattern, _ = compiled[0]
self.assertTrue(pattern.search("NEWS"))
def test_compiles_case_sensitive_by_default(self):
filter_obj = MagicMock()
filter_obj.regex_pattern = "news"
filter_obj.custom_properties = {}
compiled = _compile_m3u_stream_filters([filter_obj])
pattern, _ = compiled[0]
self.assertIsNone(pattern.search("NEWS"))
self.assertTrue(pattern.search("news"))
class StreamPassesM3UFiltersTests(SimpleTestCase):
def _compiled(self, *, filter_type="name", exclude=False, pattern="Adult"):
filter_obj = MagicMock()
filter_obj.filter_type = filter_type
filter_obj.exclude = exclude
filter_obj.regex_pattern = pattern
filter_obj.custom_properties = {}
return _compile_m3u_stream_filters([filter_obj])
def test_include_filter_passes_matching_stream(self):
compiled = self._compiled(exclude=False)
self.assertTrue(
_stream_passes_m3u_filters("Adult Channel", "http://x", "News", compiled)
)
def test_include_filter_passes_non_matching_stream(self):
"""Non-matching streams still pass unless a matching exclude filter hits."""
compiled = self._compiled(exclude=False, pattern="news")
self.assertTrue(
_stream_passes_m3u_filters("Sports", "http://x", "Sports", compiled)
)
def test_exclude_filter_rejects_matching_stream(self):
compiled = self._compiled(exclude=True, pattern="Adult")
self.assertFalse(
_stream_passes_m3u_filters("Adult Channel", "http://x", "News", compiled)
)
def test_url_filter_type_targets_url(self):
compiled = self._compiled(filter_type="url", exclude=True, pattern="blocked")
self.assertFalse(
_stream_passes_m3u_filters("OK", "http://blocked.example/live", "News", compiled)
)
self.assertTrue(
_stream_passes_m3u_filters("blocked name", "http://ok.example/live", "News", compiled)
)
def test_group_filter_type_targets_group(self):
compiled = self._compiled(filter_type="group", exclude=True, pattern="Hidden")
self.assertFalse(
_stream_passes_m3u_filters("Channel", "http://x", "Hidden Group", compiled)
)
class ProcessM3UBatchFilterTests(TestCase):
def _mock_stream_meta(self, mock_stream_cls, max_length=255):
mock_field = MagicMock()
mock_field.max_length = max_length
mock_stream_cls._meta.get_field.return_value = mock_field
@patch("apps.m3u.tasks._bulk_update_stream_refresh_batches")
@patch("apps.m3u.tasks.Stream")
@patch("apps.m3u.tasks.M3UAccount")
def test_exclude_filter_skips_stream_import(
self, mock_account_cls, mock_stream_cls, mock_bulk_update,
):
self._mock_stream_meta(mock_stream_cls)
mock_account = MagicMock()
mock_account.account_type = "STD"
mock_account_cls.objects.get.return_value = mock_account
mock_stream_cls.objects.filter.return_value.select_related.return_value.only.return_value = (
[]
)
mock_stream_cls.generate_hash_key = MagicMock(return_value="hash123")
filter_obj = MagicMock()
filter_obj.regex_pattern = "skip-me"
filter_obj.filter_type = "name"
filter_obj.exclude = True
filter_obj.custom_properties = {}
compiled = _compile_m3u_stream_filters([filter_obj])
batch = [{
"name": "skip-me channel",
"url": "http://example/live",
"attributes": {"group-title": "News"},
"vlc_opts": {},
}]
with patch("django.db.connections"):
result = process_m3u_batch_direct(
1, batch, {"News": 1}, ["name", "url"], compiled_filters=compiled,
)
self.assertIn("0 created", result)
mock_stream_cls.objects.bulk_create.assert_not_called()
mock_bulk_update.assert_called_once_with([], [], batch_size=200)
@patch("apps.m3u.tasks._bulk_update_stream_refresh_batches")
@patch("apps.m3u.tasks.Stream")
@patch("apps.m3u.tasks.M3UAccount")
def test_no_filters_imports_matching_stream(
self, mock_account_cls, mock_stream_cls, mock_bulk_update,
):
self._mock_stream_meta(mock_stream_cls)
mock_account = MagicMock()
mock_account.account_type = "STD"
mock_account_cls.objects.get.return_value = mock_account
mock_stream_cls.objects.filter.return_value.select_related.return_value.only.return_value = (
[]
)
mock_stream_cls.generate_hash_key = MagicMock(return_value="hash123")
mock_stream_cls.objects.bulk_create.return_value = []
batch = [{
"name": "News One",
"url": "http://example/live",
"attributes": {"group-title": "News"},
"vlc_opts": {},
}]
with patch("django.db.connections"), patch(
"apps.m3u.tasks.transaction.atomic",
):
result = process_m3u_batch_direct(
1, batch, {"News": 1}, ["name", "url"], compiled_filters=[],
)
self.assertIn("1 created", result)
mock_stream_cls.objects.bulk_create.assert_called_once()

View file

@ -7,6 +7,7 @@ the correct post-fix behavior. Comments call out the failure mode and the
fix location. fix location.
""" """
from unittest import skipUnless from unittest import skipUnless
from unittest.mock import MagicMock
from django.db import connection from django.db import connection
from django.test import TestCase, TransactionTestCase from django.test import TestCase, TransactionTestCase
@ -46,13 +47,14 @@ def _attach_group_to_account(account, group, custom_properties=None):
) )
def _make_stream(account, group, name="ESPN", tvg_id="espn"): def _make_stream(account, group, name="ESPN", tvg_id="espn", stream_chno=None):
return Stream.objects.create( return Stream.objects.create(
name=name, name=name,
url=f"http://example.com/{name.lower()}.m3u8", url=f"http://example.com/{name.lower()}.m3u8",
m3u_account=account, m3u_account=account,
channel_group=group, channel_group=group,
tvg_id=tvg_id, tvg_id=tvg_id,
stream_chno=stream_chno,
last_seen=timezone.now(), last_seen=timezone.now(),
) )
@ -272,16 +274,37 @@ class AccountDeleteCleanupTests(TestCase):
class ChannelDeleteStopsProxyTests(TestCase): class ChannelDeleteStopsProxyTests(TestCase):
""" """
Issue #870: When an auto-sync refresh deletes a channel that has an Issue #870: sync-driven channel deletes must stop active proxy sessions
active proxy session, the session's Redis state survives, making the UI before the DB row is removed. Manual deletes leave this optional via the
"Stop" button fail with 'Channel not found'. Fix is a pre_delete signal stop_stream API flag (plain model.delete() does not stop).
on Channel that calls ChannelService.stop_channel first, covering
manual, bulk, and sync-triggered deletes uniformly.
""" """
def test_pre_delete_signal_calls_stop_channel(self): def test_model_delete_does_not_auto_stop_proxy(self):
from unittest.mock import patch from unittest.mock import patch
account = _make_account()
group = _make_group(name="Sports")
channel = Channel.objects.create(
name="ESPN",
channel_number=1,
channel_group=group,
auto_created=True,
auto_created_by=account,
)
with patch(
"apps.proxy.live_proxy.services.channel_service.ChannelService.stop_channel"
) as mock_stop:
channel.delete()
mock_stop.assert_not_called()
self.assertFalse(Channel.objects.filter(id=channel.id).exists())
def test_sync_helper_stops_proxy_before_delete(self):
from unittest.mock import patch
from apps.m3u.tasks import _delete_channels_stopping_streams
account = _make_account() account = _make_account()
group = _make_group(name="Sports") group = _make_group(name="Sports")
channel = Channel.objects.create( channel = Channel.objects.create(
@ -294,16 +317,22 @@ class ChannelDeleteStopsProxyTests(TestCase):
channel_uuid = str(channel.uuid) channel_uuid = str(channel.uuid)
with patch( with patch(
"apps.proxy.live_proxy.services.channel_service.ChannelService.stop_channel" "apps.proxy.live_proxy.services.channel_service.ChannelService.stop_channels"
) as mock_stop: ) as mock_stop:
channel.delete() deleted = _delete_channels_stopping_streams([channel])
mock_stop.assert_called_once_with(channel_uuid) self.assertEqual(deleted, 1)
mock_stop.assert_called_once()
stopped = [str(u) for u in mock_stop.call_args[0][0] if u]
self.assertEqual(stopped, [channel_uuid])
self.assertFalse(Channel.objects.filter(id=channel.id).exists())
def test_pre_delete_signal_swallows_stop_errors(self): def test_sync_helper_continues_delete_when_stop_channel_fails(self):
"""Proxy failure must not block the DB delete.""" """Per-channel proxy failures must not block the DB delete."""
from unittest.mock import patch from unittest.mock import patch
from apps.m3u.tasks import _delete_channels_stopping_streams
account = _make_account() account = _make_account()
group = _make_group(name="Sports") group = _make_group(name="Sports")
channel = Channel.objects.create( channel = Channel.objects.create(
@ -313,14 +342,16 @@ class ChannelDeleteStopsProxyTests(TestCase):
auto_created=True, auto_created=True,
auto_created_by=account, auto_created_by=account,
) )
channel_id = channel.id
with patch( with patch(
"apps.proxy.live_proxy.services.channel_service.ChannelService.stop_channel", "apps.proxy.live_proxy.services.channel_service.ChannelService.stop_channel",
side_effect=Exception("proxy is down"), side_effect=Exception("proxy is down"),
): ):
channel.delete() deleted = _delete_channels_stopping_streams([channel])
self.assertFalse(Channel.objects.filter(id=channel.id).exists()) self.assertEqual(deleted, 1)
self.assertFalse(Channel.objects.filter(id=channel_id).exists())
class RangeEnforcementTests(TestCase): class RangeEnforcementTests(TestCase):
@ -413,21 +444,21 @@ class RangeEnforcementTests(TestCase):
"new auto-channel duplicates A's effective channel number.", "new auto-channel duplicates A's effective channel number.",
) )
def test_provider_mode_fallback_respects_range_start(self): def test_provider_mode_numberless_fallback_uses_visible_start(self):
# Provider-mode streams without a usable stream_chno fall back to # In provider mode the visible "Start #" is channel_numbering_fallback,
# the next-available picker. The fallback must honor the group's # so a numberless stream's fallback walks from there, not from the
# configured `auto_sync_channel_start` so freshly-created channels # hidden auto_sync_channel_start (set far above the range here to prove
# never land below the user's chosen range. Without this, a group # it is ignored).
# configured for [100, 200] silently spawned channels at #1 when # Fail signature: 0 channels created, or a channel below 100 = fallback
# the provider omitted channel-number metadata. # seeded from the wrong field.
account = _make_account() account = _make_account()
group = _make_group(name="Sports") group = _make_group(name="Sports")
rel = _attach_group_to_account(account, group) rel = _attach_group_to_account(account, group)
rel.auto_sync_channel_start = 100 rel.auto_sync_channel_start = 5000 # hidden; must be ignored
rel.auto_sync_channel_end = 200 rel.auto_sync_channel_end = 200
rel.custom_properties = { rel.custom_properties = {
"channel_numbering_mode": "provider", "channel_numbering_mode": "provider",
"channel_numbering_fallback": 1, "channel_numbering_fallback": 100, # the visible "Start #"
} }
rel.save() rel.save()
@ -643,6 +674,34 @@ class NumbersInRangeLookupTests(TestCase):
) )
self.assertFalse(occupant["has_channel_number_override"]) self.assertFalse(occupant["has_channel_number_override"])
def test_group_override_channel_reports_target_group(self):
# When auto-sync routes channels into a different group via
# group_override, the occupant's channel_group_id is the override
# target, not the source group being configured. The frontend relies
# on this to recognize override-routed channels as the config's own
# output (effectiveSyncGroupId), so the warning does not flag them.
account = _make_account()
source = _make_group(name="SourceGrp")
target = _make_group(name="TargetGrp")
Channel.objects.create(
name="Routed",
channel_number=3210,
channel_group=target,
auto_created=True,
auto_created_by=account,
)
client = self._client()
response = client.get(
"/api/channels/channels/numbers-in-range/?start=3210&end=3210"
)
occupant = response.data["occupants"][0]
self.assertEqual(occupant["channel_group_id"], target.id)
self.assertNotEqual(occupant["channel_group_id"], source.id)
self.assertTrue(occupant["auto_created"])
self.assertEqual(occupant["auto_created_by_account_id"], account.id)
def test_manual_channel_exposed_with_auto_created_false(self): def test_manual_channel_exposed_with_auto_created_false(self):
# Manual channels are always a real collision worth surfacing. # Manual channels are always a real collision worth surfacing.
# The response must flag them with auto_created=False and a null # The response must flag them with auto_created=False and a null
@ -734,6 +793,128 @@ class RegexPreviewTests(TestCase):
self.assertEqual(response.data["total_scanned"], 3) self.assertEqual(response.data["total_scanned"], 3)
self.assertFalse(response.data["scan_limit_hit"]) self.assertFalse(response.data["scan_limit_hit"])
def test_find_replace_applies_numbered_capture_group(self):
# The replace field accepts JS-style $1 backreferences, but the regex
# engine expects \1. Without the conversion the preview echoes the
# literal "$1", so the previewed "after" disagrees with the name the
# live rename produces.
account = self._make_account()
group = _make_group(name="Sports")
Stream.objects.create(
name="High Limit Racing at Eagle @ Jun 9 7:00 PM",
url="http://example.com/hlr.m3u8",
m3u_account=account,
channel_group=group,
last_seen=timezone.now(),
)
client = self._client()
response = client.get(
"/api/channels/streams/regex-preview/",
{"channel_group": "Sports", "find": r"(.+) @.*", "replace": "$1"},
)
self.assertEqual(response.status_code, 200)
self.assertEqual(response.data["find_match_count"], 1)
after = response.data["find_matches"][0]["after"]
self.assertEqual(after, "High Limit Racing at Eagle")
self.assertNotIn("$1", after)
def test_preview_after_matches_live_sync_rename(self):
# Guards the defect class: the preview and the live rename are
# separate code paths that must convert the replacement identically,
# so the preview can never promise an output the sync would not yield.
name = "High Limit Racing at Eagle @ Jun 9 7:00 PM"
account = self._make_account()
group = _make_group(name="Racing")
_attach_group_to_account(
account,
group,
custom_properties={
"name_regex_pattern": r"(.+) @.*",
"name_replace_pattern": "$1",
},
)
_make_stream(account, group, name=name, tvg_id="hlr")
result = _sync(account)
self.assertEqual(result.get("status"), "ok")
channel = Channel.objects.get(auto_created=True, auto_created_by=account)
live_name = channel.name
client = self._client()
response = client.get(
"/api/channels/streams/regex-preview/",
{"channel_group": "Racing", "find": r"(.+) @.*", "replace": "$1"},
)
self.assertEqual(response.status_code, 200)
preview_after = response.data["find_matches"][0]["after"]
self.assertEqual(preview_after, live_name)
self.assertEqual(preview_after, "High Limit Racing at Eagle")
def test_regex_engine_pattern_transforms_in_preview(self):
# Both the preview and the live rename use the regex module, which is
# more permissive than stdlib re and matches the JS-style syntax the UI
# authors. A quantified anchor like "^*" (which stdlib re rejects)
# compiles and transforms rather than reporting an error.
account = self._make_account()
group = _make_group(name="Sports")
Stream.objects.create(
name="Doc95",
url="http://example.com/doc95.m3u8",
m3u_account=account,
channel_group=group,
last_seen=timezone.now(),
)
client = self._client()
response = client.get(
"/api/channels/streams/regex-preview/",
{"channel_group": "Sports", "find": "^*", "replace": "$"},
)
self.assertEqual(response.status_code, 200)
self.assertNotIn("find_error", response.data)
self.assertEqual(response.data["find_match_count"], 1)
# ^* matches the empty string at every position, so the literal $
# replacement is inserted between characters.
self.assertEqual(
response.data["find_matches"][0]["after"], "$D$o$c$9$5$"
)
def test_preview_and_sync_agree_on_regex_only_pattern(self):
# Parity guard for the engine alignment: a pattern valid in regex but
# not stdlib re must transform identically in the sync and the preview,
# rather than diverging (the sync no longer silently keeps the
# original name for these patterns).
name = "Doc95"
account = self._make_account()
group = _make_group(name="Docs")
_attach_group_to_account(
account,
group,
custom_properties={
"name_regex_pattern": "^*",
"name_replace_pattern": "$",
},
)
_make_stream(account, group, name=name, tvg_id="doc95")
result = _sync(account)
self.assertEqual(result.get("status"), "ok")
channel = Channel.objects.get(auto_created=True, auto_created_by=account)
live_name = channel.name
self.assertNotEqual(live_name, name)
client = self._client()
response = client.get(
"/api/channels/streams/regex-preview/",
{"channel_group": "Docs", "find": "^*", "replace": "$"},
)
self.assertEqual(response.status_code, 200)
self.assertEqual(response.data["find_matches"][0]["after"], live_name)
def test_filter_returns_matched_names_with_count(self): def test_filter_returns_matched_names_with_count(self):
account = self._make_account() account = self._make_account()
group = _make_group(name="Sports") group = _make_group(name="Sports")
@ -2039,10 +2220,13 @@ class Migration0037DemoteOrphansTests(TestCase):
"apps.channels.migrations.0037_auto_sync_overhaul" "apps.channels.migrations.0037_auto_sync_overhaul"
) )
# The migration function takes (apps, schema_editor); apps is connection = MagicMock()
# the historical app registry. For this unit test we call with cursor = MagicMock()
# the live registry. connection.cursor.return_value.__enter__.return_value = cursor
module.backfill_auto_created_by_null(django_apps, None) connection.cursor.return_value.__exit__.return_value = False
schema_editor = MagicMock(connection=connection)
module.backfill_auto_created_by_null(django_apps, schema_editor)
ch.refresh_from_db() ch.refresh_from_db()
self.assertFalse( self.assertFalse(
@ -2300,3 +2484,315 @@ class CompactNumberingIdempotencyTests(TransactionTestCase):
"pass with no hide or override change. _repack_inner is following " "pass with no hide or override change. _repack_inner is following "
"physical row order instead of id order.", "physical row order instead of id order.",
) )
class ProviderNumberingHonorsProviderNumberTests(TestCase):
"""
Provider numbering uses a stream's provider number (stream_chno) verbatim.
The group start is auto-populated by the UI and is not editable in provider
mode (the UI binds "Start #" to channel_numbering_fallback), so treating it
as a lower bound silently discarded valid provider numbers: on a lineup
topping out near 5000, provider numbers 100-150 landed at ~5000.
The start and end bound only the fallback for numberless streams.
"""
def test_provider_number_below_high_auto_start_is_honored(self):
# Provider numbers 100-104 with an auto-set start of 5000 must land at
# their provider numbers.
# Fail signature: channels at 5000-5004 = start used as a hard floor.
account = _make_account()
group = _make_group(name="PPV")
rel = _attach_group_to_account(account, group)
rel.auto_sync_channel_start = 5000
rel.auto_sync_channel_end = None
rel.custom_properties = {
"channel_numbering_mode": "provider",
"channel_numbering_fallback": 1,
}
rel.save()
for i in range(5):
_make_stream(
account, group, name=f"PPV {i}", tvg_id=f"ppv{i}",
stream_chno=100 + i,
)
result = _sync(account)
self.assertEqual(result["status"], "ok")
numbers = sorted(
Channel.objects.filter(
auto_created=True, auto_created_by=account
).values_list("channel_number", flat=True)
)
self.assertEqual(numbers, [100.0, 101.0, 102.0, 103.0, 104.0])
def test_provider_number_honored_when_start_unset(self):
# start blank -> defaults to 1.0; provider numbers still honored.
account = _make_account()
group = _make_group(name="PPV")
rel = _attach_group_to_account(account, group)
rel.auto_sync_channel_start = None
rel.auto_sync_channel_end = None
rel.custom_properties = {"channel_numbering_mode": "provider"}
rel.save()
_make_stream(account, group, name="PPV", tvg_id="ppv", stream_chno=100)
result = _sync(account)
self.assertEqual(result["status"], "ok")
created = Channel.objects.get(auto_created=True, auto_created_by=account)
self.assertEqual(created.channel_number, 100.0)
def test_numberless_stream_uses_fallback_not_hidden_start(self):
# In provider mode without a range, a stream lacking a provider
# number falls back to channel_numbering_fallback (the visible
# "Start #"), not the hidden auto_sync_channel_start.
# Fail signature: channel at 5000 = fallback bumped to hidden start.
account = _make_account()
group = _make_group(name="PPV")
rel = _attach_group_to_account(account, group)
rel.auto_sync_channel_start = 5000
rel.auto_sync_channel_end = None
rel.custom_properties = {
"channel_numbering_mode": "provider",
"channel_numbering_fallback": 300,
}
rel.save()
_make_stream(account, group, name="NoChno", tvg_id="nc")
result = _sync(account)
self.assertEqual(result["status"], "ok")
created = Channel.objects.get(auto_created=True, auto_created_by=account)
self.assertEqual(created.channel_number, 300.0)
def test_provider_number_below_range_is_honored_verbatim(self):
# A provider number below the group's Start/End is used as-is, not
# coerced into the range.
# Fail signature: channel pulled to >= 100 = range coercing a provider
# number.
account = _make_account()
group = _make_group(name="PPV")
rel = _attach_group_to_account(account, group)
rel.auto_sync_channel_end = 200
rel.custom_properties = {
"channel_numbering_mode": "provider",
"channel_numbering_fallback": 100,
}
rel.save()
_make_stream(account, group, name="Low", tvg_id="low", stream_chno=50)
result = _sync(account)
self.assertEqual(result["status"], "ok")
created = Channel.objects.get(auto_created=True, auto_created_by=account)
self.assertEqual(created.channel_number, 50.0)
def test_provider_number_above_end_is_honored_verbatim(self):
# Provider numbers above the configured End are also honored as-is;
# the End caps only the fallback for numberless streams.
account = _make_account()
group = _make_group(name="PPV")
rel = _attach_group_to_account(account, group)
rel.auto_sync_channel_end = 200
rel.custom_properties = {
"channel_numbering_mode": "provider",
"channel_numbering_fallback": 1,
}
rel.save()
_make_stream(account, group, name="High", tvg_id="high", stream_chno=5000)
result = _sync(account)
self.assertEqual(result["status"], "ok")
created = Channel.objects.get(auto_created=True, auto_created_by=account)
self.assertEqual(created.channel_number, 5000.0)
def test_provider_number_within_range_is_honored(self):
# An in-range provider number is used as-is.
account = _make_account()
group = _make_group(name="PPV")
rel = _attach_group_to_account(account, group)
rel.auto_sync_channel_end = 200
rel.custom_properties = {
"channel_numbering_mode": "provider",
"channel_numbering_fallback": 1,
}
rel.save()
_make_stream(account, group, name="Mid", tvg_id="mid", stream_chno=150)
result = _sync(account)
self.assertEqual(result["status"], "ok")
created = Channel.objects.get(auto_created=True, auto_created_by=account)
self.assertEqual(created.channel_number, 150.0)
def test_duplicate_provider_numbers_keep_one_and_fall_back_the_other(self):
# Two streams claim the same provider number (common in messy event
# feeds). One keeps it; the colliding one falls back to a different free
# number rather than being dropped or overwriting the first.
account = _make_account()
group = _make_group(name="PPV")
rel = _attach_group_to_account(account, group)
rel.custom_properties = {
"channel_numbering_mode": "provider",
"channel_numbering_fallback": 1,
}
rel.save()
_make_stream(account, group, name="A", tvg_id="a", stream_chno=77250)
_make_stream(account, group, name="B", tvg_id="b", stream_chno=77250)
result = _sync(account)
self.assertEqual(result["status"], "ok")
numbers = sorted(
Channel.objects.filter(
auto_created=True, auto_created_by=account
).values_list("channel_number", flat=True)
)
self.assertEqual(len(numbers), 2)
self.assertIn(77250.0, numbers)
self.assertNotEqual(numbers[0], numbers[1])
def test_provider_number_colliding_with_manual_channel_falls_back(self):
# A provider number matching an existing channel must not overwrite it;
# the auto-created channel falls back to a different free number.
account = _make_account()
group = _make_group(name="PPV")
rel = _attach_group_to_account(account, group)
rel.custom_properties = {
"channel_numbering_mode": "provider",
"channel_numbering_fallback": 1,
}
rel.save()
manual = Channel.objects.create(name="Manual", channel_number=88250)
_make_stream(account, group, name="P", tvg_id="p", stream_chno=88250)
result = _sync(account)
self.assertEqual(result["status"], "ok")
created = Channel.objects.get(auto_created=True, auto_created_by=account)
self.assertNotEqual(created.channel_number, 88250.0)
manual.refresh_from_db()
self.assertEqual(manual.channel_number, 88250.0)
def test_provider_fallback_exhaustion_reports_visible_start(self):
# RANGE_EXHAUSTED must cite the fallback range (channel_numbering_fallback
# to End), not the hidden auto_sync_channel_start left from another mode.
account = _make_account()
group = _make_group(name="PPV")
rel = _attach_group_to_account(account, group)
rel.auto_sync_channel_start = 5000
rel.auto_sync_channel_end = 102
rel.custom_properties = {
"channel_numbering_mode": "provider",
"channel_numbering_fallback": 100,
}
rel.save()
for i in range(4):
_make_stream(account, group, name=f"S{i}", tvg_id=f"s{i}")
result = _sync(account)
self.assertEqual(result["channels_created"], 3)
self.assertEqual(result["channels_failed"], 1)
error = result["failed_stream_details"][0]["error"]
self.assertIn("100-102", error)
self.assertNotIn("5000", error)
class CrossModeNumberingFieldTests(TestCase):
"""
Each numbering mode's UI exposes only a subset of the persisted fields,
and switching modes does not reset the others. The backend must therefore
read only the fields a mode actually owns, so a stale/hidden value left by
another mode cannot silently change numbering. These guard the two
remaining facets of that family (the provider-floor facet is covered by
ProviderNumberingHonorsProviderNumberTests).
"""
def _restamp(self, account):
Stream.objects.filter(m3u_account=account).update(
last_seen=timezone.now()
)
def test_next_available_ignores_configured_end(self):
# next_available exposes no Start/End in its UI, so a stale End left
# over from a prior mode must not cap it. Every stream gets the lowest
# free number from 1 regardless of the End.
# Fail signature: streams beyond the End fail = next_available honoring
# a hidden cap.
account = _make_account()
group = _make_group(name="PPV")
rel = _attach_group_to_account(account, group)
rel.auto_sync_channel_start = 1
rel.auto_sync_channel_end = 3 # stale cap from a prior mode
rel.custom_properties = {"channel_numbering_mode": "next_available"}
rel.save()
for i in range(5):
_make_stream(account, group, name=f"S{i}", tvg_id=f"s{i}")
result = _sync(account)
self.assertEqual(result["status"], "ok")
self.assertEqual(result["channels_created"], 5)
self.assertEqual(result["channels_failed"], 0)
def test_provider_channels_outside_range_are_not_deleted(self):
# Range enforcement (the overflow-delete) is fixed-mode only. A provider
# channel whose number is outside [start, end] is authoritative and must
# survive sync, not be deleted and churned into a new row.
# Fail signature: channels_deleted > 0 on the second sync = overflow
# delete firing in provider mode.
account = _make_account()
group = _make_group(name="PPV")
rel = _attach_group_to_account(account, group)
rel.auto_sync_channel_end = 200
rel.custom_properties = {
"channel_numbering_mode": "provider",
"channel_numbering_fallback": 1,
}
rel.save()
_make_stream(account, group, name="High", tvg_id="high", stream_chno=5000)
first = _sync(account)
self.assertEqual(first["channels_created"], 1)
original = Channel.objects.get(auto_created=True, auto_created_by=account)
self.assertEqual(original.channel_number, 5000.0)
self._restamp(account)
second = _sync(account)
self.assertEqual(second["channels_deleted"], 0)
survivor = Channel.objects.get(auto_created=True, auto_created_by=account)
self.assertEqual(survivor.id, original.id)
self.assertEqual(survivor.channel_number, 5000.0)
def test_next_available_channels_outside_stale_range_not_deleted(self):
# Same gate for next_available: tightening a stale End must not delete
# already-assigned channels (range enforcement is fixed-mode only).
account = _make_account()
group = _make_group(name="PPV")
rel = _attach_group_to_account(account, group)
rel.auto_sync_channel_start = 1
rel.auto_sync_channel_end = None
rel.custom_properties = {"channel_numbering_mode": "next_available"}
rel.save()
for i in range(5):
_make_stream(account, group, name=f"S{i}", tvg_id=f"s{i}")
self.assertEqual(_sync(account)["channels_created"], 5)
rel.auto_sync_channel_end = 3 # stale cap appears
rel.save()
self._restamp(account)
second = _sync(account)
self.assertEqual(second["channels_deleted"], 0)
self.assertEqual(
Channel.objects.filter(
auto_created=True, auto_created_by=account
).count(),
5,
)

View file

@ -0,0 +1,131 @@
"""
Regression test for the Xtream Codes empty-fetch channel wipe.
Bug: when an XC provider returns no live streams on a routine refresh (a
transient upstream failure, a fetch exception, or no enabled category
matching), ``collect_xc_streams`` returns ``[]`` and the refresh used to fall
through to stale-marking and ``sync_auto_channels``. With nothing "seen" this
refresh, auto-sync deletes the account's entire auto-created channel lineup.
Fix: ``_refresh_single_m3u_account_impl`` aborts the XC branch when
``collect_xc_streams`` returns empty, setting the account to ERROR before any
stale-marking or auto-sync runs, mirroring the standard-path empty guards.
"""
from unittest.mock import MagicMock, patch
from django.test import TransactionTestCase
from django.utils import timezone
from apps.channels.models import (
Channel,
ChannelGroup,
ChannelGroupM3UAccount,
Stream,
)
from apps.m3u.models import M3UAccount
from apps.m3u.tasks import _refresh_single_m3u_account_impl
class XCEmptyFetchGuardTests(TransactionTestCase):
def _setup_xc_account_with_auto_channel(self):
account = M3UAccount.objects.create(
name="Test XC Provider",
server_url="http://example.com",
username="user",
password="pass",
account_type=M3UAccount.Types.XC,
is_active=True,
)
group = ChannelGroup.objects.create(name="Sports")
ChannelGroupM3UAccount.objects.create(
m3u_account=account,
channel_group=group,
enabled=True,
auto_channel_sync=True,
auto_sync_channel_start=100,
custom_properties={"xc_id": "123"},
)
# A pre-existing stream and the auto-created channel built from it on a
# prior healthy refresh -- this is exactly what the bug deletes.
stream = Stream.objects.create(
name="ESPN",
url="http://example.com/espn.m3u8",
m3u_account=account,
channel_group=group,
last_seen=timezone.now(),
is_stale=False,
)
channel = Channel.objects.create(
channel_number=100,
name="ESPN",
channel_group=group,
auto_created=True,
auto_created_by=account,
)
return account, group, stream, channel
@patch("apps.m3u.tasks.sync_auto_channels")
@patch("apps.m3u.tasks.collect_xc_streams", return_value=[])
@patch("apps.m3u.tasks.refresh_m3u_groups")
def test_empty_xc_fetch_aborts_before_sync_and_preserves_channels(
self, mock_refresh_groups, _mock_collect, mock_sync
):
account, group, stream, channel = self._setup_xc_account_with_auto_channel()
# XC refresh: empty extinf_data is normal, groups must be present.
mock_refresh_groups.return_value = ([], {"Sports": group.id})
result = _refresh_single_m3u_account_impl(account.id)
# The refresh aborts, so auto channel sync never runs.
mock_sync.assert_not_called()
# The auto-created channel survives the empty fetch.
self.assertTrue(Channel.objects.filter(pk=channel.pk).exists())
# The stream is not marked stale (stale-marking is skipped on abort).
stream.refresh_from_db()
self.assertFalse(stream.is_stale)
# The account is surfaced as errored, not silently "successful".
account.refresh_from_db()
self.assertEqual(account.status, M3UAccount.Status.ERROR)
self.assertIn("no streams returned from provider", result)
@patch("apps.m3u.tasks.log_system_event")
@patch("apps.m3u.tasks.send_m3u_update")
@patch("apps.m3u.tasks.cleanup_stale_group_relationships")
@patch("apps.m3u.tasks.cleanup_streams", return_value=0)
@patch("apps.m3u.tasks.process_m3u_batch_direct", return_value="1 created, 0 updated")
@patch("apps.m3u.tasks.sync_auto_channels")
@patch("apps.m3u.tasks.refresh_m3u_groups")
def test_non_empty_xc_fetch_still_runs_sync(
self,
mock_refresh_groups,
mock_sync,
_mock_process,
_mock_cleanup_streams,
_mock_cleanup_groups,
_mock_ws,
_mock_log,
):
# The guard must not fire on a healthy refresh: a non-empty fetch
# proceeds to auto channel sync as before.
account, group, _stream, _channel = self._setup_xc_account_with_auto_channel()
mock_refresh_groups.return_value = ([], {"Sports": group.id})
mock_sync.return_value = {
"status": "ok",
"channels_created": 1,
"channels_updated": 0,
"channels_deleted": 0,
"channels_failed": 0,
"failed_stream_details": [],
}
xc_stream = {
"name": "ESPN",
"url": "http://example.com/espn.m3u8",
"attributes": {"group-title": "Sports", "stream_id": "1"},
}
with patch("apps.m3u.tasks.collect_xc_streams", return_value=[xc_stream]):
_refresh_single_m3u_account_impl(account.id)
mock_sync.assert_called_once()
account.refresh_from_db()
self.assertEqual(account.status, M3UAccount.Status.SUCCESS)

View file

@ -0,0 +1,139 @@
"""Tests for XC stream URL normalization and on-demand URL building."""
from django.test import TestCase
from apps.channels.models import Stream
from apps.m3u.models import M3UAccount, M3UAccountProfile
from apps.m3u.tasks import get_transformed_credentials
from apps.proxy.live_proxy.url_utils import _resolve_live_stream_url
from apps.vod.models import Episode, M3UEpisodeRelation, M3UMovieRelation, Movie, Series
from core.xtream_codes import normalize_server_url
class NormalizeServerUrlTests(TestCase):
def test_preserves_sub_path(self):
url = "https://myserver.fun/server1"
self.assertEqual(normalize_server_url(url), "https://myserver.fun/server1")
def test_strips_player_api_php_and_query_params(self):
url = "https://myserver.fun/server1/player_api.php?username=foo&password=bar"
self.assertEqual(normalize_server_url(url), "https://myserver.fun/server1")
def test_strips_trailing_slash(self):
url = "https://myserver.fun/server1/"
self.assertEqual(normalize_server_url(url), "https://myserver.fun/server1")
def test_nested_sub_path_with_php_endpoint(self):
url = "http://server/Pluto/gb/player_api.php"
self.assertEqual(normalize_server_url(url), "http://server/Pluto/gb")
class GetTransformedCredentialsTests(TestCase):
def test_returns_normalized_server_url(self):
account = M3UAccount.objects.create(
name="Sub-path XC",
account_type="XC",
server_url="https://myserver.fun/server1/player_api.php?username=foo",
username="alice",
password="secret",
)
profile = M3UAccountProfile.objects.get(m3u_account=account, is_default=True)
server_url, username, password = get_transformed_credentials(account, profile)
self.assertEqual(server_url, "https://myserver.fun/server1")
self.assertEqual(username, "alice")
self.assertEqual(password, "secret")
class ResolveLiveStreamUrlTests(TestCase):
def test_builds_url_from_normalized_base_not_raw_account_url(self):
account = M3UAccount.objects.create(
name="Live sub-path",
account_type="XC",
server_url="https://myserver.fun/server1/player_api.php?username=foo",
username="alice",
password="secret",
)
profile = M3UAccountProfile.objects.get(m3u_account=account, is_default=True)
stream = Stream.objects.create(
name="Test Channel",
m3u_account=account,
stream_id="12345",
url="https://myserver.fun/server1/live/olduser/oldpass/12345.ts",
)
url = _resolve_live_stream_url(stream, account, profile)
self.assertEqual(
url,
"https://myserver.fun/server1/live/alice/secret/12345.ts",
)
def test_std_account_uses_stored_stream_url(self):
account = M3UAccount.objects.create(
name="STD account",
account_type="STD",
server_url="https://example.com/list.m3u",
username="alice",
password="secret",
)
profile = M3UAccountProfile.objects.get(m3u_account=account, is_default=True)
stream = Stream.objects.create(
name="STD Stream",
m3u_account=account,
url="https://provider.example/stream/abc123",
)
url = _resolve_live_stream_url(stream, account, profile)
self.assertEqual(url, "https://provider.example/stream/abc123")
class VodStreamUrlTests(TestCase):
def setUp(self):
self.account = M3UAccount.objects.create(
name="VOD sub-path",
account_type="XC",
server_url="https://myserver.fun/server1/player_api.php?username=foo",
username="alice",
password="secret",
)
def test_movie_relation_builds_normalized_url(self):
movie = Movie.objects.create(name="Test Movie")
relation = M3UMovieRelation.objects.create(
m3u_account=self.account,
movie=movie,
stream_id="999",
container_extension="mkv",
)
url = relation.get_stream_url()
self.assertEqual(
url,
"https://myserver.fun/server1/movie/alice/secret/999.mkv",
)
def test_episode_relation_builds_normalized_url(self):
series = Series.objects.create(name="Test Series")
episode = Episode.objects.create(
series=series,
name="Pilot",
season_number=1,
episode_number=1,
)
relation = M3UEpisodeRelation.objects.create(
m3u_account=self.account,
episode=episode,
stream_id="888",
container_extension="mp4",
)
url = relation.get_stream_url()
self.assertEqual(
url,
"https://myserver.fun/server1/series/alice/secret/888.mp4",
)

View file

@ -0,0 +1,66 @@
"""Tests for native xz (.xz) support for uploaded M3U playlists.
Mirrors the existing .gz handling: an uploaded .xz playlist is treated as a
streamable text source (opened lazily via _open_m3u_text_source), never
loaded fully into memory like the .zip path.
"""
import lzma
import os
import tempfile
from django.test import SimpleTestCase, TestCase
from apps.m3u.models import M3UAccount
from apps.m3u.tasks import _open_m3u_text_source, fetch_m3u_lines
SAMPLE_M3U = (
"#EXTM3U\n"
'#EXTINF:-1 tvg-id="channel.one",Channel One\n'
"http://example.com/stream1\n"
)
class OpenM3uTextSourceXzTests(SimpleTestCase):
def test_opens_xz_playlist_for_line_by_line_reading(self):
xz_path = None
try:
with tempfile.NamedTemporaryFile(suffix=".xz", delete=False) as xz_file:
xz_path = xz_file.name
xz_file.write(lzma.compress(SAMPLE_M3U.encode("utf-8")))
with _open_m3u_text_source(xz_path) as f:
content = f.read()
self.assertEqual(content, SAMPLE_M3U)
finally:
if xz_path and os.path.exists(xz_path):
os.unlink(xz_path)
class FetchM3uLinesXzUploadTests(TestCase):
def setUp(self):
self.xz_path = None
def tearDown(self):
if self.xz_path and os.path.exists(self.xz_path):
os.unlink(self.xz_path)
def test_fetch_m3u_lines_returns_path_for_xz_upload(self):
with tempfile.NamedTemporaryFile(suffix=".xz", delete=False) as xz_file:
self.xz_path = xz_file.name
xz_file.write(lzma.compress(SAMPLE_M3U.encode("utf-8")))
account = M3UAccount.objects.create(
name="XZ upload account",
file_path=self.xz_path,
)
source, success = fetch_m3u_lines(account)
self.assertTrue(success)
# Like the .gz path, .xz playlists are streamed rather than loaded
# into memory, so fetch_m3u_lines hands back the path itself.
self.assertEqual(source, self.xz_path)
with _open_m3u_text_source(source) as f:
self.assertEqual(f.read(), SAMPLE_M3U)

View file

@ -1,4 +1,5 @@
# apps/m3u/utils.py # apps/m3u/utils.py
import regex
import threading import threading
import logging import logging
from django.db import models from django.db import models
@ -9,6 +10,18 @@ active_streams_map = {}
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
def convert_js_numbered_backreferences(replacement):
"""Translate JS-style ``$1``/``$2`` backreferences to Python ``\\1``/``\\2``.
Auto-sync replace patterns are authored in JS regex syntax, but Python's
regex engines honor backslash backreferences, not ``$1``. The live rename
and the UI preview must convert identically, so both call this single
helper and cannot drift apart (otherwise the preview promises an output
the sync would never produce).
"""
return regex.sub(r"\$(\d+)", r"\\\1", replacement)
def normalize_stream_url(url): def normalize_stream_url(url):
""" """
Normalize stream URLs for compatibility with FFmpeg. Normalize stream URLs for compatibility with FFmpeg.

1739
apps/output/epg.py Normal file

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,242 @@
"""Single-flight Redis chunk cache for large streaming HTTP responses."""
import logging
import time
from django.http import StreamingHttpResponse
logger = logging.getLogger(__name__)
STATUS_BUILDING = "building"
STATUS_READY = "ready"
STATUS_ERROR = "error"
DEFAULT_CACHE_TTL = 300
DEFAULT_LOCK_TTL = 120
DEFAULT_POLL_INTERVAL = 0.05
DEFAULT_MAX_FOLLOWER_WAIT = 600
def _chunks_key(base_key):
return f"{base_key}:chunks"
def _ready_key(base_key):
return f"{base_key}:ready"
def _status_key(base_key):
return f"{base_key}:status"
def _lock_key(base_key):
return f"{base_key}:lock"
def _decode_chunk(chunk):
if chunk is None:
return None
if isinstance(chunk, bytes):
return chunk.decode("utf-8")
return chunk
def _encode_chunk(chunk):
if isinstance(chunk, bytes):
return chunk
return chunk.encode("utf-8")
def _poll_wait(interval):
try:
from core.utils import _is_gevent_monkey_patched
if _is_gevent_monkey_patched():
import gevent
gevent.sleep(interval)
return
except ImportError:
pass
time.sleep(interval)
def _get_redis():
from django_redis import get_redis_connection
return get_redis_connection("default")
def _get_status(redis, base_key):
raw = redis.get(_status_key(base_key))
if raw is None:
return None
return _decode_chunk(raw)
def _clear_build_keys(redis, base_key):
redis.delete(
_chunks_key(base_key),
_status_key(base_key),
_ready_key(base_key),
_lock_key(base_key),
)
def _try_acquire_lock(redis, base_key, lock_ttl):
return bool(redis.set(_lock_key(base_key), "1", nx=True, ex=lock_ttl))
def _refresh_build_ttl(redis, base_key, lock_ttl):
redis.expire(_lock_key(base_key), lock_ttl)
redis.expire(_status_key(base_key), lock_ttl)
redis.expire(_chunks_key(base_key), lock_ttl)
def _stream_ready(redis, base_key):
offset = 0
chunks_key = _chunks_key(base_key)
while True:
chunk = redis.lindex(chunks_key, offset)
if chunk is None:
break
yield _decode_chunk(chunk)
offset += 1
def _stream_build(redis, base_key, source, cache_ttl, lock_ttl):
"""Leader: stream to client and append each chunk to Redis."""
chunks_key = _chunks_key(base_key)
status_key = _status_key(base_key)
try:
from django.core.cache import cache as django_cache
django_cache.delete(base_key) # clear any non-chunked entry under this key
redis.delete(chunks_key, _ready_key(base_key))
redis.set(status_key, STATUS_BUILDING, ex=lock_ttl)
refresh_interval = max(1, lock_ttl // 4)
last_refresh = 0.0
from core.utils import _cooperative_yield
for chunk in source():
redis.rpush(chunks_key, _encode_chunk(chunk))
now = time.monotonic()
if now - last_refresh >= refresh_interval:
_refresh_build_ttl(redis, base_key, lock_ttl)
last_refresh = now
_cooperative_yield()
yield chunk
redis.set(status_key, STATUS_READY)
redis.set(_ready_key(base_key), "1")
redis.expire(chunks_key, cache_ttl)
redis.expire(status_key, cache_ttl)
redis.expire(_ready_key(base_key), cache_ttl)
logger.debug("Cached response in %s chunks", redis.llen(chunks_key))
except Exception:
logger.exception("Chunk cache build failed for %s", base_key)
redis.delete(chunks_key)
redis.set(status_key, STATUS_ERROR, ex=60)
raise
finally:
redis.delete(_lock_key(base_key))
def _stream_follow(redis, base_key, source, cache_ttl, lock_ttl, poll_interval, max_follower_wait):
"""Follower: read chunks as the leader writes them."""
offset = 0
deadline = time.monotonic() + max_follower_wait
idle_polls = 0
chunks_key = _chunks_key(base_key)
lock_key = _lock_key(base_key)
while True:
chunk = redis.lindex(chunks_key, offset)
if chunk is not None:
idle_polls = 0
yield _decode_chunk(chunk)
offset += 1
continue
status = _get_status(redis, base_key)
if status == STATUS_READY:
break
if status == STATUS_ERROR:
_clear_build_keys(redis, base_key)
if offset == 0 and _try_acquire_lock(redis, base_key, lock_ttl):
yield from _stream_build(redis, base_key, source, cache_ttl, lock_ttl)
return
raise RuntimeError("Chunk cache build failed")
if time.monotonic() >= deadline:
if offset == 0 and _try_acquire_lock(redis, base_key, lock_ttl):
logger.warning("Chunk cache follower timed out; rebuilding %s", base_key)
yield from _stream_build(redis, base_key, source, cache_ttl, lock_ttl)
return
logger.warning("Chunk cache follower timed out after partial read for %s", base_key)
break
lock_active = bool(redis.exists(lock_key))
if status != STATUS_BUILDING and not lock_active:
idle_polls += 1
if offset == 0 and idle_polls >= max(1, int(1.0 / poll_interval)):
if _try_acquire_lock(redis, base_key, lock_ttl):
logger.warning("Chunk cache leader lost; rebuilding %s", base_key)
yield from _stream_build(redis, base_key, source, cache_ttl, lock_ttl)
return
else:
idle_polls = 0
_poll_wait(poll_interval)
def stream_cached_response(
cache_key,
source,
*,
content_type="application/xml",
filename=None,
cache_ttl=DEFAULT_CACHE_TTL,
lock_ttl=DEFAULT_LOCK_TTL,
poll_interval=DEFAULT_POLL_INTERVAL,
max_follower_wait=DEFAULT_MAX_FOLLOWER_WAIT,
redis=None,
):
"""
Stream a large response with single-flight Redis chunk caching.
``source`` must be a callable returning a chunk iterator. Only the leader
invokes it; concurrent followers replay chunks already written to Redis, so
the expensive ``source`` runs at most once per ``cache_key``.
"""
if redis is None:
redis = _get_redis()
if redis.get(_ready_key(cache_key)):
logger.debug("Serving response from chunk cache")
stream = _stream_ready(redis, cache_key)
else:
status = _get_status(redis, cache_key)
if status == STATUS_ERROR:
_clear_build_keys(redis, cache_key)
if _try_acquire_lock(redis, cache_key, lock_ttl):
logger.debug("Building response (cache leader)")
stream = _stream_build(redis, cache_key, source, cache_ttl, lock_ttl)
else:
logger.debug("Following in-flight cache build")
stream = _stream_follow(
redis,
cache_key,
source,
cache_ttl,
lock_ttl,
poll_interval,
max_follower_wait,
)
response = StreamingHttpResponse(stream, content_type=content_type)
if filename:
response["Content-Disposition"] = f'attachment; filename="{filename}"'
response["Cache-Control"] = "no-cache"
return response

View file

@ -1,145 +0,0 @@
from django.test import TestCase, Client
from django.urls import reverse
from apps.channels.models import Channel, ChannelGroup
from apps.epg.models import EPGData, EPGSource
import xml.etree.ElementTree as ET
class OutputM3UTest(TestCase):
def setUp(self):
self.client = Client()
def test_generate_m3u_response(self):
"""
Test that the M3U endpoint returns a valid M3U file.
"""
url = reverse('output:generate_m3u')
response = self.client.get(url)
self.assertEqual(response.status_code, 200)
content = response.content.decode()
self.assertIn("#EXTM3U", content)
def test_generate_m3u_response_post_empty_body(self):
"""
Test that a POST request with an empty body returns 200 OK.
"""
url = reverse('output:generate_m3u')
response = self.client.post(url, data=None, content_type='application/x-www-form-urlencoded')
content = response.content.decode()
self.assertEqual(response.status_code, 200, "POST with empty body should return 200 OK")
self.assertIn("#EXTM3U", content)
def test_generate_m3u_response_post_with_body(self):
"""
Test that a POST request with a non-empty body returns 403 Forbidden.
"""
url = reverse('output:generate_m3u')
response = self.client.post(url, data={'evilstring': 'muhahaha'})
self.assertEqual(response.status_code, 403, "POST with body should return 403 Forbidden")
self.assertIn("POST requests with body are not allowed, body is:", response.content.decode())
class OutputEPGXMLEscapingTest(TestCase):
"""Test XML escaping of channel_id attributes in EPG generation"""
def setUp(self):
self.client = Client()
self.group = ChannelGroup.objects.create(name="Test Group")
def test_channel_id_with_ampersand(self):
"""Test channel ID with ampersand is properly escaped"""
channel = Channel.objects.create(
channel_number=1.0,
name="Test Channel",
tvg_id="News & Sports",
channel_group=self.group
)
url = reverse('output:generate_epg') + '?tvg_id_source=tvg_id'
response = self.client.get(url)
self.assertEqual(response.status_code, 200)
content = response.content.decode()
# Should contain escaped ampersand
self.assertIn('id="News &amp; Sports"', content)
self.assertNotIn('id="News & Sports"', content)
# Verify XML is parseable
try:
ET.fromstring(content)
except ET.ParseError as e:
self.fail(f"Generated EPG is not valid XML: {e}")
def test_channel_id_with_angle_brackets(self):
"""Test channel ID with < and > characters"""
channel = Channel.objects.create(
channel_number=2.0,
name="HD Channel",
tvg_id="Channel <HD>",
channel_group=self.group
)
url = reverse('output:generate_epg') + '?tvg_id_source=tvg_id'
response = self.client.get(url)
content = response.content.decode()
self.assertIn('id="Channel &lt;HD&gt;"', content)
try:
ET.fromstring(content)
except ET.ParseError as e:
self.fail(f"Generated EPG with < > is not valid XML: {e}")
def test_channel_id_with_all_special_chars(self):
"""Test channel ID with all XML special characters"""
channel = Channel.objects.create(
channel_number=3.0,
name="Complex Channel",
tvg_id='Test & "Special" <Chars>',
channel_group=self.group
)
url = reverse('output:generate_epg') + '?tvg_id_source=tvg_id'
response = self.client.get(url)
content = response.content.decode()
self.assertIn('id="Test &amp; &quot;Special&quot; &lt;Chars&gt;"', content)
try:
tree = ET.fromstring(content)
# Verify we can find the channel with correct ID in parsed tree
channel_elem = tree.find('.//channel[@id="Test & \\"Special\\" <Chars>"]')
self.assertIsNotNone(channel_elem)
except ET.ParseError as e:
self.fail(f"Generated EPG with all special chars is not valid XML: {e}")
def test_program_channel_attribute_escaping(self):
"""Test that programme elements also have escaped channel attributes"""
epg_source = EPGSource.objects.create(name="Test EPG", source_type="dummy")
epg_data = EPGData.objects.create(name="Test EPG Data", epg_source=epg_source)
channel = Channel.objects.create(
channel_number=4.0,
name="Program Test",
tvg_id="News & Sports",
epg_data=epg_data,
channel_group=self.group
)
url = reverse('output:generate_epg') + '?tvg_id_source=tvg_id'
response = self.client.get(url)
content = response.content.decode()
# Check programme elements have escaped channel attributes
self.assertIn('channel="News &amp; Sports"', content)
try:
tree = ET.fromstring(content)
programmes = tree.findall('.//programme[@channel="News & Sports"]')
self.assertGreater(len(programmes), 0)
except ET.ParseError as e:
self.fail(f"Generated EPG with programme elements is not valid XML: {e}")

View file

View file

@ -0,0 +1,187 @@
import threading
import time
from unittest import TestCase
from apps.output.streaming_chunk_cache import (
STATUS_BUILDING,
STATUS_READY,
_chunks_key,
_lock_key,
_ready_key,
_status_key,
stream_cached_response,
)
class FakeRedis:
"""Minimal Redis stand-in for chunk-cache unit tests."""
def __init__(self):
self._strings = {}
self._lists = {}
self._expires_at = {}
def _purge_expired(self):
now = time.monotonic()
expired = [key for key, deadline in self._expires_at.items() if deadline <= now]
for key in expired:
self._strings.pop(key, None)
self._lists.pop(key, None)
self._expires_at.pop(key, None)
def get(self, key):
self._purge_expired()
return self._strings.get(key)
def set(self, key, value, nx=False, ex=None):
self._purge_expired()
if nx and key in self._strings:
return None
self._strings[key] = value
if ex is not None:
self._expires_at[key] = time.monotonic() + ex
return True
def delete(self, *keys):
for key in keys:
self._strings.pop(key, None)
self._lists.pop(key, None)
self._expires_at.pop(key, None)
def exists(self, key):
self._purge_expired()
return key in self._strings or key in self._lists
def expire(self, key, ttl):
if key in self._strings or key in self._lists:
self._expires_at[key] = time.monotonic() + ttl
return True
def rpush(self, key, value):
self._lists.setdefault(key, []).append(value)
def lindex(self, key, offset):
items = self._lists.get(key, [])
if offset < len(items):
return items[offset]
return None
def llen(self, key):
return len(self._lists.get(key, []))
def _consume(response):
return b"".join(response.streaming_content).decode("utf-8")
class StreamingChunkCacheTests(TestCase):
def test_leader_caches_chunks_and_sets_ready(self):
redis = FakeRedis()
calls = []
def source():
calls.append(1)
yield "<tv>"
yield "</tv>"
body = _consume(stream_cached_response("cache:test", source, redis=redis))
self.assertEqual(body, "<tv></tv>")
self.assertEqual(calls, [1])
self.assertEqual(redis.get(_ready_key("cache:test")), "1")
self.assertEqual(redis.get(_status_key("cache:test")), STATUS_READY)
self.assertEqual(redis.llen(_chunks_key("cache:test")), 2)
self.assertFalse(redis.exists(_lock_key("cache:test")))
def test_cache_hit_skips_source(self):
redis = FakeRedis()
calls = []
def source():
calls.append(1)
yield "<tv>"
yield "</tv>"
_consume(stream_cached_response("cache:test", source, redis=redis))
calls.clear()
body = _consume(stream_cached_response("cache:test", source, redis=redis))
self.assertEqual(body, "<tv></tv>")
self.assertEqual(calls, [])
def test_follower_reads_leader_chunks_without_rebuilding(self):
redis = FakeRedis()
base = "cache:follow"
leader_started = threading.Event()
rebuild_calls = []
def slow_source():
rebuild_calls.append(1)
leader_started.set()
yield "a"
time.sleep(0.05)
yield "b"
def forbidden_source():
rebuild_calls.append(2)
yield "SHOULD_NOT_RUN"
def leader():
_consume(
stream_cached_response(
base,
slow_source,
redis=redis,
poll_interval=0.01,
)
)
leader_thread = threading.Thread(target=leader)
leader_thread.start()
leader_started.wait(timeout=5)
follower_body = _consume(
stream_cached_response(
base,
forbidden_source,
redis=redis,
poll_interval=0.01,
)
)
leader_thread.join(timeout=5)
self.assertEqual(follower_body, "ab")
self.assertEqual(rebuild_calls, [1])
def test_only_one_leader_when_two_clients_start_together(self):
redis = FakeRedis()
build_calls = []
barrier = threading.Barrier(2)
results = {}
def source():
build_calls.append(threading.current_thread().name)
yield "x"
def worker():
barrier.wait()
results[threading.current_thread().name] = _consume(
stream_cached_response(
"cache:race",
source,
redis=redis,
poll_interval=0.01,
)
)
threads = [
threading.Thread(target=worker, name="t1"),
threading.Thread(target=worker, name="t2"),
]
for thread in threads:
thread.start()
for thread in threads:
thread.join(timeout=10)
self.assertEqual(results["t1"], "x")
self.assertEqual(results["t2"], "x")
self.assertEqual(len(build_calls), 1)

File diff suppressed because it is too large Load diff

View file

@ -1,6 +1,5 @@
from django.urls import path, re_path, include from django.urls import path, re_path, include
from .views import m3u_endpoint, epg_endpoint, xc_get, xc_movie_stream, xc_series_stream from .views import m3u_endpoint, epg_endpoint, xc_get, xc_movie_stream, xc_series_stream
from core.views import stream_view
app_name = "output" app_name = "output"
@ -9,6 +8,4 @@ urlpatterns = [
re_path(r"^m3u(?:/(?P<profile_name>[^/]+))?/?$", m3u_endpoint, name="m3u_endpoint"), re_path(r"^m3u(?:/(?P<profile_name>[^/]+))?/?$", m3u_endpoint, name="m3u_endpoint"),
# Allow `/epg`, `/epg/`, `/epg/profile_name`, and `/epg/profile_name/` # Allow `/epg`, `/epg/`, `/epg/profile_name`, and `/epg/profile_name/`
re_path(r"^epg(?:/(?P<profile_name>[^/]+))?/?$", epg_endpoint, name="epg_endpoint"), re_path(r"^epg(?:/(?P<profile_name>[^/]+))?/?$", epg_endpoint, name="epg_endpoint"),
# Allow both `/stream/<int:stream_id>` and `/stream/<int:stream_id>/`
re_path(r"^stream/(?P<channel_uuid>[0-9a-fA-F\-]+)/?$", stream_view, name="stream"),
] ]

File diff suppressed because it is too large Load diff

View file

@ -335,16 +335,28 @@ def _save_fetched_manifest_to_repo(repo, data, verified):
return None return None
def _resolve_manifest_base_urls(manifest: dict) -> tuple[str, str]:
"""Return (download_base_url, metadata_base_url) from a manifest dict.
Both fields fall back to root_url when not set. All base URL fields are
optional; callers must guard against empty strings before building URLs.
"""
root_url = manifest.get("root_url", "").rstrip("/")
download_base_url = manifest.get("download_base_url", "").rstrip("/") or root_url
metadata_base_url = manifest.get("metadata_base_url", "").rstrip("/") or root_url
return download_base_url, metadata_base_url
def _invalidate_plugin_detail_cache(repo_id, manifest_data): def _invalidate_plugin_detail_cache(repo_id, manifest_data):
manifest = manifest_data.get("manifest", manifest_data) manifest = manifest_data.get("manifest", manifest_data)
root_url = manifest.get("root_url", "").rstrip("/") _, metadata_base_url = _resolve_manifest_base_urls(manifest)
keys = [] keys = []
for p in manifest.get("plugins", []): for p in manifest.get("plugins", []):
url = p.get("manifest_url", "") url = p.get("manifest_url", "")
if not url: if not url:
continue continue
if root_url and not url.startswith(("http://", "https://")): if metadata_base_url and not url.startswith(("http://", "https://")):
url = f"{root_url}/{url}" url = f"{metadata_base_url}/{url}"
keys.append(f"plugin_detail:{repo_id}:{hashlib.md5(url.encode()).hexdigest()}") keys.append(f"plugin_detail:{repo_id}:{hashlib.md5(url.encode()).hexdigest()}")
if keys: if keys:
cache.delete_many(keys) cache.delete_many(keys)
@ -1045,18 +1057,28 @@ class AvailablePluginsAPIView(PluginAuthMixin, APIView):
for repo in repos: for repo in repos:
manifest_data = repo.cached_manifest or {} manifest_data = repo.cached_manifest or {}
manifest = manifest_data.get("manifest", manifest_data) manifest = manifest_data.get("manifest", manifest_data)
root_url = manifest.get("root_url", "").rstrip("/") download_base_url, metadata_base_url = _resolve_manifest_base_urls(manifest)
registry_url = manifest.get("registry_url", "").rstrip("/") registry_url = manifest.get("registry_url", "").rstrip("/")
repo_plugins = manifest.get("plugins", []) repo_plugins = manifest.get("plugins", [])
for p in repo_plugins: for p in repo_plugins:
slug = p.get("slug", "") slug = p.get("slug", "")
plugin_data = {**p} plugin_data = {**p}
# Resolve relative URLs against root_url; absolute URLs pass through # Resolve relative URLs; metadata and download assets use separate bases
if root_url: if metadata_base_url:
for url_field in ("manifest_url", "latest_url", "icon_url"): for url_field in ("manifest_url", "icon_url"):
val = plugin_data.get(url_field, "") val = plugin_data.get(url_field, "")
if val and not val.startswith(("http://", "https://")): if val and not val.startswith(("http://", "https://")):
plugin_data[url_field] = f"{root_url}/{val}" plugin_data[url_field] = f"{metadata_base_url}/{val}"
if download_base_url:
val = plugin_data.get("latest_url", "")
if val and not val.startswith(("http://", "https://")):
plugin_data["latest_url"] = f"{download_base_url}/{val}"
# Fallback icon_url: if metadata_base_url is explicitly set and manifest_url
# is known, assume logo.png lives in the same directory as the per-plugin
# manifest. Guard against root_url-only manifests to preserve the GitHub fallback.
if not plugin_data.get("icon_url") and manifest.get("metadata_base_url") and plugin_data.get("manifest_url"):
manifest_dir = plugin_data["manifest_url"].rsplit("/", 1)[0]
plugin_data["icon_url"] = f"{manifest_dir}/logo.png"
# Fallback icon_url from main branch when not provided # Fallback icon_url from main branch when not provided
if not plugin_data.get("icon_url") and registry_url: if not plugin_data.get("icon_url") and registry_url:
# registry_url is e.g. https://github.com/Dispatcharr/Plugins # registry_url is e.g. https://github.com/Dispatcharr/Plugins
@ -1161,18 +1183,18 @@ class PluginDetailManifestAPIView(PluginAuthMixin, APIView):
# Resolve relative URLs in versions # Resolve relative URLs in versions
repo_manifest = repo.cached_manifest or {} repo_manifest = repo.cached_manifest or {}
inner = repo_manifest.get("manifest", repo_manifest) inner = repo_manifest.get("manifest", repo_manifest)
root_url = inner.get("root_url", "").rstrip("/") download_base_url, _ = _resolve_manifest_base_urls(inner)
if root_url and isinstance(manifest_obj.get("versions"), list): if download_base_url and isinstance(manifest_obj.get("versions"), list):
for v in manifest_obj["versions"]: for v in manifest_obj["versions"]:
url_val = v.get("url", "") url_val = v.get("url", "")
if url_val and not url_val.startswith(("http://", "https://")): if url_val and not url_val.startswith(("http://", "https://")):
v["url"] = f"{root_url}/{url_val}" v["url"] = f"{download_base_url}/{url_val}"
if root_url and isinstance(manifest_obj.get("latest"), dict): if download_base_url and isinstance(manifest_obj.get("latest"), dict):
for url_field in ("url", "latest_url"): for url_field in ("url", "latest_url"):
url_val = manifest_obj["latest"].get(url_field, "") url_val = manifest_obj["latest"].get(url_field, "")
if url_val and not url_val.startswith(("http://", "https://")): if url_val and not url_val.startswith(("http://", "https://")):
manifest_obj["latest"][url_field] = f"{root_url}/{url_val}" manifest_obj["latest"][url_field] = f"{download_base_url}/{url_val}"
result = { result = {
"manifest": manifest_obj, "manifest": manifest_obj,

View file

@ -87,6 +87,8 @@ class PluginsConfig(AppConfig):
) )
def _setup_repo_refresh_schedule(self): def _setup_repo_refresh_schedule(self):
from django.db import close_old_connections
from dispatcharr.app_initialization import should_skip_initialization from dispatcharr.app_initialization import should_skip_initialization
if should_skip_initialization(): if should_skip_initialization():
return return
@ -116,3 +118,6 @@ class PluginsConfig(AppConfig):
logging.getLogger(__name__).debug( logging.getLogger(__name__).debug(
"Could not set up plugin repo refresh schedule (migrations may not have run yet)" "Could not set up plugin repo refresh schedule (migrations may not have run yet)"
) )
finally:
# Boot ORM runs outside a request cycle; return geventpool checkouts.
close_old_connections()

View file

@ -8,9 +8,9 @@ import sys
import threading import threading
import types import types
from dataclasses import dataclass, field from dataclasses import dataclass, field
from typing import Any, Dict, List, Optional from typing import Any, Dict, Iterator, List, Optional, Tuple
from django.db import transaction from django.db import close_old_connections, transaction
from .models import PluginConfig from .models import PluginConfig
@ -68,7 +68,13 @@ class PluginManager:
sync_db: bool = True, sync_db: bool = True,
force_reload: bool = False, force_reload: bool = False,
use_cache: bool = False, use_cache: bool = False,
release_connections: bool = True,
) -> Dict[str, LoadedPlugin]: ) -> Dict[str, LoadedPlugin]:
# Only an explicit caller force_reload broadcasts via the shared token.
# Reacting to a newer token must reload locally without re-touching it;
# otherwise every consumer becomes a producer and multi-worker setups
# never converge.
caller_force_reload = force_reload
token = self._get_reload_token() token = self._get_reload_token()
if use_cache and not force_reload: if use_cache and not force_reload:
with self._lock: with self._lock:
@ -76,7 +82,7 @@ class PluginManager:
return self._registry return self._registry
if token > self._last_reload_token: if token > self._last_reload_token:
force_reload = True force_reload = True
if force_reload: if caller_force_reload:
self._touch_reload_token() self._touch_reload_token()
token = self._get_reload_token() token = self._get_reload_token()
@ -92,6 +98,30 @@ class PluginManager:
key: lp.path for key, lp in self._registry.items() if lp and lp.path key: lp.path for key, lp in self._registry.items() if lp and lp.path
} }
try:
return self._discover_plugins_impl(
sync_db=sync_db,
force_reload=force_reload,
previous_packages=previous_packages,
previous_aliases=previous_aliases,
previous_paths=previous_paths,
token=token,
)
finally:
# Discovery runs outside Django's request/task cycle (boot, worker_ready).
if release_connections:
close_old_connections()
def _discover_plugins_impl(
self,
*,
sync_db: bool,
force_reload: bool,
previous_packages: Dict[str, str],
previous_aliases: Dict[str, str],
previous_paths: Dict[str, str],
token: int,
) -> Dict[str, LoadedPlugin]:
try: try:
configs: Optional[Dict[str, PluginConfig]] = None configs: Optional[Dict[str, PluginConfig]] = None
try: try:
@ -247,6 +277,23 @@ class PluginManager:
logger.exception("Deferring plugin DB sync; database not ready yet") logger.exception("Deferring plugin DB sync; database not ready yet")
return self._registry return self._registry
def iter_actions_for_event(self, event_name: str) -> Iterator[Tuple[str, str]]:
"""Yield (plugin_key, action_id) pairs from the in-memory registry."""
with self._lock:
registry = list(self._registry.items())
for key, lp in registry:
for action in lp.actions or []:
if not isinstance(action, dict):
continue
action_id = action.get("id")
events = action.get("events")
if (
action_id
and isinstance(events, (list, tuple))
and event_name in events
):
yield key, action_id
def _load_plugin( def _load_plugin(
self, self,
key: str, key: str,
@ -492,79 +539,85 @@ class PluginManager:
return cfg.settings return cfg.settings
def run_action(self, key: str, action_id: str, params: Optional[Dict[str, Any]] = None) -> Dict[str, Any]: def run_action(self, key: str, action_id: str, params: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
lp = self.get_plugin(key) try:
if not lp or not lp.instance:
# Attempt a lightweight re-discovery in case the registry was rebuilt
self.discover_plugins(sync_db=False, force_reload=False, use_cache=False)
lp = self.get_plugin(key) lp = self.get_plugin(key)
if not lp or not lp.instance: if not lp or not lp.instance:
raise ValueError(f"Plugin '{key}' not found") # Attempt a lightweight re-discovery in case the registry was rebuilt
self.discover_plugins(sync_db=False, force_reload=False, use_cache=False)
lp = self.get_plugin(key)
if not lp or not lp.instance:
raise ValueError(f"Plugin '{key}' not found")
cfg = PluginConfig.objects.get(key=key) cfg = PluginConfig.objects.get(key=key)
if not cfg.enabled: if not cfg.enabled:
raise PermissionError(f"Plugin '{key}' is disabled") raise PermissionError(f"Plugin '{key}' is disabled")
params = params or {} params = params or {}
context = self._build_context(lp, cfg) context = self._build_context(lp, cfg)
# Run either via Celery if plugin provides a delayed method, or inline run_method = getattr(lp.instance, "run", None)
run_method = getattr(lp.instance, "run", None) if not callable(run_method):
if not callable(run_method): raise ValueError(f"Plugin '{key}' has no runnable 'run' method")
raise ValueError(f"Plugin '{key}' has no runnable 'run' method")
try: try:
result = run_method(action_id, params, context) result = run_method(action_id, params, context)
except Exception: except Exception:
logger.exception(f"Plugin '{key}' action '{action_id}' failed") logger.exception(f"Plugin '{key}' action '{action_id}' failed")
raise raise
# Normalize return if isinstance(result, dict):
if isinstance(result, dict): return result
return result return {"status": "ok", "result": result}
return {"status": "ok", "result": result} finally:
# Return geventpool checkouts for this greenlet/thread after every action,
# including Connect event hooks and manual UI runs.
close_old_connections()
def stop_plugin(self, key: str, reason: Optional[str] = None) -> bool: def stop_plugin(self, key: str, reason: Optional[str] = None) -> bool:
lp = self.get_plugin(key)
if not lp or not lp.instance:
return False
try: try:
cfg = PluginConfig.objects.get(key=key) lp = self.get_plugin(key)
except PluginConfig.DoesNotExist: if not lp or not lp.instance:
return False return False
if not cfg.enabled:
return False
context = self._build_context(lp, cfg)
if reason:
context["reason"] = reason
stop_method = getattr(lp.instance, "stop", None)
if callable(stop_method):
try: try:
stop_method(context) cfg = PluginConfig.objects.get(key=key)
return True except PluginConfig.DoesNotExist:
except TypeError: return False
if not cfg.enabled:
return False
context = self._build_context(lp, cfg)
if reason:
context["reason"] = reason
stop_method = getattr(lp.instance, "stop", None)
if callable(stop_method):
try: try:
stop_method() stop_method(context)
return True return True
except TypeError:
try:
stop_method()
return True
except Exception:
logger.exception("Plugin '%s' stop() failed", key)
return False
except Exception: except Exception:
logger.exception("Plugin '%s' stop() failed", key) logger.exception("Plugin '%s' stop() failed", key)
return False return False
except Exception:
logger.exception("Plugin '%s' stop() failed", key)
return False
run_method = getattr(lp.instance, "run", None) run_method = getattr(lp.instance, "run", None)
if callable(run_method): if callable(run_method):
actions = {a.get("id") for a in (lp.actions or []) if isinstance(a, dict)} actions = {a.get("id") for a in (lp.actions or []) if isinstance(a, dict)}
if "stop" in actions: if "stop" in actions:
try: try:
run_method("stop", {}, context) run_method("stop", {}, context)
return True return True
except Exception: except Exception:
logger.exception("Plugin '%s' stop action failed", key) logger.exception("Plugin '%s' stop action failed", key)
return False return False
return False return False
finally:
close_old_connections()
def stop_all_plugins(self, reason: Optional[str] = None) -> int: def stop_all_plugins(self, reason: Optional[str] = None) -> int:
stopped = 0 stopped = 0

View file

View file

@ -0,0 +1,51 @@
from django.test import SimpleTestCase
from apps.plugins.loader import LoadedPlugin, PluginManager
class IterActionsForEventTests(SimpleTestCase):
def test_yields_matching_handlers(self):
pm = PluginManager()
pm._registry = {
"alpha": LoadedPlugin(
key="alpha",
name="Alpha",
actions=[
{"id": "on_start", "events": ["channel_start"]},
{"id": "manual"},
],
),
"beta": LoadedPlugin(
key="beta",
name="Beta",
actions=[
{"id": "on_both", "events": ["channel_start", "channel_stop"]},
],
),
}
self.assertEqual(
list(pm.iter_actions_for_event("channel_start")),
[("alpha", "on_start"), ("beta", "on_both")],
)
self.assertEqual(list(pm.iter_actions_for_event("channel_stop")), [("beta", "on_both")])
def test_ignores_string_events_value(self):
pm = PluginManager()
pm._registry = {
"bad": LoadedPlugin(
key="bad",
name="Bad",
actions=[{"id": "hook", "events": "client_connect"}],
),
"good": LoadedPlugin(
key="good",
name="Good",
actions=[{"id": "hook", "events": ["client_connect"]}],
),
}
self.assertEqual(
list(pm.iter_actions_for_event("client_connect")),
[("good", "hook")],
)

View file

@ -0,0 +1,124 @@
"""Reload-token handling must converge across workers.
Reacting to a stale .reload_token must reload locally without re-touching the
token. Re-touching turns every consumer into a producer and causes a permanent
force-reload ping-pong under multi-worker uWSGI.
"""
import os
import shutil
import tempfile
import time
from unittest.mock import patch
from django.test import SimpleTestCase
from apps.plugins.loader import PluginManager
class PluginReloadTokenTests(SimpleTestCase):
def setUp(self):
self._tmpdir = tempfile.mkdtemp(prefix="dispatcharr-plugins-")
self._env = patch.dict(os.environ, {"DISPATCHARR_PLUGINS_DIR": self._tmpdir})
self._env.start()
def tearDown(self):
self._env.stop()
shutil.rmtree(self._tmpdir, ignore_errors=True)
def _make_worker(self) -> PluginManager:
return PluginManager()
def _pin_token_mtime(self, worker: PluginManager, age_seconds: float = 60.0) -> float:
"""Write the token file with an older mtime so a retouch is detectable."""
worker._touch_reload_token()
pinned = time.time() - age_seconds
os.utime(worker._reload_token_path, (pinned, pinned))
return worker._get_reload_token()
def test_reacting_to_stale_token_does_not_retouch(self):
"""Consuming a reload signal must not re-broadcast it."""
worker = self._make_worker()
seed = self._pin_token_mtime(worker)
self.assertGreater(seed, 0.0)
# Process has never observed this token (fresh worker after a reload).
worker._last_reload_token = 0.0
worker._discovery_completed = False
worker.discover_plugins(sync_db=False, use_cache=True)
self.assertEqual(
worker._get_reload_token(),
seed,
"reacting to a stale reload token must not bump .reload_token",
)
self.assertEqual(worker._last_reload_token, seed)
def test_stale_token_still_force_reloads_locally(self):
"""A stale token must still purge/reload modules in this process."""
worker = self._make_worker()
self._pin_token_mtime(worker)
worker._last_reload_token = 0.0
worker._discovery_completed = False
with patch.object(worker, "_touch_reload_token") as mock_touch:
with patch.object(
worker, "_discover_plugins_impl", return_value={}
) as mock_impl:
worker.discover_plugins(sync_db=False, use_cache=True)
mock_touch.assert_not_called()
mock_impl.assert_called_once()
self.assertTrue(mock_impl.call_args.kwargs["force_reload"])
def test_explicit_force_reload_touches_token(self):
"""Caller-requested force_reload must broadcast to other workers."""
worker = self._make_worker()
seed = self._pin_token_mtime(worker)
worker._last_reload_token = seed
worker._discovery_completed = True
worker.discover_plugins(sync_db=False, force_reload=True)
self.assertGreater(worker._get_reload_token(), seed)
self.assertEqual(worker._last_reload_token, worker._get_reload_token())
def test_multi_worker_stale_reactions_converge(self):
"""Two workers reacting to one broadcast must not ping-pong forever."""
worker_a = self._make_worker()
worker_b = self._make_worker()
# Legitimate broadcast (install/update/reload API), pinned so any
# later consumer retouch is visible in mtime comparisons.
worker_a.discover_plugins(sync_db=False, force_reload=True)
broadcast = self._pin_token_mtime(worker_a)
worker_a._last_reload_token = broadcast
worker_a._discovery_completed = True
# Worker B has not seen the broadcast yet.
worker_b._last_reload_token = 0.0
worker_b._discovery_completed = False
# Alternate discoveries as connect-event dispatch would across workers.
for _ in range(5):
worker_a.discover_plugins(sync_db=False, use_cache=True)
worker_b.discover_plugins(sync_db=False, use_cache=True)
self.assertEqual(worker_a._get_reload_token(), broadcast)
self.assertEqual(worker_b._get_reload_token(), broadcast)
self.assertEqual(worker_a._last_reload_token, broadcast)
self.assertEqual(worker_b._last_reload_token, broadcast)
def test_cache_hit_skips_discovery_after_convergence(self):
"""Steady-state connect events must not re-enter discovery."""
worker = self._make_worker()
worker.discover_plugins(sync_db=False, force_reload=True)
with patch.object(
worker, "_discover_plugins_impl", return_value={}
) as mock_impl:
worker.discover_plugins(sync_db=False, use_cache=True)
worker.discover_plugins(sync_db=False, use_cache=True)
mock_impl.assert_not_called()

Some files were not shown because too many files have changed in this diff Show more