Compare commits

...

99 commits

Author SHA1 Message Date
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
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
97515c4d05 tests: fix proxy setting test 2026-06-30 10:34:40 -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
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
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
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
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
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
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
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
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
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
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
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
133 changed files with 16261 additions and 4837 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

View file

@ -7,6 +7,152 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
## [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
### Added

View file

@ -144,7 +144,19 @@ Untested code is significantly less likely to be merged.
- Use Django's `TestCase` for unit/integration 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

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
| Field | Required | Description |
| --------------- | -------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `registry_name` | **Yes** | Display name for the repo. Must not contain words like "official" or "dispatcharr" that could be mistaken for an official repo (see [Name Restrictions](#name-restrictions)). |
| `registry_url` | No | URL to the repo's home page (e.g. GitHub). Used as a fallback for generating icon URLs. |
| `root_url` | No | Base URL for resolving relative URLs in plugin entries. Trailing slashes are stripped. |
| `plugins` | **Yes** | Array of plugin entry objects. |
| Field | Required | Description |
| -------------------- | -------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `registry_name` | **Yes** | Display name for the repo. Must not contain words like "official" or "dispatcharr" that could be mistaken for an official repo (see [Name Restrictions](#name-restrictions)). |
| `registry_url` | No | URL to the repo's home page (e.g. GitHub). Used as a fallback for generating icon URLs. |
| `root_url` | No | 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. |
| `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
@ -155,13 +157,18 @@ Extra fields in a plugin entry are passed through to the frontend as-is, so you
### 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).
```
{root_url}/{field_value}
```
**Resolution priority:**
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
{
@ -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)
@ -563,7 +603,9 @@ You can host release zips as GitHub Release assets and reference them with absol
"manifest": {
"registry_name": "string (required)",
"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": [
{
"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.
### 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
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:

View file

@ -93,7 +93,8 @@ class QueryParamJWTAuthentication(JWTAuthentication):
where the browser cannot set Authorization headers (e.g. <video src>)."""
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:
return None
try:

View file

@ -13,6 +13,7 @@ from .api_views import (
UpdateChannelMembershipAPIView,
BulkUpdateChannelMembershipAPIView,
RecordingViewSet,
RECORDING_PLAYBACK_AUTHENTICATORS,
RecurringRecordingRuleViewSet,
GetChannelStreamsAPIView,
GetChannelStreamStatsAPIView,
@ -53,7 +54,10 @@ urlpatterns = [
path('recordings/bulk-delete-upcoming/', BulkDeleteUpcomingRecordingsAPIView.as_view(), name='bulk_delete_upcoming_recordings'),
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',
),
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.permissions import AllowAny
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 drf_spectacular.utils import extend_schema, OpenApiParameter, inline_serializer
from drf_spectacular.types import OpenApiTypes
@ -12,6 +14,7 @@ from django.db import connection, transaction
from django.db.models import Count, F, Prefetch
from django.db.models import Q
import os, json, requests, logging, mimetypes, threading, time
from urllib.parse import urlencode
from datetime import timedelta
from django.utils.http import http_date
from apps.accounts.permissions import (
@ -2087,7 +2090,7 @@ class ChannelViewSet(viewsets.ModelViewSet):
fields={
'channel_ids': serializers.ListField(
child=serializers.IntegerField(),
help_text='List of channel IDs to process. If empty or not provided, all channels without EPG will be processed.',
help_text='List of channel IDs to process (includes channels that already have EPG). If empty or not provided, only channels without EPG are processed.',
required=False,
)
}
@ -2120,23 +2123,15 @@ class ChannelViewSet(viewsets.ModelViewSet):
def match_channel_epg(self, request, pk=None):
channel = self.get_object()
# Import the matching logic
from apps.channels.tasks import match_single_channel_epg
try:
# Try to match this specific channel - call synchronously for immediate response
result = match_single_channel_epg.apply_async(args=[channel.id]).get(timeout=30)
# Refresh the channel from DB to get any updates
channel.refresh_from_db()
return Response({
"message": result.get("message", "Channel matching completed"),
"matched": result.get("matched", False),
"channel": self.get_serializer(channel).data
})
except Exception as e:
return Response({"error": str(e)}, status=400)
match_single_channel_epg.delay(channel.id)
return Response(
{
"message": f"EPG matching started for channel '{channel.name}'",
"accepted": True,
"channel_id": channel.id,
},
status=status.HTTP_202_ACCEPTED,
)
# ─────────────────────────────────────────────────────────
# 7) Set EPG and Refresh
@ -2171,22 +2166,14 @@ class ChannelViewSet(viewsets.ModelViewSet):
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.save(update_fields=["epg_data"])
# Only trigger program refresh for non-dummy EPG sources
status_message = None
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"
if task_result.result == "Task already running":
status_message = "EPG refresh already in progress"
# Build response message
message = f"EPG data set to {epg_data.tvg_id} for channel {channel.name}"
@ -2321,7 +2308,6 @@ class ChannelViewSet(viewsets.ModelViewSet):
# Extract channel IDs upfront
channel_updates = {}
unique_epg_ids = set()
for assoc in associations:
channel_id = assoc.get("channel_id")
@ -2331,24 +2317,28 @@ class ChannelViewSet(viewsets.ModelViewSet):
continue
channel_updates[channel_id] = epg_data_id
if epg_data_id:
unique_epg_ids.add(epg_data_id)
# Batch fetch all channels (single query)
channels_dict = {
c.id: c for c in Channel.objects.filter(id__in=channel_updates.keys())
}
# Collect channels to update
# Collect channels whose EPG assignment actually changes
channels_to_update = []
changed_epg_ids = set()
for channel_id, epg_data_id in channel_updates.items():
if channel_id not in channels_dict:
logger.error(f"Channel with ID {channel_id} not found")
continue
channel = channels_dict[channel_id]
if channel.epg_data_id == epg_data_id:
continue
channel.epg_data_id = epg_data_id
channels_to_update.append(channel)
if epg_data_id:
changed_epg_ids.add(epg_data_id)
# Bulk update all channels (single query)
if channels_to_update:
@ -2361,27 +2351,9 @@ class ChannelViewSet(viewsets.ModelViewSet):
channels_updated = len(channels_to_update)
# Trigger program refresh for unique EPG data IDs (skip dummy EPGs)
from apps.epg.tasks import parse_programs_for_tvg_id
from apps.epg.models import EPGData
from apps.epg.tasks import dispatch_program_refresh_for_epg_ids
# Batch fetch EPG data (single query)
epg_data_dict = {
epg.id: epg
for epg in EPGData.objects.filter(id__in=unique_epg_ids).select_related('epg_source')
}
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
programs_refreshed = dispatch_program_refresh_for_epg_ids(changed_epg_ids)
return Response(
{
@ -2805,8 +2777,9 @@ class LogoViewSet(viewsets.ModelViewSet):
user_agent_obj = UserAgent.objects.get(id=int(default_user_agent_id))
user_agent = user_agent_obj.user_agent
except (CoreSettings.DoesNotExist, UserAgent.DoesNotExist, ValueError):
# Fallback to hardcoded if default not found
user_agent = 'Dispatcharr/1.0'
# Fallback if default not found
from core.utils import dispatcharr_user_agent
user_agent = dispatcharr_user_agent()
# Hard total timeout (connect + full download) prevents a slow
# server dripping bytes from holding a greenlet indefinitely.
@ -3266,12 +3239,41 @@ def _stop_dvr_clients(channel_uuid, recording_id=None):
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):
queryset = Recording.objects.all()
serializer_class = RecordingSerializer
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'):
return [AllowAny()]
try:
@ -3335,7 +3337,12 @@ class RecordingViewSet(viewsets.ModelViewSet):
except Exception as e:
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):
"""Stream a completed recording file with HTTP Range support for seeking.
@ -3359,7 +3366,7 @@ class RecordingViewSet(viewsets.ModelViewSet):
if hls_dir and os.path.isdir(hls_dir):
hls_url = request.build_absolute_uri(
f"/api/channels/recordings/{pk}/hls/index.m3u8"
)
) + _recording_auth_query_suffix(request)
return HttpResponseRedirect(hls_url)
if not file_path or not os.path.exists(file_path):
raise Http404("Recording file not found")
@ -3423,7 +3430,12 @@ class RecordingViewSet(viewsets.ModelViewSet):
response["Content-Disposition"] = f"inline; filename=\"{file_name}\""
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):
"""Serve HLS playlist and segment files for an in-progress (or completed) recording.
@ -3447,9 +3459,10 @@ class RecordingViewSet(viewsets.ModelViewSet):
cp = recording.custom_properties or {}
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:
return HttpResponseRedirect(
request.build_absolute_uri(f"/api/channels/recordings/{pk}/file/")
)
file_url = request.build_absolute_uri(
f"/api/channels/recordings/{pk}/file/"
) + _recording_auth_query_suffix(request)
return HttpResponseRedirect(file_url)
raise Http404("HLS content not available for this recording")
# Security: prevent path traversal outside the HLS directory
@ -3462,16 +3475,18 @@ class RecordingViewSet(viewsets.ModelViewSet):
raise Http404(f"HLS file not found: {seg_path}")
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(
f"/api/channels/recordings/{pk}/hls/"
)
auth_suffix = _recording_auth_query_suffix(request)
lines = []
with open(requested) as _f:
for line in _f:
stripped = line.strip()
if stripped and not stripped.startswith("#"):
lines.append(f"{base_url}{stripped}\n")
lines.append(f"{base_url}{stripped}{auth_suffix}\n")
else:
lines.append(line)
return HttpResponse("".join(lines), content_type="application/x-mpegURL")

View file

@ -25,7 +25,8 @@ import logging
from django.db import transaction
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 (
acquire_task_lock,
natural_sort_key,
@ -37,7 +38,7 @@ logger = logging.getLogger(__name__)
def is_compact_group(group_relation):
"""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"))
@ -72,7 +73,7 @@ def get_group_relation_for_channel(channel):
for rel in ChannelGroupM3UAccount.objects.filter(
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:
return rel
return None
@ -179,7 +180,7 @@ def assign_compact_numbers_for_channels(channel_ids):
for rel in ChannelGroupM3UAccount.objects.filter(
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")
if not target:
continue
@ -295,7 +296,7 @@ def _repack_inner(group_relation):
account_id = group_relation.m3u_account_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_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

@ -2,9 +2,9 @@ from django.db import models
from django.core.exceptions import ValidationError
from django.conf import settings
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.constants import ChannelMetadataField
from apps.proxy.live_proxy.constants import ChannelMetadataField, ChannelState
import logging
import uuid
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:
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
@ -209,13 +210,14 @@ class Stream(models.Model):
Finds an available profile for this stream and reserves a connection slot.
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()
profile_id = redis_client.get(f"stream_profile:{self.id}")
if 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.
m3u_account = self.m3u_account
@ -226,29 +228,22 @@ class Stream(models.Model):
]
for profile in profiles:
logger.info(profile)
logger.debug("Evaluating profile %s for stream %s", profile.id, self.id)
# Skip inactive profiles
if profile.is_active == False:
continue
# Atomic slot reservation: INCR first, check, rollback if over capacity
if profile.max_streams == 0:
reserved = True
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
# Atomic slot reservation via shared connection pool helper
reserved, _count, _failure_reason = reserve_profile_slot(
profile, redis_client
)
if reserved:
redis_client.set(f"channel_stream:{self.id}", self.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):
"""
@ -277,12 +272,7 @@ class Stream(models.Model):
f"Stream {stream_id}: found profile_id={profile_id}"
)
profile_connections_key = f"profile_connections:{profile_id}"
# 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)
release_profile_slot(profile_id, redis_client)
return True
@ -599,44 +589,91 @@ class Channel(models.Model):
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
condition where separate GET + check + INCR operations could allow
concurrent requests to both pass the capacity check.
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)
Reuse when the proxy is active, or when metadata is not written yet
(between get_stream() reserving slots and initialize_channel() starting).
When metadata exists but the proxy is inactive, the assignment is stale.
"""
if profile.max_streams == 0:
return (True, 0)
if self._channel_proxy_is_active(redis_client):
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
new_count = redis_client.incr(profile_connections_key)
return False
if new_count <= profile.max_streams:
return (True, new_count)
def _release_stale_stream_assignment(self, redis_client, stream_id: int) -> None:
"""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
redis_client.decr(profile_connections_key)
return (False, new_count - 1)
if profile_id is None:
metadata_key = RedisKeys.channel_metadata(str(self.uuid))
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):
"""
Finds an available stream for the requested channel and returns the selected stream and profile.
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()
error_reason = None
@ -644,26 +681,42 @@ class Channel(models.Model):
# Check if this channel has any streams
if not self.streams.exists():
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}")
if stream_id_bytes:
try:
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):
logger.debug(
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
has_streams_but_maxed_out = False
@ -697,7 +750,7 @@ class Channel(models.Model):
has_active_profiles = True
# 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
)
@ -705,11 +758,16 @@ class Channel(models.Model):
# Slot reserved — assign stream to this channel
redis_client.set(f"channel_stream:{self.id}", stream.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 (
stream.id,
profile.id,
None,
True,
) # Return newly assigned stream and matched profile
else:
# At capacity: try to preempt a lower-impact channel on this profile
@ -723,12 +781,21 @@ class Channel(models.Model):
logger.info(f"Preempting channel {victim_channel_id} for new stream on profile {profile.id}")
# return self.id, profile.id, victim_channel_id
# This profile is at max connections
has_streams_but_maxed_out = True
logger.debug(
f"Profile {profile.id} at max connections: "
f"{current_count}/{profile.max_streams}"
)
if failure_reason == "profile_full":
logger.info(
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
if has_streams_but_maxed_out:
@ -738,7 +805,7 @@ class Channel(models.Model):
else:
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):
"""
@ -782,12 +849,7 @@ class Channel(models.Model):
ChannelMetadataField.M3U_PROFILE,
)
profile_connections_key = f"profile_connections:{profile_id}"
current_count = int(
redis_client.get(profile_connections_key) or 0
)
if current_count > 0:
redis_client.decr(profile_connections_key)
release_profile_slot(profile_id, redis_client)
return True
logger.debug(
@ -832,12 +894,7 @@ class Channel(models.Model):
f"stream {stream_id}"
)
profile_connections_key = f"profile_connections:{profile_id}"
# 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)
release_profile_slot(profile_id, redis_client)
# Clear metadata fields so duplicate release_stream() calls
# (e.g. from _clean_redis_keys or ChannelService.stop_channel)
@ -883,10 +940,31 @@ class Channel(models.Model):
if current_profile_id == new_profile_id:
return True
# Use pipeline for atomic profile switch to prevent counter drift
# if an exception occurs between DECR and INCR
old_profile_connections_key = f"profile_connections:{current_profile_id}"
new_profile_connections_key = f"profile_connections:{new_profile_id}"
from apps.m3u.connection_pool import (
move_credential_slot_on_profile_switch,
profile_connections_key,
)
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)
pipe = redis_client.pipeline()
@ -1041,6 +1119,13 @@ class ChannelGroupM3UAccount(models.Model):
def __str__(self):
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):
name = models.CharField(max_length=255)

View file

@ -221,17 +221,6 @@ class ChannelGroupM3UAccountSerializer(serializers.ModelSerializer):
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):
# Partial PATCHes only carry submitted fields; fill missing
# start/end from the instance so the validator catches a PATCH
@ -562,10 +551,13 @@ class ChannelSerializer(serializers.ModelSerializer):
return LogoSerializer(obj.logo).data
def get_streams(self, obj):
"""Retrieve ordered stream IDs for GET requests."""
return StreamSerializer(
obj.streams.all().order_by("channelstream__order"), many=True
).data
"""Retrieve ordered streams for GET requests using prefetched channelstream_set."""
ordered_streams = [
cs.stream
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):
streams = validated_data.pop("streams", [])

View file

@ -201,18 +201,13 @@ def refresh_epg_programs(sender, instance, created, **kwargs):
if not created and kwargs.get('update_fields') and 'epg_data' in kwargs['update_fields']:
logger.info(f"Channel {instance.id} ({instance.name}) EPG data updated, refreshing program data")
if instance.epg_data:
# Skip XMLTV program parser for SD sources — program data is handled
# by fetch_schedules_direct() directly.
if instance.epg_data.epg_source and instance.epg_data.epg_source.source_type == 'schedules_direct':
logger.info(f"Skipping XMLTV parse for SD source {instance.epg_data.tvg_id}")
if instance.epg_data.epg_source and instance.epg_data.epg_source.source_type == 'dummy':
return
logger.info(f"Triggering EPG program refresh for {instance.epg_data.tvg_id}")
parse_programs_for_tvg_id.delay(instance.epg_data.id)
# For new channels with EPG data, also refresh
elif created and instance.epg_data:
# Skip XMLTV program parser for SD sources
if instance.epg_data.epg_source and instance.epg_data.epg_source.source_type == 'schedules_direct':
logger.info(f"Skipping XMLTV parse for SD source {instance.epg_data.tvg_id}")
if instance.epg_data.epg_source and instance.epg_data.epg_source.source_type == 'dummy':
return
logger.info(f"New channel {instance.id} ({instance.name}) created with EPG data, refreshing program data")
parse_programs_for_tvg_id.delay(instance.epg_data.id)

File diff suppressed because it is too large Load diff

View file

@ -504,3 +504,72 @@ class SeriesRuleAPITests(TestCase):
def test_bulk_remove_requires_tvg_id_or_title(self):
resp = self.client.post(self.bulk_remove_url, {}, format="json")
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",
)

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 east")
def test_advanced_mode_strips_prefix(self):
self._set_epg_settings(
epg_match_mode="advanced",
epg_match_ignore_prefixes=["HD:"],
)
self.assertEqual(
normalize_name("HD:ABC 7 (WXYZ) - Springfield"),
"abc 7 springfield wxyz",
)
def test_advanced_mode_strips_suffix(self):
self._set_epg_settings(
epg_match_mode="advanced",
epg_match_ignore_suffixes=[" 4K"],
)
self.assertEqual(
normalize_name("NBC 5 (KABC) - Metro 4K"),
"nbc 5 metro kabc",
)
def test_advanced_mode_removes_custom_strings(self):
self._set_epg_settings(
epg_match_mode="advanced",
epg_match_ignore_custom=["Plus"],
)
self.assertEqual(
normalize_name("HBO Plus East"),
"hbo east",
)
def test_advanced_mode_applies_prefix_suffix_and_custom_in_order(self):
self._set_epg_settings(
epg_match_mode="advanced",
epg_match_ignore_prefixes=["Sling:"],
epg_match_ignore_suffixes=[" HD"],
epg_match_ignore_custom=["Plus"],
)
self.assertEqual(
normalize_name("Sling:HBO Plus East HD"),
"hbo east",
)
def test_only_first_matching_prefix_is_removed(self):
self._set_epg_settings(
epg_match_mode="advanced",
epg_match_ignore_prefixes=["HD:", "SD:"],
)
self.assertEqual(normalize_name("HD:SD:Channel 5"), "sd channel 5")
def test_call_sign_preserved_from_original_name(self):
self._set_epg_settings(epg_match_mode="default")
self.assertEqual(
normalize_name("NBC 5 (KABC) - Metro"),
"nbc 5 metro kabc",
)
def test_tvg_id_index_prefers_first_entry_when_catalog_sorted_by_priority(self):
# Catalog from build_epg_matching_catalog() is highest-priority first.
epg_data = [
{"id": 2, "tvg_id": "abc.us", "epg_source_priority": 50, "name": "High"},
{"id": 1, "tvg_id": "abc.us", "epg_source_priority": 10, "name": "Low"},
]
index = build_epg_tvg_id_index(epg_data)
self.assertEqual(index["abc.us"]["id"], 2)
def test_settings_cache_refresh_picks_up_new_rules(self):
self._set_epg_settings(epg_match_mode="default")
self.assertEqual(normalize_name("HD:ABC"), "hdabc")
self._set_epg_settings(
epg_match_mode="advanced",
epg_match_ignore_prefixes=["HD:"],
)
self.assertEqual(normalize_name("HD:ABC"), "abc")

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,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.smembers.return_value = client_ids
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
pipe = MagicMock()

View file

@ -218,7 +218,7 @@ class DoStatsUpdateTests(TestCase):
mock_redis.scan.return_value = (0, [])
with patch("apps.proxy.live_proxy.client_manager.send_websocket_update") as mock_ws, \
patch("redis.Redis.from_url", return_value=mock_redis):
patch("core.utils.RedisClient.get_client", return_value=mock_redis):
cm._do_stats_update()
mock_ws.assert_called_once()
@ -231,25 +231,25 @@ class DoStatsUpdateTests(TestCase):
"""Redis failure must be swallowed (logged), not propagated."""
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:
cm._do_stats_update()
except Exception as e:
self.fail(f"_do_stats_update raised an exception: {e}")
def test_do_stats_update_scans_channel_client_keys(self):
"""Must scan for live:channel:*:clients pattern."""
def test_do_stats_update_scans_channel_metadata_keys(self):
"""Must scan for live:channel:*:metadata pattern."""
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"), \
patch("redis.Redis.from_url", return_value=mock_redis):
patch("core.utils.RedisClient.get_client", return_value=mock_redis):
cm._do_stats_update()
scan_call = mock_redis.scan.call_args
self.assertIn("live:channel:*:clients", str(scan_call))
self.assertIn("live:channel:*:metadata", str(scan_call))
# ---------------------------------------------------------------------------

View file

@ -0,0 +1,984 @@
"""Tests for multi-worker channel teardown coordination."""
import time
from unittest.mock import ANY, MagicMock, patch
from django.test import TestCase
from apps.proxy.live_proxy.constants import ChannelMetadataField, ChannelState
from apps.proxy.live_proxy.input.buffer import StreamBuffer
from apps.proxy.live_proxy.input.manager import StreamManager
from apps.proxy.live_proxy.redis_keys import RedisKeys
from apps.proxy.live_proxy.server import ProxyServer
from apps.proxy.live_proxy.services.channel_service import ChannelService
CHANNEL_ID = "00000000-0000-0000-0000-000000000099"
def _configure_ownership_pipeline(
redis,
*,
stop_exists=0,
metadata_exists=1,
client_count=0,
owner=None,
disconnect=None,
state=None,
):
pipe = MagicMock()
redis.pipeline.return_value = pipe
pipe.execute.return_value = (
stop_exists,
metadata_exists,
client_count,
owner,
disconnect,
state,
)
return pipe
def _mock_proxy_server(redis_client=None):
server = MagicMock()
server.redis_client = redis_client or MagicMock()
server._stopping_channels = set()
return server
class ChannelTeardownAvailabilityTests(TestCase):
@patch("apps.proxy.live_proxy.services.channel_service.ProxyServer.get_instance")
def test_teardown_active_when_stopping_key_exists(self, mock_get_instance):
redis = MagicMock()
redis.exists.side_effect = lambda key: key == RedisKeys.channel_stopping(CHANNEL_ID)
mock_get_instance.return_value = _mock_proxy_server(redis)
self.assertTrue(ChannelService.is_channel_teardown_active(CHANNEL_ID))
@patch("apps.proxy.live_proxy.services.channel_service.ProxyServer.get_instance")
def test_teardown_active_when_metadata_state_is_stopping(self, mock_get_instance):
redis = MagicMock()
redis.exists.return_value = False
redis.hget.return_value = ChannelState.STOPPING.encode()
mock_get_instance.return_value = _mock_proxy_server(redis)
self.assertTrue(ChannelService.is_channel_teardown_active(CHANNEL_ID))
@patch("apps.proxy.live_proxy.services.channel_service.ConfigHelper.channel_shutdown_delay")
@patch("apps.proxy.live_proxy.services.channel_service.ProxyServer.get_instance")
def test_shutdown_pending_within_delay_window(self, mock_get_instance, mock_delay):
mock_delay.return_value = 5
redis = MagicMock()
redis.exists.return_value = False
redis.get.return_value = str(time.time() - 2).encode()
mock_get_instance.return_value = _mock_proxy_server(redis)
self.assertTrue(ChannelService.is_shutdown_pending(CHANNEL_ID))
self.assertFalse(ChannelService.is_channel_unavailable_for_new_clients(CHANNEL_ID))
@patch("apps.proxy.live_proxy.services.channel_service.ConfigHelper.channel_shutdown_delay")
@patch("apps.proxy.live_proxy.services.channel_service.ProxyServer.get_instance")
def test_cancel_pending_shutdown_clears_disconnect_key(self, mock_get_instance, mock_delay):
mock_delay.return_value = 30
redis = MagicMock()
redis.exists.side_effect = lambda key: "last_client_disconnect" in key
redis.get.return_value = None
redis.hget.return_value = ChannelState.ACTIVE.encode()
mock_get_instance.return_value = _mock_proxy_server(redis)
self.assertTrue(ChannelService.cancel_pending_shutdown(CHANNEL_ID))
redis.delete.assert_any_call(RedisKeys.last_client_disconnect(CHANNEL_ID))
@patch("apps.proxy.live_proxy.services.channel_service.ConfigHelper.channel_shutdown_delay")
@patch("apps.proxy.live_proxy.services.channel_service.ProxyServer.get_instance")
def test_cancel_pending_shutdown_skips_during_active_stop(self, mock_get_instance, mock_delay):
mock_delay.return_value = 30
redis = MagicMock()
redis.exists.return_value = True
server = _mock_proxy_server(redis)
server._stopping_channels = {CHANNEL_ID}
mock_get_instance.return_value = server
self.assertFalse(ChannelService.cancel_pending_shutdown(CHANNEL_ID))
redis.delete.assert_not_called()
@patch("apps.proxy.live_proxy.services.channel_service.ConfigHelper.channel_shutdown_delay")
@patch("apps.proxy.live_proxy.services.channel_service.ProxyServer.get_instance")
def test_cancel_pending_shutdown_skips_real_teardown_without_grace(self, mock_get_instance, mock_delay):
mock_delay.return_value = 30
redis = MagicMock()
redis.exists.side_effect = lambda key: "stopping" in key
redis.get.return_value = None
redis.hget.return_value = ChannelState.STOPPING.encode()
mock_get_instance.return_value = _mock_proxy_server(redis)
self.assertFalse(ChannelService.cancel_pending_shutdown(CHANNEL_ID))
redis.hset.assert_not_called()
redis.delete.assert_not_called()
@patch("apps.proxy.live_proxy.services.channel_service.ConfigHelper.channel_shutdown_delay")
@patch("apps.proxy.live_proxy.services.channel_service.ProxyServer.get_instance")
def test_shutdown_pending_expired_after_delay(self, mock_get_instance, mock_delay):
mock_delay.return_value = 5
redis = MagicMock()
redis.exists.return_value = False
redis.get.return_value = str(time.time() - 10).encode()
mock_get_instance.return_value = _mock_proxy_server(redis)
self.assertFalse(ChannelService.is_shutdown_pending(CHANNEL_ID))
class ClientManagerAddClientTests(TestCase):
@patch("apps.proxy.live_proxy.services.channel_service.ChannelService.cancel_pending_shutdown", return_value=False)
@patch("apps.proxy.live_proxy.client_manager.send_websocket_update")
def test_add_client_stores_ip_and_user_agent_in_redis(self, _mock_ws, _mock_cancel):
from apps.proxy.live_proxy.client_manager import ClientManager
redis = MagicMock()
cm = ClientManager(CHANNEL_ID, redis_client=redis, worker_id="worker-1")
cm.proxy_server = MagicMock()
result = cm.add_client(
"client-1",
"10.0.2.163",
user_agent="VLC/3.0.21",
)
self.assertEqual(result, 1)
mapping = redis.hset.call_args[1]["mapping"]
self.assertEqual(mapping["ip_address"], "10.0.2.163")
self.assertEqual(mapping["user_agent"], "VLC/3.0.21")
class LocalStreamActivityTests(TestCase):
def _make_server(self):
with patch("apps.proxy.live_proxy.server.RedisClient.get_client", return_value=MagicMock()):
server = ProxyServer()
server.worker_id = "testhost:1"
server.stream_managers = {}
server.stream_buffers = {}
server.client_managers = {}
server.profile_managers = {}
server.profile_buffers = {}
server._live_stream_managers = {}
server._stopping_channels = set()
server.redis_client = MagicMock()
server.stop_all_output_formats = MagicMock()
server.stop_all_output_profiles = MagicMock()
return server
@patch.object(ProxyServer, "_join_stream_thread")
def test_stop_local_stream_activity_stops_live_manager(self, mock_join):
server = self._make_server()
manager = MagicMock()
server._live_stream_managers[CHANNEL_ID] = manager
server._stop_local_stream_activity(CHANNEL_ID)
manager.stop.assert_called_once()
mock_join.assert_called_once_with(CHANNEL_ID)
self.assertNotIn(CHANNEL_ID, server._live_stream_managers)
class OrphanMetadataCleanupTests(TestCase):
def _make_server(self):
with patch("apps.proxy.live_proxy.server.RedisClient.get_client", return_value=MagicMock()):
server = ProxyServer()
server.worker_id = "testhost:1"
server.stream_managers = {}
server.stream_buffers = {}
server.client_managers = {}
server.profile_managers = {}
server.profile_buffers = {}
server._live_stream_managers = {}
server._stopping_channels = set()
server.redis_client = MagicMock()
return server
@patch.object(ProxyServer, "_clean_redis_keys")
@patch.object(ProxyServer, "_stop_local_stream_activity")
@patch.object(ProxyServer, "_has_local_upstream_activity", return_value=True)
def test_orphan_metadata_stops_local_processes_before_redis(
self, mock_has_upstream, mock_stop_local, mock_clean_redis
):
server = self._make_server()
metadata_key = RedisKeys.channel_metadata(CHANNEL_ID)
server.redis_client.keys.return_value = [metadata_key.encode()]
server.redis_client.hgetall.return_value = {
b"owner": b"",
b"state": b"unknown",
}
server.redis_client.exists.return_value = False
server.redis_client.scard.return_value = 0
server._check_orphaned_metadata()
mock_has_upstream.assert_called_with(CHANNEL_ID)
mock_stop_local.assert_called_once_with(CHANNEL_ID)
mock_clean_redis.assert_called_once_with(CHANNEL_ID)
@patch.object(ProxyServer, "_clean_redis_keys")
@patch.object(ProxyServer, "_broadcast_upstream_stop")
@patch.object(ProxyServer, "_stop_local_stream_activity")
@patch.object(ProxyServer, "_has_local_upstream_activity", return_value=False)
def test_orphan_metadata_remote_channel_broadcasts_stop(
self, mock_has_upstream, mock_stop_local, mock_broadcast, mock_clean_redis
):
server = self._make_server()
metadata_key = RedisKeys.channel_metadata(CHANNEL_ID)
server.redis_client.keys.return_value = [metadata_key.encode()]
server.redis_client.hgetall.return_value = {b"owner": b"", b"state": b"unknown"}
server.redis_client.exists.return_value = False
server.redis_client.scard.return_value = 0
server._check_orphaned_metadata()
mock_broadcast.assert_called_once_with(CHANNEL_ID)
mock_stop_local.assert_not_called()
mock_clean_redis.assert_called_once_with(CHANNEL_ID)
class OrphanChannelCleanupTests(TestCase):
def _make_server(self):
with patch("apps.proxy.live_proxy.server.RedisClient.get_client", return_value=MagicMock()):
server = ProxyServer()
server.worker_id = "testhost:1"
server.stream_managers = {}
server.stream_buffers = {}
server.client_managers = {}
server.profile_managers = {}
server.profile_buffers = {}
server._live_stream_managers = {}
server._stopping_channels = set()
server.redis_client = MagicMock()
server.get_channel_owner = MagicMock(return_value=None)
return server
@patch.object(ProxyServer, "_clean_redis_keys")
@patch.object(ProxyServer, "_stop_local_stream_activity")
@patch.object(ProxyServer, "_has_local_upstream_activity", return_value=True)
def test_orphan_channel_stops_local_before_redis(
self, mock_has_upstream, mock_stop_local, mock_clean_redis
):
server = self._make_server()
metadata_key = RedisKeys.channel_metadata(CHANNEL_ID)
server.redis_client.keys.return_value = [metadata_key.encode()]
server.redis_client.scard.return_value = 0
server._check_orphaned_channels()
mock_stop_local.assert_called_once_with(CHANNEL_ID)
mock_clean_redis.assert_called_once_with(CHANNEL_ID)
class StreamManagerOwnershipTests(TestCase):
def test_still_owner_false_when_different_worker(self):
buffer = MagicMock()
_configure_ownership_pipeline(
buffer.redis_client,
owner=b"worker-b",
)
manager = StreamManager(
CHANNEL_ID, "http://example/stream", buffer, worker_id="worker-a"
)
self.assertFalse(manager._still_owner())
def test_still_owner_true_when_owner_lock_expired_but_not_stopping(self):
buffer = MagicMock()
_configure_ownership_pipeline(
buffer.redis_client,
client_count=0,
owner=None,
state=ChannelState.CONNECTING.encode(),
)
manager = StreamManager(
CHANNEL_ID, "http://example/stream", buffer, worker_id="worker-a"
)
self.assertTrue(manager._still_owner())
def test_still_owner_false_when_channel_stopping_key_set(self):
buffer = MagicMock()
_configure_ownership_pipeline(
buffer.redis_client,
stop_exists=1,
)
manager = StreamManager(
CHANNEL_ID, "http://example/stream", buffer, worker_id="worker-a"
)
self.assertFalse(manager._still_owner())
def test_update_bytes_skipped_after_ownership_lost(self):
buffer = MagicMock()
_configure_ownership_pipeline(
buffer.redis_client,
owner=b"other-worker",
)
manager = StreamManager(
CHANNEL_ID, "http://example/stream", buffer, worker_id="worker-a"
)
manager.bytes_processed = 1000
manager._update_bytes_processed(500)
buffer.redis_client.hincrby.assert_not_called()
class StreamBufferStopTests(TestCase):
def test_stop_discards_local_data_without_redis_writes(self):
redis = MagicMock()
buffer = StreamBuffer(channel_id=CHANNEL_ID, redis_client=redis)
buffer._write_buffer = bytearray(b"x" * 376)
buffer.stop()
self.assertTrue(buffer.stopping)
self.assertEqual(len(buffer._write_buffer), 0)
redis.incr.assert_not_called()
redis.setex.assert_not_called()
redis.delete.assert_not_called()
class StopChannelTeardownTests(TestCase):
def _make_server(self):
with patch("apps.proxy.live_proxy.server.RedisClient.get_client", return_value=MagicMock()):
server = ProxyServer()
server.worker_id = "testhost:1"
server.stream_managers = {}
server.stream_buffers = {}
server.client_managers = {}
server.profile_managers = {}
server.profile_buffers = {}
server._live_stream_managers = {}
server._stopping_channels = set()
server._stopping_since = {}
server.redis_client = MagicMock()
server.redis_client.exists.return_value = False
server.am_i_owner = MagicMock(return_value=False)
server._collect_channel_stop_event_data = MagicMock(return_value=None)
server.release_ownership = MagicMock()
return server
@patch.object(ProxyServer, "_spawn_channel_stop_event")
@patch.object(ProxyServer, "_clean_redis_keys")
@patch.object(ProxyServer, "_stop_local_stream_activity")
def test_stop_channel_cleans_redis_before_blocking_local_stop(
self, mock_stop_local, mock_clean_redis, mock_spawn_event
):
server = self._make_server()
call_order = []
def stop_local(channel_id):
call_order.append("local")
def clean_redis(channel_id):
call_order.append("redis")
mock_stop_local.side_effect = stop_local
mock_clean_redis.side_effect = clean_redis
server.stop_channel(CHANNEL_ID)
self.assertEqual(call_order, ["redis", "local"])
mock_spawn_event.assert_called_once_with(None)
@patch.object(ProxyServer, "_spawn_channel_stop_event")
@patch.object(ProxyServer, "_clean_redis_keys")
@patch.object(ProxyServer, "_stop_local_stream_activity", side_effect=RuntimeError("boom"))
def test_stop_channel_cleans_redis_in_finally_when_local_stop_fails(
self, mock_stop_local, mock_clean_redis, mock_spawn_event
):
server = self._make_server()
result = server.stop_channel(CHANNEL_ID)
self.assertFalse(result)
mock_clean_redis.assert_called_once_with(CHANNEL_ID)
mock_spawn_event.assert_not_called()
self.assertNotIn(CHANNEL_ID, server._stopping_channels)
@patch.object(ProxyServer, "_spawn_channel_stop_event")
@patch.object(ProxyServer, "_clean_redis_keys")
@patch.object(ProxyServer, "_stop_local_stream_activity")
def test_stop_channel_owner_releases_after_redis_cleanup(
self, mock_stop_local, mock_clean_redis, mock_spawn_event
):
server = self._make_server()
server.am_i_owner.return_value = True
stop_data = {"channel_id": CHANNEL_ID}
server._collect_channel_stop_event_data.return_value = stop_data
call_order = []
def stop_local(channel_id):
call_order.append("local")
def release(channel_id):
call_order.append("release")
def clean_redis(channel_id):
call_order.append("redis")
mock_stop_local.side_effect = stop_local
server.release_ownership.side_effect = release
mock_clean_redis.side_effect = clean_redis
server.stop_channel(CHANNEL_ID)
self.assertEqual(call_order, ["redis", "release", "local"])
server._collect_channel_stop_event_data.assert_called_once_with(CHANNEL_ID)
mock_spawn_event.assert_called_once_with(stop_data)
class CleanRedisKeysOrderTests(TestCase):
@patch("apps.proxy.live_proxy.server.Stream.objects.get")
@patch("apps.proxy.live_proxy.server.Channel.objects.get")
def test_clean_redis_keys_releases_profile_slot_before_live_keys_deleted(
self, mock_channel_get, mock_stream_get
):
from apps.channels.models import Channel, Stream
with patch("apps.proxy.live_proxy.server.RedisClient.get_client", return_value=MagicMock()):
server = ProxyServer()
server.redis_client = MagicMock()
call_order = []
channel = MagicMock()
channel.release_stream.return_value = True
def channel_get(uuid):
call_order.append("release")
return channel
mock_channel_get.side_effect = channel_get
mock_stream_get.side_effect = Stream.DoesNotExist
channel_key = f"live:channel:{CHANNEL_ID}:input:buffer:index".encode()
def scan(cursor, match=None, count=100):
call_order.append("redis")
if match == f"live:channel:{CHANNEL_ID}:*":
return (0, [channel_key])
return (0, [])
server.redis_client.scan.side_effect = scan
server._clean_redis_keys(CHANNEL_ID)
self.assertEqual(call_order, ["release", "redis", "redis"])
channel.release_stream.assert_called_once()
server.redis_client.delete.assert_called_once_with(channel_key)
class LocalUpstreamActivityTests(TestCase):
def _make_server(self):
with patch("apps.proxy.live_proxy.server.RedisClient.get_client", return_value=MagicMock()):
server = ProxyServer()
server.worker_id = "testhost:1"
server.stream_managers = {}
server.stream_buffers = {}
server.client_managers = {}
server._live_stream_managers = {}
return server
def test_upstream_activity_excludes_reader_only_client_manager(self):
server = self._make_server()
server.client_managers[CHANNEL_ID] = MagicMock()
server.stream_buffers[CHANNEL_ID] = MagicMock()
self.assertFalse(server._has_local_upstream_activity(CHANNEL_ID))
def test_upstream_activity_from_live_registry(self):
server = self._make_server()
server._live_stream_managers[CHANNEL_ID] = MagicMock()
self.assertTrue(server._has_local_upstream_activity(CHANNEL_ID))
class ClientDisconnectOwnershipTests(TestCase):
def test_last_client_triggers_stop_when_upstream_active_without_owner_lock(self):
from apps.proxy.live_proxy.client_manager import ClientManager
proxy_server = MagicMock()
proxy_server.worker_id = "testhost:1"
proxy_server.am_i_owner.return_value = False
proxy_server._has_local_upstream_activity.return_value = True
proxy_server.extend_ownership.return_value = False
redis = MagicMock()
redis.hget.return_value = b"viewer"
redis.scard.return_value = 0
manager = ClientManager(
channel_id=CHANNEL_ID,
redis_client=redis,
worker_id="testhost:1",
)
manager.proxy_server = proxy_server
manager.clients = {"client-1"}
manager.client_set_key = RedisKeys.clients(CHANNEL_ID)
manager._notify_owner_of_activity = MagicMock()
manager._trigger_stats_update = MagicMock()
manager.get_total_client_count = MagicMock(return_value=0)
proxy_server._spawn_on_hub = MagicMock()
manager.remove_client("client-1")
proxy_server._spawn_on_hub.assert_called_once_with(
proxy_server.handle_client_disconnect, CHANNEL_ID
)
class TeardownActiveLocalStopTests(TestCase):
@patch("apps.proxy.live_proxy.services.channel_service.ProxyServer.get_instance")
def test_teardown_active_when_local_stop_in_progress(self, mock_get_instance):
server = MagicMock()
server._stopping_channels = {CHANNEL_ID}
server.redis_client = MagicMock()
server.redis_client.exists.return_value = False
mock_get_instance.return_value = server
self.assertTrue(ChannelService.is_channel_teardown_active(CHANNEL_ID))
class HandleClientDisconnectUpstreamFirstTests(TestCase):
def _make_server(self):
with patch("apps.proxy.live_proxy.server.RedisClient.get_client", return_value=MagicMock()):
server = ProxyServer()
server.worker_id = "testhost:1"
server.client_managers = {CHANNEL_ID: MagicMock()}
server.stream_managers = {CHANNEL_ID: MagicMock()}
server._live_stream_managers = {}
server.profile_managers = {}
server.output_managers = {}
server.redis_client = MagicMock()
server.redis_client.scard.return_value = 0
server._stopping_channels = set()
return server
@patch.object(ProxyServer, "_coordinated_stop_channel")
@patch.object(ProxyServer, "_stop_upstream_before_redis_cleanup")
@patch.object(ProxyServer, "_has_local_upstream_activity", return_value=True)
def test_last_client_uses_coordinated_stop_only(
self, _mock_upstream, mock_stop_upstream, mock_coordinated
):
server = self._make_server()
with patch(
"apps.proxy.live_proxy.server.ConfigHelper.channel_shutdown_delay",
return_value=0,
):
server.handle_client_disconnect(CHANNEL_ID)
mock_stop_upstream.assert_not_called()
mock_coordinated.assert_called_once_with(CHANNEL_ID)
class ShutdownDelayWaitTests(TestCase):
def _make_server(self):
with patch("apps.proxy.live_proxy.server.RedisClient.get_client", return_value=MagicMock()):
with patch.object(ProxyServer, "_start_cleanup_thread"):
server = ProxyServer()
server.redis_client = MagicMock()
server.redis_client.scard.return_value = 0
return server
@patch("apps.proxy.live_proxy.server.gevent.sleep")
@patch("apps.proxy.live_proxy.server.time.time", return_value=1000.0)
@patch("apps.proxy.live_proxy.server.ConfigHelper.channel_shutdown_delay", return_value=30)
def test_aborts_when_disconnect_key_deleted(self, _mock_delay, _mock_time, mock_sleep):
server = self._make_server()
server.redis_client.get.side_effect = [b"1000.0", None]
result = server._wait_for_shutdown_delay(CHANNEL_ID)
self.assertFalse(result)
self.assertGreaterEqual(mock_sleep.call_count, 1)
@patch("apps.proxy.live_proxy.server.gevent.sleep")
@patch("apps.proxy.live_proxy.server.time.time", return_value=1000.0)
@patch("apps.proxy.live_proxy.server.ConfigHelper.channel_shutdown_delay", return_value=30)
def test_aborts_when_clients_reconnect(self, _mock_delay, _mock_time, _mock_sleep):
server = self._make_server()
server.redis_client.get.return_value = b"1000.0"
server.redis_client.scard.side_effect = [0, 1]
result = server._wait_for_shutdown_delay(CHANNEL_ID)
self.assertFalse(result)
server.redis_client.delete.assert_called_with(
RedisKeys.last_client_disconnect(CHANNEL_ID)
)
@patch("apps.proxy.live_proxy.server.gevent.sleep")
@patch("apps.proxy.live_proxy.server.time.time")
@patch("apps.proxy.live_proxy.server.ConfigHelper.channel_shutdown_delay", return_value=30)
def test_timer_resets_when_disconnect_timestamp_updated(
self, _mock_delay, mock_time, mock_sleep
):
server = self._make_server()
disconnect_key = RedisKeys.last_client_disconnect(CHANNEL_ID)
current_time = [1000.0]
disconnect_timestamp = [1000.0]
poll_count = [0]
mock_time.side_effect = lambda: current_time[0]
def get_side_effect(key):
poll_count[0] += 1
if poll_count[0] >= 3:
disconnect_timestamp[0] = 1020.0
if key == disconnect_key:
return str(disconnect_timestamp[0]).encode()
return None
server.redis_client.get.side_effect = get_side_effect
def advance_sleep(duration):
current_time[0] += duration
mock_sleep.side_effect = advance_sleep
result = server._wait_for_shutdown_delay(CHANNEL_ID)
self.assertTrue(result)
self.assertGreaterEqual(current_time[0], 1050.0)
@patch.object(ProxyServer, "_coordinated_stop_channel")
@patch.object(ProxyServer, "_wait_for_shutdown_delay", return_value=True)
@patch("apps.proxy.live_proxy.server.ConfigHelper.channel_shutdown_delay", return_value=30)
def test_handle_client_disconnect_uses_polling_wait(
self, _mock_delay, mock_wait, mock_coordinated
):
server = HandleClientDisconnectUpstreamFirstTests()._make_server()
server.redis_client.get.return_value = b"1700000000.0"
server.handle_client_disconnect(CHANNEL_ID)
mock_wait.assert_called_once_with(CHANNEL_ID)
mock_coordinated.assert_called_once_with(CHANNEL_ID)
@patch.object(ProxyServer, "_coordinated_stop_channel")
@patch.object(ProxyServer, "_wait_for_shutdown_delay", return_value=False)
@patch("apps.proxy.live_proxy.server.ConfigHelper.channel_shutdown_delay", return_value=30)
def test_handle_client_disconnect_skips_stop_when_wait_aborted(
self, _mock_delay, _mock_wait, mock_coordinated
):
server = HandleClientDisconnectUpstreamFirstTests()._make_server()
server.redis_client.get.return_value = b"1700000000.0"
server.handle_client_disconnect(CHANNEL_ID)
mock_coordinated.assert_not_called()
class InitWaitAbortTests(TestCase):
def _make_generator(self):
from apps.proxy.live_proxy.output.ts.generator import StreamGenerator
return StreamGenerator(
CHANNEL_ID,
"client-1",
"127.0.0.1",
"test-agent",
channel_initializing=True,
)
def test_abort_when_client_removed_locally(self):
generator = self._make_generator()
server = MagicMock()
client_manager = MagicMock()
client_manager.clients = set()
server.client_managers = {CHANNEL_ID: client_manager}
server.redis_client = MagicMock()
self.assertEqual(generator._init_wait_abort_reason(server, time.time()), "client_gone")
@patch("apps.proxy.live_proxy.output.ts.generator.ConfigHelper.channel_init_grace_period", return_value=10)
def test_abort_when_connect_stalled_without_buffer(self, _mock_grace):
generator = self._make_generator()
server = MagicMock()
client_manager = MagicMock()
client_manager.clients = {"client-1"}
server.client_managers = {CHANNEL_ID: client_manager}
buffer = MagicMock()
buffer.index = 0
server.stream_buffers = {CHANNEL_ID: buffer}
server.redis_client = MagicMock()
server.redis_client.hget.return_value = b"connecting"
server.redis_client.get.return_value = None
started = time.time() - 11
self.assertEqual(generator._init_wait_abort_reason(server, started), "stalled")
@patch("apps.proxy.live_proxy.output.ts.generator.ConfigHelper.channel_init_grace_period", return_value=30)
def test_no_stall_abort_within_init_grace_period(self, _mock_grace):
generator = self._make_generator()
server = MagicMock()
client_manager = MagicMock()
client_manager.clients = {"client-1"}
server.client_managers = {CHANNEL_ID: client_manager}
buffer = MagicMock()
buffer.index = 0
server.stream_buffers = {CHANNEL_ID: buffer}
server.redis_client = MagicMock()
server.redis_client.hget.return_value = b"connecting"
server.redis_client.get.return_value = None
started = time.time() - 15
self.assertIsNone(generator._init_wait_abort_reason(server, started))
@patch("apps.proxy.live_proxy.output.ts.generator.ConfigHelper.channel_init_grace_period", return_value=30)
def test_stall_abort_after_init_grace_period(self, _mock_grace):
generator = self._make_generator()
server = MagicMock()
client_manager = MagicMock()
client_manager.clients = {"client-1"}
server.client_managers = {CHANNEL_ID: client_manager}
buffer = MagicMock()
buffer.index = 0
server.stream_buffers = {CHANNEL_ID: buffer}
server.redis_client = MagicMock()
server.redis_client.hget.return_value = b"connecting"
server.redis_client.get.return_value = None
started = time.time() - 31
self.assertEqual(generator._init_wait_abort_reason(server, started), "stalled")
class PromoteChannelWhenBufferReadyTests(TestCase):
def _mock_proxy(self, redis_client):
proxy_server = MagicMock()
proxy_server.redis_client = redis_client
return patch(
"apps.proxy.live_proxy.services.channel_service.ProxyServer.get_instance",
return_value=proxy_server,
), proxy_server
@patch("apps.proxy.live_proxy.services.channel_service.ConfigHelper.initial_behind_chunks", return_value=4)
def test_buffer_ready_with_clients_becomes_active(self, _mock_chunks):
redis = MagicMock()
redis.hget.return_value = ChannelState.CONNECTING.encode()
redis.get.return_value = b"4"
redis.scard.return_value = 2
ctx, proxy_server = self._mock_proxy(redis)
with ctx:
result = ChannelService.promote_channel_when_buffer_ready(CHANNEL_ID)
self.assertEqual(result, ChannelState.ACTIVE)
proxy_server.update_channel_state.assert_called_once()
args = proxy_server.update_channel_state.call_args[0]
self.assertEqual(args[1], ChannelState.ACTIVE)
self.assertEqual(args[2]["clients_at_activation"], "2")
@patch("apps.proxy.live_proxy.services.channel_service.ConfigHelper.initial_behind_chunks", return_value=4)
def test_buffer_ready_without_clients_becomes_waiting(self, _mock_chunks):
redis = MagicMock()
redis.hget.return_value = ChannelState.CONNECTING.encode()
redis.get.return_value = b"5"
redis.scard.return_value = 0
ctx, proxy_server = self._mock_proxy(redis)
with ctx:
result = ChannelService.promote_channel_when_buffer_ready(CHANNEL_ID)
self.assertEqual(result, ChannelState.WAITING_FOR_CLIENTS)
proxy_server.update_channel_state.assert_called_once_with(
CHANNEL_ID,
ChannelState.WAITING_FOR_CLIENTS,
{
ChannelMetadataField.CONNECTION_READY_TIME: ANY,
ChannelMetadataField.BUFFER_CHUNKS: "5",
},
)
@patch("apps.proxy.live_proxy.services.channel_service.ConfigHelper.initial_behind_chunks", return_value=4)
def test_buffer_not_ready_does_not_promote(self, _mock_chunks):
redis = MagicMock()
redis.hget.return_value = ChannelState.CONNECTING.encode()
redis.get.return_value = b"2"
ctx, proxy_server = self._mock_proxy(redis)
with ctx:
result = ChannelService.promote_channel_when_buffer_ready(CHANNEL_ID)
self.assertIsNone(result)
proxy_server.update_channel_state.assert_not_called()
def test_waiting_for_clients_with_clients_becomes_active(self):
redis = MagicMock()
def hget_side_effect(key, field):
if field == ChannelMetadataField.STATE:
return ChannelState.WAITING_FOR_CLIENTS.encode()
if field == ChannelMetadataField.CONNECTION_READY_TIME:
return b"1700000000.0"
return None
redis.hget.side_effect = hget_side_effect
redis.scard.return_value = 1
ctx, proxy_server = self._mock_proxy(redis)
with ctx:
result = ChannelService.promote_channel_when_buffer_ready(CHANNEL_ID)
self.assertEqual(result, ChannelState.ACTIVE)
proxy_server.update_channel_state.assert_called_once_with(
CHANNEL_ID,
ChannelState.ACTIVE,
{"clients_at_activation": "1"},
)
class UpstreamStopBroadcastTests(TestCase):
def _make_server(self):
with patch("apps.proxy.live_proxy.server.RedisClient.get_client", return_value=MagicMock()):
server = ProxyServer()
server.worker_id = "testhost:1"
server.stream_managers = {}
server._live_stream_managers = {}
server.redis_client = MagicMock()
return server
@patch.object(ProxyServer, "_stop_local_stream_activity")
@patch.object(ProxyServer, "_broadcast_upstream_stop")
@patch.object(ProxyServer, "_has_local_upstream_activity", return_value=True)
def test_local_upstream_stops_locally_without_broadcast(
self, _mock_has, mock_broadcast, mock_stop_local
):
server = self._make_server()
server._stop_upstream_before_redis_cleanup(CHANNEL_ID)
mock_stop_local.assert_called_once_with(CHANNEL_ID)
mock_broadcast.assert_not_called()
@patch.object(ProxyServer, "_stop_local_stream_activity")
@patch.object(ProxyServer, "_broadcast_upstream_stop")
@patch.object(ProxyServer, "_has_local_upstream_activity", return_value=False)
def test_orphan_cleanup_broadcasts_when_no_local_upstream(
self, _mock_has, mock_broadcast, mock_stop_local
):
server = self._make_server()
server._stop_upstream_before_redis_cleanup(CHANNEL_ID)
mock_broadcast.assert_called_once_with(CHANNEL_ID)
mock_stop_local.assert_not_called()
class StreamManagerStillOwnerTests(TestCase):
def _make_manager(self, redis_client):
buffer = MagicMock()
buffer.redis_client = redis_client
buffer.channel_id = CHANNEL_ID
manager = StreamManager(
CHANNEL_ID,
"http://example/stream.ts",
buffer,
worker_id="testhost:1",
)
return manager
def test_stops_when_metadata_removed(self):
redis = MagicMock()
_configure_ownership_pipeline(redis, metadata_exists=0)
manager = self._make_manager(redis)
self.assertFalse(manager._still_owner())
def test_keeps_running_during_connecting_before_client_registered(self):
redis = MagicMock()
_configure_ownership_pipeline(
redis,
client_count=0,
owner=b"testhost:1",
state=ChannelState.CONNECTING.encode(),
)
manager = self._make_manager(redis)
self.assertTrue(manager._still_owner())
def test_stops_after_disconnect_when_shutdown_delay_is_zero(self):
redis = MagicMock()
_configure_ownership_pipeline(
redis,
client_count=0,
owner=b"testhost:1",
disconnect=b"1700000000.0",
state=ChannelState.ACTIVE.encode(),
)
manager = self._make_manager(redis)
with patch(
"apps.proxy.live_proxy.input.manager.ConfigHelper.channel_shutdown_delay",
return_value=0,
):
self.assertFalse(manager._still_owner())
def test_keeps_running_during_shutdown_delay(self):
redis = MagicMock()
_configure_ownership_pipeline(
redis,
client_count=0,
owner=b"testhost:1",
disconnect=str(time.time()).encode(),
state=ChannelState.ACTIVE.encode(),
)
manager = self._make_manager(redis)
with patch(
"apps.proxy.live_proxy.input.manager.ConfigHelper.channel_shutdown_delay",
return_value=5,
):
self.assertTrue(manager._still_owner())
class PreActiveNoClientsTimeoutTests(TestCase):
@patch("apps.proxy.live_proxy.server.ConfigHelper.channel_client_wait_period", return_value=5)
def test_buffer_ready_uses_client_wait_period(self, _mock_client_wait):
should_stop, timeout, reason = ProxyServer._pre_active_no_clients_should_stop(
connection_ready_time=1000.0,
start_time=900.0,
now=1006.0,
)
self.assertTrue(should_stop)
self.assertEqual(timeout, 5)
self.assertEqual(reason, "client_wait")
@patch("apps.proxy.live_proxy.server.ConfigHelper.channel_client_wait_period", return_value=5)
def test_buffer_ready_within_client_wait_period(self, _mock_client_wait):
should_stop, timeout, reason = ProxyServer._pre_active_no_clients_should_stop(
connection_ready_time=1000.0,
start_time=900.0,
now=1003.0,
)
self.assertFalse(should_stop)
self.assertEqual(timeout, 5)
self.assertEqual(reason, "client_wait")
@patch("apps.proxy.live_proxy.server.ConfigHelper.channel_init_grace_period", return_value=60)
def test_startup_uses_init_grace_period(self, _mock_init_grace):
should_stop, timeout, reason = ProxyServer._pre_active_no_clients_should_stop(
connection_ready_time=None,
start_time=1000.0,
now=1070.0,
)
self.assertTrue(should_stop)
self.assertEqual(timeout, 60)
self.assertEqual(reason, "startup")
@patch("apps.proxy.live_proxy.server.ConfigHelper.channel_init_grace_period", return_value=60)
def test_startup_within_init_grace_period(self, _mock_init_grace):
should_stop, timeout, reason = ProxyServer._pre_active_no_clients_should_stop(
connection_ready_time=None,
start_time=1000.0,
now=1030.0,
)
self.assertFalse(should_stop)
self.assertEqual(timeout, 60)
self.assertEqual(reason, "startup")
@patch("apps.proxy.live_proxy.server.ConfigHelper.channel_shutdown_delay", return_value=30)
@patch("apps.proxy.live_proxy.server.ConfigHelper.channel_client_wait_period", return_value=5)
def test_buffer_ready_does_not_use_shutdown_delay(self, mock_client_wait, mock_shutdown_delay):
should_stop, _, reason = ProxyServer._pre_active_no_clients_should_stop(
connection_ready_time=1000.0,
start_time=900.0,
now=1006.0,
)
self.assertTrue(should_stop)
self.assertEqual(reason, "client_wait")
mock_client_wait.assert_called_once()
mock_shutdown_delay.assert_not_called()

View file

@ -35,43 +35,56 @@ def _empty_subscription_chain():
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.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(
"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(event_name, payload)
return pm
def test_disabled_plugin_does_not_abort_dispatch_for_later_enabled_plugin(self):
"""The original bug: AttributeError on a disabled plugin's debug log
aborted the loop, so any plugin iterated AFTER a disabled one never
received events."""
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"]},
],
},
"""Enabled handlers still run when other plugins are disabled in DB."""
handlers = [
("enabled-plugin", "on_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(
@ -81,22 +94,64 @@ class TriggerEventDispatchTests(SimpleTestCase):
)
def test_action_without_matching_event_is_not_dispatched(self):
"""Sanity check: actions whose `events` list doesn't include the
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
],
},
]
"""When no handlers are registered for the event, run_action is not called."""
pm = self._run_trigger_event(
plugins, "channel_start", {"channel_name": "TEST"}
[], "channel_start", {"channel_name": "TEST"}
)
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
import logging, json
import logging
from django.template import Template, Context
from .models import EventSubscription, DeliveryLog, SUPPORTED_EVENTS
from .handlers.webhook import WebhookHandler
@ -94,23 +94,44 @@ def trigger_event(event_name, payload):
pm = PluginManager.get()
pm.discover_plugins(sync_db=False, use_cache=True)
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}'")
for plugin in plugins:
if not plugin["enabled"]:
logger.debug(f"Skipping disabled plugin id={plugin['key']} name={plugin['name']}")
from apps.plugins.models import PluginConfig
handler_keys = {key for key, _ in handlers}
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
logger.debug(json.dumps(plugin))
for action in plugin["actions"]:
if "events" in action and event_name in action["events"]:
key = plugin["key"]
params = {"event": event_name, "payload": payload}
action_name = action.get("label") or action.get("id")
action_id = action.get("id")
logger.debug(
f"Triggering plugin action for event '{event_name}' on plugin id={key} action={action_name}"
)
if action_id:
pm.run_action(key, action_id, params)
logger.debug(
"Triggering plugin action for event '%s' on plugin id=%s action=%s",
event_name,
key,
action_id,
)
try:
pm.run_action(key, action_id, params)
except Exception:
logger.exception(
"Plugin action failed for event '%s' on plugin id=%s action=%s",
event_name,
key,
action_id,
)

View file

@ -64,8 +64,6 @@ class EPGSourceViewSet(viewsets.ModelViewSet):
from apps.channels.models import Channel
return EPGSource.objects.select_related(
"refresh_task__crontab", "refresh_task__interval"
).defer(
'programme_index'
).annotate(
has_channels=Exists(
Channel.objects.filter(epg_data__epg_source_id=OuterRef('pk'))
@ -128,7 +126,7 @@ class EPGSourceViewSet(viewsets.ModelViewSet):
import hashlib
import requests as http_requests
from apps.epg.tasks import SD_BASE_URL
from version import __version__ as dispatcharr_version
from core.utils import dispatcharr_http_headers
username = (source.username or '').strip()
password = (source.password or '').strip()
@ -143,7 +141,7 @@ class EPGSourceViewSet(viewsets.ModelViewSet):
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}'},
headers=dispatcharr_http_headers(),
timeout=15,
)
auth_response.raise_for_status()
@ -227,6 +225,24 @@ class EPGSourceViewSet(viewsets.ModelViewSet):
f"Lockout set until {reset_at.isoformat()}."
)
def _fetch_sd_countries(self):
"""Fetch the SD country list (token not required; User-Agent is)."""
import requests as http_requests
from apps.epg.tasks import SD_BASE_URL
from core.utils import dispatcharr_http_headers
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):
"""
@ -236,7 +252,7 @@ class EPGSourceViewSet(viewsets.ModelViewSet):
"""
import requests as http_requests
from apps.epg.tasks import SD_BASE_URL
from version import __version__ as dispatcharr_version
from core.utils import dispatcharr_http_headers
source = self.get_object()
if source.source_type != 'schedules_direct':
@ -249,13 +265,10 @@ class EPGSourceViewSet(viewsets.ModelViewSet):
if error:
return error
headers = {
'Content-Type': 'application/json',
'User-Agent': f'Dispatcharr/{dispatcharr_version}',
'token': token,
}
headers = dispatcharr_http_headers(token=token)
if request.method == "GET":
countries = self._fetch_sd_countries()
try:
resp = http_requests.get(
f"{SD_BASE_URL}/lineups",
@ -272,6 +285,7 @@ class EPGSourceViewSet(viewsets.ModelViewSet):
"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()
@ -281,6 +295,7 @@ class EPGSourceViewSet(viewsets.ModelViewSet):
"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(
@ -399,7 +414,7 @@ class EPGSourceViewSet(viewsets.ModelViewSet):
"""
import requests as http_requests
from apps.epg.tasks import SD_BASE_URL
from version import __version__ as dispatcharr_version
from core.utils import dispatcharr_http_headers
source = self.get_object()
if source.source_type != 'schedules_direct':
@ -420,11 +435,7 @@ class EPGSourceViewSet(viewsets.ModelViewSet):
if error:
return error
headers = {
'Content-Type': 'application/json',
'User-Agent': f'Dispatcharr/{dispatcharr_version}',
'token': token,
}
headers = dispatcharr_http_headers(token=token)
try:
resp = http_requests.get(
@ -493,6 +504,7 @@ class ProgramViewSet(viewsets.ModelViewSet):
"""Proxy endpoint for SD program poster images. Nginx caches the response."""
import requests as http_requests
from apps.epg.tasks import SD_BASE_URL
from core.utils import dispatcharr_http_headers
program = self.get_object()
poster_sd_url = (program.custom_properties or {}).get('sd_icon')
@ -516,14 +528,10 @@ class ProgramViewSet(viewsets.ModelViewSet):
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}',
},
headers=dispatcharr_http_headers(),
timeout=10,
)
auth_data = auth_resp.json()
@ -549,7 +557,7 @@ class ProgramViewSet(viewsets.ModelViewSet):
try:
img_resp = http_requests.get(
poster_sd_url,
headers={'token': token},
headers=dispatcharr_http_headers(token=token, content_type=None),
timeout=15,
allow_redirects=True,
)
@ -976,7 +984,9 @@ class EPGGridAPIView(APIView):
channel_name=name_to_parse,
num_days=1,
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)
@ -1072,13 +1082,19 @@ class EPGGridAPIView(APIView):
f"Error creating standard dummy programs for channel {channel.name} (ID: {channel.id}): {str(e)}"
)
# Combine regular and dummy programs
all_programs = list(serialized_programs) + dummy_programs
# Combine regular and dummy programs in place to avoid copying the large list
serialized_programs.extend(dummy_programs)
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 +1125,24 @@ class EPGImportAPIView(APIView):
epg_id = request.data.get("id", None)
force = bool(request.data.get("force", False))
# Check if this is a dummy EPG source
try:
# Reject dummy sources with a narrow existence query, no full row load.
if epg_id is not None:
from .models import EPGSource
epg_source = EPGSource.objects.get(id=epg_id)
if epg_source.source_type == 'dummy':
logger.info(f"EPGImportAPIView: Skipping refresh for dummy EPG source {epg_id}")
if EPGSource.objects.filter(
id=epg_id, source_type="dummy"
).exists():
logger.info(
"EPGImportAPIView: Skipping refresh for dummy EPG source %s",
epg_id,
)
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,
)
except EPGSource.DoesNotExist:
pass # Let the task handle the missing source
refresh_epg_data.delay(epg_id, force=force) # Trigger Celery task
logger.info("EPGImportAPIView: Task dispatched to refresh EPG data.")
@ -1220,10 +1242,9 @@ class CurrentProgramsAPIView(APIView):
# Limit to 50 IDs per request
epg_data_ids = epg_data_ids[:50]
# Defer the multi-MB programme_index the JOIN would pull once per row. The lookup reads it via a targeted refresh_from_db
epg_data_entries = EPGData.objects.select_related('epg_source').defer(
'epg_source__programme_index'
).filter(id__in=epg_data_ids)
epg_data_entries = EPGData.objects.select_related('epg_source').filter(
id__in=epg_data_ids
)
# Batch-fetch current programs for all requested EPG entries in one query
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,
help_text="Last status message, including success results or error information"
)
programme_index = models.JSONField(
null=True,
blank=True,
default=None,
help_text="Byte-offset index mapping tvg_id to file positions, built after each EPG refresh"
)
created_at = models.DateTimeField(
auto_now_add=True,
help_text="Time when this source was created"
@ -124,6 +118,37 @@ class EPGSource(models.Model):
kwargs['update_fields'].remove('updated_at')
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):
tvg_id = models.CharField(max_length=255, null=True, blank=True, db_index=True)
name = models.CharField(max_length=512)
@ -153,6 +178,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.')
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):
return f"{self.title} ({self.start_time} - {self.end_time})"

File diff suppressed because it is too large Load diff

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

@ -0,0 +1,238 @@
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,
_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')

View file

@ -519,7 +519,6 @@ class FindCurrentProgramTests(TestCase):
name="No Index",
source_type="xmltv",
file_path=FIXTURE_XML,
programme_index=None,
)
epg = EPGData.objects.create(
tvg_id="channel.current",

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

View file

@ -7,19 +7,27 @@ Covers:
- fetch_schedules_direct: credential validation
- fetch_schedules_direct: SHA1 password hashing and token exchange
- fetch_schedules_direct: graceful error handling on auth failure
- fetch_schedules_direct: schedule MD5 delta, backfill, and cache invalidation
- parse_schedules_direct_time: correct UTC parsing
- EPG signals: SD sources skip the XMLTV program parser
- fetch_sd_guide_for_epg: per-channel guide fetch on map
- EPG signals: SD sources queue guide fetch when a channel is mapped
"""
import hashlib
from datetime import datetime
from datetime import date, datetime, timedelta, timezone as dt_timezone
from unittest.mock import MagicMock, patch
from django.test import TestCase
from django.utils import timezone
from apps.epg.models import EPGSource, EPGData
from apps.channels.models import Channel
from apps.epg.models import EPGSource, EPGData, ProgramData, SDScheduleMD5
from apps.epg.serializers import EPGSourceSerializer
from apps.epg.tasks import (
_sd_backfill_schedule_dates_without_data,
_sd_compute_schedule_changes_from_md5,
_sd_programs_needing_metadata,
)
# ---------------------------------------------------------------------------
@ -270,6 +278,469 @@ class FetchSchedulesDirectStationsOnlyTests(TestCase):
self.assertEqual(complete_call[1]['channels_count'], 1)
# ---------------------------------------------------------------------------
# Schedule MD5 delta, backfill, and cache tests
# ---------------------------------------------------------------------------
class SDScheduleMd5DeltaTests(TestCase):
"""Pure-function tests for schedule MD5 comparison."""
DATE_LIST = ['2026-06-11', '2026-06-12', '2026-06-13']
def test_detects_changed_md5(self):
server_md5s = {
('10001', '2026-06-11'): {'md5': 'abc', 'last_modified': ''},
('10001', '2026-06-12'): {'md5': 'def', 'last_modified': ''},
}
cached_md5s = {
('10001', '2026-06-11'): 'abc',
('10001', '2026-06-12'): 'old',
}
changed = _sd_compute_schedule_changes_from_md5(
server_md5s, cached_md5s, self.DATE_LIST,
)
self.assertEqual(changed['10001'], ['2026-06-12'])
def test_missing_cache_treated_as_changed(self):
server_md5s = {
('10001', '2026-06-11'): {'md5': 'abc', 'last_modified': ''},
}
changed = _sd_compute_schedule_changes_from_md5(server_md5s, {}, self.DATE_LIST)
self.assertEqual(changed['10001'], ['2026-06-11'])
def test_ignores_dates_outside_fetch_window(self):
server_md5s = {
('10001', '2026-06-01'): {'md5': 'abc', 'last_modified': ''},
}
changed = _sd_compute_schedule_changes_from_md5(server_md5s, {}, self.DATE_LIST)
self.assertEqual(changed, {})
class SDScheduleBackfillTests(TestCase):
"""Backfill must fix stale-cache gaps without re-fetching empty cached days."""
DATE_LIST = ['2026-06-11', '2026-06-12', '2026-06-13']
EPG_ID = 42
EPG_ID_MAP = {'10001': 42}
def _server_md5s(self):
return {
(sid, ds): {'md5': 'hash', 'last_modified': ''}
for sid in ('10001', '10002')
for ds in self.DATE_LIST
}
def test_backfills_when_no_cache_and_no_program_data(self):
changed = {}
count = _sd_backfill_schedule_dates_without_data(
changed,
self._server_md5s(),
self.DATE_LIST,
['10001'],
self.EPG_ID_MAP,
set(),
{},
{'10001'},
)
self.assertEqual(count, 3)
self.assertEqual(len(changed['10001']), 3)
def test_stale_cache_ignored_when_station_has_zero_program_data(self):
"""Newly mapped channel: stale MD5 cache must not block backfill."""
changed = {}
cached_md5s = {
(sid, ds): 'hash'
for sid in ('10001',)
for ds in self.DATE_LIST
}
count = _sd_backfill_schedule_dates_without_data(
changed,
self._server_md5s(),
self.DATE_LIST,
['10001'],
self.EPG_ID_MAP,
set(),
cached_md5s,
{'10001'},
)
self.assertEqual(count, 3)
self.assertEqual(len(changed['10001']), 3)
def test_skips_cached_empty_day_when_station_has_other_program_data(self):
"""Legitimately empty schedule day must not be re-fetched every refresh."""
changed = {}
cached_md5s = {('10001', '2026-06-12'): 'hash'}
dates_with_data = {(self.EPG_ID, date(2026, 6, 11))}
count = _sd_backfill_schedule_dates_without_data(
changed,
self._server_md5s(),
self.DATE_LIST,
['10001'],
self.EPG_ID_MAP,
dates_with_data,
cached_md5s,
set(),
)
self.assertEqual(count, 1)
self.assertEqual(changed['10001'], ['2026-06-13'])
def test_does_not_duplicate_dates_already_marked_changed(self):
changed = {'10001': ['2026-06-11']}
count = _sd_backfill_schedule_dates_without_data(
changed,
self._server_md5s(),
self.DATE_LIST,
['10001'],
self.EPG_ID_MAP,
set(),
{},
{'10001'},
)
self.assertEqual(count, 2)
self.assertEqual(sorted(changed['10001']), self.DATE_LIST)
class SDProgramMetadataDeltaTests(TestCase):
def test_fetches_when_md5_changed(self):
needed = _sd_programs_needing_metadata(
{'EP0001'},
{'EP0001': 'new'},
{'EP0001': 'old'},
{'EP0001'},
)
self.assertEqual(needed, {'EP0001'})
def test_fetches_when_no_local_program_data(self):
needed = _sd_programs_needing_metadata(
{'EP0001', 'EP0002'},
{'EP0001': 'same', 'EP0002': 'same'},
{'EP0001': 'same', 'EP0002': 'same'},
{'EP0001'},
)
self.assertEqual(needed, {'EP0002'})
def test_skips_when_md5_matches_and_program_data_exists(self):
needed = _sd_programs_needing_metadata(
{'EP0001'},
{'EP0001': 'same'},
{'EP0001': 'same'},
{'EP0001'},
)
self.assertEqual(needed, set())
class SDScheduleDeltaIntegrationTests(TestCase):
"""DB-backed tests for cache pruning and mapped-only MD5 API calls."""
MAPPED_STATION = '10001'
UNMAPPED_STATION = '10002'
def _make_sd_source(self):
return EPGSource.objects.create(
name='SD Integration',
source_type='schedules_direct',
username='sduser',
password='sdpass',
)
def _lineup_get_side_effect(self, url, **kwargs):
if url.endswith('/status'):
return MagicMock(
status_code=200,
json=MagicMock(return_value={'systemStatus': [{'status': 'Online'}]}),
)
if url.endswith('/lineups'):
return MagicMock(
status_code=200,
json=MagicMock(return_value={'lineups': [{'lineupID': 'USA-TEST-X'}]}),
)
if '/lineups/USA-TEST-X' in url:
return MagicMock(
status_code=200,
json=MagicMock(return_value={
'stations': [
{
'stationID': self.MAPPED_STATION,
'name': 'Mapped Station',
'callsign': 'MAP',
},
{
'stationID': self.UNMAPPED_STATION,
'name': 'Unmapped Station',
'callsign': 'UNM',
},
],
}),
)
raise AssertionError(f'Unexpected GET URL: {url}')
def _build_date_list(self, days=3):
today = date.today()
return [(today + timedelta(days=i)).strftime('%Y-%m-%d') for i in range(days)]
def _seed_full_window_program_data(self, epg, days=3):
today = date.today()
for i in range(days):
day = today + timedelta(days=i)
start = datetime(day.year, day.month, day.day, 12, 0, tzinfo=dt_timezone.utc)
ProgramData.objects.create(
epg=epg,
start_time=start,
end_time=start + timedelta(hours=1),
title='Show',
tvg_id=epg.tvg_id,
)
@patch('apps.epg.tasks.SD_DAYS_TO_FETCH', 3)
@patch('apps.epg.tasks.send_epg_update')
@patch('apps.epg.tasks.requests.get')
@patch('apps.epg.tasks.requests.post')
def test_md5_api_only_requests_mapped_stations(
self, mock_post, mock_get, mock_send_epg_update,
):
from apps.epg.tasks import fetch_schedules_direct
source = self._make_sd_source()
mapped_epg = EPGData.objects.create(
tvg_id=self.MAPPED_STATION,
name='Mapped',
epg_source=source,
)
Channel.objects.create(name='Mapped Ch', epg_data=mapped_epg)
date_list = self._build_date_list(3)
self._seed_full_window_program_data(mapped_epg, days=3)
today = date.today()
for i, ds in enumerate(date_list):
SDScheduleMD5.objects.create(
epg_source=source,
station_id=self.MAPPED_STATION,
date=today + timedelta(days=i),
md5=f'md5-{ds}',
last_modified=timezone.now(),
)
SDScheduleMD5.objects.create(
epg_source=source,
station_id=self.UNMAPPED_STATION,
date=today,
md5='unmapped-stale',
last_modified=timezone.now(),
)
mock_get.side_effect = self._lineup_get_side_effect
md5_response_payload = {
self.MAPPED_STATION: {
ds: {'code': 0, 'md5': f'md5-{ds}', 'lastModified': '2026-06-11T00:00:00Z'}
for ds in date_list
},
}
def post_side_effect(url, **kwargs):
if url.endswith('/token'):
return MagicMock(
status_code=200,
json=MagicMock(return_value={'code': 0, 'token': 'tok'}),
)
if url.endswith('/schedules/md5'):
return MagicMock(
status_code=200,
json=MagicMock(return_value=md5_response_payload),
)
raise AssertionError(f'Unexpected POST URL: {url}')
mock_post.side_effect = post_side_effect
fetch_schedules_direct(source, force=True)
md5_calls = [c for c in mock_post.call_args_list if c[0][0].endswith('/schedules/md5')]
self.assertEqual(len(md5_calls), 1)
request_body = md5_calls[0][1]['json']
station_ids_in_request = {entry['stationID'] for entry in request_body}
self.assertEqual(station_ids_in_request, {self.MAPPED_STATION})
self.assertFalse(
SDScheduleMD5.objects.filter(
epg_source=source,
station_id=self.UNMAPPED_STATION,
).exists()
)
schedule_calls = [c for c in mock_post.call_args_list if c[0][0].endswith('/schedules')]
self.assertEqual(len(schedule_calls), 0)
source.refresh_from_db()
self.assertEqual(source.status, EPGSource.STATUS_SUCCESS)
@patch('apps.epg.tasks.SD_DAYS_TO_FETCH', 3)
@patch('apps.epg.tasks.send_epg_update')
@patch('apps.epg.tasks.requests.get')
@patch('apps.epg.tasks.requests.post')
def test_newly_mapped_station_fetches_despite_stale_cache(
self, mock_post, mock_get, mock_send_epg_update,
):
from apps.epg.tasks import fetch_schedules_direct
source = self._make_sd_source()
mapped_epg = EPGData.objects.create(
tvg_id=self.MAPPED_STATION,
name='Mapped',
epg_source=source,
)
Channel.objects.create(name='Mapped Ch', epg_data=mapped_epg)
date_list = self._build_date_list(3)
today = date.today()
for i, ds in enumerate(date_list):
SDScheduleMD5.objects.create(
epg_source=source,
station_id=self.MAPPED_STATION,
date=today + timedelta(days=i),
md5=f'md5-{ds}',
last_modified=timezone.now(),
)
mock_get.side_effect = self._lineup_get_side_effect
schedule_payload = [{
'stationID': self.MAPPED_STATION,
'metadata': {'startDate': date_list[0], 'md5': 'md5-' + date_list[0], 'modified': '2026-06-11T00:00:00Z'},
'programs': [{
'programID': 'EP000000000001',
'airDateTime': f'{date_list[0]}T12:00:00Z',
'duration': 3600,
'md5': 'prog-md5-1',
}],
}]
program_payload = [{
'programID': 'EP000000000001',
'titles': [{'title120': 'Test Show'}],
}]
def post_side_effect(url, **kwargs):
if url.endswith('/token'):
return MagicMock(
status_code=200,
json=MagicMock(return_value={'code': 0, 'token': 'tok'}),
)
if url.endswith('/schedules/md5'):
return MagicMock(
status_code=200,
json=MagicMock(return_value={
self.MAPPED_STATION: {
ds: {'code': 0, 'md5': f'md5-{ds}', 'lastModified': '2026-06-11T00:00:00Z'}
for ds in date_list
},
}),
)
if url.endswith('/schedules'):
return MagicMock(status_code=200, json=MagicMock(return_value=schedule_payload))
if url.endswith('/programs'):
return MagicMock(status_code=200, json=MagicMock(return_value=program_payload))
raise AssertionError(f'Unexpected POST URL: {url}')
mock_post.side_effect = post_side_effect
fetch_schedules_direct(source, force=True)
schedule_calls = [c for c in mock_post.call_args_list if c[0][0].endswith('/schedules')]
self.assertGreaterEqual(len(schedule_calls), 1)
self.assertEqual(
ProgramData.objects.filter(epg=mapped_epg).count(),
1,
)
@patch('apps.epg.tasks.SD_DAYS_TO_FETCH', 3)
@patch('apps.epg.tasks.send_epg_update')
@patch('apps.epg.tasks.requests.get')
@patch('apps.epg.tasks.requests.post')
def test_orphan_program_data_removed_on_post_refresh(
self, mock_post, mock_get, mock_send_epg_update,
):
from apps.epg.tasks import fetch_schedules_direct
source = self._make_sd_source()
mapped_epg = EPGData.objects.create(
tvg_id=self.MAPPED_STATION,
name='Mapped',
epg_source=source,
)
orphan_epg = EPGData.objects.create(
tvg_id=self.UNMAPPED_STATION,
name='Orphan',
epg_source=source,
)
Channel.objects.create(name='Mapped Ch', epg_data=mapped_epg)
date_list = self._build_date_list(3)
self._seed_full_window_program_data(mapped_epg, days=3)
start = timezone.now()
ProgramData.objects.create(
epg=orphan_epg,
start_time=start,
end_time=start + timedelta(hours=1),
title='Orphan Show',
tvg_id=orphan_epg.tvg_id,
)
today = date.today()
for i, ds in enumerate(date_list):
SDScheduleMD5.objects.create(
epg_source=source,
station_id=self.MAPPED_STATION,
date=today + timedelta(days=i),
md5=f'md5-{ds}',
last_modified=timezone.now(),
)
mock_get.side_effect = self._lineup_get_side_effect
def post_side_effect(url, **kwargs):
if url.endswith('/token'):
return MagicMock(
status_code=200,
json=MagicMock(return_value={'code': 0, 'token': 'tok'}),
)
if url.endswith('/schedules/md5'):
return MagicMock(
status_code=200,
json=MagicMock(return_value={
self.MAPPED_STATION: {
ds: {'code': 0, 'md5': f'md5-{ds}', 'lastModified': '2026-06-11T00:00:00Z'}
for ds in date_list
},
}),
)
raise AssertionError(f'Unexpected POST URL: {url}')
mock_post.side_effect = post_side_effect
fetch_schedules_direct(source, force=True)
self.assertFalse(ProgramData.objects.filter(epg=orphan_epg).exists())
def test_stale_program_md5_fetched_when_no_program_data(self):
programs_with_data = set()
needed = _sd_programs_needing_metadata(
{'EP0001'},
{'EP0001': 'cached-md5'},
{'EP0001': 'cached-md5'},
programs_with_data,
)
self.assertEqual(needed, {'EP0001'})
def test_shared_program_skips_metadata_when_cached(self):
needed = _sd_programs_needing_metadata(
{'EP0001'},
{'EP0001': 'cached-md5'},
{'EP0001': 'cached-md5'},
{'EP0001'},
)
self.assertEqual(needed, set())
# ---------------------------------------------------------------------------
# parse_schedules_direct_time tests
# ---------------------------------------------------------------------------
@ -298,17 +769,400 @@ class ParseSchedulesDirectTimeTests(TestCase):
parse_schedules_direct_time('not-a-timestamp')
# ---------------------------------------------------------------------------
# dispatch_program_refresh_for_epg_ids tests
# ---------------------------------------------------------------------------
class SDDispatchProgramRefreshTests(TestCase):
"""Bulk SD assignment should batch guide fetches above the threshold."""
STATION = '10001'
def _make_sd_source(self):
return EPGSource.objects.create(
name='SD Dispatch Test',
source_type='schedules_direct',
username='sduser',
password='sdpass',
)
def _make_xml_source(self):
return EPGSource.objects.create(
name='XML Dispatch Test',
source_type='xmltv',
url='http://example.com/epg.xml',
)
@patch('apps.epg.tasks.fetch_sd_mapped_guide_batch.delay')
@patch('apps.epg.tasks.parse_programs_for_tvg_id.delay')
def test_xmltv_still_uses_parse_programs_per_id(
self, mock_parse_delay, mock_batch_delay,
):
from apps.epg.tasks import dispatch_program_refresh_for_epg_ids
xml_source = self._make_xml_source()
epg = EPGData.objects.create(
tvg_id='xml-1',
name='XML Channel',
epg_source=xml_source,
)
count = dispatch_program_refresh_for_epg_ids({epg.id})
self.assertEqual(count, 1)
mock_parse_delay.assert_called_once_with(epg.id)
mock_batch_delay.assert_not_called()
@patch('apps.epg.tasks.fetch_sd_mapped_guide_batch.delay')
@patch('apps.epg.tasks.parse_programs_for_tvg_id.delay')
def test_sd_below_threshold_uses_per_epg_tasks(
self, mock_parse_delay, mock_batch_delay,
):
from apps.epg.tasks import dispatch_program_refresh_for_epg_ids
source = self._make_sd_source()
epgs = [
EPGData.objects.create(
tvg_id=f'{self.STATION}{i}',
name=f'Station {i}',
epg_source=source,
)
for i in range(2)
]
count = dispatch_program_refresh_for_epg_ids({e.id for e in epgs})
self.assertEqual(count, 2)
self.assertEqual(mock_parse_delay.call_count, 2)
mock_batch_delay.assert_not_called()
@patch('apps.epg.tasks.fetch_sd_mapped_guide_batch.delay')
@patch('apps.epg.tasks.parse_programs_for_tvg_id.delay')
def test_sd_at_threshold_uses_batched_fetch(
self, mock_parse_delay, mock_batch_delay,
):
from apps.epg.tasks import dispatch_program_refresh_for_epg_ids
source = self._make_sd_source()
epgs = [
EPGData.objects.create(
tvg_id=f'{self.STATION}{i}',
name=f'Station {i}',
epg_source=source,
)
for i in range(3)
]
count = dispatch_program_refresh_for_epg_ids({e.id for e in epgs})
self.assertEqual(count, 1)
mock_batch_delay.assert_called_once_with(source.id)
mock_parse_delay.assert_not_called()
@patch('apps.epg.tasks.fetch_sd_mapped_guide_batch.delay')
@patch('apps.epg.tasks.parse_programs_for_tvg_id.delay')
def test_sd_skips_when_program_data_exists(
self, mock_parse_delay, mock_batch_delay,
):
from apps.epg.tasks import dispatch_program_refresh_for_epg_ids
source = self._make_sd_source()
epg = EPGData.objects.create(
tvg_id=self.STATION,
name='Has Data',
epg_source=source,
)
start = timezone.now()
ProgramData.objects.create(
epg=epg,
start_time=start,
end_time=start + timedelta(hours=1),
title='Show',
tvg_id=epg.tvg_id,
)
count = dispatch_program_refresh_for_epg_ids({epg.id})
self.assertEqual(count, 0)
mock_parse_delay.assert_not_called()
mock_batch_delay.assert_not_called()
class SDGuideFetchCoordinationTests(TestCase):
"""Batch and single-EPG SD fetches coordinate via locks and deferred retries."""
STATION = '10001'
def _make_sd_source(self):
return EPGSource.objects.create(
name='SD Coordination',
source_type='schedules_direct',
username='sduser',
password='sdpass',
)
@patch('apps.epg.tasks.fetch_schedules_direct')
@patch('apps.epg.tasks.acquire_task_lock', return_value=False)
@patch('apps.epg.tasks.fetch_sd_mapped_guide_batch.apply_async')
def test_batch_fetch_defers_when_lock_held(
self, mock_apply_async, mock_acquire, mock_fetch,
):
from apps.epg.tasks import (
fetch_sd_mapped_guide_batch,
SD_MAPPED_GUIDE_BATCH_DEFER_SECONDS,
)
source = self._make_sd_source()
result = fetch_sd_mapped_guide_batch(source.id)
self.assertEqual(result, 'Deferred - batch already in progress')
mock_apply_async.assert_called_once_with(
args=[source.id],
kwargs={'force': False, '_defer_retry': 1},
countdown=SD_MAPPED_GUIDE_BATCH_DEFER_SECONDS,
)
mock_fetch.assert_not_called()
@patch('apps.epg.tasks.fetch_schedules_direct')
@patch('apps.epg.tasks.acquire_task_lock', return_value=False)
@patch('apps.epg.tasks.fetch_sd_mapped_guide_batch.apply_async')
def test_batch_fetch_stops_after_max_defer_retries(
self, mock_apply_async, mock_acquire, mock_fetch,
):
from apps.epg.tasks import fetch_sd_mapped_guide_batch
source = self._make_sd_source()
result = fetch_sd_mapped_guide_batch(source.id, _defer_retry=2)
self.assertEqual(result, 'Task already running')
mock_apply_async.assert_not_called()
mock_fetch.assert_not_called()
@patch('apps.epg.tasks.fetch_schedules_direct')
@patch('apps.epg.tasks.acquire_task_lock', return_value=True)
@patch('apps.epg.tasks.release_task_lock')
@patch('apps.epg.tasks.TaskLockRenewer')
@patch('apps.epg.tasks.is_task_lock_held', return_value=True)
@patch('apps.epg.tasks.fetch_sd_guide_for_epg.apply_async')
def test_single_epg_defers_while_batch_running(
self, mock_apply_async, mock_batch_held, mock_renewer,
mock_release, mock_acquire, mock_fetch,
):
from apps.epg.tasks import (
fetch_sd_guide_for_epg,
SD_MAPPED_GUIDE_BATCH_DEFER_SECONDS,
)
source = self._make_sd_source()
epg = EPGData.objects.create(
tvg_id=self.STATION,
name='Deferred Station',
epg_source=source,
)
result = fetch_sd_guide_for_epg(epg.id)
self.assertEqual(result, 'Deferred - mapped batch in progress')
mock_apply_async.assert_called_once_with(
args=[epg.id],
kwargs={'force': False, '_defer_retry': 1},
countdown=SD_MAPPED_GUIDE_BATCH_DEFER_SECONDS,
)
mock_fetch.assert_not_called()
mock_acquire.assert_not_called()
@patch('apps.epg.tasks.fetch_schedules_direct')
@patch('apps.epg.tasks.acquire_task_lock', return_value=True)
@patch('apps.epg.tasks.release_task_lock')
@patch('apps.epg.tasks.TaskLockRenewer')
@patch('apps.epg.tasks.is_task_lock_held', return_value=True)
@patch('apps.epg.tasks.fetch_sd_guide_for_epg.apply_async')
def test_single_epg_proceeds_after_max_batch_deferrals(
self, mock_apply_async, mock_batch_held, mock_renewer,
mock_release, mock_acquire, mock_fetch,
):
from apps.epg.tasks import fetch_sd_guide_for_epg
source = self._make_sd_source()
epg = EPGData.objects.create(
tvg_id=self.STATION,
name='Fallback Station',
epg_source=source,
)
result = fetch_sd_guide_for_epg(epg.id, _defer_retry=2)
self.assertEqual(result, 'SD guide fetch complete')
mock_apply_async.assert_not_called()
mock_fetch.assert_called_once()
mock_acquire.assert_called_once_with('parse_epg_programs', epg.id)
# ---------------------------------------------------------------------------
# Signal tests
# ---------------------------------------------------------------------------
class SDSingleEpgFetchTests(TestCase):
"""Per-channel SD guide fetch on map (epg_id_only path)."""
MAPPED_STATION = '10001'
def _make_sd_source(self, updated_at=None):
source = EPGSource.objects.create(
name='SD Single EPG',
source_type='schedules_direct',
username='sduser',
password='sdpass',
)
if updated_at is not None:
EPGSource.objects.filter(id=source.id).update(updated_at=updated_at)
source.refresh_from_db()
return source
def _lineup_get_side_effect(self, url, **kwargs):
if url.endswith('/status'):
return MagicMock(
status_code=200,
json=MagicMock(return_value={'systemStatus': [{'status': 'Online'}]}),
)
if url.endswith('/lineups'):
return MagicMock(
status_code=200,
json=MagicMock(return_value={'lineups': [{'lineupID': 'USA-TEST-X'}]}),
)
raise AssertionError(f'Unexpected GET URL: {url}')
@patch('apps.epg.tasks.acquire_task_lock', return_value=True)
@patch('apps.epg.tasks.release_task_lock')
@patch('apps.epg.tasks.TaskLockRenewer')
def test_fetch_sd_guide_skips_when_program_data_exists(
self, mock_renewer, mock_release, mock_acquire,
):
from apps.epg.tasks import fetch_sd_guide_for_epg
source = self._make_sd_source()
epg = EPGData.objects.create(
tvg_id=self.MAPPED_STATION,
name='Mapped',
epg_source=source,
)
start = timezone.now()
ProgramData.objects.create(
epg=epg,
start_time=start,
end_time=start + timedelta(hours=1),
title='Existing',
tvg_id=epg.tvg_id,
)
with patch('apps.epg.tasks.fetch_schedules_direct') as mock_fetch:
result = fetch_sd_guide_for_epg(epg.id)
self.assertEqual(result, 'Guide data already present')
mock_fetch.assert_not_called()
@patch('apps.epg.tasks.acquire_task_lock', return_value=True)
@patch('apps.epg.tasks.release_task_lock')
@patch('apps.epg.tasks.TaskLockRenewer')
def test_parse_programs_for_tvg_id_delegates_to_sd_fetch(
self, mock_renewer, mock_release, mock_acquire,
):
from apps.epg.tasks import parse_programs_for_tvg_id
source = self._make_sd_source()
epg = EPGData.objects.create(
tvg_id=self.MAPPED_STATION,
name='Mapped',
epg_source=source,
)
with patch('apps.epg.tasks.fetch_sd_guide_for_epg', return_value='SD guide fetch complete') as mock_sd:
result = parse_programs_for_tvg_id(epg.id)
mock_sd.assert_called_once_with(epg.id, force=False)
self.assertEqual(result, 'SD guide fetch complete')
@patch('apps.epg.tasks.SD_DAYS_TO_FETCH', 3)
@patch('apps.epg.tasks.send_epg_update')
@patch('apps.epg.tasks.requests.get')
@patch('apps.epg.tasks.requests.post')
def test_single_epg_fetch_skips_lineup_sync_and_updated_at(
self, mock_post, mock_get, mock_send_epg_update,
):
from apps.epg.tasks import fetch_schedules_direct
prior_updated = timezone.now() - timedelta(hours=1)
source = self._make_sd_source(updated_at=prior_updated)
epg = EPGData.objects.create(
tvg_id=self.MAPPED_STATION,
name='Mapped',
epg_source=source,
)
Channel.objects.create(name='Mapped Ch', epg_data=epg)
date_list = [
(date.today() + timedelta(days=i)).strftime('%Y-%m-%d')
for i in range(3)
]
mock_get.side_effect = self._lineup_get_side_effect
schedule_payload = [{
'stationID': self.MAPPED_STATION,
'metadata': {'startDate': date_list[0], 'md5': 'md5-new', 'modified': '2026-06-11T00:00:00Z'},
'programs': [{
'programID': 'EP000000000001',
'airDateTime': f'{date_list[0]}T12:00:00Z',
'duration': 3600,
'md5': 'prog-md5-1',
}],
}]
program_payload = [{
'programID': 'EP000000000001',
'titles': [{'title120': 'Test Show'}],
}]
def post_side_effect(url, **kwargs):
if url.endswith('/token'):
return MagicMock(
status_code=200,
json=MagicMock(return_value={'code': 0, 'token': 'tok'}),
)
if url.endswith('/schedules/md5'):
return MagicMock(
status_code=200,
json=MagicMock(return_value={
self.MAPPED_STATION: {
ds: {'code': 0, 'md5': f'md5-{ds}', 'lastModified': '2026-06-11T00:00:00Z'}
for ds in date_list
},
}),
)
if url.endswith('/schedules'):
return MagicMock(status_code=200, json=MagicMock(return_value=schedule_payload))
if url.endswith('/programs'):
return MagicMock(status_code=200, json=MagicMock(return_value=program_payload))
raise AssertionError(f'Unexpected POST URL: {url}')
mock_post.side_effect = post_side_effect
fetch_schedules_direct(source, epg_id_only=epg.id)
lineup_detail_calls = [
c for c in mock_get.call_args_list
if '/lineups/' in c[0][0] and not c[0][0].endswith('/lineups')
]
self.assertEqual(lineup_detail_calls, [])
source.refresh_from_db()
self.assertEqual(source.updated_at, prior_updated)
self.assertEqual(ProgramData.objects.filter(epg=epg).count(), 1)
class SDSourceSignalTests(TestCase):
"""SD EPG sources must skip the XMLTV program parser signal."""
"""SD EPG sources queue per-EPG guide fetch when a channel is mapped."""
@patch('apps.channels.signals.parse_programs_for_tvg_id')
def test_sd_source_skips_xmltv_parse_on_channel_create(self, mock_parse):
"""Creating a channel linked to an SD EPG source must not trigger
the XMLTV program parser SD data is handled by fetch_schedules_direct."""
def test_sd_source_queues_guide_fetch_on_channel_create(self, mock_parse):
from apps.epg.models import EPGData
from apps.channels.models import Channel
@ -329,7 +1183,7 @@ class SDSourceSignalTests(TestCase):
epg_data=epg_data,
)
mock_parse.delay.assert_not_called()
mock_parse.delay.assert_called_once_with(epg_data.id)
# ---------------------------------------------------------------------------

View file

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

View file

@ -23,7 +23,7 @@ logger = logging.getLogger(__name__)
from .models import M3UAccount, M3UFilter, ServerGroup, M3UAccountProfile
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 core.serializers import UserAgentSerializer
from apps.vod.models import M3UVODCategoryRelation
@ -536,7 +536,9 @@ class M3UAccountViewSet(viewsets.ModelViewSet):
auto_channel_sync=setting.get("auto_channel_sync", False),
auto_sync_channel_start=setting.get("auto_sync_channel_start"),
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
if setting.get("channel_group")
@ -561,7 +563,9 @@ class M3UAccountViewSet(viewsets.ModelViewSet):
category_id=setting["id"],
m3u_account=account,
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
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
from django import forms
from .models import M3UAccount, M3UFilter
from core.utils import ensure_custom_properties_dict
import re
class M3UAccountForm(forms.ModelForm):
@ -28,7 +29,9 @@ class M3UAccountForm(forms.ModelForm):
# Set initial value for enable_vod from 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)
def save(self, commit=True):
@ -37,8 +40,9 @@ class M3UAccountForm(forms.ModelForm):
# Handle enable_vod field
enable_vod = self.cleaned_data.get('enable_vod', False)
# Parse existing custom_properties
custom_props = instance.custom_properties or {}
if not isinstance(custom_props, dict):
custom_props = ensure_custom_properties_dict(custom_props)
# Update VOD preference
custom_props['enable_vod'] = enable_vod

View file

@ -7,6 +7,7 @@ from django.dispatch import receiver
from apps.channels.models import StreamProfile
from django_celery_beat.models import PeriodicTask
from core.models import CoreSettings, UserAgent
from core.utils import custom_properties_as_dict
CUSTOM_M3U_ACCOUNT_NAME = "custom"
@ -124,6 +125,11 @@ class M3UAccount(models.Model):
return user_agent
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
if "update_fields" in kwargs and "updated_at" not in kwargs["update_fields"]:
# Don't modify updated_at for regular updates
@ -194,7 +200,13 @@ class M3UFilter(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(
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):
"""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."""
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()
if parsed is not None:
# 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.response import Response
from .models import M3UAccount, M3UFilter, ServerGroup, M3UAccountProfile
@ -270,11 +270,15 @@ class M3UAccountSerializer(serializers.ModelSerializer):
# overwrite their corresponding keys; clients should set those via
# the typed top-level fields rather than the custom_properties
# payload.
incoming_custom = validated_data.get("custom_properties") or {}
custom_props = {
**(instance.custom_properties or {}),
**incoming_custom,
}
incoming_custom = {}
if "custom_properties" in validated_data:
incoming_custom = validated_data["custom_properties"] or {}
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:
custom_props["enable_vod"] = enable_vod
@ -346,7 +350,9 @@ class M3UAccountSerializer(serializers.ModelSerializer):
auto_enable_new_groups_series = validated_data.pop("auto_enable_new_groups_series", True)
# 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)
custom_props["enable_vod"] = enable_vod

View file

@ -7,29 +7,23 @@ import os
import gc
import gzip, zipfile
from concurrent.futures import ThreadPoolExecutor, as_completed
from celery.app.control import Inspect
from celery.result import AsyncResult
from celery import shared_task, current_app, group
from celery import shared_task
from django.conf import settings
from django.core.cache import cache
from django.db import models, transaction
from .models import M3UAccount
from apps.channels.models import Stream, ChannelGroup, ChannelGroupM3UAccount
from asgiref.sync import async_to_sync
from channels.layers import get_channel_layer
from django.utils import timezone
import time
import json
from core.utils import (
RedisClient,
acquire_task_lock,
release_task_lock,
TaskLockRenewer,
natural_sort_key,
log_system_event,
ensure_custom_properties_dict,
)
from core.models import CoreSettings, UserAgent
from asgiref.sync import async_to_sync
from core.xtream_codes import Client as XCClient
from core.utils import send_websocket_update
from .utils import normalize_stream_url
@ -39,6 +33,104 @@ logger = logging.getLogger(__name__)
BATCH_SIZE = 1500 # Optimized batch size for threading
m3u_dir = os.path.join(settings.MEDIA_ROOT, "cached_m3u")
_NON_TERMINAL_REFRESH_STATUSES = frozenset({
M3UAccount.Status.FETCHING,
M3UAccount.Status.PARSING,
})
def _release_task_db_connection():
"""Return the Celery worker's DB connection to a clean state after ORM errors."""
from django.db import close_old_connections
close_old_connections()
def _db_query_with_retry(fn, *, label="DB query", max_retries=2):
"""
Run an ORM read with one connection reset + retry on transient failures.
Poisoned Celery worker connections often surface as OperationalError or as
``IndexError: list index out of range`` inside Django's row converters.
"""
from django.db import InterfaceError, OperationalError
transient_errors = (OperationalError, InterfaceError, IndexError)
for attempt in range(max_retries):
try:
return fn()
except transient_errors as exc:
if attempt + 1 >= max_retries:
raise
logger.warning(
"%s failed (%s), resetting DB connection (%s/%s)",
label,
exc,
attempt + 1,
max_retries,
)
_release_task_db_connection()
def _get_active_m3u_account(account_id):
return _db_query_with_retry(
lambda: M3UAccount.objects.get(id=account_id, is_active=True),
label=f"load active M3U account {account_id}",
)
def _set_m3u_account_status(
account_id,
status,
last_message=None,
*,
notify_error=False,
ws_action="parsing",
ws_error=None,
):
"""Update account status using a fresh connection (safe after DB failures)."""
_release_task_db_connection()
update = {"status": status}
if last_message is not None:
update["last_message"] = last_message
try:
M3UAccount.objects.filter(id=account_id).update(**update)
if notify_error:
send_m3u_update(
account_id,
ws_action,
100,
status="error",
error=ws_error or last_message,
)
except Exception as e:
logger.error(
f"Failed to set account {account_id} status to {status}: {e}"
)
def _ensure_m3u_refresh_terminal_status(account_id):
"""Mark refresh as failed when the task exits while still in progress."""
_release_task_db_connection()
try:
current_status = (
M3UAccount.objects.filter(id=account_id)
.values_list("status", flat=True)
.first()
)
if current_status in _NON_TERMINAL_REFRESH_STATUSES:
message = "Refresh did not complete successfully"
M3UAccount.objects.filter(id=account_id).update(
status=M3UAccount.Status.ERROR,
last_message=message,
)
send_m3u_update(
account_id, "parsing", 100, status="error", error=message
)
except Exception as e:
logger.debug(
f"Could not verify terminal refresh status for account {account_id}: {e}"
)
_EXTINF_ATTR_RE = re.compile(r'([^\s=]+)\s*=\s*(["\'])(.*?)\2')
@ -630,7 +722,7 @@ def process_groups(account, groups, scan_start_time=None):
logger.info(f"Currently {len(existing_groups)} existing groups")
# Check if we should auto-enable new groups based on account settings
account_custom_props = account.custom_properties or {}
account_custom_props = ensure_custom_properties_dict(account.custom_properties)
auto_enable_new_groups_live = account_custom_props.get("auto_enable_new_groups_live", True)
# Separate existing groups from groups that need to be created
@ -673,7 +765,9 @@ def process_groups(account, groups, scan_start_time=None):
existing_rel = existing_relationships[group.name]
# Get existing custom properties (now JSONB, no need to parse)
existing_custom_props = existing_rel.custom_properties or {}
existing_custom_props = ensure_custom_properties_dict(
existing_rel.custom_properties
)
# Get the new xc_id from groups data
new_xc_id = custom_props.get("xc_id")
@ -696,6 +790,8 @@ def process_groups(account, groups, scan_start_time=None):
logger.debug(f"Updated xc_id for group '{group.name}' from '{existing_xc_id}' to '{new_xc_id}' - account {account.id}")
else:
# Update last_seen even if xc_id hasn't changed
if isinstance(existing_rel.custom_properties, str):
existing_rel.custom_properties = existing_custom_props
existing_rel.last_seen = scan_start_time
existing_rel.is_stale = False
relations_to_update.append(existing_rel)
@ -790,6 +886,7 @@ def collect_xc_streams(account_id, enabled_groups):
"""Collect all XC streams in a single API call and filter by enabled groups."""
account = M3UAccount.objects.get(id=account_id)
all_streams = []
filtered_count = 0
# Create a mapping from category_id to group info for filtering
enabled_category_ids = {}
@ -819,7 +916,6 @@ def collect_xc_streams(account_id, enabled_groups):
logger.info(f"Retrieved {len(all_xc_streams)} total live streams from provider")
# Filter streams based on enabled categories
filtered_count = 0
for stream in all_xc_streams:
# Fall back to a generated name if the provider returns null/empty
stream_name = stream.get("name") or f"{account.name} - {stream.get('stream_id', 'Unknown')}"
@ -862,11 +958,17 @@ def collect_xc_streams(account_id, enabled_groups):
all_streams.append(stream_data)
filtered_count += 1
# Drop the full provider catalog before returning; only filtered rows are needed.
del all_xc_streams
gc.collect()
except Exception as e:
logger.error(f"Failed to fetch XC streams: {str(e)}")
return []
logger.info(f"Filtered {filtered_count} streams from {len(enabled_category_ids)} enabled categories")
logger.info(
f"Filtered {filtered_count} streams from {len(enabled_category_ids)} enabled categories"
)
return all_streams
def process_xc_category_direct(account_id, batch, groups, hash_keys):
@ -1270,6 +1372,7 @@ def process_m3u_batch_direct(account_id, batch, groups, hash_keys):
# Free batch data structures (reference-counted deallocation)
del streams_to_create, streams_to_update, stream_hashes, existing_streams
gc.collect()
return retval
@ -1716,70 +1819,36 @@ def _pick_target_number(
fixed_cursor,
fallback_start,
end_number=None,
range_start=None,
):
"""
Return the channel number a given stream should claim under the group's
numbering mode, or None if the configured range is exhausted.
Return the channel number a stream should claim under the group's numbering
mode, or None if the range is exhausted. Shared by the renumber and create
passes. Each mode reads only the fields its UI exposes:
Shared by the existing-channel renumber pass and the new-channel create
pass so both honor identical mode semantics: provider-supplied number
when available and free, otherwise fall back; or always next-available;
or fixed-cursor sequential.
`range_start`, when provided, is the inclusive lower bound for the
group's configured numbering range. Provider-supplied numbers below
this bound fall back to the next-available picker so freshly-created
channels never land outside the configured range.
- provider: the provider number is authoritative and used as-is when free.
Start (`channel_numbering_fallback`) and End bound only the fallback for
streams with no provider number; `auto_sync_channel_start` does not apply.
- next_available: lowest free number from 1; End does not apply (its UI has
no range, so a stale End must not cap it).
- fixed: sequential from the cursor, bounded by End.
"""
if mode == "provider":
chno = stream.stream_chno
if (
chno is not None
and chno not in used_numbers
and (range_start is None or chno >= range_start)
and (end_number is None or chno <= end_number)
):
if chno is not None and chno not in used_numbers:
return chno
# No usable provider number: walk from fallback_start, bumped up
# to range_start when set so the fallback never lands below the
# configured range.
effective_start = (
max(fallback_start, range_start)
if range_start is not None
else fallback_start
)
return _next_available_number(used_numbers, effective_start, end=end_number)
# No usable provider number: fall back into the configured range.
return _next_available_number(used_numbers, fallback_start, end=end_number)
if mode == "next_available":
return _next_available_number(used_numbers, 1, end=end_number)
return _next_available_number(used_numbers, 1)
return _next_available_number(used_numbers, fixed_cursor, end=end_number)
def _custom_properties_as_dict(value):
"""
Normalize a JSONField-backed custom_properties value into a dict.
Historical data has rows where the field holds a JSON-encoded string
instead of a dict. Django's JSONField serializes whatever it gets, so
`.get()` on one of those rows raises AttributeError and aborts the
entire sync. Treat string values as JSON to parse, and fall back to an
empty dict for anything that isn't a dict after parsing.
"""
import json
if isinstance(value, dict):
return value
if isinstance(value, str):
try:
parsed = json.loads(value)
except (ValueError, TypeError):
logger.warning(
"custom_properties stored as non-JSON string; ignoring: %r",
value[:100],
)
return {}
return parsed if isinstance(parsed, dict) else {}
return {}
def _range_exhausted_error(mode, start_number, end_number, fallback_start):
"""User-facing range text for RANGE_EXHAUSTED failures."""
range_start = (
int(fallback_start) if mode == "provider" else int(start_number)
)
return f"Channel number range {range_start}-{int(end_number)} is full"
def _classify_sync_failure(exc):
@ -1890,7 +1959,7 @@ def sync_auto_channels(account_id, scan_start_time=None):
custom_epg_id = None # New option: select specific EPG source (takes priority over force_dummy_epg)
channel_numbering_mode = "fixed" # Default mode
channel_numbering_fallback = 1 # Default fallback for provider mode
group_custom_props = _custom_properties_as_dict(
group_custom_props = ensure_custom_properties_dict(
group_relation.custom_properties
)
if group_custom_props:
@ -2253,7 +2322,6 @@ def sync_auto_channels(account_id, scan_start_time=None):
temp_channel_number,
channel_numbering_fallback,
end_number=end_number,
range_start=start_number,
)
# Range exhausted: leave the channel at its existing
@ -2290,17 +2358,16 @@ def sync_auto_channels(account_id, scan_start_time=None):
f"Renumbered {len(channels_to_renumber)} channels to maintain sort order"
)
# When the group's configured range is narrower than its existing
# channels span, any non-hidden auto-created channel whose number
# falls outside [start, end] gets deleted. The new-channel
# creation loop below picks up the freed stream and re-creates
# the channel at a slot inside the new range, so the net user
# outcome is a renumber, not a failure. Counted in
# channels_deleted; the replacement counts in channels_created.
# Hidden channels are preserved. Runs BEFORE new-channel creation
# so slots freed by the deletions are available to incoming
# streams.
if end_number is not None:
# Range enforcement runs in fixed mode only: it is the one mode with
# a user-set [start, end]. Provider numbers are authoritative and
# next_available has no range, so their channels are never deleted
# for falling outside start/end.
#
# Channels outside the range are deleted (hidden ones preserved);
# the creation loop below re-adds the freed streams inside the range,
# so the net effect is a renumber, not a failure. Runs first so the
# freed slots are available.
if end_number is not None and channel_numbering_mode == "fixed":
overflow_delete_ids = []
for stream_id, ch in list(existing_channel_map.items()):
if ch.hidden_from_output:
@ -2461,7 +2528,6 @@ def sync_auto_channels(account_id, scan_start_time=None):
current_channel_number,
channel_numbering_fallback,
end_number=end_number,
range_start=start_number,
)
if target_number is None:
@ -2472,9 +2538,11 @@ def sync_auto_channels(account_id, scan_start_time=None):
"stream_id": stream.id,
"group": channel_group.name,
"reason": "RANGE_EXHAUSTED",
"error": (
f"Channel number range "
f"{int(start_number)}-{int(end_number)} is full"
"error": _range_exhausted_error(
channel_numbering_mode,
start_number,
end_number,
channel_numbering_fallback,
),
})
processed_stream_ids.add(stream.id)
@ -2570,20 +2638,18 @@ def sync_auto_channels(account_id, scan_start_time=None):
channels_created += len(channel_objs)
# One EPG parse task per unique EPGData replaces the
# per-channel post_save dispatch bypassed by bulk_create.
from apps.epg.tasks import parse_programs_for_tvg_id
from apps.epg.tasks import dispatch_program_refresh_for_epg_ids
unique_epg_ids = {
ch.epg_data_id for ch in channel_objs if ch.epg_data_id
}
for epg_id in unique_epg_ids:
parse_programs_for_tvg_id.delay(epg_id)
parse_dispatched = dispatch_program_refresh_for_epg_ids(unique_epg_ids)
logger.debug(
f"Bulk created {len(channel_objs)} channels in group "
f"'{channel_group.name}'; dispatched "
f"{len(unique_epg_ids)} unique EPG parse task(s)"
f"{parse_dispatched} EPG refresh task(s) for "
f"{len(unique_epg_ids)} unique EPG id(s)"
)
# bulk_update writes only the columns named in `fields` and
@ -2597,17 +2663,14 @@ def sync_auto_channels(account_id, scan_start_time=None):
batch_size=500,
)
if epg_dirty_channel_ids:
from apps.epg.tasks import parse_programs_for_tvg_id
from apps.epg.tasks import dispatch_program_refresh_for_epg_ids
# Dispatch only for channels whose epg_data_id changed.
# Other dirty channels would queue redundant parses.
unique_epg_ids = {
ch.epg_data_id
for ch in existing_dirty_channels
if ch.id in epg_dirty_channel_ids and ch.epg_data_id
}
for epg_id in unique_epg_ids:
parse_programs_for_tvg_id.delay(epg_id)
dispatch_program_refresh_for_epg_ids(unique_epg_ids)
logger.debug(
f"Bulk updated {len(existing_dirty_channels)} existing "
f"channels (fields: {sorted(existing_dirty_field_set)})"
@ -2740,7 +2803,7 @@ def sync_auto_channels(account_id, scan_start_time=None):
# "preserve_customized" keeps those with a ChannelOverride row;
# "never" disables cleanup. Hidden channels are preserved across all
# modes so event/PPV channels that come and go are not silently lost.
cleanup_mode = (account.custom_properties or {}).get(
cleanup_mode = ensure_custom_properties_dict(account.custom_properties).get(
"orphan_channel_cleanup", "always"
)
if cleanup_mode != "never":
@ -2822,12 +2885,13 @@ def get_transformed_credentials(account, profile=None):
logger.warning(f"Could not get primary profile for account {account.name}: {e}")
profile = None
base_url = account.server_url
from core.xtream_codes import normalize_server_url
base_url = normalize_server_url(account.server_url)
base_username = account.username
base_password = account.password # Build a complete URL with credentials (similar to how IPTV URLs are structured)
# Format: http://server.com:port/live/username/password/1234.ts
if base_url and base_username and base_password:
# Remove trailing slash from server URL if present
clean_server_url = base_url.rstrip('/')
# Build the complete URL with embedded credentials
@ -2953,12 +3017,19 @@ def refresh_account_profiles(account_id):
if profile_client.authenticate():
# Get account information specific to this profile's credentials
profile_account_info = profile_client.get_account_info()
if not isinstance(profile_account_info, dict):
raise TypeError(
f"Unexpected account info type: {type(profile_account_info).__name__}"
)
# Merge with existing custom_properties if they exist
existing_props = profile.custom_properties or {}
existing_props.update(profile_account_info)
profile.custom_properties = existing_props
profile.save(update_fields=['custom_properties'])
profile.custom_properties = {
**ensure_custom_properties_dict(
profile.custom_properties
),
**profile_account_info,
}
profile.save(update_fields=['custom_properties', 'exp_date'])
profiles_updated += 1
logger.info(f"Updated account information for profile '{profile.name}' ({profiles_updated}/{profiles.count()})")
@ -2969,6 +3040,7 @@ def refresh_account_profiles(account_id):
except Exception as profile_error:
profiles_failed += 1
logger.error(f"Failed to update account information for profile '{profile.name}': {str(profile_error)}")
_release_task_db_connection()
# Continue with other profiles even if one fails
result_msg = f"Profile refresh complete for account {account.name}: {profiles_updated} updated, {profiles_failed} failed"
@ -2983,6 +3055,8 @@ def refresh_account_profiles(account_id):
error_msg = f"Error refreshing profiles for account {account_id}: {str(e)}"
logger.error(error_msg)
return error_msg
finally:
_release_task_db_connection()
@shared_task
@ -3037,10 +3111,11 @@ def refresh_account_info(profile_id):
account_info = client.get_account_info()
# Update only this specific profile with the new account info
if not profile.custom_properties:
profile.custom_properties = {}
profile.custom_properties.update(account_info)
profile.save()
profile.custom_properties = {
**ensure_custom_properties_dict(profile.custom_properties),
**account_info,
}
profile.save(update_fields=['custom_properties', 'exp_date'])
# Send success notification to frontend via websocket
send_websocket_update(
@ -3103,10 +3178,25 @@ def refresh_single_m3u_account(account_id):
lock_renewer = TaskLockRenewer("refresh_single_m3u_account", account_id)
lock_renewer.start()
_release_task_db_connection()
try:
return _refresh_single_m3u_account_impl(account_id)
except Exception as e:
logger.error(
f"refresh_single_m3u_account failed for account {account_id}: {e}",
exc_info=True,
)
_set_m3u_account_status(
account_id,
M3UAccount.Status.ERROR,
f"Error processing M3U: {str(e)[:500]}",
notify_error=True,
ws_error=str(e)[:500],
)
finally:
# Guaranteed cleanup on all exit paths (success, exception, early return)
_ensure_m3u_refresh_terminal_status(account_id)
_release_task_db_connection()
lock_renewer.stop()
release_task_lock("refresh_single_m3u_account", account_id)
@ -3121,22 +3211,25 @@ def _refresh_single_m3u_account_impl(account_id):
streams_deleted = 0
try:
account = M3UAccount.objects.get(id=account_id, is_active=True)
account = _get_active_m3u_account(account_id)
if not account.is_active:
logger.debug(f"Account {account_id} is not active, skipping.")
return
# Set status to fetching
account.status = M3UAccount.Status.FETCHING
account.save(update_fields=['status'])
# Set status to fetching and replace stale completion messages.
_set_m3u_account_status(
account_id,
M3UAccount.Status.FETCHING,
"Refresh in progress...",
)
account = _get_active_m3u_account(account_id)
filters = list(account.filters.all())
# Check if VOD is enabled for this account
vod_enabled = False
if account.custom_properties:
custom_props = account.custom_properties or {}
vod_enabled = custom_props.get('enable_vod', False)
vod_enabled = ensure_custom_properties_dict(account.custom_properties).get(
'enable_vod', False
)
except M3UAccount.DoesNotExist:
# The M3U account doesn't exist, so delete the periodic task if it exists
@ -3202,6 +3295,16 @@ def _refresh_single_m3u_account_impl(account_id):
logger.error(
f"Failed to refresh M3U groups for account {account_id}: {result}"
)
error_msg = (
"Failed to refresh M3U groups - download failed or other error"
)
_set_m3u_account_status(
account_id,
M3UAccount.Status.ERROR,
error_msg,
notify_error=True,
ws_error=error_msg,
)
return "Failed to update m3u account - download failed or other error"
extinf_data, groups = result
@ -3216,23 +3319,23 @@ def _refresh_single_m3u_account_impl(account_id):
# For XC accounts, empty extinf_data is normal at this stage
if not extinf_data and not is_xc_account:
logger.error(f"No streams found for non-XC account {account_id}")
account.status = M3UAccount.Status.ERROR
account.last_message = "No streams found in M3U source"
account.save(update_fields=["status", "last_message"])
send_m3u_update(
account_id, "parsing", 100, status="error", error="No streams found"
error_msg = "No streams found in M3U source"
_set_m3u_account_status(
account_id,
M3UAccount.Status.ERROR,
error_msg,
notify_error=True,
ws_error=error_msg,
)
except Exception as e:
logger.error(f"Exception in refresh_m3u_groups: {str(e)}", exc_info=True)
account.status = M3UAccount.Status.ERROR
account.last_message = f"Error refreshing M3U groups: {str(e)}"
account.save(update_fields=["status", "last_message"])
send_m3u_update(
error_msg = f"Error refreshing M3U groups: {str(e)[:500]}"
_set_m3u_account_status(
account_id,
"parsing",
100,
status="error",
error=f"Error refreshing M3U groups: {str(e)}",
M3UAccount.Status.ERROR,
error_msg,
notify_error=True,
ws_error=error_msg,
)
return "Failed to update m3u account"
@ -3246,15 +3349,13 @@ def _refresh_single_m3u_account_impl(account_id):
# Modified validation logic for different account types
if (not groups) or (not is_xc_account and not extinf_data):
logger.error(f"No data to process for account {account_id}")
account.status = M3UAccount.Status.ERROR
account.last_message = "No data available for processing"
account.save(update_fields=["status", "last_message"])
send_m3u_update(
error_msg = "No data available for processing"
_set_m3u_account_status(
account_id,
"parsing",
100,
status="error",
error="No data available for processing",
M3UAccount.Status.ERROR,
error_msg,
notify_error=True,
ws_error=error_msg,
)
return "Failed to update m3u account, no data available"
@ -3351,6 +3452,8 @@ def _refresh_single_m3u_account_impl(account_id):
except Exception as e:
logger.error(f"Error in thread batch {batch_idx}: {str(e)}")
completed_batches += 1 # Still count it to avoid hanging
finally:
batches[batch_idx] = None
logger.info(f"Thread-based processing completed for account {account_id}")
else:
@ -3368,7 +3471,7 @@ def _refresh_single_m3u_account_impl(account_id):
group_id = rel.channel_group.id
# Load the custom properties with the xc_id
custom_props = rel.custom_properties or {}
custom_props = ensure_custom_properties_dict(rel.custom_properties)
if "xc_id" in custom_props:
filtered_groups[group_name] = {
"xc_id": custom_props["xc_id"],
@ -3462,6 +3565,8 @@ def _refresh_single_m3u_account_impl(account_id):
except Exception as e:
logger.error(f"Error in XC thread batch {batch_idx}: {str(e)}")
completed_batches += 1 # Still count it to avoid hanging
finally:
batches[batch_idx] = None
logger.info(f"XC thread-based processing completed for account {account_id}")
@ -3591,13 +3696,7 @@ def _refresh_single_m3u_account_impl(account_id):
except Exception as e:
logger.error(f"Error processing M3U for account {account_id}: {str(e)}")
try:
account.status = M3UAccount.Status.ERROR
account.last_message = f"Error processing M3U: {str(e)}"
account.save(update_fields=["status", "last_message"])
except Exception:
logger.debug(f"Failed to update account {account_id} status during error handling")
raise # Re-raise the exception for Celery to handle
raise
finally:
# Free large data structures regardless of success or failure
if 'existing_groups' in locals():
@ -3624,6 +3723,8 @@ def _refresh_single_m3u_account_impl(account_id):
except OSError:
pass
_release_task_db_connection()
return f"Dispatched jobs complete."

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

@ -33,6 +33,24 @@ class ProcessM3UBatchCleanupTests(SimpleTestCase):
process_m3u_batch_direct(1, [], {}, ["name", "url"])
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"])
mock_gc.assert_called()
class LockReleaseTests(SimpleTestCase):
"""Verify task lock is released on all exit paths."""

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

@ -46,13 +46,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(
name=name,
url=f"http://example.com/{name.lower()}.m3u8",
m3u_account=account,
channel_group=group,
tvg_id=tvg_id,
stream_chno=stream_chno,
last_seen=timezone.now(),
)
@ -413,21 +414,21 @@ class RangeEnforcementTests(TestCase):
"new auto-channel duplicates A's effective channel number.",
)
def test_provider_mode_fallback_respects_range_start(self):
# Provider-mode streams without a usable stream_chno fall back to
# the next-available picker. The fallback must honor the group's
# configured `auto_sync_channel_start` so freshly-created channels
# never land below the user's chosen range. Without this, a group
# configured for [100, 200] silently spawned channels at #1 when
# the provider omitted channel-number metadata.
def test_provider_mode_numberless_fallback_uses_visible_start(self):
# In provider mode the visible "Start #" is channel_numbering_fallback,
# so a numberless stream's fallback walks from there, not from the
# hidden auto_sync_channel_start (set far above the range here to prove
# it is ignored).
# Fail signature: 0 channels created, or a channel below 100 = fallback
# seeded from the wrong field.
account = _make_account()
group = _make_group(name="Sports")
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.custom_properties = {
"channel_numbering_mode": "provider",
"channel_numbering_fallback": 1,
"channel_numbering_fallback": 100, # the visible "Start #"
}
rel.save()
@ -643,6 +644,34 @@ class NumbersInRangeLookupTests(TestCase):
)
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):
# Manual channels are always a real collision worth surfacing.
# The response must flag them with auto_created=False and a null
@ -2300,3 +2329,315 @@ class CompactNumberingIdempotencyTests(TransactionTestCase):
"pass with no hide or override change. _repack_inner is following "
"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,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",
)

1727
apps/output/epg.py Normal file

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,239 @@
"""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
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
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

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

View file

@ -1,31 +1,131 @@
from django.test import TestCase, Client
from django.test import TestCase, Client, SimpleTestCase, RequestFactory
from django.urls import reverse
from apps.channels.models import Channel, ChannelGroup
from unittest import skipUnless
from unittest.mock import patch
from uuid import uuid4
from django.db import connection
from django.test.utils import CaptureQueriesContext
from apps.channels.models import Channel, ChannelGroup, ChannelProfile, ChannelProfileMembership
from apps.epg.models import EPGData, EPGSource
from apps.accounts.models import User
from apps.m3u.models import M3UAccount
from apps.output.views import xc_get_series, xc_get_vod_streams
from apps.vod.models import (
M3UMovieRelation,
M3USeriesRelation,
Movie,
Series,
VODCategory,
VODLogo,
)
import xml.etree.ElementTree as ET
from datetime import timedelta
def _response_text(response):
"""Read body from HttpResponse or StreamingHttpResponse."""
if getattr(response, "streaming", False):
return b"".join(response.streaming_content).decode()
return response.content.decode()
def _epg_response_without_redis(cache_key, source, **kwargs):
"""Test helper: stream EPG directly without Redis chunk caching."""
from django.http import StreamingHttpResponse
response = StreamingHttpResponse(source(), content_type="application/xml")
response["Content-Disposition"] = 'attachment; filename="Dispatcharr.xml"'
response["Cache-Control"] = "no-cache"
return response
class OutputEndpointTestMixin:
"""Isolate HTTP endpoint tests from network ACL, logging, DB teardown, and Redis."""
class OutputM3UTest(TestCase):
def setUp(self):
super().setUp()
self._network_patch = patch(
"apps.output.views.network_access_allowed",
return_value=True,
)
self._epg_teardown_patch = patch("apps.output.epg._epg_export_teardown")
self._log_event_patch = patch("apps.output.views.log_system_event")
self._epg_log_event_patch = patch("apps.output.epg.log_system_event")
self._close_db_patch = patch("django.db.close_old_connections")
self._epg_cache_patch = patch(
"apps.output.epg.stream_cached_response",
side_effect=_epg_response_without_redis,
)
self._network_patch.start()
self._epg_teardown_patch.start()
self._log_event_patch.start()
self._epg_log_event_patch.start()
self._close_db_patch.start()
self._epg_cache_patch.start()
def tearDown(self):
from django.core.cache import cache
cache.clear()
self._epg_cache_patch.stop()
self._close_db_patch.stop()
self._epg_log_event_patch.stop()
self._log_event_patch.stop()
self._epg_teardown_patch.stop()
self._network_patch.stop()
super().tearDown()
def _create_isolated_profile(self, prefix):
"""New profiles auto-include every channel via signal; clear that for tests."""
profile = ChannelProfile.objects.create(name=f"{prefix}-{uuid4().hex[:8]}")
ChannelProfileMembership.objects.filter(channel_profile=profile).delete()
return profile
def _add_channel_to_profile(self, profile, group, **kwargs):
channel = Channel.objects.create(channel_group=group, **kwargs)
ChannelProfileMembership.objects.create(
channel_profile=profile,
channel=channel,
enabled=True,
)
return channel
class OutputM3UTest(OutputEndpointTestMixin, TestCase):
def setUp(self):
super().setUp()
self.client = Client()
self.group = ChannelGroup.objects.create(name=f"M3U Group {uuid4().hex[:8]}")
self.profile = self._create_isolated_profile("m3u")
self._add_channel_to_profile(
self.profile,
self.group,
channel_number=1.0,
name="Test M3U Channel",
)
def _m3u_url(self):
return reverse("output:m3u_endpoint", kwargs={"profile_name": self.profile.name})
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)
response = self.client.get(self._m3u_url())
self.assertEqual(response.status_code, 200)
content = response.content.decode()
content = _response_text(response)
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()
response = self.client.post(
self._m3u_url(),
data=None,
content_type="application/x-www-form-urlencoded",
)
content = _response_text(response)
self.assertEqual(response.status_code, 200, "POST with empty body should return 200 OK")
self.assertIn("#EXTM3U", content)
@ -34,35 +134,40 @@ class OutputM3UTest(TestCase):
"""
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'})
response = self.client.post(self._m3u_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())
self.assertIn("POST requests with body are not allowed", _response_text(response))
class OutputEPGXMLEscapingTest(TestCase):
class OutputEPGXMLEscapingTest(OutputEndpointTestMixin, TestCase):
"""Test XML escaping of channel_id attributes in EPG generation"""
def setUp(self):
super().setUp()
self.client = Client()
self.group = ChannelGroup.objects.create(name="Test Group")
self.group = ChannelGroup.objects.create(name=f"Test Group {uuid4().hex[:8]}")
self.profile = self._create_isolated_profile("epg-xml")
def _add_channel(self, **kwargs):
return self._add_channel_to_profile(self.profile, self.group, **kwargs)
def _epg_url(self, query="tvg_id_source=tvg_id&days=0&prev_days=0"):
base = reverse("output:epg_endpoint", kwargs={"profile_name": self.profile.name})
return f"{base}?{query}"
def test_channel_id_with_ampersand(self):
"""Test channel ID with ampersand is properly escaped"""
channel = Channel.objects.create(
self._add_channel(
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)
response = self.client.get(self._epg_url())
self.assertEqual(response.status_code, 200)
content = response.content.decode()
content = _response_text(response)
# Should contain escaped ampersand
self.assertIn('id="News &amp; Sports"', content)
@ -76,17 +181,15 @@ class OutputEPGXMLEscapingTest(TestCase):
def test_channel_id_with_angle_brackets(self):
"""Test channel ID with < and > characters"""
channel = Channel.objects.create(
self._add_channel(
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)
response = self.client.get(self._epg_url())
content = response.content.decode()
content = _response_text(response)
self.assertIn('id="Channel &lt;HD&gt;"', content)
try:
@ -96,23 +199,28 @@ class OutputEPGXMLEscapingTest(TestCase):
def test_channel_id_with_all_special_chars(self):
"""Test channel ID with all XML special characters"""
channel = Channel.objects.create(
expected_id = 'Test & "Special" <Chars>'
self._add_channel(
channel_number=3.0,
name="Complex Channel",
tvg_id='Test & "Special" <Chars>',
channel_group=self.group
tvg_id=expected_id,
)
url = reverse('output:generate_epg') + '?tvg_id_source=tvg_id'
response = self.client.get(url)
response = self.client.get(self._epg_url())
content = response.content.decode()
content = _response_text(response)
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>"]')
channel_elem = next(
(
elem
for elem in tree.findall(".//channel")
if elem.get("id") == expected_id
),
None,
)
self.assertIsNotNone(channel_elem)
except ET.ParseError as e:
self.fail(f"Generated EPG with all special chars is not valid XML: {e}")
@ -121,25 +229,670 @@ class OutputEPGXMLEscapingTest(TestCase):
"""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(
self._add_channel(
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)
response = self.client.get(self._epg_url())
content = response.content.decode()
content = _response_text(response)
# 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"]')
programmes = [
programme
for programme in tree.findall(".//programme")
if programme.get("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}")
def test_programmes_emitted_in_start_time_order(self):
"""Programmes for a channel are emitted in start_time order, not insert order."""
from django.utils import timezone
from apps.epg.models import ProgramData
epg_source = EPGSource.objects.create(name="Real EPG", source_type="xmltv")
epg_data = EPGData.objects.create(name="Station", epg_source=epg_source, tvg_id="station1")
self._add_channel(
channel_number=149.0,
name="Food Network",
tvg_id="station1",
epg_data=epg_data,
)
now = timezone.now()
# Insert out of chronological order so id order != start_time order.
ProgramData.objects.create(
epg=epg_data,
start_time=now + timedelta(days=3),
end_time=now + timedelta(days=3, hours=1),
title="Third",
tvg_id="station1",
)
ProgramData.objects.create(
epg=epg_data,
start_time=now + timedelta(days=1),
end_time=now + timedelta(days=1, hours=1),
title="First",
tvg_id="station1",
)
ProgramData.objects.create(
epg=epg_data,
start_time=now + timedelta(days=2),
end_time=now + timedelta(days=2, hours=1),
title="Second",
tvg_id="station1",
)
content = _response_text(self.client.get(self._epg_url("tvg_id_source=tvg_id&days=7")))
self.assertLess(content.find('<title>First</title>'), content.find('<title>Second</title>'))
self.assertLess(content.find('<title>Second</title>'), content.find('<title>Third</title>'))
class OutputEPGCustomDummyTest(TestCase):
"""Custom dummy EPG must not fall back to default when pattern matched but event is outside window."""
def setUp(self):
self.group = ChannelGroup.objects.create(name="Sports Group")
def test_custom_dummy_outside_window_fills_with_ended_programmes(self):
from django.utils import timezone
from apps.output.views import generate_dummy_programs
epg_source = EPGSource.objects.create(
name="NHL Dummy",
source_type="dummy",
custom_properties={
"title_pattern": r"(?<league>.*)\s\d+:\s(?<team1>.*?)(?:\s+vs\s+)(?<team2>.*?)\s*@.*",
"time_pattern": r"(?<hour>\d{1,2}):(?<minute>\d{2})\s*(?<ampm>AM|PM)",
"date_pattern": r"@ (?<month>[A-Za-z]+)\s+(?<day>\d{1,2})",
"timezone": "US/Eastern",
"program_duration": 180,
},
)
channel_name = (
"NHL 01: Washington Capitals vs Philadelphia Flyers @ April 16 07:30 PM ET"
)
now = timezone.now()
lookback = now - timedelta(days=7)
programs = generate_dummy_programs(
channel_id="nhl01",
channel_name=channel_name,
num_days=7,
epg_source=epg_source,
export_lookback=lookback,
export_cutoff=now + timedelta(days=7),
)
self.assertGreater(len(programs), 0)
self.assertTrue(
all(p['end_time'] >= lookback for p in programs),
"All programmes should fall inside the export window",
)
self.assertTrue(
any('Ended' in p['description'] for p in programs),
"Past events outside the window should still show ended filler",
)
for program in programs:
start = program['start_time']
self.assertEqual(start.second, 0)
self.assertEqual(start.microsecond, 0)
self.assertIn(
start.minute, (0, 30),
"Filler programmes should start on half-hour boundaries",
)
self.assertGreaterEqual(programs[0]['start_time'], lookback)
def test_custom_dummy_future_event_fills_grid_window_with_upcoming(self):
"""Grid-style window: future event should show upcoming filler, not empty."""
from django.utils import timezone
from apps.output.epg import _programme_overlaps_export_window, generate_dummy_programs
epg_source = EPGSource.objects.create(
name="NHL Dummy Future",
source_type="dummy",
custom_properties={
"title_pattern": r"(?<league>.*)\s\d+:\s(?<team1>.*?)(?:\s+vs\s+)(?<team2>.*?)\s*@.*",
"time_pattern": r"(?<hour>\d{1,2}):(?<minute>\d{2})\s*(?<ampm>AM|PM)",
"date_pattern": r"@ (?<month>[A-Za-z]+)\s+(?<day>\d{1,2})",
"timezone": "US/Eastern",
"program_duration": 180,
},
)
now = timezone.now()
grid_start = now - timedelta(hours=1)
grid_end = now + timedelta(hours=24)
future = now + timedelta(days=3)
channel_name = (
f"NHL 01: Washington Capitals vs Philadelphia Flyers @ "
f"{future.strftime('%B')} {future.day} 07:30 PM ET"
)
programs = generate_dummy_programs(
channel_id="nhl01",
channel_name=channel_name,
num_days=1,
epg_source=epg_source,
export_lookback=grid_start,
export_cutoff=grid_end,
)
self.assertGreater(len(programs), 0)
self.assertTrue(
all(
_programme_overlaps_export_window(
p["start_time"], p["end_time"], grid_start, grid_end
)
for p in programs
),
"All programmes should overlap the grid query window",
)
self.assertTrue(
any("Upcoming" in p.get("description", "") for p in programs),
"Future events outside the window should show upcoming filler",
)
class OutputEPGHelperTest(SimpleTestCase):
def test_ceil_to_half_hour_on_boundary(self):
from django.utils import timezone
from apps.output.epg import _ceil_to_half_hour
dt = timezone.now().replace(minute=30, second=0, microsecond=0)
self.assertEqual(_ceil_to_half_hour(dt), dt)
def test_ceil_to_half_hour_rounds_up(self):
from django.utils import timezone
from apps.output.epg import _ceil_to_half_hour
dt = timezone.now().replace(minute=17, second=42, microsecond=123456)
aligned = _ceil_to_half_hour(dt)
self.assertEqual(aligned.minute, 30)
self.assertEqual(aligned.second, 0)
self.assertGreaterEqual(aligned, dt.replace(microsecond=0))
def test_ceil_to_half_hour_past_boundary_second(self):
from django.utils import timezone
from apps.output.epg import _ceil_to_half_hour
dt = timezone.now().replace(minute=0, second=52, microsecond=123456)
aligned = _ceil_to_half_hour(dt)
self.assertEqual(aligned.minute, 30)
self.assertEqual(aligned.second, 0)
self.assertGreaterEqual(aligned, dt.replace(microsecond=0))
class XcVodSeriesDistinctTests(TestCase):
def setUp(self):
self.factory = RequestFactory()
self.user = User.objects.create_user(
username=f"xc-{uuid4().hex[:8]}",
password="pass",
custom_properties={"xc_password": "xcpass"},
)
self.request = self.factory.get("/player_api.php")
def _account(self, name, *, priority=0, is_active=True):
return M3UAccount.objects.create(
name=name,
server_url="http://example.com",
priority=priority,
is_active=is_active,
)
def test_vod_streams_picks_highest_priority_relation(self):
low = self._account(f"low-{uuid4().hex[:6]}", priority=1)
high = self._account(f"high-{uuid4().hex[:6]}", priority=10)
movie = Movie.objects.create(name="Shared Movie", year=2020)
M3UMovieRelation.objects.create(
m3u_account=low,
movie=movie,
stream_id="low-stream",
container_extension="mkv",
)
M3UMovieRelation.objects.create(
m3u_account=high,
movie=movie,
stream_id="high-stream",
container_extension="mp4",
)
streams = xc_get_vod_streams(self.request, self.user)
self.assertEqual(len(streams), 1)
self.assertEqual(streams[0]["name"], "Shared Movie")
self.assertEqual(streams[0]["container_extension"], "mp4")
def test_vod_streams_excludes_inactive_accounts(self):
active = self._account(f"active-{uuid4().hex[:6]}", priority=1)
inactive = self._account(
f"inactive-{uuid4().hex[:6]}", priority=99, is_active=False
)
active_movie = Movie.objects.create(name="Active Movie")
inactive_movie = Movie.objects.create(name="Inactive Only Movie")
M3UMovieRelation.objects.create(
m3u_account=active,
movie=active_movie,
stream_id="active-1",
)
M3UMovieRelation.objects.create(
m3u_account=inactive,
movie=inactive_movie,
stream_id="inactive-1",
)
streams = xc_get_vod_streams(self.request, self.user)
names = {s["name"] for s in streams}
self.assertEqual(names, {"Active Movie"})
def test_vod_streams_category_filter(self):
account = self._account(f"acct-{uuid4().hex[:6]}")
action = VODCategory.objects.create(name="Action", category_type="movie")
comedy = VODCategory.objects.create(name="Comedy", category_type="movie")
action_movie = Movie.objects.create(name="Action Movie")
comedy_movie = Movie.objects.create(name="Comedy Movie")
M3UMovieRelation.objects.create(
m3u_account=account,
movie=action_movie,
category=action,
stream_id="action-1",
)
M3UMovieRelation.objects.create(
m3u_account=account,
movie=comedy_movie,
category=comedy,
stream_id="comedy-1",
)
streams = xc_get_vod_streams(self.request, self.user, category_id=action.id)
self.assertEqual(len(streams), 1)
self.assertEqual(streams[0]["name"], "Action Movie")
self.assertEqual(streams[0]["category_id"], str(action.id))
def test_vod_streams_sorted_alphabetically_by_name(self):
account = self._account(f"acct-{uuid4().hex[:6]}")
zebra = Movie.objects.create(name="Zebra Film")
apple = Movie.objects.create(name="Apple Film")
M3UMovieRelation.objects.create(
m3u_account=account, movie=zebra, stream_id="z-1"
)
M3UMovieRelation.objects.create(
m3u_account=account, movie=apple, stream_id="a-1"
)
streams = xc_get_vod_streams(self.request, self.user)
self.assertEqual([s["name"] for s in streams], ["Apple Film", "Zebra Film"])
def test_vod_streams_includes_metadata_fields(self):
account = self._account(f"acct-{uuid4().hex[:6]}")
movie = Movie.objects.create(
name="Rich Movie",
description="A plot",
genre="Drama",
year=2021,
rating="8",
custom_properties={
"director": "Dir",
"actors": "Cast",
"release_date": "2021-01-01",
"youtube_trailer": "yt123",
},
)
M3UMovieRelation.objects.create(
m3u_account=account,
movie=movie,
stream_id="rich-1",
container_extension="avi",
)
stream = xc_get_vod_streams(self.request, self.user)[0]
self.assertEqual(stream["plot"], "A plot")
self.assertEqual(stream["genre"], "Drama")
self.assertEqual(stream["year"], 2021)
self.assertEqual(stream["director"], "Dir")
self.assertEqual(stream["cast"], "Cast")
self.assertEqual(stream["release_date"], "2021-01-01")
self.assertEqual(stream["trailer"], "yt123")
self.assertEqual(stream["container_extension"], "avi")
def test_vod_streams_stream_icon_uses_logo_id_without_logo_join(self):
account = self._account(f"acct-{uuid4().hex[:6]}")
logo = VODLogo.objects.create(name="Poster", url="http://example.com/poster.png")
movie = Movie.objects.create(name="Logo Movie", logo=logo)
M3UMovieRelation.objects.create(
m3u_account=account,
movie=movie,
stream_id="logo-1",
)
stream = xc_get_vod_streams(self.request, self.user)[0]
self.assertIn(f"/{logo.id}/", stream["stream_icon"])
def test_series_picks_highest_priority_relation(self):
low = self._account(f"low-{uuid4().hex[:6]}", priority=1)
high = self._account(f"high-{uuid4().hex[:6]}", priority=10)
series = Series.objects.create(name="Shared Series", year=2019)
M3USeriesRelation.objects.create(
m3u_account=low,
series=series,
external_series_id="low-series",
)
high_rel = M3USeriesRelation.objects.create(
m3u_account=high,
series=series,
external_series_id="high-series",
)
results = xc_get_series(self.request, self.user)
self.assertEqual(len(results), 1)
self.assertEqual(results[0]["name"], "Shared Series")
self.assertEqual(results[0]["series_id"], high_rel.id)
def test_series_excludes_inactive_accounts(self):
active = self._account(f"active-{uuid4().hex[:6]}")
inactive = self._account(f"inactive-{uuid4().hex[:6]}", is_active=False)
active_series = Series.objects.create(name="Active Series")
inactive_series = Series.objects.create(name="Inactive Only Series")
M3USeriesRelation.objects.create(
m3u_account=active,
series=active_series,
external_series_id="active-s",
)
M3USeriesRelation.objects.create(
m3u_account=inactive,
series=inactive_series,
external_series_id="inactive-s",
)
results = xc_get_series(self.request, self.user)
self.assertEqual({r["name"] for r in results}, {"Active Series"})
def test_series_sorted_alphabetically_by_name(self):
account = self._account(f"acct-{uuid4().hex[:6]}")
z = Series.objects.create(name="Zulu Show")
a = Series.objects.create(name="Alpha Show")
M3USeriesRelation.objects.create(
m3u_account=account, series=z, external_series_id="z"
)
M3USeriesRelation.objects.create(
m3u_account=account, series=a, external_series_id="a"
)
results = xc_get_series(self.request, self.user)
self.assertEqual([r["name"] for r in results], ["Alpha Show", "Zulu Show"])
@skipUnless(connection.vendor == "postgresql", "PostgreSQL-specific query shape")
def test_vod_streams_dedupe_query_avoids_movie_join(self):
account = self._account(f"acct-{uuid4().hex[:6]}")
movie = Movie.objects.create(name="Query Shape Movie")
M3UMovieRelation.objects.create(
m3u_account=account, movie=movie, stream_id="qs-1"
)
with CaptureQueriesContext(connection) as ctx:
xc_get_vod_streams(self.request, self.user)
distinct_queries = [q for q in ctx.captured_queries if "DISTINCT" in q["sql"]]
self.assertEqual(len(distinct_queries), 1)
self.assertNotIn('"vod_movie"', distinct_queries[0]["sql"])
self.assertNotIn('"vod_vodlogo"', distinct_queries[0]["sql"])
fetch_queries = [
q
for q in ctx.captured_queries
if '"vod_movie"' in q["sql"] and "DISTINCT" not in q["sql"]
]
self.assertGreaterEqual(len(fetch_queries), 1)
fetch_sql = fetch_queries[0]["sql"]
self.assertNotIn('"vod_vodlogo"', fetch_sql)
self.assertNotIn('"vod_vodcategory"', fetch_sql)
@skipUnless(connection.vendor == "postgresql", "PostgreSQL-specific query shape")
def test_series_dedupe_query_avoids_series_join(self):
account = self._account(f"acct-{uuid4().hex[:6]}")
series = Series.objects.create(name="Query Shape Series")
M3USeriesRelation.objects.create(
m3u_account=account, series=series, external_series_id="qs-s"
)
with CaptureQueriesContext(connection) as ctx:
xc_get_series(self.request, self.user)
distinct_queries = [q for q in ctx.captured_queries if "DISTINCT" in q["sql"]]
self.assertEqual(len(distinct_queries), 1)
self.assertNotIn('"vod_series"', distinct_queries[0]["sql"])
fetch_queries = [
q
for q in ctx.captured_queries
if '"vod_series"' in q["sql"] and "DISTINCT" not in q["sql"]
]
self.assertGreaterEqual(len(fetch_queries), 1)
fetch_sql = fetch_queries[0]["sql"]
self.assertNotIn('"vod_vodlogo"', fetch_sql)
self.assertNotIn('"vod_vodcategory"', fetch_sql)
XC_VOD_STREAM_KEYS = frozenset({
"num", "name", "stream_type", "stream_id", "stream_icon", "rating",
"rating_5based", "added", "is_adult", "tmdb_id", "imdb_id", "trailer",
"plot", "genre", "year", "director", "cast", "release_date", "category_id",
"category_ids", "container_extension", "custom_sid", "direct_source",
})
XC_SERIES_KEYS = frozenset({
"num", "name", "series_id", "cover", "plot", "cast", "director", "genre",
"release_date", "releaseDate", "last_modified", "rating", "rating_5based",
"backdrop_path", "youtube_trailer", "episode_run_time", "category_id",
"category_ids", "tmdb_id", "imdb_id",
})
class XcVodSeriesRegressionTests(TestCase):
"""Full output-shape and edge-case regressions for XC list endpoints."""
def setUp(self):
self.factory = RequestFactory()
self.user = User.objects.create_user(
username=f"xc-reg-{uuid4().hex[:8]}",
password="pass",
custom_properties={"xc_password": "xcpass"},
)
self.request = self.factory.get("/player_api.php")
def _account(self, name, *, priority=0):
return M3UAccount.objects.create(
name=name,
server_url="http://example.com",
priority=priority,
)
def test_vod_streams_empty_library(self):
self.assertEqual(xc_get_vod_streams(self.request, self.user), [])
def test_series_empty_library(self):
self.assertEqual(xc_get_series(self.request, self.user), [])
def test_vod_streams_response_keys(self):
account = self._account(f"acct-{uuid4().hex[:6]}")
movie = Movie.objects.create(name="Schema Movie", rating="10")
M3UMovieRelation.objects.create(
m3u_account=account, movie=movie, stream_id="schema-1"
)
stream = xc_get_vod_streams(self.request, self.user)[0]
self.assertEqual(set(stream.keys()), XC_VOD_STREAM_KEYS)
self.assertEqual(stream["stream_type"], "movie")
self.assertEqual(stream["stream_id"], movie.id)
self.assertEqual(stream["rating_5based"], 5.0)
self.assertEqual(stream["custom_sid"], None)
self.assertEqual(stream["direct_source"], "")
def test_vod_streams_null_optional_fields(self):
account = self._account(f"acct-{uuid4().hex[:6]}")
movie = Movie.objects.create(name="Sparse Movie")
M3UMovieRelation.objects.create(
m3u_account=account,
movie=movie,
stream_id="sparse-1",
container_extension=None,
)
stream = xc_get_vod_streams(self.request, self.user)[0]
self.assertIsNone(stream["stream_icon"])
self.assertEqual(stream["category_id"], "0")
self.assertEqual(stream["category_ids"], [])
self.assertEqual(stream["container_extension"], "mp4")
self.assertEqual(stream["plot"], "")
self.assertEqual(stream["trailer"], "")
self.assertEqual(stream["tmdb_id"], "")
self.assertEqual(stream["imdb_id"], "")
def test_vod_streams_category_from_winning_relation(self):
"""Category must come from the highest-priority relation, not any relation."""
low = self._account(f"low-{uuid4().hex[:6]}", priority=1)
high = self._account(f"high-{uuid4().hex[:6]}", priority=10)
action = VODCategory.objects.create(name="Action", category_type="movie")
comedy = VODCategory.objects.create(name="Comedy", category_type="movie")
movie = Movie.objects.create(name="Dual Category Movie")
M3UMovieRelation.objects.create(
m3u_account=low,
movie=movie,
category=action,
stream_id="low-cat",
)
M3UMovieRelation.objects.create(
m3u_account=high,
movie=movie,
category=comedy,
stream_id="high-cat",
)
stream = xc_get_vod_streams(self.request, self.user)[0]
self.assertEqual(stream["category_id"], str(comedy.id))
self.assertEqual(stream["category_ids"], [comedy.id])
def test_series_response_keys_and_metadata(self):
account = self._account(f"acct-{uuid4().hex[:6]}")
logo = VODLogo.objects.create(name="Cover", url="http://example.com/cover.png")
category = VODCategory.objects.create(name="Drama", category_type="series")
series = Series.objects.create(
name="Schema Series",
description="Series plot",
genre="Sci-Fi",
year=2022,
rating="8",
tmdb_id="tm123",
imdb_id="tt123",
logo=logo,
custom_properties={
"cast": "Actor A",
"director": "Director B",
"release_date": "2022-06-01",
"backdrop_path": ["/img1.jpg"],
"youtube_trailer": "yt-series",
"episode_run_time": "45",
},
)
relation = M3USeriesRelation.objects.create(
m3u_account=account,
series=series,
category=category,
external_series_id="schema-s",
)
row = xc_get_series(self.request, self.user)[0]
self.assertEqual(set(row.keys()), XC_SERIES_KEYS)
self.assertEqual(row["series_id"], relation.id)
self.assertIn(f"/{logo.id}/", row["cover"])
self.assertEqual(row["plot"], "Series plot")
self.assertEqual(row["cast"], "Actor A")
self.assertEqual(row["director"], "Director B")
self.assertEqual(row["genre"], "Sci-Fi")
self.assertEqual(row["release_date"], "2022-06-01")
self.assertEqual(row["releaseDate"], "2022-06-01")
self.assertEqual(row["backdrop_path"], ["/img1.jpg"])
self.assertEqual(row["youtube_trailer"], "yt-series")
self.assertEqual(row["episode_run_time"], "45")
self.assertEqual(row["tmdb_id"], "tm123")
self.assertEqual(row["imdb_id"], "tt123")
self.assertEqual(row["category_id"], str(category.id))
self.assertEqual(row["category_ids"], [category.id])
self.assertEqual(row["last_modified"], str(int(relation.updated_at.timestamp())))
def test_series_null_optional_fields(self):
account = self._account(f"acct-{uuid4().hex[:6]}")
series = Series.objects.create(name="Sparse Series")
M3USeriesRelation.objects.create(
m3u_account=account,
series=series,
external_series_id="sparse-s",
)
row = xc_get_series(self.request, self.user)[0]
self.assertIsNone(row["cover"])
self.assertEqual(row["category_id"], "0")
self.assertEqual(row["category_ids"], [])
self.assertEqual(row["release_date"], "")
self.assertEqual(row["releaseDate"], "")
self.assertEqual(row["backdrop_path"], [])
self.assertEqual(row["youtube_trailer"], "")
self.assertEqual(row["episode_run_time"], "")
def test_series_release_date_falls_back_to_year(self):
account = self._account(f"acct-{uuid4().hex[:6]}")
series = Series.objects.create(name="Year Only", year=2018)
M3USeriesRelation.objects.create(
m3u_account=account,
series=series,
external_series_id="year-s",
)
row = xc_get_series(self.request, self.user)[0]
self.assertEqual(row["release_date"], "2018")
self.assertEqual(row["releaseDate"], "2018")
def test_priority_tiebreaker_uses_lower_relation_id(self):
"""Same priority: DISTINCT ON tie-breaks on relation id ascending."""
a1 = self._account(f"a1-{uuid4().hex[:6]}", priority=5)
a2 = self._account(f"a2-{uuid4().hex[:6]}", priority=5)
movie = Movie.objects.create(name="Tie Movie")
first = M3UMovieRelation.objects.create(
m3u_account=a1,
movie=movie,
stream_id="first",
container_extension="mkv",
)
M3UMovieRelation.objects.create(
m3u_account=a2,
movie=movie,
stream_id="second",
container_extension="mp4",
)
stream = xc_get_vod_streams(self.request, self.user)[0]
self.assertEqual(stream["container_extension"], first.container_extension)

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
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):
manifest = manifest_data.get("manifest", manifest_data)
root_url = manifest.get("root_url", "").rstrip("/")
_, metadata_base_url = _resolve_manifest_base_urls(manifest)
keys = []
for p in manifest.get("plugins", []):
url = p.get("manifest_url", "")
if not url:
continue
if root_url and not url.startswith(("http://", "https://")):
url = f"{root_url}/{url}"
if metadata_base_url and not url.startswith(("http://", "https://")):
url = f"{metadata_base_url}/{url}"
keys.append(f"plugin_detail:{repo_id}:{hashlib.md5(url.encode()).hexdigest()}")
if keys:
cache.delete_many(keys)
@ -1045,18 +1057,28 @@ class AvailablePluginsAPIView(PluginAuthMixin, APIView):
for repo in repos:
manifest_data = repo.cached_manifest or {}
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("/")
repo_plugins = manifest.get("plugins", [])
for p in repo_plugins:
slug = p.get("slug", "")
plugin_data = {**p}
# Resolve relative URLs against root_url; absolute URLs pass through
if root_url:
for url_field in ("manifest_url", "latest_url", "icon_url"):
# Resolve relative URLs; metadata and download assets use separate bases
if metadata_base_url:
for url_field in ("manifest_url", "icon_url"):
val = plugin_data.get(url_field, "")
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
if not plugin_data.get("icon_url") and registry_url:
# registry_url is e.g. https://github.com/Dispatcharr/Plugins
@ -1161,18 +1183,18 @@ class PluginDetailManifestAPIView(PluginAuthMixin, APIView):
# Resolve relative URLs in versions
repo_manifest = repo.cached_manifest or {}
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"]:
url_val = v.get("url", "")
if url_val and not url_val.startswith(("http://", "https://")):
v["url"] = f"{root_url}/{url_val}"
if root_url and isinstance(manifest_obj.get("latest"), dict):
v["url"] = f"{download_base_url}/{url_val}"
if download_base_url and isinstance(manifest_obj.get("latest"), dict):
for url_field in ("url", "latest_url"):
url_val = manifest_obj["latest"].get(url_field, "")
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 = {
"manifest": manifest_obj,

View file

@ -8,9 +8,9 @@ import sys
import threading
import types
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
@ -92,6 +92,29 @@ class PluginManager:
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).
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:
configs: Optional[Dict[str, PluginConfig]] = None
try:
@ -247,6 +270,23 @@ class PluginManager:
logger.exception("Deferring plugin DB sync; database not ready yet")
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(
self,
key: str,
@ -492,79 +532,85 @@ class PluginManager:
return cfg.settings
def run_action(self, key: str, action_id: str, params: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
lp = self.get_plugin(key)
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)
try:
lp = self.get_plugin(key)
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)
if not cfg.enabled:
raise PermissionError(f"Plugin '{key}' is disabled")
params = params or {}
cfg = PluginConfig.objects.get(key=key)
if not cfg.enabled:
raise PermissionError(f"Plugin '{key}' is disabled")
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)
if not callable(run_method):
raise ValueError(f"Plugin '{key}' has no runnable 'run' method")
run_method = getattr(lp.instance, "run", None)
if not callable(run_method):
raise ValueError(f"Plugin '{key}' has no runnable 'run' method")
try:
result = run_method(action_id, params, context)
except Exception:
logger.exception(f"Plugin '{key}' action '{action_id}' failed")
raise
try:
result = run_method(action_id, params, context)
except Exception:
logger.exception(f"Plugin '{key}' action '{action_id}' failed")
raise
# Normalize return
if isinstance(result, dict):
return result
return {"status": "ok", "result": result}
if isinstance(result, dict):
return 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:
lp = self.get_plugin(key)
if not lp or not lp.instance:
return False
try:
cfg = PluginConfig.objects.get(key=key)
except PluginConfig.DoesNotExist:
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):
lp = self.get_plugin(key)
if not lp or not lp.instance:
return False
try:
stop_method(context)
return True
except TypeError:
cfg = PluginConfig.objects.get(key=key)
except PluginConfig.DoesNotExist:
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:
stop_method()
stop_method(context)
return True
except TypeError:
try:
stop_method()
return True
except Exception:
logger.exception("Plugin '%s' stop() failed", key)
return False
except Exception:
logger.exception("Plugin '%s' stop() failed", key)
return False
except Exception:
logger.exception("Plugin '%s' stop() failed", key)
return False
run_method = getattr(lp.instance, "run", None)
if callable(run_method):
actions = {a.get("id") for a in (lp.actions or []) if isinstance(a, dict)}
if "stop" in actions:
try:
run_method("stop", {}, context)
return True
except Exception:
logger.exception("Plugin '%s' stop action failed", key)
return False
return False
run_method = getattr(lp.instance, "run", None)
if callable(run_method):
actions = {a.get("id") for a in (lp.actions or []) if isinstance(a, dict)}
if "stop" in actions:
try:
run_method("stop", {}, context)
return True
except Exception:
logger.exception("Plugin '%s' stop action failed", key)
return False
return False
finally:
close_old_connections()
def stop_all_plugins(self, reason: Optional[str] = None) -> int:
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,81 @@
"""PluginManager must release geventpool checkouts after every run/stop."""
from contextlib import contextmanager
from unittest.mock import MagicMock, patch
from django.test import SimpleTestCase
from apps.plugins.loader import LoadedPlugin, PluginManager
class PluginRunActionDbCleanupTests(SimpleTestCase):
@contextmanager
def _manager_with_plugin(self, run_impl):
instance = MagicMock()
instance.run = run_impl
lp = LoadedPlugin(
key="test_plugin",
name="Test Plugin",
instance=instance,
actions=[{"id": "do_work"}],
)
pm = PluginManager()
cfg = MagicMock(enabled=True, settings={})
with patch.object(pm, "get_plugin", return_value=lp), patch(
"apps.plugins.loader.PluginConfig.objects.get", return_value=cfg
):
yield pm
@patch("apps.plugins.loader.close_old_connections")
def test_run_action_closes_connections_on_success(self, mock_close):
with self._manager_with_plugin(lambda *_a, **_k: {"status": "ok"}) as pm:
result = pm.run_action("test_plugin", "do_work")
self.assertEqual(result, {"status": "ok"})
mock_close.assert_called_once()
@patch("apps.plugins.loader.close_old_connections")
def test_run_action_closes_connections_on_plugin_error(self, mock_close):
def _boom(*_a, **_k):
raise RuntimeError("plugin failed")
with self._manager_with_plugin(_boom) as pm:
with self.assertRaises(RuntimeError):
pm.run_action("test_plugin", "do_work")
mock_close.assert_called_once()
@patch("apps.plugins.loader.close_old_connections")
def test_stop_plugin_closes_connections(self, mock_close):
instance = MagicMock()
instance.stop = MagicMock()
lp = LoadedPlugin(
key="test_plugin",
name="Test Plugin",
instance=instance,
)
pm = PluginManager()
cfg = MagicMock(enabled=True, settings={})
with patch.object(pm, "get_plugin", return_value=lp), patch(
"apps.plugins.loader.PluginConfig.objects.get", return_value=cfg
):
self.assertTrue(pm.stop_plugin("test_plugin", reason="shutdown"))
mock_close.assert_called_once()
class PluginDiscoverDbCleanupTests(SimpleTestCase):
@patch("apps.plugins.loader.close_old_connections")
@patch.object(PluginManager, "_discover_plugins_impl", return_value={})
def test_discover_plugins_closes_connections(self, _mock_impl, mock_close):
pm = PluginManager()
pm.discover_plugins(sync_db=False)
mock_close.assert_called_once()
@patch("apps.plugins.loader.close_old_connections")
def test_discover_plugins_cache_hit_skips_close(self, mock_close):
pm = PluginManager()
pm._discovery_completed = True
pm._last_reload_token = pm._get_reload_token()
pm.discover_plugins(sync_db=False, use_cache=True)
mock_close.assert_not_called()

View file

@ -42,7 +42,8 @@ class BaseConfig:
"buffering_speed": 1.0,
"redis_chunk_ttl": 60,
"channel_shutdown_delay": 0,
"channel_init_grace_period": 5,
"channel_init_grace_period": 60,
"channel_client_wait_period": 5,
"new_client_behind_seconds": 5,
}
@ -133,9 +134,15 @@ class TSConfig(BaseConfig):
@classmethod
def get_channel_init_grace_period(cls):
"""Get channel init grace period from database or default"""
"""Max seconds to wait for initial buffer fill during channel startup."""
settings = cls.get_proxy_settings()
return settings.get("channel_init_grace_period", 5)
return settings.get("channel_init_grace_period", 60)
@classmethod
def get_channel_client_wait_period(cls):
"""Seconds to keep a ready channel alive waiting for the first client to connect."""
settings = cls.get_proxy_settings()
return settings.get("channel_client_wait_period", 5)
# Dynamic property access for these settings
@property
@ -154,5 +161,6 @@ class TSConfig(BaseConfig):
def CHANNEL_INIT_GRACE_PERIOD(self):
return self.get_channel_init_grace_period()
@property
def CHANNEL_CLIENT_WAIT_PERIOD(self):
return self.get_channel_client_wait_period()

View file

@ -102,6 +102,7 @@ class ClientManager:
break
# Send heartbeat for all local clients
clients_to_remove = set()
with self.lock:
# Skip this cycle if we have no local clients
if not self.clients:
@ -109,7 +110,6 @@ class ClientManager:
# IMPROVED GHOST DETECTION: Check for stale clients before sending heartbeats
current_time = time.time()
clients_to_remove = set()
# First identify clients that should be removed
for client_id in self.clients:
@ -132,12 +132,11 @@ class ClientManager:
logger.debug(f"Client {client_id} inactive for {current_time - last_active_time:.1f}s, removing as ghost")
clients_to_remove.add(client_id)
# Remove ghost clients in a separate step
for client_id in clients_to_remove:
self.remove_client(client_id)
if clients_to_remove:
logger.info(f"Removed {len(clients_to_remove)} ghost clients from channel {self.channel_id}")
logger.info(
f"Identified {len(clients_to_remove)} ghost clients "
f"for channel {self.channel_id}"
)
# Now send heartbeats only for remaining clients
pipe = self.redis_client.pipeline()
@ -172,6 +171,9 @@ class ClientManager:
if self.clients and not all(c in clients_to_remove for c in self.clients):
self._notify_owner_of_activity()
for client_id in clients_to_remove:
self.remove_client(client_id)
except Exception as e:
logger.error(f"Error in client heartbeat thread: {e}")
@ -254,6 +256,14 @@ class ClientManager:
# Store in Redis
if self.redis_client:
from .services.channel_service import ChannelService
if ChannelService.cancel_pending_shutdown(self.channel_id):
logger.info(
f"Cancelled pending shutdown for channel {self.channel_id} "
f"(client {client_id} reconnected)"
)
self.redis_client.hset(client_key, mapping=client_data)
self.redis_client.expire(client_key, self.client_ttl)
@ -291,6 +301,8 @@ class ClientManager:
# Trigger channel stats update via WebSocket
self._trigger_stats_update()
ChannelService.promote_channel_when_buffer_ready(self.channel_id)
# Get total clients across all workers
total_clients = self.get_total_client_count()
logger.info(f"New client connected: {client_id} (local: {len(self.clients)}, total: {total_clients})")
@ -300,11 +312,17 @@ class ClientManager:
return len(self.clients)
except Exception as e:
logger.error(f"Error adding client {client_id}: {e}")
logger.error(f"Error adding client {client_id}: {e}", exc_info=True)
with self.lock:
self.clients.discard(client_id)
self._registered_clients.discard(client_id)
return False
def remove_client(self, client_id):
"""Remove a client from this channel and Redis"""
client_username = "unknown"
remaining = None
with self.lock:
if client_id in self.clients:
self.clients.remove(client_id)
@ -315,61 +333,81 @@ class ClientManager:
self.last_active_time = time.time()
if self.redis_client:
# Get client data before removing the data
client_key = f"live:channel:{self.channel_id}:clients:{client_id}"
client_username = self.redis_client.hget(client_key, "username") or "unknown"
if isinstance(client_username, bytes):
client_username = client_username.decode("utf-8")
# Remove from channel's client set
self.redis_client.srem(self.client_set_key, client_id)
client_key = f"live:channel:{self.channel_id}:clients:{client_id}"
self.redis_client.delete(client_key)
# Check if this was the last client
remaining = self.redis_client.scard(self.client_set_key) or 0
if remaining == 0:
logger.warning(f"Last client removed: {client_id} - channel may shut down soon")
# Trigger disconnect time tracking even if we're not the owner
disconnect_key = RedisKeys.last_client_disconnect(self.channel_id)
self.redis_client.setex(disconnect_key, 60, str(time.time()))
local_count = len(self.clients)
self._notify_owner_of_activity()
if not self.redis_client:
return local_count
# Check if we're the owner - if so, handle locally; if not, publish event
am_i_owner = self.proxy_server and self.proxy_server.am_i_owner(self.channel_id)
if remaining == 0:
logger.warning(f"Last client removed: {client_id} - channel may shut down soon")
disconnect_key = RedisKeys.last_client_disconnect(self.channel_id)
ttl = max(int(ConfigHelper.channel_shutdown_delay() * 2), 60)
self.redis_client.setex(disconnect_key, ttl, str(time.time()))
if am_i_owner:
# We're the owner - handle the disconnect directly
logger.debug(f"Owner handling CLIENT_DISCONNECTED for client {client_id} locally (not publishing)")
if (remaining == 0
or self.proxy_server.output_managers.get(self.channel_id)
or self.proxy_server.profile_managers.get(self.channel_id)):
import gevent
gevent.spawn(self.proxy_server.handle_client_disconnect, self.channel_id)
else:
# We're not the owner - publish event so owner can handle it
logger.debug(f"Non-owner publishing CLIENT_DISCONNECTED event for client {client_id} on channel {self.channel_id} from worker {self.worker_id}")
event_data = json.dumps({
"event": EventType.CLIENT_DISCONNECTED,
"channel_id": self.channel_id,
"client_id": client_id,
"worker_id": self.worker_id or "unknown",
"timestamp": time.time(),
"remaining_clients": remaining,
"username": client_username
})
self.redis_client.publish(RedisKeys.events_channel(self.channel_id), event_data)
self._notify_owner_of_activity()
# Trigger channel stats update via WebSocket
self._trigger_stats_update()
am_i_owner = self.proxy_server and self.proxy_server.am_i_owner(self.channel_id)
total_clients = self.get_total_client_count()
logger.info(f"Client disconnected: {client_id} (local: {len(self.clients)}, total: {total_clients})")
# Owner lock TTL can expire while local ffmpeg is still running on this worker.
if (not am_i_owner and self.proxy_server
and self.proxy_server._has_local_upstream_activity(self.channel_id)):
if self.proxy_server.extend_ownership(self.channel_id):
am_i_owner = True
return len(self.clients)
schedule_disconnect = False
if am_i_owner:
logger.debug(
f"Owner handling CLIENT_DISCONNECTED for client {client_id} locally (not publishing)"
)
if (remaining == 0
or self.proxy_server.output_managers.get(self.channel_id)
or self.proxy_server.profile_managers.get(self.channel_id)):
schedule_disconnect = True
elif (remaining == 0 and self.proxy_server
and self.proxy_server._has_local_upstream_activity(self.channel_id)):
logger.warning(
f"Last client removed while local upstream still active for "
f"{self.channel_id} without owner lock - stopping channel"
)
schedule_disconnect = True
else:
logger.debug(
f"Non-owner publishing CLIENT_DISCONNECTED event for client {client_id} "
f"on channel {self.channel_id} from worker {self.worker_id}"
)
event_data = json.dumps({
"event": EventType.CLIENT_DISCONNECTED,
"channel_id": self.channel_id,
"client_id": client_id,
"worker_id": self.worker_id or "unknown",
"timestamp": time.time(),
"remaining_clients": remaining,
"username": client_username
})
self.redis_client.publish(RedisKeys.events_channel(self.channel_id), event_data)
if schedule_disconnect and self.proxy_server:
self.proxy_server._spawn_on_hub(
self.proxy_server.handle_client_disconnect, self.channel_id
)
self._trigger_stats_update()
total_clients = self.get_total_client_count()
logger.info(
f"Client disconnected: {client_id} (local: {local_count}, total: {total_clients})"
)
return local_count
def get_client_count(self):
"""Get local client count"""
@ -442,3 +480,19 @@ class ClientManager:
)
return stale_ids
@staticmethod
def clear_all_clients(redis_client, channel_id):
"""Remove every client entry from Redis for a channel."""
client_set_key = RedisKeys.clients(channel_id)
client_ids = redis_client.smembers(client_set_key)
if not client_ids:
return 0
pipe = redis_client.pipeline(transaction=False)
for cid in client_ids:
cid_str = cid.decode() if isinstance(cid, bytes) else cid
pipe.delete(RedisKeys.client_metadata(channel_id, cid_str))
pipe.delete(client_set_key)
pipe.execute()
return len(client_ids)

View file

@ -107,9 +107,14 @@ class ConfigHelper:
@staticmethod
def channel_init_grace_period():
"""Get channel initialization grace period in seconds"""
"""Max seconds to wait for initial buffer fill during channel startup."""
return Config.get_channel_init_grace_period()
@staticmethod
def channel_client_wait_period():
"""Seconds to keep a ready channel alive waiting for the first client to connect."""
return Config.get_channel_client_wait_period()
@staticmethod
def chunk_timeout():
"""

View file

@ -64,7 +64,7 @@ class StreamBuffer:
def add_chunk(self, chunk):
"""Add data with optimized Redis storage and TS packet alignment"""
if not chunk:
if not chunk or self.stopping:
return False
try:
@ -309,38 +309,16 @@ class StreamBuffer:
self.fill_timers.clear()
try:
# Flush any remaining data in the write buffer
if hasattr(self, '_write_buffer') and len(self._write_buffer) > 0:
# Ensure remaining data is aligned to TS packets
complete_size = (len(self._write_buffer) // 188) * 188
if complete_size > 0:
final_chunk = self._write_buffer[:complete_size]
# Write final chunk to Redis
with self.lock:
if self.redis_client:
try:
chunk_index = self.redis_client.incr(self.buffer_index_key)
chunk_key = f"{self.buffer_prefix}{chunk_index}"
self.redis_client.setex(chunk_key, self.chunk_ttl, bytes(final_chunk))
self.index = chunk_index
logger.info(f"Flushed final chunk of {len(final_chunk)} bytes to Redis")
except Exception as e:
logger.error(f"Error flushing final chunk: {e}")
# Clear buffers
self._write_buffer = bytearray()
if hasattr(self, '_partial_packet'):
self._partial_packet = bytearray()
# Clean up the chunk timestamps sorted set
if self.redis_client and self.chunk_timestamps_key:
try:
self.redis_client.delete(self.chunk_timestamps_key)
except Exception as e:
logger.error(f"Error deleting chunk timestamps key: {e}")
with self.lock:
if hasattr(self, '_write_buffer') and len(self._write_buffer) > 0:
discarded = len(self._write_buffer)
self._write_buffer = bytearray()
if hasattr(self, '_partial_packet'):
self._partial_packet = bytearray()
logger.debug(
f"Discarded {discarded} bytes from local write buffer "
f"for channel {self.channel_id}"
)
except Exception as e:
logger.error(f"Error during buffer stop: {e}")

View file

@ -67,6 +67,7 @@ class StreamManager:
# Add to your __init__ method
self._buffer_check_timers = []
self.stopping = False
self.stop_requested = False
# Add tracking for tried streams and current stream
self.current_stream_id = stream_id
@ -112,6 +113,11 @@ class StreamManager:
# Add tracking for data throughput
self.bytes_processed = 0
self.last_bytes_update = time.time()
# Cached result of the pipelined Redis ownership audit (hot read path).
self._ownership_cache_valid_until = 0.0
self._ownership_cached = True
self._OWNERSHIP_CHECK_INTERVAL = 1.0
self.bytes_update_interval = 5 # Update Redis every 5 seconds
# Add stderr reader thread property
@ -182,6 +188,153 @@ class StreamManager:
logger.warning(f"Timeout waiting for existing processes to close for channel {self.channel_id} after {timeout}s")
return False
def _invalidate_ownership_cache(self):
self._ownership_cache_valid_until = 0.0
@staticmethod
def _decode_redis_value(value):
if value is None:
return None
if isinstance(value, bytes):
return value.decode()
return value
def _disconnect_shutdown_ready(self, disconnect_value):
"""True when last-client disconnect has passed the configured shutdown delay."""
if not disconnect_value:
return False
shutdown_delay = ConfigHelper.channel_shutdown_delay()
if shutdown_delay <= 0:
return True
disconnect_value = self._decode_redis_value(disconnect_value)
try:
disconnect_time = float(disconnect_value)
except (ValueError, TypeError):
return False
return (time.time() - disconnect_time) >= shutdown_delay
def _evaluate_ownership_from_redis(self, redis_client):
"""Single pipelined Redis round-trip for the full ownership audit."""
stop_key = RedisKeys.channel_stopping(self.channel_id)
metadata_key = RedisKeys.channel_metadata(self.channel_id)
clients_key = RedisKeys.clients(self.channel_id)
owner_key = RedisKeys.channel_owner(self.channel_id)
disconnect_key = RedisKeys.last_client_disconnect(self.channel_id)
pipe = redis_client.pipeline(transaction=False)
pipe.exists(stop_key)
pipe.exists(metadata_key)
pipe.scard(clients_key)
pipe.get(owner_key)
pipe.get(disconnect_key)
pipe.hget(metadata_key, ChannelMetadataField.STATE)
(
stop_exists,
metadata_exists,
client_count,
current_owner,
disconnect_value,
state_raw,
) = pipe.execute()
if stop_exists:
return False
if not metadata_exists:
logger.warning(
f"Channel {self.channel_id} metadata removed from Redis - stopping upstream"
)
return False
client_count = client_count or 0
state = self._decode_redis_value(state_raw)
current_owner = self._decode_redis_value(current_owner)
if current_owner and current_owner != self.worker_id:
return False
if not current_owner:
if client_count == 0 and state not in ChannelState.PRE_ACTIVE:
logger.warning(
f"Channel {self.channel_id} has no owner and no clients - stopping upstream"
)
return False
return True
if client_count == 0 and self._disconnect_shutdown_ready(disconnect_value):
logger.info(
f"Channel {self.channel_id} disconnect shutdown ready - stopping upstream"
)
return False
return True
def _still_owner(self, *, force=False):
"""Return True while this worker should keep the upstream connection open."""
if self.stopping or self.stop_requested:
return False
if not self.worker_id:
return True
redis_client = getattr(self.buffer, 'redis_client', None)
if not redis_client:
return True
now = time.time()
if not force and now < self._ownership_cache_valid_until:
return self._ownership_cached
try:
result = self._evaluate_ownership_from_redis(redis_client)
self._ownership_cached = result
self._ownership_cache_valid_until = now + self._OWNERSHIP_CHECK_INTERVAL
return result
except Exception as e:
logger.debug(f"Ownership check failed for channel {self.channel_id}: {e}")
return True
def _upstream_may_continue(self):
"""
Per-chunk gate for the hot read path.
Local stop flags are checked every chunk. The coordinated-teardown Redis
flag is checked every chunk (one EXISTS). The full ownership audit is
pipelined and cached for ~1s enough for loop boundaries while avoiding
6+ Redis round-trips per chunk during steady streaming.
"""
if self.stopping or self.stop_requested or not self.running:
return False
if self.buffer is not None and self.buffer.stopping:
return False
redis_client = getattr(self.buffer, 'redis_client', None)
if redis_client and self.worker_id:
try:
if redis_client.exists(RedisKeys.channel_stopping(self.channel_id)):
self._ownership_cached = False
self._ownership_cache_valid_until = 0.0
return False
except Exception as e:
logger.debug(
f"Channel stopping check failed for {self.channel_id}: {e}"
)
return self._still_owner()
def _ensure_owner_or_stop(self):
if self._still_owner(force=True):
return True
logger.warning(
f"Stream manager for channel {self.channel_id} lost ownership "
f"(worker {self.worker_id}) - stopping upstream"
)
self.stop()
return False
def run(self):
"""Main execution loop using HTTP streaming with improved connection handling and stream switching"""
# Add a stop flag to the class properties
@ -203,6 +356,8 @@ class StreamManager:
# Main stream switching loop - we'll try different streams if needed
while self.running and stream_switch_attempts <= max_stream_switches:
close_old_connections()
if not self._ensure_owner_or_stop():
break
# Check for stuck switching state
if self.url_switching and time.time() - self.url_switch_start_time > self.url_switch_timeout:
logger.warning(f"URL switching state appears stuck for channel {self.channel_id} "
@ -255,6 +410,8 @@ class StreamManager:
continue
# Connection retry loop for current URL
while self.running and self.retry_count < self.max_retries and not url_failed and not self.needs_stream_switch:
if not self._ensure_owner_or_stop():
break
logger.info(f"Connection attempt {self.retry_count + 1}/{self.max_retries} for URL: {self.url} for channel {self.channel_id}")
@ -382,6 +539,12 @@ class StreamManager:
except Exception as e:
logger.error(f"Stream error: {e}", exc_info=True)
finally:
try:
from ..server import ProxyServer
ProxyServer.get_instance()._live_stream_managers.pop(self.channel_id, None)
except Exception:
pass
# Enhanced cleanup in the finally block
self.connected = False
@ -410,7 +573,9 @@ class StreamManager:
try:
metadata_key = RedisKeys.channel_metadata(self.channel_id)
owner_key = RedisKeys.channel_owner(self.channel_id)
current_owner = self.buffer.redis_client.get(owner_key)
current_owner = self._decode_redis_value(
self.buffer.redis_client.get(owner_key)
)
is_owner = (
current_owner
@ -421,12 +586,10 @@ class StreamManager:
should_update = is_owner
if not should_update and no_owner:
current_state_bytes = self.buffer.redis_client.hget(
metadata_key, ChannelMetadataField.STATE
)
current_state = (
current_state_bytes
if current_state_bytes else None
current_state = self._decode_redis_value(
self.buffer.redis_client.hget(
metadata_key, ChannelMetadataField.STATE
)
)
should_update = current_state in ChannelState.PRE_ACTIVE
if not should_update and current_state:
@ -488,44 +651,48 @@ class StreamManager:
logger.debug(f"Closing existing HTTP connections before establishing transcode connection for channel {self.channel_id}")
self._close_connection()
channel = get_stream_object(self.channel_id)
try:
channel = get_stream_object(self.channel_id)
# Use FFmpeg specifically for HLS streams
if hasattr(self, 'force_ffmpeg') and self.force_ffmpeg:
from core.models import StreamProfile
try:
stream_profile = StreamProfile.objects.get(name='ffmpeg', locked=True)
logger.info("Using FFmpeg stream profile for unsupported proxy content (HLS/RTSP/UDP)")
except StreamProfile.DoesNotExist:
# Fall back to channel's profile if FFmpeg not found
# Use FFmpeg specifically for HLS streams
if hasattr(self, 'force_ffmpeg') and self.force_ffmpeg:
from core.models import StreamProfile
try:
stream_profile = StreamProfile.objects.get(name='ffmpeg', locked=True)
logger.info("Using FFmpeg stream profile for unsupported proxy content (HLS/RTSP/UDP)")
except StreamProfile.DoesNotExist:
# Fall back to channel's profile if FFmpeg not found
stream_profile = channel.get_stream_profile()
logger.warning(f"FFmpeg profile not found, using channel default profile for channel: {self.channel_id}")
else:
stream_profile = channel.get_stream_profile()
logger.warning(f"FFmpeg profile not found, using channel default profile for channel: {self.channel_id}")
else:
stream_profile = channel.get_stream_profile()
# Build and start transcode command
self.transcode_cmd = stream_profile.build_command(self.url, self.user_agent)
# Build and start transcode command
self.transcode_cmd = stream_profile.build_command(self.url, self.user_agent)
# Store stream command for efficient log parser routing
self.stream_command = stream_profile.command
# Map actual commands to parser types for direct routing
command_to_parser = {
'ffmpeg': 'ffmpeg',
'cvlc': 'vlc',
'vlc': 'vlc',
'streamlink': 'streamlink'
}
self.parser_type = command_to_parser.get(self.stream_command.lower())
if self.parser_type:
logger.debug(f"Using {self.parser_type} parser for log parsing (command: {self.stream_command})")
else:
logger.debug(f"Unknown stream command '{self.stream_command}', will use auto-detection for log parsing")
# Store stream command for efficient log parser routing
self.stream_command = stream_profile.command
# Map actual commands to parser types for direct routing
command_to_parser = {
'ffmpeg': 'ffmpeg',
'cvlc': 'vlc',
'vlc': 'vlc',
'streamlink': 'streamlink'
}
self.parser_type = command_to_parser.get(self.stream_command.lower())
if self.parser_type:
logger.debug(f"Using {self.parser_type} parser for log parsing (command: {self.stream_command})")
else:
logger.debug(f"Unknown stream command '{self.stream_command}', will use auto-detection for log parsing")
# For UDP streams, remove any user_agent parameters from the command
if hasattr(self, 'stream_type') and self.stream_type == StreamType.UDP:
# Filter out any arguments that contain the user_agent value or related headers
self.transcode_cmd = [arg for arg in self.transcode_cmd if self.user_agent not in arg and 'user-agent' not in arg.lower() and 'user_agent' not in arg.lower()]
logger.debug(f"Removed user_agent parameters from UDP stream command for channel: {self.channel_id}")
# For UDP streams, remove any user_agent parameters from the command
if hasattr(self, 'stream_type') and self.stream_type == StreamType.UDP:
# Filter out any arguments that contain the user_agent value or related headers
self.transcode_cmd = [arg for arg in self.transcode_cmd if self.user_agent not in arg and 'user-agent' not in arg.lower() and 'user_agent' not in arg.lower()]
logger.debug(f"Removed user_agent parameters from UDP stream command for channel: {self.channel_id}")
finally:
# Release the pool slot before posix_spawn or before returning on profile errors.
close_old_connections()
logger.debug(f"Starting transcode process: {self.transcode_cmd} for channel: {self.channel_id}")
@ -1020,6 +1187,9 @@ class StreamManager:
def _update_bytes_processed(self, chunk_size):
"""Update the total bytes processed in Redis metadata"""
if not self._upstream_may_continue():
return
try:
# Update local counter
self.bytes_processed += chunk_size
@ -1092,14 +1262,10 @@ class StreamManager:
"""Stop the stream manager and cancel all timers"""
logger.info(f"Stopping stream manager for channel {self.channel_id}")
# Add at the beginning of your stop method
self.stopping = True
# Release stream resources if we're the owner
if self.current_stream_id and hasattr(self, 'worker_id') and self.worker_id:
if hasattr(self.buffer, 'redis_client') and self.buffer.redis_client:
owner_key = RedisKeys.channel_owner(self.channel_id)
current_owner = self.buffer.redis_client.get(owner_key)
self._invalidate_ownership_cache()
if self.buffer is not None:
self.buffer.stopping = True
# Cancel all buffer check timers
for timer in list(self._buffer_check_timers):
@ -1254,6 +1420,12 @@ class StreamManager:
"""Check if connection retry is allowed"""
return self.retry_count < self.max_retries
def _health_inactivity_threshold(self):
"""How long without data before marking the stream unhealthy."""
if self.connected and getattr(self.buffer, 'index', 0) == 0:
return ConfigHelper.channel_init_grace_period()
return getattr(Config, 'CONNECTION_TIMEOUT', 10)
def _monitor_health(self):
"""Monitor stream health and set flags for the main loop to handle recovery"""
consecutive_unhealthy_checks = 0
@ -1269,7 +1441,7 @@ class StreamManager:
try:
now = time.time()
inactivity_duration = now - self.last_data_time
timeout_threshold = getattr(Config, 'CONNECTION_TIMEOUT', 10)
timeout_threshold = self._health_inactivity_threshold()
if inactivity_duration > timeout_threshold and self.connected:
if self.healthy:
@ -1489,7 +1661,8 @@ class StreamManager:
if hasattr(self, 'stderr_reader_thread') and self.stderr_reader_thread and self.stderr_reader_thread.is_alive():
try:
logger.debug(f"Waiting for stderr reader thread to terminate for channel {self.channel_id}")
self.stderr_reader_thread.join(timeout=2.0)
stderr_join_timeout = 0.25 if self.stopping else 2.0
self.stderr_reader_thread.join(timeout=stderr_join_timeout)
if self.stderr_reader_thread.is_alive():
logger.warning(f"Stderr reader thread did not terminate within timeout for channel {self.channel_id}")
except Exception as e:
@ -1582,6 +1755,10 @@ class StreamManager:
self.connected = False
return False
if not self._upstream_may_continue():
self.stop()
return False
# Track chunk size before adding to buffer
chunk_size = len(chunk)
self._update_bytes_processed(chunk_size)
@ -1589,7 +1766,6 @@ class StreamManager:
# Add directly to buffer without TS-specific processing
success = self.buffer.add_chunk(chunk)
# Update last data timestamp in Redis if successful
if success and hasattr(self.buffer, 'redis_client') and self.buffer.redis_client:
last_data_key = RedisKeys.last_data(self.buffer.channel_id)
self.buffer.redis_client.set(last_data_key, str(time.time()), ex=60)
@ -1660,19 +1836,9 @@ class StreamManager:
timer.start()
return False
# We have enough buffer, proceed with state change
update_data = {
ChannelMetadataField.STATE: ChannelState.WAITING_FOR_CLIENTS,
ChannelMetadataField.CONNECTION_READY_TIME: current_time,
ChannelMetadataField.STATE_CHANGED_AT: current_time,
ChannelMetadataField.BUFFER_CHUNKS: str(current_buffer_index)
}
redis_client.hset(metadata_key, mapping=update_data)
from ..services.channel_service import ChannelService
# Get configured grace period or default
grace_period = ConfigHelper.channel_init_grace_period()
logger.info(f"STREAM MANAGER: Updated channel {channel_id} state: {current_state or 'None'} -> {ChannelState.WAITING_FOR_CLIENTS} with {current_buffer_index} buffer chunks")
logger.info(f"Started initial connection grace period ({grace_period}s) for channel {channel_id}")
ChannelService.promote_channel_when_buffer_ready(channel_id)
else:
logger.debug(f"Not changing state: channel {channel_id} already in {current_state} state")
except Exception as e:

View file

@ -11,6 +11,7 @@ import time
import gevent
from apps.channels.models import Channel, Stream
from core.utils import log_system_event
from django.db import close_old_connections
from ...server import ProxyServer
from ...redis_keys import RedisKeys
from ...constants import ChannelMetadataField
@ -331,46 +332,53 @@ class FMP4StreamGenerator:
# ------------------------------------------------------------------
def _cleanup(self):
elapsed = time.time() - self.stream_start_time
proxy_server = ProxyServer.get_instance()
try:
elapsed = time.time() - self.stream_start_time
proxy_server = ProxyServer.get_instance()
# Release stream allocation if last client (mirrors StreamGenerator)
if proxy_server.redis_client:
try:
meta_key = RedisKeys.channel_metadata(self.channel_id)
stream_id_bytes = proxy_server.redis_client.hget(
meta_key, ChannelMetadataField.STREAM_ID
)
if stream_id_bytes:
if self.channel_id in proxy_server.client_managers:
client_count = proxy_server.client_managers[
self.channel_id
].get_total_client_count()
if client_count <= 1 and proxy_server.am_i_owner(self.channel_id):
try:
# Release stream allocation if last client (mirrors StreamGenerator)
if proxy_server.redis_client:
try:
meta_key = RedisKeys.channel_metadata(self.channel_id)
stream_id_bytes = proxy_server.redis_client.hget(
meta_key, ChannelMetadataField.STREAM_ID
)
if stream_id_bytes:
if self.channel_id in proxy_server.client_managers:
client_count = proxy_server.client_managers[
self.channel_id
].get_total_client_count()
if (
client_count <= 1
and proxy_server.am_i_owner(self.channel_id)
and ConfigHelper.channel_shutdown_delay() <= 0
):
try:
obj = Channel.objects.get(uuid=self.channel_id)
except (Channel.DoesNotExist, Exception):
obj = Stream.objects.get(stream_hash=self.channel_id)
obj.release_stream()
except Exception as e:
logger.error(
f"[{self.client_id}] Error releasing stream: {e}"
)
except Exception as e:
logger.error(f"[{self.client_id}] Error in stream release check: {e}")
try:
obj = Channel.objects.get(uuid=self.channel_id)
except (Channel.DoesNotExist, Exception):
obj = Stream.objects.get(stream_hash=self.channel_id)
obj.release_stream()
except Exception as e:
logger.error(
f"[{self.client_id}] Error releasing stream: {e}"
)
except Exception as e:
logger.error(f"[{self.client_id}] Error in stream release check: {e}")
# Remove from MAIN ClientManager - this is what triggers handle_client_disconnect
# and the zero-clients → stop_channel path, same as TS clients.
local_clients = 0
total_clients = 0
if self.channel_id in proxy_server.client_managers:
client_manager = proxy_server.client_managers[self.channel_id]
local_clients = client_manager.remove_client(self.client_id)
total_clients = client_manager.get_total_client_count()
# Remove from MAIN ClientManager - this is what triggers handle_client_disconnect
# and the zero-clients → stop_channel path, same as TS clients.
local_clients = 0
total_clients = 0
if self.channel_id in proxy_server.client_managers:
client_manager = proxy_server.client_managers[self.channel_id]
local_clients = client_manager.remove_client(self.client_id)
total_clients = client_manager.get_total_client_count()
logger.info(
f"[{self.client_id}] fMP4 client disconnected after {elapsed:.2f}s "
f"({self.bytes_sent / 1024:.1f} KB sent, "
f"local: {local_clients}, total: {total_clients})"
)
logger.info(
f"[{self.client_id}] fMP4 client disconnected after {elapsed:.2f}s "
f"({self.bytes_sent / 1024:.1f} KB sent, "
f"local: {local_clients}, total: {total_clients})"
)
finally:
close_old_connections()

View file

@ -7,6 +7,7 @@ import time
import gevent
from apps.proxy.config import TSConfig as Config
from apps.channels.models import Channel, Stream
from django.db import close_old_connections
from core.utils import log_system_event
from ...server import ProxyServer
from ...utils import create_ts_packet, get_logger
@ -139,12 +140,75 @@ class StreamGenerator:
finally:
self._cleanup()
def _init_wait_abort_reason(self, proxy_server, initialization_start):
"""Return a reason string when init wait should end early, else None."""
from ...services.channel_service import ChannelService
if ChannelService.is_channel_teardown_active(self.channel_id):
return "stopping"
client_mgr = proxy_server.client_managers.get(self.channel_id)
if client_mgr is not None:
if self.client_id not in client_mgr.clients:
return "client_gone"
elif proxy_server.redis_client:
client_key = RedisKeys.client_metadata(self.channel_id, self.client_id)
if not proxy_server.redis_client.exists(client_key):
total = proxy_server.redis_client.scard(RedisKeys.clients(self.channel_id)) or 0
if total == 0:
return "client_gone"
elapsed = time.time() - initialization_start
init_grace_period = ConfigHelper.channel_init_grace_period()
if elapsed < init_grace_period or not proxy_server.redis_client:
return None
metadata_key = RedisKeys.channel_metadata(self.channel_id)
state_raw = proxy_server.redis_client.hget(metadata_key, 'state')
if not state_raw:
return None
state = state_raw.decode() if isinstance(state_raw, bytes) else state_raw
if state not in ('connecting', 'initializing', 'buffering'):
return None
buffer = proxy_server.stream_buffers.get(self.channel_id)
if buffer is not None and buffer.index > 0:
return None
if proxy_server.redis_client.get(RedisKeys.last_data(self.channel_id)):
return None
return "stalled"
def _wait_for_initialization(self):
initialization_start = time.time()
max_init_wait = ConfigHelper.client_wait_timeout()
proxy_server = ProxyServer.get_instance()
while time.time() - initialization_start < max_init_wait:
abort_reason = self._init_wait_abort_reason(proxy_server, initialization_start)
if abort_reason == "stopping":
logger.error(
f"[{self.client_id}] Channel {self.channel_id} teardown active during initialization"
)
yield create_ts_packet('error', "Error: Channel is stopping")
return False
if abort_reason == "client_gone":
logger.info(
f"[{self.client_id}] Client disconnected during initialization wait, aborting"
)
yield create_ts_packet('error', "Error: Client disconnected")
return False
if abort_reason == "stalled":
logger.warning(
f"[{self.client_id}] Channel {self.channel_id} stalled in connecting state "
f"with no buffer data after "
f"{ConfigHelper.channel_init_grace_period()}s, aborting init wait"
)
yield create_ts_packet('error', "Error: Connection stalled")
return False
if proxy_server.redis_client:
metadata_key = RedisKeys.channel_metadata(self.channel_id)
metadata = proxy_server.redis_client.hgetall(metadata_key)
@ -527,63 +591,63 @@ class StreamGenerator:
def _cleanup(self):
"""Clean up resources and report final statistics."""
# Client cleanup
elapsed = time.time() - self.stream_start_time
local_clients = 0
total_clients = 0
proxy_server = ProxyServer.get_instance()
try:
# Client cleanup
elapsed = time.time() - self.stream_start_time
local_clients = 0
total_clients = 0
proxy_server = ProxyServer.get_instance()
# Release M3U profile stream allocation if this is the last client
stream_released = False
if proxy_server.redis_client:
try:
metadata_key = RedisKeys.channel_metadata(self.channel_id)
metadata = proxy_server.redis_client.hgetall(metadata_key)
if metadata:
stream_id_bytes = proxy_server.redis_client.hget(metadata_key, ChannelMetadataField.STREAM_ID)
if stream_id_bytes:
# Check if we're the last client
if self.channel_id in proxy_server.client_managers:
client_count = proxy_server.client_managers[self.channel_id].get_total_client_count()
# Only the last client or owner should release the stream
if client_count <= 1 and proxy_server.am_i_owner(self.channel_id):
# Release M3U profile stream allocation if this is the last client
stream_released = False
if proxy_server.redis_client:
try:
if self.channel_id in proxy_server.client_managers:
client_count = proxy_server.client_managers[self.channel_id].get_total_client_count()
# Pool slots are global; the last client on any worker must release.
# During shutdown_delay, keep the slot until coordinated stop runs.
if client_count <= 1 and ConfigHelper.channel_shutdown_delay() <= 0:
try:
try:
# Try Channel first (normal flow), fall back to Stream (preview flow)
try:
obj = Channel.objects.get(uuid=self.channel_id)
except (Channel.DoesNotExist, Exception):
obj = Stream.objects.get(stream_hash=self.channel_id)
stream_released = obj.release_stream()
if stream_released:
logger.debug(f"[{self.client_id}] Released stream for channel {self.channel_id}")
else:
logger.warning(f"[{self.client_id}] release_stream found no keys for channel {self.channel_id}")
except Exception as e:
logger.error(f"[{self.client_id}] Error releasing stream for channel {self.channel_id}: {e}")
except Exception as e:
logger.error(f"[{self.client_id}] Error checking stream data for release: {e}")
obj = Channel.objects.get(uuid=self.channel_id)
except (Channel.DoesNotExist, Exception):
obj = Stream.objects.get(stream_hash=self.channel_id)
stream_released = obj.release_stream()
if stream_released:
logger.debug(f"[{self.client_id}] Released stream for channel {self.channel_id}")
else:
logger.warning(f"[{self.client_id}] release_stream found no keys for channel {self.channel_id}")
except Exception as e:
logger.error(f"[{self.client_id}] Error releasing stream for channel {self.channel_id}: {e}")
except Exception as e:
logger.error(f"[{self.client_id}] Error checking stream data for release: {e}")
if self.channel_id in proxy_server.client_managers:
client_manager = proxy_server.client_managers[self.channel_id]
local_clients = client_manager.remove_client(self.client_id)
total_clients = client_manager.get_total_client_count()
logger.info(f"[{self.client_id}] Disconnected after {elapsed:.2f}s (local: {local_clients}, total: {total_clients})")
if self.channel_id in proxy_server.client_managers:
client_manager = proxy_server.client_managers[self.channel_id]
if self.client_id in client_manager.clients:
local_clients = client_manager.remove_client(self.client_id)
else:
local_clients = client_manager.get_client_count()
total_clients = client_manager.get_total_client_count()
logger.info(f"[{self.client_id}] Disconnected after {elapsed:.2f}s (local: {local_clients}, total: {total_clients})")
# Log client disconnect event
try:
log_system_event(
'client_disconnect',
channel_id=self.channel_id,
channel_name=self.channel_name,
client_ip=self.client_ip,
client_id=self.client_id,
user_agent=self.client_user_agent[:100] if self.client_user_agent else None,
duration=round(elapsed, 2),
bytes_sent=self.bytes_sent,
username=self.user.username if self.user else None
)
except Exception as e:
logger.error(f"Could not log client disconnect event: {e}")
# Log client disconnect event
try:
log_system_event(
'client_disconnect',
channel_id=self.channel_id,
channel_name=self.channel_name,
client_ip=self.client_ip,
client_id=self.client_id,
user_agent=self.client_user_agent[:100] if self.client_user_agent else None,
duration=round(elapsed, 2),
bytes_sent=self.bytes_sent,
username=self.user.username if self.user else None
)
except Exception as e:
logger.error(f"Could not log client disconnect event: {e}")
finally:
close_old_connections()
def create_stream_generator(channel_id, client_id, client_ip, client_user_agent, channel_initializing=False, user=None, buffer=None):

File diff suppressed because it is too large Load diff

View file

@ -10,6 +10,7 @@ from apps.channels.models import Channel, Stream
from ..server import ProxyServer
from ..redis_keys import RedisKeys
from ..constants import EventType, ChannelState, ChannelMetadataField
from ..config_helper import ConfigHelper
from ..url_utils import get_stream_info_for_switch
from core.utils import log_system_event
from .log_parsers import LogParserFactory
@ -19,6 +20,250 @@ logger = logging.getLogger("live_proxy")
class ChannelService:
"""Service class for channel operations"""
@staticmethod
def mark_channel_stopping(channel_id, broadcast=False):
"""Mark a channel as stopping in Redis so all uWSGI workers converge on teardown."""
proxy_server = ProxyServer.get_instance()
if not proxy_server.redis_client:
return False
try:
metadata_key = RedisKeys.channel_metadata(channel_id)
if proxy_server.redis_client.exists(metadata_key):
proxy_server.redis_client.hset(metadata_key, mapping={
ChannelMetadataField.STATE: ChannelState.STOPPING,
ChannelMetadataField.STATE_CHANGED_AT: str(time.time()),
})
stop_key = RedisKeys.channel_stopping(channel_id)
proxy_server.redis_client.setex(stop_key, 60, "true")
if broadcast:
ChannelService._publish_channel_stop_event(channel_id)
return True
except Exception as e:
logger.error(f"Error marking channel {channel_id} as stopping: {e}")
return False
@staticmethod
def is_shutdown_pending(channel_id):
"""True while the post-disconnect shutdown delay has started but stop has not run."""
proxy_server = ProxyServer.get_instance()
if not proxy_server.redis_client:
return False
delay = ConfigHelper.channel_shutdown_delay()
if delay <= 0:
return False
disconnect_value = proxy_server.redis_client.get(
RedisKeys.last_client_disconnect(channel_id)
)
if not disconnect_value:
return False
try:
disconnect_time = float(disconnect_value)
except (ValueError, TypeError):
return False
return (time.time() - disconnect_time) < delay
@staticmethod
def is_channel_teardown_active(channel_id):
"""True when a coordinated channel stop is in progress (visible to all workers)."""
proxy_server = ProxyServer.get_instance()
if channel_id in proxy_server._stopping_channels:
return True
if not proxy_server.redis_client:
return False
if proxy_server.redis_client.exists(RedisKeys.channel_stopping(channel_id)):
return True
metadata_key = RedisKeys.channel_metadata(channel_id)
state = proxy_server.redis_client.hget(metadata_key, ChannelMetadataField.STATE)
if state:
state_str = state.decode() if isinstance(state, bytes) else state
if state_str == ChannelState.STOPPING:
return True
return False
@staticmethod
def is_channel_unavailable_for_new_clients(channel_id):
"""Reject new stream requests only while coordinated teardown is active."""
return ChannelService.is_channel_teardown_active(channel_id)
@staticmethod
def cancel_pending_shutdown(channel_id):
"""
Abort the post-disconnect grace timer when a client reconnects.
Clears the disconnect timestamp and any leaked stopping markers. When
upstream is still active but the last client released its profile slot
during the grace window, re-reserve the slot from Redis metadata.
Does not run during coordinated stop_channel() clearing teardown
markers mid-stop would leave clients attached to upstream that is
about to be torn down.
"""
from django.db import close_old_connections
proxy_server = ProxyServer.get_instance()
if not proxy_server.redis_client:
return False
if channel_id in proxy_server._stopping_channels:
return False
disconnect_key = RedisKeys.last_client_disconnect(channel_id)
had_pending = bool(proxy_server.redis_client.exists(disconnect_key))
in_grace = had_pending or ChannelService.is_shutdown_pending(channel_id)
if not in_grace:
return False
try:
proxy_server.redis_client.delete(disconnect_key)
metadata_key = RedisKeys.channel_metadata(channel_id)
state = proxy_server.redis_client.hget(
metadata_key, ChannelMetadataField.STATE
)
if state:
state_str = state.decode() if isinstance(state, bytes) else state
if state_str == ChannelState.STOPPING:
proxy_server.redis_client.hset(metadata_key, mapping={
ChannelMetadataField.STATE: ChannelState.ACTIVE,
ChannelMetadataField.STATE_CHANGED_AT: str(time.time()),
})
stop_key = RedisKeys.channel_stopping(channel_id)
if proxy_server.redis_client.exists(stop_key):
proxy_server.redis_client.delete(stop_key)
if ChannelService._channel_proxy_is_active(
proxy_server.redis_client, channel_id
):
from apps.channels.models import Channel
channel = Channel.objects.filter(uuid=channel_id).first()
if channel and not proxy_server.redis_client.get(
f"channel_stream:{channel.id}"
):
sid, pid, error, slot_reserved = channel.get_stream()
if error:
logger.warning(
f"Could not re-reserve stream for {channel_id} "
f"after shutdown cancel: {error}"
)
elif slot_reserved and sid and pid:
proxy_server.redis_client.hset(metadata_key, mapping={
ChannelMetadataField.STREAM_ID: str(sid),
ChannelMetadataField.M3U_PROFILE: str(pid),
})
logger.info(
f"Re-reserved profile slot for {channel_id} "
f"(stream={sid}, profile={pid})"
)
finally:
close_old_connections()
return True
@staticmethod
def _channel_proxy_is_active(redis_client, channel_id):
"""True when live proxy metadata shows this channel is still running."""
metadata_key = RedisKeys.channel_metadata(channel_id)
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,
)
@staticmethod
def promote_channel_when_buffer_ready(channel_id):
"""
Promote channel state once the initial buffer threshold is met.
- connecting/initializing + buffer ready + clients -> active
- connecting/initializing + buffer ready + no clients -> waiting_for_clients
- waiting_for_clients + clients -> active
Returns the resulting state, or None when no promotion applies.
"""
proxy_server = ProxyServer.get_instance()
redis_client = proxy_server.redis_client
if not redis_client:
return None
metadata_key = RedisKeys.channel_metadata(channel_id)
state_raw = redis_client.hget(metadata_key, ChannelMetadataField.STATE)
if not state_raw:
return None
state = state_raw.decode() if isinstance(state_raw, bytes) else state_raw
if state == ChannelState.ACTIVE:
return ChannelState.ACTIVE
if state == ChannelState.WAITING_FOR_CLIENTS:
ready_raw = redis_client.hget(
metadata_key, ChannelMetadataField.CONNECTION_READY_TIME
)
if not ready_raw:
return None
client_count = redis_client.scard(RedisKeys.clients(channel_id)) or 0
if client_count <= 0:
return ChannelState.WAITING_FOR_CLIENTS
proxy_server.update_channel_state(
channel_id,
ChannelState.ACTIVE,
{"clients_at_activation": str(client_count)},
)
return ChannelState.ACTIVE
if state not in (ChannelState.INITIALIZING, ChannelState.CONNECTING):
return None
try:
buffer_index = int(redis_client.get(RedisKeys.buffer_index(channel_id)) or 0)
except (TypeError, ValueError):
buffer_index = 0
chunks_needed = ConfigHelper.initial_behind_chunks()
if buffer_index < chunks_needed:
return None
client_count = redis_client.scard(RedisKeys.clients(channel_id)) or 0
new_state = (
ChannelState.ACTIVE if client_count > 0 else ChannelState.WAITING_FOR_CLIENTS
)
current_time = str(time.time())
extra = {
ChannelMetadataField.CONNECTION_READY_TIME: current_time,
ChannelMetadataField.BUFFER_CHUNKS: str(buffer_index),
}
if new_state == ChannelState.ACTIVE:
extra["clients_at_activation"] = str(client_count)
proxy_server.update_channel_state(channel_id, new_state, extra)
logger.info(
f"Channel {channel_id} buffer ready ({buffer_index}/{chunks_needed} chunks) "
f"-> {new_state} (clients={client_count})"
)
return new_state
@staticmethod
def initialize_channel(channel_id, stream_url, user_agent, transcode=False, stream_profile_value=None, stream_id=None, m3u_profile_id=None, channel_name=None, stream_name=None):
"""
@ -39,6 +284,7 @@ class ChannelService:
bool: Success status
"""
proxy_server = ProxyServer.get_instance()
if stream_id and proxy_server.redis_client:
metadata_key = RedisKeys.channel_metadata(channel_id)
# Check if metadata already exists
@ -67,34 +313,38 @@ class ChannelService:
# Store additional metadata if initialization was successful
if success and proxy_server.redis_client:
metadata_key = RedisKeys.channel_metadata(channel_id)
update_data = {}
if stream_profile_value:
update_data[ChannelMetadataField.STREAM_PROFILE] = stream_profile_value
if stream_id:
update_data[ChannelMetadataField.STREAM_ID] = str(stream_id)
if m3u_profile_id:
update_data[ChannelMetadataField.M3U_PROFILE] = str(m3u_profile_id)
# Store channel name and stream name so stats workers don't need DB calls
try:
if not channel_name:
from apps.channels.models import Channel
channel_name = Channel.objects.filter(uuid=channel_id).values_list('name', flat=True).first()
if channel_name:
update_data[ChannelMetadataField.CHANNEL_NAME] = channel_name
else:
# No channel name means stream preview mode, use stream name as display fallback
if stream_id and not stream_name:
from apps.channels.models import Stream
stream_name = Stream.objects.filter(id=stream_id).values_list('name', flat=True).first()
if stream_name:
update_data[ChannelMetadataField.STREAM_NAME] = stream_name
except Exception as e:
logger.warning(f"Failed to store channel/stream names in Redis for {channel_id}: {e}")
metadata_key = RedisKeys.channel_metadata(channel_id)
update_data = {}
if stream_profile_value:
update_data[ChannelMetadataField.STREAM_PROFILE] = stream_profile_value
if stream_id:
update_data[ChannelMetadataField.STREAM_ID] = str(stream_id)
if m3u_profile_id:
update_data[ChannelMetadataField.M3U_PROFILE] = str(m3u_profile_id)
if update_data:
proxy_server.redis_client.hset(metadata_key, mapping=update_data)
# Store channel name and stream name so stats workers don't need DB calls
try:
if not channel_name:
from apps.channels.models import Channel
channel_name = Channel.objects.filter(uuid=channel_id).values_list('name', flat=True).first()
if channel_name:
update_data[ChannelMetadataField.CHANNEL_NAME] = channel_name
else:
# No channel name means stream preview mode, use stream name as display fallback
if stream_id and not stream_name:
from apps.channels.models import Stream
stream_name = Stream.objects.filter(id=stream_id).values_list('name', flat=True).first()
if stream_name:
update_data[ChannelMetadataField.STREAM_NAME] = stream_name
except Exception as e:
logger.warning(f"Failed to store channel/stream names in Redis for {channel_id}: {e}")
if update_data:
proxy_server.redis_client.hset(metadata_key, mapping=update_data)
finally:
from django.db import close_old_connections
close_old_connections()
return success
@ -263,58 +513,23 @@ class ChannelService:
try:
metadata = proxy_server.redis_client.hgetall(metadata_key)
if metadata and 'state' in metadata:
state = metadata['state']
channel_info = {"state": state}
# Immediately mark as stopping in metadata so clients detect it faster
proxy_server.redis_client.hset(metadata_key, ChannelMetadataField.STATE, ChannelState.STOPPING)
proxy_server.redis_client.hset(metadata_key, ChannelMetadataField.STATE_CHANGED_AT, str(time.time()))
channel_info = {"state": metadata['state']}
except Exception as e:
logger.error(f"Error fetching channel state: {e}")
# Set stopping flag with higher TTL to ensure it persists
# Mark stopping in Redis and notify all workers before local teardown.
# stop_channel() releases profile slots via _clean_redis_keys() before Redis deletion.
if proxy_server.redis_client:
stop_key = RedisKeys.channel_stopping(channel_id)
proxy_server.redis_client.setex(stop_key, 60, "true") # Higher TTL of 60 seconds
logger.info(f"Set channel stopping flag with 60s TTL for channel {channel_id}")
# Broadcast stop event to all workers via PubSub
if proxy_server.redis_client:
ChannelService._publish_channel_stop_event(channel_id)
# Also stop locally to ensure this worker cleans up right away
local_result = proxy_server.stop_channel(channel_id)
else:
# No Redis, just stop locally
local_result = proxy_server.stop_channel(channel_id)
# Release the channel in the channel model if applicable
try:
channel = Channel.objects.get(uuid=channel_id)
model_released = channel.release_stream()
if model_released:
logger.info(f"Released channel {channel_id} stream allocation")
else:
logger.warning(f"Channel {channel_id}: release_stream found no keys to clean")
except (Channel.DoesNotExist, Exception):
logger.warning(f"Could not find Channel model for UUID {channel_id}, attempting stream hash")
try:
stream = Stream.objects.get(stream_hash=channel_id)
model_released = stream.release_stream()
if model_released:
logger.info(f"Released stream {channel_id} stream allocation")
else:
logger.warning(f"Stream {channel_id}: release_stream found no keys to clean")
except (Stream.DoesNotExist, Exception) as e:
logger.error(f"No Channel or Stream found for {channel_id}: {e}")
model_released = False
ChannelService.mark_channel_stopping(channel_id, broadcast=True)
logger.info(f"Marked channel {channel_id} stopping and broadcast stop to all workers")
local_result = proxy_server.stop_channel(channel_id)
return {
'status': 'success',
'message': 'Channel stop request sent',
'channel_id': channel_id,
'previous_state': channel_info,
'model_released': model_released,
'model_released': bool(local_result),
'local_stop_result': local_result
}
@ -597,53 +812,57 @@ class ChannelService:
@staticmethod
def _update_channel_metadata(channel_id, url, user_agent=None, stream_id=None, m3u_profile_id=None, stream_name=None):
"""Update channel metadata in Redis"""
proxy_server = ProxyServer.get_instance()
try:
proxy_server = ProxyServer.get_instance()
if not proxy_server.redis_client:
return False
if not proxy_server.redis_client:
return False
metadata_key = RedisKeys.channel_metadata(channel_id)
metadata_key = RedisKeys.channel_metadata(channel_id)
# First check if the key exists and what type it is
key_type = proxy_server.redis_client.type(metadata_key)
logger.debug(f"Redis key {metadata_key} is of type: {key_type}")
# First check if the key exists and what type it is
key_type = proxy_server.redis_client.type(metadata_key)
logger.debug(f"Redis key {metadata_key} is of type: {key_type}")
# Build metadata update dict
metadata = {ChannelMetadataField.URL: url}
if user_agent:
metadata[ChannelMetadataField.USER_AGENT] = user_agent
if stream_id:
metadata[ChannelMetadataField.STREAM_ID] = str(stream_id)
if not stream_name:
try:
from apps.channels.models import Stream
stream_name = Stream.objects.filter(id=stream_id).values_list('name', flat=True).first()
except Exception as e:
logger.warning(f"Failed to update stream name in Redis for stream {stream_id}: {e}")
if stream_name:
metadata[ChannelMetadataField.STREAM_NAME] = stream_name
if m3u_profile_id:
metadata[ChannelMetadataField.M3U_PROFILE] = str(m3u_profile_id)
# Build metadata update dict
metadata = {ChannelMetadataField.URL: url}
if user_agent:
metadata[ChannelMetadataField.USER_AGENT] = user_agent
if stream_id:
metadata[ChannelMetadataField.STREAM_ID] = str(stream_id)
if not stream_name:
try:
from apps.channels.models import Stream
stream_name = Stream.objects.filter(id=stream_id).values_list('name', flat=True).first()
except Exception as e:
logger.warning(f"Failed to update stream name in Redis for stream {stream_id}: {e}")
if stream_name:
metadata[ChannelMetadataField.STREAM_NAME] = stream_name
if m3u_profile_id:
metadata[ChannelMetadataField.M3U_PROFILE] = str(m3u_profile_id)
# Also update the stream switch time field
metadata[ChannelMetadataField.STREAM_SWITCH_TIME] = str(time.time())
# Also update the stream switch time field
metadata[ChannelMetadataField.STREAM_SWITCH_TIME] = str(time.time())
# Use the appropriate method based on the key type
if key_type == 'hash':
proxy_server.redis_client.hset(metadata_key, mapping=metadata)
elif key_type == 'none': # Key doesn't exist yet
proxy_server.redis_client.hset(metadata_key, mapping=metadata)
else:
# If key exists with wrong type, delete it and recreate
proxy_server.redis_client.delete(metadata_key)
proxy_server.redis_client.hset(metadata_key, mapping=metadata)
# Use the appropriate method based on the key type
if key_type == 'hash':
proxy_server.redis_client.hset(metadata_key, mapping=metadata)
elif key_type == 'none': # Key doesn't exist yet
proxy_server.redis_client.hset(metadata_key, mapping=metadata)
else:
# If key exists with wrong type, delete it and recreate
proxy_server.redis_client.delete(metadata_key)
proxy_server.redis_client.hset(metadata_key, mapping=metadata)
# Set switch request flag to ensure all workers see it
switch_key = RedisKeys.switch_request(channel_id)
proxy_server.redis_client.setex(switch_key, 30, url) # 30 second TTL
# Set switch request flag to ensure all workers see it
switch_key = RedisKeys.switch_request(channel_id)
proxy_server.redis_client.setex(switch_key, 30, url) # 30 second TTL
logger.debug(f"Updated metadata for channel {channel_id} in Redis")
return True
logger.debug(f"Updated metadata for channel {channel_id} in Redis")
return True
finally:
from django.db import close_old_connections
close_old_connections()
@staticmethod
def _publish_stream_switch_event(channel_id, new_url, user_agent=None, stream_id=None, m3u_profile_id=None):

View file

@ -0,0 +1,142 @@
"""Live proxy must release geventpool checkouts after ORM on stream and URL paths."""
from unittest.mock import MagicMock, patch
from django.http import StreamingHttpResponse
from django.test import RequestFactory, SimpleTestCase
class StreamTsDbCleanupTests(SimpleTestCase):
def setUp(self):
self.factory = RequestFactory()
@patch("apps.proxy.live_proxy.views.close_old_connections")
@patch("apps.proxy.live_proxy.views.create_stream_generator")
@patch("apps.proxy.live_proxy.views._resolve_output_format", return_value="mpegts")
@patch("apps.proxy.live_proxy.views._resolve_output_profile", return_value=None)
@patch("apps.proxy.live_proxy.views.ChannelService.is_channel_unavailable_for_new_clients", return_value=False)
@patch("apps.proxy.live_proxy.views.get_stream_object")
@patch("apps.proxy.live_proxy.views.network_access_allowed", return_value=True)
@patch("apps.proxy.live_proxy.views.ProxyServer")
def test_stream_ts_closes_db_before_streaming_response(
self,
mock_proxy_cls,
_network_ok,
mock_get_stream_object,
_unavailable,
_output_profile,
_output_format,
mock_create_generator,
mock_close,
):
channel = MagicMock()
channel.id = 1
channel.uuid = "channel-uuid"
channel.name = "Test Channel"
mock_get_stream_object.return_value = channel
client_manager = MagicMock()
proxy_server = MagicMock()
proxy_server.redis_client = MagicMock()
proxy_server.redis_client.exists.return_value = True
proxy_server.redis_client.hgetall.return_value = {"state": "active"}
proxy_server.stream_buffers = {"channel-uuid": MagicMock()}
proxy_server.client_managers = {"channel-uuid": client_manager}
proxy_server.check_if_channel_exists.return_value = True
proxy_server.get_buffer.return_value = MagicMock()
mock_proxy_cls.get_instance.return_value = proxy_server
def _generate():
yield b"chunk"
mock_create_generator.return_value = lambda: _generate()
request = self.factory.get("/proxy/live/channel-uuid/")
request.user = MagicMock(is_authenticated=False)
from apps.proxy.live_proxy.views import stream_ts
response = stream_ts(request, "channel-uuid")
self.assertIsInstance(response, StreamingHttpResponse)
mock_close.assert_called_once()
class UrlUtilsDbCleanupTests(SimpleTestCase):
@patch("apps.proxy.live_proxy.url_utils.close_old_connections")
@patch("apps.proxy.live_proxy.url_utils.get_stream_object")
def test_generate_stream_url_closes_db(self, mock_get_object, mock_close):
channel = MagicMock()
channel.get_stream.return_value = (None, None, "no streams", False)
mock_get_object.return_value = channel
from apps.proxy.live_proxy.url_utils import generate_stream_url
result = generate_stream_url("channel-uuid")
self.assertIsNone(result[0])
mock_close.assert_called_once()
@patch("apps.proxy.live_proxy.url_utils.close_old_connections")
@patch("apps.proxy.live_proxy.url_utils.get_stream_object")
def test_get_alternate_streams_closes_db(self, mock_get_object, mock_close):
channel = MagicMock()
channel.streams.all.return_value.order_by.return_value.exists.return_value = False
mock_get_object.return_value = channel
from apps.proxy.live_proxy.url_utils import get_alternate_streams
result = get_alternate_streams("channel-uuid", current_stream_id=1)
self.assertEqual(result, [])
mock_close.assert_called_once()
@patch("apps.proxy.live_proxy.url_utils.close_old_connections")
@patch("apps.proxy.live_proxy.url_utils.get_object_or_404")
def test_get_stream_info_for_switch_closes_db_on_error(self, mock_get_404, mock_close):
mock_get_404.side_effect = RuntimeError("db error")
from apps.proxy.live_proxy.url_utils import get_stream_info_for_switch
result = get_stream_info_for_switch("channel-uuid", target_stream_id=99)
self.assertIn("error", result)
mock_close.assert_called_once()
@patch("apps.proxy.live_proxy.url_utils.close_old_connections")
@patch("apps.proxy.live_proxy.url_utils.M3UAccountProfile.objects.get")
def test_get_connections_left_closes_db(self, mock_get, mock_close):
mock_get.side_effect = Exception("not found")
from apps.proxy.live_proxy.url_utils import get_connections_left
result = get_connections_left(999)
self.assertEqual(result, 0)
mock_close.assert_called_once()
class TsGeneratorDbCleanupTests(SimpleTestCase):
@patch("apps.proxy.live_proxy.output.ts.generator.close_old_connections")
@patch("apps.proxy.live_proxy.output.ts.generator.ProxyServer.get_instance")
def test_ts_cleanup_closes_db(self, mock_proxy_cls, mock_close):
proxy_server = MagicMock()
proxy_server.redis_client = None
proxy_server.client_managers = {}
mock_proxy_cls.return_value = proxy_server
from apps.proxy.live_proxy.output.ts.generator import StreamGenerator
gen = StreamGenerator.__new__(StreamGenerator)
gen.channel_id = "channel-uuid"
gen.client_id = "client-1"
gen.stream_start_time = 0
gen.channel_name = "Test"
gen.client_ip = "127.0.0.1"
gen.client_user_agent = "agent"
gen.bytes_sent = 0
gen.user = None
gen._cleanup()
mock_close.assert_called_once()

View file

@ -0,0 +1,143 @@
"""Tests for proxy settings defaults, serializer validation, and migration 0026."""
from importlib import import_module
from unittest.mock import patch
from django.apps import apps
from django.test import SimpleTestCase, TestCase
from apps.proxy.config import TSConfig
from core.models import CoreSettings
from core.serializers import ProxySettingsSerializer
MIGRATION_0026 = import_module("core.migrations.0026_add_channel_client_wait_period")
class TSConfigProxySettingsDefaultsTests(SimpleTestCase):
@patch.object(TSConfig, "get_proxy_settings", return_value={})
def test_channel_init_grace_period_default(self, _mock_settings):
self.assertEqual(TSConfig.get_channel_init_grace_period(), 60)
@patch.object(TSConfig, "get_proxy_settings", return_value={})
def test_channel_client_wait_period_default(self, _mock_settings):
self.assertEqual(TSConfig.get_channel_client_wait_period(), 5)
@patch.object(
TSConfig,
"get_proxy_settings",
return_value={
"channel_init_grace_period": 120,
"channel_client_wait_period": 15,
},
)
def test_settings_override_db_values(self, _mock_settings):
self.assertEqual(TSConfig.get_channel_init_grace_period(), 120)
self.assertEqual(TSConfig.get_channel_client_wait_period(), 15)
class ProxySettingsSerializerTests(SimpleTestCase):
def _valid_payload(self, **overrides):
payload = {
"buffering_timeout": 15,
"buffering_speed": 1.0,
"redis_chunk_ttl": 60,
"channel_shutdown_delay": 0,
"channel_init_grace_period": 60,
"channel_client_wait_period": 5,
"new_client_behind_seconds": 5,
}
payload.update(overrides)
return payload
def test_accepts_new_client_wait_period(self):
serializer = ProxySettingsSerializer(data=self._valid_payload())
self.assertTrue(serializer.is_valid(), serializer.errors)
self.assertEqual(serializer.validated_data["channel_client_wait_period"], 5)
def test_init_grace_period_allows_up_to_300(self):
serializer = ProxySettingsSerializer(
data=self._valid_payload(channel_init_grace_period=300)
)
self.assertTrue(serializer.is_valid(), serializer.errors)
def test_init_grace_period_rejects_above_300(self):
serializer = ProxySettingsSerializer(
data=self._valid_payload(channel_init_grace_period=301)
)
self.assertFalse(serializer.is_valid())
self.assertIn("channel_init_grace_period", serializer.errors)
class CoreSettingsProxyDefaultsTests(TestCase):
def test_get_proxy_settings_defaults_when_missing(self):
CoreSettings.objects.filter(key="proxy_settings").delete()
defaults = CoreSettings.get_proxy_settings()
self.assertEqual(defaults["channel_init_grace_period"], 60)
self.assertEqual(defaults["channel_client_wait_period"], 5)
class Migration0026ProxySettingsTests(TestCase):
def _run_migration_forward(self):
MIGRATION_0026.add_channel_client_wait_period(apps, None)
def _set_proxy_settings(self, value):
settings_obj, _ = CoreSettings.objects.get_or_create(
key="proxy_settings",
defaults={"name": "Proxy Settings", "value": value},
)
settings_obj.value = value
settings_obj.save(update_fields=["value"])
return settings_obj
def test_bumps_legacy_init_grace_and_adds_client_wait(self):
settings_obj = self._set_proxy_settings(
{
"buffering_timeout": 15,
"buffering_speed": 1.0,
"redis_chunk_ttl": 60,
"channel_shutdown_delay": 0,
"channel_init_grace_period": 5,
"new_client_behind_seconds": 5,
}
)
self._run_migration_forward()
settings_obj.refresh_from_db()
self.assertEqual(settings_obj.value["channel_init_grace_period"], 60)
self.assertEqual(settings_obj.value["channel_client_wait_period"], 5)
def test_bumps_init_grace_below_new_default(self):
settings_obj = self._set_proxy_settings(
{
"buffering_timeout": 15,
"buffering_speed": 1.0,
"redis_chunk_ttl": 60,
"channel_shutdown_delay": 0,
"channel_init_grace_period": 45,
"new_client_behind_seconds": 5,
}
)
self._run_migration_forward()
settings_obj.refresh_from_db()
self.assertEqual(settings_obj.value["channel_init_grace_period"], 60)
def test_preserves_init_grace_at_or_above_new_default(self):
settings_obj = self._set_proxy_settings(
{
"buffering_timeout": 15,
"buffering_speed": 1.0,
"redis_chunk_ttl": 60,
"channel_shutdown_delay": 0,
"channel_init_grace_period": 90,
"new_client_behind_seconds": 5,
}
)
self._run_migration_forward()
settings_obj.refresh_from_db()
self.assertEqual(settings_obj.value["channel_init_grace_period"], 90)
self.assertEqual(settings_obj.value["channel_client_wait_period"], 5)

View file

@ -5,9 +5,14 @@ Utilities for handling stream URLs and transformations.
import logging
import regex
from typing import Optional, Tuple, List
from django.db import close_old_connections
from django.shortcuts import get_object_or_404
from apps.channels.models import Channel, Stream
from apps.m3u.models import M3UAccount, M3UAccountProfile
from apps.m3u.connection_pool import (
get_profile_connection_count,
profile_available_for_channel_switch,
)
from core.models import UserAgent, CoreSettings, StreamProfile
from .utils import get_logger
from uuid import UUID
@ -15,6 +20,35 @@ import requests
logger = get_logger()
def _resolve_live_stream_url(stream, m3u_account, m3u_profile):
"""
Build the upstream URL for live playback.
XC accounts use current transformed credentials plus provider stream_id so
playback matches the account login (not a stale stream.url from an old sync).
STD/M3U accounts keep using the URL stored on the stream row.
"""
if (
m3u_account.account_type == M3UAccount.Types.XC
and stream.stream_id
):
from apps.m3u.tasks import get_transformed_credentials
server_url, username, password = get_transformed_credentials(
m3u_account, m3u_profile
)
if server_url and username and password:
base = server_url.rstrip("/")
return f"{base}/live/{username}/{password}/{stream.stream_id}.ts"
return transform_url(
stream.url or "",
m3u_profile.search_pattern,
m3u_profile.replace_pattern,
)
def get_stream_object(id: str):
try:
logger.info(f"Fetching channel ID {id}")
@ -24,15 +58,14 @@ def get_stream_object(id: str):
logger.info(f"Fetching stream hash {id}")
return get_object_or_404(Stream, stream_hash=id)
def generate_stream_url(channel_id: str) -> Tuple[str, str, bool, Optional[int]]:
def generate_stream_url(
channel_id: str,
) -> Tuple[str, str, bool, Optional[int], bool, Optional[str]]:
"""
Generate the appropriate stream URL for a channel or stream based on its profile settings.
Args:
channel_id: The UUID of the channel or stream hash
Returns:
Tuple[str, str, bool, Optional[int]]: (stream_url, user_agent, transcode_flag, profile_id)
Tuple: (stream_url, user_agent, transcode_flag, profile_id, slot_reserved, error_reason)
"""
try:
channel_or_stream = get_stream_object(channel_id)
@ -44,15 +77,12 @@ def generate_stream_url(channel_id: str) -> Tuple[str, str, bool, Optional[int]]
if not stream.m3u_account:
logger.error(f"Stream {stream.id} has no M3U account")
return None, None, False, None
return None, None, False, None, False, "Stream has no M3U account"
# Use get_stream() to atomically reserve a slot and write the
# channel_stream / stream_profile Redis keys, matching the channel
# path so stream_name and stream_stats work correctly.
stream_id, profile_id, error_reason = stream.get_stream()
stream_id, profile_id, error_reason, slot_reserved = stream.get_stream()
if not stream_id or not profile_id:
logger.error(f"No profile available for stream {stream.id}: {error_reason}")
return None, None, False, None
return None, None, False, None, False, error_reason
try:
profile = M3UAccountProfile.objects.get(id=profile_id)
@ -63,7 +93,7 @@ def generate_stream_url(channel_id: str) -> Tuple[str, str, bool, Optional[int]]
stream_user_agent = UserAgent.objects.get(id=CoreSettings.get_default_user_agent_id())
logger.debug(f"No user agent found for account, using default: {stream_user_agent}")
stream_url = transform_url(stream.url, profile.search_pattern, profile.replace_pattern)
stream_url = _resolve_live_stream_url(stream, m3u_account, profile)
stream_profile = stream.get_stream_profile()
logger.debug(f"Using stream profile: {stream_profile.name}")
@ -71,23 +101,23 @@ def generate_stream_url(channel_id: str) -> Tuple[str, str, bool, Optional[int]]
transcode = not stream_profile.is_proxy()
stream_profile_id = stream_profile.id
return stream_url, stream_user_agent, transcode, stream_profile_id
return stream_url, stream_user_agent, transcode, stream_profile_id, slot_reserved, None
except Exception as e:
logger.error(f"Error generating stream URL for stream {stream.id}: {e}")
stream.release_stream()
return None, None, False, None
if slot_reserved:
stream.release_stream()
return None, None, False, None, False, str(e)
# Handle channel preview (existing logic)
channel = channel_or_stream
# Get stream and profile for this channel
# Note: get_stream now returns 3 values (stream_id, profile_id, error_reason)
stream_id, profile_id, error_reason = channel.get_stream()
stream_id, profile_id, error_reason, slot_reserved = channel.get_stream()
if not stream_id or not profile_id:
logger.error(f"No stream available for channel {channel_id}: {error_reason}")
return None, None, False, None
return None, None, False, None, False, error_reason
# get_stream() allocated a connection slot - ensure it's released on any error
try:
@ -106,9 +136,7 @@ def generate_stream_url(channel_id: str) -> Tuple[str, str, bool, Optional[int]]
stream_user_agent = UserAgent.objects.get(id=CoreSettings.get_default_user_agent_id())
logger.debug(f"No user agent found for account, using default: {stream_user_agent}")
# Generate stream URL based on the selected profile
input_url = stream.url
stream_url = transform_url(input_url, m3u_profile.search_pattern, m3u_profile.replace_pattern)
stream_url = _resolve_live_stream_url(stream, m3u_account, m3u_profile)
# Check if transcoding is needed
stream_profile = channel.get_stream_profile()
@ -119,15 +147,18 @@ def generate_stream_url(channel_id: str) -> Tuple[str, str, bool, Optional[int]]
stream_profile_id = stream_profile.id
return stream_url, stream_user_agent, transcode, stream_profile_id
return stream_url, stream_user_agent, transcode, stream_profile_id, slot_reserved, None
except Exception as e:
logger.error(f"Error generating stream URL for channel {channel_id}: {e}")
if not channel.release_stream():
logger.warning(f"Failed to release stream for channel {channel_id} after URL generation error")
return None, None, False, None
if slot_reserved:
if not channel.release_stream():
logger.warning(f"Failed to release stream for channel {channel_id} after URL generation error")
return None, None, False, None, False, str(e)
except Exception as e:
logger.error(f"Error generating stream URL: {e}")
return None, None, False, None
return None, None, False, None, False, str(e)
finally:
close_old_connections()
def transform_url(input_url: str, search_pattern: str, replace_pattern: str) -> str:
"""
@ -175,6 +206,8 @@ def get_stream_info_for_switch(channel_id: str, target_stream_id: Optional[int]
Returns:
dict: Stream information including URL, user agent and transcode flag
"""
slot_reserved = False
channel = None
try:
from core.utils import RedisClient
@ -204,35 +237,34 @@ def get_stream_info_for_switch(channel_id: str, target_stream_id: Optional[int]
selected_profile = None
for profile in profiles:
# Check connection availability
if redis_client:
profile_connections_key = f"profile_connections:{profile.id}"
current_connections = int(redis_client.get(profile_connections_key) or 0)
# Check if this channel is already using this profile
channel_using_profile = False
existing_stream_id = redis_client.get(f"channel_stream:{channel.id}")
if existing_stream_id:
# Decode bytes to string/int for proper Redis key lookup
existing_stream_id = existing_stream_id
existing_profile_id = redis_client.get(f"stream_profile:{existing_stream_id}")
existing_profile_id = redis_client.get(
f"stream_profile:{existing_stream_id}"
)
if existing_profile_id and int(existing_profile_id) == profile.id:
channel_using_profile = True
logger.debug(f"Channel {channel.id} already using profile {profile.id}")
# Calculate effective connections (subtract 1 if channel already using this profile)
effective_connections = current_connections - (1 if channel_using_profile else 0)
# Check if profile has available slots
if profile.max_streams == 0 or effective_connections < profile.max_streams:
if profile_available_for_channel_switch(
profile,
redis_client,
channel_already_on_profile=channel_using_profile,
):
current_connections = get_profile_connection_count(
profile, redis_client
)
selected_profile = profile
logger.debug(f"Selected profile {profile.id} with {effective_connections}/{profile.max_streams} effective connections (current: {current_connections}, already using: {channel_using_profile})")
logger.debug(
f"Selected profile {profile.id} with "
f"{current_connections}/{profile.max_streams} connections"
)
break
else:
logger.debug(f"Profile {profile.id} at max connections: {effective_connections}/{profile.max_streams} (current: {current_connections}, already using: {channel_using_profile})")
logger.debug(
f"Profile {profile.id} unavailable for channel switch"
)
else:
# No Redis available, assume first active profile is okay
selected_profile = profile
break
@ -241,29 +273,18 @@ def get_stream_info_for_switch(channel_id: str, target_stream_id: Optional[int]
m3u_profile_id = selected_profile.id
else:
stream_id, m3u_profile_id, error_reason = channel.get_stream()
stream_id, m3u_profile_id, error_reason, slot_reserved = channel.get_stream()
if stream_id is None or m3u_profile_id is None:
return {'error': error_reason or 'No stream assigned to channel'}
# Get the stream and profile objects directly
stream = get_object_or_404(Stream, pk=stream_id)
profile = get_object_or_404(M3UAccountProfile, pk=m3u_profile_id)
# Check connections left
m3u_account = M3UAccount.objects.get(id=profile.m3u_account.id)
#connections_left = get_connections_left(m3u_profile_id)
#if connections_left <= 0:
#logger.warning(f"No connections left for M3U account {m3u_account.id}")
#return {'error': 'No connections left'}
# Get the user agent from the M3U account
user_agent = m3u_account.get_user_agent().user_agent
# Generate URL using the transform function directly
stream_url = transform_url(stream.url, profile.search_pattern, profile.replace_pattern)
stream_url = _resolve_live_stream_url(stream, m3u_account, profile)
# Get transcode info from the channel's stream profile
stream_profile = channel.get_stream_profile()
transcode = not (stream_profile.is_proxy() or stream_profile is None)
profile_value = stream_profile.id
@ -278,8 +299,12 @@ def get_stream_info_for_switch(channel_id: str, target_stream_id: Optional[int]
'stream_name': stream.name,
}
except Exception as e:
if slot_reserved and channel is not None:
channel.release_stream()
logger.error(f"Error getting stream info for switch: {e}", exc_info=True)
return {'error': f'Error: {str(e)}'}
finally:
close_old_connections()
def get_alternate_streams(channel_id: str, current_stream_id: Optional[int] = None) -> List[dict]:
"""
@ -344,34 +369,38 @@ def get_alternate_streams(channel_id: str, current_stream_id: Optional[int] = No
selected_profile = None
for profile in profiles:
# Check connection availability
if redis_client:
profile_connections_key = f"profile_connections:{profile.id}"
current_connections = int(redis_client.get(profile_connections_key) or 0)
# Check if this channel is already using this profile
channel_using_profile = False
existing_stream_id = redis_client.get(f"channel_stream:{channel.id}")
if existing_stream_id:
# Decode bytes to string/int for proper Redis key lookup
existing_stream_id = existing_stream_id
existing_profile_id = redis_client.get(f"stream_profile:{existing_stream_id}")
existing_profile_id = redis_client.get(
f"stream_profile:{existing_stream_id}"
)
if existing_profile_id and int(existing_profile_id) == profile.id:
channel_using_profile = True
logger.debug(f"Channel {channel.id} already using profile {profile.id}")
logger.debug(
f"Channel {channel.id} already using profile {profile.id}"
)
# Calculate effective connections (subtract 1 if channel already using this profile)
effective_connections = current_connections - (1 if channel_using_profile else 0)
# Check if profile has available slots
if profile.max_streams == 0 or effective_connections < profile.max_streams:
if profile_available_for_channel_switch(
profile,
redis_client,
channel_already_on_profile=channel_using_profile,
):
current_connections = get_profile_connection_count(
profile, redis_client
)
selected_profile = profile
logger.debug(f"Found available profile {profile.id} for stream {stream.id}: {effective_connections}/{profile.max_streams} effective (current: {current_connections}, already using: {channel_using_profile})")
logger.debug(
f"Found available profile {profile.id} for stream {stream.id}: "
f"{current_connections}/{profile.max_streams} "
f"(already using: {channel_using_profile})"
)
break
else:
logger.debug(f"Profile {profile.id} at max connections: {effective_connections}/{profile.max_streams} (current: {current_connections}, already using: {channel_using_profile})")
logger.debug(
f"Profile {profile.id} unavailable for alternate stream {stream.id}"
)
else:
# No Redis available, assume first active profile is okay
selected_profile = profile
break
@ -398,6 +427,8 @@ def get_alternate_streams(channel_id: str, current_stream_id: Optional[int] = No
except Exception as e:
logger.error(f"Error getting alternate streams for channel {channel_id}: {e}", exc_info=True)
return []
finally:
close_old_connections()
def validate_stream_url(url, user_agent=None, timeout=(5, 5)):
"""
@ -568,3 +599,5 @@ def get_connections_left(m3u_profile_id: int) -> int:
except Exception as e:
logger.error(f"Error getting connections left for M3U profile {m3u_profile_id}: {e}")
return 0
finally:
close_old_connections()

View file

@ -3,6 +3,7 @@ import time
import random
import re
import pathlib
from django.db import close_old_connections
from django.http import StreamingHttpResponse, JsonResponse, HttpResponseRedirect, HttpResponse
from django.views.decorators.csrf import csrf_exempt
from django.shortcuts import get_object_or_404
@ -43,6 +44,15 @@ from apps.proxy.utils import check_user_stream_limits
logger = get_logger()
def _channel_stopping_response():
response = JsonResponse(
{"error": "Channel is stopping, retry shortly"},
status=503,
)
response["Retry-After"] = "1"
return response
def _resolve_output_format(user, force=None, request=None):
"""Return the output format string to use for this client."""
_FORMAT_ALIASES = {
@ -123,6 +133,12 @@ def stream_ts(request, channel_id, user=None, force_output_format=None):
status=429
)
if ChannelService.is_channel_unavailable_for_new_clients(channel_id):
logger.info(
f"[{client_id}] Channel {channel_id} unavailable. Teardown or pending shutdown"
)
return _channel_stopping_response()
# Check if we need to reinitialize the channel
needs_initialization = True
channel_state = None
@ -144,7 +160,6 @@ def stream_ts(request, channel_id, user=None, force_output_format=None):
ChannelState.BUFFERING,
ChannelState.INITIALIZING,
ChannelState.CONNECTING,
ChannelState.STOPPING,
]:
needs_initialization = False
logger.debug(
@ -160,6 +175,11 @@ def stream_ts(request, channel_id, user=None, force_output_format=None):
logger.debug(
f"[{client_id}] Channel {channel_id} is still initializing, client will wait"
)
elif channel_state == ChannelState.STOPPING:
logger.info(
f"[{client_id}] Channel {channel_id} is stopping, rejecting request"
)
return _channel_stopping_response()
# Terminal states - channel needs cleanup before reinitialization
elif channel_state in [
ChannelState.ERROR,
@ -192,6 +212,12 @@ def stream_ts(request, channel_id, user=None, force_output_format=None):
# Start initialization if needed
if needs_initialization or not proxy_server.check_if_channel_exists(channel_id):
if ChannelService.is_channel_unavailable_for_new_clients(channel_id):
logger.info(
f"[{client_id}] Channel {channel_id} became unavailable before init, rejecting"
)
return _channel_stopping_response()
logger.info(f"[{client_id}] Starting channel {channel_id} initialization")
# Force cleanup of any previous instance if in terminal state
if channel_state in [
@ -213,6 +239,7 @@ def stream_ts(request, channel_id, user=None, force_output_format=None):
stream_user_agent = None
transcode = False
profile_value = None
slot_reserved = False
error_reason = None
attempt = 0
should_retry = True
@ -220,9 +247,14 @@ def stream_ts(request, channel_id, user=None, force_output_format=None):
# Try to get a stream with fixed interval retries
while should_retry and time.time() - wait_start_time < retry_timeout:
attempt += 1
stream_url, stream_user_agent, transcode, profile_value = (
generate_stream_url(channel_id)
)
(
stream_url,
stream_user_agent,
transcode,
profile_value,
slot_reserved,
error_reason,
) = generate_stream_url(channel_id)
if stream_url is not None:
logger.info(
@ -232,7 +264,6 @@ def stream_ts(request, channel_id, user=None, force_output_format=None):
# On first failure, check if the error is retryable
if attempt == 1:
_, _, error_reason = channel.get_stream()
if error_reason and "maximum connection limits" not in error_reason:
logger.warning(
f"[{client_id}] Can't retry - error not related to connection limits: {error_reason}"
@ -265,18 +296,21 @@ def stream_ts(request, channel_id, user=None, force_output_format=None):
logger.info(
f"[{client_id}] Making final attempt {attempt} at timeout boundary"
)
stream_url, stream_user_agent, transcode, profile_value = (
generate_stream_url(channel_id)
)
(
stream_url,
stream_user_agent,
transcode,
profile_value,
slot_reserved,
error_reason,
) = generate_stream_url(channel_id)
if stream_url is not None:
logger.info(
f"[{client_id}] Successfully obtained stream on final attempt for channel {channel_id}"
)
if stream_url is None:
# Release any connection slot that may have been allocated
# by the error-checking get_stream() call during retries
if not channel.release_stream():
if slot_reserved and not channel.release_stream():
logger.debug(f"[{client_id}] release_stream found no keys during failed init cleanup")
# Get the specific error message if available
@ -295,7 +329,7 @@ def stream_ts(request, channel_id, user=None, force_output_format=None):
# generate_stream_url() called get_stream() which allocated a connection
# slot (INCR'd profile_connections) - track this for cleanup on error
if needs_initialization:
if needs_initialization and slot_reserved:
connection_allocated = True
# Read stream assignment from Redis (already set by generate_stream_url → get_stream).
@ -378,8 +412,8 @@ def stream_ts(request, channel_id, user=None, force_output_format=None):
logger.warning(
f"[{client_id}] Alternate stream #{alt['stream_id']} failed validation: {message}"
)
# Release stream lock before redirecting
if not channel.release_stream():
# Release stream lock before redirecting only if we reserved a slot
if connection_allocated and not channel.release_stream():
logger.warning(f"[{client_id}] Failed to release stream before redirect")
connection_allocated = False
# Final decision based on validation results
@ -406,6 +440,16 @@ def stream_ts(request, channel_id, user=None, force_output_format=None):
) # 502 Bad Gateway
# Initialize channel with the stream's user agent (not the client's)
if ChannelService.is_channel_unavailable_for_new_clients(channel_id):
if connection_allocated:
if not channel.release_stream():
logger.warning(f"[{client_id}] Failed to release stream before teardown reject")
connection_allocated = False
logger.info(
f"[{client_id}] Channel {channel_id} unavailable before init call, rejecting"
)
return _channel_stopping_response()
success = ChannelService.initialize_channel(
channel_id,
stream_url,
@ -514,6 +558,16 @@ def stream_ts(request, channel_id, user=None, force_output_format=None):
f"[{client_id}] Successfully initialized channel {channel_id} locally"
)
if ChannelService.is_channel_unavailable_for_new_clients(channel_id):
if _client_pre_registered:
mgr = proxy_server.client_managers.get(channel_id)
if mgr:
mgr.remove_client(client_id)
logger.info(
f"[{client_id}] Channel {channel_id} became unavailable during setup, rejecting"
)
return _channel_stopping_response()
# Register client
output_profile = _resolve_output_profile(request, user)
if output_profile:
@ -564,7 +618,9 @@ def stream_ts(request, channel_id, user=None, force_output_format=None):
)
content_type = "video/mp2t"
# Return the StreamingHttpResponse from the main function
# Release ORM checkout before returning a long-lived StreamingHttpResponse.
close_old_connections()
response = StreamingHttpResponse(
streaming_content=generate(), content_type=content_type
)

View file

@ -735,68 +735,35 @@ class MultiWorkerVODConnectionManager:
"""Get Redis key for tracking connections per profile - STANDARDIZED with TS proxy"""
return f"profile_connections:{profile_id}"
def _check_profile_limits(self, m3u_profile) -> bool:
"""Check if profile has available connection slots"""
if m3u_profile.max_streams == 0: # Unlimited
return True
try:
profile_connections_key = self._get_profile_connections_key(m3u_profile.id)
current_connections = int(self.redis_client.get(profile_connections_key) or 0)
logger.info(f"[PROFILE-CHECK] Profile {m3u_profile.id} has {current_connections}/{m3u_profile.max_streams} connections")
return current_connections < m3u_profile.max_streams
except Exception as e:
logger.error(f"Error checking profile limits: {e}")
return False
def _check_and_reserve_profile_slot(self, m3u_profile) -> bool:
"""
Atomically check and reserve a connection slot for the given profile.
Uses an INCR-first-then-check pattern to eliminate the TOCTOU race
condition where separate GET > check > INCR operations could allow
concurrent requests to both pass the capacity check.
For profiles with max_streams=0 (unlimited), no reservation is needed.
Returns:
bool: True if slot was reserved (or unlimited), False if at capacity
"""
if m3u_profile.max_streams == 0: # Unlimited
return True
from apps.m3u.connection_pool import reserve_profile_slot
try:
profile_connections_key = self._get_profile_connections_key(m3u_profile.id)
# Atomically increment first — single Redis command eliminates race window
new_count = self.redis_client.incr(profile_connections_key)
if new_count <= m3u_profile.max_streams:
logger.info(f"[PROFILE-RESERVE] Profile {m3u_profile.id} slot reserved: {new_count}/{m3u_profile.max_streams}")
return True
# Over capacity — roll back the increment
self.redis_client.decr(profile_connections_key)
logger.info(f"[PROFILE-RESERVE] Profile {m3u_profile.id} at capacity: {new_count - 1}/{m3u_profile.max_streams}")
return False
reserved, new_count, _failure_reason = reserve_profile_slot(
m3u_profile, self.redis_client
)
if reserved:
logger.info(
f"[PROFILE-RESERVE] Profile {m3u_profile.id} slot reserved: "
f"{new_count}/{m3u_profile.max_streams}"
)
else:
logger.info(
f"[PROFILE-RESERVE] Profile {m3u_profile.id} at capacity: "
f"{new_count}/{m3u_profile.max_streams}"
)
return reserved
except Exception as e:
logger.error(f"Error reserving profile slot: {e}")
return False
def _increment_profile_connections(self, m3u_profile):
"""Increment profile connection count"""
try:
profile_connections_key = self._get_profile_connections_key(m3u_profile.id)
new_count = self.redis_client.incr(profile_connections_key)
logger.info(f"[PROFILE-INCR] Profile {m3u_profile.id} connections: {new_count}")
return new_count
except Exception as e:
logger.error(f"Error incrementing profile connections: {e}")
return None
def _trigger_vod_stats_update(self):
"""Trigger a VOD stats WebSocket update in a background thread."""
threading.Thread(target=self._do_vod_stats_update, daemon=True).start()
@ -859,21 +826,14 @@ class MultiWorkerVODConnectionManager:
logger.error(f"Failed to trigger VOD stats update: {e}")
def _decrement_profile_connections(self, m3u_profile_id: int):
"""Decrement profile connection count.
"""Decrement profile and shared pool connection counters."""
from apps.m3u.connection_pool import release_profile_slot
Uses a single atomic DECR (no GET-before-DECR) to avoid the race condition
where two concurrent decrements both pass a >0 guard and both fire, sending
the counter negative. If the counter would go below zero it is clamped to 0.
"""
try:
profile_connections_key = self._get_profile_connections_key(m3u_profile_id)
new_count = self.redis_client.decr(profile_connections_key)
if new_count < 0:
self.redis_client.set(profile_connections_key, 0)
new_count = 0
logger.warning(f"[PROFILE-DECR] Profile {m3u_profile_id} counter went negative, clamped to 0")
else:
logger.info(f"[PROFILE-DECR] Profile {m3u_profile_id} connections: {new_count}")
release_profile_slot(m3u_profile_id, self.redis_client)
profile_key = self._get_profile_connections_key(m3u_profile_id)
new_count = int(self.redis_client.get(profile_key) or 0)
logger.info(f"[PROFILE-DECR] Profile {m3u_profile_id} connections: {new_count}")
return new_count
except Exception as e:
logger.error(f"Error decrementing profile connections: {e}")

View file

@ -0,0 +1,146 @@
"""VOD proxy must release geventpool checkouts after ORM on stream and stats paths."""
from unittest.mock import MagicMock, patch
from django.http import StreamingHttpResponse
from django.test import RequestFactory, SimpleTestCase
class StreamVodDbCleanupTests(SimpleTestCase):
def setUp(self):
self.factory = RequestFactory()
@patch("apps.proxy.vod_proxy.views.close_old_connections")
@patch("apps.proxy.vod_proxy.views.MultiWorkerVODConnectionManager")
@patch("apps.proxy.vod_proxy.views._transform_url", return_value="http://example.com/movie.mp4")
@patch("apps.proxy.vod_proxy.views._get_m3u_profile")
@patch("apps.proxy.vod_proxy.views._get_stream_url_from_relation", return_value="http://upstream/movie.mp4")
@patch("apps.proxy.vod_proxy.views._get_content_and_relation")
@patch("apps.proxy.vod_proxy.views.network_access_allowed", return_value=True)
def test_stream_vod_closes_db_before_streaming_response(
self,
_network_ok,
mock_content,
_stream_url,
mock_profile,
_transform,
mock_manager_cls,
mock_close,
):
movie = MagicMock()
movie.name = "Test Movie"
relation = MagicMock()
relation.m3u_account.name = "Provider"
mock_content.return_value = (movie, relation)
profile = MagicMock()
profile.id = 1
profile.max_streams = 5
mock_profile.return_value = (profile, 0)
mock_manager = MagicMock()
mock_manager.stream_content_with_session.return_value = StreamingHttpResponse(
streaming_content=iter([b"data"]),
content_type="video/mp4",
)
mock_manager_cls.get_instance.return_value = mock_manager
request = self.factory.get(
"/proxy/vod/movie/uuid/session123/",
HTTP_USER_AGENT="test-agent",
)
request.user = MagicMock(is_authenticated=False)
from apps.proxy.vod_proxy.views import stream_vod
response = stream_vod(
request,
content_type="movie",
content_id="uuid",
session_id="session123",
)
self.assertIsInstance(response, StreamingHttpResponse)
mock_close.assert_called_once()
mock_manager.stream_content_with_session.assert_called_once()
class BuildVodStatsDbCleanupTests(SimpleTestCase):
@patch("apps.proxy.vod_proxy.views.close_old_connections")
@patch("apps.proxy.vod_proxy.views.Movie")
def test_build_vod_stats_data_closes_db(self, mock_movie, mock_close):
redis_client = MagicMock()
redis_client.scan.side_effect = [
(0, ["vod_persistent_connection:s1"]),
]
redis_client.hgetall.return_value = {
"content_obj_type": "movie",
"content_uuid": "movie-uuid",
"content_name": "Test Movie",
"m3u_profile_id": "1",
"client_ip": "127.0.0.1",
"client_user_agent": "agent",
"connected_at": "1000.0",
"last_activity": "1001.0",
"active_streams": "1",
}
movie_obj = MagicMock(
name="Test Movie",
logo=None,
year=2020,
rating=7.5,
genre="Action",
description="Desc",
tmdb_id="1",
imdb_id="tt1",
)
mock_movie.objects.select_related.return_value.get.return_value = movie_obj
with patch("apps.m3u.models.M3UAccountProfile") as mock_profile_model:
mock_profile_model.objects.select_related.return_value.get.return_value = MagicMock(
name="Profile 1",
m3u_account=MagicMock(name="Account", id=1),
)
from apps.proxy.vod_proxy.views import build_vod_stats_data
stats = build_vod_stats_data(redis_client)
self.assertEqual(stats["total_connections"], 1)
mock_close.assert_called_once()
@patch("apps.proxy.vod_proxy.views.close_old_connections")
def test_build_vod_stats_data_closes_db_on_error(self, mock_close):
redis_client = MagicMock()
redis_client.scan.side_effect = RuntimeError("redis down")
from apps.proxy.vod_proxy.views import build_vod_stats_data
stats = build_vod_stats_data(redis_client)
self.assertEqual(stats["total_connections"], 0)
mock_close.assert_called_once()
class VodStatsUpdateDbCleanupTests(SimpleTestCase):
@patch("core.utils.send_websocket_update")
@patch("apps.proxy.vod_proxy.views.build_vod_stats_data")
def test_do_vod_stats_update_uses_build_vod_stats_data(self, mock_build, mock_ws):
mock_build.return_value = {
"vod_connections": [],
"total_connections": 0,
"timestamp": 0,
}
from apps.proxy.vod_proxy.multi_worker_connection_manager import (
MultiWorkerVODConnectionManager,
)
manager = MultiWorkerVODConnectionManager.__new__(MultiWorkerVODConnectionManager)
manager.redis_client = MagicMock()
manager._do_vod_stats_update()
mock_build.assert_called_once_with(manager.redis_client)
mock_ws.assert_called_once()

View file

@ -8,6 +8,7 @@ import random
import logging
import requests
from urllib.parse import urlencode
from django.db import close_old_connections
from django.http import JsonResponse, Http404, HttpResponse
from django.shortcuts import get_object_or_404
from django.views.decorators.csrf import csrf_exempt
@ -23,6 +24,7 @@ from rest_framework_simplejwt.authentication import JWTAuthentication
from apps.accounts.authentication import ApiKeyAuthentication, QueryParamJWTAuthentication
from apps.proxy.utils import check_user_stream_limits
from dispatcharr.utils import network_access_allowed
from core.utils import dispatcharr_user_agent
logger = logging.getLogger(__name__)
@ -260,6 +262,10 @@ def _get_m3u_profile(m3u_account, profile_id, session_id=None):
"""
try:
from core.utils import RedisClient
from apps.m3u.connection_pool import (
get_profile_connection_count,
pool_has_capacity_for_profile,
)
redis_client = RedisClient.get_client()
if not redis_client:
@ -296,7 +302,11 @@ def _get_m3u_profile(m3u_account, profile_id, session_id=None):
except Exception as e:
logger.warning(f"[PROFILE-SELECTION] Error checking existing profile for session {session_id}: {e}")
else:
logger.debug(f"[PROFILE-SELECTION] Session {session_id} exists but has no profile ID stored") # If specific profile requested, try to use it
logger.debug(
f"[PROFILE-SELECTION] Session {session_id} exists but has no profile ID stored"
)
# If specific profile requested, try to use it
if profile_id:
try:
profile = M3UAccountProfile.objects.get(
@ -306,13 +316,12 @@ def _get_m3u_profile(m3u_account, profile_id, session_id=None):
)
# Check Redis-based current connections
profile_connections_key = f"profile_connections:{profile.id}"
current_connections = int(redis_client.get(profile_connections_key) or 0)
current_connections = get_profile_connection_count(profile, redis_client)
if profile.max_streams == 0 or current_connections < profile.max_streams:
if pool_has_capacity_for_profile(profile, redis_client):
logger.info(f"[PROFILE-SELECTION] Using requested profile {profile.id}: {current_connections}/{profile.max_streams} connections")
return (profile, current_connections)
else:
logger.warning(f"[PROFILE-SELECTION] Requested profile {profile.id} is at capacity: {current_connections}/{profile.max_streams}")
logger.warning(f"[PROFILE-SELECTION] Requested profile {profile.id} is at capacity: {current_connections}/{profile.max_streams}")
except M3UAccountProfile.DoesNotExist:
logger.warning(f"[PROFILE-SELECTION] Requested profile {profile_id} not found")
@ -331,15 +340,16 @@ def _get_m3u_profile(m3u_account, profile_id, session_id=None):
profiles = [default_profile] + list(m3u_profiles.filter(is_default=False))
for profile in profiles:
profile_connections_key = f"profile_connections:{profile.id}"
current_connections = int(redis_client.get(profile_connections_key) or 0)
current_connections = get_profile_connection_count(profile, redis_client)
# Check if profile has available connection slots
if profile.max_streams == 0 or current_connections < profile.max_streams:
if pool_has_capacity_for_profile(profile, redis_client):
logger.info(f"[PROFILE-SELECTION] Selected profile {profile.id} ({profile.name}): {current_connections}/{profile.max_streams} connections")
return (profile, current_connections)
else:
logger.debug(f"[PROFILE-SELECTION] Profile {profile.id} at capacity: {current_connections}/{profile.max_streams}")
logger.debug(
f"[PROFILE-SELECTION] Profile {profile.id} unavailable "
f"(profile={current_connections}/{profile.max_streams})"
)
# All profiles are at capacity - return None to trigger error response
logger.error(f"[PROFILE-SELECTION] All profiles at capacity for M3U account {m3u_account.id}, rejecting request")
@ -592,6 +602,9 @@ def stream_vod(request, content_type, content_id, session_id=None, profile_id=No
# Get connection manager (Redis-backed for multi-worker support)
connection_manager = MultiWorkerVODConnectionManager.get_instance()
# Release ORM checkout before returning a long-lived StreamingHttpResponse.
close_old_connections()
# Stream the content with session-based connection reuse
logger.info("[VOD-STREAM] Calling connection manager to stream content")
response = connection_manager.stream_content_with_session(
@ -696,7 +709,7 @@ def head_vod(request, content_type, content_id, session_id=None, profile_id=None
# Use M3U account's user agent as primary, client user agent as fallback
m3u_user_agent = m3u_account.get_user_agent().user_agent if m3u_account.get_user_agent() else None
headers = {
'User-Agent': m3u_user_agent or client_user_agent or 'Dispatcharr/1.0',
'User-Agent': m3u_user_agent or client_user_agent or dispatcharr_user_agent(),
'Accept': '*/*',
'Range': 'bytes=0-1' # Request only first 2 bytes
}
@ -906,13 +919,14 @@ def build_vod_stats_data(redis_client):
if m3u_profile_id:
try:
from apps.m3u.models import M3UAccountProfile
profile = M3UAccountProfile.objects.select_related('m3u_account').get(id=m3u_profile_id)
m3u_profile_info = {
'profile_name': profile.name,
'account_name': profile.m3u_account.name,
'account_id': profile.m3u_account.id,
'max_streams': profile.m3u_account.max_streams,
'm3u_profile_id': int(m3u_profile_id)
'm3u_profile_id': int(m3u_profile_id),
}
except Exception as e:
logger.warning(f"Could not fetch M3U profile {m3u_profile_id}: {e}")
@ -1053,6 +1067,8 @@ def build_vod_stats_data(redis_client):
except Exception as e:
logger.error(f"Error building VOD stats: {e}")
return {'vod_connections': [], 'total_connections': 0, 'timestamp': time.time()}
finally:
close_old_connections()
@api_view(["GET"])

View file

@ -243,12 +243,12 @@ class M3UMovieRelation(models.Model):
def get_stream_url(self):
"""Get the full stream URL for this movie from this provider"""
# Build URL dynamically for XtreamCodes accounts
if self.m3u_account.account_type == 'XC':
from core.xtream_codes import Client as XCClient
# Use XC client's URL normalization to handle malformed URLs
# (e.g., URLs with /player_api.php or query parameters)
normalized_url = XCClient(self.m3u_account.server_url, '', '')._normalize_url(self.m3u_account.server_url)
from core.xtream_codes import normalize_server_url
normalized_url = normalize_server_url(self.m3u_account.server_url)
if not normalized_url:
return None
username = self.m3u_account.username
password = self.m3u_account.password
return f"{normalized_url}/movie/{username}/{password}/{self.stream_id}.{self.container_extension or 'mp4'}"
@ -292,13 +292,12 @@ class M3UEpisodeRelation(models.Model):
def get_stream_url(self):
"""Get the full stream URL for this episode from this provider"""
from core.xtream_codes import Client as XtreamCodesClient
if self.m3u_account.account_type == 'XC':
# For XtreamCodes accounts, build the URL dynamically
# Use XC client's URL normalization to handle malformed URLs
# (e.g., URLs with /player_api.php or query parameters)
normalized_url = XtreamCodesClient(self.m3u_account.server_url, '', '')._normalize_url(self.m3u_account.server_url)
from core.xtream_codes import normalize_server_url
normalized_url = normalize_server_url(self.m3u_account.server_url)
if not normalized_url:
return None
username = self.m3u_account.username
password = self.m3u_account.password
return f"{normalized_url}/series/{username}/{password}/{self.stream_id}.{self.container_extension or 'mp4'}"

View file

@ -16,6 +16,27 @@ import re
logger = logging.getLogger(__name__)
def lookup_by_name_year(model, name_year_pairs):
"""Return {(name, year): row} for rows without TMDB/IMDB IDs.
Scoped to the names in this batch instead of scanning the full table.
"""
if not name_year_pairs:
return {}
wanted = set(name_year_pairs)
names = {name for name, _ in wanted}
found = {}
for row in model.objects.filter(
tmdb_id__isnull=True,
imdb_id__isnull=True,
name__in=names,
):
key = (row.name, row.year)
if key in wanted:
found[key] = row
return found
@shared_task
def refresh_vod_content(account_id):
"""Refresh VOD content for an M3U account with batch processing for improved performance"""
@ -176,6 +197,7 @@ def refresh_movies(client, account, categories_by_provider, relations, scan_star
logger.info(f"Processing movie chunk {chunk_num}/{total_chunks} ({len(chunk)} movies)")
process_movie_batch(account, chunk, categories_by_provider, relations, scan_start_time)
del all_movies_data
logger.info(f"Completed processing all {total_movies} movies in {total_chunks} chunks")
@ -230,6 +252,7 @@ def refresh_series(client, account, categories_by_provider, relations, scan_star
logger.info(f"Processing series chunk {chunk_num}/{total_chunks} ({len(chunk)} series)")
process_series_batch(account, chunk, categories_by_provider, relations, scan_start_time)
del all_series_data
logger.info(f"Completed processing all {total_series} series in {total_chunks} chunks")
@ -539,10 +562,12 @@ def process_movie_batch(account, batch, categories, relations, scan_start_time=N
# Query by name+year for movies without external IDs
name_year_keys = [k for k in movie_keys.keys() if k.startswith('name_')]
if name_year_keys:
for movie in Movie.objects.filter(tmdb_id__isnull=True, imdb_id__isnull=True):
key = f"name_{movie.name}_{movie.year or 'None'}"
if key in name_year_keys:
existing_movies[key] = movie
name_year_pairs = [
(movie_keys[k]['props']['name'], movie_keys[k]['props'].get('year'))
for k in name_year_keys
]
for key_tuple, movie in lookup_by_name_year(Movie, name_year_pairs).items():
existing_movies[f"name_{key_tuple[0]}_{key_tuple[1] or 'None'}"] = movie
# Get existing relations
stream_ids = [data['stream_id'] for data in movie_keys.values()]
@ -659,12 +684,7 @@ def process_movie_batch(account, batch, categories, relations, scan_start_time=N
existing_by_tmdb = {m.tmdb_id: m for m in Movie.objects.filter(tmdb_id__in=tmdb_ids)} if tmdb_ids else {}
existing_by_imdb = {m.imdb_id: m for m in Movie.objects.filter(imdb_id__in=imdb_ids)} if imdb_ids else {}
existing_by_name_year = {}
if name_year_pairs:
for movie in Movie.objects.filter(tmdb_id__isnull=True, imdb_id__isnull=True):
key = (movie.name, movie.year)
if key in name_year_pairs:
existing_by_name_year[key] = movie
existing_by_name_year = lookup_by_name_year(Movie, name_year_pairs)
# Check each movie against the bulk query results
movies_actually_created = []
@ -911,10 +931,12 @@ def process_series_batch(account, batch, categories, relations, scan_start_time=
# Query by name+year for series without external IDs
name_year_keys = [k for k in series_keys.keys() if k.startswith('name_')]
if name_year_keys:
for series in Series.objects.filter(tmdb_id__isnull=True, imdb_id__isnull=True):
key = f"name_{series.name}_{series.year or 'None'}"
if key in name_year_keys:
existing_series[key] = series
name_year_pairs = [
(series_keys[k]['props']['name'], series_keys[k]['props'].get('year'))
for k in name_year_keys
]
for key_tuple, series in lookup_by_name_year(Series, name_year_pairs).items():
existing_series[f"name_{key_tuple[0]}_{key_tuple[1] or 'None'}"] = series
# Get existing relations
series_ids = [data['series_id'] for data in series_keys.values()]
@ -1023,12 +1045,7 @@ def process_series_batch(account, batch, categories, relations, scan_start_time=
existing_by_tmdb = {s.tmdb_id: s for s in Series.objects.filter(tmdb_id__in=tmdb_ids)} if tmdb_ids else {}
existing_by_imdb = {s.imdb_id: s for s in Series.objects.filter(imdb_id__in=imdb_ids)} if imdb_ids else {}
existing_by_name_year = {}
if name_year_pairs:
for series in Series.objects.filter(tmdb_id__isnull=True, imdb_id__isnull=True):
key = (series.name, series.year)
if key in name_year_pairs:
existing_by_name_year[key] = series
existing_by_name_year = lookup_by_name_year(Series, name_year_pairs)
# Check each series against the bulk query results
series_actually_created = []

View file

@ -225,7 +225,8 @@ class ProxySettingsViewSet(viewsets.ViewSet):
"buffering_speed": 1.0,
"redis_chunk_ttl": 60,
"channel_shutdown_delay": 0,
"channel_init_grace_period": 5,
"channel_init_grace_period": 60,
"channel_client_wait_period": 5,
"new_client_behind_seconds": 5,
}
settings_obj, created = CoreSettings.objects.get_or_create(

View file

@ -0,0 +1,51 @@
from django.db import migrations
PROXY_SETTINGS_KEY = "proxy_settings"
def add_channel_client_wait_period(apps, schema_editor):
CoreSettings = apps.get_model("core", "CoreSettings")
try:
obj = CoreSettings.objects.get(key=PROXY_SETTINGS_KEY)
except CoreSettings.DoesNotExist:
return
value = obj.value if isinstance(obj.value, dict) else {}
# Add the new client-connect grace period default.
value.setdefault("channel_client_wait_period", 5)
# channel_init_grace_period was repurposed in 0.27.1 as the channel startup
# timeout (replacing a hardcoded 10s). Values below the new 60s default are
# too short when a channel has many failover streams to cycle through.
current_init = value.get("channel_init_grace_period", 5)
if current_init < 60:
value["channel_init_grace_period"] = 60
obj.value = value
obj.save()
def remove_channel_client_wait_period(apps, schema_editor):
CoreSettings = apps.get_model("core", "CoreSettings")
try:
obj = CoreSettings.objects.get(key=PROXY_SETTINGS_KEY)
except CoreSettings.DoesNotExist:
return
value = obj.value if isinstance(obj.value, dict) else {}
value.pop("channel_client_wait_period", None)
obj.value = value
obj.save()
class Migration(migrations.Migration):
dependencies = [
("core", "0025_move_preferred_region_and_auto_import_to_system_settings"),
]
operations = [
migrations.RunPython(add_channel_client_wait_period, remove_channel_client_wait_period),
]

View file

@ -394,7 +394,8 @@ class CoreSettings(models.Model):
"buffering_speed": 1.0,
"redis_chunk_ttl": 60,
"channel_shutdown_delay": 0,
"channel_init_grace_period": 5,
"channel_init_grace_period": 60,
"channel_client_wait_period": 5,
"new_client_behind_seconds": 5,
})

View file

@ -96,7 +96,8 @@ class ProxySettingsSerializer(serializers.Serializer):
buffering_speed = serializers.FloatField(min_value=0.1, max_value=10.0)
redis_chunk_ttl = serializers.IntegerField(min_value=10, max_value=3600)
channel_shutdown_delay = serializers.IntegerField(min_value=0, max_value=300)
channel_init_grace_period = serializers.IntegerField(min_value=0, max_value=60)
channel_init_grace_period = serializers.IntegerField(min_value=0, max_value=300)
channel_client_wait_period = serializers.IntegerField(min_value=0, max_value=300, required=False, default=5)
new_client_behind_seconds = serializers.IntegerField(min_value=0, max_value=120, required=False, default=5)
def validate_buffering_timeout(self, value):
@ -120,8 +121,17 @@ class ProxySettingsSerializer(serializers.Serializer):
return value
def validate_channel_init_grace_period(self, value):
if value < 0 or value > 60:
raise serializers.ValidationError("Channel init grace period must be between 0 and 60 seconds")
if value < 0 or value > 300:
raise serializers.ValidationError(
"Channel initialization timeout must be between 0 and 300 seconds"
)
return value
def validate_channel_client_wait_period(self, value):
if value < 0 or value > 300:
raise serializers.ValidationError(
"Client connect grace period must be between 0 and 300 seconds"
)
return value
def validate_new_client_behind_seconds(self, value):

View file

@ -398,12 +398,16 @@ def scan_and_process_files():
def _rebuild_programme_indices():
"""Queue index builds for active EPG sources that are missing their DB index."""
try:
from django.db.models import Q
from apps.epg.tasks import build_programme_index_task
sources = EPGSource.objects.filter(
is_active=True,
programme_index__isnull=True,
).exclude(source_type__in=('dummy', 'schedules_direct'))
).exclude(
source_type__in=('dummy', 'schedules_direct')
).filter(
Q(index_record__isnull=True) | Q(index_record__data__isnull=True)
)
count = 0
for source in sources:
@ -796,11 +800,11 @@ def check_for_version_update():
from packaging import version as pkg_version
from version import __version__, __timestamp__
from core.models import SystemNotification
from core.utils import send_websocket_notification
from core.utils import dispatcharr_http_headers, send_websocket_notification
try:
is_dev_build = __timestamp__ is not None
DISPATCHARR_HEADERS = {'User-Agent': f'Dispatcharr/{__version__}'}
DISPATCHARR_HEADERS = dispatcharr_http_headers(content_type=None)
if is_dev_build:
# Check Docker Hub for newer dev builds

View file

@ -1,21 +1,46 @@
from unittest.mock import patch, MagicMock
from django.test import TestCase
from django.test import TestCase, SimpleTestCase
from apps.epg.models import EPGSource
from apps.epg.models import EPGSource, EPGSourceIndex
from core.models import CoreSettings, DVR_SETTINGS_KEY, EPG_SETTINGS_KEY
class DispatcharrUserAgentTests(TestCase):
@patch('version.__version__', '1.2.3')
def test_dispatcharr_user_agent(self):
from core.utils import dispatcharr_user_agent
self.assertEqual(dispatcharr_user_agent(), 'Dispatcharr/1.2.3')
def test_dispatcharr_dvr_user_agent(self):
from core.utils import dispatcharr_dvr_user_agent
self.assertEqual(dispatcharr_dvr_user_agent(42), 'Dispatcharr-DVR/recording-42')
@patch('version.__version__', '1.2.3')
def test_dispatcharr_http_headers_with_token(self):
from core.utils import dispatcharr_http_headers
headers = dispatcharr_http_headers(token='tok123')
self.assertEqual(headers, {
'User-Agent': 'Dispatcharr/1.2.3',
'Content-Type': 'application/json',
'token': 'tok123',
})
@patch('version.__version__', '1.2.3')
def test_dispatcharr_http_headers_without_content_type(self):
from core.utils import dispatcharr_http_headers
self.assertEqual(
dispatcharr_http_headers(content_type=None),
{'User-Agent': 'Dispatcharr/1.2.3'},
)
class ProgrammeIndexRebuildTests(TestCase):
def test_startup_rebuild_does_not_lock_out_queued_build_task(self):
EPGSource.objects.update(
programme_index={"channels": {}, "interleaved_channels": []}
)
source = EPGSource.objects.create(
name="Missing Index",
source_type="xmltv",
is_active=True,
programme_index=None,
)
class FakeRedis:
@ -272,3 +297,14 @@ class DropDBCommandTlsTest(TestCase):
host='localhost', port=5432,
autocommit=True,
)
class MallocTrimTests(SimpleTestCase):
def test_trim_is_noop_when_libc_has_no_malloc_trim(self):
from core.utils import trim_c_allocator_heap
fake_libc = MagicMock(spec=[])
with patch('ctypes.util.find_library', return_value='libc.so.6'), patch(
'ctypes.CDLL', return_value=fake_libc
):
self.assertFalse(trim_c_allocator_heap())

View file

@ -1,3 +1,4 @@
import json
import redis
import logging
import time
@ -7,7 +8,6 @@ from pathlib import Path
import re
from django.conf import settings
from redis.exceptions import ConnectionError, TimeoutError
from django.core.cache import cache
from asgiref.sync import async_to_sync
from channels.layers import get_channel_layer
from django.core.validators import URLValidator
@ -21,6 +21,33 @@ logger = logging.getLogger(__name__)
# Import the command detector
from .command_utils import is_management_command
def dispatcharr_user_agent():
"""Return the standard Dispatcharr User-Agent string (Dispatcharr/{version})."""
from version import __version__
return f'Dispatcharr/{__version__}'
def dispatcharr_dvr_user_agent(recording_id):
"""Return the User-Agent string used by DVR FFmpeg clients for a recording."""
return f'Dispatcharr-DVR/recording-{recording_id}'
def dispatcharr_http_headers(*, token=None, content_type='application/json'):
"""
Build HTTP headers for outbound Dispatcharr requests.
content_type=None omits Content-Type (e.g. simple GET proxies).
token is included when authenticating with Schedules Direct.
"""
headers = {'User-Agent': dispatcharr_user_agent()}
if content_type:
headers['Content-Type'] = content_type
if token:
headers['token'] = token
return headers
def natural_sort_key(text):
"""
Convert a string into a list of string and number chunks for natural sorting.
@ -44,6 +71,46 @@ def natural_sort_key(text):
return [convert(c) for c in re.split('([0-9]+)', text)]
def custom_properties_as_dict(value):
"""
Normalize a JSONField-backed custom_properties value into a dict.
Historical rows (TextField era and early JSONField migration) may store a
JSON-encoded string instead of an object. API clients can also submit a
string value because JSONField accepts any JSON type. Call this before
reading or merging custom_properties.
"""
if isinstance(value, dict):
return value
if isinstance(value, str):
try:
parsed = json.loads(value)
except (ValueError, TypeError):
logger.warning(
"custom_properties stored as non-JSON string; ignoring: %r",
value[:100],
)
return {}
return parsed if isinstance(parsed, dict) else {}
if value is None:
return {}
return {}
def ensure_custom_properties_dict(value):
"""
Return a dict for read/merge/bulk-write paths. Dict values pass through
without re-parsing. Use model ``save()`` (not this) as the canonical
normalizer for ORM writes that go through ``save()``.
"""
if isinstance(value, dict):
return value
if value is None:
return {}
return custom_properties_as_dict(value)
class RedisClient:
_client = None
_buffer = None
@ -244,6 +311,15 @@ def release_task_lock(task_name, id):
redis_client.delete(lock_id)
def is_task_lock_held(task_name, id):
"""Return True when another worker holds the task lock (read-only check)."""
redis_client = RedisClient.get_client()
if redis_client is None:
return False
lock_id = f"task_lock_{task_name}_{id}"
return bool(redis_client.exists(lock_id))
class TaskLockRenewer:
"""Periodically renews a Redis task lock to prevent expiry during long-running tasks.
@ -470,6 +546,25 @@ def monitor_memory_usage(func):
return result
return wrapper
def trim_c_allocator_heap():
"""Return unused C heap pages to the OS where supported (glibc malloc_trim)."""
try:
import ctypes
import ctypes.util
libc_name = ctypes.util.find_library("c")
if not libc_name:
return False
libc = ctypes.CDLL(libc_name)
if not hasattr(libc, "malloc_trim"):
return False
libc.malloc_trim(0)
return True
except Exception:
logger.debug("malloc_trim unavailable or failed", exc_info=True)
return False
def cleanup_memory(log_usage=False, force_collection=True):
"""
Comprehensive memory cleanup function to reduce memory footprint
@ -513,6 +608,30 @@ def cleanup_memory(log_usage=False, force_collection=True):
pass
logger.trace("Memory cleanup complete for django")
def spawn_memory_trim(close_connections=False):
"""Reclaim a request's heap pages: GC, then return freed C pages to the OS.
On gevent uWSGI workers the trim runs in a spawned greenlet so it never
blocks the caller; Celery prefork workers (no gevent hub) run it inline.
Set close_connections=True when called from a streaming generator's teardown
so the pooled DB connection is released first.
"""
def _run():
cleanup_memory(force_collection=True)
trim_c_allocator_heap()
if close_connections:
from django.db import close_old_connections
close_old_connections()
if _is_gevent_monkey_patched():
import gevent
gevent.spawn(_run)
else:
_run()
def safe_upload_path(filename: str, base_dir) -> str:
"""Return a safe absolute path for an uploaded file within base_dir.
@ -587,6 +706,8 @@ def validate_flexible_url(value):
raise ValidationError("Enter a valid URL.")
def dispatch_event_system(event_type, channel_id=None, channel_name=None, **details):
from django.db import close_old_connections
try:
from apps.connect.utils import trigger_event
from apps.channels.models import Channel, Stream
@ -660,9 +781,48 @@ def dispatch_event_system(event_type, channel_id=None, channel_name=None, **deta
trigger_event(event_type, payload)
except Exception as e:
except Exception:
# Don't fail main path if connect dispatch fails
pass
finally:
close_old_connections()
def _dispatch_system_event_integrations(
event_type, channel_id=None, channel_name=None, **details
):
"""
Run Connect subscriptions and plugin event hooks without blocking the caller.
On gevent uWSGI workers, dispatch runs in a spawned greenlet so slow webhooks,
scripts, or plugin handlers cannot stall live-proxy teardown or streaming paths.
Celery prefork workers (gevent patched but no hub) run synchronously instead.
"""
def _run():
try:
dispatch_event_system(
event_type,
channel_id=channel_id,
channel_name=channel_name,
**details,
)
except Exception as e:
logger.error(
"Failed to dispatch Connect/plugin handlers for event %s: %s",
event_type,
e,
)
if _should_use_sync_websocket_send():
_run()
elif _is_gevent_monkey_patched():
import gevent
gevent.spawn(_run)
else:
_run()
def log_system_event(event_type, channel_id=None, channel_name=None, **details):
"""
@ -679,6 +839,7 @@ def log_system_event(event_type, channel_id=None, channel_name=None, **details):
stream_url='http://...', user='admin')
"""
from core.models import SystemEvent, CoreSettings
from django.db import close_old_connections
try:
# Create the event
@ -689,8 +850,13 @@ def log_system_event(event_type, channel_id=None, channel_name=None, **details):
details=details
)
# Trigger connect integrations for specific events
dispatch_event_system(event_type, channel_id=channel_id, channel_name=channel_name, **details)
# Connect integrations and plugin event hooks (non-blocking on gevent uWSGI)
_dispatch_system_event_integrations(
event_type,
channel_id=channel_id,
channel_name=channel_name,
**details,
)
# Get max events from settings (default 100)
try:
@ -714,6 +880,10 @@ def log_system_event(event_type, channel_id=None, channel_name=None, **details):
except Exception as e:
# Don't let event logging break the main application
logger.error(f"Failed to log system event {event_type}: {e}")
finally:
# geventpool keeps checked-out connections until close(); release promptly
# when logging from proxy greenlets/threads outside a normal request cycle.
close_old_connections()
def _send_async(channel_layer, group, message):

View file

@ -5,6 +5,26 @@ import json
logger = logging.getLogger(__name__)
def normalize_server_url(url):
"""Normalize server URL: strip XC API endpoints and query params, preserve base path."""
if not url:
return url
from urllib.parse import urlparse, urlunparse
parsed = urlparse(url.strip())
path = parsed.path.rstrip('/')
# XC API endpoints are always .php files; legitimate base paths never are.
# Stripping the trailing segment when it ends in .php handles any pasted API URL.
last_segment = path.rsplit('/', 1)[-1]
if last_segment.endswith('.php'):
path = path[:-(len(last_segment) + 1)] if '/' in path else ''
return urlunparse((parsed.scheme, parsed.netloc, path, '', '', ''))
class Client:
"""Xtream Codes API Client with robust error handling"""
@ -43,22 +63,10 @@ class Client:
self.server_info = None
def _normalize_url(self, url):
"""Normalize server URL: strip XC API endpoints and query params, preserve base path."""
if not url:
normalized = normalize_server_url(url)
if not normalized:
raise ValueError("Server URL cannot be empty")
from urllib.parse import urlparse, urlunparse
parsed = urlparse(url.strip())
path = parsed.path.rstrip('/')
# XC API endpoints are always .php files; legitimate base paths never are.
# Stripping the trailing segment when it ends in .php handles any pasted API URL.
last_segment = path.rsplit('/', 1)[-1]
if last_segment.endswith('.php'):
path = path[:-(len(last_segment) + 1)] if '/' in path else ''
return urlunparse((parsed.scheme, parsed.netloc, path, '', '', ''))
return normalized
def _make_request(self, endpoint, params=None):
"""Make request with detailed error handling"""

View file

@ -2,7 +2,7 @@
import os
from celery import Celery
import logging
from celery.signals import task_postrun, worker_ready
from celery.signals import task_postrun, task_prerun, worker_ready
logger = logging.getLogger(__name__)
@ -68,18 +68,30 @@ app.conf.task_routes = {
'apps.channels.tasks.run_recording': {'queue': 'dvr'},
}
@task_prerun.connect
def reset_db_connection_before_task(**kwargs):
"""Discard stale DB connections before each task (Celery workers are long-lived)."""
from django.db import close_old_connections
try:
close_old_connections()
except Exception:
pass
# Add memory cleanup after task completion
@task_postrun.connect # Use the imported signal
def cleanup_task_memory(**kwargs):
"""Clean up memory and database connections after each task completes"""
from django.db import connection
from django.db import close_old_connections
# Get task name from kwargs
task_name = kwargs.get('task').name if kwargs.get('task') else ''
# Close database connection for this Celery worker process
# Return all DB connections to the pool in a clean state
try:
connection.close()
close_old_connections()
except Exception:
pass
@ -94,8 +106,13 @@ def cleanup_task_memory(**kwargs):
'apps.epg.tasks.refresh_all_epg_data',
'apps.epg.tasks.parse_programs_for_source',
'apps.epg.tasks.parse_programs_for_tvg_id',
'apps.epg.tasks.build_programme_index_task',
'apps.channels.tasks.match_epg_channels',
'core.tasks.rehash_streams'
'apps.channels.tasks.match_selected_channels_epg',
'apps.channels.tasks.match_single_channel_epg',
'core.tasks.rehash_streams',
'apps.vod.tasks.refresh_vod_content',
'apps.vod.tasks.batch_refresh_series_episodes',
]
# Check if this is a memory-intensive task

View file

View file

View file

@ -0,0 +1,47 @@
try:
import psycopg
except ImportError as e:
from django.core.exceptions import ImproperlyConfigured
raise ImproperlyConfigured("Error loading psycopg3 module: %s" % e) from e
from django.db.backends.postgresql.base import DatabaseWrapper as OriginalDatabaseWrapper
from django_db_geventpool.backends.base import DatabaseWrapperMixin
from .pool import DatabaseConnectionPool
class PostgresConnectionPool(DatabaseConnectionPool):
DBERROR = psycopg.DatabaseError
def __init__(self, *args, **kwargs):
self.connect = kwargs.pop("connect", psycopg.connect)
self.connection = None
maxsize = kwargs.pop("MAX_CONNS", 4)
reuse = kwargs.pop("REUSE_CONNS", maxsize)
max_lifetime = kwargs.pop("CONN_MAX_LIFETIME", None)
self.args = args
self.kwargs = kwargs
self.kwargs["client_encoding"] = "UTF8"
super().__init__(maxsize, reuse, max_lifetime=max_lifetime)
def create_connection(self):
return self.connect(*self.args, **self.kwargs)
def check_usable(self, connection):
connection.cursor().execute("SELECT 1")
class DatabaseWrapper(DatabaseWrapperMixin, OriginalDatabaseWrapper):
pool_class = PostgresConnectionPool
INTRANS = psycopg.pq.TransactionStatus.INTRANS
def get_connection_params(self) -> dict:
from dispatcharr.db.process_label import db_application_name
conn_params = super().get_connection_params()
conn_params["application_name"] = db_application_name()
for attr in ("MAX_CONNS", "REUSE_CONNS", "CONN_MAX_LIFETIME"):
if attr in self.settings_dict["OPTIONS"]:
conn_params[attr] = self.settings_dict["OPTIONS"][attr]
return conn_params

View file

@ -0,0 +1,92 @@
"""geventpool with bounded connection lifetime.
django-db-geventpool keeps warm connections open indefinitely. psycopg3 accumulates
cache on long-lived handles. We close and replace after
CONN_MAX_LIFETIME rather than recycling uWSGI workers, which would interrupt
live stream backends.
"""
import logging
import time
try:
from gevent import queue
except ImportError:
from eventlet import queue
from django_db_geventpool.backends.pool import DatabaseConnectionPool as BasePool
logger = logging.getLogger("django.geventpool")
class DatabaseConnectionPool(BasePool):
def __init__(self, maxsize: int = 100, reuse: int = 100, max_lifetime: float | None = None):
super().__init__(maxsize, reuse)
self.max_lifetime = max_lifetime
def _stamp_connection(self, conn) -> None:
conn._dispatcharr_pool_created_at = time.monotonic()
def _connection_expired(self, conn) -> bool:
if not self.max_lifetime:
return False
created_at = getattr(conn, "_dispatcharr_pool_created_at", None)
if created_at is None:
return False
return (time.monotonic() - created_at) >= self.max_lifetime
def _close_connection(self, conn) -> None:
try:
conn.close()
except Exception:
logger.debug("Error closing pool connection", exc_info=True)
finally:
self._conns.discard(conn)
def get(self):
conn = None
try:
if self.size >= self.maxsize or self.pool.qsize():
conn = self.pool.get()
else:
conn = self.pool.get_nowait()
if conn is not None and self._connection_expired(conn):
logger.debug(
"DB connection expired after %ss, replacing",
int(self.max_lifetime),
)
self._close_connection(conn)
conn = None
elif conn is not None:
try:
self.check_usable(conn)
logger.trace("DB connection reused")
except self.DBERROR:
logger.debug("DB connection was closed, creating a new one")
self._close_connection(conn)
conn = None
except queue.Empty:
conn = None
logger.trace("DB connection queue empty, creating a new one")
if conn is None:
conn = self.create_connection()
self._stamp_connection(conn)
self._conns.add(conn)
return conn
def put(self, item):
if self._connection_expired(item):
logger.debug(
"DB connection expired after %ss on return, closing",
int(self.max_lifetime),
)
self._close_connection(item)
return
try:
self.pool.put_nowait(item)
logger.trace("DB connection returned to the pool")
except queue.Full:
self._close_connection(item)

View file

@ -0,0 +1,43 @@
"""Label Postgres connections by Dispatcharr process role (for pg_stat_activity)."""
from __future__ import annotations
import os
import sys
def _is_uwsgi_worker() -> bool:
"""True when running inside a uWSGI worker (not master or a non-uWSGI process)."""
try:
import uwsgi
except ImportError:
return False
return uwsgi.worker_id() > 0
def get_process_role(argv: list[str] | None = None) -> str:
argv = argv if argv is not None else sys.argv
argv0 = os.path.basename(argv[0]) if argv else ""
cmdline = " ".join(argv)
if "celery" in argv0 or any("celery" in arg for arg in argv):
if "beat" in cmdline:
return "celery-beat"
if "-Q" in argv:
try:
if "dvr" in argv[argv.index("-Q") + 1]:
return "celery-dvr"
except (IndexError, ValueError):
pass
return "celery-worker"
if "daphne" in argv0:
return "daphne"
if argv0 == "manage.py" and len(argv) > 1:
return f"manage-{argv[1]}"
if _is_uwsgi_worker() or argv0 == "uwsgi":
return "uwsgi"
return "django"
def db_application_name() -> str:
return f"Dispatcharr-{get_process_role()}-{os.getpid()}"

View file

@ -114,6 +114,12 @@ XC_PROFILE_REFRESH_DELAY = float(os.environ.get('XC_PROFILE_REFRESH_DELAY', '2.5
# Database optimization settings
DATABASE_STATEMENT_TIMEOUT = 300 # Seconds before timing out long-running queries
DATABASE_CONN_MAX_AGE = 0 # geventpool intercepts close(); pool handles reuse
# Pooled connections are closed and replaced after this many seconds (per worker).
# psycopg3 cache grows on long-lived handles; rotating bounds RAM without recycling
# uWSGI workers (which would interrupt live streams). Reuse within the window keeps
# the warm-pool performance win over opening a new TCP session every request.
# Override via DATABASE_POOL_CONN_MAX_LIFETIME; set 0 to disable. Default 600 (10 min).
DATABASE_POOL_CONN_MAX_LIFETIME = int(os.environ.get("DATABASE_POOL_CONN_MAX_LIFETIME", "600"))
# Disable atomic requests for performance-sensitive views
ATOMIC_REQUESTS = False
@ -223,7 +229,7 @@ if os.getenv("DB_ENGINE", None) == "sqlite":
else:
DATABASES = {
"default": {
"ENGINE": "django_db_geventpool.backends.postgresql_psycopg3",
"ENGINE": "dispatcharr.db.backends.postgresql_psycopg3",
"NAME": os.environ.get("POSTGRES_DB", "dispatcharr"),
"USER": os.environ.get("POSTGRES_USER", "dispatch"),
"PASSWORD": os.environ.get("POSTGRES_PASSWORD", "secret"),
@ -234,6 +240,7 @@ else:
"MAX_CONNS": 8, # Per-worker pool size; 4 workers × 8 = 32 total < pg max_connections=100
"REUSE_CONNS": 3, # Connections to keep warm between requests
"pool": False, # Disable Django's native psycopg3 pool; geventpool manages connections
"CONN_MAX_LIFETIME": DATABASE_POOL_CONN_MAX_LIFETIME or None,
},
}
}
@ -557,6 +564,12 @@ LOGGING = {
"level": LOG_LEVEL, # Use configured log level for scheduler logs
"propagate": False,
},
# geventpool connection lifecycle
"django.geventpool": {
"handlers": ["console"],
"level": LOG_LEVEL,
"propagate": False,
},
# Add any other loggers you need to capture TRACE logs from
},
"root": {

View file

@ -0,0 +1,58 @@
"""
Django settings for running the backend test suite in isolation.
Always use this module instead of dispatcharr.settings when running tests:
python manage.py test
`manage.py` selects this module automatically for the ``test`` command.
Django creates a separate empty database (``test_<POSTGRES_DB>``) and runs
migrations your live data under /data/db is not used.
Why not dispatcharr.settings?
- Production/AIO points at the live ``dispatcharr`` database.
- django-db-geventpool breaks TestCase transaction isolation on pooled connections.
SQLite (``TEST_USE_SQLITE=1``) is an optional fallback for machines without
Postgres; production and CI should use the default Postgres test database.
"""
import os
from dispatcharr.settings import * # noqa: F401,F403
# Fast password hashing for tests.
PASSWORD_HASHERS = ["django.contrib.auth.hashers.MD5PasswordHasher"]
# Do NOT run Celery tasks inline during tests. post_save signals on M3UAccount and
# EPGSource call .delay(); eager mode runs them inside TestCase transactions and
# closes/poisons the DB connection for subsequent queries in the same test.
CELERY_TASK_ALWAYS_EAGER = False
CELERY_TASK_EAGER_PROPAGATES = False
_use_sqlite = os.environ.get("TEST_USE_SQLITE", "").lower() in ("1", "true", "yes")
if _use_sqlite:
DATABASES = {
"default": {
"ENGINE": "django.db.backends.sqlite3",
"NAME": ":memory:",
}
}
else:
# Default: PostgreSQL with Django-managed test_dispatcharr (matches production).
# Uses the standard backend (not geventpool) so TestCase transactions isolate.
_pg_name = os.environ.get("POSTGRES_DB", "dispatcharr")
DATABASES = {
"default": {
"ENGINE": "django.db.backends.postgresql",
"NAME": _pg_name,
"USER": os.environ.get("POSTGRES_USER", "dispatch"),
"PASSWORD": os.environ.get("POSTGRES_PASSWORD", "secret"),
"HOST": os.environ.get("POSTGRES_HOST", "localhost"),
"PORT": int(os.environ.get("POSTGRES_PORT", 5432)),
"TEST": {
"NAME": "test_" + _pg_name,
},
}
}

View file

@ -70,7 +70,6 @@ py-autoreload = 1
honour-stdin = true
# Environment variables
env = GEVENT_SUPPORT=True
env = PYTHONPATH=/app
env = PYTHONUNBUFFERED=1
env = PYDEVD_DISABLE_FILE_VALIDATION=1

View file

@ -57,7 +57,7 @@
"globals": "^15.15.0",
"jsdom": "^27.0.0",
"prettier": "^3.5.3",
"vite": "^7.1.7",
"vite": "^7.3.5",
"vitest": "^4.1.8"
}
},
@ -595,9 +595,9 @@
"license": "MIT"
},
"node_modules/@esbuild/aix-ppc64": {
"version": "0.27.3",
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.3.tgz",
"integrity": "sha512-9fJMTNFTWZMh5qwrBItuziu834eOCUcEqymSH7pY+zoMVEZg3gcPuBNxH1EvfVYe9h0x/Ptw8KBzv7qxb7l8dg==",
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.1.tgz",
"integrity": "sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==",
"cpu": [
"ppc64"
],
@ -612,9 +612,9 @@
}
},
"node_modules/@esbuild/android-arm": {
"version": "0.27.3",
"resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.3.tgz",
"integrity": "sha512-i5D1hPY7GIQmXlXhs2w8AWHhenb00+GxjxRncS2ZM7YNVGNfaMxgzSGuO8o8SJzRc/oZwU2bcScvVERk03QhzA==",
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.1.tgz",
"integrity": "sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==",
"cpu": [
"arm"
],
@ -629,9 +629,9 @@
}
},
"node_modules/@esbuild/android-arm64": {
"version": "0.27.3",
"resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.3.tgz",
"integrity": "sha512-YdghPYUmj/FX2SYKJ0OZxf+iaKgMsKHVPF1MAq/P8WirnSpCStzKJFjOjzsW0QQ7oIAiccHdcqjbHmJxRb/dmg==",
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.1.tgz",
"integrity": "sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==",
"cpu": [
"arm64"
],
@ -646,9 +646,9 @@
}
},
"node_modules/@esbuild/android-x64": {
"version": "0.27.3",
"resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.3.tgz",
"integrity": "sha512-IN/0BNTkHtk8lkOM8JWAYFg4ORxBkZQf9zXiEOfERX/CzxW3Vg1ewAhU7QSWQpVIzTW+b8Xy+lGzdYXV6UZObQ==",
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.1.tgz",
"integrity": "sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==",
"cpu": [
"x64"
],
@ -663,9 +663,9 @@
}
},
"node_modules/@esbuild/darwin-arm64": {
"version": "0.27.3",
"resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.3.tgz",
"integrity": "sha512-Re491k7ByTVRy0t3EKWajdLIr0gz2kKKfzafkth4Q8A5n1xTHrkqZgLLjFEHVD+AXdUGgQMq+Godfq45mGpCKg==",
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.1.tgz",
"integrity": "sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==",
"cpu": [
"arm64"
],
@ -680,9 +680,9 @@
}
},
"node_modules/@esbuild/darwin-x64": {
"version": "0.27.3",
"resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.3.tgz",
"integrity": "sha512-vHk/hA7/1AckjGzRqi6wbo+jaShzRowYip6rt6q7VYEDX4LEy1pZfDpdxCBnGtl+A5zq8iXDcyuxwtv3hNtHFg==",
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.1.tgz",
"integrity": "sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==",
"cpu": [
"x64"
],
@ -697,9 +697,9 @@
}
},
"node_modules/@esbuild/freebsd-arm64": {
"version": "0.27.3",
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.3.tgz",
"integrity": "sha512-ipTYM2fjt3kQAYOvo6vcxJx3nBYAzPjgTCk7QEgZG8AUO3ydUhvelmhrbOheMnGOlaSFUoHXB6un+A7q4ygY9w==",
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.1.tgz",
"integrity": "sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw==",
"cpu": [
"arm64"
],
@ -714,9 +714,9 @@
}
},
"node_modules/@esbuild/freebsd-x64": {
"version": "0.27.3",
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.3.tgz",
"integrity": "sha512-dDk0X87T7mI6U3K9VjWtHOXqwAMJBNN2r7bejDsc+j03SEjtD9HrOl8gVFByeM0aJksoUuUVU9TBaZa2rgj0oA==",
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.1.tgz",
"integrity": "sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ==",
"cpu": [
"x64"
],
@ -731,9 +731,9 @@
}
},
"node_modules/@esbuild/linux-arm": {
"version": "0.27.3",
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.3.tgz",
"integrity": "sha512-s6nPv2QkSupJwLYyfS+gwdirm0ukyTFNl3KTgZEAiJDd+iHZcbTPPcWCcRYH+WlNbwChgH2QkE9NSlNrMT8Gfw==",
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.1.tgz",
"integrity": "sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ==",
"cpu": [
"arm"
],
@ -748,9 +748,9 @@
}
},
"node_modules/@esbuild/linux-arm64": {
"version": "0.27.3",
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.3.tgz",
"integrity": "sha512-sZOuFz/xWnZ4KH3YfFrKCf1WyPZHakVzTiqji3WDc0BCl2kBwiJLCXpzLzUBLgmp4veFZdvN5ChW4Eq/8Fc2Fg==",
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.1.tgz",
"integrity": "sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==",
"cpu": [
"arm64"
],
@ -765,9 +765,9 @@
}
},
"node_modules/@esbuild/linux-ia32": {
"version": "0.27.3",
"resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.3.tgz",
"integrity": "sha512-yGlQYjdxtLdh0a3jHjuwOrxQjOZYD/C9PfdbgJJF3TIZWnm/tMd/RcNiLngiu4iwcBAOezdnSLAwQDPqTmtTYg==",
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.1.tgz",
"integrity": "sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==",
"cpu": [
"ia32"
],
@ -782,9 +782,9 @@
}
},
"node_modules/@esbuild/linux-loong64": {
"version": "0.27.3",
"resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.3.tgz",
"integrity": "sha512-WO60Sn8ly3gtzhyjATDgieJNet/KqsDlX5nRC5Y3oTFcS1l0KWba+SEa9Ja1GfDqSF1z6hif/SkpQJbL63cgOA==",
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.1.tgz",
"integrity": "sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg==",
"cpu": [
"loong64"
],
@ -799,9 +799,9 @@
}
},
"node_modules/@esbuild/linux-mips64el": {
"version": "0.27.3",
"resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.3.tgz",
"integrity": "sha512-APsymYA6sGcZ4pD6k+UxbDjOFSvPWyZhjaiPyl/f79xKxwTnrn5QUnXR5prvetuaSMsb4jgeHewIDCIWljrSxw==",
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.1.tgz",
"integrity": "sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==",
"cpu": [
"mips64el"
],
@ -816,9 +816,9 @@
}
},
"node_modules/@esbuild/linux-ppc64": {
"version": "0.27.3",
"resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.3.tgz",
"integrity": "sha512-eizBnTeBefojtDb9nSh4vvVQ3V9Qf9Df01PfawPcRzJH4gFSgrObw+LveUyDoKU3kxi5+9RJTCWlj4FjYXVPEA==",
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.1.tgz",
"integrity": "sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ==",
"cpu": [
"ppc64"
],
@ -833,9 +833,9 @@
}
},
"node_modules/@esbuild/linux-riscv64": {
"version": "0.27.3",
"resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.3.tgz",
"integrity": "sha512-3Emwh0r5wmfm3ssTWRQSyVhbOHvqegUDRd0WhmXKX2mkHJe1SFCMJhagUleMq+Uci34wLSipf8Lagt4LlpRFWQ==",
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.1.tgz",
"integrity": "sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==",
"cpu": [
"riscv64"
],
@ -850,9 +850,9 @@
}
},
"node_modules/@esbuild/linux-s390x": {
"version": "0.27.3",
"resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.3.tgz",
"integrity": "sha512-pBHUx9LzXWBc7MFIEEL0yD/ZVtNgLytvx60gES28GcWMqil8ElCYR4kvbV2BDqsHOvVDRrOxGySBM9Fcv744hw==",
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.1.tgz",
"integrity": "sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag==",
"cpu": [
"s390x"
],
@ -867,9 +867,9 @@
}
},
"node_modules/@esbuild/linux-x64": {
"version": "0.27.3",
"resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.3.tgz",
"integrity": "sha512-Czi8yzXUWIQYAtL/2y6vogER8pvcsOsk5cpwL4Gk5nJqH5UZiVByIY8Eorm5R13gq+DQKYg0+JyQoytLQas4dA==",
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.1.tgz",
"integrity": "sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA==",
"cpu": [
"x64"
],
@ -884,9 +884,9 @@
}
},
"node_modules/@esbuild/netbsd-arm64": {
"version": "0.27.3",
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.3.tgz",
"integrity": "sha512-sDpk0RgmTCR/5HguIZa9n9u+HVKf40fbEUt+iTzSnCaGvY9kFP0YKBWZtJaraonFnqef5SlJ8/TiPAxzyS+UoA==",
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.1.tgz",
"integrity": "sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw==",
"cpu": [
"arm64"
],
@ -901,9 +901,9 @@
}
},
"node_modules/@esbuild/netbsd-x64": {
"version": "0.27.3",
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.3.tgz",
"integrity": "sha512-P14lFKJl/DdaE00LItAukUdZO5iqNH7+PjoBm+fLQjtxfcfFE20Xf5CrLsmZdq5LFFZzb5JMZ9grUwvtVYzjiA==",
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.1.tgz",
"integrity": "sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==",
"cpu": [
"x64"
],
@ -918,9 +918,9 @@
}
},
"node_modules/@esbuild/openbsd-arm64": {
"version": "0.27.3",
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.3.tgz",
"integrity": "sha512-AIcMP77AvirGbRl/UZFTq5hjXK+2wC7qFRGoHSDrZ5v5b8DK/GYpXW3CPRL53NkvDqb9D+alBiC/dV0Fb7eJcw==",
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.1.tgz",
"integrity": "sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q==",
"cpu": [
"arm64"
],
@ -935,9 +935,9 @@
}
},
"node_modules/@esbuild/openbsd-x64": {
"version": "0.27.3",
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.3.tgz",
"integrity": "sha512-DnW2sRrBzA+YnE70LKqnM3P+z8vehfJWHXECbwBmH/CU51z6FiqTQTHFenPlHmo3a8UgpLyH3PT+87OViOh1AQ==",
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.1.tgz",
"integrity": "sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==",
"cpu": [
"x64"
],
@ -952,9 +952,9 @@
}
},
"node_modules/@esbuild/openharmony-arm64": {
"version": "0.27.3",
"resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.3.tgz",
"integrity": "sha512-NinAEgr/etERPTsZJ7aEZQvvg/A6IsZG/LgZy+81wON2huV7SrK3e63dU0XhyZP4RKGyTm7aOgmQk0bGp0fy2g==",
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.1.tgz",
"integrity": "sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg==",
"cpu": [
"arm64"
],
@ -969,9 +969,9 @@
}
},
"node_modules/@esbuild/sunos-x64": {
"version": "0.27.3",
"resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.3.tgz",
"integrity": "sha512-PanZ+nEz+eWoBJ8/f8HKxTTD172SKwdXebZ0ndd953gt1HRBbhMsaNqjTyYLGLPdoWHy4zLU7bDVJztF5f3BHA==",
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.1.tgz",
"integrity": "sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==",
"cpu": [
"x64"
],
@ -986,9 +986,9 @@
}
},
"node_modules/@esbuild/win32-arm64": {
"version": "0.27.3",
"resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.3.tgz",
"integrity": "sha512-B2t59lWWYrbRDw/tjiWOuzSsFh1Y/E95ofKz7rIVYSQkUYBjfSgf6oeYPNWHToFRr2zx52JKApIcAS/D5TUBnA==",
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.1.tgz",
"integrity": "sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==",
"cpu": [
"arm64"
],
@ -1003,9 +1003,9 @@
}
},
"node_modules/@esbuild/win32-ia32": {
"version": "0.27.3",
"resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.3.tgz",
"integrity": "sha512-QLKSFeXNS8+tHW7tZpMtjlNb7HKau0QDpwm49u0vUp9y1WOF+PEzkU84y9GqYaAVW8aH8f3GcBck26jh54cX4Q==",
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.1.tgz",
"integrity": "sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==",
"cpu": [
"ia32"
],
@ -1020,9 +1020,9 @@
}
},
"node_modules/@esbuild/win32-x64": {
"version": "0.27.3",
"resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.3.tgz",
"integrity": "sha512-4uJGhsxuptu3OcpVAzli+/gWusVGwZZHTlS63hh++ehExkVT8SgiEf7/uC/PclrPPkLhZqGgCTjd0VWLo6xMqA==",
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.1.tgz",
"integrity": "sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==",
"cpu": [
"x64"
],
@ -3163,9 +3163,9 @@
"license": "MIT"
},
"node_modules/esbuild": {
"version": "0.27.3",
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.3.tgz",
"integrity": "sha512-8VwMnyGCONIs6cWue2IdpHxHnAjzxnw2Zr7MkVxB2vjmQ2ivqGFb4LEG3SMnv0Gb2F/G/2yA8zUaiL1gywDCCg==",
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.1.tgz",
"integrity": "sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==",
"dev": true,
"hasInstallScript": true,
"license": "MIT",
@ -3176,32 +3176,32 @@
"node": ">=18"
},
"optionalDependencies": {
"@esbuild/aix-ppc64": "0.27.3",
"@esbuild/android-arm": "0.27.3",
"@esbuild/android-arm64": "0.27.3",
"@esbuild/android-x64": "0.27.3",
"@esbuild/darwin-arm64": "0.27.3",
"@esbuild/darwin-x64": "0.27.3",
"@esbuild/freebsd-arm64": "0.27.3",
"@esbuild/freebsd-x64": "0.27.3",
"@esbuild/linux-arm": "0.27.3",
"@esbuild/linux-arm64": "0.27.3",
"@esbuild/linux-ia32": "0.27.3",
"@esbuild/linux-loong64": "0.27.3",
"@esbuild/linux-mips64el": "0.27.3",
"@esbuild/linux-ppc64": "0.27.3",
"@esbuild/linux-riscv64": "0.27.3",
"@esbuild/linux-s390x": "0.27.3",
"@esbuild/linux-x64": "0.27.3",
"@esbuild/netbsd-arm64": "0.27.3",
"@esbuild/netbsd-x64": "0.27.3",
"@esbuild/openbsd-arm64": "0.27.3",
"@esbuild/openbsd-x64": "0.27.3",
"@esbuild/openharmony-arm64": "0.27.3",
"@esbuild/sunos-x64": "0.27.3",
"@esbuild/win32-arm64": "0.27.3",
"@esbuild/win32-ia32": "0.27.3",
"@esbuild/win32-x64": "0.27.3"
"@esbuild/aix-ppc64": "0.28.1",
"@esbuild/android-arm": "0.28.1",
"@esbuild/android-arm64": "0.28.1",
"@esbuild/android-x64": "0.28.1",
"@esbuild/darwin-arm64": "0.28.1",
"@esbuild/darwin-x64": "0.28.1",
"@esbuild/freebsd-arm64": "0.28.1",
"@esbuild/freebsd-x64": "0.28.1",
"@esbuild/linux-arm": "0.28.1",
"@esbuild/linux-arm64": "0.28.1",
"@esbuild/linux-ia32": "0.28.1",
"@esbuild/linux-loong64": "0.28.1",
"@esbuild/linux-mips64el": "0.28.1",
"@esbuild/linux-ppc64": "0.28.1",
"@esbuild/linux-riscv64": "0.28.1",
"@esbuild/linux-s390x": "0.28.1",
"@esbuild/linux-x64": "0.28.1",
"@esbuild/netbsd-arm64": "0.28.1",
"@esbuild/netbsd-x64": "0.28.1",
"@esbuild/openbsd-arm64": "0.28.1",
"@esbuild/openbsd-x64": "0.28.1",
"@esbuild/openharmony-arm64": "0.28.1",
"@esbuild/sunos-x64": "0.28.1",
"@esbuild/win32-arm64": "0.28.1",
"@esbuild/win32-ia32": "0.28.1",
"@esbuild/win32-x64": "0.28.1"
}
},
"node_modules/escape-string-regexp": {
@ -3814,16 +3814,26 @@
"license": "MIT"
},
"node_modules/js-yaml": {
"version": "4.1.1",
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz",
"integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==",
"version": "5.1.0",
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-5.1.0.tgz",
"integrity": "sha512-s8VA5jkR8f22S3NAXmhKPFqGUduqZGlsufabVOgN14iTdw/RXcym7bKkbwjxLK9Yw2lEvvmJjFp119+KPeo8Kg==",
"dev": true,
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/puzrin"
},
{
"type": "github",
"url": "https://github.com/sponsors/nodeca"
}
],
"license": "MIT",
"dependencies": {
"argparse": "^2.0.1"
},
"bin": {
"js-yaml": "bin/js-yaml.js"
"js-yaml": "bin/js-yaml.mjs"
}
},
"node_modules/jsdom": {
@ -5425,9 +5435,9 @@
}
},
"node_modules/vite": {
"version": "7.3.2",
"resolved": "https://registry.npmjs.org/vite/-/vite-7.3.2.tgz",
"integrity": "sha512-Bby3NOsna2jsjfLVOHKes8sGwgl4TT0E6vvpYgnAYDIF/tie7MRaFthmKuHx1NSXjiTueXH3do80FMQgvEktRg==",
"version": "7.3.5",
"resolved": "https://registry.npmjs.org/vite/-/vite-7.3.5.tgz",
"integrity": "sha512-KuOaNhcnGFN2zIPGA7wRmzF+lJA1sea7rHq17aiJ++9lzY1WWG6Jpwqwe1KNbRVPIqHmr8GLYx7jbrQcN/7/ww==",
"dev": true,
"license": "MIT",
"dependencies": {

View file

@ -61,16 +61,17 @@
"globals": "^15.15.0",
"jsdom": "^27.0.0",
"prettier": "^3.5.3",
"vite": "^7.1.7",
"vite": "^7.3.5",
"vitest": "^4.1.8"
},
"resolutions": {
"vite": "7.1.7",
"vite": "7.3.5",
"react": "19.1.0",
"react-dom": "19.1.0"
},
"overrides": {
"js-yaml": "^4.1.1",
"esbuild": "^0.28.1",
"js-yaml": "^5.1.0",
"minimatch": "^10.2.1"
}
}

View file

@ -203,7 +203,7 @@ export const WebsocketProvider = ({ children }) => {
notifications.update({
id,
title: 'Commercials removed',
message: `${title} kept ${parsedEvent.data.segments_kept} segments`,
message: `${title}: kept ${parsedEvent.data.segments_kept} segments`,
color: 'green.5',
loading: false,
autoClose: 4000,
@ -366,22 +366,26 @@ export const WebsocketProvider = ({ children }) => {
fetchEPGData();
break;
case 'single_channel_epg_match': {
const matchResult = parsedEvent.data;
if (matchResult.channel) {
useChannelsStore.getState().updateChannel(matchResult.channel);
}
window.dispatchEvent(
new CustomEvent('single-channel-epg-match', {
detail: matchResult,
})
);
break;
}
case 'epg_match':
notifications.show({
message: parsedEvent.data.message || 'EPG match is complete!',
color: 'green.5',
});
// Check if we have associations data and use the more efficient batch API
if (
parsedEvent.data.associations &&
parsedEvent.data.associations.length > 0
) {
API.batchSetEPG(parsedEvent.data.associations);
}
// Refresh EPG store first, then requery channels so the table
// cross-references updated epg_data_id assignments immediately
// Celery already applied assignments server-side; refresh local state.
fetchEPGData();
API.requeryChannels();
break;
@ -647,8 +651,13 @@ export const WebsocketProvider = ({ children }) => {
// Read from the store directly. connectWebSocket closes over a stale
// epgs snapshot, so a newly created source is missed and the old early-
// return path never reached fetchEPGData on parsing_channels completion.
let { epgs: epgsState, updateEPG, updateEPGProgress, fetchEPGs, fetchEPGData } =
useEPGsStore.getState();
let {
epgs: epgsState,
updateEPG,
updateEPGProgress,
fetchEPGs,
fetchEPGData,
} = useEPGsStore.getState();
if (!epgsState[sourceId]) {
try {
@ -694,8 +703,7 @@ export const WebsocketProvider = ({ children }) => {
updateEPG({
...epg,
status: parsedEvent.data.status || 'success',
last_message:
parsedEvent.data.message || epg.last_message,
last_message: parsedEvent.data.message || epg.last_message,
...(parsedEvent.data.updated_at && {
updated_at: parsedEvent.data.updated_at,
}),

View file

@ -3,6 +3,7 @@ import useAuthStore from './store/auth';
import useChannelsStore from './store/channels';
import useLogosStore from './store/logos';
import useUserAgentsStore from './store/userAgents';
import useServerGroupsStore from './store/serverGroups';
import usePlaylistsStore from './store/playlists';
import useEPGsStore from './store/epgs';
import useStreamsStore from './store/streams';
@ -1305,6 +1306,59 @@ export default class API {
}
}
static async getServerGroups() {
try {
const response = await request(`${host}/api/m3u/server-groups/`);
return response;
} catch (e) {
errorNotification('Failed to retrieve server groups', e);
}
}
static async addServerGroup(values) {
try {
const response = await request(`${host}/api/m3u/server-groups/`, {
method: 'POST',
body: values,
});
useServerGroupsStore.getState().addServerGroup(response);
return response;
} catch (e) {
errorNotification('Failed to create server group', e);
}
}
static async updateServerGroup(values) {
try {
const { id, ...payload } = values;
const response = await request(`${host}/api/m3u/server-groups/${id}/`, {
method: 'PUT',
body: payload,
});
useServerGroupsStore.getState().updateServerGroup(response);
return response;
} catch (e) {
errorNotification('Failed to update server group', e);
}
}
static async deleteServerGroup(id) {
try {
await request(`${host}/api/m3u/server-groups/${id}/`, {
method: 'DELETE',
});
useServerGroupsStore.getState().removeServerGroups([id]);
} catch (e) {
errorNotification('Failed to delete server group', e);
}
}
static async getPlaylist(id) {
try {
const response = await request(`${host}/api/m3u/accounts/${id}/`);

View file

@ -16,6 +16,20 @@ import {
savePlayerPrefs,
} from '../utils/components/FloatingVideoUtils.js';
// Native <video src> cannot send Authorization headers. Append ?token= at playback
// time (not when building the URL in cards/modals) so the JWT is fresh. hls.js
// paths authenticate via xhrSetup instead and do not need this helper.
const withRecordingAuthToken = (url) => {
if (!url || typeof url !== 'string') return url;
if (!url.includes('/api/channels/recordings/')) return url;
const token = useAuthStore.getState().accessToken;
if (!token) return url;
const [base, query = ''] = url.split('?');
const params = new URLSearchParams(query);
params.set('token', token);
return `${base}?${params.toString()}`;
};
const ResizeHandles = ({ startResize }) => {
const HANDLE_SIZE = 18;
const HANDLE_OFFSET = 0;
@ -333,72 +347,75 @@ export default function FloatingVideo() {
let hls = null;
if (isHls && Hls.isSupported()) {
hls = new Hls({
// Open at the very beginning of the recording rather than the live
// edge. Without this, an in-progress recording would start at "now"
// and hide everything already recorded. hls.js applies this AFTER
// MEDIA_ATTACHED, so the listener-driven `seekToStart()` above is
// also kept as a safety net for the Safari native-HLS path and for
// edge cases where this initial-position logic loses to the user's
// first interaction.
startPosition: 0,
// Allow seeking back to the start of the recording, regardless of
// current playhead position. Recordings can be hours long and the
// user may want to scrub anywhere; we explicitly disable buffer
// eviction by setting a very large back-buffer length.
backBufferLength: 90 * 60, // 90 minutes
maxBufferLength: 60,
maxMaxBufferLength: 600,
// For an in-progress recording, hls.js refreshes the playlist on
// its target-duration cadence; let it follow the live edge but keep
// the full DVR window seekable.
liveSyncDurationCount: 3,
liveMaxLatencyDurationCount: 10,
enableWorker: true,
lowLatencyMode: false,
// Inject the JWT into every playlist + segment XHR. Read the token
// from the auth store at request time rather than capturing the
// closure value at hls.js init, so a refreshed access token mid-
// playback is picked up on the next segment fetch.
xhrSetup: (xhr) => {
const token = useAuthStore.getState().accessToken;
if (token) {
xhr.setRequestHeader('Authorization', `Bearer ${token}`);
}
},
});
hls.on(Hls.Events.ERROR, (_evt, data) => {
if (data.fatal) {
// eslint-disable-next-line no-console
console.error('HLS fatal error:', data.type, data.details);
if (data.type === Hls.ErrorTypes.NETWORK_ERROR) {
try {
hls.startLoad();
} catch {
// ignore
try {
hls = new Hls({
// Open at the very beginning of the recording rather than the live
// edge. Without this, an in-progress recording would start at "now"
// and hide everything already recorded. hls.js applies this AFTER
// MEDIA_ATTACHED, so the listener-driven `seekToStart()` above is
// also kept as a safety net for the Safari native-HLS path and for
// edge cases where this initial-position logic loses to the user's
// first interaction.
startPosition: 0,
// Allow seeking back to the start of the recording, regardless of
// current playhead position. Recordings can be hours long and the
// user may want to scrub anywhere; we explicitly disable buffer
// eviction by setting a very large back-buffer length.
backBufferLength: 90 * 60, // 90 minutes
maxBufferLength: 60,
maxMaxBufferLength: 600,
// Leave liveMaxLatencyDurationCount at the hls.js default (Infinity).
// A finite value forces the playhead to the live edge during playback.
enableWorker: true,
lowLatencyMode: false,
// Inject the JWT into every playlist + segment XHR. Read the token
// from the auth store at request time rather than capturing the
// closure value at hls.js init, so a refreshed access token mid-
// playback is picked up on the next segment fetch.
xhrSetup: (xhr) => {
const token = useAuthStore.getState().accessToken;
if (token) {
xhr.setRequestHeader('Authorization', `Bearer ${token}`);
}
} else if (data.type === Hls.ErrorTypes.MEDIA_ERROR) {
try {
hls.recoverMediaError();
} catch {
// ignore
},
});
hls.on(Hls.Events.ERROR, (_evt, data) => {
if (data.fatal) {
// eslint-disable-next-line no-console
console.error('HLS fatal error:', data.type, data.details);
if (data.type === Hls.ErrorTypes.NETWORK_ERROR) {
try {
hls.startLoad();
} catch {
// ignore
}
} else if (data.type === Hls.ErrorTypes.MEDIA_ERROR) {
try {
hls.recoverMediaError();
} catch {
// ignore
}
} else {
setLoadError(`HLS playback error: ${data.details || data.type}`);
}
} else {
setLoadError(`HLS playback error: ${data.details || data.type}`);
}
}
});
hls.attachMedia(video);
hls.on(Hls.Events.MEDIA_ATTACHED, () => {
hls.loadSource(streamUrl);
});
});
hls.attachMedia(video);
hls.on(Hls.Events.MEDIA_ATTACHED, () => {
hls.loadSource(streamUrl);
});
} catch (error) {
setIsLoading(false);
setLoadError(`HLS initialization error: ${error.message}`);
return;
}
} else if (isHls && video.canPlayType('application/vnd.apple.mpegurl')) {
// Safari path: native HLS support, including seekable DVR windows.
video.src = streamUrl;
video.src = withRecordingAuthToken(streamUrl);
video.load();
} else {
// Plain progressive file (MKV/MP4): native HTML5.
video.src = streamUrl;
video.src = withRecordingAuthToken(streamUrl);
video.load();
}

View file

@ -0,0 +1,28 @@
import { Modal } from '@mantine/core';
import ServerGroupsTable from './tables/ServerGroupsTable';
const ServerGroupsManagerModal = ({
isOpen,
onClose,
onGroupCreated,
openCreateOnMount = false,
}) => {
return (
<Modal
opened={isOpen}
onClose={onClose}
title="Server Groups"
size="md"
centered
>
{isOpen ? (
<ServerGroupsTable
onGroupCreated={onGroupCreated}
openCreateOnMount={openCreateOnMount}
/>
) : null}
</Modal>
);
};
export default ServerGroupsManagerModal;

View file

@ -20,8 +20,49 @@ vi.mock('mpegts.js', () => ({
},
}));
const mockHlsInstance = {
attachMedia: vi.fn(),
loadSource: vi.fn(),
destroy: vi.fn(),
on: vi.fn(),
};
let capturedHlsConfig = null;
let forceHlsInitError = false;
vi.mock('hls.js', () => ({
default: class MockHls {
static isSupported = vi.fn(() => true);
static Events = {
ERROR: 'error',
MEDIA_ATTACHED: 'media_attached',
};
static ErrorTypes = {
NETWORK_ERROR: 'networkError',
MEDIA_ERROR: 'mediaError',
};
constructor(config) {
if (forceHlsInitError) {
throw new Error('Illegal hls.js config');
}
capturedHlsConfig = config;
Object.assign(this, mockHlsInstance);
}
},
}));
vi.mock('../../store/auth', () => ({
default: {
getState: vi.fn(() => ({ accessToken: 'test-token' })),
},
}));
// Import the mocked module after mocking
const mpegts = (await import('mpegts.js')).default;
const Hls = (await import('hls.js')).default;
// Mock react-draggable
vi.mock('react-draggable', () => ({
@ -53,6 +94,8 @@ describe('FloatingVideo', () => {
beforeEach(async () => {
vi.clearAllMocks();
capturedHlsConfig = null;
forceHlsInitError = false;
// Mock HTMLVideoElement methods
HTMLVideoElement.prototype.load = vi.fn();
@ -239,6 +282,57 @@ describe('FloatingVideo', () => {
expect(video.poster).toBe('http://example.com/poster.jpg');
});
it('should disable live-edge sync for in-progress recording HLS', () => {
useVideoStore.mockImplementation((selector) => {
const state = {
isVisible: true,
streamUrl:
'http://example.com/api/channels/recordings/1/hls/index.m3u8',
contentType: 'vod',
metadata: { name: 'News Recording' },
hideVideo: mockHideVideo,
};
return selector ? selector(state) : state;
});
Hls.isSupported.mockReturnValue(true);
render(<FloatingVideo />);
expect(capturedHlsConfig).toEqual(
expect.objectContaining({
startPosition: 0,
})
);
expect(capturedHlsConfig).not.toHaveProperty(
'liveMaxLatencyDurationCount'
);
expect(capturedHlsConfig).not.toHaveProperty('liveSyncDurationCount');
});
it('shows an in-player error when hls.js config is invalid', () => {
useVideoStore.mockImplementation((selector) => {
const state = {
isVisible: true,
streamUrl:
'http://example.com/api/channels/recordings/1/hls/index.m3u8',
contentType: 'vod',
metadata: { name: 'News Recording' },
hideVideo: mockHideVideo,
};
return selector ? selector(state) : state;
});
Hls.isSupported.mockReturnValue(true);
forceHlsInitError = true;
render(<FloatingVideo />);
expect(
screen.getByText(/HLS initialization error: Illegal hls.js config/i)
).toBeInTheDocument();
});
it('should show metadata overlay', () => {
const { container } = render(<FloatingVideo />);
const video = container.querySelector('video');

View file

@ -0,0 +1,61 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { render, screen, waitFor } from '@testing-library/react';
import { MantineProvider } from '@mantine/core';
import ServerGroupsManagerModal from '../ServerGroupsManagerModal';
vi.mock('../../api', () => ({
default: {
deleteServerGroup: vi.fn(),
},
}));
vi.mock('../../store/serverGroups', () => ({
default: (selector) =>
selector({
serverGroups: [
{ id: 1, name: 'Pool A' },
{ id: 2, name: 'Pool B' },
],
isLoading: false,
error: null,
fetchServerGroups: vi.fn().mockResolvedValue(undefined),
}),
}));
vi.mock('../../store/playlists', () => ({
default: (selector) =>
selector({
playlists: [{ id: 10, server_group: 2 }],
}),
}));
vi.mock('../forms/ServerGroup', () => ({
default: () => null,
}));
vi.mock('../../hooks/useLocalStorage', () => ({
default: () => ['default', vi.fn()],
}));
const renderModal = (props = {}) =>
render(
<MantineProvider>
<ServerGroupsManagerModal isOpen onClose={vi.fn()} {...props} />
</MantineProvider>
);
describe('ServerGroupsManagerModal', () => {
beforeEach(() => {
vi.clearAllMocks();
});
it('opens without Mantine Tooltip errors', async () => {
expect(() => renderModal()).not.toThrow();
await waitFor(() => {
expect(screen.getByText('Pool A')).toBeInTheDocument();
expect(screen.getByText('Pool B')).toBeInTheDocument();
expect(screen.getByText('Accounts')).toBeInTheDocument();
});
});
});

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