Compare commits

..

921 commits
v0.7.0 ... main

Author SHA1 Message Date
SergeantPanda
8521df94ad
Merge pull request #868 from DawtCom:main
Some checks failed
Build and Push Multi-Arch Docker Image / build-and-push (push) Has been cancelled
Move caching to client to remove burden on dispatch server
2026-01-18 17:26:49 -06:00
DawtCom
c970cfcf9a Move caching to client to remove burden on dispatch server 2026-01-18 00:49:17 -06:00
SergeantPanda
fe60c4f3bc Enhancement: Update frontend tests workflow to ensure proper triggering on push and pull request events only when frontend code changes.
Some checks failed
Build and Push Multi-Arch Docker Image / build-and-push (push) Waiting to run
Frontend Tests / test (push) Has been cancelled
2026-01-17 18:30:13 -06:00
SergeantPanda
7cf7aecdf2
Merge pull request #857 from Dispatcharr/dev
Some checks failed
Build and Push Multi-Arch Docker Image / build-and-push (push) Has been cancelled
Frontend Tests / test (push) Has been cancelled
Dev
2026-01-15 09:05:06 -06:00
SergeantPanda
54644df9a3 Test Fix: Fixed SettingsUtils frontend tests for new grouped settings architecture. Updated test suite to properly verify grouped JSON settings (stream_settings, dvr_settings, etc.) instead of individual CharField settings, including tests for type conversions, array-to-CSV transformations, and special handling of proxy_settings and network_access. Frontend tests GitHub workflow now uses Node.js 24 (matching Dockerfile) and runs on both main and dev branch pushes and pull requests for comprehensive CI coverage.
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-01-15 08:55:38 -06:00
SergeantPanda
38fa0fe99d Bug Fix: Fixed NumPy baseline detection in Docker entrypoint. Now calls numpy.show_config() directly with case-insensitive grep instead of incorrectly wrapping the output.
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-01-14 17:10:06 -06:00
SergeantPanda
a772f5c353 changelog: Update missed close on 0.17.0 changelog.
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-01-13 17:03:30 -06:00
GitHub Actions
da186bcb9d Release v0.17.0
Some checks failed
Build and Push Multi-Arch Docker Image / build-and-push (push) Has been cancelled
Frontend Tests / test (push) Has been cancelled
2026-01-13 22:48:13 +00:00
SergeantPanda
75df00e329
Merge pull request #849 from Dispatcharr/dev
Version 0.17.0
2026-01-13 16:45:33 -06:00
SergeantPanda
d0ed682b3d Bug Fix: Fixed bulk channel profile membership update endpoint silently ignoring channels without existing membership records. The endpoint now creates missing memberships automatically (matching single-channel endpoint behavior), validates that all channel IDs exist before processing, and provides detailed response feedback including counts of updated vs. created memberships. Added comprehensive Swagger documentation with request/response schemas.
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-01-13 15:43:44 -06:00
SergeantPanda
60955a39c7 changelog: Update changelog for settings refactor. 2026-01-13 14:35:01 -06:00
SergeantPanda
6c15ae940d changelog: Update changelog for PR 837 2026-01-13 14:30:25 -06:00
SergeantPanda
516d0e02aa
Merge pull request #837 from mdellavo/bulk-update-fix 2026-01-13 14:26:04 -06:00
Marc DellaVolpe
6607cef5d4 fix bulk update error on unmatched first entry, add tests to cover bulk update api 2026-01-13 15:05:40 -05:00
SergeantPanda
2f9b544519
Merge pull request #848 from Dispatcharr/settings-refactor
Refactor CoreSettings to use JSONField for value storage and update r…
2026-01-13 13:34:50 -06:00
SergeantPanda
36967c10ce Refactor CoreSettings to use JSONField for value storage and update related logic for proper type handling. Adjusted serializers and forms to accommodate new data structure, ensuring seamless integration across the application. 2026-01-13 12:18:34 -06:00
SergeantPanda
4bfdd15b37 Bug Fix: Fixed PostgreSQL backup restore not completely cleaning database before restoration. The restore process now drops and recreates the entire public schema before running pg_restore, ensuring a truly clean restore that removes all tables, functions, and other objects not present in the backup file. This prevents leftover database objects from persisting when restoring backups from older branches or versions. Added --no-owner flag to pg_restore to avoid role permission errors when the backup was created by a different PostgreSQL user.
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-01-12 16:38:20 -06:00
SergeantPanda
2a3d0db670 Enhancement: Loading feedback for all confirmation dialogs: Extended visual loading indicators across all confirmation dialogs throughout the application. Delete, cleanup, and bulk operation dialogs now show an animated dots loader and disabled state during async operations, providing consistent user feedback for backups (restore/delete), channels, EPGs, logos, VOD logos, M3U accounts, streams, users, groups, filters, profiles, batch operations, and network access changes.
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-01-12 13:53:44 -06:00
SergeantPanda
43636a84d0 Enhancement: Added visual loading indicator to the backup restore confirmation dialog. When clicking "Restore", the button now displays an animated dots loader and becomes disabled, providing clear feedback that the restore operation is in progress. 2026-01-12 13:22:24 -06:00
SergeantPanda
6d5d16d667 Enhancement: Add check for existing NumPy baseline support before reinstalling legacy NumPy to avoid unnecessary installations. 2026-01-12 12:29:54 -06:00
SergeantPanda
f821dabe8e Enhancement: Users can now rename existing channel profiles and create duplicates with automatic channel membership cloning. Each profile action (edit, duplicate, delete) in the profile dropdown for quick access. 2026-01-12 11:29:33 -06:00
SergeantPanda
564dceb210 Bug fix: Fixed TV Guide loading overlay not disappearing after navigating from DVR page. The fetchRecordings() function in the channels store was setting isLoading: true on start but never resetting it to false on successful completion, causing the Guide page's loading overlay to remain visible indefinitely when accessed after the DVR page.
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-01-11 20:51:26 -06:00
SergeantPanda
2e9280cf59
Merge pull request #834 from justinforlenza/stream-profile-argument-parse
Fix: Support quoted stream profile arguments
2026-01-11 20:27:15 -06:00
SergeantPanda
7594ba0a08 changelog: Update changelog for command line parsing PR. 2026-01-11 20:24:59 -06:00
Justin
e8d949db86 replaced standard str.split() with shlex.split() 2026-01-11 20:40:18 -05:00
SergeantPanda
a9a433bc5b changelog: Update changelog for test PR submitted. 2026-01-11 19:24:18 -06:00
SergeantPanda
e72e0215cb
Merge pull request #841 from nick4810/tests/frontend-unit-tests
Tests/frontend unit tests
2026-01-11 19:20:37 -06:00
SergeantPanda
b8374fcc68 Refactor/Enhancement: Refactored channel numbering dialogs into a unified CreateChannelModal component that now includes channel profile selection alongside channel number assignment for both single and bulk channel creation. Users can choose to add channels to all profiles, no profiles, or specific profiles with mutual exclusivity between special options ("All Profiles", "None") and specific profile selections. Profile selection defaults to the current table filter for intuitive workflow. 2026-01-11 19:05:07 -06:00
SergeantPanda
6b873be3cf Bug Fix: Fixed bulk and manual channel creation not refreshing channel profile memberships in the UI for all connected clients. WebSocket channels_created event now calls fetchChannelProfiles() to ensure profile membership updates are reflected in real-time for all users without requiring a page refresh. 2026-01-11 17:51:00 -06:00
SergeantPanda
edfa497203 Enhancement: Channel Profile membership control for manual channel creation and bulk operations: Extended the existing channel_profile_ids parameter from POST /api/channels/from-stream/ to also support POST /api/channels/ (manual creation) and bulk creation tasks with the same flexible semantics:
- Omitted parameter (default): Channels are added to ALL profiles (preserves backward compatibility)
  - Empty array `[]`: Channels are added to NO profiles
  - Sentinel value `[0]`: Channels are added to ALL profiles (explicit)
  - Specific IDs `[1, 2, ...]`: Channels are added only to the specified profiles
  This allows API consumers to control profile membership across all channel creation methods without requiring all channels to be added to every profile by default.
2026-01-11 17:31:15 -06:00
Nick Sandstrom
0242eb69ee Updated tests for mocked regex 2026-01-10 20:22:36 -08:00
Nick Sandstrom
93f74c9d91 Squashed commit of the following:
commit df18a89d0562edc8fd8fb5bc4cac702aefb5272c
Author: Nick Sandstrom <32273437+nick4810@users.noreply.github.com>
Date:   Sat Jan 10 19:18:23 2026 -0800

    Updated tests

commit 90240344b89717fbad0e16fe209dbf00c567b1a8
Author: Nick Sandstrom <32273437+nick4810@users.noreply.github.com>
Date:   Sun Jan 4 03:18:41 2026 -0800

    Updated tests

commit 525b7cb32bc8d235613706d6795795a0177ea24b
Author: Nick Sandstrom <32273437+nick4810@users.noreply.github.com>
Date:   Sun Jan 4 03:18:31 2026 -0800

    Extracted component and util logic

commit e54ea2c3173c0ce3cfb0a2d70d76fdd0a66accc8
Author: Nick Sandstrom <32273437+nick4810@users.noreply.github.com>
Date:   Wed Dec 31 11:55:40 2025 -0800

    Updated tests

commit 5cbe164cb9818d8eab607af037da5faee2c1556f
Author: Nick Sandstrom <32273437+nick4810@users.noreply.github.com>
Date:   Wed Dec 31 11:55:14 2025 -0800

    Minor changes

    Exporting UiSettingsForm as default
    Reverted admin level type check

commit f9ab0d2a06091a2eed3ee6f34268c81bfd746f1e
Author: Nick Sandstrom <32273437+nick4810@users.noreply.github.com>
Date:   Tue Dec 30 23:31:29 2025 -0800

    Extracted component and util logic

commit a705a4db4a32d0851d087a984111837a0a83f722
Author: Nick Sandstrom <32273437+nick4810@users.noreply.github.com>
Date:   Sun Dec 28 00:47:29 2025 -0800

    Updated tests

commit a72c6720a3980d0f279edf050b6b51eaae11cdbd
Merge: e8dcab6f 43525ca3
Author: Nick Sandstrom <32273437+nick4810@users.noreply.github.com>
Date:   Sun Dec 28 00:04:24 2025 -0800

    Merge branch 'enhancement/component-cleanup' into test/component-cleanup

commit e8dcab6f832570cb986f114cfa574db4994b3aab
Author: Nick Sandstrom <32273437+nick4810@users.noreply.github.com>
Date:   Sat Dec 27 22:35:59 2025 -0800

    Updated tests

commit 0fd230503844fba0c418ab0a03c46dc878697a55
Author: Nick Sandstrom <32273437+nick4810@users.noreply.github.com>
Date:   Sat Dec 27 22:35:53 2025 -0800

    Added plugins store

commit d987f2de72272f24e26b1ed5bc04bb5c83033868
Author: Nick Sandstrom <32273437+nick4810@users.noreply.github.com>
Date:   Sat Dec 27 22:35:43 2025 -0800

    Extracted component and util logic

commit 5a3138370a468a99c9f1ed0a36709a173656d809
Author: Nick Sandstrom <32273437+nick4810@users.noreply.github.com>
Date:   Wed Dec 24 23:13:07 2025 -0800

    Lazy-loading button modals

commit ac6945b5b55e0e16d050d4412a20c82f19250c4b
Author: Nick Sandstrom <32273437+nick4810@users.noreply.github.com>
Date:   Wed Dec 24 22:41:51 2025 -0800

    Extracted notification util

commit befe159fc06b67ee415f7498b5400fee0dc82528
Author: Nick Sandstrom <32273437+nick4810@users.noreply.github.com>
Date:   Wed Dec 24 22:28:12 2025 -0800

    Extracted component and util logic

commit ec10a3a4200a0c94cae29691a9fe06e5c4317bb7
Author: Nick Sandstrom <32273437+nick4810@users.noreply.github.com>
Date:   Wed Dec 24 22:22:09 2025 -0800

    Updated tests

commit c1c7214c8589c0ce7645ea24418d9dd978ac8c1f
Merge: eba6dce7 9c9cbab9
Author: Nick Sandstrom <32273437+nick4810@users.noreply.github.com>
Date:   Tue Dec 23 12:41:25 2025 -0800

    Merge branch 'enhancement/component-cleanup' into test/component-cleanup

commit eba6dce786495e352d4696030500db41d028036e
Author: Nick Sandstrom <32273437+nick4810@users.noreply.github.com>
Date:   Sun Dec 21 10:12:19 2025 -0800

    Updated style props

commit 2024b0b267b849a5f100e5543b9188e8ad6dd3d9
Merge: b3700956 1029eb5b
Author: Nick Sandstrom <32273437+nick4810@users.noreply.github.com>
Date:   Sun Dec 21 09:27:21 2025 -0800

    Merge branch 'enhancement/component-cleanup' into test/component-cleanup

commit b3700956a4c2f473f1e977826f9537d27ea018ae
Author: Nick Sandstrom <32273437+nick4810@users.noreply.github.com>
Date:   Thu Dec 18 07:45:36 2025 -0800

    Reverted Channels change

commit 137cbb02473b7f2f41488601e3b64e5ff45ac656
Merge: 644ed001 2a0df81c
Author: Nick Sandstrom <32273437+nick4810@users.noreply.github.com>
Date:   Wed Dec 17 13:36:05 2025 -0800

    Merge branch 'enhancement/component-cleanup' into test/component-cleanup

commit 644ed00196c41eaa44df1b98236b7e5cc3124d82
Author: Nick Sandstrom <32273437+nick4810@users.noreply.github.com>
Date:   Wed Dec 17 13:29:13 2025 -0800

    Updated tests

commit c62d1bd0534aa19be99b8f87232ba872420111a0
Author: Nick Sandstrom <32273437+nick4810@users.noreply.github.com>
Date:   Tue Dec 16 14:12:31 2025 -0800

    Updated tests

commit 0cc0ee31d5ad84c59d8eba9fc4424f118f5e0ee2
Author: Nick Sandstrom <32273437+nick4810@users.noreply.github.com>
Date:   Tue Dec 16 13:44:55 2025 -0800

    Extracted component and util logic

commit 25d1b112af250b5ccebb1006511bff8e4387fc76
Author: Nick Sandstrom <32273437+nick4810@users.noreply.github.com>
Date:   Tue Dec 16 13:44:11 2025 -0800

    Added correct import for Text component

commit d8a04c6c09edf158220d3073939c9fb60069745c
Author: Nick Sandstrom <32273437+nick4810@users.noreply.github.com>
Date:   Tue Dec 16 13:43:55 2025 -0800

    Fixed component syntax

commit 59e35d3a4d0da8ed8476560cedacadf76162ea43
Author: Nick Sandstrom <32273437+nick4810@users.noreply.github.com>
Date:   Tue Dec 16 13:43:39 2025 -0800

    Fixed cache_url fallback

commit d2a170d2efd3d2b0e6078c9eebeb8dcea237be3b
Merge: b8f7e435 6c1b0f9a
Author: Nick Sandstrom <32273437+nick4810@users.noreply.github.com>
Date:   Tue Dec 16 12:00:45 2025 -0800

    Merge branch 'enhancement/component-cleanup' into test/component-cleanup

commit b8f7e4358a23f2e3a902929b57ab7a7d115241c5
Merge: 5b12c68a d97f0c90
Author: Nick Sandstrom <32273437+nick4810@users.noreply.github.com>
Date:   Mon Dec 15 07:42:06 2025 -0800

    Merge branch 'enhancement/component-cleanup' into test/component-cleanup

commit 5b12c68ab8ce429adc8d1355632aa411007d365b
Merge: eff58126 c63cb75b
Author: Nick Sandstrom <32273437+nick4810@users.noreply.github.com>
Date:   Mon Dec 8 16:56:14 2025 -0800

    Merge branch 'enhancement/unit-tests' into stage

commit eff58126fb6aba4ebe9a0c67eee65773bffb8ae9
Author: Nick Sandstrom <32273437+nick4810@users.noreply.github.com>
Date:   Mon Dec 8 16:49:43 2025 -0800

    Update .gitignore

commit c63cb75b8cad204d48a392a28d8a5bdf8c270496
Author: Nick Sandstrom <32273437+nick4810@users.noreply.github.com>
Date:   Mon Dec 8 16:28:03 2025 -0800

    Added unit tests for pages

commit 75306a6181ddeb2eaeb306387ba2b44c7fcfd5e3
Author: Nick Sandstrom <32273437+nick4810@users.noreply.github.com>
Date:   Mon Dec 8 16:27:19 2025 -0800

    Added Actions workflow
2026-01-10 19:36:23 -08:00
Nick Sandstrom
e2e6f61dee Merge remote-tracking branch 'upstream/dev' into tests/frontend-unit-tests 2026-01-10 19:35:40 -08:00
SergeantPanda
719a975210 Enhancement: Visual stale indicators for streams and groups: Added is_stale field to Stream and both is_stale and last_seen fields to ChannelGroupM3UAccount models to track items in their retention grace period. Stale groups display with orange buttons and a warning tooltip, while stale streams show with a red background color matching the visual treatment of empty channels.
Some checks failed
CI Pipeline / prepare (push) Has been cancelled
Build and Push Multi-Arch Docker Image / build-and-push (push) Has been cancelled
CI Pipeline / docker (amd64, ubuntu-24.04) (push) Has been cancelled
CI Pipeline / docker (arm64, ubuntu-24.04-arm) (push) Has been cancelled
CI Pipeline / create-manifest (push) Has been cancelled
2026-01-09 14:57:07 -06:00
SergeantPanda
a84553d15c Enhancement: Stale status indicators for streams and groups: Added is_stale field to both Stream and ChannelGroupM3UAccount models to track items in their grace period (seen in previous refresh but not current). 2026-01-09 13:53:01 -06:00
SergeantPanda
cc9d38212e Enhancement: Groups now follow the same stale retention logic as streams, using the account's stale_stream_days setting. Groups that temporarily disappear from an M3U source are retained for the configured retention period instead of being immediately deleted, preserving user settings and preventing data loss when providers temporarily remove/re-add groups. (Closes #809) 2026-01-09 12:03:55 -06:00
SergeantPanda
caf56a59f3 Bug Fix: Fixed manual channel creation not adding channels to channel profiles. Manually created channels are now added to the selected profile if one is active, or to all profiles if "All" is selected, matching the behavior of channels created from streams.
Some checks are pending
CI Pipeline / prepare (push) Waiting to run
CI Pipeline / docker (amd64, ubuntu-24.04) (push) Blocked by required conditions
CI Pipeline / docker (arm64, ubuntu-24.04-arm) (push) Blocked by required conditions
CI Pipeline / create-manifest (push) Blocked by required conditions
Build and Push Multi-Arch Docker Image / build-and-push (push) Waiting to run
2026-01-09 10:41:04 -06:00
SergeantPanda
ba5aa861e3 Bug Fix: Fixed Channel Profile filter incorrectly applying profile membership filtering even when "Show Disabled" was enabled, preventing all channels from being displayed. Profile filter now only applies when hiding disabled channels. (Fixes #825) 2026-01-09 10:26:09 -06:00
SergeantPanda
312fa11cfb More cleanup of base image.
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-01-08 14:53:25 -06:00
SergeantPanda
ad334347a9 More cleanup of base image.
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-01-08 14:52:58 -06:00
SergeantPanda
74a9d3d0cb
Merge pull request #823 from patchy8736/uwsgi-socket-timeout
Some checks are pending
Base Image Build / prepare (push) Waiting to run
Base Image Build / docker (amd64, ubuntu-24.04) (push) Blocked by required conditions
Base Image Build / docker (arm64, ubuntu-24.04-arm) (push) Blocked by required conditions
Base Image Build / create-manifest (push) Blocked by required conditions
CI Pipeline / prepare (push) Waiting to run
CI Pipeline / docker (amd64, ubuntu-24.04) (push) Blocked by required conditions
CI Pipeline / docker (arm64, ubuntu-24.04-arm) (push) Blocked by required conditions
CI Pipeline / create-manifest (push) Blocked by required conditions
Build and Push Multi-Arch Docker Image / build-and-push (push) Waiting to run
Bug Fix: Add socket-timeout to uWSGI production config to prevent VOD stream timeouts/streams vanishing from stats page
2026-01-08 13:36:18 -06:00
SergeantPanda
fa6315de33 changelog: Fix VOD streams disappearing from stats page during playback by updating uWSGI config to prevent premature cleanup 2026-01-08 13:35:38 -06:00
SergeantPanda
d6c1a2369b Merge branch 'dev' of https://github.com/Dispatcharr/Dispatcharr into pr/patchy8736/823 2026-01-08 13:27:42 -06:00
SergeantPanda
72d9125c36
Merge pull request #811 from nick4810/enhancement/component-cleanup
Extracted component and util logic
2026-01-08 13:07:41 -06:00
SergeantPanda
6e74c370cb changelog: Document refactor of Stats and VOD pages for improved readability and maintainability 2026-01-08 13:06:30 -06:00
SergeantPanda
10447f8c86 Merge branch 'dev' of https://github.com/Dispatcharr/Dispatcharr into pr/nick4810/811 2026-01-08 11:50:39 -06:00
SergeantPanda
1a2d39de91 Merge branch 'dev' of https://github.com/Dispatcharr/Dispatcharr into dev 2026-01-08 10:28:15 -06:00
SergeantPanda
f389420251 Add optional legacy NumPy support for older CPUs in Docker configurations 2026-01-08 10:27:58 -06:00
SergeantPanda
3f6eff96fc changelog: Update changelog for USE_LEGACY_NUMPY support 2026-01-08 10:26:08 -06:00
SergeantPanda
02faa1a4a7
Merge pull request #827 from Dispatcharr/numpy-none-baseline
Enhance Docker setup for legacy NumPy support and streamline installa…
2026-01-08 10:04:58 -06:00
SergeantPanda
c5a3a2af81 Enhance Docker setup for legacy NumPy support and streamline installation process
Some checks are pending
Base Image Build / prepare (push) Waiting to run
Base Image Build / docker (amd64, ubuntu-24.04) (push) Blocked by required conditions
Base Image Build / docker (arm64, ubuntu-24.04-arm) (push) Blocked by required conditions
Base Image Build / create-manifest (push) Blocked by required conditions
Build and Push Multi-Arch Docker Image / build-and-push (push) Waiting to run
2026-01-08 10:02:29 -06:00
SergeantPanda
01370e8892 Bug fix: Fixed duplicate key constraint violations by treating TMDB/IMDB ID values of 0 or '0' as invalid (some providers use this to indicate "no ID"), converting them to NULL to prevent multiple items from incorrectly sharing the same ID. (Fixes #813)
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-01-07 16:38:09 -06:00
SergeantPanda
8cbb55c44b Bug Fix: Fixed Channels table EPG column showing "Not Assigned" on initial load for users with large EPG datasets. Added tvgsLoaded flag to EPG store to track when EPG data has finished loading, ensuring the table waits for EPG data before displaying. EPG cells now show animated skeleton placeholders while loading instead of incorrectly showing "Not Assigned". (Fixes #810) 2026-01-07 16:08:53 -06:00
patchy8736
0441dd7b7e Bug Fix: Add socket-timeout to uWSGI production config to prevent VOD stream timeouts during client buffering
The production uWSGI configuration (docker/uwsgi.ini) was missing the socket-timeout directive, causing it to default to 4 seconds. When clients (e.g., VLC) buffer VOD streams and temporarily stop reading from the HTTP socket, uWSGI's write operations timeout after 4 seconds, triggering premature stream cleanup and causing VOD streams to disappear from the stats page.

The fix adds socket-timeout = 600 to match the existing http-timeout = 600 value, giving uWSGI sufficient time to wait for clients to resume reading from buffered sockets. This prevents:
- uwsgi_response_write_body_do() TIMEOUT !!! errors in logs
- GeneratorExit exceptions and premature stream cleanup
- VOD streams vanishing from the stats page when clients buffer

The debug config already had socket-timeout = 3600, which is why the issue wasn't observed in debug mode. This fix aligns production behavior with the debug config while maintaining the production-appropriate 10-minute timeout duration.
2026-01-07 14:10:17 +01:00
SergeantPanda
30d093a2d3 Fixed bulk_create and bulk_update errors during VOD content refresh by pre-checking object existence with optimized bulk queries (3 queries total instead of N per batch) before creating new objects. This ensures all movie/series objects have primary keys before relation operations, preventing "prohibited to prevent data loss due to unsaved related object" errors. (Fixes #813)
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-01-06 16:12:50 -06:00
SergeantPanda
518c93c398 Enhance Docker setup for legacy NumPy support and streamline installation process 2026-01-06 14:07:37 -06:00
SergeantPanda
cc09c89156
Merge pull request #812 from Dispatcharr/React-Hooke-Form
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
Refactor forms to use react-hook-form and Yup validation
2026-01-04 21:03:10 -06:00
Nick Sandstrom
21c0758cc9 Extracted component and util logic 2026-01-04 18:51:09 -08:00
SergeantPanda
f664910bf4 changelog: Add RHF and removetrailingzeros changes . 2026-01-04 20:49:39 -06:00
SergeantPanda
bc19bf8629 Remove "removeTrailingZeros" prop from the Channel Edit Form 2026-01-04 20:45:52 -06:00
SergeantPanda
16bbc1d875 Refactor forms to use react-hook-form and Yup for validation
- Replaced Formik with react-hook-form in Logo, M3UGroupFilter, M3UProfile, Stream, StreamProfile, and UserAgent components.
- Integrated Yup for schema validation in all updated forms.
- Updated form submission logic to accommodate new form handling methods.
- Adjusted state management and error handling to align with react-hook-form's API.
- Ensured compatibility with existing functionality while improving code readability and maintainability.
2026-01-04 20:40:16 -06:00
SergeantPanda
9612a67412 Change: VOD upstream read timeout reduced from 30 seconds to 10 seconds to minimize lock hold time when clients disconnect during connection phase
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-01-04 15:21:22 -06:00
SergeantPanda
4e65ffd113 Bug fix: Fixed VOD profile connection count not being decremented when stream connection fails (timeout, 404, etc.), preventing profiles from reaching capacity limits and rejecting valid stream requests 2026-01-04 15:00:08 -06:00
SergeantPanda
6031885537 Bug Fix: M3UMovieRelation.get_stream_url() and M3UEpisodeRelation.get_stream_url() to use XC client's _normalize_url() method instead of simple rstrip('/'). This properly handles malformed M3U account URLs (e.g., containing /player_api.php or query parameters) before constructing VOD stream endpoints, matching behavior of live channel URL building. (Closes #722) 2026-01-04 14:36:03 -06:00
SergeantPanda
8ae1a98a3b Bug Fix: Fixed onboarding message appearing in the Channels Table when filtered results are empty. The onboarding message now only displays when there are no channels created at all, not when channels exist but are filtered out by current filters. 2026-01-04 14:05:30 -06:00
SergeantPanda
48bdcfbd65 Bug fix: Release workflow Docker tagging: Fixed issue where latest and version tags (e.g., 0.16.0) were creating separate manifests instead of pointing to the same image digest, which caused old latest tags to become orphaned/untagged after new releases. Now creates a single multi-arch manifest with both tags, maintaining proper tag relationships and download statistics visibility on GitHub. 2026-01-04 12:05:01 -06:00
GitHub Actions
e151da27b9 Release v0.16.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
2026-01-04 01:15:46 +00:00
SergeantPanda
fdca1fd165
Merge pull request #803 from Dispatcharr/dev
Some checks are pending
Build and Push Multi-Arch Docker Image / build-and-push (push) Waiting to run
Version 0.16.0
2026-01-03 19:15:10 -06:00
SergeantPanda
9cc90354ee changelog: Update changelog for region code addition.
Some checks failed
CI Pipeline / prepare (push) Has been cancelled
Build and Push Multi-Arch Docker Image / build-and-push (push) Has been cancelled
CI Pipeline / docker (amd64, ubuntu-24.04) (push) Has been cancelled
CI Pipeline / docker (arm64, ubuntu-24.04-arm) (push) Has been cancelled
CI Pipeline / create-manifest (push) Has been cancelled
2026-01-02 15:45:05 -06:00
SergeantPanda
62b6cfa2fb
Merge pull request #423 from bigpandaaaa/uk-region
Add 'UK' region
2026-01-02 15:39:27 -06:00
SergeantPanda
3f46f28a70 Bug Fix: Auto Channel Sync Force EPG Source feature not properly forcing "No EPG" assignment - When selecting "Force EPG Source" > "No EPG (Disabled)", channels were still being auto-matched to EPG data instead of forcing dummy/no EPG. Now correctly sets force_dummy_epg flag to prevent unwanted EPG assignment. (Fixes #788) 2026-01-02 15:22:25 -06:00
SergeantPanda
058de26bdf
Merge pull request #787 from nick4810/enhancement/component-cleanup
Enhancement/component cleanup
2026-01-02 13:56:08 -06:00
SergeantPanda
f51463162c
Merge pull request #794 from sethwv:dev
Fix root-owned __pycache__ by running Django commands as non-root user
2026-01-02 12:06:14 -06:00
SergeantPanda
0cb189acba changelog: Document Docker container file permissions update for Django management commands 2026-01-02 12:03:42 -06:00
SergeantPanda
3fe5ff9130 Merge branch 'dev' of https://github.com/Dispatcharr/Dispatcharr into pr/sethwv/794 2026-01-02 11:54:14 -06:00
SergeantPanda
131ebf9f55 changelog: Updated changelog for new refactor. 2026-01-02 11:29:01 -06:00
SergeantPanda
2ed784e8c4 Merge branch 'dev' of https://github.com/Dispatcharr/Dispatcharr into pr/nick4810/787 2026-01-02 11:27:22 -06:00
Nick Sandstrom
2e0aa90cd6 Merge remote-tracking branch 'upstream/dev' into enhancement/component-cleanup 2026-01-02 08:33:06 -08:00
SergeantPanda
a363d9f0e6
Merge pull request #796 from patchy8736: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
Fix episode processing issues in VOD tasks (#770)
2026-01-02 10:13:48 -06:00
SergeantPanda
6a985d7a7d changelog: Update changelog for PR 2026-01-02 10:13:01 -06:00
SergeantPanda
1a67f3c8ec Merge branch 'dev' of https://github.com/Dispatcharr/Dispatcharr into pr/patchy8736/796 2026-01-02 09:53:54 -06:00
SergeantPanda
6bd8a0c12d Enhance error logging for invalid season and episode numbers in batch_process_episodes 2026-01-02 09:53:45 -06:00
Nick Sandstrom
6678311fa7 Added loading overlay while programs are fetching 2026-01-02 02:03:50 -08:00
SergeantPanda
e8c9432f65 changelog: Update changelog for VOD category filtering.
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-01-01 18:29:54 -06:00
SergeantPanda
33f988b2c6
Merge pull request #784 from Vitekant:fix/vod-category-pipe-parsing
Fix VOD category filtering for names containing pipe "|" characters
2026-01-01 18:28:07 -06:00
SergeantPanda
13e4b19960 changelog: Add change for settings/logo refactor. 2026-01-01 18:21:52 -06:00
SergeantPanda
042c34eecc
Merge pull request #795 from nick4810/enhancement/component-cleanup-logos-settings 2026-01-01 18:15:47 -06:00
SergeantPanda
ded785de54
Merge pull request #789 from nick4810/fix/standard-users-signal-logos-ready
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-01-01 17:37:01 -06:00
patchy8736
c57f9fd7e7 Fix episode processing issues in VOD tasks
- Ensure season and episode numbers are properly converted to integers with error handling
- Remove zero-padding from debug log format for season/episode numbers
- Add validation to filter out relations with unsaved episodes that have no primary key
- Add proper logging for skipped relations when episode is not saved to database

These changes address potential crashes when API returns string values instead of integers
and prevent database errors when bulk creation operations fail silently due to conflicts.

Fixes issue #770
2026-01-01 15:57:27 +01:00
Nick Sandstrom
b4b0774189 Including notification util changes 2025-12-31 13:20:09 -08:00
Nick Sandstrom
7b1a85617f Minor changes
Exporting UiSettingsForm as default
Reverted admin level type check
2025-12-31 13:12:24 -08:00
Nick Sandstrom
a6361a07d2 Extracted component and util logic 2025-12-31 13:12:24 -08:00
sethwv-alt
b157159b87
Fix root-owned __pycache__ by running Django commands as non-root user 2025-12-31 12:16:19 -05:00
Nick Sandstrom
d9fc0e68d6 Signaling ready when no StreamTable rendered 2025-12-29 22:18:42 -08:00
Nick Sandstrom
43525ca32a Moved RecordingList outside of DVRPage
Helps to prevent renders
2025-12-27 23:49:06 -08:00
Nick Sandstrom
ffa1331c3b Updated to use util functions 2025-12-27 23:17:42 -08:00
Nick Sandstrom
26d9dbd246 Added plugins store 2025-12-27 22:45:48 -08:00
Nick Sandstrom
f97399de07 Extracted component and util logic 2025-12-27 22:45:48 -08:00
Nick Sandstrom
a5688605cd Lazy-loading button modals 2025-12-27 22:45:48 -08:00
Nick Sandstrom
ca96adf781 Extracted notification util 2025-12-27 22:45:48 -08:00
Nick Sandstrom
61247a452a Extracted component and util logic 2025-12-27 22:45:48 -08:00
Nick Sandstrom
fda188e738 Updated style props 2025-12-27 22:45:48 -08:00
SergeantPanda
57a6a842b2 Bug Fix/Enhancement:
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
- M3U and EPG URLs now correctly preserve non-standard HTTPS ports (e.g., `:8443`) when accessed behind reverse proxies that forward the port in headers — `get_host_and_port()` now properly checks `X-Forwarded-Port` header before falling back to other detection methods (Fixes #704)
- M3U stream URLs now use `build_absolute_uri_with_port()` for consistency with EPG and logo URLs, ensuring uniform port handling across all M3U file URLs
2025-12-27 09:57:36 -06:00
SergeantPanda
f1c096bc94 Bug Fix: XtreamCodes M3U files now correctly set x-tvg-url and url-tvg headers to reference XC EPG URL (xmltv.php) instead of standard EPG endpoint when downloaded via XC API (Fixes #629) 2025-12-27 08:19:58 -06:00
Vitek
5a4be532fd Fix VOD category filtering for names containing pipe characters
Use rsplit('|', 1) instead of split('|', 1) to split from the right,
preserving any pipe characters in category names (e.g., "PL | BAJKI",
"EN | MOVIES"). This ensures the category_type is correctly extracted
as the last segment while keeping the full category name intact.

Fixes MovieFilter, SeriesFilter, and UnifiedContentViewSet category parsing.
2025-12-27 00:21:42 +01:00
SergeantPanda
cc3ed80e1a changelog: Add thanks for errorboundary.
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
2025-12-26 16:16:25 -06:00
SergeantPanda
af88756197
Merge pull request #761 from nick4810/enhancement/component-cleanup
Enhancement/component cleanup
2025-12-26 16:08:49 -06:00
SergeantPanda
1b1f360705 Enhancement: Channel number inputs in stream-to-channel creation modals no longer have a maximum value restriction, allowing users to enter any valid channel number supported by the database 2025-12-26 15:55:25 -06:00
SergeantPanda
bc3ef1a3a9 Bug Fix: M3U and EPG manager page no longer crashes when a playlist references a deleted channel group (Fixes screen blank on navigation)
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
2025-12-26 14:58:02 -06:00
SergeantPanda
81af73a086 changelog: Add entry for stream validation continuing GET request on HEAD failure 2025-12-26 13:53:48 -06:00
SergeantPanda
0abacf1fef
Merge pull request #783 from kvnnap/dev 2025-12-26 13:51:03 -06:00
SergeantPanda
36a39cd4de Bug fix: XtreamCodes EPG limit parameter now properly converted to integer to prevent type errors when accessing EPG listings (Fixes #781) 2025-12-26 13:34:53 -06:00
SergeantPanda
46413b7e3a changelog: Update changelog for code refactoring and logo changes. 2025-12-26 12:44:26 -06:00
SergeantPanda
874e981449 Merge branch 'dev' of https://github.com/Dispatcharr/Dispatcharr into pr/nick4810/761 2025-12-26 12:37:57 -06:00
SergeantPanda
f5c6d2b576 Enhancement: Implement event-driven logo loading orchestration on Channels page
Introduce gated logo loading system that ensures logos render after both
ChannelsTable and StreamsTable have completed their initial data fetch,
preventing visual race conditions and ensuring proper paint order.

Changes:
- Add `allowLogoRendering` flag to logos store to gate logo fetching
- Implement `onReady` callbacks in ChannelsTable and StreamsTable
- Add orchestration logic in Channels.jsx to coordinate table readiness
- Use double requestAnimationFrame to defer logo loading until after browser paint
- Remove background logo loading from App.jsx (now page-specific)
- Simplify fetchChannelAssignableLogos to reuse fetchAllLogos
- Remove logos dependency from ChannelsTable columns to prevent re-renders

This ensures visual loading order: Channels → EPG → Streams → Logos,
regardless of network speed or data size, without timer-based hacks.
2025-12-26 12:30:08 -06:00
Kevin Napoli
1ef5a9ca13
Fix: Continue GET request if HEAD fails with the peer closing the connection without returning a response 2025-12-26 15:27:51 +01:00
SergeantPanda
2d31eca93d changelog: Correct formatting for thanks of event viewer arrow direction fix entry
Some checks failed
Base Image Build / prepare (push) Has been cancelled
CI Pipeline / prepare (push) Has been cancelled
Build and Push Multi-Arch Docker Image / build-and-push (push) Has been cancelled
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
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
2025-12-24 16:15:23 -06:00
SergeantPanda
510c9fc617
Merge pull request #757 from sethwv/dev 2025-12-24 16:14:29 -06:00
SergeantPanda
8f63659ad7 changelog: Update changelog for VLC support 2025-12-24 16:11:52 -06:00
SergeantPanda
31b9868bfd Merge branch 'dev' of https://github.com/Dispatcharr/Dispatcharr into pr/sethwv/757 2025-12-24 16:04:04 -06:00
SergeantPanda
da4597ac95 Merge branch 'main' of https://github.com/Dispatcharr/Dispatcharr into dev 2025-12-24 15:49:20 -06:00
SergeantPanda
523a127c81 Enhancement: Add VLC dependencies to DispatcharrBase image.
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
2025-12-24 15:48:56 -06:00
SergeantPanda
ec3093d9af Changelog: Update changelog to include client IP display in network access warning modal 2025-12-24 15:43:33 -06:00
SergeantPanda
5481b18d8a
Merge pull request #779 from damien-alt-sudo/feature/ui-network-access-clientip 2025-12-24 15:39:31 -06:00
Damien
bfca663870 Feature: Add client_ip response from settings check api to UI 2025-12-24 19:30:03 +00:00
Damien
11b3320277 Feature: Add client_ip response from settings check API 2025-12-24 19:27:38 +00:00
SergeantPanda
44a122924f advanced filtering for hiding disabled channels and viewing only empty channels
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
(cherry picked from commit ea38c0b4b8)
Closes #182
2025-12-23 17:37:38 -06:00
SergeantPanda
48ebaffadd Cleanup dockerfile a bit. 2025-12-23 17:04:09 -06:00
SergeantPanda
daa919c764 Refactor logging messages in StreamManager for clarity and consistency. Also removed redundant parsing. 2025-12-23 15:52:56 -06:00
SergeantPanda
8f811f2ed3 Correct profile name casing for FFmpeg, Streamlink, and VLC in fixtures.json 2025-12-23 15:17:50 -06:00
SergeantPanda
ff7298a93e Enhance StreamManager for efficient log parsing and update VLC stream profile naming 2025-12-23 15:07:25 -06:00
Nick Sandstrom
9c9cbab94c Reverted lazy load of StreamsTable 2025-12-23 12:27:29 -08:00
SergeantPanda
904500906c Bug Fix: Update stream validation to return original URL instead of redirected URL when using redirect profile. 2025-12-23 09:51:02 -06:00
SergeantPanda
106ea72c9d Changelog: Fix event viewer arrow direction for corrected UI behavior 2025-12-22 17:38:55 -06:00
drnikcuk
eea84cfd8b
Update Stats.jsx (#773)
* Update Stats.jsx

Adds fix for stats control arrows direction swap
2025-12-22 17:33:26 -06:00
GitHub Actions
c7590d204e Release v0.15.1 2025-12-22 22:58:41 +00:00
SergeantPanda
7a0af3445a
Merge pull request #774 from Dispatcharr/dev
Version 0.15.1
2025-12-22 16:55:59 -06:00
SergeantPanda
18645fc08f Bug Fix: Re-apply failed merge to fix clients that don't have ipv6 support. 2025-12-22 16:39:09 -06:00
Seth Van Niekerk
aa5db6c3f4
Squash: Log Parsing Refactor & Enhancing 2025-12-22 15:14:46 -05:00
Nick Sandstrom
1029eb5b5c Table length checking if data is already set 2025-12-19 19:19:04 -08:00
SergeantPanda
ee183a9f75 Bug Fix: XtreamCodes EPG has_archive field now returns integer 0 instead of string "0" for proper JSON type consistency 2025-12-19 18:39:43 -06:00
nick4810
63daa3ddf2
Merge branch 'dev' into enhancement/component-cleanup 2025-12-19 16:35:26 -08:00
Nick Sandstrom
4cd63bc898 Reverted LoadingOverlay 2025-12-19 16:33:21 -08:00
GitHub Actions
05b62c22ad Release v0.15.0 2025-12-20 00:08:41 +00:00
SergeantPanda
2c12e8b872
Merge pull request #767 from Dispatcharr/dev
Version 0.15.0
2025-12-19 17:55:40 -06:00
SergeantPanda
20182c7ebf
Merge branch 'main' into dev 2025-12-19 17:53:06 -06:00
SergeantPanda
f0a9a3fc15 Bug Fix: Docker init script now validates DISPATCHARR_PORT is an integer before using it, preventing sed errors when Kubernetes sets it to a service URL like tcp://10.98.37.10:80. Falls back to default port 9191 when invalid (Fixes #737) 2025-12-19 17:00:30 -06:00
nick4810
097551ccf7
Merge branch 'dev' into enhancement/component-cleanup 2025-12-19 14:11:01 -08:00
Nick Sandstrom
22527b085d Checking if data has been fetched before displaying empty channels 2025-12-19 14:09:17 -08:00
SergeantPanda
944736612b Bug Fix: M3U profile form resets local state for search and replace patterns after saving, preventing validation errors when adding multiple profiles in a row 2025-12-19 15:49:18 -06:00
SergeantPanda
abc6ae94e5 Enhancement: Update SuperuserForm to include logo, version info, and improved layout 2025-12-19 10:44:39 -06:00
SergeantPanda
5371519d8a Enhancement: Update default backup settings to enable backups and set retention count to 3 2025-12-19 10:40:56 -06:00
SergeantPanda
b83f12809f Enhancement: Add HEADER_HEIGHT and ERROR_HEIGHT constants for improved layout calculations in FloatingVideo component 2025-12-18 17:18:44 -06:00
SergeantPanda
601f7d0297 changelog: Update changelog for DVR bug fix. 2025-12-18 16:57:43 -06:00
SergeantPanda
de31826137 refactor: externalize Redis and Celery configuration via environment variables
Replace hardcoded localhost:6379 values throughout codebase with environment-based configuration. Add REDIS_PORT support and allow REDIS_URL override for external Redis services. Configure Celery broker/result backend to use Redis settings with environment variable overrides.

Closes #762
2025-12-18 16:54:59 -06:00
SergeantPanda
e78c18c473 Bug Fix: XC get_simple_data_table now returns the id of the program in the database and epg_id the epg id from the matched epg. 2025-12-18 16:11:26 -06:00
SergeantPanda
73956924f5 Enhancement: Stream group as available hash option: Users can now select 'Group' as a hash key option in Settings → Stream Settings → M3U Hash Key, allowing streams to be differentiated by their group membership in addition to name, URL, TVG-ID, and M3U ID 2025-12-18 15:26:08 -06:00
SergeantPanda
0a4d27c236 Merge branch 'dev' of https://github.com/Dispatcharr/Dispatcharr into dev 2025-12-18 14:47:04 -06:00
SergeantPanda
45ea63e9cf chore: update dependencies in package.json
- Bump eslint from ^9.21.0 to ^9.27.0
- Upgrade vite from ^6.2.0 to ^7.1.7
- Add overrides for js-yaml to ^4.1.1
2025-12-18 14:45:55 -06:00
Dispatcharr
1510197bf0 Floating Video
Added handles on the corners of FloatingVideo to resize
2025-12-18 14:19:51 -06:00
SergeantPanda
9623dff6b1 Enhancement: Updated dependencies: Django (5.2.4 → 5.2.9) includes CVE security patch, psycopg2-binary (2.9.10 → 2.9.11), celery (5.5.3 → 5.6.0), djangorestframework (3.16.0 → 3.16.1), requests (2.32.4 → 2.32.5), psutil (7.0.0 → 7.1.3), gevent (25.5.1 → 25.9.1), rapidfuzz (3.13.0 → 3.14.3), torch (2.7.1 → 2.9.1), sentence-transformers (5.1.0 → 5.2.0), lxml (6.0.0 → 6.0.2) (Closes #662) 2025-12-18 13:19:18 -06:00
SergeantPanda
3ddcadb50d changelog: Give acknowledgement and reference issue. 2025-12-18 11:07:13 -06:00
SergeantPanda
1e42aa1011
Merge pull request #763 from Dispatcharr/OCI-docker-labels
Enhancement: Refactor Docker workflows to use docker/metadata-action …
2025-12-18 11:02:50 -06:00
SergeantPanda
ee0502f559
Merge branch 'dev' into OCI-docker-labels 2025-12-18 11:01:55 -06:00
SergeantPanda
f43de44946 Enhancement: Refactor Docker workflows to use docker/metadata-action for cleaner OCI label management 2025-12-18 10:58:48 -06:00
Nick Sandstrom
2b1d5622a6 Setting User before fetch settings completes 2025-12-18 07:47:18 -08:00
Nick Sandstrom
bd148a7f14 Reverted Channels change for initial render 2025-12-18 07:46:21 -08:00
SergeantPanda
a76a81c7f4
Merge pull request #738 from jdblack/flexible_devbuild
Give arguments to docker/build-dev.sh
2025-12-18 08:57:45 -06:00
SergeantPanda
bd57ee3f3c
Merge branch 'dev' into flexible_devbuild 2025-12-18 08:56:58 -06:00
SergeantPanda
2558ea0b0b Enhancement: Add VOD client stop functionality to Stats page 2025-12-17 16:54:10 -06:00
Nick Sandstrom
2a0df81c59 Lazy loading components 2025-12-17 13:35:12 -08:00
Nick Sandstrom
1906c9955e Updated to default export 2025-12-17 13:34:53 -08:00
Nick Sandstrom
4c60ce0c28 Extracted Series and Movie components 2025-12-17 13:34:20 -08:00
Dispatcharr
865ba432d3 Updated url path
#697 Encode tvg_id for DVR series rule deletions
2025-12-16 23:06:49 -06:00
Dispatcharr
7ea843956b Updated FloatingVideo.jsx
Added resizing of the floating video
Fixed floating video dragging
2025-12-16 21:52:35 -06:00
SergeantPanda
98a016a418 Enhance series info retrieval to return unique episodes and improve relation handling for active M3U accounts 2025-12-16 15:54:33 -06:00
Nick Sandstrom
36ec2fb1b0 Extracted component and util logic 2025-12-16 13:46:24 -08:00
Nick Sandstrom
dd75b5b21a Added correct import for Text component 2025-12-16 13:46:24 -08:00
Nick Sandstrom
38033da90f Fixed component syntax 2025-12-16 13:46:24 -08:00
Nick Sandstrom
7c45542332 Fixed cache_url fallback 2025-12-16 13:46:24 -08:00
SergeantPanda
748d5dc72d Bug Fix: When multiple m3uepisoderelations for a requested episode existed, the xc api would fail.(Fixes #569) 2025-12-16 15:44:42 -06:00
SergeantPanda
48e7060cdb Bug Fix: VOD episode processing now correctly handles duplicate episodes from the same provider. (Fixes #556) 2025-12-16 15:24:16 -06:00
Nick Sandstrom
6c1b0f9a60 Extracted component and util logic 2025-12-16 11:55:22 -08:00
Nick Sandstrom
ffd8d9fe6b Using util for getPosterUrl 2025-12-16 11:53:45 -08:00
Nick Sandstrom
0ba22df233 Updated Component syntax 2025-12-16 11:53:26 -08:00
Seth Van Niekerk
bc72b2d4a3
Add VLC and streamlink codec parsing support 2025-12-15 20:09:54 -05:00
Seth Van Niekerk
88c10e85c3
Add VLC TS demux output detection for codec parsing 2025-12-15 20:09:54 -05:00
Seth Van Niekerk
1ad8d6cdfd
Add VLC profile to fixtures with correct parameter order 2025-12-15 20:09:54 -05:00
Seth Van Niekerk
ee7a39fe21
Add VLC stream profile migration with correct parameters 2025-12-15 20:09:54 -05:00
Seth Van Niekerk
3b7f6dadaa
Add VLC packages and environment variables to DispatcharrBase 2025-12-15 20:09:54 -05:00
SergeantPanda
41642cd479 Improve orphaned CrontabSchedule cleanup logic to avoid deleting in-use schedules 2025-12-15 16:54:12 -06:00
SergeantPanda
1b27472c81 changelog: Add automated configuration backup/restore system to changelog 2025-12-15 16:22:38 -06:00
SergeantPanda
a60fd530f3 Merge branch 'dev' of https://github.com/Dispatcharr/Dispatcharr into dev 2025-12-15 16:17:53 -06:00
SergeantPanda
4878e92f44
Merge pull request #488 from stlalpha/feature/automated-backups
Enhancement: Add automated configuration backups
2025-12-15 16:17:33 -06:00
Nick Sandstrom
3bf8ddf376 Removed unused imports 2025-12-15 09:19:54 -08:00
Nick Sandstrom
65dbc5498d Fixed handler arrow functions 2025-12-15 08:21:00 -08:00
Nick Sandstrom
85390a078c Removed unused imports 2025-12-15 07:48:24 -08:00
Jim McBride
bd6cf287dc
Clean up orphaned CrontabSchedule records
- Add _cleanup_orphaned_crontab() helper function
  - Delete old crontab when disabling backup schedule
  - Delete old crontab when schedule settings change
  - Prevents database bloat from unused CrontabSchedule records
2025-12-13 19:02:36 -06:00
Jim McBride
662c5ff89a Reorganize simple mode backup scheduler layout
- Row 1: Frequency, Day (if weekly), Hour, Minute, Period (if 12h)
- Row 2: Retention, Save button
- Use wrap=nowrap to keep time selectors on same row
2025-12-13 18:49:36 -06:00
Jim McBride
1dc7700a62
Add timezone support for backup scheduling
- Set CrontabSchedule timezone to system timezone for accurate scheduling
  - Replace time TextInput with hour/minute Select dropdowns for cleaner UX
  - Remove UTC/local time conversion logic (handled by Celery)
  - Add tests for timezone functionality in simple and advanced modes
2025-12-13 13:27:56 -06:00
Nick Sandstrom
d97f0c907f Updated DVR for extracted logic 2025-12-13 06:33:28 -08:00
Nick Sandstrom
ae60f81314 Extracted DVR utils 2025-12-13 06:32:16 -08:00
Nick Sandstrom
bfcc47c331 Extracted DVR components 2025-12-13 06:31:56 -08:00
SergeantPanda
679adb324c changelog: Update changelog to reference github issue. 2025-12-12 17:26:24 -06:00
SergeantPanda
58a6cdedf7 Bug Fix: Fix handling of None values in xc_get_epg output to prevent AttributeError when title and/or description are none. 2025-12-12 17:23:02 -06:00
SergeantPanda
dedd898a29 Changelog: Document removal of unreachable code path in m3u output 2025-12-12 16:44:09 -06:00
SergeantPanda
0b09cd18b9
Merge pull request #725 from DawtCom/main
Removing unreachable code
2025-12-12 16:19:52 -06:00
dekzter
3537c9ee09
Merge pull request #741 from Dispatcharr/hide-disabled-channels
Advanced Filtering
2025-12-12 08:42:34 -05:00
dekzter
97930c3de8
Merge pull request #740 from Dispatcharr/revert-736-hide-disabled-channels
Revert "Advanced Filtering"
2025-12-12 08:41:20 -05:00
dekzter
c51916b40c
Revert "Advanced Filtering" 2025-12-12 08:30:17 -05:00
dekzter
ed61ac656a
Merge pull request #736 from Dispatcharr/hide-disabled-channels
Advanced Filtering
2025-12-12 08:05:21 -05:00
James Blackwell
56cf37d637 Give arguments to docker/build-dev.sh
This command improves docker/build-dev.sh, providing a variety of
arguments to assist building images

 -h for help

 -p push the build

 -r Specify a different registry, such as myname on dockerhub, or
  myregistry.local

 -a arch[,arch] cross build to one or more architectures; .e.g. -a
   linux/arm64,linux/amd64
2025-12-12 12:59:03 +07:00
dekzter
ea38c0b4b8 advanced filtering for hiding disabled channels and viewing only empty channels 2025-12-11 11:54:41 -05:00
Nick Sandstrom
dd5ae8450d Updated pages to utilize error boundary 2025-12-10 22:31:04 -08:00
Nick Sandstrom
0070d9e500 Added ErrorBoundary component 2025-12-10 22:29:48 -08:00
Nick Sandstrom
aea888238a Removed unused pages 2025-12-10 21:38:46 -08:00
Nick Sandstrom
700d0d2383 Moved error logic to separate component 2025-12-10 20:38:28 -08:00
dekzter
0bfd06a5a3 Merge remote-tracking branch 'origin/dev' into hide-disabled-channels 2025-12-10 17:32:50 -05:00
Jim McBride
8388152d79
Use system timezone for backup filenames
Updated create_backup to use the system's configured timezone for backup
filenames instead of always using UTC. This makes filenames more intuitive
and matches users' local time expectations.

Changes:
- Import pytz and CoreSettings
- Get system timezone from CoreSettings.get_system_time_zone()
- Convert current UTC time to system timezone for filename timestamp
- Fallback to UTC if timezone conversion fails
- Internal metadata timestamps remain UTC for consistency

Example:
- System timezone: America/New_York (EST)
- Created at 3:00 PM EST
- Old filename: dispatcharr-backup-2025.12.09.20.00.00.zip (UTC time)
- New filename: dispatcharr-backup-2025.12.09.15.00.00.zip (local time)

This aligns with the timezone-aware scheduling already implemented.
2025-12-09 09:06:22 -06:00
Jim McBride
795934dafe
Give Created column flexible width to prevent wrapping
Changed Created column from fixed size (160px) to flexible minSize (180px)
with whiteSpace: nowrap to ensure date/time displays on a single line.

The column can now expand as needed while maintaining readability of
timestamps in various date/time format combinations.
2025-12-09 08:58:38 -06:00
Jim McBride
70e574e25a
Add tests for cron expression functionality
Added comprehensive test coverage for cron expression support:

New tests in BackupSchedulerTestCase:
- test_cron_expression_stores_value: Verify cron_expression persists correctly
- test_cron_expression_creates_correct_schedule: Verify CrontabSchedule creation from cron
- test_cron_expression_invalid_format: Verify validation rejects malformed expressions
- test_cron_expression_empty_uses_simple_mode: Verify fallback to frequency/time mode
- test_cron_expression_overrides_simple_settings: Verify cron takes precedence

Updated existing tests to include cron_expression field:
- test_get_schedule_settings_defaults: Now checks cron_expression default
- test_get_schedule_success: Added cron_expression to mock response
- test_update_schedule_success: Added cron_expression to mock response

All tests verify the new cron functionality works correctly alongside
existing simple scheduling mode.
2025-12-09 08:54:23 -06:00
Jim McBride
3c76c72479
Add Enabled/Disabled label to Advanced toggle
Added dynamic label to the Advanced (Cron Expression) switch to match
the Scheduled Backups toggle above it.

Now displays:
  Scheduled Backups          [Enabled]
  Advanced (Cron Expression) [Enabled]

Provides consistent UI pattern and clearer status indication.
2025-12-09 08:38:57 -06:00
Jim McBride
53159bd420
Improve Advanced toggle layout alignment
Changed Advanced (Cron Expression) from a labeled switch to a proper
Group with space-between layout matching the Scheduled Backups row above.

Now displays as:
  Scheduled Backups          [Enabled toggle]
  Advanced (Cron Expression) [Toggle]

This creates consistent visual alignment with both text labels on the left
and toggle switches on the right.
2025-12-09 08:35:55 -06:00
Jim McBride
901cc09e38
Align Advanced toggle below Scheduled Backups header
Moved the Advanced (Cron Expression) switch outside the scheduleLoading
conditional and wrapped it in its own Group for proper alignment.

Layout is now:
- Scheduled Backups header with Enabled/Disabled switch
- Advanced (Cron Expression) toggle (aligned left)
- Schedule configuration inputs (conditional based on mode)

This provides clearer visual hierarchy and better UX.
2025-12-09 08:32:49 -06:00
Jim McBride
d4fbc9dc61
Honor user date/time format preferences for backup timestamps
- Import dayjs for date formatting
- Read date-format setting from localStorage ('mdy' or 'dmy')
- Move formatDate function into component to access user preferences
- Format dates according to user's date and time format settings:
  - MDY: MM/DD/YYYY
  - DMY: DD/MM/YYYY
  - 12h: h:mm:ss A
  - 24h: HH:mm:ss

The Created column now respects the same date/time format preferences
used throughout the app (Guide, Stats, DVR, SystemEvents, etc).
2025-12-09 08:29:28 -06:00
Jim McBride
1a350e79e0
Fix cron validation to support */N step notation
Updated regex to properly support step notation with asterisk (e.g., */2, */5).

Now supports all common cron patterns:
- * (wildcard)
- */2 (every 2 units - step notation)
- 5 (specific value)
- 1-5 (range)
- 1-5/2 (step within range)
- 1,3,5 (list)
- 10-20/5 (step within range)

Changed regex from:
  /^(\*|(\d+(-\d+)?(,\d+(-\d+)?)*)(\/\d+)?)$/
To:
  /^(\*\/\d+|\*|\d+(-\d+)?(\/\d+)?(,\d+(-\d+)?(\/\d+)?)*)$/

The key change is adding \*\/\d+ as the first alternative to explicitly
match step notation like */2, */5, */10, etc.

Backend already supports this via Django Celery Beat's CrontabSchedule,
which accepts standard cron syntax including step notation.
2025-12-09 08:22:20 -06:00
Jim McBride
e71e6bc3d7
Fix backup timestamp display to use UTC timezone
The list_backups function was creating timezone-naive datetime objects,
which caused the frontend to incorrectly interpret timestamps.

Now uses datetime.UTC when creating timestamps from file modification time
(consistent with other usage in this file on lines 186, 216), so the ISO
string includes timezone info (+00:00). This allows the browser to properly
convert UTC timestamps to the user's local timezone for display.

Before: Backend sends "2025-12-09T14:12:44" (ambiguous timezone)
After: Backend sends "2025-12-09T14:12:44+00:00" (explicit UTC)

The frontend's toLocaleString() will now correctly convert to local time.
2025-12-09 08:16:04 -06:00
Jim McBride
c65df2de89
Add real-time validation for cron expressions
- Add validateCronExpression function with comprehensive validation:
  - Checks for exactly 5 parts (minute hour day month weekday)
  - Validates cron syntax (*, ranges, lists, steps)
  - Validates numeric ranges (minute 0-59, hour 0-23, etc.)
  - Returns detailed error messages for each validation failure

- Add cronError state to track validation errors
- Validate on input change with handleScheduleChange
- Display error message below input field
- Disable Save button when cron expression is invalid
- Auto-validate when switching to advanced mode
- Clear errors when switching back to simple mode

User gets immediate feedback on cron syntax errors before attempting to save.
2025-12-09 08:09:56 -06:00
Jim McBride
5fbcaa91e0
Add custom cron expression support for backup scheduling
Frontend changes:
- Add advanced mode toggle switch for cron expressions
- Show cron expression input with helpful examples when enabled
- Display format hints: "minute hour day month weekday"
- Provide common examples (daily, weekly, every 6 hours, etc.)
- Conditionally render simple or advanced scheduling UI
- Support switching between simple and advanced modes

Backend changes:
- Add cron_expression to schedule settings (SETTING_KEYS, DEFAULTS)
- Update get_schedule_settings to include cron_expression
- Update update_schedule_settings to handle cron_expression
- Extend _sync_periodic_task to parse and use cron expressions
- Parse 5-part cron format: minute hour day_of_month month_of_year day_of_week
- Create CrontabSchedule from cron expression or simple frequency
- Add validation and error handling for invalid cron expressions

This addresses maintainer feedback for "custom scheduler (cron style) for more control".
Users can now schedule backups with full cron flexibility beyond daily/weekly.
2025-12-09 07:55:47 -06:00
Jim McBride
d718e5a142
Implement timezone-aware backup scheduling
- Add timezone conversion functions (utcToLocal, localToUtc)
- Use user's configured timezone from Settings (localStorage 'time-zone')
- Convert times to UTC when saving to backend
- Convert times from UTC to local when loading from backend
- Display timezone info showing user's timezone and scheduled time
- Helper text shows: "Timezone: America/New_York • Backup will run at 03:00"

This addresses maintainer feedback to handle timezone properly:
backend stores/schedules in UTC, frontend displays/edits in user's local time.
2025-12-09 07:52:53 -06:00
Jim McBride
806f78244d
Add proper ConfirmationDialog usage to BackupManager
- Import useWarningsStore from warnings store
- Add suppressWarning hook to component
- Add actionKey props to restore and delete confirmation dialogs
- Add onSuppressChange callback to enable "Don't ask again" functionality

This aligns BackupManager with the project's standard confirmation dialog pattern
used throughout the codebase (ChannelsTable, EPGsTable, etc).
2025-12-09 07:49:31 -06:00
DawtCom
e8fb01ebdd Removing unreachable code 2025-12-08 21:50:13 -06:00
SergeantPanda
514e7e06e4 Bug fix: EPG API now returns correct date/time format for start/end fields and proper string types for timestamps and channel_id 2025-12-08 20:50:50 -06:00
SergeantPanda
69f9ecd93c Bug Fix: Remove ipv6 binding from nginx config if ipv6 is not available. 2025-12-08 20:12:44 -06:00
GitHub Actions
4df4e5f963 Release v0.14.0 2025-12-09 00:01:50 +00:00
SergeantPanda
ecbef65891
Merge pull request #723 from Dispatcharr:dev
Version 0.14.0
2025-12-08 17:59:12 -06:00
SergeantPanda
98b29f97a1 changelog: Update verbiage 2025-12-08 17:49:40 -06:00
SergeantPanda
62f5c32609 Remove DJANGO_SECRET_KEY environment variable from uwsgi configuration files 2025-12-08 17:27:07 -06:00
dekzter
43b55e2d99 first run at hiding disabled channels in channel profiles 2025-12-08 08:38:39 -05:00
SergeantPanda
c03ddf60a0 Fixed verbiage for epg parsing status. 2025-12-07 21:28:04 -06:00
SergeantPanda
ce70b04097 changelog: update changelog 2025-12-07 20:56:59 -06:00
SergeantPanda
e2736babaa Reset umask after creating secret file. 2025-12-07 20:04:58 -06:00
SergeantPanda
2155229d7f Fix uwsgi command path in entrypoint script 2025-12-07 19:40:32 -06:00
SergeantPanda
cf37c6fd98 changelog: Updated changelog for 0.13.1 2025-12-07 19:06:45 -06:00
SergeantPanda
3512c3a623 Add DJANGO_SECRET_KEY environment variable to uwsgi configuration files 2025-12-07 19:05:31 -06:00
dekzter
d0edc3fa07 remove permission lines to see if this resolves lack of django secret key in environment profile.d 2025-12-07 07:54:30 -05:00
dekzter
b18bc62983 merged in from main 2025-12-06 14:13:06 -05:00
GitHub Actions
a912055255 Release v0.13.1 2025-12-06 18:43:16 +00:00
dekzter
10f329d673 release notes for built 2025-12-06 13:42:48 -05:00
dekzter
f3a901cb3a Security Fix - generate JWT on application init 2025-12-06 13:40:10 -05:00
SergeantPanda
759569b871 Enhancement: Add a priority field to EPGSource and prefer higher-priority sources when matching channels. Also ignore EPG sources where is_active is false during matching, and update serializers/forms/frontend accordingly.(Closes #603, #672) 2025-12-05 09:54:11 -06:00
SergeantPanda
c1d960138e Fix: Bulk channel editor confirmation dialog now shows the correct stream profile that will be set. 2025-12-05 09:02:03 -06:00
SergeantPanda
0d177e44f8 changelog: Change updated change to bug fix instead of change. 2025-12-04 15:45:09 -06:00
SergeantPanda
3b34fb11ef Fix: Fixes bug where Updated column wouldn't update in the EPG table without a webui refresh. 2025-12-04 15:43:33 -06:00
SergeantPanda
6c8270d0e5 Enhancement: Add support for 'extracting' status and display additional progress information in EPGsTable 2025-12-04 15:28:21 -06:00
SergeantPanda
5693ee7f9e perf: optimize EPG program parsing and implement atomic database updates to reduce I/O overhead and prevent partial data visibility 2025-12-04 14:57:57 -06:00
SergeantPanda
256ac2f55a Enhancement: Clean up orphaned programs for unmapped EPG entries 2025-12-04 14:25:44 -06:00
SergeantPanda
2a8ba9125c perf: optimize EPG program parsing for multi-channel sources
Dramatically improve EPG refresh performance by parsing the XML file once
per source instead of once per channel. The new implementation:

- Pre-filters to only process EPG entries mapped to actual channels
- Parses the entire XML file in a single pass
- Uses O(1) set lookups to skip unmapped channel programmes
- Skips non-mapped channels entirely with minimal overhead

For EPG sources with many channels but few mapped (e.g., 10,000 channels
with 100 mapped to channels), this provides approximately:
- 99% reduction in file open operations
- 99% reduction in XML file scans
- Proportional reduction in CPU and I/O overhead

The parse_programs_for_tvg_id() function is retained for single-channel
use cases (e.g., when a new channel is mapped via signals).

Fixes inefficient repeated file parsing that was occurring with large
EPG sources.
2025-12-04 14:07:28 -06:00
SergeantPanda
2de6ac5da1 changelog: Add sort buttons for 'Group' and 'M3U' columns in Streams table 2025-12-03 17:31:16 -06:00
SergeantPanda
6a96b6b485
Merge pull request #707 from bobey6/main
Enhancement: Add sort by 'Group' or 'M3U' buttons to Streams
2025-12-03 17:27:42 -06:00
SergeantPanda
5fce83fb51 style: Adjust table header and input components for consistent width 2025-12-03 17:13:50 -06:00
SergeantPanda
81b6570366 Fix name not sorting. 2025-12-03 17:03:58 -06:00
SergeantPanda
042612c677 Merge branch 'dev' of https://github.com/Dispatcharr/Dispatcharr into pr/bobey6/707 2025-12-03 16:49:21 -06:00
Jim McBride
e64002dfc4
Refactor BackupManager to match app table conventions 2025-12-02 22:19:20 -06:00
Jim McBride
70cf8928c4
Use CustomTable component for backup list 2025-12-02 22:01:59 -06:00
Jim McBride
3f9fd424e2
Update backup feature based on PR feedback
- Simplify to database-only backups (remove data directory backup)
- Update UI to match app styling patterns:
  - Use ActionIcon with transparent variant for table actions
  - Match icon/color conventions (SquareMinus/red.9, RotateCcw/yellow.5, Download/blue.5)
  - Use standard button bar layout with Paper/Box/Flex
  - Green "Create Backup" button matching "Add" pattern
  - Remove Card wrapper, Alert, and Divider for cleaner layout
  - Update to Mantine v8 Table syntax
- Use standard ConfirmationDialog (remove unused color prop)
- Update tests to remove get_data_dirs references
2025-12-02 19:33:27 -06:00
SergeantPanda
f38fb36eba Skip builds during documentation updates. 2025-12-02 15:03:34 -06:00
SergeantPanda
5e1ae23c4e docs: Update CHANGELOG 2025-12-02 14:58:22 -06:00
SergeantPanda
53a50474ba
Merge pull request #701 from jordandalley/nginx-add-ipv6-bind 2025-12-02 14:49:49 -06:00
SergeantPanda
92ced69bfd
Merge pull request #698 from adrianmace/fix-ipv6-access-issues
fix: Allow all IPv6 CIDRs by default
2025-12-02 14:36:51 -06:00
SergeantPanda
f1320c9a5d Merge branch 'dev' of https://github.com/Dispatcharr/Dispatcharr into pr/stlalpha/488 2025-12-02 13:39:06 -06:00
GitHub Actions
5b193249a8 Release v0.13.0 2025-12-02 19:25:22 +00:00
SergeantPanda
0571c6801a
Merge pull request #709 from Dispatcharr:dev
Version 0.13.0
2025-12-02 13:24:28 -06:00
SergeantPanda
c57c7d64de Docs: Improved wording for DVR date/time formats. 2025-12-02 13:10:34 -06:00
SergeantPanda
0bf3499917 Add update_changelog script and integrate it into release workflow 2025-12-02 12:14:50 -06:00
SergeantPanda
3cb695279a Docs: Added issue references to changelog. 2025-12-02 11:49:32 -06:00
SergeantPanda
2c5fbaffb4 Update changelog with current unreleased changes. 2025-12-02 11:09:09 -06:00
SergeantPanda
85b5b18a57 Add CHANGELOG.md to document project updates and notable changes 2025-12-02 09:40:31 -06:00
SergeantPanda
be0409bfc2 Add referrorpolicy to Youtube iframe's for series and VOD modals. 2025-12-01 18:21:19 -06:00
SergeantPanda
bd3709463a Bug fix: Use UUID instead of ID for episode URL. This fixes links not working in the series modal. 2025-12-01 16:30:51 -06:00
root
cf08e54bd8 Fix sorting functionality for Group and M3U columns
- Add missing header properties to group and m3u columns
- Fix layout issues with sort buttons (proper flex layout, remove blocking onClick)
- Fix sorting state initialization (use boolean instead of empty string)
- Fix sorting comparison operators (use strict equality)
- Fix 3rd click behavior to return to default sort instead of clearing
- Map frontend column IDs to backend field names for proper API requests
2025-12-01 18:11:58 +00:00
GitHub Copilot
641dcfc21e Add sorting functionality to Group and M3U columns in Streams table
- Added m3u_account__name to backend ordering_fields in StreamViewSet
- Implemented field mapping in frontend to convert column IDs to backend field names
- Added sort buttons to both Group and M3U columns with proper icons
- Sort buttons show current sort state (ascending/descending/none)
- Maintains consistent UX with existing Name column sorting
2025-11-30 19:20:25 +00:00
3l3m3nt
43949c3ef4 Added IPv6 port bind to nginx.conf 2025-11-30 19:30:47 +13:00
Adrian Mace
6a9b5282cd
fix: allow all IPv6 CIDRs by default
This change ensures that by default, IPv6 clients can
connect to the service unless explicitly denied.

Fixes #593
2025-11-30 00:39:30 +11:00
SergeantPanda
b791190e3b Enhancement: Add scrollable modal support for M3UFilters and M3UProfiles components to improve usability on mobile devices. 2025-11-28 12:05:08 -06:00
SergeantPanda
1d23ed3685 Enhancement: Allow scrolling when edit m3u account modal is open on mobile. 2025-11-28 11:10:11 -06:00
Jim McBride
3fb18ecce8 Enhancement: Respect user's 12h/24h time format preference in backup scheduler
- Read time-format setting from UI Settings via useLocalStorage
- Show 12-hour time input with AM/PM selector when user prefers 12h
- Show 24-hour time input when user prefers 24h
- Backend always stores 24-hour format (no API changes)
2025-11-27 08:49:29 -06:00
Jim McBride
3eaa76174e Feature: Automated configuration backups with scheduling
- Create/Download/Upload/Restore database backups (PostgreSQL and SQLite)
- Configurable data directory backups (via settings.py)
- Scheduled backups (daily/weekly) via Celery Beat
- Retention policy (keep last N backups)
- Token-based auth for async task polling
- X-Accel-Redirect support for nginx file serving
- Comprehensive tests
2025-11-26 21:11:13 -06:00
SergeantPanda
2b58d7d46e Enhancement: Ensure "Uncategorized" categories and relations exist for VOD accounts. This improves content management for movies and series without assigned categories. Closes #627 2025-11-25 17:14:51 -06:00
SergeantPanda
fb084d013b Bug Fix: Filter out non-existent channel groups in M3UGroupFilter component. This fixes a bug where if a group was removed and you attempt to edit an m3u before the frontend was notified, the webui would go blank. 2025-11-25 14:51:41 -06:00
SergeantPanda
8754839c81 Enhancement: Add search icon to name headers for the channels and streams tables. Closes #686 2025-11-25 13:45:25 -06:00
SergeantPanda
13ad62d3e1 Bug Fix: Fix bug where previewing a stream would always select the default m3u profile regardless of utilization. 2025-11-25 13:07:49 -06:00
SergeantPanda
0997cd7a9d Enhancement: Improved minimum horizontal size in the stats page for improved usability on smaller displays. 2025-11-25 10:43:02 -06:00
SergeantPanda
962d5e965b Enhancement: Hide drop-downs for the system event viewer when in the collapsed state and on a smaller display. 2025-11-25 10:04:52 -06:00
SergeantPanda
7673cd0793 fix: Convert float channel numbers to integers for XC client compatibility
XC (Xtream Codes) clients require integer channel numbers and fail to parse
float values (e.g., 100.5). This change implements collision-free mapping
that converts floats to integers while preserving existing integer channel
numbers where possible.

Changes:
- Implemented two-pass collision detection algorithm that assigns integers
  to float channel numbers by incrementing until an unused number is found
- Applied mapping to all XC client interfaces: live streams API, EPG API,
  and XMLTV endpoint
- Detection: XC clients identified by authenticated user (user is not None)
- Regular M3U/EPG clients (user is None) continue to receive float channel
  numbers without modification

Example: Channels 100, 100.5, 100.9, 101 become 100, 101, 102, 103 for XC
clients, ensuring no duplicate channel numbers and full compatibility.
2025-11-22 09:01:46 -06:00
SergeantPanda
aae7b1bc14 Enhancement: Refactor xc_player_api to streamline action handling and ensure consistent responses for unknown actions 2025-11-21 16:49:45 -06:00
SergeantPanda
e7700b60f3 Enhancement: Add validation for EPG objects and payloads in updateEPG functions to prevent errors from invalid data 2025-11-21 15:10:54 -06:00
SergeantPanda
aa9fa09822 Fix: Improve date parsing logic in generate_custom_dummy_programs to handle empty or invalid inputs 2025-11-21 14:19:35 -06:00
SergeantPanda
c5f6d8ccf3 Enhancement: Update xc_player_api to return server_info for unknown actions to align with provider behavior 2025-11-21 10:57:35 -06:00
SergeantPanda
cb1953baf2 Enhancement: Implement comprehensive logging for user authentication events and network access restrictions 2025-11-21 10:50:48 -06:00
SergeantPanda
d94d615d76 Fix: Handle missing channel profiles in m3u and EPG generation with appropriate error logging 2025-11-20 18:54:32 -06:00
SergeantPanda
05f98e9275
Bug fix: Fixes DVR cards not respecting users preference for date and time formats.
UI now reflects date and time formats chosen by user
2025-11-20 18:22:43 -06:00
SergeantPanda
db276f6d32
Merge pull request #679 from Dispatcharr/event-viewer
Enhancement: Add system event logging and viewer with M3U/EPG endpoint caching
2025-11-20 17:44:29 -06:00
SergeantPanda
89a23164ff Enhancement: Add system event logging and viewer with M3U/EPG endpoint caching
System Event Logging:
- Add SystemEvent model with 15 event types tracking channel operations, client connections, M3U/EPG activities, and buffering events
- Log detailed metrics for M3U/EPG refresh operations (streams/programs created/updated/deleted)
- Track M3U/EPG downloads with client information (IP address, user agent, profile, channel count)
- Record channel lifecycle events (start, stop, reconnect) with stream and client details
- Monitor client connections/disconnections and buffering events with stream metadata

Event Viewer UI:
- Add SystemEvents component with real-time updates via WebSocket
- Implement pagination, filtering by event type, and configurable auto-refresh
- Display events with color-coded badges and type-specific icons
- Integrate event viewer into Stats page with modal display
- Add event management settings (retention period, refresh rate)

M3U/EPG Endpoint Optimizations:
- Implement content caching with 5-minute TTL to reduce duplicate processing
- Add client-based event deduplication (2-second window) using IP and user agent hashing
- Support HEAD requests for efficient preflight checks
- Cache streamed EPG responses while maintaining streaming behavior for first request
2025-11-20 17:41:06 -06:00
Biologisten
1f0fe00cbf UI now reflects date and time formats chosen by user 2025-11-20 17:34:03 +01:00
SergeantPanda
204a5a0c76
Merge pull request #659 from FiveBoroughs/fix/channel-stream-order-serialization
Fix: Preserve stream order in ChannelSerializer PATCH/PUT responses
2025-11-19 15:59:38 -06:00
GitHub Actions
fea7c99021 Release v0.12.0 2025-11-19 03:39:13 +00:00
SergeantPanda
3e77259b2c
Merge pull request #670 from Dispatcharr/dev 2025-11-18 21:38:34 -06:00
SergeantPanda
968a8f1cd0 Merge branch 'dev' of https://github.com/Dispatcharr/Dispatcharr into dev 2025-11-18 10:02:47 -06:00
SergeantPanda
b6c3234e96 Enhancement: Improve zombie channel handling in ProxyServer by checking client connections and cleaning up orphaned metadata, ensuring better resource management and stability. 2025-11-18 10:02:35 -06:00
SergeantPanda
dc2a408041
Merge pull request #666 from Dispatcharr:XC-Profile-rate-limit
Enhancement: Implement background profile refresh task with rate limiting to prevent provider bans during account profile updates.
2025-11-18 09:28:36 -06:00
SergeantPanda
afedce5cb2 Enhancement: Implement background profile refresh task with rate limiting to prevent provider bans during account profile updates. 2025-11-18 09:27:22 -06:00
SergeantPanda
d8df848136 Enhancement: Add success notification for channel updates in API, improving user feedback on successful operations. 2025-11-17 17:57:04 -06:00
SergeantPanda
1b16df4482 Enhancement: Improve batch EPG association in ChannelViewSet by adding validation for associations and implementing bulk updates, enhancing performance and error handling. 2025-11-17 17:41:52 -06:00
SergeantPanda
1560afab97 Enhancement: Optimize bulk channel editing in ChannelViewSet by validating updates first and applying them in a single transaction, improving performance by about 50% and error handling. 2025-11-17 17:33:10 -06:00
FiveBoroughs
bbe1f6364b Fix: Preserve stream order in ChannelSerializer PATCH/PUT responses
The ChannelSerializer.to_representation() method was not respecting the
ChannelStream.order field when serializing PATCH/PUT responses. This
caused streams to be returned in an arbitrary order rather than the
order specified in the request.

The update() method correctly saves the stream order to the database
using the ChannelStream.order field, and GET requests (with
include_streams=True) correctly return ordered streams via
get_streams(). However, standard PATCH/PUT responses were using
PrimaryKeyRelatedField which doesn't respect the ordering.

This fix ensures that all representations (GET, PATCH, PUT) return
streams ordered by the channelstream__order field.

Impact:
- PATCH/PUT responses now correctly reflect the stream order saved
- Clients can trust the response data without needing a follow-up GET
- No breaking changes - only fixes inconsistent behavior

Tested with:
- PATCH request with ordered stream IDs
- Verified response matches request order
- Verified GET request confirms order persisted to database
2025-11-16 23:29:17 +01:00
SergeantPanda
6bd5958c3c Enhancement: Improve channel shutdown logic in ProxyServer to handle connection timeouts and grace periods more effectively, ensuring proper channel management based on client connections. 2025-11-15 14:22:26 -06:00
SergeantPanda
0700cf29ea Enhancement: Add copy link functionality to SeriesModal and VODModal, allowing users to easily copy episode and VOD links to clipboard with notifications for success or failure. 2025-11-14 20:13:40 -06:00
SergeantPanda
2514528337 Enhancement: Update channel state handling in ProxyServer and views to include 'STOPPING' state, ensuring proper cleanup and preventing reinitialization during shutdown. 2025-11-14 19:57:59 -06:00
SergeantPanda
827501c9f7 Better spacing for version text. 2025-11-14 18:00:08 -06:00
SergeantPanda
23e2814fe7 Enhancement: Add loading state and dynamic text to submit buttons in Channel forms. Also remove old unused "Channels.jsx" form 2025-11-14 17:52:57 -06:00
SergeantPanda
5160ead093
Merge pull request #166 from maluueu/dev
Improve handling POST requests for M3U generation and add tests
2025-11-14 17:35:32 -06:00
SergeantPanda
acbcc46a91 Revert "docker: init: 02-postgres.sh: allow DB user to create new DB (for tests)"
This reverts commit 7e5be6094f.
2025-11-14 17:32:05 -06:00
SergeantPanda
ed7e16483b Merge branch 'dev' of https://github.com/Dispatcharr/Dispatcharr into pr/maluueu/166 2025-11-14 17:11:00 -06:00
SergeantPanda
a3be679acf Enhancement: Add loading state to login button for better user feedback 2025-11-14 16:52:26 -06:00
SergeantPanda
4f29f7f3f9 Enhancement: Conditionally render Sidebar based on authentication status 2025-11-14 16:45:38 -06:00
SergeantPanda
7321a6d7f8 Enhancement: Allow saving password to local storage. 2025-11-14 16:42:40 -06:00
SergeantPanda
761ee42396 Enhancement: Add version to login form. 2025-11-14 16:02:10 -06:00
SergeantPanda
6dab5e3cf3 Enhancement: Add "Remember Me" checkbox to login for that will save the current username to local storage. 2025-11-14 15:25:56 -06:00
SergeantPanda
b2a041c7c4 Enhancement: Add forgot password link to login form with instructions on how to properly reset a forgotten password. 2025-11-14 15:18:57 -06:00
SergeantPanda
575b764487 Refactor LoginForm: Enhance UI with logo, updated title, and improved layout for better user experience. 2025-11-14 15:06:33 -06:00
SergeantPanda
325c836510
Merge pull request #625 from 0x53c65c0a8bd30fff/force-epg-menu-alphabetical-sorting 2025-11-14 13:51:08 -06:00
SergeantPanda
0360292b94 Refactor LoginForm: Restore navigation effect and streamline login handling. Also remove "Loading..." from M3U & EPG Manager that would clear forms during initial login. 2025-11-14 12:00:22 -06:00
SergeantPanda
cc7cd32c90 Improved syncronization of timeline and guide for mobile touch (including momentum) 2025-11-13 21:19:54 -06:00
SergeantPanda
4b5d3047bb Enhancement: Add wheel scrolling support for TV guide and synchronize scrolling with timeline 2025-11-13 19:47:39 -06:00
SergeantPanda
6e79b37a66 When stream_type is UDP do not add user_agent to FFmpeg command. 2025-11-13 14:04:46 -06:00
SergeantPanda
4720e045a3 Implement manual redirect for non-HTTP protocols (RTSP/RTP/UDP) in stream URL handling 2025-11-12 17:31:27 -06:00
SergeantPanda
79895a1ce4 Enhancement: Update URL validation to support authentication for non-FQDN hostnames in rtsp/rtp/udp URLs 2025-11-12 16:58:07 -06:00
SergeantPanda
a3c16d48ec Skip HTTP validation for non-HTTP protocols (UDP/RTP/RTSP) in stream URL validation 2025-11-12 16:17:06 -06:00
SergeantPanda
431ea6da32
Merge pull request #640 from Dispatcharr:udp-stream-support
Enhancement: Adds support for UDP streams. Closes #617
2025-11-11 18:32:09 -06:00
SergeantPanda
b9e819e343 Enhancement: Adds support for UDP streams. Closes #617 2025-11-11 18:30:59 -06:00
SergeantPanda
a7f449f746
Merge pull request #565 from ragchuck:enable-rtsp
feat: added support for rtsp
2025-11-11 17:29:03 -06:00
SergeantPanda
b608af1d51 Auto detect RTSP streams when proxy profile is selected and force FFmpeg. 2025-11-11 17:26:56 -06:00
SergeantPanda
21723e29bc Update URL validation to support FQDNs for rtsp/rtp protocols and improve regex pattern for flexibility. 2025-11-11 17:17:01 -06:00
SergeantPanda
dc22dff713 Bug Fix: Refactor parse_extinf_line to improve attribute extraction and display name handling. Fixes #637 2025-11-11 16:11:42 -06:00
SergeantPanda
9a5e04af0e Better confirmation dialog messages for vod logo table. 2025-11-07 16:16:07 -06:00
SergeantPanda
6037c158f4 Fix header checkbox not clearing on bulk delete in logo tables. 2025-11-07 15:25:09 -06:00
SergeantPanda
860c671f8c Fix delete button not activating for vod logo table. 2025-11-07 15:05:43 -06:00
SergeantPanda
4701456a46 Enhancement: Force fetch all logos after logo cleanup. 2025-11-07 14:43:41 -06:00
SergeantPanda
c3153f6b93
Merge pull request #628 from Dispatcharr:vod-logos
Separate VOD and channel logos into distinct tables with dedicated management UI
2025-11-07 14:07:59 -06:00
SergeantPanda
da628705df Separate VOD and channel logos into distinct tables with dedicated management UI
- Created VODLogo model for movies/series, separate from Logo (channels only)
- Added database migration to create vodlogo table and migrate existing VOD logos
- Implemented VODLogoViewSet with pagination, filtering (used/unused/movies/series), and bulk operations
- Built VODLogosTable component with server-side pagination matching channel logos styling
- Added VOD logos tab with on-demand loading to Logos page
- Fixed orphaned VOD content cleanup to always remove unused entries
- Removed redundant channel_assignable filtering from channel logos
2025-11-07 13:19:18 -06:00
0x68732f6e69622fff
ed86eb2274 Ensures that in the groups section of M3U playlist management, the EPG Source dropdown for the 'Force EPG Source' option displays entries sorted alphabetically by name 2025-11-06 14:29:13 +00:00
SergeantPanda
871f9f953e Another attempt for the get_host_and_port function to better handle port detection behind reverse proxies. 2025-11-05 16:23:13 -06:00
SergeantPanda
77e98508fb Enhancement: Refactor get_host_and_port for smarter port selection when using reverse proxies. Fixes #618 2025-11-04 19:08:31 -06:00
SergeantPanda
e6146e5243 Bug fix: Reduce websocket message size when processing epgs. Also remove unnecessary console logging during epg refresh. Fixes [Bug]: Page goes blank if sending too many requests / responses
Fixes #327
2025-11-04 18:23:45 -06:00
GitHub Actions
d0ebfb57c4 Release v0.11.2 2025-11-04 18:25:03 +00:00
SergeantPanda
81639c0f15
Merge pull request #615 from Dispatcharr/dev
Version 0.11.2
2025-11-04 12:23:35 -06:00
SergeantPanda
93f074241d Bug fix/enhancement: Check if we're the owner of /data and set ownership if not. Also pre-create /data/models folder 2025-11-04 09:03:39 -06:00
SergeantPanda
d15d8f6644 Bug Fix: Fix bug for the frontend custom dummy epg examples where DST was calculated on todays date instead of date of the event. 2025-11-01 14:08:36 -05:00
SergeantPanda
12aae44672 Enhancement: For custom dummy epg's, use correct date for placeholders if provided. 2025-11-01 13:39:55 -05:00
SergeantPanda
60f77c85da Enhancement: Support month strings for date parsing. 2025-10-31 14:08:07 -05:00
SergeantPanda
c7e955b4a8
Merge pull request #604 from Dispatcharr:proxy-client-ttl
Enhance client record TTL settings and periodic refresh during streaming
2025-10-31 13:00:07 -05:00
SergeantPanda
6715bc7c5c Enhancement: Update TTL settings for client records and implement periodic refresh during active streaming 2025-10-31 11:53:16 -05:00
SergeantPanda
1b282f1987
Merge pull request #594 from 0x53c65c0a8bd30fff/truncated-channel-titles-fix
Channel name truncation when containing apostrophes or special characters
2025-10-30 16:53:30 -05:00
SergeantPanda
16c44ea851
Merge pull request #600 from lasharor/patch-2
Check for "SERVER_PORT" header before falling back to 9191
2025-10-30 16:30:32 -05:00
lasharor
400c77f258
Update views.py
I moved on to LXC install on PVE. The x-tvg-url defaults back to 9191 port whilst in reality the port is defined as :80, therefore this obviously brakes this functionality. 

Check request.META.get("SERVER_PORT") before falling back to defaults. This should capture the actual server port from the request.
2025-10-30 22:25:13 +01:00
SergeantPanda
9d4fd63cde Enhancement: Add date placeholders for custom dummy EPG based on output and source timezones. Closes #597 2025-10-30 16:13:45 -05:00
SergeantPanda
0741e45ce6 Enhancement: Add custom fallback templates for dummy EPG generation to handle unmatched patterns 2025-10-30 14:57:32 -05:00
SergeantPanda
4284955412 Enhancement: Adds the ability to use an exisitng custom dummy epg as a template for a new epg. 2025-10-29 17:38:47 -05:00
SergeantPanda
c9d7e66545 Enhancement: Add {starttime_long} and {endtime_long} to custom dummy EPG. 2025-10-29 17:21:37 -05:00
SergeantPanda
28c211cd56 Enhancement: Add {endtime} as an available output for custom dummy epg. Convert {time} to {starttime}
Closes #590
2025-10-29 17:10:35 -05:00
0x68732f6e69622fff
1fde8e4600 Fixes the issue where channel titles are truncated early after an apostrophe 2025-10-29 13:38:51 +00:00
SergeantPanda
5c27bd2c10 Enhancement: Increase maximum URL length for Stream model to 4096 characters (from 2000) and add validation for URL length in processing tasks.
Fixes #585
2025-10-28 15:02:36 -05:00
SergeantPanda
3e2e704765 Enhancement: Add 'Include New Tag' option to mark programs as new in custom dummy EPG output 2025-10-25 11:47:54 -05:00
SergeantPanda
423c56f582 Enhancement: Sort groups during xc_get_live_categories in the order that they first appear in the channel list (lowest channel number) 2025-10-25 09:47:39 -05:00
SergeantPanda
2042274f10 Enhancement: Bulk assign custom dummy epgs.
Enhancement: Display a dynamic warning dialog when saving batch channel edits that will display what will be changed.
2025-10-25 09:27:31 -05:00
SergeantPanda
0fd464cb96 Enhancement: Adds ability to set a custom poster and channel logo with regex for custom epg dummy's. 2025-10-24 12:55:25 -05:00
SergeantPanda
a1834d9885 Merge branch 'main' of https://github.com/Dispatcharr/Dispatcharr into dev 2025-10-22 17:03:34 -05:00
SergeantPanda
57b99e3900 Enhancement: Change sub_title field in ProgramData model from CharField to TextField. This will allow for longer than 255 character sub titles. (Closes #579) 2025-10-22 17:02:40 -05:00
GitHub Actions
29c46eeb0a Release v0.11.1 2025-10-22 21:35:39 +00:00
SergeantPanda
2de5acf12c
Merge pull request #580 from Dispatcharr/dev
# 🚀 Release Notes – Version 0.11.1
2025-10-22 16:35:02 -05:00
SergeantPanda
0a6f9eb8e1 Bug fix: Fixes uWSGI not getting environmental variables passed to it and LXC not being able to access daemons launched by uWSGI.
Fixes #575, #576, #577
2025-10-22 15:31:12 -05:00
SergeantPanda
73bb1ecd2d Trying a different approach to fix LXC issues. 2025-10-22 08:26:15 -05:00
SergeantPanda
645c1ec9df Testing redis potential fix. Launch redis in entrypoint before uWSGI 2025-10-21 21:47:15 -05:00
GitHub Actions
dd5f0d0753 Release v0.11.0 2025-10-22 01:16:18 +00:00
SergeantPanda
d5de69cd6a
Merge pull request #574 from Dispatcharr/dev
Version 0.11.0
2025-10-21 19:55:15 -05:00
SergeantPanda
119b222428 Enhancement: Allow setting both Celery and UWSGI nice levels. Default nice levels are UWSGI=0, Celery=5. Added documentation to the compose files on how to use it. Using nice levels for UWSGI allows us to have streaming run at a high priority. 2025-10-21 10:11:53 -05:00
SergeantPanda
92d499a274 Enhancement: Switch regex compilation from re to regex module
Use the 'regex' package instead of Python's standard 're' module for pattern
compilation in custom dummy EPG generation. This enables variable-width
lookbehind support, matching JavaScript regex behavior and removing the
fixed-width limitation of the standard library.

This fixes issues where patterns like (?<=\d{1,2}...) would fail with
"look-behind requires fixed-width pattern" errors.
2025-10-19 19:39:53 -05:00
SergeantPanda
97c24dbea3 Bug Fix: Fix inconsistency with how 24 hour time is displayed between frontend and backend. If minutes were > 0 and using 24 hour time, hours 0-9 would show as single digit but if minute was 0 they would show double digit. 2025-10-19 17:16:49 -05:00
SergeantPanda
4b74673795 Bug fix: Use search instead of match for checking if the title matches the title_match regex for custom dummy. 2025-10-19 17:06:44 -05:00
SergeantPanda
6a85475402 Enhancement: Adds ability to specify categories (comma separted) as well as include date and live tags in custom epg dummy output. 2025-10-19 10:06:48 -05:00
SergeantPanda
6e0e646938 Enhancement: EPG Dummy - Convert time based on timezone for preview. 2025-10-19 09:00:13 -05:00
SergeantPanda
937c20c082 Enhancement: Add upcoming and ended previews for custom dummy epg. 2025-10-19 08:53:49 -05:00
SergeantPanda
75215cfdc6 Enhancement: Convert <time> and <time24> in the frontend. 2025-10-19 08:42:27 -05:00
SergeantPanda
163b1dd7cf Move buttons to the right side and correctly load in output timezone for custom dummy epg. 2025-10-18 21:19:54 -05:00
SergeantPanda
603c9f9269 Enhancement: Added ability to specify output timezone for custom epg dummy. 2025-10-18 21:12:20 -05:00
SergeantPanda
fe540045fc Enhancement: Add a {time} and {time24} output for better output formatting of the time. 2025-10-18 21:06:27 -05:00
SergeantPanda
dee672287b Bug fix: Fixes bug where if minute was not specified matching would fail for custom dummy. 2025-10-18 20:50:56 -05:00
SergeantPanda
c21ea5ecbe Bug Fix: Use event timezone for date calculation in custom dummy channels
Previously used UTC date which caused events to be scheduled a day late when current UTC time had crossed midnight but the event timezone hadn't.
2025-10-18 20:46:06 -05:00
SergeantPanda
d456051eb3 Verify /app also has the correct permissions. 2025-10-18 19:52:22 -05:00
SergeantPanda
9b07f013a4 Refactor directory creation and ownership management in init script for clarity and maintainability. Will only chown recursively if we are not the owner. This should help improve boot speeds. 2025-10-18 19:33:23 -05:00
SergeantPanda
7cbdb61f2c Enhancement: Ensure root's .bashrc sources Dispatcharr profile scripts for interactive non-login shells. This will help when running root commands from the container cli 2025-10-18 19:08:02 -05:00
SergeantPanda
8494f615d0 Bug fix: Use correct name if stream name is supposed to be used to build dummy. 2025-10-18 17:29:44 -05:00
SergeantPanda
0d987aae99 Enhancement: If a stream profile is set for a custom stream, when previewing the stream Dispatcharr will now use the assigned stream profile. Fixes #186 2025-10-18 16:24:47 -05:00
SergeantPanda
81276bfc16 Bug fix: Current settings for Stream Profile and Group were not displayed when opening the edit form for custom streams 2025-10-18 16:04:55 -05:00
SergeantPanda
1a541bd133 Bug fix: Unable to preview custom streams. 2025-10-18 15:52:48 -05:00
SergeantPanda
fa2a90fab4 Bug fix: Remove bottom horizontal scroll bar from TV Guide, it was not synced with the guide and served no purpose. 2025-10-18 14:07:34 -05:00
SergeantPanda
91eaa64ebb Enhancement: Force a specific EPG for auto channel sync channels. 2025-10-18 13:43:49 -05:00
SergeantPanda
0a4c7cae25 Bug fix: Fix bug where channel logo would revert back to provider logo after a refresh (for auto channel sync channels) 2025-10-18 12:38:43 -05:00
SergeantPanda
ba695ebbe9
Merge pull request #568 from Dispatcharr/dummy-epgs
Enhancement: Add Custom Dummy EPG with Dynamic Pattern Matching
2025-10-18 12:25:37 -05:00
SergeantPanda
22fb0b3bdd Enhancement: Add Custom Dummy EPG with Dynamic Pattern Matching and Name Source Selection
This enhancement introduces a powerful custom dummy EPG system that allows users to generate EPG programs on-demand by parsing channel or stream names using configurable regex patterns.

Key Features:
- Custom Pattern Matching: Define regex patterns to extract information from channel/stream names (teams, leagues, times, dates, etc.)
- Flexible Name Source: Choose to parse either the channel name or a specific stream name (by index)
- Timezone-Aware Scheduling: Automatic DST handling using pytz timezone names (e.g., 'US/Eastern', 'Europe/London')
- Time Format Support: Parse both 12-hour (AM/PM) and 24-hour time formats
- Date Parsing: Extract dates from names with flexible month/day/year patterns
- Custom Templates: Format EPG titles and descriptions using captured groups with {placeholder} syntax
- Upcoming/Ended Customization: Define custom titles and descriptions for programs before and after scheduled events
- Live Preview: Test patterns and templates in real-time with sample input
- Smart Program Generation: Automatically creates "Upcoming" and "Ended" programs around scheduled events

Use Cases:
- Sports channels with event details in stream names (e.g., "NHL 01: Bruins VS Leafs @ 8:00PM ET")
- Movie channels with genre/title/year information
- Racing events with driver/track/series details
- Any scenario where EPG data is embedded in channel/stream naming conventions

Technical Implementation:
- Backend: Pattern matching engine with timezone conversion and program scheduling logic
- Frontend: Interactive form with validation, pattern testing, and visual group preview
- Name Source Options: Parse from channel name or selectable stream index (1-based)
- Fallback Behavior: Uses standard dummy EPG if patterns don't match
- Custom Properties: Stores all configuration in EPGSource.custom_properties JSON field

Configuration Options:
- Title Pattern: Extract primary information (required)
- Time Pattern: Extract hour/minute/AM-PM (optional)
- Date Pattern: Extract month/day/year (optional)
- Timezone: Event timezone with automatic DST support
- Program Duration: Length of generated programs in minutes
- Title Template: Format EPG title using captured groups
- Description Template: Format EPG description using captured groups
- Upcoming Title Template: Custom title for programs before event starts (optional)
- Upcoming Description Template: Custom description for programs before event starts (optional)
- Ended Title Template: Custom title for programs after event ends (optional)
- Ended Description Template: Custom description for programs after event ends (optional)
- Name Source: Channel name or stream name
- Stream Index: Which stream to use when parsing stream names (1, 2, 3, etc.)

Closes #293
2025-10-18 12:08:56 -05:00
SergeantPanda
ca8e9d0143 Enhancement: Add custom logo support for channel groups in Auto Sync Channels.
Closes #555
2025-10-17 10:03:21 -05:00
SergeantPanda
d3d7f3c733
Merge pull request #531 from jordandalley/fix-ipv6-validation
Fix: Add IPv6 CIDR validation in Settings
2025-10-16 18:04:36 -05:00
SergeantPanda
7744d7287b Merge branch 'dev' of https://github.com/Dispatcharr/Dispatcharr into pr/jordandalley/531 2025-10-16 17:57:39 -05:00
SergeantPanda
ec21e8329d Enhancement: Increase time for a client to search for an available connection from 1.5 seconds to 3 seconds. This will help when clients are changing channels and release the old connection AFTER attempting to start the new connection. Closes #503
Bug Fix: Fix a bug where searching for an available stream could clear out stream locks for streams that it never acquired a lock for.
2025-10-16 17:38:23 -05:00
SergeantPanda
0031d55bab Bug Fix: Resizing columns in the channel table may cause the page to crash.
Fixes #516
2025-10-16 16:49:13 -05:00
SergeantPanda
b9a0aaa574 Re-allign tables and buttons after adjusting button layout. 2025-10-16 14:33:15 -05:00
SergeantPanda
9b2ebf169b Better database connection cleanup. 2025-10-16 14:22:19 -05:00
Ragchuck
ae8b85a3e2 feat: added support for rtsp 2025-10-15 22:06:01 +02:00
SergeantPanda
4df2f79bcf Bug fix: Fixes bug where if there were no channel profiles other than ALL, streamer and standard accounts could not stream any channels even if they had ALL profiles selected. 2025-10-14 15:03:42 -05:00
SergeantPanda
ed065f718d Enhancement: Implement caching for proxy settings to improve performance and reduce database load. Also, ensure database connections are closed after use in both config and stream manager. 2025-10-14 13:44:28 -05:00
SergeantPanda
90d065df80 Enhancement: Show "Saved Successfully" message when changing stream settings and DVR settings. Also only show "Saved Successfully" when the api actually returns a success. 2025-10-13 17:00:18 -05:00
SergeantPanda
87d2131789 Bug fix: Fixes saving settings returning error.
Fixes [Bug]: Error saving Stream Settings
Fixes #535
2025-10-13 16:45:22 -05:00
SergeantPanda
071561c570
Merge pull request #553 from Dispatcharr:Proxy-changes
Enhance HTTP streaming and timeout configurations
2025-10-12 09:46:21 -05:00
SergeantPanda
404d2f82a3 Switch HTTP streamer to a thread and pipe its output to a local pipe where the fetch chunk can access it the same way our transcode processes would be accessed. Simplifies the code. 2025-10-12 09:42:15 -05:00
SergeantPanda
74280baa85 Changed read timeout for http connection for the proxy server to 10 secounds to avoid unnecessary timeouts.
Improved try_next_stream to not fail if the returned stream is the same. It will now try a different stream.
Force a client to jump ahead in the buffer if they fall to far behind.
2025-10-11 19:45:21 -05:00
SergeantPanda
fa08216600 Enhancement: Add chunk timeout configuration in ConfigHelper. Improve StreamManager timeout handling for consistency. Only 1 heartbeat thread per worker should be started now. Timeout on proxy reduced from 60 seconds to 5. 2025-10-11 18:08:20 -05:00
SergeantPanda
fbd83e61b7 Enhancement: Added tooltips to stream table fields. Also removed unneeded imports for logos. 2025-10-11 10:54:11 -05:00
SergeantPanda
6acb0da933 Bug fix/Enhancement: Selecting many streams will no longer cause the stream table to create a new row for buttons. Also reordered buttons in the stream table slightly. 2025-10-11 10:46:52 -05:00
SergeantPanda
d32abecb25 Enhancement: Add confirmation dialog to stream delete functions. 2025-10-11 10:36:04 -05:00
SergeantPanda
d5f9ba7e5e Sort EPG output by channel number. 2025-10-10 17:55:51 -05:00
SergeantPanda
f58bc81c36 Enhancement: Optimize EPG program fetching by implementing chunked retrieval and explicit ordering to improve performance and reduce memory issues. 2025-10-10 17:52:05 -05:00
SergeantPanda
fefab4c4c6 Enhancement: Improve resource cleanup in ProxyServer and StreamManager classes to avoid "SystemError: (libev) error creating signal/async pipe: Too many open files" errors 2025-10-10 15:26:02 -05:00
SergeantPanda
9dc54fdcff Fix: Ensure channel_id and channel.uuid are converted to strings before processing. This fixes an issue where sending a stream switch event would fail if the event was sent from a non owning worker.
Fixes [Bug]: Manually switching active stream not working when using XC client.
Fixes #269
2025-10-09 19:10:38 -05:00
SergeantPanda
85fdfedabe
Merge pull request #543 from Dispatcharr/Auto-disable-new-categories 2025-10-09 15:32:57 -05:00
SergeantPanda
951af5f3fb Enhancement: Add auto-enable settings for new groups and categories in M3U and VOD components
Bug Fix: Remove orphaned categories for VOD and Series
Fixes #540
Closes #208
2025-10-09 15:28:37 -05:00
GitHub Actions
fe58594a36 Release v0.10.4 2025-10-08 00:55:25 +00:00
SergeantPanda
072201016c
Merge pull request #534 from Dispatcharr/dev
Dispatcharr – Version 0.10.4
2025-10-07 19:54:34 -05:00
SergeantPanda
8794156767 Enhancement: Only fetch playlists and channel profiles after successful m3u refresh not every m3u_refresh status update. This should reduce network traffice and efficiency.
This may fix #327
2025-10-07 10:02:26 -05:00
SergeantPanda
da245c409a Bug fix: Backend now notifies frontend when a new playlist is creating. This should fix where accounts will sometimes only show fetching groups after a new account is added. 2025-10-07 09:55:35 -05:00
SergeantPanda
171bb004c4 Set XC as default for new M3U accounts 2025-10-07 09:20:01 -05:00
SergeantPanda
99ad0ecb7b Bug fix: Fixes bug where adding multiple M3U accounts in a row would only modify the first M3U account that was added unless you refresh the web UI. 2025-10-07 09:18:43 -05:00
3l3m3nt
a959ba1748 Fix: Add IPv6 CIDR validation in Settings. Add ::0/0 as a default as well as 0.0.0.0/0 2025-10-07 20:02:47 +13:00
SergeantPanda
d1aa9fe441 Bug fix: Add logo URL validation to prevent PostgreSQL btree index errors during channel creation from stream 2025-10-06 21:22:16 -05:00
SergeantPanda
3326b9fbdc Bug fix: Add logo URL validation to prevent PostgreSQL btree index errors during bulk channel creation
Fixes #519
2025-10-06 21:11:53 -05:00
SergeantPanda
13874d64ad Bug fix: If no streams are found during an XC account refresh we were not releasing the task lock and we weren't processing vods.
Fixes #449
2025-10-06 20:04:22 -05:00
SergeantPanda
bc574c272c Bug fix: convert commas to decimals and verify float before saving vod/series to database.
Fixes #526
2025-10-06 19:45:43 -05:00
SergeantPanda
144a861142 Bug fix: When using direct urls in m3u output, use the correct stream based on the order for the channel.
Fixes #528
2025-10-06 18:18:05 -05:00
SergeantPanda
e01338f055 Enhancement: Properly track channel creation time and channel update times in the database. XC Get Live streams will now use this for the added field. 2025-10-06 18:07:51 -05:00
SergeantPanda
22493c2797 Merge branch 'dev' of https://github.com/Dispatcharr/Dispatcharr into dev 2025-10-06 16:50:28 -05:00
SergeantPanda
a4a677a6fb Bug fix: Streamers with ALL assigned will now get all channels they have access to instead of All profiles. If there wasn't a profile other than default before, the streamer would not get any channels. 2025-10-06 16:50:17 -05:00
OkinawaBoss
8db9689999
Merge branch 'DVR-Update' into dev 2025-10-06 06:25:40 -07:00
Dispatcharr
dea6411e1c Time Zones
- Added time zone settings
2025-10-06 07:46:23 -05:00
SergeantPanda
a31feee311 Bug fix: Ensure distinct channel results in generate_m3u, generate_epg, and xc_get_live_streams functions. Fixes duplicate channels output for streamer profiles that were set to "All" 2025-10-05 19:32:40 -05:00
SergeantPanda
ad4143d035
Merge pull request #514 from Blarm1959/dev
Replaces #499 - debian_install.sh - Update installed packages, node and create missing folders
2025-10-05 18:54:56 -05:00
SergeantPanda
0406c868bc
Merge pull request #515 from EmeraldPi/set-name-logo 2025-10-05 18:51:30 -05:00
SergeantPanda
cfd235ba34
Merge pull request #457 from EmeraldPi/logo-batch-edit 2025-10-05 18:47:29 -05:00
SergeantPanda
cedd0d6f4d Finish merge 2025-10-05 18:44:26 -05:00
SergeantPanda
e6a9672a14 Merge branch 'dev' into pr/csmith1210/457 2025-10-05 18:40:59 -05:00
SergeantPanda
d7735255ec Enhancement: Add confirmation dialogs for setting names, logos, TVG-IDs, and clearing EPG assignments in ChannelBatchForm 2025-10-04 17:39:56 -05:00
SergeantPanda
67a6ad1168 Enhancement: Add clear EPG button to ChannelBatchForm for resetting EPG assignments 2025-10-04 17:35:01 -05:00
SergeantPanda
18dc73cbcb
Merge pull request #518 from Dispatcharr/Assign-tvg-id-from-epg 2025-10-04 17:24:49 -05:00
SergeantPanda
25d6322186 Merge branch 'dev' of https://github.com/Dispatcharr/Dispatcharr into Assign-tvg-id-from-epg 2025-10-04 16:59:53 -05:00
GitHub Actions
882be5cdf8 Release v0.10.3 2025-10-04 21:54:01 +00:00
SergeantPanda
9173f0b876
Merge pull request #517 from Dispatcharr/dev 2025-10-04 16:52:13 -05:00
SergeantPanda
29ee837b24 Add recover=True to iterparse for parse_programs_for_tvg_id as well to fix cloudflare script injection. 2025-10-04 16:36:49 -05:00
SergeantPanda
94f966e027 Reverted to old method for parsing xml. Still will not break if Cloudflare adds a new root element. 2025-10-04 16:25:35 -05:00
SergeantPanda
d1ac5b11e5 Add EPG TVG-ID setting functionality
- Implemented API endpoint to set channel TVG-IDs from EPG data.
- Created Celery task to handle TVG-ID updates for multiple channels.
- Added frontend methods to initiate TVG-ID setting from EPG for both single and batch channel forms.
- Enhanced notifications for task status updates.
2025-10-04 15:20:35 -05:00
Connor Smith
23209e79e9 [Enhancement] Set logo name from url 2025-10-04 14:23:53 -04:00
Blarm1959
a502d309f1 Update installed packages, node and create missing folders 2025-10-04 19:11:30 +01:00
SergeantPanda
d316173ab6
Merge pull request #512 from Dispatcharr/revert-511-revert-458-set-logo-url-v1
Revert "Revert "Add option to add a logo by a URL in Channel editor/creator""
2025-10-04 09:23:01 -05:00
SergeantPanda
23be065c52
Revert "Revert "Add option to add a logo by a URL in Channel editor/creator"" 2025-10-04 09:22:09 -05:00
SergeantPanda
9947f36286
Merge pull request #511 from Dispatcharr/revert-458-set-logo-url-v1
Revert "Add option to add a logo by a URL in Channel editor/creator"
2025-10-04 09:12:13 -05:00
SergeantPanda
e4a6d19c17
Revert "Add option to add a logo by a URL in Channel editor/creator" 2025-10-04 09:11:38 -05:00
SergeantPanda
3128c116e8
Merge pull request #458 from csmith1210/set-logo-url-v1 2025-10-04 08:54:27 -05:00
Connor Smith
ff894acf4d Fix: Need onSuccess in file upload to update Channel.jsx logo 2025-10-03 21:15:45 -04:00
SergeantPanda
d0e31e8acd Rebuilt FFmpeg base container 2025-10-03 17:11:00 -05:00
SergeantPanda
4aafece68e Updated GitHub Actions workflow for building base image. Changed runner version, added native runner builds instead of qemu 2025-10-03 17:02:10 -05:00
GitHub Actions
edda2ca3a5 Release v0.10.2 2025-10-03 21:08:54 +00:00
SergeantPanda
d0413e63be Push releases to Dockerhub. 2025-10-03 16:05:56 -05:00
SergeantPanda
9b2402a421
Merge pull request #510 from Dispatcharr/dev
Release 0.10.2
2025-10-03 15:39:15 -05:00
SergeantPanda
22409b4f01
Merge branch 'main' into dev 2025-10-03 15:36:28 -05:00
SergeantPanda
172bb204f4 Refector release workflow to run on native architecture. 2025-10-03 14:33:18 -05:00
SergeantPanda
0a15e09805 Refactor XMLTV parsing to use iterparse for <tv> element. This fixes issues where Cloudflare is adding a root element and breaking the xml.
Fixes #497: Cloudflare hosted EPG not supported in dispatcharr
2025-10-03 13:30:20 -05:00
SergeantPanda
d06c5bfdf3 Add recover to xmltv parser if there are errors. 2025-10-03 10:15:24 -05:00
Connor Smith
68c8d4dc23 Change logo upload to link to Logo.jsx form 2025-10-03 07:05:56 -04:00
SergeantPanda
c73271c617 Bug Fix: Sort order while bulk creating channels will now be in the order they were selected in not reverse order. 2025-10-02 13:23:24 -05:00
SergeantPanda
5bb2b57a4e Remove redundant h from 12 hour time in settings page. 2025-10-02 11:48:16 -05:00
SergeantPanda
d8ad33ff77 Fix bug where m3u hash settings wouldn't save properly. 2025-10-02 11:45:39 -05:00
SergeantPanda
e841343e5b Dont convert m3u_id to string for hash. 2025-10-02 11:06:10 -05:00
SergeantPanda
f90b24db40 Change default M3U hash key to URL only for new installs. 2025-10-02 09:28:02 -05:00
SergeantPanda
a3e4f23891 Enhancement: Add m3u_id parameter to generate_hash_key and update related calls 2025-10-02 09:14:22 -05:00
SergeantPanda
c99456f77d Remove sha_short tag to reduce number of tags. 2025-09-30 20:12:33 -05:00
SergeantPanda
9de6f79016 More organization changes. 2025-09-30 20:03:02 -05:00
SergeantPanda
b74b5b9b1b Add DOCKERHUB_ORGANIZATION secret 2025-09-30 19:55:47 -05:00
SergeantPanda
c7c9607071 Remove comments 2025-09-30 19:32:58 -05:00
SergeantPanda
041cb69bb8 Push to dockerhub as well. 2025-09-30 19:17:35 -05:00
SergeantPanda
b5223c13e7 Use the same timestamp for all images. 2025-09-30 18:30:27 -05:00
SergeantPanda
7136f8392d Create combined manifest 2025-09-30 18:07:05 -05:00
SergeantPanda
58924e6834 Only build for current architecture. 2025-09-30 14:54:23 -05:00
SergeantPanda
44478804f8 Split install and build into separate tasks. 2025-09-30 10:25:39 -05:00
SergeantPanda
37b05f1dde Attempt to fix epipe error in docker build. 2025-09-30 10:01:11 -05:00
SergeantPanda
9da20b1941 Switch back to node 24, switch to npm for building frontend. Remove node_modules before starting frontend build. Fixes bug with NPM 2025-09-30 09:22:05 -05:00
SergeantPanda
0e6889c6c1 Try node 20 (again) 2025-09-28 18:43:11 -05:00
SergeantPanda
e072fb88f9 Another test. 2025-09-28 18:30:28 -05:00
SergeantPanda
bbb559d1cd Attempt to work around known npm bug for ARM. 2025-09-28 17:38:15 -05:00
SergeantPanda
a0745fcaa5 Switch from yarn to npm for building 2025-09-28 11:57:13 -05:00
SergeantPanda
f3de398c89 Remove extra build tools. 2025-09-28 11:49:24 -05:00
SergeantPanda
836871f102 Add installation of build dependencies in Dockerfile 2025-09-28 11:36:59 -05:00
SergeantPanda
cd5135ba27 Add testing-library/dom to package-lock 2025-09-28 11:30:14 -05:00
SergeantPanda
c5029213fc Add testing-library/dom to dev dependencies 2025-09-28 10:20:30 -05:00
SergeantPanda
deb532071b Switch from emulated arm building to native. 2025-09-28 10:06:58 -05:00
SergeantPanda
01971fb91a Switch from emulated arm building to native. 2025-09-28 10:04:34 -05:00
SergeantPanda
e281041458 Get logs from yarn install if failed. 2025-09-28 09:38:01 -05:00
SergeantPanda
19f0088b40 Switch from slim build to full build of node 24. 2025-09-28 09:30:26 -05:00
SergeantPanda
db5713a050 Update Dockerfile to use Node.js 24-slim for frontend build 2025-09-27 17:53:15 -05:00
SergeantPanda
563f890cf4 Yarn apparently no longer supports igrnore optional. 2025-09-27 17:47:23 -05:00
SergeantPanda
09b79bd174 Fix: Update yarn install command to remove fallback option 2025-09-27 17:41:30 -05:00
SergeantPanda
857471e1ad Attempt to get arm builds working 2025-09-27 17:39:00 -05:00
SergeantPanda
1493eaf28b Merge branch 'main' of https://github.com/Dispatcharr/Dispatcharr into dev 2025-09-27 17:33:25 -05:00
SergeantPanda
4a4d93767e Separate steps to debug failed builds 2025-09-27 17:28:34 -05:00
SergeantPanda
134093b18e Enhancement: Add 'nice -n 5' to celery commands in configuration files for better process priority management 2025-09-27 15:32:29 -05:00
SergeantPanda
93dd37e822 Enhancement: Add x-tg-url and url-tvg URL generation with preserved query parameters to M3U content 2025-09-26 14:10:42 -05:00
SergeantPanda
ec193813b4 Enhancement: Update logo options to include the default logo 2025-09-26 10:24:00 -05:00
SergeantPanda
70f7484fb5 Better connection tracking for apps that do not reuse connections during seeking operations. 2025-09-26 09:18:43 -05:00
SergeantPanda
7bb4df78c8 Enhancement: Update M3U profile retrieval to include current connection counts and session handling during vod session start. 2025-09-26 09:18:42 -05:00
SergeantPanda
d961d4cad1 Bug fix: VOD will now select the correct M3U profile while starting.Fixes #461 2025-09-26 09:18:22 -05:00
SergeantPanda
0cdce1a81b Enhancement: Refactor stream selection logic when all available profiles have max connections used. Will retry faster. 2025-09-25 17:49:05 -05:00
SergeantPanda
23f397c805 Enhancement: Add exact Gracenote ID matching to EPG channel mapping 2025-09-25 11:51:10 -05:00
GitHub Actions
70aaa2a04c Release v0.10.1 2025-09-24 21:40:25 +00:00
SergeantPanda
86344b43ba
Merge pull request #468 from Dispatcharr:dev
# Dispatcharr Release – Version 0.10.1
2025-09-24 16:39:30 -05:00
SergeantPanda
a12bfeab46 Enhancement: Increase max_length of URL field in EPGSource model to 1000 characters 2025-09-24 16:12:17 -05:00
SergeantPanda
6fa12f90c5 Improved url transofrmation logic to support more advanced regex during profile refreshes. 2025-09-24 15:51:26 -05:00
Connor Smith
fd9038463b Allow for batch editing channel logos 2025-09-22 20:29:32 -04:00
SergeantPanda
75fbf9639a Enhancement: Update channel and program mapping to support multiple channels per TVG ID 2025-09-21 16:23:07 -05:00
SergeantPanda
4db8eca391
Merge pull request #448 from stlalpha/fix-438
Fix #438: Virtualize TV Guide rendering to improve large channel count performance and add regression tests
2025-09-21 15:50:28 -05:00
SergeantPanda
08e5b6f36f Apply prettier formatting. 2025-09-21 15:17:15 -05:00
SergeantPanda
6f79845b21 Enhancement: Only grab first display name for a channel during epg scanning. 2025-09-21 12:40:20 -05:00
SergeantPanda
99122cac7c Bug fix: If URL for Channel element in EPG is longer than 500 characters parsing would fail. Added validation during scanning. 2025-09-21 12:23:48 -05:00
SergeantPanda
3f7edd840e Change whiteSpace style from 'nowrap' to 'pre' in StreamsTable for better text formatting.
Users can you reliably copy a name or group from the stream table and use as a filter for m3u accounts
2025-09-21 11:17:14 -05:00
SergeantPanda
63729fb0ea Improved logging for stream filters. 2025-09-21 10:42:23 -05:00
SergeantPanda
207613c00b Fix frontend saving case sensitive setting as json string. 2025-09-21 10:06:53 -05:00
Jim McBride
323f1d5c05
Add TV guide utility tests and vitest setup 2025-09-21 10:00:15 -05:00
Jim McBride
00b8119b81 Revert "Virtualize TV guide rendering"
This reverts commit db024130be.
2025-09-21 01:25:29 -05:00
Jim McBride
db024130be Virtualize TV guide rendering 2025-09-21 01:02:32 -05:00
Dispatcharr
6536f35dc0 FIxed bug
Fixed bug that stopped stream from ending
2025-09-19 19:47:59 -05:00
Dispatcharr
eee4ab0725 Update for recurring rules 2025-09-19 16:21:28 -05:00
OkinawaBoss
02ac0d8de5
Merge pull request #430 from Dispatcharr/dev
Syncing with dev
2025-09-19 12:38:57 -07:00
GitHub Actions
773e8e7d54 Release v0.10.0 2025-09-18 22:28:47 +00:00
SergeantPanda
9eade91958
Merge pull request #424 from Dispatcharr/dev 2025-09-18 17:27:31 -05:00
BigPanda
0dbc5221b2 Add 'UK' region
I'm not sure if this was intentional, but the UK seems to be missing from
the region list.
2025-09-18 21:20:52 +01:00
SergeantPanda
b3debcd014 Fix bug during VOD cleanup where all VODs not from the current m3u scan would be deleted. 2025-09-18 14:14:04 -05:00
SergeantPanda
48a2f2da39 Simplify VOD and series access for all authenticated users by removing user-level restrictions on M3U accounts. 2025-09-18 11:17:49 -05:00
SergeantPanda
f4f29a0e27 Break out of channel child elements once required data is acquired. 2025-09-18 10:34:51 -05:00
Dispatcharr
424a450654 DVR Features and bug fixes
Added ability to use custom comskip.ini
Added series recording without reliance on EPG
Fixed comskip bug
Fixed timezone mismatch when scheduling DVR recordings

No migrations completed yet
2025-09-18 10:23:16 -05:00
SergeantPanda
edc18e07fe Auto-focus filter for epg. 2025-09-16 20:10:49 -05:00
SergeantPanda
00da233322 Rename logos variable to channelLogos to avoid future confusion. 2025-09-16 19:49:41 -05:00
SergeantPanda
9ef2aa966d Requery channels when setting channel names from epg. 2025-09-16 19:44:41 -05:00
SergeantPanda
ab3350d08d Search all logos instead of just channel assignable. 2025-09-16 19:41:11 -05:00
SergeantPanda
2e5280c46a Remove unneeded logo call. 2025-09-16 19:17:31 -05:00
SergeantPanda
8b740fc3ac Move buttons for use epg name and use epg logo . 2025-09-16 18:49:02 -05:00
SergeantPanda
7e13e51198 Update the frontend on logo change. 2025-09-16 17:55:55 -05:00
SergeantPanda
3cb5a061c9 Show progress as notifications. 2025-09-16 17:35:38 -05:00
SergeantPanda
d2d1984797 Switch bulk epg name and logo to backend celery tasks for efficiency and scrape epg channel logo during epg scanning. 2025-09-16 17:17:07 -05:00
SergeantPanda
8607d626fa Update logo store when bulk changing logos. 2025-09-16 16:32:32 -05:00
SergeantPanda
388d9e7171 Fix logos not being set. 2025-09-16 16:25:50 -05:00
SergeantPanda
64a019597d Add ability to channel edit form and bulk channel editor to set logos and names from assigned epg.
Closes #157 [Feature]: Logo from EPG
2025-09-16 16:20:16 -05:00
SergeantPanda
cc03ad7d64
Merge pull request #413 from Dispatcharr/epg-auto-match-refactor 2025-09-16 15:58:56 -05:00
SergeantPanda
a846b09ad3 Minor formatting adjustment. 2025-09-16 14:39:04 -05:00
SergeantPanda
60e378b1ce Add support for matching selected channels with EPG data
- Updated API to accept optional channel IDs for EPG matching.
- Enhanced match_epg method to process only specified channels if provided.
- Implemented new task for matching selected channels in the backend.
- Updated frontend to trigger EPG matching for selected channels with notifications.
2025-09-16 14:38:16 -05:00
SergeantPanda
20685b8344 Lower regional bonus. Remove epg_match script. 2025-09-16 14:27:07 -05:00
SergeantPanda
c7235f66ba Use stricter matching for bulk matching. 2025-09-16 14:12:45 -05:00
SergeantPanda
6384f4f56f Add progress notifications for EPG matching process 2025-09-16 13:47:59 -05:00
SergeantPanda
d6bb9e40b2 Implement memory cleanup for ML models after channel matching operations 2025-09-16 13:15:32 -05:00
SergeantPanda
c55dcfd26a Remove unnecessary checking of cache directories. Lets sentence transformers handle it. 2025-09-16 13:01:43 -05:00
SergeantPanda
fedc98f848 Removed unneeded debug logging. 2025-09-16 12:54:19 -05:00
SergeantPanda
d2085d57f8 Add sentence transformers to new matching function. 2025-09-16 12:43:21 -05:00
SergeantPanda
f6be6bc3a9 Don't use matching script 2025-09-16 09:18:41 -05:00
SergeantPanda
f1739f2394 Add EPG auto-match functionality for specific channels and update UI 2025-09-16 08:55:10 -05:00
SergeantPanda
eccc5d709a Sort categories by name during api retrieval. 2025-09-15 20:38:18 -05:00
SergeantPanda
f4e91013f2
Remove local data volume binding
Removed local data volume binding from docker-compose.
2025-09-15 20:14:02 -05:00
SergeantPanda
56aa5c77d2 Filter out profiles during db query that are inactive. 2025-09-15 20:02:40 -05:00
SergeantPanda
ed0b291237 Skip disabled m3u accounts when choosing streams during playback.
Closes #402
2025-09-15 17:36:31 -05:00
SergeantPanda
dfaae6e617 Enhance UserViewSet queryset to prefetch related channel_profiles for improved performance 2025-09-14 19:47:40 -05:00
SergeantPanda
22e1a8cc05 Fix: "ReferenceError: setIsInitialized is not defined" when logging into web ui. 2025-09-14 19:34:32 -05:00
SergeantPanda
5e661ea208 Fix VOD page not displaying correct order while changing pages. 2025-09-14 19:27:11 -05:00
SergeantPanda
f8e91155e2 Fix bug: cannot access local variable 'total_chunks' where it is not associated with a value 2025-09-14 15:19:51 -05:00
SergeantPanda
0c507988b6 Remove max size on group and epg columns. Increase default column size for epg 2025-09-14 14:53:55 -05:00
SergeantPanda
d7129d6195 Change EPG column to have (EPG ID - TVG-ID) and standardize header label. 2025-09-14 14:35:51 -05:00
SergeantPanda
f84a347514 Don't put channel name in EPG name when no epg is assigned. 2025-09-14 13:53:02 -05:00
SergeantPanda
661d5f9d43
Merge pull request #392 from Dispatcharr:bulk-channel-creation-refactor
Enhance channel creation process with starting channel number and bulk creation updates
2025-09-14 13:37:50 -05:00
SergeantPanda
a1d35a8dad Additional check if channel number is in use before creating. 2025-09-14 13:34:03 -05:00
SergeantPanda
6c1dbff91c Send websocket when channel is created. 2025-09-14 13:27:31 -05:00
SergeantPanda
58a3da386a Allow single channel creation from stream to specify channel number 2025-09-14 13:24:11 -05:00
SergeantPanda
4886426ea0 New feature: Allow specifying channel number during channel creation.
Closes #377
Closes #169 [Feature]: Starting channel number/continue auto numbering from channel number X (user defined)
2025-09-14 12:46:30 -05:00
SergeantPanda
0411fe003a Add ability to specify starting channel numbers to the api. 2025-09-14 12:30:24 -05:00
SergeantPanda
4f49636899 Remove old bulk create view. 2025-09-14 12:19:23 -05:00
SergeantPanda
8ab9a508d4 Remove deprecated createChannelsFromStreams method 2025-09-13 20:32:25 -05:00
SergeantPanda
05641cfb02 Remove duplicate websocket notification 2025-09-13 20:30:18 -05:00
SergeantPanda
7866eed613 Fetch channels after successful creation of channels. 2025-09-13 20:21:27 -05:00
SergeantPanda
301a162e71 Switch to normal notifications 2025-09-13 20:16:14 -05:00
SergeantPanda
cf7ea09341 Implement asynchronous bulk channel creation from stream IDs with WebSocket progress updates 2025-09-13 19:59:43 -05:00
SergeantPanda
5875c31750 Add POST endpoint to retrieve streams by IDs and enhance channel creation process 2025-09-13 19:10:47 -05:00
SergeantPanda
d09a5efe80 Merge branch 'main' of https://github.com/Dispatcharr/Dispatcharr into dev 2025-09-13 18:24:04 -05:00
SergeantPanda
fe2edf16b0 Better rendering of horizontal scroll bar. 2025-09-13 18:17:09 -05:00
SergeantPanda
40cbb745cd Fix bug where not setting a channel number would cause an error when creating a channel. 2025-09-13 17:15:59 -05:00
SergeantPanda
92a26caf03 Fix bug where clicking add channel with a channel selected opened the edit form. 2025-09-13 17:09:52 -05:00
SergeantPanda
81520293f4 Fix bug where a channel created could use streams from another channel. The form wasn't being fully cleared. 2025-09-13 17:02:40 -05:00
SergeantPanda
0d659f4435 Set name to expand/contract automatically when growing/shrinking the stream and channel table. 2025-09-13 16:55:50 -05:00
SergeantPanda
d55909989c Remove unnecessary destructuring in StreamsTable component definition 2025-09-13 16:39:11 -05:00
SergeantPanda
33700ccc06
Merge pull request #379 from Dispatcharr:table-column-resizing
Implement column resizing and improve table layouts
2025-09-13 12:16:41 -05:00
GitHub Actions
082bbdd82b Release v0.9.1 2025-09-13 17:10:01 +00:00
SergeantPanda
59379ae59a
Merge pull request #385 from Dispatcharr:dev
Dispatcharr Release Notes – Version 0.9.1
2025-09-13 12:09:20 -05:00
SergeantPanda
41d7066d6e Fix incorrect paths for DVR and Plugins. 2025-09-13 11:49:04 -05:00
SergeantPanda
75816b5d8e Change folder creation for Recordings in entrypoint from 'recordings' to 'Recordings' 2025-09-13 09:07:08 -05:00
SergeantPanda
892fde89b5 Fixed broken migration for plugins 2025-09-13 08:52:53 -05:00
SergeantPanda
55fdc28450 Better sizing on user-agents table. 2025-09-12 17:10:49 -05:00
SergeantPanda
b578969b8e Better sizing of stream profiles table 2025-09-12 17:04:52 -05:00
SergeantPanda
5e2794a62e Fix sizing on logo table and user table. 2025-09-12 17:00:59 -05:00
SergeantPanda
65e947c95b Rename type columns. 2025-09-12 16:40:30 -05:00
SergeantPanda
900ce73200 Better sizing for epg and m3u tables. Rearranged m3u table slightly. 2025-09-12 16:38:18 -05:00
GitHub Actions
42f52b7484 Release v0.9.0 2025-09-12 18:43:30 +00:00
SergeantPanda
d88003b542
Merge pull request #378 from Dispatcharr/dev
# Dispatcharr – Version 0.9.0
2025-09-12 13:42:33 -05:00
SergeantPanda
fa41b8eb23 Save divider location in local storage. 2025-09-12 11:12:27 -05:00
SergeantPanda
448ef16987 Expand header width to fill container. 2025-09-12 10:56:17 -05:00
SergeantPanda
a9ca6502ed Better calculation for horizontal scroll bar in channel and stream tables. 2025-09-12 10:31:35 -05:00
SergeantPanda
51352ba734 Save stream table header sizing to local storage. 2025-09-12 10:24:23 -05:00
SergeantPanda
09ba42d36c Enhance column resizing functionality in ChannelsTable, CustomTableHeader, and CustomTableBody components 2025-09-12 10:08:46 -05:00
SergeantPanda
22f0a4078b Implement column resizing functionality in Channels Table, Streams Table and CustomTableHeader 2025-09-12 09:22:02 -05:00
Dispatcharr
c85316b912 Added DVR offset
Added pre/post offset for DVR recordings.
2025-09-11 20:58:42 -05:00
SergeantPanda
ab36b28b51 Fix logos not loading in channel edit form. 2025-09-11 15:03:26 -05:00
SergeantPanda
3d2873fd4f Add EPG Channel name to epg filter.
Closes #286 [Feature]: 2 Value/Column EPG in Channel Edit
2025-09-11 13:48:12 -05:00
SergeantPanda
0a8b8919b9 Add EPG source name before epg name in channel edit form. 2025-09-11 13:26:40 -05:00
SergeantPanda
43afbd4505 Shrink width of group column. 2025-09-11 12:57:25 -05:00
SergeantPanda
e5ee64c575 Allow filtering by EPG assignment.
Closes #155
2025-09-11 12:43:44 -05:00
SergeantPanda
7925b601f4 Add epg column to channels table. 2025-09-11 12:10:27 -05:00
SergeantPanda
f797e6fff5 Persist page size in localStorage and update page size change handler in VODsPage component 2025-09-11 11:00:47 -05:00
SergeantPanda
92b4e9e348 Reset category filter on type change in VODsPage component 2025-09-11 10:56:41 -05:00
SergeantPanda
5f00027425 Reorganize VODModal buttons: move Play button next to Watch Trailer and update styles 2025-09-11 10:55:32 -05:00
SergeantPanda
6cc67851b3 Remove account type badge from modals. 2025-09-11 10:43:22 -05:00
SergeantPanda
307bd25e3e Filter category options based on selected type in VODsPage component 2025-09-11 10:36:06 -05:00
SergeantPanda
45817c699f Sort combined VODs alphabetically by name/title and update fetch logic for 'all' type 2025-09-11 10:34:28 -05:00
SergeantPanda
7a95555bd5 Add page size to vod page. 2025-09-11 10:26:31 -05:00
SergeantPanda
d1a3b667fe Store file modification time in Redis for uploaded logo files 2025-09-11 09:50:21 -05:00
SergeantPanda
4c3aef196d Refactor VOD to separate out series and vod modals. 2025-09-11 09:19:27 -05:00
SergeantPanda
74aff3fb1a Enhance M3U profile management: add optional notes field, improve validation for default profiles, and update UI to display notes where applicable.
Also adds the ability to modify the name of the defualt profile.
Closes #280 [Feature]: add general text field in m3u/xs
2025-09-10 17:26:18 -05:00
SergeantPanda
f218eaad51
Merge pull request #366 from Dispatcharr:XC-account-info
Enhance M3U account profile with custom properties and account info retrieval
2025-09-09 18:28:07 -05:00
SergeantPanda
893fdcade3 Use playlist store 2025-09-09 18:22:47 -05:00
SergeantPanda
84c752761a Ability to refresh account info from account info modal and provide notification on results. 2025-09-09 18:15:33 -05:00
SergeantPanda
d1a5143312 Convert refresh time to local time. 2025-09-09 16:43:57 -05:00
SergeantPanda
c25de2a191 Add AccountInfoModal component and integrate account information display in M3UProfiles 2025-09-09 16:29:24 -05:00
SergeantPanda
c023cde8fe Add custom properties to M3UAccountProfile and implement account info retrieval 2025-09-09 16:05:58 -05:00
SergeantPanda
c0ddec6b4b Reduced cleanup time on vod from 10 seconds to 1. 2025-09-09 13:06:40 -05:00
SergeantPanda
64e500c524 Add logging for profile connection cleanup and implement delayed cleanup for idle Redis connections 2025-09-09 12:58:35 -05:00
OkinawaBoss
3fb8e0ebd1
Merge pull request #363 from Dispatcharr/Plugins
Plugins
2025-09-08 09:11:19 -05:00
SergeantPanda
0938a3c592 Better headers for the client including content range that includes total length as well. 2025-09-07 18:51:58 -05:00
SergeantPanda
b45c6eda38 Better content-type detection 2025-09-07 18:25:39 -05:00
Dispatcharr
1200d7d894 Plugin discovery fix
Fixed problem where plugins were trying to load before DB was ready.
2025-09-07 17:38:34 -05:00
SergeantPanda
be9823c5ce Added debug logs for active clients 2025-09-07 17:14:29 -05:00
SergeantPanda
adf960753c Changed user-agent for head request to use m3u account 2025-09-07 15:17:19 -05:00
Dispatcharr
5b31440018 Updated Plugins
Added Import + Delete
Added Modal confirmations
Safer Defaults
2025-09-07 11:54:22 -05:00
SergeantPanda
c239f0300f Better progress tracking for clients that start a new session on every seek instead of reusing existing session. 2025-09-06 15:36:14 -05:00
SergeantPanda
4ca6bf763e Add position calculation 2025-09-06 10:16:54 -05:00
SergeantPanda
80c3d2fa58 Reformat vod card a bit. 2025-09-06 08:34:28 -05:00
SergeantPanda
aeb1933abb Separate every vod connection even if same vod is being played. 2025-09-06 08:13:28 -05:00
SergeantPanda
f135c6ae8b
Merge pull request #357 from Dispatcharr:vod-stats
Rename Active Channels to Active Streams and enhance VOD features
2025-09-05 21:31:40 -05:00
SergeantPanda
1817aab2da Fix channel cards not closing when channel stops. 2025-09-05 21:09:03 -05:00
SergeantPanda
db80a2fa09 Made vod card similar to channel card 2025-09-05 21:04:41 -05:00
SergeantPanda
d4b7b3d3d2 Combine vod and streams into same view 2025-09-05 20:40:55 -05:00
SergeantPanda
6b9d42fec1 Refactor VODStatsView to use correct field names for content type and M3U profile ID, and update user agent handling 2025-09-05 20:34:43 -05:00
SergeantPanda
1a8763731b Remove unneeded headers 2025-09-05 20:26:35 -05:00
SergeantPanda
18b8462a5f Refactor user-agent handling to use 'client_user_agent' for consistency across VOD connection management 2025-09-05 20:15:11 -05:00
SergeantPanda
7c4d7865ea Fixed not using user-agent from m3u (was using client user-agent) 2025-09-05 19:36:08 -05:00
SergeantPanda
1080b1fb94 Refactor stats and vod proxy 2025-09-05 19:30:13 -05:00
Dispatcharr
e9a11588c4 Init Plugins 2025-09-05 17:10:11 -05:00
SergeantPanda
4712a4305c Rename "Active Channels" to "Active Streams" and enhance automatic refresh interval UI 2025-09-05 12:22:06 -05:00
SergeantPanda
b68b904838
Merge pull request #356 from Dispatcharr:stats-direct-api
Switch to api calls instead of celery beat for stats
2025-09-05 12:10:10 -05:00
SergeantPanda
f1196bb988 Send websocket update on client disconnect and connect. 2025-09-05 12:04:30 -05:00
SergeantPanda
98f485bac9 Fixed issue with recurring api calls. 2025-09-05 11:37:49 -05:00
SergeantPanda
870e77b137 Disable fetch-channel-statuses 2025-09-05 10:23:25 -05:00
SergeantPanda
d709d92936 Refactor channel stats fetching and enhance settings UI for better user experience 2025-09-05 09:42:52 -05:00
SergeantPanda
ca79cc1a1d
Merge pull request #354 from Dispatcharr:DVR-Refresh
DVR enhancements and Comskip integration
2025-09-04 17:30:29 -05:00
SergeantPanda
67939ba44b Auto cleanup orphaned vods and series during scan. 2025-09-04 16:57:32 -05:00
Dispatcharr
8c364d3eb8 Fix DVR
Fixed bug where connection wouldn't release
2025-09-04 16:00:01 -05:00
SergeantPanda
e80e1b9014 Add comskip to base image for upcoming DVR feature/overhaul. 2025-09-04 14:50:32 -05:00
Dispatcharr
7401b4c8d3 Combined migrations 2025-09-04 14:36:14 -05:00
Dispatcharr
f652d2b233 Comskip Update 2025-09-04 13:45:25 -05:00
SergeantPanda
fbeca53cd7 Minor fixes to VOD api. trailer returned Null instead of empty string, removed decimal from added time. 2025-09-04 13:44:15 -05:00
Dispatcharr
c76d68f382 Updated guide
Updated series rules button to match the rest of the layout.
Fixed problem where the remove button would not remove the rule
2025-09-04 08:40:48 -05:00
Dispatcharr
41e32bc08a DVR Updates
Added fallback settings.
Added subtitles to cards.
Add data volume mount to Docker container.
2025-09-04 08:22:13 -05:00
Dispatcharr
00cc83882a DVR update 2025-09-03 21:35:45 -05:00
SergeantPanda
5806464406 Fix unique constraint violations when rehashing streams 2025-09-03 16:23:58 -05:00
SergeantPanda
55f54081b1
Merge pull request #343 from Dispatcharr:Channel-Batch-Update
Add batch channel name updates with regex support
2025-09-02 19:51:22 -05:00
Dispatcharr
c4f9a998e5 Added batch channel name updates 2025-09-02 17:23:18 -05:00
SergeantPanda
5ccef5db32
Merge pull request #342 from Dispatcharr:vod-endpoint-optimizations
Vod-endpoint-optimizations
2025-09-02 15:54:49 -05:00
SergeantPanda
eff4b665b1 Fix bug where multiple relations would error out get_vod_info 2025-09-02 15:52:40 -05:00
SergeantPanda
ffe6ce5a6b Removed unnecessary fall backs for trailer. 2025-09-02 15:35:04 -05:00
SergeantPanda
ac5df3fd28 Optimize VOD streams retrieval by using prefetch_related to eliminate N+1 queries 2025-09-02 14:59:33 -05:00
SergeantPanda
e7201b4429
Merge pull request #341 from Dispatcharr:convert-custom_properties-from-text-to-jsonb-fields-
Convert-custom_properties-from-text-to-jsonb-fields-
2025-09-02 10:35:27 -05:00
SergeantPanda
6c1bfaf052 Increase server url max length from 200 to 1000 characters to accomodate tokens in the url 2025-09-02 10:20:23 -05:00
SergeantPanda
8cae13f845 Remove unneeded migration. 2025-09-02 09:59:06 -05:00
SergeantPanda
9c41960405 Added conversion to jsonb for users table. 2025-09-02 09:52:39 -05:00
SergeantPanda
246b00e2bf Convert backend to use jsonb fields. 2025-09-02 09:48:54 -05:00
SergeantPanda
6f6c28ca7c Convert custom_properties to jsonb in the backend. 2025-09-02 09:41:51 -05:00
SergeantPanda
a87f7c875d Merge custom properties when refreshing groups instead of overwriting. 2025-09-02 09:10:52 -05:00
SergeantPanda
071e74124e Remove relations to groups that no longer exist in the m3u account. Also delete groups that are left orphaned after the relation is removed. 2025-09-01 21:09:50 -05:00
SergeantPanda
7ea10064d7 Update group custom_properties during m3u update.
Fixes issue when converting from M3U account to XC. Will add the xc_id now.
2025-09-01 19:48:30 -05:00
SergeantPanda
648e2bb2dd Applied our Prettier formatting to all frontend code. 2025-08-31 09:50:37 -05:00
SergeantPanda
59c6b0565e Use buffering_speed from settings to determine color for speed badge instead of hardcoded at 1.0
Fixes [Bug]: Stat's "Current Speed" does not reflect "Buffering Speed" setting
Fixes #311
2025-08-31 09:28:30 -05:00
SergeantPanda
17cb303273 Refactor: Standardize string quotes and improve code formatting in ChannelCard component 2025-08-31 09:12:17 -05:00
SergeantPanda
10d5d487c3 Fix regex replacement pattern for M3U profile consistency in auto channel sync 2025-08-31 09:03:11 -05:00
SergeantPanda
3aa68c1a36 Enhancement: Allow overriding of default stream profile for auto channel sync. 2025-08-31 08:49:45 -05:00
SergeantPanda
b17bc21159 Cleanup orphaned channels if stale stream retention deleted the last stream from the channel and was created by auto channel sync for the current m3u. 2025-08-31 08:30:17 -05:00
SergeantPanda
fc2caa0e12 Cleaned up code for fetching. 2025-08-30 20:21:19 -05:00
SergeantPanda
22498de395 Better error detection and logging for the user when m3u downloads fail. 2025-08-30 20:14:58 -05:00
SergeantPanda
58446fdd33
Merge pull request #334 from RocketCaptain/main
indentation in the `clone_dispatcharr_repo` function for consistency …
2025-08-30 18:48:04 -05:00
SergeantPanda
12c2b8c7a7 Merge branch 'dev' of https://github.com/Dispatcharr/Dispatcharr into dev 2025-08-30 10:53:47 -05:00
SergeantPanda
0228d8d7f4 Fix auto sync channel settings not saving. 2025-08-30 10:48:30 -05:00
SergeantPanda
144a71a636
Merge pull request #338 from Dispatcharr:m3u-batch-processing
M3u-batch-processing
2025-08-30 09:56:12 -05:00
SergeantPanda
b5e32421bd Cleanup unused code. 2025-08-30 09:33:56 -05:00
SergeantPanda
41bd372b14 Save all custom_properties for xc. 2025-08-29 19:55:02 -05:00
SergeantPanda
c24da847fc Get all live streams at once and filter by category. 2025-08-29 19:18:25 -05:00
SergeantPanda
d2b6096570 Changes to XC processing for faster parsing. 2025-08-29 16:39:57 -05:00
SergeantPanda
e3f988b071 Remove shared task from batch m3u processesing. 2025-08-29 15:51:42 -05:00
SergeantPanda
3a81227a73 Move tuner calculation to central location and use it to dynamically calculate tuner counts for XC api user_info 2025-08-28 15:26:51 -05:00
SergeantPanda
62c4e1625b Dynamically calculate expiration date 90 days out instead of hardcoded. 2025-08-28 15:12:13 -05:00
SergeantPanda
5c6d1fe6fd
Merge pull request #336 from Dispatcharr/vod-relationtest
Implement Video on Demand (VOD) functionality
2025-08-28 13:39:24 -05:00
SergeantPanda
2026eab7f2 Default VOD categories to disabled. 2025-08-28 13:17:22 -05:00
SergeantPanda
ad774db59e Fixed episode image failing to load. 2025-08-28 11:45:16 -05:00
SergeantPanda
11f54f3cda Fix VOD categories not loading on new accounts without webui refresh. 2025-08-28 11:20:37 -05:00
SergeantPanda
2b484a94ec Applied formatting to floating video. 2025-08-28 10:44:11 -05:00
SergeantPanda
d2b852c9a2 Fix cyclic error with logos loading. 2025-08-28 10:42:42 -05:00
SergeantPanda
73065ed319 Load logos after all initalization is done. True background logo loading. 2025-08-26 17:54:28 -05:00
SergeantPanda
fe350bda91 Smarter tracking of logos loading throughout different pages. 2025-08-26 17:26:59 -05:00
SergeantPanda
774c80e2a4 Convert a couple more functions to fetch ALL logos. 2025-08-26 16:42:16 -05:00
SergeantPanda
59dd6383eb Fetch all logos when running cleanup. 2025-08-26 15:48:51 -05:00
SergeantPanda
c35c02c64b Updated verbiage for cleanup unused logo function. 2025-08-26 15:37:05 -05:00
SergeantPanda
a0ab0e8688 Don't fetch all logos when uploading a new logo. 2025-08-26 15:33:35 -05:00
SergeantPanda
2645166c8c Don't fetch all logos if editing. 2025-08-26 15:18:44 -05:00
SergeantPanda
fd01d1b6af Fix bulk logo cleanup. 2025-08-26 14:59:05 -05:00
SergeantPanda
0e52117e78 Add channel logo selection and background loading functionality
- Introduced `useChannelLogoSelection` hook for managing channel-assignable logos.
- Updated `ChannelForm` and `ChannelsForm` components to use the new logo selection hook.
- Implemented background loading of channel-assignable logos after user login in the auth store.
- Enhanced logos store to handle separate state for channel logos and added fetching logic.
2025-08-26 14:30:41 -05:00
SergeantPanda
8ad1c6ab1a Better badge labeling depending on logo type. 2025-08-26 13:25:12 -05:00
SergeantPanda
b92d7c2c21 Major rework of how logos are handle. We will no longer load logos on login and they will only be loaded into the store when needed. 2025-08-26 13:19:29 -05:00
SergeantPanda
85a0b584a5 Added formatting to logos table and user agents table. 2025-08-26 08:53:35 -05:00
Michael Bateson
4942393842 indentation in the clone_dispatcharr_repo function for consistency and readability:
- Unindented `EOSU` marker and `else` block
   - Aligned code structure within the conditional blocks
2025-08-26 18:26:17 +10:00
SergeantPanda
5eedceedc3 Combine migrations. 2025-08-25 17:27:15 -05:00
SergeantPanda
07a29916de Reset formatting. 2025-08-25 17:11:41 -05:00
SergeantPanda
2d49b72f06 Reset Blame 2025-08-25 17:10:32 -05:00
dekzter
11bc2e57a9 optimized vod parsing, added in vod category filtering, added UI individual tabs for movies vs series VOD category filters 2025-08-25 14:37:20 -04:00
SergeantPanda
3ecd7137ff Added misssing bulk fetch function for vod categories. 2025-08-22 17:12:50 -05:00
dekzter
c80752a21d attempting to fix updated reference name 2025-08-22 17:36:57 -04:00
dekzter
efcf4c7920 missed commit 2025-08-22 17:31:22 -04:00
dekzter
a19bd14a84 added vod category filtering 2025-08-22 16:59:00 -04:00
SergeantPanda
e45082f5d6 Merge branch 'dev' of https://github.com/Dispatcharr/Dispatcharr into vod-relationtest 2025-08-22 14:53:17 -05:00
SergeantPanda
0a5e7a3231 Enhance logo upload functionality: allow custom logo names and update handling in LogoForm component.
Fixes [Bug]: Logo Manager not allowing a change to name field
Fixes #320
2025-08-22 13:34:17 -05:00
SergeantPanda
6746588c15 Merge branch 'dev' of https://github.com/Dispatcharr/Dispatcharr into dev 2025-08-22 13:24:58 -05:00
SergeantPanda
22e9e56a5b Add JSONParser to LogoViewSet parser_classes for improved request handling
Fixes [Bug]: Updating Logo throws error
2025-08-22 13:24:50 -05:00
SergeantPanda
48c4cd8ca9 Merge branch 'logosstore' of https://github.com/Dispatcharr/Dispatcharr into vod-relationtest 2025-08-22 12:09:45 -05:00
SergeantPanda
52d96201fe
Merge pull request #328 from Dispatcharr/logosstore 2025-08-22 12:07:30 -05:00
SergeantPanda
39598b4ff4 Refactor logo handling: Introduce useLogosStore, implement lazy loading for logos, and update components to use new store methods. 2025-08-22 09:56:20 -05:00
SergeantPanda
3bea39a117 Fix VOD video player to allow clicking seek bar (overlay was interferring) 2025-08-21 16:48:28 -05:00
SergeantPanda
f1a80c3389 Check for null values before saving to database and scrub empty string. 2025-08-21 16:31:41 -05:00
SergeantPanda
3590265836 Show all available options for VODs and their corresponding quality. 2025-08-21 15:04:47 -05:00
SergeantPanda
a4df1f1fb8 Allow specifying which account to play from. 2025-08-21 13:14:16 -05:00
SergeantPanda
a95ed79e93 Handle issue with relations. 2025-08-21 12:24:06 -05:00
SergeantPanda
90eccd6edf When getting detailed VOD and series info if an imdb or tmdb id were added it could have caused duplicate key violations, we now merge the two VOD's deleting the VOD that isn't being accessed at that time to avoid the user getting invalid links. 2025-08-21 11:41:58 -05:00
SergeantPanda
a03744e24e Merge branch 'dev' of https://github.com/Dispatcharr/Dispatcharr into vod-relationtest 2025-08-21 09:35:40 -05:00
SergeantPanda
56af468320 Merge migrations 2025-08-21 09:35:25 -05:00
SergeantPanda
a95d2a77d8
Merge pull request #322 from Biologisten:dev
Added date and time formats to UI Settings
2025-08-21 09:31:39 -05:00
Biologisten
81a293e7dd Added date and time formats to settings 2025-08-21 02:31:08 +02:00
SergeantPanda
24f876d09f Add priority for providers so VOD's can be auto selected based on the priority. 2025-08-20 17:38:21 -05:00
SergeantPanda
fa2b3fbe3e Merge branch 'dev' of https://github.com/Dispatcharr/Dispatcharr into vod-relationtest 2025-08-19 18:24:27 -05:00
SergeantPanda
e12ce4e596 Swagger endpoint can now be accessed with or without a trailing slash. 2025-08-19 18:16:21 -05:00
SergeantPanda
3a91994549 Rollback accidental change to uwsgi debug config. 2025-08-19 18:07:46 -05:00
SergeantPanda
c87bd79051 Cleanup the rest of the redis keys on connection close. 2025-08-19 17:52:59 -05:00
SergeantPanda
083eb264e6 Properly track m3u profile connections. 2025-08-19 17:45:09 -05:00
SergeantPanda
97b82e5520 Use redis to track provider connections to work with multi-worker uwsgi. 2025-08-19 17:35:51 -05:00
SergeantPanda
d04fdb6f69 Fix categories not linking. 2025-08-19 15:42:30 -05:00
SergeantPanda
9137bd47c9 Switch to pulling ALL vods, series and categories instead of an api call for each category. 2025-08-19 14:53:43 -05:00
SergeantPanda
2903773c86 Merge branch 'dev' of https://github.com/Dispatcharr/Dispatcharr into vod-relationtest 2025-08-19 12:39:21 -05:00
GitHub Actions
750bf90576 Release v0.8.0 2025-08-19 17:05:08 +00:00
SergeantPanda
0843d32388
Merge pull request #313 from Dispatcharr/dev
Dev
2025-08-19 12:04:23 -05:00
SergeantPanda
fa19525ab9 Remove url from movie table and save custom_properties for series. 2025-08-19 11:25:56 -05:00
SergeantPanda
54404339c2 Fix batch processing using simpler method for duplicate detection. 2025-08-19 10:01:37 -05:00
SergeantPanda
0f8fe82f17 Skip logos if they are long. 2025-08-18 14:22:40 -05:00
SergeantPanda
1bf947caf2 Fix not detecting duplicates based on imdb and tmdb ids. 2025-08-14 13:40:31 -05:00
SergeantPanda
d92ba0c7e1 Fix VOD page not using pagination properly. 2025-08-14 12:51:56 -05:00
SergeantPanda
0cf2c74b40 Change from individual database calls to batch. 2025-08-14 12:51:39 -05:00
SergeantPanda
1ed4e97167 Let celery[redis] choose redis version. Update sentence-transformers. 2025-08-14 10:42:46 -05:00
SergeantPanda
eec53d5874
Merge pull request #294 from pantherale0/feat/chunked-extractor
Fix: Prevent memory exhaustion when extracting large compressed files
2025-08-14 09:50:52 -05:00
SergeantPanda
6a0871183c
Merge pull request #277 from deku-m/patch-1
Update debian script
2025-08-14 09:50:11 -05:00
SergeantPanda
e481ea4457 Bump celery to 5.5.3 from 5.3.1 2025-08-14 09:42:01 -05:00
SergeantPanda
dac1490acc Attempt to match incoming connections to sessions that are running with no clients. This will help with clients that disconnect with every timeshift and connect to the original URL. 2025-08-12 21:34:30 -05:00
SergeantPanda
3e16614eab Store full content-length if using head request to initalize. 2025-08-12 17:32:07 -05:00
SergeantPanda
5eeb51585d Support HEAD requests, keep connection alive. 2025-08-12 15:27:39 -05:00
SergeantPanda
be51be7872 Switched to connection tracking by url instead of parameter and 301 redirect instead of 302. 2025-08-12 14:48:35 -05:00
SergeantPanda
a5db9d98e9 Track number of connections to avoid shutting down connection during time shifting. Lower grace period to 10 seconds if client disconnects. 2025-08-12 13:26:32 -05:00
SergeantPanda
19017317f6 Fix invalid import. 2025-08-12 11:48:59 -05:00
SergeantPanda
2632e71815 Increased grace period to 30 seconds before closing provider connection 2025-08-12 11:11:03 -05:00
SergeantPanda
6addcebaf5 Fix duplicate connection counting in redis. 2025-08-12 10:59:40 -05:00
SergeantPanda
4acdfa99f9 Properly cleanup redis keys on client disconnect. 2025-08-12 10:51:06 -05:00
SergeantPanda
310f3c455e Properly close connection if client disconnects and is not seeking a new position. 2025-08-12 10:42:59 -05:00
SergeantPanda
b7fb9336be Reuse connections when seeking. 2025-08-12 09:56:30 -05:00
SergeantPanda
07966424f8 Fix seeking not working. 2025-08-12 08:29:26 -05:00
Jordan
72fee02ec4
ensure chunk is either null or empty to exit loop 2025-08-12 11:26:50 +01:00
SergeantPanda
7e94ee5d49 Fix duration display after converting from minutes to seconds in the table. 2025-08-11 16:45:44 -05:00
SergeantPanda
920201312e Fix episodes api call for series endpoint. 2025-08-11 16:07:41 -05:00
Jordan
429b01b569
prevent memory issues by implementing a chunked extractor 2025-08-11 16:28:23 +01:00
SergeantPanda
d4a93b8e4b Populate seasons array. 2025-08-10 21:56:39 -05:00
SergeantPanda
46a5b3355a Lots of fixes for series xc api responses. 2025-08-10 21:30:05 -05:00
SergeantPanda
3942517032 Save custom properties that aren't stream specific for episodes. 2025-08-09 16:55:26 -05:00
SergeantPanda
4d7987214b Fixed some vod apis, converted duration to duration_secs, add custom_poperties to vod_episode 2025-08-09 16:31:33 -05:00
SergeantPanda
3ac84e530a Switch to smarter extraction of port and if fails, take a guess. 2025-08-09 13:55:49 -05:00
SergeantPanda
21b7f80d42 Add forward port to headers. 2025-08-09 10:20:58 -05:00
SergeantPanda
b90956c49f Couple fixes for vod api. 2025-08-08 13:18:20 -05:00
SergeantPanda
19e59ae178 Switch to movie and episode id instead of relations. 2025-08-08 12:48:02 -05:00
SergeantPanda
4a80cb6d5c Fix get_vod_info 2025-08-08 11:34:27 -05:00
SergeantPanda
d054e2cac5 Add XC endpoints for VOD. 2025-08-08 09:03:25 -05:00
SergeantPanda
00361c15b9 Merged all migrations. 2025-08-08 08:41:43 -05:00
dekzter
44fecff2f2 return new object from store call 2025-08-08 09:40:02 -04:00
SergeantPanda
345247df11 Fix vod streaming. 2025-08-08 08:35:59 -05:00
SergeantPanda
22bc573c10 Always display play button for movies. 2025-08-08 08:15:02 -05:00
SergeantPanda
a332678cfb Remove URL from episode relation table. 2025-08-08 08:06:28 -05:00
SergeantPanda
84dfe69f0a Add series badge to series for easy distinction between the two. 2025-08-08 07:50:37 -05:00
SergeantPanda
5feb843d73 Add imdb and tmdb links to episodes if available. 2025-08-08 07:40:36 -05:00
SergeantPanda
b229c9628d Fix movies not loading on first modal open. 2025-08-07 20:59:45 -05:00
SergeantPanda
430a486438 Add imdb_id to movie api output 2025-08-07 20:52:24 -05:00
SergeantPanda
325d832c1b Fixes movie details after db changes. 2025-08-07 20:42:20 -05:00
SergeantPanda
3741d28565 Fix typo in air date parsing. 2025-08-07 19:41:10 -05:00
SergeantPanda
8d37c678d3 Fix air_date not populating for series. 2025-08-07 19:28:42 -05:00
SergeantPanda
56922e1c01 Fix custom properties. 2025-08-07 16:10:45 -05:00
SergeantPanda
656cd3007b Fix release date and description 2025-08-07 15:37:08 -05:00
SergeantPanda
5b931f6272 Add missing functions for processing episodes. 2025-08-07 14:57:28 -05:00
SergeantPanda
a6bace9241 Add custom properties for movies and series. Refactor update logic. 2025-08-07 14:22:27 -05:00
SergeantPanda
0e388968c4 Convert to relation tables to support multiple providers for each vod. 2025-08-07 12:31:05 -05:00
dekzter
5a92487d38 fixed group filtering: 2025-08-06 14:45:55 -04:00
SergeantPanda
44a2cf518c Track active connections the same way as ts_proxy 2025-08-05 21:35:04 -05:00
SergeantPanda
d18817acb0 Track VOD connections in Redis. 2025-08-05 21:24:41 -05:00
SergeantPanda
5e7987ce1a Add IMDB and TMDB links to modal and fix cast not displaying. 2025-08-05 21:04:49 -05:00
SergeantPanda
8275e33223 Move episode count to series modal. 2025-08-05 20:35:11 -05:00
SergeantPanda
5e5b5e9797 Fix logos resetting when opening series. 2025-08-05 20:23:05 -05:00
SergeantPanda
0565e0ae50 Fix backdrop and release date for series. 2025-08-05 20:01:29 -05:00
SergeantPanda
22b7a3efb2 Fix youtube trailers for series. 2025-08-05 16:43:51 -05:00
SergeantPanda
4b6792ccbe Add ability to expand an episode row to view additional information. 2025-08-05 16:19:50 -05:00
SergeantPanda
cc8064877d Fix episode number not displaying. 2025-08-05 15:55:29 -05:00
SergeantPanda
0120419d09 Get full release date. 2025-08-05 15:50:28 -05:00
SergeantPanda
a66028ff02 Fixes tabs not changing view. 2025-08-05 14:03:05 -05:00
SergeantPanda
4754a0dcd7 Show epsiodes nested under a season tab. 2025-08-05 14:00:05 -05:00
SergeantPanda
b11270ce4a Fix not populating episodes. 2025-08-05 13:53:43 -05:00
SergeantPanda
3c2534d8aa Combine a couple api calls. 2025-08-05 13:28:58 -05:00
SergeantPanda
8584aae675 Better naming for different functions. 2025-08-05 12:53:22 -05:00
SergeantPanda
151f654dd9 Only query series info if data is stale 2025-08-05 11:18:06 -05:00
SergeantPanda
9a927b1fc7 Update migration to track last series refresh. 2025-08-05 10:55:44 -05:00
SergeantPanda
4b27f95b30 Keep track of last time a series was refreshed. 2025-08-05 10:45:02 -05:00
SergeantPanda
ce331abdbc Don't merge provider details with vod store. 2025-08-05 10:23:49 -05:00
dekzter
b7abdac800 merged in dev 2025-08-05 10:56:01 -04:00
dekzter
2f1da5d76a fixed modal closing issues 2025-08-05 10:55:15 -04:00
SergeantPanda
fa29c679f5 Fix eslint error. 2025-08-04 18:33:42 -05:00
SergeantPanda
36450af23f Fix youtube links not loading. 2025-08-04 18:30:33 -05:00
SergeantPanda
b19efd2f75 Use backdrop image as background for modal. 2025-08-04 18:24:45 -05:00
SergeantPanda
d917a3a915 Rearranged data. 2025-08-04 18:21:18 -05:00
SergeantPanda
10ab3e4bd8 Fix movie link not building correctly for web player. 2025-08-04 17:28:20 -05:00
SergeantPanda
4accd2be85 Pull advanced info from provider when opening a movie. 2025-08-03 22:02:34 -05:00
SergeantPanda
e1f5cb24ec Smart sizing based on screen sizing. 2025-08-03 20:03:45 -05:00
SergeantPanda
9f8054c9de Fix video player in dev mode. 2025-08-03 19:59:10 -05:00
SergeantPanda
3baaf8c170 More flexible playback for vods. 2025-08-03 19:53:22 -05:00
SergeantPanda
88a9f8b14e Fix vods not displaying in the webui. 2025-08-03 10:40:17 -05:00
dekzter
f300da6eff case sensitive flag and other possible custom properties for filters 2025-08-03 08:40:00 -04:00
SergeantPanda
d9192d003d Remove json dumps from custom_properties as now the fields are jsonb in postgres. Also fix category matching. 2025-08-02 19:03:39 -05:00
SergeantPanda
f9a9d5d336 Convert postgres to UTF8 from ASCII 2025-08-02 19:02:57 -05:00
deku-m
5010cae068
Merge branch 'Dispatcharr:main' into patch-1 2025-08-02 21:53:07 +02:00
SergeantPanda
0585fc1dfe Separate Movies, Series and Episodes into their own tables 2025-08-02 13:32:44 -05:00
SergeantPanda
0b5906538e More robust parsing of year. 2025-08-02 12:59:02 -05:00
SergeantPanda
0ece58572e Extract year from other data fields if available, otherwise attempt name. 2025-08-02 12:33:41 -05:00
SergeantPanda
bcebcadfaa Add ability to scan for vods during m3u refresh. 2025-08-02 12:01:26 -05:00
SergeantPanda
386a03381c Initial frontend commit for vods. 2025-08-02 10:48:48 -05:00
SergeantPanda
84aa631196 Initial backend commit for vod 2025-08-02 10:42:36 -05:00
SergeantPanda
1c47b7f84a Adds ability to reverse the sort order for auto channel sync. 2025-08-01 16:42:42 -05:00
dekzter
f1752cc720 fixed editing 2025-08-01 17:12:06 -04:00
dekzter
1612df14c1 fixed deleting filter not removing it from the ui 2025-08-01 15:12:19 -04:00
dekzter
ead76fe661 first run at m3u filtering 2025-08-01 15:02:43 -04:00
SergeantPanda
7b5a617bf8 Use custom validator for urls fields to allow for non fqdn hostnames.
Fixes #63
2025-08-01 11:28:51 -05:00
SergeantPanda
953db79476 Display stream logo and name in channel card when previewing streams. 2025-07-31 21:26:59 -05:00
SergeantPanda
6945cecaca Merge branch 'main' of https://github.com/Dispatcharr/Dispatcharr into dev 2025-07-31 20:15:42 -05:00
SergeantPanda
42c792cbb7
Merge pull request #278 from Dispatcharr/Updated-dependencies-
Update dependencies in requirements.txt
2025-07-31 20:13:58 -05:00
SergeantPanda
20651a8d59 Update dependencies in requirements.txt for compatibility and improvements 2025-07-31 20:10:36 -05:00
SergeantPanda
59b75c18fc Add ability to preview streams under a channel. 2025-07-31 15:54:24 -05:00
deku-m
a9aac72a60
Update debian_install.sh 2025-07-31 22:14:35 +02:00
SergeantPanda
406ac37fb9 Delete temp folder if it exists during upgrade. 2025-07-31 15:01:28 -05:00
SergeantPanda
108a992643 Detect mismatched Postgres version and automatically run pg_upgrade 2025-07-31 14:53:55 -05:00
SergeantPanda
826c824084
Bump Postgres to version 17 2025-07-31 14:03:37 -05:00
SergeantPanda
5a887cc55a Bump Postgres to version 17. 2025-07-31 13:54:20 -05:00
SergeantPanda
e029cd8b3d Fix XML escaping for channel ID in generate_dummy_epg function 2025-07-31 10:22:43 -05:00
SergeantPanda
4ae66e0bc9 Add membership creation in UpdateChannelMembershipAPIView if not found.
Fixes #275
2025-07-31 09:52:02 -05:00
SergeantPanda
06d8d01fd9 Merge branch 'main' of https://github.com/Dispatcharr/Dispatcharr into dev 2025-07-29 16:22:48 -05:00
SergeantPanda
64ec6de414
Merge pull request #274 from Dispatcharr/stream-stats
Stream stats
2025-07-29 16:21:37 -05:00
GitHub Actions
26e237f2d1 Release v0.7.1 2025-07-29 21:06:06 +00:00
SergeantPanda
38813e6c27
Merge pull request #273 from Dispatcharr/dev
Release Notes – Version 0.7.1
2025-07-29 16:05:23 -05:00
SergeantPanda
613c0d8bb5 Add input_bitrate to technical for future use. 2025-07-29 15:43:44 -05:00
SergeantPanda
e26ecad013 Move m3u and url badges to same line as stream name. 2025-07-29 15:17:18 -05:00
SergeantPanda
7551869a2e Remove audio bitrate from basic stats. 2025-07-29 15:12:14 -05:00
SergeantPanda
44f8d45768 Add the ability to see advanced stats for channel streams. 2025-07-29 15:02:35 -05:00
SergeantPanda
cb49172e98 Fix bug where creating a channel from a stream that isn't displayed in the table has invalid stream name. 2025-07-29 15:02:13 -05:00
SergeantPanda
72ecb88c76 Fix bug where creating a channel from a stream that isn't displayed in the table has invalid stream name. 2025-07-29 14:59:03 -05:00
SergeantPanda
5b076fd00b Allow for clicking url badge and copy to clipboard. 2025-07-29 11:35:22 -05:00
SergeantPanda
b222dbe5a3 Display stream qualites in channel table. 2025-07-29 11:21:44 -05:00
SergeantPanda
6180b4ffef Save stream stats to database. 2025-07-28 21:40:29 -05:00
SergeantPanda
db6e09370c Fix bug when sorting by name. 2025-07-28 18:09:43 -05:00
SergeantPanda
7c442064e6 Implement natural sorting for channel names in auto channel sync 2025-07-28 18:01:50 -05:00
SergeantPanda
336a0d2558 Add ability to sort auto channel sync order by either provider order (default), name, TVG ID or updated at. 2025-07-28 17:43:19 -05:00
SergeantPanda
491b0cea29 Fetch channel profiles on successful m3u refresh. 2025-07-27 19:27:50 -05:00
SergeantPanda
7313cafa1c Adds the ability to have the auto channel sync create channels in specificed channel profiles.
Closes #255
2025-07-27 19:14:39 -05:00
SergeantPanda
39ec4d39fc Allow for multiple profiles to be added with the api. This will later be used for auto channel sync. 2025-07-27 17:48:09 -05:00
SergeantPanda
8f529bf495 Only add to selected profile if profile is selected during channel creation. 2025-07-27 17:33:23 -05:00
SergeantPanda
9c32cc68d9 Properly track uploaded logos in Redis so avoid redundant message from file scanner. 2025-07-27 14:55:02 -05:00
SergeantPanda
a7b9d278c2 In the logo manager, don't save when file is selected, wait for create button to be clicked. 2025-07-27 14:44:20 -05:00
SergeantPanda
1475ca70ab Fixes being unable to add a new logo via URL. 2025-07-27 14:18:48 -05:00
SergeantPanda
7eef45f1c0 Only use whole numbers when looking for a number not in use. 2025-07-20 20:13:59 -05:00
SergeantPanda
5148a5a79b Use channel name for display name in EPG output. 2025-07-20 20:01:51 -05:00
Marlon Alkan
192edda48e
apps: output: change body detection logic and add tests 2025-06-08 17:29:20 +02:00
Marlon Alkan
7e5be6094f
docker: init: 02-postgres.sh: allow DB user to create new DB (for tests) 2025-06-08 17:29:13 +02:00
325 changed files with 68728 additions and 10765 deletions

View file

@ -31,3 +31,4 @@
LICENSE
README.md
data/
docker/data/

View file

@ -2,42 +2,37 @@ name: Base Image Build
on:
push:
branches: [ main, dev ]
branches: [main, dev]
paths:
- 'docker/DispatcharrBase'
- '.github/workflows/base-image.yml'
- 'requirements.txt'
pull_request:
branches: [ main, dev ]
branches: [main, dev]
paths:
- 'docker/DispatcharrBase'
- '.github/workflows/base-image.yml'
- 'requirements.txt'
workflow_dispatch: # Allow manual triggering
workflow_dispatch: # Allow manual triggering
permissions:
contents: write # For managing releases and pushing tags
packages: write # For publishing to GitHub Container Registry
contents: write # For managing releases and pushing tags
packages: write # For publishing to GitHub Container Registry
jobs:
build-base-image:
runs-on: ubuntu-latest
prepare:
runs-on: ubuntu-24.04
outputs:
repo_owner: ${{ steps.meta.outputs.repo_owner }}
repo_name: ${{ steps.meta.outputs.repo_name }}
branch_tag: ${{ steps.meta.outputs.branch_tag }}
timestamp: ${{ steps.timestamp.outputs.timestamp }}
steps:
- uses: actions/checkout@v3
with:
fetch-depth: 0
token: ${{ secrets.GITHUB_TOKEN }}
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v2
- name: Login to GitHub Container Registry
uses: docker/login-action@v2
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Generate timestamp for build
id: timestamp
run: |
@ -66,13 +61,190 @@ jobs:
echo "branch_tag=base-${BRANCH}" >> $GITHUB_OUTPUT
fi
docker:
needs: [prepare]
strategy:
fail-fast: false
matrix:
platform: [amd64, arm64]
include:
- platform: amd64
runner: ubuntu-24.04
- platform: arm64
runner: ubuntu-24.04-arm
runs-on: ${{ matrix.runner }}
steps:
- uses: actions/checkout@v3
with:
fetch-depth: 0
token: ${{ secrets.GITHUB_TOKEN }}
- name: Configure Git
run: |
git config user.name "GitHub Actions"
git config user.email "actions@github.com"
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v2
- name: Login to GitHub Container Registry
uses: docker/login-action@v2
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Login to Docker Hub
uses: docker/login-action@v2
with:
registry: docker.io
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Extract metadata for Docker
id: meta
uses: docker/metadata-action@v5
with:
images: |
ghcr.io/${{ needs.prepare.outputs.repo_owner }}/${{ needs.prepare.outputs.repo_name }}
docker.io/${{ secrets.DOCKERHUB_ORGANIZATION }}/${{ needs.prepare.outputs.repo_name }}
labels: |
org.opencontainers.image.title=${{ needs.prepare.outputs.repo_name }}
org.opencontainers.image.description=Your ultimate IPTV & stream Management companion.
org.opencontainers.image.url=https://github.com/${{ github.repository }}
org.opencontainers.image.source=https://github.com/${{ github.repository }}
org.opencontainers.image.version=${{ needs.prepare.outputs.branch_tag }}-${{ needs.prepare.outputs.timestamp }}
org.opencontainers.image.created=${{ needs.prepare.outputs.timestamp }}
org.opencontainers.image.revision=${{ github.sha }}
org.opencontainers.image.licenses=See repository
org.opencontainers.image.documentation=https://dispatcharr.github.io/Dispatcharr-Docs/
org.opencontainers.image.vendor=${{ needs.prepare.outputs.repo_owner }}
org.opencontainers.image.authors=${{ github.actor }}
maintainer=${{ github.actor }}
build_version=DispatcharrBase version: ${{ needs.prepare.outputs.branch_tag }}-${{ needs.prepare.outputs.timestamp }}
- name: Build and push Docker base image
uses: docker/build-push-action@v4
with:
context: .
file: ./docker/DispatcharrBase
push: true
platforms: linux/amd64,linux/arm64
push: ${{ github.event_name != 'pull_request' }}
platforms: linux/${{ matrix.platform }}
tags: |
ghcr.io/${{ steps.meta.outputs.repo_owner }}/${{ steps.meta.outputs.repo_name }}:base
ghcr.io/${{ steps.meta.outputs.repo_owner }}/${{ steps.meta.outputs.repo_name }}:base-${{ steps.timestamp.outputs.timestamp }}
ghcr.io/${{ needs.prepare.outputs.repo_owner }}/${{ needs.prepare.outputs.repo_name }}:${{ needs.prepare.outputs.branch_tag }}-${{ matrix.platform }}
ghcr.io/${{ needs.prepare.outputs.repo_owner }}/${{ needs.prepare.outputs.repo_name }}:${{ needs.prepare.outputs.branch_tag }}-${{ needs.prepare.outputs.timestamp }}-${{ matrix.platform }}
docker.io/${{ secrets.DOCKERHUB_ORGANIZATION }}/${{ needs.prepare.outputs.repo_name }}:${{ needs.prepare.outputs.branch_tag }}-${{ matrix.platform }}
docker.io/${{ secrets.DOCKERHUB_ORGANIZATION }}/${{ needs.prepare.outputs.repo_name }}:${{ needs.prepare.outputs.branch_tag }}-${{ needs.prepare.outputs.timestamp }}-${{ matrix.platform }}
labels: ${{ steps.meta.outputs.labels }}
build-args: |
REPO_OWNER=${{ needs.prepare.outputs.repo_owner }}
REPO_NAME=${{ needs.prepare.outputs.repo_name }}
BRANCH=${{ github.ref_name }}
REPO_URL=https://github.com/${{ github.repository }}
TIMESTAMP=${{ needs.prepare.outputs.timestamp }}
create-manifest:
needs: [prepare, docker]
runs-on: ubuntu-24.04
if: ${{ github.event_name != 'pull_request' }}
steps:
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v2
- name: Login to GitHub Container Registry
uses: docker/login-action@v2
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Login to Docker Hub
uses: docker/login-action@v2
with:
registry: docker.io
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Create multi-arch manifest tags
run: |
set -euo pipefail
OWNER=${{ needs.prepare.outputs.repo_owner }}
REPO=${{ needs.prepare.outputs.repo_name }}
BRANCH_TAG=${{ needs.prepare.outputs.branch_tag }}
TIMESTAMP=${{ needs.prepare.outputs.timestamp }}
echo "Creating multi-arch manifest for ${OWNER}/${REPO}"
# GitHub Container Registry manifests
# branch tag (e.g. base or base-dev)
docker buildx imagetools create \
--annotation "index:org.opencontainers.image.title=${{ needs.prepare.outputs.repo_name }}" \
--annotation "index:org.opencontainers.image.description=Your ultimate IPTV & stream Management companion." \
--annotation "index:org.opencontainers.image.url=https://github.com/${{ github.repository }}" \
--annotation "index:org.opencontainers.image.source=https://github.com/${{ github.repository }}" \
--annotation "index:org.opencontainers.image.version=${BRANCH_TAG}-${TIMESTAMP}" \
--annotation "index:org.opencontainers.image.created=${TIMESTAMP}" \
--annotation "index:org.opencontainers.image.revision=${{ github.sha }}" \
--annotation "index:org.opencontainers.image.licenses=See repository" \
--annotation "index:org.opencontainers.image.documentation=https://dispatcharr.github.io/Dispatcharr-Docs/" \
--annotation "index:org.opencontainers.image.vendor=${OWNER}" \
--annotation "index:org.opencontainers.image.authors=${{ github.actor }}" \
--annotation "index:maintainer=${{ github.actor }}" \
--annotation "index:build_version=DispatcharrBase version: ${BRANCH_TAG}-${TIMESTAMP}" \
--tag ghcr.io/${OWNER}/${REPO}:${BRANCH_TAG} \
ghcr.io/${OWNER}/${REPO}:${BRANCH_TAG}-amd64 ghcr.io/${OWNER}/${REPO}:${BRANCH_TAG}-arm64
# branch + timestamp tag
docker buildx imagetools create \
--annotation "index:org.opencontainers.image.title=${{ needs.prepare.outputs.repo_name }}" \
--annotation "index:org.opencontainers.image.description=Your ultimate IPTV & stream Management companion." \
--annotation "index:org.opencontainers.image.url=https://github.com/${{ github.repository }}" \
--annotation "index:org.opencontainers.image.source=https://github.com/${{ github.repository }}" \
--annotation "index:org.opencontainers.image.version=${BRANCH_TAG}-${TIMESTAMP}" \
--annotation "index:org.opencontainers.image.created=${TIMESTAMP}" \
--annotation "index:org.opencontainers.image.revision=${{ github.sha }}" \
--annotation "index:org.opencontainers.image.licenses=See repository" \
--annotation "index:org.opencontainers.image.documentation=https://dispatcharr.github.io/Dispatcharr-Docs/" \
--annotation "index:org.opencontainers.image.vendor=${OWNER}" \
--annotation "index:org.opencontainers.image.authors=${{ github.actor }}" \
--annotation "index:maintainer=${{ github.actor }}" \
--annotation "index:build_version=DispatcharrBase version: ${BRANCH_TAG}-${TIMESTAMP}" \
--tag ghcr.io/${OWNER}/${REPO}:${BRANCH_TAG}-${TIMESTAMP} \
ghcr.io/${OWNER}/${REPO}:${BRANCH_TAG}-${TIMESTAMP}-amd64 ghcr.io/${OWNER}/${REPO}:${BRANCH_TAG}-${TIMESTAMP}-arm64
# Docker Hub manifests
# branch tag (e.g. base or base-dev)
docker buildx imagetools create \
--annotation "index:org.opencontainers.image.title=${{ needs.prepare.outputs.repo_name }}" \
--annotation "index:org.opencontainers.image.description=Your ultimate IPTV & stream Management companion." \
--annotation "index:org.opencontainers.image.url=https://github.com/${{ github.repository }}" \
--annotation "index:org.opencontainers.image.source=https://github.com/${{ github.repository }}" \
--annotation "index:org.opencontainers.image.version=${BRANCH_TAG}-${TIMESTAMP}" \
--annotation "index:org.opencontainers.image.created=${TIMESTAMP}" \
--annotation "index:org.opencontainers.image.revision=${{ github.sha }}" \
--annotation "index:org.opencontainers.image.licenses=See repository" \
--annotation "index:org.opencontainers.image.documentation=https://dispatcharr.github.io/Dispatcharr-Docs/" \
--annotation "index:org.opencontainers.image.vendor=${OWNER}" \
--annotation "index:org.opencontainers.image.authors=${{ github.actor }}" \
--annotation "index:maintainer=${{ github.actor }}" \
--annotation "index:build_version=DispatcharrBase version: ${BRANCH_TAG}-${TIMESTAMP}" \
--tag docker.io/${{ secrets.DOCKERHUB_ORGANIZATION }}/${REPO}:${BRANCH_TAG} \
docker.io/${{ secrets.DOCKERHUB_ORGANIZATION }}/${REPO}:${BRANCH_TAG}-amd64 docker.io/${{ secrets.DOCKERHUB_ORGANIZATION }}/${REPO}:${BRANCH_TAG}-arm64
# branch + timestamp tag
docker buildx imagetools create \
--annotation "index:org.opencontainers.image.title=${{ needs.prepare.outputs.repo_name }}" \
--annotation "index:org.opencontainers.image.description=Your ultimate IPTV & stream Management companion." \
--annotation "index:org.opencontainers.image.url=https://github.com/${{ github.repository }}" \
--annotation "index:org.opencontainers.image.source=https://github.com/${{ github.repository }}" \
--annotation "index:org.opencontainers.image.version=${BRANCH_TAG}-${TIMESTAMP}" \
--annotation "index:org.opencontainers.image.created=${TIMESTAMP}" \
--annotation "index:org.opencontainers.image.revision=${{ github.sha }}" \
--annotation "index:org.opencontainers.image.licenses=See repository" \
--annotation "index:org.opencontainers.image.documentation=https://dispatcharr.github.io/Dispatcharr-Docs/" \
--annotation "index:org.opencontainers.image.vendor=${OWNER}" \
--annotation "index:org.opencontainers.image.authors=${{ github.actor }}" \
--annotation "index:maintainer=${{ github.actor }}" \
--annotation "index:build_version=DispatcharrBase version: ${BRANCH_TAG}-${TIMESTAMP}" \
--tag docker.io/${{ secrets.DOCKERHUB_ORGANIZATION }}/${REPO}:${BRANCH_TAG}-${TIMESTAMP} \
docker.io/${{ secrets.DOCKERHUB_ORGANIZATION }}/${REPO}:${BRANCH_TAG}-${TIMESTAMP}-amd64 docker.io/${{ secrets.DOCKERHUB_ORGANIZATION }}/${REPO}:${BRANCH_TAG}-${TIMESTAMP}-arm64

View file

@ -2,19 +2,86 @@ name: CI Pipeline
on:
push:
branches: [ dev ]
branches: [dev]
paths-ignore:
- '**.md'
pull_request:
branches: [ dev ]
workflow_dispatch: # Allow manual triggering
branches: [dev]
workflow_dispatch:
# Add explicit permissions for the workflow
permissions:
contents: write # For managing releases and pushing tags
packages: write # For publishing to GitHub Container Registry
contents: write
packages: write
jobs:
build:
runs-on: ubuntu-latest
prepare:
runs-on: ubuntu-24.04
# compute a single timestamp, version, and repo metadata for the entire workflow
outputs:
repo_owner: ${{ steps.meta.outputs.repo_owner }}
repo_name: ${{ steps.meta.outputs.repo_name }}
branch_tag: ${{ steps.meta.outputs.branch_tag }}
version: ${{ steps.version.outputs.version }}
timestamp: ${{ steps.timestamp.outputs.timestamp }}
steps:
- uses: actions/checkout@v3
with:
fetch-depth: 0
token: ${{ secrets.GITHUB_TOKEN }}
- name: Generate timestamp for build
id: timestamp
run: |
TIMESTAMP=$(date -u +'%Y%m%d%H%M%S')
echo "timestamp=${TIMESTAMP}" >> $GITHUB_OUTPUT
- name: Extract version info
id: version
run: |
VERSION=$(python -c "import version; print(version.__version__)")
echo "version=${VERSION}" >> $GITHUB_OUTPUT
- name: Set repository and image metadata
id: meta
run: |
REPO_OWNER=$(echo "${{ github.repository_owner }}" | tr '[:upper:]' '[:lower:]')
echo "repo_owner=${REPO_OWNER}" >> $GITHUB_OUTPUT
REPO_NAME=$(echo "${{ github.repository }}" | cut -d '/' -f 2 | tr '[:upper:]' '[:lower:]')
echo "repo_name=${REPO_NAME}" >> $GITHUB_OUTPUT
if [[ "${{ github.ref }}" == "refs/heads/main" ]]; then
echo "branch_tag=latest" >> $GITHUB_OUTPUT
echo "is_main=true" >> $GITHUB_OUTPUT
elif [[ "${{ github.ref }}" == "refs/heads/dev" ]]; then
echo "branch_tag=dev" >> $GITHUB_OUTPUT
echo "is_main=false" >> $GITHUB_OUTPUT
else
BRANCH=$(echo "${{ github.ref }}" | sed 's/refs\/heads\///' | sed 's/[^a-zA-Z0-9]/-/g')
echo "branch_tag=${BRANCH}" >> $GITHUB_OUTPUT
echo "is_main=false" >> $GITHUB_OUTPUT
fi
if [[ "${{ github.event.pull_request.head.repo.fork }}" == "true" ]]; then
echo "is_fork=true" >> $GITHUB_OUTPUT
else
echo "is_fork=false" >> $GITHUB_OUTPUT
fi
docker:
needs: [prepare]
strategy:
fail-fast: false
matrix:
platform: [amd64, arm64]
include:
- platform: amd64
runner: ubuntu-24.04
- platform: arm64
runner: ubuntu-24.04-arm
runs-on: ${{ matrix.runner }}
# no per-job outputs here; shared metadata comes from the `prepare` job
steps:
- uses: actions/checkout@v3
with:
@ -45,66 +112,162 @@ jobs:
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Generate timestamp for build
id: timestamp
run: |
TIMESTAMP=$(date -u +'%Y%m%d%H%M%S')
echo "timestamp=${TIMESTAMP}" >> $GITHUB_OUTPUT
- name: Login to Docker Hub
uses: docker/login-action@v2
with:
registry: docker.io
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Extract version info
id: version
run: |
VERSION=$(python -c "import version; print(version.__version__)")
echo "version=${VERSION}" >> $GITHUB_OUTPUT
echo "sha_short=${GITHUB_SHA::7}" >> $GITHUB_OUTPUT
- name: Set repository and image metadata
- name: Extract metadata for Docker
id: meta
run: |
# Get lowercase repository owner
REPO_OWNER=$(echo "${{ github.repository_owner }}" | tr '[:upper:]' '[:lower:]')
echo "repo_owner=${REPO_OWNER}" >> $GITHUB_OUTPUT
# Get repository name
REPO_NAME=$(echo "${{ github.repository }}" | cut -d '/' -f 2 | tr '[:upper:]' '[:lower:]')
echo "repo_name=${REPO_NAME}" >> $GITHUB_OUTPUT
# Determine branch name
if [[ "${{ github.ref }}" == "refs/heads/main" ]]; then
echo "branch_tag=latest" >> $GITHUB_OUTPUT
echo "is_main=true" >> $GITHUB_OUTPUT
elif [[ "${{ github.ref }}" == "refs/heads/dev" ]]; then
echo "branch_tag=dev" >> $GITHUB_OUTPUT
echo "is_main=false" >> $GITHUB_OUTPUT
else
# For other branches, use the branch name
BRANCH=$(echo "${{ github.ref }}" | sed 's/refs\/heads\///' | sed 's/[^a-zA-Z0-9]/-/g')
echo "branch_tag=${BRANCH}" >> $GITHUB_OUTPUT
echo "is_main=false" >> $GITHUB_OUTPUT
fi
# Determine if this is from a fork
if [[ "${{ github.event.pull_request.head.repo.fork }}" == "true" ]]; then
echo "is_fork=true" >> $GITHUB_OUTPUT
else
echo "is_fork=false" >> $GITHUB_OUTPUT
fi
uses: docker/metadata-action@v5
with:
images: |
ghcr.io/${{ needs.prepare.outputs.repo_owner }}/${{ needs.prepare.outputs.repo_name }}
docker.io/${{ secrets.DOCKERHUB_ORGANIZATION }}/${{ needs.prepare.outputs.repo_name }}
labels: |
org.opencontainers.image.title=${{ needs.prepare.outputs.repo_name }}
org.opencontainers.image.description=Your ultimate IPTV & stream Management companion.
org.opencontainers.image.url=https://github.com/${{ github.repository }}
org.opencontainers.image.source=https://github.com/${{ github.repository }}
org.opencontainers.image.version=${{ needs.prepare.outputs.version }}-${{ needs.prepare.outputs.timestamp }}
org.opencontainers.image.created=${{ needs.prepare.outputs.timestamp }}
org.opencontainers.image.revision=${{ github.sha }}
org.opencontainers.image.licenses=See repository
org.opencontainers.image.documentation=https://dispatcharr.github.io/Dispatcharr-Docs/
org.opencontainers.image.vendor=${{ needs.prepare.outputs.repo_owner }}
org.opencontainers.image.authors=${{ github.actor }}
maintainer=${{ github.actor }}
build_version=Dispatcharr version: ${{ needs.prepare.outputs.version }}-${{ needs.prepare.outputs.timestamp }}
- name: Build and push Docker image
uses: docker/build-push-action@v4
with:
context: .
push: ${{ github.event_name != 'pull_request' }}
platforms: linux/amd64,linux/arm64
# Build only the platform for this matrix job to avoid running amd64
# stages under qemu on an arm64 runner (and vice-versa). This makes
# the matrix runner's platform the one built by buildx.
platforms: linux/${{ matrix.platform }}
# push arch-specific tags from each matrix job (they will be combined
# into a multi-arch manifest in a follow-up job)
tags: |
ghcr.io/${{ steps.meta.outputs.repo_owner }}/${{ steps.meta.outputs.repo_name }}:${{ steps.meta.outputs.branch_tag }}
ghcr.io/${{ steps.meta.outputs.repo_owner }}/${{ steps.meta.outputs.repo_name }}:${{ steps.version.outputs.version }}-${{ steps.timestamp.outputs.timestamp }}
ghcr.io/${{ steps.meta.outputs.repo_owner }}/${{ steps.meta.outputs.repo_name }}:${{ steps.version.outputs.sha_short }}
ghcr.io/${{ needs.prepare.outputs.repo_owner }}/${{ needs.prepare.outputs.repo_name }}:${{ needs.prepare.outputs.branch_tag }}-${{ matrix.platform }}
ghcr.io/${{ needs.prepare.outputs.repo_owner }}/${{ needs.prepare.outputs.repo_name }}:${{ needs.prepare.outputs.version }}-${{ needs.prepare.outputs.timestamp }}-${{ matrix.platform }}
docker.io/${{ secrets.DOCKERHUB_ORGANIZATION }}/${{ needs.prepare.outputs.repo_name }}:${{ needs.prepare.outputs.branch_tag }}-${{ matrix.platform }}
docker.io/${{ secrets.DOCKERHUB_ORGANIZATION }}/${{ needs.prepare.outputs.repo_name }}:${{ needs.prepare.outputs.version }}-${{ needs.prepare.outputs.timestamp }}-${{ matrix.platform }}
labels: ${{ steps.meta.outputs.labels }}
build-args: |
REPO_OWNER=${{ steps.meta.outputs.repo_owner }}
REPO_NAME=${{ steps.meta.outputs.repo_name }}
REPO_OWNER=${{ needs.prepare.outputs.repo_owner }}
REPO_NAME=${{ needs.prepare.outputs.repo_name }}
BASE_TAG=base
BRANCH=${{ github.ref_name }}
REPO_URL=https://github.com/${{ github.repository }}
TIMESTAMP=${{ steps.timestamp.outputs.timestamp }}
TIMESTAMP=${{ needs.prepare.outputs.timestamp }}
file: ./docker/Dockerfile
create-manifest:
# wait for prepare and all matrix builds to finish
needs: [prepare, docker]
runs-on: ubuntu-24.04
if: ${{ github.event_name != 'pull_request' }}
steps:
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v2
- name: Login to GitHub Container Registry
uses: docker/login-action@v2
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Login to Docker Hub
uses: docker/login-action@v2
with:
registry: docker.io
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Create multi-arch manifest tags
run: |
set -euo pipefail
OWNER=${{ needs.prepare.outputs.repo_owner }}
REPO=${{ needs.prepare.outputs.repo_name }}
BRANCH_TAG=${{ needs.prepare.outputs.branch_tag }}
VERSION=${{ needs.prepare.outputs.version }}
TIMESTAMP=${{ needs.prepare.outputs.timestamp }}
echo "Creating multi-arch manifest for ${OWNER}/${REPO}"
# branch tag (e.g. latest or dev)
docker buildx imagetools create \
--annotation "index:org.opencontainers.image.title=${{ needs.prepare.outputs.repo_name }}" \
--annotation "index:org.opencontainers.image.description=Your ultimate IPTV & stream Management companion." \
--annotation "index:org.opencontainers.image.url=https://github.com/${{ github.repository }}" \
--annotation "index:org.opencontainers.image.source=https://github.com/${{ github.repository }}" \
--annotation "index:org.opencontainers.image.version=${BRANCH_TAG}" \
--annotation "index:org.opencontainers.image.created=${TIMESTAMP}" \
--annotation "index:org.opencontainers.image.revision=${{ github.sha }}" \
--annotation "index:org.opencontainers.image.licenses=See repository" \
--annotation "index:org.opencontainers.image.documentation=https://dispatcharr.github.io/Dispatcharr-Docs/" \
--annotation "index:org.opencontainers.image.vendor=${OWNER}" \
--annotation "index:org.opencontainers.image.authors=${{ github.actor }}" \
--annotation "index:maintainer=${{ github.actor }}" \
--annotation "index:build_version=Dispatcharr version: ${VERSION}-${TIMESTAMP}" \
--tag ghcr.io/${OWNER}/${REPO}:${BRANCH_TAG} \
ghcr.io/${OWNER}/${REPO}:${BRANCH_TAG}-amd64 ghcr.io/${OWNER}/${REPO}:${BRANCH_TAG}-arm64
# version + timestamp tag
docker buildx imagetools create \
--annotation "index:org.opencontainers.image.title=${{ needs.prepare.outputs.repo_name }}" \
--annotation "index:org.opencontainers.image.description=Your ultimate IPTV & stream Management companion." \
--annotation "index:org.opencontainers.image.url=https://github.com/${{ github.repository }}" \
--annotation "index:org.opencontainers.image.source=https://github.com/${{ github.repository }}" \
--annotation "index:org.opencontainers.image.version=${VERSION}-${TIMESTAMP}" \
--annotation "index:org.opencontainers.image.created=${TIMESTAMP}" \
--annotation "index:org.opencontainers.image.revision=${{ github.sha }}" \
--annotation "index:org.opencontainers.image.licenses=See repository" \
--annotation "index:org.opencontainers.image.documentation=https://dispatcharr.github.io/Dispatcharr-Docs/" \
--annotation "index:org.opencontainers.image.vendor=${OWNER}" \
--annotation "index:org.opencontainers.image.authors=${{ github.actor }}" \
--annotation "index:maintainer=${{ github.actor }}" \
--annotation "index:build_version=Dispatcharr version: ${VERSION}-${TIMESTAMP}" \
--tag ghcr.io/${OWNER}/${REPO}:${VERSION}-${TIMESTAMP} \
ghcr.io/${OWNER}/${REPO}:${VERSION}-${TIMESTAMP}-amd64 ghcr.io/${OWNER}/${REPO}:${VERSION}-${TIMESTAMP}-arm64
# also create Docker Hub manifests using the same username
docker buildx imagetools create \
--annotation "index:org.opencontainers.image.title=${{ needs.prepare.outputs.repo_name }}" \
--annotation "index:org.opencontainers.image.description=Your ultimate IPTV & stream Management companion." \
--annotation "index:org.opencontainers.image.url=https://github.com/${{ github.repository }}" \
--annotation "index:org.opencontainers.image.source=https://github.com/${{ github.repository }}" \
--annotation "index:org.opencontainers.image.version=${BRANCH_TAG}" \
--annotation "index:org.opencontainers.image.created=${TIMESTAMP}" \
--annotation "index:org.opencontainers.image.revision=${{ github.sha }}" \
--annotation "index:org.opencontainers.image.licenses=See repository" \
--annotation "index:org.opencontainers.image.documentation=https://dispatcharr.github.io/Dispatcharr-Docs/" \
--annotation "index:org.opencontainers.image.vendor=${OWNER}" \
--annotation "index:org.opencontainers.image.authors=${{ github.actor }}" \
--annotation "index:maintainer=${{ github.actor }}" \
--annotation "index:build_version=Dispatcharr version: ${VERSION}-${TIMESTAMP}" \
--tag docker.io/${{ secrets.DOCKERHUB_ORGANIZATION }}/${REPO}:${BRANCH_TAG} \
docker.io/${{ secrets.DOCKERHUB_ORGANIZATION }}/${REPO}:${BRANCH_TAG}-amd64 docker.io/${{ secrets.DOCKERHUB_ORGANIZATION }}/${REPO}:${BRANCH_TAG}-arm64
docker buildx imagetools create \
--annotation "index:org.opencontainers.image.title=${{ needs.prepare.outputs.repo_name }}" \
--annotation "index:org.opencontainers.image.description=Your ultimate IPTV & stream Management companion." \
--annotation "index:org.opencontainers.image.url=https://github.com/${{ github.repository }}" \
--annotation "index:org.opencontainers.image.source=https://github.com/${{ github.repository }}" \
--annotation "index:org.opencontainers.image.version=${VERSION}-${TIMESTAMP}" \
--annotation "index:org.opencontainers.image.created=${TIMESTAMP}" \
--annotation "index:org.opencontainers.image.revision=${{ github.sha }}" \
--annotation "index:org.opencontainers.image.licenses=See repository" \
--annotation "index:org.opencontainers.image.documentation=https://dispatcharr.github.io/Dispatcharr-Docs/" \
--annotation "index:org.opencontainers.image.vendor=${OWNER}" \
--annotation "index:org.opencontainers.image.authors=${{ github.actor }}" \
--annotation "index:maintainer=${{ github.actor }}" \
--annotation "index:build_version=Dispatcharr version: ${VERSION}-${TIMESTAMP}" \
--tag docker.io/${{ secrets.DOCKERHUB_ORGANIZATION }}/${REPO}:${VERSION}-${TIMESTAMP} \
docker.io/${{ secrets.DOCKERHUB_ORGANIZATION }}/${REPO}:${VERSION}-${TIMESTAMP}-amd64 docker.io/${{ secrets.DOCKERHUB_ORGANIZATION }}/${REPO}:${VERSION}-${TIMESTAMP}-arm64

41
.github/workflows/frontend-tests.yml vendored Normal file
View file

@ -0,0 +1,41 @@
name: Frontend Tests
on:
push:
branches: [main, dev]
paths:
- 'frontend/**'
- '.github/workflows/frontend-tests.yml'
pull_request:
branches: [main, dev]
paths:
- 'frontend/**'
- '.github/workflows/frontend-tests.yml'
jobs:
test:
runs-on: ubuntu-latest
defaults:
run:
working-directory: ./frontend
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '24'
cache: 'npm'
cache-dependency-path: './frontend/package-lock.json'
- name: Install dependencies
run: npm ci
# - name: Run linter
# run: npm run lint
- name: Run tests
run: npm test

View file

@ -15,16 +15,22 @@ on:
# Add explicit permissions for the workflow
permissions:
contents: write # For managing releases and pushing tags
packages: write # For publishing to GitHub Container Registry
contents: write # For managing releases and pushing tags
packages: write # For publishing to GitHub Container Registry
jobs:
release:
runs-on: ubuntu-latest
prepare:
runs-on: ubuntu-24.04
outputs:
new_version: ${{ steps.update_version.outputs.new_version }}
repo_owner: ${{ steps.meta.outputs.repo_owner }}
repo_name: ${{ steps.meta.outputs.repo_name }}
timestamp: ${{ steps.timestamp.outputs.timestamp }}
steps:
- uses: actions/checkout@v3
with:
fetch-depth: 0
token: ${{ secrets.GITHUB_TOKEN }}
- name: Configure Git
run: |
@ -38,14 +44,55 @@ jobs:
NEW_VERSION=$(python -c "import version; print(f'{version.__version__}')")
echo "new_version=${NEW_VERSION}" >> $GITHUB_OUTPUT
- name: Set lowercase repo owner
id: repo_owner
- name: Update Changelog
run: |
python scripts/update_changelog.py ${{ steps.update_version.outputs.new_version }}
- name: Set repository metadata
id: meta
run: |
REPO_OWNER=$(echo "${{ github.repository_owner }}" | tr '[:upper:]' '[:lower:]')
echo "lowercase=${REPO_OWNER}" >> $GITHUB_OUTPUT
echo "repo_owner=${REPO_OWNER}" >> $GITHUB_OUTPUT
- name: Set up QEMU
uses: docker/setup-qemu-action@v2
REPO_NAME=$(echo "${{ github.repository }}" | cut -d '/' -f 2 | tr '[:upper:]' '[:lower:]')
echo "repo_name=${REPO_NAME}" >> $GITHUB_OUTPUT
- name: Generate timestamp for build
id: timestamp
run: |
TIMESTAMP=$(date -u +'%Y%m%d%H%M%S')
echo "timestamp=${TIMESTAMP}" >> $GITHUB_OUTPUT
- name: Commit and Tag
run: |
git add version.py CHANGELOG.md
git commit -m "Release v${{ steps.update_version.outputs.new_version }}"
git tag -a "v${{ steps.update_version.outputs.new_version }}" -m "Release v${{ steps.update_version.outputs.new_version }}"
git push origin main --tags
docker:
needs: [prepare]
strategy:
fail-fast: false
matrix:
platform: [amd64, arm64]
include:
- platform: amd64
runner: ubuntu-24.04
- platform: arm64
runner: ubuntu-24.04-arm
runs-on: ${{ matrix.runner }}
steps:
- uses: actions/checkout@v3
with:
fetch-depth: 0
token: ${{ secrets.GITHUB_TOKEN }}
ref: main
- name: Configure Git
run: |
git config user.name "GitHub Actions"
git config user.email "actions@github.com"
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v2
@ -57,36 +104,134 @@ jobs:
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Commit and Tag
run: |
git add version.py
git commit -m "Release v${{ steps.update_version.outputs.new_version }}"
git tag -a "v${{ steps.update_version.outputs.new_version }}" -m "Release v${{ steps.update_version.outputs.new_version }}"
git push origin main --tags
- name: Login to Docker Hub
uses: docker/login-action@v2
with:
registry: docker.io
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Build and Push Release Image
- name: Extract metadata for Docker
id: meta
uses: docker/metadata-action@v5
with:
images: |
ghcr.io/${{ needs.prepare.outputs.repo_owner }}/${{ needs.prepare.outputs.repo_name }}
docker.io/${{ secrets.DOCKERHUB_ORGANIZATION }}/${{ needs.prepare.outputs.repo_name }}
labels: |
org.opencontainers.image.title=${{ needs.prepare.outputs.repo_name }}
org.opencontainers.image.description=Your ultimate IPTV & stream Management companion.
org.opencontainers.image.url=https://github.com/${{ github.repository }}
org.opencontainers.image.source=https://github.com/${{ github.repository }}
org.opencontainers.image.version=${{ needs.prepare.outputs.new_version }}
org.opencontainers.image.created=${{ needs.prepare.outputs.timestamp }}
org.opencontainers.image.revision=${{ github.sha }}
org.opencontainers.image.licenses=See repository
org.opencontainers.image.documentation=https://dispatcharr.github.io/Dispatcharr-Docs/
org.opencontainers.image.vendor=${{ needs.prepare.outputs.repo_owner }}
org.opencontainers.image.authors=${{ github.actor }}
maintainer=${{ github.actor }}
build_version=Dispatcharr version: ${{ needs.prepare.outputs.new_version }} Build date: ${{ needs.prepare.outputs.timestamp }}
- name: Build and push Docker image
uses: docker/build-push-action@v4
with:
context: .
push: true
platforms: linux/amd64,linux/arm64, #linux/arm/v7 # Multi-arch support for releases
platforms: linux/${{ matrix.platform }}
tags: |
ghcr.io/${{ steps.repo_owner.outputs.lowercase }}/dispatcharr:latest
ghcr.io/${{ steps.repo_owner.outputs.lowercase }}/dispatcharr:${{ steps.update_version.outputs.new_version }}
ghcr.io/${{ steps.repo_owner.outputs.lowercase }}/dispatcharr:latest-amd64
ghcr.io/${{ steps.repo_owner.outputs.lowercase }}/dispatcharr:latest-arm64
ghcr.io/${{ steps.repo_owner.outputs.lowercase }}/dispatcharr:${{ steps.update_version.outputs.new_version }}-amd64
ghcr.io/${{ steps.repo_owner.outputs.lowercase }}/dispatcharr:${{ steps.update_version.outputs.new_version }}-arm64
ghcr.io/${{ needs.prepare.outputs.repo_owner }}/${{ needs.prepare.outputs.repo_name }}:latest-${{ matrix.platform }}
ghcr.io/${{ needs.prepare.outputs.repo_owner }}/${{ needs.prepare.outputs.repo_name }}:${{ needs.prepare.outputs.new_version }}-${{ matrix.platform }}
docker.io/${{ secrets.DOCKERHUB_ORGANIZATION }}/${{ needs.prepare.outputs.repo_name }}:latest-${{ matrix.platform }}
docker.io/${{ secrets.DOCKERHUB_ORGANIZATION }}/${{ needs.prepare.outputs.repo_name }}:${{ needs.prepare.outputs.new_version }}-${{ matrix.platform }}
labels: ${{ steps.meta.outputs.labels }}
build-args: |
REPO_OWNER=${{ needs.prepare.outputs.repo_owner }}
REPO_NAME=${{ needs.prepare.outputs.repo_name }}
BRANCH=${{ github.ref_name }}
REPO_URL=https://github.com/${{ github.repository }}
file: ./docker/Dockerfile
create-manifest:
needs: [prepare, docker]
runs-on: ubuntu-24.04
steps:
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v2
- name: Login to GitHub Container Registry
uses: docker/login-action@v2
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Login to Docker Hub
uses: docker/login-action@v2
with:
registry: docker.io
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Create multi-arch manifest tags
run: |
set -euo pipefail
OWNER=${{ needs.prepare.outputs.repo_owner }}
REPO=${{ needs.prepare.outputs.repo_name }}
VERSION=${{ needs.prepare.outputs.new_version }}
TIMESTAMP=${{ needs.prepare.outputs.timestamp }}
echo "Creating multi-arch manifest for ${OWNER}/${REPO}"
# GitHub Container Registry manifests
# Create one manifest with both latest and version tags
docker buildx imagetools create \
--annotation "index:org.opencontainers.image.title=${{ needs.prepare.outputs.repo_name }}" \
--annotation "index:org.opencontainers.image.description=Your ultimate IPTV & stream Management companion." \
--annotation "index:org.opencontainers.image.url=https://github.com/${{ github.repository }}" \
--annotation "index:org.opencontainers.image.source=https://github.com/${{ github.repository }}" \
--annotation "index:org.opencontainers.image.version=${VERSION}" \
--annotation "index:org.opencontainers.image.created=${TIMESTAMP}" \
--annotation "index:org.opencontainers.image.revision=${{ github.sha }}" \
--annotation "index:org.opencontainers.image.licenses=See repository" \
--annotation "index:org.opencontainers.image.documentation=https://dispatcharr.github.io/Dispatcharr-Docs/" \
--annotation "index:org.opencontainers.image.vendor=${OWNER}" \
--annotation "index:org.opencontainers.image.authors=${{ github.actor }}" \
--annotation "index:maintainer=${{ github.actor }}" \
--annotation "index:build_version=Dispatcharr version: ${VERSION} Build date: ${TIMESTAMP}" \
--tag ghcr.io/${OWNER}/${REPO}:latest \
--tag ghcr.io/${OWNER}/${REPO}:${VERSION} \
ghcr.io/${OWNER}/${REPO}:${VERSION}-amd64 ghcr.io/${OWNER}/${REPO}:${VERSION}-arm64
# Docker Hub manifests
# Create one manifest with both latest and version tags
docker buildx imagetools create \
--annotation "index:org.opencontainers.image.title=${{ needs.prepare.outputs.repo_name }}" \
--annotation "index:org.opencontainers.image.description=Your ultimate IPTV & stream Management companion." \
--annotation "index:org.opencontainers.image.url=https://github.com/${{ github.repository }}" \
--annotation "index:org.opencontainers.image.source=https://github.com/${{ github.repository }}" \
--annotation "index:org.opencontainers.image.version=${VERSION}" \
--annotation "index:org.opencontainers.image.created=${TIMESTAMP}" \
--annotation "index:org.opencontainers.image.revision=${{ github.sha }}" \
--annotation "index:org.opencontainers.image.licenses=See repository" \
--annotation "index:org.opencontainers.image.documentation=https://dispatcharr.github.io/Dispatcharr-Docs/" \
--annotation "index:org.opencontainers.image.vendor=${OWNER}" \
--annotation "index:org.opencontainers.image.authors=${{ github.actor }}" \
--annotation "index:maintainer=${{ github.actor }}" \
--annotation "index:build_version=Dispatcharr version: ${VERSION} Build date: ${TIMESTAMP}" \
--tag docker.io/${{ secrets.DOCKERHUB_ORGANIZATION }}/${REPO}:latest \
--tag docker.io/${{ secrets.DOCKERHUB_ORGANIZATION }}/${REPO}:${VERSION} \
docker.io/${{ secrets.DOCKERHUB_ORGANIZATION }}/${REPO}:${VERSION}-amd64 docker.io/${{ secrets.DOCKERHUB_ORGANIZATION }}/${REPO}:${VERSION}-arm64
create-release:
needs: [prepare, create-manifest]
runs-on: ubuntu-24.04
steps:
- name: Create GitHub Release
uses: softprops/action-gh-release@v1
with:
tag_name: v${{ steps.update_version.outputs.new_version }}
name: Release v${{ steps.update_version.outputs.new_version }}
tag_name: v${{ needs.prepare.outputs.new_version }}
name: Release v${{ needs.prepare.outputs.new_version }}
draft: false
prerelease: false
token: ${{ secrets.GITHUB_TOKEN }}

3
.gitignore vendored
View file

@ -18,4 +18,5 @@ dump.rdb
debugpy*
uwsgi.sock
package-lock.json
models
models
.idea

1014
CHANGELOG.md Normal file

File diff suppressed because it is too large Load diff

286
Plugins.md Normal file
View file

@ -0,0 +1,286 @@
# Dispatcharr Plugins
This document explains how to build, install, and use Python plugins in Dispatcharr. It covers discovery, the plugin interface, settings, actions, how to access application APIs, and examples.
---
## Quick Start
1) Create a folder under `/app/data/plugins/my_plugin/` (host path `data/plugins/my_plugin/` in the repo).
2) Add a `plugin.py` file exporting a `Plugin` class:
```
# /app/data/plugins/my_plugin/plugin.py
class Plugin:
name = "My Plugin"
version = "0.1.0"
description = "Does something useful"
# Settings fields rendered by the UI and persisted by the backend
fields = [
{"id": "enabled", "label": "Enabled", "type": "boolean", "default": True},
{"id": "limit", "label": "Item limit", "type": "number", "default": 5},
{"id": "mode", "label": "Mode", "type": "select", "default": "safe",
"options": [
{"value": "safe", "label": "Safe"},
{"value": "fast", "label": "Fast"},
]},
{"id": "note", "label": "Note", "type": "string", "default": ""},
]
# Actions appear as buttons. Clicking one calls run(action, params, context)
actions = [
{"id": "do_work", "label": "Do Work", "description": "Process items"},
]
def run(self, action: str, params: dict, context: dict):
settings = context.get("settings", {})
logger = context.get("logger")
if action == "do_work":
limit = int(settings.get("limit", 5))
mode = settings.get("mode", "safe")
logger.info(f"My Plugin running with limit={limit}, mode={mode}")
# Do a small amount of work here. Schedule Celery tasks for heavy work.
return {"status": "ok", "processed": limit, "mode": mode}
return {"status": "error", "message": f"Unknown action {action}"}
```
3) Open the Plugins page in the UI, click the refresh icon to reload discovery, then configure and run your plugin.
---
## Where Plugins Live
- Default directory: `/app/data/plugins` inside the container.
- Override with env var: `DISPATCHARR_PLUGINS_DIR`.
- Each plugin is a directory containing either:
- `plugin.py` exporting a `Plugin` class, or
- a Python package (`__init__.py`) exporting a `Plugin` class.
The directory name (lowercased, spaces as `_`) is used as the registry key and module import path (e.g. `my_plugin.plugin`).
---
## Discovery & Lifecycle
- Discovery runs at server startup and on-demand when:
- Fetching the plugins list from the UI
- Hitting `POST /api/plugins/plugins/reload/`
- The loader imports each plugin module and instantiates `Plugin()`.
- Metadata (name, version, description) and a per-plugin settings JSON are stored in the DB.
Backend code:
- Loader: `apps/plugins/loader.py`
- API Views: `apps/plugins/api_views.py`
- API URLs: `apps/plugins/api_urls.py`
- Model: `apps/plugins/models.py` (stores `enabled` flag and `settings` per plugin)
---
## Plugin Interface
Export a `Plugin` class. Supported attributes and behavior:
- `name` (str): Human-readable name.
- `version` (str): Semantic version string.
- `description` (str): Short description.
- `fields` (list): Settings schema used by the UI to render controls.
- `actions` (list): Available actions; the UI renders a Run button for each.
- `run(action, params, context)` (callable): Invoked when a user clicks an action.
### Settings Schema
Supported field `type`s:
- `boolean`
- `number`
- `string`
- `select` (requires `options`: `[{"value": ..., "label": ...}, ...]`)
Common field keys:
- `id` (str): Settings key.
- `label` (str): Label shown in the UI.
- `type` (str): One of above.
- `default` (any): Default value used until saved.
- `help_text` (str, optional): Shown under the control.
- `options` (list, for select): List of `{value, label}`.
The UI automatically renders settings and persists them. The backend stores settings in `PluginConfig.settings`.
Read settings in `run` via `context["settings"]`.
### Actions
Each action is a dict:
- `id` (str): Unique action id.
- `label` (str): Button label.
- `description` (str, optional): Helper text.
Clicking an action calls your plugins `run(action, params, context)` and shows a notification with the result or error.
### Action Confirmation (Modal)
Developers can request a confirmation modal per action using the `confirm` key on the action. Options:
- Boolean: `confirm: true` will show a default confirmation modal.
- Object: `confirm: { required: true, title: '...', message: '...' }` to customize the modal title and message.
Example:
```
actions = [
{
"id": "danger_run",
"label": "Do Something Risky",
"description": "Runs a job that affects many records.",
"confirm": { "required": true, "title": "Proceed?", "message": "This will modify many records." },
}
]
```
---
## Accessing Dispatcharr APIs from Plugins
Plugins are server-side Python code running within the Django application. You can:
- Import models and run queries/updates:
```
from apps.m3u.models import M3UAccount
from apps.epg.models import EPGSource
from apps.channels.models import Channel
from core.models import CoreSettings
```
- Dispatch Celery tasks for heavy work (recommended):
```
from apps.m3u.tasks import refresh_m3u_accounts # apps/m3u/tasks.py
from apps.epg.tasks import refresh_all_epg_data # apps/epg/tasks.py
refresh_m3u_accounts.delay()
refresh_all_epg_data.delay()
```
- Send WebSocket updates:
```
from core.utils import send_websocket_update
send_websocket_update('updates', 'update', {"type": "plugin", "plugin": "my_plugin", "message": "Done"})
```
- Use transactions:
```
from django.db import transaction
with transaction.atomic():
# bulk updates here
...
```
- Log via provided context or standard logging:
```
def run(self, action, params, context):
logger = context.get("logger") # already configured
logger.info("running action %s", action)
```
Prefer Celery tasks (`.delay()`) to keep `run` fast and non-blocking.
---
## REST Endpoints (for UI and tooling)
- List plugins: `GET /api/plugins/plugins/`
- Response: `{ "plugins": [{ key, name, version, description, enabled, fields, settings, actions }, ...] }`
- Reload discovery: `POST /api/plugins/plugins/reload/`
- Import plugin: `POST /api/plugins/plugins/import/` with form-data file field `file`
- Update settings: `POST /api/plugins/plugins/<key>/settings/` with `{"settings": {...}}`
- Run action: `POST /api/plugins/plugins/<key>/run/` with `{"action": "id", "params": {...}}`
- Enable/disable: `POST /api/plugins/plugins/<key>/enabled/` with `{"enabled": true|false}`
Notes:
- When disabled, a plugin cannot run actions; backend returns HTTP 403.
---
## Importing Plugins
- In the UI, click the Import button on the Plugins page and upload a `.zip` containing a plugin folder.
- The archive should contain either `plugin.py` or a Python package (`__init__.py`).
- On success, the UI shows the plugin name/description and lets you enable it immediately (plugins are disabled by default).
---
## Enabling / Disabling Plugins
- Each plugin has a persisted `enabled` flag (default: disabled) and `ever_enabled` flag in the DB (`apps/plugins/models.py`).
- New plugins are disabled by default and require an explicit enable.
- The first time a plugin is enabled, the UI shows a trust warning modal explaining that plugins can run arbitrary server-side code.
- The Plugins page shows a toggle in the card header. Turning it off dims the card and disables the Run button.
- Backend enforcement: Attempts to run an action for a disabled plugin return HTTP 403.
---
## Example: Refresh All Sources Plugin
Path: `data/plugins/refresh_all/plugin.py`
```
class Plugin:
name = "Refresh All Sources"
version = "1.0.0"
description = "Force refresh all M3U accounts and EPG sources."
fields = [
{"id": "confirm", "label": "Require confirmation", "type": "boolean", "default": True,
"help_text": "If enabled, the UI should ask before running."}
]
actions = [
{"id": "refresh_all", "label": "Refresh All M3Us and EPGs",
"description": "Queues background refresh for all active M3U accounts and EPG sources."}
]
def run(self, action: str, params: dict, context: dict):
if action == "refresh_all":
from apps.m3u.tasks import refresh_m3u_accounts
from apps.epg.tasks import refresh_all_epg_data
refresh_m3u_accounts.delay()
refresh_all_epg_data.delay()
return {"status": "queued", "message": "Refresh jobs queued"}
return {"status": "error", "message": f"Unknown action: {action}"}
```
---
## Best Practices
- Keep `run` short and schedule heavy operations via Celery tasks.
- Validate and sanitize `params` received from the UI.
- Use database transactions for bulk or related updates.
- Log actionable messages for troubleshooting.
- Only write files under `/data` or `/app/data` paths.
- Treat plugins as trusted code: they run with full app permissions.
---
## Troubleshooting
- Plugin not listed: ensure the folder exists and contains `plugin.py` with a `Plugin` class.
- Import errors: the folder name is the import name; avoid spaces or exotic characters.
- No confirmation: include a boolean field with `id: "confirm"` and set it to true or default true.
- HTTP 403 on run: the plugin is disabled; enable it from the toggle or via the `enabled/` endpoint.
---
## Contributing
- Keep dependencies minimal. Vendoring small helpers into the plugin folder is acceptable.
- Use the existing task and model APIs where possible; propose extensions if you need new capabilities.
---
## Internals Reference
- Loader: `apps/plugins/loader.py`
- API Views: `apps/plugins/api_views.py`
- API URLs: `apps/plugins/api_urls.py`
- Model: `apps/plugins/models.py`
- Frontend page: `frontend/src/pages/Plugins.jsx`
- Sidebar entry: `frontend/src/components/Sidebar.jsx`

View file

@ -22,6 +22,7 @@ Dispatcharr has officially entered **BETA**, bringing powerful new features and
📊 **Real-Time Stats Dashboard** — Live insights into stream health and client activity\
🧠 **EPG Auto-Match** — Match program data to channels automatically\
⚙️ **Streamlink + FFmpeg Support** — Flexible backend options for streaming and recording\
🎬 **VOD Management** — Full Video on Demand support with movies and TV series\
🧼 **UI & UX Enhancements** — Smoother, faster, more responsive interface\
🛁 **Output Compatibility** — HDHomeRun, M3U, and XMLTV EPG support for Plex, Jellyfin, and more
@ -31,6 +32,7 @@ Dispatcharr has officially entered **BETA**, bringing powerful new features and
**Full IPTV Control** — Import, organize, proxy, and monitor IPTV streams on your own terms\
**Smart Playlist Handling** — M3U import, filtering, grouping, and failover support\
**VOD Content Management** — Organize movies and TV series with metadata and streaming\
**Reliable EPG Integration** — Match and manage TV guide data with ease\
**Clean & Responsive Interface** — Modern design that gets out of your way\
**Fully Self-Hosted** — Total control, zero reliance on third-party services

View file

@ -20,30 +20,88 @@ class TokenObtainPairView(TokenObtainPairView):
def post(self, request, *args, **kwargs):
# Custom logic here
if not network_access_allowed(request, "UI"):
# Log blocked login attempt due to network restrictions
from core.utils import log_system_event
username = request.data.get("username", 'unknown')
client_ip = request.META.get('REMOTE_ADDR', 'unknown')
user_agent = request.META.get('HTTP_USER_AGENT', 'unknown')
log_system_event(
event_type='login_failed',
user=username,
client_ip=client_ip,
user_agent=user_agent,
reason='Network access denied',
)
return Response({"error": "Forbidden"}, status=status.HTTP_403_FORBIDDEN)
# Get the response from the parent class first
response = super().post(request, *args, **kwargs)
username = request.data.get("username")
# If login was successful, update last_login
if response.status_code == 200:
username = request.data.get("username")
if username:
from django.utils import timezone
try:
user = User.objects.get(username=username)
user.last_login = timezone.now()
user.save(update_fields=['last_login'])
except User.DoesNotExist:
pass # User doesn't exist, but login somehow succeeded
# Log login attempt
from core.utils import log_system_event
client_ip = request.META.get('REMOTE_ADDR', 'unknown')
user_agent = request.META.get('HTTP_USER_AGENT', 'unknown')
return response
try:
response = super().post(request, *args, **kwargs)
# If login was successful, update last_login and log success
if response.status_code == 200:
if username:
from django.utils import timezone
try:
user = User.objects.get(username=username)
user.last_login = timezone.now()
user.save(update_fields=['last_login'])
# Log successful login
log_system_event(
event_type='login_success',
user=username,
client_ip=client_ip,
user_agent=user_agent,
)
except User.DoesNotExist:
pass # User doesn't exist, but login somehow succeeded
else:
# Log failed login attempt
log_system_event(
event_type='login_failed',
user=username or 'unknown',
client_ip=client_ip,
user_agent=user_agent,
reason='Invalid credentials',
)
return response
except Exception as e:
# If parent class raises an exception (e.g., validation error), log failed attempt
log_system_event(
event_type='login_failed',
user=username or 'unknown',
client_ip=client_ip,
user_agent=user_agent,
reason=f'Authentication error: {str(e)[:100]}',
)
raise # Re-raise the exception to maintain normal error flow
class TokenRefreshView(TokenRefreshView):
def post(self, request, *args, **kwargs):
# Custom logic here
if not network_access_allowed(request, "UI"):
# Log blocked token refresh attempt due to network restrictions
from core.utils import log_system_event
client_ip = request.META.get('REMOTE_ADDR', 'unknown')
user_agent = request.META.get('HTTP_USER_AGENT', 'unknown')
log_system_event(
event_type='login_failed',
user='token_refresh',
client_ip=client_ip,
user_agent=user_agent,
reason='Network access denied (token refresh)',
)
return Response({"error": "Unauthorized"}, status=status.HTTP_403_FORBIDDEN)
return super().post(request, *args, **kwargs)
@ -80,6 +138,15 @@ def initialize_superuser(request):
class AuthViewSet(viewsets.ViewSet):
"""Handles user login and logout"""
def get_permissions(self):
"""
Login doesn't require auth, but logout does
"""
if self.action == 'logout':
from rest_framework.permissions import IsAuthenticated
return [IsAuthenticated()]
return []
@swagger_auto_schema(
operation_description="Authenticate and log in a user",
request_body=openapi.Schema(
@ -100,6 +167,11 @@ class AuthViewSet(viewsets.ViewSet):
password = request.data.get("password")
user = authenticate(request, username=username, password=password)
# Get client info for logging
from core.utils import log_system_event
client_ip = request.META.get('REMOTE_ADDR', 'unknown')
user_agent = request.META.get('HTTP_USER_AGENT', 'unknown')
if user:
login(request, user)
# Update last_login timestamp
@ -107,6 +179,14 @@ class AuthViewSet(viewsets.ViewSet):
user.last_login = timezone.now()
user.save(update_fields=['last_login'])
# Log successful login
log_system_event(
event_type='login_success',
user=username,
client_ip=client_ip,
user_agent=user_agent,
)
return Response(
{
"message": "Login successful",
@ -118,6 +198,15 @@ class AuthViewSet(viewsets.ViewSet):
},
}
)
# Log failed login attempt
log_system_event(
event_type='login_failed',
user=username or 'unknown',
client_ip=client_ip,
user_agent=user_agent,
reason='Invalid credentials',
)
return Response({"error": "Invalid credentials"}, status=400)
@swagger_auto_schema(
@ -126,6 +215,19 @@ class AuthViewSet(viewsets.ViewSet):
)
def logout(self, request):
"""Logs out the authenticated user"""
# Log logout event before actually logging out
from core.utils import log_system_event
username = request.user.username if request.user and request.user.is_authenticated else 'unknown'
client_ip = request.META.get('REMOTE_ADDR', 'unknown')
user_agent = request.META.get('HTTP_USER_AGENT', 'unknown')
log_system_event(
event_type='logout',
user=username,
client_ip=client_ip,
user_agent=user_agent,
)
logout(request)
return Response({"message": "Logout successful"})
@ -134,7 +236,7 @@ class AuthViewSet(viewsets.ViewSet):
class UserViewSet(viewsets.ModelViewSet):
"""Handles CRUD operations for Users"""
queryset = User.objects.all()
queryset = User.objects.all().prefetch_related('channel_profiles')
serializer_class = UserSerializer
def get_permissions(self):

View file

@ -0,0 +1,18 @@
# Generated by Django 5.2.4 on 2025-09-02 14:30
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('accounts', '0002_remove_user_channel_groups_user_channel_profiles_and_more'),
]
operations = [
migrations.AlterField(
model_name='user',
name='custom_properties',
field=models.JSONField(blank=True, default=dict, null=True),
),
]

View file

@ -21,7 +21,7 @@ class User(AbstractUser):
related_name="users",
)
user_level = models.IntegerField(default=UserLevel.STREAMER)
custom_properties = models.TextField(null=True, blank=True)
custom_properties = models.JSONField(default=dict, blank=True, null=True)
def __str__(self):
return self.username

View file

@ -1,11 +1,10 @@
from django.urls import path, include
from django.urls import path, include, re_path
from drf_yasg.views import get_schema_view
from drf_yasg import openapi
from rest_framework.permissions import AllowAny
app_name = 'api'
# Configure Swagger Schema
schema_view = get_schema_view(
openapi.Info(
title="Dispatcharr API",
@ -26,6 +25,9 @@ urlpatterns = [
path('hdhr/', include(('apps.hdhr.api_urls', 'hdhr'), namespace='hdhr')),
path('m3u/', include(('apps.m3u.api_urls', 'm3u'), namespace='m3u')),
path('core/', include(('core.api_urls', 'core'), namespace='core')),
path('plugins/', include(('apps.plugins.api_urls', 'plugins'), namespace='plugins')),
path('vod/', include(('apps.vod.api_urls', 'vod'), namespace='vod')),
path('backups/', include(('apps.backups.api_urls', 'backups'), namespace='backups')),
# path('output/', include(('apps.output.api_urls', 'output'), namespace='output')),
#path('player/', include(('apps.player.api_urls', 'player'), namespace='player')),
#path('settings/', include(('apps.settings.api_urls', 'settings'), namespace='settings')),
@ -34,7 +36,7 @@ urlpatterns = [
# Swagger Documentation api_urls
path('swagger/', schema_view.with_ui('swagger', cache_timeout=0), name='schema-swagger-ui'),
re_path(r'^swagger/?$', schema_view.with_ui('swagger', cache_timeout=0), name='schema-swagger-ui'),
path('redoc/', schema_view.with_ui('redoc', cache_timeout=0), name='schema-redoc'),
path('swagger.json', schema_view.without_ui(cache_timeout=0), name='schema-json'),
]

0
apps/backups/__init__.py Normal file
View file

18
apps/backups/api_urls.py Normal file
View file

@ -0,0 +1,18 @@
from django.urls import path
from . import api_views
app_name = "backups"
urlpatterns = [
path("", api_views.list_backups, name="backup-list"),
path("create/", api_views.create_backup, name="backup-create"),
path("upload/", api_views.upload_backup, name="backup-upload"),
path("schedule/", api_views.get_schedule, name="backup-schedule-get"),
path("schedule/update/", api_views.update_schedule, name="backup-schedule-update"),
path("status/<str:task_id>/", api_views.backup_status, name="backup-status"),
path("<str:filename>/download-token/", api_views.get_download_token, name="backup-download-token"),
path("<str:filename>/download/", api_views.download_backup, name="backup-download"),
path("<str:filename>/delete/", api_views.delete_backup, name="backup-delete"),
path("<str:filename>/restore/", api_views.restore_backup, name="backup-restore"),
]

364
apps/backups/api_views.py Normal file
View file

@ -0,0 +1,364 @@
import hashlib
import hmac
import logging
import os
from pathlib import Path
from celery.result import AsyncResult
from django.conf import settings
from django.http import HttpResponse, StreamingHttpResponse, Http404
from rest_framework import status
from rest_framework.decorators import api_view, permission_classes, parser_classes
from rest_framework.permissions import IsAdminUser, AllowAny
from rest_framework.parsers import MultiPartParser, FormParser
from rest_framework.response import Response
from . import services
from .tasks import create_backup_task, restore_backup_task
from .scheduler import get_schedule_settings, update_schedule_settings
logger = logging.getLogger(__name__)
def _generate_task_token(task_id: str) -> str:
"""Generate a signed token for task status access without auth."""
secret = settings.SECRET_KEY.encode()
return hmac.new(secret, task_id.encode(), hashlib.sha256).hexdigest()[:32]
def _verify_task_token(task_id: str, token: str) -> bool:
"""Verify a task token is valid."""
expected = _generate_task_token(task_id)
return hmac.compare_digest(expected, token)
@api_view(["GET"])
@permission_classes([IsAdminUser])
def list_backups(request):
"""List all available backup files."""
try:
backups = services.list_backups()
return Response(backups, status=status.HTTP_200_OK)
except Exception as e:
return Response(
{"detail": f"Failed to list backups: {str(e)}"},
status=status.HTTP_500_INTERNAL_SERVER_ERROR,
)
@api_view(["POST"])
@permission_classes([IsAdminUser])
def create_backup(request):
"""Create a new backup (async via Celery)."""
try:
task = create_backup_task.delay()
return Response(
{
"detail": "Backup started",
"task_id": task.id,
"task_token": _generate_task_token(task.id),
},
status=status.HTTP_202_ACCEPTED,
)
except Exception as e:
return Response(
{"detail": f"Failed to start backup: {str(e)}"},
status=status.HTTP_500_INTERNAL_SERVER_ERROR,
)
@api_view(["GET"])
@permission_classes([AllowAny])
def backup_status(request, task_id):
"""Check the status of a backup/restore task.
Requires either:
- Valid admin authentication, OR
- Valid task_token query parameter
"""
# Check for token-based auth (for restore when session is invalidated)
token = request.query_params.get("token")
if token:
if not _verify_task_token(task_id, token):
return Response(
{"detail": "Invalid task token"},
status=status.HTTP_403_FORBIDDEN,
)
else:
# Fall back to admin auth check
if not request.user.is_authenticated or not request.user.is_staff:
return Response(
{"detail": "Authentication required"},
status=status.HTTP_401_UNAUTHORIZED,
)
try:
result = AsyncResult(task_id)
if result.ready():
task_result = result.get()
if task_result.get("status") == "completed":
return Response({
"state": "completed",
"result": task_result,
})
else:
return Response({
"state": "failed",
"error": task_result.get("error", "Unknown error"),
})
elif result.failed():
return Response({
"state": "failed",
"error": str(result.result),
})
else:
return Response({
"state": result.state.lower(),
})
except Exception as e:
return Response(
{"detail": f"Failed to get task status: {str(e)}"},
status=status.HTTP_500_INTERNAL_SERVER_ERROR,
)
@api_view(["GET"])
@permission_classes([IsAdminUser])
def get_download_token(request, filename):
"""Get a signed token for downloading a backup file."""
try:
# Security: prevent path traversal
if ".." in filename or "/" in filename or "\\" in filename:
raise Http404("Invalid filename")
backup_dir = services.get_backup_dir()
backup_file = backup_dir / filename
if not backup_file.exists():
raise Http404("Backup file not found")
token = _generate_task_token(filename)
return Response({"token": token})
except Http404:
raise
except Exception as e:
return Response(
{"detail": f"Failed to generate token: {str(e)}"},
status=status.HTTP_500_INTERNAL_SERVER_ERROR,
)
@api_view(["GET"])
@permission_classes([AllowAny])
def download_backup(request, filename):
"""Download a backup file.
Requires either:
- Valid admin authentication, OR
- Valid download_token query parameter
"""
# Check for token-based auth (avoids CORS preflight issues)
token = request.query_params.get("token")
if token:
if not _verify_task_token(filename, token):
return Response(
{"detail": "Invalid download token"},
status=status.HTTP_403_FORBIDDEN,
)
else:
# Fall back to admin auth check
if not request.user.is_authenticated or not request.user.is_staff:
return Response(
{"detail": "Authentication required"},
status=status.HTTP_401_UNAUTHORIZED,
)
try:
# Security: prevent path traversal by checking for suspicious characters
if ".." in filename or "/" in filename or "\\" in filename:
raise Http404("Invalid filename")
backup_dir = services.get_backup_dir()
backup_file = (backup_dir / filename).resolve()
# Security: ensure the resolved path is still within backup_dir
if not str(backup_file).startswith(str(backup_dir.resolve())):
raise Http404("Invalid filename")
if not backup_file.exists() or not backup_file.is_file():
raise Http404("Backup file not found")
file_size = backup_file.stat().st_size
# Use X-Accel-Redirect for nginx (AIO container) - nginx serves file directly
# Fall back to streaming for non-nginx deployments
use_nginx_accel = os.environ.get("USE_NGINX_ACCEL", "").lower() == "true"
logger.info(f"[DOWNLOAD] File: {filename}, Size: {file_size}, USE_NGINX_ACCEL: {use_nginx_accel}")
if use_nginx_accel:
# X-Accel-Redirect: Django returns immediately, nginx serves file
logger.info(f"[DOWNLOAD] Using X-Accel-Redirect: /protected-backups/{filename}")
response = HttpResponse()
response["X-Accel-Redirect"] = f"/protected-backups/{filename}"
response["Content-Type"] = "application/zip"
response["Content-Length"] = file_size
response["Content-Disposition"] = f'attachment; filename="{filename}"'
return response
else:
# Streaming fallback for non-nginx deployments
logger.info(f"[DOWNLOAD] Using streaming fallback (no nginx)")
def file_iterator(file_path, chunk_size=2 * 1024 * 1024):
with open(file_path, "rb") as f:
while chunk := f.read(chunk_size):
yield chunk
response = StreamingHttpResponse(
file_iterator(backup_file),
content_type="application/zip",
)
response["Content-Length"] = file_size
response["Content-Disposition"] = f'attachment; filename="{filename}"'
return response
except Http404:
raise
except Exception as e:
return Response(
{"detail": f"Download failed: {str(e)}"},
status=status.HTTP_500_INTERNAL_SERVER_ERROR,
)
@api_view(["DELETE"])
@permission_classes([IsAdminUser])
def delete_backup(request, filename):
"""Delete a backup file."""
try:
# Security: prevent path traversal
if ".." in filename or "/" in filename or "\\" in filename:
raise Http404("Invalid filename")
services.delete_backup(filename)
return Response(
{"detail": "Backup deleted successfully"},
status=status.HTTP_204_NO_CONTENT,
)
except FileNotFoundError:
raise Http404("Backup file not found")
except Exception as e:
return Response(
{"detail": f"Delete failed: {str(e)}"},
status=status.HTTP_500_INTERNAL_SERVER_ERROR,
)
@api_view(["POST"])
@permission_classes([IsAdminUser])
@parser_classes([MultiPartParser, FormParser])
def upload_backup(request):
"""Upload a backup file for restoration."""
uploaded = request.FILES.get("file")
if not uploaded:
return Response(
{"detail": "No file uploaded"},
status=status.HTTP_400_BAD_REQUEST,
)
try:
backup_dir = services.get_backup_dir()
filename = uploaded.name or "uploaded-backup.zip"
# Ensure unique filename
backup_file = backup_dir / filename
counter = 1
while backup_file.exists():
name_parts = filename.rsplit(".", 1)
if len(name_parts) == 2:
backup_file = backup_dir / f"{name_parts[0]}-{counter}.{name_parts[1]}"
else:
backup_file = backup_dir / f"{filename}-{counter}"
counter += 1
# Save uploaded file
with backup_file.open("wb") as f:
for chunk in uploaded.chunks():
f.write(chunk)
return Response(
{
"detail": "Backup uploaded successfully",
"filename": backup_file.name,
},
status=status.HTTP_201_CREATED,
)
except Exception as e:
return Response(
{"detail": f"Upload failed: {str(e)}"},
status=status.HTTP_500_INTERNAL_SERVER_ERROR,
)
@api_view(["POST"])
@permission_classes([IsAdminUser])
def restore_backup(request, filename):
"""Restore from a backup file (async via Celery). WARNING: This will flush the database!"""
try:
# Security: prevent path traversal
if ".." in filename or "/" in filename or "\\" in filename:
raise Http404("Invalid filename")
backup_dir = services.get_backup_dir()
backup_file = backup_dir / filename
if not backup_file.exists():
raise Http404("Backup file not found")
task = restore_backup_task.delay(filename)
return Response(
{
"detail": "Restore started",
"task_id": task.id,
"task_token": _generate_task_token(task.id),
},
status=status.HTTP_202_ACCEPTED,
)
except Http404:
raise
except Exception as e:
return Response(
{"detail": f"Failed to start restore: {str(e)}"},
status=status.HTTP_500_INTERNAL_SERVER_ERROR,
)
@api_view(["GET"])
@permission_classes([IsAdminUser])
def get_schedule(request):
"""Get backup schedule settings."""
try:
settings = get_schedule_settings()
return Response(settings)
except Exception as e:
return Response(
{"detail": f"Failed to get schedule: {str(e)}"},
status=status.HTTP_500_INTERNAL_SERVER_ERROR,
)
@api_view(["PUT"])
@permission_classes([IsAdminUser])
def update_schedule(request):
"""Update backup schedule settings."""
try:
settings = update_schedule_settings(request.data)
return Response(settings)
except ValueError as e:
return Response(
{"detail": str(e)},
status=status.HTTP_400_BAD_REQUEST,
)
except Exception as e:
return Response(
{"detail": f"Failed to update schedule: {str(e)}"},
status=status.HTTP_500_INTERNAL_SERVER_ERROR,
)

7
apps/backups/apps.py Normal file
View file

@ -0,0 +1,7 @@
from django.apps import AppConfig
class BackupsConfig(AppConfig):
default_auto_field = "django.db.models.BigAutoField"
name = "apps.backups"
verbose_name = "Backups"

View file

0
apps/backups/models.py Normal file
View file

202
apps/backups/scheduler.py Normal file
View file

@ -0,0 +1,202 @@
import json
import logging
from django_celery_beat.models import PeriodicTask, CrontabSchedule
from core.models import CoreSettings
logger = logging.getLogger(__name__)
BACKUP_SCHEDULE_TASK_NAME = "backup-scheduled-task"
DEFAULTS = {
"schedule_enabled": True,
"schedule_frequency": "daily",
"schedule_time": "03:00",
"schedule_day_of_week": 0, # Sunday
"retention_count": 3,
"schedule_cron_expression": "",
}
def _get_backup_settings():
"""Get all backup settings from CoreSettings grouped JSON."""
try:
settings_obj = CoreSettings.objects.get(key="backup_settings")
return settings_obj.value if isinstance(settings_obj.value, dict) else DEFAULTS.copy()
except CoreSettings.DoesNotExist:
return DEFAULTS.copy()
def _update_backup_settings(updates: dict) -> None:
"""Update backup settings in the grouped JSON."""
obj, created = CoreSettings.objects.get_or_create(
key="backup_settings",
defaults={"name": "Backup Settings", "value": DEFAULTS.copy()}
)
current = obj.value if isinstance(obj.value, dict) else {}
current.update(updates)
obj.value = current
obj.save()
def get_schedule_settings() -> dict:
"""Get all backup schedule settings."""
settings = _get_backup_settings()
return {
"enabled": bool(settings.get("schedule_enabled", DEFAULTS["schedule_enabled"])),
"frequency": str(settings.get("schedule_frequency", DEFAULTS["schedule_frequency"])),
"time": str(settings.get("schedule_time", DEFAULTS["schedule_time"])),
"day_of_week": int(settings.get("schedule_day_of_week", DEFAULTS["schedule_day_of_week"])),
"retention_count": int(settings.get("retention_count", DEFAULTS["retention_count"])),
"cron_expression": str(settings.get("schedule_cron_expression", DEFAULTS["schedule_cron_expression"])),
}
def update_schedule_settings(data: dict) -> dict:
"""Update backup schedule settings and sync the PeriodicTask."""
# Validate
if "frequency" in data and data["frequency"] not in ("daily", "weekly"):
raise ValueError("frequency must be 'daily' or 'weekly'")
if "time" in data:
try:
hour, minute = data["time"].split(":")
int(hour)
int(minute)
except (ValueError, AttributeError):
raise ValueError("time must be in HH:MM format")
if "day_of_week" in data:
day = int(data["day_of_week"])
if day < 0 or day > 6:
raise ValueError("day_of_week must be 0-6 (Sunday-Saturday)")
if "retention_count" in data:
count = int(data["retention_count"])
if count < 0:
raise ValueError("retention_count must be >= 0")
# Update settings with proper key names
updates = {}
if "enabled" in data:
updates["schedule_enabled"] = bool(data["enabled"])
if "frequency" in data:
updates["schedule_frequency"] = str(data["frequency"])
if "time" in data:
updates["schedule_time"] = str(data["time"])
if "day_of_week" in data:
updates["schedule_day_of_week"] = int(data["day_of_week"])
if "retention_count" in data:
updates["retention_count"] = int(data["retention_count"])
if "cron_expression" in data:
updates["schedule_cron_expression"] = str(data["cron_expression"])
_update_backup_settings(updates)
# Sync the periodic task
_sync_periodic_task()
return get_schedule_settings()
def _sync_periodic_task() -> None:
"""Create, update, or delete the scheduled backup task based on settings."""
settings = get_schedule_settings()
if not settings["enabled"]:
# Delete the task if it exists
task = PeriodicTask.objects.filter(name=BACKUP_SCHEDULE_TASK_NAME).first()
if task:
old_crontab = task.crontab
task.delete()
_cleanup_orphaned_crontab(old_crontab)
logger.info("Backup schedule disabled, removed periodic task")
return
# Get old crontab before creating new one
old_crontab = None
try:
old_task = PeriodicTask.objects.get(name=BACKUP_SCHEDULE_TASK_NAME)
old_crontab = old_task.crontab
except PeriodicTask.DoesNotExist:
pass
# Check if using cron expression (advanced mode)
if settings["cron_expression"]:
# Parse cron expression: "minute hour day month weekday"
try:
parts = settings["cron_expression"].split()
if len(parts) != 5:
raise ValueError("Cron expression must have 5 parts: minute hour day month weekday")
minute, hour, day_of_month, month_of_year, day_of_week = parts
crontab, _ = CrontabSchedule.objects.get_or_create(
minute=minute,
hour=hour,
day_of_week=day_of_week,
day_of_month=day_of_month,
month_of_year=month_of_year,
timezone=CoreSettings.get_system_time_zone(),
)
except Exception as e:
logger.error(f"Invalid cron expression '{settings['cron_expression']}': {e}")
raise ValueError(f"Invalid cron expression: {e}")
else:
# Use simple frequency-based scheduling
# Parse time
hour, minute = settings["time"].split(":")
# Build crontab based on frequency
system_tz = CoreSettings.get_system_time_zone()
if settings["frequency"] == "daily":
crontab, _ = CrontabSchedule.objects.get_or_create(
minute=minute,
hour=hour,
day_of_week="*",
day_of_month="*",
month_of_year="*",
timezone=system_tz,
)
else: # weekly
crontab, _ = CrontabSchedule.objects.get_or_create(
minute=minute,
hour=hour,
day_of_week=str(settings["day_of_week"]),
day_of_month="*",
month_of_year="*",
timezone=system_tz,
)
# Create or update the periodic task
task, created = PeriodicTask.objects.update_or_create(
name=BACKUP_SCHEDULE_TASK_NAME,
defaults={
"task": "apps.backups.tasks.scheduled_backup_task",
"crontab": crontab,
"enabled": True,
"kwargs": json.dumps({"retention_count": settings["retention_count"]}),
},
)
# Clean up old crontab if it changed and is orphaned
if old_crontab and old_crontab.id != crontab.id:
_cleanup_orphaned_crontab(old_crontab)
action = "Created" if created else "Updated"
logger.info(f"{action} backup schedule: {settings['frequency']} at {settings['time']}")
def _cleanup_orphaned_crontab(crontab_schedule):
"""Delete old CrontabSchedule if no other tasks are using it."""
if crontab_schedule is None:
return
# Check if any other tasks are using this crontab
if PeriodicTask.objects.filter(crontab=crontab_schedule).exists():
logger.debug(f"CrontabSchedule {crontab_schedule.id} still in use, not deleting")
return
logger.debug(f"Cleaning up orphaned CrontabSchedule: {crontab_schedule.id}")
crontab_schedule.delete()

350
apps/backups/services.py Normal file
View file

@ -0,0 +1,350 @@
import datetime
import json
import os
import shutil
import subprocess
import tempfile
from pathlib import Path
from zipfile import ZipFile, ZIP_DEFLATED
import logging
import pytz
from django.conf import settings
from core.models import CoreSettings
logger = logging.getLogger(__name__)
def get_backup_dir() -> Path:
"""Get the backup directory, creating it if necessary."""
backup_dir = Path(settings.BACKUP_ROOT)
backup_dir.mkdir(parents=True, exist_ok=True)
return backup_dir
def _is_postgresql() -> bool:
"""Check if we're using PostgreSQL."""
return settings.DATABASES["default"]["ENGINE"] == "django.db.backends.postgresql"
def _get_pg_env() -> dict:
"""Get environment variables for PostgreSQL commands."""
db_config = settings.DATABASES["default"]
env = os.environ.copy()
env["PGPASSWORD"] = db_config.get("PASSWORD", "")
return env
def _get_pg_args() -> list[str]:
"""Get common PostgreSQL command arguments."""
db_config = settings.DATABASES["default"]
return [
"-h", db_config.get("HOST", "localhost"),
"-p", str(db_config.get("PORT", 5432)),
"-U", db_config.get("USER", "postgres"),
"-d", db_config.get("NAME", "dispatcharr"),
]
def _dump_postgresql(output_file: Path) -> None:
"""Dump PostgreSQL database using pg_dump."""
logger.info("Dumping PostgreSQL database with pg_dump...")
cmd = [
"pg_dump",
*_get_pg_args(),
"-Fc", # Custom format for pg_restore
"-v", # Verbose
"-f", str(output_file),
]
result = subprocess.run(
cmd,
env=_get_pg_env(),
capture_output=True,
text=True,
)
if result.returncode != 0:
logger.error(f"pg_dump failed: {result.stderr}")
raise RuntimeError(f"pg_dump failed: {result.stderr}")
logger.debug(f"pg_dump output: {result.stderr}")
def _clean_postgresql_schema() -> None:
"""Drop and recreate the public schema to ensure a completely clean restore."""
logger.info("[PG_CLEAN] Dropping and recreating public schema...")
# Commands to drop and recreate schema
sql_commands = "DROP SCHEMA IF EXISTS public CASCADE; CREATE SCHEMA public; GRANT ALL ON SCHEMA public TO public;"
cmd = [
"psql",
*_get_pg_args(),
"-c", sql_commands,
]
result = subprocess.run(
cmd,
env=_get_pg_env(),
capture_output=True,
text=True,
)
if result.returncode != 0:
logger.error(f"[PG_CLEAN] Failed to clean schema: {result.stderr}")
raise RuntimeError(f"Failed to clean PostgreSQL schema: {result.stderr}")
logger.info("[PG_CLEAN] Schema cleaned successfully")
def _restore_postgresql(dump_file: Path) -> None:
"""Restore PostgreSQL database using pg_restore."""
logger.info("[PG_RESTORE] Starting pg_restore...")
logger.info(f"[PG_RESTORE] Dump file: {dump_file}")
# Drop and recreate schema to ensure a completely clean restore
_clean_postgresql_schema()
pg_args = _get_pg_args()
logger.info(f"[PG_RESTORE] Connection args: {pg_args}")
cmd = [
"pg_restore",
"--no-owner", # Skip ownership commands (we already created schema)
*pg_args,
"-v", # Verbose
str(dump_file),
]
logger.info(f"[PG_RESTORE] Running command: {' '.join(cmd)}")
result = subprocess.run(
cmd,
env=_get_pg_env(),
capture_output=True,
text=True,
)
logger.info(f"[PG_RESTORE] Return code: {result.returncode}")
# pg_restore may return non-zero even on partial success
# Check for actual errors vs warnings
if result.returncode != 0:
# Some errors during restore are expected (e.g., "does not exist" when cleaning)
# Only fail on critical errors
stderr = result.stderr.lower()
if "fatal" in stderr or "could not connect" in stderr:
logger.error(f"[PG_RESTORE] Failed critically: {result.stderr}")
raise RuntimeError(f"pg_restore failed: {result.stderr}")
else:
logger.warning(f"[PG_RESTORE] Completed with warnings: {result.stderr[:500]}...")
logger.info("[PG_RESTORE] Completed successfully")
def _dump_sqlite(output_file: Path) -> None:
"""Dump SQLite database using sqlite3 .backup command."""
logger.info("Dumping SQLite database with sqlite3 .backup...")
db_path = Path(settings.DATABASES["default"]["NAME"])
if not db_path.exists():
raise FileNotFoundError(f"SQLite database not found: {db_path}")
# Use sqlite3 .backup command via stdin for reliable execution
result = subprocess.run(
["sqlite3", str(db_path)],
input=f".backup '{output_file}'\n",
capture_output=True,
text=True,
)
if result.returncode != 0:
logger.error(f"sqlite3 backup failed: {result.stderr}")
raise RuntimeError(f"sqlite3 backup failed: {result.stderr}")
# Verify the backup file was created
if not output_file.exists():
raise RuntimeError("sqlite3 backup failed: output file not created")
logger.info(f"sqlite3 backup completed successfully: {output_file}")
def _restore_sqlite(dump_file: Path) -> None:
"""Restore SQLite database by replacing the database file."""
logger.info("Restoring SQLite database...")
db_path = Path(settings.DATABASES["default"]["NAME"])
backup_current = None
# Backup current database before overwriting
if db_path.exists():
backup_current = db_path.with_suffix(".db.bak")
shutil.copy2(db_path, backup_current)
logger.info(f"Backed up current database to {backup_current}")
# Ensure parent directory exists
db_path.parent.mkdir(parents=True, exist_ok=True)
# The backup file from _dump_sqlite is a complete SQLite database file
# We can simply copy it over the existing database
shutil.copy2(dump_file, db_path)
# Verify the restore worked by checking if sqlite3 can read it
result = subprocess.run(
["sqlite3", str(db_path)],
input=".tables\n",
capture_output=True,
text=True,
)
if result.returncode != 0:
logger.error(f"sqlite3 verification failed: {result.stderr}")
# Try to restore from backup
if backup_current and backup_current.exists():
shutil.copy2(backup_current, db_path)
logger.info("Restored original database from backup")
raise RuntimeError(f"sqlite3 restore verification failed: {result.stderr}")
logger.info("sqlite3 restore completed successfully")
def create_backup() -> Path:
"""
Create a backup archive containing database dump and data directories.
Returns the path to the created backup file.
"""
backup_dir = get_backup_dir()
# Use system timezone for filename (user-friendly), but keep internal timestamps as UTC
system_tz_name = CoreSettings.get_system_time_zone()
try:
system_tz = pytz.timezone(system_tz_name)
now_local = datetime.datetime.now(datetime.UTC).astimezone(system_tz)
timestamp = now_local.strftime("%Y.%m.%d.%H.%M.%S")
except Exception as e:
logger.warning(f"Failed to use system timezone {system_tz_name}: {e}, falling back to UTC")
timestamp = datetime.datetime.now(datetime.UTC).strftime("%Y.%m.%d.%H.%M.%S")
backup_name = f"dispatcharr-backup-{timestamp}.zip"
backup_file = backup_dir / backup_name
logger.info(f"Creating backup: {backup_name}")
with tempfile.TemporaryDirectory(prefix="dispatcharr-backup-") as temp_dir:
temp_path = Path(temp_dir)
# Determine database type and dump accordingly
if _is_postgresql():
db_dump_file = temp_path / "database.dump"
_dump_postgresql(db_dump_file)
db_type = "postgresql"
else:
db_dump_file = temp_path / "database.sqlite3"
_dump_sqlite(db_dump_file)
db_type = "sqlite"
# Create ZIP archive with compression and ZIP64 support for large files
with ZipFile(backup_file, "w", compression=ZIP_DEFLATED, allowZip64=True) as zip_file:
# Add database dump
zip_file.write(db_dump_file, db_dump_file.name)
# Add metadata
metadata = {
"format": "dispatcharr-backup",
"version": 2,
"database_type": db_type,
"database_file": db_dump_file.name,
"created_at": datetime.datetime.now(datetime.UTC).isoformat(),
}
zip_file.writestr("metadata.json", json.dumps(metadata, indent=2))
logger.info(f"Backup created successfully: {backup_file}")
return backup_file
def restore_backup(backup_file: Path) -> None:
"""
Restore from a backup archive.
WARNING: This will overwrite the database!
"""
if not backup_file.exists():
raise FileNotFoundError(f"Backup file not found: {backup_file}")
logger.info(f"Restoring from backup: {backup_file}")
with tempfile.TemporaryDirectory(prefix="dispatcharr-restore-") as temp_dir:
temp_path = Path(temp_dir)
# Extract backup
logger.debug("Extracting backup archive...")
with ZipFile(backup_file, "r") as zip_file:
zip_file.extractall(temp_path)
# Read metadata
metadata_file = temp_path / "metadata.json"
if not metadata_file.exists():
raise ValueError("Invalid backup: missing metadata.json")
with open(metadata_file) as f:
metadata = json.load(f)
# Restore database
_restore_database(temp_path, metadata)
logger.info("Restore completed successfully")
def _restore_database(temp_path: Path, metadata: dict) -> None:
"""Restore database from backup."""
db_type = metadata.get("database_type", "postgresql")
db_file = metadata.get("database_file", "database.dump")
dump_file = temp_path / db_file
if not dump_file.exists():
raise ValueError(f"Invalid backup: missing {db_file}")
current_db_type = "postgresql" if _is_postgresql() else "sqlite"
if db_type != current_db_type:
raise ValueError(
f"Database type mismatch: backup is {db_type}, "
f"but current database is {current_db_type}"
)
if db_type == "postgresql":
_restore_postgresql(dump_file)
else:
_restore_sqlite(dump_file)
def list_backups() -> list[dict]:
"""List all available backup files with metadata."""
backup_dir = get_backup_dir()
backups = []
for backup_file in sorted(backup_dir.glob("dispatcharr-backup-*.zip"), reverse=True):
# Use UTC timezone so frontend can convert to user's local time
created_time = datetime.datetime.fromtimestamp(backup_file.stat().st_mtime, datetime.UTC)
backups.append({
"name": backup_file.name,
"size": backup_file.stat().st_size,
"created": created_time.isoformat(),
})
return backups
def delete_backup(filename: str) -> None:
"""Delete a backup file."""
backup_dir = get_backup_dir()
backup_file = backup_dir / filename
if not backup_file.exists():
raise FileNotFoundError(f"Backup file not found: {filename}")
if not backup_file.is_file():
raise ValueError(f"Invalid backup file: {filename}")
backup_file.unlink()
logger.info(f"Deleted backup: {filename}")

106
apps/backups/tasks.py Normal file
View file

@ -0,0 +1,106 @@
import logging
import traceback
from celery import shared_task
from . import services
logger = logging.getLogger(__name__)
def _cleanup_old_backups(retention_count: int) -> int:
"""Delete old backups, keeping only the most recent N. Returns count deleted."""
if retention_count <= 0:
return 0
backups = services.list_backups()
if len(backups) <= retention_count:
return 0
# Backups are sorted newest first, so delete from the end
to_delete = backups[retention_count:]
deleted = 0
for backup in to_delete:
try:
services.delete_backup(backup["name"])
deleted += 1
logger.info(f"[CLEANUP] Deleted old backup: {backup['name']}")
except Exception as e:
logger.error(f"[CLEANUP] Failed to delete {backup['name']}: {e}")
return deleted
@shared_task(bind=True)
def create_backup_task(self):
"""Celery task to create a backup asynchronously."""
try:
logger.info(f"[BACKUP] Starting backup task {self.request.id}")
backup_file = services.create_backup()
logger.info(f"[BACKUP] Task {self.request.id} completed: {backup_file.name}")
return {
"status": "completed",
"filename": backup_file.name,
"size": backup_file.stat().st_size,
}
except Exception as e:
logger.error(f"[BACKUP] Task {self.request.id} failed: {str(e)}")
logger.error(f"[BACKUP] Traceback: {traceback.format_exc()}")
return {
"status": "failed",
"error": str(e),
}
@shared_task(bind=True)
def restore_backup_task(self, filename: str):
"""Celery task to restore a backup asynchronously."""
try:
logger.info(f"[RESTORE] Starting restore task {self.request.id} for {filename}")
backup_dir = services.get_backup_dir()
backup_file = backup_dir / filename
logger.info(f"[RESTORE] Backup file path: {backup_file}")
services.restore_backup(backup_file)
logger.info(f"[RESTORE] Task {self.request.id} completed successfully")
return {
"status": "completed",
"filename": filename,
}
except Exception as e:
logger.error(f"[RESTORE] Task {self.request.id} failed: {str(e)}")
logger.error(f"[RESTORE] Traceback: {traceback.format_exc()}")
return {
"status": "failed",
"error": str(e),
}
@shared_task(bind=True)
def scheduled_backup_task(self, retention_count: int = 0):
"""Celery task for scheduled backups with optional retention cleanup."""
try:
logger.info(f"[SCHEDULED] Starting scheduled backup task {self.request.id}")
# Create backup
backup_file = services.create_backup()
logger.info(f"[SCHEDULED] Backup created: {backup_file.name}")
# Cleanup old backups if retention is set
deleted = 0
if retention_count > 0:
deleted = _cleanup_old_backups(retention_count)
logger.info(f"[SCHEDULED] Cleanup complete, deleted {deleted} old backup(s)")
return {
"status": "completed",
"filename": backup_file.name,
"size": backup_file.stat().st_size,
"deleted_count": deleted,
}
except Exception as e:
logger.error(f"[SCHEDULED] Task {self.request.id} failed: {str(e)}")
logger.error(f"[SCHEDULED] Traceback: {traceback.format_exc()}")
return {
"status": "failed",
"error": str(e),
}

1163
apps/backups/tests.py Normal file

File diff suppressed because it is too large Load diff

View file

@ -13,7 +13,14 @@ from .api_views import (
UpdateChannelMembershipAPIView,
BulkUpdateChannelMembershipAPIView,
RecordingViewSet,
RecurringRecordingRuleViewSet,
GetChannelStreamsAPIView,
SeriesRulesAPIView,
DeleteSeriesRuleAPIView,
EvaluateSeriesRulesAPIView,
BulkRemoveSeriesRecordingsAPIView,
BulkDeleteUpcomingRecordingsAPIView,
ComskipConfigAPIView,
)
app_name = 'channels' # for DRF routing
@ -25,6 +32,7 @@ router.register(r'channels', ChannelViewSet, basename='channel')
router.register(r'logos', LogoViewSet, basename='logo')
router.register(r'profiles', ChannelProfileViewSet, basename='profile')
router.register(r'recordings', RecordingViewSet, basename='recording')
router.register(r'recurring-rules', RecurringRecordingRuleViewSet, basename='recurring-rule')
urlpatterns = [
# Bulk delete is a single APIView, not a ViewSet
@ -35,6 +43,13 @@ urlpatterns = [
path('channels/<int:channel_id>/streams/', GetChannelStreamsAPIView.as_view(), name='get_channel_streams'),
path('profiles/<int:profile_id>/channels/<int:channel_id>/', UpdateChannelMembershipAPIView.as_view(), name='update_channel_membership'),
path('profiles/<int:profile_id>/channels/bulk-update/', BulkUpdateChannelMembershipAPIView.as_view(), name='bulk_update_channel_membership'),
# DVR series rules (order matters: specific routes before catch-all slug)
path('series-rules/', SeriesRulesAPIView.as_view(), name='series_rules'),
path('series-rules/evaluate/', EvaluateSeriesRulesAPIView.as_view(), name='evaluate_series_rules'),
path('series-rules/bulk-remove/', BulkRemoveSeriesRecordingsAPIView.as_view(), name='bulk_remove_series_recordings'),
path('series-rules/<path:tvg_id>/', DeleteSeriesRuleAPIView.as_view(), name='delete_series_rule'),
path('recordings/bulk-delete-upcoming/', BulkDeleteUpcomingRecordingsAPIView.as_view(), name='bulk_delete_upcoming_recordings'),
path('dvr/comskip-config/', ComskipConfigAPIView.as_view(), name='comskip_config'),
]
urlpatterns += router.urls

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,23 @@
# Generated by Django 5.1.6 on 2025-07-29 02:39
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('dispatcharr_channels', '0022_channel_auto_created_channel_auto_created_by_and_more'),
]
operations = [
migrations.AddField(
model_name='stream',
name='stream_stats',
field=models.JSONField(blank=True, help_text='JSON object containing stream statistics like video codec, resolution, etc.', null=True),
),
migrations.AddField(
model_name='stream',
name='stream_stats_updated_at',
field=models.DateTimeField(blank=True, db_index=True, help_text='When stream statistics were last updated', null=True),
),
]

View file

@ -0,0 +1,19 @@
# Generated by Django 5.2.4 on 2025-08-22 20:14
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('dispatcharr_channels', '0023_stream_stream_stats_stream_stream_stats_updated_at'),
]
operations = [
migrations.AlterField(
model_name='channelgroupm3uaccount',
name='channel_group',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='m3u_accounts', to='dispatcharr_channels.channelgroup'),
),
]

View file

@ -0,0 +1,28 @@
# Generated by Django 5.2.4 on 2025-09-02 14:30
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('dispatcharr_channels', '0024_alter_channelgroupm3uaccount_channel_group'),
]
operations = [
migrations.AlterField(
model_name='channelgroupm3uaccount',
name='custom_properties',
field=models.JSONField(blank=True, default=dict, null=True),
),
migrations.AlterField(
model_name='recording',
name='custom_properties',
field=models.JSONField(blank=True, default=dict, null=True),
),
migrations.AlterField(
model_name='stream',
name='custom_properties',
field=models.JSONField(blank=True, default=dict, null=True),
),
]

View file

@ -0,0 +1,31 @@
# Generated by Django 5.0.14 on 2025-09-18 14:56
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('dispatcharr_channels', '0025_alter_channelgroupm3uaccount_custom_properties_and_more'),
]
operations = [
migrations.CreateModel(
name='RecurringRecordingRule',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('days_of_week', models.JSONField(default=list)),
('start_time', models.TimeField()),
('end_time', models.TimeField()),
('enabled', models.BooleanField(default=True)),
('name', models.CharField(blank=True, max_length=255)),
('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)),
('channel', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='recurring_rules', to='dispatcharr_channels.channel')),
],
options={
'ordering': ['channel', 'start_time'],
},
),
]

View file

@ -0,0 +1,23 @@
# Generated by Django 5.2.4 on 2025-10-05 20:50
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('dispatcharr_channels', '0026_recurringrecordingrule'),
]
operations = [
migrations.AddField(
model_name='recurringrecordingrule',
name='end_date',
field=models.DateField(blank=True, null=True),
),
migrations.AddField(
model_name='recurringrecordingrule',
name='start_date',
field=models.DateField(blank=True, null=True),
),
]

View file

@ -0,0 +1,25 @@
# Generated by Django 5.2.4 on 2025-10-06 22:55
import django.utils.timezone
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('dispatcharr_channels', '0027_recurringrecordingrule_end_date_and_more'),
]
operations = [
migrations.AddField(
model_name='channel',
name='created_at',
field=models.DateTimeField(auto_now_add=True, default=django.utils.timezone.now, help_text='Timestamp when this channel was created'),
preserve_default=False,
),
migrations.AddField(
model_name='channel',
name='updated_at',
field=models.DateTimeField(auto_now=True, help_text='Timestamp when this channel was last updated'),
),
]

View file

@ -0,0 +1,54 @@
# Generated migration to backfill stream_hash for existing custom streams
from django.db import migrations
import hashlib
def backfill_custom_stream_hashes(apps, schema_editor):
"""
Generate stream_hash for all custom streams that don't have one.
Uses stream ID to create a stable hash that won't change when name/url is edited.
"""
Stream = apps.get_model('dispatcharr_channels', 'Stream')
custom_streams_without_hash = Stream.objects.filter(
is_custom=True,
stream_hash__isnull=True
)
updated_count = 0
for stream in custom_streams_without_hash:
# Generate a stable hash using the stream's ID
# This ensures the hash never changes even if name/url is edited
unique_string = f"custom_stream_{stream.id}"
stream.stream_hash = hashlib.sha256(unique_string.encode()).hexdigest()
stream.save(update_fields=['stream_hash'])
updated_count += 1
if updated_count > 0:
print(f"Backfilled stream_hash for {updated_count} custom streams")
else:
print("No custom streams needed stream_hash backfill")
def reverse_backfill(apps, schema_editor):
"""
Reverse migration - clear stream_hash for custom streams.
Note: This will break preview functionality for custom streams.
"""
Stream = apps.get_model('dispatcharr_channels', 'Stream')
custom_streams = Stream.objects.filter(is_custom=True)
count = custom_streams.update(stream_hash=None)
print(f"Cleared stream_hash for {count} custom streams")
class Migration(migrations.Migration):
dependencies = [
('dispatcharr_channels', '0028_channel_created_at_channel_updated_at'),
]
operations = [
migrations.RunPython(backfill_custom_stream_hashes, reverse_backfill),
]

View file

@ -0,0 +1,18 @@
# Generated by Django 5.2.4 on 2025-10-28 20:00
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('dispatcharr_channels', '0029_backfill_custom_stream_hashes'),
]
operations = [
migrations.AlterField(
model_name='stream',
name='url',
field=models.URLField(blank=True, max_length=4096, null=True),
),
]

View file

@ -0,0 +1,29 @@
# Generated by Django 5.2.9 on 2026-01-09 18:19
import datetime
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('dispatcharr_channels', '0030_alter_stream_url'),
]
operations = [
migrations.AddField(
model_name='channelgroupm3uaccount',
name='is_stale',
field=models.BooleanField(db_index=True, default=False, help_text='Whether this group relationship is stale (not seen in recent refresh, pending deletion)'),
),
migrations.AddField(
model_name='channelgroupm3uaccount',
name='last_seen',
field=models.DateTimeField(db_index=True, default=datetime.datetime.now, help_text='Last time this group was seen in the M3U source during a refresh'),
),
migrations.AddField(
model_name='stream',
name='is_stale',
field=models.BooleanField(db_index=True, default=False, help_text='Whether this stream is stale (not seen in recent refresh, pending deletion)'),
),
]

View file

@ -55,7 +55,7 @@ class Stream(models.Model):
"""
name = models.CharField(max_length=255, default="Default Stream")
url = models.URLField(max_length=2000, blank=True, null=True)
url = models.URLField(max_length=4096, blank=True, null=True)
m3u_account = models.ForeignKey(
M3UAccount,
on_delete=models.CASCADE,
@ -94,7 +94,25 @@ class Stream(models.Model):
db_index=True,
)
last_seen = models.DateTimeField(db_index=True, default=datetime.now)
custom_properties = models.TextField(null=True, blank=True)
is_stale = models.BooleanField(
default=False,
db_index=True,
help_text="Whether this stream is stale (not seen in recent refresh, pending deletion)"
)
custom_properties = models.JSONField(default=dict, blank=True, null=True)
# Stream statistics fields
stream_stats = models.JSONField(
null=True,
blank=True,
help_text="JSON object containing stream statistics like video codec, resolution, etc."
)
stream_stats_updated_at = models.DateTimeField(
null=True,
blank=True,
help_text="When stream statistics were last updated",
db_index=True
)
class Meta:
# If you use m3u_account, you might do unique_together = ('name','url','m3u_account')
@ -106,11 +124,11 @@ class Stream(models.Model):
return self.name or self.url or f"Stream ID {self.id}"
@classmethod
def generate_hash_key(cls, name, url, tvg_id, keys=None):
def generate_hash_key(cls, name, url, tvg_id, keys=None, m3u_id=None, group=None):
if keys is None:
keys = CoreSettings.get_m3u_hash_key().split(",")
stream_parts = {"name": name, "url": url, "tvg_id": tvg_id}
stream_parts = {"name": name, "url": url, "tvg_id": tvg_id, "m3u_id": m3u_id, "group": group}
hash_parts = {key: stream_parts[key] for key in keys if key in stream_parts}
@ -139,8 +157,14 @@ class Stream(models.Model):
stream = cls.objects.create(**fields_to_update)
return stream, True # True means it was created
# @TODO: honor stream's stream profile
def get_stream_profile(self):
"""
Get the stream profile for this stream.
Uses the stream's own profile if set, otherwise returns the default.
"""
if self.stream_profile:
return self.stream_profile
stream_profile = StreamProfile.objects.get(
id=CoreSettings.get_default_stream_profile_id()
)
@ -290,6 +314,15 @@ class Channel(models.Model):
help_text="The M3U account that auto-created this channel"
)
created_at = models.DateTimeField(
auto_now_add=True,
help_text="Timestamp when this channel was created"
)
updated_at = models.DateTimeField(
auto_now=True,
help_text="Timestamp when this channel was last updated"
)
def clean(self):
# Enforce unique channel_number within a given group
existing = Channel.objects.filter(
@ -366,14 +399,17 @@ class Channel(models.Model):
if not m3u_account:
logger.debug(f"Stream {stream.id} has no M3U account")
continue
if m3u_account.is_active == False:
logger.debug(f"M3U account {m3u_account.id} is inactive, skipping.")
continue
m3u_profiles = m3u_account.profiles.all()
m3u_profiles = m3u_account.profiles.filter(is_active=True)
default_profile = next(
(obj for obj in m3u_profiles if obj.is_default), None
)
if not default_profile:
logger.debug(f"M3U account {m3u_account.id} has no default profile")
logger.debug(f"M3U account {m3u_account.id} has no active default profile")
continue
profiles = [default_profile] + [
@ -381,11 +417,6 @@ class Channel(models.Model):
]
for profile in profiles:
# Skip inactive profiles
if not profile.is_active:
logger.debug(f"Skipping inactive profile {profile.id}")
continue
has_active_profiles = True
profile_connections_key = f"profile_connections:{profile.id}"
@ -420,9 +451,9 @@ class Channel(models.Model):
# No available streams - determine specific reason
if has_streams_but_maxed_out:
error_reason = "All M3U profiles have reached maximum connection limits"
error_reason = "All active M3U profiles have reached maximum connection limits"
elif has_active_profiles:
error_reason = "No compatible profile found for any assigned stream"
error_reason = "No compatible active profile found for any assigned stream"
else:
error_reason = "No active profiles found for any assigned stream"
@ -547,12 +578,12 @@ class ChannelStream(models.Model):
class ChannelGroupM3UAccount(models.Model):
channel_group = models.ForeignKey(
ChannelGroup, on_delete=models.CASCADE, related_name="m3u_account"
ChannelGroup, on_delete=models.CASCADE, related_name="m3u_accounts"
)
m3u_account = models.ForeignKey(
M3UAccount, on_delete=models.CASCADE, related_name="channel_group"
)
custom_properties = models.TextField(null=True, blank=True)
custom_properties = models.JSONField(default=dict, blank=True, null=True)
enabled = models.BooleanField(default=True)
auto_channel_sync = models.BooleanField(
default=False,
@ -563,6 +594,16 @@ class ChannelGroupM3UAccount(models.Model):
blank=True,
help_text='Starting channel number for auto-created channels in this group'
)
last_seen = models.DateTimeField(
default=datetime.now,
db_index=True,
help_text='Last time this group was seen in the M3U source during a refresh'
)
is_stale = models.BooleanField(
default=False,
db_index=True,
help_text='Whether this group relationship is stale (not seen in recent refresh, pending deletion)'
)
class Meta:
unique_together = ("channel_group", "m3u_account")
@ -586,7 +627,39 @@ class Recording(models.Model):
start_time = models.DateTimeField()
end_time = models.DateTimeField()
task_id = models.CharField(max_length=255, null=True, blank=True)
custom_properties = models.TextField(null=True, blank=True)
custom_properties = models.JSONField(default=dict, blank=True, null=True)
def __str__(self):
return f"{self.channel.name} - {self.start_time} to {self.end_time}"
class RecurringRecordingRule(models.Model):
"""Rule describing a recurring manual DVR schedule."""
channel = models.ForeignKey(
"Channel",
on_delete=models.CASCADE,
related_name="recurring_rules",
)
days_of_week = models.JSONField(default=list)
start_time = models.TimeField()
end_time = models.TimeField()
enabled = models.BooleanField(default=True)
name = models.CharField(max_length=255, blank=True)
start_date = models.DateField(null=True, blank=True)
end_date = models.DateField(null=True, blank=True)
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
class Meta:
ordering = ["channel", "start_time"]
def __str__(self):
channel_name = getattr(self.channel, "name", str(self.channel_id))
return f"Recurring rule for {channel_name}"
def cleaned_days(self):
try:
return sorted({int(d) for d in (self.days_of_week or []) if 0 <= int(d) <= 6})
except Exception:
return []

View file

@ -1,3 +1,6 @@
import json
from datetime import datetime
from rest_framework import serializers
from .models import (
Stream,
@ -9,6 +12,7 @@ from .models import (
ChannelProfile,
ChannelProfileMembership,
Recording,
RecurringRecordingRule,
)
from apps.epg.serializers import EPGDataSerializer
from core.models import StreamProfile
@ -16,6 +20,7 @@ from apps.epg.models import EPGData
from django.urls import reverse
from rest_framework import serializers
from django.utils import timezone
from core.utils import validate_flexible_url
class LogoSerializer(serializers.ModelSerializer):
@ -32,12 +37,23 @@ class LogoSerializer(serializers.ModelSerializer):
"""Validate that the URL is unique for creation or update"""
if self.instance and self.instance.url == value:
return value
if Logo.objects.filter(url=value).exists():
raise serializers.ValidationError("A logo with this URL already exists.")
return value
def create(self, validated_data):
"""Handle logo creation with proper URL validation"""
return Logo.objects.create(**validated_data)
def update(self, instance, validated_data):
"""Handle logo updates"""
for attr, value in validated_data.items():
setattr(instance, attr, value)
instance.save()
return instance
def get_cache_url(self, obj):
# return f"/api/channels/logos/{obj.id}/cache/"
request = self.context.get("request")
@ -57,10 +73,18 @@ class LogoSerializer(serializers.ModelSerializer):
def get_channel_names(self, obj):
"""Get the names of channels using this logo (limited to first 5)"""
names = []
# Get channel names
channels = obj.channels.all()[:5]
names = [channel.name for channel in channels]
if obj.channels.count() > 5:
names.append(f"...and {obj.channels.count() - 5} more")
for channel in channels:
names.append(f"Channel: {channel.name}")
# Calculate total count for "more" message
total_count = self.get_channel_count(obj)
if total_count > 5:
names.append(f"...and {total_count - 5} more")
return names
@ -68,6 +92,12 @@ class LogoSerializer(serializers.ModelSerializer):
# Stream
#
class StreamSerializer(serializers.ModelSerializer):
url = serializers.CharField(
required=False,
allow_blank=True,
allow_null=True,
validators=[validate_flexible_url]
)
stream_profile_id = serializers.PrimaryKeyRelatedField(
queryset=StreamProfile.objects.all(),
source="stream_profile",
@ -89,10 +119,13 @@ class StreamSerializer(serializers.ModelSerializer):
"current_viewers",
"updated_at",
"last_seen",
"is_stale",
"stream_profile_id",
"is_custom",
"channel_group",
"stream_hash",
"stream_stats",
"stream_stats_updated_at",
]
def get_fields(self):
@ -114,16 +147,57 @@ class StreamSerializer(serializers.ModelSerializer):
return fields
class ChannelGroupM3UAccountSerializer(serializers.ModelSerializer):
m3u_accounts = serializers.IntegerField(source="m3u_accounts.id", read_only=True)
enabled = serializers.BooleanField()
auto_channel_sync = serializers.BooleanField(default=False)
auto_sync_channel_start = serializers.FloatField(allow_null=True, required=False)
custom_properties = serializers.JSONField(required=False)
class Meta:
model = ChannelGroupM3UAccount
fields = ["m3u_accounts", "channel_group", "enabled", "auto_channel_sync", "auto_sync_channel_start", "custom_properties", "is_stale", "last_seen"]
def to_representation(self, instance):
data = super().to_representation(instance)
custom_props = instance.custom_properties or {}
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)
#
# Channel Group
#
class ChannelGroupSerializer(serializers.ModelSerializer):
channel_count = serializers.IntegerField(read_only=True)
m3u_account_count = serializers.IntegerField(read_only=True)
channel_count = serializers.SerializerMethodField()
m3u_account_count = serializers.SerializerMethodField()
m3u_accounts = ChannelGroupM3UAccountSerializer(
many=True,
read_only=True
)
class Meta:
model = ChannelGroup
fields = ["id", "name", "channel_count", "m3u_account_count"]
fields = ["id", "name", "channel_count", "m3u_account_count", "m3u_accounts"]
def get_channel_count(self, obj):
"""Get count of channels in this group"""
return obj.channels.count()
def get_m3u_account_count(self, obj):
"""Get count of M3U accounts associated with this group"""
return obj.m3u_accounts.count()
class ChannelProfileSerializer(serializers.ModelSerializer):
@ -229,8 +303,17 @@ class ChannelSerializer(serializers.ModelSerializer):
if include_streams:
self.fields["streams"] = serializers.SerializerMethodField()
return super().to_representation(instance)
return super().to_representation(instance)
else:
# Fix: For PATCH/PUT responses, ensure streams are ordered
representation = super().to_representation(instance)
if "streams" in representation:
representation["streams"] = list(
instance.streams.all()
.order_by("channelstream__order")
.values_list("id", flat=True)
)
return representation
def get_logo(self, obj):
return LogoSerializer(obj.logo).data
@ -327,40 +410,6 @@ class ChannelSerializer(serializers.ModelSerializer):
return None
class ChannelGroupM3UAccountSerializer(serializers.ModelSerializer):
enabled = serializers.BooleanField()
auto_channel_sync = serializers.BooleanField(default=False)
auto_sync_channel_start = serializers.FloatField(allow_null=True, required=False)
custom_properties = serializers.JSONField(required=False)
class Meta:
model = ChannelGroupM3UAccount
fields = ["id", "channel_group", "enabled", "auto_channel_sync", "auto_sync_channel_start", "custom_properties"]
def to_representation(self, instance):
ret = super().to_representation(instance)
# Ensure custom_properties is always a dict or None
val = ret.get("custom_properties")
if isinstance(val, str):
import json
try:
ret["custom_properties"] = json.loads(val)
except Exception:
ret["custom_properties"] = None
return ret
def to_internal_value(self, data):
# Accept both dict and JSON string for custom_properties
val = data.get("custom_properties")
if isinstance(val, str):
import json
try:
data["custom_properties"] = json.loads(val)
except Exception:
pass
return super().to_internal_value(data)
class RecordingSerializer(serializers.ModelSerializer):
class Meta:
model = Recording
@ -368,9 +417,48 @@ class RecordingSerializer(serializers.ModelSerializer):
read_only_fields = ["task_id"]
def validate(self, data):
from core.models import CoreSettings
start_time = data.get("start_time")
end_time = data.get("end_time")
if start_time and timezone.is_naive(start_time):
start_time = timezone.make_aware(start_time, timezone.get_current_timezone())
data["start_time"] = start_time
if end_time and timezone.is_naive(end_time):
end_time = timezone.make_aware(end_time, timezone.get_current_timezone())
data["end_time"] = end_time
# If this is an EPG-based recording (program provided), apply global pre/post offsets
try:
cp = data.get("custom_properties") or {}
is_epg_based = isinstance(cp, dict) and isinstance(cp.get("program"), (dict,))
except Exception:
is_epg_based = False
if is_epg_based and start_time and end_time:
try:
pre_min = int(CoreSettings.get_dvr_pre_offset_minutes())
except Exception:
pre_min = 0
try:
post_min = int(CoreSettings.get_dvr_post_offset_minutes())
except Exception:
post_min = 0
from datetime import timedelta
try:
if pre_min and pre_min > 0:
start_time = start_time - timedelta(minutes=pre_min)
except Exception:
pass
try:
if post_min and post_min > 0:
end_time = end_time + timedelta(minutes=post_min)
except Exception:
pass
# write back adjusted times so scheduling uses them
data["start_time"] = start_time
data["end_time"] = end_time
now = timezone.now() # timezone-aware current time
if end_time < now:
@ -383,3 +471,56 @@ class RecordingSerializer(serializers.ModelSerializer):
raise serializers.ValidationError("End time must be after start time.")
return data
class RecurringRecordingRuleSerializer(serializers.ModelSerializer):
class Meta:
model = RecurringRecordingRule
fields = "__all__"
read_only_fields = ["created_at", "updated_at"]
def validate_days_of_week(self, value):
if not value:
raise serializers.ValidationError("Select at least one day of the week")
cleaned = []
for entry in value:
try:
iv = int(entry)
except (TypeError, ValueError):
raise serializers.ValidationError("Days of week must be integers 0-6")
if iv < 0 or iv > 6:
raise serializers.ValidationError("Days of week must be between 0 (Monday) and 6 (Sunday)")
cleaned.append(iv)
return sorted(set(cleaned))
def validate(self, attrs):
start = attrs.get("start_time") or getattr(self.instance, "start_time", None)
end = attrs.get("end_time") or getattr(self.instance, "end_time", None)
start_date = attrs.get("start_date") if "start_date" in attrs else getattr(self.instance, "start_date", None)
end_date = attrs.get("end_date") if "end_date" in attrs else getattr(self.instance, "end_date", None)
if start_date is None:
existing_start = getattr(self.instance, "start_date", None)
if existing_start is None:
raise serializers.ValidationError("Start date is required")
if start_date and end_date and end_date < start_date:
raise serializers.ValidationError("End date must be on or after start date")
if end_date is None:
existing_end = getattr(self.instance, "end_date", None)
if existing_end is None:
raise serializers.ValidationError("End date is required")
if start and end and start_date and end_date:
start_dt = datetime.combine(start_date, start)
end_dt = datetime.combine(end_date, end)
if end_dt <= start_dt:
raise serializers.ValidationError("End datetime must be after start datetime")
elif start and end and end == start:
raise serializers.ValidationError("End time must be different from start time")
# Normalize empty strings to None for dates
if attrs.get("end_date") == "":
attrs["end_date"] = None
if attrs.get("start_date") == "":
attrs["start_date"] = None
return super().validate(attrs)
def create(self, validated_data):
return super().create(validated_data)

View file

@ -8,7 +8,7 @@ from .models import Channel, Stream, ChannelProfile, ChannelProfileMembership, R
from apps.m3u.models import M3UAccount
from apps.epg.tasks import parse_programs_for_tvg_id
import logging, requests, time
from .tasks import run_recording
from .tasks import run_recording, prefetch_recording_artwork
from django.utils.timezone import now, is_aware, make_aware
from datetime import timedelta
@ -45,6 +45,20 @@ def set_default_m3u_account(sender, instance, **kwargs):
else:
raise ValueError("No default M3UAccount found.")
@receiver(post_save, sender=Stream)
def generate_custom_stream_hash(sender, instance, created, **kwargs):
"""
Generate a stable stream_hash for custom streams after creation.
Uses the stream's ID to ensure the hash never changes even if name/url is edited.
"""
if instance.is_custom and not instance.stream_hash and created:
import hashlib
# Use stream ID for a stable, unique hash that never changes
unique_string = f"custom_stream_{instance.id}"
instance.stream_hash = hashlib.sha256(unique_string.encode()).hexdigest()
# Use update to avoid triggering signals again
Stream.objects.filter(id=instance.id).update(stream_hash=instance.stream_hash)
@receiver(post_save, sender=Channel)
def refresh_epg_programs(sender, instance, created, **kwargs):
"""
@ -62,15 +76,6 @@ def refresh_epg_programs(sender, instance, created, **kwargs):
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)
@receiver(post_save, sender=Channel)
def add_new_channel_to_groups(sender, instance, created, **kwargs):
if created:
profiles = ChannelProfile.objects.all()
ChannelProfileMembership.objects.bulk_create([
ChannelProfileMembership(channel_profile=profile, channel=instance)
for profile in profiles
])
@receiver(post_save, sender=ChannelProfile)
def create_profile_memberships(sender, instance, created, **kwargs):
if created:
@ -82,8 +87,9 @@ def create_profile_memberships(sender, instance, created, **kwargs):
def schedule_recording_task(instance):
eta = instance.start_time
# Pass recording_id first so task can persist metadata to the correct row
task = run_recording.apply_async(
args=[instance.channel_id, str(instance.start_time), str(instance.end_time)],
args=[instance.id, instance.channel_id, str(instance.start_time), str(instance.end_time)],
eta=eta
)
return task.id
@ -132,6 +138,11 @@ def schedule_task_on_save(sender, instance, created, **kwargs):
instance.save(update_fields=['task_id'])
else:
print("Start time is in the past. Not scheduling.")
# Kick off poster/artwork prefetch to enrich Upcoming cards
try:
prefetch_recording_artwork.apply_async(args=[instance.id], countdown=1)
except Exception as e:
print("Error scheduling artwork prefetch:", e)
except Exception as e:
import traceback
print("Error in post_save signal:", e)

File diff suppressed because it is too large Load diff

View file

View file

@ -0,0 +1,211 @@
from django.test import TestCase
from django.contrib.auth import get_user_model
from rest_framework.test import APIClient
from rest_framework import status
from apps.channels.models import Channel, ChannelGroup
User = get_user_model()
class ChannelBulkEditAPITests(TestCase):
def setUp(self):
# Create a test admin user (user_level >= 10) and authenticate
self.user = User.objects.create_user(username="testuser", password="testpass123")
self.user.user_level = 10 # Set admin level
self.user.save()
self.client = APIClient()
self.client.force_authenticate(user=self.user)
self.bulk_edit_url = "/api/channels/channels/edit/bulk/"
# Create test channel group
self.group1 = ChannelGroup.objects.create(name="Test Group 1")
self.group2 = ChannelGroup.objects.create(name="Test Group 2")
# Create test channels
self.channel1 = Channel.objects.create(
channel_number=1.0,
name="Channel 1",
tvg_id="channel1",
channel_group=self.group1
)
self.channel2 = Channel.objects.create(
channel_number=2.0,
name="Channel 2",
tvg_id="channel2",
channel_group=self.group1
)
self.channel3 = Channel.objects.create(
channel_number=3.0,
name="Channel 3",
tvg_id="channel3"
)
def test_bulk_edit_success(self):
"""Test successful bulk update of multiple channels"""
data = [
{"id": self.channel1.id, "name": "Updated Channel 1"},
{"id": self.channel2.id, "name": "Updated Channel 2", "channel_number": 22.0},
]
response = self.client.patch(self.bulk_edit_url, data, format="json")
self.assertEqual(response.status_code, status.HTTP_200_OK)
self.assertEqual(response.data["message"], "Successfully updated 2 channels")
self.assertEqual(len(response.data["channels"]), 2)
# Verify database changes
self.channel1.refresh_from_db()
self.channel2.refresh_from_db()
self.assertEqual(self.channel1.name, "Updated Channel 1")
self.assertEqual(self.channel2.name, "Updated Channel 2")
self.assertEqual(self.channel2.channel_number, 22.0)
def test_bulk_edit_with_empty_validated_data_first(self):
"""
Test the bug fix: when first channel has empty validated_data.
This was causing: ValueError: Field names must be given to bulk_update()
"""
# Create a channel with data that will be "unchanged" (empty validated_data)
# We'll send the same data it already has
data = [
# First channel: no actual changes (this would create empty validated_data)
{"id": self.channel1.id},
# Second channel: has changes
{"id": self.channel2.id, "name": "Updated Channel 2"},
]
response = self.client.patch(self.bulk_edit_url, data, format="json")
# Should not crash with ValueError
self.assertEqual(response.status_code, status.HTTP_200_OK)
self.assertEqual(response.data["message"], "Successfully updated 2 channels")
# Verify the channel with changes was updated
self.channel2.refresh_from_db()
self.assertEqual(self.channel2.name, "Updated Channel 2")
def test_bulk_edit_all_empty_updates(self):
"""Test when all channels have empty updates (no actual changes)"""
data = [
{"id": self.channel1.id},
{"id": self.channel2.id},
]
response = self.client.patch(self.bulk_edit_url, data, format="json")
# Should succeed without calling bulk_update
self.assertEqual(response.status_code, status.HTTP_200_OK)
self.assertEqual(response.data["message"], "Successfully updated 2 channels")
def test_bulk_edit_mixed_fields(self):
"""Test bulk update where different channels update different fields"""
data = [
{"id": self.channel1.id, "name": "New Name 1"},
{"id": self.channel2.id, "channel_number": 99.0},
{"id": self.channel3.id, "tvg_id": "new_tvg_id", "name": "New Name 3"},
]
response = self.client.patch(self.bulk_edit_url, data, format="json")
self.assertEqual(response.status_code, status.HTTP_200_OK)
self.assertEqual(response.data["message"], "Successfully updated 3 channels")
# Verify all updates
self.channel1.refresh_from_db()
self.channel2.refresh_from_db()
self.channel3.refresh_from_db()
self.assertEqual(self.channel1.name, "New Name 1")
self.assertEqual(self.channel2.channel_number, 99.0)
self.assertEqual(self.channel3.tvg_id, "new_tvg_id")
self.assertEqual(self.channel3.name, "New Name 3")
def test_bulk_edit_with_channel_group(self):
"""Test bulk update with channel_group_id changes"""
data = [
{"id": self.channel1.id, "channel_group_id": self.group2.id},
{"id": self.channel3.id, "channel_group_id": self.group1.id},
]
response = self.client.patch(self.bulk_edit_url, data, format="json")
self.assertEqual(response.status_code, status.HTTP_200_OK)
# Verify group changes
self.channel1.refresh_from_db()
self.channel3.refresh_from_db()
self.assertEqual(self.channel1.channel_group, self.group2)
self.assertEqual(self.channel3.channel_group, self.group1)
def test_bulk_edit_nonexistent_channel(self):
"""Test bulk update with a channel that doesn't exist"""
nonexistent_id = 99999
data = [
{"id": nonexistent_id, "name": "Should Fail"},
{"id": self.channel1.id, "name": "Should Still Update"},
]
response = self.client.patch(self.bulk_edit_url, data, format="json")
# Should return 400 with errors
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
self.assertIn("errors", response.data)
self.assertEqual(len(response.data["errors"]), 1)
self.assertEqual(response.data["errors"][0]["channel_id"], nonexistent_id)
self.assertEqual(response.data["errors"][0]["error"], "Channel not found")
# The valid channel should still be updated
self.assertEqual(response.data["updated_count"], 1)
def test_bulk_edit_validation_error(self):
"""Test bulk update with invalid data (validation error)"""
data = [
{"id": self.channel1.id, "channel_number": "invalid_number"},
]
response = self.client.patch(self.bulk_edit_url, data, format="json")
# Should return 400 with validation errors
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
self.assertIn("errors", response.data)
self.assertEqual(len(response.data["errors"]), 1)
self.assertIn("channel_number", response.data["errors"][0]["errors"])
def test_bulk_edit_empty_channel_updates(self):
"""Test bulk update with empty list"""
data = []
response = self.client.patch(self.bulk_edit_url, data, format="json")
# Empty list is accepted and returns success with 0 updates
self.assertEqual(response.status_code, status.HTTP_200_OK)
self.assertEqual(response.data["message"], "Successfully updated 0 channels")
def test_bulk_edit_missing_channel_updates(self):
"""Test bulk update without proper format (dict instead of list)"""
data = {"channel_updates": {}}
response = self.client.patch(self.bulk_edit_url, data, format="json")
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
self.assertEqual(response.data["error"], "Expected a list of channel updates")
def test_bulk_edit_preserves_other_fields(self):
"""Test that bulk update only changes specified fields"""
original_channel_number = self.channel1.channel_number
original_tvg_id = self.channel1.tvg_id
data = [
{"id": self.channel1.id, "name": "Only Name Changed"},
]
response = self.client.patch(self.bulk_edit_url, data, format="json")
self.assertEqual(response.status_code, status.HTTP_200_OK)
# Verify only name changed, other fields preserved
self.channel1.refresh_from_db()
self.assertEqual(self.channel1.name, "Only Name Changed")
self.assertEqual(self.channel1.channel_number, original_channel_number)
self.assertEqual(self.channel1.tvg_id, original_tvg_id)

View file

@ -0,0 +1,40 @@
from datetime import datetime, timedelta
from django.test import TestCase
from django.utils import timezone
from apps.channels.models import Channel, RecurringRecordingRule, Recording
from apps.channels.tasks import sync_recurring_rule_impl, purge_recurring_rule_impl
class RecurringRecordingRuleTasksTests(TestCase):
def test_sync_recurring_rule_creates_and_purges_recordings(self):
now = timezone.now()
channel = Channel.objects.create(channel_number=1, name='Test Channel')
start_time = (now + timedelta(minutes=15)).time().replace(second=0, microsecond=0)
end_time = (now + timedelta(minutes=75)).time().replace(second=0, microsecond=0)
rule = RecurringRecordingRule.objects.create(
channel=channel,
days_of_week=[now.weekday()],
start_time=start_time,
end_time=end_time,
)
created = sync_recurring_rule_impl(rule.id, drop_existing=True, horizon_days=1)
self.assertEqual(created, 1)
recording = Recording.objects.filter(custom_properties__rule__id=rule.id).first()
self.assertIsNotNone(recording)
self.assertEqual(recording.channel, channel)
self.assertEqual(recording.custom_properties.get('rule', {}).get('id'), rule.id)
expected_start = timezone.make_aware(
datetime.combine(recording.start_time.date(), start_time),
timezone.get_current_timezone(),
)
self.assertLess(abs((recording.start_time - expected_start).total_seconds()), 60)
removed = purge_recurring_rule_impl(rule.id)
self.assertEqual(removed, 1)
self.assertFalse(Recording.objects.filter(custom_properties__rule__id=rule.id).exists())

View file

@ -147,23 +147,37 @@ class EPGGridAPIView(APIView):
f"EPGGridAPIView: Found {count} program(s), including recently ended, currently running, and upcoming shows."
)
# Generate dummy programs for channels that have no EPG data
# Generate dummy programs for channels that have no EPG data OR dummy EPG sources
from apps.channels.models import Channel
from apps.epg.models import EPGSource
from django.db.models import Q
# Get channels with no EPG data
# Get channels with no EPG data at all (standard dummy)
channels_without_epg = Channel.objects.filter(Q(epg_data__isnull=True))
channels_count = channels_without_epg.count()
# Log more detailed information about channels missing EPG data
if channels_count > 0:
# Get channels with custom dummy EPG sources (generate on-demand with patterns)
channels_with_custom_dummy = Channel.objects.filter(
epg_data__epg_source__source_type='dummy'
).distinct()
# Log what we found
without_count = channels_without_epg.count()
custom_count = channels_with_custom_dummy.count()
if without_count > 0:
channel_names = [f"{ch.name} (ID: {ch.id})" for ch in channels_without_epg]
logger.warning(
f"EPGGridAPIView: Missing EPG data for these channels: {', '.join(channel_names)}"
logger.debug(
f"EPGGridAPIView: Channels needing standard dummy EPG: {', '.join(channel_names)}"
)
if custom_count > 0:
channel_names = [f"{ch.name} (ID: {ch.id})" for ch in channels_with_custom_dummy]
logger.debug(
f"EPGGridAPIView: Channels needing custom dummy EPG: {', '.join(channel_names)}"
)
logger.debug(
f"EPGGridAPIView: Found {channels_count} channels with no EPG data."
f"EPGGridAPIView: Found {without_count} channels needing standard dummy, {custom_count} needing custom dummy EPG."
)
# Serialize the regular programs
@ -205,12 +219,91 @@ class EPGGridAPIView(APIView):
# Generate and append dummy programs
dummy_programs = []
for channel in channels_without_epg:
# Use the channel UUID as tvg_id for dummy programs to match in the guide
# Import the function from output.views
from apps.output.views import generate_dummy_programs as gen_dummy_progs
# Handle channels with CUSTOM dummy EPG sources (with patterns)
for channel in channels_with_custom_dummy:
# For dummy EPGs, ALWAYS use channel UUID to ensure unique programs per channel
# This prevents multiple channels assigned to the same dummy EPG from showing identical data
# Each channel gets its own unique program data even if they share the same EPG source
dummy_tvg_id = str(channel.uuid)
try:
# Create programs every 4 hours for the next 24 hours
# Get the custom dummy EPG source
epg_source = channel.epg_data.epg_source if channel.epg_data else None
logger.debug(f"Generating custom dummy programs for channel: {channel.name} (ID: {channel.id})")
# Determine which name to parse based on custom properties
name_to_parse = channel.name
if epg_source and epg_source.custom_properties:
custom_props = epg_source.custom_properties
name_source = custom_props.get('name_source')
if name_source == 'stream':
# Get the stream index (1-based from user, convert to 0-based)
stream_index = custom_props.get('stream_index', 1) - 1
# Get streams ordered by channelstream order
channel_streams = channel.streams.all().order_by('channelstream__order')
if channel_streams.exists() and 0 <= stream_index < channel_streams.count():
stream = list(channel_streams)[stream_index]
name_to_parse = stream.name
logger.debug(f"Using stream name for parsing: {name_to_parse} (stream index: {stream_index})")
else:
logger.warning(f"Stream index {stream_index} not found for channel {channel.name}, falling back to channel name")
elif name_source == 'channel':
logger.debug(f"Using channel name for parsing: {name_to_parse}")
# Generate programs using custom patterns from the dummy EPG source
# Use the same tvg_id that will be set in the program data
generated = gen_dummy_progs(
channel_id=dummy_tvg_id,
channel_name=name_to_parse,
num_days=1,
program_length_hours=4,
epg_source=epg_source
)
# Custom dummy should always return data (either from patterns or fallback)
if generated:
logger.debug(f"Generated {len(generated)} custom dummy programs for {channel.name}")
# Convert generated programs to API format
for program in generated:
dummy_program = {
"id": f"dummy-custom-{channel.id}-{program['start_time'].hour}",
"epg": {"tvg_id": dummy_tvg_id, "name": channel.name},
"start_time": program['start_time'].isoformat(),
"end_time": program['end_time'].isoformat(),
"title": program['title'],
"description": program['description'],
"tvg_id": dummy_tvg_id,
"sub_title": None,
"custom_properties": None,
}
dummy_programs.append(dummy_program)
else:
logger.warning(f"No programs generated for custom dummy EPG channel: {channel.name}")
except Exception as e:
logger.error(
f"Error creating custom dummy programs for channel {channel.name} (ID: {channel.id}): {str(e)}"
)
# Handle channels with NO EPG data (standard dummy with humorous descriptions)
for channel in channels_without_epg:
# For channels with no EPG, use UUID to ensure uniqueness (matches frontend logic)
# The frontend uses: tvgRecord?.tvg_id ?? channel.uuid
# Since there's no EPG data, it will fall back to UUID
dummy_tvg_id = str(channel.uuid)
try:
logger.debug(f"Generating standard dummy programs for channel: {channel.name} (ID: {channel.id})")
# Create programs every 4 hours for the next 24 hours with humorous descriptions
for hour_offset in range(0, 24, 4):
# Use timedelta for time arithmetic instead of replace() to avoid hour overflow
start_time = now + timedelta(hours=hour_offset)
@ -238,7 +331,7 @@ class EPGGridAPIView(APIView):
# Create a dummy program in the same format as regular programs
dummy_program = {
"id": f"dummy-{channel.id}-{hour_offset}", # Create a unique ID
"id": f"dummy-standard-{channel.id}-{hour_offset}",
"epg": {"tvg_id": dummy_tvg_id, "name": channel.name},
"start_time": start_time.isoformat(),
"end_time": end_time.isoformat(),
@ -252,7 +345,7 @@ class EPGGridAPIView(APIView):
except Exception as e:
logger.error(
f"Error creating dummy programs for channel {channel.name} (ID: {channel.id}): {str(e)}"
f"Error creating standard dummy programs for channel {channel.name} (ID: {channel.id}): {str(e)}"
)
# Combine regular and dummy programs
@ -284,7 +377,22 @@ class EPGImportAPIView(APIView):
)
def post(self, request, format=None):
logger.info("EPGImportAPIView: Received request to import EPG data.")
refresh_epg_data.delay(request.data.get("id", None)) # Trigger Celery task
epg_id = request.data.get("id", None)
# Check if this is a dummy EPG source
try:
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}")
return Response(
{"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) # Trigger Celery task
logger.info("EPGImportAPIView: Task dispatched to refresh EPG data.")
return Response(
{"success": True, "message": "EPG data import initiated."},
@ -308,3 +416,4 @@ class EPGDataViewSet(viewsets.ReadOnlyModelViewSet):
return [perm() for perm in permission_classes_by_action[self.action]]
except KeyError:
return [Authenticated()]

View file

@ -0,0 +1,18 @@
# Generated by Django 5.2.4 on 2025-09-02 14:30
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('epg', '0014_epgsource_extracted_file_path'),
]
operations = [
migrations.AlterField(
model_name='programdata',
name='custom_properties',
field=models.JSONField(blank=True, default=dict, null=True),
),
]

View file

@ -0,0 +1,18 @@
# Generated by Django 5.2.4 on 2025-09-16 22:01
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('epg', '0015_alter_programdata_custom_properties'),
]
operations = [
migrations.AddField(
model_name='epgdata',
name='icon_url',
field=models.URLField(blank=True, max_length=500, null=True),
),
]

View file

@ -0,0 +1,18 @@
# Generated by Django 5.2.4 on 2025-09-24 21:07
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('epg', '0016_epgdata_icon_url'),
]
operations = [
migrations.AlterField(
model_name='epgsource',
name='url',
field=models.URLField(blank=True, max_length=1000, null=True),
),
]

View file

@ -0,0 +1,23 @@
# Generated by Django 5.2.4 on 2025-10-17 17:02
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('epg', '0017_alter_epgsource_url'),
]
operations = [
migrations.AddField(
model_name='epgsource',
name='custom_properties',
field=models.JSONField(blank=True, default=dict, help_text='Custom properties for dummy EPG configuration (regex patterns, timezone, duration, etc.)', null=True),
),
migrations.AlterField(
model_name='epgsource',
name='source_type',
field=models.CharField(choices=[('xmltv', 'XMLTV URL'), ('schedules_direct', 'Schedules Direct API'), ('dummy', 'Custom Dummy EPG')], max_length=20),
),
]

View file

@ -0,0 +1,18 @@
# Generated by Django 5.2.4 on 2025-10-22 21:59
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('epg', '0018_epgsource_custom_properties_and_more'),
]
operations = [
migrations.AlterField(
model_name='programdata',
name='sub_title',
field=models.TextField(blank=True, null=True),
),
]

View file

@ -0,0 +1,119 @@
# Generated migration to replace {time} placeholders with {starttime}
import re
from django.db import migrations
def migrate_time_placeholders(apps, schema_editor):
"""
Replace {time} with {starttime} and {time24} with {starttime24}
in all dummy EPG source custom_properties templates.
"""
EPGSource = apps.get_model('epg', 'EPGSource')
# Fields that contain templates with placeholders
template_fields = [
'title_template',
'description_template',
'upcoming_title_template',
'upcoming_description_template',
'ended_title_template',
'ended_description_template',
'channel_logo_url',
'program_poster_url',
]
# Get all dummy EPG sources
dummy_sources = EPGSource.objects.filter(source_type='dummy')
updated_count = 0
for source in dummy_sources:
if not source.custom_properties:
continue
modified = False
custom_props = source.custom_properties.copy()
for field in template_fields:
if field in custom_props and custom_props[field]:
original_value = custom_props[field]
# Replace {time24} first (before {time}) to avoid double replacement
# e.g., {time24} shouldn't become {starttime24} via {time} -> {starttime}
new_value = original_value
new_value = re.sub(r'\{time24\}', '{starttime24}', new_value)
new_value = re.sub(r'\{time\}', '{starttime}', new_value)
if new_value != original_value:
custom_props[field] = new_value
modified = True
if modified:
source.custom_properties = custom_props
source.save(update_fields=['custom_properties'])
updated_count += 1
if updated_count > 0:
print(f"Migration complete: Updated {updated_count} dummy EPG source(s) with new placeholder names.")
else:
print("No dummy EPG sources needed placeholder updates.")
def reverse_migration(apps, schema_editor):
"""
Reverse the migration by replacing {starttime} back to {time}.
"""
EPGSource = apps.get_model('epg', 'EPGSource')
template_fields = [
'title_template',
'description_template',
'upcoming_title_template',
'upcoming_description_template',
'ended_title_template',
'ended_description_template',
'channel_logo_url',
'program_poster_url',
]
dummy_sources = EPGSource.objects.filter(source_type='dummy')
updated_count = 0
for source in dummy_sources:
if not source.custom_properties:
continue
modified = False
custom_props = source.custom_properties.copy()
for field in template_fields:
if field in custom_props and custom_props[field]:
original_value = custom_props[field]
# Reverse the replacements
new_value = original_value
new_value = re.sub(r'\{starttime24\}', '{time24}', new_value)
new_value = re.sub(r'\{starttime\}', '{time}', new_value)
if new_value != original_value:
custom_props[field] = new_value
modified = True
if modified:
source.custom_properties = custom_props
source.save(update_fields=['custom_properties'])
updated_count += 1
if updated_count > 0:
print(f"Reverse migration complete: Reverted {updated_count} dummy EPG source(s) to old placeholder names.")
class Migration(migrations.Migration):
dependencies = [
('epg', '0019_alter_programdata_sub_title'),
]
operations = [
migrations.RunPython(migrate_time_placeholders, reverse_migration),
]

View file

@ -0,0 +1,18 @@
# Generated by Django 5.2.4 on 2025-12-05 15:24
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('epg', '0020_migrate_time_to_starttime_placeholders'),
]
operations = [
migrations.AddField(
model_name='epgsource',
name='priority',
field=models.PositiveIntegerField(default=0, help_text='Priority for EPG matching (higher numbers = higher priority). Used when multiple EPG sources have matching entries for a channel.'),
),
]

View file

@ -8,6 +8,7 @@ class EPGSource(models.Model):
SOURCE_TYPE_CHOICES = [
('xmltv', 'XMLTV URL'),
('schedules_direct', 'Schedules Direct API'),
('dummy', 'Custom Dummy EPG'),
]
STATUS_IDLE = 'idle'
@ -28,7 +29,7 @@ class EPGSource(models.Model):
name = models.CharField(max_length=255, unique=True)
source_type = models.CharField(max_length=20, choices=SOURCE_TYPE_CHOICES)
url = models.URLField(blank=True, null=True) # For XMLTV
url = models.URLField(max_length=1000, blank=True, null=True) # For XMLTV
api_key = models.CharField(max_length=255, blank=True, null=True) # For Schedules Direct
is_active = models.BooleanField(default=True)
file_path = models.CharField(max_length=1024, blank=True, null=True)
@ -38,6 +39,16 @@ class EPGSource(models.Model):
refresh_task = models.ForeignKey(
PeriodicTask, on_delete=models.SET_NULL, null=True, blank=True
)
custom_properties = models.JSONField(
default=dict,
blank=True,
null=True,
help_text="Custom properties for dummy EPG configuration (regex patterns, timezone, duration, etc.)"
)
priority = models.PositiveIntegerField(
default=0,
help_text="Priority for EPG matching (higher numbers = higher priority). Used when multiple EPG sources have matching entries for a channel."
)
status = models.CharField(
max_length=20,
choices=STATUS_CHOICES,
@ -127,6 +138,7 @@ class EPGData(models.Model):
# and a name (which might simply be the tvg_id if no real channel exists).
tvg_id = models.CharField(max_length=255, null=True, blank=True, db_index=True)
name = models.CharField(max_length=255)
icon_url = models.URLField(max_length=500, null=True, blank=True)
epg_source = models.ForeignKey(
EPGSource,
on_delete=models.CASCADE,
@ -147,10 +159,10 @@ class ProgramData(models.Model):
start_time = models.DateTimeField()
end_time = models.DateTimeField()
title = models.CharField(max_length=255)
sub_title = models.CharField(max_length=255, blank=True, null=True)
sub_title = models.TextField(blank=True, null=True)
description = models.TextField(blank=True, null=True)
tvg_id = models.CharField(max_length=255, null=True, blank=True)
custom_properties = models.TextField(null=True, blank=True)
custom_properties = models.JSONField(default=dict, blank=True, null=True)
def __str__(self):
return f"{self.title} ({self.start_time} - {self.end_time})"

View file

@ -1,10 +1,17 @@
from core.utils import validate_flexible_url
from rest_framework import serializers
from .models import EPGSource, EPGData, ProgramData
from apps.channels.models import Channel
class EPGSourceSerializer(serializers.ModelSerializer):
epg_data_ids = serializers.SerializerMethodField()
epg_data_count = serializers.SerializerMethodField()
read_only_fields = ['created_at', 'updated_at']
url = serializers.CharField(
required=False,
allow_blank=True,
allow_null=True,
validators=[validate_flexible_url]
)
class Meta:
model = EPGSource
@ -17,15 +24,18 @@ class EPGSourceSerializer(serializers.ModelSerializer):
'is_active',
'file_path',
'refresh_interval',
'priority',
'status',
'last_message',
'created_at',
'updated_at',
'epg_data_ids'
'custom_properties',
'epg_data_count'
]
def get_epg_data_ids(self, obj):
return list(obj.epgs.values_list('id', flat=True))
def get_epg_data_count(self, obj):
"""Return the count of EPG data entries instead of all IDs to prevent large payloads"""
return obj.epgs.count()
class ProgramDataSerializer(serializers.ModelSerializer):
class Meta:
@ -45,5 +55,6 @@ class EPGDataSerializer(serializers.ModelSerializer):
'id',
'tvg_id',
'name',
'icon_url',
'epg_source',
]

View file

@ -1,9 +1,9 @@
from django.db.models.signals import post_save, post_delete, pre_save
from django.dispatch import receiver
from .models import EPGSource
from .models import EPGSource, EPGData
from .tasks import refresh_epg_data, delete_epg_refresh_task_by_id
from django_celery_beat.models import PeriodicTask, IntervalSchedule
from core.utils import is_protected_path
from core.utils import is_protected_path, send_websocket_update
import json
import logging
import os
@ -12,15 +12,77 @@ logger = logging.getLogger(__name__)
@receiver(post_save, sender=EPGSource)
def trigger_refresh_on_new_epg_source(sender, instance, created, **kwargs):
# Trigger refresh only if the source is newly created and active
if created and instance.is_active:
# Trigger refresh only if the source is newly created, active, and not a dummy EPG
if created and instance.is_active and instance.source_type != 'dummy':
refresh_epg_data.delay(instance.id)
@receiver(post_save, sender=EPGSource)
def create_dummy_epg_data(sender, instance, created, **kwargs):
"""
Automatically create EPGData for dummy EPG sources when they are created.
This allows channels to be assigned to dummy EPGs immediately without
requiring a refresh first.
"""
if instance.source_type == 'dummy':
# Ensure dummy EPGs always have idle status and no status message
if instance.status != EPGSource.STATUS_IDLE or instance.last_message:
instance.status = EPGSource.STATUS_IDLE
instance.last_message = None
instance.save(update_fields=['status', 'last_message'])
# Create a URL-friendly tvg_id from the dummy EPG name
# Replace spaces and special characters with underscores
friendly_tvg_id = instance.name.replace(' ', '_').replace('-', '_')
# Remove any characters that aren't alphanumeric or underscores
friendly_tvg_id = ''.join(c for c in friendly_tvg_id if c.isalnum() or c == '_')
# Convert to lowercase for consistency
friendly_tvg_id = friendly_tvg_id.lower()
# Prefix with 'dummy_' to make it clear this is a dummy EPG
friendly_tvg_id = f"dummy_{friendly_tvg_id}"
# Create or update the EPGData record
epg_data, data_created = EPGData.objects.get_or_create(
tvg_id=friendly_tvg_id,
epg_source=instance,
defaults={
'name': instance.name,
'icon_url': None
}
)
# Update name if it changed and record already existed
if not data_created and epg_data.name != instance.name:
epg_data.name = instance.name
epg_data.save(update_fields=['name'])
if data_created:
logger.info(f"Auto-created EPGData for dummy EPG source: {instance.name} (ID: {instance.id})")
# Send websocket update to notify frontend that EPG data has been created
# This allows the channel form to immediately show the new dummy EPG without refreshing
send_websocket_update('updates', 'update', {
'type': 'epg_data_created',
'source_id': instance.id,
'source_name': instance.name,
'epg_data_id': epg_data.id
})
else:
logger.debug(f"EPGData already exists for dummy EPG source: {instance.name} (ID: {instance.id})")
@receiver(post_save, sender=EPGSource)
def create_or_update_refresh_task(sender, instance, **kwargs):
"""
Create or update a Celery Beat periodic task when an EPGSource is created/updated.
Skip creating tasks for dummy EPG sources as they don't need refreshing.
"""
# Skip task creation for dummy EPGs
if instance.source_type == 'dummy':
# If there's an existing task, disable it
if instance.refresh_task:
instance.refresh_task.enabled = False
instance.refresh_task.save(update_fields=['enabled'])
return
task_name = f"epg_source-refresh-{instance.id}"
interval, _ = IntervalSchedule.objects.get_or_create(
every=int(instance.refresh_interval),
@ -80,7 +142,14 @@ def delete_refresh_task(sender, instance, **kwargs):
def update_status_on_active_change(sender, instance, **kwargs):
"""
When an EPGSource's is_active field changes, update the status accordingly.
For dummy EPGs, always ensure status is idle and no status message.
"""
# Dummy EPGs should always be idle with no status message
if instance.source_type == 'dummy':
instance.status = EPGSource.STATUS_IDLE
instance.last_message = None
return
if instance.pk: # Only for existing records, not new ones
try:
# Get the current record from the database

View file

@ -24,11 +24,30 @@ from asgiref.sync import async_to_sync
from channels.layers import get_channel_layer
from .models import EPGSource, EPGData, ProgramData
from core.utils import acquire_task_lock, release_task_lock, send_websocket_update, cleanup_memory
from core.utils import acquire_task_lock, release_task_lock, send_websocket_update, cleanup_memory, log_system_event
logger = logging.getLogger(__name__)
def validate_icon_url_fast(icon_url, max_length=None):
"""
Fast validation for icon URLs during parsing.
Returns None if URL is too long, original URL otherwise.
If max_length is None, gets it dynamically from the EPGData model field.
"""
if max_length is None:
# Get max_length dynamically from the model field
max_length = EPGData._meta.get_field('icon_url').max_length
if icon_url and len(icon_url) > max_length:
logger.warning(f"Icon URL too long ({len(icon_url)} > {max_length}), skipping: {icon_url[:100]}...")
return None
return icon_url
MAX_EXTRACT_CHUNK_SIZE = 65536 # 64kb (base2)
def send_epg_update(source_id, action, progress, **kwargs):
"""Send WebSocket update about EPG download/parsing progress"""
# Start with the base data dictionary
@ -114,8 +133,9 @@ def delete_epg_refresh_task_by_id(epg_id):
@shared_task
def refresh_all_epg_data():
logger.info("Starting refresh_epg_data task.")
active_sources = EPGSource.objects.filter(is_active=True)
logger.debug(f"Found {active_sources.count()} active EPGSource(s).")
# Exclude dummy EPG sources from refresh - they don't need refreshing
active_sources = EPGSource.objects.filter(is_active=True).exclude(source_type='dummy')
logger.debug(f"Found {active_sources.count()} active EPGSource(s) (excluding dummy EPGs).")
for source in active_sources:
refresh_epg_data(source.id)
@ -161,6 +181,13 @@ def refresh_epg_data(source_id):
gc.collect()
return
# Skip refresh for dummy EPG sources - they don't need refreshing
if source.source_type == 'dummy':
logger.info(f"Skipping refresh for dummy EPG source {source.name} (ID: {source_id})")
release_task_lock('refresh_epg_data', source_id)
gc.collect()
return
# Continue with the normal processing...
logger.info(f"Processing EPGSource: {source.name} (type: {source.source_type})")
if source.source_type == 'xmltv':
@ -186,6 +213,12 @@ def refresh_epg_data(source_id):
fetch_schedules_direct(source)
source.save(update_fields=['updated_at'])
# After successful EPG refresh, evaluate DVR series rules to schedule new episodes
try:
from apps.channels.tasks import evaluate_series_rules
evaluate_series_rules.delay()
except Exception:
pass
except Exception as e:
logger.error(f"Error in refresh_epg_data for source {source_id}: {e}", exc_info=True)
try:
@ -253,11 +286,12 @@ def fetch_xmltv(source):
logger.info(f"Fetching XMLTV data from source: {source.name}")
try:
# Get default user agent from settings
default_user_agent_setting = CoreSettings.objects.filter(key='default-user-agent').first()
stream_settings = CoreSettings.get_stream_settings()
user_agent = "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:138.0) Gecko/20100101 Firefox/138.0" # Fallback default
if default_user_agent_setting and default_user_agent_setting.value:
default_user_agent_id = stream_settings.get('default_user_agent')
if default_user_agent_id:
try:
user_agent_obj = UserAgent.objects.filter(id=int(default_user_agent_setting.value)).first()
user_agent_obj = UserAgent.objects.filter(id=int(default_user_agent_id)).first()
if user_agent_obj and user_agent_obj.user_agent:
user_agent = user_agent_obj.user_agent
logger.debug(f"Using default user agent: {user_agent}")
@ -641,7 +675,11 @@ def extract_compressed_file(file_path, output_path=None, delete_original=False):
# Reset file pointer and extract the content
gz_file.seek(0)
with open(extracted_path, 'wb') as out_file:
out_file.write(gz_file.read())
while True:
chunk = gz_file.read(MAX_EXTRACT_CHUNK_SIZE)
if not chunk or len(chunk) == 0:
break
out_file.write(chunk)
except Exception as e:
logger.error(f"Error extracting GZIP file: {e}", exc_info=True)
return None
@ -685,9 +723,13 @@ def extract_compressed_file(file_path, output_path=None, delete_original=False):
return None
# Extract the first XML file
xml_content = zip_file.read(xml_files[0])
with open(extracted_path, 'wb') as out_file:
out_file.write(xml_content)
with zip_file.open(xml_files[0], "r") as xml_file:
while True:
chunk = xml_file.read(MAX_EXTRACT_CHUNK_SIZE)
if not chunk or len(chunk) == 0:
break
out_file.write(chunk)
logger.info(f"Successfully extracted zip file to: {extracted_path}")
@ -815,6 +857,7 @@ def parse_channels_only(source):
processed_channels = 0
batch_size = 500 # Process in batches to limit memory usage
progress = 0 # Initialize progress variable here
icon_url_max_length = EPGData._meta.get_field('icon_url').max_length # Get max length for icon_url field
# Track memory at key points
if process:
@ -843,7 +886,7 @@ def parse_channels_only(source):
# Change iterparse to look for both channel and programme elements
logger.debug(f"Creating iterparse context for channels and programmes")
channel_parser = etree.iterparse(source_file, events=('end',), tag=('channel', 'programme'), remove_blank_text=True)
channel_parser = etree.iterparse(source_file, events=('end',), tag=('channel', 'programme'), remove_blank_text=True, recover=True)
if process:
logger.debug(f"[parse_channels_only] Memory after creating iterparse: {process.memory_info().rss / 1024 / 1024:.2f} MB")
@ -857,10 +900,15 @@ def parse_channels_only(source):
tvg_id = elem.get('id', '').strip()
if tvg_id:
display_name = None
icon_url = None
for child in elem:
if child.tag == 'display-name' and child.text:
if display_name is None and child.tag == 'display-name' and child.text:
display_name = child.text.strip()
break
elif child.tag == 'icon':
raw_icon_url = child.get('src', '').strip()
icon_url = validate_icon_url_fast(raw_icon_url, icon_url_max_length)
if display_name and icon_url:
break # No need to continue if we have both
if not display_name:
display_name = tvg_id
@ -878,17 +926,24 @@ def parse_channels_only(source):
epgs_to_create.append(EPGData(
tvg_id=tvg_id,
name=display_name,
icon_url=icon_url,
epg_source=source,
))
logger.debug(f"[parse_channels_only] Added new channel to epgs_to_create 1: {tvg_id} - {display_name}")
processed_channels += 1
continue
# We use the cached object to check if the name has changed
# We use the cached object to check if the name or icon_url has changed
epg_obj = existing_epgs[tvg_id]
needs_update = False
if epg_obj.name != display_name:
# Only update if the name actually changed
epg_obj.name = display_name
needs_update = True
if epg_obj.icon_url != icon_url:
epg_obj.icon_url = icon_url
needs_update = True
if needs_update:
epgs_to_update.append(epg_obj)
logger.debug(f"[parse_channels_only] Added channel to update to epgs_to_update: {tvg_id} - {display_name}")
else:
@ -899,6 +954,7 @@ def parse_channels_only(source):
epgs_to_create.append(EPGData(
tvg_id=tvg_id,
name=display_name,
icon_url=icon_url,
epg_source=source,
))
logger.debug(f"[parse_channels_only] Added new channel to epgs_to_create 2: {tvg_id} - {display_name}")
@ -921,7 +977,7 @@ def parse_channels_only(source):
logger.info(f"[parse_channels_only] Bulk updating {len(epgs_to_update)} EPG entries")
if process:
logger.info(f"[parse_channels_only] Memory before bulk_update: {process.memory_info().rss / 1024 / 1024:.2f} MB")
EPGData.objects.bulk_update(epgs_to_update, ["name"])
EPGData.objects.bulk_update(epgs_to_update, ["name", "icon_url"])
if process:
logger.info(f"[parse_channels_only] Memory after bulk_update: {process.memory_info().rss / 1024 / 1024:.2f} MB")
epgs_to_update = []
@ -988,7 +1044,7 @@ def parse_channels_only(source):
logger.debug(f"[parse_channels_only] Created final batch of {len(epgs_to_create)} EPG entries")
if epgs_to_update:
EPGData.objects.bulk_update(epgs_to_update, ["name"])
EPGData.objects.bulk_update(epgs_to_update, ["name", "icon_url"])
logger.debug(f"[parse_channels_only] Updated final batch of {len(epgs_to_update)} EPG entries")
if process:
logger.debug(f"[parse_channels_only] Memory after final batch creation: {process.memory_info().rss / 1024 / 1024:.2f} MB")
@ -1102,6 +1158,12 @@ def parse_programs_for_tvg_id(epg_id):
epg = EPGData.objects.get(id=epg_id)
epg_source = epg.epg_source
# Skip program parsing for dummy EPG sources - they don't have program data files
if epg_source.source_type == 'dummy':
logger.info(f"Skipping program parsing for dummy EPG source {epg_source.name} (ID: {epg_id})")
release_task_lock('parse_epg_programs', epg_id)
return
if not Channel.objects.filter(epg_data=epg).exists():
logger.info(f"No channels matched to EPG {epg.tvg_id}")
release_task_lock('parse_epg_programs', epg_id)
@ -1195,7 +1257,7 @@ def parse_programs_for_tvg_id(epg_id):
source_file = open(file_path, 'rb')
# Stream parse the file using lxml's iterparse
program_parser = etree.iterparse(source_file, events=('end',), tag='programme', remove_blank_text=True)
program_parser = etree.iterparse(source_file, events=('end',), tag='programme', remove_blank_text=True, recover=True)
for _, elem in program_parser:
if elem.get('channel') == epg.tvg_id:
@ -1224,10 +1286,7 @@ def parse_programs_for_tvg_id(epg_id):
if custom_props:
logger.trace(f"Number of custom properties: {len(custom_props)}")
try:
custom_properties_json = json.dumps(custom_props)
except Exception as e:
logger.error(f"Error serializing custom properties to JSON: {e}", exc_info=True)
custom_properties_json = custom_props
programs_to_create.append(ProgramData(
epg=epg,
@ -1335,11 +1394,23 @@ def parse_programs_for_tvg_id(epg_id):
def parse_programs_for_source(epg_source, tvg_id=None):
"""
Parse programs for all MAPPED channels from an EPG source in a single pass.
This is an optimized version that:
1. Only processes EPG entries that are actually mapped to channels
2. Parses the XML file ONCE instead of once per channel
3. Skips programmes for unmapped channels entirely during parsing
This dramatically improves performance when an EPG source has many channels
but only a fraction are mapped.
"""
# Send initial programs parsing notification
send_epg_update(epg_source.id, "parsing_programs", 0)
should_log_memory = False
process = None
initial_memory = 0
source_file = None
# Add memory tracking only in trace mode or higher
try:
@ -1359,91 +1430,251 @@ def parse_programs_for_source(epg_source, tvg_id=None):
should_log_memory = False
try:
# Process EPG entries in batches rather than all at once
batch_size = 20 # Process fewer channels at once to reduce memory usage
epg_count = EPGData.objects.filter(epg_source=epg_source).count()
# Only get EPG entries that are actually mapped to channels
mapped_epg_ids = set(
Channel.objects.filter(
epg_data__epg_source=epg_source,
epg_data__isnull=False
).values_list('epg_data_id', flat=True)
)
if epg_count == 0:
logger.info(f"No EPG entries found for source: {epg_source.name}")
# Update status - this is not an error, just no entries
if not mapped_epg_ids:
total_epg_count = EPGData.objects.filter(epg_source=epg_source).count()
logger.info(f"No channels mapped to any EPG entries from source: {epg_source.name} "
f"(source has {total_epg_count} EPG entries, 0 mapped)")
# Update status - this is not an error, just no mapped entries
epg_source.status = 'success'
epg_source.save(update_fields=['status'])
epg_source.last_message = f"No channels mapped to this EPG source ({total_epg_count} entries available)"
epg_source.save(update_fields=['status', 'last_message'])
send_epg_update(epg_source.id, "parsing_programs", 100, status="success")
return True
logger.info(f"Parsing programs for {epg_count} EPG entries from source: {epg_source.name}")
# Get the mapped EPG entries with their tvg_ids
mapped_epgs = EPGData.objects.filter(id__in=mapped_epg_ids).values('id', 'tvg_id')
tvg_id_to_epg_id = {epg['tvg_id']: epg['id'] for epg in mapped_epgs if epg['tvg_id']}
mapped_tvg_ids = set(tvg_id_to_epg_id.keys())
failed_entries = []
program_count = 0
channel_count = 0
updated_count = 0
processed = 0
# Process in batches using cursor-based approach to limit memory usage
last_id = 0
while True:
# Get a batch of EPG entries
batch_entries = list(EPGData.objects.filter(
epg_source=epg_source,
id__gt=last_id
).order_by('id')[:batch_size])
total_epg_count = EPGData.objects.filter(epg_source=epg_source).count()
mapped_count = len(mapped_tvg_ids)
if not batch_entries:
break # No more entries to process
logger.info(f"Parsing programs for {mapped_count} MAPPED channels from source: {epg_source.name} "
f"(skipping {total_epg_count - mapped_count} unmapped EPG entries)")
# Update last_id for next iteration
last_id = batch_entries[-1].id
# Get the file path
file_path = epg_source.extracted_file_path if epg_source.extracted_file_path else epg_source.file_path
if not file_path:
file_path = epg_source.get_cache_file()
# Process this batch
for epg in batch_entries:
if epg.tvg_id:
try:
result = parse_programs_for_tvg_id(epg.id)
if result == "Task already running":
logger.info(f"Program parse for {epg.id} already in progress, skipping")
# Check if the file exists
if not os.path.exists(file_path):
logger.error(f"EPG file not found at: {file_path}")
processed += 1
progress = min(95, int((processed / epg_count) * 100)) if epg_count > 0 else 50
send_epg_update(epg_source.id, "parsing_programs", progress)
except Exception as e:
logger.error(f"Error parsing programs for tvg_id={epg.tvg_id}: {e}", exc_info=True)
failed_entries.append(f"{epg.tvg_id}: {str(e)}")
if epg_source.url:
# Update the file path in the database
new_path = epg_source.get_cache_file()
logger.info(f"Updating file_path from '{file_path}' to '{new_path}'")
epg_source.file_path = new_path
epg_source.save(update_fields=['file_path'])
logger.info(f"Fetching new EPG data from URL: {epg_source.url}")
# Force garbage collection after each batch
batch_entries = None # Remove reference to help garbage collection
# Fetch new data before continuing
fetch_success = fetch_xmltv(epg_source)
if not fetch_success:
logger.error(f"Failed to fetch EPG data for source: {epg_source.name}")
epg_source.status = 'error'
epg_source.last_message = f"Failed to download EPG data"
epg_source.save(update_fields=['status', 'last_message'])
send_epg_update(epg_source.id, "parsing_programs", 100, status="error", error="Failed to download EPG file")
return False
# Update file_path with the new location
file_path = epg_source.extracted_file_path if epg_source.extracted_file_path else epg_source.file_path
else:
logger.error(f"No URL provided for EPG source {epg_source.name}, cannot fetch new data")
epg_source.status = 'error'
epg_source.last_message = f"No URL provided, cannot fetch EPG data"
epg_source.save(update_fields=['status', 'last_message'])
send_epg_update(epg_source.id, "parsing_programs", 100, status="error", error="No URL provided")
return False
# SINGLE PASS PARSING: Parse the XML file once and collect all programs in memory
# We parse FIRST, then do an atomic delete+insert to avoid race conditions
# where clients might see empty/partial EPG data during the transition
all_programs_to_create = []
programs_by_channel = {tvg_id: 0 for tvg_id in mapped_tvg_ids} # Track count per channel
total_programs = 0
skipped_programs = 0
last_progress_update = 0
try:
logger.debug(f"Opening file for single-pass parsing: {file_path}")
source_file = open(file_path, 'rb')
# Stream parse the file using lxml's iterparse
program_parser = etree.iterparse(source_file, events=('end',), tag='programme', remove_blank_text=True, recover=True)
for _, elem in program_parser:
channel_id = elem.get('channel')
# Skip programmes for unmapped channels immediately
if channel_id not in mapped_tvg_ids:
skipped_programs += 1
# Clear element to free memory
clear_element(elem)
continue
# This programme is for a mapped channel - process it
try:
start_time = parse_xmltv_time(elem.get('start'))
end_time = parse_xmltv_time(elem.get('stop'))
title = None
desc = None
sub_title = None
# Efficiently process child elements
for child in elem:
if child.tag == 'title':
title = child.text or 'No Title'
elif child.tag == 'desc':
desc = child.text or ''
elif child.tag == 'sub-title':
sub_title = child.text or ''
if not title:
title = 'No Title'
# Extract custom properties
custom_props = extract_custom_properties(elem)
custom_properties_json = custom_props if custom_props else None
epg_id = tvg_id_to_epg_id[channel_id]
all_programs_to_create.append(ProgramData(
epg_id=epg_id,
start_time=start_time,
end_time=end_time,
title=title,
description=desc,
sub_title=sub_title,
tvg_id=channel_id,
custom_properties=custom_properties_json
))
total_programs += 1
programs_by_channel[channel_id] += 1
# Clear the element to free memory
clear_element(elem)
# Send progress update (estimate based on programs processed)
if total_programs - last_progress_update >= 5000:
last_progress_update = total_programs
# Cap at 70% during parsing phase (save 30% for DB operations)
progress = min(70, 10 + int((total_programs / max(total_programs + 10000, 1)) * 60))
send_epg_update(epg_source.id, "parsing_programs", progress,
processed=total_programs, channels=mapped_count)
# Periodic garbage collection during parsing
if total_programs % 5000 == 0:
gc.collect()
except Exception as e:
logger.error(f"Error processing program for {channel_id}: {e}", exc_info=True)
clear_element(elem)
continue
except etree.XMLSyntaxError as xml_error:
logger.error(f"XML syntax error parsing program data: {xml_error}")
epg_source.status = EPGSource.STATUS_ERROR
epg_source.last_message = f"XML parsing error: {str(xml_error)}"
epg_source.save(update_fields=['status', 'last_message'])
send_epg_update(epg_source.id, "parsing_programs", 100, status="error", message=str(xml_error))
return False
except Exception as e:
logger.error(f"Error parsing XML for programs: {e}", exc_info=True)
raise
finally:
if source_file:
source_file.close()
source_file = None
# Now perform atomic delete + bulk insert
# This ensures clients never see empty/partial EPG data
logger.info(f"Parsed {total_programs} programs, performing atomic database update...")
send_epg_update(epg_source.id, "parsing_programs", 75, message="Updating database...")
batch_size = 1000
try:
with transaction.atomic():
# Delete existing programs for mapped EPGs
deleted_count = ProgramData.objects.filter(epg_id__in=mapped_epg_ids).delete()[0]
logger.debug(f"Deleted {deleted_count} existing programs")
# Clean up orphaned programs for unmapped EPG entries
unmapped_epg_ids = list(EPGData.objects.filter(
epg_source=epg_source
).exclude(id__in=mapped_epg_ids).values_list('id', flat=True))
if unmapped_epg_ids:
orphaned_count = ProgramData.objects.filter(epg_id__in=unmapped_epg_ids).delete()[0]
if orphaned_count > 0:
logger.info(f"Cleaned up {orphaned_count} orphaned programs for {len(unmapped_epg_ids)} unmapped EPG entries")
# Bulk insert all new programs in batches within the same transaction
for i in range(0, len(all_programs_to_create), batch_size):
batch = all_programs_to_create[i:i + batch_size]
ProgramData.objects.bulk_create(batch)
# Update progress during insertion
progress = 75 + int((i / len(all_programs_to_create)) * 20) if all_programs_to_create else 95
if i % (batch_size * 5) == 0:
send_epg_update(epg_source.id, "parsing_programs", min(95, progress),
message=f"Inserting programs... {i}/{len(all_programs_to_create)}")
logger.info(f"Atomic update complete: deleted {deleted_count}, inserted {total_programs} programs")
except Exception as db_error:
logger.error(f"Database error during atomic update: {db_error}", exc_info=True)
epg_source.status = EPGSource.STATUS_ERROR
epg_source.last_message = f"Database error: {str(db_error)}"
epg_source.save(update_fields=['status', 'last_message'])
send_epg_update(epg_source.id, "parsing_programs", 100, status="error", message=str(db_error))
return False
finally:
# Clear the large list to free memory
all_programs_to_create = None
gc.collect()
# If there were failures, include them in the message but continue
if failed_entries:
epg_source.status = EPGSource.STATUS_SUCCESS # Still mark as success if some processed
error_summary = f"Failed to parse {len(failed_entries)} of {epg_count} entries"
stats_summary = f"Processed {program_count} programs across {channel_count} channels. Updated: {updated_count}."
epg_source.last_message = f"{stats_summary} Warning: {error_summary}"
epg_source.updated_at = timezone.now()
epg_source.save(update_fields=['status', 'last_message', 'updated_at'])
# Count channels that actually got programs
channels_with_programs = sum(1 for count in programs_by_channel.values() if count > 0)
# Send completion notification with mixed status
send_epg_update(epg_source.id, "parsing_programs", 100,
status="success",
message=epg_source.last_message)
# Explicitly release memory of large lists before returning
del failed_entries
gc.collect()
return True
# If all successful, set a comprehensive success message
# Success message
epg_source.status = EPGSource.STATUS_SUCCESS
epg_source.last_message = f"Successfully processed {program_count} programs across {channel_count} channels. Updated: {updated_count}."
epg_source.last_message = (
f"Parsed {total_programs:,} programs for {channels_with_programs} channels "
f"(skipped {skipped_programs:,} programs for {total_epg_count - mapped_count} unmapped channels)"
)
epg_source.updated_at = timezone.now()
epg_source.save(update_fields=['status', 'last_message', 'updated_at'])
# Log system event for EPG refresh
log_system_event(
event_type='epg_refresh',
source_name=epg_source.name,
programs=total_programs,
channels=channels_with_programs,
skipped_programs=skipped_programs,
unmapped_channels=total_epg_count - mapped_count,
)
# Send completion notification with status
send_epg_update(epg_source.id, "parsing_programs", 100,
status="success",
message=epg_source.last_message)
message=epg_source.last_message,
updated_at=epg_source.updated_at.isoformat())
logger.info(f"Completed parsing all programs for source: {epg_source.name}")
logger.info(f"Completed parsing programs for source: {epg_source.name} - "
f"{total_programs:,} programs for {channels_with_programs} channels, "
f"skipped {skipped_programs:,} programs for unmapped channels")
return True
except Exception as e:
@ -1458,14 +1689,19 @@ def parse_programs_for_source(epg_source, tvg_id=None):
return False
finally:
# Final memory cleanup and tracking
if source_file:
try:
source_file.close()
except:
pass
source_file = None
# Explicitly release any remaining large data structures
failed_entries = None
program_count = None
channel_count = None
updated_count = None
processed = None
programs_to_create = None
programs_by_channel = None
mapped_epg_ids = None
mapped_tvg_ids = None
tvg_id_to_epg_id = None
gc.collect()
# Add comprehensive memory cleanup at the end
@ -1479,12 +1715,13 @@ def fetch_schedules_direct(source):
logger.info(f"Fetching Schedules Direct data from source: {source.name}")
try:
# Get default user agent from settings
default_user_agent_setting = CoreSettings.objects.filter(key='default-user-agent').first()
stream_settings = CoreSettings.get_stream_settings()
user_agent = "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:138.0) Gecko/20100101 Firefox/138.0" # Fallback default
default_user_agent_id = stream_settings.get('default_user_agent')
if default_user_agent_setting and default_user_agent_setting.value:
if default_user_agent_id:
try:
user_agent_obj = UserAgent.objects.filter(id=int(default_user_agent_setting.value)).first()
user_agent_obj = UserAgent.objects.filter(id=int(default_user_agent_id)).first()
if user_agent_obj and user_agent_obj.user_agent:
user_agent = user_agent_obj.user_agent
logger.debug(f"Using default user agent: {user_agent}")
@ -1899,3 +2136,20 @@ def detect_file_format(file_path=None, content=None):
# If we reach here, we couldn't reliably determine the format
return format_type, is_compressed, file_extension
def generate_dummy_epg(source):
"""
DEPRECATED: This function is no longer used.
Dummy EPG programs are now generated on-demand when they are requested
(during XMLTV export or EPG grid display), rather than being pre-generated
and stored in the database.
See: apps/output/views.py - generate_custom_dummy_programs()
This function remains for backward compatibility but should not be called.
"""
logger.warning(f"generate_dummy_epg() called for {source.name} but this function is deprecated. "
f"Dummy EPG programs are now generated on-demand.")
return True

View file

@ -17,7 +17,6 @@ from django.views import View
from django.utils.decorators import method_decorator
from django.contrib.auth.decorators import login_required
from django.views.decorators.csrf import csrf_exempt
from apps.m3u.models import M3UAccountProfile
# Configure logger
logger = logging.getLogger(__name__)
@ -60,43 +59,9 @@ class DiscoverAPIView(APIView):
base_url = request.build_absolute_uri(f'/{"/".join(uri_parts)}/').rstrip("/")
device = HDHRDevice.objects.first()
# Calculate tuner count from active profiles from active M3U accounts (excluding default "custom Default" profile)
profiles = M3UAccountProfile.objects.filter(
is_active=True,
m3u_account__is_active=True, # Only include profiles from enabled M3U accounts
).exclude(id=1)
# 1. Check if any profile has unlimited streams (max_streams=0)
has_unlimited = profiles.filter(max_streams=0).exists()
# 2. Calculate tuner count from limited profiles
limited_tuners = 0
if not has_unlimited:
limited_tuners = (
profiles.filter(max_streams__gt=0)
.aggregate(total=models.Sum("max_streams"))
.get("total", 0)
or 0
)
# 3. Add custom stream count to tuner count
custom_stream_count = Stream.objects.filter(is_custom=True).count()
logger.debug(f"Found {custom_stream_count} custom streams")
# 4. Calculate final tuner count
if has_unlimited:
# If there are unlimited profiles, start with 10 plus custom streams
tuner_count = 10 + custom_stream_count
else:
# Otherwise use the limited profile sum plus custom streams
tuner_count = limited_tuners + custom_stream_count
# 5. Ensure minimum of 1 tuners
tuner_count = max(1, tuner_count)
logger.debug(
f"Calculated tuner count: {tuner_count} (limited profiles: {limited_tuners}, custom streams: {custom_stream_count}, unlimited: {has_unlimited})"
)
# Calculate tuner count using centralized function
from apps.m3u.utils import calculate_tuner_count
tuner_count = calculate_tuner_count(minimum=1, unlimited_default=10)
# Create a unique DeviceID for the HDHomeRun device based on profile ID or a default value
device_ID = "12345678" # Default DeviceID

View file

@ -1,6 +1,8 @@
from django.contrib import admin
from django.utils.html import format_html
from .models import M3UAccount, M3UFilter, ServerGroup, UserAgent
from .models import M3UAccount, M3UFilter, ServerGroup, UserAgent, M3UAccountProfile
import json
class M3UFilterInline(admin.TabularInline):
model = M3UFilter
@ -8,50 +10,181 @@ class M3UFilterInline(admin.TabularInline):
verbose_name = "M3U Filter"
verbose_name_plural = "M3U Filters"
@admin.register(M3UAccount)
class M3UAccountAdmin(admin.ModelAdmin):
list_display = ('name', 'server_url', 'server_group', 'max_streams', 'is_active', 'user_agent_display', 'uploaded_file_link', 'created_at', 'updated_at')
list_filter = ('is_active', 'server_group')
search_fields = ('name', 'server_url', 'server_group__name')
list_display = (
"name",
"server_url",
"server_group",
"max_streams",
"priority",
"is_active",
"user_agent_display",
"uploaded_file_link",
"created_at",
"updated_at",
)
list_filter = ("is_active", "server_group")
search_fields = ("name", "server_url", "server_group__name")
inlines = [M3UFilterInline]
actions = ['activate_accounts', 'deactivate_accounts']
actions = ["activate_accounts", "deactivate_accounts"]
# Handle both ForeignKey and ManyToManyField cases for UserAgent
def user_agent_display(self, obj):
if hasattr(obj, 'user_agent'): # ForeignKey case
if hasattr(obj, "user_agent"): # ForeignKey case
return obj.user_agent.user_agent if obj.user_agent else "None"
elif hasattr(obj, 'user_agents'): # ManyToManyField case
elif hasattr(obj, "user_agents"): # ManyToManyField case
return ", ".join([ua.user_agent for ua in obj.user_agents.all()]) or "None"
return "None"
user_agent_display.short_description = "User Agent(s)"
def vod_enabled_display(self, obj):
"""Display whether VOD is enabled for this account"""
if obj.custom_properties:
custom_props = obj.custom_properties or {}
return "Yes" if custom_props.get('enable_vod', False) else "No"
return "No"
vod_enabled_display.short_description = "VOD Enabled"
vod_enabled_display.boolean = True
def uploaded_file_link(self, obj):
if obj.uploaded_file:
return format_html("<a href='{}' target='_blank'>Download M3U</a>", obj.uploaded_file.url)
return format_html(
"<a href='{}' target='_blank'>Download M3U</a>", obj.uploaded_file.url
)
return "No file uploaded"
uploaded_file_link.short_description = "Uploaded File"
@admin.action(description='Activate selected accounts')
@admin.action(description="Activate selected accounts")
def activate_accounts(self, request, queryset):
queryset.update(is_active=True)
@admin.action(description='Deactivate selected accounts')
@admin.action(description="Deactivate selected accounts")
def deactivate_accounts(self, request, queryset):
queryset.update(is_active=False)
# Add ManyToManyField for Django Admin (if applicable)
if hasattr(M3UAccount, 'user_agents'):
filter_horizontal = ('user_agents',) # Only for ManyToManyField
if hasattr(M3UAccount, "user_agents"):
filter_horizontal = ("user_agents",) # Only for ManyToManyField
@admin.register(M3UFilter)
class M3UFilterAdmin(admin.ModelAdmin):
list_display = ('m3u_account', 'filter_type', 'regex_pattern', 'exclude')
list_filter = ('filter_type', 'exclude')
search_fields = ('regex_pattern',)
ordering = ('m3u_account',)
list_display = ("m3u_account", "filter_type", "regex_pattern", "exclude")
list_filter = ("filter_type", "exclude")
search_fields = ("regex_pattern",)
ordering = ("m3u_account",)
@admin.register(ServerGroup)
class ServerGroupAdmin(admin.ModelAdmin):
list_display = ('name',)
search_fields = ('name',)
list_display = ("name",)
search_fields = ("name",)
@admin.register(M3UAccountProfile)
class M3UAccountProfileAdmin(admin.ModelAdmin):
list_display = (
"name",
"m3u_account",
"is_default",
"is_active",
"max_streams",
"current_viewers",
"account_status_display",
"account_expiration_display",
"last_refresh_display",
)
list_filter = ("is_active", "is_default", "m3u_account__account_type")
search_fields = ("name", "m3u_account__name")
readonly_fields = ("account_info_display",)
def account_status_display(self, obj):
"""Display account status from custom properties"""
status = obj.get_account_status()
if status:
# Create colored status display
color_map = {
'Active': 'green',
'Expired': 'red',
'Disabled': 'red',
'Banned': 'red',
}
color = color_map.get(status, 'black')
return format_html(
'<span style="color: {};">{}</span>',
color,
status
)
return "Unknown"
account_status_display.short_description = "Account Status"
def account_expiration_display(self, obj):
"""Display account expiration from custom properties"""
expiration = obj.get_account_expiration()
if expiration:
from datetime import datetime
if expiration < datetime.now():
return format_html(
'<span style="color: red;">{}</span>',
expiration.strftime('%Y-%m-%d %H:%M')
)
else:
return format_html(
'<span style="color: green;">{}</span>',
expiration.strftime('%Y-%m-%d %H:%M')
)
return "Unknown"
account_expiration_display.short_description = "Expires"
def last_refresh_display(self, obj):
"""Display last refresh time from custom properties"""
last_refresh = obj.get_last_refresh()
if last_refresh:
return last_refresh.strftime('%Y-%m-%d %H:%M:%S')
return "Never"
last_refresh_display.short_description = "Last Refresh"
def account_info_display(self, obj):
"""Display formatted account information from custom properties"""
if not obj.custom_properties:
return "No account information available"
html_parts = []
# User Info
user_info = obj.custom_properties.get('user_info', {})
if user_info:
html_parts.append("<h3>User Information:</h3>")
html_parts.append("<ul>")
for key, value in user_info.items():
if key == 'exp_date' and value:
try:
from datetime import datetime
exp_date = datetime.fromtimestamp(float(value))
value = exp_date.strftime('%Y-%m-%d %H:%M:%S')
except (ValueError, TypeError):
pass
html_parts.append(f"<li><strong>{key}:</strong> {value}</li>")
html_parts.append("</ul>")
# Server Info
server_info = obj.custom_properties.get('server_info', {})
if server_info:
html_parts.append("<h3>Server Information:</h3>")
html_parts.append("<ul>")
for key, value in server_info.items():
html_parts.append(f"<li><strong>{key}:</strong> {value}</li>")
html_parts.append("</ul>")
# Last Refresh
last_refresh = obj.custom_properties.get('last_refresh')
if last_refresh:
html_parts.append(f"<p><strong>Last Refresh:</strong> {last_refresh}</p>")
return format_html(''.join(html_parts)) if html_parts else "No account information available"
account_info_display.short_description = "Account Information"

View file

@ -1,18 +1,44 @@
from django.urls import path, include
from rest_framework.routers import DefaultRouter
from .api_views import M3UAccountViewSet, M3UFilterViewSet, ServerGroupViewSet, RefreshM3UAPIView, RefreshSingleM3UAPIView, UserAgentViewSet, M3UAccountProfileViewSet
from .api_views import (
M3UAccountViewSet,
M3UFilterViewSet,
ServerGroupViewSet,
RefreshM3UAPIView,
RefreshSingleM3UAPIView,
RefreshAccountInfoAPIView,
UserAgentViewSet,
M3UAccountProfileViewSet,
)
app_name = 'm3u'
app_name = "m3u"
router = DefaultRouter()
router.register(r'accounts', M3UAccountViewSet, basename='m3u-account')
router.register(r'accounts\/(?P<account_id>\d+)\/profiles', M3UAccountProfileViewSet, basename='m3u-account-profiles')
router.register(r'filters', M3UFilterViewSet, basename='m3u-filter')
router.register(r'server-groups', ServerGroupViewSet, basename='server-group')
router.register(r"accounts", M3UAccountViewSet, basename="m3u-account")
router.register(
r"accounts\/(?P<account_id>\d+)\/profiles",
M3UAccountProfileViewSet,
basename="m3u-account-profiles",
)
router.register(
r"accounts\/(?P<account_id>\d+)\/filters",
M3UFilterViewSet,
basename="m3u-filters",
)
router.register(r"server-groups", ServerGroupViewSet, basename="server-group")
urlpatterns = [
path('refresh/', RefreshM3UAPIView.as_view(), name='m3u_refresh'),
path('refresh/<int:account_id>/', RefreshSingleM3UAPIView.as_view(), name='m3u_refresh_single'),
path("refresh/", RefreshM3UAPIView.as_view(), name="m3u_refresh"),
path(
"refresh/<int:account_id>/",
RefreshSingleM3UAPIView.as_view(),
name="m3u_refresh_single",
),
path(
"refresh-account-info/<int:profile_id>/",
RefreshAccountInfoAPIView.as_view(),
name="m3u_refresh_account_info",
),
]
urlpatterns += router.urls

View file

@ -21,6 +21,7 @@ from .models import M3UAccount, M3UFilter, ServerGroup, M3UAccountProfile
from core.models import UserAgent
from apps.channels.models import ChannelGroupM3UAccount
from core.serializers import UserAgentSerializer
from apps.vod.models import M3UVODCategoryRelation
from .serializers import (
M3UAccountSerializer,
@ -29,9 +30,8 @@ from .serializers import (
M3UAccountProfileSerializer,
)
from .tasks import refresh_single_m3u_account, refresh_m3u_accounts
from django.core.files.storage import default_storage
from django.core.files.base import ContentFile
from .tasks import refresh_single_m3u_account, refresh_m3u_accounts, refresh_account_info
import json
class M3UAccountViewSet(viewsets.ModelViewSet):
@ -78,15 +78,37 @@ class M3UAccountViewSet(viewsets.ModelViewSet):
# Now call super().create() to create the instance
response = super().create(request, *args, **kwargs)
print(response.data.get("account_type"))
if response.data.get("account_type") == M3UAccount.Types.XC:
refresh_m3u_groups(response.data.get("id"))
account_type = response.data.get("account_type")
account_id = response.data.get("id")
# Notify frontend that a new playlist was created
from core.utils import send_websocket_update
send_websocket_update('updates', 'update', {
'type': 'playlist_created',
'playlist_id': account_id
})
if account_type == M3UAccount.Types.XC:
refresh_m3u_groups(account_id)
# Check if VOD is enabled
enable_vod = request.data.get("enable_vod", False)
if enable_vod:
from apps.vod.tasks import refresh_categories
refresh_categories(account_id)
# After the instance is created, return the response
return response
def update(self, request, *args, **kwargs):
instance = self.get_object()
old_vod_enabled = False
# Check current VOD setting
if instance.custom_properties:
custom_props = instance.custom_properties or {}
old_vod_enabled = custom_props.get("enable_vod", False)
# Handle file upload first, if any
file_path = None
@ -122,6 +144,58 @@ class M3UAccountViewSet(viewsets.ModelViewSet):
# Now call super().update() to update the instance
response = super().update(request, *args, **kwargs)
# Check if VOD setting changed and trigger refresh if needed
new_vod_enabled = request.data.get("enable_vod", old_vod_enabled)
if (
instance.account_type == M3UAccount.Types.XC
and not old_vod_enabled
and new_vod_enabled
):
# Create Uncategorized categories immediately so they're available in the UI
from apps.vod.models import VODCategory, M3UVODCategoryRelation
# Create movie Uncategorized category
movie_category, _ = VODCategory.objects.get_or_create(
name="Uncategorized",
category_type="movie",
defaults={}
)
# Create series Uncategorized category
series_category, _ = VODCategory.objects.get_or_create(
name="Uncategorized",
category_type="series",
defaults={}
)
# Create relations for both categories (disabled by default until first refresh)
account_custom_props = instance.custom_properties or {}
auto_enable_new = account_custom_props.get("auto_enable_new_groups_vod", True)
M3UVODCategoryRelation.objects.get_or_create(
category=movie_category,
m3u_account=instance,
defaults={
'enabled': auto_enable_new,
'custom_properties': {}
}
)
M3UVODCategoryRelation.objects.get_or_create(
category=series_category,
m3u_account=instance,
defaults={
'enabled': auto_enable_new,
'custom_properties': {}
}
)
# Trigger full VOD refresh
from apps.vod.tasks import refresh_vod_content
refresh_vod_content.delay(instance.id)
# After the instance is updated, return the response
return response
@ -143,11 +217,49 @@ class M3UAccountViewSet(viewsets.ModelViewSet):
# Continue with regular partial update
return super().partial_update(request, *args, **kwargs)
@action(detail=True, methods=["post"], url_path="refresh-vod")
def refresh_vod(self, request, pk=None):
"""Trigger VOD content refresh for XtreamCodes accounts"""
account = self.get_object()
if account.account_type != M3UAccount.Types.XC:
return Response(
{"error": "VOD refresh is only available for XtreamCodes accounts"},
status=status.HTTP_400_BAD_REQUEST,
)
# Check if VOD is enabled
vod_enabled = False
if account.custom_properties:
custom_props = account.custom_properties or {}
vod_enabled = custom_props.get("enable_vod", False)
if not vod_enabled:
return Response(
{"error": "VOD is not enabled for this account"},
status=status.HTTP_400_BAD_REQUEST,
)
try:
from apps.vod.tasks import refresh_vod_content
refresh_vod_content.delay(account.id)
return Response(
{"message": f"VOD refresh initiated for account {account.name}"},
status=status.HTTP_202_ACCEPTED,
)
except Exception as e:
return Response(
{"error": f"Failed to initiate VOD refresh: {str(e)}"},
status=status.HTTP_500_INTERNAL_SERVER_ERROR,
)
@action(detail=True, methods=["patch"], url_path="group-settings")
def update_group_settings(self, request, pk=None):
"""Update auto channel sync settings for M3U account groups"""
account = self.get_object()
group_settings = request.data.get("group_settings", [])
category_settings = request.data.get("category_settings", [])
try:
for setting in group_settings:
@ -165,11 +277,22 @@ class M3UAccountViewSet(viewsets.ModelViewSet):
"enabled": enabled,
"auto_channel_sync": auto_sync,
"auto_sync_channel_start": sync_start,
"custom_properties": (
custom_properties
if isinstance(custom_properties, str)
else json.dumps(custom_properties)
),
"custom_properties": custom_properties,
},
)
for setting in category_settings:
category_id = setting.get("id")
enabled = setting.get("enabled", True)
custom_properties = setting.get("custom_properties", {})
if category_id:
M3UVODCategoryRelation.objects.update_or_create(
category_id=category_id,
m3u_account=account,
defaults={
"enabled": enabled,
"custom_properties": custom_properties,
},
)
@ -183,8 +306,6 @@ class M3UAccountViewSet(viewsets.ModelViewSet):
class M3UFilterViewSet(viewsets.ModelViewSet):
"""Handles CRUD operations for M3U filters"""
queryset = M3UFilter.objects.all()
serializer_class = M3UFilterSerializer
@ -194,6 +315,23 @@ class M3UFilterViewSet(viewsets.ModelViewSet):
except KeyError:
return [Authenticated()]
def get_queryset(self):
m3u_account_id = self.kwargs["account_id"]
return M3UFilter.objects.filter(m3u_account_id=m3u_account_id)
def perform_create(self, serializer):
# Get the account ID from the URL
account_id = self.kwargs["account_id"]
# # Get the M3UAccount instance for the account_id
# m3u_account = M3UAccount.objects.get(id=account_id)
# Save the 'm3u_account' in the serializer context
serializer.context["m3u_account"] = account_id
# Perform the actual save
serializer.save(m3u_account_id=account_id)
class ServerGroupViewSet(viewsets.ModelViewSet):
"""Handles CRUD operations for Server Groups"""
@ -257,6 +395,54 @@ class RefreshSingleM3UAPIView(APIView):
)
class RefreshAccountInfoAPIView(APIView):
"""Triggers account info refresh for a single M3U account"""
def get_permissions(self):
try:
return [
perm() for perm in permission_classes_by_method[self.request.method]
]
except KeyError:
return [Authenticated()]
@swagger_auto_schema(
operation_description="Triggers a refresh of account information for a specific M3U profile",
responses={202: "Account info refresh initiated", 400: "Profile not found or not XtreamCodes"},
)
def post(self, request, profile_id, format=None):
try:
from .models import M3UAccountProfile
profile = M3UAccountProfile.objects.get(id=profile_id)
account = profile.m3u_account
if account.account_type != M3UAccount.Types.XC:
return Response(
{
"success": False,
"error": "Account info refresh is only available for XtreamCodes accounts",
},
status=status.HTTP_400_BAD_REQUEST,
)
refresh_account_info.delay(profile_id)
return Response(
{
"success": True,
"message": f"Account info refresh initiated for profile {profile.name}.",
},
status=status.HTTP_202_ACCEPTED,
)
except M3UAccountProfile.DoesNotExist:
return Response(
{
"success": False,
"error": "Profile not found",
},
status=status.HTTP_404_NOT_FOUND,
)
class UserAgentViewSet(viewsets.ModelViewSet):
"""Handles CRUD operations for User Agents"""

View file

@ -4,6 +4,13 @@ from .models import M3UAccount, M3UFilter
import re
class M3UAccountForm(forms.ModelForm):
enable_vod = forms.BooleanField(
required=False,
initial=False,
label="Enable VOD Content",
help_text="Parse and import VOD (movies/series) content for XtreamCodes accounts"
)
class Meta:
model = M3UAccount
fields = [
@ -13,8 +20,34 @@ class M3UAccountForm(forms.ModelForm):
'server_group',
'max_streams',
'is_active',
'enable_vod',
]
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
# Set initial value for enable_vod from custom_properties
if self.instance and self.instance.custom_properties:
custom_props = self.instance.custom_properties or {}
self.fields['enable_vod'].initial = custom_props.get('enable_vod', False)
def save(self, commit=True):
instance = super().save(commit=False)
# Handle enable_vod field
enable_vod = self.cleaned_data.get('enable_vod', False)
# Parse existing custom_properties
custom_props = instance.custom_properties or {}
# Update VOD preference
custom_props['enable_vod'] = enable_vod
instance.custom_properties = custom_props
if commit:
instance.save()
return instance
def clean_uploaded_file(self):
uploaded_file = self.cleaned_data.get('uploaded_file')
if uploaded_file:

View file

@ -0,0 +1,18 @@
# Generated by Django 5.1.6 on 2025-07-22 21:16
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('m3u', '0012_alter_m3uaccount_refresh_interval'),
]
operations = [
migrations.AlterField(
model_name='m3ufilter',
name='filter_type',
field=models.CharField(choices=[('group', 'Group'), ('name', 'Stream Name'), ('url', 'Stream URL')], default='group', help_text='Filter based on either group title or stream name.', max_length=50),
),
]

View file

@ -0,0 +1,22 @@
# Generated by Django 5.1.6 on 2025-07-31 17:14
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('m3u', '0013_alter_m3ufilter_filter_type'),
]
operations = [
migrations.AlterModelOptions(
name='m3ufilter',
options={'ordering': ['order']},
),
migrations.AddField(
model_name='m3ufilter',
name='order',
field=models.PositiveIntegerField(default=0),
),
]

View file

@ -0,0 +1,22 @@
# Generated by Django 5.2.4 on 2025-08-02 16:06
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('m3u', '0014_alter_m3ufilter_options_m3ufilter_order'),
]
operations = [
migrations.AlterModelOptions(
name='m3ufilter',
options={},
),
migrations.AddField(
model_name='m3ufilter',
name='custom_properties',
field=models.TextField(blank=True, null=True),
),
]

View file

@ -0,0 +1,18 @@
# Generated by Django 5.2.4 on 2025-08-20 22:35
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('m3u', '0015_alter_m3ufilter_options_m3ufilter_custom_properties'),
]
operations = [
migrations.AddField(
model_name='m3uaccount',
name='priority',
field=models.PositiveIntegerField(default=0, help_text='Priority for VOD provider selection (higher numbers = higher priority). Used when multiple providers offer the same content.'),
),
]

View file

@ -0,0 +1,28 @@
# Generated by Django 5.2.4 on 2025-09-02 15:19
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('m3u', '0016_m3uaccount_priority'),
]
operations = [
migrations.AlterField(
model_name='m3uaccount',
name='custom_properties',
field=models.JSONField(blank=True, default=dict, null=True),
),
migrations.AlterField(
model_name='m3uaccount',
name='server_url',
field=models.URLField(blank=True, help_text='The base URL of the M3U server (optional if a file is uploaded)', max_length=1000, null=True),
),
migrations.AlterField(
model_name='m3ufilter',
name='custom_properties',
field=models.JSONField(blank=True, default=dict, null=True),
),
]

View file

@ -0,0 +1,18 @@
# Generated by Django 5.2.4 on 2025-09-09 20:57
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('m3u', '0017_alter_m3uaccount_custom_properties_and_more'),
]
operations = [
migrations.AddField(
model_name='m3uaccountprofile',
name='custom_properties',
field=models.JSONField(blank=True, default=dict, help_text='Custom properties for storing account information from provider (e.g., XC account details, expiration dates)', null=True),
),
]

View file

@ -29,6 +29,7 @@ class M3UAccount(models.Model):
max_length=255, unique=True, help_text="Unique name for this M3U account"
)
server_url = models.URLField(
max_length=1000,
blank=True,
null=True,
help_text="The base URL of the M3U server (optional if a file is uploaded)",
@ -85,7 +86,7 @@ class M3UAccount(models.Model):
account_type = models.CharField(choices=Types.choices, default=Types.STADNARD)
username = models.CharField(max_length=255, null=True, blank=True)
password = models.CharField(max_length=255, null=True, blank=True)
custom_properties = models.TextField(null=True, blank=True)
custom_properties = models.JSONField(default=dict, blank=True, null=True)
refresh_interval = models.IntegerField(default=0)
refresh_task = models.ForeignKey(
PeriodicTask, on_delete=models.SET_NULL, null=True, blank=True
@ -94,6 +95,10 @@ class M3UAccount(models.Model):
default=7,
help_text="Number of days after which a stream will be removed if not seen in the M3U source.",
)
priority = models.PositiveIntegerField(
default=0,
help_text="Priority for VOD provider selection (higher numbers = higher priority). Used when multiple providers offer the same content.",
)
def __str__(self):
return self.name
@ -155,9 +160,11 @@ class M3UFilter(models.Model):
"""Defines filters for M3U accounts based on stream name or group title."""
FILTER_TYPE_CHOICES = (
("group", "Group Title"),
("group", "Group"),
("name", "Stream Name"),
("url", "Stream URL"),
)
m3u_account = models.ForeignKey(
M3UAccount,
on_delete=models.CASCADE,
@ -177,6 +184,8 @@ class M3UFilter(models.Model):
default=True,
help_text="If True, matching items are excluded; if False, only matches are included.",
)
order = models.PositiveIntegerField(default=0)
custom_properties = models.JSONField(default=dict, blank=True, null=True)
def applies_to(self, stream_name, group_name):
target = group_name if self.filter_type == "group" else stream_name
@ -226,9 +235,6 @@ class ServerGroup(models.Model):
return self.name
from django.db import models
class M3UAccountProfile(models.Model):
"""Represents a profile associated with an M3U Account."""
@ -257,6 +263,12 @@ class M3UAccountProfile(models.Model):
max_length=255,
)
current_viewers = models.PositiveIntegerField(default=0)
custom_properties = models.JSONField(
default=dict,
blank=True,
null=True,
help_text="Custom properties for storing account information from provider (e.g., XC account details, expiration dates)"
)
class Meta:
constraints = [
@ -268,6 +280,70 @@ class M3UAccountProfile(models.Model):
def __str__(self):
return f"{self.name} ({self.m3u_account.name})"
def get_account_expiration(self):
"""Get account expiration date from custom properties if available"""
if not self.custom_properties:
return None
user_info = self.custom_properties.get('user_info', {})
exp_date = user_info.get('exp_date')
if exp_date:
try:
from datetime import datetime
# XC exp_date is typically a Unix timestamp
if isinstance(exp_date, (int, float)):
return datetime.fromtimestamp(exp_date)
elif isinstance(exp_date, str):
# Try to parse as timestamp first, then as ISO date
try:
return datetime.fromtimestamp(float(exp_date))
except ValueError:
return datetime.fromisoformat(exp_date)
except (ValueError, TypeError):
pass
return None
def get_account_status(self):
"""Get account status from custom properties if available"""
if not self.custom_properties:
return None
user_info = self.custom_properties.get('user_info', {})
return user_info.get('status')
def get_max_connections(self):
"""Get maximum connections from custom properties if available"""
if not self.custom_properties:
return None
user_info = self.custom_properties.get('user_info', {})
return user_info.get('max_connections')
def get_active_connections(self):
"""Get active connections from custom properties if available"""
if not self.custom_properties:
return None
user_info = self.custom_properties.get('user_info', {})
return user_info.get('active_cons')
def get_last_refresh(self):
"""Get last refresh timestamp from custom properties if available"""
if not self.custom_properties:
return None
last_refresh = self.custom_properties.get('last_refresh')
if last_refresh:
try:
from datetime import datetime
return datetime.fromisoformat(last_refresh)
except (ValueError, TypeError):
pass
return None
@receiver(models.signals.post_save, sender=M3UAccount)
def create_profile_for_m3u_account(sender, instance, created, **kwargs):

View file

@ -1,13 +1,14 @@
from rest_framework import serializers
from core.utils import validate_flexible_url
from rest_framework import serializers, status
from rest_framework.response import Response
from .models import M3UAccount, M3UFilter, ServerGroup, M3UAccountProfile
from core.models import UserAgent
from apps.channels.models import ChannelGroup, ChannelGroupM3UAccount
from apps.channels.serializers import (
ChannelGroupM3UAccountSerializer,
ChannelGroupSerializer,
)
import logging
import json
logger = logging.getLogger(__name__)
@ -15,14 +16,30 @@ logger = logging.getLogger(__name__)
class M3UFilterSerializer(serializers.ModelSerializer):
"""Serializer for M3U Filters"""
channel_groups = ChannelGroupM3UAccountSerializer(source="m3u_account", many=True)
class Meta:
model = M3UFilter
fields = ["id", "filter_type", "regex_pattern", "exclude", "channel_groups"]
fields = [
"id",
"filter_type",
"regex_pattern",
"exclude",
"order",
"custom_properties",
]
class M3UAccountProfileSerializer(serializers.ModelSerializer):
account = serializers.SerializerMethodField()
def get_account(self, obj):
"""Include basic account information for frontend use"""
return {
'id': obj.m3u_account.id,
'name': obj.m3u_account.name,
'account_type': obj.m3u_account.account_type,
'is_xtream_codes': obj.m3u_account.account_type == 'XC'
}
class Meta:
model = M3UAccountProfile
fields = [
@ -34,8 +51,14 @@ class M3UAccountProfileSerializer(serializers.ModelSerializer):
"current_viewers",
"search_pattern",
"replace_pattern",
"custom_properties",
"account",
]
read_only_fields = ["id"]
read_only_fields = ["id", "account"]
extra_kwargs = {
'search_pattern': {'required': False, 'allow_blank': True},
'replace_pattern': {'required': False, 'allow_blank': True},
}
def create(self, validated_data):
m3u_account = self.context.get("m3u_account")
@ -45,9 +68,39 @@ class M3UAccountProfileSerializer(serializers.ModelSerializer):
return super().create(validated_data)
def validate(self, data):
"""Custom validation to handle default profiles"""
# For updates to existing instances
if self.instance and self.instance.is_default:
# For default profiles, search_pattern and replace_pattern are not required
# and we don't want to validate them since they shouldn't be changed
return data
# For non-default profiles or new profiles, ensure required fields are present
if not data.get('search_pattern'):
raise serializers.ValidationError({
'search_pattern': ['This field is required for non-default profiles.']
})
if not data.get('replace_pattern'):
raise serializers.ValidationError({
'replace_pattern': ['This field is required for non-default profiles.']
})
return data
def update(self, instance, validated_data):
if instance.is_default:
raise serializers.ValidationError("Default profiles cannot be modified.")
# For default profiles, only allow updating name and custom_properties (for notes)
allowed_fields = {'name', 'custom_properties'}
# Remove any fields that aren't allowed for default profiles
disallowed_fields = set(validated_data.keys()) - allowed_fields
if disallowed_fields:
raise serializers.ValidationError(
f"Default profiles can only modify name and notes. "
f"Cannot modify: {', '.join(disallowed_fields)}"
)
return super().update(instance, validated_data)
def destroy(self, request, *args, **kwargs):
@ -63,7 +116,7 @@ class M3UAccountProfileSerializer(serializers.ModelSerializer):
class M3UAccountSerializer(serializers.ModelSerializer):
"""Serializer for M3U Account"""
filters = M3UFilterSerializer(many=True, read_only=True)
filters = serializers.SerializerMethodField()
# Include user_agent as a mandatory field using its primary key.
user_agent = serializers.PrimaryKeyRelatedField(
queryset=UserAgent.objects.all(),
@ -76,6 +129,16 @@ class M3UAccountSerializer(serializers.ModelSerializer):
channel_groups = ChannelGroupM3UAccountSerializer(
source="channel_group", many=True, required=False
)
server_url = serializers.CharField(
required=False,
allow_blank=True,
allow_null=True,
validators=[validate_flexible_url],
)
enable_vod = serializers.BooleanField(required=False, write_only=True)
auto_enable_new_groups_live = serializers.BooleanField(required=False, write_only=True)
auto_enable_new_groups_vod = serializers.BooleanField(required=False, write_only=True)
auto_enable_new_groups_series = serializers.BooleanField(required=False, write_only=True)
class Meta:
model = M3UAccount
@ -100,8 +163,13 @@ class M3UAccountSerializer(serializers.ModelSerializer):
"username",
"password",
"stale_stream_days",
"priority",
"status",
"last_message",
"enable_vod",
"auto_enable_new_groups_live",
"auto_enable_new_groups_vod",
"auto_enable_new_groups_series",
]
extra_kwargs = {
"password": {
@ -110,7 +178,40 @@ class M3UAccountSerializer(serializers.ModelSerializer):
},
}
def to_representation(self, instance):
data = super().to_representation(instance)
# Parse custom_properties to get VOD preference and auto_enable_new_groups settings
custom_props = instance.custom_properties or {}
data["enable_vod"] = custom_props.get("enable_vod", False)
data["auto_enable_new_groups_live"] = custom_props.get("auto_enable_new_groups_live", True)
data["auto_enable_new_groups_vod"] = custom_props.get("auto_enable_new_groups_vod", True)
data["auto_enable_new_groups_series"] = custom_props.get("auto_enable_new_groups_series", True)
return data
def update(self, instance, validated_data):
# Handle enable_vod preference and auto_enable_new_groups settings
enable_vod = validated_data.pop("enable_vod", None)
auto_enable_new_groups_live = validated_data.pop("auto_enable_new_groups_live", None)
auto_enable_new_groups_vod = validated_data.pop("auto_enable_new_groups_vod", None)
auto_enable_new_groups_series = validated_data.pop("auto_enable_new_groups_series", None)
# Get existing custom_properties
custom_props = instance.custom_properties or {}
# Update preferences
if enable_vod is not None:
custom_props["enable_vod"] = enable_vod
if auto_enable_new_groups_live is not None:
custom_props["auto_enable_new_groups_live"] = auto_enable_new_groups_live
if auto_enable_new_groups_vod is not None:
custom_props["auto_enable_new_groups_vod"] = auto_enable_new_groups_vod
if auto_enable_new_groups_series is not None:
custom_props["auto_enable_new_groups_series"] = auto_enable_new_groups_series
validated_data["custom_properties"] = custom_props
# Pop out channel group memberships so we can handle them manually
channel_group_data = validated_data.pop("channel_group", [])
@ -142,6 +243,29 @@ class M3UAccountSerializer(serializers.ModelSerializer):
return instance
def create(self, validated_data):
# Handle enable_vod preference and auto_enable_new_groups settings during creation
enable_vod = validated_data.pop("enable_vod", False)
auto_enable_new_groups_live = validated_data.pop("auto_enable_new_groups_live", True)
auto_enable_new_groups_vod = validated_data.pop("auto_enable_new_groups_vod", True)
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", {})
# Set preferences (default to True for auto_enable_new_groups)
custom_props["enable_vod"] = enable_vod
custom_props["auto_enable_new_groups_live"] = auto_enable_new_groups_live
custom_props["auto_enable_new_groups_vod"] = auto_enable_new_groups_vod
custom_props["auto_enable_new_groups_series"] = auto_enable_new_groups_series
validated_data["custom_properties"] = custom_props
return super().create(validated_data)
def get_filters(self, obj):
filters = obj.filters.order_by("order")
return M3UFilterSerializer(filters, many=True).data
class ServerGroupSerializer(serializers.ModelSerializer):
"""Serializer for Server Group"""

File diff suppressed because it is too large Load diff

View file

@ -1,9 +1,40 @@
# apps/m3u/utils.py
import threading
import logging
from django.db import models
lock = threading.Lock()
# Dictionary to track usage: {m3u_account_id: current_usage}
active_streams_map = {}
logger = logging.getLogger(__name__)
def normalize_stream_url(url):
"""
Normalize stream URLs for compatibility with FFmpeg.
Handles VLC-specific syntax like udp://@239.0.0.1:1234 by removing the @ symbol.
FFmpeg doesn't recognize the @ prefix for multicast addresses.
Args:
url (str): The stream URL to normalize
Returns:
str: The normalized URL
"""
if not url:
return url
# Handle VLC-style UDP multicast URLs: udp://@239.0.0.1:1234 -> udp://239.0.0.1:1234
# The @ symbol in VLC means "listen on all interfaces" but FFmpeg doesn't use this syntax
if url.startswith('udp://@'):
normalized = url.replace('udp://@', 'udp://', 1)
logger.debug(f"Normalized VLC-style UDP URL: {url} -> {normalized}")
return normalized
# Could add other normalizations here in the future (rtp://@, etc.)
return url
def increment_stream_count(account):
with lock:
@ -24,3 +55,64 @@ def decrement_stream_count(account):
active_streams_map[account.id] = current_usage
account.active_streams = current_usage
account.save(update_fields=['active_streams'])
def calculate_tuner_count(minimum=1, unlimited_default=10):
"""
Calculate tuner/connection count from active M3U profiles and custom streams.
This is the centralized function used by both HDHR and XtreamCodes APIs.
Args:
minimum (int): Minimum number to return (default: 1)
unlimited_default (int): Default value when unlimited profiles exist (default: 10)
Returns:
int: Calculated tuner/connection count
"""
try:
from apps.m3u.models import M3UAccountProfile
from apps.channels.models import Stream
# Calculate tuner count from active profiles from active M3U accounts (excluding default "custom Default" profile)
profiles = M3UAccountProfile.objects.filter(
is_active=True,
m3u_account__is_active=True, # Only include profiles from enabled M3U accounts
).exclude(id=1)
# 1. Check if any profile has unlimited streams (max_streams=0)
has_unlimited = profiles.filter(max_streams=0).exists()
# 2. Calculate tuner count from limited profiles
limited_tuners = 0
if not has_unlimited:
limited_tuners = (
profiles.filter(max_streams__gt=0)
.aggregate(total=models.Sum("max_streams"))
.get("total", 0)
or 0
)
# 3. Add custom stream count to tuner count
custom_stream_count = Stream.objects.filter(is_custom=True).count()
logger.debug(f"Found {custom_stream_count} custom streams")
# 4. Calculate final tuner count
if has_unlimited:
# If there are unlimited profiles, start with unlimited_default plus custom streams
tuner_count = unlimited_default + custom_stream_count
else:
# Otherwise use the limited profile sum plus custom streams
tuner_count = limited_tuners + custom_stream_count
# 5. Ensure minimum number
tuner_count = max(minimum, tuner_count)
logger.debug(
f"Calculated tuner count: {tuner_count} (limited profiles: {limited_tuners}, custom streams: {custom_stream_count}, unlimited: {has_unlimited})"
)
return tuner_count
except Exception as e:
logger.error(f"Error calculating tuner count: {e}")
return minimum # Fallback to minimum value

View file

@ -3,6 +3,7 @@ from django.views import View
from django.utils.decorators import method_decorator
from django.contrib.auth.decorators import login_required
from django.views.decorators.csrf import csrf_exempt
from django.http import JsonResponse
from apps.m3u.models import M3UAccount
import json

View file

@ -14,3 +14,26 @@ class OutputM3UTest(TestCase):
self.assertEqual(response.status_code, 200)
content = response.content.decode()
self.assertIn("#EXTM3U", content)
def test_generate_m3u_response_post_empty_body(self):
"""
Test that a POST request with an empty body returns 200 OK.
"""
url = reverse('output:generate_m3u')
response = self.client.post(url, data=None, content_type='application/x-www-form-urlencoded')
content = response.content.decode()
self.assertEqual(response.status_code, 200, "POST with empty body should return 200 OK")
self.assertIn("#EXTM3U", content)
def test_generate_m3u_response_post_with_body(self):
"""
Test that a POST request with a non-empty body returns 403 Forbidden.
"""
url = reverse('output:generate_m3u')
response = self.client.post(url, data={'evilstring': 'muhahaha'})
self.assertEqual(response.status_code, 403, "POST with body should return 403 Forbidden")
self.assertIn("POST requests with body are not allowed, body is:", response.content.decode())

View file

@ -1,5 +1,5 @@
from django.urls import path, re_path, include
from .views import m3u_endpoint, epg_endpoint, xc_get
from .views import m3u_endpoint, epg_endpoint, xc_get, xc_movie_stream, xc_series_stream
from core.views import stream_view
app_name = "output"

File diff suppressed because it is too large Load diff

2
apps/plugins/__init__.py Normal file
View file

@ -0,0 +1,2 @@
default_app_config = "apps.plugins.apps.PluginsConfig"

22
apps/plugins/api_urls.py Normal file
View file

@ -0,0 +1,22 @@
from django.urls import path
from .api_views import (
PluginsListAPIView,
PluginReloadAPIView,
PluginSettingsAPIView,
PluginRunAPIView,
PluginEnabledAPIView,
PluginImportAPIView,
PluginDeleteAPIView,
)
app_name = "plugins"
urlpatterns = [
path("plugins/", PluginsListAPIView.as_view(), name="list"),
path("plugins/reload/", PluginReloadAPIView.as_view(), name="reload"),
path("plugins/import/", PluginImportAPIView.as_view(), name="import"),
path("plugins/<str:key>/delete/", PluginDeleteAPIView.as_view(), name="delete"),
path("plugins/<str:key>/settings/", PluginSettingsAPIView.as_view(), name="settings"),
path("plugins/<str:key>/run/", PluginRunAPIView.as_view(), name="run"),
path("plugins/<str:key>/enabled/", PluginEnabledAPIView.as_view(), name="enabled"),
]

306
apps/plugins/api_views.py Normal file
View file

@ -0,0 +1,306 @@
import logging
from rest_framework.views import APIView
from rest_framework.response import Response
from rest_framework import status
from rest_framework.decorators import api_view
from django.conf import settings
from django.core.files.uploadedfile import UploadedFile
import io
import os
import zipfile
import shutil
import tempfile
from apps.accounts.permissions import (
Authenticated,
permission_classes_by_method,
)
from .loader import PluginManager
from .models import PluginConfig
logger = logging.getLogger(__name__)
class PluginsListAPIView(APIView):
def get_permissions(self):
try:
return [
perm() for perm in permission_classes_by_method[self.request.method]
]
except KeyError:
return [Authenticated()]
def get(self, request):
pm = PluginManager.get()
# Ensure registry is up-to-date on each request
pm.discover_plugins()
return Response({"plugins": pm.list_plugins()})
class PluginReloadAPIView(APIView):
def get_permissions(self):
try:
return [
perm() for perm in permission_classes_by_method[self.request.method]
]
except KeyError:
return [Authenticated()]
def post(self, request):
pm = PluginManager.get()
pm.discover_plugins()
return Response({"success": True, "count": len(pm._registry)})
class PluginImportAPIView(APIView):
def get_permissions(self):
try:
return [
perm() for perm in permission_classes_by_method[self.request.method]
]
except KeyError:
return [Authenticated()]
def post(self, request):
file: UploadedFile = request.FILES.get("file")
if not file:
return Response({"success": False, "error": "Missing 'file' upload"}, status=status.HTTP_400_BAD_REQUEST)
pm = PluginManager.get()
plugins_dir = pm.plugins_dir
try:
zf = zipfile.ZipFile(file)
except zipfile.BadZipFile:
return Response({"success": False, "error": "Invalid zip file"}, status=status.HTTP_400_BAD_REQUEST)
# Extract to a temporary directory first to avoid server reload thrash
tmp_root = tempfile.mkdtemp(prefix="plugin_import_")
try:
file_members = [m for m in zf.infolist() if not m.is_dir()]
if not file_members:
shutil.rmtree(tmp_root, ignore_errors=True)
return Response({"success": False, "error": "Archive is empty"}, status=status.HTTP_400_BAD_REQUEST)
for member in file_members:
name = member.filename
if not name or name.endswith("/"):
continue
# Normalize and prevent path traversal
norm = os.path.normpath(name)
if norm.startswith("..") or os.path.isabs(norm):
shutil.rmtree(tmp_root, ignore_errors=True)
return Response({"success": False, "error": "Unsafe path in archive"}, status=status.HTTP_400_BAD_REQUEST)
dest_path = os.path.join(tmp_root, norm)
os.makedirs(os.path.dirname(dest_path), exist_ok=True)
with zf.open(member, 'r') as src, open(dest_path, 'wb') as dst:
shutil.copyfileobj(src, dst)
# Find candidate directory containing plugin.py or __init__.py
candidates = []
for dirpath, dirnames, filenames in os.walk(tmp_root):
has_pluginpy = "plugin.py" in filenames
has_init = "__init__.py" in filenames
if has_pluginpy or has_init:
depth = len(os.path.relpath(dirpath, tmp_root).split(os.sep))
candidates.append((0 if has_pluginpy else 1, depth, dirpath))
if not candidates:
shutil.rmtree(tmp_root, ignore_errors=True)
return Response({"success": False, "error": "Invalid plugin: missing plugin.py or package __init__.py"}, status=status.HTTP_400_BAD_REQUEST)
candidates.sort()
chosen = candidates[0][2]
# Determine plugin key: prefer chosen folder name; if chosen is tmp_root, use zip base name
base_name = os.path.splitext(getattr(file, "name", "plugin"))[0]
plugin_key = os.path.basename(chosen.rstrip(os.sep))
if chosen.rstrip(os.sep) == tmp_root.rstrip(os.sep):
plugin_key = base_name
plugin_key = plugin_key.replace(" ", "_").lower()
final_dir = os.path.join(plugins_dir, plugin_key)
if os.path.exists(final_dir):
# If final dir exists but contains a valid plugin, refuse; otherwise clear it
if os.path.exists(os.path.join(final_dir, "plugin.py")) or os.path.exists(os.path.join(final_dir, "__init__.py")):
shutil.rmtree(tmp_root, ignore_errors=True)
return Response({"success": False, "error": f"Plugin '{plugin_key}' already exists"}, status=status.HTTP_400_BAD_REQUEST)
try:
shutil.rmtree(final_dir)
except Exception:
pass
# Move chosen directory into final location
if chosen.rstrip(os.sep) == tmp_root.rstrip(os.sep):
# Move all contents into final_dir
os.makedirs(final_dir, exist_ok=True)
for item in os.listdir(tmp_root):
shutil.move(os.path.join(tmp_root, item), os.path.join(final_dir, item))
else:
shutil.move(chosen, final_dir)
# Cleanup temp
shutil.rmtree(tmp_root, ignore_errors=True)
target_dir = final_dir
finally:
try:
shutil.rmtree(tmp_root, ignore_errors=True)
except Exception:
pass
# Reload discovery and validate plugin entry
pm.discover_plugins()
plugin = pm._registry.get(plugin_key)
if not plugin:
# Cleanup the copied folder to avoid leaving invalid plugin behind
try:
shutil.rmtree(target_dir, ignore_errors=True)
except Exception:
pass
return Response({"success": False, "error": "Invalid plugin: missing Plugin class in plugin.py or __init__.py"}, status=status.HTTP_400_BAD_REQUEST)
# Extra validation: ensure Plugin.run exists
instance = getattr(plugin, "instance", None)
run_method = getattr(instance, "run", None)
if not callable(run_method):
try:
shutil.rmtree(target_dir, ignore_errors=True)
except Exception:
pass
return Response({"success": False, "error": "Invalid plugin: Plugin class must define a callable run(action, params, context)"}, status=status.HTTP_400_BAD_REQUEST)
# Find DB config to return enabled/ever_enabled
try:
cfg = PluginConfig.objects.get(key=plugin_key)
enabled = cfg.enabled
ever_enabled = getattr(cfg, "ever_enabled", False)
except PluginConfig.DoesNotExist:
enabled = False
ever_enabled = False
return Response({
"success": True,
"plugin": {
"key": plugin.key,
"name": plugin.name,
"version": plugin.version,
"description": plugin.description,
"enabled": enabled,
"ever_enabled": ever_enabled,
"fields": plugin.fields or [],
"actions": plugin.actions or [],
}
})
class PluginSettingsAPIView(APIView):
def get_permissions(self):
try:
return [
perm() for perm in permission_classes_by_method[self.request.method]
]
except KeyError:
return [Authenticated()]
def post(self, request, key):
pm = PluginManager.get()
data = request.data or {}
settings = data.get("settings", {})
try:
updated = pm.update_settings(key, settings)
return Response({"success": True, "settings": updated})
except Exception as e:
return Response({"success": False, "error": str(e)}, status=status.HTTP_400_BAD_REQUEST)
class PluginRunAPIView(APIView):
def get_permissions(self):
try:
return [
perm() for perm in permission_classes_by_method[self.request.method]
]
except KeyError:
return [Authenticated()]
def post(self, request, key):
pm = PluginManager.get()
action = request.data.get("action")
params = request.data.get("params", {})
if not action:
return Response({"success": False, "error": "Missing 'action'"}, status=status.HTTP_400_BAD_REQUEST)
# Respect plugin enabled flag
try:
cfg = PluginConfig.objects.get(key=key)
if not cfg.enabled:
return Response({"success": False, "error": "Plugin is disabled"}, status=status.HTTP_403_FORBIDDEN)
except PluginConfig.DoesNotExist:
return Response({"success": False, "error": "Plugin not found"}, status=status.HTTP_404_NOT_FOUND)
try:
result = pm.run_action(key, action, params)
return Response({"success": True, "result": result})
except PermissionError as e:
return Response({"success": False, "error": str(e)}, status=status.HTTP_403_FORBIDDEN)
except Exception as e:
logger.exception("Plugin action failed")
return Response({"success": False, "error": str(e)}, status=status.HTTP_500_INTERNAL_SERVER_ERROR)
class PluginEnabledAPIView(APIView):
def get_permissions(self):
try:
return [
perm() for perm in permission_classes_by_method[self.request.method]
]
except KeyError:
return [Authenticated()]
def post(self, request, key):
enabled = request.data.get("enabled")
if enabled is None:
return Response({"success": False, "error": "Missing 'enabled' boolean"}, status=status.HTTP_400_BAD_REQUEST)
try:
cfg = PluginConfig.objects.get(key=key)
cfg.enabled = bool(enabled)
# Mark that this plugin has been enabled at least once
if cfg.enabled and not cfg.ever_enabled:
cfg.ever_enabled = True
cfg.save(update_fields=["enabled", "ever_enabled", "updated_at"])
return Response({"success": True, "enabled": cfg.enabled, "ever_enabled": cfg.ever_enabled})
except PluginConfig.DoesNotExist:
return Response({"success": False, "error": "Plugin not found"}, status=status.HTTP_404_NOT_FOUND)
class PluginDeleteAPIView(APIView):
def get_permissions(self):
try:
return [
perm() for perm in permission_classes_by_method[self.request.method]
]
except KeyError:
return [Authenticated()]
def delete(self, request, key):
pm = PluginManager.get()
plugins_dir = pm.plugins_dir
target_dir = os.path.join(plugins_dir, key)
# Safety: ensure path inside plugins_dir
abs_plugins = os.path.abspath(plugins_dir) + os.sep
abs_target = os.path.abspath(target_dir)
if not abs_target.startswith(abs_plugins):
return Response({"success": False, "error": "Invalid plugin path"}, status=status.HTTP_400_BAD_REQUEST)
# Remove files
if os.path.isdir(target_dir):
try:
shutil.rmtree(target_dir)
except Exception as e:
return Response({"success": False, "error": f"Failed to delete plugin files: {e}"}, status=status.HTTP_500_INTERNAL_SERVER_ERROR)
# Remove DB record
try:
PluginConfig.objects.filter(key=key).delete()
except Exception:
pass
# Reload registry
pm.discover_plugins()
return Response({"success": True})

54
apps/plugins/apps.py Normal file
View file

@ -0,0 +1,54 @@
from django.apps import AppConfig
import os
import sys
from django.db.models.signals import post_migrate
class PluginsConfig(AppConfig):
name = "apps.plugins"
verbose_name = "Plugins"
def ready(self):
"""Wire up plugin discovery without hitting the DB during app init.
- Skip during common management commands that don't need discovery.
- Register post_migrate handler to sync plugin registry to DB after migrations.
- Do an in-memory discovery (no DB) so registry is available early.
"""
try:
# Allow explicit opt-out via env var
if os.environ.get("DISPATCHARR_SKIP_PLUGIN_AUTODISCOVERY", "").lower() in ("1", "true", "yes"):
return
argv = sys.argv[1:] if len(sys.argv) > 1 else []
mgmt_cmds_to_skip = {
# Skip immediate discovery for these commands
"makemigrations", "collectstatic", "check", "test", "shell", "showmigrations",
}
if argv and argv[0] in mgmt_cmds_to_skip:
return
# Run discovery with DB sync after the plugins app has been migrated
def _post_migrate_discover(sender=None, app_config=None, **kwargs):
try:
if app_config and getattr(app_config, 'label', None) != 'plugins':
return
from .loader import PluginManager
PluginManager.get().discover_plugins(sync_db=True)
except Exception:
import logging
logging.getLogger(__name__).exception("Plugin discovery failed in post_migrate")
post_migrate.connect(
_post_migrate_discover,
dispatch_uid="apps.plugins.post_migrate_discover",
)
# Perform non-DB discovery now to populate in-memory registry.
from .loader import PluginManager
PluginManager.get().discover_plugins(sync_db=False)
except Exception:
# Avoid breaking startup due to plugin errors
import logging
logging.getLogger(__name__).exception("Plugin discovery wiring failed during app ready")

254
apps/plugins/loader.py Normal file
View file

@ -0,0 +1,254 @@
import importlib
import json
import logging
import os
import sys
from dataclasses import dataclass, field
from typing import Any, Dict, List, Optional
from django.db import transaction
from .models import PluginConfig
logger = logging.getLogger(__name__)
@dataclass
class LoadedPlugin:
key: str
name: str
version: str = ""
description: str = ""
module: Any = None
instance: Any = None
fields: List[Dict[str, Any]] = field(default_factory=list)
actions: List[Dict[str, Any]] = field(default_factory=list)
class PluginManager:
"""Singleton manager that discovers and runs plugins from /data/plugins."""
_instance: Optional["PluginManager"] = None
@classmethod
def get(cls) -> "PluginManager":
if not cls._instance:
cls._instance = PluginManager()
return cls._instance
def __init__(self) -> None:
self.plugins_dir = os.environ.get("DISPATCHARR_PLUGINS_DIR", "/data/plugins")
self._registry: Dict[str, LoadedPlugin] = {}
# Ensure plugins directory exists
os.makedirs(self.plugins_dir, exist_ok=True)
if self.plugins_dir not in sys.path:
sys.path.append(self.plugins_dir)
def discover_plugins(self, *, sync_db: bool = True) -> Dict[str, LoadedPlugin]:
if sync_db:
logger.info(f"Discovering plugins in {self.plugins_dir}")
else:
logger.debug(f"Discovering plugins (no DB sync) in {self.plugins_dir}")
self._registry.clear()
try:
for entry in sorted(os.listdir(self.plugins_dir)):
path = os.path.join(self.plugins_dir, entry)
if not os.path.isdir(path):
continue
plugin_key = entry.replace(" ", "_").lower()
try:
self._load_plugin(plugin_key, path)
except Exception:
logger.exception(f"Failed to load plugin '{plugin_key}' from {path}")
logger.info(f"Discovered {len(self._registry)} plugin(s)")
except FileNotFoundError:
logger.warning(f"Plugins directory not found: {self.plugins_dir}")
# Sync DB records (optional)
if sync_db:
try:
self._sync_db_with_registry()
except Exception:
# Defer sync if database is not ready (e.g., first startup before migrate)
logger.exception("Deferring plugin DB sync; database not ready yet")
return self._registry
def _load_plugin(self, key: str, path: str):
# Plugin can be a package and/or contain plugin.py. Prefer plugin.py when present.
has_pkg = os.path.exists(os.path.join(path, "__init__.py"))
has_pluginpy = os.path.exists(os.path.join(path, "plugin.py"))
if not (has_pkg or has_pluginpy):
logger.debug(f"Skipping {path}: no plugin.py or package")
return
candidate_modules = []
if has_pluginpy:
candidate_modules.append(f"{key}.plugin")
if has_pkg:
candidate_modules.append(key)
module = None
plugin_cls = None
last_error = None
for module_name in candidate_modules:
try:
logger.debug(f"Importing plugin module {module_name}")
module = importlib.import_module(module_name)
plugin_cls = getattr(module, "Plugin", None)
if plugin_cls is not None:
break
else:
logger.warning(f"Module {module_name} has no Plugin class")
except Exception as e:
last_error = e
logger.exception(f"Error importing module {module_name}")
if plugin_cls is None:
if last_error:
raise last_error
else:
logger.warning(f"No Plugin class found for {key}; skipping")
return
instance = plugin_cls()
name = getattr(instance, "name", key)
version = getattr(instance, "version", "")
description = getattr(instance, "description", "")
fields = getattr(instance, "fields", [])
actions = getattr(instance, "actions", [])
self._registry[key] = LoadedPlugin(
key=key,
name=name,
version=version,
description=description,
module=module,
instance=instance,
fields=fields,
actions=actions,
)
def _sync_db_with_registry(self):
with transaction.atomic():
for key, lp in self._registry.items():
obj, _ = PluginConfig.objects.get_or_create(
key=key,
defaults={
"name": lp.name,
"version": lp.version,
"description": lp.description,
"settings": {},
},
)
# Update meta if changed
changed = False
if obj.name != lp.name:
obj.name = lp.name
changed = True
if obj.version != lp.version:
obj.version = lp.version
changed = True
if obj.description != lp.description:
obj.description = lp.description
changed = True
if changed:
obj.save()
def list_plugins(self) -> List[Dict[str, Any]]:
from .models import PluginConfig
plugins: List[Dict[str, Any]] = []
try:
configs = {c.key: c for c in PluginConfig.objects.all()}
except Exception as e:
# Database might not be migrated yet; fall back to registry only
logger.warning("PluginConfig table unavailable; listing registry only: %s", e)
configs = {}
# First, include all discovered plugins
for key, lp in self._registry.items():
conf = configs.get(key)
plugins.append(
{
"key": key,
"name": lp.name,
"version": lp.version,
"description": lp.description,
"enabled": conf.enabled if conf else False,
"ever_enabled": getattr(conf, "ever_enabled", False) if conf else False,
"fields": lp.fields or [],
"settings": (conf.settings if conf else {}),
"actions": lp.actions or [],
"missing": False,
}
)
# Then, include any DB-only configs (files missing or failed to load)
discovered_keys = set(self._registry.keys())
for key, conf in configs.items():
if key in discovered_keys:
continue
plugins.append(
{
"key": key,
"name": conf.name,
"version": conf.version,
"description": conf.description,
"enabled": conf.enabled,
"ever_enabled": getattr(conf, "ever_enabled", False),
"fields": [],
"settings": conf.settings or {},
"actions": [],
"missing": True,
}
)
return plugins
def get_plugin(self, key: str) -> Optional[LoadedPlugin]:
return self._registry.get(key)
def update_settings(self, key: str, settings: Dict[str, Any]) -> Dict[str, Any]:
cfg = PluginConfig.objects.get(key=key)
cfg.settings = settings or {}
cfg.save(update_fields=["settings", "updated_at"])
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:
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 {}
# Provide a context object to the plugin
context = {
"settings": cfg.settings or {},
"logger": logger,
"actions": {a.get("id"): a for a in (lp.actions or [])},
}
# 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")
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}

View file

@ -0,0 +1,29 @@
# Generated by Django 5.2.4 on 2025-09-13 13:51
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = [
]
operations = [
migrations.CreateModel(
name='PluginConfig',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('key', models.CharField(max_length=128, unique=True)),
('name', models.CharField(max_length=255)),
('version', models.CharField(blank=True, default='', max_length=64)),
('description', models.TextField(blank=True, default='')),
('enabled', models.BooleanField(default=False)),
('ever_enabled', models.BooleanField(default=False)),
('settings', models.JSONField(blank=True, default=dict)),
('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)),
],
),
]

View file

@ -0,0 +1 @@
# This file marks the migrations package for the plugins app.

19
apps/plugins/models.py Normal file
View file

@ -0,0 +1,19 @@
from django.db import models
class PluginConfig(models.Model):
"""Stores discovered plugins and their persisted settings."""
key = models.CharField(max_length=128, unique=True)
name = models.CharField(max_length=255)
version = models.CharField(max_length=64, blank=True, default="")
description = models.TextField(blank=True, default="")
enabled = models.BooleanField(default=False)
# Tracks whether this plugin has ever been enabled at least once
ever_enabled = models.BooleanField(default=False)
settings = models.JSONField(default=dict, blank=True)
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
def __str__(self) -> str:
return f"{self.name} ({self.key})"

View file

@ -0,0 +1,28 @@
from rest_framework import serializers
class PluginActionSerializer(serializers.Serializer):
id = serializers.CharField()
label = serializers.CharField()
description = serializers.CharField(required=False, allow_blank=True)
class PluginFieldSerializer(serializers.Serializer):
id = serializers.CharField()
label = serializers.CharField()
type = serializers.ChoiceField(choices=["string", "number", "boolean", "select"]) # simple types
default = serializers.JSONField(required=False)
help_text = serializers.CharField(required=False, allow_blank=True)
options = serializers.ListField(child=serializers.DictField(), required=False)
class PluginSerializer(serializers.Serializer):
key = serializers.CharField()
name = serializers.CharField()
version = serializers.CharField(allow_blank=True)
description = serializers.CharField(allow_blank=True)
enabled = serializers.BooleanField()
fields = PluginFieldSerializer(many=True)
settings = serializers.JSONField()
actions = PluginActionSerializer(many=True)

View file

@ -1,4 +1,6 @@
"""Shared configuration between proxy types"""
import time
from django.db import connection
class BaseConfig:
DEFAULT_USER_AGENT = 'VLC/3.0.20 LibVLC/3.0.20' # Will only be used if connection to settings fail
@ -12,13 +14,29 @@ class BaseConfig:
BUFFERING_TIMEOUT = 15 # Seconds to wait for buffering before switching streams
BUFFER_SPEED = 1 # What speed to condsider the stream buffering, 1x is normal speed, 2x is double speed, etc.
# Cache for proxy settings (class-level, shared across all instances)
_proxy_settings_cache = None
_proxy_settings_cache_time = 0
_proxy_settings_cache_ttl = 10 # Cache for 10 seconds
@classmethod
def get_proxy_settings(cls):
"""Get proxy settings from CoreSettings JSON data with fallback to defaults"""
"""Get proxy settings from CoreSettings JSON data with fallback to defaults (cached)"""
# Check if cache is still valid
now = time.time()
if cls._proxy_settings_cache is not None and (now - cls._proxy_settings_cache_time) < cls._proxy_settings_cache_ttl:
return cls._proxy_settings_cache
# Cache miss or expired - fetch from database
try:
from core.models import CoreSettings
return CoreSettings.get_proxy_settings()
settings = CoreSettings.get_proxy_settings()
cls._proxy_settings_cache = settings
cls._proxy_settings_cache_time = now
return settings
except Exception:
# Return defaults if database query fails
return {
"buffering_timeout": 15,
"buffering_speed": 1.0,
@ -27,6 +45,13 @@ class BaseConfig:
"channel_init_grace_period": 5,
}
finally:
# Always close the connection after reading settings
try:
connection.close()
except Exception:
pass
@classmethod
def get_redis_chunk_ttl(cls):
"""Get Redis chunk TTL from database or default"""
@ -69,10 +94,10 @@ class TSConfig(BaseConfig):
CLEANUP_INTERVAL = 60 # Check for inactive channels every 60 seconds
# Client tracking settings
CLIENT_RECORD_TTL = 5 # How long client records persist in Redis (seconds). Client will be considered MIA after this time.
CLIENT_RECORD_TTL = 60 # How long client records persist in Redis (seconds). Client will be considered MIA after this time.
CLEANUP_CHECK_INTERVAL = 1 # How often to check for disconnected clients (seconds)
CLIENT_HEARTBEAT_INTERVAL = 1 # How often to send client heartbeats (seconds)
GHOST_CLIENT_MULTIPLIER = 5.0 # How many heartbeat intervals before client considered ghost (5 would mean 5 secondsif heartbeat interval is 1)
CLIENT_HEARTBEAT_INTERVAL = 5 # How often to send client heartbeats (seconds)
GHOST_CLIENT_MULTIPLIER = 6.0 # How many heartbeat intervals before client considered ghost (6 would mean 36 seconds if heartbeat interval is 6)
CLIENT_WAIT_TIMEOUT = 30 # Seconds to wait for client to connect
# Stream health and recovery settings

View file

@ -10,6 +10,7 @@ import gc # Add import for garbage collection
from core.utils import RedisClient
from apps.proxy.ts_proxy.channel_status import ChannelStatus
from core.utils import send_websocket_update
from apps.proxy.vod_proxy.connection_manager import get_connection_manager
logger = logging.getLogger(__name__)
@ -59,3 +60,13 @@ def fetch_channel_stats():
# Explicitly clean up large data structures
all_channels = None
gc.collect()
@shared_task
def cleanup_vod_connections():
"""Clean up stale VOD connections"""
try:
connection_manager = get_connection_manager()
connection_manager.cleanup_stale_connections(max_age_seconds=3600) # 1 hour
logger.info("VOD connection cleanup completed")
except Exception as e:
logger.error(f"Error in VOD connection cleanup: {e}", exc_info=True)

View file

@ -8,10 +8,11 @@ import gevent
from typing import Set, Optional
from apps.proxy.config import TSConfig as Config
from redis.exceptions import ConnectionError, TimeoutError
from .constants import EventType
from .constants import EventType, ChannelState, ChannelMetadataField
from .config_helper import ConfigHelper
from .redis_keys import RedisKeys
from .utils import get_logger
from core.utils import send_websocket_update
logger = get_logger()
@ -25,6 +26,7 @@ class ClientManager:
self.lock = threading.Lock()
self.last_active_time = time.time()
self.worker_id = worker_id # Store worker ID as instance variable
self._heartbeat_running = True # Flag to control heartbeat thread
# STANDARDIZED KEYS: Move client set under channel namespace
self.client_set_key = RedisKeys.clients(channel_id)
@ -32,61 +34,78 @@ class ClientManager:
self.heartbeat_interval = ConfigHelper.get('CLIENT_HEARTBEAT_INTERVAL', 10)
self.last_heartbeat_time = {}
# Get ProxyServer instance for ownership checks
from .server import ProxyServer
self.proxy_server = ProxyServer.get_instance()
# Start heartbeat thread for local clients
self._start_heartbeat_thread()
self._registered_clients = set() # Track already registered client IDs
def _start_heartbeat_thread(self):
"""Start thread to regularly refresh client presence in Redis"""
def heartbeat_task():
no_clients_count = 0 # Track consecutive empty cycles
max_empty_cycles = 3 # Exit after this many consecutive empty checks
def _trigger_stats_update(self):
"""Trigger a channel stats update via WebSocket"""
try:
# Import here to avoid potential import issues
from apps.proxy.ts_proxy.channel_status import ChannelStatus
import redis
from django.conf import settings
logger.debug(f"Started heartbeat thread for channel {self.channel_id} (interval: {self.heartbeat_interval}s)")
# Get all channels from Redis using settings
redis_url = getattr(settings, 'REDIS_URL', 'redis://localhost:6379/0')
redis_client = redis.Redis.from_url(redis_url, decode_responses=True)
all_channels = []
cursor = 0
while True:
cursor, keys = redis_client.scan(cursor, match="ts_proxy:channel:*:clients", count=100)
for key in keys:
# Extract channel ID from key
parts = key.split(':')
if len(parts) >= 4:
ch_id = parts[2]
channel_info = ChannelStatus.get_basic_channel_info(ch_id)
if channel_info:
all_channels.append(channel_info)
if cursor == 0:
break
# Send WebSocket update using existing infrastructure
send_websocket_update(
"updates",
"update",
{
"success": True,
"type": "channel_stats",
"stats": json.dumps({'channels': all_channels, 'count': len(all_channels)})
}
)
except Exception as e:
logger.debug(f"Failed to trigger stats update: {e}")
def _start_heartbeat_thread(self):
"""Start thread to regularly refresh client presence in Redis for local clients"""
def heartbeat_task():
logger.debug(f"Started heartbeat thread for channel {self.channel_id} (interval: {self.heartbeat_interval}s)")
while self._heartbeat_running:
try:
# Wait for the interval
gevent.sleep(self.heartbeat_interval)
# Wait for the interval, but check stop flag frequently for quick shutdown
# Sleep in 1-second increments to allow faster response to stop signal
for _ in range(int(self.heartbeat_interval)):
if not self._heartbeat_running:
break
time.sleep(1)
# Final check before doing work
if not self._heartbeat_running:
break
# Send heartbeat for all local clients
with self.lock:
if not self.clients or not self.redis_client:
# No clients left, increment our counter
no_clients_count += 1
# Check if we're in a shutdown delay period before exiting
in_shutdown_delay = False
if self.redis_client:
try:
disconnect_key = RedisKeys.last_client_disconnect(self.channel_id)
disconnect_time_bytes = self.redis_client.get(disconnect_key)
if disconnect_time_bytes:
disconnect_time = float(disconnect_time_bytes.decode('utf-8'))
elapsed = time.time() - disconnect_time
shutdown_delay = ConfigHelper.channel_shutdown_delay()
if elapsed < shutdown_delay:
in_shutdown_delay = True
logger.debug(f"Channel {self.channel_id} in shutdown delay: {elapsed:.1f}s of {shutdown_delay}s elapsed")
except Exception as e:
logger.debug(f"Error checking shutdown delay: {e}")
# Only exit if we've seen no clients for several consecutive checks AND we're not in shutdown delay
if no_clients_count >= max_empty_cycles and not in_shutdown_delay:
logger.info(f"No clients for channel {self.channel_id} after {no_clients_count} consecutive checks and not in shutdown delay, exiting heartbeat thread")
return # This exits the thread
# Skip this cycle if we have no clients but continue if in shutdown delay
if not in_shutdown_delay:
continue
else:
# Reset counter during shutdown delay to prevent premature exit
no_clients_count = 0
continue
else:
# Reset counter when we see clients
no_clients_count = 0
# Skip this cycle if we have no local clients
if not self.clients:
continue
# IMPROVED GHOST DETECTION: Check for stale clients before sending heartbeats
current_time = time.time()
@ -157,11 +176,20 @@ class ClientManager:
except Exception as e:
logger.error(f"Error in client heartbeat thread: {e}")
logger.debug(f"Heartbeat thread exiting for channel {self.channel_id}")
thread = threading.Thread(target=heartbeat_task, daemon=True)
thread.name = f"client-heartbeat-{self.channel_id}"
thread.start()
logger.debug(f"Started client heartbeat thread for channel {self.channel_id} (interval: {self.heartbeat_interval}s)")
def stop(self):
"""Stop the heartbeat thread and cleanup"""
logger.debug(f"Stopping ClientManager for channel {self.channel_id}")
self._heartbeat_running = False
# Give the thread a moment to exit gracefully
# Note: We don't join() here because it's a daemon thread and will exit on its own
def _execute_redis_command(self, command_func):
"""Execute Redis command with error handling"""
if not self.redis_client:
@ -260,6 +288,9 @@ class ClientManager:
json.dumps(event_data)
)
# Trigger channel stats update via WebSocket
self._trigger_stats_update()
# 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})")
@ -274,6 +305,8 @@ class ClientManager:
def remove_client(self, client_id):
"""Remove a client from this channel and Redis"""
client_ip = None
with self.lock:
if client_id in self.clients:
self.clients.remove(client_id)
@ -284,6 +317,14 @@ class ClientManager:
self.last_active_time = time.time()
if self.redis_client:
# Get client IP before removing the data
client_key = f"ts_proxy:channel:{self.channel_id}:clients:{client_id}"
client_data = self.redis_client.hgetall(client_key)
if client_data and b'ip_address' in client_data:
client_ip = client_data[b'ip_address'].decode('utf-8')
elif client_data and 'ip_address' in client_data:
client_ip = client_data['ip_address']
# Remove from channel's client set
self.redis_client.srem(self.client_set_key, client_id)
@ -302,16 +343,33 @@ class ClientManager:
self._notify_owner_of_activity()
# Publish client disconnected event
event_data = json.dumps({
"event": EventType.CLIENT_DISCONNECTED, # Use constant instead of string
"channel_id": self.channel_id,
"client_id": client_id,
"worker_id": self.worker_id or "unknown",
"timestamp": time.time(),
"remaining_clients": remaining
})
self.redis_client.publish(RedisKeys.events_channel(self.channel_id), event_data)
# 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 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:
# Trigger shutdown check directly via ProxyServer method
logger.debug(f"No clients left - triggering immediate shutdown check")
# Spawn greenlet to avoid blocking
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
})
self.redis_client.publish(RedisKeys.events_channel(self.channel_id), event_data)
# Trigger channel stats update via WebSocket
self._trigger_stats_update()
total_clients = self.get_total_client_count()
logger.info(f"Client disconnected: {client_id} (local: {len(self.clients)}, total: {total_clients})")

View file

@ -100,3 +100,12 @@ class ConfigHelper:
def channel_init_grace_period():
"""Get channel initialization grace period in seconds"""
return Config.get_channel_init_grace_period()
@staticmethod
def chunk_timeout():
"""
Get chunk timeout in seconds (used for both socket and HTTP read timeouts).
This controls how long we wait for each chunk before timing out.
Set this higher (e.g., 30s) for slow providers that may have intermittent delays.
"""
return ConfigHelper.get('CHUNK_TIMEOUT', 5) # Default 5 seconds

View file

@ -33,6 +33,8 @@ class EventType:
# Stream types
class StreamType:
HLS = "hls"
RTSP = "rtsp"
UDP = "udp"
TS = "ts"
UNKNOWN = "unknown"

View file

@ -0,0 +1,138 @@
"""
HTTP Stream Reader - Thread-based HTTP stream reader that writes to a pipe.
This allows us to use the same fetch_chunk() path for both transcode and HTTP streams.
"""
import threading
import os
import requests
from requests.adapters import HTTPAdapter
from .utils import get_logger
logger = get_logger()
class HTTPStreamReader:
"""Thread-based HTTP stream reader that writes to a pipe"""
def __init__(self, url, user_agent=None, chunk_size=8192):
self.url = url
self.user_agent = user_agent
self.chunk_size = chunk_size
self.session = None
self.response = None
self.thread = None
self.pipe_read = None
self.pipe_write = None
self.running = False
def start(self):
"""Start the HTTP stream reader thread"""
# Create a pipe (works on Windows and Unix)
self.pipe_read, self.pipe_write = os.pipe()
# Start the reader thread
self.running = True
self.thread = threading.Thread(target=self._read_stream, daemon=True)
self.thread.start()
logger.info(f"Started HTTP stream reader thread for {self.url}")
return self.pipe_read
def _read_stream(self):
"""Thread worker that reads HTTP stream and writes to pipe"""
try:
# Build headers
headers = {}
if self.user_agent:
headers['User-Agent'] = self.user_agent
logger.info(f"HTTP reader connecting to {self.url}")
# Create session
self.session = requests.Session()
# Disable retries for faster failure detection
adapter = HTTPAdapter(max_retries=0, pool_connections=1, pool_maxsize=1)
self.session.mount('http://', adapter)
self.session.mount('https://', adapter)
# Stream the URL
self.response = self.session.get(
self.url,
headers=headers,
stream=True,
timeout=(5, 30) # 5s connect, 30s read
)
if self.response.status_code != 200:
logger.error(f"HTTP {self.response.status_code} from {self.url}")
return
logger.info(f"HTTP reader connected successfully, streaming data...")
# Stream chunks to pipe
chunk_count = 0
for chunk in self.response.iter_content(chunk_size=self.chunk_size):
if not self.running:
break
if chunk:
try:
# Write binary data to pipe
os.write(self.pipe_write, chunk)
chunk_count += 1
# Log progress periodically
if chunk_count % 1000 == 0:
logger.debug(f"HTTP reader streamed {chunk_count} chunks")
except OSError as e:
logger.error(f"Pipe write error: {e}")
break
logger.info("HTTP stream ended")
except requests.exceptions.RequestException as e:
logger.error(f"HTTP reader request error: {e}")
except Exception as e:
logger.error(f"HTTP reader unexpected error: {e}", exc_info=True)
finally:
self.running = False
# Close write end of pipe to signal EOF
try:
if self.pipe_write is not None:
os.close(self.pipe_write)
self.pipe_write = None
except:
pass
def stop(self):
"""Stop the HTTP stream reader"""
logger.info("Stopping HTTP stream reader")
self.running = False
# Close response
if self.response:
try:
self.response.close()
except:
pass
# Close session
if self.session:
try:
self.session.close()
except:
pass
# Close write end of pipe
if self.pipe_write is not None:
try:
os.close(self.pipe_write)
self.pipe_write = None
except:
pass
# Wait for thread
if self.thread and self.thread.is_alive():
self.thread.join(timeout=2.0)

View file

@ -19,7 +19,7 @@ import gevent # Add gevent import
from typing import Dict, Optional, Set
from apps.proxy.config import TSConfig as Config
from apps.channels.models import Channel, Stream
from core.utils import RedisClient
from core.utils import RedisClient, log_system_event
from redis.exceptions import ConnectionError, TimeoutError
from .stream_manager import StreamManager
from .stream_buffer import StreamBuffer
@ -131,6 +131,8 @@ class ProxyServer:
max_retries = 10
base_retry_delay = 1 # Start with 1 second delay
max_retry_delay = 30 # Cap at 30 seconds
pubsub_client = None
pubsub = None
while True:
try:
@ -192,35 +194,11 @@ class ProxyServer:
self.redis_client.delete(disconnect_key)
elif event_type == EventType.CLIENT_DISCONNECTED:
logger.debug(f"Owner received {EventType.CLIENT_DISCONNECTED} event for channel {channel_id}")
# Check if any clients remain
if channel_id in self.client_managers:
# VERIFY REDIS CLIENT COUNT DIRECTLY
client_set_key = RedisKeys.clients(channel_id)
total = self.redis_client.scard(client_set_key) or 0
if total == 0:
logger.debug(f"No clients left after disconnect event - stopping channel {channel_id}")
# Set the disconnect timer for other workers to see
disconnect_key = RedisKeys.last_client_disconnect(channel_id)
self.redis_client.setex(disconnect_key, 60, str(time.time()))
# Get configured shutdown delay or default
shutdown_delay = ConfigHelper.channel_shutdown_delay()
if shutdown_delay > 0:
logger.info(f"Waiting {shutdown_delay}s before stopping channel...")
gevent.sleep(shutdown_delay) # REPLACE: time.sleep(shutdown_delay)
# Re-check client count before stopping
total = self.redis_client.scard(client_set_key) or 0
if total > 0:
logger.info(f"New clients connected during shutdown delay - aborting shutdown")
self.redis_client.delete(disconnect_key)
return
# Stop the channel directly
self.stop_channel(channel_id)
client_id = data.get("client_id")
worker_id = data.get("worker_id")
logger.debug(f"Owner received {EventType.CLIENT_DISCONNECTED} event for channel {channel_id}, client {client_id} from worker {worker_id}")
# Delegate to dedicated method
self.handle_client_disconnect(channel_id)
elif event_type == EventType.STREAM_SWITCH:
@ -339,20 +317,27 @@ class ProxyServer:
logger.error(f"Error in event listener: {e}. Retrying in {final_delay:.1f}s (attempt {retry_count})")
gevent.sleep(final_delay) # REPLACE: time.sleep(final_delay)
# Try to clean up the old connection
try:
if 'pubsub' in locals():
pubsub.close()
if 'pubsub_client' in locals():
pubsub_client.close()
except:
pass
except Exception as e:
logger.error(f"Error in event listener: {e}")
# Add a short delay to prevent rapid retries on persistent errors
gevent.sleep(5) # REPLACE: time.sleep(5)
finally:
# Always clean up PubSub connections in all error paths
try:
if pubsub:
pubsub.close()
pubsub = None
except Exception as e:
logger.debug(f"Error closing pubsub: {e}")
try:
if pubsub_client:
pubsub_client.close()
pubsub_client = None
except Exception as e:
logger.debug(f"Error closing pubsub_client: {e}")
thread = threading.Thread(target=event_listener, daemon=True)
thread.name = "redis-event-listener"
thread.start()
@ -486,17 +471,18 @@ class ProxyServer:
)
return True
# Create buffer and client manager instances
buffer = StreamBuffer(channel_id, redis_client=self.redis_client)
client_manager = ClientManager(
channel_id,
redis_client=self.redis_client,
worker_id=self.worker_id
)
# Create buffer and client manager instances (or reuse if they exist)
if channel_id not in self.stream_buffers:
buffer = StreamBuffer(channel_id, redis_client=self.redis_client)
self.stream_buffers[channel_id] = buffer
# Store in local tracking
self.stream_buffers[channel_id] = buffer
self.client_managers[channel_id] = client_manager
if channel_id not in self.client_managers:
client_manager = ClientManager(
channel_id,
redis_client=self.redis_client,
worker_id=self.worker_id
)
self.client_managers[channel_id] = client_manager
# IMPROVED: Set initializing state in Redis BEFORE any other operations
if self.redis_client:
@ -550,13 +536,15 @@ class ProxyServer:
logger.info(f"Channel {channel_id} already owned by worker {current_owner}")
logger.info(f"This worker ({self.worker_id}) will read from Redis buffer only")
# Create buffer but not stream manager
buffer = StreamBuffer(channel_id=channel_id, redis_client=self.redis_client)
self.stream_buffers[channel_id] = buffer
# Create buffer but not stream manager (only if not already exists)
if channel_id not in self.stream_buffers:
buffer = StreamBuffer(channel_id=channel_id, redis_client=self.redis_client)
self.stream_buffers[channel_id] = buffer
# Create client manager with channel_id and redis_client
client_manager = ClientManager(channel_id=channel_id, redis_client=self.redis_client, worker_id=self.worker_id)
self.client_managers[channel_id] = client_manager
# Create client manager with channel_id and redis_client (only if not already exists)
if channel_id not in self.client_managers:
client_manager = ClientManager(channel_id=channel_id, redis_client=self.redis_client, worker_id=self.worker_id)
self.client_managers[channel_id] = client_manager
return True
@ -571,13 +559,15 @@ class ProxyServer:
# Another worker just acquired ownership
logger.info(f"Another worker just acquired ownership of channel {channel_id}")
# Create buffer but not stream manager
buffer = StreamBuffer(channel_id=channel_id, redis_client=self.redis_client)
self.stream_buffers[channel_id] = buffer
# Create buffer but not stream manager (only if not already exists)
if channel_id not in self.stream_buffers:
buffer = StreamBuffer(channel_id=channel_id, redis_client=self.redis_client)
self.stream_buffers[channel_id] = buffer
# Create client manager with channel_id and redis_client
client_manager = ClientManager(channel_id=channel_id, redis_client=self.redis_client, worker_id=self.worker_id)
self.client_managers[channel_id] = client_manager
# Create client manager with channel_id and redis_client (only if not already exists)
if channel_id not in self.client_managers:
client_manager = ClientManager(channel_id=channel_id, redis_client=self.redis_client, worker_id=self.worker_id)
self.client_managers[channel_id] = client_manager
return True
@ -596,7 +586,7 @@ class ProxyServer:
if channel_user_agent:
metadata["user_agent"] = channel_user_agent
# CRITICAL FIX: Make sure stream_id is always set in metadata and properly logged
# Make sure stream_id is always set in metadata and properly logged
if channel_stream_id:
metadata["stream_id"] = str(channel_stream_id)
logger.info(f"Storing stream_id {channel_stream_id} in metadata for channel {channel_id}")
@ -632,13 +622,37 @@ class ProxyServer:
logger.info(f"Created StreamManager for channel {channel_id} with stream ID {channel_stream_id}")
self.stream_managers[channel_id] = stream_manager
# Create client manager with channel_id, redis_client AND worker_id
client_manager = ClientManager(
channel_id=channel_id,
redis_client=self.redis_client,
worker_id=self.worker_id
)
self.client_managers[channel_id] = client_manager
# Log channel start event
try:
channel_obj = Channel.objects.get(uuid=channel_id)
# Get stream name if stream_id is available
stream_name = None
if channel_stream_id:
try:
stream_obj = Stream.objects.get(id=channel_stream_id)
stream_name = stream_obj.name
except Exception:
pass
log_system_event(
'channel_start',
channel_id=channel_id,
channel_name=channel_obj.name,
stream_name=stream_name,
stream_id=channel_stream_id
)
except Exception as e:
logger.error(f"Could not log channel start event: {e}")
# Create client manager with channel_id, redis_client AND worker_id (only if not already exists)
if channel_id not in self.client_managers:
client_manager = ClientManager(
channel_id=channel_id,
redis_client=self.redis_client,
worker_id=self.worker_id
)
self.client_managers[channel_id] = client_manager
# Start stream manager thread only for the owner
thread = threading.Thread(target=stream_manager.run, daemon=True)
@ -688,9 +702,10 @@ class ProxyServer:
state = metadata.get(b'state', b'unknown').decode('utf-8')
owner = metadata.get(b'owner', b'').decode('utf-8')
# States that indicate the channel is running properly
# States that indicate the channel is running properly or shutting down
valid_states = [ChannelState.ACTIVE, ChannelState.WAITING_FOR_CLIENTS,
ChannelState.CONNECTING, ChannelState.BUFFERING, ChannelState.INITIALIZING]
ChannelState.CONNECTING, ChannelState.BUFFERING, ChannelState.INITIALIZING,
ChannelState.STOPPING]
# If the channel is in a valid state, check if the owner is still active
if state in valid_states:
@ -703,12 +718,24 @@ class ProxyServer:
else:
# This is a zombie channel - owner is gone but metadata still exists
logger.warning(f"Detected zombie channel {channel_id} - owner {owner} is no longer active")
# Check if there are any clients connected
client_set_key = RedisKeys.clients(channel_id)
client_count = self.redis_client.scard(client_set_key) or 0
if client_count > 0:
logger.warning(f"Zombie channel {channel_id} has {client_count} clients - attempting ownership takeover")
# Could potentially take ownership here in the future
# For now, just clean it up to be safe
else:
logger.warning(f"Zombie channel {channel_id} has no clients - cleaning up")
self._clean_zombie_channel(channel_id, metadata)
return False
elif state in [ChannelState.STOPPING, ChannelState.STOPPED, ChannelState.ERROR]:
# These states indicate the channel should be reinitialized
logger.info(f"Channel {channel_id} exists but in terminal state: {state}")
return True
elif state in [ChannelState.STOPPED, ChannelState.ERROR]:
# These terminal states indicate the channel should be cleaned up and reinitialized
logger.info(f"Channel {channel_id} in terminal state {state} - returning False to trigger cleanup")
return False
else:
# Unknown or initializing state, check how long it's been in this state
if b'state_changed_at' in metadata:
@ -772,6 +799,44 @@ class ProxyServer:
logger.error(f"Error cleaning zombie channel {channel_id}: {e}", exc_info=True)
return False
def handle_client_disconnect(self, channel_id):
"""
Handle client disconnect event - check if channel should shut down.
Can be called directly by owner or via PubSub from non-owner workers.
"""
if channel_id not in self.client_managers:
return
try:
# VERIFY REDIS CLIENT COUNT DIRECTLY
client_set_key = RedisKeys.clients(channel_id)
total = self.redis_client.scard(client_set_key) or 0
if total == 0:
logger.debug(f"No clients left after disconnect event - stopping channel {channel_id}")
# Set the disconnect timer for other workers to see
disconnect_key = RedisKeys.last_client_disconnect(channel_id)
self.redis_client.setex(disconnect_key, 60, str(time.time()))
# Get configured shutdown delay or default
shutdown_delay = ConfigHelper.channel_shutdown_delay()
if shutdown_delay > 0:
logger.info(f"Waiting {shutdown_delay}s before stopping channel...")
gevent.sleep(shutdown_delay)
# Re-check client count before stopping
total = self.redis_client.scard(client_set_key) or 0
if total > 0:
logger.info(f"New clients connected during shutdown delay - aborting shutdown")
self.redis_client.delete(disconnect_key)
return
# Stop the channel directly
self.stop_channel(channel_id)
except Exception as e:
logger.error(f"Error handling client disconnect for channel {channel_id}: {e}")
def stop_channel(self, channel_id):
"""Stop a channel with proper ownership handling"""
try:
@ -819,6 +884,41 @@ class ProxyServer:
self.release_ownership(channel_id)
logger.info(f"Released ownership of channel {channel_id}")
# Log channel stop event (after cleanup, before releasing ownership section ends)
try:
channel_obj = Channel.objects.get(uuid=channel_id)
# Calculate runtime and get total bytes from metadata
runtime = None
total_bytes = None
if self.redis_client:
metadata_key = RedisKeys.channel_metadata(channel_id)
metadata = self.redis_client.hgetall(metadata_key)
if metadata:
# Calculate runtime from init_time
if b'init_time' in metadata:
try:
init_time = float(metadata[b'init_time'].decode('utf-8'))
runtime = round(time.time() - init_time, 2)
except Exception:
pass
# Get total bytes transferred
if b'total_bytes' in metadata:
try:
total_bytes = int(metadata[b'total_bytes'].decode('utf-8'))
except Exception:
pass
log_system_event(
'channel_stop',
channel_id=channel_id,
channel_name=channel_obj.name,
runtime=runtime,
total_bytes=total_bytes
)
except Exception as e:
logger.error(f"Could not log channel stop event: {e}")
# Always clean up local resources - WITH SAFE CHECKS
if channel_id in self.stream_managers:
del self.stream_managers[channel_id]
@ -846,6 +946,10 @@ class ProxyServer:
# Clean up client manager - SAFE CHECK HERE TOO
if channel_id in self.client_managers:
try:
client_manager = self.client_managers[channel_id]
# Stop the heartbeat thread before deleting
if hasattr(client_manager, 'stop'):
client_manager.stop()
del self.client_managers[channel_id]
logger.info(f"Removed client manager for channel {channel_id}")
except KeyError:
@ -920,6 +1024,15 @@ class ProxyServer:
if channel_id in self.client_managers:
client_manager = self.client_managers[channel_id]
total_clients = client_manager.get_total_client_count()
else:
# This can happen during reconnection attempts or crashes
# Check Redis directly for any connected clients
if self.redis_client:
client_set_key = RedisKeys.clients(channel_id)
total_clients = self.redis_client.scard(client_set_key) or 0
if total_clients == 0:
logger.warning(f"Channel {channel_id} is missing client_manager but we're the owner with 0 clients - will trigger cleanup")
# Log client count periodically
if time.time() % 30 < 1: # Every ~30 seconds
@ -927,7 +1040,14 @@ class ProxyServer:
# If in connecting or waiting_for_clients state, check grace period
if channel_state in [ChannelState.CONNECTING, ChannelState.WAITING_FOR_CLIENTS]:
# Get connection ready time from metadata
# Check if channel is already stopping
if self.redis_client:
stop_key = RedisKeys.channel_stopping(channel_id)
if self.redis_client.exists(stop_key):
logger.debug(f"Channel {channel_id} is already stopping - skipping monitor shutdown")
continue
# Get connection_ready_time from metadata (indicates if channel reached ready state)
connection_ready_time = None
if metadata and b'connection_ready_time' in metadata:
try:
@ -935,17 +1055,60 @@ class ProxyServer:
except (ValueError, TypeError):
pass
# If still connecting, give it more time
if channel_state == ChannelState.CONNECTING:
logger.debug(f"Channel {channel_id} still connecting - not checking for clients yet")
continue
if total_clients == 0:
# Check if we have a connection_attempt timestamp (set when CONNECTING starts)
connection_attempt_time = None
attempt_key = RedisKeys.connection_attempt(channel_id)
if self.redis_client:
attempt_value = self.redis_client.get(attempt_key)
if attempt_value:
try:
connection_attempt_time = float(attempt_value.decode('utf-8'))
except (ValueError, TypeError):
pass
# If waiting for clients, check grace period
if connection_ready_time:
# Also get init time as a fallback
init_time = None
if metadata and b'init_time' in metadata:
try:
init_time = float(metadata[b'init_time'].decode('utf-8'))
except (ValueError, TypeError):
pass
# Use whichever timestamp we have (prefer connection_attempt as it's more recent)
start_time = connection_attempt_time or init_time
if start_time:
# Check which timeout to apply based on channel lifecycle
if connection_ready_time:
# Already reached ready - use shutdown_delay
time_since_ready = time.time() - connection_ready_time
shutdown_delay = ConfigHelper.channel_shutdown_delay()
if time_since_ready > shutdown_delay:
logger.warning(
f"Channel {channel_id} in {channel_state} state with 0 clients for {time_since_ready:.1f}s "
f"(after reaching ready, shutdown_delay: {shutdown_delay}s) - stopping channel"
)
self.stop_channel(channel_id)
continue
else:
# Never reached ready - use grace_period timeout
time_since_start = time.time() - start_time
connecting_timeout = ConfigHelper.channel_init_grace_period()
if time_since_start > connecting_timeout:
logger.warning(
f"Channel {channel_id} stuck in {channel_state} state for {time_since_start:.1f}s "
f"with no clients (timeout: {connecting_timeout}s) - stopping channel due to upstream issues"
)
self.stop_channel(channel_id)
continue
elif connection_ready_time:
# We have clients now, but check grace period for state transition
grace_period = ConfigHelper.channel_init_grace_period()
time_since_ready = time.time() - connection_ready_time
# Add this debug log
logger.debug(f"GRACE PERIOD CHECK: Channel {channel_id} in {channel_state} state, "
f"time_since_ready={time_since_ready:.1f}s, grace_period={grace_period}s, "
f"total_clients={total_clients}")
@ -954,16 +1117,9 @@ class ProxyServer:
# Still within grace period
logger.debug(f"Channel {channel_id} in grace period - {time_since_ready:.1f}s of {grace_period}s elapsed")
continue
elif total_clients == 0:
# Grace period expired with no clients
logger.info(f"Grace period expired ({time_since_ready:.1f}s > {grace_period}s) with no clients - stopping channel {channel_id}")
self.stop_channel(channel_id)
else:
# Grace period expired but we have clients - mark channel as active
# Grace period expired with clients - mark channel as active
logger.info(f"Grace period expired with {total_clients} clients - marking channel {channel_id} as active")
old_state = "unknown"
if metadata and b'state' in metadata:
old_state = metadata[b'state'].decode('utf-8')
if self.update_channel_state(channel_id, ChannelState.ACTIVE, {
"grace_period_ended_at": str(time.time()),
"clients_at_activation": str(total_clients)
@ -971,6 +1127,13 @@ class ProxyServer:
logger.info(f"Channel {channel_id} activated with {total_clients} clients after grace period")
# If active and no clients, start normal shutdown procedure
elif channel_state not in [ChannelState.CONNECTING, ChannelState.WAITING_FOR_CLIENTS] and total_clients == 0:
# Check if channel is already stopping
if self.redis_client:
stop_key = RedisKeys.channel_stopping(channel_id)
if self.redis_client.exists(stop_key):
logger.debug(f"Channel {channel_id} is already stopping - skipping monitor shutdown")
continue
# Check if there's a pending no-clients timeout
disconnect_key = RedisKeys.last_client_disconnect(channel_id)
disconnect_time = None
@ -1030,14 +1193,30 @@ class ProxyServer:
continue
# Check for local client count - if zero, clean up our local resources
if self.client_managers[channel_id].get_client_count() == 0:
# We're not the owner, and we have no local clients - clean up our resources
logger.debug(f"Non-owner cleanup: Channel {channel_id} has no local clients, cleaning up local resources")
if channel_id in self.client_managers:
if self.client_managers[channel_id].get_client_count() == 0:
# We're not the owner, and we have no local clients - clean up our resources
logger.debug(f"Non-owner cleanup: Channel {channel_id} has no local clients, cleaning up local resources")
self._cleanup_local_resources(channel_id)
else:
# This shouldn't happen, but clean up anyway
logger.warning(f"Non-owner cleanup: Channel {channel_id} has no client_manager entry, cleaning up local resources")
self._cleanup_local_resources(channel_id)
except Exception as e:
logger.error(f"Error in cleanup thread: {e}", exc_info=True)
# Periodically check for orphaned channels (every 30 seconds)
if hasattr(self, '_last_orphan_check'):
if time.time() - self._last_orphan_check > 30:
try:
self._check_orphaned_metadata()
self._last_orphan_check = time.time()
except Exception as orphan_error:
logger.error(f"Error checking orphaned metadata: {orphan_error}", exc_info=True)
else:
self._last_orphan_check = time.time()
gevent.sleep(ConfigHelper.cleanup_check_interval()) # REPLACE: time.sleep(ConfigHelper.cleanup_check_interval())
thread = threading.Thread(target=cleanup_task, daemon=True)
@ -1059,10 +1238,6 @@ class ProxyServer:
try:
channel_id = key.decode('utf-8').split(':')[2]
# Skip channels we already have locally
if channel_id in self.stream_buffers:
continue
# Check if this channel has an owner
owner = self.get_channel_owner(channel_id)
@ -1077,13 +1252,84 @@ class ProxyServer:
else:
# Orphaned channel with no clients - clean it up
logger.info(f"Cleaning up orphaned channel {channel_id}")
self._clean_redis_keys(channel_id)
# If we have it locally, stop it properly to clean up processes
if channel_id in self.stream_managers or channel_id in self.client_managers:
logger.info(f"Orphaned channel {channel_id} is local - calling stop_channel")
self.stop_channel(channel_id)
else:
# Just clean up Redis keys for remote channels
self._clean_redis_keys(channel_id)
except Exception as e:
logger.error(f"Error processing channel key {key}: {e}")
except Exception as e:
logger.error(f"Error checking orphaned channels: {e}")
def _check_orphaned_metadata(self):
"""
Check for metadata entries that have no owner and no clients.
This catches zombie channels that weren't cleaned up properly.
"""
if not self.redis_client:
return
try:
# Get all channel metadata keys
channel_pattern = "ts_proxy:channel:*:metadata"
channel_keys = self.redis_client.keys(channel_pattern)
for key in channel_keys:
try:
channel_id = key.decode('utf-8').split(':')[2]
# Get metadata first
metadata = self.redis_client.hgetall(key)
if not metadata:
# Empty metadata - clean it up
logger.warning(f"Found empty metadata for channel {channel_id} - cleaning up")
# If we have it locally, stop it properly
if channel_id in self.stream_managers or channel_id in self.client_managers:
self.stop_channel(channel_id)
else:
self._clean_redis_keys(channel_id)
continue
# Get owner
owner = metadata.get(b'owner', b'').decode('utf-8') if b'owner' in metadata else ''
# Check if owner is still alive
owner_alive = False
if owner:
owner_heartbeat_key = f"ts_proxy:worker:{owner}:heartbeat"
owner_alive = self.redis_client.exists(owner_heartbeat_key)
# Check client count
client_set_key = RedisKeys.clients(channel_id)
client_count = self.redis_client.scard(client_set_key) or 0
# If no owner and no clients, clean it up
if not owner_alive and client_count == 0:
state = metadata.get(b'state', b'unknown').decode('utf-8') if b'state' in metadata else 'unknown'
logger.warning(f"Found orphaned metadata for channel {channel_id} (state: {state}, owner: {owner}, clients: {client_count}) - cleaning up")
# If we have it locally, stop it properly to clean up transcode/proxy processes
if channel_id in self.stream_managers or channel_id in self.client_managers:
logger.info(f"Channel {channel_id} is local - calling stop_channel to clean up processes")
self.stop_channel(channel_id)
else:
# Just clean up Redis keys for remote channels
self._clean_redis_keys(channel_id)
elif not owner_alive and client_count > 0:
# Owner is gone but clients remain - just log for now
logger.warning(f"Found orphaned channel {channel_id} with {client_count} clients but no owner - may need ownership takeover")
except Exception as e:
logger.error(f"Error processing metadata key {key}: {e}", exc_info=True)
except Exception as e:
logger.error(f"Error checking orphaned metadata: {e}", exc_info=True)
def _clean_redis_keys(self, channel_id):
"""Clean up all Redis keys for a channel more efficiently"""
# Release the channel, stream, and profile keys from the channel

View file

@ -14,6 +14,8 @@ from ..server import ProxyServer
from ..redis_keys import RedisKeys
from ..constants import EventType, ChannelState, ChannelMetadataField
from ..url_utils import get_stream_info_for_switch
from core.utils import log_system_event
from .log_parsers import LogParserFactory
logger = logging.getLogger("ts_proxy")
@ -417,106 +419,52 @@ class ChannelService:
return False, None, None, {"error": f"Exception: {str(e)}"}
@staticmethod
def parse_and_store_stream_info(channel_id, stream_info_line, stream_type="video"):
"""Parse FFmpeg stream info line and store in Redis metadata"""
def parse_and_store_stream_info(channel_id, stream_info_line, stream_type="video", stream_id=None):
"""
Parse stream info from FFmpeg/VLC/Streamlink logs and store in Redis/DB.
Uses specialized parsers for each streaming tool.
"""
try:
if stream_type == "input":
# Example lines:
# Input #0, mpegts, from 'http://example.com/stream.ts':
# Input #0, hls, from 'http://example.com/stream.m3u8':
# Use factory to parse the line based on stream type
parsed_data = LogParserFactory.parse(stream_type, stream_info_line)
if not parsed_data:
return
# Extract input format (e.g., "mpegts", "hls", "flv", etc.)
input_match = re.search(r'Input #\d+,\s*([^,]+)', stream_info_line)
input_format = input_match.group(1).strip() if input_match else None
# Update Redis and database with parsed data
ChannelService._update_stream_info_in_redis(
channel_id,
parsed_data.get('video_codec'),
parsed_data.get('resolution'),
parsed_data.get('width'),
parsed_data.get('height'),
parsed_data.get('source_fps'),
parsed_data.get('pixel_format'),
parsed_data.get('video_bitrate'),
parsed_data.get('audio_codec'),
parsed_data.get('sample_rate'),
parsed_data.get('audio_channels'),
parsed_data.get('audio_bitrate'),
parsed_data.get('stream_type')
)
# Store in Redis if we have valid data
if input_format:
ChannelService._update_stream_info_in_redis(channel_id, None, None, None, None, None, None, None, None, None, None, None, input_format)
logger.debug(f"Input format info - Format: {input_format} for channel {channel_id}")
elif stream_type == "video":
# Example line:
# Stream #0:0: Video: h264 (Main), yuv420p(tv, progressive), 1280x720 [SAR 1:1 DAR 16:9], q=2-31, 2000 kb/s, 29.97 fps, 90k tbn
# Extract video codec (e.g., "h264", "mpeg2video", etc.)
codec_match = re.search(r'Video:\s*([a-zA-Z0-9_]+)', stream_info_line)
video_codec = codec_match.group(1) if codec_match else None
# Extract resolution (e.g., "1280x720") - be more specific to avoid hex values
# Look for resolution patterns that are realistic video dimensions
resolution_match = re.search(r'\b(\d{3,5})x(\d{3,5})\b', stream_info_line)
if resolution_match:
width = int(resolution_match.group(1))
height = int(resolution_match.group(2))
# Validate that these look like reasonable video dimensions
if 100 <= width <= 10000 and 100 <= height <= 10000:
resolution = f"{width}x{height}"
else:
width = height = resolution = None
else:
width = height = resolution = None
# Extract source FPS (e.g., "29.97 fps")
fps_match = re.search(r'(\d+(?:\.\d+)?)\s*fps', stream_info_line)
source_fps = float(fps_match.group(1)) if fps_match else None
# Extract pixel format (e.g., "yuv420p")
pixel_format_match = re.search(r'Video:\s*[^,]+,\s*([^,(]+)', stream_info_line)
pixel_format = None
if pixel_format_match:
pf = pixel_format_match.group(1).strip()
# Clean up pixel format (remove extra info in parentheses)
if '(' in pf:
pf = pf.split('(')[0].strip()
pixel_format = pf
# Extract bitrate if present (e.g., "2000 kb/s")
video_bitrate = None
bitrate_match = re.search(r'(\d+(?:\.\d+)?)\s*kb/s', stream_info_line)
if bitrate_match:
video_bitrate = float(bitrate_match.group(1))
# Store in Redis if we have valid data
if any(x is not None for x in [video_codec, resolution, source_fps, pixel_format, video_bitrate]):
ChannelService._update_stream_info_in_redis(channel_id, video_codec, resolution, width, height, source_fps, pixel_format, video_bitrate, None, None, None, None, None)
logger.info(f"Video stream info - Codec: {video_codec}, Resolution: {resolution}, "
f"Source FPS: {source_fps}, Pixel Format: {pixel_format}, "
f"Video Bitrate: {video_bitrate} kb/s")
elif stream_type == "audio":
# Example line:
# Stream #0:1[0x101]: Audio: aac (LC) ([15][0][0][0] / 0x000F), 48000 Hz, stereo, fltp, 64 kb/s
# Extract audio codec (e.g., "aac", "mp3", etc.)
codec_match = re.search(r'Audio:\s*([a-zA-Z0-9_]+)', stream_info_line)
audio_codec = codec_match.group(1) if codec_match else None
# Extract sample rate (e.g., "48000 Hz")
sample_rate_match = re.search(r'(\d+)\s*Hz', stream_info_line)
sample_rate = int(sample_rate_match.group(1)) if sample_rate_match else None
# Extract channel layout (e.g., "stereo", "5.1", "mono")
# Look for common channel layouts
channel_match = re.search(r'\b(mono|stereo|5\.1|7\.1|quad|2\.1)\b', stream_info_line, re.IGNORECASE)
channels = channel_match.group(1) if channel_match else None
# Extract audio bitrate if present (e.g., "64 kb/s")
audio_bitrate = None
bitrate_match = re.search(r'(\d+(?:\.\d+)?)\s*kb/s', stream_info_line)
if bitrate_match:
audio_bitrate = float(bitrate_match.group(1))
# Store in Redis if we have valid data
if any(x is not None for x in [audio_codec, sample_rate, channels, audio_bitrate]):
ChannelService._update_stream_info_in_redis(channel_id, None, None, None, None, None, None, None, audio_codec, sample_rate, channels, audio_bitrate, None)
logger.info(f"Audio stream info - Codec: {audio_codec}, Sample Rate: {sample_rate} Hz, "
f"Channels: {channels}, Audio Bitrate: {audio_bitrate} kb/s")
if stream_id:
ChannelService._update_stream_stats_in_db(
stream_id,
video_codec=parsed_data.get('video_codec'),
resolution=parsed_data.get('resolution'),
source_fps=parsed_data.get('source_fps'),
pixel_format=parsed_data.get('pixel_format'),
video_bitrate=parsed_data.get('video_bitrate'),
audio_codec=parsed_data.get('audio_codec'),
sample_rate=parsed_data.get('sample_rate'),
audio_channels=parsed_data.get('audio_channels'),
audio_bitrate=parsed_data.get('audio_bitrate'),
stream_type=parsed_data.get('stream_type')
)
except Exception as e:
logger.debug(f"Error parsing FFmpeg {stream_type} stream info: {e}")
logger.debug(f"Error parsing {stream_type} stream info: {e}")
@staticmethod
def _update_stream_info_in_redis(channel_id, codec, resolution, width, height, fps, pixel_format, video_bitrate, audio_codec=None, sample_rate=None, channels=None, audio_bitrate=None, input_format=None):
@ -575,6 +523,44 @@ class ChannelService:
logger.error(f"Error updating stream info in Redis: {e}")
return False
@staticmethod
def _update_stream_stats_in_db(stream_id, **stats):
"""Update stream stats in database"""
from django.db import connection
try:
from apps.channels.models import Stream
from django.utils import timezone
stream = Stream.objects.get(id=stream_id)
# Get existing stats or create new dict
current_stats = stream.stream_stats or {}
# Update with new stats
for key, value in stats.items():
if value is not None:
current_stats[key] = value
# Save updated stats and timestamp
stream.stream_stats = current_stats
stream.stream_stats_updated_at = timezone.now()
stream.save(update_fields=['stream_stats', 'stream_stats_updated_at'])
logger.debug(f"Updated stream stats in database for stream {stream_id}: {stats}")
return True
except Exception as e:
logger.error(f"Error updating stream stats in database for stream {stream_id}: {e}")
return False
finally:
# Always close database connection after update
try:
connection.close()
except Exception:
pass
# Helper methods for Redis operations
@staticmethod
@ -630,7 +616,7 @@ class ChannelService:
switch_request = {
"event": EventType.STREAM_SWITCH,
"channel_id": channel_id,
"channel_id": str(channel_id),
"url": new_url,
"user_agent": user_agent,
"stream_id": stream_id,
@ -643,6 +629,7 @@ class ChannelService:
RedisKeys.events_channel(channel_id),
json.dumps(switch_request)
)
return True
@staticmethod
@ -655,7 +642,7 @@ class ChannelService:
stop_request = {
"event": EventType.CHANNEL_STOP,
"channel_id": channel_id,
"channel_id": str(channel_id),
"requester_worker_id": proxy_server.worker_id,
"timestamp": time.time()
}
@ -678,7 +665,7 @@ class ChannelService:
stop_request = {
"event": EventType.CLIENT_STOP,
"channel_id": channel_id,
"channel_id": str(channel_id),
"client_id": client_id,
"requester_worker_id": proxy_server.worker_id,
"timestamp": time.time()

View file

@ -0,0 +1,410 @@
"""Log parsers for FFmpeg, Streamlink, and VLC output."""
import re
import logging
from abc import ABC, abstractmethod
from typing import Optional, Dict, Any
logger = logging.getLogger(__name__)
class BaseLogParser(ABC):
"""Base class for log parsers"""
# Map of stream_type -> method_name that this parser handles
STREAM_TYPE_METHODS: Dict[str, str] = {}
@abstractmethod
def can_parse(self, line: str) -> Optional[str]:
"""
Check if this parser can handle the line.
Returns the stream_type if it can parse, None otherwise.
e.g., 'video', 'audio', 'vlc_video', 'vlc_audio', 'streamlink'
"""
pass
@abstractmethod
def parse_input_format(self, line: str) -> Optional[Dict[str, Any]]:
pass
@abstractmethod
def parse_video_stream(self, line: str) -> Optional[Dict[str, Any]]:
pass
@abstractmethod
def parse_audio_stream(self, line: str) -> Optional[Dict[str, Any]]:
pass
class FFmpegLogParser(BaseLogParser):
"""Parser for FFmpeg log output"""
STREAM_TYPE_METHODS = {
'input': 'parse_input_format',
'video': 'parse_video_stream',
'audio': 'parse_audio_stream'
}
def can_parse(self, line: str) -> Optional[str]:
"""Check if this is an FFmpeg line we can parse"""
lower = line.lower()
# Input format detection
if lower.startswith('input #'):
return 'input'
# Stream info (only during input phase, but we'll let stream_manager handle phase tracking)
if 'stream #' in lower:
if 'video:' in lower:
return 'video'
elif 'audio:' in lower:
return 'audio'
return None
def parse_input_format(self, line: str) -> Optional[Dict[str, Any]]:
"""Parse FFmpeg input format (e.g., mpegts, hls)"""
try:
input_match = re.search(r'Input #\d+,\s*([^,]+)', line)
input_format = input_match.group(1).strip() if input_match else None
if input_format:
logger.debug(f"Input format info - Format: {input_format}")
return {'stream_type': input_format}
except Exception as e:
logger.debug(f"Error parsing FFmpeg input format: {e}")
return None
def parse_video_stream(self, line: str) -> Optional[Dict[str, Any]]:
"""Parse FFmpeg video stream info"""
try:
result = {}
# Extract codec, resolution, fps, pixel format, bitrate
codec_match = re.search(r'Video:\s*([a-zA-Z0-9_]+)', line)
if codec_match:
result['video_codec'] = codec_match.group(1)
resolution_match = re.search(r'\b(\d{3,5})x(\d{3,5})\b', line)
if resolution_match:
width = int(resolution_match.group(1))
height = int(resolution_match.group(2))
if 100 <= width <= 10000 and 100 <= height <= 10000:
result['resolution'] = f"{width}x{height}"
result['width'] = width
result['height'] = height
fps_match = re.search(r'(\d+(?:\.\d+)?)\s*fps', line)
if fps_match:
result['source_fps'] = float(fps_match.group(1))
pixel_format_match = re.search(r'Video:\s*[^,]+,\s*([^,(]+)', line)
if pixel_format_match:
pf = pixel_format_match.group(1).strip()
if '(' in pf:
pf = pf.split('(')[0].strip()
result['pixel_format'] = pf
bitrate_match = re.search(r'(\d+(?:\.\d+)?)\s*kb/s', line)
if bitrate_match:
result['video_bitrate'] = float(bitrate_match.group(1))
if result:
logger.info(f"Video stream info - Codec: {result.get('video_codec')}, "
f"Resolution: {result.get('resolution')}, "
f"Source FPS: {result.get('source_fps')}, "
f"Pixel Format: {result.get('pixel_format')}, "
f"Video Bitrate: {result.get('video_bitrate')} kb/s")
return result
except Exception as e:
logger.debug(f"Error parsing FFmpeg video stream info: {e}")
return None
def parse_audio_stream(self, line: str) -> Optional[Dict[str, Any]]:
"""Parse FFmpeg audio stream info"""
try:
result = {}
codec_match = re.search(r'Audio:\s*([a-zA-Z0-9_]+)', line)
if codec_match:
result['audio_codec'] = codec_match.group(1)
sample_rate_match = re.search(r'(\d+)\s*Hz', line)
if sample_rate_match:
result['sample_rate'] = int(sample_rate_match.group(1))
channel_match = re.search(r'\b(mono|stereo|5\.1|7\.1|quad|2\.1)\b', line, re.IGNORECASE)
if channel_match:
result['audio_channels'] = channel_match.group(1)
bitrate_match = re.search(r'(\d+(?:\.\d+)?)\s*kb/s', line)
if bitrate_match:
result['audio_bitrate'] = float(bitrate_match.group(1))
if result:
return result
except Exception as e:
logger.debug(f"Error parsing FFmpeg audio stream info: {e}")
return None
class VLCLogParser(BaseLogParser):
"""Parser for VLC log output"""
STREAM_TYPE_METHODS = {
'vlc_video': 'parse_video_stream',
'vlc_audio': 'parse_audio_stream'
}
def can_parse(self, line: str) -> Optional[str]:
"""Check if this is a VLC line we can parse"""
lower = line.lower()
# VLC TS demux codec detection
if 'ts demux debug' in lower and 'type=' in lower:
if 'video' in lower:
return 'vlc_video'
elif 'audio' in lower:
return 'vlc_audio'
# VLC decoder output
if 'decoder' in lower and ('channels:' in lower or 'samplerate:' in lower or 'x' in line or 'fps' in lower):
if 'audio' in lower or 'channels:' in lower or 'samplerate:' in lower:
return 'vlc_audio'
else:
return 'vlc_video'
# VLC transcode output for resolution/FPS
if 'stream_out_transcode' in lower and ('source fps' in lower or ('source ' in lower and 'x' in line)):
return 'vlc_video'
return None
def parse_input_format(self, line: str) -> Optional[Dict[str, Any]]:
return None
def parse_video_stream(self, line: str) -> Optional[Dict[str, Any]]:
"""Parse VLC TS demux output and decoder info for video"""
try:
lower = line.lower()
result = {}
# Codec detection from TS demux
video_codec_map = {
('avc', 'h.264', 'type=0x1b'): "h264",
('hevc', 'h.265', 'type=0x24'): "hevc",
('mpeg-2', 'type=0x02'): "mpeg2video",
('mpeg-4', 'type=0x10'): "mpeg4"
}
for patterns, codec in video_codec_map.items():
if any(p in lower for p in patterns):
result['video_codec'] = codec
break
# Extract FPS from transcode output: "source fps 30/1"
fps_fraction_match = re.search(r'source fps\s+(\d+)/(\d+)', lower)
if fps_fraction_match:
numerator = int(fps_fraction_match.group(1))
denominator = int(fps_fraction_match.group(2))
if denominator > 0:
result['source_fps'] = numerator / denominator
# Extract resolution from transcode output: "source 1280x720"
source_res_match = re.search(r'source\s+(\d{3,4})x(\d{3,4})', lower)
if source_res_match:
width = int(source_res_match.group(1))
height = int(source_res_match.group(2))
if 100 <= width <= 10000 and 100 <= height <= 10000:
result['resolution'] = f"{width}x{height}"
result['width'] = width
result['height'] = height
else:
# Fallback: generic resolution pattern
resolution_match = re.search(r'(\d{3,4})x(\d{3,4})', line)
if resolution_match:
width = int(resolution_match.group(1))
height = int(resolution_match.group(2))
if 100 <= width <= 10000 and 100 <= height <= 10000:
result['resolution'] = f"{width}x{height}"
result['width'] = width
result['height'] = height
# Fallback: try to extract FPS from generic format
if 'source_fps' not in result:
fps_match = re.search(r'(\d+\.?\d*)\s*fps', lower)
if fps_match:
result['source_fps'] = float(fps_match.group(1))
return result if result else None
except Exception as e:
logger.debug(f"Error parsing VLC video stream info: {e}")
return None
def parse_audio_stream(self, line: str) -> Optional[Dict[str, Any]]:
"""Parse VLC TS demux output and decoder info for audio"""
try:
lower = line.lower()
result = {}
# Codec detection from TS demux
audio_codec_map = {
('type=0xf', 'adts'): "aac",
('type=0x03', 'type=0x04'): "mp3",
('type=0x06', 'type=0x81'): "ac3",
('type=0x0b', 'lpcm'): "pcm"
}
for patterns, codec in audio_codec_map.items():
if any(p in lower for p in patterns):
result['audio_codec'] = codec
break
# VLC decoder format: "AAC channels: 2 samplerate: 48000"
if 'channels:' in lower:
channels_match = re.search(r'channels:\s*(\d+)', lower)
if channels_match:
num_channels = int(channels_match.group(1))
# Convert number to name
channel_names = {1: 'mono', 2: 'stereo', 6: '5.1', 8: '7.1'}
result['audio_channels'] = channel_names.get(num_channels, str(num_channels))
if 'samplerate:' in lower:
samplerate_match = re.search(r'samplerate:\s*(\d+)', lower)
if samplerate_match:
result['sample_rate'] = int(samplerate_match.group(1))
# Try to extract sample rate (Hz format)
sample_rate_match = re.search(r'(\d+)\s*hz', lower)
if sample_rate_match and 'sample_rate' not in result:
result['sample_rate'] = int(sample_rate_match.group(1))
# Try to extract channels (word format)
if 'audio_channels' not in result:
channel_match = re.search(r'\b(mono|stereo|5\.1|7\.1|quad|2\.1)\b', lower)
if channel_match:
result['audio_channels'] = channel_match.group(1)
return result if result else None
except Exception as e:
logger.error(f"[VLC AUDIO PARSER] Error parsing VLC audio stream info: {e}")
return None
class StreamlinkLogParser(BaseLogParser):
"""Parser for Streamlink log output"""
STREAM_TYPE_METHODS = {
'streamlink': 'parse_video_stream'
}
def can_parse(self, line: str) -> Optional[str]:
"""Check if this is a Streamlink line we can parse"""
lower = line.lower()
if 'opening stream:' in lower or 'available streams:' in lower:
return 'streamlink'
return None
def parse_input_format(self, line: str) -> Optional[Dict[str, Any]]:
return None
def parse_video_stream(self, line: str) -> Optional[Dict[str, Any]]:
"""Parse Streamlink quality/resolution"""
try:
quality_match = re.search(r'(\d+p|\d+x\d+)', line)
if quality_match:
quality = quality_match.group(1)
if 'x' in quality:
resolution = quality
width, height = map(int, quality.split('x'))
else:
resolutions = {
'2160p': ('3840x2160', 3840, 2160),
'1080p': ('1920x1080', 1920, 1080),
'720p': ('1280x720', 1280, 720),
'480p': ('854x480', 854, 480),
'360p': ('640x360', 640, 360)
}
resolution, width, height = resolutions.get(quality, ('1920x1080', 1920, 1080))
return {
'video_codec': 'h264',
'resolution': resolution,
'width': width,
'height': height,
'pixel_format': 'yuv420p'
}
except Exception as e:
logger.debug(f"Error parsing Streamlink video info: {e}")
return None
def parse_audio_stream(self, line: str) -> Optional[Dict[str, Any]]:
return None
class LogParserFactory:
"""Factory to get the appropriate log parser"""
_parsers = {
'ffmpeg': FFmpegLogParser(),
'vlc': VLCLogParser(),
'streamlink': StreamlinkLogParser()
}
@classmethod
def _get_parser_and_method(cls, stream_type: str) -> Optional[tuple[BaseLogParser, str]]:
"""Determine parser and method from stream_type"""
# Check each parser to see if it handles this stream_type
for parser in cls._parsers.values():
method_name = parser.STREAM_TYPE_METHODS.get(stream_type)
if method_name:
return (parser, method_name)
return None
@classmethod
def parse(cls, stream_type: str, line: str) -> Optional[Dict[str, Any]]:
"""
Parse a log line based on stream type.
Returns parsed data or None if parsing fails.
"""
result = cls._get_parser_and_method(stream_type)
if not result:
return None
parser, method_name = result
method = getattr(parser, method_name, None)
if method:
return method(line)
return None
@classmethod
def auto_parse(cls, line: str) -> Optional[tuple[str, Dict[str, Any]]]:
"""
Automatically detect which parser can handle this line and parse it.
Returns (stream_type, parsed_data) or None if no parser can handle it.
"""
# Try each parser to see if it can handle this line
for parser in cls._parsers.values():
stream_type = parser.can_parse(line)
if stream_type:
# Parser can handle this line, now parse it
parsed_data = cls.parse(stream_type, line)
if parsed_data:
return (stream_type, parsed_data)
return None

View file

@ -303,6 +303,14 @@ class StreamBuffer:
# Retrieve chunks
chunks = self.get_chunks_exact(client_index, chunk_count)
# Check if we got significantly fewer chunks than expected (likely due to expiration)
# Only check if we expected multiple chunks and got none or very few
if chunk_count > 3 and len(chunks) == 0 and chunks_behind > 10:
# Chunks are missing - likely expired from Redis
# Return empty list to signal client should skip forward
logger.debug(f"Chunks missing for client at index {client_index}, buffer at {self.index} ({chunks_behind} behind)")
return [], client_index
# Check total size
total_size = sum(len(c) for c in chunks)
@ -316,7 +324,7 @@ class StreamBuffer:
additional_size = sum(len(c) for c in more_chunks)
if total_size + additional_size <= MAX_SIZE:
chunks.extend(more_chunks)
chunk_count += additional
chunk_count += len(more_chunks) # Fixed: count actual additional chunks retrieved
return chunks, client_index + chunk_count

View file

@ -8,6 +8,8 @@ import logging
import threading
import gevent # Add this import at the top of your file
from apps.proxy.config import TSConfig as Config
from apps.channels.models import Channel
from core.utils import log_system_event
from .server import ProxyServer
from .utils import create_ts_packet, get_logger
from .redis_keys import RedisKeys
@ -52,6 +54,10 @@ class StreamGenerator:
self.last_stats_bytes = 0
self.current_rate = 0.0
# TTL refresh tracking
self.last_ttl_refresh = time.time()
self.ttl_refresh_interval = 3 # Refresh TTL every 3 seconds of active streaming
def generate(self):
"""
Generator function that produces the stream content for the client.
@ -84,6 +90,20 @@ class StreamGenerator:
if not self._setup_streaming():
return
# Log client connect event
try:
channel_obj = Channel.objects.get(uuid=self.channel_id)
log_system_event(
'client_connect',
channel_id=self.channel_id,
channel_name=channel_obj.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
)
except Exception as e:
logger.error(f"Could not log client connect event: {e}")
# Main streaming loop
for chunk in self._stream_data_generator():
yield chunk
@ -204,6 +224,18 @@ class StreamGenerator:
self.empty_reads += 1
self.consecutive_empty += 1
# Check if we're too far behind (chunks expired from Redis)
chunks_behind = self.buffer.index - self.local_index
if chunks_behind > 50: # If more than 50 chunks behind, jump forward
# Calculate new position: stay a few chunks behind current buffer
initial_behind = ConfigHelper.initial_behind_chunks()
new_index = max(self.local_index, self.buffer.index - initial_behind)
logger.warning(f"[{self.client_id}] Client too far behind ({chunks_behind} chunks), jumping from {self.local_index} to {new_index}")
self.local_index = new_index
self.consecutive_empty = 0 # Reset since we're repositioning
continue # Try again immediately with new position
if self._should_send_keepalive(self.local_index):
keepalive_packet = create_ts_packet('keepalive')
logger.debug(f"[{self.client_id}] Sending keepalive packet while waiting at buffer head")
@ -324,7 +356,20 @@ class StreamGenerator:
ChannelMetadataField.STATS_UPDATED_AT: str(current_time)
}
proxy_server.redis_client.hset(client_key, mapping=stats)
# No need to set expiration as client heartbeat will refresh this key
# Refresh TTL periodically while actively streaming
# This provides proof-of-life independent of heartbeat thread
if current_time - self.last_ttl_refresh > self.ttl_refresh_interval:
try:
# Refresh TTL on client key
proxy_server.redis_client.expire(client_key, Config.CLIENT_RECORD_TTL)
# Also refresh the client set TTL
client_set_key = f"ts_proxy:channel:{self.channel_id}:clients"
proxy_server.redis_client.expire(client_set_key, Config.CLIENT_RECORD_TTL)
self.last_ttl_refresh = current_time
logger.debug(f"[{self.client_id}] Refreshed client TTL (active streaming)")
except Exception as ttl_error:
logger.debug(f"[{self.client_id}] Failed to refresh TTL: {ttl_error}")
except Exception as e:
logger.warning(f"[{self.client_id}] Failed to store stats in Redis: {e}")
@ -410,6 +455,22 @@ class StreamGenerator:
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:
channel_obj = Channel.objects.get(uuid=self.channel_id)
log_system_event(
'client_disconnect',
channel_id=self.channel_id,
channel_name=channel_obj.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
)
except Exception as e:
logger.error(f"Could not log client disconnect event: {e}")
# Schedule channel shutdown if no clients left
if not stream_released: # Only if we haven't already released the stream
self._schedule_channel_shutdown_if_needed(local_clients)

View file

@ -9,11 +9,14 @@ import subprocess
import gevent
import re
from typing import Optional, List
from django.db import connection
from django.shortcuts import get_object_or_404
from urllib3.exceptions import ReadTimeoutError
from apps.proxy.config import TSConfig as Config
from apps.channels.models import Channel, Stream
from apps.m3u.models import M3UAccount, M3UAccountProfile
from core.models import UserAgent, CoreSettings
from core.utils import log_system_event
from .stream_buffer import StreamBuffer
from .utils import detect_stream_type, get_logger
from .redis_keys import RedisKeys
@ -91,17 +94,23 @@ class StreamManager:
self.tried_stream_ids.add(self.current_stream_id)
logger.info(f"Loaded stream ID {self.current_stream_id} from Redis for channel {buffer.channel_id}")
else:
logger.warning(f"No stream_id found in Redis for channel {channel_id}")
logger.warning(f"No stream_id found in Redis for channel {channel_id}. "
f"Stream switching will rely on URL comparison to avoid selecting the same stream.")
except Exception as e:
logger.warning(f"Error loading stream ID from Redis: {e}")
else:
logger.warning(f"Unable to get stream ID for channel {channel_id} - stream switching may not work correctly")
logger.warning(f"Unable to get stream ID for channel {channel_id}. "
f"Stream switching will rely on URL comparison to avoid selecting the same stream.")
logger.info(f"Initialized stream manager for channel {buffer.channel_id}")
# Add this flag for tracking transcoding process status
self.transcode_process_active = False
# Track stream command for efficient log parser routing
self.stream_command = None
self.parser_type = None # Will be set when transcode process starts
# Add tracking for data throughput
self.bytes_processed = 0
self.last_bytes_update = time.time()
@ -111,6 +120,9 @@ class StreamManager:
self.stderr_reader_thread = None
self.ffmpeg_input_phase = True # Track if we're still reading input info
# Add HTTP reader thread property
self.http_reader = None
def _create_session(self):
"""Create and configure requests session with optimal settings"""
session = requests.Session()
@ -220,11 +232,12 @@ class StreamManager:
# Continue with normal flow
# Check stream type before connecting
stream_type = detect_stream_type(self.url)
if self.transcode == False and stream_type == StreamType.HLS:
logger.info(f"Detected HLS stream: {self.url} for channel {self.channel_id}")
logger.info(f"HLS streams will be handled with FFmpeg for now - future version will support HLS natively for channel {self.channel_id}")
# Enable transcoding for HLS streams
self.stream_type = detect_stream_type(self.url)
if self.transcode == False and self.stream_type in (StreamType.HLS, StreamType.RTSP, StreamType.UDP):
stream_type_name = "HLS" if self.stream_type == StreamType.HLS else ("RTSP/RTP" if self.stream_type == StreamType.RTSP else "UDP")
logger.info(f"Detected {stream_type_name} stream: {self.url} for channel {self.channel_id}")
logger.info(f"{stream_type_name} streams require FFmpeg for channel {self.channel_id}")
# Enable transcoding for HLS, RTSP/RTP, and UDP streams
self.transcode = True
# We'll override the stream profile selection with ffmpeg in the transcoding section
self.force_ffmpeg = True
@ -252,6 +265,20 @@ class StreamManager:
# Store connection start time to measure success duration
connection_start_time = time.time()
# Log reconnection event if this is a retry (not first attempt)
if self.retry_count > 0:
try:
channel_obj = Channel.objects.get(uuid=self.channel_id)
log_system_event(
'channel_reconnect',
channel_id=self.channel_id,
channel_name=channel_obj.name,
attempt=self.retry_count + 1,
max_attempts=self.max_retries
)
except Exception as e:
logger.error(f"Could not log reconnection event: {e}")
# Successfully connected - read stream data until disconnect/error
self._process_stream_data()
# If we get here, the connection was closed/failed
@ -281,6 +308,20 @@ class StreamManager:
if self.retry_count >= self.max_retries:
url_failed = True
logger.warning(f"Maximum retry attempts ({self.max_retries}) reached for URL: {self.url} for channel: {self.channel_id}")
# Log connection error event
try:
channel_obj = Channel.objects.get(uuid=self.channel_id)
log_system_event(
'channel_error',
channel_id=self.channel_id,
channel_name=channel_obj.name,
error_type='connection_failed',
url=self.url[:100] if self.url else None,
attempts=self.max_retries
)
except Exception as e:
logger.error(f"Could not log connection error event: {e}")
else:
# Wait with exponential backoff before retrying
timeout = min(.25 * self.retry_count, 3) # Cap at 3 seconds
@ -294,6 +335,21 @@ class StreamManager:
if self.retry_count >= self.max_retries:
url_failed = True
# Log connection error event with exception details
try:
channel_obj = Channel.objects.get(uuid=self.channel_id)
log_system_event(
'channel_error',
channel_id=self.channel_id,
channel_name=channel_obj.name,
error_type='connection_exception',
error_message=str(e)[:200],
url=self.url[:100] if self.url else None,
attempts=self.max_retries
)
except Exception as log_error:
logger.error(f"Could not log connection error event: {log_error}")
else:
# Wait with exponential backoff before retrying
timeout = min(.25 * self.retry_count, 3) # Cap at 3 seconds
@ -378,6 +434,12 @@ class StreamManager:
except Exception as e:
logger.error(f"Failed to update channel state in Redis: {e} for channel {self.channel_id}", exc_info=True)
# Close database connection for this thread
try:
connection.close()
except Exception:
pass
logger.info(f"Stream manager stopped for channel {self.channel_id}")
def _establish_transcode_connection(self):
@ -407,7 +469,7 @@ class StreamManager:
from core.models import StreamProfile
try:
stream_profile = StreamProfile.objects.get(name='ffmpeg', locked=True)
logger.info("Using FFmpeg stream profile for HLS content")
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()
@ -417,6 +479,28 @@ class StreamManager:
# 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")
# 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}")
logger.debug(f"Starting transcode process: {self.transcode_cmd} for channel: {self.channel_id}")
# Modified to capture stderr instead of discarding it
@ -580,35 +664,51 @@ class StreamManager:
if content_lower.startswith('output #') or 'encoder' in content_lower:
self.ffmpeg_input_phase = False
# Only parse stream info if we're still in the input phase
if ("stream #" in content_lower and
("video:" in content_lower or "audio:" in content_lower) and
self.ffmpeg_input_phase):
# Route to appropriate parser based on known command type
from .services.log_parsers import LogParserFactory
from .services.channel_service import ChannelService
from .services.channel_service import ChannelService
if "video:" in content_lower:
ChannelService.parse_and_store_stream_info(self.channel_id, content, "video")
elif "audio:" in content_lower:
ChannelService.parse_and_store_stream_info(self.channel_id, content, "audio")
parse_result = None
# If we know the parser type, use direct routing for efficiency
if self.parser_type:
# Get the appropriate parser and check what it can parse
parser = LogParserFactory._parsers.get(self.parser_type)
if parser:
stream_type = parser.can_parse(content)
if stream_type:
# Parser can handle this line, parse it directly
parsed_data = LogParserFactory.parse(stream_type, content)
if parsed_data:
parse_result = (stream_type, parsed_data)
else:
# Unknown command type - use auto-detection as fallback
parse_result = LogParserFactory.auto_parse(content)
if parse_result:
stream_type, parsed_data = parse_result
# For FFmpeg, only parse during input phase
if stream_type in ['video', 'audio', 'input']:
if self.ffmpeg_input_phase:
ChannelService.parse_and_store_stream_info(self.channel_id, content, stream_type, self.current_stream_id)
else:
# VLC and Streamlink can be parsed anytime
ChannelService.parse_and_store_stream_info(self.channel_id, content, stream_type, self.current_stream_id)
# Determine log level based on content
if any(keyword in content_lower for keyword in ['error', 'failed', 'cannot', 'invalid', 'corrupt']):
logger.error(f"FFmpeg stderr for channel {self.channel_id}: {content}")
logger.error(f"Stream process error for channel {self.channel_id}: {content}")
elif any(keyword in content_lower for keyword in ['warning', 'deprecated', 'ignoring']):
logger.warning(f"FFmpeg stderr for channel {self.channel_id}: {content}")
logger.warning(f"Stream process warning for channel {self.channel_id}: {content}")
elif content.startswith('frame=') or 'fps=' in content or 'speed=' in content:
# Stats lines - log at trace level to avoid spam
logger.trace(f"FFmpeg stats for channel {self.channel_id}: {content}")
logger.trace(f"Stream stats for channel {self.channel_id}: {content}")
elif any(keyword in content_lower for keyword in ['input', 'output', 'stream', 'video', 'audio']):
# Stream info - log at info level
logger.info(f"FFmpeg info for channel {self.channel_id}: {content}")
if content.startswith('Input #0'):
# If it's input 0, parse stream info
from .services.channel_service import ChannelService
ChannelService.parse_and_store_stream_info(self.channel_id, content, "input")
logger.info(f"Stream info for channel {self.channel_id}: {content}")
else:
# Everything else at debug level
logger.debug(f"FFmpeg stderr for channel {self.channel_id}: {content}")
logger.debug(f"Stream process output for channel {self.channel_id}: {content}")
except Exception as e:
logger.error(f"Error logging stderr content for channel {self.channel_id}: {e}")
@ -649,6 +749,14 @@ class StreamManager:
if any(x is not None for x in [ffmpeg_speed, ffmpeg_fps, actual_fps, ffmpeg_output_bitrate]):
self._update_ffmpeg_stats_in_redis(ffmpeg_speed, ffmpeg_fps, actual_fps, ffmpeg_output_bitrate)
# Also save ffmpeg_output_bitrate to database if we have stream_id
if ffmpeg_output_bitrate is not None and self.current_stream_id:
from .services.channel_service import ChannelService
ChannelService._update_stream_stats_in_db(
self.current_stream_id,
ffmpeg_output_bitrate=ffmpeg_output_bitrate
)
# Fix the f-string formatting
actual_fps_str = f"{actual_fps:.1f}" if actual_fps is not None else "N/A"
ffmpeg_output_bitrate_str = f"{ffmpeg_output_bitrate:.1f}" if ffmpeg_output_bitrate is not None else "N/A"
@ -673,6 +781,19 @@ class StreamManager:
# Reset buffering state
self.buffering = False
self.buffering_start_time = None
# Log failover event
try:
channel_obj = Channel.objects.get(uuid=self.channel_id)
log_system_event(
'channel_failover',
channel_id=self.channel_id,
channel_name=channel_obj.name,
reason='buffering_timeout',
duration=buffering_duration
)
except Exception as e:
logger.error(f"Could not log failover event: {e}")
else:
logger.error(f"Failed to switch to next stream for channel {self.channel_id} after buffering timeout")
else:
@ -680,6 +801,19 @@ class StreamManager:
self.buffering = True
self.buffering_start_time = time.time()
logger.warning(f"Buffering started for channel {self.channel_id} - speed: {ffmpeg_speed}x")
# Log system event for buffering
try:
channel_obj = Channel.objects.get(uuid=self.channel_id)
log_system_event(
'channel_buffering',
channel_id=self.channel_id,
channel_name=channel_obj.name,
speed=ffmpeg_speed
)
except Exception as e:
logger.error(f"Could not log buffering event: {e}")
# Log buffering warning
logger.debug(f"FFmpeg speed on channel {self.channel_id} is below {self.buffering_speed} ({ffmpeg_speed}x) - buffering detected")
# Set channel state to buffering
@ -729,9 +863,9 @@ class StreamManager:
def _establish_http_connection(self):
"""Establish a direct HTTP connection to the stream"""
"""Establish HTTP connection using thread-based reader (same as transcode path)"""
try:
logger.debug(f"Using TS Proxy to connect to stream: {self.url}")
logger.debug(f"Using HTTP streamer thread to connect to stream: {self.url}")
# Check if we already have active HTTP connections
if self.current_response or self.current_session:
@ -748,41 +882,39 @@ class StreamManager:
logger.debug(f"Closing existing transcode process before establishing HTTP connection for channel {self.channel_id}")
self._close_socket()
# Create new session for each connection attempt
session = self._create_session()
self.current_session = session
# Use HTTPStreamReader to fetch stream and pipe to a readable file descriptor
# This allows us to use the same fetch_chunk() path as transcode
from .http_streamer import HTTPStreamReader
# Stream the URL with proper timeout handling
response = session.get(
self.url,
stream=True,
timeout=(10, 60) # 10s connect timeout, 60s read timeout
# Create and start the HTTP stream reader
self.http_reader = HTTPStreamReader(
url=self.url,
user_agent=self.user_agent,
chunk_size=self.chunk_size
)
self.current_response = response
if response.status_code == 200:
self.connected = True
self.healthy = True
logger.info(f"Successfully connected to stream source for channel {self.channel_id}")
# Start the reader thread and get the read end of the pipe
pipe_fd = self.http_reader.start()
# Store connection start time for stability tracking
self.connection_start_time = time.time()
# Wrap the file descriptor in a file object (same as transcode stdout)
import os
self.socket = os.fdopen(pipe_fd, 'rb', buffering=0)
self.connected = True
self.healthy = True
# Set channel state to waiting for clients
self._set_waiting_for_clients()
logger.info(f"Successfully started HTTP streamer thread for channel {self.channel_id}")
# Store connection start time for stability tracking
self.connection_start_time = time.time()
# Set channel state to waiting for clients
self._set_waiting_for_clients()
return True
return True
else:
logger.error(f"Failed to connect to stream for channel {self.channel_id}: HTTP {response.status_code}")
self._close_connection()
return False
except requests.exceptions.RequestException as e:
logger.error(f"HTTP request error: {e}")
self._close_connection()
return False
except Exception as e:
logger.error(f"Error establishing HTTP connection for channel {self.channel_id}: {e}", exc_info=True)
self._close_connection()
self._close_socket()
return False
def _update_bytes_processed(self, chunk_size):
@ -810,48 +942,19 @@ class StreamManager:
logger.error(f"Error updating bytes processed: {e}")
def _process_stream_data(self):
"""Process stream data until disconnect or error"""
"""Process stream data until disconnect or error - unified path for both transcode and HTTP"""
try:
if self.transcode:
# Handle transcoded stream data
while self.running and self.connected and not self.stop_requested and not self.needs_stream_switch:
if self.fetch_chunk():
self.last_data_time = time.time()
else:
if not self.running:
break
gevent.sleep(0.1) # REPLACE time.sleep(0.1)
else:
# Handle direct HTTP connection
chunk_count = 0
try:
for chunk in self.current_response.iter_content(chunk_size=self.chunk_size):
# Check if we've been asked to stop
if self.stop_requested or self.url_switching or self.needs_stream_switch:
break
if chunk:
# Track chunk size before adding to buffer
chunk_size = len(chunk)
self._update_bytes_processed(chunk_size)
# Add chunk to buffer with TS packet alignment
success = self.buffer.add_chunk(chunk)
if success:
self.last_data_time = time.time()
chunk_count += 1
# Update last data timestamp in Redis
if 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)
except (AttributeError, ConnectionError) as e:
if self.stop_requested or self.url_switching:
logger.debug(f"Expected connection error during shutdown/URL switch for channel {self.channel_id}: {e}")
else:
logger.error(f"Unexpected stream error for channel {self.channel_id}: {e}")
raise
# Both transcode and HTTP now use the same subprocess/socket approach
# This gives us perfect control: check flags between chunks, timeout just returns False
while self.running and self.connected and not self.stop_requested and not self.needs_stream_switch:
if self.fetch_chunk():
self.last_data_time = time.time()
else:
# fetch_chunk() returned False - could be timeout, no data, or error
if not self.running:
break
# Brief sleep before retry to avoid tight loop
gevent.sleep(0.1)
except Exception as e:
logger.error(f"Error processing stream data for channel {self.channel_id}: {e}", exc_info=True)
@ -940,6 +1043,7 @@ class StreamManager:
# Import both models for proper resource management
from apps.channels.models import Stream, Channel
from django.db import connection
# Update stream profile if we're switching streams
if self.current_stream_id and stream_id and self.current_stream_id != stream_id:
@ -957,9 +1061,17 @@ class StreamManager:
logger.debug(f"Updated m3u profile for channel {self.channel_id} to use profile from stream {stream_id}")
else:
logger.warning(f"Failed to update stream profile for channel {self.channel_id}")
except Exception as e:
logger.error(f"Error updating stream profile for channel {self.channel_id}: {e}")
finally:
# Always close database connection after profile update
try:
connection.close()
except Exception:
pass
# CRITICAL: Set a flag to prevent immediate reconnection with old URL
self.url_switching = True
self.url_switch_start_time = time.time()
@ -997,6 +1109,19 @@ class StreamManager:
except Exception as e:
logger.warning(f"Failed to reset buffer position: {e}")
# Log stream switch event
try:
channel_obj = Channel.objects.get(uuid=self.channel_id)
log_system_event(
'stream_switch',
channel_id=self.channel_id,
channel_name=channel_obj.name,
new_url=new_url[:100] if new_url else None,
stream_id=stream_id
)
except Exception as e:
logger.error(f"Could not log stream switch event: {e}")
return True
except Exception as e:
logger.error(f"Error during URL update for channel {self.channel_id}: {e}", exc_info=True)
@ -1115,6 +1240,19 @@ class StreamManager:
if connection_result:
self.connection_start_time = time.time()
logger.info(f"Reconnect successful for channel {self.channel_id}")
# Log reconnection event
try:
channel_obj = Channel.objects.get(uuid=self.channel_id)
log_system_event(
'channel_reconnect',
channel_id=self.channel_id,
channel_name=channel_obj.name,
reason='health_monitor'
)
except Exception as e:
logger.error(f"Could not log reconnection event: {e}")
return True
else:
logger.warning(f"Reconnect failed for channel {self.channel_id}")
@ -1175,6 +1313,15 @@ class StreamManager:
if self.current_response or self.current_session:
self._close_connection()
# Stop HTTP reader thread if it exists
if hasattr(self, 'http_reader') and self.http_reader:
try:
logger.debug(f"Stopping HTTP reader thread for channel {self.channel_id}")
self.http_reader.stop()
self.http_reader = None
except Exception as e:
logger.debug(f"Error stopping HTTP reader for channel {self.channel_id}: {e}")
# Otherwise handle socket and transcode resources
if self.socket:
try:
@ -1183,25 +1330,17 @@ class StreamManager:
logger.debug(f"Error closing socket for channel {self.channel_id}: {e}")
pass
# Enhanced transcode process cleanup with more aggressive termination
# Enhanced transcode process cleanup with immediate termination
if self.transcode_process:
try:
# First try polite termination
logger.debug(f"Terminating transcode process for channel {self.channel_id}")
self.transcode_process.terminate()
logger.debug(f"Killing transcode process for channel {self.channel_id}")
self.transcode_process.kill()
# Give it a short time to terminate gracefully
# Give it a very short time to die
try:
self.transcode_process.wait(timeout=1.0)
self.transcode_process.wait(timeout=0.5)
except subprocess.TimeoutExpired:
# If it doesn't terminate quickly, kill it
logger.warning(f"Transcode process didn't terminate within timeout, killing forcefully for channel {self.channel_id}")
self.transcode_process.kill()
try:
self.transcode_process.wait(timeout=1.0)
except subprocess.TimeoutExpired:
logger.error(f"Failed to kill transcode process even with force for channel {self.channel_id}")
logger.error(f"Failed to kill transcode process even with force for channel {self.channel_id}")
except Exception as e:
logger.debug(f"Error terminating transcode process for channel {self.channel_id}: {e}")
@ -1211,6 +1350,30 @@ class StreamManager:
except Exception as e:
logger.error(f"Final kill attempt failed for channel {self.channel_id}: {e}")
# Explicitly close all subprocess pipes to prevent file descriptor leaks
try:
if self.transcode_process.stdin:
self.transcode_process.stdin.close()
if self.transcode_process.stdout:
self.transcode_process.stdout.close()
if self.transcode_process.stderr:
self.transcode_process.stderr.close()
logger.debug(f"Closed all subprocess pipes for channel {self.channel_id}")
except Exception as e:
logger.debug(f"Error closing subprocess pipes for channel {self.channel_id}: {e}")
# Join stderr reader thread to ensure it's fully terminated
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)
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:
logger.debug(f"Error joining stderr reader thread for channel {self.channel_id}: {e}")
finally:
self.stderr_reader_thread = None
self.transcode_process = None
self.transcode_process_active = False # Reset the flag
@ -1242,7 +1405,7 @@ class StreamManager:
try:
# Set timeout for chunk reads
chunk_timeout = ConfigHelper.get('CHUNK_TIMEOUT', 10) # Default 10 seconds
chunk_timeout = ConfigHelper.chunk_timeout() # Use centralized timeout configuration
try:
# Handle different socket types with timeout
@ -1325,7 +1488,17 @@ class StreamManager:
# Only update if not already past connecting
if not current_state or current_state in [ChannelState.INITIALIZING, ChannelState.CONNECTING]:
# NEW CODE: Check if buffer has enough chunks
current_buffer_index = getattr(self.buffer, 'index', 0)
# IMPORTANT: Read from Redis, not local buffer.index, because in multi-worker setup
# each worker has its own StreamBuffer instance with potentially stale local index
buffer_index_key = RedisKeys.buffer_index(channel_id)
current_buffer_index = 0
try:
redis_index = redis_client.get(buffer_index_key)
if redis_index:
current_buffer_index = int(redis_index)
except Exception as e:
logger.error(f"Error reading buffer index from Redis: {e}")
initial_chunks_needed = ConfigHelper.initial_behind_chunks()
if current_buffer_index < initial_chunks_needed:
@ -1373,10 +1546,21 @@ class StreamManager:
# Clean up completed timers
self._buffer_check_timers = [t for t in self._buffer_check_timers if t.is_alive()]
if hasattr(self.buffer, 'index') and hasattr(self.buffer, 'channel_id'):
current_buffer_index = self.buffer.index
initial_chunks_needed = getattr(Config, 'INITIAL_BEHIND_CHUNKS', 10)
if hasattr(self.buffer, 'channel_id') and hasattr(self.buffer, 'redis_client'):
channel_id = self.buffer.channel_id
redis_client = self.buffer.redis_client
# IMPORTANT: Read from Redis, not local buffer.index
buffer_index_key = RedisKeys.buffer_index(channel_id)
current_buffer_index = 0
try:
redis_index = redis_client.get(buffer_index_key)
if redis_index:
current_buffer_index = int(redis_index)
except Exception as e:
logger.error(f"Error reading buffer index from Redis: {e}")
initial_chunks_needed = ConfigHelper.initial_behind_chunks() # Use ConfigHelper for consistency
if current_buffer_index >= initial_chunks_needed:
# We now have enough buffer, call _set_waiting_for_clients again
@ -1401,6 +1585,7 @@ class StreamManager:
def _try_next_stream(self):
"""
Try to switch to the next available stream for this channel.
Will iterate through multiple alternate streams if needed to find one with a different URL.
Returns:
bool: True if successfully switched to a new stream, False otherwise
@ -1426,60 +1611,71 @@ class StreamManager:
logger.warning(f"All {len(alternate_streams)} alternate streams have been tried for channel {self.channel_id}")
return False
# Get the next stream to try
next_stream = untried_streams[0]
stream_id = next_stream['stream_id']
profile_id = next_stream['profile_id'] # This is the M3U profile ID we need
# IMPROVED: Try multiple streams until we find one with a different URL
for next_stream in untried_streams:
stream_id = next_stream['stream_id']
profile_id = next_stream['profile_id'] # This is the M3U profile ID we need
# Add to tried streams
self.tried_stream_ids.add(stream_id)
# Add to tried streams
self.tried_stream_ids.add(stream_id)
# Get stream info including URL using the profile_id we already have
logger.info(f"Trying next stream ID {stream_id} with profile ID {profile_id} for channel {self.channel_id}")
stream_info = get_stream_info_for_switch(self.channel_id, stream_id)
# Get stream info including URL using the profile_id we already have
logger.info(f"Trying next stream ID {stream_id} with profile ID {profile_id} for channel {self.channel_id}")
stream_info = get_stream_info_for_switch(self.channel_id, stream_id)
if 'error' in stream_info or not stream_info.get('url'):
logger.error(f"Error getting info for stream {stream_id} for channel {self.channel_id}: {stream_info.get('error', 'No URL')}")
return False
if 'error' in stream_info or not stream_info.get('url'):
logger.error(f"Error getting info for stream {stream_id} for channel {self.channel_id}: {stream_info.get('error', 'No URL')}")
continue # Try next stream instead of giving up
# Update URL and user agent
new_url = stream_info['url']
new_user_agent = stream_info['user_agent']
new_transcode = stream_info['transcode']
# Update URL and user agent
new_url = stream_info['url']
new_user_agent = stream_info['user_agent']
new_transcode = stream_info['transcode']
logger.info(f"Switching from URL {self.url} to {new_url} for channel {self.channel_id}")
# CRITICAL FIX: Check if the new URL is the same as current URL
# This can happen when current_stream_id is None and we accidentally select the same stream
if new_url == self.url:
logger.warning(f"Stream ID {stream_id} generates the same URL as current stream ({new_url}). "
f"Skipping this stream and trying next alternative.")
continue # Try next stream instead of giving up
# IMPORTANT: Just update the URL, don't stop the channel or release resources
switch_result = self.update_url(new_url, stream_id, profile_id)
if not switch_result:
logger.error(f"Failed to update URL for stream ID {stream_id} for channel {self.channel_id}")
return False
logger.info(f"Switching from URL {self.url} to {new_url} for channel {self.channel_id}")
# Update stream ID tracking
self.current_stream_id = stream_id
# IMPORTANT: Just update the URL, don't stop the channel or release resources
switch_result = self.update_url(new_url, stream_id, profile_id)
if not switch_result:
logger.error(f"Failed to update URL for stream ID {stream_id} for channel {self.channel_id}")
continue # Try next stream
# Store the new user agent and transcode settings
self.user_agent = new_user_agent
self.transcode = new_transcode
# Update stream ID tracking
self.current_stream_id = stream_id
# Update stream metadata in Redis - use the profile_id we got from get_alternate_streams
if hasattr(self.buffer, 'redis_client') and self.buffer.redis_client:
metadata_key = RedisKeys.channel_metadata(self.channel_id)
self.buffer.redis_client.hset(metadata_key, mapping={
ChannelMetadataField.URL: new_url,
ChannelMetadataField.USER_AGENT: new_user_agent,
ChannelMetadataField.STREAM_PROFILE: stream_info['stream_profile'],
ChannelMetadataField.M3U_PROFILE: str(profile_id), # Use the profile_id from get_alternate_streams
ChannelMetadataField.STREAM_ID: str(stream_id),
ChannelMetadataField.STREAM_SWITCH_TIME: str(time.time()),
ChannelMetadataField.STREAM_SWITCH_REASON: "max_retries_exceeded"
})
# Store the new user agent and transcode settings
self.user_agent = new_user_agent
self.transcode = new_transcode
# Log the switch
logger.info(f"Stream metadata updated for channel {self.channel_id} to stream ID {stream_id} with M3U profile {profile_id}")
# Update stream metadata in Redis - use the profile_id we got from get_alternate_streams
if hasattr(self.buffer, 'redis_client') and self.buffer.redis_client:
metadata_key = RedisKeys.channel_metadata(self.channel_id)
self.buffer.redis_client.hset(metadata_key, mapping={
ChannelMetadataField.URL: new_url,
ChannelMetadataField.USER_AGENT: new_user_agent,
ChannelMetadataField.STREAM_PROFILE: stream_info['stream_profile'],
ChannelMetadataField.M3U_PROFILE: str(profile_id), # Use the profile_id from get_alternate_streams
ChannelMetadataField.STREAM_ID: str(stream_id),
ChannelMetadataField.STREAM_SWITCH_TIME: str(time.time()),
ChannelMetadataField.STREAM_SWITCH_REASON: "max_retries_exceeded"
})
logger.info(f"Successfully switched to stream ID {stream_id} with URL {new_url} for channel {self.channel_id}")
return True
# Log the switch
logger.info(f"Stream metadata updated for channel {self.channel_id} to stream ID {stream_id} with M3U profile {profile_id}")
logger.info(f"Successfully switched to stream ID {stream_id} with URL {new_url} for channel {self.channel_id}")
return True
# If we get here, we tried all streams but none worked
logger.error(f"Tried {len(untried_streams)} alternate streams but none were suitable for channel {self.channel_id}")
return False
except Exception as e:
logger.error(f"Error trying next stream for channel {self.channel_id}: {e}", exc_info=True)

View file

@ -8,7 +8,7 @@ from typing import Optional, Tuple, List
from django.shortcuts import get_object_or_404
from apps.channels.models import Channel, Stream
from apps.m3u.models import M3UAccount, M3UAccountProfile
from core.models import UserAgent, CoreSettings
from core.models import UserAgent, CoreSettings, StreamProfile
from .utils import get_logger
from uuid import UUID
import requests
@ -26,16 +26,100 @@ def get_stream_object(id: str):
def generate_stream_url(channel_id: str) -> Tuple[str, str, bool, Optional[int]]:
"""
Generate the appropriate stream URL for a channel based on its profile settings.
Generate the appropriate stream URL for a channel or stream based on its profile settings.
Args:
channel_id: The UUID of the channel
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)
"""
try:
channel = get_stream_object(channel_id)
channel_or_stream = get_stream_object(channel_id)
# Handle direct stream preview (custom streams)
if isinstance(channel_or_stream, Stream):
from core.utils import RedisClient
stream = channel_or_stream
logger.info(f"Previewing stream directly: {stream.id} ({stream.name})")
# For custom streams, we need to get the M3U account and profile
m3u_account = stream.m3u_account
if not m3u_account:
logger.error(f"Stream {stream.id} has no M3U account")
return None, None, False, None
# Get active profiles for this M3U account
m3u_profiles = m3u_account.profiles.filter(is_active=True)
default_profile = next((obj for obj in m3u_profiles if obj.is_default), None)
if not default_profile:
logger.error(f"No default active profile found for M3U account {m3u_account.id}")
return None, None, False, None
# Check profiles in order: default first, then others
profiles = [default_profile] + [obj for obj in m3u_profiles if not obj.is_default]
# Try to find an available profile with connection capacity
redis_client = RedisClient.get_client()
selected_profile = None
for profile in profiles:
logger.info(profile)
# 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 profile has available slots (or unlimited connections)
if profile.max_streams == 0 or current_connections < profile.max_streams:
selected_profile = profile
logger.debug(f"Selected profile {profile.id} with {current_connections}/{profile.max_streams} connections for stream preview")
break
else:
logger.debug(f"Profile {profile.id} at max connections: {current_connections}/{profile.max_streams}")
else:
# No Redis available, use first active profile
selected_profile = profile
break
if not selected_profile:
logger.error(f"No profiles available with connection capacity for M3U account {m3u_account.id}")
return None, None, False, None
# Get the appropriate user agent
stream_user_agent = m3u_account.get_user_agent().user_agent
if stream_user_agent is None:
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}")
# Get stream URL with the selected profile's URL transformation
stream_url = transform_url(stream.url, selected_profile.search_pattern, selected_profile.replace_pattern)
# Check if the stream has its own stream_profile set, otherwise use default
if stream.stream_profile:
stream_profile = stream.stream_profile
logger.debug(f"Using stream's own stream profile: {stream_profile.name}")
else:
stream_profile = StreamProfile.objects.get(
id=CoreSettings.get_default_stream_profile_id()
)
logger.debug(f"Using default stream profile: {stream_profile.name}")
# Check if transcoding is needed
if stream_profile.is_proxy() or stream_profile is None:
transcode = False
else:
transcode = True
stream_profile_id = stream_profile.id
return stream_url, stream_user_agent, transcode, stream_profile_id
# 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)
@ -142,7 +226,7 @@ def get_stream_info_for_switch(channel_id: str, target_stream_id: Optional[int]
if not m3u_account:
return {'error': 'Stream has no M3U account'}
m3u_profiles = m3u_account.profiles.all()
m3u_profiles = m3u_account.profiles.filter(is_active=True)
default_profile = next((obj for obj in m3u_profiles if obj.is_default), None)
if not default_profile:
@ -153,10 +237,6 @@ def get_stream_info_for_switch(channel_id: str, target_stream_id: Optional[int]
selected_profile = None
for profile in profiles:
# Skip inactive profiles
if not profile.is_active:
logger.debug(f"Skipping inactive profile {profile.id}")
continue
# Check connection availability
if redis_client:
@ -281,8 +361,10 @@ def get_alternate_streams(channel_id: str, current_stream_id: Optional[int] = No
if not m3u_account:
logger.debug(f"Stream {stream.id} has no M3U account")
continue
m3u_profiles = m3u_account.profiles.all()
if m3u_account.is_active == False:
logger.debug(f"M3U account {m3u_account.id} is inactive, skipping.")
continue
m3u_profiles = m3u_account.profiles.filter(is_active=True)
default_profile = next((obj for obj in m3u_profiles if obj.is_default), None)
if not default_profile:
@ -294,11 +376,6 @@ def get_alternate_streams(channel_id: str, current_stream_id: Optional[int] = No
selected_profile = None
for profile in profiles:
# Skip inactive profiles
if not profile.is_active:
logger.debug(f"Skipping inactive profile {profile.id}")
continue
# Check connection availability
if redis_client:
profile_connections_key = f"profile_connections:{profile.id}"
@ -358,6 +435,9 @@ def validate_stream_url(url, user_agent=None, timeout=(5, 5)):
"""
Validate if a stream URL is accessible without downloading the full content.
Note: UDP/RTP/RTSP streams are automatically considered valid as they cannot
be validated via HTTP methods.
Args:
url (str): The URL to validate
user_agent (str): User agent to use for the request
@ -366,6 +446,12 @@ def validate_stream_url(url, user_agent=None, timeout=(5, 5)):
Returns:
tuple: (is_valid, final_url, status_code, message)
"""
# Check if URL uses non-HTTP protocols (UDP/RTP/RTSP)
# These cannot be validated via HTTP methods, so we skip validation
if url.startswith(('udp://', 'rtp://', 'rtsp://')):
logger.info(f"Skipping HTTP validation for non-HTTP protocol: {url}")
return True, url, 200, "Non-HTTP protocol (UDP/RTP/RTSP) - validation skipped"
try:
# Create session with proper headers
session = requests.Session()
@ -376,16 +462,21 @@ def validate_stream_url(url, user_agent=None, timeout=(5, 5)):
session.headers.update(headers)
# Make HEAD request first as it's faster and doesn't download content
head_response = session.head(
url,
timeout=timeout,
allow_redirects=True
)
head_request_success = True
try:
head_response = session.head(
url,
timeout=timeout,
allow_redirects=True
)
except requests.exceptions.RequestException as e:
head_request_success = False
logger.warning(f"Request error (HEAD), assuming HEAD not supported: {str(e)}")
# If HEAD not supported, server will return 405 or other error
if 200 <= head_response.status_code < 300:
if head_request_success and (200 <= head_response.status_code < 300):
# HEAD request successful
return True, head_response.url, head_response.status_code, "Valid (HEAD request)"
return True, url, head_response.status_code, "Valid (HEAD request)"
# Try a GET request with stream=True to avoid downloading all content
get_response = session.get(
@ -398,7 +489,7 @@ def validate_stream_url(url, user_agent=None, timeout=(5, 5)):
# IMPORTANT: Check status code first before checking content
if not (200 <= get_response.status_code < 300):
logger.warning(f"Stream validation failed with HTTP status {get_response.status_code}")
return False, get_response.url, get_response.status_code, f"Invalid HTTP status: {get_response.status_code}"
return False, url, get_response.status_code, f"Invalid HTTP status: {get_response.status_code}"
# Only check content if status code is valid
try:
@ -452,7 +543,7 @@ def validate_stream_url(url, user_agent=None, timeout=(5, 5)):
get_response.close()
# If we have content, consider it valid even with unrecognized content type
return is_valid, get_response.url, get_response.status_code, message
return is_valid, url, get_response.status_code, message
except requests.exceptions.Timeout:
return False, url, 0, "Timeout connecting to stream"

View file

@ -7,19 +7,27 @@ logger = logging.getLogger("ts_proxy")
def detect_stream_type(url):
"""
Detect if stream URL is HLS or TS format.
Detect if stream URL is HLS, RTSP/RTP, UDP, or TS format.
Args:
url (str): The stream URL to analyze
Returns:
str: 'hls' or 'ts' depending on detected format
str: 'hls', 'rtsp', 'udp', or 'ts' depending on detected format
"""
if not url:
return 'unknown'
url_lower = url.lower()
# Check for UDP streams (requires FFmpeg)
if url_lower.startswith('udp://'):
return 'udp'
# Check for RTSP/RTP streams (requires FFmpeg)
if url_lower.startswith('rtsp://') or url_lower.startswith('rtp://'):
return 'rtsp'
# Look for common HLS indicators
if (url_lower.endswith('.m3u8') or
'.m3u8?' in url_lower or

View file

@ -4,7 +4,7 @@ import time
import random
import re
import pathlib
from django.http import StreamingHttpResponse, JsonResponse, HttpResponseRedirect
from django.http import StreamingHttpResponse, JsonResponse, HttpResponseRedirect, HttpResponse
from django.views.decorators.csrf import csrf_exempt
from django.shortcuts import get_object_or_404
from apps.proxy.config import TSConfig as Config
@ -28,6 +28,7 @@ from apps.accounts.permissions import (
from .constants import ChannelState, EventType, StreamType, ChannelMetadataField
from .config_helper import ConfigHelper
from .services.channel_service import ChannelService
from core.utils import send_websocket_update
from .url_utils import (
generate_stream_url,
transform_url,
@ -83,11 +84,18 @@ def stream_ts(request, channel_id):
if state_field in metadata:
channel_state = metadata[state_field].decode("utf-8")
if channel_state:
# Channel is being initialized or already active - no need for reinitialization
# Active/running states - channel is operational, don't reinitialize
if channel_state in [
ChannelState.ACTIVE,
ChannelState.WAITING_FOR_CLIENTS,
ChannelState.BUFFERING,
ChannelState.INITIALIZING,
ChannelState.CONNECTING,
ChannelState.STOPPING,
]:
needs_initialization = False
logger.debug(
f"[{client_id}] Channel {channel_id} already in state {channel_state}, skipping initialization"
f"[{client_id}] Channel {channel_id} in state {channel_state}, skipping initialization"
)
# Special handling for initializing/connecting states
@ -97,19 +105,34 @@ def stream_ts(request, channel_id):
]:
channel_initializing = True
logger.debug(
f"[{client_id}] Channel {channel_id} is still initializing, client will wait for completion"
f"[{client_id}] Channel {channel_id} is still initializing, client will wait"
)
# Terminal states - channel needs cleanup before reinitialization
elif channel_state in [
ChannelState.ERROR,
ChannelState.STOPPED,
]:
needs_initialization = True
logger.info(
f"[{client_id}] Channel {channel_id} in terminal state {channel_state}, will reinitialize"
)
# Unknown/empty state - check if owner is alive
else:
# Only check for owner if channel is in a valid state
owner_field = ChannelMetadataField.OWNER.encode("utf-8")
if owner_field in metadata:
owner = metadata[owner_field].decode("utf-8")
owner_heartbeat_key = f"ts_proxy:worker:{owner}:heartbeat"
if proxy_server.redis_client.exists(owner_heartbeat_key):
# Owner is still active, so we don't need to reinitialize
# Owner is still active with unknown state - don't reinitialize
needs_initialization = False
logger.debug(
f"[{client_id}] Channel {channel_id} has active owner {owner}"
f"[{client_id}] Channel {channel_id} has active owner {owner}, skipping init"
)
else:
# Owner dead - needs reinitialization
needs_initialization = True
logger.warning(
f"[{client_id}] Channel {channel_id} owner {owner} is dead, will reinitialize"
)
# Start initialization if needed
@ -126,9 +149,9 @@ def stream_ts(request, channel_id):
)
ChannelService.stop_channel(channel_id)
# Use max retry attempts and connection timeout from config
max_retries = ConfigHelper.max_retries()
retry_timeout = ConfigHelper.connection_timeout()
# Use fixed retry interval and timeout
retry_timeout = 3 # 3 seconds total timeout
retry_interval = 0.1 # 100ms between attempts
wait_start_time = time.time()
stream_url = None
@ -136,54 +159,69 @@ def stream_ts(request, channel_id):
transcode = False
profile_value = None
error_reason = None
attempt = 0
should_retry = True
# Try to get a stream with configured retries
for attempt in range(max_retries):
# 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)
)
if stream_url is not None:
logger.info(
f"[{client_id}] Successfully obtained stream for channel {channel_id}"
f"[{client_id}] Successfully obtained stream for channel {channel_id} after {attempt} attempts"
)
break
# If we failed because there are no streams assigned, don't retry
_, _, 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}"
# 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}"
)
should_retry = False
break
# Check if we have time remaining for another sleep cycle
elapsed_time = time.time() - wait_start_time
remaining_time = retry_timeout - elapsed_time
# If we don't have enough time for the next sleep interval, break
# but only after we've already made an attempt (the while condition will try one more time)
if remaining_time <= retry_interval:
logger.info(
f"[{client_id}] Insufficient time ({remaining_time:.1f}s) for another sleep cycle, will make one final attempt"
)
break
# Don't exceed the overall connection timeout
if time.time() - wait_start_time > retry_timeout:
logger.warning(
f"[{client_id}] Connection wait timeout exceeded ({retry_timeout}s)"
)
break
# Wait before retrying (using exponential backoff with a cap)
wait_time = min(0.5 * (2**attempt), 2.0) # Caps at 2 seconds
# Wait before retrying
logger.info(
f"[{client_id}] Waiting {wait_time:.1f}s for a connection to become available (attempt {attempt+1}/{max_retries})"
f"[{client_id}] Waiting {retry_interval*1000:.0f}ms for a connection to become available (attempt {attempt}, {remaining_time:.1f}s remaining)"
)
gevent.sleep(
wait_time
) # FIXED: Using gevent.sleep instead of time.sleep
gevent.sleep(retry_interval)
retry_interval += 0.025 # Increase wait time by 25ms for next attempt
# Make one final attempt if we still don't have a stream, should retry, and haven't exceeded timeout
if stream_url is None and should_retry and time.time() - wait_start_time < retry_timeout:
attempt += 1
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)
)
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:
# Make sure to release any stream locks that might have been acquired
if hasattr(channel, "streams") and channel.streams.exists():
for stream in channel.streams.all():
try:
stream.release_stream()
logger.info(
f"[{client_id}] Released stream {stream.id} for channel {channel_id}"
)
except Exception as e:
logger.error(f"[{client_id}] Error releasing stream: {e}")
# Release the channel's stream lock if one was acquired
# Note: Only call this if get_stream() actually assigned a stream
# In our case, if stream_url is None, no stream was ever assigned, so don't release
# Get the specific error message if available
wait_duration = f"{int(time.time() - wait_start_time)}s"
@ -192,6 +230,9 @@ def stream_ts(request, channel_id):
if error_reason
else "No available streams for this channel"
)
logger.info(
f"[{client_id}] Failed to obtain stream after {attempt} attempts over {wait_duration}: {error_msg}"
)
return JsonResponse(
{"error": error_msg, "waited": wait_duration}, status=503
) # 503 Service Unavailable is appropriate here
@ -273,6 +314,15 @@ def stream_ts(request, channel_id):
logger.info(
f"[{client_id}] Redirecting to validated URL: {final_url} ({message})"
)
# For non-HTTP protocols (RTSP/RTP/UDP), we need to manually create the redirect
# because Django's HttpResponseRedirect blocks them for security
if final_url.startswith(('rtsp://', 'rtp://', 'udp://')):
logger.info(f"[{client_id}] Using manual redirect for non-HTTP protocol")
response = HttpResponse(status=301)
response['Location'] = final_url
return response
return HttpResponseRedirect(final_url)
else:
logger.error(
@ -467,9 +517,7 @@ def stream_xc(request, username, password, channel_id):
extension = pathlib.Path(channel_id).suffix
channel_id = pathlib.Path(channel_id).stem
custom_properties = (
json.loads(user.custom_properties) if user.custom_properties else {}
)
custom_properties = user.custom_properties or {}
if "xc_password" not in custom_properties:
return Response({"error": "Invalid credentials"}, status=401)
@ -479,24 +527,33 @@ def stream_xc(request, username, password, channel_id):
print(f"Fetchin channel with ID: {channel_id}")
if user.user_level < 10:
filters = {
"id": int(channel_id),
"channelprofilemembership__enabled": True,
"user_level__lte": user.user_level,
}
user_profile_count = user.channel_profiles.count()
if user.channel_profiles.count() > 0:
channel_profiles = user.channel_profiles.all()
filters["channelprofilemembership__channel_profile__in"] = channel_profiles
# If user has ALL profiles or NO profiles, give unrestricted access
if user_profile_count == 0:
# No profile filtering - user sees all channels based on user_level
filters = {
"id": int(channel_id),
"user_level__lte": user.user_level
}
channel = Channel.objects.filter(**filters).first()
else:
# User has specific limited profiles assigned
filters = {
"id": int(channel_id),
"channelprofilemembership__enabled": True,
"user_level__lte": user.user_level,
"channelprofilemembership__channel_profile__in": user.channel_profiles.all()
}
channel = Channel.objects.filter(**filters).distinct().first()
channel = Channel.objects.filter(**filters).distinct().first()
if not channel:
return JsonResponse({"error": "Not found"}, status=404)
else:
channel = get_object_or_404(Channel, id=channel_id)
# @TODO: we've got the file 'type' via extension, support this when we support multiple outputs
return stream_ts(request._request, channel.uuid)
return stream_ts(request._request, str(channel.uuid))
@csrf_exempt
@ -635,6 +692,18 @@ def channel_status(request, channel_id=None):
if cursor == 0:
break
# Send WebSocket update with the stats
# Format it the same way the original Celery task did
send_websocket_update(
"updates",
"update",
{
"success": True,
"type": "channel_stats",
"stats": json.dumps({'channels': all_channels, 'count': len(all_channels)})
}
)
return JsonResponse({"channels": all_channels, "count": len(all_channels)})
except Exception as e:

View file

@ -5,4 +5,5 @@ app_name = 'proxy'
urlpatterns = [
path('ts/', include('apps.proxy.ts_proxy.urls')),
path('hls/', include('apps.proxy.hls_proxy.urls')),
path('vod/', include('apps.proxy.vod_proxy.urls')),
]

View file

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

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