From f56b088643c431dd1ed2f147b3028fc9dc625c58 Mon Sep 17 00:00:00 2001 From: root Date: Sat, 9 May 2026 21:24:50 +0200 Subject: [PATCH] Proyecto LCX Dispatcharr multicuenta --- .dockerignore | 33 + .env | 9 + .gitattributes | 20 + .github/FUNDING.yml | 15 + .github/ISSUE_TEMPLATE/bug_report.yml | 64 + .github/ISSUE_TEMPLATE/config.yml | 1 + .github/ISSUE_TEMPLATE/feature_request.yml | 39 + .github/pull_request_template.md | 29 + .github/workflows/base-image.yml | 250 ++ .github/workflows/ci.yml | 243 + .github/workflows/docker-build.yml | 56 + .github/workflows/frontend-tests.yml | 41 + .github/workflows/release.yml | 237 + .gitignore | 24 + .python-version | 1 + CHANGELOG.md | 1620 +++++++ CONTRIBUTING.md | 245 + LICENSE | 661 +++ Plugin_repo.md | 586 +++ Plugins.md | 518 +++ README.md | 179 + apps/accounts/__init__.py | 0 apps/accounts/admin.py | 16 + apps/accounts/api_urls.py | 42 + apps/accounts/api_views.py | 366 ++ apps/accounts/apps.py | 7 + apps/accounts/authentication.py | 86 + apps/accounts/forms.py | 59 + apps/accounts/migrations/0001_initial.py | 47 + ...l_groups_user_channel_profiles_and_more.py | 43 + .../0003_alter_user_custom_properties.py | 18 + apps/accounts/migrations/0004_user_api_key.py | 18 + .../migrations/0005_alter_user_managers.py | 20 + .../migrations/0006_user_stream_limit.py | 18 + apps/accounts/migrations/__init__.py | 0 apps/accounts/models.py | 48 + apps/accounts/permissions.py | 56 + apps/accounts/serializers.py | 145 + apps/accounts/signals.py | 15 + apps/accounts/tests.py | 72 + apps/accounts/urls.py | 12 + apps/api/urls.py | 29 + apps/backups/__init__.py | 0 apps/backups/api_urls.py | 18 + apps/backups/api_views.py | 374 ++ apps/backups/apps.py | 40 + apps/backups/migrations/__init__.py | 0 apps/backups/models.py | 0 apps/backups/scheduler.py | 133 + apps/backups/services.py | 376 ++ apps/backups/tasks.py | 106 + apps/backups/tests.py | 1342 ++++++ apps/channels/__init__.py | 0 apps/channels/admin.py | 38 + apps/channels/api_urls.py | 55 + apps/channels/api_views.py | 3135 +++++++++++++ apps/channels/apps.py | 11 + apps/channels/forms.py | 53 + apps/channels/migrations/0001_initial.py | 77 + ...name_channel_name_channel_name_and_more.py | 27 + apps/channels/migrations/0003_channel_uuid.py | 38 + .../migrations/0004_stream_is_custom.py | 18 + ...channel_group_stream_last_seen_and_more.py | 44 + .../migrations/0006_migrate_stream_groups.py | 51 + .../0007_remove_stream_group_name.py | 17 + .../migrations/0008_stream_stream_hash.py | 23 + ...emove_channel_tvg_name_channel_epg_data.py | 24 + .../0010_stream_custom_properties.py | 18 + ..._logo_remove_channel_logo_file_and_more.py | 35 + ...channelprofile_channelprofilemembership.py | 33 + .../migrations/0013_alter_logo_url.py | 18 + apps/channels/migrations/0014_recording.py | 24 + .../0015_recording_custom_properties.py | 18 + ...016_channelstream_unique_channel_stream.py | 38 + .../0017_alter_channelgroup_name.py | 18 + ...upm3uaccount_custom_properties_and_more.py | 18 + .../0019_channel_tvc_guide_stationid.py | 18 + .../0020_alter_channel_channel_number.py | 18 + .../migrations/0021_channel_user_level.py | 18 + ...reated_channel_auto_created_by_and_more.py | 35 + ...am_stats_stream_stream_stats_updated_at.py | 23 + ...er_channelgroupm3uaccount_channel_group.py | 19 + ...upm3uaccount_custom_properties_and_more.py | 28 + .../migrations/0026_recurringrecordingrule.py | 31 + ...ecurringrecordingrule_end_date_and_more.py | 23 + ...8_channel_created_at_channel_updated_at.py | 25 + .../0029_backfill_custom_stream_hashes.py | 54 + .../migrations/0030_alter_stream_url.py | 18 + ...hannelgroupm3uaccount_is_stale_and_more.py | 29 + .../0032_channel_is_adult_stream_is_adult.py | 23 + .../migrations/0033_stream_id_stream_chno.py | 205 + ...ream_dispatcharr_stream_id_idx_and_more.py | 31 + apps/channels/migrations/__init__.py | 0 apps/channels/models.py | 988 ++++ apps/channels/models.py.bak.1778276755 | 958 ++++ apps/channels/serializers.py | 537 +++ apps/channels/signals.py | 236 + apps/channels/tasks.py | 3973 +++++++++++++++++ apps/channels/tests/__init__.py | 0 apps/channels/tests/test_channel_api.py | 211 + apps/channels/tests/test_db_retry.py | 278 ++ .../tests/test_dvr_port_resolution.py | 59 + apps/channels/tests/test_epg_matching.py | 180 + apps/channels/tests/test_recording_extend.py | 235 + .../channels/tests/test_recording_metadata.py | 379 ++ .../channels/tests/test_recording_pipeline.py | 524 +++ .../tests/test_recording_scheduling.py | 585 +++ .../tests/test_recording_stop_cancel.py | 356 ++ apps/channels/tests/test_recurring_rules.py | 40 + apps/channels/tests/test_series_rule_dedup.py | 718 +++ .../tests/test_ts_proxy_ghost_clients.py | 385 ++ .../tests/test_ts_proxy_initializing.py | 231 + .../channels/tests/test_ts_proxy_keepalive.py | 331 ++ .../tests/test_ts_proxy_keepalive_duration.py | 195 + apps/channels/tests/test_validate_url.py | 169 + apps/channels/urls.py | 12 + apps/channels/utils.py | 25 + apps/channels/views.py | 41 + apps/connect/__init__.py | 0 apps/connect/api_urls.py | 17 + apps/connect/api_views.py | 226 + apps/connect/apps.py | 8 + apps/connect/handlers/__init__.py | 0 apps/connect/handlers/api.py | 0 apps/connect/handlers/base.py | 12 + apps/connect/handlers/script.py | 81 + apps/connect/handlers/webhook.py | 21 + apps/connect/migrations/0001_initial.py | 52 + .../0002_alter_eventsubscription_event.py | 18 + apps/connect/migrations/__init__.py | 0 apps/connect/models.py | 47 + apps/connect/serializers.py | 68 + apps/connect/utils.py | 116 + apps/dashboard/__init__.py | 0 apps/dashboard/admin.py | 2 + apps/dashboard/api_urls.py | 8 + apps/dashboard/apps.py | 6 + apps/dashboard/migrations/0001_initial.py | 46 + apps/dashboard/migrations/__init__.py | 0 apps/dashboard/models.py | 66 + apps/dashboard/tests.py | 3 + apps/dashboard/urls.py | 10 + apps/dashboard/views.py | 79 + apps/epg/__init__.py | 0 apps/epg/admin.py | 19 + apps/epg/api_urls.py | 18 + apps/epg/api_views.py | 553 +++ apps/epg/apps.py | 10 + apps/epg/migrations/0001_initial.py | 47 + .../migrations/0002_epgsource_file_path.py | 18 + .../migrations/0003_alter_epgdata_tvg_id.py | 18 + ...epgdata_epg_source_alter_epgdata_tvg_id.py | 24 + ..._programdata_custom_properties_and_more.py | 22 + ...refresh_interval_epgsource_refresh_task.py | 25 + ...7_epgsource_status_epgsource_last_error.py | 23 + .../0007_populate_periodic_tasks.py | 52 + ...gsource_created_at_epgsource_updated_at.py | 24 + ...009_alter_epgsource_created_at_and_more.py | 23 + .../migrations/0010_merge_20250503_2147.py | 14 + .../0011_update_epgsource_fields.py | 42 + .../migrations/0012_alter_epgsource_status.py | 18 + .../0013_alter_epgsource_refresh_interval.py | 18 + .../0014_epgsource_extracted_file_path.py | 18 + ...015_alter_programdata_custom_properties.py | 18 + apps/epg/migrations/0016_epgdata_icon_url.py | 18 + .../migrations/0017_alter_epgsource_url.py | 18 + ...18_epgsource_custom_properties_and_more.py | 23 + .../0019_alter_programdata_sub_title.py | 18 + ..._migrate_time_to_starttime_placeholders.py | 119 + .../epg/migrations/0021_epgsource_priority.py | 18 + apps/epg/migrations/__init__.py | 0 apps/epg/models.py | 168 + apps/epg/serializers.py | 172 + apps/epg/signals.py | 206 + apps/epg/tasks.py | 2336 ++++++++++ apps/epg/tests/__init__.py | 0 apps/epg/tests/test_entity_resolution.py | 195 + apps/epg/tests/test_serializers.py | 655 +++ apps/epg/urls.py | 9 + apps/epg/utils.py | 61 + apps/epg/views.py | 66 + apps/hdhr/__init__.py | 0 apps/hdhr/admin.py | 6 + apps/hdhr/api_urls.py | 19 + apps/hdhr/api_views.py | 182 + apps/hdhr/apps.py | 10 + apps/hdhr/migrations/0001_initial.py | 23 + apps/hdhr/migrations/__init__.py | 0 apps/hdhr/models.py | 9 + apps/hdhr/serializers.py | 10 + apps/hdhr/ssdp.py | 69 + apps/hdhr/urls.py | 22 + apps/hdhr/views.py | 138 + apps/m3u/__init__.py | 0 apps/m3u/admin.py | 190 + apps/m3u/api_urls.py | 44 + apps/m3u/api_views.py | 501 +++ apps/m3u/apps.py | 10 + apps/m3u/forms.py | 79 + apps/m3u/migrations/0001_initial.py | 65 + apps/m3u/migrations/0002_m3uaccount_locked.py | 18 + .../migrations/0003_create_custom_account.py | 40 + .../0004_m3uaccount_stream_profile.py | 20 + ...5_m3uaccount_custom_properties_and_more.py | 35 + .../0006_populate_periodic_tasks.py | 52 + ...ount_uploaded_file_m3uaccount_file_path.py | 22 + .../0008_m3uaccount_stale_stream_days.py | 18 + ...count_type_m3uaccount_password_and_more.py | 28 + ...0_add_status_fields_and_remove_auto_now.py | 28 + .../0011_alter_m3uaccount_status.py | 18 + .../0012_alter_m3uaccount_refresh_interval.py | 18 + .../0013_alter_m3ufilter_filter_type.py | 18 + ...alter_m3ufilter_options_m3ufilter_order.py | 22 + ...ter_options_m3ufilter_custom_properties.py | 22 + .../migrations/0016_m3uaccount_priority.py | 18 + ...r_m3uaccount_custom_properties_and_more.py | 28 + .../0018_add_profile_custom_properties.py | 18 + .../0019_m3uaccountprofile_exp_date.py | 57 + apps/m3u/migrations/__init__.py | 0 apps/m3u/models.py | 389 ++ apps/m3u/serializers.py | 384 ++ apps/m3u/signals.py | 196 + apps/m3u/tasks.py | 3360 ++++++++++++++ apps/m3u/tests/__init__.py | 0 .../tests/test_expiration_notifications.py | 216 + apps/m3u/tests/test_extinf_parsing.py | 41 + apps/m3u/tests/test_memory_cleanup.py | 106 + apps/m3u/urls.py | 6 + apps/m3u/utils.py | 118 + apps/m3u/views.py | 35 + apps/output/__init__.py | 0 apps/output/apps.py | 6 + apps/output/tests.py | 145 + apps/output/urls.py | 14 + apps/output/views.py | 3140 +++++++++++++ apps/plugins/__init__.py | 2 + apps/plugins/api_urls.py | 41 + apps/plugins/api_views.py | 1302 ++++++ apps/plugins/apps.py | 104 + apps/plugins/keys/dispatcharr-plugins.pub | 11 + apps/plugins/loader.py | 805 ++++ apps/plugins/migrations/0001_initial.py | 29 + apps/plugins/migrations/0002_pluginrepo.py | 84 + apps/plugins/migrations/__init__.py | 1 + apps/plugins/models.py | 63 + apps/plugins/serializers.py | 86 + apps/plugins/tasks.py | 33 + apps/proxy/__init__.py | 1 + apps/proxy/apps.py | 17 + apps/proxy/config.py | 158 + apps/proxy/hls_proxy/__init__.py | 0 apps/proxy/hls_proxy/server.py | 1106 +++++ apps/proxy/hls_proxy/urls.py | 11 + apps/proxy/hls_proxy/views.py | 101 + apps/proxy/management/commands/proxy.py | 67 + apps/proxy/signals.py | 19 + apps/proxy/tasks.py | 59 + apps/proxy/ts_proxy/__init__.py | 0 apps/proxy/ts_proxy/apps.py | 13 + apps/proxy/ts_proxy/channel_status.py | 538 +++ apps/proxy/ts_proxy/client_manager.py | 451 ++ apps/proxy/ts_proxy/config_helper.py | 120 + apps/proxy/ts_proxy/constants.py | 117 + apps/proxy/ts_proxy/http_streamer.py | 138 + apps/proxy/ts_proxy/redis_keys.py | 96 + apps/proxy/ts_proxy/server.py | 1558 +++++++ .../ts_proxy/services/channel_service.py | 693 +++ apps/proxy/ts_proxy/services/log_parsers.py | 410 ++ apps/proxy/ts_proxy/stream_buffer.py | 546 +++ apps/proxy/ts_proxy/stream_generator.py | 641 +++ apps/proxy/ts_proxy/stream_manager.py | 1750 ++++++++ apps/proxy/ts_proxy/url_utils.py | 566 +++ apps/proxy/ts_proxy/urls.py | 14 + apps/proxy/ts_proxy/utils.py | 116 + apps/proxy/ts_proxy/views.py | 962 ++++ apps/proxy/urls.py | 9 + apps/proxy/utils.py | 185 + apps/proxy/views.py | 64 + apps/proxy/vod_proxy/__init__.py | 0 .../multi_worker_connection_manager.py | 1617 +++++++ apps/proxy/vod_proxy/tests/__init__.py | 0 .../tests/test_profile_connections.py | 253 ++ apps/proxy/vod_proxy/urls.py | 22 + apps/proxy/vod_proxy/utils.py | 58 + apps/proxy/vod_proxy/views.py | 1571 +++++++ apps/proxy/vod_proxy/views.py.bak.1778276755 | 1062 +++++ apps/vod/__init__.py | 0 apps/vod/admin.py | 67 + apps/vod/api_urls.py | 22 + apps/vod/api_views.py | 1000 +++++ apps/vod/apps.py | 12 + apps/vod/catalog_settings.py | 149 + apps/vod/migrations/0001_initial.py | 201 + .../0002_add_last_seen_with_default.py | 29 + ...logo_alter_movie_logo_alter_series_logo.py | 264 ++ ...0004_m3uepisoderelation_series_relation.py | 19 + apps/vod/migrations/__init__.py | 0 apps/vod/models.py | 361 ++ .../models.py.bak.jellyfin-stream.1777823495 | 331 ++ apps/vod/serializers.py | 304 ++ apps/vod/tasks.py | 2224 +++++++++ apps/vod/urls.py | 16 + core/__init__.py | 0 core/admin.py | 41 + core/api_urls.py | 29 + core/api_views.py | 665 +++ core/apps.py | 48 + core/command_utils.py | 34 + core/developer_notifications.py | 412 ++ core/fixtures/developer_notifications.json | 43 + core/fixtures/initial_data.json | 55 + core/management/commands/__init__.py | 0 core/management/commands/dropdb.py | 54 + core/management/commands/kill_processes.py | 27 + .../commands/reset_network_access.py | 13 + core/migrations/0001_initial.py | 46 + core/migrations/0002_preload_user_agents.py | 36 + .../0003_preload_stream_profiles.py | 31 + core/migrations/0004_preload_core_settings.py | 28 + ...ed_alter_streamprofile_command_and_more.py | 34 + .../0006_set_locked_stream_profiles.py | 59 + ...eate_proxy_and_redirect_stream_profiles.py | 36 + ...rofile_name_streamprofile_name_and_more.py | 23 + core/migrations/0009_m3u_hash_settings.py | 22 + .../0010_reload_additional_settings.py | 22 + ...011_fix_stream_profiles_and_user_agents.py | 27 + .../0012_default_active_m3u_accounts.py | 22 + .../0013_default_network_access_settings.py | 24 + .../migrations/0014_default_proxy_settings.py | 35 + core/migrations/0015_dvr_templates.py | 30 + .../0016_update_dvr_template_paths.py | 61 + core/migrations/0017_systemevent.py | 28 + .../0018_alter_systemevent_event_type.py | 18 + .../migrations/0019_add_vlc_stream_profile.py | 42 + ..._change_coresettings_value_to_jsonfield.py | 267 ++ ...ystemnotification_notificationdismissal.py | 52 + .../022_default_user_limit_settings.py | 24 + core/migrations/__init__.py | 0 core/models.py | 568 +++ core/redis_pubsub.py | 263 ++ core/scheduling.py | 203 + core/serializers.py | 166 + core/signals.py | 39 + core/tasks.py | 992 ++++ core/tests.py | 223 + core/urls.py | 6 + core/utils.py | 707 +++ core/views.py | 195 + core/xtream_codes.py | 465 ++ debian_install.sh | 505 +++ dispatcharr/__init__.py | 6 + dispatcharr/admin.py | 8 + dispatcharr/app_initialization.py | 52 + dispatcharr/asgi.py | 17 + dispatcharr/celery.py | 161 + dispatcharr/consumers.py | 72 + dispatcharr/jwt_ws_auth.py | 47 + dispatcharr/persistent_lock.py | 126 + dispatcharr/routing.py | 6 + dispatcharr/settings.py | 570 +++ dispatcharr/urls.py | 70 + dispatcharr/utils.py | 69 + dispatcharr/wsgi.py | 8 + docker/DispatcharrBase | 69 + docker/Dockerfile | 54 + docker/build-dev.sh | 69 + docker/comskip.ini | 6 + docker/docker-compose.aio.yml | 46 + docker/docker-compose.debug.yml | 33 + docker/docker-compose.dev.yml | 57 + docker/docker-compose.yml | 229 + docker/entrypoint.aio.sh | 105 + docker/entrypoint.celery.sh | 70 + docker/entrypoint.sh | 396 ++ docker/init/00-fix-pg-ssl-key.sh | 44 + docker/init/01-user-setup.sh | 127 + docker/init/02-postgres.sh | 507 +++ docker/init/03-init-dispatcharr.sh | 130 + docker/init/04-check-hwaccel.sh | 715 +++ docker/init/99-init-dev.sh | 33 + docker/nginx.conf | 99 + docker/tests/test-puid-pgid.sh | 1517 +++++++ docker/tests/test-tls-postgres.sh | 892 ++++ docker/uwsgi.debug.ini | 84 + docker/uwsgi.dev.ini | 60 + docker/uwsgi.ini | 62 + docker/uwsgi.modular.ini | 59 + docs/images/channels.png | Bin 0 -> 130246 bytes docs/images/guide.png | Bin 0 -> 235200 bytes docs/images/m3u-epg-manager.png | Bin 0 -> 62820 bytes docs/images/settings.png | Bin 0 -> 56849 bytes docs/images/stats.png | Bin 0 -> 84755 bytes docs/images/vod-library.png | Bin 0 -> 1121112 bytes fixtures.json | 99 + frontend/.gitignore | 24 + frontend/README.md | 12 + frontend/eslint.config.js | 33 + frontend/index.html | 33 + frontend/package.json | 77 + frontend/prettier.config.js | 11 + frontend/public/android-chrome-192x192.png | Bin 0 -> 12652 bytes frontend/public/android-chrome-512x512.png | Bin 0 -> 32394 bytes frontend/public/apple-touch-icon.png | Bin 0 -> 12159 bytes frontend/public/favicon-16x16.png | Bin 0 -> 772 bytes frontend/public/favicon-32x32.png | Bin 0 -> 1730 bytes frontend/public/favicon.ico | Bin 0 -> 15406 bytes frontend/public/logo.png | Bin 0 -> 44564 bytes frontend/public/site.webmanifest | 19 + frontend/public/vite.svg | 1 + frontend/src/App.css | 38 + frontend/src/App.jsx | 203 + frontend/src/WebSocket.jsx | 1060 +++++ frontend/src/api.js | 3579 +++++++++++++++ .../src/assets/android-chrome-192x192.png | Bin 0 -> 12652 bytes .../src/assets/android-chrome-512x512.png | Bin 0 -> 32394 bytes frontend/src/assets/apple-touch-icon.png | Bin 0 -> 12159 bytes frontend/src/assets/favicon-16x16.png | Bin 0 -> 772 bytes frontend/src/assets/favicon-32x32.png | Bin 0 -> 1730 bytes frontend/src/assets/favicon.ico | Bin 0 -> 15406 bytes frontend/src/assets/logo.png | Bin 0 -> 44564 bytes frontend/src/assets/react.svg | 1 + frontend/src/assets/site.webmanifest | 19 + frontend/src/assets/vite.svg | 1 + .../src/components/ConfirmationDialog.jsx | 115 + frontend/src/components/ErrorBoundary.jsx | 22 + frontend/src/components/Field.jsx | 84 + frontend/src/components/FloatingVideo.jsx | 970 ++++ frontend/src/components/GuideRow.jsx | 250 ++ frontend/src/components/HourTimeline.jsx | 104 + frontend/src/components/LazyLogo.jsx | 128 + .../src/components/M3URefreshNotification.jsx | 186 + .../src/components/NotificationCenter.jsx | 435 ++ frontend/src/components/PluginDetailPanel.jsx | 430 ++ .../src/components/ProgramDetailModal.jsx | 409 ++ frontend/src/components/RecordingSynopsis.jsx | 26 + frontend/src/components/SeriesModal.jsx | 728 +++ frontend/src/components/Sidebar.jsx | 400 ++ frontend/src/components/SystemEvents.jsx | 335 ++ frontend/src/components/VODModal.jsx | 503 +++ .../__tests__/ConfirmationDialog.test.jsx | 275 ++ .../__tests__/ErrorBoundary.test.jsx | 101 + .../src/components/__tests__/Field.test.jsx | 802 ++++ .../__tests__/FloatingVideo.test.jsx | 482 ++ .../components/__tests__/GuideRow.test.jsx | 718 +++ .../__tests__/HourTimeline.test.jsx | 335 ++ .../components/__tests__/LazyLogo.test.jsx | 398 ++ .../__tests__/M3URefreshNotification.test.jsx | 717 +++ .../__tests__/NotificationCenter.test.jsx | 729 +++ .../__tests__/ProgramDetailModal.test.jsx | 435 ++ .../__tests__/RecordingSynopsis.test.jsx | 169 + .../components/__tests__/SeriesModal.test.jsx | 964 ++++ .../src/components/__tests__/Sidebar.test.jsx | 686 +++ .../__tests__/SystemEvents.test.jsx | 251 ++ .../components/__tests__/VODModal.test.jsx | 470 ++ .../src/components/backups/BackupManager.jsx | 825 ++++ .../components/cards/AvailablePluginCard.jsx | 769 ++++ frontend/src/components/cards/PluginCard.jsx | 621 +++ .../src/components/cards/RecordingCard.jsx | 672 +++ frontend/src/components/cards/SeriesCard.jsx | 85 + .../components/cards/StreamConnectionCard.jsx | 798 ++++ frontend/src/components/cards/VODCard.jsx | 143 + .../components/cards/VodConnectionCard.jsx | 464 ++ .../cards/__tests__/PluginCard.test.jsx | 503 +++ .../cards/__tests__/RecordingCard.test.jsx | 1077 +++++ .../cards/__tests__/SeriesCard.test.jsx | 206 + .../__tests__/StreamConnectionCard.test.jsx | 900 ++++ .../cards/__tests__/VODCard.test.jsx | 328 ++ .../__tests__/VodConnectionCard.test.jsx | 838 ++++ .../src/components/forms/AccountInfoModal.jsx | 421 ++ .../components/forms/AssignChannelNumbers.jsx | 83 + frontend/src/components/forms/Channel.jsx | 971 ++++ .../src/components/forms/ChannelBatch.jsx | 1002 +++++ .../src/components/forms/ChannelGroup.jsx | 93 + frontend/src/components/forms/Connection.jsx | 348 ++ frontend/src/components/forms/CronBuilder.jsx | 299 ++ frontend/src/components/forms/DummyEPG.jsx | 1221 +++++ frontend/src/components/forms/EPG.jsx | 250 ++ .../src/components/forms/GroupManager.jsx | 735 +++ .../src/components/forms/LiveGroupFilter.jsx | 1400 ++++++ frontend/src/components/forms/LoginForm.jsx | 321 ++ frontend/src/components/forms/Logo.jsx | 383 ++ frontend/src/components/forms/M3U.jsx | 544 +++ frontend/src/components/forms/M3UFilter.jsx | 140 + frontend/src/components/forms/M3UFilters.jsx | 352 ++ .../src/components/forms/M3UGroupFilter.jsx | 222 + frontend/src/components/forms/M3UProfile.jsx | 514 +++ frontend/src/components/forms/M3UProfiles.jsx | 397 ++ .../forms/ProgramRecordingModal.jsx | 111 + frontend/src/components/forms/Recording.jsx | 506 +++ .../forms/RecordingDetailsModal.jsx | 606 +++ .../components/forms/RecurringRuleModal.jsx | 430 ++ .../src/components/forms/ScheduleInput.jsx | 229 + .../components/forms/SeriesRecordingModal.jsx | 95 + frontend/src/components/forms/Stream.jsx | 137 + .../src/components/forms/StreamProfile.jsx | 217 + .../src/components/forms/SuperuserForm.jsx | 141 + frontend/src/components/forms/User.jsx | 458 ++ frontend/src/components/forms/UserAgent.jsx | 113 + .../components/forms/VODCategoryFilter.jsx | 185 + .../forms/__tests__/AccountInfoModal.test.jsx | 447 ++ .../__tests__/AssignChannelNumbers.test.jsx | 346 ++ .../forms/__tests__/Channel.test.jsx | 1083 +++++ .../forms/__tests__/ChannelBatch.test.jsx | 958 ++++ .../forms/__tests__/ChannelGroup.test.jsx | 403 ++ .../forms/__tests__/Connection.test.jsx | 955 ++++ .../forms/__tests__/CronBuilder.test.jsx | 518 +++ .../forms/__tests__/DummyEPG.test.jsx | 801 ++++ .../components/forms/__tests__/EPG.test.jsx | 609 +++ .../settings/ConnectionSecurityPanel.jsx | 170 + .../forms/settings/DvrSettingsForm.jsx | 237 + .../forms/settings/NavOrderForm.jsx | 295 ++ .../forms/settings/NetworkAccessForm.jsx | 216 + .../forms/settings/ProxySettingsForm.jsx | 169 + .../forms/settings/StreamSettingsForm.jsx | 300 ++ .../forms/settings/SystemSettingsForm.jsx | 101 + .../forms/settings/UiSettingsForm.jsx | 158 + .../forms/settings/UserLimitsForm.jsx | 116 + .../__tests__/DvrSettingsForm.test.jsx | 531 +++ .../settings/__tests__/NavOrderForm.test.jsx | 256 ++ .../__tests__/NetworkAccessForm.test.jsx | 409 ++ .../__tests__/ProxySettingsForm.test.jsx | 363 ++ .../__tests__/StreamSettingsForm.test.jsx | 804 ++++ .../__tests__/SystemSettingsForm.test.jsx | 378 ++ .../__tests__/UiSettingsForm.test.jsx | 458 ++ .../components/modals/CreateChannelModal.jsx | 189 + .../src/components/modals/EPGMatchModal.jsx | 214 + .../src/components/modals/ProfileModal.jsx | 193 + .../components/modals/YouTubeTrailerModal.jsx | 29 + .../modals/__tests__/EPGMatchModal.test.jsx | 318 ++ .../__tests__/YouTubeTrailerModal.test.jsx | 109 + frontend/src/components/pluginUtils.js | 25 + frontend/src/components/sidebar.css | 58 + .../components/tables/ChannelTableStreams.jsx | 677 +++ .../src/components/tables/ChannelsTable.jsx | 1657 +++++++ .../ChannelsTable/ChannelTableHeader.jsx | 509 +++ .../ChannelsTable/ChannelsTableOnboarding.jsx | 86 + .../tables/ChannelsTable/DraggableRow.jsx | 59 + .../tables/ChannelsTable/EditableCell.jsx | 834 ++++ .../tables/CustomTable/CustomTable.jsx | 70 + .../tables/CustomTable/CustomTableBody.jsx | 226 + .../tables/CustomTable/CustomTableHeader.jsx | 184 + .../CustomTable/MultiSelectHeaderWrapper.jsx | 185 + .../components/tables/CustomTable/index.jsx | 284 ++ frontend/src/components/tables/EPGsTable.jsx | 720 +++ frontend/src/components/tables/LogosTable.jsx | 897 ++++ frontend/src/components/tables/M3UsTable.jsx | 1055 +++++ .../components/tables/StreamProfilesTable.jsx | 333 ++ .../src/components/tables/StreamsTable.jsx | 1867 ++++++++ .../src/components/tables/UserAgentsTable.jsx | 268 ++ frontend/src/components/tables/UsersTable.jsx | 441 ++ .../src/components/tables/VODLogosTable.jsx | 680 +++ frontend/src/components/tables/table.css | 245 + frontend/src/components/theme/Button.jsx | 19 + .../src/config/__tests__/navigation.test.js | 212 + frontend/src/config/navigation.js | 172 + frontend/src/constants.js | 406 ++ frontend/src/helpers/index.jsx | 3 + frontend/src/helpers/table.jsx | 71 + .../hooks/__tests__/useLocalStorage.test.jsx | 123 + .../hooks/__tests__/useSmartLogos.test.jsx | 345 ++ .../__tests__/useTablePreferences.test.jsx | 411 ++ frontend/src/hooks/useLocalStorage.jsx | 28 + frontend/src/hooks/useSmartLogos.jsx | 130 + frontend/src/hooks/useTablePreferences.jsx | 117 + frontend/src/images/dispatcharr.svg | 23 + frontend/src/images/ghost.svg | 66 + frontend/src/images/logo.png | Bin 0 -> 44564 bytes frontend/src/index.css | 142 + frontend/src/logo.svg | 1 + frontend/src/main.jsx | 9 + frontend/src/mantineTheme.jsx | 235 + frontend/src/pages/Channels.jsx | 111 + frontend/src/pages/Connect.jsx | 203 + frontend/src/pages/ConnectLogs.jsx | 299 ++ frontend/src/pages/ContentSources.jsx | 41 + frontend/src/pages/DVR.jsx | 460 ++ frontend/src/pages/Guide.jsx | 1511 +++++++ frontend/src/pages/Login.jsx | 24 + frontend/src/pages/Logos.jsx | 83 + frontend/src/pages/PluginBrowse.jsx | 885 ++++ frontend/src/pages/Plugins.jsx | 639 +++ frontend/src/pages/Settings.jsx | 227 + frontend/src/pages/Stats.jsx | 436 ++ frontend/src/pages/Users.jsx | 25 + frontend/src/pages/VODs.jsx | 254 ++ .../src/pages/__tests__/Channels.test.jsx | 48 + .../pages/__tests__/ContentSources.test.jsx | 33 + frontend/src/pages/__tests__/DVR.test.jsx | 632 +++ frontend/src/pages/__tests__/Guide.test.jsx | 787 ++++ frontend/src/pages/__tests__/Login.test.jsx | 37 + frontend/src/pages/__tests__/Logos.test.jsx | 182 + frontend/src/pages/__tests__/Plugins.test.jsx | 652 +++ .../src/pages/__tests__/Settings.test.jsx | 301 ++ frontend/src/pages/__tests__/Stats.test.jsx | 515 +++ frontend/src/pages/__tests__/Users.test.jsx | 58 + frontend/src/pages/__tests__/VODs.test.jsx | 462 ++ .../src/pages/__tests__/guideUtils.test.js | 1299 ++++++ frontend/src/pages/guide.css | 57 + frontend/src/store/__tests__/auth.test.jsx | 680 +++ .../src/store/__tests__/channels.test.jsx | 434 ++ .../store/__tests__/channelsTable.test.jsx | 412 ++ frontend/src/store/__tests__/epgs.test.jsx | 732 +++ frontend/src/store/__tests__/logos.test.jsx | 999 +++++ .../src/store/__tests__/playlists.test.jsx | 294 ++ frontend/src/store/__tests__/plugins.test.jsx | 236 + .../src/store/__tests__/settings.test.jsx | 362 ++ .../store/__tests__/streamProfiles.test.jsx | 276 ++ frontend/src/store/__tests__/streams.test.jsx | 310 ++ .../src/store/__tests__/streamsTable.test.jsx | 345 ++ .../src/store/__tests__/useVODStore.test.jsx | 799 ++++ .../store/__tests__/useVideoStore.test.jsx | 192 + .../src/store/__tests__/userAgents.test.jsx | 298 ++ frontend/src/store/__tests__/users.test.jsx | 287 ++ .../src/store/__tests__/vodLogos.test.jsx | 569 +++ .../src/store/__tests__/warnings.test.jsx | 195 + frontend/src/store/auth.jsx | 252 ++ frontend/src/store/channels.jsx | 527 +++ frontend/src/store/channelsTable.jsx | 71 + frontend/src/store/connect.jsx | 46 + frontend/src/store/epgs.jsx | 148 + frontend/src/store/logos.jsx | 354 ++ frontend/src/store/notifications.jsx | 114 + frontend/src/store/playlists.jsx | 147 + frontend/src/store/plugins.jsx | 117 + frontend/src/store/settings.jsx | 87 + frontend/src/store/streamProfiles.jsx | 40 + frontend/src/store/streams.jsx | 41 + frontend/src/store/streamsTable.jsx | 55 + frontend/src/store/useVODStore.jsx | 426 ++ frontend/src/store/useVideoStore.jsx | 30 + frontend/src/store/userAgents.jsx | 40 + frontend/src/store/users.jsx | 41 + frontend/src/store/vodLogos.jsx | 146 + frontend/src/store/warnings.jsx | 29 + frontend/src/test/setupTests.js | 42 + frontend/src/utils.js | 154 + .../src/utils/__tests__/dateTimeUtils.test.js | 669 +++ .../src/utils/__tests__/networkUtils.test.js | 152 + .../utils/__tests__/notificationUtils.test.js | 162 + frontend/src/utils/cards/PluginCardUtils.js | 24 + .../src/utils/cards/RecordingCardUtils.js | 123 + .../utils/cards/StreamConnectionCardUtils.js | 131 + frontend/src/utils/cards/VODCardUtils.js | 13 + .../src/utils/cards/VodConnectionCardUtils.js | 142 + .../cards/__tests__/PluginCardUtils.test.js | 157 + .../__tests__/RecordingCardUtils.test.js | 396 ++ .../StreamConnectionCardUtils.test.js | 340 ++ .../cards/__tests__/VODCardUtils.test.js | 90 + .../__tests__/VodConnectionCardUtils.test.js | 354 ++ .../utils/components/FloatingVideoUtils.js | 151 + .../components/NotificationCenterUtils.js | 11 + .../src/utils/components/SeriesModalUtils.js | 166 + .../src/utils/components/VODModalUtils.js | 124 + .../__tests__/FloatingVideoUtils.test.js | 441 ++ .../__tests__/NotificationCenterUtils.test.js | 83 + .../__tests__/SeriesModalUtils.test.js | 397 ++ .../__tests__/VODModalUtils.test.js | 344 ++ frontend/src/utils/cronUtils.js | 63 + frontend/src/utils/dateTimeUtils.js | 339 ++ frontend/src/utils/externalUrls.js | 8 + .../src/utils/forms/AccountInfoModalUtils.js | 54 + frontend/src/utils/forms/ChannelBatchUtils.js | 186 + frontend/src/utils/forms/ChannelUtils.js | 97 + frontend/src/utils/forms/ConnectionUtils.js | 75 + frontend/src/utils/forms/CronBuilderUtils.js | 131 + frontend/src/utils/forms/DummyEpgUtils.js | 263 ++ .../utils/forms/RecordingDetailsModalUtils.js | 88 + .../utils/forms/RecurringRuleModalUtils.js | 66 + .../__tests__/AccountInfoModalUtils.test.js | 236 + .../forms/__tests__/ChannelBatchUtils.test.js | 649 +++ .../forms/__tests__/ChannelUtils.test.js | 461 ++ .../forms/__tests__/ConnectionUtils.test.js | 399 ++ .../forms/__tests__/CronBuilderUtils.test.js | 359 ++ .../forms/__tests__/DummyEpgUtils.test.js | 642 +++ .../RecordingDetailsModalUtils.test.js | 627 +++ .../__tests__/RecurringRuleModalUtils.test.js | 533 +++ .../forms/settings/DvrSettingsFormUtils.js | 22 + .../forms/settings/NetworkAccessFormUtils.js | 47 + .../forms/settings/ProxySettingsFormUtils.js | 19 + .../forms/settings/StreamSettingsFormUtils.js | 19 + .../forms/settings/SystemSettingsFormUtils.js | 5 + .../forms/settings/UiSettingsFormUtils.js | 17 + .../__tests__/DvrSettingsFormUtils.test.js | 100 + .../__tests__/NetworkAccessFormUtils.test.js | 146 + .../__tests__/ProxySettingsFormUtils.test.js | 87 + .../__tests__/StreamSettingsFormUtils.test.js | 113 + .../__tests__/SystemSettingsFormUtils.test.js | 40 + .../__tests__/UiSettingsFormUtils.test.js | 152 + frontend/src/utils/guideUtils.js | 429 ++ frontend/src/utils/networkUtils.js | 25 + frontend/src/utils/notificationUtils.js | 9 + frontend/src/utils/pages/DVRUtils.js | 142 + frontend/src/utils/pages/PluginsUtils.js | 20 + frontend/src/utils/pages/SettingsUtils.js | 348 ++ frontend/src/utils/pages/StatsUtils.js | 158 + frontend/src/utils/pages/VODsUtils.js | 28 + .../utils/pages/__tests__/DVRUtils.test.js | 702 +++ .../pages/__tests__/PluginsUtils.test.js | 301 ++ .../pages/__tests__/SettingsUtils.test.js | 603 +++ .../utils/pages/__tests__/StatsUtils.test.js | 640 +++ .../utils/pages/__tests__/VODsUtils.test.js | 270 ++ frontend/vite.config.js | 35 + manage.py | 19 + pyproject.toml | 58 + scripts/bump_version.py | 62 + scripts/debug_wrapper.py | 103 + scripts/increment_build.py | 33 + scripts/jellyfin_vod_sync_dispatcharr.py | 644 +++ scripts/link_backup_streams_boss_javi.py | 93 + scripts/run_jellyfin_sync_cron.sh | 13 + scripts/standalone_debug.py | 36 + scripts/update_changelog.py | 59 + scripts/wait_for_redis.py | 137 + start-celery.sh | 6 + start-celerybeat.sh | 6 + start-daphne.sh | 6 + start-gunicorn.sh | 11 + tests/__init__.py | 0 tests/test_user_preferences.py | 142 + tests/test_wait_for_redis.py | 99 + version.py | 5 + 721 files changed, 177870 insertions(+) create mode 100755 .dockerignore create mode 100644 .env create mode 100644 .gitattributes create mode 100644 .github/FUNDING.yml create mode 100644 .github/ISSUE_TEMPLATE/bug_report.yml create mode 100644 .github/ISSUE_TEMPLATE/config.yml create mode 100644 .github/ISSUE_TEMPLATE/feature_request.yml create mode 100644 .github/pull_request_template.md create mode 100644 .github/workflows/base-image.yml create mode 100644 .github/workflows/ci.yml create mode 100644 .github/workflows/docker-build.yml create mode 100644 .github/workflows/frontend-tests.yml create mode 100644 .github/workflows/release.yml create mode 100755 .gitignore create mode 100644 .python-version create mode 100644 CHANGELOG.md create mode 100644 CONTRIBUTING.md create mode 100644 LICENSE create mode 100644 Plugin_repo.md create mode 100644 Plugins.md create mode 100644 README.md create mode 100644 apps/accounts/__init__.py create mode 100644 apps/accounts/admin.py create mode 100644 apps/accounts/api_urls.py create mode 100644 apps/accounts/api_views.py create mode 100644 apps/accounts/apps.py create mode 100644 apps/accounts/authentication.py create mode 100644 apps/accounts/forms.py create mode 100644 apps/accounts/migrations/0001_initial.py create mode 100644 apps/accounts/migrations/0002_remove_user_channel_groups_user_channel_profiles_and_more.py create mode 100644 apps/accounts/migrations/0003_alter_user_custom_properties.py create mode 100644 apps/accounts/migrations/0004_user_api_key.py create mode 100644 apps/accounts/migrations/0005_alter_user_managers.py create mode 100644 apps/accounts/migrations/0006_user_stream_limit.py create mode 100644 apps/accounts/migrations/__init__.py create mode 100644 apps/accounts/models.py create mode 100644 apps/accounts/permissions.py create mode 100644 apps/accounts/serializers.py create mode 100644 apps/accounts/signals.py create mode 100644 apps/accounts/tests.py create mode 100644 apps/accounts/urls.py create mode 100644 apps/api/urls.py create mode 100644 apps/backups/__init__.py create mode 100644 apps/backups/api_urls.py create mode 100644 apps/backups/api_views.py create mode 100644 apps/backups/apps.py create mode 100644 apps/backups/migrations/__init__.py create mode 100644 apps/backups/models.py create mode 100644 apps/backups/scheduler.py create mode 100644 apps/backups/services.py create mode 100644 apps/backups/tasks.py create mode 100644 apps/backups/tests.py create mode 100644 apps/channels/__init__.py create mode 100644 apps/channels/admin.py create mode 100644 apps/channels/api_urls.py create mode 100644 apps/channels/api_views.py create mode 100644 apps/channels/apps.py create mode 100644 apps/channels/forms.py create mode 100644 apps/channels/migrations/0001_initial.py create mode 100644 apps/channels/migrations/0002_rename_channel_name_channel_name_and_more.py create mode 100644 apps/channels/migrations/0003_channel_uuid.py create mode 100644 apps/channels/migrations/0004_stream_is_custom.py create mode 100644 apps/channels/migrations/0005_stream_channel_group_stream_last_seen_and_more.py create mode 100644 apps/channels/migrations/0006_migrate_stream_groups.py create mode 100644 apps/channels/migrations/0007_remove_stream_group_name.py create mode 100644 apps/channels/migrations/0008_stream_stream_hash.py create mode 100644 apps/channels/migrations/0009_remove_channel_tvg_name_channel_epg_data.py create mode 100644 apps/channels/migrations/0010_stream_custom_properties.py create mode 100644 apps/channels/migrations/0011_logo_remove_channel_logo_file_and_more.py create mode 100644 apps/channels/migrations/0012_channelprofile_channelprofilemembership.py create mode 100644 apps/channels/migrations/0013_alter_logo_url.py create mode 100644 apps/channels/migrations/0014_recording.py create mode 100644 apps/channels/migrations/0015_recording_custom_properties.py create mode 100644 apps/channels/migrations/0016_channelstream_unique_channel_stream.py create mode 100644 apps/channels/migrations/0017_alter_channelgroup_name.py create mode 100644 apps/channels/migrations/0018_channelgroupm3uaccount_custom_properties_and_more.py create mode 100644 apps/channels/migrations/0019_channel_tvc_guide_stationid.py create mode 100644 apps/channels/migrations/0020_alter_channel_channel_number.py create mode 100644 apps/channels/migrations/0021_channel_user_level.py create mode 100644 apps/channels/migrations/0022_channel_auto_created_channel_auto_created_by_and_more.py create mode 100644 apps/channels/migrations/0023_stream_stream_stats_stream_stream_stats_updated_at.py create mode 100644 apps/channels/migrations/0024_alter_channelgroupm3uaccount_channel_group.py create mode 100644 apps/channels/migrations/0025_alter_channelgroupm3uaccount_custom_properties_and_more.py create mode 100644 apps/channels/migrations/0026_recurringrecordingrule.py create mode 100644 apps/channels/migrations/0027_recurringrecordingrule_end_date_and_more.py create mode 100644 apps/channels/migrations/0028_channel_created_at_channel_updated_at.py create mode 100644 apps/channels/migrations/0029_backfill_custom_stream_hashes.py create mode 100644 apps/channels/migrations/0030_alter_stream_url.py create mode 100644 apps/channels/migrations/0031_channelgroupm3uaccount_is_stale_and_more.py create mode 100644 apps/channels/migrations/0032_channel_is_adult_stream_is_adult.py create mode 100644 apps/channels/migrations/0033_stream_id_stream_chno.py create mode 100644 apps/channels/migrations/0034_remove_stream_dispatcharr_stream_id_idx_and_more.py create mode 100644 apps/channels/migrations/__init__.py create mode 100644 apps/channels/models.py create mode 100644 apps/channels/models.py.bak.1778276755 create mode 100644 apps/channels/serializers.py create mode 100644 apps/channels/signals.py create mode 100755 apps/channels/tasks.py create mode 100644 apps/channels/tests/__init__.py create mode 100644 apps/channels/tests/test_channel_api.py create mode 100644 apps/channels/tests/test_db_retry.py create mode 100644 apps/channels/tests/test_dvr_port_resolution.py create mode 100644 apps/channels/tests/test_epg_matching.py create mode 100644 apps/channels/tests/test_recording_extend.py create mode 100644 apps/channels/tests/test_recording_metadata.py create mode 100644 apps/channels/tests/test_recording_pipeline.py create mode 100644 apps/channels/tests/test_recording_scheduling.py create mode 100644 apps/channels/tests/test_recording_stop_cancel.py create mode 100644 apps/channels/tests/test_recurring_rules.py create mode 100644 apps/channels/tests/test_series_rule_dedup.py create mode 100644 apps/channels/tests/test_ts_proxy_ghost_clients.py create mode 100644 apps/channels/tests/test_ts_proxy_initializing.py create mode 100644 apps/channels/tests/test_ts_proxy_keepalive.py create mode 100644 apps/channels/tests/test_ts_proxy_keepalive_duration.py create mode 100644 apps/channels/tests/test_validate_url.py create mode 100644 apps/channels/urls.py create mode 100644 apps/channels/utils.py create mode 100644 apps/channels/views.py create mode 100644 apps/connect/__init__.py create mode 100644 apps/connect/api_urls.py create mode 100644 apps/connect/api_views.py create mode 100644 apps/connect/apps.py create mode 100644 apps/connect/handlers/__init__.py create mode 100644 apps/connect/handlers/api.py create mode 100644 apps/connect/handlers/base.py create mode 100644 apps/connect/handlers/script.py create mode 100644 apps/connect/handlers/webhook.py create mode 100644 apps/connect/migrations/0001_initial.py create mode 100644 apps/connect/migrations/0002_alter_eventsubscription_event.py create mode 100644 apps/connect/migrations/__init__.py create mode 100644 apps/connect/models.py create mode 100644 apps/connect/serializers.py create mode 100644 apps/connect/utils.py create mode 100644 apps/dashboard/__init__.py create mode 100644 apps/dashboard/admin.py create mode 100644 apps/dashboard/api_urls.py create mode 100644 apps/dashboard/apps.py create mode 100644 apps/dashboard/migrations/0001_initial.py create mode 100644 apps/dashboard/migrations/__init__.py create mode 100644 apps/dashboard/models.py create mode 100644 apps/dashboard/tests.py create mode 100644 apps/dashboard/urls.py create mode 100644 apps/dashboard/views.py create mode 100644 apps/epg/__init__.py create mode 100644 apps/epg/admin.py create mode 100644 apps/epg/api_urls.py create mode 100644 apps/epg/api_views.py create mode 100644 apps/epg/apps.py create mode 100644 apps/epg/migrations/0001_initial.py create mode 100644 apps/epg/migrations/0002_epgsource_file_path.py create mode 100644 apps/epg/migrations/0003_alter_epgdata_tvg_id.py create mode 100644 apps/epg/migrations/0004_epgdata_epg_source_alter_epgdata_tvg_id.py create mode 100644 apps/epg/migrations/0005_programdata_custom_properties_and_more.py create mode 100644 apps/epg/migrations/0006_epgsource_refresh_interval_epgsource_refresh_task.py create mode 100644 apps/epg/migrations/0007_epgsource_status_epgsource_last_error.py create mode 100644 apps/epg/migrations/0007_populate_periodic_tasks.py create mode 100644 apps/epg/migrations/0008_epgsource_created_at_epgsource_updated_at.py create mode 100644 apps/epg/migrations/0009_alter_epgsource_created_at_and_more.py create mode 100644 apps/epg/migrations/0010_merge_20250503_2147.py create mode 100644 apps/epg/migrations/0011_update_epgsource_fields.py create mode 100644 apps/epg/migrations/0012_alter_epgsource_status.py create mode 100644 apps/epg/migrations/0013_alter_epgsource_refresh_interval.py create mode 100644 apps/epg/migrations/0014_epgsource_extracted_file_path.py create mode 100644 apps/epg/migrations/0015_alter_programdata_custom_properties.py create mode 100644 apps/epg/migrations/0016_epgdata_icon_url.py create mode 100644 apps/epg/migrations/0017_alter_epgsource_url.py create mode 100644 apps/epg/migrations/0018_epgsource_custom_properties_and_more.py create mode 100644 apps/epg/migrations/0019_alter_programdata_sub_title.py create mode 100644 apps/epg/migrations/0020_migrate_time_to_starttime_placeholders.py create mode 100644 apps/epg/migrations/0021_epgsource_priority.py create mode 100644 apps/epg/migrations/__init__.py create mode 100644 apps/epg/models.py create mode 100644 apps/epg/serializers.py create mode 100644 apps/epg/signals.py create mode 100644 apps/epg/tasks.py create mode 100644 apps/epg/tests/__init__.py create mode 100644 apps/epg/tests/test_entity_resolution.py create mode 100644 apps/epg/tests/test_serializers.py create mode 100644 apps/epg/urls.py create mode 100644 apps/epg/utils.py create mode 100644 apps/epg/views.py create mode 100644 apps/hdhr/__init__.py create mode 100644 apps/hdhr/admin.py create mode 100644 apps/hdhr/api_urls.py create mode 100644 apps/hdhr/api_views.py create mode 100644 apps/hdhr/apps.py create mode 100644 apps/hdhr/migrations/0001_initial.py create mode 100644 apps/hdhr/migrations/__init__.py create mode 100644 apps/hdhr/models.py create mode 100644 apps/hdhr/serializers.py create mode 100644 apps/hdhr/ssdp.py create mode 100644 apps/hdhr/urls.py create mode 100644 apps/hdhr/views.py create mode 100644 apps/m3u/__init__.py create mode 100644 apps/m3u/admin.py create mode 100644 apps/m3u/api_urls.py create mode 100644 apps/m3u/api_views.py create mode 100644 apps/m3u/apps.py create mode 100644 apps/m3u/forms.py create mode 100644 apps/m3u/migrations/0001_initial.py create mode 100644 apps/m3u/migrations/0002_m3uaccount_locked.py create mode 100644 apps/m3u/migrations/0003_create_custom_account.py create mode 100644 apps/m3u/migrations/0004_m3uaccount_stream_profile.py create mode 100644 apps/m3u/migrations/0005_m3uaccount_custom_properties_and_more.py create mode 100644 apps/m3u/migrations/0006_populate_periodic_tasks.py create mode 100644 apps/m3u/migrations/0007_remove_m3uaccount_uploaded_file_m3uaccount_file_path.py create mode 100644 apps/m3u/migrations/0008_m3uaccount_stale_stream_days.py create mode 100644 apps/m3u/migrations/0009_m3uaccount_account_type_m3uaccount_password_and_more.py create mode 100644 apps/m3u/migrations/0010_add_status_fields_and_remove_auto_now.py create mode 100644 apps/m3u/migrations/0011_alter_m3uaccount_status.py create mode 100644 apps/m3u/migrations/0012_alter_m3uaccount_refresh_interval.py create mode 100644 apps/m3u/migrations/0013_alter_m3ufilter_filter_type.py create mode 100644 apps/m3u/migrations/0014_alter_m3ufilter_options_m3ufilter_order.py create mode 100644 apps/m3u/migrations/0015_alter_m3ufilter_options_m3ufilter_custom_properties.py create mode 100644 apps/m3u/migrations/0016_m3uaccount_priority.py create mode 100644 apps/m3u/migrations/0017_alter_m3uaccount_custom_properties_and_more.py create mode 100644 apps/m3u/migrations/0018_add_profile_custom_properties.py create mode 100644 apps/m3u/migrations/0019_m3uaccountprofile_exp_date.py create mode 100644 apps/m3u/migrations/__init__.py create mode 100644 apps/m3u/models.py create mode 100644 apps/m3u/serializers.py create mode 100644 apps/m3u/signals.py create mode 100644 apps/m3u/tasks.py create mode 100644 apps/m3u/tests/__init__.py create mode 100644 apps/m3u/tests/test_expiration_notifications.py create mode 100644 apps/m3u/tests/test_extinf_parsing.py create mode 100644 apps/m3u/tests/test_memory_cleanup.py create mode 100644 apps/m3u/urls.py create mode 100644 apps/m3u/utils.py create mode 100644 apps/m3u/views.py create mode 100644 apps/output/__init__.py create mode 100644 apps/output/apps.py create mode 100644 apps/output/tests.py create mode 100644 apps/output/urls.py create mode 100644 apps/output/views.py create mode 100644 apps/plugins/__init__.py create mode 100644 apps/plugins/api_urls.py create mode 100644 apps/plugins/api_views.py create mode 100644 apps/plugins/apps.py create mode 100644 apps/plugins/keys/dispatcharr-plugins.pub create mode 100644 apps/plugins/loader.py create mode 100644 apps/plugins/migrations/0001_initial.py create mode 100644 apps/plugins/migrations/0002_pluginrepo.py create mode 100644 apps/plugins/migrations/__init__.py create mode 100644 apps/plugins/models.py create mode 100644 apps/plugins/serializers.py create mode 100644 apps/plugins/tasks.py create mode 100644 apps/proxy/__init__.py create mode 100644 apps/proxy/apps.py create mode 100644 apps/proxy/config.py create mode 100644 apps/proxy/hls_proxy/__init__.py create mode 100644 apps/proxy/hls_proxy/server.py create mode 100644 apps/proxy/hls_proxy/urls.py create mode 100644 apps/proxy/hls_proxy/views.py create mode 100644 apps/proxy/management/commands/proxy.py create mode 100644 apps/proxy/signals.py create mode 100644 apps/proxy/tasks.py create mode 100644 apps/proxy/ts_proxy/__init__.py create mode 100644 apps/proxy/ts_proxy/apps.py create mode 100644 apps/proxy/ts_proxy/channel_status.py create mode 100644 apps/proxy/ts_proxy/client_manager.py create mode 100644 apps/proxy/ts_proxy/config_helper.py create mode 100644 apps/proxy/ts_proxy/constants.py create mode 100644 apps/proxy/ts_proxy/http_streamer.py create mode 100644 apps/proxy/ts_proxy/redis_keys.py create mode 100644 apps/proxy/ts_proxy/server.py create mode 100644 apps/proxy/ts_proxy/services/channel_service.py create mode 100644 apps/proxy/ts_proxy/services/log_parsers.py create mode 100644 apps/proxy/ts_proxy/stream_buffer.py create mode 100644 apps/proxy/ts_proxy/stream_generator.py create mode 100644 apps/proxy/ts_proxy/stream_manager.py create mode 100644 apps/proxy/ts_proxy/url_utils.py create mode 100644 apps/proxy/ts_proxy/urls.py create mode 100644 apps/proxy/ts_proxy/utils.py create mode 100644 apps/proxy/ts_proxy/views.py create mode 100644 apps/proxy/urls.py create mode 100644 apps/proxy/utils.py create mode 100644 apps/proxy/views.py create mode 100644 apps/proxy/vod_proxy/__init__.py create mode 100644 apps/proxy/vod_proxy/multi_worker_connection_manager.py create mode 100644 apps/proxy/vod_proxy/tests/__init__.py create mode 100644 apps/proxy/vod_proxy/tests/test_profile_connections.py create mode 100644 apps/proxy/vod_proxy/urls.py create mode 100644 apps/proxy/vod_proxy/utils.py create mode 100644 apps/proxy/vod_proxy/views.py create mode 100644 apps/proxy/vod_proxy/views.py.bak.1778276755 create mode 100644 apps/vod/__init__.py create mode 100644 apps/vod/admin.py create mode 100644 apps/vod/api_urls.py create mode 100644 apps/vod/api_views.py create mode 100644 apps/vod/apps.py create mode 100644 apps/vod/catalog_settings.py create mode 100644 apps/vod/migrations/0001_initial.py create mode 100644 apps/vod/migrations/0002_add_last_seen_with_default.py create mode 100644 apps/vod/migrations/0003_vodlogo_alter_movie_logo_alter_series_logo.py create mode 100644 apps/vod/migrations/0004_m3uepisoderelation_series_relation.py create mode 100644 apps/vod/migrations/__init__.py create mode 100644 apps/vod/models.py create mode 100644 apps/vod/models.py.bak.jellyfin-stream.1777823495 create mode 100644 apps/vod/serializers.py create mode 100644 apps/vod/tasks.py create mode 100644 apps/vod/urls.py create mode 100644 core/__init__.py create mode 100644 core/admin.py create mode 100644 core/api_urls.py create mode 100644 core/api_views.py create mode 100644 core/apps.py create mode 100644 core/command_utils.py create mode 100644 core/developer_notifications.py create mode 100644 core/fixtures/developer_notifications.json create mode 100644 core/fixtures/initial_data.json create mode 100644 core/management/commands/__init__.py create mode 100644 core/management/commands/dropdb.py create mode 100644 core/management/commands/kill_processes.py create mode 100644 core/management/commands/reset_network_access.py create mode 100644 core/migrations/0001_initial.py create mode 100644 core/migrations/0002_preload_user_agents.py create mode 100644 core/migrations/0003_preload_stream_profiles.py create mode 100644 core/migrations/0004_preload_core_settings.py create mode 100644 core/migrations/0005_streamprofile_locked_alter_streamprofile_command_and_more.py create mode 100644 core/migrations/0006_set_locked_stream_profiles.py create mode 100644 core/migrations/0007_create_proxy_and_redirect_stream_profiles.py create mode 100644 core/migrations/0008_rename_profile_name_streamprofile_name_and_more.py create mode 100644 core/migrations/0009_m3u_hash_settings.py create mode 100644 core/migrations/0010_reload_additional_settings.py create mode 100644 core/migrations/0011_fix_stream_profiles_and_user_agents.py create mode 100644 core/migrations/0012_default_active_m3u_accounts.py create mode 100644 core/migrations/0013_default_network_access_settings.py create mode 100644 core/migrations/0014_default_proxy_settings.py create mode 100644 core/migrations/0015_dvr_templates.py create mode 100644 core/migrations/0016_update_dvr_template_paths.py create mode 100644 core/migrations/0017_systemevent.py create mode 100644 core/migrations/0018_alter_systemevent_event_type.py create mode 100644 core/migrations/0019_add_vlc_stream_profile.py create mode 100644 core/migrations/0020_change_coresettings_value_to_jsonfield.py create mode 100644 core/migrations/0021_systemnotification_notificationdismissal.py create mode 100644 core/migrations/022_default_user_limit_settings.py create mode 100644 core/migrations/__init__.py create mode 100644 core/models.py create mode 100644 core/redis_pubsub.py create mode 100644 core/scheduling.py create mode 100644 core/serializers.py create mode 100644 core/signals.py create mode 100644 core/tasks.py create mode 100644 core/tests.py create mode 100644 core/urls.py create mode 100644 core/utils.py create mode 100644 core/views.py create mode 100644 core/xtream_codes.py create mode 100755 debian_install.sh create mode 100644 dispatcharr/__init__.py create mode 100644 dispatcharr/admin.py create mode 100644 dispatcharr/app_initialization.py create mode 100644 dispatcharr/asgi.py create mode 100644 dispatcharr/celery.py create mode 100644 dispatcharr/consumers.py create mode 100644 dispatcharr/jwt_ws_auth.py create mode 100644 dispatcharr/persistent_lock.py create mode 100644 dispatcharr/routing.py create mode 100644 dispatcharr/settings.py create mode 100644 dispatcharr/urls.py create mode 100644 dispatcharr/utils.py create mode 100644 dispatcharr/wsgi.py create mode 100644 docker/DispatcharrBase create mode 100644 docker/Dockerfile create mode 100755 docker/build-dev.sh create mode 100644 docker/comskip.ini create mode 100644 docker/docker-compose.aio.yml create mode 100644 docker/docker-compose.debug.yml create mode 100644 docker/docker-compose.dev.yml create mode 100644 docker/docker-compose.yml create mode 100755 docker/entrypoint.aio.sh create mode 100644 docker/entrypoint.celery.sh create mode 100755 docker/entrypoint.sh create mode 100644 docker/init/00-fix-pg-ssl-key.sh create mode 100644 docker/init/01-user-setup.sh create mode 100644 docker/init/02-postgres.sh create mode 100644 docker/init/03-init-dispatcharr.sh create mode 100644 docker/init/04-check-hwaccel.sh create mode 100644 docker/init/99-init-dev.sh create mode 100644 docker/nginx.conf create mode 100644 docker/tests/test-puid-pgid.sh create mode 100644 docker/tests/test-tls-postgres.sh create mode 100644 docker/uwsgi.debug.ini create mode 100644 docker/uwsgi.dev.ini create mode 100644 docker/uwsgi.ini create mode 100644 docker/uwsgi.modular.ini create mode 100644 docs/images/channels.png create mode 100644 docs/images/guide.png create mode 100644 docs/images/m3u-epg-manager.png create mode 100644 docs/images/settings.png create mode 100644 docs/images/stats.png create mode 100644 docs/images/vod-library.png create mode 100644 fixtures.json create mode 100644 frontend/.gitignore create mode 100644 frontend/README.md create mode 100644 frontend/eslint.config.js create mode 100644 frontend/index.html create mode 100644 frontend/package.json create mode 100644 frontend/prettier.config.js create mode 100644 frontend/public/android-chrome-192x192.png create mode 100644 frontend/public/android-chrome-512x512.png create mode 100644 frontend/public/apple-touch-icon.png create mode 100644 frontend/public/favicon-16x16.png create mode 100644 frontend/public/favicon-32x32.png create mode 100644 frontend/public/favicon.ico create mode 100644 frontend/public/logo.png create mode 100644 frontend/public/site.webmanifest create mode 100644 frontend/public/vite.svg create mode 100644 frontend/src/App.css create mode 100644 frontend/src/App.jsx create mode 100644 frontend/src/WebSocket.jsx create mode 100644 frontend/src/api.js create mode 100644 frontend/src/assets/android-chrome-192x192.png create mode 100644 frontend/src/assets/android-chrome-512x512.png create mode 100644 frontend/src/assets/apple-touch-icon.png create mode 100644 frontend/src/assets/favicon-16x16.png create mode 100644 frontend/src/assets/favicon-32x32.png create mode 100644 frontend/src/assets/favicon.ico create mode 100644 frontend/src/assets/logo.png create mode 100644 frontend/src/assets/react.svg create mode 100644 frontend/src/assets/site.webmanifest create mode 100644 frontend/src/assets/vite.svg create mode 100644 frontend/src/components/ConfirmationDialog.jsx create mode 100644 frontend/src/components/ErrorBoundary.jsx create mode 100644 frontend/src/components/Field.jsx create mode 100644 frontend/src/components/FloatingVideo.jsx create mode 100644 frontend/src/components/GuideRow.jsx create mode 100644 frontend/src/components/HourTimeline.jsx create mode 100644 frontend/src/components/LazyLogo.jsx create mode 100644 frontend/src/components/M3URefreshNotification.jsx create mode 100644 frontend/src/components/NotificationCenter.jsx create mode 100644 frontend/src/components/PluginDetailPanel.jsx create mode 100644 frontend/src/components/ProgramDetailModal.jsx create mode 100644 frontend/src/components/RecordingSynopsis.jsx create mode 100644 frontend/src/components/SeriesModal.jsx create mode 100644 frontend/src/components/Sidebar.jsx create mode 100644 frontend/src/components/SystemEvents.jsx create mode 100644 frontend/src/components/VODModal.jsx create mode 100644 frontend/src/components/__tests__/ConfirmationDialog.test.jsx create mode 100644 frontend/src/components/__tests__/ErrorBoundary.test.jsx create mode 100644 frontend/src/components/__tests__/Field.test.jsx create mode 100644 frontend/src/components/__tests__/FloatingVideo.test.jsx create mode 100644 frontend/src/components/__tests__/GuideRow.test.jsx create mode 100644 frontend/src/components/__tests__/HourTimeline.test.jsx create mode 100644 frontend/src/components/__tests__/LazyLogo.test.jsx create mode 100644 frontend/src/components/__tests__/M3URefreshNotification.test.jsx create mode 100644 frontend/src/components/__tests__/NotificationCenter.test.jsx create mode 100644 frontend/src/components/__tests__/ProgramDetailModal.test.jsx create mode 100644 frontend/src/components/__tests__/RecordingSynopsis.test.jsx create mode 100644 frontend/src/components/__tests__/SeriesModal.test.jsx create mode 100644 frontend/src/components/__tests__/Sidebar.test.jsx create mode 100644 frontend/src/components/__tests__/SystemEvents.test.jsx create mode 100644 frontend/src/components/__tests__/VODModal.test.jsx create mode 100644 frontend/src/components/backups/BackupManager.jsx create mode 100644 frontend/src/components/cards/AvailablePluginCard.jsx create mode 100644 frontend/src/components/cards/PluginCard.jsx create mode 100644 frontend/src/components/cards/RecordingCard.jsx create mode 100644 frontend/src/components/cards/SeriesCard.jsx create mode 100644 frontend/src/components/cards/StreamConnectionCard.jsx create mode 100644 frontend/src/components/cards/VODCard.jsx create mode 100644 frontend/src/components/cards/VodConnectionCard.jsx create mode 100644 frontend/src/components/cards/__tests__/PluginCard.test.jsx create mode 100644 frontend/src/components/cards/__tests__/RecordingCard.test.jsx create mode 100644 frontend/src/components/cards/__tests__/SeriesCard.test.jsx create mode 100644 frontend/src/components/cards/__tests__/StreamConnectionCard.test.jsx create mode 100644 frontend/src/components/cards/__tests__/VODCard.test.jsx create mode 100644 frontend/src/components/cards/__tests__/VodConnectionCard.test.jsx create mode 100644 frontend/src/components/forms/AccountInfoModal.jsx create mode 100644 frontend/src/components/forms/AssignChannelNumbers.jsx create mode 100644 frontend/src/components/forms/Channel.jsx create mode 100644 frontend/src/components/forms/ChannelBatch.jsx create mode 100644 frontend/src/components/forms/ChannelGroup.jsx create mode 100644 frontend/src/components/forms/Connection.jsx create mode 100644 frontend/src/components/forms/CronBuilder.jsx create mode 100644 frontend/src/components/forms/DummyEPG.jsx create mode 100644 frontend/src/components/forms/EPG.jsx create mode 100644 frontend/src/components/forms/GroupManager.jsx create mode 100644 frontend/src/components/forms/LiveGroupFilter.jsx create mode 100644 frontend/src/components/forms/LoginForm.jsx create mode 100644 frontend/src/components/forms/Logo.jsx create mode 100644 frontend/src/components/forms/M3U.jsx create mode 100644 frontend/src/components/forms/M3UFilter.jsx create mode 100644 frontend/src/components/forms/M3UFilters.jsx create mode 100644 frontend/src/components/forms/M3UGroupFilter.jsx create mode 100644 frontend/src/components/forms/M3UProfile.jsx create mode 100644 frontend/src/components/forms/M3UProfiles.jsx create mode 100644 frontend/src/components/forms/ProgramRecordingModal.jsx create mode 100644 frontend/src/components/forms/Recording.jsx create mode 100644 frontend/src/components/forms/RecordingDetailsModal.jsx create mode 100644 frontend/src/components/forms/RecurringRuleModal.jsx create mode 100644 frontend/src/components/forms/ScheduleInput.jsx create mode 100644 frontend/src/components/forms/SeriesRecordingModal.jsx create mode 100644 frontend/src/components/forms/Stream.jsx create mode 100644 frontend/src/components/forms/StreamProfile.jsx create mode 100644 frontend/src/components/forms/SuperuserForm.jsx create mode 100644 frontend/src/components/forms/User.jsx create mode 100644 frontend/src/components/forms/UserAgent.jsx create mode 100644 frontend/src/components/forms/VODCategoryFilter.jsx create mode 100644 frontend/src/components/forms/__tests__/AccountInfoModal.test.jsx create mode 100644 frontend/src/components/forms/__tests__/AssignChannelNumbers.test.jsx create mode 100644 frontend/src/components/forms/__tests__/Channel.test.jsx create mode 100644 frontend/src/components/forms/__tests__/ChannelBatch.test.jsx create mode 100644 frontend/src/components/forms/__tests__/ChannelGroup.test.jsx create mode 100644 frontend/src/components/forms/__tests__/Connection.test.jsx create mode 100644 frontend/src/components/forms/__tests__/CronBuilder.test.jsx create mode 100644 frontend/src/components/forms/__tests__/DummyEPG.test.jsx create mode 100644 frontend/src/components/forms/__tests__/EPG.test.jsx create mode 100644 frontend/src/components/forms/settings/ConnectionSecurityPanel.jsx create mode 100644 frontend/src/components/forms/settings/DvrSettingsForm.jsx create mode 100644 frontend/src/components/forms/settings/NavOrderForm.jsx create mode 100644 frontend/src/components/forms/settings/NetworkAccessForm.jsx create mode 100644 frontend/src/components/forms/settings/ProxySettingsForm.jsx create mode 100644 frontend/src/components/forms/settings/StreamSettingsForm.jsx create mode 100644 frontend/src/components/forms/settings/SystemSettingsForm.jsx create mode 100644 frontend/src/components/forms/settings/UiSettingsForm.jsx create mode 100644 frontend/src/components/forms/settings/UserLimitsForm.jsx create mode 100644 frontend/src/components/forms/settings/__tests__/DvrSettingsForm.test.jsx create mode 100644 frontend/src/components/forms/settings/__tests__/NavOrderForm.test.jsx create mode 100644 frontend/src/components/forms/settings/__tests__/NetworkAccessForm.test.jsx create mode 100644 frontend/src/components/forms/settings/__tests__/ProxySettingsForm.test.jsx create mode 100644 frontend/src/components/forms/settings/__tests__/StreamSettingsForm.test.jsx create mode 100644 frontend/src/components/forms/settings/__tests__/SystemSettingsForm.test.jsx create mode 100644 frontend/src/components/forms/settings/__tests__/UiSettingsForm.test.jsx create mode 100644 frontend/src/components/modals/CreateChannelModal.jsx create mode 100644 frontend/src/components/modals/EPGMatchModal.jsx create mode 100644 frontend/src/components/modals/ProfileModal.jsx create mode 100644 frontend/src/components/modals/YouTubeTrailerModal.jsx create mode 100644 frontend/src/components/modals/__tests__/EPGMatchModal.test.jsx create mode 100644 frontend/src/components/modals/__tests__/YouTubeTrailerModal.test.jsx create mode 100644 frontend/src/components/pluginUtils.js create mode 100644 frontend/src/components/sidebar.css create mode 100644 frontend/src/components/tables/ChannelTableStreams.jsx create mode 100644 frontend/src/components/tables/ChannelsTable.jsx create mode 100644 frontend/src/components/tables/ChannelsTable/ChannelTableHeader.jsx create mode 100644 frontend/src/components/tables/ChannelsTable/ChannelsTableOnboarding.jsx create mode 100644 frontend/src/components/tables/ChannelsTable/DraggableRow.jsx create mode 100644 frontend/src/components/tables/ChannelsTable/EditableCell.jsx create mode 100644 frontend/src/components/tables/CustomTable/CustomTable.jsx create mode 100644 frontend/src/components/tables/CustomTable/CustomTableBody.jsx create mode 100644 frontend/src/components/tables/CustomTable/CustomTableHeader.jsx create mode 100644 frontend/src/components/tables/CustomTable/MultiSelectHeaderWrapper.jsx create mode 100644 frontend/src/components/tables/CustomTable/index.jsx create mode 100644 frontend/src/components/tables/EPGsTable.jsx create mode 100644 frontend/src/components/tables/LogosTable.jsx create mode 100644 frontend/src/components/tables/M3UsTable.jsx create mode 100644 frontend/src/components/tables/StreamProfilesTable.jsx create mode 100644 frontend/src/components/tables/StreamsTable.jsx create mode 100644 frontend/src/components/tables/UserAgentsTable.jsx create mode 100644 frontend/src/components/tables/UsersTable.jsx create mode 100644 frontend/src/components/tables/VODLogosTable.jsx create mode 100644 frontend/src/components/tables/table.css create mode 100644 frontend/src/components/theme/Button.jsx create mode 100644 frontend/src/config/__tests__/navigation.test.js create mode 100644 frontend/src/config/navigation.js create mode 100644 frontend/src/constants.js create mode 100644 frontend/src/helpers/index.jsx create mode 100644 frontend/src/helpers/table.jsx create mode 100644 frontend/src/hooks/__tests__/useLocalStorage.test.jsx create mode 100644 frontend/src/hooks/__tests__/useSmartLogos.test.jsx create mode 100644 frontend/src/hooks/__tests__/useTablePreferences.test.jsx create mode 100644 frontend/src/hooks/useLocalStorage.jsx create mode 100644 frontend/src/hooks/useSmartLogos.jsx create mode 100644 frontend/src/hooks/useTablePreferences.jsx create mode 100644 frontend/src/images/dispatcharr.svg create mode 100644 frontend/src/images/ghost.svg create mode 100644 frontend/src/images/logo.png create mode 100644 frontend/src/index.css create mode 100644 frontend/src/logo.svg create mode 100644 frontend/src/main.jsx create mode 100644 frontend/src/mantineTheme.jsx create mode 100644 frontend/src/pages/Channels.jsx create mode 100644 frontend/src/pages/Connect.jsx create mode 100644 frontend/src/pages/ConnectLogs.jsx create mode 100644 frontend/src/pages/ContentSources.jsx create mode 100644 frontend/src/pages/DVR.jsx create mode 100644 frontend/src/pages/Guide.jsx create mode 100644 frontend/src/pages/Login.jsx create mode 100644 frontend/src/pages/Logos.jsx create mode 100644 frontend/src/pages/PluginBrowse.jsx create mode 100644 frontend/src/pages/Plugins.jsx create mode 100644 frontend/src/pages/Settings.jsx create mode 100644 frontend/src/pages/Stats.jsx create mode 100644 frontend/src/pages/Users.jsx create mode 100644 frontend/src/pages/VODs.jsx create mode 100644 frontend/src/pages/__tests__/Channels.test.jsx create mode 100644 frontend/src/pages/__tests__/ContentSources.test.jsx create mode 100644 frontend/src/pages/__tests__/DVR.test.jsx create mode 100644 frontend/src/pages/__tests__/Guide.test.jsx create mode 100644 frontend/src/pages/__tests__/Login.test.jsx create mode 100644 frontend/src/pages/__tests__/Logos.test.jsx create mode 100644 frontend/src/pages/__tests__/Plugins.test.jsx create mode 100644 frontend/src/pages/__tests__/Settings.test.jsx create mode 100644 frontend/src/pages/__tests__/Stats.test.jsx create mode 100644 frontend/src/pages/__tests__/Users.test.jsx create mode 100644 frontend/src/pages/__tests__/VODs.test.jsx create mode 100644 frontend/src/pages/__tests__/guideUtils.test.js create mode 100644 frontend/src/pages/guide.css create mode 100644 frontend/src/store/__tests__/auth.test.jsx create mode 100644 frontend/src/store/__tests__/channels.test.jsx create mode 100644 frontend/src/store/__tests__/channelsTable.test.jsx create mode 100644 frontend/src/store/__tests__/epgs.test.jsx create mode 100644 frontend/src/store/__tests__/logos.test.jsx create mode 100644 frontend/src/store/__tests__/playlists.test.jsx create mode 100644 frontend/src/store/__tests__/plugins.test.jsx create mode 100644 frontend/src/store/__tests__/settings.test.jsx create mode 100644 frontend/src/store/__tests__/streamProfiles.test.jsx create mode 100644 frontend/src/store/__tests__/streams.test.jsx create mode 100644 frontend/src/store/__tests__/streamsTable.test.jsx create mode 100644 frontend/src/store/__tests__/useVODStore.test.jsx create mode 100644 frontend/src/store/__tests__/useVideoStore.test.jsx create mode 100644 frontend/src/store/__tests__/userAgents.test.jsx create mode 100644 frontend/src/store/__tests__/users.test.jsx create mode 100644 frontend/src/store/__tests__/vodLogos.test.jsx create mode 100644 frontend/src/store/__tests__/warnings.test.jsx create mode 100644 frontend/src/store/auth.jsx create mode 100644 frontend/src/store/channels.jsx create mode 100644 frontend/src/store/channelsTable.jsx create mode 100644 frontend/src/store/connect.jsx create mode 100644 frontend/src/store/epgs.jsx create mode 100644 frontend/src/store/logos.jsx create mode 100644 frontend/src/store/notifications.jsx create mode 100644 frontend/src/store/playlists.jsx create mode 100644 frontend/src/store/plugins.jsx create mode 100644 frontend/src/store/settings.jsx create mode 100644 frontend/src/store/streamProfiles.jsx create mode 100644 frontend/src/store/streams.jsx create mode 100644 frontend/src/store/streamsTable.jsx create mode 100644 frontend/src/store/useVODStore.jsx create mode 100644 frontend/src/store/useVideoStore.jsx create mode 100644 frontend/src/store/userAgents.jsx create mode 100644 frontend/src/store/users.jsx create mode 100644 frontend/src/store/vodLogos.jsx create mode 100644 frontend/src/store/warnings.jsx create mode 100644 frontend/src/test/setupTests.js create mode 100644 frontend/src/utils.js create mode 100644 frontend/src/utils/__tests__/dateTimeUtils.test.js create mode 100644 frontend/src/utils/__tests__/networkUtils.test.js create mode 100644 frontend/src/utils/__tests__/notificationUtils.test.js create mode 100644 frontend/src/utils/cards/PluginCardUtils.js create mode 100644 frontend/src/utils/cards/RecordingCardUtils.js create mode 100644 frontend/src/utils/cards/StreamConnectionCardUtils.js create mode 100644 frontend/src/utils/cards/VODCardUtils.js create mode 100644 frontend/src/utils/cards/VodConnectionCardUtils.js create mode 100644 frontend/src/utils/cards/__tests__/PluginCardUtils.test.js create mode 100644 frontend/src/utils/cards/__tests__/RecordingCardUtils.test.js create mode 100644 frontend/src/utils/cards/__tests__/StreamConnectionCardUtils.test.js create mode 100644 frontend/src/utils/cards/__tests__/VODCardUtils.test.js create mode 100644 frontend/src/utils/cards/__tests__/VodConnectionCardUtils.test.js create mode 100644 frontend/src/utils/components/FloatingVideoUtils.js create mode 100644 frontend/src/utils/components/NotificationCenterUtils.js create mode 100644 frontend/src/utils/components/SeriesModalUtils.js create mode 100644 frontend/src/utils/components/VODModalUtils.js create mode 100644 frontend/src/utils/components/__tests__/FloatingVideoUtils.test.js create mode 100644 frontend/src/utils/components/__tests__/NotificationCenterUtils.test.js create mode 100644 frontend/src/utils/components/__tests__/SeriesModalUtils.test.js create mode 100644 frontend/src/utils/components/__tests__/VODModalUtils.test.js create mode 100644 frontend/src/utils/cronUtils.js create mode 100644 frontend/src/utils/dateTimeUtils.js create mode 100644 frontend/src/utils/externalUrls.js create mode 100644 frontend/src/utils/forms/AccountInfoModalUtils.js create mode 100644 frontend/src/utils/forms/ChannelBatchUtils.js create mode 100644 frontend/src/utils/forms/ChannelUtils.js create mode 100644 frontend/src/utils/forms/ConnectionUtils.js create mode 100644 frontend/src/utils/forms/CronBuilderUtils.js create mode 100644 frontend/src/utils/forms/DummyEpgUtils.js create mode 100644 frontend/src/utils/forms/RecordingDetailsModalUtils.js create mode 100644 frontend/src/utils/forms/RecurringRuleModalUtils.js create mode 100644 frontend/src/utils/forms/__tests__/AccountInfoModalUtils.test.js create mode 100644 frontend/src/utils/forms/__tests__/ChannelBatchUtils.test.js create mode 100644 frontend/src/utils/forms/__tests__/ChannelUtils.test.js create mode 100644 frontend/src/utils/forms/__tests__/ConnectionUtils.test.js create mode 100644 frontend/src/utils/forms/__tests__/CronBuilderUtils.test.js create mode 100644 frontend/src/utils/forms/__tests__/DummyEpgUtils.test.js create mode 100644 frontend/src/utils/forms/__tests__/RecordingDetailsModalUtils.test.js create mode 100644 frontend/src/utils/forms/__tests__/RecurringRuleModalUtils.test.js create mode 100644 frontend/src/utils/forms/settings/DvrSettingsFormUtils.js create mode 100644 frontend/src/utils/forms/settings/NetworkAccessFormUtils.js create mode 100644 frontend/src/utils/forms/settings/ProxySettingsFormUtils.js create mode 100644 frontend/src/utils/forms/settings/StreamSettingsFormUtils.js create mode 100644 frontend/src/utils/forms/settings/SystemSettingsFormUtils.js create mode 100644 frontend/src/utils/forms/settings/UiSettingsFormUtils.js create mode 100644 frontend/src/utils/forms/settings/__tests__/DvrSettingsFormUtils.test.js create mode 100644 frontend/src/utils/forms/settings/__tests__/NetworkAccessFormUtils.test.js create mode 100644 frontend/src/utils/forms/settings/__tests__/ProxySettingsFormUtils.test.js create mode 100644 frontend/src/utils/forms/settings/__tests__/StreamSettingsFormUtils.test.js create mode 100644 frontend/src/utils/forms/settings/__tests__/SystemSettingsFormUtils.test.js create mode 100644 frontend/src/utils/forms/settings/__tests__/UiSettingsFormUtils.test.js create mode 100644 frontend/src/utils/guideUtils.js create mode 100644 frontend/src/utils/networkUtils.js create mode 100644 frontend/src/utils/notificationUtils.js create mode 100644 frontend/src/utils/pages/DVRUtils.js create mode 100644 frontend/src/utils/pages/PluginsUtils.js create mode 100644 frontend/src/utils/pages/SettingsUtils.js create mode 100644 frontend/src/utils/pages/StatsUtils.js create mode 100644 frontend/src/utils/pages/VODsUtils.js create mode 100644 frontend/src/utils/pages/__tests__/DVRUtils.test.js create mode 100644 frontend/src/utils/pages/__tests__/PluginsUtils.test.js create mode 100644 frontend/src/utils/pages/__tests__/SettingsUtils.test.js create mode 100644 frontend/src/utils/pages/__tests__/StatsUtils.test.js create mode 100644 frontend/src/utils/pages/__tests__/VODsUtils.test.js create mode 100644 frontend/vite.config.js create mode 100644 manage.py create mode 100644 pyproject.toml create mode 100644 scripts/bump_version.py create mode 100644 scripts/debug_wrapper.py create mode 100644 scripts/increment_build.py create mode 100644 scripts/jellyfin_vod_sync_dispatcharr.py create mode 100644 scripts/link_backup_streams_boss_javi.py create mode 100755 scripts/run_jellyfin_sync_cron.sh create mode 100644 scripts/standalone_debug.py create mode 100644 scripts/update_changelog.py create mode 100644 scripts/wait_for_redis.py create mode 100755 start-celery.sh create mode 100755 start-celerybeat.sh create mode 100755 start-daphne.sh create mode 100755 start-gunicorn.sh create mode 100644 tests/__init__.py create mode 100644 tests/test_user_preferences.py create mode 100644 tests/test_wait_for_redis.py create mode 100644 version.py diff --git a/.dockerignore b/.dockerignore new file mode 100755 index 0000000..002f1c6 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,33 @@ +**/__pycache__ +**/.venv +**/venv +**/.classpath +**/.dockerignore +**/.env +**/.git +**/.gitignore +**/.project +**/.settings +**/.toolstarget +**/.vs +**/.vscode +**/.history +**/media +**/models +**/static +**/*.*proj.user +**/*.dbmdl +**/*.jfm +**/bin +**/charts +**/docker-compose* +**/compose* +**/Dockerfile* +**/node_modules +**/npm-debug.log +**/obj +**/secrets.dev.yaml +**/values.dev.yaml +LICENSE +data/ +docker/data/ diff --git a/.env b/.env new file mode 100644 index 0000000..b32f906 --- /dev/null +++ b/.env @@ -0,0 +1,9 @@ +DATABASE_URL=postgresql://dispatcharr_usr:N9Ol57j5ENUa1@localhost:5432/dispatcharr_db +POSTGRES_DB=dispatcharr_db +POSTGRES_USER=dispatcharr_usr +POSTGRES_PASSWORD=N9Ol57j5ENUa1 +POSTGRES_HOST=localhost +CELERY_BROKER_URL=redis://localhost:6379/0 +DJANGO_SECRET_KEY=knZbsSxqlGozLGvJSfx02uHq1zOXhDuq8Vp34u4o8vyB71OoZU +DISPATCHARR_VOD_CATALOG_ACCOUNT_ID=10 +DISPATCHARR_VOD_PLAYBACK_ACCOUNT_IDS=10,11,12 diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..2cbc864 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,20 @@ +# Auto detect text files and perform LF normalization +* text=auto + +# Explicitly declare text files that should always be normalized +*.py text +*.js text +*.jsx text +*.css text +*.html text +*.md text + +# Declare files that will always have specific line endings +*.bat text eol=crlf +*.sh text eol=lf + +# Denote all files that are truly binary and should not be modified +*.png binary +*.jpg binary +*.gif binary +*.ico binary diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml new file mode 100644 index 0000000..c9a35e5 --- /dev/null +++ b/.github/FUNDING.yml @@ -0,0 +1,15 @@ +# These are supported funding model platforms + +github: # Replace with up to 4 GitHub Sponsors-enabled usernames e.g., [user1, user2] +patreon: # Replace with a single Patreon username +open_collective: dispatcharr # Replace with a single Open Collective username +ko_fi: # Replace with a single Ko-fi username +tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel +community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry +liberapay: # Replace with a single Liberapay username +issuehunt: # Replace with a single IssueHunt username +lfx_crowdfunding: # Replace with a single LFX Crowdfunding project-name e.g., cloud-foundry +polar: # Replace with a single Polar username +buy_me_a_coffee: # Replace with a single Buy Me a Coffee username +thanks_dev: # Replace with a single thanks.dev username +custom: # Replace with up to 4 custom sponsorship URLs e.g., ['link1', 'link2'] diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml new file mode 100644 index 0000000..47f12f7 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -0,0 +1,64 @@ +name: Bug Report +description: I have an issue with Dispatcharr +title: "[Bug]: " +labels: ["Triage"] +type: "Bug" +projects: [] +assignees: [] +body: + - type: markdown + attributes: + value: | + Please make sure you search for similar issues before submitting. Thank you for your bug report! + - type: textarea + id: describe-the-bug + attributes: + label: Describe the bug + description: Make sure to attach screenshots if possible! + placeholder: Tell us what you see! + value: "A clear and concise description of what the bug is. What did you expect to happen?" + validations: + required: true + - type: textarea + id: reproduce + attributes: + label: How can we recreate this bug? + description: Be detailed! + placeholder: Tell us what you see! + value: "1. Go to '...' 2. Click on '....' 3. Scroll down to '....' 4. See error" + validations: + required: true + - type: input + id: dispatcharr-version + attributes: + label: Dispatcharr Version + description: What version of Dispatcharr are you running? + placeholder: Located bottom left of main screen + validations: + required: true + - type: input + id: docker-version + attributes: + label: Docker Version + description: What version of Docker are you running? + placeholder: docker --version + validations: + required: true + - type: textarea + id: docker-compose + attributes: + label: What's in your Docker Compose file? + description: Please share your docker-compose.yml file + placeholder: Tell us what you see! + value: "If not using Docker Compose just put not using." + validations: + required: true + - type: textarea + id: client-info + attributes: + label: Client Information + description: What are you using the view the streams from Dispatcharr + placeholder: Tell us what you see! + value: "Device, App, Versions for both, etc..." + validations: + required: true \ No newline at end of file diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml new file mode 100644 index 0000000..ec4bb38 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -0,0 +1 @@ +blank_issues_enabled: false \ No newline at end of file diff --git a/.github/ISSUE_TEMPLATE/feature_request.yml b/.github/ISSUE_TEMPLATE/feature_request.yml new file mode 100644 index 0000000..77a03df --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.yml @@ -0,0 +1,39 @@ +name: Feature request +description: I want to suggest a new feature for Dispatcharr +title: "[Feature]: " +labels: ["Triage"] +type: "Feature" +projects: [] +assignees: [] +body: + - type: markdown + attributes: + value: | + Thank you for helping to make Dispatcharr better! + - type: textarea + id: describe-problem + attributes: + label: Is your feature request related to a problem? + description: Make sure to attach screenshots if possible! + placeholder: Tell us what you see! + value: "A clear and concise description of what the problem is. Ex. I'm always frustrated when [...]" + validations: + required: true + - type: textarea + id: describe-solution + attributes: + label: Describe the solution you'd like + description: A clear and concise description of what you want to happen. + placeholder: Tell us what you see! + value: "Describe here." + validations: + required: true + - type: textarea + id: extras + attributes: + label: Additional context + description: Anything else you want to add? + placeholder: Tell us what you see! + value: "Nothing Extra" + validations: + required: true \ No newline at end of file diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md new file mode 100644 index 0000000..192ecfa --- /dev/null +++ b/.github/pull_request_template.md @@ -0,0 +1,29 @@ +## Description + + + +## Related Issue + + + +Closes # + +## How was it tested? + + + +## Checklist + +Please check every item before marking this PR as ready for review. Unchecked items will be flagged during review. + +- [ ] I have read the [CONTRIBUTING.md](../blob/dev/CONTRIBUTING.md) in full +- [ ] I agree to the [Contributor License Agreement](../blob/dev/CONTRIBUTING.md#contributor-license-agreement) +- [ ] I understand — line by line — every change in this PR and can explain it if asked +- [ ] This PR targets the `dev` branch +- [ ] Backend: migrations are included if any models were changed +- [ ] Backend: new API endpoints appear correctly in the OpenAPI schema +- [ ] Frontend: ESLint and Prettier pass cleanly (`npm run lint`, `npm run format`) +- [ ] Tests are included for new functionality +- [ ] Existing tests still pass +- [ ] No `console.log`, `print()`, debug statements, or commented-out code is left in the diff +- [ ] I have not reformatted or refactored code outside the scope of this change diff --git a/.github/workflows/base-image.yml b/.github/workflows/base-image.yml new file mode 100644 index 0000000..4791de4 --- /dev/null +++ b/.github/workflows/base-image.yml @@ -0,0 +1,250 @@ +name: Base Image Build + +on: + push: + branches: [main, dev] + paths: + - 'docker/DispatcharrBase' + - '.github/workflows/base-image.yml' + - 'pyproject.toml' + pull_request: + branches: [main, dev] + paths: + - 'docker/DispatcharrBase' + - '.github/workflows/base-image.yml' + - 'pyproject.toml' + workflow_dispatch: # Allow manual triggering + +permissions: + contents: write # For managing releases and pushing tags + packages: write # For publishing to GitHub Container Registry + +jobs: + 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@v6 + 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: Set repository and image metadata + 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=base" >> $GITHUB_OUTPUT + elif [[ "${{ github.ref }}" == "refs/heads/dev" ]]; then + echo "branch_tag=base-dev" >> $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=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@v6 + 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@v4 + + - name: Login to GitHub Container Registry + uses: docker/login-action@v4 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Login to Docker Hub + uses: docker/login-action@v4 + with: + registry: docker.io + username: ${{ secrets.DOCKERHUB_USERNAME }} + password: ${{ secrets.DOCKERHUB_TOKEN }} + + - name: Extract metadata for Docker + id: meta + uses: docker/metadata-action@v6 + 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@v7 + with: + context: . + file: ./docker/DispatcharrBase + push: ${{ github.event_name != 'pull_request' }} + platforms: linux/${{ matrix.platform }} + tags: | + 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@v4 + + - name: Login to GitHub Container Registry + uses: docker/login-action@v4 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Login to Docker Hub + uses: docker/login-action@v4 + 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 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..95769a0 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,243 @@ +name: CI Pipeline + +on: + push: + branches: [dev] + paths-ignore: + - '**.md' + pull_request: + branches: [dev] + workflow_dispatch: + +permissions: + contents: write + packages: write + +jobs: + 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@v6 + 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@v6 + 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: Check if commit is from GitHub Actions + id: check_actor + run: | + if [[ "${{ github.actor }}" == "github-actions" ]]; then + echo "is_bot=true" >> $GITHUB_OUTPUT + else + echo "is_bot=false" >> $GITHUB_OUTPUT + fi + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v4 + + - name: Login to GitHub Container Registry + uses: docker/login-action@v4 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Login to Docker Hub + if: github.event_name != 'pull_request' + uses: docker/login-action@v4 + with: + registry: docker.io + username: ${{ secrets.DOCKERHUB_USERNAME }} + password: ${{ secrets.DOCKERHUB_TOKEN }} + + - name: Extract metadata for Docker + id: meta + uses: docker/metadata-action@v6 + 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@v7 + with: + context: . + push: ${{ github.event_name != 'pull_request' }} + # 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/${{ 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 }} + ${{ github.event_name != 'pull_request' && format('docker.io/{0}/{1}:{2}-{3}', secrets.DOCKERHUB_ORGANIZATION, needs.prepare.outputs.repo_name, needs.prepare.outputs.branch_tag, matrix.platform) || '' }} + ${{ github.event_name != 'pull_request' && format('docker.io/{0}/{1}:{2}-{3}-{4}', 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=${{ 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=${{ 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@v4 + + - name: Login to GitHub Container Registry + uses: docker/login-action@v4 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Login to Docker Hub + uses: docker/login-action@v4 + 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}" + + # ghcr.io: both the branch tag (e.g. dev) and the version+timestamp tag + # point to the same manifest by using multiple --tag flags in one call, + # which prevents orphaned untagged images on each new build + 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}:${BRANCH_TAG} \ + --tag ghcr.io/${OWNER}/${REPO}:${VERSION}-${TIMESTAMP} \ + ghcr.io/${OWNER}/${REPO}:${BRANCH_TAG}-amd64 ghcr.io/${OWNER}/${REPO}:${BRANCH_TAG}-arm64 + + # Docker Hub: same single call with both 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}-${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}:${BRANCH_TAG} \ + --tag docker.io/${{ secrets.DOCKERHUB_ORGANIZATION }}/${REPO}:${VERSION}-${TIMESTAMP} \ + docker.io/${{ secrets.DOCKERHUB_ORGANIZATION }}/${REPO}:${BRANCH_TAG}-amd64 docker.io/${{ secrets.DOCKERHUB_ORGANIZATION }}/${REPO}:${BRANCH_TAG}-arm64 diff --git a/.github/workflows/docker-build.yml b/.github/workflows/docker-build.yml new file mode 100644 index 0000000..c5d3322 --- /dev/null +++ b/.github/workflows/docker-build.yml @@ -0,0 +1,56 @@ +name: Build and Push Multi-Arch Docker Image + +on: + push: + branches: + - main + - dev + +jobs: + build-and-push: + runs-on: ubuntu-latest + permissions: + contents: read + packages: write + + steps: + - name: Checkout repository + uses: actions/checkout@v6 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v4 + + - name: Log in to GitHub Container Registry + uses: docker/login-action@v4 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Determine image tag and branch + id: set-tag-branch + run: | + if [[ "${{ github.ref }}" == "refs/heads/main" ]]; then + echo "TAG=latest" >> $GITHUB_ENV + echo "BRANCH=main" >> $GITHUB_ENV + elif [[ "${{ github.ref }}" == "refs/heads/dev" ]]; then + echo "TAG=dev" >> $GITHUB_ENV + echo "BRANCH=dev" >> $GITHUB_ENV + fi + + - name: Convert repository name to lowercase + run: echo "REPO_NAME=$(echo '${{ github.repository }}' | tr '[:upper:]' '[:lower:]')" >> $GITHUB_ENV + + - name: Build and push Docker image + uses: docker/build-push-action@v7 + with: + context: docker + file: docker/Dockerfile + push: true + platforms: linux/amd64,linux/arm64 + build-args: | + BRANCH=${{ env.BRANCH }} + REPO_URL=https://github.com/${{ github.repository }} + tags: | + ghcr.io/${{ env.REPO_NAME }}:${{ env.TAG }} + ghcr.io/${{ env.REPO_NAME }}:${{ github.sha }} diff --git a/.github/workflows/frontend-tests.yml b/.github/workflows/frontend-tests.yml new file mode 100644 index 0000000..4baa627 --- /dev/null +++ b/.github/workflows/frontend-tests.yml @@ -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@v6 + + - 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 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..6945d32 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,237 @@ +name: Create Release + +on: + workflow_dispatch: + inputs: + version_type: + description: 'Type of version increment' + required: true + default: 'patch' + type: choice + options: + - major + - minor + - patch + +# Add explicit permissions for the workflow +permissions: + contents: write # For managing releases and pushing tags + packages: write # For publishing to GitHub Container Registry + +jobs: + 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@v6 + 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: Update Version + id: update_version + run: | + python scripts/bump_version.py ${{ github.event.inputs.version_type }} + NEW_VERSION=$(python -c "import version; print(f'{version.__version__}')") + echo "new_version=${NEW_VERSION}" >> $GITHUB_OUTPUT + + - 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 "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 + + - 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@v6 + 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@v4 + + - name: Login to GitHub Container Registry + uses: docker/login-action@v4 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Login to Docker Hub + uses: docker/login-action@v4 + with: + registry: docker.io + username: ${{ secrets.DOCKERHUB_USERNAME }} + password: ${{ secrets.DOCKERHUB_TOKEN }} + + - name: Extract metadata for Docker + id: meta + uses: docker/metadata-action@v6 + 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@v7 + with: + context: . + push: true + platforms: linux/${{ matrix.platform }} + tags: | + 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@v4 + + - name: Login to GitHub Container Registry + uses: docker/login-action@v4 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Login to Docker Hub + uses: docker/login-action@v4 + 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@v2 + with: + tag_name: v${{ needs.prepare.outputs.new_version }} + name: Release v${{ needs.prepare.outputs.new_version }} + draft: false + prerelease: false + token: ${{ secrets.GITHUB_TOKEN }} diff --git a/.gitignore b/.gitignore new file mode 100755 index 0000000..c75198a --- /dev/null +++ b/.gitignore @@ -0,0 +1,24 @@ +.DS_Store +**/__pycache__/ +**/.vscode/ +**/venv +*.pyc +node_modules/ +.history/ +staticfiles/ +docker/DockerfileAIO +docker/Dockerfile DEV +static/ +data/ +.next +next-env.d.ts +media/ +celerybeat-schedule* +dump.rdb +debugpy* +uwsgi.sock +package-lock.json +models +.idea +.vite/ +uv.lock \ No newline at end of file diff --git a/.python-version b/.python-version new file mode 100644 index 0000000..24ee5b1 --- /dev/null +++ b/.python-version @@ -0,0 +1 @@ +3.13 diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..3a1f493 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,1620 @@ +# Changelog + +All notable changes to this project will be documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## [Unreleased] + +## [0.23.0] - 2026-04-17 + +### Security + +- Set `DEFAULT_PERMISSION_CLASSES` to `IsAdmin` in the DRF configuration. All viewsets and function-based views that require non-admin or unauthenticated access were explicitly annotated: proxy streaming endpoints (`stream_ts`, `stream_xc`, `stream_vod`, `head_vod`, `stream_xc_movie`, `stream_xc_episode`) use `@permission_classes([AllowAny])` (access is controlled by the per-stream-type network allow-list inside the view body); the `UserAgentViewSet`, `StreamProfileViewSet`, `CoreSettingsViewSet`, and `ProxySettingsViewSet` gained `get_permissions()` methods mapping read actions to `IsStandardUser` and write actions to `IsAdmin`; and `AuthViewSet.logout` was updated to return `[Authenticated()]`. +- Fixed missing `network_access_allowed` checks in the VOD proxy. `stream_vod`, `head_vod`, `stream_xc_movie`, and `stream_xc_episode` were not checking the `STREAMS` network policy, unlike the equivalent TS proxy endpoints. +- Explicitly marked the HDHomeRun discovery endpoints (`DiscoverAPIView`, `LineupAPIView`, `LineupStatusAPIView`, `HDHRDeviceXMLAPIView`) and the version endpoint with `permission_classes = [AllowAny]` to document their intentionally public access now that the global default is `IsAdmin`. +- Fixed path traversal vulnerability in file uploads. The M3U account upload (`apps/m3u/api_views.py`), logo upload (`apps/channels/api_views.py`), and backup upload (`apps/backups/api_views.py`) all used the uploaded filename directly without sanitization. `os.path.join()` discards all preceding components when it encounters an absolute path segment, and `pathlib`'s `/` operator behaves identically; a relative `../` sequence also escapes via OS path resolution at `open()` time. All three upload paths now strip directory components via `Path(name).name` and validate the resolved path remains within the intended upload directory. Exploiting any of these required admin credentials. +- Prevented users from setting `xc_password` (and other admin-managed keys) on their own account via the `PATCH /api/accounts/users/me/` endpoint. +- Hardened the HLS proxy `change_stream` endpoint by converting it from a plain Django view to a DRF `@api_view` with `@permission_classes([IsAdmin])`, ensuring the endpoint actually enforces admin-only access. The previous decorator arrangement (`@csrf_exempt` + `@permission_classes`) had no effect on a plain Django view. +- Added rate limiting to the login endpoint (`POST /api/accounts/token/`) using DRF's built-in throttling. A `LoginRateThrottle` (3 requests/minute per IP, sliding window) is applied to the `TokenObtainPairView`. Repeated failed attempts from the same IP receive `429 Too Many Requests`. +- Extended rate limiting to the session-auth login alias (`POST /api/accounts/auth/login/`). It now delegates entirely to `TokenObtainPairView`, inheriting its throttle, network access check, and audit logging, and returns JWT tokens instead of a session cookie (the session-based response was unusable since `SessionAuthentication` is not in `DEFAULT_AUTHENTICATION_CLASSES`). Both endpoints share the same `"login"` throttle scope, so attempts across either path count against the same per-IP limit. +- Removed `CORS_ALLOW_CREDENTIALS = True` from CORS configuration. Dispatcharr authenticates via JWT `Authorization` headers and API keys — not cookies — so credentials are never sent cross-origin by browsers. The setting was also redundant: browsers reject `Access-Control-Allow-Credentials: true` when `Access-Control-Allow-Origin` is a wildcard (`*`), so it had no effect in practice. +- Updated frontend npm dependencies to resolve 6 audit vulnerabilities (6 high): + - Updated `@xmldom/xmldom` 0.8.11 → 0.8.12, resolving **high** XML injection via unsafe CDATA serialization allowing attacker-controlled markup insertion ([GHSA-wh4c-j3r5-mjhp](https://github.com/advisories/GHSA-wh4c-j3r5-mjhp)) + - Updated `lodash` 4.17.23 → 4.18.1, resolving **high** Code Injection via `_.template` imports key names ([GHSA-r5fr-rjxr-66jc](https://github.com/advisories/GHSA-r5fr-rjxr-66jc)) and **high** Prototype Pollution via array path bypass in `_.unset` and `_.omit` ([GHSA-f23m-r3pf-42rh](https://github.com/advisories/GHSA-f23m-r3pf-42rh)) + - Updated `vite` 7.3.1 → 7.3.2, resolving **high** Path Traversal in optimized deps `.map` handling ([GHSA-4w7w-66w2-5vf9](https://github.com/advisories/GHSA-4w7w-66w2-5vf9)), **high** `server.fs.deny` bypass with queries ([GHSA-v2wj-q39q-566r](https://github.com/advisories/GHSA-v2wj-q39q-566r)), and **high** Arbitrary File Read via dev server WebSocket ([GHSA-p9ff-h696-f583](https://github.com/advisories/GHSA-p9ff-h696-f583)) +- Updated `Django` 6.0.3 → 6.0.4, resolving the following CVEs: + - **CVE-2026-33033**: Potential DoS via `MultiPartParser` through crafted multipart uploads. + - **CVE-2026-33034**: SGI requests with a missing or understated `Content-Length` header could bypass the `DATA_UPLOAD_MAX_MEMORY_SIZE` limit. + - **CVE-2026-4292**: Privilege abuse in `ModelAdmin.list_editable`. + - **CVE-2026-3902**: ASGI header spoofing via underscore/hyphen conflation. + - **CVE-2026-4277**: Privilege abuse in `GenericInlineModelAdmin`. + +### Added + +- **EPG historical data window**: the EPG XML output and XC EPG API now support a `prev_days` URL parameter (e.g. `&prev_days=3`) to include past programs in the EPG response. This allows third-party players that request historical program schedules to receive the data they need. The EPG URL builder in the Channels page exposes "Days forward" and "Days back" controls. Per-user defaults for both values (`epg_days` / `epg_prev_days`) can be configured in the User settings modal and are applied automatically when no URL parameter is present. (Closes #1154) +- **Plugin Hub**: administrators can now browse, install, and update plugins directly from remote repositories via a new Plugin Hub page in Settings. (Closes #393) — Thanks [@sethwv](https://github.com/sethwv) + - Install plugins directly from the hub: the release zip is downloaded, SHA256 integrity is verified, and the plugin is installed atomically. + - Update managed plugins when a newer version is available from their source repo. Version compatibility constraints (`min_dispatcharr_version` / `max_dispatcharr_version`) are enforced at install time. + - Browse available plugins from all enabled repos with name, description, version, author, and icon. + - Plugins installed from a repo are tracked as "managed": source repo, slug, installed version, prerelease flag, and deprecated status are all persisted and surfaced in the UI. + - Add plugin repositories by manifest URL. The official Dispatcharr Plugins repository is pre-configured; third-party repos are supported by supplying an optional GPG public key. + - Manifest signatures are verified via GPG; the official repo uses a bundled public key. Signature status is displayed per-repo. + - Preview a repository URL before adding it - validates the manifest and reports plugin count and signature status without saving anything. + - Configurable automatic manifest refresh interval (in hours; 0 to disable) runs as a Celery background task. + +### Removed + +- Removed dead `VODConnectionManager` class (`apps/proxy/vod_proxy/connection_manager.py`) and its associated helpers, which had been superseded by `MultiWorkerVODConnectionManager`. All active code already used the multi-worker implementation. Removed the unused `VODConnectionManager` import from `vod_proxy/views.py`, the unscheduled `cleanup_vod_connections` task from `apps/proxy/tasks.py`, and the unscheduled `cleanup_vod_persistent_connections` task from `core/tasks.py`. +- Removed dead VOD URL routes: `VODPlaylistView` (playlist generation), `VODPositionView` (position tracking), and the class-based `VODStatsView` (replaced by the existing function-based `vod_stats` view). +- Removed dead `updateVODPosition()` API method from `frontend/src/api.js`, which called the now-removed position tracking endpoint. + +### Fixed + +- Fixed TV Guide "Record One" always scheduling the recording on the first channel that matched the program's `tvg_id`, rather than the channel the user actually selected. When multiple channels share the same EPG source, the intended channel was silently ignored. The selected channel object is now passed explicitly through the click handler chain to `recordOne`, bypassing the `findChannelByTvgId` fallback lookup entirely. (Fixes #1140) — Thanks [@fezster](https://github.com/fezster) +- Graceful container shutdown: `docker stop` no longer results in exit 137 (SIGKILL). The entrypoint now explicitly stops all child processes — including uWSGI workers, Celery, Daphne, and Redis, which are spawned as uWSGI `attach-daemon` children and were previously invisible to the signal handler. A polling loop replaces the old fixed `sleep`, exiting as soon as all processes have stopped (up to an 8-second ceiling before force-stopping). PostgreSQL is stopped using `pg_ctl stop -m immediate` as a fallback rather than SIGKILL to avoid data corruption. Process names are now recorded at startup and displayed correctly in crash diagnostics. The unexpected-exit diagnostic block is now suppressed on normal `docker stop` shutdowns. — Thanks [@Shokkstokk](https://github.com/Shokkstokk) for the initial fix! +- Fixed two race conditions in the VOD proxy that caused the `profile_connections` counter to go permanently negative, allowing connections beyond the configured profile limit. (1) `_decrement_profile_connections()` used a GET-before-DECR guard: two concurrent decrements could both read the same positive value, both pass the guard, and both fire, driving the counter below zero. Replaced with an unconditional `DECR` followed by a clamp-to-zero if the result is negative. (2) The `stream_generator` decremented `active_streams` and then checked `has_active_streams()` in two separate Redis round-trips without locking. A concurrent generator on another worker could read `active_streams=0` in the window between those two calls and also decrement the profile counter, producing a double-decrement. A new `decrement_active_streams_and_check()` method performs both operations under a single distributed lock, and a `profile_decremented` flag guards all four call sites in the generator so the profile counter is only ever decremented once per stream. (Closes #1125) — Thanks [@firestaerter3](https://github.com/firestaerter3) +- Fixed a provider TCP connection leak in the VOD proxy `stream_generator`. When a stream ended via an unhandled exception path that reached the `finally` block without any of the three exception handlers having run (e.g. an error raised before the first `yield`), the `finally` block decremented counters but never called `redis_connection.cleanup()`. The upstream `requests.Response` and `requests.Session` were left open until garbage collection. The `finally` block now starts a `delayed_cleanup` daemon thread (matching the 1-second delay used by the normal-completion and `GeneratorExit` paths) so that seeking clients have time to reconnect and increment `active_streams` before `cleanup()` checks whether it is safe to close the connection. +- Fixed manual stream selection from the Stats page not enforcing M3U profile connection limits in multi-worker deployments. When a non-owning worker handled the `change_stream` request it correctly packaged `stream_id` and `m3u_profile_id` into the Redis pubsub message, but the owning worker's pubsub handler only consumed `url` and `user_agent` silently dropping both IDs before calling `stream_manager.update_url()`. Because `update_url` only calls `update_stream_profile()` when a `stream_id` is provided, the `profile_connections` counter was never updated after the switch, causing subsequent capacity checks to see incorrect counts and bypass the full-profile guard. The handler now extracts `stream_id` and `m3u_profile_id` from the event and forwards them to `update_url()`. The bug did not affect single-worker / dev-mode deployments because the owning worker handles those requests directly without pubsub. +- Fixed the `next_stream` rotation endpoint applying the same class of bug: `get_stream_info_for_switch()` was called and returned `m3u_profile_id`, but the result was dropped when forwarding to `ChannelService.change_stream_url()`, so `update_stream_profile()` was never called and `profile_connections` counters were not updated after an automatic stream rotation. +- Fixed stream switch metadata (`url`, `user_agent`, `stream_id`, `m3u_profile`) being written to Redis before the switch was confirmed to succeed. If the switch failed, URL unchanged or exception during teardown, Redis described a URL not actually in use. Metadata is now written only after `update_url()` returns `True`; on failure the owner writes `stream_manager.url` back as the ground truth. The non-owner no longer pre-writes metadata at all, all needed info is carried in the pubsub payload and written by the owner after confirmation. +- Fixed the Stats page "Active Stream" dropdown not updating when a stream switch occurs. The card was matching the active stream by comparing the URL stored in Redis against stream URLs from the database, which failed silently when the stored URL was a transformed/rewritten value that didn't substring-match the original. The dropdown now matches by `stream_id` (the authoritative value already present in the stats payload) and re-runs only when `stream_id` changes, so the normal polling interval drives updates with no extra renders. +- Fixed the XC Password field in the User modal being editable by standard users despite the backend (`PATCH /api/accounts/users/me/`) stripping `xc_password` from `custom_properties` for non-admin users, causing the change to silently revert on save. The field and its generate button are now disabled with an explanatory description when the current user is not an administrator. +- Fixed live stream hiccups caused by nginx buffering TS proxy data to disk. The `/proxy/` location block used `proxy_buffering off` and `proxy_read/send_timeout` directives, which are silently ignored when the upstream is `uwsgi_pass` (a different directive family). nginx was therefore defaulting to `uwsgi_buffering on`, spooling stream data through temp files on disk. Replaced with the correct `uwsgi_buffering off`, `uwsgi_read_timeout 300s`, and `uwsgi_send_timeout 300s` directives so stream data flows directly from uWSGI to the client socket without intermediate disk I/O. +- Fixed the logo cache endpoint (`/api/channels/logos/{id}/cache/`) holding a uWSGI greenlet indefinitely when fetching from a slow or dripping remote server. The previous implementation used `StreamingHttpResponse(iter_content())` with only a per-chunk read timeout; a server that drips data just fast enough to reset the per-read timer could hold the greenlet open forever. Replaced with an eager read loop enforcing a hard total-download deadline (10 s) and a size cap (5 MB). Also fixed a race condition in the existing negative-cache logic: the failure entry for a URL was cleared immediately upon receiving HTTP 200, before the body was read. A concurrent greenlet seeing no failure entry during a slow download that ultimately timed out would also attempt the fetch, defeating the cache. The entry is now cleared only after the full body has been successfully received. +- Fixed uploading a local M3U file with no expiration date set sending the string `"null"` as the `exp_date` field in the `FormData` request, causing a 400 validation error from the API. Null/undefined values are now skipped when building the `FormData` body, matching the behaviour already present in the update path. +- Fixed `PATCH /api/channels/channels/edit/bulk/` returning a 500 error when the request body included a `streams` list. The bulk edit handler was iterating `validated_data` directly and calling `setattr(channel, "streams", value)`, which Django prohibits on ManyToMany fields. Also added an `@extend_schema` decorator so the Swagger UI correctly documents the endpoint as accepting a JSON array and shows the `streams` field. (Fixes #883) +- Fixed several incorrect or incomplete OpenAPI (`@extend_schema`) schemas across the API: + - `POST /api/epg/import/` — request body was undocumented; now correctly shows the `id` field. Description updated from "import" to "refresh" to match frontend and backend terminology. + - `DELETE /api/channels/logos/bulk-delete/` — `delete_files` boolean was missing from the documented request body. + - `POST /api/channels/channels/batch-set-epg/` — `epg_data_id` inside each association object was not marked `allow_null`/`required=False`, even though passing `null` is the correct way to remove an EPG link. + - `PUT /api/connect/integrations/{id}/subscriptions/set/` — endpoint had no `@extend_schema` at all; now documents that the request body is a JSON array of subscription objects. + +### Changed + +- **Output bitrate DB persistence**: the `ffmpeg_output_bitrate` stat is no longer written to the database on every FFmpeg stats tick (~2/second). Instead, a local exponential moving average (EMA, α=0.1) accumulates readings continuously. The first 10 samples (~5 seconds) are discarded as warmup to avoid polluting the average with FFmpeg's unstable ramp-up values. After warmup, the smoothed value is flushed to the database at most once every 30 seconds, and a final flush occurs when the stream stops but only if the EMA has been seeded (i.e. the stream ran past warmup). Streams that stop during warmup leave the existing database value untouched, preserving previously accurate measurements when channel-hopping. +- Performance: `generate_m3u`, `generate_epg`, and `xc_get_live_streams` now use `select_related('channel_group', 'logo')` (or `select_related('logo')` for EPG) on every Channel queryset in `apps/output/views.py`. Previously each channel in the loop triggered a separate database query for its `logo` and `channel_group` foreign keys; with the JOIN-based prefetch this is reduced to a single query per request. On deployments with ~2 000 channels, `xc_get_live_streams` response time drops from ~2.5–4 s to ~250–450 ms. (Closes #1127) — Thanks [@xBOBxSAGETx](https://github.com/xBOBxSAGETx) +- Performance: `generate_epg` now uses `select_related('epg_data__epg_source')` on all EPG channel querysets, eliminating N+1 database queries for `EPGSource` traversal per channel (~15 s improvement on ~2000-channel deployments; total EPG generation time dropped from ~87 s to ~72 s in benchmarks). +- Performance: `xc_get_epg` now uses `select_related('epg_data__epg_source')` on all three channel fetch paths. Previously each request triggered 2 additional queries to resolve `channel.epg_data` and `channel.epg_data.epg_source`. +- Performance: `generate_m3u` now uses `prefetch_related` for streams when `?direct=true` is requested, eliminating N+1 stream queries (one per channel) on that code path. +- Performance: `EPGGridAPIView` (`apps/epg/api_views.py`) now uses `select_related('epg_data__epg_source')` on the `channels_with_custom_dummy` queryset, eliminating 2 extra queries per channel (for `epg_data` and `epg_source`) in the dummy EPG generation loop. +- Performance: `generate_epg` now issues a single cross-channel `ProgramData` bulk query. `.values()` returns plain dicts, bypassing per-row Django model instantiation. Results are consumed in independent 5000-row keyset-paginated chunks. Combined with the `select_related` improvements above, EPG generation time on large deployments is significantly reduced. +- Performance: `xc_get_live_streams` no longer calls `ChannelGroup.objects.get_or_create(name="Default Group")` once per null-group channel; replaced with a lazy-initialised closure that executes at most one query regardless of how many ungrouped channels are present. +- AIO containers now connect to the internal PostgreSQL instance via a Unix domain socket instead of TCP loopback. Users who have `POSTGRES_HOST` explicitly set to `localhost` or `127.0.0.1` in their compose file are automatically migrated to the socket path; any other explicit value (external host/IP) is left untouched. — Thanks [@JCBird1012](https://github.com/JCBird1012) +- Improved the EPG response cache key. Previously it was based on the raw query string and username, meaning a user default of `epg_days=7` and an explicit `&days=7` URL parameter produced different cache entries for identical output. The key is now built from all resolved effective parameter values (`days`, `prev_days`, `cachedlogos`, `tvg_id_source`) so semantically equivalent requests always share the same cache entry. +- Improved the HDHR, M3U, and EPG URL builder popovers in the Channels table: each popover now opens with a brief intro sentence describing its purpose. Toggle switches were refactored to use Mantine's native `label` and `description` props (replacing the previous manual `Group`/`Stack`/`Text` layout), giving each switch a properly styled description line beneath its label. Switch alignment was also corrected. Toggles now appear on the left with the label and description stacked to the right, consistent with standard Mantine form layout. +- Redesigned the User settings modal with a tabbed layout: **Account** (username, email, name, password), **Permissions** (user level, stream limit, channel profiles, mature content filter - admin only), **EPG Defaults** (days forward/back), and **API & XC** (XC password, API key management). Fields are now logically grouped rather than split across two ad-hoc columns. +- EPG channel scanning now automatically removes stale `EPGData` entries. tvg-ids that were present in a previous scan but are no longer found in the upstream source, provided they are not mapped to any channel. This prevents unbounded database bloat over time. Entries mapped to at least one channel are always preserved. +- Rewrote the M3U line parser as an `iter_m3u_entries` generator that owns the full per-entry state machine. Intermediate directive lines between `#EXTINF` and the stream URL are now handled correctly rather than corrupting the pending entry or being silently misassigned. A `#EXTINF` with no following URL is discarded with a warning instead of carrying over a `url`-less entry into batch processing. Attribute keys are normalised to lowercase during parsing (provider attribute names remain case-insensitive end-to-end). The `#EXTINF` attribute regex is pre-compiled at module load, and attribute lookups use O(1) `dict.get()` instead of linear scans — approximately 10% faster parsing on large M3U files. +- Added support for the `#EXTGRP` directive in M3U files. When a `group-title` attribute is absent from the `#EXTINF` line, the value from a following `#EXTGRP:` line is used as the group. An explicit `group-title` attribute always takes priority. (Closes #1088) +- Added accumulation of `#EXTVLCOPT` directives per entry. Options are stored as a list under `vlc_opts` inside the stream's `custom_properties`, available for downstream use (e.g. passing VLC-specific options to the player). This is for a planned future enhancement and can also be utlized with the API. +- M3U stream name parsing now uses the comma text (the canonical display title per the base `#EXTINF` spec) as the primary stream name, falling back to `tvc-guide-title`, then `tvg-name`, rather than preferring `tvg-name` first. Providers that use `tvg-name` as an EPG key and put the human-readable title after the comma will now display the correct name. Providers that duplicate the same value in both fields are unaffected. (Fixes #1081) +- FloatingVideo player: the native video controls (timeline, play/pause, volume) are now hidden by default when a live stream starts and only appear when the user hovers over the player. +- Enhanced Swagger UI authorization dialog: registered a custom `OpenApiAuthenticationExtension` for `ApiKeyAuthentication` so drf-spectacular now generates an `ApiKeyAuth (apiKey)` entry alongside `jwtAuth`. Both entries include descriptive text linking to the relevant endpoints (`/api/accounts/token/`, `/api/accounts/api-keys/generate/`, `/api/accounts/api-keys/revoke/`). +- Refactored frontend form components (`AccountInfoModal`, `AssignChannelNumbers`, `Channel`, `ChannelBatch`, `ChannelGroup`, `Connection`, `CronBuilder`, `DummyEPG`, and `EPG`) to extract business logic into dedicated utility modules under `src/utils/forms/`. Each extracted module is covered by unit tests. Mantine compound component references (`Table.Tbody`, `Popover.Target`, `Accordion.Item`, etc.) have been updated to use flat named imports. — Thanks [@nick4810](https://github.com/nick4810) +- Improved the EPG BOM fix from v0.22.1: replaced the `lstrip(b'\xef\xbb\xbf')` / `startswith` approach with `start.find(b'...)`) without any conversion, eliminating the root cause of patterns that matched in the frontend live preview but failed on the backend with a `re.error`. As a further improvement, `regex` also supports variable-length lookbehind assertions (e.g. `(?<=a+)`), which `re` rejects with an error; patterns using these will now work correctly on the backend as well. Replace-pattern JS tokens are still normalised before calling `regex.sub`: `$` → `\g` and `$1`/`$2`/… → `\1`/`\2`/… (Python replacement syntax). Also fixed a bug in the WebSocket preview handler where a pattern error was incorrectly returning the search pattern string as the preview output instead of the original URL. (Fixes #1005) +- Web UI stream preview (`FloatingVideo`) was calling `mpegts.createPlayer()` with all `Config` options (e.g. `enableWorker`, `liveSync`, `headers`) merged into the first `MediaDataSource` argument. mpegts.js only reads `Config` from the optional second argument; unrecognised fields in the first are silently ignored. As a result all player configuration was effectively the library defaults — worker offloading was disabled, latency management had no effect, and the `Authorization: Bearer` header (required for user identification) was never sent. Fixed by splitting into the correct two-argument call. Both `liveBufferLatencyChasing` and `liveSync` have been disabled, eliminating playback-rate fluctuations that caused audible stuttering on live streams. SourceBuffer cleanup thresholds were also relaxed from 10s/5s to 120s/60s to prevent frequent SourceBuffer pauses. +- HTML named entities in XMLTV EPG files are now correctly preserved during lxml parsing. Some EPG providers (particularly French and other European sources) use HTML named entities like `é`, `î`, `ü` in channel names, program titles, and metadata. These are not valid XML entities — lxml 6.0.2 with `recover=True` silently drops them, causing characters to go missing (e.g., "Chaîne Télé" becomes "Chane Tl"). This is now fixed by injecting an XML `` internal subset declaring all 252 HTML 4 named entities directly into the byte stream that lxml reads, using a lightweight in-memory wrapper (`_PrependStream`) with zero disk I/O. libxml2 resolves the entities during its normal C-level parse pass — no Python-level preprocessing or temporary files are involved. The DOCTYPE block (~8 KB) is built once at module load from Python's stdlib `html.entities.name2codepoint` and reused for every parse. Files that already declare their own `` are passed through unchanged. (Closes #1095) — Thanks [@CodeBormen](https://github.com/CodeBormen) for helping with this! +- Duplicate recordings created when EPG sources refresh and re-evaluate series rules (Fixes #940) — Thanks [@CodeBormen](https://github.com/CodeBormen): + - **Program ID instability**: `parse_programs_for_source()` deletes and recreates all `ProgramData` rows with new auto-increment IDs on every EPG refresh. The dedup set used these IDs, so it never matched after a refresh. Deduplication now uses a stable `(tvg_id, start_time, end_time)` composite key sourced from `Recording.custom_properties.program`. + - **Secondary guard using wrong times**: The DB guard compared unadjusted program times against offset-adjusted `Recording.start_time`/`end_time`, so it never matched when any DVR pre/post offset was configured. It now queries `custom_properties__program__start_time/end_time` (the original, unadjusted program times stored at recording creation). + - **No concurrency guard**: Each EPG source refresh fired `evaluate_series_rules.delay()` independently. Concurrent tasks loaded the dedup set before others committed, allowing races. Evaluation is now serialized with `acquire_task_lock` (reusing the existing EPG task pattern). Gracefully degrades if Redis is unavailable — the primary and secondary dedup guards still protect. +- EPG refresh tasks (`refresh_epg_data`) were being killed mid-transaction on large EPG sources. The `soft_time_limit=1700s` introduced in v0.21.0 raised `SoftTimeLimitExceeded`, a subclass of `Exception`, which was swallowed by the existing `except Exception` handler in `parse_programs_for_source`, leaving the database in a partial state with no logged error. `soft_time_limit` has been removed from `refresh_epg_data` and `time_limit` raised to 14400s (4 hours) as a true last-resort ceiling; the existing `TaskLockRenewer` daemon thread continues to renew the Redis lock every 120s for legitimately long-running tasks. + +## [0.21.1] - 2026-03-18 + +### Fixed + +- Docker container initialization fixes for PUID/PGID handling — Thanks [@CodeBormen](https://github.com/CodeBormen): + - Backups failing on previous installations where `/data/backups` already existed: `/data/backups` was missing from the `DATA_DIRS` list in the init script, causing the PUID/PGID ownership migration to skip the directory and leave it with incorrect permissions. + - Container startup failure on upgrade when data directories reside on external mounts (NFS, SMB/CIFS, FUSE): `chown` failures under `set -e` were crashing the container, breaking setups that worked fine on the previous image. Failures are now collected per-directory and reported as a consolidated warning; the container continues to start and Django reports at runtime if it cannot write a specific directory. + - Upgrading users running as UID 102 (the internal PostgreSQL system user) instead of the expected UID 1000: the PUID/PGID auto-detect introduced in v0.21.0 read ownership from `/data/db`, which was UID 102 in pre-PUID images, causing Django, file creation, and comskip to all run as the wrong user. PUID/PGID now default to 1000 (matching the original Django UID) rather than auto-detecting from data directory ownership. + +## [0.21.0] - 2026-03-17 + +### Security + +- Updated frontend npm dependencies to resolve 1 high-severity vulnerability: + - Updated `flatted` to 3.4.1, resolving **high** unbounded recursion DoS in the `parse()` revive phase ([GHSA-25h7-pfq9-p65f](https://github.com/advisories/GHSA-25h7-pfq9-p65f)) +- Updated `Django` to 6.0.3 and `django-celery-beat` to 2.9.0, resolving new security vulnerabilities: + - [CVE-2026-25673](https://www.cve.org/CVERecord?id=CVE-2026-25673): Potential denial-of-service vulnerability in URLField via Unicode normalization on Windows (March 3, 2026) + - [CVE-2026-25674](https://www.cve.org/CVERecord?id=CVE-2026-25674): Potential incorrect permissions on newly created file system objects (March 3, 2026) + +### Added + +- Configurable sidebar navigation ordering and visibility — Thanks [@jcasimir](https://github.com/jcasimir) + - Sidebar nav items can be reordered via drag-and-drop in Settings → UI Settings → Navigation. + - Individual nav items can be hidden from the sidebar using the eye toggle. Hiding an item preserves its position in the order. + - A "Reset to Default" button restores the role-appropriate default order and clears all hidden items. + - Order and visibility are saved per-user with optimistic updates and automatic rollback on failure. Changes appear in the sidebar immediately without a page reload. + - Admin users see a grouped navigation: flat items (`Channels`, `VODs`, `M3U & EPG Manager`, `TV Guide`, `DVR`, `Stats`, `Plugins`) plus collapsible `Integrations` (Connections, Logs) and `System` (Users, Logo Manager, Settings) groups. The `System` group cannot be hidden. + - Non-admin users see `Channels`, `TV Guide`, and `Settings`, with the `Settings` item not hideable. +- Unit tests for `NotificationCenter`, `NotificationCenterUtils`, and `M3URefreshNotification` components, and for settings form components `DvrSettingsForm`, `NetworkAccessForm`, `ProxySettingsForm`, `StreamSettingsForm`, `SystemSettingsForm`, `UiSettingsForm`. — Thanks [@nick4810](https://github.com/nick4810) +- Unit tests for DVR port resolution (`build_dvr_candidates`) and selective Redis flush behavior in modular mode. — Thanks [@CodeBormen](https://github.com/CodeBormen) +- Floating video player improvements + - **Title display**: The channel, stream, or VOD title is now shown in the player header bar. Title is passed through from all preview entry points: channel table, stream table, stream connection card, guide, DVR, recording cards, recording details modal, VOD modal, and series modal. + - **Persistent state**: Size, position, volume level, and mute state are now saved across sessions using a single `dispatcharr-player-prefs` localStorage key. Size and position are restored on next open (clamped to the current viewport); volume and mute are restored when the player initialises. +- New Client Buffer proxy setting: new clients joining an active channel are now positioned a configurable number of seconds behind live rather than a fixed chunk count. The start position is determined by wall-clock chunk receive time (stored as a Redis sorted set alongside the buffer), so the buffer depth is consistent in seconds regardless of stream bitrate. Setting the value to `0` starts clients at live with no buffer. Defaults to 5 seconds. Existing chunk-count gating for the first client connecting to a channel is unchanged. The setting is exposed in Settings → Proxy as "New Client Buffer (seconds)". +- Channel table filter for channels that have stale streams: A new "Has Stale Streams" filter option in the channel table header menu highlights and filters channels containing at least one stale stream. Channels with stale streams are visually distinguished with an orange tint. The filter is mutually exclusive with "Only Empty Channels". - Thanks [@JCBird1012](https://github.com/JCBird1012) +- "Next Highest Channel" numbering mode when creating channels from streams: A new `Next Highest` option is available alongside `Provider`, `Auto`, and `Custom` when creating channels from the Streams table. Selecting it assigns channel numbers starting one above the current highest channel number; the next available number is fetched from the backend at selection time. (Closes #1000) — Thanks [@JCBird1012](https://github.com/JCBird1012) +- TV Guide program cards now display richer metadata — Thanks [@CodeBormen](https://github.com/CodeBormen) + - **Season/episode badges** (e.g. `S12E06`) extracted from EPG `` elements, `onscreen` episode strings (e.g. `S12 E6`, `S3E21`, `S8 E8 P2/2`), and as a last-resort fallback from description text patterns at parse time (3-tier pipeline). (Closes #1065) + - **Episode subtitle** shown below the program title on guide cards; falls back to the short description when no subtitle is available. + - **Status badges**: `LIVE`, `NEW`, `PREMIERE`, and `FINALE` surfaced from EPG flags on both compact cards and the detail modal. + - **Program detail modal**: Clicking any guide program opens a modal with full program details — poster/icon image, season/episode, subtitle, duration, categories, cast/director/writer credits, content rating, star ratings, production date, original air date, video quality, and external links to IMDb and TMDB where available. Detail data is fetched from a new `GET /api/epg/programs/{id}/` endpoint backed by the new `ProgramDetailSerializer`. Dummy/placeholder programs skip the fetch. + - **Real-time progress bars**: Currently-airing programs show a green progress bar on their guide card that updates every second via direct DOM manipulation (no React re-renders). + - **Channel name tooltip**: Hovering the channel logo column shows the channel name. +- Sort icons added to the Group and EPG column headers in the Channels table, and to the Group column header in the Streams table. Clicking a sort icon cycles through ascending/descending/unsorted states. EPG sorting required a backend change (`epg_data__name` added to `ChannelViewSet.ordering_fields`); Group sorting was already supported by the API in both tables. (Closes #854) — Thanks [@CodeBormen](https://github.com/CodeBormen) +- DVR enhancements — Thanks [@CodeBormen](https://github.com/CodeBormen) + - **Stop Recording**: A new Stop button (distinct from Cancel) cleanly ends an in-progress recording early and keeps the partial file available for playback. The API returns immediately; stream teardown and task revocation happen in a background thread to prevent 504 timeouts. When multiple recordings run simultaneously, stopping one only terminates that recording's proxy client by ID, leaving all others unaffected. (Closes #454) + - **Extend Recording**: In-progress recordings can be extended by 15, 30, or 60 minutes without interrupting the stream. + - **Inline metadata editing**: Title and description can now be edited directly in the recording details modal. + - **Refresh artwork button**: Manually re-run poster resolution on demand from the recording card. + - **Multi-source poster resolution**: Added pipeline querying EPG, VOD, TMDB, OMDb, TVMaze, and iTunes for richer recording artwork. + - **Series rules for currently-airing episodes**: Series rules now capture currently-airing episodes in addition to future scheduled ones. (Closes #473) + - **Search and filter controls**: Added search and filter controls to the recordings list. + - **Stream generator throttling**: Cached the `ProxyServer` singleton reference per client and throttled Redis resource checks (1 s) and non-owner health checks (2 s), eliminating 3+ Redis round-trips per stream loop iteration. + - **Automatic crash recovery on worker restart**: A `worker_ready` Celery signal now fires `recover_recordings_on_startup` automatically when the worker starts, so recordings stuck in "recording" status are recovered without manual intervention. +- Account expiration tracking and notifications for M3U profiles + - A new `exp_date` field on `M3UAccountProfile` stores the account expiration date as a proper `DateTimeField`. For Xtream Codes accounts the field is auto-synced from `custom_properties.user_info.exp_date` on every save (supports both Unix timestamps and ISO date strings). For non-XC M3U accounts the date can be entered manually via the account or profile form. + - The M3U accounts table now shows an **Expiration** column displaying the earliest expiration date across all profiles for that account (color-coded: red = expired, orange = expiring soon, green = OK). Hovering the cell shows a tooltip with per-profile expiration details including inactive-profile labels. + - A daily Celery Beat task (`check_xc_account_expirations`) checks all active profiles with an expiration date and manages system notifications: a normal-priority warning is raised for profiles expiring within 7 days; a high-priority alert is raised once the profile has already expired. Warning and expired notifications use separate keys so dismissing the 7-day warning does not suppress the expiration alert. + - Notifications are also updated immediately when a profile is saved: if the expiration date is cleared or pushed beyond the 7-day window, any existing warning/expired notifications are deleted; if the date falls within the window or is already past, the matching notification is updated in place. + - Non-XC accounts expose a `DateTimePicker` on both the M3U account form and the profile form. + +### Changed + +- Dependency updates: + - `Django` 5.2.11 → 6.0.3 (security patch + major version upgrade; see Security section) + - `django-celery-beat` ≥2.8.1 → ≥2.9.0 (adds explicit Django 6.0 support) +- When selecting an EPG source for a channel, the EPG source dropdown now only lists enabled (active) EPGs, sorted alphabetically. +- Channels page default splitter ratio changed from 50/50 to 60/40 (channels/streams) so all channel action buttons are visible without scrolling on 1080p displays. +- Frontend component refactoring and cleanup — Thanks [@nick4810](https://github.com/nick4810) + - `FloatingVideo`, `SeriesModal`, `VODModal`, `SystemEvents`, `M3URefreshNotification`, and `NotificationCenter` significantly reduced in size by separating business logic into dedicated utility modules under `utils/components/` (`FloatingVideoUtils.js`, `SeriesModalUtils.js`, `VODModalUtils.js`, `NotificationCenterUtils.js`). + - `FloatingVideo` resize handle elements extracted into a standalone `ResizeHandles` sub-component. + - `YouTubeTrailerModal` extracted into a standalone component (`components/modals/YouTubeTrailerModal.jsx`). + - `NotificationCenter` and `Sidebar` updated from Mantine dot-notation sub-components (`Popover.Target`, `Popover.Dropdown`, `ScrollArea.Autosize`, `AppShell.Navbar`) to Mantine v7 named imports (`PopoverTarget`, `PopoverDropdown`, `ScrollAreaAutosize`, `AppShellNavbar`). + - `M3URefreshNotification` now uses the centralized `showNotification()` utility (from `notificationUtils.js`) instead of calling `notifications.show()` directly, bringing it in line with the rest of the app. State updates also converted to functional updater form (`prev => ...`) to eliminate potential stale-closure bugs. + - `SystemEvents` now imports `format` from `dateTimeUtils` for consistent date/time formatting. + - Removed a dead `onLogout` handler in `Sidebar` that called `logout()` and `window.location.reload()` but was never wired to any UI element. +- EPG output when no `days` parameter is specified now excludes already-ended programs instead of returning all historical data. + +### Fixed + +- Single-stream channel creation modal not opening correctly when clicking the channel-creation button on an individual stream row in the Streams table. — Thanks [@JCBird1012](https://github.com/JCBird1012) +- DVR series rule creation failing with a 500 error when the stored `series_rules` data contained corrupted (non-dict) entries. Added type guards on the getter, setter, and generic settings serializer to filter invalid entries on read and write. Hardened the EPG ignore list getters (`prefixes`, `suffixes`, `custom`) with the same pattern. Frontend settings parse and save now validate `series_rules` with `Array.isArray()`, matching the existing EPG field pattern, preventing corrupted data from being round-tripped back to the database. (Fixes #1059) — Thanks [@CodeBormen](https://github.com/CodeBormen) +- TS proxy clients stuck indefinitely in keepalive mode when a stream fails and never recovers (Fixes #1102, #1103) — Thanks [@cmcpherson274](https://github.com/cmcpherson274) & [@CodeBormen](https://github.com/CodeBormen) + - **Keepalive duration cap**: Non-owner worker clients sending keepalive packets to hold a connection open during failover can now be held at most `MAX_KEEPALIVE_DURATION` seconds (default 300 s). If no real stream data has been received within that window, the client is disconnected with a warning log. The timer resets each time real data resumes, so independent stalls do not accumulate. + - **`last_active` tracking**: `last_active` is now updated on every keepalive packet and on every real data chunk, so clients actively waiting during a failover are not incorrectly evicted as ghost clients by the heartbeat thread. The heartbeat thread now only refreshes the Redis TTL rather than updating `last_active`, ensuring the ghost-detection check reflects true client activity rather than heartbeat activity. + - **Buffer reset on stream transition**: A new `reset_buffer_position()` method on `StreamBuffer` clears the in-memory write buffer and partial-packet accumulator when switching between FFmpeg processes. Without this, a partial 188-byte TS packet from the dying FFmpeg process was being prepended to the first bytes from the new FFmpeg process, producing a corrupted TS packet boundary that broke audio decoder sync on the client side. Redis-stored chunks already consumed by clients are unaffected. + - **`add_chunk()` locking hardened**: The lock scope in `add_chunk()` was expanded to cover the entire partial-packet merge and write-buffer accumulation phase, preventing a race condition between `add_chunk()` and the new `reset_buffer_position()` call. +- uWSGI segfaults caused by mixing threading and gevent concurrency models. The dev and debug uWSGI configs had `threads` and `enable-threads = true` set alongside gevent, which triggers segmentation faults particularly on ARM64/Python 3.13. Removed those options to match the already-correct production config. — Thanks [@jcasimir](https://github.com/jcasimir) +- `Stream.last_seen` and `ChannelGroupM3UAccount.last_seen` model defaults now use `django.utils.timezone.now` instead of `datetime.datetime.now`, eliminating spurious `RuntimeWarning: DateTimeField received a naive datetime` warnings emitted during test database creation and on new record creation when `USE_TZ=True`. +- EPG programme parsing crash when an XMLTV source contains programme titles exceeding 255 characters. Previously, a single oversized title would cause the entire `bulk_create` batch to fail with a database truncation error, silently dropping all programmes in that batch. Titles are now truncated to 255 characters before being saved. (Fixes #1039) +- Container startup failure when `PUID`/`PGID` is set, caused by `/data/db` ownership conflicts between the `postgres` system user (UID 102) and the configured PUID/PGID. PostgreSQL now runs as the PUID/PGID user in AIO mode, eliminating all `chown`-to-UID-102 operations and unifying `/data` ownership. (Fixes #1078) — Thanks [@CodeBormen](https://github.com/CodeBormen) + - Existing installations where PUID/PGID differs from the current `/data/db` owner are migrated automatically on first startup; a sentinel file prevents redundant recursive `chown` on subsequent boots. + - PUID/PGID auto-detected from existing data ownership when not explicitly set, avoiding cross-UID `chown` failures on restricted filesystems (NFS `root_squash`, CIFS). + - PUID/PGID validated as positive non-zero integers before any user/group operations. + - UID collisions with the `postgres` system user (e.g. PUID=102) are now handled gracefully. + - Ensured proper variable quoting in the /docker/ directory to guard from inappropriate input +- Floating video player bug fixes + - **Resize stuck after releasing mouse outside window**: The `mouseup` event is not delivered when the pointer leaves the viewport, leaving the `mousemove` listener active indefinitely. Fixed by checking `event.buttons === 0` at the top of `handleResizeMove`; when no button is held the resize session is torn down immediately. + - **Drag stuck after releasing mouse outside window**: Same root cause as the resize bug. Fixed by detecting `event.buttons === 0` in the `onDrag` handler and dispatching a synthetic `mouseup` event so react-draggable cleanly ends the drag session. + - **Player draggable off screen**: The player could be dragged off any edge, making the header (and drag handle) unreachable. The player is now fully bounded: left and top edges are clamped to `x ≥ 0` / `y ≥ 0` so the header is always reachable, and right/bottom edges are clamped to the viewport. Size and position are also re-clamped automatically when the browser window is resized, with proportional scale-down if the saved size exceeds the new viewport. +- Double error notification when saving user preferences: `API.updateMe` was catching errors internally and displaying a notification before re-throwing, causing callers to display a second notification for the same failure. +- Navigation preference saves from concurrent sessions could overwrite each other due to a double-merge race: the frontend was pre-merging `custom_properties` before sending, then the backend merged again against the DB value, causing the second session's write to silently drop keys set by the first. The frontend now sends only the delta; the backend merges authoritatively against the stored value. +- Stale nav item IDs (e.g. from a previous nav structure) are now scrubbed from `navOrder` and `hiddenNav` on the next preference save, preventing unbounded growth of the `custom_properties` JSON field. +- Version update notification persisting after upgrading to the notified version (e.g. "v0.20.2 available" shown while already running v0.20.2). Root cause: `check_for_version_update.delay()` was called from `AppConfig.ready()`, which fires inside Celery prefork pool subprocesses before the broker connection is established, causing the dispatch to fail silently with no log output. Fixed by moving the startup dispatch to the `worker_ready` signal in `celery.py` (consistent with the existing `recover_recordings_on_startup` pattern), and deleting the stale `version-{current_version}` notification at the top of the production check path so it is cleared even when GitHub is unreachable. A WebSocket update is sent immediately on deletion so the frontend badge clears without waiting for the API response. +- VOD orphan cleanup crashing with a `ForeignKeyViolation` (`IntegrityError`) when a concurrent refresh task created a new `M3UMovieRelation` or `M3USeriesRelation` for a movie/series between the orphan-detection query and the `DELETE` SQL. Both `orphaned_movies.delete()` and `orphaned_series.delete()` are now wrapped in `try/except IntegrityError`; affected records are skipped with a warning and will be cleaned up on the next scheduled run. +- XC stream refresh crashing with a `null value in column "name"` database error when a provider returns streams with a null or empty name. Affected streams are now assigned a generated fallback name in the format ` - ` so the refresh completes successfully and the stream remains accessible. A warning is logged for each affected stream. +- 504 Gateway Timeout when saving M3U group settings on slower hardware (e.g. Synology NAS). Replaced per-row `update_or_create()` loops with `bulk_create(update_conflicts=True)` wrapped in `transaction.atomic()` for both `ChannelGroupM3UAccount` and `M3UVODCategoryRelation`, reducing hundreds of individual DB round-trips to a single query per model. (Fixes #745) — Thanks [@nickgerrer](https://github.com/nickgerrer) +- Improved frontend table stability during M3U imports: Fixed incorrect default `state` initialization (`[]` → `{}`) in `CustomTable` to match TanStack Table v8's expected state object shape. Added `autoResetPageIndex: false` and `autoResetExpanded: false` to prevent TanStack Table from issuing internal state resets on data updates. Memoized `processedData` in `M3UsTable` to avoid redundant sort/filter recomputation on re-renders. - Thanks [@marcinolek](https://github.com/marcinolek) +- `debian_install.sh` hardened for non-UTF8 environments (common in minimal LXC containers) - Thanks [@marcinolek](https://github.com/marcinolek) + - Added `setup_locales` step that installs the `locales` package, enables `en_US.UTF-8`, regenerates locales, and exports `LANG`/`LC_ALL` before any other work runs, preventing PostgreSQL from defaulting to `SQL_ASCII` encoding. + - PostgreSQL database creation now explicitly passes `-E UTF8` to `createdb`. + - `PATH` in the Celery worker, Celery Beat, and Daphne systemd service files extended to include `/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin`, fixing failures where background tasks could not locate `ffmpeg` or `ffprobe`. +- `is_adult` field parsing now guards against invalid values (e.g. the string `"None"`) that providers may send instead of a valid integer, preventing a `ValueError` crash during M3U/XC stream refresh. A new `parse_is_adult()` helper wraps the cast in a `try/except`, returning `False` for anything that cannot be interpreted as `1`. (Fixes #1061) — Thanks [@JCBird1012](https://github.com/JCBird1012) +- M3U EXTINF attribute parsing for values containing `=` or `==` (e.g. base64-padded `tvg-logo` URLs, catchup tokens with query strings). The previous regex used `[^\s]+` for the key pattern, allowing `=` signs inside a quoted value to be greedily absorbed into the next attribute's key name, causing that attribute and all subsequent ones on the line to be silently dropped. Changed to `[^\s=]+` so the key match always stops at the first `=`. (Fixes #1055) - Thanks [@JCBird1012](https://github.com/JCBird1012) +- Celery worker memory leak during M3U/XC refresh causing 20–80 MB growth per cycle with no reclamation (Fixes #1012, #1053) - Thanks [@CodeBormen](https://github.com/CodeBormen) + - Restructured `refresh_single_m3u_account()` with a `try/finally` that guarantees `del` of large data structures runs before Celery's `gc.collect()`, and lock release on all exit paths (success, exception, early return) + - Re-enabled batch data cleanup in `process_m3u_batch_direct()` (was commented out) + - Added `CELERY_WORKER_MAX_MEMORY_PER_CHILD = 512 MB` as a safety net against pymalloc arena fragmentation +- EPG output was filtering programs using `start_time__gte=now` when the `days` parameter was specified, which caused currently-airing programs (started before the request time but not yet ended) to be omitted from the XML output. This produced a gap in clients' guides immediately after an EPG refresh, lasting until the next program started. Fixed by changing the filter to `end_time__gte=now` so any program that has not yet finished is included. +- TS proxy connection slot leaks and TOCTOU races in stream initialization (Fixes #947) - Thanks [@CodeBormen](https://github.com/CodeBormen) + - **TOCTOU race in slot reservation**: `get_stream()` previously used a `GET`→check→`INCR` sequence, allowing concurrent requests to both read the same count below the limit and both reserve a slot, silently exceeding `max_streams`. Replaced with an atomic `INCR`-first pattern: increment unconditionally, check the result, roll back with `DECR` if over capacity. — Thanks [@patchy8736](https://github.com/patchy8736) + - **Leak on URL generation failure**: `generate_stream_url()` called `get_stream()` (which `INCR`s the counter) but had no cleanup path if subsequent DB lookups or URL construction failed. The post-`get_stream()` block is now wrapped in a `try/except` that calls `release_stream()` on any error. + - **Leak on retry-loop timeout**: the retry loop in `stream_ts()` called a bare `get_stream()` on the first failure to classify the error reason. If a slot was available, this `INCR`'d the counter and set Redis keys that were never released when the loop timed out. A `release_stream()` call is now issued before returning 503. + - **Leak on `initialize_channel()` failure**: when `initialize_channel()` returned `False`, the connection slot allocated by the preceding `get_stream()` was never released. A `connection_allocated` flag now tracks whether this request performed the `INCR` (fresh initialization vs. joining an existing channel), and `release_stream()` is called guarded by that flag to prevent incorrect decrements when attaching to an already-running channel. + - **Safety net for unexpected exceptions**: the outer `except` in `stream_ts()` now checks `connection_allocated` and calls `release_stream()` as a last-resort cleanup for any unhandled exception that escapes before the channel is handed off to the stream lifecycle. + - **`release_stream()` now returns `bool`** and adds a metadata-hash fallback: if the primary `channel_stream` / `stream_profile` Redis keys have already been cleaned up by the proxy, it recovers `stream_id` and `profile_id` from the channel's metadata hash and clears those fields atomically to prevent duplicate `DECR`s on repeated calls. — Thanks [@patchy8736](https://github.com/patchy8736) + - **`update_stream_profile()` uses a Redis pipeline** for the old-profile DECR + key update + new-profile INCR sequence, preventing counter drift if the process crashes between operations. + - **`stream_generator._cleanup()`** now falls back to `Stream.objects.get()` when the channel UUID resolves to a preview flow rather than a normal channel, rather than silently skipping the slot release. + - **VOD `cleanup_persistent_connection()`** fallback DECR is now conditional: it only decrements the profile counter when the connection tracking key had already expired by TTL (i.e., `remove_connection()` would have skipped the DECR), preventing double-decrements when the key is still present. +- Ghost clients and channels stuck in `INITIALIZING` state in the TS proxy (Fixes #695, #669) — Thanks [@CodeBormen](https://github.com/CodeBormen) + - **`INITIALIZING` added to cleanup grace period monitoring**: channels stuck in `INITIALIZING` are now surfaced by the cleanup task and torn down, preventing indefinite hangs when stream startup fails. + - **Orphaned channel cleanup validates client SET entries**: the cleanup task now cross-checks client SET members against actual metadata hashes; ghost SET entries are removed and the channel is torn down cleanly when no real clients remain. + - **Stats page self-heals**: ghost client SET entries are detected and removed when reading channel stats, preventing stale entries from inflating the active-client count. + - **`remove_ghost_clients()` extracted to `ClientManager`**: ghost-detection logic is now a single authoritative helper, callable with an optional pre-fetched `client_ids` set to eliminate a redundant Redis `SMEMBERS` round-trip when the caller already holds the set. + - **Ownership TTL fallback**: the error-state writer now triggers via ownership check _or_ state guard fallback, so a channel stuck in a pre-active state is correctly marked `ERROR` even when the ownership TTL expired during retries. + - **Missing `SOURCE_BITRATE` / `FFMPEG_BITRATE` metadata constants** added to `ChannelMetadataField`, preventing `AttributeError` on detailed channel stats reads. +- TS proxy client stream lag recovery now only bumps clients forward when their next required chunk has genuinely expired from Redis (TTL), rather than unconditionally jumping if they fell more than 50 chunks behind. Clients are repositioned to the oldest available chunk (minimum data loss) using an atomic server-side Lua binary search, falling back to near the buffer head if nothing is available. +- TS proxy streams dying after 30–200 seconds in multi-worker uWSGI/Celery deployments, caused by three interrelated bugs. (Fixes #992, #980) - Thanks [@PFalko](https://github.com/PFalko) + - **Double ProxyServer instantiation**: `ProxyConfig.ready()` called `TSProxyServer()` directly while `TSProxyConfig.ready()` also called `TSProxyServer.get_instance()`, creating two instances per worker — each with its own cleanup thread. The orphaned thread could not extend ownership because it had no entries in `stream_managers`. Fixed by using `TSProxyServer.get_instance()` in `ProxyConfig.ready()`. + - **`flushdb()` on every Redis client init**: `RedisClient.get_client()` called `client.flushdb()` whenever `_client` was `None`. Celery autoscale (`--autoscale=6,1`) spawning new workers mid-stream triggered this path, nuking all Redis keys including active ownership keys, client records, and channel metadata. Removed the `flushdb()` call entirely. + - **No recovery from expired ownership**: `get_channel_owner()` called `redis.get()` twice inside a lambda (TOCTOU race — key could expire between calls); `extend_ownership()` silently returned `False` on expiry with no re-acquisition; and the non-owner cleanup path unconditionally killed streams even when the worker held the `stream_manager`. Fixed with a single `GET` in `get_channel_owner()`, re-acquisition via atomic `SET NX EX` in `extend_ownership()`, and a re-acquisition attempt with client-aware cleanup deferral in the cleanup thread. +- `get_instance()` deadlock: if `ProxyServer()` raised an exception during singleton construction, `_instance` was left permanently as the `_INITIALIZING` sentinel, causing all subsequent `get_instance()` callers to spin in an infinite `gevent.sleep()` loop. Construction is now wrapped in `try/except`; on failure `_instance` resets to `None` so the next call can retry. +- Non-atomic ownership acquisition in `try_acquire_ownership()`: replaced the separate `setnx()` + `expire()` calls with a single atomic `SET NX EX`, eliminating the race window where a process crash between the two calls could leave an ownership key with no TTL (permanent ownership lock). +- DVR bug fixes — Thanks [@CodeBormen](https://github.com/CodeBormen) + - **Duplicate recording execution**: `run_recording.apply_async(countdown=...)` exceeded Redis' default `visibility_timeout` (3600 s) for recordings scheduled more than one hour out, causing Redis to redeliver the task to multiple workers simultaneously and producing corrupted output files. Replaced `apply_async` with `ClockedSchedule` + `PeriodicTask` for database-backed one-shot scheduling that survives restarts and upgrades without the redelivery race. `run_recording` also now exits immediately if the recording is already in progress, completed, or stopped. `revoke_task()` cleans up both the `PeriodicTask` and its orphaned `ClockedSchedule` on execution. (Fixes #940, #641) + - **Stream reconnection resilience**: Recordings now survive transient network drops with automatic reconnection retrying up to 5 times and appending to the existing file. DB operations use exponential-backoff retry for transient database errors throughout the recording lifecycle. + - **Crash recovery pipeline**: On worker restart, recordings stuck in "recording" status have their segments concatenated and remuxed. Remux sanity checks reject MKV output that is less than 50% the size of a previous MKV (duplicate-task overwrite) or less than 10% of the source TS (corrupt first attempt); the source `.ts` is preserved for manual recovery on all failure paths. (Fixes #619, #624) + - **Output file collision**: Fixed collision when multiple tasks targeted the same filename. + - **WebSocket deadlock**: `send_websocket_update()` was deadlocking the gevent event loop, causing one recording's WebSocket events to block all other simultaneous recordings. + - **DVR client isolation**: Stop and Cancel operations now identify the target client by recording ID (via `User-Agent: Dispatcharr-DVR/recording-{id}`), ensuring only the correct proxy client is torn down and never affecting other recordings on the same channel. + - **Accidental stream termination on delete**: `destroy()` now only calls `_stop_dvr_clients()` for in-progress recordings, preventing stream termination when deleting a completed recording. + - **Recording card logos**: Logos were not displaying due to a channel summary API shape mismatch. + - **Logo fetch negative cache**: Added negative cache for failed remote logo fetches so dead CDNs no longer block Daphne workers on repeated requests. + - **Artwork fuzzy-match sanitisation**: Poster artwork fuzzy-matching against external APIs (TMDB, OMDb, etc.) was producing incorrect results for channels with names like "USA A&E SD\*"; channel-name strings are now sanitised before querying external sources. + - **Series modal "No upcoming episodes"**: Fixed due to a missing `_group_count` merge and an incorrect time filter. + - **Series rule cleanup**: Deleting a series rule left orphaned recordings and stale Guide indicators; rule deletion now cleans up all associated recordings. Orphaned recordings with no parent rule are also cleaned up automatically. (Fixes #1041) + - **Series rule timezone calculation**: Recurring rules silently dropped scheduled recordings for users in UTC-negative timezones after 4 pm local time. (Fixes #1042) + - **Recording modal TDZ crash**: Modal crashed on load in production bundles due to a Temporal Dead Zone error — editing state was referenced before its declaration in the minified bundle. + - **Description textarea focus loss**: The description textarea lost focus immediately when opened because the inline editing component was remounting on every render. + - **WebSocket-driven refresh**: Replaced all manual `fetchRecordings()` polling calls with debounced WebSocket-driven refresh so the recordings list stays up to date without redundant API requests. + - **comskip exit code handling**: comskip treated exit code 1 ("no commercials found") as a fatal error, causing post-processing to fail on clean recordings. Exit code 1 is now recognised as a successful no-op. + - **Differentiated WebSocket notification events**: `recording_stopped`, `recording_cancelled` (in-progress cancel), and `recording_deleted` with a `was_in_progress` flag now allow the frontend to display distinct "Recording stopped", "Recording cancelled", and "Recording deleted" toasts. + - **Duplicate series rule evaluation race**: Creating a series rule fired `evaluate_series_rules.delay()` in the API view while the frontend immediately called the synchronous evaluate endpoint, racing to create duplicate recordings for the same program. Removed the redundant async call from the API; the frontend's explicit evaluate call is now the sole evaluation path. + - **Recording card S/E badge overlap**: Season/episode badges were overlapping and metadata was hidden on the recording card. + - **Orphaned recording fallback in series modal**: When a series rule no longer exists, the recurring rule modal now shows a "Delete Recording" button for the orphaned recording instead of failing silently. +- Modular mode deployment hardening — Thanks [@CodeBormen](https://github.com/CodeBormen) + - **Postgres version check with restricted DB users**: The version check was connecting to the hardcoded `postgres` database, which fails when the configured user lacks access to it. Changed to use `$POSTGRES_DB` so the check works with least-privilege database users. (Fixes #1045) + - **DVR recording broken in modular mode**: Internal TS stream URL candidates hardcoded port `9191`, so recordings failed when `DISPATCHARR_PORT` was set to any other value. URL construction now reads `DISPATCHARR_PORT` from the environment via the new `build_dvr_candidates()` helper. `DISPATCHARR_PORT` is also now explicitly passed to the Celery container in `docker-compose.yml`. + - **Selective Redis flush in modular mode**: `wait_for_redis.py` now performs a targeted flush in modular mode — clearing stale stream locks, proxy metadata, and server-state keys — while preserving Celery broker and result-backend keys. Previously either a full `flushdb()` (which wiped Celery queues) or no flush at all was performed. + - **Redis wait stripping environment variables**: The modular-mode Redis readiness check ran as a uWSGI `exec-pre` hook, which executes under `su -` and strips Docker environment variables, making `DISPATCHARR_ENV` and `REDIS_HOST` unavailable. Moved to the container entrypoint so all env vars are present. + - **Stale environment variables after container restart**: `/etc/profile.d/dispatcharr.sh` was only written on the first container run; restarts with changed env vars (e.g. a rotated `POSTGRES_PASSWORD`) retained stale values. The file is now truncated and rewritten on every startup. `/etc/environment` entries are likewise updated rather than skipped when a key already exists. All exported values are now quoted to prevent breakage from special characters. + - **Celery entrypoint startup timeouts**: The JWT key wait and migration wait loops had no timeout, leaving the Celery worker hanging indefinitely if the web container was stuck. Each loop now times out (120 s for JWT, 300 s for migrations) and exits with a clear diagnostic message. The migration readiness check is also replaced from a fragile `showmigrations | grep` pattern to `migrate --check`, which exits cleanly on both unapplied migrations and connection errors. + - **Service startup ordering**: `depends_on` entries for `db` and `redis` in `docker-compose.yml` upgraded from plain name-link ordering to `condition: service_healthy`, ensuring containers wait for actual readiness signals before starting. + - **`host.docker.internal` resolution on Linux**: Added `extra_hosts: host.docker.internal:host-gateway` to the web service in `docker-compose.yml` so Linux hosts resolve `host.docker.internal` the same way Docker Desktop does on macOS/Windows. + +## [0.20.2] - 2026-03-03 + +### Security + +- Updated frontend npm dependencies to resolve 2 high-severity vulnerabilities: + - Updated `minimatch` to ≥10.2.3, resolving **high** ReDoS via matchOne() combinatorial backtracking with multiple non-adjacent GLOBSTAR segments ([GHSA-7r86-cg39-jmmj](https://github.com/advisories/GHSA-7r86-cg39-jmmj)) + - Updated `rollup` to ≥4.58.1, resolving **high** Arbitrary File Write via Path Traversal ([GHSA-mw96-cpmx-2vgc](https://github.com/advisories/GHSA-mw96-cpmx-2vgc)) + +### Fixed + +- EPG filter regression in channel table (introduced in 0.20.0 channel store refactor): The EPG filter dropdown was showing all EPG sources regardless of whether they had any channels assigned, and the "No EPG" option was never displayed. Fixed by annotating EPGSource records with a `has_channels` flag (via a lightweight `EXISTS` subquery) so only active EPG sources with at least one channel assigned appear as filter options. "No EPG" now appears only when at least one channel globally has no EPG assigned; this is determined by a second `EXISTS` query embedded directly in the paginated channel response (`has_unassigned_epg_channels`), avoiding any additional network requests. +- Stale stream rows missing hover effect: Stale streams in the streams table had no hover color change, unlike channels with no streams assigned. Converted the inline `backgroundColor` style to a CSS class (`stale-stream-row`) so the `:hover` rule can apply correctly. Applied the same fix to the channel-streams sub-table, where the teal expanded-row background caused the semi-transparent red tint to visually mismatch; the sub-table now uses a pre-blended solid color via `color-mix()` to match the appearance of stale rows in the main streams table. +- Channel table onboarding shown when filter returns zero results: The channel store refactor changed to loading only channel IDs instead of full channel objects, leaving `Object.keys(channels).length` always `0` and incorrectly triggering the onboarding state on any empty filter. Fixed by checking `channelIds.length` instead. +- TV Guide scrolls to position 0 when a filter yields no results: Applying any filter that temporarily empties the channel list (e.g. switching directly between two channel groups, or typing a search query that matches nothing) caused the guide to show a blank/empty view with no programs visible. The `VariableSizeList` unmounts when `filteredChannels` becomes empty, destroying its DOM node and resetting `scrollLeft` to 0. On remount the scroll position was never restored because `initialScrollComplete` was still `true`. Fixed by saving the user's current scroll position when the channel list empties mid-transition, then restoring it once new channels have loaded. On first load the guide still scrolls to the current time as before. +- `debian_install.sh` regressions after `uv` migration on clean/minimal Debian installs: fixed pip-less venv (`ensurepip`), missing `gunicorn` for the systemd unit, and inconsistent `DJANGO_SECRET_KEY` availability (now persisted to `.env` via `EnvironmentFile`). Docker unaffected. - Thanks [@marcinolek](https://github.com/marcinolek) + +## [0.20.1] - 2026-02-26 + +### Fixed + +- Login form disabled after token expiry: The login button was permanently rendered as disabled ("Logging you in...") on page load after a session expired, preventing users from logging back in. A regression in v0.20.0 caused `LoginForm` to check `if (user)` to detect an already-authenticated reload, but the Zustand auth store initializes `user` as a truthy empty object `{ username: '', email: '', user_level: '' }`, so the loading state was set immediately on every mount. Reverted to pre-regression behavior. (Fixes #1029) + +## [0.20.0] - 2026-02-26 + +### Security + +- Updated Django 5.2.9 → 5.2.11, resolving the following CVEs: + - **CVE-2025-13473** (low): Username enumeration via timing difference in mod_wsgi authentication handler. + - **CVE-2025-14550** (moderate): Potential denial-of-service via repeated headers on ASGI requests. + - **CVE-2026-1207** (high): Potential SQL injection via raster lookups on PostGIS. + - **CVE-2026-1285** (moderate): Potential denial-of-service in `django.utils.text.Truncator` HTML methods via inputs with large numbers of unmatched HTML end tags. + - **CVE-2026-1287** (high): Potential SQL injection in column aliases via control characters in `FilteredRelation`. + - **CVE-2026-1312** (high): Potential SQL injection via `QuerySet.order_by()` and `FilteredRelation` when using column aliases containing periods. +- Updated frontend npm dependencies to resolve 5 audit vulnerabilities (1 moderate, 4 high): + - Updated `ajv` 6.12.6 → 6.14.0, resolving a **moderate** ReDoS vulnerability when using the `$data` option ([GHSA-2g4f-4pwh-qvx6](https://github.com/advisories/GHSA-2g4f-4pwh-qvx6)) + - Enforced `minimatch` ≥10.2.2 via npm overrides, resolving **high** ReDoS via repeated wildcards with non-matching literal patterns ([GHSA-3ppc-4f35-3m26](https://github.com/advisories/GHSA-3ppc-4f35-3m26)) affecting `minimatch`, `@eslint/config-array`, `@eslint/eslintrc`, and `eslint` + +### Added + +- API key authentication: Added support for API key-based authentication as an alternative to JWT tokens. Users can generate and revoke their own personal API key from their profile page, enabling programmatic access for scripts, automations, and third-party integrations without exposing account credentials. Keys authenticate via the `Authorization: ApiKey ` header or the `X-API-Key: ` header. Admin users can additionally generate and revoke keys on behalf of any user. +- Lightweight channel summary API endpoint: Added a new `/api/channels/summary/` endpoint that returns only the minimal channel data needed for TV Guide and DVR scheduling (id, name, logo), avoiding the overhead of serializing full channel objects for high-frequency UI operations. +- Custom Dummy EPG subtitle template support: Added optional subtitle template field to custom dummy EPG configuration. Users can now define subtitle patterns using extracted regex groups and time/date placeholders (e.g., `{starttime} - {endtime}`). (Closes #942) +- Event-driven webhooks and script execution (Integrations): Added new Integrations feature that enables event-driven execution of custom scripts and webhooks in response to system events. (Closes #203) + - **Supported event types**: channel lifecycle (start, stop, reconnect, error, failover), stream operations (switch), recording events (start, end), data refreshes (EPG, M3U), and client activity (connect, disconnect) + - **Event data delivery**: available as environment variables in scripts (prefixed with `DISPATCHARR_`), POST payloads for webhooks, and plugin execution payloads + - **Plugin support**: plugins can subscribe to events by specifying an `events` array in their action definitions + - **Connection testing**: test endpoint with dummy payloads for validation before going live + - **Custom HTTP headers**: webhook connections support configurable key/value header pairs + - **Per-event Jinja2 payload templates**: each enabled event can have its own template rendered with the full event payload as context; rendered output is sent as JSON (with `Content-Type: application/json` set automatically) if valid, or as a raw string body otherwise + - **Tabbed connection form**: Settings, Event Triggers, and Payload Templates organized into separate tabs for clarity +- Cron scheduling support for M3U and EPG refreshes: Added interactive cron expression builder with preset buttons and custom field editors, plus info popover with common cron examples. Refactored backup scheduling to use shared ScheduleInput component for consistency across all scheduling interfaces. (Closes #165) +- Channel numbering modes for auto channel sync: Added three channel numbering modes when auto-syncing channels from M3U groups: + - **Fixed Start Number** (default): Start at a specified number and increment sequentially + - **Use Provider Number**: Use channel numbers from the M3U source (tvg-chno), with configurable fallback if provider number is missing + - **Next Available**: Auto-assign starting from 1, skipping all used channel numbers + Each mode includes its own configuration options accessible via the "Channel Numbering Mode" dropdown in auto sync settings. (Closes #956, #433) +- Legacy NumPy for modular Docker: Added entrypoint detection and automatic installation for the Celery container (use `USE_LEGACY_NUMPY`) to support older CPUs. - Thanks [@patrickjmcd](https://github.com/patrickjmcd) +- `series_relation` foreign key on `M3UEpisodeRelation`: episode relations now carry a direct FK to their parent `M3USeriesRelation`. This enables correct CASCADE deletion (removing a series relation automatically removes its episode relations), precise per-provider scoping during stale-stream cleanup. +- Streamer accounts attempting to log into the web UI now receive a clear notification explaining they cannot access the UI but their stream URLs still work. Previously the login button would silently stop with no feedback. + +### Changed + +- Dependency updates: + - `Django` 5.2.9 → 5.2.11 (security patch; see Security section) + - `celery` 5.6.0 → 5.6.2 + - `psutil` 7.1.3 → 7.2.2 + - `torch` 2.9.1+cpu → 2.10.0+cpu + - `sentence-transformers` 5.2.0 → 5.2.3 + - `ajv` 6.12.6 → 6.14.0 (security patch; see Security section) + - `minimatch` enforced ≥10.2.2 via npm overrides (security patch; see Security section) + - `react` / `react-dom` 19.2.3 → 19.2.4 + - `react-router-dom` / `react-router` 7.12.0 → 7.13.0 + - `react-hook-form` 7.70.0 → 7.71.2 + - `react-draggable` 4.4.6 → 4.5.0 + - `@tanstack/react-table` 8.21.2 → 8.21.3 + - `video.js` 8.23.4 → 8.23.7 + - `vite` 7.3.0 → 7.3.1 + - `zustand` 5.0.9 → 5.0.11 + - `allotment` 1.20.4 → 1.20.5 + - `prettier` 3.7.4 → 3.8.1 + - `@swc/wasm` 1.15.7 → 1.15.11 + - `@testing-library/react` 16.3.1 → 16.3.2 + - `@types/react` 19.2.7 → 19.2.14 + - `@vitejs/plugin-react-swc` 4.2.2 → 4.2.3 +- Channel store optimization: Refactored frontend channel loading to only fetch channel IDs on initial login (matching the streams store pattern), instead of loading full channel objects upfront. Full channel data is fetched lazily as needed. This dramatically reduces login time and initial page load when large channel libraries are present. +- DVR scheduling: Channel selector now displays the channel number alongside the channel name when scheduling a recording. +- TV Guide performance improvements: Optimized the TV Guide with horizontal culling for off-screen program rows (only rendering visible programs), throttled now-line position updates, and improved scroll performance. Reduces unnecessary DOM work and improves responsiveness with large EPG datasets. +- Stream Profile form rework: Replaced the plain command text field with a dropdown listing built-in tools (FFmpeg, Streamlink, VLC, yt-dlp) plus a Custom option that reveals a free-text input. Each built-in now shows its default parameter string as a live example in the Parameters field description, updating as the command selection changes. Added descriptive help text to all fields to improve clarity. +- Custom Dummy EPG form UI improvements: Reorganized the form into collapsible accordion sections (Pattern Configuration, Output Templates, Upcoming/Ended Templates, Fallback Templates, EPG Settings) for better organization. Field descriptions now appear in info icon popovers instead of taking up vertical space, making the form more compact and easier to navigate while keeping help text accessible. +- XC API M3U stream URLs: M3U generation for Xtream Codes API endpoints now use proper XC-style stream URLs (`/live/username/password/channel_id`) instead of UUID-based stream endpoints, ensuring full compatibility with XC clients. (Fixes #839) +- XC API `get_series` now includes `tmdb_id` and `imdb_id` fields, matching `get_vod_streams`. Clients that use TMDB enrichment (e.g. Chillio) can now resolve clean series titles and poster images. - Thanks [@firestaerter3](https://github.com/firestaerter3) +- Stats page "Now Playing" EPG lookup updated to use `channel_uuids` directly (the proxy stats already key active channels by UUID), removing the need for a UUID→integer ID conversion step introduced alongside the lazy channel-fetch refactor. Stream preview sessions (which use a content hash rather than a UUID as their channel ID) are now filtered out before any API call is made, preventing a backend `ValidationError` on both the `current-programs` and `by-uuids` endpoints when a stream preview is active on the Stats page. + +### Fixed + +- Fixed admin permission checks inconsistently using `is_superuser`/`is_staff` instead of `user_level>=10`, causing API-created admin accounts to intermittently see the setup page, lose access to backup endpoints, and miss admin-only notifications. `manage.py createsuperuser` now also correctly sets `user_level=10`. (Fixes #954) - Thanks [@CodeBormen](https://github.com/CodeBormen) +- Channel table group filter sort order: The group dropdown in the channel table is now sorted alphabetically. +- DVR one-time recording scheduling: Fixed a bug where scheduling a one-time recording for a future program caused the recording to start immediately instead of at the scheduled time. +- XC API `added` field type inconsistencies: `get_live_streams` and `get_vod_info` now return the `added` field as a string (e.g., `"1708300800"`) instead of an integer, fixing compatibility with XC clients that have strict JSON serializers (such as Jellyfin's Xtream Library plugin). (Closes #978) +- Stream Profile form User-Agent not populating when editing: The User-Agent field was not correctly loaded from the existing profile when opening the edit modal. (Fixes #650) +- VOD proxy connection counter leak on client disconnect: Fixed a connection leak in the VOD proxy where connection counters were not properly decremented when clients disconnected, causing the connection pool to lose track of available connections. The multi-worker connection manager now correctly handles client disconnection events across all proxy configurations. Includes three key fixes: (1) Replaced GET-check-INCR race condition with atomic INCR-first-then-check pattern in both connection managers to prevent concurrent requests exceeding max_streams; (2) Decrement profile counter directly in stream generator exit paths instead of deferring to daemon thread cleanup; (3) Decrement profile counter on create_connection() failure to release reserved slots. (Fixes #962, #971, #451, #533) - Thanks [@CodeBormen](https://github.com/CodeBormen) +- XC profile refresh credential extraction with sub-paths: Fixed credential extraction in `get_transformed_credentials()` to use negative indices anchored to the known tail structure instead of hardcoded indices that broke when server URLs contained sub-paths (e.g., `http://server.com/portal/a/`). This ensures XC accounts with sub-paths in their server URLs work correctly for profile refreshes. (Fixes #945) - Thanks [@CodeBormen](https://github.com/CodeBormen) +- XC EPG URL construction for accounts with sub-paths or trailing slashes: Fixed EPG URL construction in M3U forms to normalize server URL to origin before appending `xmltv.php` endpoint, preventing double slashes and incorrect path placement when server URLs include sub-paths or trailing slashes. (Fixes #800) - Thanks [@CodeBormen](https://github.com/CodeBormen) +- Auto channel sync duplicate channel numbers across groups: Fixed issue where multiple auto-sync groups starting at the same number would create duplicate channel numbers. The used channel number tracking now persists across all groups in a single sync operation, ensuring each assigned channel number is globally unique. +- Modular mode PostgreSQL/Redis connection checks: Replaced raw Python socket checks with native tools (`pg_isready` for PostgreSQL and `socket.create_connection` for Redis) in modular deployment mode to prevent indefinite hangs in Docker environments with non-standard networking or DNS configurations. Now properly supports IPv4 and IPv6 configurations. (Fixes #952) - Thanks [@CodeBormen](https://github.com/CodeBormen) +- VOD episode UUID regeneration on every refresh: a pre-emptive `Episode.objects.delete()` in `refresh_series_episodes` ran before `batch_process_episodes`, defeating its update-in-place logic and forcing all episodes to be recreated with new UUIDs on every refresh. Clients (Jellyfin, Emby, Plex, etc.) with cached episode paths received 500 errors until a full library rescan. Removing the delete allows episodes to be updated in place with stable UUIDs. (Fixes #785, #985, #820) - Thanks [@znake-oil](https://github.com/znake-oil) +- VOD stale episode stream cleanup scoped incorrectly per provider: when a provider removed a stream from a series, `batch_process_episodes` could delete episode relations belonging to a different provider version of the same series (e.g. EN vs ES) that had deduped to the same `Series` object via TMDB/IMDB ID. Cleanup is now scoped to the specific `M3USeriesRelation` that was queried. + +## [0.19.0] - 2026-02-10 + +### Added + +- Add system notifications and update checks + -Real-time notifications for system events and alerts + -Per-user notification management and dismissal + -Update check on startup and every 24 hours to notify users of available versions + -Notification center UI component + -Automatic cleanup of expired notifications +- Network Access "Reset to Defaults" button: Added a "Reset to Defaults" button to the Network Access settings form, matching the functionality in Proxy Settings. Users can now quickly restore recommended network access settings with one click. +- Streams table column visibility toggle: Added column menu to Streams table header allowing users to show/hide optional columns (TVG-ID, Stats) based on preference, with optional columns hidden by default for cleaner default view. +- Streams table TVG-ID column with search filter and sort: Added TVG-ID column to streams table with search filtering and sort capability for better stream organization. (Closes #866) - Thanks [@CodeBormen](https://github.com/CodeBormen) +- Frontend now automatically refreshes streams and channels after a stream rehash completes, ensuring the UI is always up-to-date following backend merge operations. +- Frontend Unit Tests: Added comprehensive unit tests for React hooks and Zustand stores, including: + - `useLocalStorage` hook tests with localStorage mocking and error handling + - `useSmartLogos` hook tests for logo loading and management + - `useTablePreferences` hook tests for table settings persistence + - `useAuthStore` tests for authentication flow and token management + - `useChannelsStore` tests for channel data management + - `useUserAgentsStore` tests for user agent CRUD operations + - `useUsersStore` tests for user management functionality + - `useVODLogosStore` tests for VOD logo operations + - `useVideoStore` tests for video player state management + - `useWarningsStore` tests for warning suppression functionality + - Code refactoring for improved readability and maintainability - Thanks [@nick4810](https://github.com/nick4810) +- EPG auto-matching: Added advanced options to strip prefixes, suffixes, and custom text from channel names to assist matching; default matching behavior and settings remain unchanged (Closes #771) - Thanks [@CodeBormen](https://github.com/CodeBormen) +- Redis authentication support for modular deployments: Added support for authentication when connecting to external Redis instances using either password-only authentication (Redis <6) or username + password authentication (Redis 6+ ACL). REDIS_PASSWORD and REDIS_USER environment variables with URL encoding for special characters. (Closes #937) - Thanks [@CodeBormen](https://github.com/CodeBormen) +- Plugin logos: if a plugin ZIP includes `logo.png`, it is surfaced in the Plugins UI and shown next to the plugin name. +- Plugin manifests (`plugin.json`) for safe metadata discovery, plus legacy warnings and folder-name fallbacks when a manifest is missing. +- Plugin stop hooks: Dispatcharr now calls a plugin's optional `stop()` method (or `run("stop")` action) when disabling, deleting, or reloading plugins to allow graceful shutdown. +- Plugin action buttons can define `button_label`, `button_variant`, and `button_color` (e.g., Stop in red), falling back to “Run” for older plugins. +- Plugin card metadata: plugins can specify `author` and `help_url` in `plugin.json` to show author and docs link in the UI. +- Plugin cards can now be expanded/collapsed by clicking the header or chevron to hide settings and actions. + +### Changed + +- XtreamCodes Authentication Optimization: Reduced API calls during XC refresh by 50% by eliminating redundant authentication step. This should help reduce rate-limiting errors. +- App initialization efficiency: Refactored app initialization to prevent redundant execution across multiple worker processes. Created `dispatcharr.app_initialization` utility module with `should_skip_initialization()` function that prevents custom initialization tasks (backup scheduler sync, developer notifications sync) from running during management commands, in worker processes, or in development servers. This significantly reduces startup overhead in multi-worker deployments (e.g., uWSGI with 10 workers now syncs the scheduler once instead of 10 times). Applied to both `CoreConfig` and `BackupsConfig` apps. +- M3U/EPG Network Access Defaults: Updated default network access settings for M3U and EPG endpoints to only allow local/private networks by default (127.0.0.0/8, 10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16, ::1/128, fc00::/7, fe80::/10). This improves security by preventing public internet access to these endpoints unless explicitly configured. Other endpoints (Streams, XC API, UI) remain open by default. +- Modular deployments: Bumped modular Postgres image to 17 and added compatibility checks (PostgreSQL version and UTF-8 database encoding) when using external databases to prevent migration/encoding issues. +- Stream Identity Stability: Added `stream_id` (provider stream identifier) and `stream_chno` (provider channel number) fields to Stream model. For XC accounts, the stream hash now uses the stable `stream_id` instead of the URL when hashing, ensuring XC streams maintain their identity and channel associations even when account credentials or server URLs change. Supports both XC `num` and M3U `tvg-chno`/`channel-number` attributes. +- Swagger/OpenAPI Migration: Migrated from `drf-yasg` (OpenAPI 2.0) to `drf-spectacular` (OpenAPI 3.0) for API documentation. This provides: + - Native Bearer token authentication support in Swagger UI - users can now enter just the JWT token and the "Bearer " prefix is automatically added + - Modern OpenAPI 3.0 specification compliance + - Better auto-generation of request/response schemas + - Improved documentation accuracy with serializer introspection +- Switched to uv for package management: Migrated from pip to uv (Astral's fast Python package installer) for improved dependency resolution speed and reliability. This includes updates to Docker build processes, installation scripts (debian_install.sh), and project configuration (pyproject.toml) to leverage uv's features like virtual environment management and lockfile generation. - Thanks [@tobimichael96](https://github.com/tobimichael96) for getting it started! +- Copy to Clipboard: Refactored `copyToClipboard` utility function to include notification handling internally, eliminating duplicate notification code across the frontend. The function now accepts optional parameters for customizing success/failure messages while providing consistent behavior across all copy operations. + +### Fixed + +- XC EPG Logic: Fixed EPG filtering issues where short EPG requests had no time-based filtering (returning expired programs) and regular EPG requests used `start_time__gte` (missing the currently playing program). Both now correctly use `end_time__gt` to show programs that haven't ended yet, with short EPG additionally limiting results. (Fixes #915) +- Automatic backups not enabled by default on new installations: Added backups app to `INSTALLED_APPS` and implemented automatic scheduler initialization in `BackupsConfig.ready()`. The backup scheduler now properly syncs the periodic task on startup, ensuring automatic daily backups are enabled and scheduled immediately on fresh database creation without requiring manual user intervention. +- Fixed modular Docker Compose deployment and entrypoint/init scripts to properly support `DISPATCHARR_ENV=modular`, use external PostgreSQL/Redis services, and handle port, version, and encoding validation (Closes #324, Fixes #61, #445, #731) - Thanks [@CodeBormen](https://github.com/CodeBormen) +- Stream rehash/merge logic now guarantees unique stream_hash and always preserves the stream with the best channel ordering and relationships. This prevents duplicate key errors and ensures the correct stream is retained when merging. (Fixes #892) +- Admin URL Conflict with XC Streams: Updated nginx configuration to only redirect exact `/admin` and `/admin/` paths to login in production, preventing interference with stream URLs that use "admin" as a username (e.g., `/admin/password/stream_id` now properly routes to stream handling instead of being redirected). +- EPG Channel ID XML Escaping: Fixed XML parsing errors in EPG output when channel IDs contain special characters (&, <, >, \") by properly escaping them in XML attributes. (Fixes #765) - Thanks [@CodeBormen](https://github.com/CodeBormen) +- Fixed NumPy baseline detection in Docker entrypoint. Now properly detects when NumPy crashes on import due to CPU baseline incompatibility and installs legacy NumPy version. Previously, if NumPy failed to import, the script would skip legacy installation assuming it was already compatible. +- Backup Scheduler Test: Fixed test to correctly validate that automatic backups are enabled by default with a retention count of 3, matching the actual scheduler defaults. - Thanks [@jcasimir](https://github.com/jcasimir) +- Hardened plugin loading to avoid executing plugin code unless the plugin is enabled. +- Prevented plugin package names from shadowing standard library or installed modules by namespacing plugin imports with safe aliases. +- Added safety limits to plugin ZIP imports (file count and size caps) and sanitized plugin keys derived from uploads. +- Enforced strict boolean parsing for plugin enable/disable requests to avoid accidental enables from truthy strings. +- Applied plugin field defaults server-side when running actions so plugins receive expected settings even before a user saves. +- Plugin settings UI improvements: render `info`/`text` fields, support `input_type: password`, show descriptions/placeholders, surface save failures, and keep settings in sync after refresh. +- Disabled plugins now collapse settings/actions to match the closed state before first enable. +- Plugin card header controls (delete/version/toggle) now stay right-aligned even with long descriptions. +- Improved plugin logo resolution (case-insensitive paths + absolute URLs), fixing dev UI logo loading without a Vite proxy. +- Plugin reload now hits the backend, clears module caches across workers, and refreshes the UI so code changes apply without a full backend restart. +- Plugin loader now supports `plugin.py` without `__init__.py`, including folders with non-identifier names, by loading modules directly from file paths. +- Plugin action handling stabilized: avoids registry race conditions and only shows loading on the active action. +- Plugin enable/disable toggles now update immediately without requiring a full page refresh. +- M3U/EPG tasks downloading endlessly for large files: Fixed the root cause where the Redis task lock (300s TTL) expired during long downloads, allowing Celery Beat to start competing duplicate tasks that never completed. Added a `TaskLockRenewer` daemon thread that periodically extends the lock TTL while a task is actively working, applied to all long-running task paths (M3U refresh, M3U group refresh, EPG refresh, EPG program parsing). Also adds an HTTP timeout to M3U download requests, streams M3U downloads directly to a temp file on disk instead of accumulating the entire file in memory, and adds Celery task time limits as a safety net against runaway tasks. (Fixes #861) - Thanks [@CodeBormen](https://github.com/CodeBormen) + +## [0.18.1] - 2026-01-27 + +### Fixed + +- Series Rules API Swagger Documentation: Fixed drf_yasg validation error where TYPE_ARRAY schemas were missing required items parameter, causing module import failure + +## [0.18.0] - 2026-01-27 + +### Security + +- Updated react-router from 7.11.0 to 7.12.0 to address two security vulnerabilities: + - **High**: Open Redirect XSS vulnerability in Action/Server Action Request Processing ([GHSA-h5cw-625j-3rxh](https://github.com/advisories/GHSA-h5cw-625j-3rxh), [GHSA-2w69-qvjg-hvjx](https://github.com/advisories/GHSA-2w69-qvjg-hvjx)) + - **Moderate**: SSR XSS vulnerability in ScrollRestoration component ([GHSA-8v8x-cx79-35w7](https://github.com/advisories/GHSA-8v8x-cx79-35w7)) +- Updated react-router-dom from 7.11.0 to 7.12.0 (dependency of react-router) +- Fixed moderate severity Prototype Pollution vulnerability in Lodash (`_.unset` and `_.omit` functions) See [GHSA-xxjr-mmjv-4gpg](https://github.com/advisories/GHSA-xxjr-mmjv-4gpg) for details. + +### Added + +- Series Rules API Swagger Documentation: Added comprehensive Swagger/OpenAPI documentation for all series-rules endpoints (`GET /series-rules/`, `POST /series-rules/`, `DELETE /series-rules/{tvg_id}/`, `POST /series-rules/evaluate/`, `POST /series-rules/bulk-remove/`), including detailed descriptions, request/response schemas, and error handling information for improved API discoverability +- Editable Channel Table Mode: + - Added a robust inline editing mode for the channels table, allowing users to quickly edit channel fields (name, number, group, EPG, logo) directly in the table without opening a modal. + - EPG and logo columns support searchable dropdowns with instant filtering and keyboard navigation for fast assignment. + - Drag-and-drop reordering of channels enabled when unlocked, with persistent order updates. (Closes #333) + - Group column uses a searchable dropdown for quick group assignment, matching the UX of EPG and logo selectors. + - All changes are saved via API with optimistic UI updates and error handling. +- Stats page enhancements: Added "Now Playing" program information for active streams with smart polling that only fetches EPG data when programs are about to change (not on every stats refresh). Features include: + - Currently playing program title displayed with live broadcast indicator (green Radio icon) + - Expandable program descriptions via chevron button + - Progress bar showing elapsed and remaining time for currently playing programs + - Efficient POST-based API endpoint (`/api/epg/current-programs/`) supporting batch channel queries or fetching all channels + - Smart scheduling that fetches new program data 5 seconds after current program ends + - Only polls when active channel list changes, not on stats refresh +- Channel preview button: Added preview functionality to active stream cards on stats page +- Unassociated streams filter: Added "Only Unassociated" filter option to streams table for quickly finding streams not assigned to any channels - Thanks [@JeffreyBytes](https://github.com/JeffreyBytes) (Closes #667) +- Streams table: Added "Hide Stale" filter to quickly hide streams marked as stale. +- Client-side logo caching: Added `Cache-Control` and `Last-Modified` headers to logo responses, enabling browsers to cache logos locally for 4 hours (local files) and respecting upstream cache headers (remote logos). This reduces network traffic and nginx load while providing faster page loads through browser-level caching that complements the existing nginx server-side cache - Thanks [@DawtCom](https://github.com/DawtCom) +- DVR recording remux fallback strategy: Implemented two-stage TS→MP4→MKV fallback when direct TS→MKV conversion fails due to timestamp issues. On remux failure, system now attempts TS→MP4 conversion (MP4 container handles broken timestamps better) followed by MP4→MKV conversion, automatically recovering from provider timestamp corruption. Failed conversions now properly clean up partial files and preserve source TS for manual recovery. +- Mature content filtering support: + - Added `is_adult` boolean field to both Stream and Channel models with database indexing for efficient filtering and sorting + - Automatically populated during M3U/XC refresh operations by extracting `is_adult` value from provider data + - Type-safe conversion supporting both integer (0/1) and string ("0"/"1") formats from different providers + - UI controls in channel edit form (Switch with tooltip) and bulk edit form (Select dropdown) for easy management + - XtreamCodes API support with proper integer formatting (0/1) in live stream responses + - Automatic propagation from streams to channels during both single and bulk channel creation operations + - Included in serializers for full API support + - User-level content filtering: Non-admin users can opt to hide mature content channels across all interfaces (web UI, M3U playlists, EPG data, XtreamCodes API) via "Hide Mature Content" toggle in user settings (stored in custom_properties, admin users always see all content) +- Table header pin toggle: Pin/unpin table headers to keep them visible while scrolling. Toggle available in channel table menu and UI Settings page. Setting persists across sessions and applies to all tables. (Closes #663) +- Cascading filters for streams table: Improved filter usability with hierarchical M3U and Group dropdowns. M3U acts as the parent filter showing only active/enabled accounts, while Group options dynamically update to display only groups available in the selected M3U(s). Only enabled M3U's are displayed. (Closes #647) +- Streams table UI: Added descriptive tooltips to top-toolbar buttons (Add to Channel, Create Channels, Filters, Create Stream, Delete) and to row action icons (Add to Channel, Create New Channel). Tooltips now use a 500ms open delay for consistent behavior with existing table header tooltips. + +### Changed + +- Data loading and initialization refactor: Major performance improvement reducing initial page load time by eliminating duplicate API requests caused by race conditions between authentication flow and route rendering: + - Fixed authentication race condition where `isAuthenticated` was set before data loading completed, causing routes to render and tables to mount prematurely + - Added `isInitialized` flag to delay route rendering until after all initialization data is loaded via `initData()` + - Consolidated version and environment settings fetching into centralized settings store with caching to prevent redundant calls + - Implemented stale fetch prevention in ChannelsTable and StreamsTable using fetch version tracking to ignore outdated responses + - Fixed filter handling in tables to use `debouncedFilters` consistently, preventing unnecessary refetches + - Added initialization guards using refs to prevent double-execution of auth and superuser checks during React StrictMode's intentional double-rendering in development + - Removed duplicate version/environment fetch calls from Sidebar, LoginForm, and SuperuserForm by using centralized store +- Table preferences (header pin and table size) now managed together with centralized state management and localStorage persistence. +- Streams table button labels: Renamed "Remove" to "Delete" and "Add Stream to Channel" to "Add to Channel" for clarity and consistency with other UI terminology. +- 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. +- Table preferences architecture refactored: Migrated `table-size` preference from individual `useLocalStorage` calls to centralized `useTablePreferences` hook. All table components now read preferences from the table instance (`table.tableSize`, `.g maintainability and providing consistent API across all tables. +- Optimized unassociated streams filter performance: Replaced inefficient reverse foreign key NULL check (`channels__isnull=True`) with Count annotation approach, reducing query time from 4-5 seconds to under 500ms for large datasets (75k+ streams) + +### Fixed + +- Channels table pagination error handling: Fixed "Invalid page" error notifications that appeared when filters reduced the result set while on a page beyond the new total. The API layer now automatically detects invalid page errors, resets pagination to page 1, and retries the request transparently. (Fixes #864) +- Fixed long IP addresses overlapping adjacent columns in stream connection card by adding truncation with tooltips displaying the full address. (Fixes #712) +- Fixed nginx startup failure due to group name mismatch in non-container deployments - Thanks [@s0len](https://github.com/s0len) (Fixes #877) +- Updated streamlink from 8.1.0 to 8.1.2 to fix YouTube live stream playback issues and improve Pluto TV ad detection (Fixes #869) +- Fixed date/time formatting across all tables to respect user's UI preferences (time format and date format) set in Settings page: + - Stream connection card "Connected" column + - VOD connection card "Connection Start Time" column + - M3U table "Updated" column + - EPG table "Updated" column + - Users table "Last Login" and "Date Joined" columns + - All components now use centralized `format()` helper from dateTimeUtils for consistency +- Removed unused imports from table components for cleaner code +- Fixed build-dev.sh script stability: Resolved Dockerfile and build context paths to be relative to script location for reliable execution from any working directory, added proper --platform argument handling with array-safe quoting, and corrected push behavior to honor -p flag with accurate messaging. Improved formatting and quoting throughout to prevent word-splitting issues - Thanks [@JeffreyBytes](https://github.com/JeffreyBytes) +- Fixed TypeError on streams table load after container restart: Added robust data validation and type coercion to handle malformed filter options during container startup. The streams table MultiSelect components now safely convert group names to strings and filter out null/undefined values, preventing "right-hand side of 'in' should be an object, got number" errors when the backend hasn't fully initialized. API error handling returns safe defaults. +- Fixed XtreamCodes API crash when channels have NULL channel_group: The `player_api.php` endpoint (`xc_get_live_streams`) now gracefully handles channels without an assigned channel_group by dynamically looking up and assigning them to "Default Group" instead of crashing with AttributeError. Additionally, the Channel serializer now auto-assigns new channels to "Default Group" when `channel_group_id` is omitted during creation, preventing future NULL channel_group issues. +- Fixed streams table column header overflow: Implemented fixed-height column headers (30px max-height) with pill-style filter display showing first selection plus count (e.g., "Sport +3"). Prevents header expansion when multiple filters are selected, maintaining compact table layout. (Fixes #613) +- Fixed VOD logo cleanup button count: The "Cleanup Unused" button now displays the total count of all unused logos across all pages instead of only counting unused logos on the current page. +- Fixed VOD refresh failures when logos are deleted: Changed logo comparisons to use `logo_id` (raw FK integer) instead of `logo` (related object) to avoid Django's lazy loading, which triggers a database fetch that fails if the referenced logo no longer exists. Also improved orphaned logo detection to properly clear stale references when logo URLs exist but logos are missing from the database. +- Fixed channel profile filtering to properly restrict content based on assigned channel profiles for all non-admin users (user_level < 10) instead of only streamers (user_level == 0). This corrects the XtreamCodes API endpoints (`get_live_categories` and `get_live_streams`) along with M3U and EPG generation, ensuring standard users (level 1) are properly restricted by their assigned channel profiles. Previously, "Standard" users with channel profiles assigned would see all channels instead of only those in their assigned profiles. +- Fixed NumPy baseline detection in Docker entrypoint. Now calls `numpy.show_config()` directly with case-insensitive grep instead of incorrectly wrapping the output. +- 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. + +## [0.17.0] - 2026-01-13 + +### Added + +- Added tooltip on filter pills showing all selected items in a vertical list (up to 10 items, with "+N more" indicator) +- 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. +- Channel profile edit and duplicate functionality: 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. +- ProfileModal component extracted for improved code organization and maintainability of channel profile management operations. +- Frontend unit tests for pages and utilities: Added comprehensive unit test coverage for frontend components within pages/ and JS files within utils/, along with a GitHub Actions workflow (`frontend-tests.yml`) to automatically run tests on commits and pull requests - Thanks [@nick4810](https://github.com/nick4810) +- 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. +- Channel profile selection in creation modal: Users can now choose which profiles to add channels to when creating channels from streams (both single and bulk operations). Options include adding 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. +- Group retention policy for M3U accounts: 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) +- 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. + +### Changed + +- Settings architecture refactored to use grouped JSON storage: Migrated from individual CharField settings to grouped JSONField settings for improved performance, maintainability, and type safety. Settings are now organized into logical groups (stream_settings, dvr_settings, backup_settings, system_settings, proxy_settings, network_access) with automatic migration handling. Backend provides helper methods (`get_stream_settings()`, `get_default_user_agent_id()`, etc.) for easy access. Frontend simplified by removing complex key mapping logic and standardizing on underscore-based field names throughout. +- Docker setup enhanced for legacy CPU support: Added `USE_LEGACY_NUMPY` environment variable to enable custom-built NumPy with no CPU baseline, allowing Dispatcharr to run on older CPUs (circa 2009) that lack support for newer baseline CPU features. When set to `true`, the entrypoint script will install the legacy NumPy build instead of the standard distribution. (Fixes #805) +- VOD upstream read timeout reduced from 30 seconds to 10 seconds to minimize lock hold time when clients disconnect during connection phase +- Form management refactored across application: Migrated Channel, Stream, M3U Profile, Stream Profile, Logo, and User Agent forms from Formik to React Hook Form (RHF) with Yup validation for improved form handling, better validation feedback, and enhanced code maintainability +- Stats and VOD pages refactored for clearer separation of concerns: extracted Stream/VOD connection cards (StreamConnectionCard, VodConnectionCard, VODCard, SeriesCard), moved page logic into dedicated utils, and lazy-loaded heavy components with ErrorBoundary fallbacks to improve readability and maintainability - Thanks [@nick4810](https://github.com/nick4810) +- Channel creation modal refactored: Extracted and unified channel numbering dialogs from StreamsTable into a dedicated CreateChannelModal component that handles both single and bulk channel creation with cleaner, more maintainable implementation and integrated profile selection controls. + +### Fixed + +- 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. +- Fixed bulk channel edit endpoint crashing with `ValueError: Field names must be given to bulk_update()` when the first channel in the update list had no actual field changes. The endpoint now collects all unique field names from all channels being updated instead of only looking at the first channel, properly handling cases where different channels update different fields or when some channels have no changes - Thanks [@mdellavo](https://github.com/mdellavo) (Fixes #804) +- 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. +- 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. +- Fixed stream profile parameters not properly handling quoted arguments. Switched from basic `.split()` to `shlex.split()` for parsing command-line parameters, allowing proper handling of multi-word arguments in quotes (e.g., OAuth tokens in HTTP headers like `"--twitch-api-header=Authorization=OAuth token123"`). This ensures external streaming tools like Streamlink and FFmpeg receive correctly formatted arguments when using stream profiles with complex parameters - Thanks [@justinforlenza](https://github.com/justinforlenza) (Fixes #833) +- 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. +- 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) +- 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. +- Fixed VOD streams disappearing from stats page during playback by adding `socket-timeout = 600` to production uWSGI config. The missing directive caused uWSGI to use its default 4-second timeout, triggering premature cleanup when clients buffered content. Now matches the existing `http-timeout = 600` value and prevents timeout errors during normal client buffering - Thanks [@patchy8736](https://github.com/patchy8736) +- 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) +- 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 +- Fixed React warning in Channel form by removing invalid `removeTrailingZeros` prop from NumberInput component +- 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. +- 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. +- Fixed `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) +- 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. Additionally 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) + +## [0.16.0] - 2026-01-04 + +### Added + +- Advanced filtering for Channels table: Filter menu now allows toggling disabled channels visibility (when a profile is selected) and filtering to show only empty channels without streams (Closes #182) +- Network Access warning modal now displays the client's IP address for better transparency when network restrictions are being enforced - Thanks [@damien-alt-sudo](https://github.com/damien-alt-sudo) (Closes #778) +- VLC streaming support - Thanks [@sethwv](https://github.com/sethwv) + - Added `cvlc` as an alternative streaming backend alongside FFmpeg and Streamlink + - Log parser refactoring: Introduced `LogParserFactory` and stream-specific parsers (`FFmpegLogParser`, `VLCLogParser`, `StreamlinkLogParser`) to enable codec and resolution detection from multiple streaming tools + - VLC log parsing for stream information: Detects video/audio codecs from TS demux output, supports both stream-copy and transcode modes with resolution/FPS extraction from transcode output + - Locked, read-only VLC stream profile configured for headless operation with intelligent audio/video codec detection + - VLC and required plugins installed in Docker environment with headless configuration +- ErrorBoundary component for handling frontend errors gracefully with generic error message - Thanks [@nick4810](https://github.com/nick4810) + +### Changed + +- Fixed event viewer arrow direction (previously inverted) — UI behavior corrected. - Thanks [@drnikcuk](https://github.com/drnikcuk) (Closes #772) +- Region code options now intentionally include both `GB` (ISO 3166-1 standard) and `UK` (commonly used by EPG/XMLTV providers) to accommodate real-world EPG data variations. Many providers use `UK` in channel identifiers (e.g., `BBCOne.uk`) despite `GB` being the official ISO country code. Users should select the region code that matches their specific EPG provider's convention for optimal region-based EPG matching bonuses - Thanks [@bigpandaaaa](https://github.com/bigpandaaaa) +- 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 +- Stream log parsing refactored to use factory pattern: Simplified `ChannelService.parse_and_store_stream_info()` to route parsing through specialized log parsers instead of inline program-specific logic (~150 lines of code removed) +- Stream profile names in fixtures updated to use proper capitalization (ffmpeg → FFmpeg, streamlink → Streamlink) +- Frontend component refactoring for improved code organization and maintainability - Thanks [@nick4810](https://github.com/nick4810) + - Extracted large nested components into separate files (RecordingCard, RecordingDetailsModal, RecurringRuleModal, RecordingSynopsis, GuideRow, HourTimeline, PluginCard, ProgramRecordingModal, SeriesRecordingModal, Field) + - Moved business logic from components into dedicated utility files (dateTimeUtils, RecordingCardUtils, RecordingDetailsModalUtils, RecurringRuleModalUtils, DVRUtils, guideUtils, PluginsUtils, PluginCardUtils, notificationUtils) + - Lazy loaded heavy components (SuperuserForm, RecordingDetailsModal, ProgramRecordingModal, SeriesRecordingModal, PluginCard) with loading fallbacks + - Removed unused Dashboard and Home pages + - Guide page refactoring: Extracted GuideRow and HourTimeline components, moved grid calculations and utility functions to guideUtils.js, added loading states for initial data fetching, improved performance through better memoization + - Plugins page refactoring: Extracted PluginCard and Field components, added Zustand store for plugin state management, improved plugin action confirmation handling, better separation of concerns between UI and business logic +- Logo loading optimization: Logos now load only after both Channels and Streams tables complete loading to prevent blocking initial page render, with rendering gated by table readiness to ensure data loads before visual elements +- 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 +- Settings and Logos page refactoring for improved readability and separation of concerns - Thanks [@nick4810](https://github.com/nick4810) + - Extracted individual settings forms (DVR, Network Access, Proxy, Stream, System, UI) into separate components with dedicated utility files + - Moved larger nested components into their own files + - Moved business logic into corresponding utils/ files + - Extracted larger in-line component logic into its own function + - Each panel in Settings now uses its own form state with the parent component handling active state management + +### Fixed + +- 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) +- VOD episode processing now properly handles season and episode numbers from APIs that return string values instead of integers, with comprehensive error logging to track data quality issues - Thanks [@patchy8736](https://github.com/patchy8736) (Fixes #770) +- VOD episode-to-stream relations are now validated to ensure episodes have been saved to the database before creating relations, preventing integrity errors when bulk_create operations encounter conflicts - Thanks [@patchy8736](https://github.com/patchy8736) +- VOD category filtering now correctly handles category names containing pipe "|" characters (e.g., "PL | BAJKI", "EN | MOVIES") by using `rsplit()` to split from the right instead of the left, ensuring the category type is correctly extracted as the last segment - Thanks [@Vitekant](https://github.com/Vitekant) +- 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 and EPG manager page no longer crashes when a playlist references a deleted channel group (Fixes screen blank on navigation) +- Stream validation now returns original URL instead of redirected URL to prevent issues with temporary redirect URLs that expire before clients can connect +- XtreamCodes EPG limit parameter now properly converted to integer to prevent type errors when accessing EPG listings (Fixes #781) +- Docker container file permissions: Django management commands (`migrate`, `collectstatic`) now run as the non-root user to prevent root-owned `__pycache__` and static files from causing permission issues - Thanks [@sethwv](https://github.com/sethwv) +- Stream validation now continues with GET request if HEAD request fails due to connection issues - Thanks [@kvnnap](https://github.com/kvnnap) (Fixes #782) +- 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) + +## [0.15.1] - 2025-12-22 + +### Fixed + +- XtreamCodes EPG `has_archive` field now returns integer `0` instead of string `"0"` for proper JSON type consistency +- nginx now gracefully handles hosts without IPv6 support by automatically disabling IPv6 binding at startup (Fixes #744) + +## [0.15.0] - 2025-12-20 + +### Added + +- VOD client stop button in Stats page: Users can now disconnect individual VOD clients from the Stats view, similar to the existing channel client disconnect functionality. +- Automated configuration backup/restore system with scheduled backups, retention policies, and async task processing - Thanks [@stlalpha](https://github.com/stlalpha) (Closes #153) +- 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 + +### Changed + +- Initial super user creation page now matches the login page design with logo, welcome message, divider, and version display for a more consistent and polished first-time setup experience +- Removed unreachable code path in m3u output - Thanks [@DawtCom](https://github.com/DawtCom) +- GitHub Actions workflows now use `docker/metadata-action` for cleaner and more maintainable OCI-compliant image label generation across all build pipelines (ci.yml, base-image.yml, release.yml). Labels are applied to both platform-specific images and multi-arch manifests with proper annotation formatting. - Thanks [@mrdynamo]https://github.com/mrdynamo) (Closes #724) +- Update docker/dev-build.sh to support private registries, multiple architectures and pushing. Now you can do things like `dev-build.sh -p -r my.private.registry -a linux/arm64,linux/amd64` - Thanks [@jdblack](https://github.com/jblack) +- 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) +- Frontend dependencies updated: Vite (6.2.0 → 7.1.7), ESLint (9.21.0 → 9.27.0), and related packages; added npm `overrides` to enforce js-yaml@^4.1.1 for transitive security fix. All 6 reported vulnerabilities resolved with `npm audit fix`. +- Floating video player now supports resizing via a drag handles, with minimum size enforcement and viewport/page boundary constraints to keep it visible. +- Redis connection settings now fully configurable via environment variables (`REDIS_HOST`, `REDIS_PORT`, `REDIS_DB`, `REDIS_URL`), replacing hardcoded `localhost:6379` values throughout the codebase. This enables use of external Redis services in production deployments. (Closes #762) +- Celery broker and result backend URLs now respect `REDIS_HOST`/`REDIS_PORT`/`REDIS_DB` settings as defaults, with `CELERY_BROKER_URL` and `CELERY_RESULT_BACKEND` environment variables available for override. + +### Fixed + +- 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) +- M3U Profile form now properly resets local state for search and replace patterns after saving, preventing validation errors when adding multiple profiles in a row +- DVR series rule deletion now properly handles TVG IDs that contain slashes by encoding them in the URL path (Fixes #697) +- VOD episode processing now correctly handles duplicate episodes (same episode in multiple languages/qualities) by reusing Episode records across multiple M3UEpisodeRelation entries instead of attempting to create duplicates (Fixes #556) +- XtreamCodes series streaming endpoint now correctly handles episodes with multiple streams (different languages/qualities) by selecting the best available stream based on account priority (Fixes #569) +- XtreamCodes series info API now returns unique episodes instead of duplicate entries when multiple streams exist for the same episode (different languages/qualities) +- nginx now gracefully handles hosts without IPv6 support by automatically disabling IPv6 binding at startup (Fixes #744) +- XtreamCodes EPG API now returns correct date/time format for start/end fields and proper string types for timestamps and channel_id +- XtreamCodes EPG API now handles None values for title and description fields to prevent AttributeError +- XtreamCodes EPG `id` field now provides unique identifiers per program listing instead of always returning "0" for better client EPG handling +- XtreamCodes EPG `epg_id` field now correctly returns the EPGData record ID (representing the EPG source/channel mapping) instead of a dummy value + +## [0.14.0] - 2025-12-09 + +### Added + +- Sort buttons for 'Group' and 'M3U' columns in Streams table for improved stream organization and filtering - Thanks [@bobey6](https://github.com/bobey6) +- EPG source priority field for controlling which EPG source is preferred when multiple sources have matching entries for a channel (higher numbers = higher priority) (Closes #603) + +### Changed + +- EPG program parsing optimized for sources with many channels but only a fraction mapped. Now parses XML file once per source instead of once per channel, dramatically reducing I/O and CPU overhead. For sources with 10,000 channels and 100 mapped, this results in ~99x fewer file opens and ~100x fewer full file scans. Orphaned programs for unmapped channels are also cleaned up during refresh to prevent database bloat. Database updates are now atomic to prevent clients from seeing empty/partial EPG data during refresh. +- EPG table now displays detailed status messages including refresh progress, success messages, and last message for idle sources (matching M3U table behavior) (Closes #214) +- IPv6 access now allowed by default with all IPv6 CIDRs accepted - Thanks [@adrianmace](https://github.com/adrianmace) +- nginx.conf updated to bind to both IPv4 and IPv6 ports - Thanks [@jordandalley](https://github.com/jordandalley) +- EPG matching now respects source priority and only uses active (enabled) EPG sources (Closes #672) +- EPG form API Key field now only visible when Schedules Direct source type is selected + +### Fixed + +- EPG table "Updated" column now updates in real-time via WebSocket using the actual backend timestamp instead of requiring a page refresh +- Bulk channel editor confirmation dialog now displays the correct stream profile name that will be applied to the selected channels. +- uWSGI not found and 502 bad gateway on first startup + +## [0.13.1] - 2025-12-06 + +### Fixed + +- JWT token generated so is unique for each deployment + +## [0.13.0] - 2025-12-02 + +### Added + +- `CHANGELOG.md` file following Keep a Changelog format to document all notable changes and project history +- System event logging and viewer: Comprehensive logging system that tracks internal application events (M3U refreshes, EPG updates, stream switches, errors) with a dedicated UI viewer for filtering and reviewing historical events. Improves monitoring, troubleshooting, and understanding system behavior +- M3U/EPG endpoint caching: Implements intelligent caching for frequently requested M3U playlists and EPG data to reduce database load and improve response times for clients. +- Search icon to name headers for the channels and streams tables (#686) +- Comprehensive logging for user authentication events and network access restrictions +- Validation for EPG objects and payloads in updateEPG functions to prevent errors from invalid data +- Referrerpolicy to YouTube iframes in series and VOD modals for better compatibility + +### Changed + +- XC player API now returns server_info for unknown actions to align with provider behavior +- XC player API refactored to streamline action handling and ensure consistent responses +- Date parsing logic in generate_custom_dummy_programs improved to handle empty or invalid inputs +- DVR cards now reflect date and time formats chosen by user - Thanks [@Biologisten](https://github.com/Biologisten) +- "Uncategorized" categories and relations now automatically created for VOD accounts to improve content management (#627) +- Improved minimum horizontal size in the stats page for better usability on smaller displays +- M3U and EPG generation now handles missing channel profiles with appropriate error logging + +### Fixed + +- Episode URLs in series modal now use UUID instead of ID, fixing broken links (#684, #694) +- Stream preview now respects selected M3U profile instead of always using default profile (#690) +- Channel groups filter in M3UGroupFilter component now filters out non-existent groups (prevents blank webui when editing M3U after a group was removed) +- Stream order now preserved in PATCH/PUT responses from ChannelSerializer, ensuring consistent ordering across all API operations - Thanks [@FiveBoroughs](https://github.com/FiveBoroughs) (#643) +- XC client compatibility: float channel numbers now converted to integers +- M3U account and profile modals now scrollable on mobile devices for improved usability + +## [0.12.0] - 2025-11-19 + +### Added + +- RTSP stream support with automatic protocol detection when a proxy profile requires it. The proxy now forces FFmpeg for RTSP sources and properly handles RTSP URLs - Thanks [@ragchuck](https://github.com/ragchuck) (#184) +- UDP stream support, including correct handling when a proxy profile specifies a UDP source. The proxy now skips HTTP-specific headers (like `user_agent`) for non-HTTP protocols and performs manual redirect handling to improve reliability (#617) +- Separate VOD logos system with a new `VODLogo` model, database migration, dedicated API/viewset, and server-paginated UI. This separates movie/series logos from channel logos, making cleanup safer and enabling independent bulk operations + +### Changed + +- Background profile refresh now uses a rate-limiting/backoff strategy to avoid provider bans +- Bulk channel editing now validates all requested changes up front and applies updates in a single database transaction +- ProxyServer shutdown & ghost-client handling improved to avoid initializing channels for transient clients and prevent duplicate reinitialization during rapid reconnects +- URL / Stream validation expanded to support credentials on non-FQDN hosts, skips HTTP-only checks for RTSP/RTP/UDP streams, and improved host/port normalization +- TV guide scrolling & timeline synchronization improved with mouse-wheel scrolling, synchronized timeline position with guide navigation, and improved mobile momentum scrolling (#252) +- EPG Source dropdown now sorts alphabetically - Thanks [@0x53c65c0a8bd30fff](https://github.com/0x53c65c0a8bd30fff) +- M3U POST handling restored and improved for clients (e.g., Smarters) that request playlists using HTTP POST - Thanks [@maluueu](https://github.com/maluueu) +- Login form revamped with branding, cleaner layout, loading state, "Remember Me" option, and focused sign-in flow +- Series & VOD now have copy-link buttons in modals for easier URL sharing +- `get_host_and_port` now prioritizes verified port sources and handles reverse-proxy edge cases more accurately (#618) + +### Fixed + +- EXTINF parsing overhauled to correctly extract attributes such as `tvg-id`, `tvg-name`, and `group-title`, even when values include quotes or commas (#637) +- Websocket payload size reduced during EPG processing to avoid UI freezes, blank screens, or memory spikes in the browser (#327) +- Logo management UI fixes including confirmation dialogs, header checkbox reset, delete button reliability, and full client refetch after cleanup + +## [0.11.2] - 2025-11-04 + +### Added + +- Custom Dummy EPG improvements: + - Support for using an existing Custom Dummy EPG as a template for creating new EPGs + - Custom fallback templates for unmatched patterns + - `{endtime}` as an available output placeholder and renamed `{time}` → `{starttime}` (#590) + - Support for date placeholders that respect both source and output timezones (#597) + - Ability to bulk assign Custom Dummy EPGs to multiple channels + - "Include New Tag" option to mark programs as new in Dummy EPG output + - Support for month strings in date parsing + - Ability to set custom posters and channel logos via regex patterns for Custom Dummy EPGs + - Improved DST handling by calculating offsets based on the actual program date, not today's date + +### Changed + +- Stream model maximum URL length increased from 2000 to 4096 characters (#585) +- Groups now sorted during `xc_get_live_categories` based on the order they first appear (by lowest channel number) +- Client TTL settings updated and periodic refresh implemented during active streaming to maintain accurate connection tracking +- `ProgramData.sub_title` field changed from `CharField` to `TextField` to allow subtitles longer than 255 characters (#579) +- Startup improved by verifying `/data` directory ownership and automatically fixing permissions if needed. Pre-creates `/data/models` during initialization (#614) +- Port detection enhanced to check `request.META.get("SERVER_PORT")` before falling back to defaults, ensuring correct port when generating M3U, EPG, and logo URLs - Thanks [@lasharor](https://github.com/lasharor) + +### Fixed + +- Custom Dummy EPG frontend DST calculation now uses program date instead of current date +- Channel titles no longer truncated early after an apostrophe - Thanks [@0x53c65c0a8bd30fff](https://github.com/0x53c65c0a8bd30fff) + +## [0.11.1] - 2025-10-22 + +### Fixed + +- uWSGI not receiving environmental variables +- LXC unable to access daemons launched by uWSGI ([#575](https://github.com/Dispatcharr/Dispatcharr/issues/575), [#576](https://github.com/Dispatcharr/Dispatcharr/issues/576), [#577](https://github.com/Dispatcharr/Dispatcharr/issues/577)) + +## [0.11.0] - 2025-10-22 + +### Added + +- Custom Dummy EPG system: + - Regex pattern matching and name source selection + - Support for custom upcoming and ended programs + - Timezone-aware with source and local timezone selection + - Option to include categories and date/live tags in Dummy EPG output + - (#293) +- Auto-Enable & Category Improvements: + - Auto-enable settings for new groups and categories in M3U and VOD components (#208) +- IPv6 CIDR validation in Settings - Thanks [@jordandalley](https://github.com/jordandalley) (#236) +- Custom logo support for channel groups in Auto Sync Channels (#555) +- Tooltips added to the Stream Table + +### Changed + +- Celery and uWSGI now have configurable `nice` levels (defaults: `uWSGI=0`, `Celery=5`) to prioritize streaming when needed. (#571) +- Directory creation and ownership management refactored in init scripts to avoid unnecessary recursive `chown` operations and improve boot speed +- HTTP streamer switched to threaded model with piped output for improved robustness +- Chunk timeout configuration improved and StreamManager timeout handling enhanced +- Proxy timeout values reduced to avoid unnecessary waiting +- Resource cleanup improved to prevent "Too many open files" errors +- Proxy settings caching implemented and database connections properly closed after use +- EPG program fetching optimized with chunked retrieval and explicit ordering to reduce memory usage during output +- EPG output now sorted by channel number for consistent presentation +- Stream Table buttons reordered for better usability +- Database connection handling improved throughout the codebase to reduce overall connection count + +### Fixed + +- Crash when resizing columns in the Channel Table (#516) +- Errors when saving stream settings (#535) +- Preview and edit bugs for custom streams where profile and group selections did not display correctly +- `channel_id` and `channel.uuid` now converted to strings before processing to fix manual switching when the uWSGI worker was not the stream owner (#269) +- Stream locking and connection search issues when switching channels; increased search timeout to reduce premature failures (#503) +- Stream Table buttons no longer shift into multiple rows when selecting many streams +- Custom stream previews +- Custom Stream settings not loading properly (#186) +- Orphaned categories now automatically removed for VOD and Series during M3U refresh (#540) + +## [0.10.4] - 2025-10-08 + +### Added + +- "Assign TVG-ID from EPG" functionality with frontend actions for single-channel and batch operations +- Confirmation dialogs in `ChannelBatchForm` for setting names, logos, TVG-IDs, and clearing EPG assignments +- "Clear EPG" button to `ChannelBatchForm` for easy reset of assignments +- Batch editing of channel logos - Thanks [@EmeraldPi](https://github.com/EmeraldPi) +- Ability to set logo name from URL - Thanks [@EmeraldPi](https://github.com/EmeraldPi) +- Proper timestamp tracking for channel creation and updates; `XC Get Live Streams` now uses this information +- Time Zone Settings added to the application ([#482](https://github.com/Dispatcharr/Dispatcharr/issues/482), [#347](https://github.com/Dispatcharr/Dispatcharr/issues/347)) +- Comskip settings support including comskip.ini upload and custom directory selection (#418) +- Manual recording scheduling for channels without EPG data (#162) + +### Changed + +- Default M3U account type is now set to XC for new accounts +- Performance optimization: Only fetch playlists and channel profiles after a successful M3U refresh (rather than every status update) +- Playlist retrieval now includes current connection counts and improved session handling during VOD session start +- Improved stream selection logic when all profiles have reached max connections (retries faster) + +### Fixed + +- Large EPGs now fully parse all channels +- Duplicate channel outputs for streamer profiles set to "All" +- Streamer profiles with "All" assigned now receive all eligible channels +- PostgreSQL btree index errors from logo URL validation during channel creation (#519) +- M3U processing lock not releasing when no streams found during XC refresh, which also skipped VOD scanning (#449) +- Float conversion errors by normalizing decimal format during VOD scanning (#526) +- Direct URL ordering in M3U output to use correct stream sequence (#528) +- Adding multiple M3U accounts without refreshing modified only the first entry (#397) +- UI state bug where new playlist creation was not notified to frontend ("Fetching Groups" stuck) +- Minor FFmpeg task and stream termination bugs in DVR module +- Input escaping issue where single quotes were interpreted as code delimiters (#406) + +## [0.10.3] - 2025-10-04 + +### Added + +- Logo management UI improvements where Channel editor now uses the Logo Manager modal, allowing users to add logos by URL directly from the edit form - Thanks [@EmeraldPi](https://github.com/EmeraldPi) + +### Changed + +- FFmpeg base container rebuilt with improved native build support - Thanks [@EmeraldPi](https://github.com/EmeraldPi) +- GitHub Actions workflow updated to use native runners instead of QEMU emulation for more reliable multi-architecture builds + +### Fixed + +- EPG parsing stability when large EPG files would not fully parse all channels. Parser now uses `iterparse` with `recover=True` for both channel and program-level parsing, ensuring complete and resilient XML processing even when Cloudflare injects additional root elements + +## [0.10.2] - 2025-10-03 + +### Added + +- `m3u_id` parameter to `generate_hash_key` and updated related calls +- Support for `x-tvg-url` and `url-tvg` generation with preserved query parameters (#345) +- Exact Gracenote ID matching for EPG channel mapping (#291) +- Recovery handling for XMLTV parser errors +- `nice -n 5` added to Celery commands for better process priority management + +### Changed + +- Default M3U hash key changed to URL only for new installs +- M3U profile retrieval now includes current connection counts and improved session handling during VOD session start +- Improved stream selection logic when all profiles have reached max connections (retries faster) +- XMLTV parsing refactored to use `iterparse` for `` element +- Release workflow refactored to run on native architecture +- Docker build system improvements: + - Split install/build steps + - Switch from Yarn → NPM + - Updated to Node.js 24 (frontend build) + - Improved ARM build reliability + - Pushes to DockerHub with combined manifest + - Removed redundant tags and improved build organization + +### Fixed + +- Cloudflare-hosted EPG feeds breaking parsing (#497) +- Bulk channel creation now preserves the order channels were selected in (no longer reversed) +- M3U hash settings not saving properly +- VOD selecting the wrong M3U profile at session start (#461) +- Redundant `h` removed from 12-hour time format in settings page + +## [0.10.1] - 2025-09-24 + +### Added + +- Virtualized rendering for TV Guide for smoother performance when displaying large guides - Thanks [@stlalpha](https://github.com/stlalpha) (#438) +- Enhanced channel/program mapping to reuse EPG data across multiple channels that share the same TVG-ID + +### Changed + +- `URL` field length in EPGSource model increased from 200 → 1000 characters to support long URLs with tokens +- Improved URL transformation logic with more advanced regex during profile refreshes +- During EPG scanning, the first display name for a channel is now used instead of the last +- `whiteSpace` style changed from `nowrap` → `pre` in StreamsTable for better text formatting + +### Fixed + +- EPG channel parsing failure when channel `URL` exceeded 500 characters by adding validation during scanning (#452) +- Frontend incorrectly saving case-sensitive setting as a JSON string for stream filters + +## [0.10.0] - 2025-09-18 + +### Added + +- Channel Creation Improvements: + - Ability to specify channel number during channel creation ([#377](https://github.com/Dispatcharr/Dispatcharr/issues/377), [#169](https://github.com/Dispatcharr/Dispatcharr/issues/169)) + - Asynchronous bulk channel creation from stream IDs with WebSocket progress updates + - WebSocket notifications when channels are created +- EPG Auto-Matching (Rewritten & Enhanced): + - Completely refactored for improved accuracy and efficiency + - Can now be applied to selected channels or triggered directly from the channel edit form + - Uses stricter matching logic with support from sentence transformers + - Added progress notifications during the matching process + - Implemented memory cleanup for ML models after matching operations + - Removed deprecated matching scripts +- Logo & EPG Management: + - Ability in channel edit form and bulk channel editor to set logos and names from assigned EPG (#157) + - Improved logo update flow: frontend refreshes on changes, store updates after bulk changes, progress shown via notifications +- Table Enhancements: + - All tables now support adjustable column resizing (#295) + - Channels and Streams tables persist column widths and center divider position to local storage + - Improved sizing and layout for user-agents, stream profiles, logos, M3U, and EPG tables + +### Changed + +- Simplified VOD and series access: removed user-level restrictions on M3U accounts +- Skip disabled M3U accounts when choosing streams during playback (#402) +- Enhanced `UserViewSet` queryset to prefetch related channel profiles for better performance +- Auto-focus added to EPG filter input +- Category API retrieval now sorts by name +- Increased default column size for EPG fields and removed max size on group/EPG columns +- Standardized EPG column header to display `(EPG ID - TVG-ID)` + +### Fixed + +- Bug during VOD cleanup where all VODs not from the current M3U scan could be deleted +- Logos not being set correctly in some cases +- Bug where not setting a channel number caused an error when creating a channel (#422) +- Bug where clicking "Add Channel" with a channel selected opened the edit form instead +- Bug where a newly created channel could reuse streams from another channel due to form not clearing properly +- VOD page not displaying correct order while changing pages +- `ReferenceError: setIsInitialized is not defined` when logging into web UI +- `cannot access local variable 'total_chunks' where it is not associated with a value` during VOD refresh + +## [0.9.1] - 2025-09-13 + +### Fixed + +- Broken migrations affecting the plugins system +- DVR and plugin paths to ensure proper functionality (#381) + +## [0.9.0] - 2025-09-12 + +### Added + +- **Video on Demand (VOD) System:** + - Complete VOD infrastructure with support for movies and TV series + - Advanced VOD metadata including IMDB/TMDB integration, trailers, cast information + - Smart VOD categorization with filtering by type (movies vs series) + - Multi-provider VOD support with priority-based selection + - VOD streaming proxy with connection tracking and statistics + - Season/episode organization for TV series with expandable episode details + - VOD statistics and monitoring integrated with existing stats dashboard + - Optimized VOD parsing and category filtering + - Dedicated VOD page with movies and series tabs + - Rich VOD modals with backdrop images, trailers, and metadata + - Episode management with season-based organization + - Play button integration with external player support + - VOD statistics cards similar to channel cards +- **Plugin System:** + - Extensible Plugin Framework - Developers can build custom functionality without modifying Dispatcharr core + - Plugin Discovery & Management - Automatic detection of installed plugins, with enable/disable controls in the UI + - Backend API Support - New APIs for listing, loading, and managing plugins programmatically + - Plugin Registry - Structured models for plugin metadata (name, version, author, description) + - UI Enhancements - Dedicated Plugins page in the admin panel for centralized plugin management + - Documentation & Scaffolding - Initial documentation and scaffolding to accelerate plugin development +- **DVR System:** + - Refreshed DVR page for managing scheduled and completed recordings + - Global pre/post padding controls surfaced in Settings + - Playback support for completed recordings directly in the UI + - DVR table view includes title, channel, time, and padding adjustments for clear scheduling + - Improved population of DVR listings, fixing intermittent blank screen issues + - Comskip integration for automated commercial detection and skipping in recordings + - User-configurable comskip toggle in Settings +- **Enhanced Channel Management:** + - EPG column added to channels table for better organization + - EPG filtering by channel assignment and source name + - Channel batch renaming for efficient bulk channel name updates + - Auto channel sync improvements with custom stream profile override + - Channel logo management overhaul with background loading +- Date and time format customization in settings - Thanks [@Biologisten](https://github.com/Biologisten) +- Auto-refresh intervals for statistics with better UI controls +- M3U profile notes field for better organization +- XC account information retrieval and display with account refresh functionality and notifications + +### Changed + +- JSONB field conversion for custom properties (replacing text fields) for better performance +- Database encoding converted from ASCII to UTF8 for better character support +- Batch processing for M3U updates and channel operations +- Query optimization with prefetch_related to eliminate N+1 queries +- Reduced API calls by fetching all data at once instead of per-category +- Buffering speed setting now affects UI indicators +- Swagger endpoint accessible with or without trailing slash +- EPG source names displayed before channel names in edit forms +- Logo loading improvements with background processing +- Channel card enhancements with better status indicators +- Group column width optimization +- Better content-type detection for streams +- Improved headers with content-range and total length +- Enhanced user-agent handling for M3U accounts +- HEAD request support with connection keep-alive +- Progress tracking improvements for clients with new sessions +- Server URL length increased to 1000 characters for token support +- Prettier formatting applied to all frontend code +- String quote standardization and code formatting improvements + +### Fixed + +- Logo loading issues in channel edit forms resolved +- M3U download error handling and user feedback improved +- Unique constraint violations fixed during stream rehashing +- Channel stats fetching moved from Celery beat task to configurable API calls +- Speed badge colors now use configurable buffering speed setting +- Channel cards properly close when streams stop +- Active streams labeling updated from "Active Channels" +- WebSocket updates for client connect/disconnect events +- Null value handling before database saves +- Empty string scrubbing for cleaner data +- Group relationship cleanup for removed M3U groups +- Logo cleanup for unused files with proper batch processing +- Recordings start 5 mins after show starts (#102) + +### Closed + +- [#350](https://github.com/Dispatcharr/Dispatcharr/issues/350): Allow DVR recordings to be played via the UI +- [#349](https://github.com/Dispatcharr/Dispatcharr/issues/349): DVR screen doesn't populate consistently +- [#340](https://github.com/Dispatcharr/Dispatcharr/issues/340): Global find and replace +- [#311](https://github.com/Dispatcharr/Dispatcharr/issues/311): Stat's "Current Speed" does not reflect "Buffering Speed" setting +- [#304](https://github.com/Dispatcharr/Dispatcharr/issues/304): Name ignored when uploading logo +- [#300](https://github.com/Dispatcharr/Dispatcharr/issues/300): Updating Logo throws error +- [#286](https://github.com/Dispatcharr/Dispatcharr/issues/286): 2 Value/Column EPG in Channel Edit +- [#280](https://github.com/Dispatcharr/Dispatcharr/issues/280): Add general text field in M3U/XS profiles +- [#190](https://github.com/Dispatcharr/Dispatcharr/issues/190): Show which stream is being used and allow it to be altered in channel properties +- [#155](https://github.com/Dispatcharr/Dispatcharr/issues/155): Additional column with EPG assignment information / Allow filtering by EPG assignment +- [#138](https://github.com/Dispatcharr/Dispatcharr/issues/138): Bulk Channel Edit Functions + +## [0.8.0] - 2025-08-19 + +### Added + +- Channel & Stream Enhancements: + - Preview streams under a channel, with stream logo and name displayed in the channel card + - Advanced stats for channel streams + - Stream qualities displayed in the channel table + - Stream stats now saved to the database + - URL badges can now be clicked to copy stream links to the clipboard +- M3U Filtering for Streams: + - Streams for an M3U account can now be filtered using flexible parameters + - Apply filters based on stream name, group title, or stream URL (via regex) + - Filters support both inclusion and exclusion logic for precise control + - Multiple filters can be layered with a priority order for complex rules +- Ability to reverse the sort order for auto channel sync +- Custom validator for URL fields now allows non-FQDN hostnames (#63) +- Membership creation added in `UpdateChannelMembershipAPIView` if not found (#275) + +### Changed + +- Bumped Postgres to version 17 +- Updated dependencies in `requirements.txt` for compatibility and improvements +- Improved chunked extraction to prevent memory issues - Thanks [@pantherale0](https://github.com/pantherale0) + +### Fixed + +- XML escaping for channel ID in `generate_dummy_epg` function +- Bug where creating a channel from a stream not displayed in the table used an invalid stream name +- Debian install script - Thanks [@deku-m](https://github.com/deku-m) + +## [0.7.1] - 2025-07-29 + +### Added + +- Natural sorting for channel names during auto channel sync +- Ability to sort auto sync order by provider order (default), channel name, TVG ID, or last updated time +- Auto-created channels can now be assigned to specific channel profiles (#255) +- Channel profiles are now fetched automatically after a successful M3U refresh +- Uses only whole numbers when assigning the next available channel number + +### Changed + +- Logo upload behavior changed to wait for the Create button before saving +- Uses the channel name as the display name in EPG output for improved readability +- Ensures channels are only added to a selected profile if one is explicitly chosen + +### Fixed + +- Logo Manager prevents redundant messages from the file scanner by properly tracking uploaded logos in Redis +- Fixed an issue preventing logo uploads via URL +- Adds internal support for assigning multiple profiles via API + +## [0.7.0] - 2025-07-19 + +### Added + +- **Logo Manager:** + - Complete logo management system with filtering, search, and usage tracking + - Upload logos directly through the UI + - Automatically scan `/data/logos` for existing files (#69) + - View which channels use each logo + - Bulk delete unused logos with cleanup + - Enhanced display with hover effects and improved sizing + - Improved logo fetching with timeouts and user-agent headers to prevent hanging +- **Group Manager:** + - Comprehensive group management interface (#128) + - Search and filter groups with ease + - Bulk operations for cleanup + - Filter channels by group membership + - Automatically clean up unused groups +- **Auto Channel Sync:** + - Automatic channel synchronization from M3U sources (#147) + - Configure auto-sync settings per M3U account group + - Set starting channel numbers by group + - Override group names during sync + - Apply regex match and replace for channel names + - Filter channels by regex match on stream name + - Track auto-created vs manually added channels + - Smart updates preserve UUIDs and existing links +- Stream rehashing with WebSocket notifications +- Better error handling for blocked rehash attempts +- Lock acquisition to prevent conflicts +- Real-time progress tracking + +### Changed + +- Persist table page sizes in local storage (streams & channels) +- Smoother pagination and improved UX +- Fixed z-index issues during table refreshes +- Improved XC client with connection pooling +- Better error handling for API and JSON decode failures +- Smarter handling of empty content and blocking responses +- Improved EPG XML generation with richer metadata +- Better support for keywords, languages, ratings, and credits +- Better form layouts and responsive buttons +- Enhanced confirmation dialogs and feedback + +### Fixed + +- Channel table now correctly restores page size from local storage +- Resolved WebSocket message formatting issues +- Fixed logo uploads and edits +- Corrected ESLint issues across the codebase +- Fixed HTML validation errors in menus +- Optimized logo fetching with proper timeouts and headers ([#101](https://github.com/Dispatcharr/Dispatcharr/issues/101), [#217](https://github.com/Dispatcharr/Dispatcharr/issues/217)) + +## [0.6.2] - 2025-07-10 + +### Fixed + +- **Streaming & Connection Stability:** + - Provider timeout issues - Slow but responsive providers no longer cause channel lockups + - Added chunk and process timeouts - Prevents hanging during stream processing and transcoding + - Improved connection handling - Enhanced process management and socket closure detection for safer streaming + - Enhanced health monitoring - Health monitor now properly notifies main thread without attempting reconnections +- **User Interface & Experience:** + - Touch screen compatibility - Web player can now be properly closed on touch devices + - Improved user management - Added support for first/last names, login tracking, and standardized table formatting +- Improved logging - Enhanced log messages with channel IDs for better debugging +- Code cleanup - Removed unused imports, variables, and dead links + +## [0.6.1] - 2025-06-27 + +### Added + +- Dynamic parameter options for M3U and EPG URLs (#207) +- Support for 'num' property in channel number extraction (fixes channel creation from XC streams not having channel numbers) + +### Changed + +- EPG generation now uses streaming responses to prevent client timeouts during large EPG file generation (#179) +- Improved reliability when downloading EPG data from external sources +- Better program positioning - Programs that start before the current view now have proper text positioning (#223) +- Better mobile support - Improved sizing and layout for mobile devices across multiple tables +- Responsive stats cards - Better calculation for card layout and improved filling on different screen sizes (#218) +- Enhanced table rendering - M3U and EPG tables now render better on small screens +- Optimized spacing - Removed unnecessary padding and blank space throughout the interface +- Better settings layout - Improved minimum widths and mobile support for settings pages +- Always show 2 decimal places for FFmpeg speed values + +### Fixed + +- TV Guide now properly filters channels based on selected channel group +- Resolved loading issues - Fixed channels and groups not loading correctly in the TV Guide +- Stream profile fixes - Resolved issue with setting stream profile to 'use default' +- Single channel editing - When only one channel is selected, the correct channel editor now opens +- Bulk edit improvements - Added "no change" options for bulk editing operations +- Bulk channel editor now properly saves changes (#222) +- Link form improvements - Better sizing and rendering of link forms with proper layering +- Confirmation dialogs added with warning suppression for user deletion, channel profile deletion, and M3U profile deletion + +## [0.6.0] - 2025-06-19 + +### Added + +- **User Management & Access Control:** + - Complete user management system with user levels and channel access controls + - Network access control with CIDR validation and IP-based restrictions + - Logout functionality and improved loading states for authenticated users +- **Xtream Codes Output:** + - Xtream Codes support enables easy output to IPTV clients (#195) +- **Stream Management & Monitoring:** + - FFmpeg statistics integration - Real-time display of video/audio codec info, resolution, speed, and stream type + - Automatic stream switching when buffering is detected + - Enhanced stream profile management with better connection tracking + - Improved stream state detection, including buffering as an active state +- **Channel Management:** + - Bulk channel editing for channel group, stream profile, and user access level +- **Enhanced M3U & EPG Features:** + - Dynamic `tvg-id` source selection for M3U and EPG (`tvg_id`, `gracenote`, or `channel_number`) + - Direct URL support in M3U output via `direct=true` parameter + - Flexible EPG output with a configurable day limit via `days=#` parameter + - Support for LIVE tags and `dd_progrid` numbering in EPG processing +- Proxy settings configuration with UI integration and improved validation +- Stream retention controls - Set stale stream days to `0` to disable retention completely (#123) +- Tuner flexibility - Minimum of 1 tuner now allowed for HDHomeRun output +- Fallback IP geolocation provider (#127) - Thanks [@maluueu](https://github.com/maluueu) +- POST method now allowed for M3U output, enabling support for Smarters IPTV - Thanks [@maluueu](https://github.com/maluueu) + +### Changed + +- Improved channel cards with better status indicators and tooltips +- Clearer error messaging for unsupported codecs in the web player +- Network access warnings to prevent accidental lockouts +- Case-insensitive M3U parsing for improved compatibility +- Better EPG processing with improved channel matching +- Replaced Mantine React Table with custom implementations +- Improved tooltips and parameter wrapping for cleaner interfaces +- Better badge colors and status indicators +- Stronger form validation and user feedback +- Streamlined settings management using JSON configs +- Default value population for clean installs +- Environment-specific configuration support for multiple deployment scenarios + +### Fixed + +- FFmpeg process cleanup - Ensures FFmpeg fully exits before marking connection closed +- Resolved stream profile update issues in statistics display +- Fixed M3U profile ID behavior when switching streams +- Corrected stream switching logic - Redis is only updated on successful switches +- Fixed connection counting - Excludes the current profile from available connection counts +- Fixed custom stream channel creation when no group is assigned (#122) +- Resolved EPG auto-matching deadlock when many channels match simultaneously - Thanks [@xham3](https://github.com/xham3) + +## [0.5.2] - 2025-06-03 + +### Added + +- Direct Logo Support: Added ability to bypass logo caching by adding `?cachedlogos=false` to the end of M3U and EPG URLs (#109) + +### Changed + +- Dynamic Resource Management: Auto-scales Celery workers based on demand, reducing overall memory and CPU usage while still allowing high-demand tasks to complete quickly (#111) +- Enhanced Logging: + - Improved logging for M3U processing + - Better error output from XML parser for easier troubleshooting + +### Fixed + +- XMLTV Parsing: Added `remove_blank_text=True` to lxml parser to prevent crashes with poorly formatted XMLTV files (#115) +- Stats Display: Refactored channel info retrieval for safer decoding and improved error logging, fixing intermittent issues with statistics not displaying properly + +## [0.5.1] - 2025-05-28 + +### Added + +- Support for ZIP-compressed EPG files +- Automatic extraction of compressed files after downloading +- Intelligent file type detection for EPG sources: + - Reads the first bits of files to determine file type + - If a compressed file is detected, it peeks inside to find XML files +- Random descriptions for dummy channels in the TV guide +- Support for decimal channel numbers (converted from integer to float) - Thanks [@MooseyOnTheLoosey](https://github.com/MooseyOnTheLoosey) +- Show channels without EPG data in TV Guide +- Profile name added to HDHR-friendly name and device ID (allows adding multiple HDHR profiles to Plex) + +### Changed + +- About 30% faster EPG processing +- Significantly improved memory usage for large EPG files +- Improved timezone handling +- Cleaned up cached files when deleting EPG sources +- Performance improvements when processing extremely large M3U files +- Improved batch processing with better cleanup +- Enhanced WebSocket update handling for large operations +- Redis configured for better performance (no longer saves to disk) +- Improved memory management for Celery tasks +- Separated beat schedules with a file scanning interval set to 20 seconds +- Improved authentication error handling with user redirection to the login page +- Improved channel card formatting for different screen resolutions (can now actually read the channel stats card on mobile) +- Decreased line height for status messages in the EPG and M3U tables for better appearance on smaller screens +- Updated the EPG form to match the M3U form for consistency + +### Fixed + +- Profile selection issues that previously caused WebUI crashes +- Issue with `tvc-guide-id` (Gracenote ID) in bulk channel creation +- Bug when uploading an M3U with the default user-agent set +- Bug where multiple channel initializations could occur, causing zombie streams and performance issues (choppy streams) +- Better error handling for buffer overflow issues +- Fixed various memory leaks +- Bug in the TV Guide that would crash the web UI when selecting a profile to filter by +- Multiple minor bug fixes and code cleanup + +## [0.5.0] - 2025-05-15 + +### Added + +- **XtreamCodes Support:** + - Initial XtreamCodes client support + - Option to add EPG source with XC account + - Improved XC login and authentication + - Improved error handling for XC connections +- **Hardware Acceleration:** + - Detection of hardware acceleration capabilities with recommendations (available in logs after startup) + - Improved support for NVIDIA, Intel (QSV), and VAAPI acceleration methods + - Added necessary drivers and libraries for hardware acceleration + - Automatically assigns required permissions for hardware acceleration + - Thanks to [@BXWeb](https://github.com/BXWeb), @chris.r3x, [@rykr](https://github.com/rykr), @j3111, [@jesmannstl](https://github.com/jesmannstl), @jimmycarbone, [@gordlaben](https://github.com/gordlaben), [@roofussummers](https://github.com/roofussummers), [@slamanna212](https://github.com/slamanna212) +- **M3U and EPG Management:** + - Enhanced M3U profile creation with live regex results + - Added stale stream detection with configurable thresholds + - Improved status messaging for M3U and EPG operations: + - Shows download speed with estimated time remaining + - Shows parsing time remaining + - Added "Pending Setup" status for M3U's requiring group selection + - Improved handling of M3U group filtering +- **UI Improvements:** + - Added configurable table sizes + - Enhanced video player with loading and error states + - Improved WebSocket connection handling with authentication + - Added confirmation dialogs for critical operations + - Auto-assign numbers now configurable by selection + - Added bulk editing of channel profile membership (select multiple channels, then click the profile toggle on any selected channel to apply the change to all) +- **Infrastructure & Performance:** + - Standardized and improved the logging system + - New environment variable to set logging level: `DISPATCHARR_LOG_LEVEL` (default: `INFO`, available: `TRACE`, `DEBUG`, `INFO`, `WARNING`, `ERROR`, `CRITICAL`) + - Introduced a new base image build process: updates are now significantly smaller (typically under 15MB unless the base image changes) + - Improved environment variable handling in container +- Support for Gracenote ID (`tvc-guide-stationid`) - Thanks [@rykr](https://github.com/rykr) +- Improved file upload handling with size limits removed + +### Fixed + +- Issues with profiles not loading correctly +- Problems with stream previews in tables +- Channel creation and editing workflows +- Logo display issues +- WebSocket connection problems +- Multiple React-related errors and warnings +- Pagination and filtering issues in tables + +## [0.4.1] - 2025-05-01 + +### Changed + +- Optimized uWSGI configuration settings for better server performance +- Improved asynchronous processing by converting additional timers to gevent +- Enhanced EPG (Electronic Program Guide) downloading with proper user agent headers + +### Fixed + +- Issue with "add streams to channel" functionality to correctly follow disabled state logic + +## [0.4.0] - 2025-05-01 + +### Added + +- URL copy buttons for stream and channel URLs +- Manual stream switching ability +- EPG auto-match notifications - Users now receive feedback about how many matches were found +- Informative tooltips throughout the interface, including stream profiles and user-agent details +- Display of connected time for each client +- Current M3U profile information to stats +- Better logging for which channel clients are getting chunks from + +### Changed + +- Table System Rewrite: Completely refactored channel and stream tables for dramatically improved performance with large datasets +- Improved Concurrency: Replaced time.sleep with gevent.sleep for better performance when handling multiple streams +- Improved table interactions: + - Restored alternating row colors and hover effects + - Added shift-click support for multiple row selection + - Preserved drag-and-drop functionality +- Adjusted logo display to prevent layout shifts with different sized logos +- Improved sticky headers in tables +- Fixed spacing and padding in EPG and M3U tables for better readability on smaller displays +- Stream URL handling improved for search/replace patterns +- Enhanced stream lock management for better reliability +- Added stream name to channel status for better visibility +- Properly track current stream ID during stream switches +- Improved EPG cache handling and cleanup of old cache files +- Corrected content type for M3U file (using m3u instead of m3u8) +- Fixed logo URL handling in M3U generation +- Enhanced tuner count calculation to include only active M3U accounts +- Increased thread stack size in uwsgi configuration +- Changed proxy to use uwsgi socket +- Added build timestamp to version information +- Reduced excessive logging during M3U/EPG file importing +- Improved store variable handling to increase application efficiency +- Frontend now being built by Yarn instead of NPM + +### Fixed + +- Issues with channel statistics randomly not working +- Stream ordering in channel selection +- M3U profile name added to stream names for better identification +- Channel form not updating some properties after saving +- Issue with setting logos to default +- Channel creation from streams +- Channel group saving +- Improved error handling throughout the application +- Bugs in deleting stream profiles +- Resolved mimetype detection issues +- Fixed form display issues +- Added proper requerying after form submissions and item deletions +- Bug overwriting tvg-id when loading TV Guide +- Bug that prevented large m3u's and epg's from uploading +- Typo in Stream Profile header column for Description - Thanks [@LoudSoftware](https://github.com/LoudSoftware) +- Typo in m3u input processing (tv-chno instead of tvg-chno) - Thanks @www2a + +## [0.3.3] - 2025-04-18 + +### Fixed + +- Issue with dummy EPG calculating hours above 24, ensuring time values remain within valid 24-hour format +- Auto import functionality to properly process old files that hadn't been imported yet, rather than ignoring them + +## [0.3.2] - 2025-04-16 + +### Fixed + +- Issue with stream ordering for channels - resolved problem where stream objects were incorrectly processed when assigning order in channel configurations + +## [0.3.1] - 2025-04-16 + +### Added + +- Key to navigation links in sidebar to resolve DOM errors when loading web UI +- Channels that are set to 'dummy' epg to the TV Guide + +### Fixed + +- Issue preventing dummy EPG from being set +- Channel numbers not saving properly +- EPGs not refreshing when linking EPG to channel +- Improved error messages in notifications + +## [0.3.0] - 2025-04-15 + +### Added + +- URL validation for redirect profile: + - Validates stream URLs before redirecting clients + - Prevents clients from being redirected to unavailable streams + - Now tries alternate streams when primary stream validation fails +- Dynamic tuner configuration for HDHomeRun devices: + - TunerCount is now dynamically created based on profile max connections + - Sets minimum of 2 tuners, up to 10 for unlimited profiles + +### Changed + +- More robust stream switching: + - Clients now wait properly if a stream is in the switching state + - Improved reliability during stream transitions +- Performance enhancements: + - Increased workers and threads for uwsgi for better concurrency + +### Fixed + +- Issue with multiple dead streams in a row - System now properly handles cases where several sequential streams are unavailable +- Broken links to compose files in documentation + +## [0.2.1] - 2025-04-13 + +### Fixed + +- Stream preview (not channel) +- Streaming wouldn't work when using default user-agent for an M3U +- WebSockets and M3U profile form issues + +## [0.2.0] - 2025-04-12 + +Initial beta public release. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..725e21e --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,245 @@ +# Contributing to Dispatcharr + +Thank you for your interest in contributing. Dispatcharr is a complex, production-oriented platform and we hold contributions to a high standard. Please read this guide in full before opening a pull request — it will save everyone time. + +--- + +## Table of Contents + +- [Before You Start](#before-you-start) +- [Project Overview](#project-overview) +- [Setting Up the Development Environment](#setting-up-the-development-environment) +- [Code Standards](#code-standards) +- [Writing Tests](#writing-tests) +- [Submitting a Pull Request](#submitting-a-pull-request) +- [On AI-Assisted Code](#on-ai-assisted-code) +- [What We Will Decline](#what-we-will-decline) +- [Contributor License Agreement](#contributor-license-agreement) + +--- + +## Before You Start + +**Open an issue before writing code.** If you want to add a feature or fix a non-trivial bug, open a GitHub issue first. This lets us tell you whether it aligns with the project's direction, whether it's already being worked on, and how it should be approached — before you invest time writing code. + +For small, obvious bug fixes (a typo, an off-by-one error, a missing validation) you can go straight to a PR. + +--- + +## Project Overview + +Understanding the architecture is a prerequisite for contributing. If you are not familiar with the following, take time to learn them before submitting changes: + +| Layer | Technology | +| ----------- | ------------------------------------------------- | +| Backend | Python 3.13, Django 5, Django REST Framework | +| Async tasks | Celery 5 with Redis broker | +| Real-time | Django Channels (WebSockets), Redis channel layer | +| Database | PostgreSQL 17 | +| Frontend | React 19, Vite, Mantine UI, Zustand | +| API docs | drf-spectacular (OpenAPI) | +| Packaging | `uv`, `pyproject.toml`, Hatchling | +| Deployment | Docker, Nginx, uWSGI/Daphne | + +### Key Django Apps + +| App | Responsibility | +| --------------- | ----------------------------------------------------- | +| `apps/channels` | Core channel management | +| `apps/proxy` | Stream proxying, client management, failover | +| `apps/epg` | EPG ingestion, matching, XMLTV output | +| `apps/m3u` | M3U playlist parsing and management | +| `apps/output` | M3U, Xtream Codes, XMLTV export | +| `apps/hdhr` | HDHomeRun device emulation | +| `apps/vod` | VOD library with TMDB/IMDB metadata | +| `apps/ffmpeg` | FFmpeg stream profile management | +| `apps/plugins` | Plugin/event-hook system | +| `apps/accounts` | Auth, permissions, API keys | +| `core/` | Shared tasks, scheduling, utilities, Xtream Codes API | + +Before touching any app, read its models, serializers, and views end-to-end so you understand what already exists. + +--- + +## Setting Up the Development Environment + +### Prerequisites + +- Docker and Docker Compose +- Node.js 24+ (for frontend) +- Python 3.13+ and [`uv`](https://docs.astral.sh/uv/) + +### Backend (Docker — recommended) + +```bash +# Full stack (modular mode with separate containers) +docker compose -f docker/docker-compose.dev.yml up + +# Or run Django directly against a local Redis/Postgres +uv sync +uv run python manage.py migrate +uv run python manage.py runserver +``` + +### Frontend + +```bash +cd frontend +npm install +npm run dev # dev server with HMR (proxies API to Django) +npm run build # production build +npm run test # run Vitest test suite +``` + +The Vite dev server is configured to proxy `/api/` requests to the Django backend. + +### Environment Variables + +Copy the relevant `docker-compose.*.yml` as a reference for required environment variables. Key ones: + +| Variable | Purpose | +| ----------------------- | ------------------------------------------------------------------------------------------- | +| `POSTGRES_*` | Database connection | +| `REDIS_*` | Redis broker / channel layer | +| `DJANGO_SECRET_KEY` | Django secret (auto-generated in Docker) | +| `DISPATCHARR_DEBUG` | Enables Django debug mode **and** starts debugpy for remote debugging (attach on port 5678) | +| `DISPATCHARR_LOG_LEVEL` | Log verbosity | + +--- + +## Code Standards + +### Backend (Python/Django) + +- Follow [PEP 8](https://peps.python.org/pep-0008/). Use 4-space indentation. +- Follow Django conventions: fat models, thin views, business logic out of serializers. +- New API endpoints must use Django REST Framework. Include serializers — do not return raw dicts from views. +- New endpoints must be registered in the appropriate `api_urls.py` and must appear correctly in the OpenAPI schema (check via drf-spectacular). +- Database changes require a migration: `uv run python manage.py makemigrations `. Migrations must be included in your PR. +- Celery tasks belong in `tasks.py` of the relevant app, or `core/tasks.py` for shared tasks. Tasks must be idempotent where possible. +- Do not introduce new top-level dependencies without discussion. Add them to `pyproject.toml` with a justification in your PR description. + +### Frontend (React/JavaScript) + +- Code must pass ESLint without errors: `npm run lint` +- Code must be formatted with Prettier: `npm run format` +- Use existing Mantine UI components. Do not introduce new UI libraries. +- State management uses Zustand. New global state belongs in a store under `frontend/src/store/`. Do not use React Context for app-level state. +- API calls belong in `frontend/src/api.js`. Do not make `fetch`/`axios` calls directly from components. +- Components should be functional. Avoid class components. + +### General + +- Do not leave debug logging, `console.log`, `print()`, or commented-out code in your PR. +- Do not reformat or refactor code outside the scope of your change. Noise in diffs makes review harder. +- Keep commits focused. One logical change per commit. + +--- + +## Writing Tests + +Untested code is significantly less likely to be merged. + +### Backend + +- Use Django's `TestCase` for unit/integration tests. +- Test files live at `apps//tests/`. +- Run the test suite with: `uv run python manage.py test` + +### Frontend + +- Use Vitest and React Testing Library. +- Test files live alongside what they test in `__tests__/` directories. +- Run with: `npm run test` +- Every new store should have a test file under `frontend/src/store/__tests__/`. +- Every new page should have a test file under `frontend/src/pages/__tests__/`. + +--- + +## Submitting a Pull Request + +### PR Checklist + +Before opening your PR, verify each of the following yourself: + +- [ ] I have read this entire document +- [ ] I opened (or was assigned to) a GitHub issue for this change before writing code +- [ ] I understand — line by line — every change in this PR +- [ ] Backend: migrations are included if models changed +- [ ] Backend: new endpoints are documented in the OpenAPI schema +- [ ] Frontend: ESLint and Prettier pass cleanly +- [ ] Tests are included for new functionality +- [ ] Existing tests still pass +- [ ] No debug artifacts are left in the code +- [ ] My PR targets the `dev` branch (or the branch specified in the issue) + +### PR Description + +A good PR description answers: + +1. **What** does this change do? +2. **Why** is this change needed? (link to the issue) +3. **How** does it work? Describe any non-obvious technical decisions. +4. **How was it tested?** What did you run to verify this works? + +One-line PR descriptions like _"fixed bug"_ or _"added feature"_ will be closed and asked to resubmit. + +### Review Process + +Maintainers review PRs as time allows. To keep the process moving: + +- Respond to review comments promptly. Stale PRs (no activity for 30 days) may be closed. +- Do not force-push to a branch under review without flagging it in a comment. +- Keep your branch up to date with `dev` by rebasing, not merging `dev` into your branch. + +--- + +## On AI-Assisted Code + +We are aware that AI coding tools are capable of generating plausible-looking code quickly. We do not prohibit their use, but we require the following: + +**You must understand every line of code you submit.** + +AI tools frequently produce code that: + +- Duplicates logic that already exists elsewhere in the codebase +- Ignores the established patterns for how the project is structured +- Introduces subtle bugs that are invisible without domain knowledge +- Passes superficial review but breaks edge cases in production + +If you cannot explain, during code review, why a particular line of code is written the way it is — including the tradeoffs involved — the PR will not be merged. There are no exceptions. + +Using an AI tool to help you understand the codebase, generate a first draft, or write boilerplate is fine. Submitting code you have not read and do not understand is not. + +--- + +## What We Will Decline + +To save your time and ours, the following types of PRs will be closed without extended review: + +- **Undiscussed feature additions.** If there is no linked issue where the feature was agreed upon, we will close the PR and ask you to open one. +- **Large, unfocused diffs.** A PR that touches 20 files across 5 apps to "improve code quality" is almost never reviewable. Scope your changes. +- **Dependency bumps without justification.** Don't open a PR just to bump a library version unless you have identified a specific bug or security issue it resolves. +- **Cosmetic/style-only changes.** Reformatting files, renaming variables for preference, or reorganizing imports with no functional change. +- **Duplicate work.** Check open PRs and issues before starting. If someone is already working on it, coordinate with them. +- **Code the author cannot explain.** See [On AI-Assisted Code](#on-ai-assisted-code). + +--- + +## Contributor License Agreement + +By submitting a pull request to this repository, you agree to the following terms: + +- You authored the contribution and have the right to submit it. +- You grant the Dispatcharr project a perpetual, worldwide, irrevocable, royalty-free license to use, reproduce, modify, distribute, sublicense, and relicense your contribution as part of this project, under any license the project maintainers choose, now or in the future. +- You retain your own copyright in your contribution — this is a license grant, not a transfer of ownership. + +This ensures you always retain the right to use your own contribution elsewhere, while the project isn't blocked from making licensing decisions by the need to track down every past contributor. + +By checking the CLA checkbox in the pull request checklist, you confirm that you have read and agree to these terms. + +--- + +## Questions + +If you are unsure whether a contribution is a good fit, join the [Discord](https://discord.gg/Sp45V5BcxU) and start a conversation, or comment on the relevant issue. We would rather have a five-minute conversation upfront than a 30-comment review thread on a PR that ultimately doesn't get merged. diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..0ad25db --- /dev/null +++ b/LICENSE @@ -0,0 +1,661 @@ + GNU AFFERO GENERAL PUBLIC LICENSE + Version 3, 19 November 2007 + + Copyright (C) 2007 Free Software Foundation, Inc. + Everyone is permitted to copy and distribute verbatim copies + of this license document, but changing it is not allowed. + + Preamble + + The GNU Affero General Public License is a free, copyleft license for +software and other kinds of works, specifically designed to ensure +cooperation with the community in the case of network server software. + + The licenses for most software and other practical works are designed +to take away your freedom to share and change the works. By contrast, +our General Public Licenses are intended to guarantee your freedom to +share and change all versions of a program--to make sure it remains free +software for all its users. + + When we speak of free software, we are referring to freedom, not +price. Our General Public Licenses are designed to make sure that you +have the freedom to distribute copies of free software (and charge for +them if you wish), that you receive source code or can get it if you +want it, that you can change the software or use pieces of it in new +free programs, and that you know you can do these things. + + Developers that use our General Public Licenses protect your rights +with two steps: (1) assert copyright on the software, and (2) offer +you this License which gives you legal permission to copy, distribute +and/or modify the software. + + A secondary benefit of defending all users' freedom is that +improvements made in alternate versions of the program, if they +receive widespread use, become available for other developers to +incorporate. Many developers of free software are heartened and +encouraged by the resulting cooperation. However, in the case of +software used on network servers, this result may fail to come about. +The GNU General Public License permits making a modified version and +letting the public access it on a server without ever releasing its +source code to the public. + + The GNU Affero General Public License is designed specifically to +ensure that, in such cases, the modified source code becomes available +to the community. It requires the operator of a network server to +provide the source code of the modified version running there to the +users of that server. Therefore, public use of a modified version, on +a publicly accessible server, gives the public access to the source +code of the modified version. + + An older license, called the Affero General Public License and +published by Affero, was designed to accomplish similar goals. This is +a different license, not a version of the Affero GPL, but Affero has +released a new version of the Affero GPL which permits relicensing under +this license. + + The precise terms and conditions for copying, distribution and +modification follow. + + TERMS AND CONDITIONS + + 0. Definitions. + + "This License" refers to version 3 of the GNU Affero General Public License. + + "Copyright" also means copyright-like laws that apply to other kinds of +works, such as semiconductor masks. + + "The Program" refers to any copyrightable work licensed under this +License. Each licensee is addressed as "you". "Licensees" and +"recipients" may be individuals or organizations. + + To "modify" a work means to copy from or adapt all or part of the work +in a fashion requiring copyright permission, other than the making of an +exact copy. The resulting work is called a "modified version" of the +earlier work or a work "based on" the earlier work. + + A "covered work" means either the unmodified Program or a work based +on the Program. + + To "propagate" a work means to do anything with it that, without +permission, would make you directly or secondarily liable for +infringement under applicable copyright law, except executing it on a +computer or modifying a private copy. Propagation includes copying, +distribution (with or without modification), making available to the +public, and in some countries other activities as well. + + To "convey" a work means any kind of propagation that enables other +parties to make or receive copies. Mere interaction with a user through +a computer network, with no transfer of a copy, is not conveying. + + An interactive user interface displays "Appropriate Legal Notices" +to the extent that it includes a convenient and prominently visible +feature that (1) displays an appropriate copyright notice, and (2) +tells the user that there is no warranty for the work (except to the +extent that warranties are provided), that licensees may convey the +work under this License, and how to view a copy of this License. If +the interface presents a list of user commands or options, such as a +menu, a prominent item in the list meets this criterion. + + 1. Source Code. + + The "source code" for a work means the preferred form of the work +for making modifications to it. "Object code" means any non-source +form of a work. + + A "Standard Interface" means an interface that either is an official +standard defined by a recognized standards body, or, in the case of +interfaces specified for a particular programming language, one that +is widely used among developers working in that language. + + The "System Libraries" of an executable work include anything, other +than the work as a whole, that (a) is included in the normal form of +packaging a Major Component, but which is not part of that Major +Component, and (b) serves only to enable use of the work with that +Major Component, or to implement a Standard Interface for which an +implementation is available to the public in source code form. A +"Major Component", in this context, means a major essential component +(kernel, window system, and so on) of the specific operating system +(if any) on which the executable work runs, or a compiler used to +produce the work, or an object code interpreter used to run it. + + The "Corresponding Source" for a work in object code form means all +the source code needed to generate, install, and (for an executable +work) run the object code and to modify the work, including scripts to +control those activities. However, it does not include the work's +System Libraries, or general-purpose tools or generally available free +programs which are used unmodified in performing those activities but +which are not part of the work. For example, Corresponding Source +includes interface definition files associated with source files for +the work, and the source code for shared libraries and dynamically +linked subprograms that the work is specifically designed to require, +such as by intimate data communication or control flow between those +subprograms and other parts of the work. + + The Corresponding Source need not include anything that users +can regenerate automatically from other parts of the Corresponding +Source. + + The Corresponding Source for a work in source code form is that +same work. + + 2. Basic Permissions. + + All rights granted under this License are granted for the term of +copyright on the Program, and are irrevocable provided the stated +conditions are met. This License explicitly affirms your unlimited +permission to run the unmodified Program. The output from running a +covered work is covered by this License only if the output, given its +content, constitutes a covered work. This License acknowledges your +rights of fair use or other equivalent, as provided by copyright law. + + You may make, run and propagate covered works that you do not +convey, without conditions so long as your license otherwise remains +in force. You may convey covered works to others for the sole purpose +of having them make modifications exclusively for you, or provide you +with facilities for running those works, provided that you comply with +the terms of this License in conveying all material for which you do +not control copyright. Those thus making or running the covered works +for you must do so exclusively on your behalf, under your direction +and control, on terms that prohibit them from making any copies of +your copyrighted material outside their relationship with you. + + Conveying under any other circumstances is permitted solely under +the conditions stated below. Sublicensing is not allowed; section 10 +makes it unnecessary. + + 3. Protecting Users' Legal Rights From Anti-Circumvention Law. + + No covered work shall be deemed part of an effective technological +measure under any applicable law fulfilling obligations under article +11 of the WIPO copyright treaty adopted on 20 December 1996, or +similar laws prohibiting or restricting circumvention of such +measures. + + When you convey a covered work, you waive any legal power to forbid +circumvention of technological measures to the extent such circumvention +is effected by exercising rights under this License with respect to +the covered work, and you disclaim any intention to limit operation or +modification of the work as a means of enforcing, against the work's +users, your or third parties' legal rights to forbid circumvention of +technological measures. + + 4. Conveying Verbatim Copies. + + You may convey verbatim copies of the Program's source code as you +receive it, in any medium, provided that you conspicuously and +appropriately publish on each copy an appropriate copyright notice; +keep intact all notices stating that this License and any +non-permissive terms added in accord with section 7 apply to the code; +keep intact all notices of the absence of any warranty; and give all +recipients a copy of this License along with the Program. + + You may charge any price or no price for each copy that you convey, +and you may offer support or warranty protection for a fee. + + 5. Conveying Modified Source Versions. + + You may convey a work based on the Program, or the modifications to +produce it from the Program, in the form of source code under the +terms of section 4, provided that you also meet all of these conditions: + + a) The work must carry prominent notices stating that you modified + it, and giving a relevant date. + + b) The work must carry prominent notices stating that it is + released under this License and any conditions added under section + 7. This requirement modifies the requirement in section 4 to + "keep intact all notices". + + c) You must license the entire work, as a whole, under this + License to anyone who comes into possession of a copy. This + License will therefore apply, along with any applicable section 7 + additional terms, to the whole of the work, and all its parts, + regardless of how they are packaged. This License gives no + permission to license the work in any other way, but it does not + invalidate such permission if you have separately received it. + + d) If the work has interactive user interfaces, each must display + Appropriate Legal Notices; however, if the Program has interactive + interfaces that do not display Appropriate Legal Notices, your + work need not make them do so. + + A compilation of a covered work with other separate and independent +works, which are not by their nature extensions of the covered work, +and which are not combined with it such as to form a larger program, +in or on a volume of a storage or distribution medium, is called an +"aggregate" if the compilation and its resulting copyright are not +used to limit the access or legal rights of the compilation's users +beyond what the individual works permit. Inclusion of a covered work +in an aggregate does not cause this License to apply to the other +parts of the aggregate. + + 6. Conveying Non-Source Forms. + + You may convey a covered work in object code form under the terms +of sections 4 and 5, provided that you also convey the +machine-readable Corresponding Source under the terms of this License, +in one of these ways: + + a) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by the + Corresponding Source fixed on a durable physical medium + customarily used for software interchange. + + b) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by a + written offer, valid for at least three years and valid for as + long as you offer spare parts or customer support for that product + model, to give anyone who possesses the object code either (1) a + copy of the Corresponding Source for all the software in the + product that is covered by this License, on a durable physical + medium customarily used for software interchange, for a price no + more than your reasonable cost of physically performing this + conveying of source, or (2) access to copy the + Corresponding Source from a network server at no charge. + + c) Convey individual copies of the object code with a copy of the + written offer to provide the Corresponding Source. This + alternative is allowed only occasionally and noncommercially, and + only if you received the object code with such an offer, in accord + with subsection 6b. + + d) Convey the object code by offering access from a designated + place (gratis or for a charge), and offer equivalent access to the + Corresponding Source in the same way through the same place at no + further charge. You need not require recipients to copy the + Corresponding Source along with the object code. If the place to + copy the object code is a network server, the Corresponding Source + may be on a different server (operated by you or a third party) + that supports equivalent copying facilities, provided you maintain + clear directions next to the object code saying where to find the + Corresponding Source. Regardless of what server hosts the + Corresponding Source, you remain obligated to ensure that it is + available for as long as needed to satisfy these requirements. + + e) Convey the object code using peer-to-peer transmission, provided + you inform other peers where the object code and Corresponding + Source of the work are being offered to the general public at no + charge under subsection 6d. + + A separable portion of the object code, whose source code is excluded +from the Corresponding Source as a System Library, need not be +included in conveying the object code work. + + A "User Product" is either (1) a "consumer product", which means any +tangible personal property which is normally used for personal, family, +or household purposes, or (2) anything designed or sold for incorporation +into a dwelling. In determining whether a product is a consumer product, +doubtful cases shall be resolved in favor of coverage. For a particular +product received by a particular user, "normally used" refers to a +typical or common use of that class of product, regardless of the status +of the particular user or of the way in which the particular user +actually uses, or expects or is expected to use, the product. A product +is a consumer product regardless of whether the product has substantial +commercial, industrial or non-consumer uses, unless such uses represent +the only significant mode of use of the product. + + "Installation Information" for a User Product means any methods, +procedures, authorization keys, or other information required to install +and execute modified versions of a covered work in that User Product from +a modified version of its Corresponding Source. The information must +suffice to ensure that the continued functioning of the modified object +code is in no case prevented or interfered with solely because +modification has been made. + + If you convey an object code work under this section in, or with, or +specifically for use in, a User Product, and the conveying occurs as +part of a transaction in which the right of possession and use of the +User Product is transferred to the recipient in perpetuity or for a +fixed term (regardless of how the transaction is characterized), the +Corresponding Source conveyed under this section must be accompanied +by the Installation Information. But this requirement does not apply +if neither you nor any third party retains the ability to install +modified object code on the User Product (for example, the work has +been installed in ROM). + + The requirement to provide Installation Information does not include a +requirement to continue to provide support service, warranty, or updates +for a work that has been modified or installed by the recipient, or for +the User Product in which it has been modified or installed. Access to a +network may be denied when the modification itself materially and +adversely affects the operation of the network or violates the rules and +protocols for communication across the network. + + Corresponding Source conveyed, and Installation Information provided, +in accord with this section must be in a format that is publicly +documented (and with an implementation available to the public in +source code form), and must require no special password or key for +unpacking, reading or copying. + + 7. Additional Terms. + + "Additional permissions" are terms that supplement the terms of this +License by making exceptions from one or more of its conditions. +Additional permissions that are applicable to the entire Program shall +be treated as though they were included in this License, to the extent +that they are valid under applicable law. If additional permissions +apply only to part of the Program, that part may be used separately +under those permissions, but the entire Program remains governed by +this License without regard to the additional permissions. + + When you convey a copy of a covered work, you may at your option +remove any additional permissions from that copy, or from any part of +it. (Additional permissions may be written to require their own +removal in certain cases when you modify the work.) You may place +additional permissions on material, added by you to a covered work, +for which you have or can give appropriate copyright permission. + + Notwithstanding any other provision of this License, for material you +add to a covered work, you may (if authorized by the copyright holders of +that material) supplement the terms of this License with terms: + + a) Disclaiming warranty or limiting liability differently from the + terms of sections 15 and 16 of this License; or + + b) Requiring preservation of specified reasonable legal notices or + author attributions in that material or in the Appropriate Legal + Notices displayed by works containing it; or + + c) Prohibiting misrepresentation of the origin of that material, or + requiring that modified versions of such material be marked in + reasonable ways as different from the original version; or + + d) Limiting the use for publicity purposes of names of licensors or + authors of the material; or + + e) Declining to grant rights under trademark law for use of some + trade names, trademarks, or service marks; or + + f) Requiring indemnification of licensors and authors of that + material by anyone who conveys the material (or modified versions of + it) with contractual assumptions of liability to the recipient, for + any liability that these contractual assumptions directly impose on + those licensors and authors. + + All other non-permissive additional terms are considered "further +restrictions" within the meaning of section 10. If the Program as you +received it, or any part of it, contains a notice stating that it is +governed by this License along with a term that is a further +restriction, you may remove that term. If a license document contains +a further restriction but permits relicensing or conveying under this +License, you may add to a covered work material governed by the terms +of that license document, provided that the further restriction does +not survive such relicensing or conveying. + + If you add terms to a covered work in accord with this section, you +must place, in the relevant source files, a statement of the +additional terms that apply to those files, or a notice indicating +where to find the applicable terms. + + Additional terms, permissive or non-permissive, may be stated in the +form of a separately written license, or stated as exceptions; +the above requirements apply either way. + + 8. Termination. + + You may not propagate or modify a covered work except as expressly +provided under this License. Any attempt otherwise to propagate or +modify it is void, and will automatically terminate your rights under +this License (including any patent licenses granted under the third +paragraph of section 11). + + However, if you cease all violation of this License, then your +license from a particular copyright holder is reinstated (a) +provisionally, unless and until the copyright holder explicitly and +finally terminates your license, and (b) permanently, if the copyright +holder fails to notify you of the violation by some reasonable means +prior to 60 days after the cessation. + + Moreover, your license from a particular copyright holder is +reinstated permanently if the copyright holder notifies you of the +violation by some reasonable means, this is the first time you have +received notice of violation of this License (for any work) from that +copyright holder, and you cure the violation prior to 30 days after +your receipt of the notice. + + Termination of your rights under this section does not terminate the +licenses of parties who have received copies or rights from you under +this License. If your rights have been terminated and not permanently +reinstated, you do not qualify to receive new licenses for the same +material under section 10. + + 9. Acceptance Not Required for Having Copies. + + You are not required to accept this License in order to receive or +run a copy of the Program. Ancillary propagation of a covered work +occurring solely as a consequence of using peer-to-peer transmission +to receive a copy likewise does not require acceptance. However, +nothing other than this License grants you permission to propagate or +modify any covered work. These actions infringe copyright if you do +not accept this License. Therefore, by modifying or propagating a +covered work, you indicate your acceptance of this License to do so. + + 10. Automatic Licensing of Downstream Recipients. + + Each time you convey a covered work, the recipient automatically +receives a license from the original licensors, to run, modify and +propagate that work, subject to this License. You are not responsible +for enforcing compliance by third parties with this License. + + An "entity transaction" is a transaction transferring control of an +organization, or substantially all assets of one, or subdividing an +organization, or merging organizations. If propagation of a covered +work results from an entity transaction, each party to that +transaction who receives a copy of the work also receives whatever +licenses to the work the party's predecessor in interest had or could +give under the previous paragraph, plus a right to possession of the +Corresponding Source of the work from the predecessor in interest, if +the predecessor has it or can get it with reasonable efforts. + + You may not impose any further restrictions on the exercise of the +rights granted or affirmed under this License. For example, you may +not impose a license fee, royalty, or other charge for exercise of +rights granted under this License, and you may not initiate litigation +(including a cross-claim or counterclaim in a lawsuit) alleging that +any patent claim is infringed by making, using, selling, offering for +sale, or importing the Program or any portion of it. + + 11. Patents. + + A "contributor" is a copyright holder who authorizes use under this +License of the Program or a work on which the Program is based. The +work thus licensed is called the contributor's "contributor version". + + A contributor's "essential patent claims" are all patent claims +owned or controlled by the contributor, whether already acquired or +hereafter acquired, that would be infringed by some manner, permitted +by this License, of making, using, or selling its contributor version, +but do not include claims that would be infringed only as a +consequence of further modification of the contributor version. For +purposes of this definition, "control" includes the right to grant +patent sublicenses in a manner consistent with the requirements of +this License. + + Each contributor grants you a non-exclusive, worldwide, royalty-free +patent license under the contributor's essential patent claims, to +make, use, sell, offer for sale, import and otherwise run, modify and +propagate the contents of its contributor version. + + In the following three paragraphs, a "patent license" is any express +agreement or commitment, however denominated, not to enforce a patent +(such as an express permission to practice a patent or covenant not to +sue for patent infringement). To "grant" such a patent license to a +party means to make such an agreement or commitment not to enforce a +patent against the party. + + If you convey a covered work, knowingly relying on a patent license, +and the Corresponding Source of the work is not available for anyone +to copy, free of charge and under the terms of this License, through a +publicly available network server or other readily accessible means, +then you must either (1) cause the Corresponding Source to be so +available, or (2) arrange to deprive yourself of the benefit of the +patent license for this particular work, or (3) arrange, in a manner +consistent with the requirements of this License, to extend the patent +license to downstream recipients. "Knowingly relying" means you have +actual knowledge that, but for the patent license, your conveying the +covered work in a country, or your recipient's use of the covered work +in a country, would infringe one or more identifiable patents in that +country that you have reason to believe are valid. + + If, pursuant to or in connection with a single transaction or +arrangement, you convey, or propagate by procuring conveyance of, a +covered work, and grant a patent license to some of the parties +receiving the covered work authorizing them to use, propagate, modify +or convey a specific copy of the covered work, then the patent license +you grant is automatically extended to all recipients of the covered +work and works based on it. + + A patent license is "discriminatory" if it does not include within +the scope of its coverage, prohibits the exercise of, or is +conditioned on the non-exercise of one or more of the rights that are +specifically granted under this License. You may not convey a covered +work if you are a party to an arrangement with a third party that is +in the business of distributing software, under which you make payment +to the third party based on the extent of your activity of conveying +the work, and under which the third party grants, to any of the +parties who would receive the covered work from you, a discriminatory +patent license (a) in connection with copies of the covered work +conveyed by you (or copies made from those copies), or (b) primarily +for and in connection with specific products or compilations that +contain the covered work, unless you entered into that arrangement, +or that patent license was granted, prior to 28 March 2007. + + Nothing in this License shall be construed as excluding or limiting +any implied license or other defenses to infringement that may +otherwise be available to you under applicable patent law. + + 12. No Surrender of Others' Freedom. + + If conditions are imposed on you (whether by court order, agreement or +otherwise) that contradict the conditions of this License, they do not +excuse you from the conditions of this License. If you cannot convey a +covered work so as to satisfy simultaneously your obligations under this +License and any other pertinent obligations, then as a consequence you may +not convey it at all. For example, if you agree to terms that obligate you +to collect a royalty for further conveying from those to whom you convey +the Program, the only way you could satisfy both those terms and this +License would be to refrain entirely from conveying the Program. + + 13. Remote Network Interaction; Use with the GNU General Public License. + + Notwithstanding any other provision of this License, if you modify the +Program, your modified version must prominently offer all users +interacting with it remotely through a computer network (if your version +supports such interaction) an opportunity to receive the Corresponding +Source of your version by providing access to the Corresponding Source +from a network server at no charge, through some standard or customary +means of facilitating copying of software. This Corresponding Source +shall include the Corresponding Source for any work covered by version 3 +of the GNU General Public License that is incorporated pursuant to the +following paragraph. + + Notwithstanding any other provision of this License, you have +permission to link or combine any covered work with a work licensed +under version 3 of the GNU General Public License into a single +combined work, and to convey the resulting work. The terms of this +License will continue to apply to the part which is the covered work, +but the work with which it is combined will remain governed by version +3 of the GNU General Public License. + + 14. Revised Versions of this License. + + The Free Software Foundation may publish revised and/or new versions of +the GNU Affero General Public License from time to time. Such new versions +will be similar in spirit to the present version, but may differ in detail to +address new problems or concerns. + + Each version is given a distinguishing version number. If the +Program specifies that a certain numbered version of the GNU Affero General +Public License "or any later version" applies to it, you have the +option of following the terms and conditions either of that numbered +version or of any later version published by the Free Software +Foundation. If the Program does not specify a version number of the +GNU Affero General Public License, you may choose any version ever published +by the Free Software Foundation. + + If the Program specifies that a proxy can decide which future +versions of the GNU Affero General Public License can be used, that proxy's +public statement of acceptance of a version permanently authorizes you +to choose that version for the Program. + + Later license versions may give you additional or different +permissions. However, no additional obligations are imposed on any +author or copyright holder as a result of your choosing to follow a +later version. + + 15. Disclaimer of Warranty. + + THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY +APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT +HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY +OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, +THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR +PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM +IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF +ALL NECESSARY SERVICING, REPAIR OR CORRECTION. + + 16. Limitation of Liability. + + IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING +WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS +THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY +GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE +USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF +DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD +PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), +EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF +SUCH DAMAGES. + + 17. Interpretation of Sections 15 and 16. + + If the disclaimer of warranty and limitation of liability provided +above cannot be given local legal effect according to their terms, +reviewing courts shall apply local law that most closely approximates +an absolute waiver of all civil liability in connection with the +Program, unless a warranty or assumption of liability accompanies a +copy of the Program in return for a fee. + + END OF TERMS AND CONDITIONS + + How to Apply These Terms to Your New Programs + + If you develop a new program, and you want it to be of the greatest +possible use to the public, the best way to achieve this is to make it +free software which everyone can redistribute and change under these terms. + + To do so, attach the following notices to the program. It is safest +to attach them to the start of each source file to most effectively +state the exclusion of warranty; and each file should have at least +the "copyright" line and a pointer to where the full notice is found. + + + Copyright (C) + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published + by the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License + along with this program. If not, see . + +Also add information on how to contact you by electronic and paper mail. + + If your software can interact with users remotely through a computer +network, you should also make sure that it provides a way for users to +get its source. For example, if your program is a web application, its +interface could display a "Source" link that leads users to an archive +of the code. There are many ways you could offer source, and different +solutions will be better for different programs; see section 13 for the +specific requirements. + + You should also get your employer (if you work as a programmer) or school, +if any, to sign a "copyright disclaimer" for the program, if necessary. +For more information on this, and how to apply and follow the GNU AGPL, see +. diff --git a/Plugin_repo.md b/Plugin_repo.md new file mode 100644 index 0000000..d846526 --- /dev/null +++ b/Plugin_repo.md @@ -0,0 +1,586 @@ +# Dispatcharr Plugin Repository Specification + +How to create and host a plugin repository that Dispatcharr can consume. + +For writing plugins themselves, see [Plugins.md](Plugins.md). + +--- + +## Overview + +Dispatcharr discovers plugins from remote repositories using a two-level manifest system: + +1. **Repo manifest** - a JSON file listing all plugins in the repo with basic metadata. +2. **Per-plugin manifest** (optional) - a JSON file per plugin with full version history, checksums, and compatibility info. + +Users add a repo by its manifest URL. Dispatcharr fetches and caches the repo manifest periodically (default: every 6 hours, configurable). The UI displays all plugins from enabled repos in a browsable store. + +--- + +## Repo Manifest + +The repo manifest is the entry point. Dispatcharr fetches this URL and caches the response. + +### Minimal Example (no signing) + +```json +{ + "registry_name": "My Plugin Repo", + "plugins": [ + { + "slug": "my_plugin", + "name": "My Plugin", + "description": "Does something useful", + "author": "Your Name", + "latest_version": "1.0.0", + "latest_url": "https://example.com/releases/my_plugin-1.0.0.zip" + } + ] +} +``` + +This is the simplest valid repo manifest - one plugin with enough info to show in the store and install. + +### Full Example (with signing) + +```json +{ + "manifest": { + "registry_name": "My Plugin Repo", + "registry_url": "https://github.com/myorg/my-plugins", + "root_url": "https://raw.githubusercontent.com/myorg/my-plugins/releases", + "plugins": [ + { + "slug": "weather_display", + "name": "Weather Display", + "description": "Shows weather info on the dashboard", + "author": "Acme Labs", + "license": "MIT", + "latest_version": "1.2.5", + "last_updated": "2025-01-20T15:30:00Z", + "manifest_url": "plugins/weather_display/manifest.json", + "latest_url": "plugins/weather_display/releases/weather_display-1.2.5.zip", + "latest_sha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "icon_url": "plugins/weather_display/logo.png", + "min_dispatcharr_version": "2.5.0", + "max_dispatcharr_version": null + } + ] + }, + "signature": "-----BEGIN PGP SIGNATURE-----\n..." +} +``` + +### Accepted Formats + +Dispatcharr accepts two top-level shapes: + +**Wrapped (supports signing):** +```json +{ + "manifest": { "plugins": [...], ... }, + "signature": "..." +} +``` + +**Flat (no signing):** +```json +{ + "plugins": [...], + "registry_name": "...", + "root_url": "..." +} +``` + +The wrapped format is required for signing. If you don't need signing, the flat format works and is simpler. + +### Name Restrictions + +`registry_name` is required. Dispatcharr rejects repos that are missing it. + +Third-party repos must not use names that could be confused with an official Dispatcharr repo. The following words are blocked in `registry_name` (case-insensitive): + +- "official" +- "dispatcharr plugins" +- "dispatcharr repo" +- "dispatcharr official" + +If the name contains any of these, the repo will be rejected on add and skipped during refresh. + +--- + +## Repo Manifest Fields + +### Top-Level Metadata + +| Field | Required | Description | +|-------|----------|-------------| +| `registry_name` | **Yes** | Display name for the repo. Must not contain words like "official" or "dispatcharr" that could be mistaken for an official repo (see [Name Restrictions](#name-restrictions)). | +| `registry_url` | No | URL to the repo's home page (e.g. GitHub). Used as a fallback for generating icon URLs. | +| `root_url` | No | Base URL for resolving relative URLs in plugin entries. Trailing slashes are stripped. | +| `plugins` | **Yes** | Array of plugin entry objects. | + +### Plugin Entry Fields + +| Field | Required | Description | +|-------|----------|-------------| +| `slug` | **Yes** | Unique identifier. Alphanumeric, dashes, and underscores. Used as the install directory name (lowercased, dashes converted to underscores). | +| `name` | **Yes** | Human-readable display name. | +| `description` | No | Short description shown on the plugin card. | +| `author` | No | Author or organization name. | +| `license` | No | SPDX license identifier (e.g. `MIT`, `GPL-3.0`). Displayed as a link to the SPDX license page. | +| `latest_version` | No | Current latest version string (semver: `1.2.3` or `v1.2.3`). Drives update detection. | +| `last_updated` | No | ISO 8601 timestamp of the latest release. Shown as "Built" date in the detail view. | +| `manifest_url` | No | URL (or relative path) to the per-plugin manifest with full version history. See [Per-Plugin Manifest](#per-plugin-manifest). | +| `latest_url` | No | Direct download URL (or relative path) to the latest release zip. | +| `latest_sha256` | No | SHA256 checksum of the latest release zip (lowercase hex, 64 chars). | +| `latest_md5` | No | MD5 checksum of the latest release zip. Informational only - not validated by Dispatcharr. | +| `icon_url` | No | URL (or relative path) to a logo image (PNG recommended). | +| `min_dispatcharr_version` | No | Minimum Dispatcharr version required. Install is blocked if the running version is older. | +| `max_dispatcharr_version` | No | Maximum Dispatcharr version supported. Install is blocked if the running version is newer. | + +Extra fields in a plugin entry are passed through to the frontend as-is, so you can include custom metadata (e.g. `homepage`, `tags`) without breaking anything. + +### URL Resolution + +If `root_url` is set and a URL field (`manifest_url`, `latest_url`, `icon_url`) does not start with `http://` or `https://`, it is treated as relative and resolved as: + +``` +{root_url}/{field_value} +``` + +This lets you keep plugin entries compact: +```json +{ + "root_url": "https://raw.githubusercontent.com/myorg/my-plugins/releases", + "plugins": [ + { + "slug": "my_plugin", + "latest_url": "plugins/my_plugin/my_plugin-1.0.0.zip", + "icon_url": "plugins/my_plugin/logo.png", + "manifest_url": "plugins/my_plugin/manifest.json" + } + ] +} +``` + +**Icon fallback:** If `icon_url` is missing and `registry_url` is set, Dispatcharr generates a fallback URL by converting the GitHub URL to a raw content URL: +``` +{registry_url => raw.githubusercontent.com}/refs/heads/main/plugins/{slug}/logo.png +``` + +--- + +## Per-Plugin Manifest (Optional) + +The per-plugin manifest provides full version history. It is fetched on-demand when a user clicks "More Info" on a plugin card. It is **not required** - if `manifest_url` is absent, the UI builds a detail view from the repo-level fields instead. + +Include a per-plugin manifest if you want to: +- Offer multiple downloadable versions +- Show per-version compatibility ranges +- Display build timestamps and commit links for each version +- Provide detailed author/license info beyond what's in the repo manifest + +### Accepted Formats + +Same as the root manifest - both flat and wrapped formats are accepted: + +**Flat (no signing):** +```json +{ + "slug": "...", + "versions": [...] +} +``` + +**Wrapped (supports signing):** +```json +{ + "manifest": { + "slug": "...", + "versions": [...] + }, + "signature": "-----BEGIN PGP SIGNATURE-----\n..." +} +``` + +Use the wrapped format if you want to GPG-sign the per-plugin manifest. + +### Example + +```json +{ + "slug": "weather_display", + "name": "Weather Display", + "description": "Shows weather information on the Dispatcharr dashboard", + "author": "Acme Labs", + "license": "MIT", + "latest_version": "1.2.5", + "versions": [ + { + "version": "1.2.5", + "url": "releases/weather_display-1.2.5.zip", + "checksum_sha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "build_timestamp": "2025-01-20T15:30:00Z", + "commit_sha": "4e8f1b108c1e84f60520710d13e54eb2fb519648", + "commit_sha_short": "4e8f1b1", + "min_dispatcharr_version": "2.5.0", + "max_dispatcharr_version": null + }, + { + "version": "1.2.5-rc.1", + "url": "releases/weather_display-1.2.5-rc.1.zip", + "checksum_sha256": "a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2", + "prerelease": true, + "build_timestamp": "2025-01-18T09:00:00Z", + "min_dispatcharr_version": "2.5.0" + }, + { + "version": "1.2.4", + "url": "releases/weather_display-1.2.4.zip", + "checksum_sha256": "d4d967a67a4947e55183308cece206b30dda3e1b4fe00aae60f45a49c83b7ed6", + "build_timestamp": "2025-01-15T10:00:00Z", + "min_dispatcharr_version": "2.4.0" + } + ], + "latest": { + "version": "1.2.5", + "url": "releases/weather_display-1.2.5.zip", + "checksum_sha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "build_timestamp": "2025-01-20T15:30:00Z", + "min_dispatcharr_version": "2.5.0" + } +} +``` + +### Per-Plugin Manifest Fields + +| Field | Required | Description | +|-------|----------|-------------| +| `slug` | No | Plugin identifier (should match the repo entry). | +| `name` | No | Display name. | +| `description` | No | Full description shown in the detail modal. | +| `author` | No | Author/org name shown in the detail modal. | +| `license` | No | SPDX license identifier. | +| `latest_version` | No | Latest version string. | +| `versions` | No | Array of version objects (newest first recommended). | +| `latest` | No | Object mirroring the latest version entry for quick access. | + +### Version Object Fields + +| Field | Required | Description | +|-------|----------|-------------| +| `version` | **Yes** | Version string (`1.2.3` or `v1.2.3`). | +| `url` | **Yes** | Download URL for the zip. Relative URLs are resolved against the repo's `root_url`. | +| `checksum_sha256` | No | SHA256 hex checksum. **Strongly recommended.** Validated on install - mismatch blocks the install. | +| `prerelease` | No | Boolean. When `true`, marks this version as a pre-release (alpha, beta, RC, etc.). If the installed version is a prerelease, Dispatcharr will not suggest updating to the latest stable version - the user must install a new version manually. The latest version in the root manifest is always assumed to be stable, so this field only needs to appear in the per-plugin manifest. Omit or set to `false` for stable releases. | +| `build_timestamp` | No | ISO 8601 build timestamp. Shown as "Built" in the version detail. | +| `commit_sha` | No | Full Git commit SHA. Used to build a commit link if `registry_url` is set. | +| `commit_sha_short` | No | Abbreviated commit SHA. Displayed in the version detail table as a clickable link. | +| `min_dispatcharr_version` | No | Minimum compatible Dispatcharr version. | +| `max_dispatcharr_version` | No | Maximum compatible Dispatcharr version. | + +Relative `url` values in versions are resolved the same way as repo-level URLs: `{root_url}/{url}`. + +--- + +## Without a Per-Plugin Manifest + +If you omit `manifest_url` from a plugin entry, the store still works. When a user clicks "More Info", the UI builds a detail view from the repo-level fields: + +- `description`, `author`, `license` from the plugin entry +- A single version entry built from `latest_version`, `latest_url`, `latest_sha256`, `min_dispatcharr_version`, `max_dispatcharr_version`, and `last_updated` + +This is the simplest path for third-party repos that only publish one version at a time. You lose version history and per-version release dates, but install, update detection, and everything else works the same. + +--- + +## Signing + +Signing your repo manifest lets Dispatcharr verify it hasn't been tampered with. Signing is **optional** - unsigned repos work fine but show an "unverified" badge in the UI. + +### How It Works + +1. You generate a GPG keypair. +2. You sign the manifest JSON and include the detached signature in the response. +3. When adding the repo in Dispatcharr, the user pastes your public key. +4. Dispatcharr verifies the signature on every manifest fetch. + +### Key Format + +Standard PGP/GPG armored keys: + +``` +-----BEGIN PGP PUBLIC KEY BLOCK----- + +mQINBG... +... +-----END PGP PUBLIC KEY BLOCK----- +``` + +### Signing Convention + +The signature is computed over the **canonical JSON** representation of the `manifest` object (not the entire response), plus a trailing newline: + +```bash +# Canonical format: compact JSON (no spaces) + trailing newline +jq -c '.manifest' manifest.json | gpg --armor --detach-sign +``` + +In code terms: +```python +import json +canonical = json.dumps(manifest_obj, separators=(",", ":")) + "\n" +``` + +> **Important:** The signing input must be `json.dumps(obj, separators=(",", ":")) + "\n"` - compact JSON with no whitespace, followed by exactly one newline. Any difference (pretty-printing, trailing spaces, key ordering changes) will cause verification to fail. + +### Manifest Structure for Signing + +Use the wrapped format so the signature sits alongside the manifest: + +```json +{ + "manifest": { + "registry_name": "...", + "plugins": [...] + }, + "signature": "-----BEGIN PGP SIGNATURE-----\n...\n-----END PGP SIGNATURE-----" +} +``` + +### Verification Results + +| Result | Meaning | UI Badge | +|--------|---------|----------| +| `true` | Valid signature | Green checkmark | +| `false` | Invalid signature or verification error | Red X | +| `null` | Not attempted (no signature, no key, or `python-gnupg` not installed) | Gray/neutral | + +### Signing Workflow Example + +```bash +# Generate a keypair (one-time) +gpg --gen-key + +# Export your public key (give this to repo users) +gpg --armor --export "your@email.com" > my-repo.pub + +# Build your manifest +cat > manifest.json << 'EOF' +{ + "manifest": { + "registry_name": "My Repo", + "root_url": "https://example.com/releases", + "plugins": [ + { + "slug": "my_plugin", + "name": "My Plugin", + "latest_version": "1.0.0", + "latest_url": "plugins/my_plugin/my_plugin-1.0.0.zip" + } + ] + } +} +EOF + +# Sign the manifest object (canonical JSON + newline) +jq -c '.manifest' manifest.json | gpg --armor --detach-sign > manifest.sig + +# Combine into final output +jq --arg sig "$(cat manifest.sig)" '.signature = $sig' manifest.json > signed_manifest.json +``` + +### Third-Party Key Management + +When a user adds your repo URL, they can paste your public key. Dispatcharr stores the key per-repo and uses it for verification. Users can update the key at any time from the repo management UI. + +If you don't provide a key and the repo is not the official Dispatcharr repo, signature verification is skipped (result: `null`). + +--- + +## Release Zip Format + +Each plugin release is a `.zip` archive. + +### Requirements + +- Must contain a `plugin.py` with a `Plugin` class, **or** a Python package with `__init__.py` exporting a `Plugin` class. +- Files can be at the top level of the zip or inside a single subdirectory. +- Optionally include `plugin.json` for metadata discovery without code execution. +- Optionally include `logo.png` for the plugin icon. + +### Size Limits + +- Maximum 2000 files per archive. +- Maximum total size: 200 MB (configurable via `MAX_PLUGIN_IMPORT_BYTES` setting). + +### Recommended Structure + +``` +my_plugin-1.0.0.zip + plugin.py + plugin.json + logo.png + (any other files your plugin needs) +``` + +Or with a subdirectory: +``` +my_plugin-1.0.0.zip + my_plugin/ + plugin.py + plugin.json + logo.png + utils.py +``` + +--- + +## Install Flow + +When a user installs a plugin from the store: + +1. **Version compatibility check** - if `min_dispatcharr_version` or `max_dispatcharr_version` is set, the running Dispatcharr version is compared. Install is blocked if out of range. +2. **Download** - the zip is streamed from `download_url` (max 200 MB). +3. **SHA256 integrity check** - if `sha256` was provided, the download is hashed and compared. Mismatch blocks the install. +4. **Extraction** - the zip is extracted to a temp directory, validated, then moved to `/data/plugins/{plugin_key}/`. If the plugin already exists, the old version is backed up and restored on failure (atomic rollback). +5. **Registration** - a `PluginConfig` record is created or updated, linking the plugin to its source repo and slug. +6. **Discovery reload** - the plugin loader re-scans all plugin directories. + +The plugin is installed **disabled** by default. The user can enable it from the post-install dialog or the My Plugins page. + +--- + +## Update Detection + +Dispatcharr detects updates by comparing `installed_version` (stored in the database) against `latest_version` from the repo manifest. This uses repo-level fields only - per-plugin manifests are not needed for update detection. + +A plugin shows "Update Available" when: +- It is managed (installed from a repo) +- Its `installed_version` differs from `latest_version` +- It was installed from the same repo + +--- + +## Hosting Options + +A plugin repo manifest is just a JSON file served over HTTPS. Some options: + +### GitHub Pages / Raw Content +Host your manifest and release zips in a GitHub repo. Use raw.githubusercontent.com URLs: +``` +https://raw.githubusercontent.com/myorg/my-plugins/main/manifest.json +``` + +Use `root_url` pointing to your releases branch/path so version URLs stay relative. + +### Static File Server +Any web server that serves JSON works. Dispatcharr fetches manifests server-side, so CORS is not needed. + +### GitHub Releases +You can host release zips as GitHub Release assets and reference them with absolute URLs in your manifest. The manifest itself can live in the repo's default branch. + +--- + +## Refresh Behavior + +- Manifests are refreshed automatically at a configurable interval (default: 6 hours, setting: `refresh_interval_hours`, 0 = disabled). +- Users can force a refresh from the repo management UI. +- A new repo is refreshed immediately when added. +- On refresh, if a plugin's `slug` disappears from the manifest, its `PluginConfig` is unlinked from the repo (becomes "unmanaged") but the installed files are not deleted. + +--- + +## Checklist: Publishing a Plugin Repo + +### Minimum Viable Repo + +- [ ] Host a JSON file at a stable, public URL +- [ ] Set `registry_name` (required, must not sound official) +- [ ] Include at least one plugin entry with `slug`, `name`, and `latest_version` +- [ ] Host a downloadable `.zip` for each plugin and set `latest_url` +- [ ] Share the manifest URL with users + +### Recommended + +- [ ] Set `root_url` so plugin URLs can be relative +- [ ] Include `description`, `author`, and `icon_url` per plugin +- [ ] Include `latest_sha256` for integrity verification +- [ ] Include `license` (SPDX identifier) +- [ ] Include `last_updated` timestamps +- [ ] Add a per-plugin `manifest_url` with version history +- [ ] Include `sha256` in every version object +- [ ] Include `min_dispatcharr_version` where applicable +- [ ] Include `plugin.json` in each release zip + +### Optional + +- [ ] Sign your manifest with GPG and publish your public key +- [ ] Set `registry_url` to enable automatic icon fallback +- [ ] Set `max_dispatcharr_version` if a plugin is incompatible with newer releases + +--- + +## Quick Reference: Repo Manifest Schema + +```json +{ + "manifest": { + "registry_name": "string (required)", + "registry_url": "string (optional)", + "root_url": "string (optional)", + "plugins": [ + { + "slug": "string (required)", + "name": "string (required)", + "description": "string", + "author": "string", + "license": "string (SPDX)", + "latest_version": "string (semver)", + "last_updated": "string (ISO 8601)", + "manifest_url": "string (URL or relative path)", + "latest_url": "string (URL or relative path)", + "latest_sha256": "string (64-char hex)", + "icon_url": "string (URL or relative path)", + "min_dispatcharr_version": "string (semver)", + "max_dispatcharr_version": "string (semver) or null" + } + ] + }, + "signature": "string (armored PGP signature, optional)" +} +``` + +## Quick Reference: Per-Plugin Manifest Schema + +```json +{ + "slug": "string", + "name": "string", + "description": "string", + "author": "string", + "license": "string (SPDX)", + "latest_version": "string (semver)", + "versions": [ + { + "version": "string (required)", + "url": "string (required, URL or relative path)", + "checksum_sha256": "string (64-char hex)", + "build_timestamp": "string (ISO 8601)", + "commit_sha": "string", + "commit_sha_short": "string", + "min_dispatcharr_version": "string (semver)", + "max_dispatcharr_version": "string (semver) or null" + } + ], + "latest": { + "version": "string", + "url": "string", + "checksum_sha256": "string", + "build_timestamp": "string", + "min_dispatcharr_version": "string", + "max_dispatcharr_version": "string or null" + } +} +``` diff --git a/Plugins.md b/Plugins.md new file mode 100644 index 0000000..d528f1e --- /dev/null +++ b/Plugins.md @@ -0,0 +1,518 @@ +# 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.json` manifest (new standard) and a `plugin.py` file: + +`/app/data/plugins/my_plugin/plugin.json` +```json +{ + "name": "My Plugin", + "version": "0.1.0", + "description": "Does something useful", + "author": "Acme Labs", + "help_url": "https://example.com/docs/my-plugin", + "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": [ + { + "id": "do_work", + "label": "Do Work", + "description": "Process items", + "button_label": "Run Job", + "button_variant": "filled", + "button_color": "blue" + } + ] +} +``` + +``` +# /app/data/plugins/my_plugin/plugin.py +class Plugin: + name = "My Plugin" + version = "0.1.0" + description = "Does something useful" + author = "Acme Labs" + help_url = "https://example.com/docs/my-plugin" + + # 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", + "button_label": "Run Job", + "button_variant": "filled", + "button_color": "blue", + }, + ] + + 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. +- New standard: include a `plugin.json` manifest alongside your code for safe metadata discovery. +- Optional: include `logo.png` next to `plugin.py` to show a logo in the UI. + +The directory name (lowercased, spaces as `_`) is used as the registry key. Plugins are imported under a safe internal package name; if the folder name is a valid identifier (and not reserved), it is also registered as an alias for convenience. + +--- + +## 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 reads `plugin.json` for metadata without executing plugin code. +- Plugin code is only imported and instantiated when the plugin is enabled. +- 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 Manifest (`plugin.json`) + +`plugin.json` lets Dispatcharr list your plugin safely without executing code. It should live next to `plugin.py`. + +Example: +``` +{ + "name": "My Plugin", + "version": "1.2.3", + "description": "Does something useful", + "author": "Acme Labs", + "help_url": "https://example.com/docs/my-plugin", + "fields": [ + { "id": "limit", "label": "Item limit", "type": "number", "default": 5 } + ], + "actions": [ + { + "id": "do_work", + "label": "Do Work", + "description": "Process items", + "button_label": "Run Job", + "button_variant": "filled", + "button_color": "blue" + } + ] +} +``` + +Notes: +- `author` and `help_url` are optional. If provided, the UI shows “By {author}” and a Docs link. +- If your plugin includes a `logo.png` file next to `plugin.py`, it will be shown on the plugin card. + +If `plugin.json` is missing or invalid, the plugin is treated as **legacy**: +- The name is inferred from the folder name. +- `logo.png` still displays if present. +- The UI shows a warning asking the developer to upgrade to the new standard. + +--- + +## Plugin Interface + +Export a `Plugin` class. Supported attributes and behavior: + +- `name` (str): Human-readable name. +- `version` (str): Semantic version string. +- `description` (str): Short description. +- `author` (str, optional): Author or team name shown on the card. +- `help_url` (str, optional): Docs/support link shown on the card. +- `fields` (list): Settings schema used by the UI to render controls. +- `actions` (list): Available actions; the UI renders a button for each (defaults to Run). +- `run(action, params, context)` (callable): Invoked when a user clicks an action. +- `stop(context)` (optional callable): Invoked when the plugin is disabled, deleted, or reloaded so you can gracefully shut down any processes you started. If `stop()` is not defined but you have an action with id `stop`, Dispatcharr will call `run("stop", {}, context)` as a fallback. + +### Settings Schema +Supported field `type`s: +- `boolean` +- `number` +- `string` (single-line text) +- `text` (multi-line textarea) +- `select` (requires `options`: `[{"value": ..., "label": ...}, ...]`) +- `info` (display-only text; useful for headings or notes) + +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` / `description` (str, optional): Shown under the control. +- `placeholder` (str, optional): Placeholder text for inputs. +- `input_type` (str, optional): For `string` fields, set to `"password"` to mask input. +- `options` (list, for select): List of `{value, label}`. + +Notes: +- For `info` fields, you can use `description`/`help_text` (or `value`) to show the text. + +The UI automatically renders settings and persists them. The backend stores settings in `PluginConfig.settings`. + +### Example: stop() Hook +``` +import signal + +class Plugin: + name = "Example Plugin" + version = "1.0.0" + description = "Shows how to shut down gracefully." + + def run(self, action: str, params: dict, context: dict): + # Start a subprocess or background task here and store its PID. + # Example: save pid in /data or in your own module-level variable. + return {"status": "ok"} + + def stop(self, context: dict): + logger = context.get("logger") + pid = self._read_pid() # your helper + if pid: + try: + os.kill(pid, signal.SIGTERM) + logger.info("Stopped process %s", pid) + except Exception: + logger.exception("Failed to stop process %s", pid) +``` + +Read settings in `run` via `context["settings"]`. + +### Actions +Each action is a dict: +- `id` (str): Unique action id. +- `label` (str): Action label. +- `description` (str, optional): Helper text. +- `button_label` (str, optional): Button text (defaults to “Run”). +- `button_variant` (str, optional): Button style (Mantine variants like `filled`, `outline`, `subtle`). +- `button_color` (str, optional): Button color (e.g., `red`, `blue`, `orange`). + +Clicking an action calls your plugin’s `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. + +### Important: Don’t Ask Users for URL/User/Password +Dispatcharr plugins run **inside** the Dispatcharr backend process. That means they already have direct access to the app’s models, tasks, and internal utilities. +Plugins **should not** ask users for “Dispatcharr URL”, “Admin Username”, or “Admin Password” just to call the API. That is unnecessary and unsafe because: + +- It encourages users to enter privileged credentials. +- Malicious plugins could exfiltrate credentials. +- It duplicates access that plugins already have internally. + +If you are writing a plugin, **use internal Python APIs** (models/tasks/utils) instead of making HTTP calls with user credentials. + +### When You Do Need HTTP +In rare cases you may need to call a Dispatcharr HTTP endpoint (for example, to reuse an existing API response serializer). In that case: + +1. **Do not ask the user for credentials.** + Use the backend’s internal access where possible. + +2. Prefer **local/internal URLs** (never user-provided): + - Docker: `http://web:9191` (service name inside the container network) + - Dev: `http://127.0.0.1:5656` + +3. Use Django helpers when building URLs: + ``` + from django.urls import reverse + path = reverse("api:channels:list") # example name + url = f"http://127.0.0.1:5656{path}" + ``` + +4. Use a short timeout and robust error handling: + ``` + import requests + resp = requests.get(url, timeout=10) + resp.raise_for_status() + data = resp.json() + ``` + +### Examples: Preferred Internal Access (No HTTP, No Credentials) + +**Example 1: List channels directly from the DB** +``` +from apps.channels.models import Channel + +channels = Channel.objects.all().values("id", "name", "number")[:50] +return {"status": "ok", "channels": list(channels)} +``` + +**Example 2: Kick off an existing refresh task** +``` +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"} +``` + +**Example 3: Send a WebSocket update to the UI** +``` +from core.utils import send_websocket_update + +send_websocket_update( + "updates", + "update", + {"type": "plugin", "plugin": "my_plugin", "message": "Refresh queued"} +) +``` + +### Example: HTTP Access (Only If You Must) + +**Find the endpoint** +- Use `reverse()` with the named route when possible. +- If you don’t know the route name, inspect `apps/*/api_urls.py` or Django’s URL config to find it. + +``` +from django.urls import reverse +import requests + +path = reverse("api:channels:list") # named route from apps/channels/api_urls.py +url = f"http://127.0.0.1:5656{path}" + +resp = requests.get(url, timeout=10) +resp.raise_for_status() +data = resp.json() +``` + +### How Developers Find the API + +1. **Prefer internal models/tasks** (best and safest). +2. **Check `apps/*/api_urls.py`** for named routes and endpoint patterns. + - Example: `apps/channels/api_urls.py` for channel endpoints. +3. **Find the view** referenced in the URL config to see required params. + - Example: `apps/channels/api_views.py` or `apps/epg/api_views.py`. +4. **Use `reverse()`** with the named route to build the path. + - This avoids hardcoding paths and keeps plugins compatible if URLs change. +5. **Only use internal hostnames** (never user-provided URL). + +### What Plugins Can Access +Because plugins run inside the server process, they can: +- Read and write database models (same permissions as the app) +- Invoke Celery tasks +- Send websocket updates +- Read configuration and settings + +Treat plugins as **trusted server code** and avoid exposing sensitive data in plugin settings or logs. + +--- + +## 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//settings/` with `{"settings": {...}}` +- Run action: `POST /api/plugins/plugins//run/` with `{"action": "id", "params": {...}}` +- Enable/disable: `POST /api/plugins/plugins//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`). +- Include `plugin.json` in the plugin folder to provide metadata without executing code. +- On success, the UI shows the plugin name/description and lets you enable it immediately (plugins are disabled by default). + - If `plugin.json` is missing, the plugin is marked as legacy and the UI will show a warning. + +--- + +## 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. +- Dispatcharr will not import or execute plugin code unless the plugin is enabled. + +--- + +## 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: ensure the folder contains `plugin.py` or a package `__init__.py`. Folder names with spaces or dashes are supported; if you need to import by folder name inside your plugin, use a valid Python identifier. +- 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` diff --git a/README.md b/README.md new file mode 100644 index 0000000..53850d9 --- /dev/null +++ b/README.md @@ -0,0 +1,179 @@ +# 🎬 Dispatcharr — Your Ultimate IPTV & Stream Management Companion + +

+ Dispatcharr Logo +

+ +--- + +## 📖 What is Dispatcharr? + +Dispatcharr (pronounced like "dispatcher") is an **open-source powerhouse** for managing IPTV streams, EPG data, and VOD content with elegance and control.\ +Born from necessity and built with passion, it started as a personal project by **[OkinawaBoss](https://github.com/OkinawaBoss)** and evolved with contributions from legends like **[dekzter](https://github.com/dekzter)**, **[SergeantPanda](https://github.com/SergeantPanda)** and **Bucatini**. + +> Think of Dispatcharr as the \*arr family's IPTV cousin — simple, smart, and designed for streamers who want reliability and flexibility. + +--- + +## 🎯 What Can I Do With Dispatcharr? + +Dispatcharr empowers you with complete IPTV control. Here are some real-world scenarios: + +💡 **Consolidate Multiple IPTV Sources**\ +Combine streams from multiple providers into a single interface. Manage, filter, and organize thousands of channels with ease. + +📺 **Integrate with Media Centers**\ +Use HDHomeRun emulation to add virtual tuners to **Plex**, **Emby**, or **Jellyfin**. They'll discover Dispatcharr as a live TV source and can record programs directly to their own DVR libraries. + +📡 **Create a Personal TV Ecosystem**\ +Merge live TV channels with custom EPG guides. Generate XMLTV schedules or use auto-matching to align channels with existing program data. Export as M3U, Xtream Codes API, or HDHomeRun device. + +🔧 **Transcode & Optimize Streams**\ +Configure output profiles with FFmpeg transcoding to optimize streams for different clients — reduce bandwidth, standardize formats, or add audio normalization. + +🔐 **Centralize VPN Access**\ +Run Dispatcharr through a VPN container (like Gluetun) so all streams route through a single VPN connection. Your clients access geo-blocked content without needing individual VPNs, reducing bandwidth overhead and simplifying network management. + +🚀 **Monitor & Manage in Real-Time**\ +Track active streams, client connections, and bandwidth usage with live statistics. Monitor buffering events and stream quality. Automatic failover keeps viewers connected when streams fail—seamlessly switching to backup sources without interruption. + +👥 **Share Access Safely**\ +Create multiple user accounts with granular permissions. Share streams via M3U playlists or Xtream Codes API while controlling which users access which channels, profiles, or features. Network-based access restrictions available for additional security. + +🔌 **Extend with Plugins**\ +Build custom integrations using Dispatcharr's robust plugin system. Automate tasks, connect to external services, or add entirely new workflows. + +--- + +## ✨ Why You'll Love Dispatcharr + +✅ **Stream Proxy & Relay** — Intercept and proxy IPTV streams with real-time client management\ +✅ **M3U & Xtream Codes** — Import, filter, and organize playlists with multiple backend support\ +✅ **EPG Matching & Generation** — Auto-match EPG to channels or generate custom TV guides\ +✅ **Video on Demand** — Stream movies and TV series with rich metadata and IMDB/TMDB integration\ +✅ **Multi-Format Output** — Export as M3U, XMLTV EPG, Xtream Codes API, or HDHomeRun device\ +✅ **Real-Time Monitoring** — Live connection stats, bandwidth tracking, and automatic failover\ +✅ **Stream Profiles** — Configure different stream profiles for various clients and bandwidth requirements\ +✅ **Flexible Streaming Backends** — VLC, FFmpeg, Streamlink, or custom backends for transcoding and streaming\ +✅ **Multi-User & Access Control** — Granular permissions and network-based access restrictions\ +✅ **Plugin System** — Extend functionality with custom plugins for automation and integrations\ +✅ **Fully Self-Hosted** — Total control, no third-party dependencies + +--- + +# Screenshots + +
+ Channels + TV Guide + Stats & Monitoring + M3U & EPG Manager + VOD Library + Settings +
+ +--- + +## 🛠️ Troubleshooting & Help + +- **General help?** Visit [Dispatcharr Docs](https://dispatcharr.github.io/Dispatcharr-Docs/) +- **Community support?** Join our [Discord](https://discord.gg/Sp45V5BcxU) + +--- + +## 🚀 Get Started in Minutes + +### 🐳 Quick Start with Docker (Recommended) + +```bash +docker pull ghcr.io/dispatcharr/dispatcharr:latest +docker run -d \ + -p 9191:9191 \ + --name dispatcharr \ + -v dispatcharr_data:/data \ + ghcr.io/dispatcharr/dispatcharr:latest +``` + +> Customize ports and volumes to fit your setup. + +--- + +### 🐋 Docker Compose Options + +| Use Case | File | Description | +| --------------------------- | ------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------- | +| **All-in-One Deployment** | [docker-compose.aio.yml](docker/docker-compose.aio.yml) | ⭐ Recommended! A simple, all-in-one solution — everything runs in a single container for quick setup. | +| **Modular Deployment** | [docker-compose.yml](docker/docker-compose.yml) | Separate containers for Dispatcharr, Celery, Redis, and Postgres — perfect if you want more granular control. | +| **Development Environment** | [docker-compose.dev.yml](docker/docker-compose.dev.yml) | Developer-friendly setup with pre-configured ports and settings for contributing and testing. | + +--- + +### 🛠️ Building from Source + +> ⚠️ **Warning**: Not officially supported — but if you're here, you know what you're doing! + +If you are running a Debian-based OS, use the `debian_install.sh` script. For other OS, contribute a script and we’ll add it! + +--- + +## 🤝 Want to Contribute? + +We welcome **PRs, issues, ideas, and suggestions**! + +- Prior to contributing, please read the [CONTRIBUTING.md](https://github.com/Dispatcharr/Dispatcharr/blob/main/CONTRIBUTING.md) + +> Whether it's writing docs, squashing bugs, or building new features, your contribution matters! 🙋 + +--- + +## 📚 Documentation & Roadmap + +- 📖 **Documentation:** [Dispatcharr Docs](https://dispatcharr.github.io/Dispatcharr-Docs/) + +**Upcoming Features (in no particular order):** + +- 🎬 **VOD Management Enhancements** — Granular metadata control and cleanup of unwanted VOD content +- 📁 **Media Library** — Import local files and serve them over XC API +- 👥 **Enhanced User Management** — Customizable XC API output per user account +- 🔄 **Output Stream Profiles** — Different clients with different stream profiles (bandwidth control, quality tiers) +- 🔌 **Fallback Videos** — Automatic fallback content when channels are unavailable + +--- + +## ❤️ Shoutouts + +A huge thank you to all the incredible open-source projects and libraries that power Dispatcharr. We stand on the shoulders of giants! + +--- + +## ✉️ Connect With Us + +Have a question? Want to suggest a feature? Just want to say hi?\ +➡️ **[Open an issue](https://github.com/Dispatcharr/Dispatcharr/issues)** or reach out on [Discord](https://discord.gg/Sp45V5BcxU). + +--- + +## 💖 Support Dispatcharr + +[![Dispatcharr Open Collective](https://opencollective.com/dispatcharr/tiers/badge.svg)](https://opencollective.com/dispatcharr/contribute) + +Open Collective provides a transparent way for anyone who finds value in Dispatcharr to support things like: +• Infrastructure costs (Domains, Servers, etc.) +• Apple Developer Program and Google Play Developer accounts +• Helping contributors dedicate more time to improving the project + +Support is completely optional, and Dispatcharr will always remain free and open-source. + +[Contribute here](https://opencollective.com/dispatcharr/contribute) + +--- + +## ⚖️ License & Legal + +Dispatcharr is licensed under **GNU AGPL v3.0**: For full license details, see [LICENSE](https://www.gnu.org/licenses/agpl-3.0.html). + +Dispatcharr is a trademark of the Dispatcharr project. Use of the Dispatcharr name or logo requires permission from the maintainers. + +--- + +### 🚀 _Happy Streaming! The Dispatcharr Team_ diff --git a/apps/accounts/__init__.py b/apps/accounts/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/accounts/admin.py b/apps/accounts/admin.py new file mode 100644 index 0000000..90aa84f --- /dev/null +++ b/apps/accounts/admin.py @@ -0,0 +1,16 @@ +from django.contrib import admin +from django.contrib.auth.admin import UserAdmin, GroupAdmin +from django.contrib.auth.models import Group +from .models import User + +@admin.register(User) +class CustomUserAdmin(UserAdmin): + fieldsets = ( + (None, {'fields': ('username', 'password', 'avatar_config', 'groups')}), + ('Permissions', {'fields': ('is_staff', 'is_superuser', 'user_permissions')}), + ('Important dates', {'fields': ('last_login', 'date_joined')}), + ) + +# Unregister default Group admin and re-register it. +admin.site.unregister(Group) +admin.site.register(Group, GroupAdmin) diff --git a/apps/accounts/api_urls.py b/apps/accounts/api_urls.py new file mode 100644 index 0000000..27de8eb --- /dev/null +++ b/apps/accounts/api_urls.py @@ -0,0 +1,42 @@ +from django.urls import path, include +from rest_framework.routers import DefaultRouter +from .api_views import ( + AuthViewSet, + UserViewSet, + GroupViewSet, + APIKeyViewSet, + TokenObtainPairView, + TokenRefreshView, + list_permissions, + initialize_superuser, +) +from rest_framework_simplejwt import views as jwt_views + +app_name = "accounts" + +# 🔹 Register ViewSets with a Router +router = DefaultRouter() +router.register(r"users", UserViewSet, basename="user") +router.register(r"groups", GroupViewSet, basename="group") +router.register(r"api-keys", APIKeyViewSet, basename="api-key") + +# 🔹 Custom Authentication Endpoints +auth_view = AuthViewSet.as_view({"post": "login"}) + +logout_view = AuthViewSet.as_view({"post": "logout"}) + +# 🔹 Define API URL patterns +urlpatterns = [ + # Authentication + path("auth/login/", auth_view, name="user-login"), + path("auth/logout/", logout_view, name="user-logout"), + # Superuser API + path("initialize-superuser/", initialize_superuser, name="initialize_superuser"), + # Permissions API + path("permissions/", list_permissions, name="list-permissions"), + path("token/", TokenObtainPairView.as_view(), name="token_obtain_pair"), + path("token/refresh/", TokenRefreshView.as_view(), name="token_refresh"), +] + +# 🔹 Include ViewSet routes +urlpatterns += router.urls diff --git a/apps/accounts/api_views.py b/apps/accounts/api_views.py new file mode 100644 index 0000000..2ac410f --- /dev/null +++ b/apps/accounts/api_views.py @@ -0,0 +1,366 @@ +from django.contrib.auth import authenticate, login, logout +import logging +from django.contrib.auth.models import Group, Permission +from django.http import JsonResponse, HttpResponse +from django.views.decorators.csrf import csrf_exempt +from rest_framework.decorators import api_view, permission_classes, action +from rest_framework.response import Response +from rest_framework import viewsets, status, serializers +from rest_framework.throttling import AnonRateThrottle +from drf_spectacular.utils import extend_schema, OpenApiParameter, inline_serializer +from drf_spectacular.types import OpenApiTypes +import json +import secrets +from .permissions import IsAdmin, Authenticated +from dispatcharr.utils import network_access_allowed + +from .models import User +from .serializers import UserSerializer, GroupSerializer, PermissionSerializer +from rest_framework_simplejwt.views import TokenObtainPairView, TokenRefreshView + +logger = logging.getLogger(__name__) + + +class LoginRateThrottle(AnonRateThrottle): + scope = "login" + + +class TokenObtainPairView(TokenObtainPairView): + throttle_classes = [LoginRateThrottle] + + def post(self, request, *args, **kwargs): + 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') + logger.info(f"Login blocked by network policy: user={username} ip={client_ip} ua={user_agent}") + 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 + username = request.data.get("username") + + # 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') + + try: + logger.debug(f"Attempting JWT login for user={username}") + 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, + ) + logger.info(f"Login success: user={username} ip={client_ip}") + 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', + ) + logger.info(f"Login failed: user={username} ip={client_ip}") + + 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]}', + ) + logger.error(f"Login error for user={username}: {e}") + 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') + logger.info(f"Token refresh blocked by network policy: ip={client_ip} ua={user_agent}") + 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) + + +@csrf_exempt # In production, consider CSRF protection strategies or ensure this endpoint is only accessible when no superuser exists. +def initialize_superuser(request): + # If an admin-level user already exists, the system is configured + if User.objects.filter(user_level__gte=10).exists(): + return JsonResponse({"superuser_exists": True}) + + if request.method == "POST": + try: + data = json.loads(request.body) + username = data.get("username") + password = data.get("password") + email = data.get("email", "") + if not username or not password: + return JsonResponse( + {"error": "Username and password are required."}, status=400 + ) + # Create the superuser + User.objects.create_superuser( + username=username, password=password, email=email, user_level=10 + ) + return JsonResponse({"superuser_exists": True}) + except Exception as e: + return JsonResponse({"error": str(e)}, status=500) + # For GET requests, indicate no superuser exists + return JsonResponse({"superuser_exists": False}) + + +# 🔹 1) Authentication APIs +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': + return [Authenticated()] + return [] + + @extend_schema( + description="Alias for POST /api/accounts/token/ — returns JWT access and refresh tokens.", + request=inline_serializer( + name="LoginRequest", + fields={ + "username": serializers.CharField(), + "password": serializers.CharField(), + }, + ), + ) + def login(self, request): + """Delegates to TokenObtainPairView (JWT login). Throttling, logging, and + network access checks are handled there.""" + view = TokenObtainPairView.as_view() + return view(request._request) + + @extend_schema( + description="Log out the current user", + ) + 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, + ) + logger.info(f"Logout: user={username} ip={client_ip}") + + logout(request) + return Response({"message": "Logout successful"}) + + +# 🔹 2) User Management APIs +class UserViewSet(viewsets.ModelViewSet): + """Handles CRUD operations for Users""" + + queryset = User.objects.all().prefetch_related('channel_profiles') + serializer_class = UserSerializer + + def get_permissions(self): + if self.action == "me": + return [Authenticated()] + + return [IsAdmin()] + + @extend_schema( + description="Retrieve a list of users", + responses={200: UserSerializer(many=True)}, + ) + def list(self, request, *args, **kwargs): + return super().list(request, *args, **kwargs) + + @extend_schema(description="Retrieve a specific user by ID") + def retrieve(self, request, *args, **kwargs): + return super().retrieve(request, *args, **kwargs) + + @extend_schema(description="Create a new user") + def create(self, request, *args, **kwargs): + return super().create(request, *args, **kwargs) + + @extend_schema(description="Update a user") + def update(self, request, *args, **kwargs): + return super().update(request, *args, **kwargs) + + @extend_schema(description="Delete a user") + def destroy(self, request, *args, **kwargs): + return super().destroy(request, *args, **kwargs) + + @extend_schema( + description="Get or update active user information. PATCH updates custom_properties with merge semantics.", + methods=["GET", "PATCH"], + ) + @action(detail=False, methods=["get", "patch"], url_path="me") + def me(self, request): + user = request.user + if request.method == "PATCH": + ALLOWED_FIELDS = {"custom_properties", "first_name", "last_name", "email", "password"} + disallowed = set(request.data.keys()) - ALLOWED_FIELDS + + for key in disallowed: + request.data.pop(key, None) + + # Strip admin-managed keys from custom_properties so users cannot + # set their own XC credentials via this endpoint. + ADMIN_ONLY_PROPS = {"xc_password"} + cp = request.data.get("custom_properties") + if isinstance(cp, dict): + for key in ADMIN_ONLY_PROPS: + cp.pop(key, None) + + serializer = UserSerializer(user, data=request.data, partial=True) + serializer.is_valid(raise_exception=True) + serializer.save() + return Response(serializer.data) + serializer = UserSerializer(user) + return Response(serializer.data) + + +# 🔹 3) Group Management APIs +class GroupViewSet(viewsets.ModelViewSet): + """Handles CRUD operations for Groups""" + + queryset = Group.objects.all() + serializer_class = GroupSerializer + permission_classes = [Authenticated] + + @extend_schema( + description="Retrieve a list of groups", + responses={200: GroupSerializer(many=True)}, + ) + def list(self, request, *args, **kwargs): + return super().list(request, *args, **kwargs) + + @extend_schema(description="Retrieve a specific group by ID") + def retrieve(self, request, *args, **kwargs): + return super().retrieve(request, *args, **kwargs) + + @extend_schema(description="Create a new group") + def create(self, request, *args, **kwargs): + return super().create(request, *args, **kwargs) + + @extend_schema(description="Update a group") + def update(self, request, *args, **kwargs): + return super().update(request, *args, **kwargs) + + @extend_schema(description="Delete a group") + def destroy(self, request, *args, **kwargs): + return super().destroy(request, *args, **kwargs) + + +# API Key management +class APIKeyViewSet(viewsets.ViewSet): + permission_classes = [Authenticated] + + def list(self, request): + user = request.user + return Response({"key": user.api_key}) + + @action(detail=False, methods=["post"], url_path="generate") + def generate(self, request): + target_user = request.user + user_id = request.data.get("user_id") + + if user_id: + from .permissions import IsAdmin + + if not IsAdmin().has_permission(request, self): + return Response({"detail": "Not allowed to create keys for other users."}, status=status.HTTP_403_FORBIDDEN) + + try: + target_user = User.objects.get(id=int(user_id)) + except Exception: + return Response({"detail": "User not found."}, status=status.HTTP_404_NOT_FOUND) + + raw = secrets.token_urlsafe(40) + target_user.api_key = raw + target_user.save(update_fields=["api_key"]) + + user_data = UserSerializer(target_user).data + return Response({"key": raw, "user": user_data}, status=status.HTTP_201_CREATED) + + @action(detail=False, methods=["post"], url_path="revoke") + def revoke(self, request): + target_user = request.user + user_id = request.data.get("user_id") + + if user_id: + from .permissions import IsAdmin + + if not IsAdmin().has_permission(request, self): + return Response({"detail": "Not allowed to revoke keys for other users."}, status=status.HTTP_403_FORBIDDEN) + + try: + target_user = User.objects.get(id=int(user_id)) + except Exception: + return Response({"detail": "User not found."}, status=status.HTTP_404_NOT_FOUND) + + target_user.api_key = None + target_user.save(update_fields=["api_key"]) + + return Response({"success": True}) + + +# 🔹 4) Permissions List API +@extend_schema( + description="Retrieve a list of all permissions", + responses={200: PermissionSerializer(many=True)}, +) +@api_view(["GET"]) +@permission_classes([Authenticated]) +def list_permissions(request): + """Returns a list of all available permissions""" + permissions = Permission.objects.all() + serializer = PermissionSerializer(permissions, many=True) + return Response(serializer.data) diff --git a/apps/accounts/apps.py b/apps/accounts/apps.py new file mode 100644 index 0000000..603ea84 --- /dev/null +++ b/apps/accounts/apps.py @@ -0,0 +1,7 @@ +from django.apps import AppConfig + + +class AccountsConfig(AppConfig): + default_auto_field = "django.db.models.BigAutoField" + name = "apps.accounts" + verbose_name = "Accounts & Authentication" diff --git a/apps/accounts/authentication.py b/apps/accounts/authentication.py new file mode 100644 index 0000000..f5169e7 --- /dev/null +++ b/apps/accounts/authentication.py @@ -0,0 +1,86 @@ +from rest_framework import authentication +from rest_framework import exceptions +from django.conf import settings +from drf_spectacular.extensions import OpenApiAuthenticationExtension +from .models import User + + +class JWTAuthenticationScheme(OpenApiAuthenticationExtension): + target_class = "rest_framework_simplejwt.authentication.JWTAuthentication" + name = "jwtAuth" + + def get_security_definition(self, auto_schema): + return { + "type": "http", + "scheme": "bearer", + "bearerFormat": "JWT", + "description": ( + "JWT Bearer authentication.\n\n" + "Obtain a token pair via `POST /api/accounts/token/` using your username and password, " + "then paste the **access token** here — Swagger adds the `Bearer ` prefix automatically.\n\n" + "Access tokens expire after 30 minutes. Refresh using `POST /api/accounts/token/refresh/`." + ), + } + + +class ApiKeyAuthenticationScheme(OpenApiAuthenticationExtension): + target_class = "apps.accounts.authentication.ApiKeyAuthentication" + name = "ApiKeyAuth" + + def get_security_definition(self, auto_schema): + return { + "type": "apiKey", + "in": "header", + "name": "X-API-Key", + "description": ( + "API key authentication.\n\n" + "Pass your personal API key in the `X-API-Key` request header. " + "Keys can be generated via `POST /api/accounts/api-keys/generate/` " + "and revoked via `POST /api/accounts/api-keys/revoke/`." + ), + } + + +class ApiKeyAuthentication(authentication.BaseAuthentication): + """ + Accepts header `Authorization: ApiKey ` or `X-API-Key: `. + """ + + keyword = "ApiKey" + + def authenticate(self, request): + # Check X-API-Key header first + raw_key = request.META.get("HTTP_X_API_KEY") + + if not raw_key: + auth = authentication.get_authorization_header(request).split() + if not auth: + return None + + if len(auth) != 2: + return None + + scheme = auth[0].decode().lower() + if scheme != self.keyword.lower(): + return None + + raw_key = auth[1].decode() + + if not raw_key: + return None + + if not raw_key: + return None + + try: + user = User.objects.get(api_key=raw_key) + except User.DoesNotExist: + raise exceptions.AuthenticationFailed("Invalid API key") + + if not user.is_active: + raise exceptions.AuthenticationFailed("User inactive") + + return (user, None) + + def authenticate_header(self, request): + return self.keyword diff --git a/apps/accounts/forms.py b/apps/accounts/forms.py new file mode 100644 index 0000000..76fcc8a --- /dev/null +++ b/apps/accounts/forms.py @@ -0,0 +1,59 @@ +from django import forms +from django.contrib.auth.forms import UserCreationForm +from django.contrib.auth.models import Permission +from django.contrib.auth.models import Group as AuthGroup +from apps.channels.models import ChannelGroup +from .models import User + +from .models import User + + +class UserRegistrationForm(UserCreationForm): + groups = forms.ModelMultipleChoiceField( + queryset=AuthGroup.objects.all(), + required=False, + widget=forms.CheckboxSelectMultiple + ) + + class Meta: + model = User + fields = ['username', 'groups', 'password1', 'password2', ] + + def save(self, commit=True): + user = super().save(commit=False) + if commit: + user.save() + self.save_m2m() # Save the many-to-many field data + return user + + + +class GroupForm(forms.ModelForm): + permissions = forms.ModelMultipleChoiceField( + queryset=Permission.objects.all(), + widget=forms.CheckboxSelectMultiple, + required=False + ) + + class Meta: + model = AuthGroup + fields = ['name', 'permissions'] + + +class UserEditForm(forms.ModelForm): + auth_groups = forms.ModelMultipleChoiceField( + queryset=AuthGroup.objects.all(), + widget=forms.CheckboxSelectMultiple, + required=False, + label="Auth Groups" + ) + channel_groups = forms.ModelMultipleChoiceField( + queryset=ChannelGroup.objects.all(), + widget=forms.CheckboxSelectMultiple, + required=False, + label="Channel Groups" + ) + + class Meta: + model = User + fields = ['username', 'email', 'auth_groups', 'channel_groups'] diff --git a/apps/accounts/migrations/0001_initial.py b/apps/accounts/migrations/0001_initial.py new file mode 100644 index 0000000..1df20e3 --- /dev/null +++ b/apps/accounts/migrations/0001_initial.py @@ -0,0 +1,47 @@ +# Generated by Django 5.1.6 on 2025-03-05 22:07 + +import django.contrib.auth.models +import django.contrib.auth.validators +import django.utils.timezone +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ('auth', '0012_alter_user_first_name_max_length'), + ('dispatcharr_channels', '0001_initial'), + ] + + operations = [ + migrations.CreateModel( + name='User', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('password', models.CharField(max_length=128, verbose_name='password')), + ('last_login', models.DateTimeField(blank=True, null=True, verbose_name='last login')), + ('is_superuser', models.BooleanField(default=False, help_text='Designates that this user has all permissions without explicitly assigning them.', verbose_name='superuser status')), + ('username', models.CharField(error_messages={'unique': 'A user with that username already exists.'}, help_text='Required. 150 characters or fewer. Letters, digits and @/./+/-/_ only.', max_length=150, unique=True, validators=[django.contrib.auth.validators.UnicodeUsernameValidator()], verbose_name='username')), + ('first_name', models.CharField(blank=True, max_length=150, verbose_name='first name')), + ('last_name', models.CharField(blank=True, max_length=150, verbose_name='last name')), + ('email', models.EmailField(blank=True, max_length=254, verbose_name='email address')), + ('is_staff', models.BooleanField(default=False, help_text='Designates whether the user can log into this admin site.', verbose_name='staff status')), + ('is_active', models.BooleanField(default=True, help_text='Designates whether this user should be treated as active. Unselect this instead of deleting accounts.', verbose_name='active')), + ('date_joined', models.DateTimeField(default=django.utils.timezone.now, verbose_name='date joined')), + ('avatar_config', models.JSONField(blank=True, default=dict, null=True)), + ('channel_groups', models.ManyToManyField(blank=True, related_name='users', to='dispatcharr_channels.channelgroup')), + ('groups', models.ManyToManyField(blank=True, help_text='The groups this user belongs to. A user will get all permissions granted to each of their groups.', related_name='user_set', related_query_name='user', to='auth.group', verbose_name='groups')), + ('user_permissions', models.ManyToManyField(blank=True, help_text='Specific permissions for this user.', related_name='user_set', related_query_name='user', to='auth.permission', verbose_name='user permissions')), + ], + options={ + 'verbose_name': 'user', + 'verbose_name_plural': 'users', + 'abstract': False, + }, + managers=[ + ('objects', django.contrib.auth.models.UserManager()), + ], + ), + ] diff --git a/apps/accounts/migrations/0002_remove_user_channel_groups_user_channel_profiles_and_more.py b/apps/accounts/migrations/0002_remove_user_channel_groups_user_channel_profiles_and_more.py new file mode 100644 index 0000000..2a09577 --- /dev/null +++ b/apps/accounts/migrations/0002_remove_user_channel_groups_user_channel_profiles_and_more.py @@ -0,0 +1,43 @@ +# Generated by Django 5.1.6 on 2025-05-18 15:47 + +from django.db import migrations, models + + +def set_user_level_to_10(apps, schema_editor): + User = apps.get_model("accounts", "User") + User.objects.update(user_level=10) + + +class Migration(migrations.Migration): + + dependencies = [ + ("accounts", "0001_initial"), + ("dispatcharr_channels", "0021_channel_user_level"), + ] + + operations = [ + migrations.RemoveField( + model_name="user", + name="channel_groups", + ), + migrations.AddField( + model_name="user", + name="channel_profiles", + field=models.ManyToManyField( + blank=True, + related_name="users", + to="dispatcharr_channels.channelprofile", + ), + ), + migrations.AddField( + model_name="user", + name="user_level", + field=models.IntegerField(default=0), + ), + migrations.AddField( + model_name="user", + name="custom_properties", + field=models.TextField(blank=True, null=True), + ), + migrations.RunPython(set_user_level_to_10), + ] diff --git a/apps/accounts/migrations/0003_alter_user_custom_properties.py b/apps/accounts/migrations/0003_alter_user_custom_properties.py new file mode 100644 index 0000000..20411f7 --- /dev/null +++ b/apps/accounts/migrations/0003_alter_user_custom_properties.py @@ -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), + ), + ] diff --git a/apps/accounts/migrations/0004_user_api_key.py b/apps/accounts/migrations/0004_user_api_key.py new file mode 100644 index 0000000..fee7c98 --- /dev/null +++ b/apps/accounts/migrations/0004_user_api_key.py @@ -0,0 +1,18 @@ +# Generated by Django 5.2.11 on 2026-02-21 18:14 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('accounts', '0003_alter_user_custom_properties'), + ] + + operations = [ + migrations.AddField( + model_name='user', + name='api_key', + field=models.CharField(blank=True, db_index=True, max_length=200, null=True), + ), + ] diff --git a/apps/accounts/migrations/0005_alter_user_managers.py b/apps/accounts/migrations/0005_alter_user_managers.py new file mode 100644 index 0000000..af66e12 --- /dev/null +++ b/apps/accounts/migrations/0005_alter_user_managers.py @@ -0,0 +1,20 @@ +# Generated by Django 5.2.11 on 2026-02-26 19:24 + +import apps.accounts.models +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('accounts', '0004_user_api_key'), + ] + + operations = [ + migrations.AlterModelManagers( + name='user', + managers=[ + ('objects', apps.accounts.models.CustomUserManager()), + ], + ), + ] diff --git a/apps/accounts/migrations/0006_user_stream_limit.py b/apps/accounts/migrations/0006_user_stream_limit.py new file mode 100644 index 0000000..5714e1e --- /dev/null +++ b/apps/accounts/migrations/0006_user_stream_limit.py @@ -0,0 +1,18 @@ +# Generated by Django 5.2.11 on 2026-03-19 13:46 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('accounts', '0005_alter_user_managers'), + ] + + operations = [ + migrations.AddField( + model_name='user', + name='stream_limit', + field=models.IntegerField(default=0), + ), + ] diff --git a/apps/accounts/migrations/__init__.py b/apps/accounts/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/accounts/models.py b/apps/accounts/models.py new file mode 100644 index 0000000..2a32f95 --- /dev/null +++ b/apps/accounts/models.py @@ -0,0 +1,48 @@ +# apps/accounts/models.py +from django.db import models +from django.contrib.auth.models import AbstractUser, Permission, UserManager + + +class CustomUserManager(UserManager): + def create_superuser(self, username, email=None, password=None, **extra_fields): + extra_fields.setdefault('user_level', 10) + return super().create_superuser(username, email, password, **extra_fields) + + +class User(AbstractUser): + objects = CustomUserManager() + """ + Custom user model for Dispatcharr. + Inherits from Django's AbstractUser to add additional fields if needed. + """ + + class UserLevel(models.IntegerChoices): + STREAMER = 0, "Streamer" + STANDARD = 1, "Standard User" + ADMIN = 10, "Admin" + + avatar_config = models.JSONField(default=dict, blank=True, null=True) + channel_profiles = models.ManyToManyField( + "dispatcharr_channels.ChannelProfile", + blank=True, + related_name="users", + ) + user_level = models.IntegerField(default=UserLevel.STREAMER) + custom_properties = models.JSONField(default=dict, blank=True, null=True) + api_key = models.CharField(max_length=200, blank=True, null=True, db_index=True) + stream_limit = models.IntegerField(default=0) + + def __str__(self): + return self.username + + def get_groups(self): + """ + Returns the groups (roles) the user belongs to. + """ + return self.groups.all() + + def get_permissions(self): + """ + Returns the permissions assigned to the user and their groups. + """ + return self.user_permissions.all() | Permission.objects.filter(group__user=self) diff --git a/apps/accounts/permissions.py b/apps/accounts/permissions.py new file mode 100644 index 0000000..6267303 --- /dev/null +++ b/apps/accounts/permissions.py @@ -0,0 +1,56 @@ +from rest_framework.permissions import IsAuthenticated +from .models import User +from dispatcharr.utils import network_access_allowed + + +class Authenticated(IsAuthenticated): + def has_permission(self, request, view): + is_authenticated = super().has_permission(request, view) + network_allowed = network_access_allowed(request, "UI") + + return is_authenticated and network_allowed + + +class IsStandardUser(Authenticated): + def has_permission(self, request, view): + if not super().has_permission(request, view): + return False + + return request.user and request.user.user_level >= User.UserLevel.STANDARD + + +class IsAdmin(Authenticated): + def has_permission(self, request, view): + if not super().has_permission(request, view): + return False + + return request.user.user_level >= 10 + + +class IsOwnerOfObject(Authenticated): + def has_object_permission(self, request, view, obj): + if not super().has_permission(request, view): + return False + + is_admin = IsAdmin().has_permission(request, view) + is_owner = request.user in obj.users.all() + + return is_admin or is_owner + + +permission_classes_by_action = { + "list": [IsStandardUser], + "create": [IsAdmin], + "retrieve": [IsStandardUser], + "update": [IsAdmin], + "partial_update": [IsAdmin], + "destroy": [IsAdmin], +} + +permission_classes_by_method = { + "GET": [IsStandardUser], + "POST": [IsAdmin], + "PATCH": [IsAdmin], + "PUT": [IsAdmin], + "DELETE": [IsAdmin], +} diff --git a/apps/accounts/serializers.py b/apps/accounts/serializers.py new file mode 100644 index 0000000..8a9f609 --- /dev/null +++ b/apps/accounts/serializers.py @@ -0,0 +1,145 @@ +import json + +from rest_framework import serializers +from django.contrib.auth.models import Group, Permission +from .models import User +from apps.channels.models import ChannelProfile + + +# Valid navigation item IDs for validation +VALID_NAV_ITEM_IDS = { + 'channels', 'vods', 'sources', 'guide', 'dvr', + 'stats', 'plugins', 'integrations', 'system', 'settings' +} +MAX_CUSTOM_PROPS_SIZE = 10240 # 10KB limit + + +def validate_nav_array(value, field_name): + """Validate that a value is an array of valid nav item ID strings.""" + if not isinstance(value, list): + raise serializers.ValidationError(f"{field_name} must be an array") + if len(value) > 50: + raise serializers.ValidationError(f"{field_name} exceeds maximum length of 50 items") + for item in value: + if not isinstance(item, str): + raise serializers.ValidationError(f"{field_name} items must be strings") + if item not in VALID_NAV_ITEM_IDS: + raise serializers.ValidationError(f"'{item}' is not a valid navigation item ID") + + +# 🔹 Fix for Permission serialization +class PermissionSerializer(serializers.ModelSerializer): + class Meta: + model = Permission + fields = ["id", "name", "codename"] + + +# 🔹 Fix for Group serialization +class GroupSerializer(serializers.ModelSerializer): + permissions = serializers.PrimaryKeyRelatedField( + many=True, queryset=Permission.objects.all() + ) # ✅ Fixes ManyToManyField `_meta` error + + class Meta: + model = Group + fields = ["id", "name", "permissions"] + + +# 🔹 Fix for User serialization +class UserSerializer(serializers.ModelSerializer): + password = serializers.CharField(write_only=True, required=False) + channel_profiles = serializers.PrimaryKeyRelatedField( + queryset=ChannelProfile.objects.all(), many=True, required=False + ) + api_key = serializers.CharField(read_only=True, allow_null=True) + + class Meta: + model = User + fields = [ + "id", + "username", + "api_key", + "email", + "user_level", + "password", + "channel_profiles", + "custom_properties", + "avatar_config", + "stream_limit", + "is_staff", + "is_superuser", + "last_login", + "date_joined", + "first_name", + "last_name", + ] + + def validate_custom_properties(self, value): + """Validate custom_properties structure and size.""" + if value is None: + return {} + if not isinstance(value, dict): + raise serializers.ValidationError("custom_properties must be a dictionary") + + # Size limit check + try: + if len(json.dumps(value)) > MAX_CUSTOM_PROPS_SIZE: + raise serializers.ValidationError( + f"custom_properties exceeds maximum size of {MAX_CUSTOM_PROPS_SIZE} bytes" + ) + except (TypeError, ValueError): + raise serializers.ValidationError("custom_properties contains non-serializable data") + + # Validate navOrder if present + if 'navOrder' in value: + validate_nav_array(value['navOrder'], 'navOrder') + + # Validate hiddenNav if present + if 'hiddenNav' in value: + validate_nav_array(value['hiddenNav'], 'hiddenNav') + + return value + + def create(self, validated_data): + channel_profiles = validated_data.pop("channel_profiles", []) + + user = User(**validated_data) + user.set_password(validated_data["password"]) + user.save() + + user.channel_profiles.set(channel_profiles) + + return user + + def update(self, instance, validated_data): + password = validated_data.pop("password", None) + channel_profiles = validated_data.pop("channel_profiles", None) + + # Merge custom_properties instead of replacing (prevents data loss) + # Strip null values — sending null for a key omits it rather than overwriting with null + custom_properties = validated_data.pop("custom_properties", None) + if custom_properties is not None: + existing = instance.custom_properties or {} + cleaned = {k: v for k, v in custom_properties.items() if v is not None} + merged = {**existing, **cleaned} + # Scrub stale nav IDs so the DB self-heals on next save + for nav_field in ('navOrder', 'hiddenNav'): + if nav_field in merged and isinstance(merged[nav_field], list): + merged[nav_field] = [ + item for item in merged[nav_field] + if item in VALID_NAV_ITEM_IDS + ] + instance.custom_properties = merged + + for attr, value in validated_data.items(): + setattr(instance, attr, value) + + if password: + instance.set_password(password) + + instance.save() + + if channel_profiles is not None: + instance.channel_profiles.set(channel_profiles) + + return instance diff --git a/apps/accounts/signals.py b/apps/accounts/signals.py new file mode 100644 index 0000000..dfc4f42 --- /dev/null +++ b/apps/accounts/signals.py @@ -0,0 +1,15 @@ +# apps/accounts/signals.py +# Example: automatically create something on user creation + +from django.db.models.signals import post_save +from django.dispatch import receiver +from .models import User + + +@receiver(post_save, sender=User) +def handle_new_user(sender, instance, created, **kwargs): + if created: + # e.g. initialize default avatar config + if not instance.avatar_config: + instance.avatar_config = {"style": "circle"} + instance.save() diff --git a/apps/accounts/tests.py b/apps/accounts/tests.py new file mode 100644 index 0000000..629207b --- /dev/null +++ b/apps/accounts/tests.py @@ -0,0 +1,72 @@ +from django.test import TestCase +from django.contrib.auth import get_user_model +from rest_framework.test import APIClient + +User = get_user_model() + + +class InitializeSuperuserTests(TestCase): + """Tests for the initialize_superuser endpoint""" + + def setUp(self): + self.client = APIClient() + self.url = "/api/accounts/initialize-superuser/" + + def test_returns_true_when_superuser_exists(self): + """Superuser with is_superuser=True should be detected""" + User.objects.create_superuser( + username="admin", password="testpass123", user_level=10 + ) + response = self.client.get(self.url) + self.assertEqual(response.status_code, 200) + self.assertTrue(response.json()["superuser_exists"]) + + def test_returns_true_when_admin_level_user_exists(self): + """User with user_level=10 but is_superuser=False should be detected""" + user = User.objects.create_user(username="admin", password="testpass123") + user.user_level = 10 + user.is_superuser = False + user.save() + response = self.client.get(self.url) + self.assertEqual(response.status_code, 200) + self.assertTrue(response.json()["superuser_exists"]) + + def test_returns_false_when_no_admin_exists(self): + """No admin or superuser should return false""" + # Create a non-admin user + User.objects.create_user(username="regular", password="testpass123") + response = self.client.get(self.url) + self.assertEqual(response.status_code, 200) + self.assertFalse(response.json()["superuser_exists"]) + + def test_returns_false_when_no_users_exist(self): + """Empty database should return false""" + response = self.client.get(self.url) + self.assertEqual(response.status_code, 200) + self.assertFalse(response.json()["superuser_exists"]) + + def test_create_superuser_when_none_exists(self): + """POST should create superuser when none exists""" + response = self.client.post( + self.url, + {"username": "newadmin", "password": "testpass123", "email": "admin@test.com"}, + format="json", + ) + self.assertEqual(response.status_code, 200) + self.assertTrue(response.json()["superuser_exists"]) + self.assertTrue(User.objects.filter(username="newadmin", user_level=10).exists()) + + def test_cannot_create_superuser_when_admin_exists(self): + """POST should fail when an admin-level user already exists""" + user = User.objects.create_user(username="existing", password="testpass123") + user.user_level = 10 + user.save() + response = self.client.post( + self.url, + {"username": "newadmin", "password": "testpass123"}, + format="json", + ) + self.assertEqual(response.status_code, 200) + self.assertTrue(response.json()["superuser_exists"]) + # Should NOT have created a new user + self.assertFalse(User.objects.filter(username="newadmin").exists()) \ No newline at end of file diff --git a/apps/accounts/urls.py b/apps/accounts/urls.py new file mode 100644 index 0000000..bcdad78 --- /dev/null +++ b/apps/accounts/urls.py @@ -0,0 +1,12 @@ +from django.urls import path +from django.contrib.auth import views as auth_views + +app_name = 'accounts' + +urlpatterns = [ + # Login view using Django's built-in authentication + path('login/', auth_views.LoginView.as_view(template_name='login.html'), name='login'), + # Logout view using Django's built-in authentication + path('logout/', auth_views.LogoutView.as_view(next_page='accounts:login'), name='logout'), + # Onetime use superuser account creation +] diff --git a/apps/api/urls.py b/apps/api/urls.py new file mode 100644 index 0000000..b176bc1 --- /dev/null +++ b/apps/api/urls.py @@ -0,0 +1,29 @@ +from django.urls import path, include, re_path +from drf_spectacular.views import SpectacularAPIView, SpectacularSwaggerView, SpectacularRedocView + +app_name = 'api' + +urlpatterns = [ + path('accounts/', include(('apps.accounts.api_urls', 'accounts'), namespace='accounts')), + path('channels/', include(('apps.channels.api_urls', 'channels'), namespace='channels')), + path('epg/', include(('apps.epg.api_urls', 'epg'), namespace='epg')), + 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('connect/', include(('apps.connect.api_urls', 'connect'), namespace='connect')), + # 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')), + #path('streams/', include(('apps.streams.api_urls', 'streams'), namespace='streams')), + + + + # OpenAPI Schema and Documentation (drf-spectacular) + path('schema/', SpectacularAPIView.as_view(), name='schema'), + re_path(r'^swagger/?$', SpectacularSwaggerView.as_view(url_name='api:schema'), name='swagger-ui'), + path('redoc/', SpectacularRedocView.as_view(url_name='api:schema'), name='redoc'), + path('swagger.json', SpectacularAPIView.as_view(), name='schema-json'), +] diff --git a/apps/backups/__init__.py b/apps/backups/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/backups/api_urls.py b/apps/backups/api_urls.py new file mode 100644 index 0000000..226758c --- /dev/null +++ b/apps/backups/api_urls.py @@ -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//", api_views.backup_status, name="backup-status"), + path("/download-token/", api_views.get_download_token, name="backup-download-token"), + path("/download/", api_views.download_backup, name="backup-download"), + path("/delete/", api_views.delete_backup, name="backup-delete"), + path("/restore/", api_views.restore_backup, name="backup-restore"), +] diff --git a/apps/backups/api_views.py b/apps/backups/api_views.py new file mode 100644 index 0000000..36334d0 --- /dev/null +++ b/apps/backups/api_views.py @@ -0,0 +1,374 @@ +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 AllowAny +from apps.accounts.permissions import IsAdmin +from rest_framework.parsers import MultiPartParser, FormParser +from rest_framework.response import Response +from core.utils import safe_upload_path + +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([IsAdmin]) +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([IsAdmin]) +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 getattr(request.user, 'user_level', 0) < 10: + 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([IsAdmin]) +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 getattr(request.user, 'user_level', 0) < 10: + 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([IsAdmin]) +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([IsAdmin]) +@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() + # Sanitize filename: strip directory components to prevent path traversal + filename = Path(uploaded.name or "uploaded-backup.zip").name + if not filename: + filename = "uploaded-backup.zip" + + try: + safe_upload_path(filename, str(backup_dir)) + except ValueError: + return Response({"detail": "Invalid filename."}, status=status.HTTP_400_BAD_REQUEST) + + # Ensure unique filename + backup_file = (backup_dir / filename).resolve() + 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([IsAdmin]) +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([IsAdmin]) +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([IsAdmin]) +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, + ) diff --git a/apps/backups/apps.py b/apps/backups/apps.py new file mode 100644 index 0000000..d22ffb2 --- /dev/null +++ b/apps/backups/apps.py @@ -0,0 +1,40 @@ +import logging + +from django.apps import AppConfig + +logger = logging.getLogger(__name__) + + +class BackupsConfig(AppConfig): + default_auto_field = "django.db.models.BigAutoField" + name = "apps.backups" + verbose_name = "Backups" + + def ready(self): + """Initialize backup scheduler on app startup.""" + from dispatcharr.app_initialization import should_skip_initialization + + # Skip if this is a management command, worker process, or dev server + if should_skip_initialization(): + return + + logger.debug("Syncing backup scheduler on app startup") + self._sync_backup_scheduler() + + def _sync_backup_scheduler(self): + """Sync backup scheduler task to database.""" + from core.models import CoreSettings + from .scheduler import _sync_periodic_task, DEFAULTS + try: + # Ensure settings exist with defaults if this is a new install + CoreSettings.objects.get_or_create( + key="backup_settings", + defaults={"name": "Backup Settings", "value": DEFAULTS.copy()} + ) + + # Always sync the periodic task (handles new installs, updates, or missing tasks) + logger.debug("Syncing backup scheduler") + _sync_periodic_task() + except Exception as e: + # Log but don't fail startup if there's an issue + logger.warning(f"Failed to initialize backup scheduler: {e}") diff --git a/apps/backups/migrations/__init__.py b/apps/backups/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/backups/models.py b/apps/backups/models.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/backups/scheduler.py b/apps/backups/scheduler.py new file mode 100644 index 0000000..a427757 --- /dev/null +++ b/apps/backups/scheduler.py @@ -0,0 +1,133 @@ +import json +import logging + +from django_celery_beat.models import PeriodicTask + +from core.models import CoreSettings +from core.scheduling import ( + create_or_update_periodic_task, + delete_periodic_task, +) + +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_periodic_task(BACKUP_SCHEDULE_TASK_NAME) + logger.info("Backup schedule disabled, removed periodic task") + return + + # Check if using cron expression (advanced mode) + if settings["cron_expression"]: + cron_expr = settings["cron_expression"] + else: + # Build a cron expression from simple frequency settings + hour, minute = settings["time"].split(":") + if settings["frequency"] == "daily": + cron_expr = f"{minute} {hour} * * *" + else: # weekly + cron_expr = f"{minute} {hour} * * {settings['day_of_week']}" + + create_or_update_periodic_task( + task_name=BACKUP_SCHEDULE_TASK_NAME, + celery_task_path="apps.backups.tasks.scheduled_backup_task", + kwargs={"retention_count": settings["retention_count"]}, + cron_expression=cron_expr, + enabled=True, + ) diff --git a/apps/backups/services.py b/apps/backups/services.py new file mode 100644 index 0000000..c70160e --- /dev/null +++ b/apps/backups/services.py @@ -0,0 +1,376 @@ +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. + + Includes PGPASSWORD for password auth and PGSSL* variables for TLS. + Reads TLS config from DATABASES['default']['OPTIONS'], which is + populated by settings.py when POSTGRES_SSL=true. + """ + db_config = settings.DATABASES["default"] + env = os.environ.copy() + + password = db_config.get("PASSWORD", "") + if password: + env["PGPASSWORD"] = password + else: + env.pop("PGPASSWORD", None) + + # Propagate TLS configuration from Django OPTIONS to libpq env vars. + options = db_config.get("OPTIONS", {}) + _ssl_env_map = { + "sslmode": "PGSSLMODE", + "sslrootcert": "PGSSLROOTCERT", + "sslcert": "PGSSLCERT", + "sslkey": "PGSSLKEY", + } + # Always strip inherited PGSSL* vars first, then set only what is explicitly configured + for opt_key, env_key in _ssl_env_map.items(): + env.pop(env_key, None) + value = options.get(opt_key) + if value: + env[env_key] = value + + 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}") diff --git a/apps/backups/tasks.py b/apps/backups/tasks.py new file mode 100644 index 0000000..f531fef --- /dev/null +++ b/apps/backups/tasks.py @@ -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), + } diff --git a/apps/backups/tests.py b/apps/backups/tests.py new file mode 100644 index 0000000..bdea2be --- /dev/null +++ b/apps/backups/tests.py @@ -0,0 +1,1342 @@ +import json +import tempfile +from io import BytesIO +from pathlib import Path +from zipfile import ZipFile +from unittest.mock import patch, MagicMock + +from django.test import TestCase +from django.contrib.auth import get_user_model +from rest_framework.test import APIClient +from rest_framework_simplejwt.tokens import RefreshToken + +from . import services + +User = get_user_model() + + +class PgEnvTlsTestCase(TestCase): + """Test that _get_pg_env includes TLS and password env vars correctly.""" + databases = [] + + @patch('apps.backups.services.settings') + def test_pg_env_includes_ssl_vars_when_tls_enabled(self, mock_settings): + mock_settings.DATABASES = { + "default": { + "ENGINE": "django.db.backends.postgresql", + "NAME": "testdb", + "USER": "testuser", + "PASSWORD": "testpass", + "HOST": "localhost", + "PORT": 5432, + "OPTIONS": { + "sslmode": "verify-full", + "sslrootcert": "/certs/ca.crt", + "sslcert": "/certs/client.crt", + "sslkey": "/certs/client.key", + }, + } + } + env = services._get_pg_env() + self.assertEqual(env["PGSSLMODE"], "verify-full") + self.assertEqual(env["PGSSLROOTCERT"], "/certs/ca.crt") + self.assertEqual(env["PGSSLCERT"], "/certs/client.crt") + self.assertEqual(env["PGSSLKEY"], "/certs/client.key") + self.assertEqual(env["PGPASSWORD"], "testpass") + + @patch('apps.backups.services.settings') + def test_pg_env_no_ssl_vars_when_tls_disabled(self, mock_settings): + mock_settings.DATABASES = { + "default": { + "ENGINE": "django.db.backends.postgresql", + "NAME": "testdb", + "USER": "testuser", + "PASSWORD": "testpass", + "HOST": "localhost", + "PORT": 5432, + } + } + env = services._get_pg_env() + self.assertNotIn("PGSSLMODE", env) + self.assertNotIn("PGSSLROOTCERT", env) + self.assertNotIn("PGSSLCERT", env) + self.assertNotIn("PGSSLKEY", env) + self.assertEqual(env["PGPASSWORD"], "testpass") + + @patch('apps.backups.services.settings') + def test_pg_env_no_password_when_empty(self, mock_settings): + """Cert-only auth: PGPASSWORD must not be set when password is empty.""" + mock_settings.DATABASES = { + "default": { + "ENGINE": "django.db.backends.postgresql", + "NAME": "testdb", + "USER": "testuser", + "PASSWORD": "", + "HOST": "localhost", + "PORT": 5432, + "OPTIONS": {"sslmode": "verify-full"}, + } + } + env = services._get_pg_env() + self.assertNotIn("PGPASSWORD", env) + self.assertEqual(env["PGSSLMODE"], "verify-full") + + @patch('apps.backups.services.settings') + def test_pg_env_partial_ssl_options(self, mock_settings): + """Server-only TLS: only sslmode and CA cert, no client cert/key.""" + mock_settings.DATABASES = { + "default": { + "ENGINE": "django.db.backends.postgresql", + "NAME": "testdb", + "USER": "testuser", + "PASSWORD": "pass", + "HOST": "localhost", + "PORT": 5432, + "OPTIONS": { + "sslmode": "verify-ca", + "sslrootcert": "/certs/ca.crt", + }, + } + } + env = services._get_pg_env() + self.assertEqual(env["PGSSLMODE"], "verify-ca") + self.assertEqual(env["PGSSLROOTCERT"], "/certs/ca.crt") + self.assertNotIn("PGSSLCERT", env) + self.assertNotIn("PGSSLKEY", env) + + +class BackupServicesTestCase(TestCase): + """Test cases for backup services""" + + def setUp(self): + self.temp_backup_dir = tempfile.mkdtemp() + + def tearDown(self): + import shutil + if Path(self.temp_backup_dir).exists(): + shutil.rmtree(self.temp_backup_dir) + + @patch('apps.backups.services.settings') + def test_get_backup_dir_creates_directory(self, mock_settings): + """Test that get_backup_dir creates the directory if it doesn't exist""" + mock_settings.BACKUP_ROOT = self.temp_backup_dir + + with patch('apps.backups.services.Path') as mock_path: + mock_path_instance = MagicMock() + mock_path_instance.mkdir = MagicMock() + mock_path.return_value = mock_path_instance + + services.get_backup_dir() + mock_path_instance.mkdir.assert_called_once_with(parents=True, exist_ok=True) + + @patch('apps.backups.services.get_backup_dir') + @patch('apps.backups.services._is_postgresql') + @patch('apps.backups.services._dump_sqlite') + def test_create_backup_success_sqlite(self, mock_dump_sqlite, mock_is_pg, mock_get_backup_dir): + """Test successful backup creation with SQLite""" + mock_get_backup_dir.return_value = Path(self.temp_backup_dir) + mock_is_pg.return_value = False + + # Mock SQLite dump to create a temp file + def mock_dump(output_file): + output_file.write_text("sqlite dump") + + mock_dump_sqlite.side_effect = mock_dump + + result = services.create_backup() + + self.assertIsInstance(result, Path) + self.assertTrue(result.exists()) + self.assertTrue(result.name.startswith('dispatcharr-backup-')) + self.assertTrue(result.name.endswith('.zip')) + + # Verify the backup contains expected files + with ZipFile(result, 'r') as zf: + names = zf.namelist() + self.assertIn('database.sqlite3', names) + self.assertIn('metadata.json', names) + + # Check metadata + metadata = json.loads(zf.read('metadata.json')) + self.assertEqual(metadata['version'], 2) + self.assertEqual(metadata['database_type'], 'sqlite') + + @patch('apps.backups.services.get_backup_dir') + @patch('apps.backups.services._is_postgresql') + @patch('apps.backups.services._dump_postgresql') + def test_create_backup_success_postgresql(self, mock_dump_pg, mock_is_pg, mock_get_backup_dir): + """Test successful backup creation with PostgreSQL""" + mock_get_backup_dir.return_value = Path(self.temp_backup_dir) + mock_is_pg.return_value = True + + # Mock PostgreSQL dump to create a temp file + def mock_dump(output_file): + output_file.write_bytes(b"pg dump data") + + mock_dump_pg.side_effect = mock_dump + + result = services.create_backup() + + self.assertIsInstance(result, Path) + self.assertTrue(result.exists()) + + # Verify the backup contains expected files + with ZipFile(result, 'r') as zf: + names = zf.namelist() + self.assertIn('database.dump', names) + self.assertIn('metadata.json', names) + + # Check metadata + metadata = json.loads(zf.read('metadata.json')) + self.assertEqual(metadata['version'], 2) + self.assertEqual(metadata['database_type'], 'postgresql') + + @patch('apps.backups.services.get_backup_dir') + def test_list_backups_empty(self, mock_get_backup_dir): + """Test listing backups when none exist""" + mock_get_backup_dir.return_value = Path(self.temp_backup_dir) + + result = services.list_backups() + + self.assertEqual(result, []) + + @patch('apps.backups.services.get_backup_dir') + def test_list_backups_with_files(self, mock_get_backup_dir): + """Test listing backups with existing backup files""" + backup_dir = Path(self.temp_backup_dir) + mock_get_backup_dir.return_value = backup_dir + + # Create a fake backup file + test_backup = backup_dir / "dispatcharr-backup-2025.01.01.12.00.00.zip" + test_backup.write_text("fake backup content") + + result = services.list_backups() + + self.assertEqual(len(result), 1) + self.assertEqual(result[0]['name'], test_backup.name) + self.assertIn('size', result[0]) + self.assertIn('created', result[0]) + + @patch('apps.backups.services.get_backup_dir') + def test_delete_backup_success(self, mock_get_backup_dir): + """Test successful backup deletion""" + backup_dir = Path(self.temp_backup_dir) + mock_get_backup_dir.return_value = backup_dir + + # Create a fake backup file + test_backup = backup_dir / "dispatcharr-backup-test.zip" + test_backup.write_text("fake backup content") + + self.assertTrue(test_backup.exists()) + + services.delete_backup(test_backup.name) + + self.assertFalse(test_backup.exists()) + + @patch('apps.backups.services.get_backup_dir') + def test_delete_backup_not_found(self, mock_get_backup_dir): + """Test deleting a non-existent backup raises error""" + mock_get_backup_dir.return_value = Path(self.temp_backup_dir) + + with self.assertRaises(FileNotFoundError): + services.delete_backup("nonexistent-backup.zip") + + @patch('apps.backups.services.get_backup_dir') + @patch('apps.backups.services._is_postgresql') + @patch('apps.backups.services._restore_postgresql') + def test_restore_backup_postgresql(self, mock_restore_pg, mock_is_pg, mock_get_backup_dir): + """Test successful restoration of PostgreSQL backup""" + backup_dir = Path(self.temp_backup_dir) + mock_get_backup_dir.return_value = backup_dir + mock_is_pg.return_value = True + + # Create PostgreSQL backup file + backup_file = backup_dir / "test-backup.zip" + with ZipFile(backup_file, 'w') as zf: + zf.writestr('database.dump', b'pg dump data') + zf.writestr('metadata.json', json.dumps({ + 'version': 2, + 'database_type': 'postgresql', + 'database_file': 'database.dump' + })) + + services.restore_backup(backup_file) + + mock_restore_pg.assert_called_once() + + @patch('apps.backups.services.get_backup_dir') + @patch('apps.backups.services._is_postgresql') + @patch('apps.backups.services._restore_sqlite') + def test_restore_backup_sqlite(self, mock_restore_sqlite, mock_is_pg, mock_get_backup_dir): + """Test successful restoration of SQLite backup""" + backup_dir = Path(self.temp_backup_dir) + mock_get_backup_dir.return_value = backup_dir + mock_is_pg.return_value = False + + # Create SQLite backup file + backup_file = backup_dir / "test-backup.zip" + with ZipFile(backup_file, 'w') as zf: + zf.writestr('database.sqlite3', 'sqlite data') + zf.writestr('metadata.json', json.dumps({ + 'version': 2, + 'database_type': 'sqlite', + 'database_file': 'database.sqlite3' + })) + + services.restore_backup(backup_file) + + mock_restore_sqlite.assert_called_once() + + @patch('apps.backups.services.get_backup_dir') + @patch('apps.backups.services._is_postgresql') + def test_restore_backup_database_type_mismatch(self, mock_is_pg, mock_get_backup_dir): + """Test restore fails when database type doesn't match""" + backup_dir = Path(self.temp_backup_dir) + mock_get_backup_dir.return_value = backup_dir + mock_is_pg.return_value = True # Current system is PostgreSQL + + # Create SQLite backup file + backup_file = backup_dir / "test-backup.zip" + with ZipFile(backup_file, 'w') as zf: + zf.writestr('database.sqlite3', 'sqlite data') + zf.writestr('metadata.json', json.dumps({ + 'version': 2, + 'database_type': 'sqlite', # Backup is SQLite + 'database_file': 'database.sqlite3' + })) + + with self.assertRaises(ValueError) as context: + services.restore_backup(backup_file) + + self.assertIn('mismatch', str(context.exception).lower()) + + def test_restore_backup_not_found(self): + """Test restoring from non-existent backup file""" + fake_path = Path("/tmp/nonexistent-backup-12345.zip") + + with self.assertRaises(FileNotFoundError): + services.restore_backup(fake_path) + + @patch('apps.backups.services.get_backup_dir') + def test_restore_backup_missing_metadata(self, mock_get_backup_dir): + """Test restoring from backup without metadata.json""" + backup_dir = Path(self.temp_backup_dir) + mock_get_backup_dir.return_value = backup_dir + + # Create a backup file missing metadata.json + backup_file = backup_dir / "invalid-backup.zip" + with ZipFile(backup_file, 'w') as zf: + zf.writestr('database.dump', b'fake dump data') + + with self.assertRaises(ValueError) as context: + services.restore_backup(backup_file) + + self.assertIn('metadata.json', str(context.exception)) + + @patch('apps.backups.services.get_backup_dir') + @patch('apps.backups.services._is_postgresql') + def test_restore_backup_missing_database(self, mock_is_pg, mock_get_backup_dir): + """Test restoring from backup missing database dump""" + backup_dir = Path(self.temp_backup_dir) + mock_get_backup_dir.return_value = backup_dir + mock_is_pg.return_value = True + + # Create backup file missing database dump + backup_file = backup_dir / "invalid-backup.zip" + with ZipFile(backup_file, 'w') as zf: + zf.writestr('metadata.json', json.dumps({ + 'version': 2, + 'database_type': 'postgresql', + 'database_file': 'database.dump' + })) + + with self.assertRaises(ValueError) as context: + services.restore_backup(backup_file) + + self.assertIn('database.dump', str(context.exception)) + + +class BackupAPITestCase(TestCase): + """Test cases for backup API endpoints""" + + def setUp(self): + self.client = APIClient() + self.user = User.objects.create_user( + username='testuser', + email='test@example.com', + password='testpass123' + ) + self.admin_user = User.objects.create_superuser( + username='admin', + email='admin@example.com', + password='adminpass123' + ) + self.temp_backup_dir = tempfile.mkdtemp() + + def get_auth_header(self, user): + """Helper method to get JWT auth header for a user""" + refresh = RefreshToken.for_user(user) + return f'Bearer {str(refresh.access_token)}' + + def tearDown(self): + import shutil + if Path(self.temp_backup_dir).exists(): + shutil.rmtree(self.temp_backup_dir) + + def test_list_backups_requires_admin(self): + """Test that listing backups requires admin privileges""" + url = '/api/backups/' + + # Unauthenticated request + response = self.client.get(url) + self.assertIn(response.status_code, [401, 403]) + + # Regular user request + response = self.client.get(url, HTTP_AUTHORIZATION=self.get_auth_header(self.user)) + self.assertIn(response.status_code, [401, 403]) + + @patch('apps.backups.services.list_backups') + def test_list_backups_success(self, mock_list_backups): + """Test successful backup listing""" + mock_list_backups.return_value = [ + { + 'name': 'backup-test.zip', + 'size': 1024, + 'created': '2025-01-01T12:00:00' + } + ] + + auth_header = self.get_auth_header(self.admin_user) + url = '/api/backups/' + response = self.client.get(url, HTTP_AUTHORIZATION=auth_header) + + self.assertEqual(response.status_code, 200) + data = response.json() + self.assertEqual(len(data), 1) + self.assertEqual(data[0]['name'], 'backup-test.zip') + + def test_create_backup_requires_admin(self): + """Test that creating backups requires admin privileges""" + url = '/api/backups/create/' + + # Unauthenticated request + response = self.client.post(url) + self.assertIn(response.status_code, [401, 403]) + + # Regular user request + response = self.client.post(url, HTTP_AUTHORIZATION=self.get_auth_header(self.user)) + self.assertIn(response.status_code, [401, 403]) + + @patch('apps.backups.tasks.create_backup_task.delay') + def test_create_backup_success(self, mock_create_task): + """Test successful backup creation via API (async task)""" + mock_task = MagicMock() + mock_task.id = 'test-task-id-123' + mock_create_task.return_value = mock_task + + auth_header = self.get_auth_header(self.admin_user) + url = '/api/backups/create/' + response = self.client.post(url, HTTP_AUTHORIZATION=auth_header) + + self.assertEqual(response.status_code, 202) + data = response.json() + self.assertIn('task_id', data) + self.assertIn('task_token', data) + self.assertEqual(data['task_id'], 'test-task-id-123') + + @patch('apps.backups.tasks.create_backup_task.delay') + def test_create_backup_failure(self, mock_create_task): + """Test backup creation failure handling""" + mock_create_task.side_effect = Exception("Failed to start task") + + auth_header = self.get_auth_header(self.admin_user) + url = '/api/backups/create/' + response = self.client.post(url, HTTP_AUTHORIZATION=auth_header) + + self.assertEqual(response.status_code, 500) + data = response.json() + self.assertIn('detail', data) + + @patch('apps.backups.services.get_backup_dir') + def test_download_backup_success(self, mock_get_backup_dir): + """Test successful backup download""" + backup_dir = Path(self.temp_backup_dir) + mock_get_backup_dir.return_value = backup_dir + + # Create a test backup file + backup_file = backup_dir / "test-backup.zip" + backup_file.write_text("test backup content") + + auth_header = self.get_auth_header(self.admin_user) + url = '/api/backups/test-backup.zip/download/' + response = self.client.get(url, HTTP_AUTHORIZATION=auth_header) + + self.assertEqual(response.status_code, 200) + self.assertEqual(response['Content-Type'], 'application/zip') + + @patch('apps.backups.services.get_backup_dir') + def test_download_backup_not_found(self, mock_get_backup_dir): + """Test downloading non-existent backup""" + mock_get_backup_dir.return_value = Path(self.temp_backup_dir) + + auth_header = self.get_auth_header(self.admin_user) + url = '/api/backups/nonexistent.zip/download/' + response = self.client.get(url, HTTP_AUTHORIZATION=auth_header) + + self.assertEqual(response.status_code, 404) + + @patch('apps.backups.services.delete_backup') + def test_delete_backup_success(self, mock_delete_backup): + """Test successful backup deletion via API""" + mock_delete_backup.return_value = None + + auth_header = self.get_auth_header(self.admin_user) + url = '/api/backups/test-backup.zip/delete/' + response = self.client.delete(url, HTTP_AUTHORIZATION=auth_header) + + self.assertEqual(response.status_code, 204) + mock_delete_backup.assert_called_once_with('test-backup.zip') + + @patch('apps.backups.services.delete_backup') + def test_delete_backup_not_found(self, mock_delete_backup): + """Test deleting non-existent backup via API""" + mock_delete_backup.side_effect = FileNotFoundError("Not found") + + auth_header = self.get_auth_header(self.admin_user) + url = '/api/backups/nonexistent.zip/delete/' + response = self.client.delete(url, HTTP_AUTHORIZATION=auth_header) + + self.assertEqual(response.status_code, 404) + + def test_upload_backup_requires_file(self): + """Test that upload requires a file""" + auth_header = self.get_auth_header(self.admin_user) + url = '/api/backups/upload/' + response = self.client.post(url, HTTP_AUTHORIZATION=auth_header) + + self.assertEqual(response.status_code, 400) + data = response.json() + self.assertIn('No file uploaded', data['detail']) + + @patch('apps.backups.services.get_backup_dir') + def test_upload_backup_success(self, mock_get_backup_dir): + """Test successful backup upload""" + mock_get_backup_dir.return_value = Path(self.temp_backup_dir) + + # Create a fake backup file + fake_backup = BytesIO(b"fake backup content") + fake_backup.name = 'uploaded-backup.zip' + + auth_header = self.get_auth_header(self.admin_user) + url = '/api/backups/upload/' + response = self.client.post(url, {'file': fake_backup}, HTTP_AUTHORIZATION=auth_header) + + self.assertEqual(response.status_code, 201) + data = response.json() + self.assertIn('filename', data) + + @patch('apps.backups.services.get_backup_dir') + @patch('apps.backups.tasks.restore_backup_task.delay') + def test_restore_backup_success(self, mock_restore_task, mock_get_backup_dir): + """Test successful backup restoration via API (async task)""" + backup_dir = Path(self.temp_backup_dir) + mock_get_backup_dir.return_value = backup_dir + + mock_task = MagicMock() + mock_task.id = 'test-restore-task-456' + mock_restore_task.return_value = mock_task + + # Create a test backup file + backup_file = backup_dir / "test-backup.zip" + backup_file.write_text("test backup content") + + auth_header = self.get_auth_header(self.admin_user) + url = '/api/backups/test-backup.zip/restore/' + response = self.client.post(url, HTTP_AUTHORIZATION=auth_header) + + self.assertEqual(response.status_code, 202) + data = response.json() + self.assertIn('task_id', data) + self.assertIn('task_token', data) + self.assertEqual(data['task_id'], 'test-restore-task-456') + + @patch('apps.backups.services.get_backup_dir') + def test_restore_backup_not_found(self, mock_get_backup_dir): + """Test restoring from non-existent backup via API""" + mock_get_backup_dir.return_value = Path(self.temp_backup_dir) + + auth_header = self.get_auth_header(self.admin_user) + url = '/api/backups/nonexistent.zip/restore/' + response = self.client.post(url, HTTP_AUTHORIZATION=auth_header) + + self.assertEqual(response.status_code, 404) + + # --- Backup Status Endpoint Tests --- + + def test_backup_status_requires_auth_or_token(self): + """Test that backup_status requires auth or valid token""" + url = '/api/backups/status/fake-task-id/' + + # Unauthenticated request without token + response = self.client.get(url) + self.assertEqual(response.status_code, 401) + + def test_backup_status_invalid_token(self): + """Test that backup_status rejects invalid tokens""" + url = '/api/backups/status/fake-task-id/?token=invalid-token' + response = self.client.get(url) + self.assertEqual(response.status_code, 403) + + @patch('apps.backups.api_views.AsyncResult') + def test_backup_status_with_admin_auth(self, mock_async_result): + """Test backup_status with admin authentication""" + mock_result = MagicMock() + mock_result.ready.return_value = False + mock_result.failed.return_value = False + mock_result.state = 'PENDING' + mock_async_result.return_value = mock_result + + auth_header = self.get_auth_header(self.admin_user) + url = '/api/backups/status/test-task-id/' + response = self.client.get(url, HTTP_AUTHORIZATION=auth_header) + + self.assertEqual(response.status_code, 200) + data = response.json() + self.assertEqual(data['state'], 'pending') + + @patch('apps.backups.api_views.AsyncResult') + @patch('apps.backups.api_views._verify_task_token') + def test_backup_status_with_valid_token(self, mock_verify, mock_async_result): + """Test backup_status with valid token""" + mock_verify.return_value = True + mock_result = MagicMock() + mock_result.ready.return_value = True + mock_result.get.return_value = {'status': 'completed', 'filename': 'test.zip'} + mock_async_result.return_value = mock_result + + url = '/api/backups/status/test-task-id/?token=valid-token' + response = self.client.get(url) + + self.assertEqual(response.status_code, 200) + data = response.json() + self.assertEqual(data['state'], 'completed') + + @patch('apps.backups.api_views.AsyncResult') + def test_backup_status_task_failed(self, mock_async_result): + """Test backup_status when task failed""" + mock_result = MagicMock() + mock_result.ready.return_value = True + mock_result.get.return_value = {'status': 'failed', 'error': 'Something went wrong'} + mock_async_result.return_value = mock_result + + auth_header = self.get_auth_header(self.admin_user) + url = '/api/backups/status/test-task-id/' + response = self.client.get(url, HTTP_AUTHORIZATION=auth_header) + + self.assertEqual(response.status_code, 200) + data = response.json() + self.assertEqual(data['state'], 'failed') + self.assertIn('Something went wrong', data['error']) + + # --- Download Token Endpoint Tests --- + + def test_get_download_token_requires_admin(self): + """Test that get_download_token requires admin privileges""" + url = '/api/backups/test.zip/download-token/' + + response = self.client.get(url) + self.assertIn(response.status_code, [401, 403]) + + response = self.client.get(url, HTTP_AUTHORIZATION=self.get_auth_header(self.user)) + self.assertIn(response.status_code, [401, 403]) + + @patch('apps.backups.services.get_backup_dir') + def test_get_download_token_success(self, mock_get_backup_dir): + """Test successful download token generation""" + backup_dir = Path(self.temp_backup_dir) + mock_get_backup_dir.return_value = backup_dir + + # Create a test backup file + backup_file = backup_dir / "test-backup.zip" + backup_file.write_text("test content") + + auth_header = self.get_auth_header(self.admin_user) + url = '/api/backups/test-backup.zip/download-token/' + response = self.client.get(url, HTTP_AUTHORIZATION=auth_header) + + self.assertEqual(response.status_code, 200) + data = response.json() + self.assertIn('token', data) + self.assertEqual(len(data['token']), 32) + + @patch('apps.backups.services.get_backup_dir') + def test_get_download_token_not_found(self, mock_get_backup_dir): + """Test download token for non-existent file""" + mock_get_backup_dir.return_value = Path(self.temp_backup_dir) + + auth_header = self.get_auth_header(self.admin_user) + url = '/api/backups/nonexistent.zip/download-token/' + response = self.client.get(url, HTTP_AUTHORIZATION=auth_header) + + self.assertEqual(response.status_code, 404) + + # --- Download with Token Auth Tests --- + + @patch('apps.backups.services.get_backup_dir') + @patch('apps.backups.api_views._verify_task_token') + def test_download_backup_with_valid_token(self, mock_verify, mock_get_backup_dir): + """Test downloading backup with valid token (no auth header)""" + backup_dir = Path(self.temp_backup_dir) + mock_get_backup_dir.return_value = backup_dir + mock_verify.return_value = True + + # Create a test backup file + backup_file = backup_dir / "test-backup.zip" + backup_file.write_text("test backup content") + + url = '/api/backups/test-backup.zip/download/?token=valid-token' + response = self.client.get(url) + + self.assertEqual(response.status_code, 200) + + @patch('apps.backups.services.get_backup_dir') + def test_download_backup_invalid_token(self, mock_get_backup_dir): + """Test downloading backup with invalid token""" + mock_get_backup_dir.return_value = Path(self.temp_backup_dir) + + url = '/api/backups/test-backup.zip/download/?token=invalid-token' + response = self.client.get(url) + + self.assertEqual(response.status_code, 403) + + @patch('apps.backups.services.get_backup_dir') + @patch('apps.backups.tasks.restore_backup_task.delay') + def test_restore_backup_task_start_failure(self, mock_restore_task, mock_get_backup_dir): + """Test restore task start failure via API""" + backup_dir = Path(self.temp_backup_dir) + mock_get_backup_dir.return_value = backup_dir + mock_restore_task.side_effect = Exception("Failed to start restore task") + + # Create a test backup file + backup_file = backup_dir / "test-backup.zip" + backup_file.write_text("test content") + + auth_header = self.get_auth_header(self.admin_user) + url = '/api/backups/test-backup.zip/restore/' + response = self.client.post(url, HTTP_AUTHORIZATION=auth_header) + + self.assertEqual(response.status_code, 500) + data = response.json() + self.assertIn('detail', data) + + def test_get_schedule_requires_admin(self): + """Test that getting schedule requires admin privileges""" + url = '/api/backups/schedule/' + + # Unauthenticated request + response = self.client.get(url) + self.assertIn(response.status_code, [401, 403]) + + # Regular user request + response = self.client.get(url, HTTP_AUTHORIZATION=self.get_auth_header(self.user)) + self.assertIn(response.status_code, [401, 403]) + + @patch('apps.backups.api_views.get_schedule_settings') + def test_get_schedule_success(self, mock_get_settings): + """Test successful schedule retrieval""" + mock_get_settings.return_value = { + 'enabled': True, + 'frequency': 'daily', + 'time': '03:00', + 'day_of_week': 0, + 'retention_count': 5, + 'cron_expression': '', + } + + auth_header = self.get_auth_header(self.admin_user) + url = '/api/backups/schedule/' + response = self.client.get(url, HTTP_AUTHORIZATION=auth_header) + + self.assertEqual(response.status_code, 200) + data = response.json() + self.assertEqual(data['enabled'], True) + self.assertEqual(data['frequency'], 'daily') + self.assertEqual(data['retention_count'], 5) + + def test_update_schedule_requires_admin(self): + """Test that updating schedule requires admin privileges""" + url = '/api/backups/schedule/update/' + + # Unauthenticated request + response = self.client.put(url, {}, content_type='application/json') + self.assertIn(response.status_code, [401, 403]) + + # Regular user request + response = self.client.put( + url, + {}, + content_type='application/json', + HTTP_AUTHORIZATION=self.get_auth_header(self.user) + ) + self.assertIn(response.status_code, [401, 403]) + + @patch('apps.backups.api_views.update_schedule_settings') + def test_update_schedule_success(self, mock_update_settings): + """Test successful schedule update""" + mock_update_settings.return_value = { + 'enabled': True, + 'frequency': 'weekly', + 'time': '02:00', + 'day_of_week': 1, + 'retention_count': 10, + 'cron_expression': '', + } + + auth_header = self.get_auth_header(self.admin_user) + url = '/api/backups/schedule/update/' + response = self.client.put( + url, + {'enabled': True, 'frequency': 'weekly', 'time': '02:00', 'day_of_week': 1, 'retention_count': 10}, + content_type='application/json', + HTTP_AUTHORIZATION=auth_header + ) + + self.assertEqual(response.status_code, 200) + data = response.json() + self.assertEqual(data['frequency'], 'weekly') + self.assertEqual(data['day_of_week'], 1) + + @patch('apps.backups.api_views.update_schedule_settings') + def test_update_schedule_validation_error(self, mock_update_settings): + """Test schedule update with invalid data""" + mock_update_settings.side_effect = ValueError("frequency must be 'daily' or 'weekly'") + + auth_header = self.get_auth_header(self.admin_user) + url = '/api/backups/schedule/update/' + response = self.client.put( + url, + {'frequency': 'invalid'}, + content_type='application/json', + HTTP_AUTHORIZATION=auth_header + ) + + self.assertEqual(response.status_code, 400) + data = response.json() + self.assertIn('frequency', data['detail']) + + +class BackupAdminPermissionTestCase(TestCase): + """Test that backup endpoints use user_level (not is_staff/is_superuser) for admin checks. + + This validates the IsAdminUser -> IsAdmin permission change. + API-created admins have user_level=10 but is_staff=False and is_superuser=False. + """ + + def setUp(self): + self.client = APIClient() + # API-created admin: user_level=10 but NOT is_staff or is_superuser + self.api_admin = User.objects.create_user( + username='api_admin', + email='apiadmin@example.com', + password='testpass123' + ) + self.api_admin.user_level = 10 + self.api_admin.is_staff = False + self.api_admin.is_superuser = False + self.api_admin.save() + + # User with is_staff=True but low user_level (should NOT have access) + self.staff_user = User.objects.create_user( + username='staffuser', + email='staff@example.com', + password='testpass123' + ) + self.staff_user.is_staff = True + self.staff_user.user_level = 1 + self.staff_user.save() + + self.temp_backup_dir = tempfile.mkdtemp() + + def get_auth_header(self, user): + refresh = RefreshToken.for_user(user) + return f'Bearer {str(refresh.access_token)}' + + def tearDown(self): + import shutil + if Path(self.temp_backup_dir).exists(): + shutil.rmtree(self.temp_backup_dir) + + @patch('apps.backups.services.list_backups') + def test_api_created_admin_can_list_backups(self, mock_list_backups): + """API-created admin (user_level=10, is_staff=False) should access backup endpoints""" + mock_list_backups.return_value = [] + + auth_header = self.get_auth_header(self.api_admin) + response = self.client.get('/api/backups/', HTTP_AUTHORIZATION=auth_header) + + self.assertEqual(response.status_code, 200) + + def test_staff_user_without_user_level_cannot_list_backups(self): + """User with is_staff=True but user_level < 10 should NOT access backup endpoints""" + auth_header = self.get_auth_header(self.staff_user) + response = self.client.get('/api/backups/', HTTP_AUTHORIZATION=auth_header) + + self.assertIn(response.status_code, [401, 403]) + + @patch('apps.backups.tasks.create_backup_task.delay') + def test_api_created_admin_can_create_backup(self, mock_create_task): + """API-created admin should be able to create backups""" + mock_task = MagicMock() + mock_task.id = 'test-task-id' + mock_create_task.return_value = mock_task + + auth_header = self.get_auth_header(self.api_admin) + response = self.client.post('/api/backups/create/', HTTP_AUTHORIZATION=auth_header) + + self.assertEqual(response.status_code, 202) + + @patch('apps.backups.services.get_backup_dir') + def test_api_created_admin_can_delete_backup(self, mock_get_backup_dir): + """API-created admin should be able to delete backups""" + backup_dir = Path(self.temp_backup_dir) + mock_get_backup_dir.return_value = backup_dir + + backup_file = backup_dir / "test-backup.zip" + backup_file.write_text("test content") + + auth_header = self.get_auth_header(self.api_admin) + response = self.client.delete( + '/api/backups/test-backup.zip/delete/', + HTTP_AUTHORIZATION=auth_header + ) + + self.assertEqual(response.status_code, 204) + + +class BackupSchedulerTestCase(TestCase): + """Test cases for backup scheduler""" + + databases = {'default'} + + @classmethod + def setUpClass(cls): + pass + + @classmethod + def tearDownClass(cls): + pass + + def setUp(self): + from core.models import CoreSettings + # Clean up any existing settings + CoreSettings.objects.filter(key__startswith='backup_').delete() + + def tearDown(self): + from core.models import CoreSettings + from django_celery_beat.models import PeriodicTask + CoreSettings.objects.filter(key__startswith='backup_').delete() + PeriodicTask.objects.filter(name='backup-scheduled-task').delete() + + def test_get_schedule_settings_defaults(self): + """Test that get_schedule_settings returns defaults when no settings exist""" + from . import scheduler + + settings = scheduler.get_schedule_settings() + + # These should match the DEFAULTS in scheduler.py + self.assertEqual(settings['enabled'], True) + self.assertEqual(settings['frequency'], 'daily') + self.assertEqual(settings['time'], '03:00') + self.assertEqual(settings['day_of_week'], 0) + self.assertEqual(settings['retention_count'], 3) + self.assertEqual(settings['cron_expression'], '') + + def test_update_schedule_settings_stores_values(self): + """Test that update_schedule_settings stores values correctly""" + from . import scheduler + + result = scheduler.update_schedule_settings({ + 'enabled': True, + 'frequency': 'weekly', + 'time': '04:30', + 'day_of_week': 3, + 'retention_count': 7, + }) + + self.assertEqual(result['enabled'], True) + self.assertEqual(result['frequency'], 'weekly') + self.assertEqual(result['time'], '04:30') + self.assertEqual(result['day_of_week'], 3) + self.assertEqual(result['retention_count'], 7) + + # Verify persistence + settings = scheduler.get_schedule_settings() + self.assertEqual(settings['enabled'], True) + self.assertEqual(settings['frequency'], 'weekly') + + def test_update_schedule_settings_invalid_frequency(self): + """Test that invalid frequency raises ValueError""" + from . import scheduler + + with self.assertRaises(ValueError) as context: + scheduler.update_schedule_settings({'frequency': 'monthly'}) + + self.assertIn('frequency', str(context.exception).lower()) + + def test_update_schedule_settings_invalid_time(self): + """Test that invalid time raises ValueError""" + from . import scheduler + + with self.assertRaises(ValueError) as context: + scheduler.update_schedule_settings({'time': 'invalid'}) + + self.assertIn('HH:MM', str(context.exception)) + + def test_update_schedule_settings_invalid_day_of_week(self): + """Test that invalid day_of_week raises ValueError""" + from . import scheduler + + with self.assertRaises(ValueError) as context: + scheduler.update_schedule_settings({'day_of_week': 7}) + + self.assertIn('day_of_week', str(context.exception).lower()) + + def test_update_schedule_settings_invalid_retention(self): + """Test that negative retention_count raises ValueError""" + from . import scheduler + + with self.assertRaises(ValueError) as context: + scheduler.update_schedule_settings({'retention_count': -1}) + + self.assertIn('retention_count', str(context.exception).lower()) + + def test_sync_creates_periodic_task_when_enabled(self): + """Test that enabling schedule creates a PeriodicTask""" + from . import scheduler + from django_celery_beat.models import PeriodicTask + + scheduler.update_schedule_settings({ + 'enabled': True, + 'frequency': 'daily', + 'time': '05:00', + }) + + task = PeriodicTask.objects.get(name='backup-scheduled-task') + self.assertTrue(task.enabled) + self.assertEqual(task.crontab.hour, '05') + self.assertEqual(task.crontab.minute, '00') + + def test_sync_deletes_periodic_task_when_disabled(self): + """Test that disabling schedule removes PeriodicTask""" + from . import scheduler + from django_celery_beat.models import PeriodicTask + + # First enable + scheduler.update_schedule_settings({ + 'enabled': True, + 'frequency': 'daily', + 'time': '05:00', + }) + + self.assertTrue(PeriodicTask.objects.filter(name='backup-scheduled-task').exists()) + + # Then disable + scheduler.update_schedule_settings({'enabled': False}) + + self.assertFalse(PeriodicTask.objects.filter(name='backup-scheduled-task').exists()) + + def test_weekly_schedule_sets_day_of_week(self): + """Test that weekly schedule sets correct day_of_week in crontab""" + from . import scheduler + from django_celery_beat.models import PeriodicTask + + scheduler.update_schedule_settings({ + 'enabled': True, + 'frequency': 'weekly', + 'time': '06:00', + 'day_of_week': 3, # Wednesday + }) + + task = PeriodicTask.objects.get(name='backup-scheduled-task') + self.assertEqual(task.crontab.day_of_week, '3') + + def test_cron_expression_stores_value(self): + """Test that cron_expression is stored and retrieved correctly""" + from . import scheduler + + result = scheduler.update_schedule_settings({ + 'enabled': True, + 'cron_expression': '*/5 * * * *', + }) + + self.assertEqual(result['cron_expression'], '*/5 * * * *') + + # Verify persistence + settings = scheduler.get_schedule_settings() + self.assertEqual(settings['cron_expression'], '*/5 * * * *') + + def test_cron_expression_creates_correct_schedule(self): + """Test that cron expression creates correct CrontabSchedule""" + from . import scheduler + from django_celery_beat.models import PeriodicTask + + scheduler.update_schedule_settings({ + 'enabled': True, + 'cron_expression': '*/15 2 * * 1-5', # Every 15 mins during 2 AM hour on weekdays + }) + + task = PeriodicTask.objects.get(name='backup-scheduled-task') + self.assertEqual(task.crontab.minute, '*/15') + self.assertEqual(task.crontab.hour, '2') + self.assertEqual(task.crontab.day_of_month, '*') + self.assertEqual(task.crontab.month_of_year, '*') + self.assertEqual(task.crontab.day_of_week, '1-5') + + def test_cron_expression_invalid_format(self): + """Test that invalid cron expression raises ValueError""" + from . import scheduler + + # Too few parts + with self.assertRaises(ValueError) as context: + scheduler.update_schedule_settings({ + 'enabled': True, + 'cron_expression': '0 3 *', + }) + self.assertIn('5 parts', str(context.exception)) + + def test_cron_expression_empty_uses_simple_mode(self): + """Test that empty cron_expression falls back to simple frequency mode""" + from . import scheduler + from django_celery_beat.models import PeriodicTask + + scheduler.update_schedule_settings({ + 'enabled': True, + 'frequency': 'daily', + 'time': '04:00', + 'cron_expression': '', # Empty, should use simple mode + }) + + task = PeriodicTask.objects.get(name='backup-scheduled-task') + self.assertEqual(task.crontab.minute, '00') + self.assertEqual(task.crontab.hour, '04') + self.assertEqual(task.crontab.day_of_week, '*') + + def test_cron_expression_overrides_simple_settings(self): + """Test that cron_expression takes precedence over frequency/time""" + from . import scheduler + from django_celery_beat.models import PeriodicTask + + scheduler.update_schedule_settings({ + 'enabled': True, + 'frequency': 'daily', + 'time': '03:00', + 'cron_expression': '0 */6 * * *', # Every 6 hours (should override daily at 3 AM) + }) + + task = PeriodicTask.objects.get(name='backup-scheduled-task') + self.assertEqual(task.crontab.minute, '0') + self.assertEqual(task.crontab.hour, '*/6') + self.assertEqual(task.crontab.day_of_week, '*') + + def test_periodic_task_uses_system_timezone(self): + """Test that CrontabSchedule is created with the system timezone""" + from . import scheduler + from django_celery_beat.models import PeriodicTask + from core.models import CoreSettings + + original_tz = CoreSettings.get_system_time_zone() + + try: + # Set a non-UTC timezone + CoreSettings.set_system_time_zone('America/New_York') + + scheduler.update_schedule_settings({ + 'enabled': True, + 'frequency': 'daily', + 'time': '03:00', + }) + + task = PeriodicTask.objects.get(name='backup-scheduled-task') + self.assertEqual(str(task.crontab.timezone), 'America/New_York') + finally: + scheduler.update_schedule_settings({'enabled': False}) + CoreSettings.set_system_time_zone(original_tz) + + def test_periodic_task_timezone_updates_with_schedule(self): + """Test that CrontabSchedule timezone is updated when schedule is modified""" + from . import scheduler + from django_celery_beat.models import PeriodicTask + from core.models import CoreSettings + + original_tz = CoreSettings.get_system_time_zone() + + try: + # Create initial schedule with one timezone + CoreSettings.set_system_time_zone('America/Los_Angeles') + scheduler.update_schedule_settings({ + 'enabled': True, + 'frequency': 'daily', + 'time': '02:00', + }) + + task = PeriodicTask.objects.get(name='backup-scheduled-task') + self.assertEqual(str(task.crontab.timezone), 'America/Los_Angeles') + + # Change system timezone and update schedule + CoreSettings.set_system_time_zone('Europe/London') + scheduler.update_schedule_settings({ + 'enabled': True, + 'time': '04:00', + }) + + task.refresh_from_db() + self.assertEqual(str(task.crontab.timezone), 'Europe/London') + finally: + scheduler.update_schedule_settings({'enabled': False}) + CoreSettings.set_system_time_zone(original_tz) + + def test_orphaned_crontab_cleanup(self): + """Test that old CrontabSchedule is deleted when schedule changes""" + from . import scheduler + from django_celery_beat.models import PeriodicTask, CrontabSchedule + + # Create initial daily schedule + scheduler.update_schedule_settings({ + 'enabled': True, + 'frequency': 'daily', + 'time': '03:00', + }) + + task = PeriodicTask.objects.get(name='backup-scheduled-task') + first_crontab_id = task.crontab.id + initial_count = CrontabSchedule.objects.count() + + # Change to weekly schedule (different crontab) + scheduler.update_schedule_settings({ + 'enabled': True, + 'frequency': 'weekly', + 'day_of_week': 3, + 'time': '03:00', + }) + + task.refresh_from_db() + second_crontab_id = task.crontab.id + + # Verify old crontab was deleted + self.assertNotEqual(first_crontab_id, second_crontab_id) + self.assertFalse(CrontabSchedule.objects.filter(id=first_crontab_id).exists()) + self.assertEqual(CrontabSchedule.objects.count(), initial_count) + + # Cleanup + scheduler.update_schedule_settings({'enabled': False}) + + +class BackupTasksTestCase(TestCase): + """Test cases for backup Celery tasks""" + + def setUp(self): + self.temp_backup_dir = tempfile.mkdtemp() + + def tearDown(self): + import shutil + if Path(self.temp_backup_dir).exists(): + shutil.rmtree(self.temp_backup_dir) + + @patch('apps.backups.tasks.services.list_backups') + @patch('apps.backups.tasks.services.delete_backup') + def test_cleanup_old_backups_keeps_recent(self, mock_delete, mock_list): + """Test that cleanup keeps the most recent backups""" + from .tasks import _cleanup_old_backups + + mock_list.return_value = [ + {'name': 'backup-3.zip'}, # newest + {'name': 'backup-2.zip'}, + {'name': 'backup-1.zip'}, # oldest + ] + + deleted = _cleanup_old_backups(retention_count=2) + + self.assertEqual(deleted, 1) + mock_delete.assert_called_once_with('backup-1.zip') + + @patch('apps.backups.tasks.services.list_backups') + @patch('apps.backups.tasks.services.delete_backup') + def test_cleanup_old_backups_does_nothing_when_under_limit(self, mock_delete, mock_list): + """Test that cleanup does nothing when under retention limit""" + from .tasks import _cleanup_old_backups + + mock_list.return_value = [ + {'name': 'backup-2.zip'}, + {'name': 'backup-1.zip'}, + ] + + deleted = _cleanup_old_backups(retention_count=5) + + self.assertEqual(deleted, 0) + mock_delete.assert_not_called() + + @patch('apps.backups.tasks.services.list_backups') + @patch('apps.backups.tasks.services.delete_backup') + def test_cleanup_old_backups_zero_retention_keeps_all(self, mock_delete, mock_list): + """Test that retention_count=0 keeps all backups""" + from .tasks import _cleanup_old_backups + + mock_list.return_value = [ + {'name': 'backup-3.zip'}, + {'name': 'backup-2.zip'}, + {'name': 'backup-1.zip'}, + ] + + deleted = _cleanup_old_backups(retention_count=0) + + self.assertEqual(deleted, 0) + mock_delete.assert_not_called() + + @patch('apps.backups.tasks.services.create_backup') + @patch('apps.backups.tasks._cleanup_old_backups') + def test_scheduled_backup_task_success(self, mock_cleanup, mock_create): + """Test scheduled backup task success""" + from .tasks import scheduled_backup_task + + mock_backup_file = MagicMock() + mock_backup_file.name = 'scheduled-backup.zip' + mock_backup_file.stat.return_value.st_size = 1024 + mock_create.return_value = mock_backup_file + mock_cleanup.return_value = 2 + + result = scheduled_backup_task(retention_count=5) + + self.assertEqual(result['status'], 'completed') + self.assertEqual(result['filename'], 'scheduled-backup.zip') + self.assertEqual(result['size'], 1024) + self.assertEqual(result['deleted_count'], 2) + mock_cleanup.assert_called_once_with(5) + + @patch('apps.backups.tasks.services.create_backup') + @patch('apps.backups.tasks._cleanup_old_backups') + def test_scheduled_backup_task_no_cleanup_when_retention_zero(self, mock_cleanup, mock_create): + """Test scheduled backup skips cleanup when retention is 0""" + from .tasks import scheduled_backup_task + + mock_backup_file = MagicMock() + mock_backup_file.name = 'scheduled-backup.zip' + mock_backup_file.stat.return_value.st_size = 1024 + mock_create.return_value = mock_backup_file + + result = scheduled_backup_task(retention_count=0) + + self.assertEqual(result['status'], 'completed') + self.assertEqual(result['deleted_count'], 0) + mock_cleanup.assert_not_called() + + @patch('apps.backups.tasks.services.create_backup') + def test_scheduled_backup_task_failure(self, mock_create): + """Test scheduled backup task handles failure""" + from .tasks import scheduled_backup_task + + mock_create.side_effect = Exception("Backup failed") + + result = scheduled_backup_task(retention_count=5) + + self.assertEqual(result['status'], 'failed') + self.assertIn('Backup failed', result['error']) diff --git a/apps/channels/__init__.py b/apps/channels/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/channels/admin.py b/apps/channels/admin.py new file mode 100644 index 0000000..410f101 --- /dev/null +++ b/apps/channels/admin.py @@ -0,0 +1,38 @@ +from django.contrib import admin +from .models import Stream, Channel, ChannelGroup + +@admin.register(Stream) +class StreamAdmin(admin.ModelAdmin): + list_display = ( + 'id', # Primary Key + 'name', + 'channel_group', + 'url', + 'current_viewers', + 'updated_at', + ) + + list_filter = ('channel_group',) # Filter by 'channel_group' (foreign key) + + search_fields = ('id', 'name', 'url', 'channel_group__name') # Search by 'ChannelGroup' name + + ordering = ('-updated_at',) + +@admin.register(Channel) +class ChannelAdmin(admin.ModelAdmin): + list_display = ( + 'id', # Primary Key + 'channel_number', + 'uuid', + 'name', + 'channel_group', + 'epg_data' + ) + list_filter = ('channel_group',) + search_fields = ('id', 'name', 'channel_group__name', 'epg_data') # Added 'id' + ordering = ('channel_number',) + +@admin.register(ChannelGroup) +class ChannelGroupAdmin(admin.ModelAdmin): + list_display = ('id', 'name') # Added 'id' + search_fields = ('id', 'name') # Added 'id' diff --git a/apps/channels/api_urls.py b/apps/channels/api_urls.py new file mode 100644 index 0000000..bd53ae4 --- /dev/null +++ b/apps/channels/api_urls.py @@ -0,0 +1,55 @@ +from django.urls import path, include +from rest_framework.routers import DefaultRouter +from .api_views import ( + StreamViewSet, + ChannelViewSet, + ChannelGroupViewSet, + BulkDeleteStreamsAPIView, + BulkDeleteChannelsAPIView, + BulkDeleteLogosAPIView, + CleanupUnusedLogosAPIView, + LogoViewSet, + ChannelProfileViewSet, + UpdateChannelMembershipAPIView, + BulkUpdateChannelMembershipAPIView, + RecordingViewSet, + RecurringRecordingRuleViewSet, + GetChannelStreamsAPIView, + SeriesRulesAPIView, + DeleteSeriesRuleAPIView, + EvaluateSeriesRulesAPIView, + BulkRemoveSeriesRecordingsAPIView, + BulkDeleteUpcomingRecordingsAPIView, + ComskipConfigAPIView, +) + +app_name = 'channels' # for DRF routing + +router = DefaultRouter() +router.register(r'streams', StreamViewSet, basename='stream') +router.register(r'groups', ChannelGroupViewSet, basename='channel-group') +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 + path('streams/bulk-delete/', BulkDeleteStreamsAPIView.as_view(), name='bulk_delete_streams'), + path('channels/bulk-delete/', BulkDeleteChannelsAPIView.as_view(), name='bulk_delete_channels'), + path('logos/bulk-delete/', BulkDeleteLogosAPIView.as_view(), name='bulk_delete_logos'), + path('logos/cleanup/', CleanupUnusedLogosAPIView.as_view(), name='cleanup_unused_logos'), + path('channels//streams/', GetChannelStreamsAPIView.as_view(), name='get_channel_streams'), + path('profiles//channels//', UpdateChannelMembershipAPIView.as_view(), name='update_channel_membership'), + path('profiles//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//', 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 diff --git a/apps/channels/api_views.py b/apps/channels/api_views.py new file mode 100644 index 0000000..10d855d --- /dev/null +++ b/apps/channels/api_views.py @@ -0,0 +1,3135 @@ +from rest_framework import viewsets, status +from rest_framework.response import Response +from rest_framework.views import APIView +from rest_framework.permissions import AllowAny +from rest_framework.decorators import action +from rest_framework.parsers import MultiPartParser, FormParser, JSONParser +from drf_spectacular.utils import extend_schema, OpenApiParameter, inline_serializer +from drf_spectacular.types import OpenApiTypes +from rest_framework import serializers +from django.shortcuts import get_object_or_404, get_list_or_404 +from django.db import transaction +from django.db.models import Count, F +from django.db.models import Q +import os, json, requests, logging, mimetypes, threading, time +from datetime import timedelta +from django.utils.http import http_date +from urllib.parse import unquote +from apps.accounts.permissions import ( + Authenticated, + IsAdmin, + IsOwnerOfObject, + permission_classes_by_action, + permission_classes_by_method, +) + +from core.models import UserAgent, CoreSettings +from core.utils import RedisClient, safe_upload_path + +from .models import ( + Stream, + Channel, + ChannelGroup, + ChannelStream, + Logo, + ChannelProfile, + ChannelProfileMembership, + Recording, + RecurringRecordingRule, +) +from .serializers import ( + StreamSerializer, + ChannelSerializer, + ChannelGroupSerializer, + LogoSerializer, + ChannelProfileMembershipSerializer, + BulkChannelProfileMembershipSerializer, + ChannelProfileSerializer, + RecordingSerializer, + RecurringRecordingRuleSerializer, +) +from .tasks import ( + match_epg_channels, + evaluate_series_rules_impl, + match_single_channel_epg, + match_selected_channels_epg, + sync_recurring_rule_impl, + purge_recurring_rule_impl, +) +import django_filters +from django_filters.rest_framework import DjangoFilterBackend +from rest_framework.filters import SearchFilter, OrderingFilter +from apps.epg.models import EPGData +from apps.vod.models import Movie, Series +from django.db.models import Q +from django.http import HttpResponse, StreamingHttpResponse, FileResponse, Http404 +from django.utils import timezone +import mimetypes +from django.conf import settings + +from rest_framework.pagination import PageNumberPagination + + + +logger = logging.getLogger(__name__) + +# Negative cache for remote logo URLs that failed to fetch. +# Prevents repeated blocking requests to unreachable hosts (e.g., dead CDNs) +# from exhausting Daphne workers. Keyed by URL, value is expiry timestamp. +_logo_fetch_failures = {} +_LOGO_FAIL_TTL = 300 # seconds + + +class OrInFilter(django_filters.Filter): + """ + Custom filter that handles the OR condition instead of AND. + """ + + def filter(self, queryset, value): + if value: + # Create a Q object for each value and combine them with OR + query = Q() + for val in value.split(","): + query |= Q(**{self.field_name: val}) + return queryset.filter(query) + return queryset + + +class StreamPagination(PageNumberPagination): + page_size = 50 # Default page size to match frontend default + page_size_query_param = "page_size" # Allow clients to specify page size + max_page_size = 10000 # Prevent excessive page sizes + + +class StreamFilter(django_filters.FilterSet): + name = django_filters.CharFilter(lookup_expr="icontains") + channel_group_name = OrInFilter( + field_name="channel_group__name", lookup_expr="icontains" + ) + m3u_account = django_filters.BaseInFilter(field_name="m3u_account__id") + m3u_account_name = django_filters.CharFilter( + field_name="m3u_account__name", lookup_expr="icontains" + ) + m3u_account_is_active = django_filters.BooleanFilter( + field_name="m3u_account__is_active" + ) + tvg_id = django_filters.CharFilter(lookup_expr="icontains") + + class Meta: + model = Stream + fields = [ + "name", + "channel_group_name", + "m3u_account", + "m3u_account_name", + "m3u_account_is_active", + "tvg_id", + ] + + +# ───────────────────────────────────────────────────────── +# 1) Stream API (CRUD) +# ───────────────────────────────────────────────────────── +class StreamViewSet(viewsets.ModelViewSet): + queryset = Stream.objects.all() + serializer_class = StreamSerializer + pagination_class = StreamPagination + + filter_backends = [DjangoFilterBackend, SearchFilter, OrderingFilter] + filterset_class = StreamFilter + search_fields = ["name", "channel_group__name"] + ordering_fields = ["name", "channel_group__name", "m3u_account__name", "tvg_id"] + ordering = ["-name"] + + def get_permissions(self): + if self.action == "duplicate": + return [IsAdmin()] + try: + return [perm() for perm in permission_classes_by_action[self.action]] + except KeyError: + return [Authenticated()] + + def get_queryset(self): + qs = super().get_queryset() + # Exclude streams from inactive M3U accounts + qs = qs.exclude(m3u_account__is_active=False) + + assigned = self.request.query_params.get("assigned") + if assigned is not None: + qs = qs.filter(channels__id=assigned) + + unassigned = self.request.query_params.get("unassigned") + if unassigned and str(unassigned).lower() in ("1", "true", "yes", "on"): + # Use annotation with Count for better performance on large datasets + qs = qs.annotate(channel_count=Count('channels')).filter(channel_count=0) + + channel_group = self.request.query_params.get("channel_group") + if channel_group: + group_names = channel_group.split(",") + qs = qs.filter(channel_group__name__in=group_names) + + # Allow client to hide stale streams (streams marked as is_stale=True) + hide_stale = self.request.query_params.get("hide_stale") + if hide_stale and str(hide_stale).lower() in ("1", "true", "yes", "on"): + qs = qs.filter(is_stale=False) + + return qs + + def list(self, request, *args, **kwargs): + ids = request.query_params.get("ids", None) + if ids: + ids = ids.split(",") + streams = get_list_or_404(Stream, id__in=ids) + serializer = self.get_serializer(streams, many=True) + return Response(serializer.data) + + return super().list(request, *args, **kwargs) + + @action(detail=False, methods=["get"], url_path="ids") + def get_ids(self, request, *args, **kwargs): + # Get the filtered queryset + queryset = self.get_queryset() + + # Apply filtering, search, and ordering + queryset = self.filter_queryset(queryset) + + # Return only the IDs from the queryset + stream_ids = queryset.values_list("id", flat=True) + + # Return the response with the list of IDs + return Response(list(stream_ids)) + + @action(detail=False, methods=["get"], url_path="groups") + def get_groups(self, request, *args, **kwargs): + # Get unique ChannelGroup names that are linked to streams + group_names = ( + ChannelGroup.objects.filter(streams__isnull=False) + .order_by("name") + .values_list("name", flat=True) + .distinct() + ) + + # Return the response with the list of unique group names + return Response(list(group_names)) + + @action(detail=False, methods=["get"], url_path="filter-options") + def get_filter_options(self, request, *args, **kwargs): + """ + Get available filter options based on current filter state. + Uses a hierarchical approach: M3U is the parent filter, Group filters based on M3U. + """ + # For group options: we need to bypass the channel_group custom queryset filtering + # Store original request params + original_params = request.query_params + + # Create modified params without channel_group for getting group options + params_without_group = request.GET.copy() + params_without_group.pop('channel_group', None) + params_without_group.pop('channel_group_name', None) + + # Temporarily modify request to exclude channel_group + request._request.GET = params_without_group + base_queryset_for_groups = self.get_queryset() + + # Apply filterset (which will apply M3U filters) + group_filterset = self.filterset_class( + params_without_group, + queryset=base_queryset_for_groups + ) + group_queryset = group_filterset.qs + + group_names = ( + group_queryset.exclude(channel_group__isnull=True) + .order_by("channel_group__name") + .values_list("channel_group__name", flat=True) + .distinct() + ) + + # For M3U options: show ALL M3Us (don't filter by anything except name search) + params_for_m3u = request.GET.copy() + params_for_m3u.pop('m3u_account', None) + params_for_m3u.pop('channel_group', None) + params_for_m3u.pop('channel_group_name', None) + + # Temporarily modify request to exclude filters for M3U options + request._request.GET = params_for_m3u + base_queryset_for_m3u = self.get_queryset() + + m3u_filterset = self.filterset_class( + params_for_m3u, + queryset=base_queryset_for_m3u + ) + m3u_queryset = m3u_filterset.qs + + m3u_accounts = ( + m3u_queryset.exclude(m3u_account__isnull=True) + .order_by("m3u_account__name") + .values("m3u_account__id", "m3u_account__name") + .distinct() + ) + + # Restore original params + request._request.GET = original_params + + return Response({ + "groups": list(group_names), + "m3u_accounts": [ + {"id": m3u["m3u_account__id"], "name": m3u["m3u_account__name"]} + for m3u in m3u_accounts + ] + }) + + @extend_schema( + methods=["POST"], + description="Retrieve streams by a list of IDs using POST to avoid URL length limitations", + request=inline_serializer( + name="StreamByIdsRequest", + fields={ + "ids": serializers.ListField( + child=serializers.IntegerField(), + help_text="List of stream IDs to retrieve" + ), + }, + ), + responses={200: StreamSerializer(many=True)}, + ) + @action(detail=False, methods=["post"], url_path="by-ids") + def get_by_ids(self, request, *args, **kwargs): + ids = request.data.get("ids", []) + if not isinstance(ids, list): + return Response( + {"error": "ids must be a list of integers"}, + status=status.HTTP_400_BAD_REQUEST, + ) + + streams = Stream.objects.filter(id__in=ids) + serializer = self.get_serializer(streams, many=True) + return Response(serializer.data) + + +# ───────────────────────────────────────────────────────── +# 2) Channel Group Management (CRUD) +# ───────────────────────────────────────────────────────── +class ChannelGroupViewSet(viewsets.ModelViewSet): + queryset = ChannelGroup.objects.all() + serializer_class = ChannelGroupSerializer + + def get_permissions(self): + try: + return [perm() for perm in permission_classes_by_action[self.action]] + except KeyError: + return [Authenticated()] + + def get_queryset(self): + """Return channel groups with prefetched relations for efficient counting""" + return ChannelGroup.objects.prefetch_related('channels', 'm3u_accounts').all() + + def update(self, request, *args, **kwargs): + """Override update to check M3U associations""" + instance = self.get_object() + + # Check if group has M3U account associations + if hasattr(instance, 'm3u_account') and instance.m3u_account.exists(): + return Response( + {"error": "Cannot edit group with M3U account associations"}, + status=status.HTTP_400_BAD_REQUEST + ) + + return super().update(request, *args, **kwargs) + + def partial_update(self, request, *args, **kwargs): + """Override partial_update to check M3U associations""" + instance = self.get_object() + + # Check if group has M3U account associations + if hasattr(instance, 'm3u_account') and instance.m3u_account.exists(): + return Response( + {"error": "Cannot edit group with M3U account associations"}, + status=status.HTTP_400_BAD_REQUEST + ) + + return super().partial_update(request, *args, **kwargs) + + @extend_schema( + methods=["POST"], + description="Delete all channel groups that have no associations (no channels or M3U accounts)", + ) + @action(detail=False, methods=["post"], url_path="cleanup") + def cleanup_unused_groups(self, request): + """Delete all channel groups with no channels or M3U account associations""" + from django.db.models import Q, Exists, OuterRef + + # Find groups with no channels and no M3U account associations using Exists subqueries + from .models import Channel, ChannelGroupM3UAccount + + has_channels = Channel.objects.filter(channel_group_id=OuterRef('pk')) + has_accounts = ChannelGroupM3UAccount.objects.filter(channel_group_id=OuterRef('pk')) + + unused_groups = ChannelGroup.objects.annotate( + has_channels=Exists(has_channels), + has_accounts=Exists(has_accounts) + ).filter( + has_channels=False, + has_accounts=False + ) + + deleted_count = unused_groups.count() + group_names = list(unused_groups.values_list('name', flat=True)) + + # Delete the unused groups + unused_groups.delete() + + return Response({ + "message": f"Successfully deleted {deleted_count} unused channel groups", + "deleted_count": deleted_count, + "deleted_groups": group_names + }) + + def destroy(self, request, *args, **kwargs): + """Override destroy to check for associations before deletion""" + instance = self.get_object() + + # Check if group has associated channels + if instance.channels.exists(): + return Response( + {"error": "Cannot delete group with associated channels"}, + status=status.HTTP_400_BAD_REQUEST + ) + + # Check if group has M3U account associations + if hasattr(instance, 'm3u_account') and instance.m3u_account.exists(): + return Response( + {"error": "Cannot delete group with M3U account associations"}, + status=status.HTTP_400_BAD_REQUEST + ) + + return super().destroy(request, *args, **kwargs) + + +# ───────────────────────────────────────────────────────── +# 3) Channel Management (CRUD) +# ───────────────────────────────────────────────────────── +class ChannelPagination(PageNumberPagination): + page_size = 50 # Default page size to match frontend default + page_size_query_param = "page_size" # Allow clients to specify page size + max_page_size = 10000 # Prevent excessive page sizes + + def paginate_queryset(self, queryset, request, view=None): + if not request.query_params.get(self.page_query_param): + return None # disables pagination, returns full queryset + + return super().paginate_queryset(queryset, request, view) + + def get_paginated_response(self, data): + from django.db.models import Exists, OuterRef + has_unassigned = Channel.objects.filter(epg_data__isnull=True).exists() + response = super().get_paginated_response(data) + response.data['has_unassigned_epg_channels'] = has_unassigned + return response + + +class EPGFilter(django_filters.Filter): + """ + Filter channels by EPG source name or null (unlinked). + """ + def filter(self, queryset, value): + if not value: + return queryset + + # Split comma-separated values + values = [v.strip() for v in value.split(',')] + query = Q() + + for val in values: + if val == 'null': + # Filter for channels with no EPG data + query |= Q(epg_data__isnull=True) + else: + # Filter for channels with specific EPG source name + query |= Q(epg_data__epg_source__name__icontains=val) + + return queryset.filter(query) + + +class ChannelFilter(django_filters.FilterSet): + name = django_filters.CharFilter(lookup_expr="icontains") + channel_group = OrInFilter( + field_name="channel_group__name", lookup_expr="icontains" + ) + epg = EPGFilter() + + class Meta: + model = Channel + fields = [ + "name", + "channel_group", + "epg", + ] + + +class ChannelViewSet(viewsets.ModelViewSet): + queryset = Channel.objects.all() + serializer_class = ChannelSerializer + pagination_class = ChannelPagination + + filter_backends = [DjangoFilterBackend, SearchFilter, OrderingFilter] + filterset_class = ChannelFilter + search_fields = ["name", "channel_group__name"] + ordering_fields = ["channel_number", "name", "channel_group__name", "epg_data__name"] + ordering = ["-channel_number"] + + def create(self, request, *args, **kwargs): + """Override create to handle channel profile membership""" + serializer = self.get_serializer(data=request.data) + serializer.is_valid(raise_exception=True) + + with transaction.atomic(): + channel = serializer.save() + + # Handle channel profile membership + # Semantics: + # - Omitted (None): add to ALL profiles (backward compatible default) + # - Empty array []: add to NO profiles + # - Sentinel [0] or 0: add to ALL profiles (explicit) + # - [1,2,...]: add to specified profile IDs only + channel_profile_ids = request.data.get("channel_profile_ids") + if channel_profile_ids is not None: + # Normalize single ID to array + if not isinstance(channel_profile_ids, list): + channel_profile_ids = [channel_profile_ids] + + # Determine action based on semantics + if channel_profile_ids is None: + # Omitted -> add to all profiles (backward compatible) + profiles = ChannelProfile.objects.all() + ChannelProfileMembership.objects.bulk_create([ + ChannelProfileMembership(channel_profile=profile, channel=channel, enabled=True) + for profile in profiles + ]) + elif isinstance(channel_profile_ids, list) and len(channel_profile_ids) == 0: + # Empty array -> add to no profiles + pass + elif isinstance(channel_profile_ids, list) and 0 in channel_profile_ids: + # Sentinel 0 -> add to all profiles (explicit) + profiles = ChannelProfile.objects.all() + ChannelProfileMembership.objects.bulk_create([ + ChannelProfileMembership(channel_profile=profile, channel=channel, enabled=True) + for profile in profiles + ]) + else: + # Specific profile IDs + try: + channel_profiles = ChannelProfile.objects.filter(id__in=channel_profile_ids) + if len(channel_profiles) != len(channel_profile_ids): + missing_ids = set(channel_profile_ids) - set(channel_profiles.values_list('id', flat=True)) + return Response( + {"error": f"Channel profiles with IDs {list(missing_ids)} not found"}, + status=status.HTTP_400_BAD_REQUEST, + ) + + ChannelProfileMembership.objects.bulk_create([ + ChannelProfileMembership( + channel_profile=profile, + channel=channel, + enabled=True + ) + for profile in channel_profiles + ]) + except Exception as e: + return Response( + {"error": f"Error creating profile memberships: {str(e)}"}, + status=status.HTTP_400_BAD_REQUEST, + ) + + headers = self.get_success_headers(serializer.data) + return Response(serializer.data, status=status.HTTP_201_CREATED, headers=headers) + + def get_permissions(self): + if self.action in [ + "edit_bulk", + "assign", + "from_stream", + "from_stream_bulk", + "match_epg", + "set_epg", + "batch_set_epg", + ]: + return [IsAdmin()] + + try: + return [perm() for perm in permission_classes_by_action[self.action]] + except KeyError: + return [Authenticated()] + + def get_queryset(self): + qs = ( + super() + .get_queryset() + .select_related( + "channel_group", + "logo", + "epg_data", + "stream_profile", + ) + .prefetch_related("streams") + ) + + channel_group = self.request.query_params.get("channel_group") + if channel_group: + group_names = channel_group.split(",") + qs = qs.filter(channel_group__name__in=group_names) + + filters = {} + q_filters = Q() + + channel_profile_id = self.request.query_params.get("channel_profile_id") + show_disabled_param = self.request.query_params.get("show_disabled", None) + only_streamless = self.request.query_params.get("only_streamless", None) + only_stale = self.request.query_params.get("only_stale", None) + + if channel_profile_id: + try: + profile_id_int = int(channel_profile_id) + + if show_disabled_param is None: + # Show only enabled channels: channels that have a membership + # record for this profile with enabled=True + # Default is DISABLED (channels without membership are hidden) + filters["channelprofilemembership__channel_profile_id"] = profile_id_int + filters["channelprofilemembership__enabled"] = True + # If show_disabled is True, show all channels (no filtering needed) + + except (ValueError, TypeError): + # Ignore invalid profile id values + pass + + if only_streamless: + q_filters &= Q(streams__isnull=True) + if only_stale: + # Filter channels that have at least one related stream marked as stale + q_filters &= Q(streams__is_stale=True) + + if self.request.user.user_level < 10: + filters["user_level__lte"] = self.request.user.user_level + # Hide adult content if user preference is set + custom_props = self.request.user.custom_properties or {} + if custom_props.get('hide_adult_content', False): + filters["is_adult"] = False + + if filters: + qs = qs.filter(**filters) + if q_filters: + qs = qs.filter(q_filters) + + return qs.distinct() + + def get_serializer_context(self): + context = super().get_serializer_context() + include_streams = ( + self.request.query_params.get("include_streams", "false") == "true" + ) + context["include_streams"] = include_streams + return context + + @extend_schema( + methods=["PATCH"], + description=( + "Bulk edit multiple channels in a single request. " + "Accepts a JSON array of channel update objects. Each object must include `id` (the channel's primary key). " + "All other fields are optional and support partial updates. " + "The `streams` field accepts a list of stream IDs and will replace the channel's current stream assignments. " + "All updates are validated before any changes are applied and executed in a single database transaction." + ), + request=inline_serializer( + name="ChannelBulkEditRequest", + fields={ + "id": serializers.IntegerField(help_text="ID of the channel to update (required)."), + "name": serializers.CharField(required=False), + "channel_number": serializers.FloatField(required=False), + "channel_group_id": serializers.IntegerField(required=False, allow_null=True), + "streams": serializers.ListField( + child=serializers.IntegerField(), + required=False, + help_text="List of stream IDs to assign to this channel (replaces existing assignments).", + ), + "stream_profile_id": serializers.IntegerField(required=False, allow_null=True), + "logo_id": serializers.IntegerField(required=False, allow_null=True), + "tvg_id": serializers.CharField(required=False, allow_blank=True), + "tvc_guide_stationid": serializers.CharField(required=False, allow_blank=True), + "epg_data_id": serializers.IntegerField(required=False, allow_null=True), + "user_level": serializers.IntegerField(required=False), + "is_adult": serializers.BooleanField(required=False), + }, + many=True, + ), + responses={ + 200: inline_serializer( + name="ChannelBulkEditResponse", + fields={ + "message": serializers.CharField(), + "channels": ChannelSerializer(many=True), + }, + ), + 400: inline_serializer( + name="ChannelBulkEditErrorResponse", + fields={ + "errors": serializers.ListField(child=serializers.DictField()), + }, + ), + }, + ) + @action(detail=False, methods=["patch"], url_path="edit/bulk") + def edit_bulk(self, request): + """ + Bulk edit channels efficiently. + Validates all updates first, then applies in a single transaction. + """ + data = request.data + if not isinstance(data, list): + return Response( + {"error": "Expected a list of channel updates"}, + status=status.HTTP_400_BAD_REQUEST, + ) + + # Extract IDs and validate presence + channel_updates = {} + missing_ids = [] + + for i, channel_data in enumerate(data): + channel_id = channel_data.get("id") + if not channel_id: + missing_ids.append(f"Item {i}: Channel ID is required") + else: + channel_updates[channel_id] = channel_data + + if missing_ids: + return Response( + {"errors": missing_ids}, + status=status.HTTP_400_BAD_REQUEST, + ) + + # Fetch all channels at once (one query) + channels_dict = { + c.id: c for c in Channel.objects.filter(id__in=channel_updates.keys()) + } + + # Validate and prepare updates + validated_updates = [] + errors = [] + + for channel_id, channel_data in channel_updates.items(): + channel = channels_dict.get(channel_id) + + if not channel: + errors.append({ + "channel_id": channel_id, + "error": "Channel not found" + }) + continue + + # Handle channel_group_id conversion + if 'channel_group_id' in channel_data: + group_id = channel_data['channel_group_id'] + if group_id is not None: + try: + channel_data['channel_group_id'] = int(group_id) + except (ValueError, TypeError): + channel_data['channel_group_id'] = None + + # Validate with serializer + serializer = ChannelSerializer( + channel, data=channel_data, partial=True + ) + + if serializer.is_valid(): + validated_updates.append((channel, serializer.validated_data)) + else: + errors.append({ + "channel_id": channel_id, + "errors": serializer.errors + }) + + if errors: + return Response( + {"errors": errors, "updated_count": len(validated_updates)}, + status=status.HTTP_400_BAD_REQUEST, + ) + + # Apply all updates in a transaction + with transaction.atomic(): + streams_updates = [] + for channel, validated_data in validated_updates: + # Pop streams before setattr loop — M2M fields can't be set via setattr + streams = validated_data.pop("streams", None) + if streams is not None: + streams_updates.append((channel, streams)) + for key, value in validated_data.items(): + setattr(channel, key, value) + + # Single bulk_update query instead of individual saves + channels_to_update = [channel for channel, _ in validated_updates] + if channels_to_update: + # Collect all unique field names from all updates (streams already popped) + all_fields = set() + for _, validated_data in validated_updates: + all_fields.update(validated_data.keys()) + + # Only call bulk_update if there are non-M2M fields to update + if all_fields: + Channel.objects.bulk_update( + channels_to_update, + fields=list(all_fields), + batch_size=100 + ) + + # Handle streams M2M updates separately + for channel, streams in streams_updates: + normalized_ids = [ + stream.id if hasattr(stream, "id") else stream for stream in streams + ] + current_links = { + cs.stream_id: cs for cs in channel.channelstream_set.all() + } + existing_ids = set(current_links.keys()) + new_ids = set(normalized_ids) + + to_remove = existing_ids - new_ids + if to_remove: + channel.channelstream_set.filter(stream_id__in=to_remove).delete() + + for order, stream_id in enumerate(normalized_ids): + if stream_id in current_links: + cs = current_links[stream_id] + if cs.order != order: + cs.order = order + cs.save(update_fields=["order"]) + else: + ChannelStream.objects.create( + channel=channel, stream_id=stream_id, order=order + ) + + # Return the updated objects (already in memory) + serialized_channels = ChannelSerializer( + [channel for channel, _ in validated_updates], + many=True, + context=self.get_serializer_context() + ).data + + return Response({ + "message": f"Successfully updated {len(validated_updates)} channels", + "channels": serialized_channels + }) + + @extend_schema( + methods=["POST"], + description=( + "Bulk rename channel names using a regex find/replace executed server-side. " + "Accepts JavaScript-style named groups (e.g., (?...)) and converts them to Python syntax. " + "Supports flags: 'i' (IGNORECASE). Replacement tokens like $1, $& and $ are translated to Python." + ), + request=inline_serializer( + name="BulkRegexRenameRequest", + fields={ + "channel_ids": serializers.ListField(child=serializers.IntegerField()), + "find": serializers.CharField(), + "replace": serializers.CharField(required=False, allow_blank=True), + "flags": serializers.CharField(required=False, allow_blank=True), + }, + ), + ) + @action(detail=False, methods=["post"], url_path="edit/bulk-regex") + def bulk_regex_rename(self, request): + """ + Efficiently apply a regex find/replace to the `name` field of multiple channels. + """ + import regex as re + + channel_ids = request.data.get("channel_ids", []) + pattern = request.data.get("find", "") + replace = request.data.get("replace", "") + flags_str = request.data.get("flags", "") or "" + + if not isinstance(channel_ids, list) or len(channel_ids) == 0: + return Response({"error": "channel_ids must be a non-empty list"}, status=status.HTTP_400_BAD_REQUEST) + if not isinstance(pattern, str) or pattern.strip() == "": + return Response({"error": "find (regex pattern) is required"}, status=status.HTTP_400_BAD_REQUEST) + if not isinstance(replace, str): + return Response({"error": "replace must be a string"}, status=status.HTTP_400_BAD_REQUEST) + + # Convert JS-style named groups to Python (?...) -> (?P...) + try: + converted_pattern = re.sub(r"\(\?<([^>]+)>", r"(?P<\1>", pattern) + except Exception as e: + return Response({"error": f"Failed to normalize pattern: {e}"}, status=status.HTTP_400_BAD_REQUEST) + + # Compile flags + re_flags = 0 + if "i" in flags_str: + re_flags |= re.IGNORECASE + # Note: 'g' (global) is the default behavior of re.sub; no action needed. + + # Translate common JS replacement tokens to Python + def translate_js_replacement(rep: str) -> str: + # $$ -> $ + rep = rep.replace("$$", "$") + # $& -> \g<0> + rep = rep.replace("$&", r"\g<0>") + # $ -> \g + rep = re.sub(r"\$<([A-Za-z_][A-Za-z0-9_]*)>", r"\\g<\1>", rep) + # $1 -> \g<1>, $2 -> \g<2>, etc. + rep = re.sub(r"\$(\d+)", r"\\g<\1>", rep) + return rep + + try: + replacement_py = translate_js_replacement(replace) + compiled = re.compile(converted_pattern, flags=re_flags) + except Exception as e: + return Response({"error": f"Invalid regex pattern: {e}"}, status=status.HTTP_400_BAD_REQUEST) + + # Fetch channels in one query + channels = list(Channel.objects.filter(id__in=channel_ids)) + if not channels: + return Response({"error": "No matching channels found for provided IDs"}, status=status.HTTP_404_NOT_FOUND) + + changed = [] + for ch in channels: + current = ch.name or "" + try: + new_name = compiled.sub(replacement_py, current) + except Exception as e: + # Skip problematic replacements but continue processing others + logger.warning(f"Regex replacement failed for channel {ch.id}: {e}") + continue + + # Only update if name actually changes and remains non-empty + if new_name != current and new_name.strip(): + ch.name = new_name + changed.append(ch) + + # Apply updates in bulk + updated_count = 0 + if changed: + with transaction.atomic(): + Channel.objects.bulk_update(changed, fields=["name"], batch_size=100) + updated_count = len(changed) + + return Response({ + "success": True, + "updated_count": updated_count, + }, status=status.HTTP_200_OK) + + @action(detail=False, methods=["post"], url_path="set-names-from-epg") + def set_names_from_epg(self, request): + """ + Trigger a Celery task to set channel names from EPG data + """ + from .tasks import set_channels_names_from_epg + + data = request.data + channel_ids = data.get("channel_ids", []) + + if not channel_ids: + return Response( + {"error": "channel_ids is required"}, + status=status.HTTP_400_BAD_REQUEST, + ) + + if not isinstance(channel_ids, list): + return Response( + {"error": "channel_ids must be a list"}, + status=status.HTTP_400_BAD_REQUEST, + ) + + # Start the Celery task + task = set_channels_names_from_epg.delay(channel_ids) + + return Response({ + "message": f"Started EPG name setting task for {len(channel_ids)} channels", + "task_id": task.id, + "channel_count": len(channel_ids) + }) + + @action(detail=False, methods=["post"], url_path="set-logos-from-epg") + def set_logos_from_epg(self, request): + """ + Trigger a Celery task to set channel logos from EPG data + """ + from .tasks import set_channels_logos_from_epg + + data = request.data + channel_ids = data.get("channel_ids", []) + + if not channel_ids: + return Response( + {"error": "channel_ids is required"}, + status=status.HTTP_400_BAD_REQUEST, + ) + + if not isinstance(channel_ids, list): + return Response( + {"error": "channel_ids must be a list"}, + status=status.HTTP_400_BAD_REQUEST, + ) + + # Start the Celery task + task = set_channels_logos_from_epg.delay(channel_ids) + + return Response({ + "message": f"Started EPG logo setting task for {len(channel_ids)} channels", + "task_id": task.id, + "channel_count": len(channel_ids) + }) + + @action(detail=False, methods=["post"], url_path="set-tvg-ids-from-epg") + def set_tvg_ids_from_epg(self, request): + """ + Trigger a Celery task to set channel TVG-IDs from EPG data + """ + from .tasks import set_channels_tvg_ids_from_epg + + data = request.data + channel_ids = data.get("channel_ids", []) + + if not channel_ids: + return Response( + {"error": "channel_ids is required"}, + status=status.HTTP_400_BAD_REQUEST, + ) + + if not isinstance(channel_ids, list): + return Response( + {"error": "channel_ids must be a list"}, + status=status.HTTP_400_BAD_REQUEST, + ) + + # Start the Celery task + task = set_channels_tvg_ids_from_epg.delay(channel_ids) + + return Response({ + "message": f"Started EPG TVG-ID setting task for {len(channel_ids)} channels", + "task_id": task.id, + "channel_count": len(channel_ids) + }) + + @action(detail=False, methods=["get"], url_path="ids") + def get_ids(self, request, *args, **kwargs): + # Get the filtered queryset + queryset = self.get_queryset() + + # Apply filtering, search, and ordering + queryset = self.filter_queryset(queryset) + + # Return only the IDs from the queryset + channel_ids = queryset.values_list("id", flat=True) + + # Return the response with the list of IDs + return Response(list(channel_ids)) + + @action(detail=False, methods=["get"], url_path="summary") + def summary(self, request, *args, **kwargs): + """Return a lightweight list of channels with only the fields needed by the TV Guide.""" + queryset = self.filter_queryset(self.get_queryset()) + data = list( + queryset.values( + "id", + "name", + "logo_id", + "channel_number", + "uuid", + "epg_data_id", + "channel_group_id", + ) + ) + return Response(data) + + @extend_schema( + methods=["POST"], + description="Retrieve channels by a list of UUIDs using POST to avoid URL length limitations", + request=inline_serializer( + name="ChannelByUUIDsRequest", + fields={ + "uuids": serializers.ListField( + child=serializers.CharField(), + help_text="List of channel UUIDs to retrieve", + ) + }, + ), + responses={200: ChannelSerializer(many=True)}, + ) + @action(detail=False, methods=["post"], url_path="by-uuids") + def get_by_uuids(self, request, *args, **kwargs): + uuids = request.data.get("uuids", []) + if not isinstance(uuids, list): + return Response( + {"error": "uuids must be a list of strings"}, + status=status.HTTP_400_BAD_REQUEST, + ) + + channels = Channel.objects.filter(uuid__in=uuids) + serializer = self.get_serializer(channels, many=True) + return Response(serializer.data) + + @extend_schema( + methods=["POST"], + description="Auto-assign channel_number in bulk by an ordered list of channel IDs.", + request=inline_serializer( + name="AssignChannelsRequest", + fields={ + "starting_number": serializers.FloatField( + help_text="Starting channel number to assign (can be decimal)", + required=False, + ), + "channel_ids": serializers.ListField( + child=serializers.IntegerField(), + help_text="Channel IDs to assign", + ), + }, + ), + ) + @action(detail=False, methods=["post"], url_path="assign") + def assign(self, request): + with transaction.atomic(): + channel_ids = request.data.get("channel_ids", []) + # Ensure starting_number is processed as a float + try: + channel_num = float(request.data.get("starting_number", 1)) + except (ValueError, TypeError): + channel_num = 1.0 + + for channel_id in channel_ids: + Channel.objects.filter(id=channel_id).update(channel_number=channel_num) + channel_num = channel_num + 1 + + return Response( + {"message": "Channels have been auto-assigned!"}, status=status.HTTP_200_OK + ) + + @extend_schema( + methods=["POST"], + description=( + "Create a new channel from an existing stream. " + "If 'channel_number' is provided, it will be used (if available); " + "otherwise, the next available channel number is assigned. " + "If 'channel_profile_ids' is provided, the channel will only be added to those profiles. " + "Accepts either a single ID or an array of IDs." + ), + request=inline_serializer( + name="FromStreamRequest", + fields={ + "stream_id": serializers.IntegerField(help_text="ID of the stream to link"), + "channel_number": serializers.FloatField( + help_text="(Optional) Desired channel number. Must not be in use.", + required=False, + ), + "name": serializers.CharField(help_text="Desired channel name", required=False), + "channel_profile_ids": serializers.ListField( + child=serializers.IntegerField(), + help_text="(Optional) Channel profile ID(s). Behavior: omitted = add to ALL profiles (default); empty array [] = add to NO profiles; [0] = add to ALL profiles (explicit); [1,2,...] = add only to specified profiles.", + required=False, + ), + }, + ), + responses={201: ChannelSerializer()}, + ) + @action(detail=False, methods=["post"], url_path="from-stream") + def from_stream(self, request): + stream_id = request.data.get("stream_id") + if not stream_id: + return Response( + {"error": "Missing stream_id"}, status=status.HTTP_400_BAD_REQUEST + ) + stream = get_object_or_404(Stream, pk=stream_id) + channel_group = stream.channel_group + + name = request.data.get("name") + + + if name is None: + name = stream.name + + # Check if client provided a channel_number; if not, use stream_chno or auto-assign + channel_number = request.data.get("channel_number") + + if channel_number is None: + # Channel number not provided by client, check stream's channel number or auto-assign + if stream.stream_chno is not None: + channel_number = stream.stream_chno + elif channel_number == 0: + # Special case: 0 means ignore provider numbers and auto-assign + channel_number = None + elif channel_number == -1: + # Special case: -1 means assign the number after the current highest + highest = Channel.objects.order_by('-channel_number').values_list('channel_number', flat=True).first() + channel_number = (int(highest) + 1) if highest is not None else 1 + + if channel_number is None: + # Still None, auto-assign the next available channel number + channel_number = Channel.get_next_available_channel_number() + + + try: + channel_number = float(channel_number) + except ValueError: + return Response( + {"error": "channel_number must be an integer."}, + status=status.HTTP_400_BAD_REQUEST, + ) + # If the provided number is already used, return an error. + if Channel.objects.filter(channel_number=channel_number).exists(): + channel_number = Channel.get_next_available_channel_number(channel_number) + # Get the tvc_guide_stationid from custom properties if it exists + stream_custom_props = stream.custom_properties or {} + tvc_guide_stationid = stream_custom_props.get("tvc-guide-stationid") + + channel_data = { + "channel_number": channel_number, + "name": name, + "tvg_id": stream.tvg_id, + "tvc_guide_stationid": tvc_guide_stationid, + "streams": [stream_id], + "is_adult": stream.is_adult, + } + + # Only add channel_group_id if the stream has a channel group + if channel_group: + channel_data["channel_group_id"] = channel_group.id + + if stream.logo_url: + # Import validation function + from apps.channels.tasks import validate_logo_url + validated_logo_url = validate_logo_url(stream.logo_url) + if validated_logo_url: + logo, _ = Logo.objects.get_or_create( + url=validated_logo_url, defaults={"name": stream.name or stream.tvg_id} + ) + channel_data["logo_id"] = logo.id + + # Attempt to find existing EPGs with the same tvg-id + epgs = EPGData.objects.filter(tvg_id=stream.tvg_id) + if epgs: + channel_data["epg_data_id"] = epgs.first().id + + serializer = self.get_serializer(data=channel_data) + serializer.is_valid(raise_exception=True) + + with transaction.atomic(): + channel = serializer.save() + channel.streams.add(stream) + + # Handle channel profile membership + # Semantics: + # - Omitted (None): add to ALL profiles (backward compatible default) + # - Empty array []: add to NO profiles + # - Sentinel [0] or 0: add to ALL profiles (explicit) + # - [1,2,...]: add to specified profile IDs only + channel_profile_ids = request.data.get("channel_profile_ids") + if channel_profile_ids is not None: + # Normalize single ID to array + if not isinstance(channel_profile_ids, list): + channel_profile_ids = [channel_profile_ids] + + # Determine action based on semantics + if channel_profile_ids is None: + # Omitted -> add to all profiles (backward compatible) + profiles = ChannelProfile.objects.all() + ChannelProfileMembership.objects.bulk_create([ + ChannelProfileMembership(channel_profile=profile, channel=channel, enabled=True) + for profile in profiles + ]) + elif isinstance(channel_profile_ids, list) and len(channel_profile_ids) == 0: + # Empty array -> add to no profiles + pass + elif isinstance(channel_profile_ids, list) and 0 in channel_profile_ids: + # Sentinel 0 -> add to all profiles (explicit) + profiles = ChannelProfile.objects.all() + ChannelProfileMembership.objects.bulk_create([ + ChannelProfileMembership(channel_profile=profile, channel=channel, enabled=True) + for profile in profiles + ]) + else: + # Specific profile IDs + try: + channel_profiles = ChannelProfile.objects.filter(id__in=channel_profile_ids) + if len(channel_profiles) != len(channel_profile_ids): + missing_ids = set(channel_profile_ids) - set(channel_profiles.values_list('id', flat=True)) + return Response( + {"error": f"Channel profiles with IDs {list(missing_ids)} not found"}, + status=status.HTTP_400_BAD_REQUEST, + ) + + ChannelProfileMembership.objects.bulk_create([ + ChannelProfileMembership( + channel_profile=profile, + channel=channel, + enabled=True + ) + for profile in channel_profiles + ]) + except Exception as e: + return Response( + {"error": f"Error creating profile memberships: {str(e)}"}, + status=status.HTTP_400_BAD_REQUEST, + ) + + # Send WebSocket notification for single channel creation + from core.utils import send_websocket_update + send_websocket_update('updates', 'update', { + 'type': 'channels_created', + 'count': 1, + 'channel_id': channel.id, + 'channel_name': channel.name, + 'channel_number': channel.channel_number + }) + + return Response(serializer.data, status=status.HTTP_201_CREATED) + + @extend_schema( + methods=["POST"], + description=( + "Asynchronously bulk create channels from stream IDs. " + "Returns a task ID to track progress via WebSocket. " + "This is the recommended approach for large bulk operations." + ), + request=inline_serializer( + name="FromStreamBulkRequest", + fields={ + "stream_ids": serializers.ListField( + child=serializers.IntegerField(), + help_text="List of stream IDs to create channels from" + ), + "channel_profile_ids": serializers.ListField( + child=serializers.IntegerField(), + help_text="(Optional) Channel profile ID(s). Behavior: omitted = add to ALL profiles (default); empty array [] = add to NO profiles; [0] = add to ALL profiles (explicit); [1,2,...] = add only to specified profiles.", + required=False, + ), + "starting_channel_number": serializers.IntegerField( + help_text="(Optional) Starting channel number mode: null=use provider numbers, 0=lowest available, other=start from specified number", + required=False, + ), + }, + ), + ) + @action(detail=False, methods=["post"], url_path="from-stream/bulk") + def from_stream_bulk(self, request): + from .tasks import bulk_create_channels_from_streams + + stream_ids = request.data.get("stream_ids", []) + channel_profile_ids = request.data.get("channel_profile_ids") + starting_channel_number = request.data.get("starting_channel_number") + + if not stream_ids: + return Response( + {"error": "stream_ids is required and cannot be empty"}, + status=status.HTTP_400_BAD_REQUEST, + ) + + if not isinstance(stream_ids, list): + return Response( + {"error": "stream_ids must be a list of integers"}, + status=status.HTTP_400_BAD_REQUEST, + ) + + # Normalize channel_profile_ids to array if single ID provided + if channel_profile_ids is not None: + if not isinstance(channel_profile_ids, list): + channel_profile_ids = [channel_profile_ids] + + # Start the async task + task = bulk_create_channels_from_streams.delay(stream_ids, channel_profile_ids, starting_channel_number) + + return Response({ + "task_id": task.id, + "message": f"Bulk channel creation task started for {len(stream_ids)} streams", + "stream_count": len(stream_ids), + "status": "started" + }, status=status.HTTP_202_ACCEPTED) + + # ───────────────────────────────────────────────────────── + # 6) EPG Fuzzy Matching + # ───────────────────────────────────────────────────────── + @extend_schema( + methods=["POST"], + description="Kick off a Celery task that tries to fuzzy-match channels with EPG data. If channel_ids are provided, only those channels will be processed.", + request=inline_serializer( + name="MatchEpgRequest", + fields={ + 'channel_ids': serializers.ListField( + child=serializers.IntegerField(), + help_text='List of channel IDs to process. If empty or not provided, all channels without EPG will be processed.', + required=False, + ) + } + ), + ) + @action(detail=False, methods=["post"], url_path="match-epg") + def match_epg(self, request): + # Get channel IDs from request body if provided + channel_ids = request.data.get('channel_ids', []) + + if channel_ids: + # Process only selected channels + from .tasks import match_selected_channels_epg + match_selected_channels_epg.delay(channel_ids) + message = f"EPG matching task initiated for {len(channel_ids)} selected channel(s)." + else: + # Process all channels without EPG (original behavior) + match_epg_channels.delay() + message = "EPG matching task initiated for all channels without EPG." + + return Response( + {"message": message}, status=status.HTTP_202_ACCEPTED + ) + + @extend_schema( + methods=["POST"], + description="Try to auto-match this specific channel with EPG data.", + ) + @action(detail=True, methods=["post"], url_path="match-epg") + def match_channel_epg(self, request, pk=None): + channel = self.get_object() + + # Import the matching logic + from apps.channels.tasks import match_single_channel_epg + + try: + # Try to match this specific channel - call synchronously for immediate response + result = match_single_channel_epg.apply_async(args=[channel.id]).get(timeout=30) + + # Refresh the channel from DB to get any updates + channel.refresh_from_db() + + return Response({ + "message": result.get("message", "Channel matching completed"), + "matched": result.get("matched", False), + "channel": self.get_serializer(channel).data + }) + except Exception as e: + return Response({"error": str(e)}, status=400) + + # ───────────────────────────────────────────────────────── + # 7) Set EPG and Refresh + # ───────────────────────────────────────────────────────── + @extend_schema( + methods=["POST"], + description="Set EPG data for a channel and refresh program data", + request=inline_serializer( + name="SetEpgRequest", + fields={ + "epg_data_id": serializers.IntegerField(help_text="EPG data ID to link") + }, + ), + responses={200: "EPG data linked and refresh triggered"}, + ) + @action(detail=True, methods=["post"], url_path="set-epg") + def set_epg(self, request, pk=None): + channel = self.get_object() + epg_data_id = request.data.get("epg_data_id") + + # Handle removing EPG link + if epg_data_id in (None, "", "0", 0): + channel.epg_data = None + channel.save(update_fields=["epg_data"]) + return Response( + {"message": f"EPG data removed from channel {channel.name}"} + ) + + try: + # Get the EPG data object + from apps.epg.models import EPGData + + epg_data = EPGData.objects.get(pk=epg_data_id) + + # Set the EPG data and save + channel.epg_data = epg_data + channel.save(update_fields=["epg_data"]) + + # Only trigger program refresh for non-dummy EPG sources + status_message = None + if epg_data.epg_source.source_type != 'dummy': + # Explicitly trigger program refresh for this EPG + from apps.epg.tasks import parse_programs_for_tvg_id + + task_result = parse_programs_for_tvg_id.delay(epg_data.id) + + # Prepare response with task status info + status_message = "EPG refresh queued" + if task_result.result == "Task already running": + status_message = "EPG refresh already in progress" + + # Build response message + message = f"EPG data set to {epg_data.tvg_id} for channel {channel.name}" + if status_message: + message += f". {status_message}" + + return Response( + { + "message": message, + "channel": self.get_serializer(channel).data, + "task_status": status_message, + } + ) + except Exception as e: + return Response({"error": str(e)}, status=400) + + @extend_schema( + description=( + "Reorder a channel by moving it after another channel (or to the start if insert_after_id is null). " + "The channel will receive the next whole number after the target channel, and all subsequent " + "channels will be renumbered accordingly." + ), + request=inline_serializer( + name="ReorderChannelRequest", + fields={ + "insert_after_id": serializers.IntegerField( + help_text="ID of the channel to insert after. Use null to move to the beginning.", + required=False, + allow_null=True, + ), + }, + ), + ) + @action(detail=True, methods=["post"], url_path="reorder") + def reorder(self, request, pk=None): + """ + Reorder a channel by moving it after another channel (or to the start if insert_after_id is null). + Shifts other channels as needed to maintain contiguous ordering. + """ + channel = self.get_object() + insert_after_id = request.data.get("insert_after_id") + old_channel_number = channel.channel_number + + with transaction.atomic(): + if insert_after_id is None: + # Move to the beginning (channel_number = 1) + target_number = 0 + desired_number = 1 + else: + try: + target_channel = Channel.objects.get(id=insert_after_id) + target_number = target_channel.channel_number or 0 + desired_number = int(target_number) + 1 + except Channel.DoesNotExist: + return Response( + {"error": "Target channel not found"}, + status=status.HTTP_404_NOT_FOUND, + ) + + if desired_number == old_channel_number: + # No change needed + return Response( + { + "message": f"Channel {channel.name} already at position {desired_number}", + "channel": self.get_serializer(channel).data, + }, + status=status.HTTP_200_OK, + ) + + if desired_number < old_channel_number: + # Moving up: increment all channels between desired_number and old_channel_number-1 + Channel.objects.filter( + channel_number__gte=desired_number, + channel_number__lt=old_channel_number + ).update(channel_number=F('channel_number') + 1) + channel.channel_number = desired_number + channel.save(update_fields=['channel_number']) + elif desired_number > old_channel_number: + # Moving down: shift down channels between old+1 and desired-1, then set to desired-1 + if desired_number > old_channel_number + 1: + Channel.objects.filter( + channel_number__gt=old_channel_number, + channel_number__lt=desired_number + ).update(channel_number=F('channel_number') - 1) + channel.channel_number = desired_number - 1 + channel.save(update_fields=['channel_number']) + else: + # No move or same position + channel.channel_number = desired_number + channel.save(update_fields=['channel_number']) + + return Response( + { + "message": f"Channel {channel.name} moved to position {desired_number}", + "channel": self.get_serializer(channel).data, + }, + status=status.HTTP_200_OK, + ) + + @extend_schema( + methods=["POST"], + description="Associate multiple channels with EPG data without triggering a full refresh", + request=inline_serializer( + name="BatchSetEpgRequest", + fields={ + "associations": serializers.ListField( + child=inline_serializer( + name="EpgAssociation", + fields={ + "channel_id": serializers.IntegerField(), + "epg_data_id": serializers.IntegerField( + required=False, + allow_null=True, + help_text="EPG data ID to link. Pass null to remove EPG linkage.", + ), + }, + ), + ) + }, + ), + ) + @action(detail=False, methods=["post"], url_path="batch-set-epg") + def batch_set_epg(self, request): + """Efficiently associate multiple channels with EPG data at once.""" + associations = request.data.get("associations", []) + + if not associations: + return Response( + {"error": "associations list is required"}, + status=status.HTTP_400_BAD_REQUEST, + ) + + # Extract channel IDs upfront + channel_updates = {} + unique_epg_ids = set() + + for assoc in associations: + channel_id = assoc.get("channel_id") + epg_data_id = assoc.get("epg_data_id") + + if not channel_id: + continue + + channel_updates[channel_id] = epg_data_id + if epg_data_id: + unique_epg_ids.add(epg_data_id) + + # Batch fetch all channels (single query) + channels_dict = { + c.id: c for c in Channel.objects.filter(id__in=channel_updates.keys()) + } + + # Collect channels to update + channels_to_update = [] + for channel_id, epg_data_id in channel_updates.items(): + if channel_id not in channels_dict: + logger.error(f"Channel with ID {channel_id} not found") + continue + + channel = channels_dict[channel_id] + channel.epg_data_id = epg_data_id + channels_to_update.append(channel) + + # Bulk update all channels (single query) + if channels_to_update: + with transaction.atomic(): + Channel.objects.bulk_update( + channels_to_update, + fields=["epg_data_id"], + batch_size=100 + ) + + channels_updated = len(channels_to_update) + + # Trigger program refresh for unique EPG data IDs (skip dummy EPGs) + from apps.epg.tasks import parse_programs_for_tvg_id + from apps.epg.models import EPGData + + # Batch fetch EPG data (single query) + epg_data_dict = { + epg.id: epg + for epg in EPGData.objects.filter(id__in=unique_epg_ids).select_related('epg_source') + } + + programs_refreshed = 0 + for epg_id in unique_epg_ids: + epg_data = epg_data_dict.get(epg_id) + if not epg_data: + logger.error(f"EPGData with ID {epg_id} not found") + continue + + # Only refresh non-dummy EPG sources + if epg_data.epg_source.source_type != 'dummy': + parse_programs_for_tvg_id.delay(epg_id) + programs_refreshed += 1 + + return Response( + { + "success": True, + "channels_updated": channels_updated, + "programs_refreshed": programs_refreshed, + } + ) + + +# ───────────────────────────────────────────────────────── +# 4) Bulk Delete Streams +# ───────────────────────────────────────────────────────── +class BulkDeleteStreamsAPIView(APIView): + def get_permissions(self): + try: + return [ + perm() for perm in permission_classes_by_method[self.request.method] + ] + except KeyError: + return [Authenticated()] + + @extend_schema( + description="Bulk delete streams by ID", + request=inline_serializer( + name="BulkDeleteStreamsRequest", + fields={ + "stream_ids": serializers.ListField( + child=serializers.IntegerField(), + help_text="Stream IDs to delete", + ) + }, + ), + ) + def delete(self, request, *args, **kwargs): + stream_ids = request.data.get("stream_ids", []) + Stream.objects.filter(id__in=stream_ids).delete() + return Response( + {"message": "Streams deleted successfully!"}, + status=status.HTTP_204_NO_CONTENT, + ) + + +# ───────────────────────────────────────────────────────── +# 5) Bulk Delete Channels +# ───────────────────────────────────────────────────────── +class BulkDeleteChannelsAPIView(APIView): + def get_permissions(self): + try: + return [ + perm() for perm in permission_classes_by_method[self.request.method] + ] + except KeyError: + return [Authenticated()] + + @extend_schema( + description="Bulk delete channels by ID", + request=inline_serializer( + name="BulkDeleteChannelsRequest", + fields={ + "channel_ids": serializers.ListField( + child=serializers.IntegerField(), + help_text="Channel IDs to delete", + ) + }, + ), + ) + def delete(self, request): + channel_ids = request.data.get("channel_ids", []) + Channel.objects.filter(id__in=channel_ids).delete() + return Response( + {"message": "Channels deleted"}, status=status.HTTP_204_NO_CONTENT + ) + + +# ───────────────────────────────────────────────────────── +# 6) Bulk Delete Logos +# ───────────────────────────────────────────────────────── +class BulkDeleteLogosAPIView(APIView): + def get_permissions(self): + try: + return [ + perm() for perm in permission_classes_by_method[self.request.method] + ] + except KeyError: + return [Authenticated()] + + @extend_schema( + description="Bulk delete logos by ID", + request=inline_serializer( + name="BulkDeleteLogosRequest", + fields={ + "logo_ids": serializers.ListField( + child=serializers.IntegerField(), + help_text="Logo IDs to delete", + ), + "delete_files": serializers.BooleanField( + required=False, + default=False, + help_text="Whether to also delete local logo files from disk.", + ), + }, + ), + ) + def delete(self, request): + logo_ids = request.data.get("logo_ids", []) + delete_files = request.data.get("delete_files", False) + + # Get logos and their usage info before deletion + logos_to_delete = Logo.objects.filter(id__in=logo_ids) + total_channels_affected = 0 + local_files_deleted = 0 + + for logo in logos_to_delete: + # Handle file deletion for local files + if delete_files and logo.url and logo.url.startswith('/data/logos'): + try: + if os.path.exists(logo.url): + os.remove(logo.url) + local_files_deleted += 1 + logger.info(f"Deleted local logo file: {logo.url}") + except Exception as e: + logger.error(f"Failed to delete logo file {logo.url}: {str(e)}") + return Response( + {"error": f"Failed to delete logo file {logo.url}: {str(e)}"}, + status=status.HTTP_500_INTERNAL_SERVER_ERROR + ) + + if logo.channels.exists(): + channel_count = logo.channels.count() + total_channels_affected += channel_count + # Remove logo from channels + logo.channels.update(logo=None) + logger.info(f"Removed logo {logo.name} from {channel_count} channels before deletion") + + # Delete logos + deleted_count = logos_to_delete.delete()[0] + + message = f"Successfully deleted {deleted_count} logos" + if total_channels_affected > 0: + message += f" and removed them from {total_channels_affected} channels" + if local_files_deleted > 0: + message += f" and deleted {local_files_deleted} local files" + + return Response( + {"message": message}, + status=status.HTTP_204_NO_CONTENT + ) + + +class CleanupUnusedLogosAPIView(APIView): + def get_permissions(self): + try: + return [ + perm() for perm in permission_classes_by_method[self.request.method] + ] + except KeyError: + return [Authenticated()] + + @extend_schema( + description="Delete all channel logos that are not used by any channels", + request=inline_serializer( + name="CleanupUnusedLogosRequest", + fields={ + "delete_files": serializers.BooleanField( + help_text="Whether to delete local logo files from disk", + default=False, + required=False, + ) + }, + ), + ) + def post(self, request): + """Delete all channel logos with no channel associations""" + delete_files = request.data.get("delete_files", False) + + # Find logos that are not used by any channels + unused_logos = Logo.objects.filter(channels__isnull=True) + deleted_count = unused_logos.count() + logo_names = list(unused_logos.values_list('name', flat=True)) + local_files_deleted = 0 + + # Handle file deletion for local files if requested + if delete_files: + for logo in unused_logos: + if logo.url and logo.url.startswith('/data/logos'): + try: + if os.path.exists(logo.url): + os.remove(logo.url) + local_files_deleted += 1 + logger.info(f"Deleted local logo file: {logo.url}") + except Exception as e: + logger.error(f"Failed to delete logo file {logo.url}: {str(e)}") + return Response( + {"error": f"Failed to delete logo file {logo.url}: {str(e)}"}, + status=status.HTTP_500_INTERNAL_SERVER_ERROR + ) + + # Delete the unused logos + unused_logos.delete() + + message = f"Successfully deleted {deleted_count} unused logos" + if local_files_deleted > 0: + message += f" and deleted {local_files_deleted} local files" + + return Response({ + "message": message, + "deleted_count": deleted_count, + "deleted_logos": logo_names, + "local_files_deleted": local_files_deleted + }) + + +class LogoPagination(PageNumberPagination): + page_size = 50 # Default page size to match frontend default + page_size_query_param = "page_size" # Allow clients to specify page size + max_page_size = 1000 # Prevent excessive page sizes + + def paginate_queryset(self, queryset, request, view=None): + # Check if pagination should be disabled for specific requests + if request.query_params.get('no_pagination') == 'true': + return None # disables pagination, returns full queryset + + return super().paginate_queryset(queryset, request, view) + + +class LogoViewSet(viewsets.ModelViewSet): + queryset = Logo.objects.all() + serializer_class = LogoSerializer + pagination_class = LogoPagination + parser_classes = (MultiPartParser, FormParser, JSONParser) + + def get_permissions(self): + if self.action in ["upload"]: + return [IsAdmin()] + + if self.action in ["cache"]: + return [AllowAny()] + + try: + return [perm() for perm in permission_classes_by_action[self.action]] + except KeyError: + return [Authenticated()] + + def get_queryset(self): + """Optimize queryset with prefetch and add filtering""" + # Start with basic prefetch for channels + queryset = Logo.objects.prefetch_related('channels').order_by('name') + + # Filter by specific IDs + ids = self.request.query_params.getlist('ids') + if ids: + try: + # Convert string IDs to integers and filter + id_list = [int(id_str) for id_str in ids if id_str.isdigit()] + if id_list: + queryset = queryset.filter(id__in=id_list) + except (ValueError, TypeError): + pass # Invalid IDs, return empty queryset + queryset = Logo.objects.none() + + # Filter by usage + used_filter = self.request.query_params.get('used', None) + if used_filter == 'true': + # Logo is used if it has any channels + queryset = queryset.filter(channels__isnull=False).distinct() + elif used_filter == 'false': + # Logo is unused if it has no channels + queryset = queryset.filter(channels__isnull=True) + + # Filter by name + name_filter = self.request.query_params.get('name', None) + if name_filter: + queryset = queryset.filter(name__icontains=name_filter) + + return queryset + + def create(self, request, *args, **kwargs): + """Create a new logo entry""" + serializer = self.get_serializer(data=request.data) + if serializer.is_valid(): + logo = serializer.save() + return Response(self.get_serializer(logo).data, status=status.HTTP_201_CREATED) + return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) + + def update(self, request, *args, **kwargs): + """Update an existing logo""" + partial = kwargs.pop('partial', False) + instance = self.get_object() + serializer = self.get_serializer(instance, data=request.data, partial=partial) + if serializer.is_valid(): + logo = serializer.save() + return Response(self.get_serializer(logo).data) + return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) + + def destroy(self, request, *args, **kwargs): + """Delete a logo and remove it from any channels using it""" + logo = self.get_object() + delete_file = request.query_params.get('delete_file', 'false').lower() == 'true' + + # Check if it's a local file that should be deleted + if delete_file and logo.url and logo.url.startswith('/data/logos'): + try: + if os.path.exists(logo.url): + os.remove(logo.url) + logger.info(f"Deleted local logo file: {logo.url}") + except Exception as e: + logger.error(f"Failed to delete logo file {logo.url}: {str(e)}") + return Response( + {"error": f"Failed to delete logo file: {str(e)}"}, + status=status.HTTP_500_INTERNAL_SERVER_ERROR + ) + + # Instead of preventing deletion, remove the logo from channels + if logo.channels.exists(): + channel_count = logo.channels.count() + logo.channels.update(logo=None) + logger.info(f"Removed logo {logo.name} from {channel_count} channels before deletion") + + return super().destroy(request, *args, **kwargs) + + @action(detail=False, methods=["post"]) + def upload(self, request): + if "file" not in request.FILES: + return Response( + {"error": "No file uploaded"}, status=status.HTTP_400_BAD_REQUEST + ) + + file = request.FILES["file"] + + # Validate file + try: + from dispatcharr.utils import validate_logo_file + validate_logo_file(file) + except Exception as e: + return Response( + {"error": str(e)}, status=status.HTTP_400_BAD_REQUEST + ) + + # Sanitize filename: strip directory components to prevent path traversal + try: + file_path = safe_upload_path(file.name, "/data/logos") + except ValueError: + return Response({"error": "Invalid filename."}, status=status.HTTP_400_BAD_REQUEST) + + os.makedirs("/data/logos", exist_ok=True) + with open(file_path, "wb+") as destination: + for chunk in file.chunks(): + destination.write(chunk) + + # Mark file as processed in Redis to prevent file scanner notifications + try: + redis_client = RedisClient.get_client() + if redis_client: + # Use the same key format as the file scanner + redis_key = f"processed_file:{file_path}" + # Store the actual file modification time to match the file scanner's expectation + file_mtime = os.path.getmtime(file_path) + redis_client.setex(redis_key, 60 * 60 * 24 * 3, str(file_mtime)) # 3 day TTL + logger.debug(f"Marked uploaded logo file as processed in Redis: {file_path} (mtime: {file_mtime})") + except Exception as e: + logger.warning(f"Failed to mark logo file as processed in Redis: {e}") + + # Get custom name from request data, fallback to filename + custom_name = request.data.get('name', '').strip() + logo_name = custom_name if custom_name else os.path.basename(file_path) + + logo, _ = Logo.objects.get_or_create( + url=file_path, + defaults={ + "name": logo_name, + }, + ) + + # Use get_serializer to ensure proper context + serializer = self.get_serializer(logo) + return Response( + serializer.data, + status=status.HTTP_201_CREATED, + ) + + @action(detail=True, methods=["get"], permission_classes=[AllowAny]) + def cache(self, request, pk=None): + """Streams the logo file, whether it's local or remote.""" + logo = self.get_object() + logo_url = logo.url + if logo_url.startswith("/data"): # Local file + if not os.path.exists(logo_url): + raise Http404("Image not found") + stat = os.stat(logo_url) + # Get proper mime type (first item of the tuple) + content_type, _ = mimetypes.guess_type(logo_url) + if not content_type: + content_type = "image/jpeg" # Default to a common image type + + # Use context manager and set Content-Disposition to inline + response = StreamingHttpResponse( + open(logo_url, "rb"), content_type=content_type + ) + response["Cache-Control"] = "public, max-age=14400" # Cache in browser for 4 hours + response["Last-Modified"] = http_date(stat.st_mtime) + response["Content-Disposition"] = 'inline; filename="{}"'.format( + os.path.basename(logo_url) + ) + return response + + else: # Remote image + # Skip URLs that recently failed to avoid blocking workers + # on unreachable hosts (e.g., dead CDNs referenced by old recordings). + fail_expiry = _logo_fetch_failures.get(logo_url) + if fail_expiry and time.monotonic() < fail_expiry: + raise Http404("Remote image temporarily unavailable") + + try: + # Get the default user agent + try: + default_user_agent_id = CoreSettings.get_default_user_agent_id() + user_agent_obj = UserAgent.objects.get(id=int(default_user_agent_id)) + user_agent = user_agent_obj.user_agent + except (CoreSettings.DoesNotExist, UserAgent.DoesNotExist, ValueError): + # Fallback to hardcoded if default not found + user_agent = 'Dispatcharr/1.0' + + # Hard total timeout (connect + full download) prevents a slow + # server dripping bytes from holding a greenlet indefinitely. + _LOGO_TOTAL_TIMEOUT = 10 # seconds + _LOGO_MAX_BYTES = 5 * 1024 * 1024 # 5 MB + + remote_response = requests.get( + logo_url, + stream=True, + timeout=(3, 5), # (connect_timeout, read_timeout per chunk) + headers={'User-Agent': user_agent} + ) + if remote_response.status_code == 200: + # Eagerly read the full image with a total time + size cap + # so the greenlet is released quickly. + chunks = [] + total = 0 + deadline = time.monotonic() + _LOGO_TOTAL_TIMEOUT + for chunk in remote_response.iter_content(chunk_size=8192): + total += len(chunk) + if total > _LOGO_MAX_BYTES: + remote_response.close() + raise Http404("Remote image too large") + if time.monotonic() > deadline: + remote_response.close() + now = time.monotonic() + _logo_fetch_failures[logo_url] = now + _LOGO_FAIL_TTL + raise Http404("Remote image fetch timed out") + chunks.append(chunk) + body = b"".join(chunks) + + # Full read succeeded, clear any previous failure entry + _logo_fetch_failures.pop(logo_url, None) + + # Try to get content type from response headers first + content_type = remote_response.headers.get("Content-Type") + + # If no content type in headers or it's empty, guess based on URL + if not content_type: + content_type, _ = mimetypes.guess_type(logo_url) + + # If still no content type, default to common image type + if not content_type: + content_type = "image/jpeg" + + response = HttpResponse( + body, + content_type=content_type, + ) + response["Content-Length"] = str(len(body)) + if remote_response.headers.get("Cache-Control"): + response["Cache-Control"] = remote_response.headers.get("Cache-Control") + if remote_response.headers.get("Last-Modified"): + response["Last-Modified"] = remote_response.headers.get("Last-Modified") + response["Content-Disposition"] = 'inline; filename="{}"'.format( + os.path.basename(logo_url) + ) + return response + # Non-200 response — cache the failure and evict stale entries + now = time.monotonic() + _logo_fetch_failures[logo_url] = now + _LOGO_FAIL_TTL + if len(_logo_fetch_failures) > 256: + for k in [k for k, v in _logo_fetch_failures.items() if v <= now]: + _logo_fetch_failures.pop(k, None) + raise Http404("Remote image not found") + except requests.RequestException as e: + now = time.monotonic() + _logo_fetch_failures[logo_url] = now + _LOGO_FAIL_TTL + if len(_logo_fetch_failures) > 256: + for k in [k for k, v in _logo_fetch_failures.items() if v <= now]: + _logo_fetch_failures.pop(k, None) + logger.warning(f"Error fetching logo from {logo_url}: {e}") + raise Http404("Error fetching remote image") + + +class ChannelProfileViewSet(viewsets.ModelViewSet): + queryset = ChannelProfile.objects.all() + serializer_class = ChannelProfileSerializer + + def get_queryset(self): + user = self.request.user + + # If user_level is 10, return all ChannelProfiles + if hasattr(user, "user_level") and user.user_level == 10: + return ChannelProfile.objects.all() + + # Otherwise, return only ChannelProfiles related to the user + return self.request.user.channel_profiles.all() + + def get_permissions(self): + if self.action == "duplicate": + return [IsAdmin()] + try: + return [perm() for perm in permission_classes_by_action[self.action]] + except KeyError: + return [Authenticated()] + + @action(detail=True, methods=["post"], url_path="duplicate", permission_classes=[IsAdmin]) + def duplicate(self, request, pk=None): + requested_name = str(request.data.get("name", "")).strip() + + if not requested_name: + return Response( + {"detail": "Name is required to duplicate a profile."}, + status=status.HTTP_400_BAD_REQUEST, + ) + + if ChannelProfile.objects.filter(name=requested_name).exists(): + return Response( + {"detail": "A channel profile with this name already exists."}, + status=status.HTTP_400_BAD_REQUEST, + ) + + source_profile = self.get_object() + + with transaction.atomic(): + new_profile = ChannelProfile.objects.create(name=requested_name) + + source_memberships = ChannelProfileMembership.objects.filter( + channel_profile=source_profile + ) + source_enabled_map = { + membership.channel_id: membership.enabled + for membership in source_memberships + } + + new_memberships = list( + ChannelProfileMembership.objects.filter(channel_profile=new_profile) + ) + for membership in new_memberships: + membership.enabled = source_enabled_map.get( + membership.channel_id, False + ) + + if new_memberships: + ChannelProfileMembership.objects.bulk_update( + new_memberships, ["enabled"] + ) + + serializer = self.get_serializer(new_profile) + return Response(serializer.data, status=status.HTTP_201_CREATED) + + +class GetChannelStreamsAPIView(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, channel_id): + channel = get_object_or_404(Channel, id=channel_id) + # Order the streams by channelstream__order to match the order in the channel view + streams = channel.streams.all().order_by("channelstream__order") + serializer = StreamSerializer(streams, many=True) + return Response(serializer.data) + + +class UpdateChannelMembershipAPIView(APIView): + permission_classes = [IsOwnerOfObject] + + def patch(self, request, profile_id, channel_id): + """Enable or disable a channel for a specific group""" + channel_profile = get_object_or_404(ChannelProfile, id=profile_id) + channel = get_object_or_404(Channel, id=channel_id) + try: + membership = ChannelProfileMembership.objects.get( + channel_profile=channel_profile, channel=channel + ) + except ChannelProfileMembership.DoesNotExist: + # Create the membership if it does not exist (for custom channels) + membership = ChannelProfileMembership.objects.create( + channel_profile=channel_profile, + channel=channel, + enabled=False # Default to False, will be updated below + ) + + serializer = ChannelProfileMembershipSerializer( + membership, data=request.data, partial=True + ) + if serializer.is_valid(): + serializer.save() + return Response(serializer.data, status=status.HTTP_200_OK) + + return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) + + +class BulkUpdateChannelMembershipAPIView(APIView): + def get_permissions(self): + try: + return [ + perm() for perm in permission_classes_by_method[self.request.method] + ] + except KeyError: + return [Authenticated()] + + @extend_schema( + description="Bulk enable or disable channels for a specific profile. Creates membership records if they don't exist.", + request=BulkChannelProfileMembershipSerializer, + ) + def patch(self, request, profile_id): + """Bulk enable or disable channels for a specific profile""" + # Get the channel profile + channel_profile = get_object_or_404(ChannelProfile, id=profile_id) + + # Validate the incoming data using the serializer + serializer = BulkChannelProfileMembershipSerializer(data=request.data) + + if serializer.is_valid(): + updates = serializer.validated_data["channels"] + channel_ids = [entry["channel_id"] for entry in updates] + + # Validate that all channels exist + existing_channels = set( + Channel.objects.filter(id__in=channel_ids).values_list("id", flat=True) + ) + invalid_channels = [cid for cid in channel_ids if cid not in existing_channels] + + if invalid_channels: + return Response( + { + "error": "Some channels do not exist", + "invalid_channels": invalid_channels, + }, + status=status.HTTP_400_BAD_REQUEST, + ) + + # Get existing memberships + existing_memberships = ChannelProfileMembership.objects.filter( + channel_profile=channel_profile, channel_id__in=channel_ids + ) + membership_dict = {m.channel_id: m for m in existing_memberships} + + # Prepare lists for bulk operations + memberships_to_update = [] + memberships_to_create = [] + + for entry in updates: + channel_id = entry["channel_id"] + enabled_status = entry["enabled"] + + if channel_id in membership_dict: + # Update existing membership + membership_dict[channel_id].enabled = enabled_status + memberships_to_update.append(membership_dict[channel_id]) + else: + # Create new membership + memberships_to_create.append( + ChannelProfileMembership( + channel_profile=channel_profile, + channel_id=channel_id, + enabled=enabled_status, + ) + ) + + # Perform bulk operations + with transaction.atomic(): + if memberships_to_update: + ChannelProfileMembership.objects.bulk_update( + memberships_to_update, ["enabled"] + ) + if memberships_to_create: + ChannelProfileMembership.objects.bulk_create(memberships_to_create) + + return Response( + { + "status": "success", + "updated": len(memberships_to_update), + "created": len(memberships_to_create), + "invalid_channels": [], + }, + status=status.HTTP_200_OK, + ) + + return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) + + +class RecurringRecordingRuleViewSet(viewsets.ModelViewSet): + queryset = RecurringRecordingRule.objects.all().select_related("channel") + serializer_class = RecurringRecordingRuleSerializer + + def get_permissions(self): + return [IsAdmin()] + + def perform_create(self, serializer): + rule = serializer.save() + try: + sync_recurring_rule_impl(rule.id, drop_existing=True) + except Exception as err: + logger.warning(f"Failed to initialize recurring rule {rule.id}: {err}") + return rule + + def perform_update(self, serializer): + rule = serializer.save() + try: + if rule.enabled: + sync_recurring_rule_impl(rule.id, drop_existing=True) + else: + purge_recurring_rule_impl(rule.id) + except Exception as err: + logger.warning(f"Failed to resync recurring rule {rule.id}: {err}") + return rule + + def perform_destroy(self, instance): + rule_id = instance.id + super().perform_destroy(instance) + try: + purge_recurring_rule_impl(rule_id) + except Exception as err: + logger.warning(f"Failed to purge recordings for rule {rule_id}: {err}") + + +def _stop_dvr_clients(channel_uuid, recording_id=None): + """Stop DVR recording clients for a channel. + + If recording_id is provided, only the client whose User-Agent contains that + recording ID is stopped (safe for simultaneous recordings on the same channel). + If recording_id is None, all Dispatcharr-DVR clients for the channel are stopped + (used by destroy() when deleting a recording whose task_id is unknown). + + Returns the number of DVR clients stopped. + """ + from core.utils import RedisClient + from apps.proxy.ts_proxy.redis_keys import RedisKeys + from apps.proxy.ts_proxy.services.channel_service import ChannelService + + r = RedisClient.get_client() + if not r: + return 0 + client_set_key = RedisKeys.clients(channel_uuid) + client_ids = r.smembers(client_set_key) or [] + stopped = 0 + for raw_id in client_ids: + try: + cid = raw_id.decode("utf-8") if isinstance(raw_id, (bytes, bytearray)) else str(raw_id) + meta_key = RedisKeys.client_metadata(channel_uuid, cid) + ua = r.hget(meta_key, "user_agent") + ua_s = ua.decode("utf-8") if isinstance(ua, (bytes, bytearray)) else (ua or "") + if not (ua_s and "Dispatcharr-DVR" in ua_s): + continue + # When a recording_id is specified, only stop the client for that recording. + # Each run_recording task connects with User-Agent "Dispatcharr-DVR/recording-{id}", + # so we can safely target just this recording without affecting others on the channel. + if recording_id is not None and f"recording-{recording_id}" not in ua_s: + continue + try: + ChannelService.stop_client(channel_uuid, cid) + stopped += 1 + except Exception as inner_e: + logger.debug(f"Failed to stop DVR client {cid} for channel {channel_uuid}: {inner_e}") + except Exception as inner: + logger.debug(f"Error while checking client metadata: {inner}") + # Do not call ChannelService.stop_channel() here. + # Stopping the channel proxy would terminate the source connection which may + # be shared with other recordings on the same channel. The TS proxy server + # already detects when client count reaches zero and tears down the channel + # cleanly on its own (with the configured shutdown delay). + return stopped + + +class RecordingViewSet(viewsets.ModelViewSet): + queryset = Recording.objects.all() + serializer_class = RecordingSerializer + + def get_permissions(self): + # Allow unauthenticated playback of recording files (like other streaming endpoints) + if self.action == 'file': + return [AllowAny()] + try: + return [perm() for perm in permission_classes_by_action[self.action]] + except KeyError: + return [Authenticated()] + + @action(detail=True, methods=["post"], url_path="comskip") + def comskip(self, request, pk=None): + """Trigger comskip processing for this recording.""" + from .tasks import comskip_process_recording + rec = get_object_or_404(Recording, pk=pk) + try: + comskip_process_recording.delay(rec.id) + return Response({"success": True, "queued": True}) + except Exception as e: + return Response({"success": False, "error": str(e)}, status=400) + + @action(detail=True, methods=["get"], url_path="file") + def file(self, request, pk=None): + """Stream a recorded file with HTTP Range support for seeking.""" + recording = get_object_or_404(Recording, pk=pk) + cp = recording.custom_properties or {} + file_path = cp.get("file_path") + file_name = cp.get("file_name") or "recording" + + if not file_path or not os.path.exists(file_path): + raise Http404("Recording file not found") + + # Guess content type + ext = os.path.splitext(file_path)[1].lower() + if ext == ".mp4": + content_type = "video/mp4" + elif ext == ".mkv": + content_type = "video/x-matroska" + else: + content_type = mimetypes.guess_type(file_path)[0] or "application/octet-stream" + + file_size = os.path.getsize(file_path) + range_header = request.META.get("HTTP_RANGE", "").strip() + + def file_iterator(path, start=0, end=None, chunk_size=8192): + with open(path, "rb") as f: + f.seek(start) + remaining = (end - start + 1) if end is not None else None + while True: + if remaining is not None and remaining <= 0: + break + bytes_to_read = min(chunk_size, remaining) if remaining is not None else chunk_size + data = f.read(bytes_to_read) + if not data: + break + if remaining is not None: + remaining -= len(data) + yield data + + if range_header and range_header.startswith("bytes="): + # Parse Range header + try: + range_spec = range_header.split("=", 1)[1] + start_str, end_str = range_spec.split("-", 1) + start = int(start_str) if start_str else 0 + end = int(end_str) if end_str else file_size - 1 + start = max(0, start) + end = min(file_size - 1, end) + length = end - start + 1 + + resp = StreamingHttpResponse( + file_iterator(file_path, start, end), + status=206, + content_type=content_type, + ) + resp["Content-Range"] = f"bytes {start}-{end}/{file_size}" + resp["Content-Length"] = str(length) + resp["Accept-Ranges"] = "bytes" + resp["Content-Disposition"] = f"inline; filename=\"{file_name}\"" + return resp + except Exception: + # Fall back to full file if parsing fails + pass + + # Full file response + response = FileResponse(open(file_path, "rb"), content_type=content_type) + response["Content-Length"] = str(file_size) + response["Accept-Ranges"] = "bytes" + response["Content-Disposition"] = f"inline; filename=\"{file_name}\"" + return response + + @action(detail=True, methods=["post"], url_path="stop") + def stop(self, request, pk=None): + """Stop a recording early while retaining the partial content for playback.""" + instance = self.get_object() + + cp = instance.custom_properties or {} + current_status = cp.get("status", "") + + # Reject stop on recordings that are already in a terminal state. + # Without this guard, stop() would overwrite "completed" or + # "interrupted" with "stopped", losing the original outcome. + terminal = {"completed", "interrupted", "failed"} + if current_status in terminal: + return Response( + {"success": False, "error": f"Recording is already {current_status}"}, + status=status.HTTP_409_CONFLICT, + ) + + # Mark as stopped in the DB first so run_recording detects it. + # This is the only operation that MUST be synchronous — run_recording reads + # the status field to decide whether the stream disconnection was deliberate. + cp["status"] = "stopped" + cp["stopped_at"] = str(timezone.now()) + instance.custom_properties = cp + instance.save(update_fields=["custom_properties"]) + + # Send the WebSocket notification before returning the response. + # send_websocket_update is gevent-safe (offloads async_to_sync to a + # real OS thread when monkey-patching is active). + channel_uuid = str(instance.channel.uuid) + recording_id = instance.id + task_id = instance.task_id + channel_name = instance.channel.name + + try: + from core.utils import send_websocket_update + send_websocket_update('updates', 'update', { + "success": True, + "type": "recording_stopped", + "channel": channel_name, + }) + except Exception: + pass + + # DVR client teardown and task revocation are deferred to a daemon thread + # because they have occasional slow paths (Redis timeouts, Celery control + # broadcasts) that would otherwise add 5-15 s to the HTTP response time. + def _background_stop(): + try: + stopped = _stop_dvr_clients(channel_uuid, recording_id=recording_id) + if stopped: + logger.info( + f"Stopped {stopped} DVR client(s) for channel {channel_uuid} (recording stopped early)" + ) + except Exception as e: + logger.debug(f"Unable to stop DVR clients for stopped recording: {e}") + + try: + from apps.channels.signals import revoke_task + revoke_task(task_id) + except Exception as e: + logger.debug(f"Unable to revoke task for stopped recording: {e}") + + try: + from django.db import connection as _conn + _conn.close() + except Exception: + pass + + threading.Thread(target=_background_stop, daemon=True).start() + + return Response({"success": True, "status": "stopped"}) + + @action(detail=True, methods=["post"], url_path="extend") + def extend(self, request, pk=None): + """Extend an in-progress recording's end_time without interrupting the stream. + + The running task re-reads end_time every ~2 s and adjusts its deadline + dynamically. The pre_save signal skips task revocation while the + recording status is 'recording'. + """ + instance = self.get_object() + cp = instance.custom_properties or {} + + if cp.get("status") in ("completed", "stopped", "interrupted"): + return Response( + {"success": False, "error": "Recording has already finished"}, + status=status.HTTP_400_BAD_REQUEST, + ) + + try: + extra_minutes = int(request.data.get("extra_minutes", 0)) + except (TypeError, ValueError): + extra_minutes = 0 + + if extra_minutes <= 0: + return Response( + {"success": False, "error": "extra_minutes must be a positive integer"}, + status=status.HTTP_400_BAD_REQUEST, + ) + + new_end_time = instance.end_time + timedelta(minutes=extra_minutes) + # Use queryset .update() to bypass pre_save/post_save signals. + # This avoids the pre_save signal revoking the scheduled/running + # Celery task. The running task's 2-second polling loop re-reads + # end_time from the DB and extends its deadline dynamically. + # If the task hasn't started yet (still in Beat's queue), it will + # read the updated end_time from the DB on its first poll cycle. + Recording.objects.filter(pk=instance.pk).update(end_time=new_end_time) + + try: + from core.utils import send_websocket_update + send_websocket_update('updates', 'update', { + "success": True, + "type": "recording_extended", + "recording_id": instance.id, + "new_end_time": new_end_time.isoformat(), + "extra_minutes": extra_minutes, + "channel": instance.channel.name, + }) + except Exception: + pass + + return Response({"success": True, "new_end_time": new_end_time.isoformat()}) + + @action(detail=True, methods=["post"], url_path="refresh-artwork") + def refresh_artwork(self, request, pk=None): + """Re-run the poster resolution pipeline for this recording. + + Useful when a recording fell back to a channel logo or default logo + because external sources were temporarily unavailable. + """ + instance = self.get_object() + + def _background_refresh(rec_id): + try: + from .tasks import _resolve_poster_for_program + from .models import Recording + from core.utils import send_websocket_update + from django.db import close_old_connections + + rec = Recording.objects.select_related("channel").get(id=rec_id) + cp = rec.custom_properties or {} + program = cp.get("program") or {} + + poster_logo_id, poster_url = _resolve_poster_for_program( + rec.channel.name, program, channel_logo_id=rec.channel.logo_id, + ) + + # Refresh and merge to avoid overwriting concurrent changes. + # Only upgrade — never replace a real poster with a channel logo fallback. + rec.refresh_from_db() + fresh_cp = rec.custom_properties or {} + updated = False + is_channel_logo_fallback = ( + poster_logo_id == rec.channel.logo_id + and not poster_url + ) + if program and program.get("id"): + fresh_cp["program"] = program + updated = True + if not is_channel_logo_fallback: + if poster_logo_id and fresh_cp.get("poster_logo_id") != poster_logo_id: + fresh_cp["poster_logo_id"] = poster_logo_id + updated = True + if poster_url and fresh_cp.get("poster_url") != poster_url: + fresh_cp["poster_url"] = poster_url + updated = True + + if updated: + rec.custom_properties = fresh_cp + rec.save(update_fields=["custom_properties"]) + + send_websocket_update('updates', 'update', { + "success": True, + "type": "recording_updated", + "recording_id": rec_id, + }) + except Exception as e: + logger.debug(f"refresh-artwork background failed for {rec_id}: {e}") + finally: + close_old_connections() + + t = threading.Thread(target=_background_refresh, args=(instance.id,), daemon=True) + t.start() + + return Response({"success": True, "message": "Artwork refresh started"}) + + @action(detail=True, methods=["post"], url_path="update-metadata") + def update_metadata(self, request, pk=None): + """Update user-editable recording metadata (title, description). + + Sets user_edited flag to prevent EPG auto-enrichment from overwriting + the user's changes on subsequent task runs. + """ + instance = self.get_object() + title = request.data.get("title") + description = request.data.get("description") + + if title is None and description is None: + return Response( + {"success": False, "error": "No fields to update"}, + status=status.HTTP_400_BAD_REQUEST, + ) + + # Strip whitespace; treat blank strings as "no change" + clean_title = str(title).strip() if title is not None else None + clean_desc = str(description).strip() if description is not None else None + + if not clean_title and not clean_desc: + return Response( + {"success": False, "error": "Title and description cannot be blank"}, + status=status.HTTP_400_BAD_REQUEST, + ) + + cp = instance.custom_properties or {} + program = cp.get("program") or {} + + if clean_title: + program["title"] = clean_title + if clean_desc: + program["description"] = clean_desc + program["user_edited"] = True + + cp["program"] = program + instance.custom_properties = cp + instance.save(update_fields=["custom_properties"]) + + try: + from core.utils import send_websocket_update + send_websocket_update('updates', 'update', { + "success": True, + "type": "recording_updated", + "recording_id": instance.id, + }) + except Exception: + pass + + return Response({"success": True}) + + def destroy(self, request, *args, **kwargs): + """Delete the Recording and ensure any active DVR client connection is closed. + + Also removes the associated file(s) from disk if present. + + Operation order matters for correctness: + 1. Delete the DB record first — run_recording's cancellation guard + (Recording.objects.filter(id=...).exists()) will now return False, + preventing it from saving 'interrupted' status or sending + recording_ended after the stream is torn down. + 2. Send recording_cancelled WebSocket immediately so the frontend + removes the card without waiting for the slow DVR client teardown. + 3. Spawn a background thread to stop the DVR client and delete files. + This mirrors the stop() endpoint's approach and avoids the 5-15 s + delay that _stop_dvr_clients() can introduce. + """ + instance = self.get_object() + recording_id = instance.pk + channel_name = instance.channel.name + + # Attempt to close the DVR client connection for this channel if active + try: + channel_uuid = str(instance.channel.uuid) + # Lazy imports to avoid module overhead if proxy isn't used + from core.utils import RedisClient + from apps.proxy.ts_proxy.redis_keys import RedisKeys + from apps.proxy.ts_proxy.services.channel_service import ChannelService + + r = RedisClient.get_client() + if r: + client_set_key = RedisKeys.clients(channel_uuid) + client_ids = r.smembers(client_set_key) or [] + stopped = 0 + for cid in client_ids: + try: + meta_key = RedisKeys.client_metadata(channel_uuid, cid) + ua = r.hget(meta_key, "user_agent") + # Identify DVR recording client by its user agent + if ua and "Dispatcharr-DVR" in ua: + try: + ChannelService.stop_client(channel_uuid, cid) + stopped += 1 + except Exception as inner_e: + logger.debug(f"Failed to stop DVR client {cid} for channel {channel_uuid}: {inner_e}") + except Exception as inner: + logger.debug(f"Error while checking client metadata: {inner}") + if stopped: + logger.info(f"Stopped {stopped} DVR client(s) for channel {channel_uuid} due to recording cancellation") + # If no clients remain after stopping DVR clients, proactively stop the channel + try: + remaining = r.scard(client_set_key) or 0 + except Exception: + remaining = 0 + if remaining == 0: + try: + ChannelService.stop_channel(channel_uuid) + logger.info(f"Stopped channel {channel_uuid} (no clients remain)") + except Exception as sc_e: + logger.debug(f"Unable to stop channel {channel_uuid}: {sc_e}") + except Exception as e: + logger.debug(f"Unable to stop DVR clients for cancelled recording: {e}") + + # Capture paths before deletion + cp = instance.custom_properties or {} + rec_status = cp.get("status", "") + file_path = cp.get("file_path") + temp_ts_path = cp.get("_temp_file_path") + channel_uuid = str(instance.channel.uuid) + + # 1. Delete the DB record (also fires post_delete → revoke_task_on_delete) + response = super().destroy(request, *args, **kwargs) + + # 2. Notify frontends immediately + try: + from core.utils import send_websocket_update + send_websocket_update('updates', 'update', { + "success": True, + "type": "recording_cancelled", + "recording_id": recording_id, + "channel": channel_name, + "was_in_progress": rec_status == "recording", + }) + except Exception: + pass + + # 3. Defer slow teardown to a background thread + library_dir = '/data' + allowed_roots = ['/data/', library_dir.rstrip('/') + '/'] + + def _safe_remove(path: str): + if not path or not isinstance(path, str): + return + try: + if any(path.startswith(root) for root in allowed_roots) and os.path.exists(path): + os.remove(path) + logger.info(f"Deleted recording artifact: {path}") + except Exception as ex: + logger.warning(f"Failed to delete recording artifact {path}: {ex}") + + def _background_cancel(): + # Only stop the DVR client if the recording was actively streaming. + # Stopping for completed/upcoming recordings would kill an unrelated + # in-progress recording on the same channel. + if rec_status == "recording": + try: + stopped = _stop_dvr_clients(channel_uuid, recording_id=recording_id) + if stopped: + logger.info( + f"Stopped {stopped} DVR client(s) for channel {channel_uuid} due to recording cancellation" + ) + except Exception as e: + logger.debug(f"Unable to stop DVR clients for cancelled recording: {e}") + + # Best-effort file cleanup in case run_recording already exited + # before the DB delete. + _safe_remove(file_path) + _safe_remove(temp_ts_path) + + try: + from django.db import connection as _conn + _conn.close() + except Exception: + pass + + threading.Thread(target=_background_cancel, daemon=True).start() + + return response + + +class ComskipConfigAPIView(APIView): + """Upload or inspect the custom comskip.ini used by DVR processing.""" + + parser_classes = [MultiPartParser, FormParser] + + def get_permissions(self): + return [IsAdmin()] + + def get(self, request): + path = CoreSettings.get_dvr_comskip_custom_path() + exists = bool(path and os.path.exists(path)) + return Response({"path": path, "exists": exists}) + + def post(self, request): + uploaded = request.FILES.get("file") or request.FILES.get("comskip_ini") + if not uploaded: + return Response({"error": "No file provided"}, status=status.HTTP_400_BAD_REQUEST) + + name = (uploaded.name or "").lower() + if not name.endswith(".ini"): + return Response({"error": "Only .ini files are allowed"}, status=status.HTTP_400_BAD_REQUEST) + + if uploaded.size and uploaded.size > 1024 * 1024: + return Response({"error": "File too large (limit 1MB)"}, status=status.HTTP_400_BAD_REQUEST) + + dest_dir = os.path.join(settings.MEDIA_ROOT, "comskip") + os.makedirs(dest_dir, exist_ok=True) + dest_path = os.path.join(dest_dir, "comskip.ini") + + try: + with open(dest_path, "wb") as dest: + for chunk in uploaded.chunks(): + dest.write(chunk) + except Exception as e: + logger.error(f"Failed to save uploaded comskip.ini: {e}") + return Response({"error": "Unable to save file"}, status=status.HTTP_500_INTERNAL_SERVER_ERROR) + + # Persist path setting so DVR processing picks it up immediately + CoreSettings.set_dvr_comskip_custom_path(dest_path) + + return Response({"success": True, "path": dest_path, "exists": os.path.exists(dest_path)}) + + +class BulkDeleteUpcomingRecordingsAPIView(APIView): + """Delete all upcoming (future) recordings.""" + 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): + now = timezone.now() + qs = Recording.objects.filter(start_time__gt=now) + removed = qs.count() + qs.delete() + try: + from core.utils import send_websocket_update + send_websocket_update('updates', 'update', {"success": True, "type": "recordings_refreshed", "removed": removed}) + except Exception: + pass + return Response({"success": True, "removed": removed}) + + +class SeriesRulesAPIView(APIView): + """Manage DVR series recording rules (list/add).""" + def get_permissions(self): + try: + return [perm() for perm in permission_classes_by_method[self.request.method]] + except KeyError: + return [Authenticated()] + + @extend_schema( + summary="List all series rules", + description="Retrieve all configured DVR series recording rules.", + ) + def get(self, request): + return Response({"rules": CoreSettings.get_dvr_series_rules()}) + + @extend_schema( + summary="Create or update a series rule", + description="Add a new series recording rule or update an existing one. Rules will be evaluated immediately to find matching episodes.", + request=inline_serializer( + name="SeriesRuleRequest", + fields={ + 'tvg_id': serializers.CharField(help_text='Channel TVG ID'), + 'mode': serializers.ChoiceField(choices=['all', 'new'], default='all', help_text='all: record all episodes, new: record only new episodes'), + 'title': serializers.CharField(help_text='Series title', required=False), + }, + ), + ) + def post(self, request): + data = request.data or {} + tvg_id = str(data.get("tvg_id") or "").strip() + mode = (data.get("mode") or "all").lower() + title = data.get("title") or "" + if mode not in ("all", "new"): + return Response({"error": "mode must be 'all' or 'new'"}, status=status.HTTP_400_BAD_REQUEST) + if not tvg_id: + return Response({"error": "tvg_id is required"}, status=status.HTTP_400_BAD_REQUEST) + rules = CoreSettings.get_dvr_series_rules() + # Upsert by tvg_id + existing = next((r for r in rules if str(r.get("tvg_id")) == tvg_id), None) + if existing: + existing.update({"mode": mode, "title": title}) + else: + rules.append({"tvg_id": tvg_id, "mode": mode, "title": title}) + CoreSettings.set_dvr_series_rules(rules) + # Note: frontend calls the evaluate endpoint explicitly after creating + # the rule, so do NOT fire evaluate_series_rules.delay() here to + # avoid a race that creates duplicate recordings. + return Response({"success": True, "rules": rules}) + + +class DeleteSeriesRuleAPIView(APIView): + def get_permissions(self): + try: + return [perm() for perm in permission_classes_by_method[self.request.method]] + except KeyError: + return [Authenticated()] + + @extend_schema( + summary="Delete a series rule", + description="Remove a series recording rule by TVG ID and clean up future scheduled recordings.", + parameters=[ + OpenApiParameter('tvg_id', str, OpenApiParameter.PATH, required=True, description='Channel TVG ID'), + ], + ) + def delete(self, request, tvg_id): + tvg_id = unquote(str(tvg_id or "")) + + # Find the rule before removing to retain the title for cleanup + rules = CoreSettings.get_dvr_series_rules() + deleted_rule = next((r for r in rules if str(r.get("tvg_id")) == tvg_id), None) + remaining = [r for r in rules if str(r.get("tvg_id")) != tvg_id] + CoreSettings.set_dvr_series_rules(remaining) + + # Delete only FUTURE recordings — preserve previously recorded episodes + removed = 0 + if deleted_rule: + from .models import Recording + qs = Recording.objects.filter( + start_time__gte=timezone.now(), + custom_properties__program__tvg_id=tvg_id, + ) + title = deleted_rule.get("title") + if title: + qs = qs.filter(custom_properties__program__title=title) + removed = qs.count() + qs.delete() + + # Notify frontend to refresh recordings list + try: + from core.utils import send_websocket_update + send_websocket_update('updates', 'update', { + "success": True, "type": "recordings_refreshed", "removed": removed, + }) + except Exception: + pass + + return Response({"success": True, "rules": remaining, "removed": removed}) + + +class EvaluateSeriesRulesAPIView(APIView): + def get_permissions(self): + try: + return [perm() for perm in permission_classes_by_method[self.request.method]] + except KeyError: + return [Authenticated()] + + @extend_schema( + summary="Evaluate series rules", + description="Trigger evaluation of series recording rules to find and schedule matching episodes. Can evaluate all rules or a specific channel.", + request=inline_serializer( + name="EvaluateSeriesRulesRequest", + fields={ + "tvg_id": serializers.CharField(required=False, help_text="Optional: evaluate only rules for this channel TVG ID. If omitted, all rules are evaluated."), + }, + ), + ) + def post(self, request): + tvg_id = request.data.get("tvg_id") + # Run synchronously so UI sees results immediately + result = evaluate_series_rules_impl(str(tvg_id)) if tvg_id else evaluate_series_rules_impl() + return Response({"success": True, **result}) + + +class BulkRemoveSeriesRecordingsAPIView(APIView): + """Bulk remove scheduled recordings for a series rule. + + POST body: + - tvg_id: required (EPG channel id) + - title: optional (series title) + - scope: 'title' (default) or 'channel' + """ + def get_permissions(self): + try: + return [perm() for perm in permission_classes_by_method[self.request.method]] + except KeyError: + return [Authenticated()] + + @extend_schema( + summary="Bulk remove scheduled recordings for a series", + description="Delete future scheduled recordings for a series rule. Useful for stopping a rule without losing the configuration. Matches by channel and optionally by series title.", + request=inline_serializer( + name="BulkRemoveSeriesRecordingsRequest", + fields={ + "tvg_id": serializers.CharField(required=True, help_text="Channel TVG ID (required)"), + "title": serializers.CharField(required=False, help_text="Series title - when scope=title, only recordings matching this title are removed"), + "scope": serializers.ChoiceField(choices=["title", "channel"], default="title", required=False, help_text="title: remove only matching title on channel, channel: remove all future recordings on channel"), + }, + ), + ) + def post(self, request): + from django.utils import timezone + tvg_id = str(request.data.get("tvg_id") or "").strip() + title = request.data.get("title") + scope = (request.data.get("scope") or "title").lower() + if not tvg_id: + return Response({"error": "tvg_id is required"}, status=status.HTTP_400_BAD_REQUEST) + + qs = Recording.objects.filter( + start_time__gte=timezone.now(), + custom_properties__program__tvg_id=tvg_id, + ) + if scope == "title" and title: + qs = qs.filter(custom_properties__program__title=title) + + count = qs.count() + qs.delete() + try: + from core.utils import send_websocket_update + send_websocket_update('updates', 'update', {"success": True, "type": "recordings_refreshed", "removed": count}) + except Exception: + pass + return Response({"success": True, "removed": count}) diff --git a/apps/channels/apps.py b/apps/channels/apps.py new file mode 100644 index 0000000..d6d29a8 --- /dev/null +++ b/apps/channels/apps.py @@ -0,0 +1,11 @@ +from django.apps import AppConfig + +class ChannelsConfig(AppConfig): + default_auto_field = 'django.db.models.BigAutoField' + name = 'apps.channels' + verbose_name = "Channel & Stream Management" + label = 'dispatcharr_channels' + + def ready(self): + # Import signals so they get registered. + import apps.channels.signals diff --git a/apps/channels/forms.py b/apps/channels/forms.py new file mode 100644 index 0000000..a566adb --- /dev/null +++ b/apps/channels/forms.py @@ -0,0 +1,53 @@ +from django import forms +from .models import Stream, Channel, ChannelGroup + +# +# ChannelGroup Form +# +class ChannelGroupForm(forms.ModelForm): + class Meta: + model = ChannelGroup + fields = ['name'] + + +# +# Channel Form +# +class ChannelForm(forms.ModelForm): + # Explicitly define channel_number as FloatField to ensure decimal values work + channel_number = forms.FloatField( + required=False, + widget=forms.NumberInput(attrs={'step': '0.1'}), # Allow decimal steps + help_text="Channel number can include decimals (e.g., 1.1, 2.5)" + ) + + channel_group = forms.ModelChoiceField( + queryset=ChannelGroup.objects.all(), + required=False, + label="Channel Group", + empty_label="--- No group ---" + ) + + class Meta: + model = Channel + fields = [ + 'channel_number', + 'name', + 'channel_group', + ] + + +# +# Example: Stream Form (optional if you want a ModelForm for Streams) +# +class StreamForm(forms.ModelForm): + class Meta: + model = Stream + fields = [ + 'name', + 'url', + 'logo_url', + 'epg_data', + 'local_file', + 'channel_group', + ] diff --git a/apps/channels/migrations/0001_initial.py b/apps/channels/migrations/0001_initial.py new file mode 100644 index 0000000..00ddbc7 --- /dev/null +++ b/apps/channels/migrations/0001_initial.py @@ -0,0 +1,77 @@ +# Generated by Django 5.1.6 on 2025-03-05 22:07 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ('core', '0001_initial'), + ('m3u', '0001_initial'), + ] + + operations = [ + migrations.CreateModel( + name='ChannelGroup', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('name', models.CharField(max_length=100, unique=True)), + ], + ), + migrations.CreateModel( + name='Channel', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('channel_number', models.IntegerField()), + ('channel_name', models.CharField(max_length=255)), + ('logo_url', models.URLField(blank=True, max_length=2000, null=True)), + ('logo_file', models.ImageField(blank=True, null=True, upload_to='logos/')), + ('tvg_id', models.CharField(blank=True, max_length=255, null=True)), + ('tvg_name', models.CharField(blank=True, max_length=255, null=True)), + ('stream_profile', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='channels', to='core.streamprofile')), + ('channel_group', models.ForeignKey(blank=True, help_text='Channel group this channel belongs to.', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='channels', to='dispatcharr_channels.channelgroup')), + ], + ), + migrations.CreateModel( + name='Stream', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('name', models.CharField(default='Default Stream', max_length=255)), + ('url', models.URLField()), + ('custom_url', models.URLField(blank=True, max_length=2000, null=True)), + ('logo_url', models.URLField(blank=True, max_length=2000, null=True)), + ('tvg_id', models.CharField(blank=True, max_length=255, null=True)), + ('local_file', models.FileField(blank=True, null=True, upload_to='uploads/')), + ('current_viewers', models.PositiveIntegerField(default=0)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('group_name', models.CharField(blank=True, max_length=255, null=True)), + ('m3u_account', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='streams', to='m3u.m3uaccount')), + ('stream_profile', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='streams', to='core.streamprofile')), + ], + options={ + 'verbose_name': 'Stream', + 'verbose_name_plural': 'Streams', + 'ordering': ['-updated_at'], + }, + ), + migrations.CreateModel( + name='ChannelStream', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('order', models.PositiveIntegerField(default=0)), + ('channel', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='dispatcharr_channels.channel')), + ('stream', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='dispatcharr_channels.stream')), + ], + options={ + 'ordering': ['order'], + }, + ), + migrations.AddField( + model_name='channel', + name='streams', + field=models.ManyToManyField(blank=True, related_name='channels', through='dispatcharr_channels.ChannelStream', to='dispatcharr_channels.stream'), + ), + ] diff --git a/apps/channels/migrations/0002_rename_channel_name_channel_name_and_more.py b/apps/channels/migrations/0002_rename_channel_name_channel_name_and_more.py new file mode 100644 index 0000000..55a134a --- /dev/null +++ b/apps/channels/migrations/0002_rename_channel_name_channel_name_and_more.py @@ -0,0 +1,27 @@ +# Generated by Django 5.1.6 on 2025-03-16 12:21 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('dispatcharr_channels', '0001_initial'), + ] + + operations = [ + migrations.RenameField( + model_name='channel', + old_name='channel_name', + new_name='name', + ), + migrations.RemoveField( + model_name='stream', + name='url', + ), + migrations.RenameField( + model_name='stream', + old_name='custom_url', + new_name='url', + ), + ] diff --git a/apps/channels/migrations/0003_channel_uuid.py b/apps/channels/migrations/0003_channel_uuid.py new file mode 100644 index 0000000..84a7a50 --- /dev/null +++ b/apps/channels/migrations/0003_channel_uuid.py @@ -0,0 +1,38 @@ +# Generated by Django 5.1.6 on 2025-03-16 13:25 + +import uuid +from django.db import migrations, models + + +# Generated by Django 5.1.6 on 2025-03-16 13:25 + +import uuid +from django.db import migrations, models + + +def generate_uuids(apps, schema_editor): + Channel = apps.get_model('dispatcharr_channels', 'Channel') + for channel in Channel.objects.all(): + if not channel.uuid: + channel.uuid = uuid.uuid4() + channel.save() + +class Migration(migrations.Migration): + + dependencies = [ + ('dispatcharr_channels', '0002_rename_channel_name_channel_name_and_more'), + ] + + operations = [ + migrations.AddField( + model_name='channel', + name='uuid', + field=models.UUIDField(default=uuid.uuid4, editable=False), + ), + migrations.RunPython(generate_uuids), + migrations.AlterField( + model_name='channel', + name='uuid', + field=models.UUIDField(default=uuid.uuid4, editable=False, unique=True), + ), + ] diff --git a/apps/channels/migrations/0004_stream_is_custom.py b/apps/channels/migrations/0004_stream_is_custom.py new file mode 100644 index 0000000..fac95a7 --- /dev/null +++ b/apps/channels/migrations/0004_stream_is_custom.py @@ -0,0 +1,18 @@ +# Generated by Django 5.1.6 on 2025-03-17 21:16 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('dispatcharr_channels', '0003_channel_uuid'), + ] + + operations = [ + migrations.AddField( + model_name='stream', + name='is_custom', + field=models.BooleanField(default=False, help_text='Whether this is a user-created stream or from an M3U account'), + ), + ] diff --git a/apps/channels/migrations/0005_stream_channel_group_stream_last_seen_and_more.py b/apps/channels/migrations/0005_stream_channel_group_stream_last_seen_and_more.py new file mode 100644 index 0000000..6cbd262 --- /dev/null +++ b/apps/channels/migrations/0005_stream_channel_group_stream_last_seen_and_more.py @@ -0,0 +1,44 @@ +# Generated by Django 5.1.6 on 2025-03-19 16:33 + +import django.db.models.deletion +import django.utils.timezone +import uuid +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('dispatcharr_channels', '0004_stream_is_custom'), + ('m3u', '0003_create_custom_account'), + ] + + operations = [ + migrations.AddField( + model_name='stream', + name='channel_group', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='streams', to='dispatcharr_channels.channelgroup'), + ), + migrations.AddField( + model_name='stream', + name='last_seen', + field=models.DateTimeField(db_index=True, default=django.utils.timezone.now), + ), + migrations.AlterField( + model_name='channel', + name='uuid', + field=models.UUIDField(db_index=True, default=uuid.uuid4, editable=False, unique=True), + ), + migrations.CreateModel( + name='ChannelGroupM3UAccount', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('enabled', models.BooleanField(default=True)), + ('channel_group', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='m3u_account', to='dispatcharr_channels.channelgroup')), + ('m3u_account', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='channel_group', to='m3u.m3uaccount')), + ], + options={ + 'unique_together': {('channel_group', 'm3u_account')}, + }, + ), + ] diff --git a/apps/channels/migrations/0006_migrate_stream_groups.py b/apps/channels/migrations/0006_migrate_stream_groups.py new file mode 100644 index 0000000..94fc823 --- /dev/null +++ b/apps/channels/migrations/0006_migrate_stream_groups.py @@ -0,0 +1,51 @@ +# In your app's migrations folder, create a new migration file +# e.g., migrations/000X_migrate_channel_group_to_foreign_key.py + +from django.db import migrations + +def migrate_channel_group(apps, schema_editor): + Stream = apps.get_model('dispatcharr_channels', 'Stream') + ChannelGroup = apps.get_model('dispatcharr_channels', 'ChannelGroup') + ChannelGroupM3UAccount = apps.get_model('dispatcharr_channels', 'ChannelGroup') + M3UAccount = apps.get_model('m3u', 'M3UAccount') + + streams_to_update = [] + for stream in Stream.objects.all(): + # If the stream has a 'channel_group' string, try to find or create the ChannelGroup + if stream.group_name: # group_name holds the channel group string + channel_group_name = stream.group_name.strip() + + # Try to find the ChannelGroup by name + channel_group, created = ChannelGroup.objects.get_or_create(name=channel_group_name) + + # Set the foreign key to the found or newly created ChannelGroup + stream.channel_group = channel_group + + streams_to_update.append(stream) + + # If the stream has an M3U account, ensure the M3U account is linked + if stream.m3u_account: + ChannelGroupM3UAccount.objects.get_or_create( + channel_group=channel_group, + m3u_account=stream.m3u_account, + enabled=True # Or set it to whatever the default logic is + ) + + Stream.objects.bulk_update(streams_to_update, ['channel_group']) + +def reverse_migration(apps, schema_editor): + # This reverse migration would undo the changes, setting `channel_group` to `None` and clearing any relationships. + Stream = apps.get_model('yourapp', 'Stream') + for stream in Stream.objects.all(): + stream.channel_group = None + stream.save() + +class Migration(migrations.Migration): + + dependencies = [ + ('dispatcharr_channels', '0005_stream_channel_group_stream_last_seen_and_more'), + ] + + operations = [ + migrations.RunPython(migrate_channel_group, reverse_code=reverse_migration), + ] diff --git a/apps/channels/migrations/0007_remove_stream_group_name.py b/apps/channels/migrations/0007_remove_stream_group_name.py new file mode 100644 index 0000000..3d7b567 --- /dev/null +++ b/apps/channels/migrations/0007_remove_stream_group_name.py @@ -0,0 +1,17 @@ +# Generated by Django 5.1.6 on 2025-03-19 16:43 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('dispatcharr_channels', '0006_migrate_stream_groups'), + ] + + operations = [ + migrations.RemoveField( + model_name='stream', + name='group_name', + ), + ] diff --git a/apps/channels/migrations/0008_stream_stream_hash.py b/apps/channels/migrations/0008_stream_stream_hash.py new file mode 100644 index 0000000..5279dd1 --- /dev/null +++ b/apps/channels/migrations/0008_stream_stream_hash.py @@ -0,0 +1,23 @@ +# Generated by Django 5.1.6 on 2025-03-19 18:21 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('dispatcharr_channels', '0007_remove_stream_group_name'), + ] + + operations = [ + migrations.AddField( + model_name='stream', + name='stream_hash', + field=models.CharField(db_index=True, help_text='Unique hash for this stream from the M3U account', max_length=255, null=True, unique=True), + ), + migrations.AlterField( + model_name='stream', + name='logo_url', + field=models.TextField(blank=True, null=True), + ), + ] diff --git a/apps/channels/migrations/0009_remove_channel_tvg_name_channel_epg_data.py b/apps/channels/migrations/0009_remove_channel_tvg_name_channel_epg_data.py new file mode 100644 index 0000000..814ea1f --- /dev/null +++ b/apps/channels/migrations/0009_remove_channel_tvg_name_channel_epg_data.py @@ -0,0 +1,24 @@ +# Generated by Django 5.1.6 on 2025-03-26 12:59 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('dispatcharr_channels', '0008_stream_stream_hash'), + ('epg', '0004_epgdata_epg_source_alter_epgdata_tvg_id'), + ] + + operations = [ + migrations.RemoveField( + model_name='channel', + name='tvg_name', + ), + migrations.AddField( + model_name='channel', + name='epg_data', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='channels', to='epg.epgdata'), + ), + ] diff --git a/apps/channels/migrations/0010_stream_custom_properties.py b/apps/channels/migrations/0010_stream_custom_properties.py new file mode 100644 index 0000000..0c21f12 --- /dev/null +++ b/apps/channels/migrations/0010_stream_custom_properties.py @@ -0,0 +1,18 @@ +# Generated by Django 5.1.6 on 2025-04-01 17:36 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('dispatcharr_channels', '0009_remove_channel_tvg_name_channel_epg_data'), + ] + + operations = [ + migrations.AddField( + model_name='stream', + name='custom_properties', + field=models.TextField(blank=True, null=True), + ), + ] diff --git a/apps/channels/migrations/0011_logo_remove_channel_logo_file_and_more.py b/apps/channels/migrations/0011_logo_remove_channel_logo_file_and_more.py new file mode 100644 index 0000000..0f0db44 --- /dev/null +++ b/apps/channels/migrations/0011_logo_remove_channel_logo_file_and_more.py @@ -0,0 +1,35 @@ +# Generated by Django 5.1.6 on 2025-04-01 22:14 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('dispatcharr_channels', '0010_stream_custom_properties'), + ] + + operations = [ + migrations.CreateModel( + name='Logo', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('name', models.CharField(max_length=255)), + ('url', models.URLField(unique=True)), + ], + ), + migrations.RemoveField( + model_name='channel', + name='logo_file', + ), + migrations.RemoveField( + model_name='channel', + name='logo_url', + ), + migrations.AddField( + model_name='channel', + name='logo', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='channels', to='dispatcharr_channels.logo'), + ), + ] diff --git a/apps/channels/migrations/0012_channelprofile_channelprofilemembership.py b/apps/channels/migrations/0012_channelprofile_channelprofilemembership.py new file mode 100644 index 0000000..53fbdef --- /dev/null +++ b/apps/channels/migrations/0012_channelprofile_channelprofilemembership.py @@ -0,0 +1,33 @@ +# Generated by Django 5.1.6 on 2025-04-02 23:27 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('dispatcharr_channels', '0011_logo_remove_channel_logo_file_and_more'), + ] + + operations = [ + migrations.CreateModel( + name='ChannelProfile', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('name', models.CharField(max_length=100, unique=True)), + ], + ), + migrations.CreateModel( + name='ChannelProfileMembership', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('enabled', models.BooleanField(default=True)), + ('channel', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='dispatcharr_channels.channel')), + ('channel_profile', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='dispatcharr_channels.channelprofile')), + ], + options={ + 'unique_together': {('channel_profile', 'channel')}, + }, + ), + ] diff --git a/apps/channels/migrations/0013_alter_logo_url.py b/apps/channels/migrations/0013_alter_logo_url.py new file mode 100644 index 0000000..002ec35 --- /dev/null +++ b/apps/channels/migrations/0013_alter_logo_url.py @@ -0,0 +1,18 @@ +# Generated by Django 5.1.6 on 2025-04-04 15:04 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('dispatcharr_channels', '0012_channelprofile_channelprofilemembership'), + ] + + operations = [ + migrations.AlterField( + model_name='logo', + name='url', + field=models.TextField(unique=True), + ), + ] diff --git a/apps/channels/migrations/0014_recording.py b/apps/channels/migrations/0014_recording.py new file mode 100644 index 0000000..44fa681 --- /dev/null +++ b/apps/channels/migrations/0014_recording.py @@ -0,0 +1,24 @@ +# Generated by Django 5.1.6 on 2025-04-05 22:25 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('dispatcharr_channels', '0013_alter_logo_url'), + ] + + operations = [ + migrations.CreateModel( + name='Recording', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('start_time', models.DateTimeField()), + ('end_time', models.DateTimeField()), + ('task_id', models.CharField(blank=True, max_length=255, null=True)), + ('channel', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='recordings', to='dispatcharr_channels.channel')), + ], + ), + ] diff --git a/apps/channels/migrations/0015_recording_custom_properties.py b/apps/channels/migrations/0015_recording_custom_properties.py new file mode 100644 index 0000000..3c01fc6 --- /dev/null +++ b/apps/channels/migrations/0015_recording_custom_properties.py @@ -0,0 +1,18 @@ +# Generated by Django 5.1.6 on 2025-04-07 16:47 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('dispatcharr_channels', '0014_recording'), + ] + + operations = [ + migrations.AddField( + model_name='recording', + name='custom_properties', + field=models.TextField(blank=True, null=True), + ), + ] diff --git a/apps/channels/migrations/0016_channelstream_unique_channel_stream.py b/apps/channels/migrations/0016_channelstream_unique_channel_stream.py new file mode 100644 index 0000000..5301530 --- /dev/null +++ b/apps/channels/migrations/0016_channelstream_unique_channel_stream.py @@ -0,0 +1,38 @@ +# Generated by Django 5.1.6 on 2025-04-18 16:21 + +from django.db import migrations, models +from django.db.models import Count + +def remove_duplicate_channel_streams(apps, schema_editor): + ChannelStream = apps.get_model('dispatcharr_channels', 'ChannelStream') + # Find duplicates by (channel, stream) + duplicates = ( + ChannelStream.objects + .values('channel', 'stream') + .annotate(count=Count('id')) + .filter(count__gt=1) + ) + + for dupe in duplicates: + # Get all duplicates for this pair + dups = ChannelStream.objects.filter( + channel=dupe['channel'], + stream=dupe['stream'] + ).order_by('id') + + # Keep the first one, delete the rest + dups.exclude(id=dups.first().id).delete() + +class Migration(migrations.Migration): + + dependencies = [ + ('dispatcharr_channels', '0015_recording_custom_properties'), + ] + + operations = [ + migrations.RunPython(remove_duplicate_channel_streams), + migrations.AddConstraint( + model_name='channelstream', + constraint=models.UniqueConstraint(fields=('channel', 'stream'), name='unique_channel_stream'), + ), + ] diff --git a/apps/channels/migrations/0017_alter_channelgroup_name.py b/apps/channels/migrations/0017_alter_channelgroup_name.py new file mode 100644 index 0000000..03043d6 --- /dev/null +++ b/apps/channels/migrations/0017_alter_channelgroup_name.py @@ -0,0 +1,18 @@ +# Generated by Django 5.1.6 on 2025-04-21 20:47 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('dispatcharr_channels', '0016_channelstream_unique_channel_stream'), + ] + + operations = [ + migrations.AlterField( + model_name='channelgroup', + name='name', + field=models.TextField(db_index=True, unique=True), + ), + ] diff --git a/apps/channels/migrations/0018_channelgroupm3uaccount_custom_properties_and_more.py b/apps/channels/migrations/0018_channelgroupm3uaccount_custom_properties_and_more.py new file mode 100644 index 0000000..5150784 --- /dev/null +++ b/apps/channels/migrations/0018_channelgroupm3uaccount_custom_properties_and_more.py @@ -0,0 +1,18 @@ +# Generated by Django 5.1.6 on 2025-04-27 14:12 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('dispatcharr_channels', '0017_alter_channelgroup_name'), + ] + + operations = [ + migrations.AddField( + model_name='channelgroupm3uaccount', + name='custom_properties', + field=models.TextField(blank=True, null=True), + ), + ] diff --git a/apps/channels/migrations/0019_channel_tvc_guide_stationid.py b/apps/channels/migrations/0019_channel_tvc_guide_stationid.py new file mode 100644 index 0000000..86f76b8 --- /dev/null +++ b/apps/channels/migrations/0019_channel_tvc_guide_stationid.py @@ -0,0 +1,18 @@ +# Generated by Django 5.1.6 on 2025-05-04 00:02 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('dispatcharr_channels', '0018_channelgroupm3uaccount_custom_properties_and_more'), + ] + + operations = [ + migrations.AddField( + model_name='channel', + name='tvc_guide_stationid', + field=models.CharField(blank=True, max_length=255, null=True), + ), + ] diff --git a/apps/channels/migrations/0020_alter_channel_channel_number.py b/apps/channels/migrations/0020_alter_channel_channel_number.py new file mode 100644 index 0000000..0a1b6ea --- /dev/null +++ b/apps/channels/migrations/0020_alter_channel_channel_number.py @@ -0,0 +1,18 @@ +# Generated by Django 5.1.6 on 2025-05-15 19:37 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('dispatcharr_channels', '0019_channel_tvc_guide_stationid'), + ] + + operations = [ + migrations.AlterField( + model_name='channel', + name='channel_number', + field=models.FloatField(db_index=True), + ), + ] diff --git a/apps/channels/migrations/0021_channel_user_level.py b/apps/channels/migrations/0021_channel_user_level.py new file mode 100644 index 0000000..2aa55ee --- /dev/null +++ b/apps/channels/migrations/0021_channel_user_level.py @@ -0,0 +1,18 @@ +# Generated by Django 5.1.6 on 2025-05-18 14:31 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('dispatcharr_channels', '0020_alter_channel_channel_number'), + ] + + operations = [ + migrations.AddField( + model_name='channel', + name='user_level', + field=models.IntegerField(default=0), + ), + ] diff --git a/apps/channels/migrations/0022_channel_auto_created_channel_auto_created_by_and_more.py b/apps/channels/migrations/0022_channel_auto_created_channel_auto_created_by_and_more.py new file mode 100644 index 0000000..b1450c0 --- /dev/null +++ b/apps/channels/migrations/0022_channel_auto_created_channel_auto_created_by_and_more.py @@ -0,0 +1,35 @@ +# Generated by Django 5.1.6 on 2025-07-13 23:08 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('dispatcharr_channels', '0021_channel_user_level'), + ('m3u', '0012_alter_m3uaccount_refresh_interval'), + ] + + operations = [ + migrations.AddField( + model_name='channel', + name='auto_created', + field=models.BooleanField(default=False, help_text='Whether this channel was automatically created via M3U auto channel sync'), + ), + migrations.AddField( + model_name='channel', + name='auto_created_by', + field=models.ForeignKey(blank=True, help_text='The M3U account that auto-created this channel', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='auto_created_channels', to='m3u.m3uaccount'), + ), + migrations.AddField( + model_name='channelgroupm3uaccount', + name='auto_channel_sync', + field=models.BooleanField(default=False, help_text='Automatically create/delete channels to match streams in this group'), + ), + migrations.AddField( + model_name='channelgroupm3uaccount', + name='auto_sync_channel_start', + field=models.FloatField(blank=True, help_text='Starting channel number for auto-created channels in this group', null=True), + ), + ] diff --git a/apps/channels/migrations/0023_stream_stream_stats_stream_stream_stats_updated_at.py b/apps/channels/migrations/0023_stream_stream_stats_stream_stream_stats_updated_at.py new file mode 100644 index 0000000..1b0fdbe --- /dev/null +++ b/apps/channels/migrations/0023_stream_stream_stats_stream_stream_stats_updated_at.py @@ -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), + ), + ] diff --git a/apps/channels/migrations/0024_alter_channelgroupm3uaccount_channel_group.py b/apps/channels/migrations/0024_alter_channelgroupm3uaccount_channel_group.py new file mode 100644 index 0000000..7ee5544 --- /dev/null +++ b/apps/channels/migrations/0024_alter_channelgroupm3uaccount_channel_group.py @@ -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'), + ), + ] diff --git a/apps/channels/migrations/0025_alter_channelgroupm3uaccount_custom_properties_and_more.py b/apps/channels/migrations/0025_alter_channelgroupm3uaccount_custom_properties_and_more.py new file mode 100644 index 0000000..980682c --- /dev/null +++ b/apps/channels/migrations/0025_alter_channelgroupm3uaccount_custom_properties_and_more.py @@ -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), + ), + ] diff --git a/apps/channels/migrations/0026_recurringrecordingrule.py b/apps/channels/migrations/0026_recurringrecordingrule.py new file mode 100644 index 0000000..1b8cfdb --- /dev/null +++ b/apps/channels/migrations/0026_recurringrecordingrule.py @@ -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'], + }, + ), + ] diff --git a/apps/channels/migrations/0027_recurringrecordingrule_end_date_and_more.py b/apps/channels/migrations/0027_recurringrecordingrule_end_date_and_more.py new file mode 100644 index 0000000..8cdb986 --- /dev/null +++ b/apps/channels/migrations/0027_recurringrecordingrule_end_date_and_more.py @@ -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), + ), + ] diff --git a/apps/channels/migrations/0028_channel_created_at_channel_updated_at.py b/apps/channels/migrations/0028_channel_created_at_channel_updated_at.py new file mode 100644 index 0000000..08c426b --- /dev/null +++ b/apps/channels/migrations/0028_channel_created_at_channel_updated_at.py @@ -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'), + ), + ] diff --git a/apps/channels/migrations/0029_backfill_custom_stream_hashes.py b/apps/channels/migrations/0029_backfill_custom_stream_hashes.py new file mode 100644 index 0000000..3e270be --- /dev/null +++ b/apps/channels/migrations/0029_backfill_custom_stream_hashes.py @@ -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), + ] diff --git a/apps/channels/migrations/0030_alter_stream_url.py b/apps/channels/migrations/0030_alter_stream_url.py new file mode 100644 index 0000000..203e411 --- /dev/null +++ b/apps/channels/migrations/0030_alter_stream_url.py @@ -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), + ), + ] diff --git a/apps/channels/migrations/0031_channelgroupm3uaccount_is_stale_and_more.py b/apps/channels/migrations/0031_channelgroupm3uaccount_is_stale_and_more.py new file mode 100644 index 0000000..f8246a0 --- /dev/null +++ b/apps/channels/migrations/0031_channelgroupm3uaccount_is_stale_and_more.py @@ -0,0 +1,29 @@ +# Generated by Django 5.2.9 on 2026-01-09 18:19 + +import django.utils.timezone +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=django.utils.timezone.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)'), + ), + ] diff --git a/apps/channels/migrations/0032_channel_is_adult_stream_is_adult.py b/apps/channels/migrations/0032_channel_is_adult_stream_is_adult.py new file mode 100644 index 0000000..cbf6ba4 --- /dev/null +++ b/apps/channels/migrations/0032_channel_is_adult_stream_is_adult.py @@ -0,0 +1,23 @@ +# Generated by Django 5.2.9 on 2026-01-17 16:56 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('dispatcharr_channels', '0031_channelgroupm3uaccount_is_stale_and_more'), + ] + + operations = [ + migrations.AddField( + model_name='channel', + name='is_adult', + field=models.BooleanField(db_index=True, default=False, help_text='Whether this channel contains adult content'), + ), + migrations.AddField( + model_name='stream', + name='is_adult', + field=models.BooleanField(db_index=True, default=False, help_text='Whether this stream contains adult content'), + ), + ] diff --git a/apps/channels/migrations/0033_stream_id_stream_chno.py b/apps/channels/migrations/0033_stream_id_stream_chno.py new file mode 100644 index 0000000..31690a9 --- /dev/null +++ b/apps/channels/migrations/0033_stream_id_stream_chno.py @@ -0,0 +1,205 @@ +# Generated by Django - Add stream_id and channel_number fields with data migration + +from django.db import migrations, models +import hashlib +import json +import logging + +logger = logging.getLogger(__name__) + + +def populate_fields_and_rehash(apps, schema_editor): + """ + Populate stream_id and stream_chno from custom_properties for XC account streams, + populate stream_chno from tvg-chno for standard M3U accounts, + then rehash XC streams using stable hash keys. + """ + Stream = apps.get_model('dispatcharr_channels', 'Stream') + M3UAccount = apps.get_model('m3u', 'M3UAccount') + CoreSettings = apps.get_model('core', 'CoreSettings') + + # Get hash keys from settings + try: + stream_settings = CoreSettings.objects.get(key='stream_settings') + hash_key_str = stream_settings.value.get('m3u_hash_key', '') if stream_settings.value else '' + keys = [k.strip() for k in hash_key_str.split(',') if k.strip()] if hash_key_str else [] + except CoreSettings.DoesNotExist: + keys = [] + + logger.info(f"Using hash keys: {keys}") + + # Get XC account IDs + xc_account_ids = set( + M3UAccount.objects.filter(account_type='XC').values_list('id', flat=True) + ) + + logger.info(f"Found {len(xc_account_ids)} XC accounts") + + # Track hash collisions for XC streams + hash_map = {} # new_hash -> stream_id + duplicates_to_delete = [] + + # Process all streams in batches + batch_size = 1000 + processed = 0 + updated = 0 + + total_count = Stream.objects.count() + logger.info(f"Processing {total_count} total streams") + + streams_to_update = [] + + for stream in Stream.objects.select_related('channel_group', 'm3u_account').iterator(chunk_size=batch_size): + processed += 1 + needs_update = False + + custom_props = stream.custom_properties or {} + is_xc = stream.m3u_account_id in xc_account_ids if stream.m3u_account_id else False + + # Extract stream_id (XC accounts only) + if is_xc and isinstance(custom_props, dict): + provider_stream_id = custom_props.get('stream_id') + if provider_stream_id: + try: + stream.stream_id = int(provider_stream_id) + needs_update = True + except (ValueError, TypeError): + pass + + # Extract stream_chno + channel_num = None + if isinstance(custom_props, dict): + if is_xc: + # XC accounts use 'num' + channel_num = custom_props.get('num') + else: + # Standard M3U accounts use 'tvg-chno' or 'channel-number' (case insensitive check) + for key in ['tvg-chno', 'TVG-CHNO', 'tvg-Chno', 'Tvg-Chno', 'channel-number', 'Channel-Number', 'CHANNEL-NUMBER']: + if key in custom_props: + channel_num = custom_props.get(key) + break + + if channel_num is not None: + try: + stream.stream_chno = float(channel_num) + needs_update = True + except (ValueError, TypeError): + pass + + # Rehash XC streams only when 'url' is in hash keys (otherwise hash wouldn't change) + if is_xc and stream.stream_id and keys and 'url' in keys: + # For XC accounts, use stream_id instead of url when 'url' is in the hash keys + # This ensures credential/URL changes don't break stream identity + effective_url = stream.stream_id + + # Get group name + group_name = stream.channel_group.name if stream.channel_group else None + + # Build hash parts + stream_parts = { + "name": stream.name, + "url": effective_url, + "tvg_id": stream.tvg_id, + "m3u_id": stream.m3u_account_id, + "group": group_name + } + hash_parts = {key: stream_parts[key] for key in keys if key in stream_parts} + + # When using stream_id instead of URL, we MUST include m3u_id to prevent + # collisions across different XC accounts (stream_id is only unique per account) + if 'm3u_id' not in hash_parts: + hash_parts['m3u_id'] = stream.m3u_account_id + + # Generate hash + serialized_obj = json.dumps(hash_parts, sort_keys=True) + new_hash = hashlib.sha256(serialized_obj.encode()).hexdigest() + + # Check for collisions + if new_hash in hash_map: + # Duplicate - mark for deletion (keep the first one) + duplicates_to_delete.append(stream.id) + continue + + hash_map[new_hash] = stream.id + stream.stream_hash = new_hash + needs_update = True + + if needs_update: + streams_to_update.append(stream) + updated += 1 + + # Bulk update in batches + if len(streams_to_update) >= batch_size: + Stream.objects.bulk_update( + streams_to_update, + ['stream_id', 'stream_chno', 'stream_hash'], + batch_size=500 + ) + logger.info(f"Updated batch: {processed}/{total_count} streams processed") + streams_to_update = [] + + # Final batch + if streams_to_update: + Stream.objects.bulk_update( + streams_to_update, + ['stream_id', 'stream_chno', 'stream_hash'], + batch_size=500 + ) + + # Delete duplicates if any + if duplicates_to_delete: + logger.warning(f"Deleting {len(duplicates_to_delete)} duplicate streams due to hash collisions") + Stream.objects.filter(id__in=duplicates_to_delete).delete() + + logger.info(f"Migration complete: {updated} streams updated, {len(duplicates_to_delete)} duplicates removed") + + +def reverse_migration(apps, schema_editor): + """ + Reverse migration - clear fields but don't attempt to reverse hash changes. + """ + Stream = apps.get_model('dispatcharr_channels', 'Stream') + Stream.objects.all().update(stream_id=None, stream_chno=None) + logger.info("Cleared stream_id and stream_chno fields. Note: stream hashes were not reverted.") + + +class Migration(migrations.Migration): + + dependencies = [ + ('dispatcharr_channels', '0032_channel_is_adult_stream_is_adult'), + ('m3u', '0018_add_profile_custom_properties'), + ('core', '0020_change_coresettings_value_to_jsonfield'), + ] + + operations = [ + # Schema changes - add fields WITHOUT indexes first + migrations.AddField( + model_name='stream', + name='stream_id', + field=models.IntegerField( + blank=True, + help_text='Provider stream ID (e.g., XC stream_id) for stable identity across credential changes', + null=True, + ), + ), + migrations.AddField( + model_name='stream', + name='stream_chno', + field=models.FloatField( + blank=True, + help_text='Provider channel number (XC num or M3U tvg-chno) for ordering - supports decimals like 2.1', + null=True, + ), + ), + # Data migration (may delete duplicates, which would conflict with pending index creation) + migrations.RunPython(populate_fields_and_rehash, reverse_migration), + # Add indexes AFTER data migration completes + migrations.AddIndex( + model_name='stream', + index=models.Index(fields=['stream_id'], name='dispatcharr_stream_id_idx'), + ), + migrations.AddIndex( + model_name='stream', + index=models.Index(fields=['stream_chno'], name='dispatcharr_stream_chno_idx'), + ), + ] diff --git a/apps/channels/migrations/0034_remove_stream_dispatcharr_stream_id_idx_and_more.py b/apps/channels/migrations/0034_remove_stream_dispatcharr_stream_id_idx_and_more.py new file mode 100644 index 0000000..77b8876 --- /dev/null +++ b/apps/channels/migrations/0034_remove_stream_dispatcharr_stream_id_idx_and_more.py @@ -0,0 +1,31 @@ +# Generated by Django 5.2.9 on 2026-02-01 03:21 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('dispatcharr_channels', '0033_stream_id_stream_chno'), + ] + + operations = [ + migrations.RemoveIndex( + model_name='stream', + name='dispatcharr_stream_id_idx', + ), + migrations.RemoveIndex( + model_name='stream', + name='dispatcharr_stream_chno_idx', + ), + migrations.AlterField( + model_name='stream', + name='stream_chno', + field=models.FloatField(blank=True, db_index=True, help_text='Provider channel number (XC num or M3U tvg-chno) for ordering - supports decimals like 2.1', null=True), + ), + migrations.AlterField( + model_name='stream', + name='stream_id', + field=models.IntegerField(blank=True, db_index=True, help_text='Provider stream ID (e.g., XC stream_id) for stable identity across credential changes', null=True), + ), + ] diff --git a/apps/channels/migrations/__init__.py b/apps/channels/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/channels/models.py b/apps/channels/models.py new file mode 100644 index 0000000..09050fc --- /dev/null +++ b/apps/channels/models.py @@ -0,0 +1,988 @@ +from django.db import models +from django.core.exceptions import ValidationError +from django.conf import settings +from core.models import StreamProfile, CoreSettings +from core.utils import RedisClient +from apps.proxy.ts_proxy.redis_keys import RedisKeys +from apps.proxy.ts_proxy.constants import ChannelMetadataField +import logging +import uuid +from django.utils import timezone +import hashlib +import json +from apps.epg.models import EPGData +from apps.accounts.models import User + +logger = logging.getLogger(__name__) + +# If you have an M3UAccount model in apps.m3u, you can still import it: +from apps.m3u.models import M3UAccount + + +# Add fallback functions if Redis isn't available +def get_total_viewers(channel_id): + """Get viewer count from Redis or return 0 if Redis isn't available""" + redis_client = RedisClient.get_client() + + try: + return int(redis_client.get(f"channel:{channel_id}:viewers") or 0) + except Exception: + return 0 + + +class ChannelGroup(models.Model): + name = models.TextField(unique=True, db_index=True) + + def related_channels(self): + # local import if needed to avoid cyc. Usually fine in a single file though + return Channel.objects.filter(channel_group=self) + + def __str__(self): + return self.name + + @classmethod + def bulk_create_and_fetch(cls, objects): + # Perform the bulk create operation + cls.objects.bulk_create(objects) + + # Use a unique field to fetch the created objects (assuming 'name' is unique) + created_objects = cls.objects.filter(name__in=[obj.name for obj in objects]) + + return created_objects + + +class Stream(models.Model): + """ + Represents a single stream (e.g. from an M3U source or custom URL). + """ + + name = models.CharField(max_length=255, default="Default Stream") + url = models.URLField(max_length=4096, blank=True, null=True) + m3u_account = models.ForeignKey( + M3UAccount, + on_delete=models.CASCADE, + null=True, + blank=True, + related_name="streams", + ) + logo_url = models.TextField(blank=True, null=True) + tvg_id = models.CharField(max_length=255, blank=True, null=True) + local_file = models.FileField(upload_to="uploads/", blank=True, null=True) + current_viewers = models.PositiveIntegerField(default=0) + updated_at = models.DateTimeField(auto_now=True) + channel_group = models.ForeignKey( + ChannelGroup, + on_delete=models.SET_NULL, + null=True, + blank=True, + related_name="streams", + ) + stream_profile = models.ForeignKey( + StreamProfile, + null=True, + blank=True, + on_delete=models.SET_NULL, + related_name="streams", + ) + is_custom = models.BooleanField( + default=False, + help_text="Whether this is a user-created stream or from an M3U account", + ) + stream_hash = models.CharField( + max_length=255, + null=True, + unique=True, + help_text="Unique hash for this stream from the M3U account", + db_index=True, + ) + last_seen = models.DateTimeField(db_index=True, default=timezone.now) + is_stale = models.BooleanField( + default=False, + db_index=True, + help_text="Whether this stream is stale (not seen in recent refresh, pending deletion)" + ) + is_adult = models.BooleanField( + default=False, + db_index=True, + help_text="Whether this stream contains adult content" + ) + custom_properties = models.JSONField(default=dict, blank=True, null=True) + + stream_id = models.IntegerField( + null=True, + blank=True, + db_index=True, + help_text="Provider stream ID (e.g., XC stream_id) for stable identity across credential changes" + ) + stream_chno = models.FloatField( + null=True, + blank=True, + db_index=True, + help_text="Provider channel number (XC num or M3U tvg-chno) for ordering - supports decimals like 2.1" + ) + + # 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') + verbose_name = "Stream" + verbose_name_plural = "Streams" + ordering = ["-updated_at"] + + def __str__(self): + return self.name or self.url or f"Stream ID {self.id}" + + @classmethod + def generate_hash_key(cls, name, url, tvg_id, keys=None, m3u_id=None, group=None, + account_type=None, stream_id=None): + if keys is None: + keys = CoreSettings.get_m3u_hash_key().split(",") + + # For XC accounts, use stream_id instead of url when 'url' is in the hash keys + # This ensures credential/URL changes don't break stream identity + effective_url = url + use_stream_id = account_type == 'XC' and stream_id and 'url' in keys + if use_stream_id: + effective_url = stream_id + + stream_parts = {"name": name, "url": effective_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} + + # When using stream_id instead of URL, we MUST include m3u_id to prevent + # collisions across different XC accounts (stream_id is only unique per account) + if use_stream_id and 'm3u_id' not in hash_parts: + hash_parts['m3u_id'] = m3u_id + + # Serialize and hash the dictionary + serialized_obj = json.dumps( + hash_parts, sort_keys=True + ) # sort_keys ensures consistent ordering + hash_object = hashlib.sha256(serialized_obj.encode()) + return hash_object.hexdigest() + + @classmethod + def update_or_create_by_hash(cls, hash_value, **fields_to_update): + try: + # Try to find the Stream object with the given hash + stream = cls.objects.get(stream_hash=hash_value) + # If it exists, update the fields + for field, value in fields_to_update.items(): + setattr(stream, field, value) + stream.save() # Save the updated object + return stream, False # False means it was updated, not created + except cls.DoesNotExist: + # If it doesn't exist, create a new object with the given hash + fields_to_update["stream_hash"] = ( + hash_value # Make sure the hash field is set + ) + stream = cls.objects.create(**fields_to_update) + return stream, True # True means it was created + + 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() + ) + + return stream_profile + + def get_stream(self, requester=None): + """ + Finds an available profile for this stream and reserves a connection slot. + + Returns: + Tuple[Optional[int], Optional[int], Optional[str]]: (stream_id, profile_id, error_reason) + """ + redis_client = RedisClient.get_client() + profile_id = redis_client.get(f"stream_profile:{self.id}") + if profile_id: + profile_id = int(profile_id) + return self.id, profile_id, None + + # Retrieve the M3U account associated with the stream. + m3u_account = self.m3u_account + m3u_profiles = m3u_account.profiles.all() + default_profile = next((obj for obj in m3u_profiles if obj.is_default), None) + profiles = [default_profile] + [ + obj for obj in m3u_profiles if not obj.is_default + ] + + for profile in profiles: + logger.info(profile) + # Skip inactive profiles + if profile.is_active == False: + continue + + # Atomic slot reservation: INCR first, check, rollback if over capacity + if profile.max_streams == 0: + reserved = True + else: + profile_connections_key = f"profile_connections:{profile.id}" + new_count = redis_client.incr(profile_connections_key) + if new_count <= profile.max_streams: + reserved = True + else: + redis_client.decr(profile_connections_key) + reserved = False + + if reserved: + redis_client.set(f"channel_stream:{self.id}", self.id) + redis_client.set(f"stream_profile:{self.id}", profile.id) + return self.id, profile.id, None + + return None, None, "All active M3U profiles have reached maximum connection limits" + + def release_stream(self): + """ + Called when a stream is finished to release the lock. + + Returns: + bool: True if stream was successfully released, False if + no profile info could be found for cleanup. + """ + redis_client = RedisClient.get_client() + + stream_id = self.id + # Get the matched profile for cleanup + profile_id = redis_client.get(f"stream_profile:{stream_id}") + if not profile_id: + logger.debug( + f"Stream {stream_id}: no profile found in " + f"stream_profile:{stream_id}" + ) + return False + + redis_client.delete(f"stream_profile:{stream_id}") # Remove profile association + + profile_id = int(profile_id) + logger.debug( + f"Stream {stream_id}: found profile_id={profile_id}" + ) + + profile_connections_key = f"profile_connections:{profile_id}" + + # Only decrement if the profile had a max_connections limit + current_count = int(redis_client.get(profile_connections_key) or 0) + if current_count > 0: + redis_client.decr(profile_connections_key) + + return True + + +class ChannelManager(models.Manager): + def active(self): + return self.all() + + +class Channel(models.Model): + channel_number = models.FloatField(db_index=True) + name = models.CharField(max_length=255) + logo = models.ForeignKey( + "Logo", + on_delete=models.SET_NULL, + null=True, + blank=True, + related_name="channels", + ) + + # M2M to Stream now in the same file + streams = models.ManyToManyField( + Stream, blank=True, through="ChannelStream", related_name="channels" + ) + + channel_group = models.ForeignKey( + "ChannelGroup", + on_delete=models.SET_NULL, + null=True, + blank=True, + related_name="channels", + help_text="Channel group this channel belongs to.", + ) + tvg_id = models.CharField(max_length=255, blank=True, null=True) + tvc_guide_stationid = models.CharField(max_length=255, blank=True, null=True) + + epg_data = models.ForeignKey( + EPGData, + on_delete=models.SET_NULL, + null=True, + blank=True, + related_name="channels", + ) + + stream_profile = models.ForeignKey( + StreamProfile, + on_delete=models.SET_NULL, + null=True, + blank=True, + related_name="channels", + ) + + uuid = models.UUIDField( + default=uuid.uuid4, editable=False, unique=True, db_index=True + ) + + user_level = models.IntegerField(default=0) + + is_adult = models.BooleanField( + default=False, + db_index=True, + help_text="Whether this channel contains adult content" + ) + + auto_created = models.BooleanField( + default=False, + help_text="Whether this channel was automatically created via M3U auto channel sync" + ) + auto_created_by = models.ForeignKey( + "m3u.M3UAccount", + on_delete=models.SET_NULL, + null=True, + blank=True, + related_name="auto_created_channels", + 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( + channel_number=self.channel_number, channel_group=self.channel_group + ).exclude(id=self.id) + if existing.exists(): + raise ValidationError( + f"Channel number {self.channel_number} already exists in group {self.channel_group}." + ) + + def __str__(self): + return f"{self.channel_number} - {self.name}" + + @classmethod + def get_next_available_channel_number(cls, starting_from=1): + used_numbers = set(cls.objects.all().values_list("channel_number", flat=True)) + n = starting_from + while n in used_numbers: + n += 1 + return n + + # @TODO: honor stream's stream profile + def get_stream_profile(self): + stream_profile = self.stream_profile + if not stream_profile: + stream_profile = StreamProfile.objects.get( + id=CoreSettings.get_default_stream_profile_id() + ) + + return stream_profile + + def _account_active_connections(self, redis_client, m3u_account, cache: dict[int, int]) -> int: + """ + Return active connection count for an M3U account across all its active profiles. + Uses Redis `profile_connections:*`, which is shared by live and VOD proxy paths. + """ + if not m3u_account: + return 0 + account_id = int(m3u_account.id) + if account_id in cache: + return cache[account_id] + + total = 0 + try: + for profile in m3u_account.profiles.filter(is_active=True): + total += int(redis_client.get(f"profile_connections:{profile.id}") or 0) + except Exception: + total = 0 + cache[account_id] = total + return total + + def _pick_channel_to_preempt( + self, + profile_id, + requester_level, + redis_client, + exclude_channel_ids=None, + cooldown_seconds=30, + ): + """ + Pick the lowest-impact channel to terminate on the given profile. + Returns: Optional[int] channel_id to preempt + """ + exclude_channel_ids = set(exclude_channel_ids or []) + candidates = [] + + # 1) Try to get active channel IDs for this profile from an index set if available + ch_set_key = f"ts_proxy:profile:{profile_id}:channels" + try: + ch_ids = { (int(x) if not isinstance(x, int) else x) for x in (redis_client.smembers(ch_set_key) or set()) } + except Exception: + ch_ids = set() + + logger.debug("Candidate channels for preemption:") + logger.debug(ch_ids) + + # 2) Fallback: scan metadata keys and filter by m3u_profile == profile_id + if not ch_ids: + cursor = 0 + pattern = "ts_proxy:channel:*:metadata" + while True: + cursor, keys = redis_client.scan(cursor=cursor, match=pattern, count=500) + if keys: + # Prefer HGET m3u_profile if metadata is a hash + pipe = redis_client.pipeline() + for k in keys: + pipe.hget(k, "m3u_profile") + prof_vals = pipe.execute() + for k, prof_val in zip(keys, prof_vals): + try: + pid = int(prof_val) if prof_val is not None else None + except Exception: + pid = None + + if pid == profile_id: + parts = k.split(":") # ts_proxy:channel:{id}:metadata + if len(parts) >= 4: + try: + ch_ids.add(int(parts[2])) + except Exception: + pass + if cursor == 0: + break + + logger.debug("Candidate channels for preemption:") + logger.debug(ch_ids) + + if not ch_ids: + return None + + # 3) Score candidates + for ch_id in ch_ids: + if ch_id in exclude_channel_ids: + continue + + # Skip if recently preempted + last_preempt_key = f"ts_proxy:channel:{ch_id}:last_preempt" + try: + last_preempt = float(redis_client.get(last_preempt_key) or 0.0) + except Exception: + last_preempt = 0.0 + if last_preempt and (time.time() - last_preempt) < cooldown_seconds: + continue + + # Clients and their levels + clients_key = f"ts_proxy:channel:{ch_id}:clients" + member_ids = list(redis_client.smembers(clients_key) or []) + viewer_count = len(member_ids) + max_viewer_level = 0 + if viewer_count: + pipe = redis_client.pipeline() + for cid in member_ids: + pipe.hget(f"ts_proxy:channel:{ch_id}:clients:{cid}", "user_level") + levels_raw = pipe.execute() + levels = [] + for lv in levels_raw: + try: + levels.append(int(lv or 0)) + except Exception: + levels.append(0) + max_viewer_level = max(levels or [0]) + + # Only preempt if requester strictly outranks this channel's viewers + if requester_level <= max_viewer_level: + continue + + # Metadata (protected/recording/started_at_ts) + meta_key = f"ts_proxy:channel:{ch_id}:metadata" + try: + protected, recording, started_at_ts = redis_client.hmget( + meta_key, "protected", "recording", "started_at_ts" + ) + except Exception: + protected = recording = started_at_ts = None + + protected = str(protected or "0") in ("1", "true", "True") + recording = str(recording or "0") in ("1", "true", "True") + if protected or recording: + continue + + try: + started_at_ts = float(started_at_ts) if started_at_ts is not None else None + except Exception: + started_at_ts = None + if started_at_ts is None: + started_at_ts = time.time() # treat unknown as newest + + # Score: lower is safer to terminate + has_viewers = 1 if viewer_count > 0 else 0 + score = (has_viewers, max_viewer_level, viewer_count, started_at_ts) + candidates.append((score, ch_id)) + + logger.debug("Candidate channels after scoring:") + logger.debug(candidates) + + if not candidates: + return None + + candidates.sort(key=lambda x: x[0]) + victim_id = candidates[0][1] + + # Mark preempt timestamp to avoid thrashing + try: + redis_client.set(f"ts_proxy:channel:{victim_id}:last_preempt", str(time.time()), ex=3600) + except Exception: + pass + + return victim_id + + def _check_and_reserve_profile_slot(self, profile, redis_client): + """ + Atomically check and reserve a connection slot for the given profile. + + Uses an INCR-first-then-check pattern to eliminate the TOCTOU race + condition where separate GET + check + INCR operations could allow + concurrent requests to both pass the capacity check. + + For profiles with max_streams=0 (unlimited), no reservation is needed. + + Args: + profile: M3UAccountProfile instance + redis_client: Redis client instance + + Returns: + tuple: (reserved: bool, current_count: int) + """ + if profile.max_streams == 0: + return (True, 0) + + profile_connections_key = f"profile_connections:{profile.id}" + + # Atomically increment first — this is a single Redis command + new_count = redis_client.incr(profile_connections_key) + + if new_count <= profile.max_streams: + return (True, new_count) + + # Over capacity — roll back the increment + redis_client.decr(profile_connections_key) + return (False, new_count - 1) + + def get_stream(self, requester=None): + """ + Finds an available stream for the requested channel and returns the selected stream and profile. + + Returns: + Tuple[Optional[int], Optional[int], Optional[str]]: (stream_id, profile_id, error_reason) + """ + redis_client = RedisClient.get_client() + error_reason = None + + # Check if this channel has any streams + if not self.streams.exists(): + error_reason = "No streams assigned to channel" + return None, None, error_reason + + # Check if a stream is already active for this channel + stream_id_bytes = redis_client.get(f"channel_stream:{self.id}") + if stream_id_bytes: + try: + stream_id = int(stream_id_bytes) + profile_id_bytes = redis_client.get(f"stream_profile:{stream_id}") + if profile_id_bytes: + try: + profile_id = int(profile_id_bytes) + return stream_id, profile_id, None + except (ValueError, TypeError): + logger.debug( + f"Invalid profile ID retrieved from Redis: {profile_id_bytes}" + ) + except (ValueError, TypeError): + logger.debug( + f"Invalid stream ID retrieved from Redis: {stream_id_bytes}" + ) + + # No existing active stream, attempt to assign a new one + has_streams_but_maxed_out = False + has_active_profiles = False + account_load_cache: dict[int, int] = {} + + ordered_streams = list(self.streams.all().order_by("channelstream__order")) + original_order = {stream.id: idx for idx, stream in enumerate(ordered_streams)} + ordered_streams.sort( + key=lambda s: ( + self._account_active_connections(redis_client, s.m3u_account, account_load_cache), + original_order.get(s.id, 0), + ) + ) + + # Iterate through channel streams and their profiles + for stream in ordered_streams: + # Retrieve the M3U account associated with the stream. + m3u_account = stream.m3u_account + 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.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 active default profile") + continue + + profiles = [default_profile] + [ + obj for obj in m3u_profiles if not obj.is_default + ] + + for profile in profiles: + has_active_profiles = True + + # Atomically check and reserve a slot (INCR-first pattern) + reserved, current_count = self._check_and_reserve_profile_slot( + profile, redis_client + ) + + if reserved: + # Slot reserved — assign stream to this channel + redis_client.set(f"channel_stream:{self.id}", stream.id) + redis_client.set(f"stream_profile:{stream.id}", profile.id) + + return ( + stream.id, + profile.id, + None, + ) # Return newly assigned stream and matched profile + else: + # At capacity: try to preempt a lower-impact channel on this profile + victim_channel_id = self._pick_channel_to_preempt( + profile_id=profile.id, + requester_level=requester.user_level if requester else 100, + redis_client=redis_client, + exclude_channel_ids=None, + ) + if victim_channel_id: + logger.info(f"Preempting channel {victim_channel_id} for new stream on profile {profile.id}") + # return self.id, profile.id, victim_channel_id + + # This profile is at max connections + has_streams_but_maxed_out = True + logger.debug( + f"Profile {profile.id} at max connections: " + f"{current_count}/{profile.max_streams}" + ) + + # No available streams - determine specific reason + if has_streams_but_maxed_out: + error_reason = "All active M3U profiles have reached maximum connection limits" + elif has_active_profiles: + error_reason = "No compatible active profile found for any assigned stream" + else: + error_reason = "No active profiles found for any assigned stream" + + return None, None, error_reason + + def release_stream(self): + """ + Called when a stream is finished to release the lock. + + Returns: + bool: True if stream was successfully released, False if + no stream/profile info could be found for cleanup. + """ + redis_client = RedisClient.get_client() + + stream_id = redis_client.get(f"channel_stream:{self.id}") + if not stream_id: + # Primary key missing — try metadata hash fallback. + # The proxy may have already cleaned up channel_stream/stream_profile + # keys, but the metadata hash can still have the stream_id and profile. + metadata_key = RedisKeys.channel_metadata(str(self.uuid)) + meta_stream_id = redis_client.hget( + metadata_key, ChannelMetadataField.STREAM_ID + ) + meta_profile_id = redis_client.hget( + metadata_key, ChannelMetadataField.M3U_PROFILE + ) + + if meta_stream_id and meta_profile_id: + stream_id = int(meta_stream_id) + profile_id = int(meta_profile_id) + logger.debug( + f"Channel {self.uuid}: recovered stream_id={stream_id}, " + f"profile_id={profile_id} from metadata fallback" + ) + # Clean up any remaining keys + redis_client.delete(f"channel_stream:{self.id}") + redis_client.delete(f"stream_profile:{stream_id}") + + # Clear metadata fields so duplicate release_stream() calls + # won't find them and DECR again + redis_client.hdel( + metadata_key, + ChannelMetadataField.STREAM_ID, + ChannelMetadataField.M3U_PROFILE, + ) + + profile_connections_key = f"profile_connections:{profile_id}" + current_count = int( + redis_client.get(profile_connections_key) or 0 + ) + if current_count > 0: + redis_client.decr(profile_connections_key) + return True + + logger.debug( + f"Channel {self.uuid}: no stream info found in primary keys " + f"or metadata fallback" + ) + return False + + redis_client.delete(f"channel_stream:{self.id}") # Remove active stream + + stream_id = int(stream_id) + logger.debug( + f"Channel {self.uuid}: found stream_id={stream_id} for " + f"channel_stream:{self.id}" + ) + + # Get the matched profile for cleanup + profile_id = redis_client.get(f"stream_profile:{stream_id}") + if profile_id: + redis_client.delete(f"stream_profile:{stream_id}") # Remove profile association + profile_id = int(profile_id) + else: + # stream_profile key missing — try metadata hash fallback + metadata_key = RedisKeys.channel_metadata(str(self.uuid)) + meta_profile_id = redis_client.hget( + metadata_key, ChannelMetadataField.M3U_PROFILE + ) + if meta_profile_id: + profile_id = int(meta_profile_id) + logger.debug( + f"Channel {self.uuid}: recovered profile_id={profile_id} " + f"from metadata fallback (stream_profile:{stream_id} was missing)" + ) + else: + logger.warning( + f"Channel {self.uuid}: no profile found for " + f"stream_profile:{stream_id} or in metadata fallback" + ) + return False + logger.debug( + f"Channel {self.uuid}: found profile_id={profile_id} for " + f"stream {stream_id}" + ) + + profile_connections_key = f"profile_connections:{profile_id}" + + # Only decrement if the profile had a max_connections limit + current_count = int(redis_client.get(profile_connections_key) or 0) + if current_count > 0: + redis_client.decr(profile_connections_key) + + # Clear metadata fields so duplicate release_stream() calls + # (e.g. from _clean_redis_keys or ChannelService.stop_channel) + # won't find them via fallback and DECR again + metadata_key = RedisKeys.channel_metadata(str(self.uuid)) + redis_client.hdel( + metadata_key, + ChannelMetadataField.STREAM_ID, + ChannelMetadataField.M3U_PROFILE, + ) + + return True + + def update_stream_profile(self, new_profile_id): + """ + Updates the profile for the current stream and adjusts connection counts. + + Args: + new_profile_id: The ID of the new stream profile to use + + Returns: + bool: True if successful, False otherwise + """ + redis_client = RedisClient.get_client() + + # Get current stream ID + stream_id_bytes = redis_client.get(f"channel_stream:{self.id}") + if not stream_id_bytes: + logger.debug("No active stream found for channel") + return False + + stream_id = int(stream_id_bytes) + + # Get current profile ID + current_profile_id_bytes = redis_client.get(f"stream_profile:{stream_id}") + if not current_profile_id_bytes: + logger.debug("No profile found for current stream") + return False + + current_profile_id = int(current_profile_id_bytes) + + # Don't do anything if the profile is already set to the requested one + if current_profile_id == new_profile_id: + return True + + # Use pipeline for atomic profile switch to prevent counter drift + # if an exception occurs between DECR and INCR + old_profile_connections_key = f"profile_connections:{current_profile_id}" + new_profile_connections_key = f"profile_connections:{new_profile_id}" + old_count = int(redis_client.get(old_profile_connections_key) or 0) + + pipe = redis_client.pipeline() + if old_count > 0: + pipe.decr(old_profile_connections_key) + pipe.set(f"stream_profile:{stream_id}", new_profile_id) + pipe.incr(new_profile_connections_key) + pipe.execute() + logger.info( + f"Updated stream {stream_id} profile from {current_profile_id} to {new_profile_id}" + ) + return True + + +class ChannelProfile(models.Model): + name = models.CharField(max_length=100, unique=True) + + +class ChannelProfileMembership(models.Model): + channel_profile = models.ForeignKey(ChannelProfile, on_delete=models.CASCADE) + channel = models.ForeignKey(Channel, on_delete=models.CASCADE) + enabled = models.BooleanField( + default=True + ) # Track if the channel is enabled for this group + + class Meta: + unique_together = ("channel_profile", "channel") + + +class ChannelStream(models.Model): + channel = models.ForeignKey(Channel, on_delete=models.CASCADE) + stream = models.ForeignKey(Stream, on_delete=models.CASCADE) + order = models.PositiveIntegerField(default=0) # Ordering field + + class Meta: + ordering = ["order"] # Ensure streams are retrieved in order + constraints = [ + models.UniqueConstraint( + fields=["channel", "stream"], name="unique_channel_stream" + ) + ] + + +class ChannelGroupM3UAccount(models.Model): + channel_group = models.ForeignKey( + 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.JSONField(default=dict, blank=True, null=True) + enabled = models.BooleanField(default=True) + auto_channel_sync = models.BooleanField( + default=False, + help_text='Automatically create/delete channels to match streams in this group' + ) + auto_sync_channel_start = models.FloatField( + null=True, + blank=True, + help_text='Starting channel number for auto-created channels in this group' + ) + last_seen = models.DateTimeField( + default=timezone.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") + + def __str__(self): + return f"{self.channel_group.name} - {self.m3u_account.name} (Enabled: {self.enabled})" + + +class Logo(models.Model): + name = models.CharField(max_length=255) + url = models.TextField(unique=True) + + def __str__(self): + return self.name + + +class Recording(models.Model): + channel = models.ForeignKey( + "Channel", on_delete=models.CASCADE, related_name="recordings" + ) + start_time = models.DateTimeField() + end_time = models.DateTimeField() + task_id = models.CharField(max_length=255, 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 [] diff --git a/apps/channels/models.py.bak.1778276755 b/apps/channels/models.py.bak.1778276755 new file mode 100644 index 0000000..51629db --- /dev/null +++ b/apps/channels/models.py.bak.1778276755 @@ -0,0 +1,958 @@ +from django.db import models +from django.core.exceptions import ValidationError +from django.conf import settings +from core.models import StreamProfile, CoreSettings +from core.utils import RedisClient +from apps.proxy.ts_proxy.redis_keys import RedisKeys +from apps.proxy.ts_proxy.constants import ChannelMetadataField +import logging +import uuid +from django.utils import timezone +import hashlib +import json +from apps.epg.models import EPGData +from apps.accounts.models import User + +logger = logging.getLogger(__name__) + +# If you have an M3UAccount model in apps.m3u, you can still import it: +from apps.m3u.models import M3UAccount + + +# Add fallback functions if Redis isn't available +def get_total_viewers(channel_id): + """Get viewer count from Redis or return 0 if Redis isn't available""" + redis_client = RedisClient.get_client() + + try: + return int(redis_client.get(f"channel:{channel_id}:viewers") or 0) + except Exception: + return 0 + + +class ChannelGroup(models.Model): + name = models.TextField(unique=True, db_index=True) + + def related_channels(self): + # local import if needed to avoid cyc. Usually fine in a single file though + return Channel.objects.filter(channel_group=self) + + def __str__(self): + return self.name + + @classmethod + def bulk_create_and_fetch(cls, objects): + # Perform the bulk create operation + cls.objects.bulk_create(objects) + + # Use a unique field to fetch the created objects (assuming 'name' is unique) + created_objects = cls.objects.filter(name__in=[obj.name for obj in objects]) + + return created_objects + + +class Stream(models.Model): + """ + Represents a single stream (e.g. from an M3U source or custom URL). + """ + + name = models.CharField(max_length=255, default="Default Stream") + url = models.URLField(max_length=4096, blank=True, null=True) + m3u_account = models.ForeignKey( + M3UAccount, + on_delete=models.CASCADE, + null=True, + blank=True, + related_name="streams", + ) + logo_url = models.TextField(blank=True, null=True) + tvg_id = models.CharField(max_length=255, blank=True, null=True) + local_file = models.FileField(upload_to="uploads/", blank=True, null=True) + current_viewers = models.PositiveIntegerField(default=0) + updated_at = models.DateTimeField(auto_now=True) + channel_group = models.ForeignKey( + ChannelGroup, + on_delete=models.SET_NULL, + null=True, + blank=True, + related_name="streams", + ) + stream_profile = models.ForeignKey( + StreamProfile, + null=True, + blank=True, + on_delete=models.SET_NULL, + related_name="streams", + ) + is_custom = models.BooleanField( + default=False, + help_text="Whether this is a user-created stream or from an M3U account", + ) + stream_hash = models.CharField( + max_length=255, + null=True, + unique=True, + help_text="Unique hash for this stream from the M3U account", + db_index=True, + ) + last_seen = models.DateTimeField(db_index=True, default=timezone.now) + is_stale = models.BooleanField( + default=False, + db_index=True, + help_text="Whether this stream is stale (not seen in recent refresh, pending deletion)" + ) + is_adult = models.BooleanField( + default=False, + db_index=True, + help_text="Whether this stream contains adult content" + ) + custom_properties = models.JSONField(default=dict, blank=True, null=True) + + stream_id = models.IntegerField( + null=True, + blank=True, + db_index=True, + help_text="Provider stream ID (e.g., XC stream_id) for stable identity across credential changes" + ) + stream_chno = models.FloatField( + null=True, + blank=True, + db_index=True, + help_text="Provider channel number (XC num or M3U tvg-chno) for ordering - supports decimals like 2.1" + ) + + # 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') + verbose_name = "Stream" + verbose_name_plural = "Streams" + ordering = ["-updated_at"] + + def __str__(self): + return self.name or self.url or f"Stream ID {self.id}" + + @classmethod + def generate_hash_key(cls, name, url, tvg_id, keys=None, m3u_id=None, group=None, + account_type=None, stream_id=None): + if keys is None: + keys = CoreSettings.get_m3u_hash_key().split(",") + + # For XC accounts, use stream_id instead of url when 'url' is in the hash keys + # This ensures credential/URL changes don't break stream identity + effective_url = url + use_stream_id = account_type == 'XC' and stream_id and 'url' in keys + if use_stream_id: + effective_url = stream_id + + stream_parts = {"name": name, "url": effective_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} + + # When using stream_id instead of URL, we MUST include m3u_id to prevent + # collisions across different XC accounts (stream_id is only unique per account) + if use_stream_id and 'm3u_id' not in hash_parts: + hash_parts['m3u_id'] = m3u_id + + # Serialize and hash the dictionary + serialized_obj = json.dumps( + hash_parts, sort_keys=True + ) # sort_keys ensures consistent ordering + hash_object = hashlib.sha256(serialized_obj.encode()) + return hash_object.hexdigest() + + @classmethod + def update_or_create_by_hash(cls, hash_value, **fields_to_update): + try: + # Try to find the Stream object with the given hash + stream = cls.objects.get(stream_hash=hash_value) + # If it exists, update the fields + for field, value in fields_to_update.items(): + setattr(stream, field, value) + stream.save() # Save the updated object + return stream, False # False means it was updated, not created + except cls.DoesNotExist: + # If it doesn't exist, create a new object with the given hash + fields_to_update["stream_hash"] = ( + hash_value # Make sure the hash field is set + ) + stream = cls.objects.create(**fields_to_update) + return stream, True # True means it was created + + 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() + ) + + return stream_profile + + def get_stream(self, requester=None): + """ + Finds an available profile for this stream and reserves a connection slot. + + Returns: + Tuple[Optional[int], Optional[int], Optional[str]]: (stream_id, profile_id, error_reason) + """ + redis_client = RedisClient.get_client() + profile_id = redis_client.get(f"stream_profile:{self.id}") + if profile_id: + profile_id = int(profile_id) + return self.id, profile_id, None + + # Retrieve the M3U account associated with the stream. + m3u_account = self.m3u_account + m3u_profiles = m3u_account.profiles.all() + default_profile = next((obj for obj in m3u_profiles if obj.is_default), None) + profiles = [default_profile] + [ + obj for obj in m3u_profiles if not obj.is_default + ] + + for profile in profiles: + logger.info(profile) + # Skip inactive profiles + if profile.is_active == False: + continue + + # Atomic slot reservation: INCR first, check, rollback if over capacity + if profile.max_streams == 0: + reserved = True + else: + profile_connections_key = f"profile_connections:{profile.id}" + new_count = redis_client.incr(profile_connections_key) + if new_count <= profile.max_streams: + reserved = True + else: + redis_client.decr(profile_connections_key) + reserved = False + + if reserved: + redis_client.set(f"channel_stream:{self.id}", self.id) + redis_client.set(f"stream_profile:{self.id}", profile.id) + return self.id, profile.id, None + + return None, None, "All active M3U profiles have reached maximum connection limits" + + def release_stream(self): + """ + Called when a stream is finished to release the lock. + + Returns: + bool: True if stream was successfully released, False if + no profile info could be found for cleanup. + """ + redis_client = RedisClient.get_client() + + stream_id = self.id + # Get the matched profile for cleanup + profile_id = redis_client.get(f"stream_profile:{stream_id}") + if not profile_id: + logger.debug( + f"Stream {stream_id}: no profile found in " + f"stream_profile:{stream_id}" + ) + return False + + redis_client.delete(f"stream_profile:{stream_id}") # Remove profile association + + profile_id = int(profile_id) + logger.debug( + f"Stream {stream_id}: found profile_id={profile_id}" + ) + + profile_connections_key = f"profile_connections:{profile_id}" + + # Only decrement if the profile had a max_connections limit + current_count = int(redis_client.get(profile_connections_key) or 0) + if current_count > 0: + redis_client.decr(profile_connections_key) + + return True + + +class ChannelManager(models.Manager): + def active(self): + return self.all() + + +class Channel(models.Model): + channel_number = models.FloatField(db_index=True) + name = models.CharField(max_length=255) + logo = models.ForeignKey( + "Logo", + on_delete=models.SET_NULL, + null=True, + blank=True, + related_name="channels", + ) + + # M2M to Stream now in the same file + streams = models.ManyToManyField( + Stream, blank=True, through="ChannelStream", related_name="channels" + ) + + channel_group = models.ForeignKey( + "ChannelGroup", + on_delete=models.SET_NULL, + null=True, + blank=True, + related_name="channels", + help_text="Channel group this channel belongs to.", + ) + tvg_id = models.CharField(max_length=255, blank=True, null=True) + tvc_guide_stationid = models.CharField(max_length=255, blank=True, null=True) + + epg_data = models.ForeignKey( + EPGData, + on_delete=models.SET_NULL, + null=True, + blank=True, + related_name="channels", + ) + + stream_profile = models.ForeignKey( + StreamProfile, + on_delete=models.SET_NULL, + null=True, + blank=True, + related_name="channels", + ) + + uuid = models.UUIDField( + default=uuid.uuid4, editable=False, unique=True, db_index=True + ) + + user_level = models.IntegerField(default=0) + + is_adult = models.BooleanField( + default=False, + db_index=True, + help_text="Whether this channel contains adult content" + ) + + auto_created = models.BooleanField( + default=False, + help_text="Whether this channel was automatically created via M3U auto channel sync" + ) + auto_created_by = models.ForeignKey( + "m3u.M3UAccount", + on_delete=models.SET_NULL, + null=True, + blank=True, + related_name="auto_created_channels", + 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( + channel_number=self.channel_number, channel_group=self.channel_group + ).exclude(id=self.id) + if existing.exists(): + raise ValidationError( + f"Channel number {self.channel_number} already exists in group {self.channel_group}." + ) + + def __str__(self): + return f"{self.channel_number} - {self.name}" + + @classmethod + def get_next_available_channel_number(cls, starting_from=1): + used_numbers = set(cls.objects.all().values_list("channel_number", flat=True)) + n = starting_from + while n in used_numbers: + n += 1 + return n + + # @TODO: honor stream's stream profile + def get_stream_profile(self): + stream_profile = self.stream_profile + if not stream_profile: + stream_profile = StreamProfile.objects.get( + id=CoreSettings.get_default_stream_profile_id() + ) + + return stream_profile + + def _pick_channel_to_preempt( + self, + profile_id, + requester_level, + redis_client, + exclude_channel_ids=None, + cooldown_seconds=30, + ): + """ + Pick the lowest-impact channel to terminate on the given profile. + Returns: Optional[int] channel_id to preempt + """ + exclude_channel_ids = set(exclude_channel_ids or []) + candidates = [] + + # 1) Try to get active channel IDs for this profile from an index set if available + ch_set_key = f"ts_proxy:profile:{profile_id}:channels" + try: + ch_ids = { (int(x) if not isinstance(x, int) else x) for x in (redis_client.smembers(ch_set_key) or set()) } + except Exception: + ch_ids = set() + + logger.debug("Candidate channels for preemption:") + logger.debug(ch_ids) + + # 2) Fallback: scan metadata keys and filter by m3u_profile == profile_id + if not ch_ids: + cursor = 0 + pattern = "ts_proxy:channel:*:metadata" + while True: + cursor, keys = redis_client.scan(cursor=cursor, match=pattern, count=500) + if keys: + # Prefer HGET m3u_profile if metadata is a hash + pipe = redis_client.pipeline() + for k in keys: + pipe.hget(k, "m3u_profile") + prof_vals = pipe.execute() + for k, prof_val in zip(keys, prof_vals): + try: + pid = int(prof_val) if prof_val is not None else None + except Exception: + pid = None + + if pid == profile_id: + parts = k.split(":") # ts_proxy:channel:{id}:metadata + if len(parts) >= 4: + try: + ch_ids.add(int(parts[2])) + except Exception: + pass + if cursor == 0: + break + + logger.debug("Candidate channels for preemption:") + logger.debug(ch_ids) + + if not ch_ids: + return None + + # 3) Score candidates + for ch_id in ch_ids: + if ch_id in exclude_channel_ids: + continue + + # Skip if recently preempted + last_preempt_key = f"ts_proxy:channel:{ch_id}:last_preempt" + try: + last_preempt = float(redis_client.get(last_preempt_key) or 0.0) + except Exception: + last_preempt = 0.0 + if last_preempt and (time.time() - last_preempt) < cooldown_seconds: + continue + + # Clients and their levels + clients_key = f"ts_proxy:channel:{ch_id}:clients" + member_ids = list(redis_client.smembers(clients_key) or []) + viewer_count = len(member_ids) + max_viewer_level = 0 + if viewer_count: + pipe = redis_client.pipeline() + for cid in member_ids: + pipe.hget(f"ts_proxy:channel:{ch_id}:clients:{cid}", "user_level") + levels_raw = pipe.execute() + levels = [] + for lv in levels_raw: + try: + levels.append(int(lv or 0)) + except Exception: + levels.append(0) + max_viewer_level = max(levels or [0]) + + # Only preempt if requester strictly outranks this channel's viewers + if requester_level <= max_viewer_level: + continue + + # Metadata (protected/recording/started_at_ts) + meta_key = f"ts_proxy:channel:{ch_id}:metadata" + try: + protected, recording, started_at_ts = redis_client.hmget( + meta_key, "protected", "recording", "started_at_ts" + ) + except Exception: + protected = recording = started_at_ts = None + + protected = str(protected or "0") in ("1", "true", "True") + recording = str(recording or "0") in ("1", "true", "True") + if protected or recording: + continue + + try: + started_at_ts = float(started_at_ts) if started_at_ts is not None else None + except Exception: + started_at_ts = None + if started_at_ts is None: + started_at_ts = time.time() # treat unknown as newest + + # Score: lower is safer to terminate + has_viewers = 1 if viewer_count > 0 else 0 + score = (has_viewers, max_viewer_level, viewer_count, started_at_ts) + candidates.append((score, ch_id)) + + logger.debug("Candidate channels after scoring:") + logger.debug(candidates) + + if not candidates: + return None + + candidates.sort(key=lambda x: x[0]) + victim_id = candidates[0][1] + + # Mark preempt timestamp to avoid thrashing + try: + redis_client.set(f"ts_proxy:channel:{victim_id}:last_preempt", str(time.time()), ex=3600) + except Exception: + pass + + return victim_id + + def _check_and_reserve_profile_slot(self, profile, redis_client): + """ + Atomically check and reserve a connection slot for the given profile. + + Uses an INCR-first-then-check pattern to eliminate the TOCTOU race + condition where separate GET + check + INCR operations could allow + concurrent requests to both pass the capacity check. + + For profiles with max_streams=0 (unlimited), no reservation is needed. + + Args: + profile: M3UAccountProfile instance + redis_client: Redis client instance + + Returns: + tuple: (reserved: bool, current_count: int) + """ + if profile.max_streams == 0: + return (True, 0) + + profile_connections_key = f"profile_connections:{profile.id}" + + # Atomically increment first — this is a single Redis command + new_count = redis_client.incr(profile_connections_key) + + if new_count <= profile.max_streams: + return (True, new_count) + + # Over capacity — roll back the increment + redis_client.decr(profile_connections_key) + return (False, new_count - 1) + + def get_stream(self, requester=None): + """ + Finds an available stream for the requested channel and returns the selected stream and profile. + + Returns: + Tuple[Optional[int], Optional[int], Optional[str]]: (stream_id, profile_id, error_reason) + """ + redis_client = RedisClient.get_client() + error_reason = None + + # Check if this channel has any streams + if not self.streams.exists(): + error_reason = "No streams assigned to channel" + return None, None, error_reason + + # Check if a stream is already active for this channel + stream_id_bytes = redis_client.get(f"channel_stream:{self.id}") + if stream_id_bytes: + try: + stream_id = int(stream_id_bytes) + profile_id_bytes = redis_client.get(f"stream_profile:{stream_id}") + if profile_id_bytes: + try: + profile_id = int(profile_id_bytes) + return stream_id, profile_id, None + except (ValueError, TypeError): + logger.debug( + f"Invalid profile ID retrieved from Redis: {profile_id_bytes}" + ) + except (ValueError, TypeError): + logger.debug( + f"Invalid stream ID retrieved from Redis: {stream_id_bytes}" + ) + + # No existing active stream, attempt to assign a new one + has_streams_but_maxed_out = False + has_active_profiles = False + + # Iterate through channel streams and their profiles + for stream in self.streams.all().order_by("channelstream__order"): + # Retrieve the M3U account associated with the stream. + m3u_account = stream.m3u_account + 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.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 active default profile") + continue + + profiles = [default_profile] + [ + obj for obj in m3u_profiles if not obj.is_default + ] + + for profile in profiles: + has_active_profiles = True + + # Atomically check and reserve a slot (INCR-first pattern) + reserved, current_count = self._check_and_reserve_profile_slot( + profile, redis_client + ) + + if reserved: + # Slot reserved — assign stream to this channel + redis_client.set(f"channel_stream:{self.id}", stream.id) + redis_client.set(f"stream_profile:{stream.id}", profile.id) + + return ( + stream.id, + profile.id, + None, + ) # Return newly assigned stream and matched profile + else: + # At capacity: try to preempt a lower-impact channel on this profile + victim_channel_id = self._pick_channel_to_preempt( + profile_id=profile.id, + requester_level=requester.user_level if requester else 100, + redis_client=redis_client, + exclude_channel_ids=None, + ) + if victim_channel_id: + logger.info(f"Preempting channel {victim_channel_id} for new stream on profile {profile.id}") + # return self.id, profile.id, victim_channel_id + + # This profile is at max connections + has_streams_but_maxed_out = True + logger.debug( + f"Profile {profile.id} at max connections: " + f"{current_count}/{profile.max_streams}" + ) + + # No available streams - determine specific reason + if has_streams_but_maxed_out: + error_reason = "All active M3U profiles have reached maximum connection limits" + elif has_active_profiles: + error_reason = "No compatible active profile found for any assigned stream" + else: + error_reason = "No active profiles found for any assigned stream" + + return None, None, error_reason + + def release_stream(self): + """ + Called when a stream is finished to release the lock. + + Returns: + bool: True if stream was successfully released, False if + no stream/profile info could be found for cleanup. + """ + redis_client = RedisClient.get_client() + + stream_id = redis_client.get(f"channel_stream:{self.id}") + if not stream_id: + # Primary key missing — try metadata hash fallback. + # The proxy may have already cleaned up channel_stream/stream_profile + # keys, but the metadata hash can still have the stream_id and profile. + metadata_key = RedisKeys.channel_metadata(str(self.uuid)) + meta_stream_id = redis_client.hget( + metadata_key, ChannelMetadataField.STREAM_ID + ) + meta_profile_id = redis_client.hget( + metadata_key, ChannelMetadataField.M3U_PROFILE + ) + + if meta_stream_id and meta_profile_id: + stream_id = int(meta_stream_id) + profile_id = int(meta_profile_id) + logger.debug( + f"Channel {self.uuid}: recovered stream_id={stream_id}, " + f"profile_id={profile_id} from metadata fallback" + ) + # Clean up any remaining keys + redis_client.delete(f"channel_stream:{self.id}") + redis_client.delete(f"stream_profile:{stream_id}") + + # Clear metadata fields so duplicate release_stream() calls + # won't find them and DECR again + redis_client.hdel( + metadata_key, + ChannelMetadataField.STREAM_ID, + ChannelMetadataField.M3U_PROFILE, + ) + + profile_connections_key = f"profile_connections:{profile_id}" + current_count = int( + redis_client.get(profile_connections_key) or 0 + ) + if current_count > 0: + redis_client.decr(profile_connections_key) + return True + + logger.debug( + f"Channel {self.uuid}: no stream info found in primary keys " + f"or metadata fallback" + ) + return False + + redis_client.delete(f"channel_stream:{self.id}") # Remove active stream + + stream_id = int(stream_id) + logger.debug( + f"Channel {self.uuid}: found stream_id={stream_id} for " + f"channel_stream:{self.id}" + ) + + # Get the matched profile for cleanup + profile_id = redis_client.get(f"stream_profile:{stream_id}") + if profile_id: + redis_client.delete(f"stream_profile:{stream_id}") # Remove profile association + profile_id = int(profile_id) + else: + # stream_profile key missing — try metadata hash fallback + metadata_key = RedisKeys.channel_metadata(str(self.uuid)) + meta_profile_id = redis_client.hget( + metadata_key, ChannelMetadataField.M3U_PROFILE + ) + if meta_profile_id: + profile_id = int(meta_profile_id) + logger.debug( + f"Channel {self.uuid}: recovered profile_id={profile_id} " + f"from metadata fallback (stream_profile:{stream_id} was missing)" + ) + else: + logger.warning( + f"Channel {self.uuid}: no profile found for " + f"stream_profile:{stream_id} or in metadata fallback" + ) + return False + logger.debug( + f"Channel {self.uuid}: found profile_id={profile_id} for " + f"stream {stream_id}" + ) + + profile_connections_key = f"profile_connections:{profile_id}" + + # Only decrement if the profile had a max_connections limit + current_count = int(redis_client.get(profile_connections_key) or 0) + if current_count > 0: + redis_client.decr(profile_connections_key) + + # Clear metadata fields so duplicate release_stream() calls + # (e.g. from _clean_redis_keys or ChannelService.stop_channel) + # won't find them via fallback and DECR again + metadata_key = RedisKeys.channel_metadata(str(self.uuid)) + redis_client.hdel( + metadata_key, + ChannelMetadataField.STREAM_ID, + ChannelMetadataField.M3U_PROFILE, + ) + + return True + + def update_stream_profile(self, new_profile_id): + """ + Updates the profile for the current stream and adjusts connection counts. + + Args: + new_profile_id: The ID of the new stream profile to use + + Returns: + bool: True if successful, False otherwise + """ + redis_client = RedisClient.get_client() + + # Get current stream ID + stream_id_bytes = redis_client.get(f"channel_stream:{self.id}") + if not stream_id_bytes: + logger.debug("No active stream found for channel") + return False + + stream_id = int(stream_id_bytes) + + # Get current profile ID + current_profile_id_bytes = redis_client.get(f"stream_profile:{stream_id}") + if not current_profile_id_bytes: + logger.debug("No profile found for current stream") + return False + + current_profile_id = int(current_profile_id_bytes) + + # Don't do anything if the profile is already set to the requested one + if current_profile_id == new_profile_id: + return True + + # Use pipeline for atomic profile switch to prevent counter drift + # if an exception occurs between DECR and INCR + old_profile_connections_key = f"profile_connections:{current_profile_id}" + new_profile_connections_key = f"profile_connections:{new_profile_id}" + old_count = int(redis_client.get(old_profile_connections_key) or 0) + + pipe = redis_client.pipeline() + if old_count > 0: + pipe.decr(old_profile_connections_key) + pipe.set(f"stream_profile:{stream_id}", new_profile_id) + pipe.incr(new_profile_connections_key) + pipe.execute() + logger.info( + f"Updated stream {stream_id} profile from {current_profile_id} to {new_profile_id}" + ) + return True + + +class ChannelProfile(models.Model): + name = models.CharField(max_length=100, unique=True) + + +class ChannelProfileMembership(models.Model): + channel_profile = models.ForeignKey(ChannelProfile, on_delete=models.CASCADE) + channel = models.ForeignKey(Channel, on_delete=models.CASCADE) + enabled = models.BooleanField( + default=True + ) # Track if the channel is enabled for this group + + class Meta: + unique_together = ("channel_profile", "channel") + + +class ChannelStream(models.Model): + channel = models.ForeignKey(Channel, on_delete=models.CASCADE) + stream = models.ForeignKey(Stream, on_delete=models.CASCADE) + order = models.PositiveIntegerField(default=0) # Ordering field + + class Meta: + ordering = ["order"] # Ensure streams are retrieved in order + constraints = [ + models.UniqueConstraint( + fields=["channel", "stream"], name="unique_channel_stream" + ) + ] + + +class ChannelGroupM3UAccount(models.Model): + channel_group = models.ForeignKey( + 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.JSONField(default=dict, blank=True, null=True) + enabled = models.BooleanField(default=True) + auto_channel_sync = models.BooleanField( + default=False, + help_text='Automatically create/delete channels to match streams in this group' + ) + auto_sync_channel_start = models.FloatField( + null=True, + blank=True, + help_text='Starting channel number for auto-created channels in this group' + ) + last_seen = models.DateTimeField( + default=timezone.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") + + def __str__(self): + return f"{self.channel_group.name} - {self.m3u_account.name} (Enabled: {self.enabled})" + + +class Logo(models.Model): + name = models.CharField(max_length=255) + url = models.TextField(unique=True) + + def __str__(self): + return self.name + + +class Recording(models.Model): + channel = models.ForeignKey( + "Channel", on_delete=models.CASCADE, related_name="recordings" + ) + start_time = models.DateTimeField() + end_time = models.DateTimeField() + task_id = models.CharField(max_length=255, 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 [] diff --git a/apps/channels/serializers.py b/apps/channels/serializers.py new file mode 100644 index 0000000..abf26e9 --- /dev/null +++ b/apps/channels/serializers.py @@ -0,0 +1,537 @@ +import json +from datetime import datetime + +from rest_framework import serializers +from .models import ( + Stream, + Channel, + ChannelGroup, + ChannelStream, + ChannelGroupM3UAccount, + Logo, + ChannelProfile, + ChannelProfileMembership, + Recording, + RecurringRecordingRule, +) +from apps.epg.serializers import EPGDataSerializer +from core.models import StreamProfile +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): + cache_url = serializers.SerializerMethodField() + channel_count = serializers.SerializerMethodField() + is_used = serializers.SerializerMethodField() + channel_names = serializers.SerializerMethodField() + + class Meta: + model = Logo + fields = ["id", "name", "url", "cache_url", "channel_count", "is_used", "channel_names"] + + def validate_url(self, value): + """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") + if request: + return request.build_absolute_uri( + reverse("api:channels:logo-cache", args=[obj.id]) + ) + return reverse("api:channels:logo-cache", args=[obj.id]) + + def get_channel_count(self, obj): + """Get the number of channels using this logo""" + return obj.channels.count() + + def get_is_used(self, obj): + """Check if this logo is used by any channels""" + return obj.channels.exists() + + 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] + 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 + + +# +# 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", + allow_null=True, + required=False, + ) + read_only_fields = ["is_custom", "m3u_account", "stream_hash", "stream_id", "stream_chno"] + + class Meta: + model = Stream + fields = [ + "id", + "name", + "url", + "m3u_account", # Uncomment if using M3U fields + "logo_url", + "tvg_id", + "local_file", + "current_viewers", + "updated_at", + "last_seen", + "is_stale", + "is_adult", + "stream_profile_id", + "is_custom", + "channel_group", + "stream_hash", + "stream_stats", + "stream_stats_updated_at", + "stream_id", + "stream_chno", + ] + + def get_fields(self): + fields = super().get_fields() + + # Unable to edit specific properties if this stream was created from an M3U account + if ( + self.instance + and getattr(self.instance, "m3u_account", None) + and not self.instance.is_custom + ): + fields["id"].read_only = True + fields["name"].read_only = True + fields["url"].read_only = True + fields["m3u_account"].read_only = True + fields["tvg_id"].read_only = True + fields["channel_group"].read_only = True + + 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.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", "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): + channels = serializers.SerializerMethodField() + + class Meta: + model = ChannelProfile + fields = ["id", "name", "channels"] + + def get_channels(self, obj): + memberships = ChannelProfileMembership.objects.filter( + channel_profile=obj, enabled=True + ) + return [membership.channel.id for membership in memberships] + + +class ChannelProfileMembershipSerializer(serializers.ModelSerializer): + class Meta: + model = ChannelProfileMembership + fields = ["channel", "enabled"] + + +class ChanneProfilelMembershipUpdateSerializer(serializers.Serializer): + channel_id = serializers.IntegerField() # Ensure channel_id is an integer + enabled = serializers.BooleanField() + + +class BulkChannelProfileMembershipSerializer(serializers.Serializer): + channels = serializers.ListField( + child=ChanneProfilelMembershipUpdateSerializer(), # Use the nested serializer + allow_empty=False, + ) + + def validate_channels(self, value): + if not value: + raise serializers.ValidationError("At least one channel must be provided.") + return value + + +# +# Channel +# +class ChannelSerializer(serializers.ModelSerializer): + # Show nested group data, or ID + # Ensure channel_number is explicitly typed as FloatField and properly validated + channel_number = serializers.FloatField( + allow_null=True, + required=False, + error_messages={"invalid": "Channel number must be a valid decimal number."}, + ) + channel_group_id = serializers.PrimaryKeyRelatedField( + queryset=ChannelGroup.objects.all(), source="channel_group", required=False + ) + epg_data_id = serializers.PrimaryKeyRelatedField( + queryset=EPGData.objects.all(), + source="epg_data", + required=False, + allow_null=True, + ) + + stream_profile_id = serializers.PrimaryKeyRelatedField( + queryset=StreamProfile.objects.all(), + source="stream_profile", + allow_null=True, + required=False, + ) + + streams = serializers.PrimaryKeyRelatedField( + queryset=Stream.objects.all(), many=True, required=False + ) + + logo_id = serializers.PrimaryKeyRelatedField( + queryset=Logo.objects.all(), + source="logo", + allow_null=True, + required=False, + ) + + auto_created_by_name = serializers.SerializerMethodField() + + class Meta: + model = Channel + fields = [ + "id", + "channel_number", + "name", + "channel_group_id", + "tvg_id", + "tvc_guide_stationid", + "epg_data_id", + "streams", + "stream_profile_id", + "uuid", + "logo_id", + "user_level", + "is_adult", + "auto_created", + "auto_created_by", + "auto_created_by_name", + ] + + def to_representation(self, instance): + include_streams = self.context.get("include_streams", False) + + if include_streams: + self.fields["streams"] = serializers.SerializerMethodField() + 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 + + def get_streams(self, obj): + """Retrieve ordered stream IDs for GET requests.""" + return StreamSerializer( + obj.streams.all().order_by("channelstream__order"), many=True + ).data + + def create(self, validated_data): + streams = validated_data.pop("streams", []) + channel_number = validated_data.pop( + "channel_number", Channel.get_next_available_channel_number() + ) + validated_data["channel_number"] = channel_number + + # Auto-assign Default Group if no channel_group is specified + if "channel_group" not in validated_data or validated_data.get("channel_group") is None: + from apps.channels.models import ChannelGroup + default_group, _ = ChannelGroup.objects.get_or_create(name="Default Group") + validated_data["channel_group"] = default_group + + channel = Channel.objects.create(**validated_data) + + # Add streams in the specified order + for index, stream in enumerate(streams): + ChannelStream.objects.create( + channel=channel, stream_id=stream.id, order=index + ) + + return channel + + def update(self, instance, validated_data): + streams = validated_data.pop("streams", None) + + # Update standard fields + for attr, value in validated_data.items(): + setattr(instance, attr, value) + + instance.save() + + if streams is not None: + # Normalize stream IDs + normalized_ids = [ + stream.id if hasattr(stream, "id") else stream for stream in streams + ] + print(normalized_ids) + + # Get current mapping of stream_id -> ChannelStream + current_links = { + cs.stream_id: cs for cs in instance.channelstream_set.all() + } + + # Track existing stream IDs + existing_ids = set(current_links.keys()) + new_ids = set(normalized_ids) + + # Delete any links not in the new list + to_remove = existing_ids - new_ids + if to_remove: + instance.channelstream_set.filter(stream_id__in=to_remove).delete() + + # Update or create with new order + for order, stream_id in enumerate(normalized_ids): + if stream_id in current_links: + cs = current_links[stream_id] + if cs.order != order: + cs.order = order + cs.save(update_fields=["order"]) + else: + ChannelStream.objects.create( + channel=instance, stream_id=stream_id, order=order + ) + + return instance + + def validate_channel_number(self, value): + """Ensure channel_number is properly processed as a float""" + if value is None: + return value + + try: + # Ensure it's processed as a float + return float(value) + except (ValueError, TypeError): + raise serializers.ValidationError( + "Channel number must be a valid decimal number." + ) + + def validate_stream_profile(self, value): + """Handle special case where empty/0 values mean 'use default' (null)""" + if value == "0" or value == 0 or value == "" or value is None: + return None + return value # PrimaryKeyRelatedField will handle the conversion to object + + def get_auto_created_by_name(self, obj): + """Get the name of the M3U account that auto-created this channel.""" + if obj.auto_created_by: + return obj.auto_created_by.name + return None + + +class RecordingSerializer(serializers.ModelSerializer): + class Meta: + model = Recording + fields = "__all__" + 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: + raise serializers.ValidationError("End time must be in the future.") + + if start_time < now: + # Optional: Adjust start_time if it's in the past but end_time is in the future + data["start_time"] = now # or: timezone.now() + timedelta(seconds=1) + if end_time <= data["start_time"]: + 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) diff --git a/apps/channels/signals.py b/apps/channels/signals.py new file mode 100644 index 0000000..dd2ac15 --- /dev/null +++ b/apps/channels/signals.py @@ -0,0 +1,236 @@ +# apps/channels/signals.py + +from django.db.models.signals import m2m_changed, pre_save, post_save, post_delete +from django.dispatch import receiver +from django.utils.timezone import now, is_aware, make_aware +from celery.result import AsyncResult +from django_celery_beat.models import ClockedSchedule, PeriodicTask +from .models import Channel, Stream, ChannelProfile, ChannelProfileMembership, Recording +from apps.m3u.models import M3UAccount +from apps.epg.tasks import parse_programs_for_tvg_id +import json +import logging +from .tasks import run_recording, prefetch_recording_artwork +from datetime import timedelta + +logger = logging.getLogger(__name__) + +@receiver(m2m_changed, sender=Channel.streams.through) +def update_channel_tvg_id_and_logo(sender, instance, action, reverse, model, pk_set, **kwargs): + """ + Whenever streams are added to a channel: + 1) If the channel doesn't have a tvg_id, fill it from the first newly-added stream that has one. + """ + # We only care about post_add, i.e. once the new streams are fully associated + if action == "post_add": + # --- 1) Populate channel.tvg_id if empty --- + if not instance.tvg_id: + # Look for newly added streams that have a nonempty tvg_id + streams_with_tvg = model.objects.filter(pk__in=pk_set).exclude(tvg_id__exact='') + if streams_with_tvg.exists(): + instance.tvg_id = streams_with_tvg.first().tvg_id + instance.save(update_fields=['tvg_id']) + +@receiver(pre_save, sender=Stream) +def set_default_m3u_account(sender, instance, **kwargs): + """ + This function will be triggered before saving a Stream instance. + It sets the default m3u_account if not provided. + """ + if not instance.m3u_account: + instance.is_custom = True + default_account = M3UAccount.get_custom_account() + + if default_account: + instance.m3u_account = default_account + 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): + """ + When a channel is saved, check if the EPG data has changed. + If so, trigger a refresh of the program data for the EPG. + """ + # Check if this is an update (not a new channel) and the epg_data has changed + if not created and kwargs.get('update_fields') and 'epg_data' in kwargs['update_fields']: + logger.info(f"Channel {instance.id} ({instance.name}) EPG data updated, refreshing program data") + if instance.epg_data: + logger.info(f"Triggering EPG program refresh for {instance.epg_data.tvg_id}") + parse_programs_for_tvg_id.delay(instance.epg_data.id) + # For new channels with EPG data, also refresh + elif created and instance.epg_data: + 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=ChannelProfile) +def create_profile_memberships(sender, instance, created, **kwargs): + if created: + channels = Channel.objects.all() + ChannelProfileMembership.objects.bulk_create([ + ChannelProfileMembership(channel_profile=instance, channel=channel) + for channel in channels + ]) + +def _dvr_task_name(recording_id): + """Predictable PeriodicTask name for a DVR recording.""" + return f"dvr-recording-{recording_id}" + + +def schedule_recording_task(instance, eta=None): + """Schedule a recording task via ClockedSchedule + one-off PeriodicTask. + + The task is stored in the database and dispatched by Celery Beat at the + scheduled time with no countdown. This avoids the Redis visibility_timeout + redelivery bug that caused duplicate recordings when using apply_async + with long countdowns. + """ + if eta is None: + eta = instance.start_time + if eta is not None and not is_aware(eta): + eta = make_aware(eta) + # Clamp to now so Beat dispatches immediately for past/current start times + if eta <= now(): + eta = now() + + task_args = [ + instance.id, + instance.channel_id, + str(instance.start_time), + str(instance.end_time), + ] + + clocked, _ = ClockedSchedule.objects.get_or_create(clocked_time=eta) + task_name = _dvr_task_name(instance.id) + PeriodicTask.objects.update_or_create( + name=task_name, + defaults={ + "task": "apps.channels.tasks.run_recording", + "clocked": clocked, + "args": json.dumps(task_args), + "one_off": True, + "enabled": True, + "interval": None, + "crontab": None, + "solar": None, + }, + ) + return task_name + + +def revoke_task(task_id): + """Cancel a pending recording task. + + task_id is normally a PeriodicTask name (e.g. "dvr-recording-42"). + For backwards compatibility with legacy Celery async-result UUIDs, + falls back to AsyncResult.revoke(). + """ + if not task_id: + return + # Primary path: delete the PeriodicTask and clean up its ClockedSchedule + try: + pt = PeriodicTask.objects.get(name=task_id) + old_clocked = pt.clocked + pt.delete() + if old_clocked and not PeriodicTask.objects.filter(clocked=old_clocked).exists(): + old_clocked.delete() + return + except PeriodicTask.DoesNotExist: + pass + # Fallback for legacy Celery task UUIDs + try: + AsyncResult(task_id).revoke() + except Exception: + pass + +@receiver(pre_save, sender=Recording) +def revoke_old_task_on_update(sender, instance, **kwargs): + if not instance.pk: + return # New instance + try: + old = Recording.objects.get(pk=instance.pk) + if old.task_id and ( + old.start_time != instance.start_time or + old.end_time != instance.end_time or + old.channel_id != instance.channel_id + ): + # Do NOT revoke while the recording is actively streaming. + # run_recording re-reads end_time from the DB every ~2 s and extends + # its internal deadline dynamically — revoking here would kill the task. + old_status = (old.custom_properties or {}).get("status", "") + if old_status == "recording": + return + revoke_task(old.task_id) + instance.task_id = None + except Recording.DoesNotExist: + pass + +@receiver(post_save, sender=Recording) +def schedule_task_on_save(sender, instance, created, **kwargs): + try: + # Skip processing for internal field-only saves (metadata updates, + # task_id assignment, end_time extensions) to prevent re-entrant + # artwork dispatch and redundant recording_updated WS events. + update_fields = kwargs.get('update_fields') + if not created and update_fields is not None and set(update_fields) <= {'custom_properties', 'task_id', 'end_time'}: + return + + if not instance.task_id: + start_time = instance.start_time + end_time = instance.end_time + + # Make datetimes aware (in UTC) + if not is_aware(start_time): + start_time = make_aware(start_time) + if end_time and not is_aware(end_time): + end_time = make_aware(end_time) + + current_time = now() + + if start_time > current_time - timedelta(seconds=1): + # Future recording — schedule at start_time + logger.info(f"Recording {instance.id}: scheduling task at {start_time}") + task_id = schedule_recording_task(instance, eta=start_time) + instance.task_id = task_id + instance.save(update_fields=['task_id']) + elif end_time and end_time > current_time: + # Currently-playing — start immediately (e.g. series rule for in-progress program) + logger.info(f"Recording {instance.id}: start_time in past but end_time still future, scheduling immediately") + task_id = schedule_recording_task(instance, eta=current_time) + instance.task_id = task_id + instance.save(update_fields=['task_id']) + else: + logger.info(f"Recording {instance.id}: start_time and end_time both in past, not scheduling") + # Kick off poster/artwork prefetch to enrich Upcoming cards. + # Skip when the recording is already active or finished — run_recording + # handles its own poster resolution, and scheduling artwork prefetch + # while the task is running causes a race that can overwrite status. + cp = instance.custom_properties or {} + rec_status = cp.get("status", "") + if rec_status not in ("recording", "completed", "stopped", "interrupted"): + 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) + traceback.print_exc() + +@receiver(post_delete, sender=Recording) +def revoke_task_on_delete(sender, instance, **kwargs): + revoke_task(instance.task_id) diff --git a/apps/channels/tasks.py b/apps/channels/tasks.py new file mode 100755 index 0000000..59122d9 --- /dev/null +++ b/apps/channels/tasks.py @@ -0,0 +1,3973 @@ +# apps/channels/tasks.py +import logging +import os +import select +import re +import requests +import time +import json +import subprocess +import signal +from zoneinfo import ZoneInfo +from datetime import datetime, timedelta +import gc + +from celery import shared_task +from django.utils.text import slugify +from rapidfuzz import fuzz + +from apps.channels.models import Channel +from apps.epg.models import EPGData +from core.models import CoreSettings +from core.utils import acquire_task_lock, release_task_lock + +from django.db import OperationalError, close_old_connections +from channels.layers import get_channel_layer +from asgiref.sync import async_to_sync +import tempfile +from urllib.parse import quote + +logger = logging.getLogger(__name__) + + +_url_validation_cache = {} +_URL_CACHE_TTL = 300 # seconds + + +def _validate_url(url, timeout=4): + """Validate that an HTTP(S) URL is reachable via HEAD request. + Returns True for non-HTTP URLs (skip validation) or 2xx/3xx responses. + Results are cached per-worker for 5 minutes to avoid redundant requests + when multiple recordings reference the same dead URL. + """ + if not url or not isinstance(url, str): + return False + if not url.startswith(("http://", "https://")): + return True + + now = time.monotonic() + cached = _url_validation_cache.get(url) + if cached is not None and (now - cached[1]) < _URL_CACHE_TTL: + return cached[0] + + try: + resp = requests.head(url, timeout=timeout, allow_redirects=True) + if resp.status_code == 405: + # Server doesn't support HEAD; fall back to ranged GET + resp = requests.get( + url, timeout=timeout, allow_redirects=True, + headers={"Range": "bytes=0-0"}, stream=True, + ) + resp.close() + result = resp.status_code < 400 + except Exception: + result = False + + _url_validation_cache[url] = (result, now) + + # Evict expired entries when cache grows large + if len(_url_validation_cache) > 512: + cutoff = now - _URL_CACHE_TTL + expired = [k for k, v in _url_validation_cache.items() if v[1] < cutoff] + for k in expired: + del _url_validation_cache[k] + + return result + + +def _pick_best_image_from_epg_props(epg_props): + """Select the highest-quality poster/cover image from EPG custom_properties.""" + try: + images = epg_props.get("images") or [] + if not isinstance(images, list): + return None + size_order = {"xxl": 6, "xl": 5, "l": 4, "m": 3, "s": 2, "xs": 1} + def score(img): + t = (img.get("type") or "").lower() + size = (img.get("size") or "").lower() + return (2 if t in ("poster", "cover") else 1, size_order.get(size, 0)) + best = None + for im in images: + if not isinstance(im, dict): + continue + url = im.get("url") + if not url: + continue + if best is None or score(im) > score(best): + best = im + return best.get("url") if best else None + except Exception: + return None + + +def _match_epg_program_by_timeslot(channel_epg_data, rec_start, rec_end): + """Find an EPG program that covers at least 80% of the recording window. + + Queries all programs overlapping the recording, calculates overlap for + each, and returns the best match only if it covers >= 80% of the + recording duration. Recordings spanning multiple programs with no + dominant show return None (displayed as "Custom Recording"). + Returns a dict with id, title, sub_title, and description, or None. + """ + if not channel_epg_data or not rec_start or not rec_end: + return None + try: + candidates = channel_epg_data.programs.filter( + start_time__lt=rec_end, + end_time__gt=rec_start, + ).only("id", "title", "sub_title", "description", "start_time", "end_time") + + rec_duration = (rec_end - rec_start).total_seconds() + if rec_duration <= 0: + return None + + best = None + best_overlap = 0 + for prog in candidates: + overlap_start = max(rec_start, prog.start_time) + overlap_end = min(rec_end, prog.end_time) + overlap = (overlap_end - overlap_start).total_seconds() + if overlap > best_overlap: + best_overlap = overlap + best = prog + + if best and (best_overlap / rec_duration) >= 0.8: + return { + "id": best.id, + "title": best.title or "", + "sub_title": best.sub_title or "", + "description": best.description or "", + } + except Exception: + pass + return None + + +def _db_retry(fn, max_retries=3, base_interval=1, label="DB operation"): + """Execute fn() with exponential backoff retry on transient DB errors. + + Follows the same backoff pattern as RedisClient.get_client(). + Resets stale connections between attempts so the ORM reconnects. + """ + for attempt in range(max_retries): + try: + return fn() + except OperationalError: + if attempt + 1 >= max_retries: + raise + wait = base_interval * (2 ** attempt) + logger.warning( + f"{label}: failed, retrying in {wait}s " + f"({attempt + 1}/{max_retries})..." + ) + close_old_connections() + time.sleep(wait) + + +# PostgreSQL btree index has a limit of ~2704 bytes (1/3 of 8KB page size) +# We use 2000 as a safe maximum to account for multibyte characters +def validate_logo_url(logo_url, max_length=2000): + """ + Fast validation for logo URLs during bulk creation. + Returns None if URL is too long (would exceed PostgreSQL btree index limit), + original URL otherwise. + + PostgreSQL btree indexes have a maximum size of ~2704 bytes. URLs longer than + this cannot be indexed and would cause database errors. These are typically + base64-encoded images embedded in URLs. + """ + if logo_url and len(logo_url) > max_length: + logger.warning(f"Logo URL too long ({len(logo_url)} > {max_length}), skipping: {logo_url[:100]}...") + return None + return logo_url + +def send_epg_matching_progress(total_channels, matched_channels, current_channel_name="", stage="matching"): + """ + Send EPG matching progress via WebSocket + """ + try: + channel_layer = get_channel_layer() + if channel_layer: + progress_data = { + 'type': 'epg_matching_progress', + 'total': total_channels, + 'matched': len(matched_channels) if isinstance(matched_channels, list) else matched_channels, + 'remaining': total_channels - (len(matched_channels) if isinstance(matched_channels, list) else matched_channels), + 'current_channel': current_channel_name, + 'stage': stage, + 'progress_percent': round((len(matched_channels) if isinstance(matched_channels, list) else matched_channels) / total_channels * 100, 1) if total_channels > 0 else 0 + } + + async_to_sync(channel_layer.group_send)( + "updates", + { + "type": "update", + "data": { + "type": "epg_matching_progress", + **progress_data + } + } + ) + except Exception as e: + logger.warning(f"Failed to send EPG matching progress: {e}") + +# Lazy loading for ML models - only imported/loaded when needed +_ml_model_cache = { + 'sentence_transformer': None +} + +def get_sentence_transformer(): + """Lazy load the sentence transformer model only when needed""" + if _ml_model_cache['sentence_transformer'] is None: + try: + from sentence_transformers import SentenceTransformer + from sentence_transformers import util + + model_name = "sentence-transformers/all-MiniLM-L6-v2" + cache_dir = "/data/models" + + # Check environment variable to disable downloads + disable_downloads = os.environ.get('DISABLE_ML_DOWNLOADS', 'false').lower() == 'true' + + if disable_downloads: + # Check if model exists before attempting to load + hf_model_path = os.path.join(cache_dir, f"models--{model_name.replace('/', '--')}") + if not os.path.exists(hf_model_path): + logger.warning("ML model not found and downloads disabled (DISABLE_ML_DOWNLOADS=true). Skipping ML matching.") + return None, None + + # Ensure cache directory exists + os.makedirs(cache_dir, exist_ok=True) + + # Let sentence-transformers handle all cache detection and management + logger.info(f"Loading sentence transformer model (cache: {cache_dir})") + _ml_model_cache['sentence_transformer'] = SentenceTransformer( + model_name, + cache_folder=cache_dir + ) + + return _ml_model_cache['sentence_transformer'], util + except ImportError: + logger.warning("sentence-transformers not available - ML-enhanced matching disabled") + return None, None + except Exception as e: + logger.error(f"Failed to load sentence transformer: {e}") + return None, None + else: + from sentence_transformers import util + return _ml_model_cache['sentence_transformer'], util + +# ML matching thresholds (same as original script) +BEST_FUZZY_THRESHOLD = 85 +LOWER_FUZZY_THRESHOLD = 40 +EMBED_SIM_THRESHOLD = 0.65 + +# Words we remove to help with fuzzy + embedding matching +COMMON_EXTRANEOUS_WORDS = [ + "tv", "channel", "network", "television", + "east", "west", "hd", "uhd", "24/7", + "1080p", "720p", "540p", "480p", + "film", "movie", "movies" +] + +def normalize_name(name: str) -> str: + """ + A more aggressive normalization that: + - Removes user-configured prefixes/suffixes/custom strings (only if mode is 'advanced') + - Lowercases + - Removes bracketed/parenthesized text + - Removes punctuation + - Strips extraneous words + - Collapses extra spaces + """ + if not name: + return "" + + # Load user-configured EPG matching rules (fail gracefully) + prefixes = [] + suffixes = [] + custom_strings = [] + + try: + from core.models import CoreSettings + settings = CoreSettings.get_epg_settings() + + # Check if user has enabled advanced mode + mode = settings.get("epg_match_mode", "default") + + # Only use custom settings if mode is 'advanced' + if mode == "advanced": + prefixes = settings.get("epg_match_ignore_prefixes", []) + suffixes = settings.get("epg_match_ignore_suffixes", []) + custom_strings = settings.get("epg_match_ignore_custom", []) + + # Ensure we have lists + if not isinstance(prefixes, list): + prefixes = [] + if not isinstance(suffixes, list): + suffixes = [] + if not isinstance(custom_strings, list): + custom_strings = [] + + except Exception as e: + # Settings unavailable or error - continue with empty lists (graceful degradation) + logger.debug(f"Could not load EPG matching settings: {e}") + prefixes = [] + suffixes = [] + custom_strings = [] + + result = name + + # Step 1: Remove prefixes (from START only - exact string match) + for prefix in prefixes: + # Skip empty or non-string entries + if not prefix or not isinstance(prefix, str): + continue + # Exact match at start + if result.startswith(prefix): + result = result[len(prefix):] + break # Only remove first matching prefix + + # Step 2: Remove suffixes (from END only - exact string match) + for suffix in suffixes: + # Skip empty or non-string entries + if not suffix or not isinstance(suffix, str): + continue + # Exact match at end + if result.endswith(suffix): + result = result[:-len(suffix)] + break # Only remove first matching suffix + + # Step 3: Remove custom strings (from ANYWHERE - exact string match) + for custom in custom_strings: + # Skip empty or non-string entries + if not custom or not isinstance(custom, str): + continue + try: + # Exact string removal (replace with empty string) + result = result.replace(custom, "") + except Exception as e: + # If removal fails for any reason, skip this entry + logger.debug(f"Failed to remove custom string '{custom}': {e}") + continue + + # Step 4: Existing normalization logic (unchanged) + norm = result.lower() + norm = re.sub(r"\[.*?\]", "", norm) + + # Extract and preserve important call signs from parentheses before removing them + # This captures call signs like (KVLY), (KING), (KARE), etc. + call_sign_match = re.search(r"\(([A-Z]{3,5})\)", name) + preserved_call_sign = "" + if call_sign_match: + preserved_call_sign = " " + call_sign_match.group(1).lower() + + # Now remove all parentheses content + norm = re.sub(r"\(.*?\)", "", norm) + + # Add back the preserved call sign + norm = norm + preserved_call_sign + + norm = re.sub(r"[^\w\s]", "", norm) + tokens = norm.split() + tokens = [t for t in tokens if t not in COMMON_EXTRANEOUS_WORDS] + norm = " ".join(tokens).strip() + return norm + +def match_channels_to_epg(channels_data, epg_data, region_code=None, use_ml=True, send_progress=True): + """ + EPG matching logic that finds the best EPG matches for channels using + multiple matching strategies including fuzzy matching and ML models. + + Automatically uses conservative thresholds for bulk matching (multiple channels) + to avoid bad matches that create user cleanup work, and aggressive thresholds + for single channel matching where users specifically requested a match attempt. + """ + channels_to_update = [] + matched_channels = [] + total_channels = len(channels_data) + + # Send initial progress + if send_progress: + send_epg_matching_progress(total_channels, 0, stage="starting") + + # Try to get ML models if requested (but don't load yet - lazy loading) + st_model, util = None, None + epg_embeddings = None + ml_available = use_ml + + # Automatically determine matching strategy based on number of channels + is_bulk_matching = len(channels_data) > 1 + + # Adjust matching thresholds based on operation type + if is_bulk_matching: + # Conservative thresholds for bulk matching to avoid creating cleanup work + FUZZY_HIGH_CONFIDENCE = 90 # Only very high fuzzy scores + FUZZY_MEDIUM_CONFIDENCE = 70 # Higher threshold for ML enhancement + ML_HIGH_CONFIDENCE = 0.75 # Higher ML confidence required + ML_LAST_RESORT = 0.65 # More conservative last resort + FUZZY_LAST_RESORT_MIN = 50 # Higher fuzzy minimum for last resort + logger.info(f"Using conservative thresholds for bulk matching ({total_channels} channels)") + else: + # More aggressive thresholds for single channel matching (user requested specific match) + FUZZY_HIGH_CONFIDENCE = 85 # Original threshold + FUZZY_MEDIUM_CONFIDENCE = 40 # Original threshold + ML_HIGH_CONFIDENCE = 0.65 # Original threshold + ML_LAST_RESORT = 0.50 # Original desperate threshold + FUZZY_LAST_RESORT_MIN = 20 # Original minimum + logger.info("Using aggressive thresholds for single channel matching") # Process each channel + for index, chan in enumerate(channels_data): + normalized_tvg_id = chan.get("tvg_id", "") + fallback_name = chan["tvg_id"].strip() if chan["tvg_id"] else chan["name"] + + # Send progress update every 5 channels or for the first few + if send_progress and (index < 5 or index % 5 == 0 or index == total_channels - 1): + send_epg_matching_progress( + total_channels, + len(matched_channels), + current_channel_name=chan["name"][:50], # Truncate long names + stage="matching" + ) + normalized_tvg_id = chan.get("tvg_id", "") + fallback_name = chan["tvg_id"].strip() if chan["tvg_id"] else chan["name"] + + # Step 1: Exact TVG ID match + epg_by_tvg_id = next((epg for epg in epg_data if epg["tvg_id"] == normalized_tvg_id), None) + if normalized_tvg_id and epg_by_tvg_id: + chan["epg_data_id"] = epg_by_tvg_id["id"] + channels_to_update.append(chan) + matched_channels.append((chan['id'], fallback_name, epg_by_tvg_id["tvg_id"])) + logger.info(f"Channel {chan['id']} '{fallback_name}' => EPG found by exact tvg_id={epg_by_tvg_id['tvg_id']}") + continue + + # Step 2: Secondary TVG ID check (legacy compatibility) + if chan["tvg_id"]: + epg_match = [epg["id"] for epg in epg_data if epg["tvg_id"] == chan["tvg_id"]] + if epg_match: + chan["epg_data_id"] = epg_match[0] + channels_to_update.append(chan) + matched_channels.append((chan['id'], fallback_name, chan["tvg_id"])) + logger.info(f"Channel {chan['id']} '{chan['name']}' => EPG found by secondary tvg_id={chan['tvg_id']}") + continue + + # Step 2.5: Exact Gracenote ID match + normalized_gracenote_id = chan.get("gracenote_id", "") + if normalized_gracenote_id: + epg_by_gracenote_id = next((epg for epg in epg_data if epg["tvg_id"] == normalized_gracenote_id), None) + if epg_by_gracenote_id: + chan["epg_data_id"] = epg_by_gracenote_id["id"] + channels_to_update.append(chan) + matched_channels.append((chan['id'], fallback_name, f"gracenote:{epg_by_gracenote_id['tvg_id']}")) + logger.info(f"Channel {chan['id']} '{fallback_name}' => EPG found by exact gracenote_id={normalized_gracenote_id}") + continue + + # Step 3: Name-based fuzzy matching + if not chan["norm_chan"]: + logger.debug(f"Channel {chan['id']} '{chan['name']}' => empty after normalization, skipping") + continue + + best_score = 0 + best_epg = None + + # Debug: show what we're matching against + logger.debug(f"Fuzzy matching '{chan['norm_chan']}' against EPG entries...") + + # Find best fuzzy match + for row in epg_data: + if not row.get("norm_name"): + continue + + base_score = fuzz.ratio(chan["norm_chan"], row["norm_name"]) + bonus = 0 + + # Apply region-based bonus/penalty + if region_code and row.get("tvg_id"): + combined_text = row["tvg_id"].lower() + " " + row["name"].lower() + dot_regions = re.findall(r'\.([a-z]{2})', combined_text) + + if dot_regions: + if region_code in dot_regions: + bonus = 15 # Bigger bonus for matching region + else: + bonus = -15 # Penalty for different region + elif region_code in combined_text: + bonus = 10 + + score = base_score + bonus + + # Debug the best few matches + if score > 50: # Only show decent matches + logger.debug(f" EPG '{row['name']}' (norm: '{row['norm_name']}') => score: {score} (base: {base_score}, bonus: {bonus})") + + # When scores are equal, prefer higher priority EPG source + row_priority = row.get('epg_source_priority', 0) + best_priority = best_epg.get('epg_source_priority', 0) if best_epg else -1 + + if score > best_score or (score == best_score and row_priority > best_priority): + best_score = score + best_epg = row + + # Log the best score we found + if best_epg: + logger.info(f"Channel {chan['id']} '{chan['name']}' => best match: '{best_epg['name']}' (score: {best_score})") + else: + logger.debug(f"Channel {chan['id']} '{chan['name']}' => no EPG entries with valid norm_name found") + continue + + # High confidence match - accept immediately + if best_score >= FUZZY_HIGH_CONFIDENCE: + chan["epg_data_id"] = best_epg["id"] + channels_to_update.append(chan) + matched_channels.append((chan['id'], chan['name'], best_epg["tvg_id"])) + logger.info(f"Channel {chan['id']} '{chan['name']}' => matched tvg_id={best_epg['tvg_id']} (score={best_score})") + + # Medium confidence - use ML if available (lazy load models here) + elif best_score >= FUZZY_MEDIUM_CONFIDENCE and ml_available: + # Lazy load ML models only when we actually need them + if st_model is None: + st_model, util = get_sentence_transformer() + + # Lazy generate embeddings only when we actually need them + if epg_embeddings is None and st_model and any(row.get("norm_name") for row in epg_data): + try: + logger.info("Generating embeddings for EPG data using ML model (lazy loading)") + epg_embeddings = st_model.encode( + [row["norm_name"] for row in epg_data if row.get("norm_name")], + convert_to_tensor=True + ) + except Exception as e: + logger.warning(f"Failed to generate embeddings: {e}") + epg_embeddings = None + + if epg_embeddings is not None and st_model: + try: + # Generate embedding for this channel + chan_embedding = st_model.encode(chan["norm_chan"], convert_to_tensor=True) + + # Calculate similarity with all EPG embeddings + sim_scores = util.cos_sim(chan_embedding, epg_embeddings)[0] + top_index = int(sim_scores.argmax()) + top_value = float(sim_scores[top_index]) + + if top_value >= ML_HIGH_CONFIDENCE: + # Find the EPG entry that corresponds to this embedding index + epg_with_names = [epg for epg in epg_data if epg.get("norm_name")] + matched_epg = epg_with_names[top_index] + + chan["epg_data_id"] = matched_epg["id"] + channels_to_update.append(chan) + matched_channels.append((chan['id'], chan['name'], matched_epg["tvg_id"])) + logger.info(f"Channel {chan['id']} '{chan['name']}' => matched EPG tvg_id={matched_epg['tvg_id']} (fuzzy={best_score}, ML-sim={top_value:.2f})") + else: + logger.info(f"Channel {chan['id']} '{chan['name']}' => fuzzy={best_score}, ML-sim={top_value:.2f} < {ML_HIGH_CONFIDENCE}, trying last resort...") + + # Last resort: try ML with very low fuzzy threshold + if top_value >= ML_LAST_RESORT: # Dynamic last resort threshold + epg_with_names = [epg for epg in epg_data if epg.get("norm_name")] + matched_epg = epg_with_names[top_index] + + chan["epg_data_id"] = matched_epg["id"] + channels_to_update.append(chan) + matched_channels.append((chan['id'], chan['name'], matched_epg["tvg_id"])) + logger.info(f"Channel {chan['id']} '{chan['name']}' => LAST RESORT match EPG tvg_id={matched_epg['tvg_id']} (fuzzy={best_score}, ML-sim={top_value:.2f})") + else: + logger.info(f"Channel {chan['id']} '{chan['name']}' => even last resort ML-sim {top_value:.2f} < {ML_LAST_RESORT}, skipping") + + except Exception as e: + logger.warning(f"ML matching failed for channel {chan['id']}: {e}") + # Fall back to non-ML decision + logger.info(f"Channel {chan['id']} '{chan['name']}' => fuzzy score {best_score} below threshold, skipping") + + # Last resort: Try ML matching even with very low fuzzy scores + elif best_score >= FUZZY_LAST_RESORT_MIN and ml_available: + # Lazy load ML models for last resort attempts + if st_model is None: + st_model, util = get_sentence_transformer() + + # Lazy generate embeddings for last resort attempts + if epg_embeddings is None and st_model and any(row.get("norm_name") for row in epg_data): + try: + logger.info("Generating embeddings for EPG data using ML model (last resort lazy loading)") + epg_embeddings = st_model.encode( + [row["norm_name"] for row in epg_data if row.get("norm_name")], + convert_to_tensor=True + ) + except Exception as e: + logger.warning(f"Failed to generate embeddings for last resort: {e}") + epg_embeddings = None + + if epg_embeddings is not None and st_model: + try: + logger.info(f"Channel {chan['id']} '{chan['name']}' => trying ML as last resort (fuzzy={best_score})") + # Generate embedding for this channel + chan_embedding = st_model.encode(chan["norm_chan"], convert_to_tensor=True) + + # Calculate similarity with all EPG embeddings + sim_scores = util.cos_sim(chan_embedding, epg_embeddings)[0] + top_index = int(sim_scores.argmax()) + top_value = float(sim_scores[top_index]) + + if top_value >= ML_LAST_RESORT: # Dynamic threshold for desperate attempts + # Find the EPG entry that corresponds to this embedding index + epg_with_names = [epg for epg in epg_data if epg.get("norm_name")] + matched_epg = epg_with_names[top_index] + + chan["epg_data_id"] = matched_epg["id"] + channels_to_update.append(chan) + matched_channels.append((chan['id'], chan['name'], matched_epg["tvg_id"])) + logger.info(f"Channel {chan['id']} '{chan['name']}' => DESPERATE LAST RESORT match EPG tvg_id={matched_epg['tvg_id']} (fuzzy={best_score}, ML-sim={top_value:.2f})") + else: + logger.info(f"Channel {chan['id']} '{chan['name']}' => desperate last resort ML-sim {top_value:.2f} < {ML_LAST_RESORT}, giving up") + except Exception as e: + logger.warning(f"Last resort ML matching failed for channel {chan['id']}: {e}") + logger.info(f"Channel {chan['id']} '{chan['name']}' => best fuzzy score={best_score} < {FUZZY_MEDIUM_CONFIDENCE}, giving up") + else: + # No ML available or very low fuzzy score + logger.info(f"Channel {chan['id']} '{chan['name']}' => best fuzzy score={best_score} < {FUZZY_MEDIUM_CONFIDENCE}, no ML fallback available") + + # Clean up ML models from memory after matching (infrequent operation) + if _ml_model_cache['sentence_transformer'] is not None: + logger.info("Cleaning up ML models from memory") + _ml_model_cache['sentence_transformer'] = None + gc.collect() + + # Send final progress update + if send_progress: + send_epg_matching_progress( + total_channels, + len(matched_channels), + stage="completed" + ) + + return { + "channels_to_update": channels_to_update, + "matched_channels": matched_channels + } + +@shared_task +def match_epg_channels(): + """ + Uses integrated EPG matching instead of external script. + Provides the same functionality with better performance and maintainability. + """ + try: + logger.info("Starting integrated EPG matching...") + + # Get region preference + try: + region_obj = CoreSettings.objects.get(key="preferred-region") + region_code = region_obj.value.strip().lower() + except CoreSettings.DoesNotExist: + region_code = None + + # Get channels that don't have EPG data assigned + channels_without_epg = Channel.objects.filter(epg_data__isnull=True) + logger.info(f"Found {channels_without_epg.count()} channels without EPG data") + + channels_data = [] + for channel in channels_without_epg: + normalized_tvg_id = channel.tvg_id.strip().lower() if channel.tvg_id else "" + normalized_gracenote_id = channel.tvc_guide_stationid.strip().lower() if channel.tvc_guide_stationid else "" + channels_data.append({ + "id": channel.id, + "name": channel.name, + "tvg_id": normalized_tvg_id, + "original_tvg_id": channel.tvg_id, + "gracenote_id": normalized_gracenote_id, + "original_gracenote_id": channel.tvc_guide_stationid, + "fallback_name": normalized_tvg_id if normalized_tvg_id else channel.name, + "norm_chan": normalize_name(channel.name) # Always use channel name for fuzzy matching! + }) + + # Get all EPG data from active sources, ordered by source priority (highest first) so we prefer higher priority matches + epg_data = [] + for epg in EPGData.objects.select_related('epg_source').filter(epg_source__is_active=True): + normalized_tvg_id = epg.tvg_id.strip().lower() if epg.tvg_id else "" + epg_data.append({ + 'id': epg.id, + 'tvg_id': normalized_tvg_id, + 'original_tvg_id': epg.tvg_id, + 'name': epg.name, + 'norm_name': normalize_name(epg.name), + 'epg_source_id': epg.epg_source.id if epg.epg_source else None, + 'epg_source_priority': epg.epg_source.priority if epg.epg_source else 0, + }) + + # Sort EPG data by source priority (highest first) so we prefer higher priority matches + epg_data.sort(key=lambda x: x['epg_source_priority'], reverse=True) + + logger.info(f"Processing {len(channels_data)} channels against {len(epg_data)} EPG entries (from active sources only)") + + # Run EPG matching with progress updates - automatically uses conservative thresholds for bulk operations + result = match_channels_to_epg(channels_data, epg_data, region_code, use_ml=True, send_progress=True) + channels_to_update_dicts = result["channels_to_update"] + matched_channels = result["matched_channels"] + + # Update channels in database + if channels_to_update_dicts: + channel_ids = [d["id"] for d in channels_to_update_dicts] + channels_qs = Channel.objects.filter(id__in=channel_ids) + channels_list = list(channels_qs) + + # Create mapping from channel_id to epg_data_id + epg_mapping = {d["id"]: d["epg_data_id"] for d in channels_to_update_dicts} + + # Update each channel with matched EPG data + for channel_obj in channels_list: + epg_data_id = epg_mapping.get(channel_obj.id) + if epg_data_id: + try: + epg_data_obj = EPGData.objects.get(id=epg_data_id) + channel_obj.epg_data = epg_data_obj + except EPGData.DoesNotExist: + logger.error(f"EPG data {epg_data_id} not found for channel {channel_obj.id}") + + # Bulk update all channels + Channel.objects.bulk_update(channels_list, ["epg_data"]) + + total_matched = len(matched_channels) + if total_matched: + logger.info(f"Match Summary: {total_matched} channel(s) matched.") + for (cid, cname, tvg) in matched_channels: + logger.info(f" - Channel ID={cid}, Name='{cname}' => tvg_id='{tvg}'") + else: + logger.info("No new channels were matched.") + + logger.info("Finished integrated EPG matching.") + + # Send WebSocket update + channel_layer = get_channel_layer() + associations = [ + {"channel_id": chan["id"], "epg_data_id": chan["epg_data_id"]} + for chan in channels_to_update_dicts + ] + + async_to_sync(channel_layer.group_send)( + 'updates', + { + 'type': 'update', + "data": { + "success": True, + "type": "epg_match", + "refresh_channels": True, + "matches_count": total_matched, + "message": f"EPG matching complete: {total_matched} channel(s) matched", + "associations": associations + } + } + ) + + return f"Done. Matched {total_matched} channel(s)." + + finally: + # Clean up ML models from memory after bulk matching + if _ml_model_cache['sentence_transformer'] is not None: + logger.info("Cleaning up ML models from memory") + _ml_model_cache['sentence_transformer'] = None + + # Memory cleanup + gc.collect() + from core.utils import cleanup_memory + cleanup_memory(log_usage=True, force_collection=True) + + +@shared_task +def match_selected_channels_epg(channel_ids): + """ + Match EPG data for only the specified selected channels. + Uses the same integrated EPG matching logic but processes only selected channels. + """ + try: + logger.info(f"Starting integrated EPG matching for {len(channel_ids)} selected channels...") + + # Get region preference + try: + region_obj = CoreSettings.objects.get(key="preferred-region") + region_code = region_obj.value.strip().lower() + except CoreSettings.DoesNotExist: + region_code = None + + # Get only the specified channels that don't have EPG data assigned + channels_without_epg = Channel.objects.filter( + id__in=channel_ids, + epg_data__isnull=True + ) + logger.info(f"Found {channels_without_epg.count()} selected channels without EPG data") + + if not channels_without_epg.exists(): + logger.info("No selected channels need EPG matching.") + + # Send WebSocket update + channel_layer = get_channel_layer() + async_to_sync(channel_layer.group_send)( + 'updates', + { + 'type': 'update', + "data": { + "success": True, + "type": "epg_match", + "refresh_channels": True, + "matches_count": 0, + "message": "No selected channels need EPG matching", + "associations": [] + } + } + ) + return "No selected channels needed EPG matching." + + channels_data = [] + for channel in channels_without_epg: + normalized_tvg_id = channel.tvg_id.strip().lower() if channel.tvg_id else "" + normalized_gracenote_id = channel.tvc_guide_stationid.strip().lower() if channel.tvc_guide_stationid else "" + channels_data.append({ + "id": channel.id, + "name": channel.name, + "tvg_id": normalized_tvg_id, + "original_tvg_id": channel.tvg_id, + "gracenote_id": normalized_gracenote_id, + "original_gracenote_id": channel.tvc_guide_stationid, + "fallback_name": normalized_tvg_id if normalized_tvg_id else channel.name, + "norm_chan": normalize_name(channel.name) + }) + + # Get all EPG data from active sources, ordered by source priority (highest first) so we prefer higher priority matches + epg_data = [] + for epg in EPGData.objects.select_related('epg_source').filter(epg_source__is_active=True): + normalized_tvg_id = epg.tvg_id.strip().lower() if epg.tvg_id else "" + epg_data.append({ + 'id': epg.id, + 'tvg_id': normalized_tvg_id, + 'original_tvg_id': epg.tvg_id, + 'name': epg.name, + 'norm_name': normalize_name(epg.name), + 'epg_source_id': epg.epg_source.id if epg.epg_source else None, + 'epg_source_priority': epg.epg_source.priority if epg.epg_source else 0, + }) + + # Sort EPG data by source priority (highest first) so we prefer higher priority matches + epg_data.sort(key=lambda x: x['epg_source_priority'], reverse=True) + + logger.info(f"Processing {len(channels_data)} selected channels against {len(epg_data)} EPG entries (from active sources only)") + + # Run EPG matching with progress updates - automatically uses appropriate thresholds + result = match_channels_to_epg(channels_data, epg_data, region_code, use_ml=True, send_progress=True) + channels_to_update_dicts = result["channels_to_update"] + matched_channels = result["matched_channels"] + + # Update channels in database + if channels_to_update_dicts: + channel_ids_to_update = [d["id"] for d in channels_to_update_dicts] + channels_qs = Channel.objects.filter(id__in=channel_ids_to_update) + channels_list = list(channels_qs) + + # Create mapping from channel_id to epg_data_id + epg_mapping = {d["id"]: d["epg_data_id"] for d in channels_to_update_dicts} + + # Update each channel with matched EPG data + for channel_obj in channels_list: + epg_data_id = epg_mapping.get(channel_obj.id) + if epg_data_id: + try: + epg_data_obj = EPGData.objects.get(id=epg_data_id) + channel_obj.epg_data = epg_data_obj + except EPGData.DoesNotExist: + logger.error(f"EPG data {epg_data_id} not found for channel {channel_obj.id}") + + # Bulk update all channels + Channel.objects.bulk_update(channels_list, ["epg_data"]) + + total_matched = len(matched_channels) + if total_matched: + logger.info(f"Selected Channel Match Summary: {total_matched} channel(s) matched.") + for (cid, cname, tvg) in matched_channels: + logger.info(f" - Channel ID={cid}, Name='{cname}' => tvg_id='{tvg}'") + else: + logger.info("No selected channels were matched.") + + logger.info("Finished integrated EPG matching for selected channels.") + + # Send WebSocket update + channel_layer = get_channel_layer() + associations = [ + {"channel_id": chan["id"], "epg_data_id": chan["epg_data_id"]} + for chan in channels_to_update_dicts + ] + + async_to_sync(channel_layer.group_send)( + 'updates', + { + 'type': 'update', + "data": { + "success": True, + "type": "epg_match", + "refresh_channels": True, + "matches_count": total_matched, + "message": f"EPG matching complete: {total_matched} selected channel(s) matched", + "associations": associations + } + } + ) + + return f"Done. Matched {total_matched} selected channel(s)." + + finally: + # Clean up ML models from memory after bulk matching + if _ml_model_cache['sentence_transformer'] is not None: + logger.info("Cleaning up ML models from memory") + _ml_model_cache['sentence_transformer'] = None + + # Memory cleanup + gc.collect() + from core.utils import cleanup_memory + cleanup_memory(log_usage=True, force_collection=True) + + +@shared_task +def match_single_channel_epg(channel_id): + """ + Try to match a single channel with EPG data using the integrated matching logic + that includes both fuzzy and ML-enhanced matching. Returns a dict with match status and message. + """ + try: + from apps.channels.models import Channel + from apps.epg.models import EPGData + + logger.info(f"Starting integrated single channel EPG matching for channel ID {channel_id}") + + # Get the channel + try: + channel = Channel.objects.get(id=channel_id) + except Channel.DoesNotExist: + return {"matched": False, "message": "Channel not found"} + + # If channel already has EPG data, skip + if channel.epg_data: + return {"matched": False, "message": f"Channel '{channel.name}' already has EPG data assigned"} + + # Prepare single channel data for matching (same format as bulk matching) + normalized_tvg_id = channel.tvg_id.strip().lower() if channel.tvg_id else "" + normalized_gracenote_id = channel.tvc_guide_stationid.strip().lower() if channel.tvc_guide_stationid else "" + channel_data = { + "id": channel.id, + "name": channel.name, + "tvg_id": normalized_tvg_id, + "original_tvg_id": channel.tvg_id, + "gracenote_id": normalized_gracenote_id, + "original_gracenote_id": channel.tvc_guide_stationid, + "fallback_name": normalized_tvg_id if normalized_tvg_id else channel.name, + "norm_chan": normalize_name(channel.name) # Always use channel name for fuzzy matching! + } + + logger.info(f"Channel data prepared: name='{channel.name}', tvg_id='{normalized_tvg_id}', gracenote_id='{normalized_gracenote_id}', norm_chan='{channel_data['norm_chan']}'") + + # Debug: Test what the normalization does to preserve call signs + test_name = "NBC 11 (KVLY) - Fargo" # Example for testing + test_normalized = normalize_name(test_name) + logger.debug(f"DEBUG normalization example: '{test_name}' → '{test_normalized}' (call sign preserved)") + + # Get all EPG data for matching from active sources - must include norm_name field + # Ordered by source priority (highest first) so we prefer higher priority matches + epg_data_list = [] + for epg in EPGData.objects.select_related('epg_source').filter(epg_source__is_active=True, name__isnull=False).exclude(name=''): + normalized_epg_tvg_id = epg.tvg_id.strip().lower() if epg.tvg_id else "" + epg_data_list.append({ + 'id': epg.id, + 'tvg_id': normalized_epg_tvg_id, + 'original_tvg_id': epg.tvg_id, + 'name': epg.name, + 'norm_name': normalize_name(epg.name), + 'epg_source_id': epg.epg_source.id if epg.epg_source else None, + 'epg_source_priority': epg.epg_source.priority if epg.epg_source else 0, + }) + + # Sort EPG data by source priority (highest first) so we prefer higher priority matches + epg_data_list.sort(key=lambda x: x['epg_source_priority'], reverse=True) + + if not epg_data_list: + return {"matched": False, "message": "No EPG data available for matching (from active sources)"} + + logger.info(f"Matching single channel '{channel.name}' against {len(epg_data_list)} EPG entries") + + # Send progress for single channel matching + send_epg_matching_progress(1, 0, current_channel_name=channel.name, stage="matching") + + # Use the EPG matching function - automatically uses aggressive thresholds for single channel + result = match_channels_to_epg([channel_data], epg_data_list, send_progress=False) + channels_to_update = result.get("channels_to_update", []) + matched_channels = result.get("matched_channels", []) + + if channels_to_update: + # Find our channel in the results + channel_match = None + for update in channels_to_update: + if update["id"] == channel.id: + channel_match = update + break + + if channel_match: + # Apply the match to the channel + try: + epg_data = EPGData.objects.get(id=channel_match['epg_data_id']) + channel.epg_data = epg_data + channel.save(update_fields=["epg_data"]) + + # Find match details from matched_channels for better reporting + match_details = None + for match_info in matched_channels: + if match_info[0] == channel.id: # matched_channels format: (channel_id, channel_name, epg_info) + match_details = match_info + break + + success_msg = f"Channel '{channel.name}' matched with EPG '{epg_data.name}'" + if match_details: + success_msg += f" (matched via: {match_details[2]})" + + logger.info(success_msg) + + # Send completion progress for single channel + send_epg_matching_progress(1, 1, current_channel_name=channel.name, stage="completed") + + # Clean up ML models from memory after single channel matching + if _ml_model_cache['sentence_transformer'] is not None: + logger.info("Cleaning up ML models from memory") + _ml_model_cache['sentence_transformer'] = None + gc.collect() + + return { + "matched": True, + "message": success_msg, + "epg_name": epg_data.name, + "epg_id": epg_data.id + } + except EPGData.DoesNotExist: + return {"matched": False, "message": "Matched EPG data not found"} + + # No match found + # Send completion progress for single channel (failed) + send_epg_matching_progress(1, 0, current_channel_name=channel.name, stage="completed") + + # Clean up ML models from memory after single channel matching + if _ml_model_cache['sentence_transformer'] is not None: + logger.info("Cleaning up ML models from memory") + _ml_model_cache['sentence_transformer'] = None + gc.collect() + + return { + "matched": False, + "message": f"No suitable EPG match found for channel '{channel.name}'" + } + + except Exception as e: + logger.error(f"Error in integrated single channel EPG matching: {e}", exc_info=True) + + # Clean up ML models from memory even on error + if _ml_model_cache['sentence_transformer'] is not None: + logger.info("Cleaning up ML models from memory after error") + _ml_model_cache['sentence_transformer'] = None + gc.collect() + + return {"matched": False, "message": f"Error during matching: {str(e)}"} + + +def evaluate_series_rules_impl(tvg_id: str | None = None): + """Synchronous implementation of series rule evaluation; returns details for debugging.""" + result = {"scheduled": 0, "details": []} + + # Serialize all invocations to prevent concurrent evaluations from + # racing to create duplicate recordings (e.g. multiple EPG sources + # refreshing simultaneously each firing evaluate_series_rules.delay()). + # If Redis is unavailable, proceed without lock — the primary and + # secondary dedup guards still prevent duplicates. + lock_acquired = False + try: + lock_acquired = acquire_task_lock('evaluate_series_rules', 'all') + if not lock_acquired: + result["details"].append({"status": "skipped", "reason": "concurrent evaluation in progress"}) + return result + except (ConnectionError, OSError, AttributeError): + logger.warning("Could not acquire series rule evaluation lock (Redis unavailable), proceeding without lock") + + try: + return _evaluate_series_rules_locked(tvg_id, result) + finally: + if lock_acquired: + try: + release_task_lock('evaluate_series_rules', 'all') + except (ConnectionError, OSError, AttributeError): + logger.warning("Could not release series rule evaluation lock") + + +def _evaluate_series_rules_locked(tvg_id, result): + """Inner implementation of series rule evaluation, called under lock.""" + from django.utils import timezone + from apps.channels.models import Recording, Channel + from apps.epg.models import EPGData, ProgramData + + rules = CoreSettings.get_dvr_series_rules() + if not isinstance(rules, list) or not rules: + return result + + # Optionally filter for tvg_id + if tvg_id: + rules = [r for r in rules if str(r.get("tvg_id")) == str(tvg_id)] + if not rules: + result["details"].append({"tvg_id": tvg_id, "status": "no_rule"}) + return result + + now = timezone.now() + horizon = now + timedelta(days=7) + + # Preload existing recordings keyed by stable program attributes that + # survive EPG refreshes (tvg_id + original start/end times stored in + # custom_properties). ProgramData.id changes on every EPG refresh so + # it cannot be used for deduplication. Only load future recordings + # to bound the set size — past recordings cannot collide with newly + # scheduled future programs. + existing_program_keys = set() + for cp in Recording.objects.filter( + end_time__gte=now, + ).values_list("custom_properties", flat=True): + try: + prog_data = (cp or {}).get("program", {}) + tvg_id_val = prog_data.get("tvg_id") + st = prog_data.get("start_time") + et = prog_data.get("end_time") + if tvg_id_val and st and et: + existing_program_keys.add((str(tvg_id_val), str(st), str(et))) + except Exception: + continue + + for rule in rules: + rv_tvg = str(rule.get("tvg_id") or "").strip() + mode = (rule.get("mode") or "all").lower() + series_title = (rule.get("title") or "").strip() + norm_series = normalize_name(series_title) if series_title else None + if not rv_tvg: + result["details"].append({"tvg_id": rv_tvg, "status": "invalid_rule"}) + continue + + epg = EPGData.objects.filter(tvg_id=rv_tvg).first() + if not epg: + result["details"].append({"tvg_id": rv_tvg, "status": "no_epg_match"}) + continue + + programs_qs = ProgramData.objects.filter( + epg=epg, + end_time__gt=now, + start_time__lte=horizon, + ) + if series_title: + programs_qs = programs_qs.filter(title__iexact=series_title) + programs = list(programs_qs.order_by("start_time")) + # Fallback: if no direct matches and we have a title, try normalized comparison in Python + if series_title and not programs: + all_progs = ProgramData.objects.filter( + epg=epg, + end_time__gt=now, + start_time__lte=horizon, + ).only("id", "title", "start_time", "end_time", "custom_properties", "tvg_id") + programs = [p for p in all_progs if normalize_name(p.title) == norm_series] + + channel = Channel.objects.filter(epg_data=epg).order_by("channel_number").first() + if not channel: + result["details"].append({"tvg_id": rv_tvg, "status": "no_channel_for_epg"}) + continue + + # + # Many providers list multiple future airings of the same episode + # (e.g., prime-time and a late-night repeat). Previously we scheduled + # a recording for each airing which shows up as duplicates in the DVR. + # + # To avoid that, we collapse programs to the earliest airing per + # unique episode using the best identifier available: + # - season+episode from ProgramData.custom_properties + # - onscreen_episode (e.g., S08E03) + # - sub_title (episode name), scoped by tvg_id+series title + # If none of the above exist, we fall back to keeping each program + # (usually movies or specials without episode identifiers). + # + def _episode_key(p: "ProgramData"): + try: + props = p.custom_properties or {} + season = props.get("season") + episode = props.get("episode") + onscreen = props.get("onscreen_episode") + except Exception: + season = episode = onscreen = None + base = f"{p.tvg_id or ''}|{(p.title or '').strip().lower()}" # series scope + if season is not None and episode is not None: + return f"{base}|s{season}e{episode}" + if onscreen: + return f"{base}|{str(onscreen).strip().lower()}" + if p.sub_title: + return f"{base}|{p.sub_title.strip().lower()}" + # No reliable episode identity; use the program id to avoid over-merging + return f"id:{p.id}" + + # Optionally filter to only brand-new episodes before grouping + if mode == "new": + filtered = [] + for p in programs: + try: + if (p.custom_properties or {}).get("new"): + filtered.append(p) + except Exception: + pass + programs = filtered + + # Pick the earliest airing for each episode key + earliest_by_key = {} + for p in programs: + k = _episode_key(p) + cur = earliest_by_key.get(k) + if cur is None or p.start_time < cur.start_time: + earliest_by_key[k] = p + + unique_programs = list(earliest_by_key.values()) + + created_here = 0 + for prog in unique_programs: + try: + # Skip if a recording already exists for this exact airing + # (keyed by tvg_id + original program times, which are stable + # across EPG refreshes unlike ProgramData.id). + prog_key = (str(prog.tvg_id), prog.start_time.isoformat(), prog.end_time.isoformat()) + if prog_key in existing_program_keys: + continue + # Extra guard: DB query using the same stable attributes + # stored in custom_properties (unadjusted program times, + # not offset-adjusted Recording.start_time/end_time). + try: + if Recording.objects.filter( + custom_properties__program__tvg_id=prog.tvg_id, + custom_properties__program__start_time=prog.start_time.isoformat(), + custom_properties__program__end_time=prog.end_time.isoformat(), + ).exists(): + continue + except Exception: + continue # already scheduled/recorded + + # Apply global DVR pre/post offsets (in minutes) + 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 + + adj_start = prog.start_time + adj_end = prog.end_time + try: + if pre_min and pre_min > 0: + adj_start = adj_start - timedelta(minutes=pre_min) + except Exception: + pass + try: + if post_min and post_min > 0: + adj_end = adj_end + timedelta(minutes=post_min) + except Exception: + pass + + rec = Recording.objects.create( + channel=channel, + start_time=adj_start, + end_time=adj_end, + custom_properties={ + "program": { + "id": prog.id, + "tvg_id": prog.tvg_id, + "title": prog.title, + "sub_title": prog.sub_title, + "description": prog.description, + "start_time": prog.start_time.isoformat(), + "end_time": prog.end_time.isoformat(), + } + }, + ) + existing_program_keys.add(prog_key) + created_here += 1 + try: + prefetch_recording_artwork.apply_async(args=[rec.id], countdown=1) + except Exception: + pass + except Exception as e: + result["details"].append({"tvg_id": rv_tvg, "status": "error", "error": str(e)}) + continue + result["scheduled"] += created_here + result["details"].append({"tvg_id": rv_tvg, "title": series_title, "status": "ok", "created": created_here}) + + # Notify frontend to refresh + try: + channel_layer = get_channel_layer() + async_to_sync(channel_layer.group_send)( + 'updates', + {'type': 'update', 'data': {"success": True, "type": "recordings_refreshed", "scheduled": result["scheduled"]}}, + ) + except Exception: + pass + + return result + + +@shared_task +def evaluate_series_rules(tvg_id: str | None = None): + return evaluate_series_rules_impl(tvg_id) + + +def reschedule_upcoming_recordings_for_offset_change_impl(): + """Recalculate start/end for all future EPG-based recordings using current DVR offsets. + + Only recordings that have not yet started (start_time > now) and that were + scheduled from EPG data (custom_properties.program present) are updated. + """ + from django.utils import timezone + from django.utils.dateparse import parse_datetime + from apps.channels.models import Recording + + now = timezone.now() + + 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 + + changed = 0 + scanned = 0 + + for rec in Recording.objects.filter(start_time__gt=now).iterator(): + scanned += 1 + try: + cp = rec.custom_properties or {} + program = cp.get("program") if isinstance(cp, dict) else None + if not isinstance(program, dict): + continue + base_start = program.get("start_time") + base_end = program.get("end_time") + if not base_start or not base_end: + continue + start_dt = parse_datetime(str(base_start)) + end_dt = parse_datetime(str(base_end)) + if start_dt is None or end_dt is None: + continue + + adj_start = start_dt + adj_end = end_dt + try: + if pre_min and pre_min > 0: + adj_start = adj_start - timedelta(minutes=pre_min) + except Exception: + pass + try: + if post_min and post_min > 0: + adj_end = adj_end + timedelta(minutes=post_min) + except Exception: + pass + + if rec.start_time != adj_start or rec.end_time != adj_end: + rec.start_time = adj_start + rec.end_time = adj_end + rec.save(update_fields=["start_time", "end_time"]) + changed += 1 + except Exception: + continue + + # Notify frontend to refresh + try: + channel_layer = get_channel_layer() + async_to_sync(channel_layer.group_send)( + 'updates', + {'type': 'update', 'data': {"success": True, "type": "recordings_refreshed", "rescheduled": changed}}, + ) + except Exception: + pass + + return {"changed": changed, "scanned": scanned, "pre": pre_min, "post": post_min} + + +@shared_task +def reschedule_upcoming_recordings_for_offset_change(): + return reschedule_upcoming_recordings_for_offset_change_impl() + + +def _notify_recordings_refresh(): + try: + from core.utils import send_websocket_update + send_websocket_update('updates', 'update', {"success": True, "type": "recordings_refreshed"}) + except Exception: + pass + + +def purge_recurring_rule_impl(rule_id: int) -> int: + """Remove all future recordings created by a recurring rule.""" + from django.utils import timezone + from .models import Recording + + now = timezone.now() + try: + removed, _ = Recording.objects.filter( + start_time__gte=now, + custom_properties__rule__id=rule_id, + ).delete() + except Exception: + removed = 0 + if removed: + _notify_recordings_refresh() + return removed + + +def sync_recurring_rule_impl(rule_id: int, drop_existing: bool = True, horizon_days: int = 14) -> int: + """Ensure recordings exist for a recurring rule within the scheduling horizon.""" + from django.utils import timezone + from .models import RecurringRecordingRule, Recording + + rule = RecurringRecordingRule.objects.filter(pk=rule_id).select_related("channel").first() + now = timezone.now() + removed = 0 + if drop_existing: + removed = purge_recurring_rule_impl(rule_id) + + if not rule or not rule.enabled: + return 0 + + days = rule.cleaned_days() + if not days: + return 0 + + tz_name = CoreSettings.get_system_time_zone() + try: + tz = ZoneInfo(tz_name) + except Exception: + logger.warning("Invalid or unsupported time zone '%s'; falling back to Server default", tz_name) + tz = timezone.get_current_timezone() + local_today = now.astimezone(tz).date() + start_limit = rule.start_date or local_today + end_limit = rule.end_date + horizon = now + timedelta(days=horizon_days) + start_window = max(start_limit, local_today) + if drop_existing and end_limit: + end_window = end_limit + else: + end_window = horizon.astimezone(tz).date() + if end_limit and end_limit < end_window: + end_window = end_limit + if end_window < start_window: + return 0 + total_created = 0 + + for offset in range((end_window - start_window).days + 1): + target_date = start_window + timedelta(days=offset) + if target_date.weekday() not in days: + continue + if end_limit and target_date > end_limit: + continue + try: + start_dt = timezone.make_aware(datetime.combine(target_date, rule.start_time), tz) + end_dt = timezone.make_aware(datetime.combine(target_date, rule.end_time), tz) + except Exception: + continue + if end_dt <= start_dt: + end_dt = end_dt + timedelta(days=1) + if start_dt <= now: + continue + exists = Recording.objects.filter( + channel=rule.channel, + start_time=start_dt, + custom_properties__rule__id=rule.id, + ).exists() + if exists: + continue + description = rule.name or f"Recurring recording for {rule.channel.name}" + cp = { + "rule": { + "type": "recurring", + "id": rule.id, + "days_of_week": days, + "name": rule.name or "", + }, + "status": "scheduled", + "description": description, + "program": { + "title": rule.name or rule.channel.name, + "description": description, + "start_time": start_dt.isoformat(), + "end_time": end_dt.isoformat(), + }, + } + try: + Recording.objects.create( + channel=rule.channel, + start_time=start_dt, + end_time=end_dt, + custom_properties=cp, + ) + total_created += 1 + except Exception as err: + logger.warning(f"Failed to create recurring recording for rule {rule.id}: {err}") + + if removed or total_created: + _notify_recordings_refresh() + + return total_created + + +@shared_task +def rebuild_recurring_rule(rule_id: int, horizon_days: int = 14): + return sync_recurring_rule_impl(rule_id, drop_existing=True, horizon_days=horizon_days) + + +@shared_task +def maintain_recurring_recordings(): + from .models import RecurringRecordingRule + + total = 0 + for rule_id in RecurringRecordingRule.objects.filter(enabled=True).values_list("id", flat=True): + try: + total += sync_recurring_rule_impl(rule_id, drop_existing=False) + except Exception as err: + logger.warning(f"Recurring rule maintenance failed for {rule_id}: {err}") + return total + + +@shared_task +def purge_recurring_rule(rule_id: int): + return purge_recurring_rule_impl(rule_id) + +@shared_task +def _safe_name(s): + try: + import re + s = s or "" + # Remove forbidden filename characters and normalize spaces + s = re.sub(r'[\\/:*?"<>|]+', '', s) + s = s.strip() + return s + except Exception: + return s or "" + + +def _parse_epg_tv_movie_info(program): + """Return tuple (is_movie, season, episode, year, sub_title) from EPG ProgramData if available.""" + is_movie = False + season = None + episode = None + year = None + sub_title = program.get('sub_title') if isinstance(program, dict) else None + try: + from apps.epg.models import ProgramData + prog_id = program.get('id') if isinstance(program, dict) else None + epg_program = ProgramData.objects.filter(id=prog_id).only('custom_properties').first() if prog_id else None + if epg_program and epg_program.custom_properties: + cp = epg_program.custom_properties + # Determine categories + cats = [c.lower() for c in (cp.get('categories') or []) if isinstance(c, str)] + is_movie = 'movie' in cats or 'film' in cats + season = cp.get('season') + episode = cp.get('episode') + onscreen = cp.get('onscreen_episode') + if (season is None or episode is None) and isinstance(onscreen, str): + import re as _re + m = _re.search(r'[sS](\d+)[eE](\d+)', onscreen) + if m: + season = season or int(m.group(1)) + episode = episode or int(m.group(2)) + d = cp.get('date') + if d: + year = str(d)[:4] + except Exception: + pass + return is_movie, season, episode, year, sub_title + + +def _build_output_paths(channel, program, start_time, end_time): + """ + Build (final_path, temp_ts_path, final_filename) using DVR templates. + """ + from core.models import CoreSettings + # Root for DVR recordings: fixed to /data/recordings inside the container + library_root = '/data/recordings' + + is_movie, season, episode, year, sub_title = _parse_epg_tv_movie_info(program) + show = _safe_name(program.get('title') if isinstance(program, dict) else channel.name) + title = _safe_name(program.get('title') if isinstance(program, dict) else channel.name) + sub_title = _safe_name(sub_title) + season = int(season) if season is not None else 0 + episode = int(episode) if episode is not None else 0 + year = year or str(start_time.year) + + values = { + 'show': show, + 'title': title, + 'sub_title': sub_title, + 'season': season, + 'episode': episode, + 'year': year, + 'channel': _safe_name(channel.name), + 'start': start_time.strftime('%Y%m%d_%H%M%S'), + 'end': end_time.strftime('%Y%m%d_%H%M%S'), + } + + template = CoreSettings.get_dvr_movie_template() if is_movie else CoreSettings.get_dvr_tv_template() + # Build relative path from templates with smart fallbacks + rel_path = None + if not is_movie and (season == 0 or episode == 0): + # TV fallback template when S/E are missing + try: + tv_fb = CoreSettings.get_dvr_tv_fallback_template() + rel_path = tv_fb.format(**values) + except Exception: + # Older setting support + try: + fallback_root = CoreSettings.get_dvr_tv_fallback_dir() + except Exception: + fallback_root = "TV_Shows" + rel_path = f"{fallback_root}/{show}/{values['start']}.mkv" + if not rel_path: + try: + rel_path = template.format(**values) + except Exception: + rel_path = None + # Movie-specific fallback if formatting failed or title missing + if is_movie and not rel_path: + try: + m_fb = CoreSettings.get_dvr_movie_fallback_template() + rel_path = m_fb.format(**values) + except Exception: + rel_path = f"Movies/{values['start']}.mkv" + # As a last resort for TV + if not is_movie and not rel_path: + rel_path = f"TV_Shows/{show}/S{season:02d}E{episode:02d}.mkv" + # Keep any leading folder like 'Recordings/' from the template so users can + # structure their library under /data as desired. + if not rel_path.lower().endswith('.mkv'): + rel_path = f"{rel_path}.mkv" + + # Normalize path (strip ./) + if rel_path.startswith('./'): + rel_path = rel_path[2:] + final_path = rel_path if rel_path.startswith('/') else os.path.join(library_root, rel_path) + final_path = os.path.normpath(final_path) + + # Avoid overwriting an existing file from a different recording. + # Check BOTH .mkv and .ts — a pre-restart TS segment may exist at + # the same base name even when the MKV is a 0-byte placeholder. + base, ext = os.path.splitext(final_path) + counter = 1 + while True: + candidate_base = final_path[:-len(ext)] # strip extension + ts_candidate = candidate_base + '.ts' + try: + mkv_occupied = os.stat(final_path).st_size > 0 + except OSError: + mkv_occupied = False + try: + ts_occupied = os.stat(ts_candidate).st_size > 0 + except OSError: + ts_occupied = False + if not mkv_occupied and not ts_occupied: + break + counter += 1 + final_path = f"{base}_{counter}{ext}" + + # Ensure directory exists + os.makedirs(os.path.dirname(final_path), exist_ok=True) + + # Derive temp TS path in same directory + base_no_ext = os.path.splitext(os.path.basename(final_path))[0] + temp_ts_path = os.path.join(os.path.dirname(final_path), f"{base_no_ext}.ts") + return final_path, temp_ts_path, os.path.basename(final_path) + + +def build_dvr_candidates(): + """Build ordered list of candidate base URLs for DVR TS streaming. + + Reads environment variables to determine which URLs to try: + - DISPATCHARR_INTERNAL_TS_BASE_URL: explicit override (first priority) + - DISPATCHARR_PORT: the external port (default 9191) + - DISPATCHARR_ENV/DISPATCHARR_DEBUG/REDIS_HOST: dev-mode detection + - DISPATCHARR_INTERNAL_API_BASE: override for the docker service URL + """ + explicit = os.environ.get('DISPATCHARR_INTERNAL_TS_BASE_URL') + dispatcharr_port = os.environ.get('DISPATCHARR_PORT', '9191') + is_dev = (os.environ.get('DISPATCHARR_ENV', '').lower() == 'dev') or \ + (os.environ.get('DISPATCHARR_DEBUG', '').lower() == 'true') or \ + (os.environ.get('REDIS_HOST', 'redis') in ('localhost', '127.0.0.1')) + candidates = [] + if explicit: + candidates.append(explicit) + if is_dev: + # Debug container typically exposes API on 5656 (uwsgi internal port) + candidates.extend(['http://127.0.0.1:5656', f'http://127.0.0.1:{dispatcharr_port}']) + # Docker service name fallback — use DISPATCHARR_PORT so modular mode works with custom ports + candidates.append(os.environ.get('DISPATCHARR_INTERNAL_API_BASE', f'http://web:{dispatcharr_port}')) + # Last-resort localhost ports + candidates.extend(['http://localhost:5656', f'http://localhost:{dispatcharr_port}']) + return candidates + + +@shared_task +def run_recording(recording_id, channel_id, start_time_str, end_time_str): + """ + Execute a scheduled recording for the given channel/recording. + + Enhancements: + - Accepts recording_id so we can persist metadata back to the Recording row + - Persists basic file info (name/path) to Recording.custom_properties + - Attempts to capture stream stats from TS proxy (codec, resolution, fps, etc.) + - Attempts to capture a poster (via program.custom_properties) and store a Logo reference + """ + from .models import Recording, Logo + + # --- Idempotency guard (prevents duplicate recordings from task redelivery) --- + # Fail closed: if the DB is unreachable, abort rather than risk a duplicate + # task overwriting a valid recording. + try: + rec_check = Recording.objects.filter(id=recording_id).only("custom_properties").first() + if not rec_check: + logger.info( + f"run_recording called for recording {recording_id} but it no longer exists — skipping." + ) + return + status = (rec_check.custom_properties or {}).get("status", "") + if status in ("recording", "completed", "stopped"): + logger.warning( + f"run_recording called for recording {recording_id} but status " + f"is already '{status}' - skipping duplicate execution." + ) + return + except Exception as e: + logger.error( + f"Idempotency guard DB check failed for recording {recording_id} " + f"({type(e).__name__}: {e}) — aborting to prevent potential duplicate." + ) + return + + # --- Clean up the one-off PeriodicTask that dispatched this task --- + try: + from apps.channels.signals import revoke_task, _dvr_task_name + revoke_task(_dvr_task_name(recording_id)) + except Exception as e: + logger.debug(f"PeriodicTask cleanup failed (non-fatal): {e}") + + channel = Channel.objects.get(id=channel_id) + + start_time = datetime.fromisoformat(start_time_str) + end_time = datetime.fromisoformat(end_time_str) + + duration_seconds = int((end_time - start_time).total_seconds()) + # Build output paths from templates (refined after loading Recording cp below) + filename = None + final_path = None + temp_ts_path = None + + channel_layer = get_channel_layer() + + async_to_sync(channel_layer.group_send)( + "updates", + { + "type": "update", + "data": {"success": True, "type": "recording_started", "channel": channel.name} + }, + ) + + logger.info(f"Starting recording for channel {channel.name}") + + # Log system event for recording start + try: + from core.utils import log_system_event + log_system_event( + 'recording_start', + channel_id=channel.uuid, + channel_name=channel.name, + recording_id=recording_id + ) + except Exception as e: + logger.error(f"Could not log recording start event: {e}") + + # Try to resolve the Recording row up front + recording_obj = None + try: + recording_obj = Recording.objects.get(id=recording_id) + # If the stop endpoint already wrote "stopped" before the task started, + # honor it instead of overwriting with "recording". + _pre_cp = recording_obj.custom_properties or {} + if _pre_cp.get("status") == "stopped": + logger.info( + f"run_recording {recording_id}: 'stopped' found in DB before stream started " + f"— task exits without connecting." + ) + return + + # Prime custom_properties with file info/status + cp = recording_obj.custom_properties or {} + cp.update({ + "status": "recording", + "started_at": str(datetime.now()), + }) + # Provide a predictable playback URL for the frontend + cp["file_url"] = f"/api/channels/recordings/{recording_id}/file/" + cp["output_file_url"] = cp["file_url"] + + # Determine program info (may include id for deeper details) + program = cp.get("program") or {} + + # Enrich empty program dicts (manual recordings) from EPG time-slot data. + if isinstance(program, dict) and not program.get("user_edited") and not program.get("id") and not program.get("title"): + epg_match = _match_epg_program_by_timeslot( + channel.epg_data, recording_obj.start_time, recording_obj.end_time, + ) + if epg_match: + program.update(epg_match) + cp["program"] = program + + final_path, temp_ts_path, filename = _build_output_paths(channel, program, start_time, end_time) + cp["file_name"] = filename + cp["file_path"] = final_path + cp["_temp_file_path"] = temp_ts_path + + # Resolve poster art via the shared pipeline (EPG → VOD → TMDB/OMDb → + # TVMaze/iTunes → direct program fields → Logo table → channel logo). + poster_logo_id, poster_url = _resolve_poster_for_program( + channel.name, program, channel_logo_id=channel.logo_id, + ) + if poster_logo_id: + cp["poster_logo_id"] = poster_logo_id + if poster_url and "poster_url" not in cp: + cp["poster_url"] = poster_url + + # Ensure destination exists so it's visible immediately + try: + os.makedirs(os.path.dirname(final_path), exist_ok=True) + if not os.path.exists(final_path): + open(final_path, 'ab').close() + except Exception: + pass + + # Re-read from DB to preserve concurrent changes (e.g., artwork + # prefetch may have saved poster/rating info while resolving). + recording_obj.refresh_from_db() + fresh_cp = recording_obj.custom_properties or {} + + # If the stop endpoint set "stopped" while resolving, honor it. + if fresh_cp.get("status") == "stopped": + logger.info( + f"run_recording {recording_id}: 'stopped' found after metadata " + f"prep — task exits without streaming." + ) + return + + # Merge only the keys explicitly set into the fresh copy + for key in ("status", "started_at", "file_url", "output_file_url", + "file_name", "file_path", "_temp_file_path", + "program", "poster_logo_id", "poster_url"): + if key in cp: + fresh_cp[key] = cp[key] + recording_obj.custom_properties = fresh_cp + recording_obj.save(update_fields=["custom_properties"]) + + # Notify frontends so the tile picks up poster/metadata immediately + try: + from core.utils import send_websocket_update + send_websocket_update('updates', 'update', { + "success": True, + "type": "recording_updated", + "recording_id": recording_id, + }) + except Exception: + pass + except Exception as e: + logger.debug(f"Unable to prime Recording metadata: {e}") + interrupted = False + interrupted_reason = None + bytes_written = 0 + + from requests.exceptions import ReadTimeout, ConnectionError as ReqConnectionError, ChunkedEncodingError + + candidates = build_dvr_candidates() + + chosen_base = None + last_error = None + bytes_written = 0 + interrupted = False + interrupted_reason = None + + def _check_recording_cancelled(rid): + """Check if a recording was stopped by user or deleted. + + Returns (should_exit, is_interrupted, reason) where should_exit + indicates the stream loop must terminate. + """ + try: + rec = Recording.objects.filter(id=rid).only("custom_properties").first() + if rec is None: + return True, True, "recording_deleted" + if (rec.custom_properties or {}).get("status") == "stopped": + return True, False, "stopped_by_user" + except Exception: + pass + return False, False, None + + # --- Retry / reconnection constants --- + # Stream reconnection: retry the same TS proxy base on transient + # connectivity loss. Counter resets when data resumes. + _dvr_max_reconnects = 5 + _dvr_reconnect_delay = 2.0 # seconds + # DB save retry: exponential backoff (1s, 2s, 4s) for transient errors. + _dvr_db_max_retries = 3 + _dvr_db_retry_interval = 1 # seconds (base for exponential backoff) + # FFmpeg remux retry: covers transient I/O errors. + _dvr_remux_max_retries = 2 + _dvr_remux_retry_interval = 2 # seconds (base for exponential backoff) + + for base in candidates: + test_url = f"{base.rstrip('/')}/proxy/ts/stream/{channel.uuid}" + logger.info(f"DVR recording {recording_id}: trying TS base {base}") + + _reconnects = 0 + _file_mode = 'wb' + _stream_started_at = None + _done = False + + while True: # Reconnection loop for this base + try: + with requests.get( + test_url, + headers={ + 'User-Agent': f'Dispatcharr-DVR/recording-{recording_id}', + }, + stream=True, + timeout=(10, 15), + ) as response: + response.raise_for_status() + + _test_window = 3.0 + _window_start = time.time() + _stop_poll_interval = 2.0 + _last_stop_poll = time.time() + + with open(temp_ts_path, _file_mode) as file: + if _stream_started_at is None: + _stream_started_at = time.time() + + for chunk in response.iter_content(chunk_size=8192): + if not chunk: + if not chosen_base and (time.time() - _window_start) > _test_window: + break + continue + + if not chosen_base: + chosen_base = base + + # Data received after reconnect — connection restored + if _reconnects > 0: + logger.info( + f"DVR recording {recording_id}: " + f"stream resumed after reconnect" + ) + _reconnects = 0 + + file.write(chunk) + bytes_written += len(chunk) + + elapsed = time.time() - _stream_started_at + if elapsed > duration_seconds: + break + + # Periodic DB poll: stop, delete, end_time extension + _now = time.time() + if _now - _last_stop_poll >= _stop_poll_interval: + _last_stop_poll = _now + try: + _sc = Recording.objects.filter( + id=recording_id + ).only("custom_properties", "end_time").first() + if _sc is None: + logger.info( + f"DVR recording {recording_id}: " + f"deleted — exiting stream loop" + ) + interrupted = False + break + if (_sc.custom_properties or {}).get("status") == "stopped": + logger.info( + f"DVR recording {recording_id}: " + f"stop requested — exiting stream loop" + ) + break + try: + new_end = _sc.end_time + if new_end is not None: + from django.utils import timezone as _tz + if _tz.is_naive(new_end): + new_end = _tz.make_aware(new_end) + _ref = start_time + if _tz.is_naive(_ref): + _ref = _tz.make_aware(_ref) + new_duration = int( + (new_end - _ref).total_seconds() + ) + if new_duration > duration_seconds: + logger.info( + f"DVR recording {recording_id}: " + f"end_time extended to {new_end}, " + f"new duration {new_duration}s" + ) + duration_seconds = new_duration + except Exception: + pass + except Exception: + pass + + # iter_content exhausted or loop exited normally + if bytes_written > 0: + logger.info( + f"DVR recording {recording_id}: " + f"stream complete, {bytes_written} bytes written" + ) + _done = True + else: + last_error = f"no_data_from_{base}" + logger.warning( + f"DVR recording {recording_id}: no data from " + f"{base} within {_test_window}s, trying next base" + ) + try: + if os.path.exists(temp_ts_path) and os.path.getsize(temp_ts_path) == 0: + os.remove(temp_ts_path) + except FileNotFoundError: + pass + break # Exit reconnection loop + + except (ReadTimeout, ReqConnectionError, ChunkedEncodingError) as e: + if bytes_written > 0: + # Active stream lost — check cancellation before reconnecting + should_exit, is_int, reason = _check_recording_cancelled(recording_id) + if should_exit: + interrupted = is_int + interrupted_reason = reason + if reason == "stopped_by_user": + logger.info( + f"DVR recording {recording_id}: " + f"stopped by user — ending stream" + ) + _done = True + break + + _reconnects += 1 + if _reconnects <= _dvr_max_reconnects: + logger.warning( + f"DVR recording {recording_id}: connection lost " + f"({type(e).__name__}), reconnecting " + f"({_reconnects}/{_dvr_max_reconnects}) " + f"in {_dvr_reconnect_delay}s..." + ) + time.sleep(_dvr_reconnect_delay) + _file_mode = 'ab' + continue + + logger.error( + f"DVR recording {recording_id}: max reconnects " + f"({_dvr_max_reconnects}) exceeded — ending recording" + ) + interrupted = True + interrupted_reason = ( + f"stream_interrupted: max reconnects exceeded ({e})" + ) + _done = True + break + + # No data received yet — retry same base before moving on + should_exit, is_int, reason = _check_recording_cancelled(recording_id) + if should_exit: + interrupted = is_int + interrupted_reason = reason + _done = True + break + _reconnects += 1 + if _reconnects <= _dvr_max_reconnects: + logger.warning( + f"DVR recording {recording_id}: initial connection " + f"to {base} failed ({type(e).__name__}), retrying " + f"({_reconnects}/{_dvr_max_reconnects}) " + f"in {_dvr_reconnect_delay}s..." + ) + time.sleep(_dvr_reconnect_delay) + continue + last_error = str(e) + logger.warning( + f"DVR recording {recording_id}: base {base} exhausted " + f"retries ({_dvr_max_reconnects}): {e}" + ) + break + + except Exception as e: + last_error = str(e) + logger.warning(f"DVR recording {recording_id}: base {base} failed: {e}") + if bytes_written > 0: + should_exit, is_int, reason = _check_recording_cancelled(recording_id) + if should_exit and reason == "stopped_by_user": + interrupted = False + logger.info( + f"DVR recording {recording_id}: " + f"stopped by user — ending stream" + ) + else: + interrupted = True + interrupted_reason = f"stream_interrupted: {e}" + _done = True + break + should_exit, is_int, reason = _check_recording_cancelled(recording_id) + if should_exit: + interrupted = is_int + interrupted_reason = reason + _done = True + break + + if _done: + break + + if chosen_base is None and bytes_written == 0: + interrupted = True + interrupted_reason = f"no_stream_data: {last_error or 'all_bases_failed'}" + + # If no bytes were written at all, check whether this was a deliberate stop or a + # genuine failure. The exception handler above already sets interrupted=False when + # it detects "stopped" status, but do not override that decision here. + if bytes_written == 0 and not interrupted: + _deliberately_stopped = False + try: + _rc = Recording.objects.filter(id=recording_id).only("custom_properties").first() + if _rc and (_rc.custom_properties or {}).get("status") == "stopped": + _deliberately_stopped = True + except Exception: + pass + + if not _deliberately_stopped: + interrupted = True + interrupted_reason = f"no_stream_data: {last_error or 'unknown'}" + + # Update DB status immediately so the UI reflects the change on the event below + try: + if recording_obj is None: + recording_obj = Recording.objects.get(id=recording_id) + cp_now = recording_obj.custom_properties or {} + cp_now.update({ + "status": "interrupted", + "ended_at": str(datetime.now()), + "file_name": filename or cp_now.get("file_name"), + "file_path": final_path or cp_now.get("file_path"), + "interrupted_reason": interrupted_reason, + }) + recording_obj.custom_properties = cp_now + recording_obj.save(update_fields=["custom_properties"]) + except Exception as e: + logger.debug(f"Failed to update immediate recording status: {e}") + + async_to_sync(channel_layer.group_send)( + "updates", + { + "type": "update", + "data": {"success": True, "type": "recording_ended", "channel": channel.name} + }, + ) + # After the loop, the file and response are closed automatically. + logger.info(f"Finished recording for channel {channel.name}") + + # Log system event for recording end + try: + from core.utils import log_system_event + log_system_event( + 'recording_end', + channel_id=channel.uuid, + channel_name=channel.name, + recording_id=recording_id, + interrupted=interrupted, + bytes_written=bytes_written + ) + except Exception as e: + logger.error(f"Could not log recording end event: {e}") + + # If the Recording was deleted (cancelled by user), skip post-processing + recording_cancelled = not Recording.objects.filter(id=recording_id).exists() + if recording_cancelled: + logger.info(f"Recording {recording_id} was cancelled — skipping remux and metadata.") + # Clean up all artifacts for the cancelled recording, + # including any pre-restart .ts segments from server recovery. + # Use the in-memory recording_obj since the DB row is already deleted. + _cancel_cleanup = [temp_ts_path, final_path] + _cancel_cp = (recording_obj.custom_properties or {}) if recording_obj else {} + _cancel_cleanup.extend(_cancel_cp.get("_pre_restart_ts_paths", [])) + for _cleanup_path in _cancel_cleanup: + if not _cleanup_path: + continue + try: + os.remove(_cleanup_path) + logger.info(f"Cleaned up cancelled recording artifact: {_cleanup_path}") + except FileNotFoundError: + pass + except Exception: + pass + return + + # Concatenate pre-restart .ts segments with the current segment. + # Instead of creating an intermediate combined.ts and then remuxing to + # MKV (which loses timestamp boundary info and causes playback freezes + # at the splice point), go directly from the concat list → MKV. + # This lets ffmpeg's MKV muxer see each segment boundary and write + # correct cue points / clusters for seamless seeking. + _concat_did_remux = False + try: + _rec_obj_for_concat = Recording.objects.filter(id=recording_id).only("custom_properties").first() + _concat_cp = (_rec_obj_for_concat.custom_properties or {}) if _rec_obj_for_concat else {} + pre_restart_segments = _concat_cp.get("_pre_restart_ts_paths", []) + # Filter to segments that still exist on disk and have data + def _has_data(p): + try: + return os.stat(p).st_size > 0 + except OSError: + return False + pre_restart_segments = [p for p in pre_restart_segments if p and _has_data(p)] + if pre_restart_segments and temp_ts_path and os.path.exists(temp_ts_path): + all_segments = pre_restart_segments + [temp_ts_path] + concat_list_path = temp_ts_path + ".concat.txt" + try: + with open(concat_list_path, "w") as cl: + for seg in all_segments: + cl.write(f"file '{seg}'\n") + + # Direct concat → MKV in a single pass. + # -reset_timestamps 1 tells the concat demuxer to reset + # timestamps at each segment boundary, eliminating the + # discontinuity that causes playback to freeze at the + # splice point. + concat_result = subprocess.run( + [ + "ffmpeg", "-y", + "-fflags", "+genpts+igndts+discardcorrupt", + "-err_detect", "ignore_err", + "-f", "concat", "-safe", "0", + "-segment_time_metadata", "1", + "-i", concat_list_path, + "-reset_timestamps", "1", + "-map", "0", + "-c", "copy", + final_path, + ], + stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, + ) + if concat_result.returncode == 0 and os.path.exists(final_path) and os.path.getsize(final_path) > 0: + _concat_did_remux = True + # Clean up individual TS segments (including current) + for seg in all_segments: + try: + os.remove(seg) + except OSError: + pass + logger.info( + f"DVR recording {recording_id}: concat→MKV succeeded — " + f"{len(all_segments)} segments → {os.path.basename(final_path)} " + f"({os.path.getsize(final_path):,} bytes)" + ) + else: + logger.warning( + f"DVR recording {recording_id}: direct concat→MKV failed " + f"(rc={concat_result.returncode}), falling back to " + f"normal remux with current segment only. " + f"stderr: {(concat_result.stderr or '')[:500]}" + ) + finally: + try: + os.remove(concat_list_path) + except OSError: + pass + # Clear the pre-restart paths from custom_properties + if _rec_obj_for_concat: + _ccp = _rec_obj_for_concat.custom_properties or {} + _ccp.pop("_pre_restart_ts_paths", None) + _ccp.pop("interrupted_reason", None) + _rec_obj_for_concat.custom_properties = _ccp + _rec_obj_for_concat.save(update_fields=["custom_properties"]) + except Exception as e: + logger.warning( + f"DVR recording {recording_id}: segment concatenation error " + f"({type(e).__name__}: {e}), proceeding with current segment only." + ) + + # Remux TS to MKV container with retry for transient I/O errors + # (Skip if concat already produced the final MKV directly.) + remux_success = _concat_did_remux + existing_mkv_size = 0 + try: + if final_path and os.path.exists(final_path): + existing_mkv_size = os.path.getsize(final_path) + except OSError: + pass + for _remux_attempt in range(_dvr_remux_max_retries): + if remux_success: + break + try: + if temp_ts_path and os.path.exists(temp_ts_path): + # First attempt: Direct TS to MKV remux + result = subprocess.run([ + "ffmpeg", "-y", + "-fflags", "+genpts+igndts+discardcorrupt", # Regenerate timestamps, ignore DTS + "-err_detect", "ignore_err", # Ignore minor stream errors + "-i", temp_ts_path, + "-map", "0", # Map all streams + "-c", "copy", + final_path + ], stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True) + + # Check if FFmpeg succeeded (return code 0) and output file is valid + if result.returncode == 0 and os.path.exists(final_path) and os.path.getsize(final_path) > 0: + remux_success = True + logger.info(f"Direct TS→MKV remux succeeded for {os.path.basename(final_path)}") + else: + # Direct remux failed - try fallback: TS → MP4 → MKV to fix timestamps + logger.warning(f"Direct TS→MKV remux failed (return code: {result.returncode}), trying fallback TS→MP4→MKV") + + # Clean up partial/failed MKV + try: + if os.path.exists(final_path): + os.remove(final_path) + except Exception: + pass + + # Step 1: TS → MP4 (MP4 container handles broken timestamps better) + temp_mp4_path = os.path.splitext(temp_ts_path)[0] + ".mp4" + result_mp4 = subprocess.run([ + "ffmpeg", "-y", + "-fflags", "+genpts+igndts+discardcorrupt", + "-err_detect", "ignore_err", + "-i", temp_ts_path, + "-map", "0", + "-c", "copy", + temp_mp4_path + ], stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True) + + if result_mp4.returncode == 0 and os.path.exists(temp_mp4_path) and os.path.getsize(temp_mp4_path) > 0: + logger.info(f"TS→MP4 conversion succeeded, now converting MP4→MKV") + + # Step 2: MP4 → MKV (clean timestamps from MP4) + result_mkv = subprocess.run([ + "ffmpeg", "-y", + "-i", temp_mp4_path, + "-map", "0", + "-c", "copy", + final_path + ], stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True) + + if result_mkv.returncode == 0 and os.path.exists(final_path) and os.path.getsize(final_path) > 0: + remux_success = True + logger.info(f"Fallback TS→MP4→MKV remux succeeded for {os.path.basename(final_path)}") + else: + logger.error(f"MP4→MKV conversion failed (return code: {result_mkv.returncode})") + + # Clean up temp MP4 + try: + if os.path.exists(temp_mp4_path): + os.remove(temp_mp4_path) + except Exception: + pass + else: + logger.error(f"TS→MP4 conversion failed (return code: {result_mp4.returncode})") + + # Sanity-check the remuxed file. Two checks: + # 1. If a pre-existing MKV was overwritten, reject a + # file that is drastically smaller (duplicate-task + # overwrite protection). + # 2. If the MKV is smaller than the .ts source, the + # remux likely produced a corrupt or truncated file. + if remux_success: + try: + new_size = os.path.getsize(final_path) + ts_size = os.path.getsize(temp_ts_path) if temp_ts_path and os.path.exists(temp_ts_path) else 0 + reject = False + if existing_mkv_size > 0 and new_size < existing_mkv_size * 0.5: + logger.error( + f"DVR recording {recording_id}: new MKV " + f"({new_size:,} bytes) is less than 50%% of " + f"the previous MKV ({existing_mkv_size:,} bytes) " + f"— refusing to overwrite. Keeping .ts for " + f"manual recovery." + ) + reject = True + elif ts_size > 0 and new_size < ts_size * 0.1: + logger.error( + f"DVR recording {recording_id}: remuxed MKV " + f"({new_size:,} bytes) is less than 10%% of " + f"the source TS ({ts_size:,} bytes) — likely " + f"corrupt. Keeping .ts for manual recovery." + ) + reject = True + if reject: + remux_success = False + try: + os.remove(final_path) + except OSError: + pass + except OSError: + pass + + # Clean up temp TS file only on successful remux + if remux_success: + try: + os.remove(temp_ts_path) + logger.debug(f"Cleaned up temp TS file: {temp_ts_path}") + except Exception as e: + logger.warning(f"Failed to remove temp TS file: {e}") + else: + # Keep TS file for debugging/manual recovery if remux failed + logger.warning(f"Remux failed - keeping temp TS file for recovery: {temp_ts_path}") + # Clean up any partial MKV + try: + if os.path.exists(final_path): + os.remove(final_path) + logger.debug(f"Cleaned up partial MKV file: {final_path}") + except Exception: + pass + break # Completed (success or deterministic failure) + + except (OSError, subprocess.SubprocessError) as e: + # Clean up partial output before potential retry + try: + if os.path.exists(final_path): + os.remove(final_path) + except Exception: + pass + if _remux_attempt + 1 < _dvr_remux_max_retries: + _wait = _dvr_remux_retry_interval * (2 ** _remux_attempt) + logger.warning( + f"DVR recording {recording_id}: remux failed " + f"({type(e).__name__}), retrying in {_wait}s " + f"({_remux_attempt + 1}/{_dvr_remux_max_retries})..." + ) + time.sleep(_wait) + else: + logger.warning( + f"DVR recording {recording_id}: remux failed " + f"after {_dvr_remux_max_retries} attempts: {e}. " + f"Keeping .ts for manual recovery: {temp_ts_path}" + ) + + # Persist final metadata to Recording (status, ended_at, and stream stats if available) + try: + if recording_obj is None: + recording_obj = Recording.objects.get(id=recording_id) + + # Re-read from DB to get the latest status (stop endpoint may have set it) + recording_obj.refresh_from_db() + cp = recording_obj.custom_properties or {} + cp["ended_at"] = str(datetime.now()) + + # Final status priority: stopped > completed > interrupted. + # "stopped" is set by the stop endpoint before stream teardown, so + # refresh_from_db() above guarantees it is visible here. + db_status_now = cp.get("status", "") + if db_status_now == "stopped": + # Deliberate user stop — preserve; do not overwrite with "completed". + cp.pop("interrupted_reason", None) + elif not interrupted: + cp["status"] = "completed" + cp.pop("interrupted_reason", None) + else: + cp["status"] = "interrupted" + if interrupted_reason: + cp["interrupted_reason"] = interrupted_reason + cp["bytes_written"] = bytes_written + cp["remux_success"] = remux_success + + # Try to get stream stats from TS proxy Redis metadata + try: + from core.utils import RedisClient + from apps.proxy.ts_proxy.redis_keys import RedisKeys + from apps.proxy.ts_proxy.constants import ChannelMetadataField + + r = RedisClient.get_client() + if r is not None: + metadata_key = RedisKeys.channel_metadata(str(channel.uuid)) + md = r.hgetall(metadata_key) + if md: + def _d(bkey, cast=str): + v = md.get(bkey) + try: + if v is None: + return None + s = v + return cast(s) if cast is not str else s + except Exception: + return None + + stream_info = {} + # Video fields + for key, caster in [ + (ChannelMetadataField.VIDEO_CODEC, str), + (ChannelMetadataField.RESOLUTION, str), + (ChannelMetadataField.WIDTH, float), + (ChannelMetadataField.HEIGHT, float), + (ChannelMetadataField.SOURCE_FPS, float), + (ChannelMetadataField.PIXEL_FORMAT, str), + (ChannelMetadataField.VIDEO_BITRATE, float), + ]: + val = _d(key, caster) + if val is not None: + stream_info[key] = val + + # Audio fields + for key, caster in [ + (ChannelMetadataField.AUDIO_CODEC, str), + (ChannelMetadataField.SAMPLE_RATE, float), + (ChannelMetadataField.AUDIO_CHANNELS, str), + (ChannelMetadataField.AUDIO_BITRATE, float), + ]: + val = _d(key, caster) + if val is not None: + stream_info[key] = val + + if stream_info: + cp["stream_info"] = stream_info + except Exception as e: + logger.debug(f"Unable to capture stream stats for recording: {e}") + + # Removed: local thumbnail generation. We rely on EPG/VOD/TMDB/OMDb/keyless providers only. + + # Final cancellation guard: destroy() may have deleted the record while + # remuxing. If it's gone now, skip saving "interrupted" status and + # skip the notification — destroy() already sent recording_cancelled. + if not Recording.objects.filter(id=recording_id).exists(): + logger.info( + f"Recording {recording_id} was deleted during post-processing — skipping final save." + ) + return + + def _save_final_metadata(): + recording_obj.custom_properties = cp + recording_obj.save(update_fields=["custom_properties"]) + + _db_retry( + _save_final_metadata, + max_retries=_dvr_db_max_retries, + base_interval=_dvr_db_retry_interval, + label=f"DVR recording {recording_id}: metadata save", + ) + + # Notify frontends so the UI refreshes immediately (e.g. "Stopped" → "Completed") + try: + async_to_sync(channel_layer.group_send)( + "updates", + { + "type": "update", + "data": {"success": True, "type": "recording_ended", "channel": channel.name}, + }, + ) + except Exception: + pass + except Exception as e: + logger.debug(f"Unable to finalize Recording metadata: {e}") + + # Optionally run comskip post-process + try: + from core.models import CoreSettings + if CoreSettings.get_dvr_comskip_enabled(): + comskip_process_recording.delay(recording_id) + except Exception: + pass + + +@shared_task +def recover_recordings_on_startup(): + """ + On service startup, reschedule or resume recordings to handle server restarts. + - For recordings whose window includes 'now': mark interrupted and start a new recording for the remainder. + - For future recordings: ensure a task is scheduled at start_time. + Uses a Redis lock to ensure only one worker runs this recovery. + """ + try: + from django.utils import timezone + from .models import Recording + from core.utils import RedisClient + from .signals import schedule_recording_task + + redis = RedisClient.get_client() + if redis: + lock_key = "dvr:recover_lock" + # Set lock with 10-minute TTL; must be long enough for Phase 2 + # ffmpeg remux operations on large files. + if not redis.set(lock_key, "1", ex=600, nx=True): + return "Recovery already in progress" + + now = timezone.now() + + # Resume in-window recordings. DB queries and saves use _db_retry + # to tolerate transient connection errors common during startup. + active = _db_retry( + lambda: list(Recording.objects.filter( + start_time__lte=now, end_time__gt=now + )), + label="DVR recovery: fetching active recordings", + ) + for rec in active: + try: + cp = rec.custom_properties or {} + current_status = cp.get("status", "") + + # Skip recordings that are already in a terminal state. + # "completed" / "stopped" — user stopped or it finished normally; do NOT + # overwrite the status and re-schedule (that would cause the + # Interrupted → In-Progress → Previously-Recorded ghost cycle). + # NOTE: "recording" is NOT skipped — this function runs on + # worker_ready, meaning all previous workers are dead. A + # recording stuck in "recording" status is from a crashed + # worker and must be recovered. + if current_status in ("completed", "stopped"): + logger.info( + f"recover_recordings_on_startup: skipping recording {rec.id} " + f"(status={current_status!r}, already in terminal/active state)." + ) + continue + + # Mark interrupted due to restart; will flip to 'recording' when task starts + cp["status"] = "interrupted" + cp["interrupted_reason"] = "server_restarted" + + # Preserve the pre-restart .ts segment path so run_recording + # can concatenate it with the resumed segment later. + old_ts = cp.get("_temp_file_path") + if old_ts and os.path.exists(old_ts) and os.path.getsize(old_ts) > 0: + prior_segments = cp.get("_pre_restart_ts_paths", []) + prior_segments.append(old_ts) + cp["_pre_restart_ts_paths"] = prior_segments + logger.info( + f"recover_recordings_on_startup: recording {rec.id} — " + f"preserving pre-restart TS segment: {old_ts}" + ) + + rec.custom_properties = cp + _db_retry( + lambda r=rec: r.save(update_fields=["custom_properties"]), + label=f"DVR recovery: recording {rec.id} status update", + ) + + # Revoke the old PeriodicTask so Celery Beat doesn't also + # fire run_recording for this recording (would be a duplicate). + old_task_id = rec.task_id + if old_task_id: + try: + revoke_task(old_task_id) + except Exception: + pass + + # Start recording for remaining window. Use a deterministic + # task_id so duplicate dispatches (e.g. from a second recovery + # attempt) are deduplicated by Celery/Redis. + recovery_task_id = f"dvr-recover-{rec.id}" + run_recording.apply_async( + args=[rec.id, rec.channel_id, str(now), str(rec.end_time)], + eta=now, + task_id=recovery_task_id, + ) + except Exception as e: + logger.warning(f"Failed to resume recording {rec.id}: {e}") + + # Finalize expired recordings that were active when the server crashed + # but whose end_time has now passed. Remux the partial .ts and mark + # as interrupted so the user can watch whatever was captured. + expired = _db_retry( + lambda: list(Recording.objects.filter( + end_time__lte=now, + custom_properties__status="recording", + )), + label="DVR recovery: fetching expired recordings", + ) + for rec in expired: + try: + cp = rec.custom_properties or {} + ts_path = cp.get("_temp_file_path") + mkv_path = cp.get("file_path") + + if ts_path and os.path.exists(ts_path) and os.path.getsize(ts_path) > 0 and mkv_path: + logger.info( + f"recover_recordings_on_startup: recording {rec.id} expired " + f"during downtime — remuxing partial TS ({os.path.getsize(ts_path):,} bytes)" + ) + os.makedirs(os.path.dirname(mkv_path), exist_ok=True) + result = subprocess.run( + [ + "ffmpeg", "-y", + "-fflags", "+genpts+igndts+discardcorrupt", + "-err_detect", "ignore_err", + "-i", ts_path, "-map", "0", "-c", "copy", mkv_path, + ], + stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, + ) + if result.returncode == 0 and os.path.exists(mkv_path) and os.path.getsize(mkv_path) > 0: + cp["status"] = "interrupted" + cp["interrupted_reason"] = "server_restarted_after_end" + cp["remux_success"] = True + try: + os.remove(ts_path) + except OSError: + pass + logger.info(f"recover_recordings_on_startup: recording {rec.id} remuxed successfully") + else: + cp["status"] = "interrupted" + cp["interrupted_reason"] = "server_restarted_after_end" + cp["remux_success"] = False + logger.warning(f"recover_recordings_on_startup: recording {rec.id} remux failed, keeping .ts") + else: + cp["status"] = "interrupted" + cp["interrupted_reason"] = "server_restarted_after_end" + cp["remux_success"] = False + + rec.custom_properties = cp + _db_retry( + lambda r=rec: r.save(update_fields=["custom_properties"]), + label=f"DVR recovery: recording {rec.id} expired status update", + ) + except Exception as e: + logger.warning(f"Failed to finalize expired recording {rec.id}: {e}") + + # Ensure future recordings are scheduled. + # With ClockedSchedule, PeriodicTasks survive restarts in the DB. + # Only recreate if the PeriodicTask is missing (safety net). + from django_celery_beat.models import PeriodicTask as _PT + from apps.channels.signals import _dvr_task_name + + upcoming = _db_retry( + lambda: list(Recording.objects.filter( + start_time__gt=now, end_time__gt=now + )), + label="DVR recovery: fetching upcoming recordings", + ) + + # Batch-fetch existing PeriodicTask names to avoid N+1 queries + task_names = {_dvr_task_name(r.id) for r in upcoming} + existing_tasks = set(_db_retry( + lambda: list(_PT.objects.filter(name__in=task_names).values_list("name", flat=True)), + label="DVR recovery: fetching existing periodic tasks", + )) if task_names else set() + + for rec in upcoming: + try: + task_name = _dvr_task_name(rec.id) + if task_name in existing_tasks: + if rec.task_id != task_name: + rec.task_id = task_name + _db_retry( + lambda r=rec: r.save(update_fields=["task_id"]), + label=f"DVR recovery: recording {rec.id} task_id update", + ) + continue + # PeriodicTask missing - recreate it + task_id = schedule_recording_task(rec) + if task_id: + rec.task_id = task_id + _db_retry( + lambda r=rec: r.save(update_fields=["task_id"]), + label=f"DVR recovery: recording {rec.id} task_id update", + ) + except Exception as e: + logger.warning(f"Failed to schedule recording {rec.id}: {e}") + + # Release the lock early so a subsequent restart can recover + # immediately. The 10-minute TTL is only a safety net in case + # recovery itself crashes before reaching this point. + if redis: + redis.delete(lock_key) + + return "Recovery complete" + except Exception as e: + logger.error(f"Error during DVR recovery: {e}") + return f"Error: {e}" + +@shared_task +def comskip_process_recording(recording_id: int): + """Run comskip on the MKV to remove commercials and replace the file in place. + Safe to call even if comskip is not installed; stores status in custom_properties.comskip. + """ + import shutil + from django.db import DatabaseError + from .models import Recording + # Helper to broadcast status over websocket + def _ws(status: str, extra: dict | None = None): + try: + from core.utils import send_websocket_update + payload = {"success": True, "type": "comskip_status", "status": status, "recording_id": recording_id} + if extra: + payload.update(extra) + send_websocket_update('updates', 'update', payload) + except Exception: + pass + + try: + rec = Recording.objects.get(id=recording_id) + except Recording.DoesNotExist: + return "not_found" + + cp = rec.custom_properties.copy() if isinstance(rec.custom_properties, dict) else {} + + def _persist_custom_properties(): + """Persist updated custom_properties without raising if the row disappeared.""" + try: + updated = Recording.objects.filter(pk=recording_id).update(custom_properties=cp) + if not updated: + logger.warning( + "Recording %s vanished before comskip status could be saved", + recording_id, + ) + return False + except DatabaseError as db_err: + logger.warning( + "Failed to persist comskip status for recording %s: %s", + recording_id, + db_err, + ) + return False + except Exception as unexpected: + logger.warning( + "Unexpected error while saving comskip status for recording %s: %s", + recording_id, + unexpected, + ) + return False + return True + file_path = (cp or {}).get("file_path") + if not file_path or not os.path.exists(file_path): + return "no_file" + + if isinstance(cp.get("comskip"), dict) and cp["comskip"].get("status") == "completed": + return "already_processed" + + comskip_bin = shutil.which("comskip") + if not comskip_bin: + cp["comskip"] = {"status": "skipped", "reason": "comskip_not_installed"} + _persist_custom_properties() + _ws('skipped', {"reason": "comskip_not_installed"}) + return "comskip_missing" + + base, _ = os.path.splitext(file_path) + edl_path = f"{base}.edl" + + # Notify start + _ws('started', {"title": (cp.get('program') or {}).get('title') or os.path.basename(file_path)}) + + try: + cmd = [comskip_bin, "--output", os.path.dirname(file_path)] + # Prefer user-specified INI, fall back to known defaults + ini_candidates = [] + try: + custom_ini = CoreSettings.get_dvr_comskip_custom_path() + if custom_ini: + ini_candidates.append(custom_ini) + except Exception as ini_err: + logger.debug(f"Unable to load custom comskip.ini path: {ini_err}") + ini_candidates.extend(["/etc/comskip/comskip.ini", "/app/docker/comskip.ini"]) + selected_ini = None + for ini_path in ini_candidates: + if ini_path and os.path.exists(ini_path): + selected_ini = ini_path + cmd.extend([f"--ini={ini_path}"]) + break + cmd.append(file_path) + result = subprocess.run( + cmd, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + ) + # comskip exit codes: 0 = commercials found, 1 = no commercials detected. + # Negative codes indicate killed by signal; anything else is a real error. + if result.returncode == 1: + # No commercials detected — not an error. + cp["comskip"] = {"status": "completed", "skipped": True} + if selected_ini: + cp["comskip"]["ini_path"] = selected_ini + _persist_custom_properties() + _ws('skipped', {"reason": "no_commercials_detected"}) + return "no_commercials" + elif result.returncode != 0: + stderr_tail = (result.stderr or "").strip().splitlines() + stderr_tail = stderr_tail[-5:] if stderr_tail else [] + detail = { + "status": "error", + "reason": "comskip_failed", + "returncode": result.returncode, + } + if result.returncode < 0: + try: + detail["signal"] = signal.Signals(-result.returncode).name + except Exception: + detail["signal"] = f"signal_{-result.returncode}" + if stderr_tail: + detail["stderr"] = "\n".join(stderr_tail) + if selected_ini: + detail["ini_path"] = selected_ini + cp["comskip"] = detail + _persist_custom_properties() + _ws('error', {"reason": "comskip_failed", "returncode": result.returncode}) + return "comskip_failed" + except Exception as e: + cp["comskip"] = {"status": "error", "reason": f"comskip_failed: {e}"} + _persist_custom_properties() + _ws('error', {"reason": str(e)}) + return "comskip_failed" + + if not os.path.exists(edl_path): + cp["comskip"] = {"status": "error", "reason": "edl_not_found"} + _persist_custom_properties() + _ws('error', {"reason": "edl_not_found"}) + return "no_edl" + + # Duration via ffprobe + def _ffprobe_duration(path): + try: + p = subprocess.run([ + "ffprobe", "-v", "error", "-show_entries", "format=duration", + "-of", "default=noprint_wrappers=1:nokey=1", path + ], stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, check=True) + return float(p.stdout.strip()) + except Exception: + return None + + duration = _ffprobe_duration(file_path) + if duration is None: + cp["comskip"] = {"status": "error", "reason": "duration_unknown"} + _persist_custom_properties() + _ws('error', {"reason": "duration_unknown"}) + return "no_duration" + + commercials = [] + try: + with open(edl_path, "r") as f: + for line in f: + parts = line.strip().split() + if len(parts) >= 2: + try: + s = float(parts[0]); e = float(parts[1]) + commercials.append((max(0.0, s), min(duration, e))) + except Exception: + pass + except Exception: + pass + + commercials.sort() + keep = [] + cur = 0.0 + for s, e in commercials: + if s > cur: + keep.append((cur, max(cur, s))) + cur = max(cur, e) + if cur < duration: + keep.append((cur, duration)) + + if not commercials or sum((e - s) for s, e in commercials) <= 0.5: + cp["comskip"] = { + "status": "completed", + "skipped": True, + "edl": os.path.basename(edl_path), + } + if selected_ini: + cp["comskip"]["ini_path"] = selected_ini + _persist_custom_properties() + _ws('skipped', {"reason": "no_commercials", "commercials": 0}) + return "no_commercials" + + workdir = os.path.dirname(file_path) + parts = [] + try: + for idx, (s, e) in enumerate(keep): + seg = os.path.join(workdir, f"segment_{idx:03d}.mkv") + dur = max(0.0, e - s) + if dur <= 0.01: + continue + subprocess.run([ + "ffmpeg", "-y", "-ss", f"{s:.3f}", "-i", file_path, "-t", f"{dur:.3f}", + "-c", "copy", "-avoid_negative_ts", "1", seg + ], check=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE) + parts.append(seg) + + if not parts: + raise RuntimeError("no_parts") + + list_path = os.path.join(workdir, "concat_list.txt") + with open(list_path, "w") as lf: + for pth in parts: + escaped = pth.replace("'", "'\\''") + lf.write(f"file '{escaped}'\n") + + output_path = os.path.join(workdir, f"{os.path.splitext(os.path.basename(file_path))[0]}.cut.mkv") + subprocess.run([ + "ffmpeg", "-y", "-f", "concat", "-safe", "0", "-i", list_path, "-c", "copy", output_path + ], check=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE) + + try: + os.replace(output_path, file_path) + except Exception: + shutil.copy(output_path, file_path) + + try: + os.remove(list_path) + except Exception: + pass + for pth in parts: + try: os.remove(pth) + except Exception: pass + + cp["comskip"] = { + "status": "completed", + "edl": os.path.basename(edl_path), + "segments_kept": len(parts), + "commercials": len(commercials), + } + if selected_ini: + cp["comskip"]["ini_path"] = selected_ini + _persist_custom_properties() + _ws('completed', {"commercials": len(commercials), "segments_kept": len(parts)}) + return "ok" + except Exception as e: + cp["comskip"] = {"status": "error", "reason": str(e)} + _persist_custom_properties() + _ws('error', {"reason": str(e)}) + return f"error:{e}" +def _resolve_poster_for_program(channel_name, program, channel_logo_id=None): + """Resolve poster URL and/or Logo id for a recording program. + + Callers should enrich the program dict via _match_epg_program_by_timeslot + before invoking this function so that EPG data is already available. + + Pipeline: EPG images → VOD logo → TMDB/OMDb → TVMaze/iTunes → + direct program fields → Logo table → Logo creation → channel logo. + Returns (poster_logo_id, poster_url) where either may be None. + """ + poster_logo_id = None + poster_url = None + epg_props = None + + _title = ((program.get("title") if isinstance(program, dict) else None) or "").strip() or None + + # Guard: if the "title" is really just the channel name (common when EPG + # has no real program data), don't use it for external API searches — + # those queries produce false-positive artwork from unrelated shows. + _title_is_channel_name = False + if _title and channel_name: + def _norm_channel(s): + return s.lower().replace("*", "").replace("-", " ").strip() + _title_is_channel_name = _norm_channel(_title) == _norm_channel(channel_name) + + # Stage 1: EPG Program images/icon (with URL validation) + try: + from apps.epg.models import ProgramData + prog_id = program.get("id") if isinstance(program, dict) else None + if prog_id: + epg_program = ProgramData.objects.filter(id=prog_id).only("custom_properties").first() + if epg_program and epg_program.custom_properties: + epg_props = epg_program.custom_properties or {} + poster_url = _pick_best_image_from_epg_props(epg_props) + if poster_url and not _validate_url(poster_url): + poster_url = None + if not poster_url: + icon = epg_props.get("icon") + if isinstance(icon, str) and icon and _validate_url(icon): + poster_url = icon + except Exception: + pass + + # Stage 2: VOD logo fallback by title + if not poster_url and not poster_logo_id and _title and not _title_is_channel_name: + try: + from apps.vod.models import Movie, Series + vod_logo = None + movie = Movie.objects.filter(name__iexact=_title).select_related("logo").first() + if movie and movie.logo: + vod_logo = movie.logo + if not vod_logo: + series = Series.objects.filter(name__iexact=_title).select_related("logo").first() + if series and series.logo: + vod_logo = series.logo + if vod_logo: + poster_logo_id = vod_logo.id + except Exception: + pass + + # Stage 3: TMDB/OMDb (keyed APIs) + if not poster_url and not poster_logo_id and _title and not _title_is_channel_name: + try: + tmdb_key = os.environ.get('TMDB_API_KEY') + omdb_key = os.environ.get('OMDB_API_KEY') + title = _title + year = None + imdb_id = None + + # Derive year and imdb_id from cached EPG data + if epg_props: + d = epg_props.get('date') + if d and len(str(d)) >= 4: + year = str(d)[:4] + imdb_id = epg_props.get('imdb.com_id') + + # TMDB: by IMDb ID + if not poster_url and tmdb_key and imdb_id: + try: + url = f"https://api.themoviedb.org/3/find/{quote(imdb_id)}?api_key={tmdb_key}&external_source=imdb_id" + resp = requests.get(url, timeout=5) + if resp.ok: + data = resp.json() or {} + picks = [] + for k in ('movie_results', 'tv_results', 'tv_episode_results', 'tv_season_results'): + picks.extend(data.get(k) or []) + for item in picks: + if item.get('poster_path'): + poster_url = f"https://image.tmdb.org/t/p/w780{item['poster_path']}" + break + except Exception: + pass + + # TMDB: by title (and year if available) + if not poster_url and tmdb_key and title: + try: + q = quote(title) + extra = f"&year={year}" if year else "" + url = f"https://api.themoviedb.org/3/search/multi?api_key={tmdb_key}&query={q}{extra}" + resp = requests.get(url, timeout=5) + if resp.ok: + data = resp.json() or {} + results = data.get('results') or [] + results.sort(key=lambda x: float(x.get('popularity') or 0), reverse=True) + for item in results: + if item.get('poster_path'): + poster_url = f"https://image.tmdb.org/t/p/w780{item['poster_path']}" + break + except Exception: + pass + + # OMDb fallback + if not poster_url and omdb_key: + try: + if imdb_id: + url = f"https://www.omdbapi.com/?apikey={omdb_key}&i={quote(imdb_id)}" + elif title: + yy = f"&y={year}" if year else "" + url = f"https://www.omdbapi.com/?apikey={omdb_key}&t={quote(title)}{yy}" + else: + url = None + if url: + resp = requests.get(url, timeout=5) + if resp.ok: + data = resp.json() or {} + p = data.get('Poster') + if p and p != 'N/A': + poster_url = p + except Exception: + pass + except Exception: + pass + + # Stage 4: Keyless providers (TVMaze & iTunes) + if not poster_url and not poster_logo_id and _title and not _title_is_channel_name: + try: + title = _title + # TVMaze + try: + url = f"https://api.tvmaze.com/singlesearch/shows?q={quote(title)}" + resp = requests.get(url, timeout=5) + if resp.ok: + data = resp.json() or {} + img = (data.get('image') or {}) + p = img.get('original') or img.get('medium') + if p: + poster_url = p + except Exception: + pass + # iTunes + if not poster_url: + try: + for media in ('movie', 'tvShow'): + url = f"https://itunes.apple.com/search?term={quote(title)}&media={media}&limit=1" + resp = requests.get(url, timeout=5) + if resp.ok: + data = resp.json() or {} + results = data.get('results') or [] + if results: + art = results[0].get('artworkUrl100') + if art: + poster_url = art.replace('100x100', '600x600') + break + except Exception: + pass + except Exception: + pass + + # Stage 5: Direct fields on program object (with URL validation) + if not poster_url and not poster_logo_id and isinstance(program, dict): + for key in ("poster", "cover", "cover_big", "image", "icon"): + val = program.get(key) + if isinstance(val, dict): + candidate = val.get("url") + if candidate and _validate_url(candidate): + poster_url = candidate + break + elif isinstance(val, str) and val and _validate_url(val): + poster_url = val + break + + # Stage 6: Search existing Logo entries by program title + if not poster_logo_id and not poster_url and _title and not _title_is_channel_name: + try: + from .models import Logo + existing = Logo.objects.filter(name__iexact=_title).first() + if existing: + poster_logo_id = existing.id + poster_url = existing.url + except Exception: + pass + + # Stage 7: Persist to Logo table if URL available + if not poster_logo_id and poster_url and len(poster_url) <= 1000: + try: + from .models import Logo + logo, _ = Logo.objects.get_or_create(url=poster_url, defaults={"name": _title or channel_name}) + poster_logo_id = logo.id + except Exception: + pass + + # Stage 8: Fall back to channel logo + if not poster_logo_id and not poster_url and channel_logo_id: + poster_logo_id = channel_logo_id + + return poster_logo_id, poster_url + + +@shared_task +def prefetch_recording_artwork(recording_id): + """Prefetch poster info for a scheduled recording so the UI can show art in Upcoming.""" + try: + from .models import Recording + rec = Recording.objects.get(id=recording_id) + cp = rec.custom_properties or {} + + # Bail out if the recording is already active or finished — run_recording + # handles poster resolution itself, and saving here can race with status updates. + current_status = cp.get("status", "") + if current_status in ("recording", "completed", "stopped", "interrupted"): + return "skipped: status is " + current_status + + program = cp.get("program") or {} + + # Enrich empty program dicts (manual recordings) from EPG time-slot data. + # Persists matched title/description for display in the recording card. + if isinstance(program, dict) and not program.get("user_edited") and not program.get("id") and not program.get("title"): + epg_match = _match_epg_program_by_timeslot( + rec.channel.epg_data, rec.start_time, rec.end_time, + ) + if epg_match: + program.update(epg_match) + cp["program"] = program + + poster_logo_id, poster_url = _resolve_poster_for_program( + rec.channel.name, program, channel_logo_id=rec.channel.logo_id, + ) + updated = False + if poster_logo_id and cp.get("poster_logo_id") != poster_logo_id: + cp["poster_logo_id"] = poster_logo_id + updated = True + if poster_url and cp.get("poster_url") != poster_url: + cp["poster_url"] = poster_url + updated = True + # Enrich with rating if available from ProgramData.custom_properties + try: + from apps.epg.models import ProgramData + prog_id = program.get("id") if isinstance(program, dict) else None + if prog_id: + epg_program = ProgramData.objects.filter(id=prog_id).only("custom_properties").first() + if epg_program and isinstance(epg_program.custom_properties, dict): + rating_val = epg_program.custom_properties.get("rating") + rating_sys = epg_program.custom_properties.get("rating_system") + season_val = epg_program.custom_properties.get("season") + episode_val = epg_program.custom_properties.get("episode") + onscreen = epg_program.custom_properties.get("onscreen_episode") + if rating_val and cp.get("rating") != rating_val: + cp["rating"] = rating_val + updated = True + if rating_sys and cp.get("rating_system") != rating_sys: + cp["rating_system"] = rating_sys + updated = True + if season_val is not None and cp.get("season") != season_val: + cp["season"] = season_val + updated = True + if episode_val is not None and cp.get("episode") != episode_val: + cp["episode"] = episode_val + updated = True + if onscreen and cp.get("onscreen_episode") != onscreen: + cp["onscreen_episode"] = onscreen + updated = True + except Exception: + pass + + if updated: + # Re-read from DB to avoid overwriting status changes made by + # the stop endpoint or run_recording's final metadata save. + rec.refresh_from_db() + fresh_cp = rec.custom_properties or {} + for key in ("program", "poster_logo_id", "poster_url", "rating", + "rating_system", "season", "episode", "onscreen_episode"): + if key in cp: + fresh_cp[key] = cp[key] + rec.custom_properties = fresh_cp + rec.save(update_fields=["custom_properties"]) + try: + from core.utils import send_websocket_update + send_websocket_update('updates', 'update', {"success": True, "type": "recording_updated", "recording_id": rec.id}) + except Exception: + pass + return "ok" + except Exception as e: + logger.debug(f"prefetch_recording_artwork failed: {e}") + return f"error: {e}" + + +@shared_task(bind=True) +def bulk_create_channels_from_streams(self, stream_ids, channel_profile_ids=None, starting_channel_number=None): + """ + Asynchronously create channels from a list of stream IDs. + Provides progress updates via WebSocket. + + Args: + stream_ids: List of stream IDs to create channels from + channel_profile_ids: Optional list of channel profile IDs to assign channels to + starting_channel_number: Optional starting channel number behavior: + - None: Use provider channel numbers, then auto-assign from 1 + - 0: Start with lowest available number and increment by 1 + - Other number: Use as starting number for auto-assignment + """ + from apps.channels.models import Stream, Channel, ChannelGroup, ChannelProfile, ChannelProfileMembership, Logo + from apps.epg.models import EPGData + from django.db import transaction + from django.shortcuts import get_object_or_404 + from core.utils import send_websocket_update + + task_id = self.request.id + total_streams = len(stream_ids) + created_channels = [] + errors = [] + + try: + # Send initial progress update + send_websocket_update('updates', 'update', { + 'type': 'bulk_channel_creation_progress', + 'task_id': task_id, + 'progress': 0, + 'total': total_streams, + 'status': 'starting', + 'message': f'Starting bulk creation of {total_streams} channels...' + }) + + # Gather current used numbers once + used_numbers = set(Channel.objects.all().values_list("channel_number", flat=True)) + + # Initialize next_number based on starting_channel_number mode + if starting_channel_number is None: + # Mode 1: Use provider numbers when available, auto-assign when not + next_number = 1 + elif starting_channel_number == 0: + # Mode 2: Start from lowest available number + next_number = 1 + elif starting_channel_number == -1: + # Mode 4: Start after the current highest channel number + highest = Channel.objects.order_by('-channel_number').values_list('channel_number', flat=True).first() + next_number = (int(highest) + 1) if highest is not None else 1 + else: + # Mode 3: Start from specified number + next_number = starting_channel_number + + def get_auto_number(): + nonlocal next_number + while next_number in used_numbers: + next_number += 1 + used_numbers.add(next_number) + return next_number + + logos_to_create = [] + channels_to_create = [] + streams_map = [] + logo_map = [] + profile_map = [] + + # Process streams in batches to avoid memory issues + batch_size = 100 + processed = 0 + + for i in range(0, total_streams, batch_size): + batch_stream_ids = stream_ids[i:i + batch_size] + # Fetch streams and preserve the order from batch_stream_ids + batch_streams_dict = {stream.id: stream for stream in Stream.objects.filter(id__in=batch_stream_ids)} + batch_streams = [batch_streams_dict[stream_id] for stream_id in batch_stream_ids if stream_id in batch_streams_dict] + + # Send progress update + send_websocket_update('updates', 'update', { + 'type': 'bulk_channel_creation_progress', + 'task_id': task_id, + 'progress': processed, + 'total': total_streams, + 'status': 'processing', + 'message': f'Processing streams {processed + 1}-{min(processed + batch_size, total_streams)} of {total_streams}...' + }) + + for stream in batch_streams: + try: + name = stream.name + channel_group = stream.channel_group + stream_custom_props = stream.custom_properties or {} + + # Determine channel number based on starting_channel_number mode + channel_number = None + + if starting_channel_number is None: + # Mode 1: Use provider numbers when available (from stream_chno field) + if stream.stream_chno is not None: + channel_number = stream.stream_chno + + # For modes 2 and 3 (starting_channel_number == 0 or specific number), + # ignore provider numbers and use sequential assignment + + # Get TVC guide station ID + tvc_guide_stationid = None + if "tvc-guide-stationid" in stream_custom_props: + tvc_guide_stationid = stream_custom_props["tvc-guide-stationid"] + + # Check if the determined/provider number is available + if channel_number is not None and ( + channel_number in used_numbers + or Channel.objects.filter(channel_number=channel_number).exists() + ): + # Provider number is taken, use auto-assignment + channel_number = get_auto_number() + elif channel_number is not None: + # Provider number is available, use it + used_numbers.add(channel_number) + else: + # No provider number or ignoring provider numbers, use auto-assignment + channel_number = get_auto_number() + + channel_data = { + "channel_number": channel_number, + "name": name, + "tvc_guide_stationid": tvc_guide_stationid, + "tvg_id": stream.tvg_id, + "is_adult": stream.is_adult, + } + + # Only add channel_group_id if the stream has a channel group + if channel_group: + channel_data["channel_group_id"] = channel_group.id + + # Attempt to find existing EPGs with the same tvg-id + epgs = EPGData.objects.filter(tvg_id=stream.tvg_id) + if epgs: + channel_data["epg_data_id"] = epgs.first().id + + channel = Channel(**channel_data) + channels_to_create.append(channel) + streams_map.append([stream.id]) + + # Store profile IDs for this channel + profile_map.append(channel_profile_ids) + + # Handle logo - validate URL length to avoid PostgreSQL btree index errors + validated_logo_url = validate_logo_url(stream.logo_url) if stream.logo_url else None + if validated_logo_url: + logos_to_create.append( + Logo( + url=validated_logo_url, + name=stream.name or stream.tvg_id, + ) + ) + logo_map.append(validated_logo_url) + else: + logo_map.append(None) + + processed += 1 + + except Exception as e: + errors.append({ + 'stream_id': stream.id if 'stream' in locals() else 'unknown', + 'error': str(e) + }) + processed += 1 + + # Create logos first + if logos_to_create: + send_websocket_update('updates', 'update', { + 'type': 'bulk_channel_creation_progress', + 'task_id': task_id, + 'progress': processed, + 'total': total_streams, + 'status': 'creating_logos', + 'message': f'Creating {len(logos_to_create)} logos...' + }) + Logo.objects.bulk_create(logos_to_create, ignore_conflicts=True) + + # Get logo objects for association + channel_logos = { + logo.url: logo + for logo in Logo.objects.filter( + url__in=[url for url in logo_map if url is not None] + ) + } + + # Create channels in database + if channels_to_create: + send_websocket_update('updates', 'update', { + 'type': 'bulk_channel_creation_progress', + 'task_id': task_id, + 'progress': processed, + 'total': total_streams, + 'status': 'creating_channels', + 'message': f'Creating {len(channels_to_create)} channels in database...' + }) + + with transaction.atomic(): + created_channels = Channel.objects.bulk_create(channels_to_create) + + # Update channels with logos and create stream associations + update = [] + channel_stream_associations = [] + channel_profile_memberships = [] + + for channel, stream_ids, logo_url, profile_ids in zip( + created_channels, streams_map, logo_map, profile_map + ): + if logo_url: + channel.logo = channel_logos[logo_url] + update.append(channel) + + # Create stream associations + for stream_id in stream_ids: + from apps.channels.models import ChannelStream + channel_stream_associations.append( + ChannelStream(channel=channel, stream_id=stream_id, order=0) + ) + + # Handle channel profile membership + # Semantics: + # - None: add to ALL profiles (backward compatible default) + # - Empty array []: add to NO profiles + # - Sentinel [0] or 0 in array: add to ALL profiles (explicit) + # - [1,2,...]: add to specified profile IDs only + if profile_ids is None: + # Omitted -> add to all profiles (backward compatible) + all_profiles = ChannelProfile.objects.all() + channel_profile_memberships.extend([ + ChannelProfileMembership( + channel_profile=profile, + channel=channel, + enabled=True + ) + for profile in all_profiles + ]) + elif isinstance(profile_ids, list) and len(profile_ids) == 0: + # Empty array -> add to no profiles + pass + elif isinstance(profile_ids, list) and 0 in profile_ids: + # Sentinel 0 -> add to all profiles (explicit) + all_profiles = ChannelProfile.objects.all() + channel_profile_memberships.extend([ + ChannelProfileMembership( + channel_profile=profile, + channel=channel, + enabled=True + ) + for profile in all_profiles + ]) + else: + # Specific profile IDs + try: + specific_profiles = ChannelProfile.objects.filter(id__in=profile_ids) + channel_profile_memberships.extend([ + ChannelProfileMembership( + channel_profile=profile, + channel=channel, + enabled=True + ) + for profile in specific_profiles + ]) + except Exception as e: + errors.append({ + 'channel_id': channel.id, + 'error': f'Failed to add to profiles: {str(e)}' + }) + + # Bulk update channels with logos + if update: + Channel.objects.bulk_update(update, ["logo"]) + + # Bulk create channel-stream associations + if channel_stream_associations: + from apps.channels.models import ChannelStream + ChannelStream.objects.bulk_create(channel_stream_associations, ignore_conflicts=True) + + # Bulk create profile memberships + if channel_profile_memberships: + ChannelProfileMembership.objects.bulk_create(channel_profile_memberships, ignore_conflicts=True) + + # Send completion update + send_websocket_update('updates', 'update', { + 'type': 'bulk_channel_creation_progress', + 'task_id': task_id, + 'progress': total_streams, + 'total': total_streams, + 'status': 'completed', + 'message': f'Successfully created {len(created_channels)} channels', + 'created_count': len(created_channels), + 'error_count': len(errors), + 'errors': errors[:10] # Send first 10 errors only + }) + + # Send general channel update notification + send_websocket_update('updates', 'update', { + 'type': 'channels_created', + 'count': len(created_channels) + }) + + return { + 'status': 'completed', + 'created_count': len(created_channels), + 'error_count': len(errors), + 'errors': errors + } + + except Exception as e: + logger.error(f"Bulk channel creation failed: {e}") + send_websocket_update('updates', 'update', { + 'type': 'bulk_channel_creation_progress', + 'task_id': task_id, + 'progress': 0, + 'total': total_streams, + 'status': 'failed', + 'message': f'Task failed: {str(e)}', + 'error': str(e) + }) + raise + + +@shared_task(bind=True) +def set_channels_names_from_epg(self, channel_ids): + """ + Celery task to set channel names from EPG data for multiple channels + """ + from core.utils import send_websocket_update + + task_id = self.request.id + total_channels = len(channel_ids) + updated_count = 0 + errors = [] + + try: + logger.info(f"Starting EPG name setting task for {total_channels} channels") + + # Send initial progress + send_websocket_update('updates', 'update', { + 'type': 'epg_name_setting_progress', + 'task_id': task_id, + 'progress': 0, + 'total': total_channels, + 'status': 'running', + 'message': 'Starting EPG name setting...' + }) + + batch_size = 100 + for i in range(0, total_channels, batch_size): + batch_ids = channel_ids[i:i + batch_size] + batch_updates = [] + + # Get channels and their EPG data + channels = Channel.objects.filter(id__in=batch_ids).select_related('epg_data') + + for channel in channels: + try: + if channel.epg_data and channel.epg_data.name: + if channel.name != channel.epg_data.name: + channel.name = channel.epg_data.name + batch_updates.append(channel) + updated_count += 1 + except Exception as e: + errors.append(f"Channel {channel.id}: {str(e)}") + logger.error(f"Error processing channel {channel.id}: {e}") + + # Bulk update the batch + if batch_updates: + Channel.objects.bulk_update(batch_updates, ['name']) + + # Send progress update + progress = min(i + batch_size, total_channels) + send_websocket_update('updates', 'update', { + 'type': 'epg_name_setting_progress', + 'task_id': task_id, + 'progress': progress, + 'total': total_channels, + 'status': 'running', + 'message': f'Updated {updated_count} channel names...', + 'updated_count': updated_count + }) + + # Send completion notification + send_websocket_update('updates', 'update', { + 'type': 'epg_name_setting_progress', + 'task_id': task_id, + 'progress': total_channels, + 'total': total_channels, + 'status': 'completed', + 'message': f'Successfully updated {updated_count} channel names from EPG data', + 'updated_count': updated_count, + 'error_count': len(errors), + 'errors': errors + }) + + logger.info(f"EPG name setting task completed. Updated {updated_count} channels") + return { + 'status': 'completed', + 'updated_count': updated_count, + 'error_count': len(errors), + 'errors': errors + } + + except Exception as e: + logger.error(f"EPG name setting task failed: {e}") + send_websocket_update('updates', 'update', { + 'type': 'epg_name_setting_progress', + 'task_id': task_id, + 'progress': 0, + 'total': total_channels, + 'status': 'failed', + 'message': f'Task failed: {str(e)}', + 'error': str(e) + }) + raise + + +@shared_task(bind=True) +def set_channels_logos_from_epg(self, channel_ids): + """ + Celery task to set channel logos from EPG data for multiple channels + Creates logos from EPG icon URLs if they don't exist + """ + from .models import Logo + from core.utils import send_websocket_update + import requests + from urllib.parse import urlparse + + task_id = self.request.id + total_channels = len(channel_ids) + updated_count = 0 + created_logos_count = 0 + errors = [] + + try: + logger.info(f"Starting EPG logo setting task for {total_channels} channels") + + # Send initial progress + send_websocket_update('updates', 'update', { + 'type': 'epg_logo_setting_progress', + 'task_id': task_id, + 'progress': 0, + 'total': total_channels, + 'status': 'running', + 'message': 'Starting EPG logo setting...' + }) + + batch_size = 50 # Smaller batch for logo processing + for i in range(0, total_channels, batch_size): + batch_ids = channel_ids[i:i + batch_size] + batch_updates = [] + + # Get channels and their EPG data + channels = Channel.objects.filter(id__in=batch_ids).select_related('epg_data', 'logo') + + for channel in channels: + try: + if channel.epg_data and channel.epg_data.icon_url: + icon_url = channel.epg_data.icon_url.strip() + + # Try to find existing logo with this URL + try: + logo = Logo.objects.get(url=icon_url) + except Logo.DoesNotExist: + # Create new logo from EPG icon URL + try: + # Generate a name for the logo + logo_name = channel.epg_data.name or f"Logo for {channel.epg_data.tvg_id}" + + # Create the logo record + logo = Logo.objects.create( + name=logo_name, + url=icon_url + ) + created_logos_count += 1 + logger.info(f"Created new logo from EPG: {logo_name} - {icon_url}") + + except Exception as create_error: + errors.append(f"Channel {channel.id}: Failed to create logo from {icon_url}: {str(create_error)}") + logger.error(f"Failed to create logo for channel {channel.id}: {create_error}") + continue + + # Update channel logo if different + if channel.logo != logo: + channel.logo = logo + batch_updates.append(channel) + updated_count += 1 + + except Exception as e: + errors.append(f"Channel {channel.id}: {str(e)}") + logger.error(f"Error processing channel {channel.id}: {e}") + + # Bulk update the batch + if batch_updates: + Channel.objects.bulk_update(batch_updates, ['logo']) + + # Send progress update + progress = min(i + batch_size, total_channels) + send_websocket_update('updates', 'update', { + 'type': 'epg_logo_setting_progress', + 'task_id': task_id, + 'progress': progress, + 'total': total_channels, + 'status': 'running', + 'message': f'Updated {updated_count} channel logos, created {created_logos_count} new logos...', + 'updated_count': updated_count, + 'created_logos_count': created_logos_count + }) + + # Send completion notification + send_websocket_update('updates', 'update', { + 'type': 'epg_logo_setting_progress', + 'task_id': task_id, + 'progress': total_channels, + 'total': total_channels, + 'status': 'completed', + 'message': f'Successfully updated {updated_count} channel logos and created {created_logos_count} new logos from EPG data', + 'updated_count': updated_count, + 'created_logos_count': created_logos_count, + 'error_count': len(errors), + 'errors': errors + }) + + logger.info(f"EPG logo setting task completed. Updated {updated_count} channels, created {created_logos_count} logos") + return { + 'status': 'completed', + 'updated_count': updated_count, + 'created_logos_count': created_logos_count, + 'error_count': len(errors), + 'errors': errors + } + + except Exception as e: + logger.error(f"EPG logo setting task failed: {e}") + send_websocket_update('updates', 'update', { + 'type': 'epg_logo_setting_progress', + 'task_id': task_id, + 'progress': 0, + 'total': total_channels, + 'status': 'failed', + 'message': f'Task failed: {str(e)}', + 'error': str(e) + }) + raise + + +@shared_task(bind=True) +def set_channels_tvg_ids_from_epg(self, channel_ids): + """ + Celery task to set channel TVG-IDs from EPG data for multiple channels + """ + from core.utils import send_websocket_update + + task_id = self.request.id + total_channels = len(channel_ids) + updated_count = 0 + errors = [] + + try: + logger.info(f"Starting EPG TVG-ID setting task for {total_channels} channels") + + # Send initial progress + send_websocket_update('updates', 'update', { + 'type': 'epg_tvg_id_setting_progress', + 'task_id': task_id, + 'progress': 0, + 'total': total_channels, + 'status': 'running', + 'message': 'Starting EPG TVG-ID setting...' + }) + + batch_size = 100 + for i in range(0, total_channels, batch_size): + batch_ids = channel_ids[i:i + batch_size] + batch_updates = [] + + # Get channels and their EPG data + channels = Channel.objects.filter(id__in=batch_ids).select_related('epg_data') + + for channel in channels: + try: + if channel.epg_data and channel.epg_data.tvg_id: + if channel.tvg_id != channel.epg_data.tvg_id: + channel.tvg_id = channel.epg_data.tvg_id + batch_updates.append(channel) + updated_count += 1 + except Exception as e: + errors.append(f"Channel {channel.id}: {str(e)}") + logger.error(f"Error processing channel {channel.id}: {e}") + + # Bulk update the batch + if batch_updates: + Channel.objects.bulk_update(batch_updates, ['tvg_id']) + + # Send progress update + progress = min(i + batch_size, total_channels) + send_websocket_update('updates', 'update', { + 'type': 'epg_tvg_id_setting_progress', + 'task_id': task_id, + 'progress': progress, + 'total': total_channels, + 'status': 'running', + 'message': f'Updated {updated_count} channel TVG-IDs...', + 'updated_count': updated_count + }) + + # Send completion notification + send_websocket_update('updates', 'update', { + 'type': 'epg_tvg_id_setting_progress', + 'task_id': task_id, + 'progress': total_channels, + 'total': total_channels, + 'status': 'completed', + 'message': f'Successfully updated {updated_count} channel TVG-IDs from EPG data', + 'updated_count': updated_count, + 'error_count': len(errors), + 'errors': errors + }) + + logger.info(f"EPG TVG-ID setting task completed. Updated {updated_count} channels") + return { + 'status': 'completed', + 'updated_count': updated_count, + 'error_count': len(errors), + 'errors': errors + } + + except Exception as e: + logger.error(f"EPG TVG-ID setting task failed: {e}") + send_websocket_update('updates', 'update', { + 'type': 'epg_tvg_id_setting_progress', + 'task_id': task_id, + 'progress': 0, + 'total': total_channels, + 'status': 'failed', + 'message': f'Task failed: {str(e)}', + 'error': str(e) + }) + raise diff --git a/apps/channels/tests/__init__.py b/apps/channels/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/channels/tests/test_channel_api.py b/apps/channels/tests/test_channel_api.py new file mode 100644 index 0000000..bb245da --- /dev/null +++ b/apps/channels/tests/test_channel_api.py @@ -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) diff --git a/apps/channels/tests/test_db_retry.py b/apps/channels/tests/test_db_retry.py new file mode 100644 index 0000000..e65d929 --- /dev/null +++ b/apps/channels/tests/test_db_retry.py @@ -0,0 +1,278 @@ +"""Tests for DVR retry logic. + +Covers: + - _db_retry(): exponential backoff, max retries, connection reset + - Final metadata save retry in run_recording post-processing + - Initial TS proxy connection retry (per-base retry on retriable errors) + - recover_recordings_on_startup DB retry wrappers +""" +from datetime import timedelta +from unittest.mock import MagicMock, patch, call + +from django.db import OperationalError +from django.test import TestCase +from django.utils import timezone + +from apps.channels.models import Channel, Recording +from apps.channels.tasks import _db_retry + + +# --------------------------------------------------------------------------- +# _db_retry unit tests +# --------------------------------------------------------------------------- + +class DbRetryTests(TestCase): + """Tests for the _db_retry() exponential backoff helper.""" + + @patch("apps.channels.tasks.time.sleep") + @patch("apps.channels.tasks.close_old_connections") + def test_succeeds_on_first_attempt(self, _close, _sleep): + """No retry needed when fn succeeds immediately.""" + result = _db_retry(lambda: "ok", max_retries=3) + self.assertEqual(result, "ok") + _sleep.assert_not_called() + + @patch("apps.channels.tasks.time.sleep") + @patch("apps.channels.tasks.close_old_connections") + def test_retries_on_operational_error_then_succeeds(self, mock_close, mock_sleep): + """Retry succeeds on second attempt after OperationalError.""" + call_count = {"n": 0} + + def flaky(): + call_count["n"] += 1 + if call_count["n"] == 1: + raise OperationalError("connection reset") + return "recovered" + + result = _db_retry(flaky, max_retries=3, base_interval=1) + self.assertEqual(result, "recovered") + self.assertEqual(call_count["n"], 2) + + @patch("apps.channels.tasks.time.sleep") + @patch("apps.channels.tasks.close_old_connections") + def test_raises_after_max_retries_exhausted(self, mock_close, mock_sleep): + """Raises OperationalError after all retries fail.""" + def always_fail(): + raise OperationalError("db gone") + + with self.assertRaises(OperationalError): + _db_retry(always_fail, max_retries=3, base_interval=1) + + @patch("apps.channels.tasks.time.sleep") + @patch("apps.channels.tasks.close_old_connections") + def test_exponential_backoff_timing(self, mock_close, mock_sleep): + """Sleep durations follow exponential backoff: 1s, 2s, 4s.""" + call_count = {"n": 0} + + def fail_twice(): + call_count["n"] += 1 + if call_count["n"] <= 2: + raise OperationalError("retry me") + return "done" + + _db_retry(fail_twice, max_retries=3, base_interval=1) + mock_sleep.assert_has_calls([call(1), call(2)]) + + @patch("apps.channels.tasks.time.sleep") + @patch("apps.channels.tasks.close_old_connections") + def test_close_old_connections_called_between_retries(self, mock_close, mock_sleep): + """Stale DB connections are reset before each retry attempt.""" + call_count = {"n": 0} + + def fail_once(): + call_count["n"] += 1 + if call_count["n"] == 1: + raise OperationalError("stale conn") + return "ok" + + _db_retry(fail_once, max_retries=3) + mock_close.assert_called_once() + + @patch("apps.channels.tasks.time.sleep") + @patch("apps.channels.tasks.close_old_connections") + def test_non_operational_error_not_retried(self, mock_close, mock_sleep): + """Non-OperationalError exceptions propagate immediately.""" + def raise_value_error(): + raise ValueError("not a DB error") + + with self.assertRaises(ValueError): + _db_retry(raise_value_error, max_retries=3) + mock_sleep.assert_not_called() + + @patch("apps.channels.tasks.time.sleep") + @patch("apps.channels.tasks.close_old_connections") + def test_returns_fn_return_value(self, mock_close, mock_sleep): + """Return value of fn() is passed through.""" + result = _db_retry(lambda: {"key": "value"}, max_retries=3) + self.assertEqual(result, {"key": "value"}) + + @patch("apps.channels.tasks.time.sleep") + @patch("apps.channels.tasks.close_old_connections") + def test_single_retry_allowed(self, mock_close, mock_sleep): + """max_retries=1 means no retry — fail immediately.""" + with self.assertRaises(OperationalError): + _db_retry( + lambda: (_ for _ in ()).throw(OperationalError("fail")), + max_retries=1, + ) + mock_sleep.assert_not_called() + + +# --------------------------------------------------------------------------- +# Final metadata save retry integration tests +# --------------------------------------------------------------------------- + +class FinalMetadataSaveRetryTests(TestCase): + """The final recording metadata save must retry on transient DB errors.""" + + def setUp(self): + self.channel = Channel.objects.create( + channel_number=95, name="Retry Test Channel" + ) + + @patch("core.utils.send_websocket_update", side_effect=lambda *a, **kw: None) + def test_metadata_save_uses_db_retry(self, _ws): + """Verify recording metadata is saved via _db_retry (retries on OperationalError).""" + now = timezone.now() + rec = Recording.objects.create( + channel=self.channel, + start_time=now - timedelta(hours=1), + end_time=now + timedelta(hours=1), + custom_properties={"status": "recording"}, + ) + # Directly call _db_retry to save metadata as run_recording does + cp = rec.custom_properties.copy() + cp["status"] = "completed" + cp["ended_at"] = str(now) + cp["bytes_written"] = 1024 + + def _save(): + rec.custom_properties = cp + rec.save(update_fields=["custom_properties"]) + + _db_retry(_save, max_retries=3, base_interval=1, label="test save") + rec.refresh_from_db() + self.assertEqual(rec.custom_properties["status"], "completed") + self.assertEqual(rec.custom_properties["bytes_written"], 1024) + + @patch("core.utils.send_websocket_update", side_effect=lambda *a, **kw: None) + def test_metadata_survives_transient_save_failure(self, _ws): + """Simulate OperationalError on first save, success on retry.""" + now = timezone.now() + rec = Recording.objects.create( + channel=self.channel, + start_time=now - timedelta(hours=1), + end_time=now + timedelta(hours=1), + custom_properties={"status": "recording"}, + ) + cp = {"status": "completed", "bytes_written": 2048} + call_count = {"n": 0} + _real_save = rec.save + + def patched_save(**kwargs): + call_count["n"] += 1 + if call_count["n"] == 1: + raise OperationalError("connection reset by peer") + return _real_save(**kwargs) + + with patch.object(rec, "save", side_effect=patched_save): + with patch("apps.channels.tasks.time.sleep"): + with patch("apps.channels.tasks.close_old_connections"): + def _save(): + rec.custom_properties = cp + rec.save(update_fields=["custom_properties"]) + _db_retry(_save, max_retries=3, base_interval=1, label="test") + + rec.refresh_from_db() + self.assertEqual(rec.custom_properties["status"], "completed") + + +# --------------------------------------------------------------------------- +# Initial connection retry tests +# --------------------------------------------------------------------------- + +class InitialConnectionRetryTests(TestCase): + """Verify that the DVR task's reconnection logic retries the same + base URL before falling back to the next candidate.""" + + def test_reconnect_max_constant_exists_in_run_recording(self): + """run_recording must define a max-reconnect limit to prevent + infinite retries on the same broken base URL.""" + import inspect + from apps.channels.tasks import run_recording + source = inspect.getsource(run_recording) + + # The reconnection counter pattern must be present + self.assertIn("reconnect", source.lower(), + "run_recording must contain reconnection logic") + + +# --------------------------------------------------------------------------- +# recover_recordings_on_startup retry tests +# --------------------------------------------------------------------------- + +class RecoveryRetryTests(TestCase): + """DB operations in recover_recordings_on_startup must use _db_retry.""" + + def setUp(self): + self.channel = Channel.objects.create( + channel_number=97, name="Recovery Retry Channel" + ) + + @patch("apps.channels.tasks.run_recording.apply_async") + @patch("core.utils.send_websocket_update", side_effect=lambda *a, **kw: None) + def test_recovery_save_retries_on_operational_error(self, _ws, mock_async): + """Recovery status update uses _db_retry — survives one OperationalError.""" + now = timezone.now() + rec = Recording.objects.create( + channel=self.channel, + start_time=now - timedelta(minutes=30), + end_time=now + timedelta(minutes=30), + custom_properties={}, + ) + # Simulate what recovery does: mark interrupted, then save with retry + cp = rec.custom_properties or {} + cp["status"] = "interrupted" + cp["interrupted_reason"] = "server_restarted" + rec.custom_properties = cp + + call_count = {"n": 0} + _real_save = Recording.save + + def patched_save(self_rec, **kwargs): + call_count["n"] += 1 + if call_count["n"] == 1: + raise OperationalError("db temporarily unavailable") + return _real_save(self_rec, **kwargs) + + with patch.object(Recording, "save", patched_save): + with patch("apps.channels.tasks.time.sleep"): + with patch("apps.channels.tasks.close_old_connections"): + _db_retry( + lambda: rec.save(update_fields=["custom_properties"]), + max_retries=3, + label="test recovery", + ) + + rec.refresh_from_db() + self.assertEqual(rec.custom_properties.get("status"), "interrupted") + self.assertEqual(rec.custom_properties.get("interrupted_reason"), "server_restarted") + + def test_db_retry_fetches_recording_list(self): + """_db_retry correctly returns query results for recording list fetch.""" + now = timezone.now() + rec = Recording.objects.create( + channel=self.channel, + start_time=now - timedelta(minutes=30), + end_time=now + timedelta(minutes=30), + custom_properties={}, + ) + result = _db_retry( + lambda: list(Recording.objects.filter( + start_time__lte=now, end_time__gt=now + )), + label="test query", + ) + self.assertGreaterEqual(len(result), 1) + ids = [r.id for r in result] + self.assertIn(rec.id, ids) diff --git a/apps/channels/tests/test_dvr_port_resolution.py b/apps/channels/tests/test_dvr_port_resolution.py new file mode 100644 index 0000000..c737395 --- /dev/null +++ b/apps/channels/tests/test_dvr_port_resolution.py @@ -0,0 +1,59 @@ +import os +from django.test import SimpleTestCase +from unittest.mock import patch + +from apps.channels.tasks import build_dvr_candidates + + +class DVRPortResolutionTests(SimpleTestCase): + """ + Tests that DVR recording candidate URLs respect the DISPATCHARR_PORT + environment variable instead of hardcoding port 9191. + """ + + @patch.dict(os.environ, {'REDIS_HOST': 'redis'}, clear=True) + def test_default_port_uses_9191(self): + """Without DISPATCHARR_PORT set, candidates default to 9191.""" + candidates = build_dvr_candidates() + self.assertIn('http://web:9191', candidates) + self.assertIn('http://localhost:9191', candidates) + + @patch.dict(os.environ, {'DISPATCHARR_PORT': '8080', 'REDIS_HOST': 'redis'}, clear=True) + def test_custom_port_reflected_in_candidates(self): + """DISPATCHARR_PORT=8080 replaces all hardcoded 9191 references.""" + candidates = build_dvr_candidates() + self.assertIn('http://web:8080', candidates) + self.assertIn('http://localhost:8080', candidates) + self.assertNotIn('http://web:9191', candidates) + self.assertNotIn('http://localhost:9191', candidates) + + @patch.dict(os.environ, { + 'DISPATCHARR_PORT': '7777', + 'DISPATCHARR_ENV': 'dev', + 'REDIS_HOST': 'redis', + }, clear=True) + def test_dev_mode_includes_5656_and_custom_port(self): + """Dev mode includes both uwsgi internal port (5656) and custom port.""" + candidates = build_dvr_candidates() + self.assertIn('http://127.0.0.1:5656', candidates) + self.assertIn('http://127.0.0.1:7777', candidates) + + @patch.dict(os.environ, { + 'DISPATCHARR_INTERNAL_TS_BASE_URL': 'http://custom:1234', + 'REDIS_HOST': 'redis', + }, clear=True) + def test_explicit_override_is_first(self): + """DISPATCHARR_INTERNAL_TS_BASE_URL should be the first candidate.""" + candidates = build_dvr_candidates() + self.assertEqual(candidates[0], 'http://custom:1234') + + @patch.dict(os.environ, { + 'DISPATCHARR_PORT': '3000', + 'DISPATCHARR_INTERNAL_API_BASE': 'http://myhost:4000', + 'REDIS_HOST': 'redis', + }, clear=True) + def test_internal_api_base_overrides_web_fallback(self): + """DISPATCHARR_INTERNAL_API_BASE replaces the http://web:{port} default.""" + candidates = build_dvr_candidates() + self.assertIn('http://myhost:4000', candidates) + self.assertNotIn('http://web:3000', candidates) diff --git a/apps/channels/tests/test_epg_matching.py b/apps/channels/tests/test_epg_matching.py new file mode 100644 index 0000000..fee7eea --- /dev/null +++ b/apps/channels/tests/test_epg_matching.py @@ -0,0 +1,180 @@ +"""Tests for the _match_epg_program_by_timeslot() helper in tasks.py. + +Covers: + - Exact time-slot match returns program dict + - 80% overlap threshold: at boundary, above, and below + - Multiple overlapping programs: dominant vs. evenly split + - Edge cases: None inputs, zero-duration recording, no EPG data + - Returned dict structure (id, title, sub_title, description) +""" +from datetime import timedelta + +from django.test import TestCase +from django.utils import timezone + +from apps.channels.models import Channel +from apps.epg.models import EPGSource, EPGData, ProgramData +from apps.channels.tasks import _match_epg_program_by_timeslot + + +class EpgMatchingSetupMixin: + """Shared setup for EPG matching tests.""" + + def setUp(self): + self.source = EPGSource.objects.create(name="Test Source") + self.epg = EPGData.objects.create( + tvg_id="test.channel", name="Test Channel EPG", epg_source=self.source, + ) + self.channel = Channel.objects.create( + channel_number=50, name="EPG Match Channel", epg_data=self.epg, + ) + self.base = timezone.now().replace(second=0, microsecond=0) + + def _prog(self, offset_min, duration_min, title="Test Show", **kwargs): + """Create a ProgramData starting offset_min from self.base.""" + start = self.base + timedelta(minutes=offset_min) + end = start + timedelta(minutes=duration_min) + return ProgramData.objects.create( + epg=self.epg, start_time=start, end_time=end, title=title, **kwargs, + ) + + +class ExactMatchTests(EpgMatchingSetupMixin, TestCase): + """Recording window exactly matches an EPG program.""" + + def test_exact_match_returns_program_dict(self): + prog = self._prog(0, 60, title="News at 9", sub_title="Top Stories", + description="Evening news broadcast") + result = _match_epg_program_by_timeslot( + self.epg, prog.start_time, prog.end_time, + ) + self.assertIsNotNone(result) + self.assertEqual(result["id"], prog.id) + self.assertEqual(result["title"], "News at 9") + self.assertEqual(result["sub_title"], "Top Stories") + self.assertEqual(result["description"], "Evening news broadcast") + + def test_missing_optional_fields_returned_as_empty_strings(self): + prog = self._prog(0, 30, title="Minimal Show") + result = _match_epg_program_by_timeslot( + self.epg, prog.start_time, prog.end_time, + ) + self.assertIsNotNone(result) + self.assertEqual(result["sub_title"], "") + self.assertEqual(result["description"], "") + + +class OverlapThresholdTests(EpgMatchingSetupMixin, TestCase): + """80% overlap threshold boundary tests.""" + + def test_exactly_80_percent_overlap_returns_match(self): + """Program covers exactly 80% of the recording window.""" + # Program: 0-60min, Recording: 0-75min → overlap = 60/75 = 80% + prog = self._prog(0, 60, title="Borderline Show") + rec_start = self.base + rec_end = self.base + timedelta(minutes=75) + result = _match_epg_program_by_timeslot(self.epg, rec_start, rec_end) + self.assertIsNotNone(result) + self.assertEqual(result["title"], "Borderline Show") + + def test_below_80_percent_returns_none(self): + """Program covers 79% of the recording — below threshold.""" + # Program: 0-60min, Recording: 0-76min → overlap = 60/76 ≈ 78.9% + prog = self._prog(0, 60, title="Too Short") + rec_start = self.base + rec_end = self.base + timedelta(minutes=76) + result = _match_epg_program_by_timeslot(self.epg, rec_start, rec_end) + self.assertIsNone(result) + + def test_above_80_percent_returns_match(self): + """Program covers 90% of the recording.""" + # Program: 0-60min, Recording: 0-66min → overlap = 60/66 ≈ 90.9% + prog = self._prog(0, 60, title="Good Match") + rec_start = self.base + rec_end = self.base + timedelta(minutes=66) + result = _match_epg_program_by_timeslot(self.epg, rec_start, rec_end) + self.assertIsNotNone(result) + self.assertEqual(result["title"], "Good Match") + + +class MultipleProgramTests(EpgMatchingSetupMixin, TestCase): + """Recording spans multiple EPG programs.""" + + def test_dominant_program_returned(self): + """Recording spans 2 programs; one covers 85%, the other 15%.""" + # Show A: 0-60min, Show B: 60-120min + # Recording: 9-69min → A overlap=51/60=85%, B overlap=9/60=15% + self._prog(0, 60, title="Show A") + self._prog(60, 60, title="Show B") + rec_start = self.base + timedelta(minutes=9) + rec_end = self.base + timedelta(minutes=69) + result = _match_epg_program_by_timeslot(self.epg, rec_start, rec_end) + self.assertIsNotNone(result) + self.assertEqual(result["title"], "Show A") + + def test_evenly_split_returns_none(self): + """Recording spans 2 equal programs — neither reaches 80%.""" + # Show A: 0-60min, Show B: 60-120min + # Recording: 30-90min → each covers 50% + self._prog(0, 60, title="Show A") + self._prog(60, 60, title="Show B") + rec_start = self.base + timedelta(minutes=30) + rec_end = self.base + timedelta(minutes=90) + result = _match_epg_program_by_timeslot(self.epg, rec_start, rec_end) + self.assertIsNone(result) + + def test_three_programs_one_dominant(self): + """Recording spans 3 programs; middle one is dominant.""" + # A: 0-30min, B: 30-90min, C: 90-120min + # Recording: 25-95min (70min window) → B overlap=60/70≈85.7% + self._prog(0, 30, title="Show A") + self._prog(30, 60, title="Show B") + self._prog(90, 30, title="Show C") + rec_start = self.base + timedelta(minutes=25) + rec_end = self.base + timedelta(minutes=95) + result = _match_epg_program_by_timeslot(self.epg, rec_start, rec_end) + self.assertIsNotNone(result) + self.assertEqual(result["title"], "Show B") + + +class EdgeCaseTests(EpgMatchingSetupMixin, TestCase): + """Edge cases and error handling.""" + + def test_none_epg_data_returns_none(self): + result = _match_epg_program_by_timeslot(None, self.base, self.base + timedelta(hours=1)) + self.assertIsNone(result) + + def test_none_start_time_returns_none(self): + result = _match_epg_program_by_timeslot(self.epg, None, self.base + timedelta(hours=1)) + self.assertIsNone(result) + + def test_none_end_time_returns_none(self): + result = _match_epg_program_by_timeslot(self.epg, self.base, None) + self.assertIsNone(result) + + def test_zero_duration_returns_none(self): + """Recording with start == end should return None.""" + result = _match_epg_program_by_timeslot(self.epg, self.base, self.base) + self.assertIsNone(result) + + def test_negative_duration_returns_none(self): + """Recording with end before start should return None.""" + result = _match_epg_program_by_timeslot( + self.epg, self.base + timedelta(hours=1), self.base, + ) + self.assertIsNone(result) + + def test_no_overlapping_programs_returns_none(self): + """No EPG programs in the recording window.""" + self._prog(0, 60, title="Earlier Show") + rec_start = self.base + timedelta(hours=5) + rec_end = rec_start + timedelta(hours=1) + result = _match_epg_program_by_timeslot(self.epg, rec_start, rec_end) + self.assertIsNone(result) + + def test_empty_epg_no_programs_returns_none(self): + """EPGData exists but has no programs.""" + result = _match_epg_program_by_timeslot( + self.epg, self.base, self.base + timedelta(hours=1), + ) + self.assertIsNone(result) diff --git a/apps/channels/tests/test_recording_extend.py b/apps/channels/tests/test_recording_extend.py new file mode 100644 index 0000000..a083b6d --- /dev/null +++ b/apps/channels/tests/test_recording_extend.py @@ -0,0 +1,235 @@ +"""Tests for the Extend In-Progress Recording feature. + +Covers: + - extend() API endpoint (happy path and validation) + - pre_save signal guard: end_time change must NOT revoke a live recording + - pre_save signal guard: end_time change MUST still revoke an upcoming recording + - TOCTOU edge cases (extend on a completed/stopped/nonexistent recording) +""" +from datetime import timedelta +from unittest.mock import MagicMock, patch + +from django.test import TestCase +from django.utils import timezone +from rest_framework.test import APIRequestFactory, force_authenticate + +from apps.channels.models import Channel, Recording +from apps.channels.api_views import RecordingViewSet + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + +def _make_admin(): + from django.contrib.auth import get_user_model + User = get_user_model() + u, _ = User.objects.get_or_create( + username="extend_test_admin", + defaults={"user_level": User.UserLevel.ADMIN}, + ) + u.set_password("pass") + u.save() + return u + + +# --------------------------------------------------------------------------- +# Extend endpoint tests +# --------------------------------------------------------------------------- + +class ExtendEndpointTests(TestCase): + """Tests for POST /api/channels/recordings/{id}/extend/""" + + def setUp(self): + self.channel = Channel.objects.create( + channel_number=88, name="Extend Test Channel" + ) + self.user = _make_admin() + self.factory = APIRequestFactory() + + def _extend(self, rec, extra_minutes): + request = self.factory.post( + f"/api/channels/recordings/{rec.id}/extend/", + {"extra_minutes": extra_minutes}, + format="json", + ) + force_authenticate(request, user=self.user) + view = RecordingViewSet.as_view({"post": "extend"}) + return view(request, pk=rec.id) + + def _make_rec(self, status="recording"): + now = timezone.now() + return Recording.objects.create( + channel=self.channel, + start_time=now - timedelta(hours=1), + end_time=now + timedelta(hours=1), + custom_properties={"status": status}, + ) + + @patch("core.utils.send_websocket_update", side_effect=lambda *a, **kw: None) + def test_extend_updates_end_time_in_db(self, _ws): + rec = self._make_rec() + original_end = rec.end_time + response = self._extend(rec, 30) + self.assertEqual(response.status_code, 200) + self.assertTrue(response.data.get("success")) + rec.refresh_from_db() + expected = original_end + timedelta(minutes=30) + delta = abs((rec.end_time - expected).total_seconds()) + self.assertLess(delta, 1, "end_time was not extended by the correct amount") + + @patch("core.utils.send_websocket_update", side_effect=lambda *a, **kw: None) + def test_extend_stacks_multiple_extensions(self, _ws): + """Calling extend() twice adds both increments.""" + rec = self._make_rec() + original_end = rec.end_time + self._extend(rec, 15) + self._extend(rec, 30) + rec.refresh_from_db() + expected = original_end + timedelta(minutes=45) + delta = abs((rec.end_time - expected).total_seconds()) + self.assertLess(delta, 1) + + @patch("core.utils.send_websocket_update", side_effect=lambda *a, **kw: None) + def test_extend_does_not_clear_task_id(self, _ws): + """The running Celery task must survive the DB save.""" + rec = self._make_rec() + rec.task_id = "dvr-recording-999" + rec.save(update_fields=["task_id"]) + self._extend(rec, 30) + rec.refresh_from_db() + self.assertEqual(rec.task_id, "dvr-recording-999") + + def test_extend_returns_400_if_finished(self): + """Cannot extend a completed, stopped, or interrupted recording.""" + for bad_status in ("completed", "stopped", "interrupted"): + with self.subTest(status=bad_status): + rec = self._make_rec(status=bad_status) + response = self._extend(rec, 30) + self.assertEqual(response.status_code, 400) + self.assertFalse(response.data.get("success")) + + @patch("core.utils.send_websocket_update", side_effect=lambda *a, **kw: None) + def test_extend_succeeds_before_task_sets_status(self, _ws): + """Extend must work when status is empty (task hasn't started yet).""" + rec = self._make_rec(status="") + response = self._extend(rec, 15) + self.assertEqual(response.status_code, 200) + rec.refresh_from_db() + expected = rec.end_time # already extended + self.assertTrue(response.data.get("success")) + + @patch("apps.channels.signals.revoke_task") + @patch("core.utils.send_websocket_update", side_effect=lambda *a, **kw: None) + def test_extend_bypasses_signals_no_revoke(self, _ws, mock_revoke): + """Extend uses .update() to bypass pre_save — revoke_task must never fire.""" + rec = self._make_rec(status="") + rec.task_id = "dvr-recording-500" + rec.save(update_fields=["task_id"]) + self._extend(rec, 15) + self._extend(rec, 30) + mock_revoke.assert_not_called() + rec.refresh_from_db() + self.assertEqual(rec.task_id, "dvr-recording-500") + + def test_extend_returns_400_for_zero_minutes(self): + response = self._extend(self._make_rec(), 0) + self.assertEqual(response.status_code, 400) + + def test_extend_returns_400_for_negative_minutes(self): + response = self._extend(self._make_rec(), -15) + self.assertEqual(response.status_code, 400) + + def test_extend_returns_400_for_non_numeric_minutes(self): + rec = self._make_rec() + request = self.factory.post( + f"/api/channels/recordings/{rec.id}/extend/", + {"extra_minutes": "lots"}, + format="json", + ) + force_authenticate(request, user=self.user) + view = RecordingViewSet.as_view({"post": "extend"}) + response = view(request, pk=rec.id) + self.assertEqual(response.status_code, 400) + + def test_extend_returns_404_for_nonexistent_recording(self): + request = self.factory.post( + "/api/channels/recordings/999999/extend/", + {"extra_minutes": 30}, + format="json", + ) + force_authenticate(request, user=self.user) + view = RecordingViewSet.as_view({"post": "extend"}) + response = view(request, pk=999999) + self.assertEqual(response.status_code, 404) + + +# --------------------------------------------------------------------------- +# pre_save signal guard tests +# --------------------------------------------------------------------------- + +class PreSaveExtendGuardTests(TestCase): + """The pre_save signal must NOT revoke a live recording when end_time changes, + but MUST still revoke a scheduled (upcoming) recording as before.""" + + def setUp(self): + self.channel = Channel.objects.create( + channel_number=77, name="Signal Guard Channel" + ) + + def _make_rec(self, status="", task_id="dvr-recording-42"): + now = timezone.now() + return Recording.objects.create( + channel=self.channel, + start_time=now + timedelta(hours=1), + end_time=now + timedelta(hours=2), + task_id=task_id, + custom_properties={"status": status} if status else {}, + ) + + @patch("apps.channels.signals.revoke_task") + def test_end_time_change_does_not_revoke_live_recording(self, mock_revoke): + """When status='recording', extending end_time must not call revoke_task.""" + rec = self._make_rec(status="recording", task_id="dvr-recording-42") + rec.end_time = rec.end_time + timedelta(minutes=30) + rec.save(update_fields=["end_time"]) + mock_revoke.assert_not_called() + + @patch("apps.channels.signals.revoke_task") + def test_task_id_preserved_after_extend_on_live_recording(self, mock_revoke): + """task_id must not be cleared for a live recording's end_time change.""" + rec = self._make_rec(status="recording", task_id="dvr-recording-42") + original_task_id = rec.task_id + rec.end_time = rec.end_time + timedelta(minutes=30) + rec.save(update_fields=["end_time"]) + rec.refresh_from_db() + self.assertEqual(rec.task_id, original_task_id) + + @patch("apps.channels.signals.revoke_task") + def test_end_time_change_still_revokes_upcoming_recording(self, mock_revoke): + """The guard must NOT apply to upcoming recordings — existing behavior preserved.""" + rec = self._make_rec(status="", task_id="dvr-recording-77") + rec.end_time = rec.end_time + timedelta(minutes=30) + rec.save(update_fields=["end_time"]) + mock_revoke.assert_called_once_with("dvr-recording-77") + + @patch("apps.channels.signals.revoke_task") + @patch("core.utils.send_websocket_update", side_effect=lambda *a, **kw: None) + def test_pre_save_guard_reads_db_status_not_memory_status(self, _ws, mock_revoke): + """pre_save reads status from DB (old object), not from the instance being saved.""" + now = timezone.now() + rec = Recording.objects.create( + channel=self.channel, + start_time=now - timedelta(hours=1), + end_time=now + timedelta(hours=1), + task_id="dvr-recording-66", + custom_properties={"status": "recording"}, + ) + # Simulate: DB status changes to 'completed' behind the instance's back + Recording.objects.filter(pk=rec.pk).update( + custom_properties={"status": "completed"} + ) + rec.end_time = rec.end_time + timedelta(minutes=30) + rec.save(update_fields=["end_time"]) + # revoke_task should be called because DB status is "completed", not "recording" + mock_revoke.assert_called_once_with("dvr-recording-66") diff --git a/apps/channels/tests/test_recording_metadata.py b/apps/channels/tests/test_recording_metadata.py new file mode 100644 index 0000000..d4db6a2 --- /dev/null +++ b/apps/channels/tests/test_recording_metadata.py @@ -0,0 +1,379 @@ +"""Tests for recording metadata endpoints and logo proxy negative cache. + +Covers: + - update_metadata endpoint: title/description, user_edited flag, validation + - refresh_artwork endpoint: returns immediately, background thread behavior + - Logo proxy negative cache: cache hit/miss, expiry, eviction, success clears +""" +import time as time_mod +from datetime import timedelta +from unittest.mock import MagicMock, patch + +from django.test import TestCase +from django.utils import timezone +from rest_framework.test import APIRequestFactory, force_authenticate + +from apps.channels.models import Channel, Recording, Logo +from apps.channels.api_views import RecordingViewSet, LogoViewSet + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + +def _make_admin(): + from django.contrib.auth import get_user_model + User = get_user_model() + u, _ = User.objects.get_or_create( + username="metadata_test_admin", + defaults={"user_level": User.UserLevel.ADMIN}, + ) + u.set_password("pass") + u.save() + return u + + +# --------------------------------------------------------------------------- +# update_metadata endpoint +# --------------------------------------------------------------------------- + +class UpdateMetadataTests(TestCase): + """Tests for POST /api/channels/recordings/{id}/update-metadata/""" + + def setUp(self): + self.channel = Channel.objects.create(channel_number=70, name="Meta Test Channel") + self.user = _make_admin() + self.factory = APIRequestFactory() + + def _update(self, rec, data): + request = self.factory.post( + f"/api/channels/recordings/{rec.id}/update-metadata/", + data, format="json", + ) + force_authenticate(request, user=self.user) + view = RecordingViewSet.as_view({"post": "update_metadata"}) + return view(request, pk=rec.id) + + def _make_rec(self, custom_properties=None): + now = timezone.now() + return Recording.objects.create( + channel=self.channel, + start_time=now - timedelta(hours=1), + end_time=now + timedelta(hours=1), + custom_properties=custom_properties or {}, + ) + + @patch("core.utils.send_websocket_update", side_effect=lambda *a, **kw: None) + def test_update_title_only(self, _ws): + rec = self._make_rec() + response = self._update(rec, {"title": "My Show"}) + self.assertEqual(response.status_code, 200) + rec.refresh_from_db() + program = rec.custom_properties["program"] + self.assertEqual(program["title"], "My Show") + self.assertTrue(program["user_edited"]) + + @patch("core.utils.send_websocket_update", side_effect=lambda *a, **kw: None) + def test_update_description_only(self, _ws): + rec = self._make_rec({"program": {"title": "Existing Title"}}) + response = self._update(rec, {"description": "A great episode"}) + self.assertEqual(response.status_code, 200) + rec.refresh_from_db() + program = rec.custom_properties["program"] + self.assertEqual(program["description"], "A great episode") + self.assertEqual(program["title"], "Existing Title") + self.assertTrue(program["user_edited"]) + + @patch("core.utils.send_websocket_update", side_effect=lambda *a, **kw: None) + def test_update_both_fields(self, _ws): + rec = self._make_rec() + response = self._update(rec, {"title": "New Title", "description": "New Desc"}) + self.assertEqual(response.status_code, 200) + rec.refresh_from_db() + program = rec.custom_properties["program"] + self.assertEqual(program["title"], "New Title") + self.assertEqual(program["description"], "New Desc") + self.assertTrue(program["user_edited"]) + + def test_no_fields_returns_400(self): + rec = self._make_rec() + response = self._update(rec, {}) + self.assertEqual(response.status_code, 400) + self.assertFalse(response.data.get("success")) + + @patch("core.utils.send_websocket_update", side_effect=lambda *a, **kw: None) + def test_whitespace_trimmed(self, _ws): + rec = self._make_rec() + response = self._update(rec, {"title": " Padded Title "}) + self.assertEqual(response.status_code, 200) + rec.refresh_from_db() + self.assertEqual(rec.custom_properties["program"]["title"], "Padded Title") + + def test_whitespace_only_title_returns_400(self): + """Whitespace-only title and description should be rejected.""" + rec = self._make_rec() + response = self._update(rec, {"title": " ", "description": " "}) + self.assertEqual(response.status_code, 400) + self.assertFalse(response.data.get("success")) + + def test_whitespace_only_title_with_valid_description(self): + """Whitespace-only title is ignored; valid description is accepted.""" + rec = self._make_rec({"program": {"title": "Original"}}) + with patch("core.utils.send_websocket_update", side_effect=lambda *a, **kw: None): + response = self._update(rec, {"title": " ", "description": "Valid desc"}) + self.assertEqual(response.status_code, 200) + rec.refresh_from_db() + # Title should remain unchanged since the whitespace-only value is not applied + self.assertEqual(rec.custom_properties["program"]["title"], "Original") + self.assertEqual(rec.custom_properties["program"]["description"], "Valid desc") + + @patch("core.utils.send_websocket_update", side_effect=lambda *a, **kw: None) + def test_creates_program_dict_when_absent(self, _ws): + """Recording with no program dict gets one created.""" + rec = self._make_rec({"status": "completed"}) + response = self._update(rec, {"title": "Brand New"}) + self.assertEqual(response.status_code, 200) + rec.refresh_from_db() + self.assertIn("program", rec.custom_properties) + self.assertEqual(rec.custom_properties["program"]["title"], "Brand New") + + def test_returns_404_for_nonexistent(self): + request = self.factory.post( + "/api/channels/recordings/99999/update-metadata/", + {"title": "Ghost"}, format="json", + ) + force_authenticate(request, user=self.user) + view = RecordingViewSet.as_view({"post": "update_metadata"}) + self.assertEqual(view(request, pk=99999).status_code, 404) + + @patch("core.utils.send_websocket_update", side_effect=lambda *a, **kw: None) + def test_sends_websocket_event(self, mock_ws): + rec = self._make_rec() + self._update(rec, {"title": "WS Test"}) + mock_ws.assert_called_once() + payload = mock_ws.call_args[0][2] + self.assertEqual(payload["type"], "recording_updated") + self.assertEqual(payload["recording_id"], rec.id) + + @patch("core.utils.send_websocket_update", side_effect=Exception("WS down")) + def test_ws_failure_does_not_fail_request(self, _ws): + """WebSocket errors are silenced — the save still succeeds.""" + rec = self._make_rec() + response = self._update(rec, {"title": "Resilient"}) + self.assertEqual(response.status_code, 200) + rec.refresh_from_db() + self.assertEqual(rec.custom_properties["program"]["title"], "Resilient") + + +# --------------------------------------------------------------------------- +# refresh_artwork endpoint +# --------------------------------------------------------------------------- + +class RefreshArtworkTests(TestCase): + """Tests for POST /api/channels/recordings/{id}/refresh-artwork/""" + + def setUp(self): + self.channel = Channel.objects.create(channel_number=71, name="Artwork Test Channel") + self.user = _make_admin() + self.factory = APIRequestFactory() + + def _refresh(self, rec): + request = self.factory.post(f"/api/channels/recordings/{rec.id}/refresh-artwork/") + force_authenticate(request, user=self.user) + view = RecordingViewSet.as_view({"post": "refresh_artwork"}) + return view(request, pk=rec.id) + + def _make_rec(self, custom_properties=None): + now = timezone.now() + return Recording.objects.create( + channel=self.channel, + start_time=now - timedelta(hours=1), + end_time=now + timedelta(hours=1), + custom_properties=custom_properties or {}, + ) + + @patch("threading.Thread") + def test_returns_200_immediately(self, mock_thread): + mock_thread.return_value.start = MagicMock() + rec = self._make_rec() + response = self._refresh(rec) + self.assertEqual(response.status_code, 200) + self.assertTrue(response.data.get("success")) + + @patch("threading.Thread") + def test_spawns_background_thread(self, mock_thread): + mock_thread.return_value.start = MagicMock() + rec = self._make_rec() + self._refresh(rec) + mock_thread.assert_called_once() + self.assertTrue(mock_thread.call_args[1].get("daemon", False)) + mock_thread.return_value.start.assert_called_once() + + def test_returns_404_for_nonexistent(self): + request = self.factory.post("/api/channels/recordings/99999/refresh-artwork/") + force_authenticate(request, user=self.user) + view = RecordingViewSet.as_view({"post": "refresh_artwork"}) + self.assertEqual(view(request, pk=99999).status_code, 404) + + @patch("django.db.close_old_connections") + @patch("core.utils.send_websocket_update", side_effect=lambda *a, **kw: None) + def test_no_downgrade_to_channel_logo(self, _ws, _close): + """When the pipeline returns the channel's own logo, existing poster is preserved.""" + logo = Logo.objects.create(name="Channel Logo", url="https://example.com/ch.png") + self.channel.logo = logo + self.channel.save() + rec = self._make_rec({ + "poster_logo_id": 999, # existing real poster + "poster_url": "https://tmdb.com/real-poster.jpg", + }) + + with patch("apps.channels.tasks._resolve_poster_for_program", + return_value=(logo.id, None)): + request = self.factory.post(f"/api/channels/recordings/{rec.id}/refresh-artwork/") + force_authenticate(request, user=self.user) + + # Run synchronously by intercepting the thread + captured_fn = None + def capture_thread(*args, **kwargs): + nonlocal captured_fn + captured_fn = kwargs.get("target") or args[0] + mock = MagicMock() + mock.start = lambda: captured_fn(*kwargs.get("args", ())) + return mock + + with patch("threading.Thread", side_effect=capture_thread): + view = RecordingViewSet.as_view({"post": "refresh_artwork"}) + view(request, pk=rec.id) + + rec.refresh_from_db() + # Existing poster should be preserved — not downgraded to channel logo + self.assertEqual(rec.custom_properties.get("poster_logo_id"), 999) + self.assertEqual(rec.custom_properties.get("poster_url"), "https://tmdb.com/real-poster.jpg") + + @patch("django.db.close_old_connections") + @patch("core.utils.send_websocket_update", side_effect=lambda *a, **kw: None) + def test_upgrade_from_no_poster(self, _ws, _close): + """When a recording has no poster and the pipeline finds one, it gets updated.""" + rec = self._make_rec({"program": {"title": "Some Show", "id": 42}}) + + with patch("apps.channels.tasks._resolve_poster_for_program", + return_value=(555, "https://tmdb.com/new-poster.jpg")): + captured_fn = None + def capture_thread(*args, **kwargs): + nonlocal captured_fn + captured_fn = kwargs.get("target") or args[0] + mock = MagicMock() + mock.start = lambda: captured_fn(*kwargs.get("args", ())) + return mock + + with patch("threading.Thread", side_effect=capture_thread): + request = self.factory.post(f"/api/channels/recordings/{rec.id}/refresh-artwork/") + force_authenticate(request, user=self.user) + view = RecordingViewSet.as_view({"post": "refresh_artwork"}) + view(request, pk=rec.id) + + rec.refresh_from_db() + self.assertEqual(rec.custom_properties.get("poster_logo_id"), 555) + self.assertEqual(rec.custom_properties.get("poster_url"), "https://tmdb.com/new-poster.jpg") + + +# --------------------------------------------------------------------------- +# Logo proxy negative cache +# --------------------------------------------------------------------------- + +class LogoNegativeCacheTests(TestCase): + """Tests for the _logo_fetch_failures negative cache in LogoViewSet.cache().""" + + def setUp(self): + from apps.channels import api_views + self._failures = api_views._logo_fetch_failures + self._failures.clear() + self.factory = APIRequestFactory() + self.user = _make_admin() + + def _fetch_logo(self, logo): + request = self.factory.get(f"/api/channels/logos/{logo.id}/cache/") + force_authenticate(request, user=self.user) + view = LogoViewSet.as_view({"get": "cache"}) + return view(request, pk=logo.id) + + def test_failed_url_cached_on_non_200(self): + """Non-200 response adds URL to negative cache.""" + logo = Logo.objects.create(name="Dead Logo", url="https://dead-cdn.com/logo.png") + mock_resp = MagicMock(status_code=404) + with patch("apps.channels.api_views.requests.get", return_value=mock_resp), \ + patch("apps.channels.api_views.CoreSettings.get_default_user_agent_id", return_value="1"), \ + patch("apps.channels.api_views.UserAgent.objects.get", return_value=MagicMock(user_agent="Test/1.0")): + response = self._fetch_logo(logo) + self.assertEqual(response.status_code, 404) + self.assertIn("https://dead-cdn.com/logo.png", self._failures) + + def test_cached_failure_returns_404_immediately(self): + """Subsequent request for a cached-failed URL returns 404 without making a request.""" + logo = Logo.objects.create(name="Cached Fail", url="https://cached-fail.com/logo.png") + self._failures["https://cached-fail.com/logo.png"] = time_mod.monotonic() + 300 + + with patch("apps.channels.api_views.requests.get") as mock_get: + response = self._fetch_logo(logo) + self.assertEqual(response.status_code, 404) + mock_get.assert_not_called() + + def test_expired_cache_entry_allows_retry(self): + """After TTL expires, a new request is made.""" + logo = Logo.objects.create(name="Expired", url="https://expired.com/logo.png") + self._failures["https://expired.com/logo.png"] = time_mod.monotonic() - 1 # already expired + + mock_resp = MagicMock(status_code=200) + mock_resp.headers = {"Content-Type": "image/png"} + mock_resp.iter_content = MagicMock(return_value=[b"img"]) + with patch("apps.channels.api_views.requests.get", return_value=mock_resp), \ + patch("apps.channels.api_views.CoreSettings.get_default_user_agent_id", return_value="1"), \ + patch("apps.channels.api_views.UserAgent.objects.get", return_value=MagicMock(user_agent="Test/1.0")): + response = self._fetch_logo(logo) + self.assertEqual(response.status_code, 200) + + def test_success_clears_previous_failure(self): + """A successful fetch removes the URL from the failure cache.""" + url = "https://recovered.com/logo.png" + logo = Logo.objects.create(name="Recovered", url=url) + self._failures[url] = time_mod.monotonic() - 1 # expired + + mock_resp = MagicMock(status_code=200) + mock_resp.headers = {"Content-Type": "image/png"} + mock_resp.iter_content = MagicMock(return_value=[b"img"]) + with patch("apps.channels.api_views.requests.get", return_value=mock_resp), \ + patch("apps.channels.api_views.CoreSettings.get_default_user_agent_id", return_value="1"), \ + patch("apps.channels.api_views.UserAgent.objects.get", return_value=MagicMock(user_agent="Test/1.0")): + self._fetch_logo(logo) + self.assertNotIn(url, self._failures) + + def test_request_exception_cached(self): + """Network errors are cached the same as non-200 responses.""" + import requests + logo = Logo.objects.create(name="Timeout", url="https://timeout.com/logo.png") + with patch("apps.channels.api_views.requests.get", side_effect=requests.Timeout("timed out")), \ + patch("apps.channels.api_views.CoreSettings.get_default_user_agent_id", return_value="1"), \ + patch("apps.channels.api_views.UserAgent.objects.get", return_value=MagicMock(user_agent="Test/1.0")): + response = self._fetch_logo(logo) + self.assertEqual(response.status_code, 404) + self.assertIn("https://timeout.com/logo.png", self._failures) + + def test_eviction_when_cache_exceeds_256(self): + """Stale entries are evicted when the cache grows past 256.""" + now = time_mod.monotonic() + # Fill with 257 expired entries + for i in range(257): + self._failures[f"https://old-{i}.com/x.png"] = now - 1 # already expired + + logo = Logo.objects.create(name="Trigger", url="https://trigger-evict.com/logo.png") + import requests + with patch("apps.channels.api_views.requests.get", side_effect=requests.ConnectionError("fail")), \ + patch("apps.channels.api_views.CoreSettings.get_default_user_agent_id", return_value="1"), \ + patch("apps.channels.api_views.UserAgent.objects.get", return_value=MagicMock(user_agent="Test/1.0")): + self._fetch_logo(logo) + + # Expired entries should be evicted + old_entries = [k for k in self._failures if k.startswith("https://old-")] + self.assertEqual(len(old_entries), 0) + # New failure entry should exist + self.assertIn("https://trigger-evict.com/logo.png", self._failures) diff --git a/apps/channels/tests/test_recording_pipeline.py b/apps/channels/tests/test_recording_pipeline.py new file mode 100644 index 0000000..b136242 --- /dev/null +++ b/apps/channels/tests/test_recording_pipeline.py @@ -0,0 +1,524 @@ +"""Tests for recent DVR fixes. + +Covers: + 1. Collision avoidance: _build_output_paths checks both .mkv and .ts files + 2. Logo guard: _resolve_poster_for_program skips external APIs when title ≈ channel name + 3. Recording status lifecycle: status transitions visible via API + 4. Concat flags: error-tolerant ffmpeg flags used for segment concatenation + 5. Recovery skip-list: "recording" status NOT in terminal skip list +""" +import os +from datetime import timedelta +from unittest.mock import MagicMock, patch + +from django.test import TestCase +from django.utils import timezone +from rest_framework.test import APIRequestFactory, force_authenticate + +from apps.channels.models import Channel, Recording + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + +def _make_admin(): + from django.contrib.auth import get_user_model + User = get_user_model() + u, _ = User.objects.get_or_create( + username="dvr_fixes_admin", + defaults={"user_level": User.UserLevel.ADMIN}, + ) + u.set_password("pass") + u.save() + return u + + +def _make_channel(name="Test Channel", number=100): + return Channel.objects.create(channel_number=number, name=name) + + +def _make_recording(channel, **overrides): + now = timezone.now() + defaults = { + "channel": channel, + "start_time": now - timedelta(hours=1), + "end_time": now + timedelta(hours=1), + "custom_properties": {}, + } + defaults.update(overrides) + return Recording.objects.create(**defaults) + + +# ========================================================================= +# 1. Collision avoidance — _build_output_paths +# ========================================================================= + +class CollisionAvoidanceTests(TestCase): + """_build_output_paths must increment the filename counter when + EITHER the .mkv OR the .ts file already exists with size > 0.""" + + def _call(self, channel, program, start, end): + from apps.channels.tasks import _build_output_paths + return _build_output_paths(channel, program, start, end) + + @patch("apps.channels.tasks.CoreSettings.get_dvr_tv_fallback_template", + return_value="TV/{show}/{start}.mkv") + @patch("apps.channels.tasks.CoreSettings.get_dvr_tv_template", + return_value="TV/{show}/S{season:02d}E{episode:02d}.mkv") + def test_no_collision_when_nothing_exists(self, _tv, _fb): + """Fresh path — no files exist, counter stays at 1.""" + ch = MagicMock(name="TestCh") + ch.name = "TestCh" + program = {"title": "My Show"} + now = timezone.now() + + def mock_stat(path): + raise OSError("No such file") + + with patch("os.stat", side_effect=mock_stat), \ + patch("os.makedirs"): + final, ts, fname = self._call(ch, program, now, now + timedelta(hours=1)) + + # Should NOT have a _2 suffix + self.assertNotIn("_2", final) + self.assertTrue(final.endswith(".mkv")) + + @patch("apps.channels.tasks.CoreSettings.get_dvr_tv_fallback_template", + return_value="TV/{show}/{start}.mkv") + @patch("apps.channels.tasks.CoreSettings.get_dvr_tv_template", + return_value="TV/{show}/S{season:02d}E{episode:02d}.mkv") + def test_collision_when_ts_exists_but_mkv_is_zero_bytes(self, _tv, _fb): + """Pre-restart scenario: MKV is 0-byte placeholder, TS has real data. + The old code only checked MKV size, so it would reuse the path. + The fix also checks TS, so it must increment.""" + ch = MagicMock(name="TestCh") + ch.name = "TestCh" + program = {"title": "My Show"} + now = timezone.now() + + def mock_stat(path): + if "_2" in path: + raise OSError("No such file") + result = MagicMock() + if path.endswith('.mkv'): + result.st_size = 0 # MKV is 0-byte placeholder + elif path.endswith('.ts'): + result.st_size = 5000000 # TS has real data from pre-restart + else: + result.st_size = 0 + return result + + with patch("os.stat", side_effect=mock_stat), \ + patch("os.makedirs"): + final, ts, fname = self._call(ch, program, now, now + timedelta(hours=1)) + + # Must have incremented to _2 + self.assertIn("_2", final, "Should increment counter when TS file has data") + + @patch("apps.channels.tasks.CoreSettings.get_dvr_tv_fallback_template", + return_value="TV/{show}/{start}.mkv") + @patch("apps.channels.tasks.CoreSettings.get_dvr_tv_template", + return_value="TV/{show}/S{season:02d}E{episode:02d}.mkv") + def test_collision_when_mkv_has_data(self, _tv, _fb): + """Standard collision: MKV file has data, should increment.""" + ch = MagicMock(name="TestCh") + ch.name = "TestCh" + program = {"title": "My Show"} + now = timezone.now() + + def mock_stat(path): + if "_2" in path: + raise OSError("No such file") + result = MagicMock() + if path.endswith('.mkv'): + result.st_size = 1000000 # MKV has data + else: + result.st_size = 0 + return result + + with patch("os.stat", side_effect=mock_stat), \ + patch("os.makedirs"): + final, ts, fname = self._call(ch, program, now, now + timedelta(hours=1)) + + self.assertIn("_2", final, "Should increment counter when MKV file has data") + + @patch("apps.channels.tasks.CoreSettings.get_dvr_tv_fallback_template", + return_value="TV/{show}/{start}.mkv") + @patch("apps.channels.tasks.CoreSettings.get_dvr_tv_template", + return_value="TV/{show}/S{season:02d}E{episode:02d}.mkv") + def test_no_collision_when_both_zero_bytes(self, _tv, _fb): + """Both MKV and TS exist but are 0 bytes — no collision.""" + ch = MagicMock(name="TestCh") + ch.name = "TestCh" + program = {"title": "My Show"} + now = timezone.now() + + def mock_stat(path): + result = MagicMock() + result.st_size = 0 # All files empty + return result + + with patch("os.stat", side_effect=mock_stat), \ + patch("os.makedirs"): + final, ts, fname = self._call(ch, program, now, now + timedelta(hours=1)) + + self.assertNotIn("_2", final, "Should NOT increment when all files are empty") + + @patch("apps.channels.tasks.CoreSettings.get_dvr_tv_fallback_template", + return_value="TV/{show}/{start}.mkv") + @patch("apps.channels.tasks.CoreSettings.get_dvr_tv_template", + return_value="TV/{show}/S{season:02d}E{episode:02d}.mkv") + def test_collision_increments_to_3_when_2_also_occupied(self, _tv, _fb): + """When both base and _2 are occupied, should go to _3.""" + ch = MagicMock(name="TestCh") + ch.name = "TestCh" + program = {"title": "My Show"} + now = timezone.now() + + def mock_stat(path): + if "_3" in path: + raise OSError("No such file") + result = MagicMock() + if path.endswith('.ts'): + result.st_size = 5000000 + else: + result.st_size = 0 + return result + + with patch("os.stat", side_effect=mock_stat), \ + patch("os.makedirs"): + final, ts, fname = self._call(ch, program, now, now + timedelta(hours=1)) + + self.assertIn("_3", final, "Should increment to _3 when base and _2 are occupied") + + +# ========================================================================= +# 2. Logo guard — _resolve_poster_for_program +# ========================================================================= + +class LogoGuardTests(TestCase): + """When the program title matches the channel name, external API + searches (VOD, TMDB, OMDb, TVMaze, iTunes) must be skipped.""" + + def _call(self, channel_name, program, channel_logo_id=None): + from apps.channels.tasks import _resolve_poster_for_program + return _resolve_poster_for_program(channel_name, program, channel_logo_id) + + @patch("apps.channels.tasks.requests.get") + def test_channel_name_as_title_skips_external_apis(self, mock_get): + """Title = 'USA A&E SD*', channel = 'USA A&E SD*' → no external calls.""" + program = {"title": "USA A&E SD*"} + logo_id, url = self._call("USA A&E SD*", program, channel_logo_id=42) + + # Should NOT have called any external APIs + mock_get.assert_not_called() + # Should fall back to channel logo + self.assertEqual(logo_id, 42) + self.assertIsNone(url) + + @patch("apps.channels.tasks.requests.get") + def test_channel_name_normalized_match(self, mock_get): + """Title = 'fox news', channel = 'FOX-News*' → normalized match, skip APIs.""" + program = {"title": "fox news"} + logo_id, url = self._call("FOX-News*", program, channel_logo_id=99) + + mock_get.assert_not_called() + self.assertEqual(logo_id, 99) + + @patch("apps.channels.tasks.requests.get") + def test_real_title_still_searched(self, mock_get): + """Title = 'Breaking Bad' on channel 'AMC' → should try external APIs.""" + # Mock TVMaze returning a result + mock_resp = MagicMock(ok=True, status_code=200) + mock_resp.json.return_value = { + "image": {"original": "https://tvmaze.com/breaking-bad.jpg"} + } + mock_get.return_value = mock_resp + + program = {"title": "Breaking Bad"} + logo_id, url = self._call("AMC", program) + + # Should have made at least one external API call + self.assertTrue(mock_get.called, "Should search external APIs for real titles") + self.assertIsNotNone(url) + + @patch("apps.channels.tasks.requests.get") + def test_no_title_skips_to_channel_logo(self, mock_get): + """No title at all → falls through to channel logo, no API calls.""" + program = {} + logo_id, url = self._call("SomeChannel", program, channel_logo_id=55) + + mock_get.assert_not_called() + self.assertEqual(logo_id, 55) + + @patch("apps.channels.tasks.requests.get") + def test_epg_image_still_used_even_when_title_is_channel_name(self, mock_get): + """Even when title = channel name, Stage 1 (EPG images) should still work.""" + from apps.epg.models import ProgramData, EPGSource, EPGData + + # Create an EPG source + EPGData entry + program with an icon URL + epg_source = EPGSource.objects.create(source_type="xmltv", name="Test EPG") + epg_data = EPGData.objects.create(tvg_id="test.ch", epg_source=epg_source) + prog = ProgramData.objects.create( + epg=epg_data, + title="Test Channel HD", + start_time=timezone.now() - timedelta(hours=1), + end_time=timezone.now() + timedelta(hours=1), + custom_properties={"icon": "https://epg-cdn.com/test-icon.png"}, + ) + + program = {"title": "Test Channel HD", "id": prog.id} + + # Mock _validate_url to return True for the icon URL + with patch("apps.channels.tasks._validate_url", return_value=True): + logo_id, url = self._call("Test Channel HD", program, channel_logo_id=10) + + # EPG icon should still be used (Stage 1 doesn't depend on title guard) + self.assertEqual(url, "https://epg-cdn.com/test-icon.png") + mock_get.assert_not_called() + + +# ========================================================================= +# 3. Recording status lifecycle via API +# ========================================================================= + +class RecordingStatusLifecycleTests(TestCase): + """Verify recording status transitions and that terminal recordings + are properly filterable (supports the red-dot fix in guideUtils).""" + + def setUp(self): + self.channel = _make_channel("Status Test Channel", 200) + self.user = _make_admin() + self.factory = APIRequestFactory() + + def _list_recordings(self): + from apps.channels.api_views import RecordingViewSet + request = self.factory.get("/api/channels/recordings/") + force_authenticate(request, user=self.user) + view = RecordingViewSet.as_view({"get": "list"}) + return view(request) + + @patch("core.utils.send_websocket_update", side_effect=lambda *a, **kw: None) + def test_stopped_recording_has_terminal_status(self, _ws): + """After stop, custom_properties.status = 'stopped'.""" + from apps.channels.api_views import RecordingViewSet + + rec = _make_recording(self.channel, custom_properties={ + "status": "recording", + "program": {"id": 1, "title": "Live Show"}, + }) + + request = self.factory.post(f"/api/channels/recordings/{rec.id}/stop/") + force_authenticate(request, user=self.user) + view = RecordingViewSet.as_view({"post": "stop"}) + + with patch("apps.channels.signals.revoke_task"): + response = view(request, pk=rec.id) + + self.assertIn(response.status_code, [200, 204]) + rec.refresh_from_db() + self.assertEqual(rec.custom_properties.get("status"), "stopped") + + def test_listing_includes_status_in_custom_properties(self): + """API listing returns custom_properties with status field.""" + _make_recording(self.channel, custom_properties={ + "status": "recording", + "program": {"id": 1, "title": "Recording Show"}, + }) + _make_recording(self.channel, custom_properties={ + "status": "stopped", + "program": {"id": 2, "title": "Stopped Show"}, + }) + + response = self._list_recordings() + self.assertEqual(response.status_code, 200) + + statuses = [r["custom_properties"].get("status") for r in response.data] + self.assertIn("recording", statuses) + self.assertIn("stopped", statuses) + + @patch("core.utils.send_websocket_update", side_effect=lambda *a, **kw: None) + def test_delete_recording_removes_from_listing(self, _ws): + """Deleting a recording removes it from the listing entirely.""" + from apps.channels.api_views import RecordingViewSet + + rec = _make_recording(self.channel, custom_properties={ + "status": "stopped", + "program": {"id": 3, "title": "To Delete"}, + }) + rec_id = rec.id + + request = self.factory.delete(f"/api/channels/recordings/{rec_id}/") + force_authenticate(request, user=self.user) + view = RecordingViewSet.as_view({"delete": "destroy"}) + + with patch("apps.channels.signals.revoke_task"): + response = view(request, pk=rec_id) + + self.assertIn(response.status_code, [200, 204]) + self.assertFalse(Recording.objects.filter(id=rec_id).exists()) + + +# ========================================================================= +# 4. Concat flags — error-tolerant ffmpeg +# ========================================================================= + +class ConcatFlagsTests(TestCase): + """Verify that the finalize phase uses error-tolerant ffmpeg flags + when concatenating pre-restart segments.""" + + def test_concat_command_includes_error_tolerant_flags(self): + """Inspect the source code to confirm error-tolerant flags are present. + This is a static analysis test — no ffmpeg execution needed.""" + import inspect + from apps.channels.tasks import run_recording + source = inspect.getsource(run_recording) + + # The concat subprocess.run call must include these flags + self.assertIn("+genpts+igndts+discardcorrupt", source, + "Concat must use +genpts+igndts+discardcorrupt fflags") + self.assertIn("ignore_err", source, + "Concat must use -err_detect ignore_err") + self.assertIn("-f", source) + self.assertIn("concat", source) + + def test_concat_goes_directly_to_mkv(self): + """Concat must produce MKV directly (not intermediate .ts) to + preserve timestamp boundaries and avoid playback freeze at splice.""" + import inspect + from apps.channels.tasks import run_recording + source = inspect.getsource(run_recording) + + # Must contain reset_timestamps for proper segment boundary handling + self.assertIn("reset_timestamps", source, + "Concat must use -reset_timestamps 1 for seamless seeking") + # Must write directly to final_path (MKV), not an intermediate .ts + self.assertIn("_concat_did_remux", source, + "Concat path must set flag to skip separate remux step") + + def test_segment_time_metadata_present(self): + """Verify concat uses -segment_time_metadata for boundary awareness.""" + import inspect + from apps.channels.tasks import run_recording + source = inspect.getsource(run_recording) + + self.assertIn("segment_time_metadata", source, + "Concat must use -segment_time_metadata 1 for segment boundary handling") + + +# ========================================================================= +# 5. Recovery skip-list +# ========================================================================= + +class RecoverySkipListTests(TestCase): + """Verify that the recovery function does NOT skip 'recording' status, + since that's the exact status recordings have when the server crashes.""" + + def test_recording_status_not_in_skip_list(self): + """Inspect recover_recordings_on_startup to ensure 'recording' is + NOT treated as a terminal/skip state.""" + import inspect + from apps.channels.tasks import recover_recordings_on_startup + source = inspect.getsource(recover_recordings_on_startup) + + # Find the skip condition line + # It should be: if current_status in ("completed", "stopped"): + # NOT: if current_status in ("completed", "stopped", "recording"): + lines = source.split('\n') + skip_line = None + for line in lines: + if 'current_status in' in line and ('completed' in line or 'stopped' in line): + skip_line = line.strip() + break + + self.assertIsNotNone(skip_line, "Should find the skip-list condition") + self.assertNotIn('"recording"', skip_line, + "Skip list must NOT contain 'recording' — " + "that's the status of crashed mid-stream recordings that need recovery") + + @patch("core.utils.RedisClient") + @patch("apps.channels.tasks.run_recording") + @patch("core.utils.send_websocket_update", side_effect=lambda *a, **kw: None) + def test_recovery_processes_recording_status(self, _ws, mock_run, mock_redis_cls): + """A recording with status='recording' should be recovered, not skipped.""" + mock_redis_conn = MagicMock() + mock_redis_conn.set.return_value = True # Acquire lock + mock_redis_cls.get_client.return_value = mock_redis_conn + + channel = _make_channel("Recovery Test", 300) + now = timezone.now() + rec = _make_recording(channel, custom_properties={ + "status": "recording", + "program": {"title": "Crashed Show"}, + }, end_time=now + timedelta(hours=2)) + + from apps.channels.tasks import recover_recordings_on_startup + + with patch("apps.channels.signals.revoke_task"): + result = recover_recordings_on_startup() + + # The recording should have been dispatched for recovery + self.assertTrue(mock_run.apply_async.called, + "Recording with status='recording' should be dispatched for recovery") + + @patch("core.utils.RedisClient") + @patch("apps.channels.tasks.run_recording") + @patch("core.utils.send_websocket_update", side_effect=lambda *a, **kw: None) + def test_recovery_skips_stopped_recordings(self, _ws, mock_run, mock_redis_cls): + """A recording with status='stopped' should be skipped by recovery.""" + mock_redis_conn = MagicMock() + mock_redis_conn.set.return_value = True + mock_redis_cls.get_client.return_value = mock_redis_conn + + channel = _make_channel("Recovery Skip Test", 301) + now = timezone.now() + rec = _make_recording(channel, custom_properties={ + "status": "stopped", + "program": {"title": "Finished Show"}, + }, end_time=now + timedelta(hours=2)) + + from apps.channels.tasks import recover_recordings_on_startup + with patch("apps.channels.signals.revoke_task"): + recover_recordings_on_startup() + + # Should NOT have dispatched a recovery task + mock_run.apply_async.assert_not_called() + + +# ========================================================================= +# 6. Frontend red-dot filter (guideUtils.mapRecordingsByProgramId) +# ========================================================================= + +class MapRecordingsByProgramIdTests(TestCase): + """These test the BACKEND side — confirming that recording status + is preserved in the API response so the frontend can filter on it. + + The actual frontend filtering is covered by frontend/src/pages/__tests__/DVR.test.jsx + and the guideUtils code, but we verify the data contract here.""" + + def test_recording_custom_properties_status_persisted(self): + """Recording status in custom_properties survives save/load cycle.""" + channel = _make_channel("Red Dot Test", 400) + rec = _make_recording(channel, custom_properties={ + "status": "stopped", + "program": {"id": 42, "title": "A Show"}, + }) + + rec.refresh_from_db() + self.assertEqual(rec.custom_properties["status"], "stopped") + + def test_terminal_statuses_are_well_defined(self): + """Verify the terminal status set matches what the frontend uses.""" + # These are the statuses that should NOT show a red dot in the Guide + terminal = {"stopped", "completed", "interrupted", "failed"} + channel = _make_channel("Terminal Status Test", 410) + + # Verify each status is a valid recording status + for status in terminal: + rec = _make_recording(channel, custom_properties={ + "status": status, + "program": {"id": 100, "title": "Test"}, + }) + rec.refresh_from_db() + self.assertEqual(rec.custom_properties["status"], status) diff --git a/apps/channels/tests/test_recording_scheduling.py b/apps/channels/tests/test_recording_scheduling.py new file mode 100644 index 0000000..c615c15 --- /dev/null +++ b/apps/channels/tests/test_recording_scheduling.py @@ -0,0 +1,585 @@ +"""Tests for DVR recording scheduling with ClockedSchedule. + +Uses ClockedSchedule instead of apply_async with countdown because Redis +visibility_timeout (default 3600s) causes task redelivery for long countdowns, +leading to duplicate recordings. +""" +from datetime import timedelta +from unittest.mock import patch, MagicMock + +from django.test import TestCase +from django.utils import timezone +from django_celery_beat.models import ClockedSchedule, PeriodicTask + +from apps.channels.models import Channel, Recording +from apps.channels.signals import ( + schedule_recording_task, + revoke_task, + _dvr_task_name, +) + + +class ScheduleRecordingTaskTests(TestCase): + """Tests for schedule_recording_task().""" + + def setUp(self): + self.channel = Channel.objects.create(channel_number=1, name="Test Channel") + + def tearDown(self): + PeriodicTask.objects.filter(name__startswith="dvr-recording-").delete() + ClockedSchedule.objects.all().delete() + + @patch("apps.channels.signals.run_recording") + def test_future_recording_creates_periodic_task(self, mock_run_recording): + """Recordings in the future create a ClockedSchedule + PeriodicTask.""" + future_time = timezone.now() + timedelta(hours=2) + rec = Recording.objects.create( + channel=self.channel, + start_time=future_time, + end_time=future_time + timedelta(hours=1), + ) + + task_id = schedule_recording_task(rec, eta=future_time) + + expected_name = f"dvr-recording-{rec.id}" + self.assertEqual(task_id, expected_name) + + pt = PeriodicTask.objects.get(name=expected_name) + self.assertTrue(pt.one_off) + self.assertTrue(pt.enabled) + self.assertEqual(pt.task, "apps.channels.tasks.run_recording") + self.assertIsNotNone(pt.clocked) + + # apply_async should not have been called + mock_run_recording.apply_async.assert_not_called() + + @patch("apps.channels.signals.run_recording") + def test_immediate_recording_creates_periodic_task(self, mock_run_recording): + """Recordings starting now also use ClockedSchedule for consistency.""" + now = timezone.now() + rec = Recording.objects.create( + channel=self.channel, + start_time=now, + end_time=now + timedelta(hours=1), + ) + + task_id = schedule_recording_task(rec, eta=now) + + expected_name = f"dvr-recording-{rec.id}" + self.assertEqual(task_id, expected_name) + self.assertTrue(PeriodicTask.objects.filter(name=expected_name).exists()) + + @patch("apps.channels.signals.run_recording") + def test_past_start_time_clamps_to_now(self, mock_run_recording): + """Recordings with past start_time get clamped to now.""" + past_time = timezone.now() - timedelta(minutes=5) + rec = Recording.objects.create( + channel=self.channel, + start_time=past_time, + end_time=timezone.now() + timedelta(hours=1), + ) + + task_id = schedule_recording_task(rec, eta=past_time) + + expected_name = f"dvr-recording-{rec.id}" + self.assertEqual(task_id, expected_name) + pt = PeriodicTask.objects.get(name=expected_name) + # Clocked time should be >= now + self.assertGreaterEqual(pt.clocked.clocked_time, past_time) + + @patch("apps.channels.signals.run_recording") + def test_reschedule_updates_existing_periodic_task(self, mock_run_recording): + """Calling schedule_recording_task twice updates the existing PeriodicTask.""" + future_time = timezone.now() + timedelta(hours=2) + rec = Recording.objects.create( + channel=self.channel, + start_time=future_time, + end_time=future_time + timedelta(hours=1), + ) + + schedule_recording_task(rec, eta=future_time) + + # Reschedule with a different time + new_eta = future_time + timedelta(hours=1) + schedule_recording_task(rec, eta=new_eta) + + # Should still be exactly one PeriodicTask + task_name = f"dvr-recording-{rec.id}" + self.assertEqual(PeriodicTask.objects.filter(name=task_name).count(), 1) + + @patch("apps.channels.signals.run_recording") + def test_naive_eta_is_made_aware(self, mock_run_recording): + """A naive (timezone-unaware) eta is made timezone-aware.""" + from datetime import datetime + naive_eta = datetime(2030, 6, 15, 14, 0, 0) + rec = Recording.objects.create( + channel=self.channel, + start_time=timezone.now() + timedelta(hours=1), + end_time=timezone.now() + timedelta(hours=2), + ) + + task_id = schedule_recording_task(rec, eta=naive_eta) + + expected_name = f"dvr-recording-{rec.id}" + self.assertEqual(task_id, expected_name) + pt = PeriodicTask.objects.get(name=expected_name) + self.assertTrue(timezone.is_aware(pt.clocked.clocked_time)) + + +class RevokeTaskTests(TestCase): + """Tests for revoke_task().""" + + def setUp(self): + self.channel = Channel.objects.create(channel_number=1, name="Test Channel") + + def tearDown(self): + PeriodicTask.objects.filter(name__startswith="dvr-recording-").delete() + ClockedSchedule.objects.all().delete() + + def test_revoke_deletes_periodic_task_and_clocked_schedule(self): + """revoke_task deletes the PeriodicTask and orphaned ClockedSchedule.""" + eta = timezone.now() + timedelta(hours=5) + clocked = ClockedSchedule.objects.create(clocked_time=eta) + PeriodicTask.objects.create( + name="dvr-recording-10", + task="apps.channels.tasks.run_recording", + clocked=clocked, + one_off=True, + enabled=True, + ) + + revoke_task("dvr-recording-10") + + self.assertFalse(PeriodicTask.objects.filter(name="dvr-recording-10").exists()) + self.assertFalse(ClockedSchedule.objects.filter(id=clocked.id).exists()) + + def test_revoke_keeps_shared_clocked_schedule(self): + """ClockedSchedule is kept if another PeriodicTask still references it.""" + eta = timezone.now() + timedelta(hours=5) + clocked = ClockedSchedule.objects.create(clocked_time=eta) + PeriodicTask.objects.create( + name="dvr-recording-10", + task="apps.channels.tasks.run_recording", + clocked=clocked, + one_off=True, + ) + PeriodicTask.objects.create( + name="dvr-recording-11", + task="apps.channels.tasks.run_recording", + clocked=clocked, + one_off=True, + ) + + revoke_task("dvr-recording-10") + + self.assertFalse(PeriodicTask.objects.filter(name="dvr-recording-10").exists()) + self.assertTrue(ClockedSchedule.objects.filter(id=clocked.id).exists()) + + @patch("apps.channels.signals.AsyncResult") + def test_revoke_falls_back_to_async_result_for_legacy_ids(self, mock_async_result): + """revoke_task falls back to AsyncResult.revoke() for old-style UUIDs.""" + revoke_task("550e8400-e29b-41d4-a716-446655440000") + + mock_async_result.assert_called_once_with("550e8400-e29b-41d4-a716-446655440000") + mock_async_result.return_value.revoke.assert_called_once() + + def test_revoke_none_is_noop(self): + """revoke_task(None) does nothing.""" + revoke_task(None) # Should not raise + + def test_revoke_empty_string_is_noop(self): + """revoke_task('') does nothing.""" + revoke_task("") # Should not raise + + +class DvrTaskNameTests(TestCase): + """Tests for the naming convention helper.""" + + def test_task_name_format(self): + self.assertEqual(_dvr_task_name(42), "dvr-recording-42") + + def test_task_name_fits_in_charfield(self): + name = _dvr_task_name(999999999) + self.assertLessEqual(len(name), 255) + + +class SignalIntegrationTests(TestCase): + """Integration tests for the post_save / post_delete signal handlers.""" + + def setUp(self): + self.channel = Channel.objects.create(channel_number=1, name="Test Channel") + + def tearDown(self): + PeriodicTask.objects.filter(name__startswith="dvr-recording-").delete() + ClockedSchedule.objects.all().delete() + + @patch("apps.channels.signals.prefetch_recording_artwork") + def test_post_save_creates_periodic_task_for_future_recording(self, mock_artwork): + """Saving a future Recording creates a PeriodicTask via post_save signal.""" + mock_artwork.apply_async.return_value = MagicMock() + + future = timezone.now() + timedelta(hours=2) + rec = Recording.objects.create( + channel=self.channel, + start_time=future, + end_time=future + timedelta(hours=1), + ) + + rec.refresh_from_db() + task_name = f"dvr-recording-{rec.id}" + self.assertEqual(rec.task_id, task_name) + self.assertTrue(PeriodicTask.objects.filter(name=task_name).exists()) + + @patch("apps.channels.signals.prefetch_recording_artwork") + def test_post_delete_removes_periodic_task(self, mock_artwork): + """Deleting a Recording removes its PeriodicTask.""" + mock_artwork.apply_async.return_value = MagicMock() + + future = timezone.now() + timedelta(hours=2) + rec = Recording.objects.create( + channel=self.channel, + start_time=future, + end_time=future + timedelta(hours=1), + ) + + rec.refresh_from_db() + task_name = rec.task_id + self.assertTrue(PeriodicTask.objects.filter(name=task_name).exists()) + + rec.delete() + self.assertFalse(PeriodicTask.objects.filter(name=task_name).exists()) + + @patch("apps.channels.signals.prefetch_recording_artwork") + def test_bulk_delete_cleans_up_all_periodic_tasks(self, mock_artwork): + """Bulk deleting recordings cleans up all their PeriodicTasks.""" + mock_artwork.apply_async.return_value = MagicMock() + + future = timezone.now() + timedelta(hours=2) + rec_ids = [] + for i in range(5): + rec = Recording.objects.create( + channel=self.channel, + start_time=future + timedelta(hours=i), + end_time=future + timedelta(hours=i + 1), + ) + rec_ids.append(rec.id) + + for rid in rec_ids: + self.assertTrue( + PeriodicTask.objects.filter(name=f"dvr-recording-{rid}").exists() + ) + + Recording.objects.filter(channel=self.channel).delete() + + self.assertEqual( + PeriodicTask.objects.filter(name__startswith="dvr-recording-").count(), 0 + ) + + @patch("apps.channels.signals.prefetch_recording_artwork") + def test_post_save_schedules_currently_playing_recording(self, mock_artwork): + """A recording with past start_time but future end_time schedules immediately.""" + mock_artwork.apply_async.return_value = MagicMock() + + past_start = timezone.now() - timedelta(minutes=30) + future_end = timezone.now() + timedelta(minutes=30) + rec = Recording.objects.create( + channel=self.channel, + start_time=past_start, + end_time=future_end, + ) + + rec.refresh_from_db() + task_name = f"dvr-recording-{rec.id}" + self.assertEqual(rec.task_id, task_name) + self.assertTrue(PeriodicTask.objects.filter(name=task_name).exists()) + + @patch("apps.channels.signals.prefetch_recording_artwork") + def test_post_save_skips_fully_past_recording(self, mock_artwork): + """A recording with both start_time and end_time in the past is not scheduled.""" + mock_artwork.apply_async.return_value = MagicMock() + + past_start = timezone.now() - timedelta(hours=2) + past_end = timezone.now() - timedelta(hours=1) + rec = Recording.objects.create( + channel=self.channel, + start_time=past_start, + end_time=past_end, + ) + + rec.refresh_from_db() + self.assertIsNone(rec.task_id) + self.assertFalse( + PeriodicTask.objects.filter(name=f"dvr-recording-{rec.id}").exists() + ) + + @patch("apps.channels.signals.prefetch_recording_artwork") + def test_pre_save_revokes_on_time_change(self, mock_artwork): + """Changing a recording's start_time revokes the old task and creates a new one.""" + mock_artwork.apply_async.return_value = MagicMock() + + future = timezone.now() + timedelta(hours=2) + rec = Recording.objects.create( + channel=self.channel, + start_time=future, + end_time=future + timedelta(hours=1), + ) + + rec.refresh_from_db() + old_task_name = rec.task_id + self.assertTrue(PeriodicTask.objects.filter(name=old_task_name).exists()) + + # Change the start time — pre_save clears task_id, post_save reschedules + new_future = future + timedelta(hours=3) + rec.start_time = new_future + rec.end_time = new_future + timedelta(hours=1) + rec.save() + + rec.refresh_from_db() + # Old PeriodicTask should be deleted; new one should exist + self.assertIsNotNone(rec.task_id) + self.assertTrue( + PeriodicTask.objects.filter(name=f"dvr-recording-{rec.id}").exists() + ) + + +class IdempotencyGuardTests(TestCase): + """Tests for the idempotency guard in run_recording().""" + + def setUp(self): + self.channel = Channel.objects.create(channel_number=1, name="Test Channel") + + @patch("apps.channels.tasks.get_channel_layer") + def test_skips_if_already_recording(self, mock_layer): + """run_recording returns early if status is already 'recording'.""" + now = timezone.now() + rec = Recording.objects.create( + channel=self.channel, + start_time=now, + end_time=now + timedelta(hours=1), + custom_properties={"status": "recording", "started_at": str(now)}, + ) + + from apps.channels.tasks import run_recording as run_rec_task + result = run_rec_task(rec.id, self.channel.id, str(now), str(now + timedelta(hours=1))) + + self.assertIsNone(result) + # get_channel_layer should not have been called (returned before) + mock_layer.assert_not_called() + + @patch("apps.channels.tasks.get_channel_layer") + def test_skips_if_already_completed(self, mock_layer): + """run_recording returns early if status is already 'completed'.""" + now = timezone.now() + rec = Recording.objects.create( + channel=self.channel, + start_time=now - timedelta(hours=2), + end_time=now - timedelta(hours=1), + custom_properties={"status": "completed"}, + ) + + from apps.channels.tasks import run_recording as run_rec_task + result = run_rec_task(rec.id, self.channel.id, str(rec.start_time), str(rec.end_time)) + + self.assertIsNone(result) + mock_layer.assert_not_called() + + @patch("apps.channels.tasks.get_channel_layer") + def test_skips_if_already_stopped(self, mock_layer): + """run_recording returns early if status is already 'stopped' (user stopped it early).""" + now = timezone.now() + rec = Recording.objects.create( + channel=self.channel, + start_time=now - timedelta(hours=1), + end_time=now + timedelta(hours=1), + custom_properties={"status": "stopped", "stopped_at": str(now)}, + ) + + from apps.channels.tasks import run_recording as run_rec_task + result = run_rec_task(rec.id, self.channel.id, str(rec.start_time), str(rec.end_time)) + + self.assertIsNone(result) + mock_layer.assert_not_called() + + +class ArtworkPrefetchSignalGuardTests(TestCase): + """Tests that the post_save signal does not schedule artwork prefetch when + the recording is in an active or terminal state.""" + + def setUp(self): + self.channel = Channel.objects.create(channel_number=1, name="Test Channel") + + def tearDown(self): + PeriodicTask.objects.filter(name__startswith="dvr-recording-").delete() + ClockedSchedule.objects.all().delete() + + @patch("apps.channels.signals.prefetch_recording_artwork") + def test_artwork_prefetch_not_scheduled_when_status_recording(self, mock_artwork): + """post_save must NOT schedule artwork prefetch when status='recording' + to prevent a race that overwrites the running task's status updates.""" + future = timezone.now() + timedelta(hours=2) + rec = Recording.objects.create( + channel=self.channel, + start_time=future, + end_time=future + timedelta(hours=1), + custom_properties={"status": "recording"}, + ) + + # Simulate a save that run_recording itself might do mid-recording + rec.custom_properties = {"status": "recording", "file_path": "/data/recordings/test.mkv"} + rec.save(update_fields=["custom_properties"]) + + # apply_async was not called for the "recording" save + mock_artwork.apply_async.assert_not_called() + + @patch("apps.channels.signals.prefetch_recording_artwork") + def test_artwork_prefetch_not_scheduled_when_status_completed(self, mock_artwork): + """post_save must NOT schedule artwork prefetch when status='completed'.""" + future = timezone.now() + timedelta(hours=2) + rec = Recording.objects.create( + channel=self.channel, + start_time=future, + end_time=future + timedelta(hours=1), + custom_properties={"status": "completed"}, + ) + + rec.custom_properties = {"status": "completed"} + rec.save(update_fields=["custom_properties"]) + + mock_artwork.apply_async.assert_not_called() + + @patch("apps.channels.signals.prefetch_recording_artwork") + def test_artwork_prefetch_not_scheduled_when_status_stopped(self, mock_artwork): + """post_save must NOT schedule artwork prefetch when status='stopped'.""" + future = timezone.now() + timedelta(hours=2) + rec = Recording.objects.create( + channel=self.channel, + start_time=future, + end_time=future + timedelta(hours=1), + custom_properties={"status": "stopped"}, + ) + + rec.custom_properties = {"status": "stopped"} + rec.save(update_fields=["custom_properties"]) + + mock_artwork.apply_async.assert_not_called() + + @patch("apps.channels.signals.prefetch_recording_artwork") + def test_artwork_prefetch_scheduled_for_new_upcoming_recording(self, mock_artwork): + """post_save SHOULD schedule artwork prefetch for a newly created upcoming recording.""" + mock_artwork.apply_async.return_value = MagicMock() + future = timezone.now() + timedelta(hours=2) + Recording.objects.create( + channel=self.channel, + start_time=future, + end_time=future + timedelta(hours=1), + custom_properties={}, # no status yet — should trigger prefetch + ) + + self.assertTrue(mock_artwork.apply_async.called) + + +class DestroyDvrClientIsolationTests(TestCase): + """Tests that deleting a recording only stops DVR clients when the + recording is actively streaming — never for completed/upcoming recordings + that could share a channel with an unrelated in-progress recording.""" + + def setUp(self): + from django.contrib.auth import get_user_model + from rest_framework.test import APIRequestFactory, force_authenticate + self.channel = Channel.objects.create(channel_number=1, name="Test Channel") + User = get_user_model() + self.user = User.objects.create_user( + username="dvr_test_admin", password="pass", + user_level=User.UserLevel.ADMIN, + ) + self.factory = APIRequestFactory() + self.force_authenticate = force_authenticate + + def _delete_recording(self, rec): + from apps.channels.api_views import RecordingViewSet + request = self.factory.delete(f"/api/channels/recordings/{rec.id}/") + self.force_authenticate(request, user=self.user) + view = RecordingViewSet.as_view({"delete": "destroy"}) + return view(request, pk=rec.id) + + @patch("apps.channels.api_views._stop_dvr_clients") + def test_destroy_completed_recording_does_not_stop_dvr_clients(self, mock_stop): + """Deleting a completed recording must NOT call _stop_dvr_clients.""" + rec = Recording.objects.create( + channel=self.channel, + start_time=timezone.now() - timedelta(hours=2), + end_time=timezone.now() - timedelta(hours=1), + custom_properties={"status": "completed", "file_path": "/data/recordings/test.mkv"}, + ) + self._delete_recording(rec) + mock_stop.assert_not_called() + + @patch("apps.channels.api_views._stop_dvr_clients") + def test_destroy_upcoming_recording_does_not_stop_dvr_clients(self, mock_stop): + """Deleting an upcoming (scheduled) recording must NOT call _stop_dvr_clients.""" + future = timezone.now() + timedelta(hours=2) + rec = Recording.objects.create( + channel=self.channel, + start_time=future, + end_time=future + timedelta(hours=1), + custom_properties={}, + ) + self._delete_recording(rec) + mock_stop.assert_not_called() + + @patch("apps.channels.api_views._stop_dvr_clients") + def test_destroy_active_recording_does_stop_dvr_clients(self, mock_stop): + """Deleting an in-progress recording MUST call _stop_dvr_clients.""" + mock_stop.return_value = 1 + now = timezone.now() + rec = Recording.objects.create( + channel=self.channel, + start_time=now - timedelta(minutes=5), + end_time=now + timedelta(hours=1), + custom_properties={"status": "recording"}, + ) + self._delete_recording(rec) + mock_stop.assert_called_once_with(str(self.channel.uuid), recording_id=rec.id) + + +class PeriodicTaskCleanupOnExecutionTests(TestCase): + """Tests for PeriodicTask cleanup when run_recording starts.""" + + def setUp(self): + self.channel = Channel.objects.create(channel_number=1, name="Test Channel") + + def tearDown(self): + PeriodicTask.objects.filter(name__startswith="dvr-recording-").delete() + ClockedSchedule.objects.all().delete() + + @patch("apps.channels.signals.prefetch_recording_artwork") + @patch("apps.channels.tasks.get_channel_layer") + def test_periodic_task_cleaned_up_on_execution(self, mock_layer, mock_artwork): + """When run_recording executes, it deletes its own PeriodicTask.""" + mock_layer.return_value = MagicMock() + mock_artwork.apply_async.return_value = MagicMock() + + future = timezone.now() + timedelta(hours=2) + rec = Recording.objects.create( + channel=self.channel, + start_time=future, + end_time=future + timedelta(hours=1), + custom_properties={}, + ) + + # post_save signal should have created the PeriodicTask + task_name = f"dvr-recording-{rec.id}" + self.assertTrue(PeriodicTask.objects.filter(name=task_name).exists()) + pt = PeriodicTask.objects.get(name=task_name) + clocked_id = pt.clocked_id + + from apps.channels.tasks import run_recording as run_rec_task + # This will proceed past guards, clean up the PeriodicTask, then + # eventually fail on the actual stream connection (expected) + try: + run_rec_task(rec.id, self.channel.id, str(future), str(future + timedelta(hours=1))) + except Exception: + pass + + self.assertFalse(PeriodicTask.objects.filter(name=task_name).exists()) + self.assertFalse(ClockedSchedule.objects.filter(id=clocked_id).exists()) diff --git a/apps/channels/tests/test_recording_stop_cancel.py b/apps/channels/tests/test_recording_stop_cancel.py new file mode 100644 index 0000000..e4b22be --- /dev/null +++ b/apps/channels/tests/test_recording_stop_cancel.py @@ -0,0 +1,356 @@ +"""Tests for the DVR Stop/Cancel feature set. + +Covers: + - stop() endpoint + - destroy() was_in_progress field in recording_cancelled WebSocket event + - signals.py update_fields re-entrancy guard + - run_recording race guard before status write + - _stop_dvr_clients() DVR client isolation +""" +from datetime import timedelta +from unittest.mock import MagicMock, AsyncMock, patch + +from django.test import TestCase +from django.utils import timezone +from rest_framework.test import APIRequestFactory, force_authenticate + +from apps.channels.models import Channel, Recording +from apps.channels.api_views import RecordingViewSet, _stop_dvr_clients + + +def _make_admin(): + from django.contrib.auth import get_user_model + User = get_user_model() + u, _ = User.objects.get_or_create( + username="stop_test_admin", + defaults={"user_level": User.UserLevel.ADMIN}, + ) + u.set_password("pass") + u.save() + return u + + +def _async_channel_layer_mock(): + layer = MagicMock() + layer.group_send = AsyncMock() + return layer + + +class StopEndpointTests(TestCase): + """Tests for POST /api/channels/recordings/{id}/stop/""" + + def setUp(self): + self.channel = Channel.objects.create(channel_number=99, name="Stop Test Channel") + self.user = _make_admin() + self.factory = APIRequestFactory() + + def _stop(self, rec): + request = self.factory.post(f"/api/channels/recordings/{rec.id}/stop/") + force_authenticate(request, user=self.user) + view = RecordingViewSet.as_view({"post": "stop"}) + return view(request, pk=rec.id) + + def _make_rec(self, status="recording"): + now = timezone.now() + return Recording.objects.create( + channel=self.channel, + start_time=now - timedelta(hours=1), + end_time=now + timedelta(hours=1), + custom_properties={"status": status}, + ) + + @patch("core.utils.send_websocket_update") + @patch("threading.Thread") + def test_stop_writes_status_to_db_before_returning(self, mock_thread, mock_ws): + """DB write is synchronous — run_recording polls for this.""" + mock_thread.return_value.start = MagicMock() + rec = self._make_rec() + response = self._stop(rec) + self.assertEqual(response.status_code, 200) + self.assertTrue(response.data.get("success")) + rec.refresh_from_db() + self.assertEqual(rec.custom_properties.get("status"), "stopped") + + @patch("core.utils.send_websocket_update") + @patch("threading.Thread") + def test_stop_writes_stopped_at_timestamp(self, mock_thread, mock_ws): + mock_thread.return_value.start = MagicMock() + rec = self._make_rec() + self._stop(rec) + rec.refresh_from_db() + self.assertIn("stopped_at", rec.custom_properties) + + def test_stop_calls_stop_dvr_clients_in_background(self): + """stop() spawns a background thread whose target calls _stop_dvr_clients.""" + rec = self._make_rec() + + with patch("apps.channels.api_views._stop_dvr_clients", return_value=1) as mock_stop, \ + patch("core.utils.send_websocket_update"), \ + patch("threading.Thread") as mock_thread: + mock_thread.return_value.start = MagicMock() + self._stop(rec) + + # Verify a daemon thread was spawned + mock_thread.assert_called_once() + thread_kwargs = mock_thread.call_args[1] + self.assertTrue(thread_kwargs.get("daemon"), "Thread must be daemon") + + # Execute the captured target with DB connection close patched out + target = thread_kwargs["target"] + with patch("apps.channels.api_views._stop_dvr_clients", return_value=1) as mock_stop2, \ + patch("apps.channels.signals.revoke_task", side_effect=Exception("skip")), \ + patch("django.db.connection") as mock_conn: + target() + + self.assertTrue(mock_stop2.called) + args, kwargs = mock_stop2.call_args + actual_rec_id = kwargs.get("recording_id") or (args[1] if len(args) > 1 else None) + self.assertEqual(actual_rec_id, rec.id) + + def test_stop_returns_404_for_nonexistent(self): + request = self.factory.post("/api/channels/recordings/99999/stop/") + force_authenticate(request, user=self.user) + view = RecordingViewSet.as_view({"post": "stop"}) + self.assertEqual(view(request, pk=99999).status_code, 404) + + @patch("core.utils.send_websocket_update") + @patch("threading.Thread") + def test_stop_idempotent_on_already_stopped(self, mock_thread, mock_ws): + mock_thread.return_value.start = MagicMock() + rec = self._make_rec(status="stopped") + self.assertEqual(self._stop(rec).status_code, 200) + + +class CancelDestroyWasInProgressTests(TestCase): + """was_in_progress field in the recording_cancelled WebSocket event.""" + + def setUp(self): + self.channel = Channel.objects.create(channel_number=98, name="Cancel Test Channel") + self.user = _make_admin() + self.factory = APIRequestFactory() + + def _delete(self, rec): + request = self.factory.delete(f"/api/channels/recordings/{rec.id}/") + force_authenticate(request, user=self.user) + return RecordingViewSet.as_view({"delete": "destroy"})(request, pk=rec.id) + + @patch("apps.channels.api_views._stop_dvr_clients", return_value=1) + @patch("core.utils.send_websocket_update") + def test_in_progress_sends_was_in_progress_true(self, mock_ws, _): + now = timezone.now() + rec = Recording.objects.create( + channel=self.channel, + start_time=now - timedelta(minutes=10), + end_time=now + timedelta(hours=1), + custom_properties={"status": "recording"}, + ) + self._delete(rec) + payload = mock_ws.call_args[0][2] + self.assertEqual(payload["type"], "recording_cancelled") + self.assertTrue(payload["was_in_progress"]) + + @patch("core.utils.send_websocket_update") + def test_completed_sends_was_in_progress_false(self, mock_ws): + rec = Recording.objects.create( + channel=self.channel, + start_time=timezone.now() - timedelta(hours=2), + end_time=timezone.now() - timedelta(hours=1), + custom_properties={"status": "completed"}, + ) + self._delete(rec) + self.assertFalse(mock_ws.call_args[0][2]["was_in_progress"]) + + +class SignalUpdateFieldsReentrancyGuardTests(TestCase): + """update_fields guard in schedule_task_on_save prevents redundant WS events.""" + + def setUp(self): + self.channel = Channel.objects.create(channel_number=97, name="Signal Guard Channel") + + def _create_upcoming(self): + future = timezone.now() + timedelta(hours=2) + return Recording.objects.create( + channel=self.channel, start_time=future, + end_time=future + timedelta(hours=1), custom_properties={}, + ) + + @patch("apps.channels.signals.prefetch_recording_artwork") + def test_custom_properties_save_skips_artwork(self, mock_artwork): + rec = self._create_upcoming() + mock_artwork.reset_mock() + rec.custom_properties = {"poster_url": "https://example.com/p.jpg"} + rec.save(update_fields=["custom_properties"]) + mock_artwork.apply_async.assert_not_called() + + @patch("apps.channels.signals.prefetch_recording_artwork") + def test_task_id_save_skips_artwork(self, mock_artwork): + rec = self._create_upcoming() + mock_artwork.reset_mock() + rec.task_id = "dvr-recording-999" + rec.save(update_fields=["task_id"]) + mock_artwork.apply_async.assert_not_called() + + @patch("apps.channels.signals.prefetch_recording_artwork") + def test_combined_metadata_save_skips_artwork(self, mock_artwork): + rec = self._create_upcoming() + mock_artwork.reset_mock() + rec.task_id = "dvr-recording-1000" + rec.custom_properties = {"poster_url": "x"} + rec.save(update_fields=["custom_properties", "task_id"]) + mock_artwork.apply_async.assert_not_called() + + @patch("apps.channels.signals.prefetch_recording_artwork") + def test_creation_dispatches_artwork(self, mock_artwork): + mock_artwork.apply_async.return_value = MagicMock() + self._create_upcoming() + self.assertTrue(mock_artwork.apply_async.called) + + @patch("apps.channels.signals.prefetch_recording_artwork") + def test_scheduling_field_update_dispatches_artwork(self, mock_artwork): + """save(update_fields=['start_time']) is not a metadata save — dispatch runs.""" + mock_artwork.apply_async.return_value = MagicMock() + rec = self._create_upcoming() + mock_artwork.reset_mock() + future = timezone.now() + timedelta(hours=3) + rec.start_time = future + rec.end_time = future + timedelta(hours=1) + rec.save(update_fields=["start_time", "end_time"]) + mock_artwork.apply_async.assert_called() + + +class RunRecordingRaceGuardTests(TestCase): + """Race guard: stop() fires between idempotency check and status write.""" + + def setUp(self): + self.channel = Channel.objects.create(channel_number=96, name="Race Guard Channel") + + def test_race_guard_exits_when_stopped_at_db_read(self): + """If Recording.objects.get() shows 'stopped', the task must exit + without writing 'recording' to the DB.""" + from apps.channels.tasks import run_recording as run_rec + now = timezone.now() + rec = Recording.objects.create( + channel=self.channel, + start_time=now - timedelta(minutes=1), + end_time=now + timedelta(hours=1), + custom_properties={}, + ) + mock_layer = _async_channel_layer_mock() + original_get = Recording.objects.get + + def patched_get(*args, **kwargs): + obj = original_get(*args, **kwargs) + if kwargs.get("id") == rec.id or (args and args[0] == rec.id): + obj.custom_properties = {"status": "stopped"} + return obj + + with patch("apps.channels.tasks.get_channel_layer", return_value=mock_layer), \ + patch("core.utils.log_system_event", side_effect=Exception("skip")), \ + patch.object(Recording.objects, "get", side_effect=patched_get): + result = run_rec( + rec.id, self.channel.id, str(rec.start_time), str(rec.end_time), + ) + + self.assertIsNone(result) + rec.refresh_from_db() + self.assertNotEqual( + rec.custom_properties.get("status"), "recording", + "Race guard failed: task overwrote 'stopped' with 'recording'", + ) + + def test_idempotency_guard_catches_stopped_before_channel_layer(self): + """When status='stopped' at the idempotency check, get_channel_layer is never called.""" + from apps.channels.tasks import run_recording as run_rec + now = timezone.now() + rec = Recording.objects.create( + channel=self.channel, + start_time=now - timedelta(minutes=5), + end_time=now + timedelta(hours=1), + custom_properties={"status": "stopped"}, + ) + with patch("apps.channels.tasks.get_channel_layer") as mock_get_layer: + result = run_rec( + rec.id, self.channel.id, str(rec.start_time), str(rec.end_time), + ) + self.assertIsNone(result) + mock_get_layer.assert_not_called() + + +class StopDvrClientsTests(TestCase): + """_stop_dvr_clients() DVR client isolation.""" + + def setUp(self): + self.channel = Channel.objects.create(channel_number=95, name="DVR Clients Channel") + self._redis = "core.utils.RedisClient" + self._sc = "apps.proxy.ts_proxy.services.channel_service.ChannelService.stop_client" + self._sch = "apps.proxy.ts_proxy.services.channel_service.ChannelService.stop_channel" + + def _mock_redis(self, client_ids, ua_map): + r = MagicMock() + r.smembers.return_value = {c.encode() for c in client_ids} + def hget_side(key, field): + ks = key if isinstance(key, str) else key.decode("utf-8", errors="replace") + for cid, ua in ua_map.items(): + if cid in ks: + return ua.encode() if isinstance(ua, str) else ua + return b"" + r.hget.side_effect = hget_side + return r + + def test_returns_zero_when_redis_none(self): + with patch(self._redis) as rc: + rc.get_client.return_value = None + self.assertEqual(_stop_dvr_clients(str(self.channel.uuid)), 0) + + def test_stops_only_matching_client_when_recording_id_given(self): + r = self._mock_redis( + ["client-a", "client-b"], + {"client-a": "Dispatcharr-DVR/recording-42", + "client-b": "Dispatcharr-DVR/recording-99"}, + ) + with patch(self._redis) as rc, patch(self._sc) as sc: + rc.get_client.return_value = r + result = _stop_dvr_clients(str(self.channel.uuid), recording_id=42) + self.assertEqual(result, 1) + stopped = [c[0][1] for c in sc.call_args_list] + self.assertIn("client-a", stopped) + self.assertNotIn("client-b", stopped) + + def test_stops_all_dvr_clients_without_recording_id(self): + r = self._mock_redis( + ["client-a", "client-b"], + {"client-a": "Dispatcharr-DVR/recording-42", + "client-b": "Dispatcharr-DVR/recording-99"}, + ) + with patch(self._redis) as rc, patch(self._sc) as sc: + rc.get_client.return_value = r + result = _stop_dvr_clients(str(self.channel.uuid)) + self.assertEqual(result, 2) + + def test_skips_non_dvr_clients(self): + r = self._mock_redis( + ["viewer", "dvr-client"], + {"viewer": "Mozilla/5.0", "dvr-client": "Dispatcharr-DVR/recording-1"}, + ) + with patch(self._redis) as rc, patch(self._sc) as sc: + rc.get_client.return_value = r + result = _stop_dvr_clients(str(self.channel.uuid)) + self.assertEqual(result, 1) + stopped = [c[0][1] for c in sc.call_args_list] + self.assertNotIn("viewer", stopped) + + def test_returns_zero_for_empty_channel(self): + r = MagicMock() + r.smembers.return_value = set() + with patch(self._redis) as rc, patch(self._sc) as sc: + rc.get_client.return_value = r + self.assertEqual(_stop_dvr_clients(str(self.channel.uuid)), 0) + sc.assert_not_called() + + def test_never_calls_stop_channel(self): + """Must not stop the whole channel proxy — only individual clients.""" + r = self._mock_redis(["dvr-1"], {"dvr-1": "Dispatcharr-DVR/recording-1"}) + with patch(self._redis) as rc, patch(self._sc), patch(self._sch) as sch: + rc.get_client.return_value = r + _stop_dvr_clients(str(self.channel.uuid)) + sch.assert_not_called() diff --git a/apps/channels/tests/test_recurring_rules.py b/apps/channels/tests/test_recurring_rules.py new file mode 100644 index 0000000..982ecb9 --- /dev/null +++ b/apps/channels/tests/test_recurring_rules.py @@ -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()) diff --git a/apps/channels/tests/test_series_rule_dedup.py b/apps/channels/tests/test_series_rule_dedup.py new file mode 100644 index 0000000..33aff69 --- /dev/null +++ b/apps/channels/tests/test_series_rule_dedup.py @@ -0,0 +1,718 @@ +"""Tests for series rule evaluation deduplication. + +Unit tests verify the dedup logic in evaluate_series_rules_impl. +Integration tests exercise the full path: EPG refresh → series rule +evaluation → Recording creation → post_save signal chain. +""" +from datetime import timedelta +from unittest.mock import patch, MagicMock + +from django.test import TestCase +from django.utils import timezone + +from apps.channels.models import Channel, Recording +from apps.epg.models import EPGSource, EPGData, ProgramData +from core.models import CoreSettings + + +def _set_series_rules(rules): + """Helper to store series rules in CoreSettings.""" + CoreSettings.set_dvr_series_rules(rules) + + +def _set_dvr_offsets(pre_min=0, post_min=0): + """Helper to store DVR pre/post offsets.""" + CoreSettings._update_group("dvr_settings", "DVR Settings", { + "pre_offset_minutes": pre_min, + "post_offset_minutes": post_min, + }) + + +class SeriesRuleDedupBaseTestCase(TestCase): + """Shared setup for series rule dedup tests.""" + + def setUp(self): + self.now = timezone.now() + self.epg_source = EPGSource.objects.create( + name="Test EPG", source_type="xmltv" + ) + self.epg = EPGData.objects.create( + tvg_id="test.channel.1", + name="Test Channel EPG", + epg_source=self.epg_source, + ) + self.channel = Channel.objects.create( + channel_number=1, name="Test Channel", epg_data=self.epg + ) + + _set_series_rules([{ + "tvg_id": "test.channel.1", + "mode": "all", + "title": "Test Show", + }]) + _set_dvr_offsets(pre_min=0, post_min=0) + + def _create_program(self, hours_from_now=1, title="Test Show", + sub_title="Episode 1", tvg_id="test.channel.1"): + """Create a ProgramData at the given offset.""" + start = self.now + timedelta(hours=hours_from_now) + end = start + timedelta(hours=1) + return ProgramData.objects.create( + epg=self.epg, + tvg_id=tvg_id, + start_time=start, + end_time=end, + title=title, + sub_title=sub_title, + ) + + def _simulate_epg_refresh(self, programs_data): + """Delete all ProgramData and recreate with new IDs (simulates EPG refresh).""" + ProgramData.objects.filter(epg=self.epg).delete() + new_programs = [] + for data in programs_data: + prog = ProgramData.objects.create(epg=self.epg, **data) + new_programs.append(prog) + return new_programs + + def _program_data_for_refresh(self, prog): + """Build the dict needed by _simulate_epg_refresh from a ProgramData.""" + return { + "tvg_id": prog.tvg_id, + "start_time": prog.start_time, + "end_time": prog.end_time, + "title": prog.title, + "sub_title": prog.sub_title, + } + + +# --------------------------------------------------------------------------- +# Unit tests: dedup logic in evaluate_series_rules_impl +# --------------------------------------------------------------------------- + +@patch("apps.channels.tasks.prefetch_recording_artwork") +@patch("apps.channels.signals.schedule_recording_task", return_value="mock-task-id") +class ProgramIdStabilityTests(SeriesRuleDedupBaseTestCase): + """Verify dedup works after EPG refresh changes ProgramData IDs.""" + + @patch("apps.channels.tasks.acquire_task_lock", return_value=True) + @patch("apps.channels.tasks.release_task_lock") + def test_no_duplicate_after_epg_refresh(self, mock_release, mock_lock, + mock_schedule, mock_artwork): + """Same program should not be recorded twice after EPG refresh.""" + from apps.channels.tasks import evaluate_series_rules_impl + + prog = self._create_program(hours_from_now=2) + old_id = prog.id + result1 = evaluate_series_rules_impl() + self.assertEqual(result1["scheduled"], 1) + self.assertEqual(Recording.objects.count(), 1) + + new_programs = self._simulate_epg_refresh( + [self._program_data_for_refresh(prog)] + ) + self.assertNotEqual(old_id, new_programs[0].id) + + result2 = evaluate_series_rules_impl() + self.assertEqual(Recording.objects.count(), 1) + self.assertEqual(result2["scheduled"], 0) + + @patch("apps.channels.tasks.acquire_task_lock", return_value=True) + @patch("apps.channels.tasks.release_task_lock") + def test_no_duplicate_with_offsets_after_refresh(self, mock_release, mock_lock, + mock_schedule, mock_artwork): + """Dedup works when DVR offsets shift Recording times away from program times.""" + from apps.channels.tasks import evaluate_series_rules_impl + + _set_dvr_offsets(pre_min=5, post_min=5) + prog = self._create_program(hours_from_now=2) + result1 = evaluate_series_rules_impl() + self.assertEqual(result1["scheduled"], 1) + + rec = Recording.objects.first() + self.assertEqual(rec.start_time, prog.start_time - timedelta(minutes=5)) + self.assertEqual(rec.end_time, prog.end_time + timedelta(minutes=5)) + + self._simulate_epg_refresh([self._program_data_for_refresh(prog)]) + result2 = evaluate_series_rules_impl() + self.assertEqual(Recording.objects.count(), 1) + + @patch("apps.channels.tasks.acquire_task_lock", return_value=True) + @patch("apps.channels.tasks.release_task_lock") + def test_different_episodes_still_recorded(self, mock_release, mock_lock, + mock_schedule, mock_artwork): + """Different episodes on the same channel should each get a recording.""" + from apps.channels.tasks import evaluate_series_rules_impl + + self._create_program(hours_from_now=2, sub_title="Episode 1") + self._create_program(hours_from_now=4, sub_title="Episode 2") + result = evaluate_series_rules_impl() + self.assertEqual(result["scheduled"], 2) + self.assertEqual(Recording.objects.count(), 2) + + @patch("apps.channels.tasks.acquire_task_lock", return_value=True) + @patch("apps.channels.tasks.release_task_lock") + def test_new_episode_after_refresh_is_recorded(self, mock_release, mock_lock, + mock_schedule, mock_artwork): + """A genuinely new episode appearing after EPG refresh should be recorded.""" + from apps.channels.tasks import evaluate_series_rules_impl + + prog = self._create_program(hours_from_now=2, sub_title="Episode 1") + evaluate_series_rules_impl() + self.assertEqual(Recording.objects.count(), 1) + + self._simulate_epg_refresh([ + self._program_data_for_refresh(prog), + { + "tvg_id": "test.channel.1", + "start_time": prog.end_time, + "end_time": prog.end_time + timedelta(hours=1), + "title": "Test Show", + "sub_title": "Episode 2", + }, + ]) + + result2 = evaluate_series_rules_impl() + self.assertEqual(Recording.objects.count(), 2) + self.assertEqual(result2["scheduled"], 1) + + @patch("apps.channels.tasks.acquire_task_lock", return_value=True) + @patch("apps.channels.tasks.release_task_lock") + def test_multiple_epg_refreshes_no_duplicates(self, mock_release, mock_lock, + mock_schedule, mock_artwork): + """Multiple consecutive EPG refreshes should not accumulate duplicates.""" + from apps.channels.tasks import evaluate_series_rules_impl + + prog = self._create_program(hours_from_now=2) + evaluate_series_rules_impl() + self.assertEqual(Recording.objects.count(), 1) + + for _ in range(5): + self._simulate_epg_refresh([self._program_data_for_refresh(prog)]) + evaluate_series_rules_impl() + + self.assertEqual(Recording.objects.count(), 1) + + +@patch("apps.channels.tasks.prefetch_recording_artwork") +@patch("apps.channels.signals.schedule_recording_task", return_value="mock-task-id") +class ConcurrencyGuardTests(SeriesRuleDedupBaseTestCase): + """Verify the task lock prevents concurrent evaluation.""" + + def test_lock_acquired_and_released(self, mock_schedule, mock_artwork): + """evaluate_series_rules_impl acquires and releases the task lock.""" + from apps.channels.tasks import evaluate_series_rules_impl + + self._create_program(hours_from_now=2) + + with patch("apps.channels.tasks.acquire_task_lock", return_value=True) as mock_lock, \ + patch("apps.channels.tasks.release_task_lock") as mock_release: + evaluate_series_rules_impl() + mock_lock.assert_called_once_with('evaluate_series_rules', 'all') + mock_release.assert_called_once_with('evaluate_series_rules', 'all') + + def test_skips_when_lock_held(self, mock_schedule, mock_artwork): + """Returns early with skip reason when lock is already held.""" + from apps.channels.tasks import evaluate_series_rules_impl + + self._create_program(hours_from_now=2) + + with patch("apps.channels.tasks.acquire_task_lock", return_value=False): + result = evaluate_series_rules_impl() + self.assertEqual(result["scheduled"], 0) + self.assertTrue( + any(d.get("reason") == "concurrent evaluation in progress" + for d in result["details"]), + ) + self.assertEqual(Recording.objects.count(), 0) + + @patch("apps.channels.tasks.acquire_task_lock", return_value=True) + @patch("apps.channels.tasks.release_task_lock") + def test_lock_released_on_exception(self, mock_release, mock_lock, + mock_schedule, mock_artwork): + """Lock is released even if the inner implementation raises.""" + from apps.channels.tasks import evaluate_series_rules_impl + + with patch("apps.channels.tasks._evaluate_series_rules_locked", + side_effect=RuntimeError("test error")): + with self.assertRaises(RuntimeError): + evaluate_series_rules_impl() + mock_release.assert_called_once_with('evaluate_series_rules', 'all') + + +@patch("apps.channels.tasks.prefetch_recording_artwork") +@patch("apps.channels.signals.schedule_recording_task", return_value="mock-task-id") +class SecondaryGuardTests(SeriesRuleDedupBaseTestCase): + """Verify the secondary DB guard uses stable program attributes.""" + + @patch("apps.channels.tasks.acquire_task_lock", return_value=True) + @patch("apps.channels.tasks.release_task_lock") + def test_secondary_guard_catches_duplicate_with_offsets(self, mock_release, mock_lock, + mock_schedule, mock_artwork): + """Secondary guard works with stale program IDs and DVR offsets.""" + from apps.channels.tasks import evaluate_series_rules_impl + + _set_dvr_offsets(pre_min=10, post_min=10) + prog = self._create_program(hours_from_now=2) + + # Pre-existing recording with a stale program ID (from previous EPG refresh) + Recording.objects.create( + channel=self.channel, + start_time=prog.start_time - timedelta(minutes=10), + end_time=prog.end_time + timedelta(minutes=10), + custom_properties={ + "program": { + "id": 99999, + "tvg_id": prog.tvg_id, + "title": prog.title, + "start_time": prog.start_time.isoformat(), + "end_time": prog.end_time.isoformat(), + } + }, + ) + + result = evaluate_series_rules_impl() + self.assertEqual(Recording.objects.count(), 1) + self.assertEqual(result["scheduled"], 0) + + +# --------------------------------------------------------------------------- +# Integration tests: full path from EPG refresh through recording creation +# --------------------------------------------------------------------------- + +@patch("apps.channels.tasks.prefetch_recording_artwork") +@patch("apps.channels.signals.schedule_recording_task", return_value="mock-task-id") +class IntegrationEPGRefreshTests(SeriesRuleDedupBaseTestCase): + """End-to-end tests simulating the EPG refresh → evaluate → record flow. + + These exercise the full signal chain: evaluate_series_rules_impl creates + a Recording, the post_save signal fires schedule_recording_task, and + subsequent evaluations (after EPG refresh) must not create duplicates. + """ + + @patch("apps.channels.tasks.acquire_task_lock", return_value=True) + @patch("apps.channels.tasks.release_task_lock") + def test_full_flow_single_episode_no_duplicates(self, mock_release, mock_lock, + mock_schedule, mock_artwork): + """Simulate: create rule → evaluate → EPG refresh → re-evaluate. + + The full recording lifecycle must result in exactly 1 recording. + """ + from apps.channels.tasks import evaluate_series_rules_impl + + # Initial EPG data + prog = self._create_program(hours_from_now=2, sub_title="Pilot") + + # First evaluation creates the recording + result1 = evaluate_series_rules_impl() + self.assertEqual(result1["scheduled"], 1) + self.assertEqual(Recording.objects.count(), 1) + + # Verify the recording was created with correct program metadata + rec = Recording.objects.first() + self.assertEqual(rec.custom_properties["program"]["tvg_id"], "test.channel.1") + self.assertEqual(rec.custom_properties["program"]["title"], "Test Show") + self.assertEqual( + rec.custom_properties["program"]["start_time"], + prog.start_time.isoformat() + ) + + # Verify the post_save signal scheduled a task + mock_schedule.assert_called() + initial_schedule_count = mock_schedule.call_count + + # Simulate EPG refresh (programs get new DB IDs) + self._simulate_epg_refresh([self._program_data_for_refresh(prog)]) + + # Re-evaluate after refresh (this is what EPG refresh triggers) + result2 = evaluate_series_rules_impl() + self.assertEqual(result2["scheduled"], 0) + self.assertEqual(Recording.objects.count(), 1) + + # No additional task scheduling should have occurred + self.assertEqual(mock_schedule.call_count, initial_schedule_count) + + @patch("apps.channels.tasks.acquire_task_lock", return_value=True) + @patch("apps.channels.tasks.release_task_lock") + def test_full_flow_with_offsets_no_duplicates(self, mock_release, mock_lock, + mock_schedule, mock_artwork): + """Full flow with DVR offsets: recording times differ from program times.""" + from apps.channels.tasks import evaluate_series_rules_impl + + _set_dvr_offsets(pre_min=5, post_min=10) + prog = self._create_program(hours_from_now=3, sub_title="Episode 1") + + result1 = evaluate_series_rules_impl() + self.assertEqual(result1["scheduled"], 1) + + rec = Recording.objects.first() + # Verify offset-adjusted recording times + self.assertEqual(rec.start_time, prog.start_time - timedelta(minutes=5)) + self.assertEqual(rec.end_time, prog.end_time + timedelta(minutes=10)) + # Verify original (unadjusted) program times in custom_properties + self.assertEqual( + rec.custom_properties["program"]["start_time"], + prog.start_time.isoformat() + ) + self.assertEqual( + rec.custom_properties["program"]["end_time"], + prog.end_time.isoformat() + ) + + # EPG refresh + re-evaluate + self._simulate_epg_refresh([self._program_data_for_refresh(prog)]) + result2 = evaluate_series_rules_impl() + self.assertEqual(Recording.objects.count(), 1) + self.assertEqual(result2["scheduled"], 0) + + @patch("apps.channels.tasks.acquire_task_lock", return_value=True) + @patch("apps.channels.tasks.release_task_lock") + def test_full_flow_multiple_episodes_across_refreshes(self, mock_release, mock_lock, + mock_schedule, mock_artwork): + """New episodes appear across multiple EPG refreshes; each recorded once.""" + from apps.channels.tasks import evaluate_series_rules_impl + + ep1 = self._create_program(hours_from_now=2, sub_title="Episode 1") + evaluate_series_rules_impl() + self.assertEqual(Recording.objects.count(), 1) + + # EPG refresh adds episode 2 alongside episode 1 + ep1_data = self._program_data_for_refresh(ep1) + ep2_start = ep1.end_time + ep2_data = { + "tvg_id": "test.channel.1", + "start_time": ep2_start, + "end_time": ep2_start + timedelta(hours=1), + "title": "Test Show", + "sub_title": "Episode 2", + } + self._simulate_epg_refresh([ep1_data, ep2_data]) + evaluate_series_rules_impl() + self.assertEqual(Recording.objects.count(), 2) + + # Another EPG refresh adds episode 3 + ep3_start = ep2_start + timedelta(hours=1) + ep3_data = { + "tvg_id": "test.channel.1", + "start_time": ep3_start, + "end_time": ep3_start + timedelta(hours=1), + "title": "Test Show", + "sub_title": "Episode 3", + } + self._simulate_epg_refresh([ep1_data, ep2_data, ep3_data]) + evaluate_series_rules_impl() + self.assertEqual(Recording.objects.count(), 3) + + # Final EPG refresh with no new episodes — count must stay at 3 + self._simulate_epg_refresh([ep1_data, ep2_data, ep3_data]) + evaluate_series_rules_impl() + self.assertEqual(Recording.objects.count(), 3) + + @patch("apps.channels.tasks.acquire_task_lock", return_value=True) + @patch("apps.channels.tasks.release_task_lock") + def test_full_flow_multiple_series_rules(self, mock_release, mock_lock, + mock_schedule, mock_artwork): + """Multiple series rules on different channels, each evaluated correctly.""" + from apps.channels.tasks import evaluate_series_rules_impl + + # Second channel with its own EPG + epg2 = EPGData.objects.create( + tvg_id="test.channel.2", + name="Channel 2 EPG", + epg_source=self.epg_source, + ) + channel2 = Channel.objects.create( + channel_number=2, name="Test Channel 2", epg_data=epg2 + ) + + _set_series_rules([ + {"tvg_id": "test.channel.1", "mode": "all", "title": "Show A"}, + {"tvg_id": "test.channel.2", "mode": "all", "title": "Show B"}, + ]) + + # Programs on both channels + start1 = self.now + timedelta(hours=2) + prog1 = ProgramData.objects.create( + epg=self.epg, tvg_id="test.channel.1", + start_time=start1, end_time=start1 + timedelta(hours=1), + title="Show A", sub_title="Episode 1", + ) + start2 = self.now + timedelta(hours=3) + prog2 = ProgramData.objects.create( + epg=epg2, tvg_id="test.channel.2", + start_time=start2, end_time=start2 + timedelta(hours=1), + title="Show B", sub_title="Episode 1", + ) + + evaluate_series_rules_impl() + self.assertEqual(Recording.objects.count(), 2) + self.assertEqual(Recording.objects.filter(channel=self.channel).count(), 1) + self.assertEqual(Recording.objects.filter(channel=channel2).count(), 1) + + # EPG refresh for both channels + ProgramData.objects.filter(epg=self.epg).delete() + ProgramData.objects.filter(epg=epg2).delete() + ProgramData.objects.create( + epg=self.epg, tvg_id="test.channel.1", + start_time=start1, end_time=start1 + timedelta(hours=1), + title="Show A", sub_title="Episode 1", + ) + ProgramData.objects.create( + epg=epg2, tvg_id="test.channel.2", + start_time=start2, end_time=start2 + timedelta(hours=1), + title="Show B", sub_title="Episode 1", + ) + + evaluate_series_rules_impl() + self.assertEqual(Recording.objects.count(), 2, + "No duplicates across multiple series rules after EPG refresh") + + @patch("apps.channels.tasks.acquire_task_lock", return_value=True) + @patch("apps.channels.tasks.release_task_lock") + def test_full_flow_rapid_epg_refreshes_simulate_user_report( + self, mock_release, mock_lock, mock_schedule, mock_artwork + ): + """Reproduce the user-reported scenario: series rule + multiple EPG refreshes + causing count to balloon from 6 to 25 and 5 simultaneous recordings. + + Simulates 6 episodes with 5 EPG refreshes (each assigning new ProgramData IDs). + """ + from apps.channels.tasks import evaluate_series_rules_impl + + # Create 6 episodes (the user had "next of 6") + episodes = [] + for i in range(6): + start = self.now + timedelta(hours=2 + i * 2) + episodes.append({ + "tvg_id": "test.channel.1", + "start_time": start, + "end_time": start + timedelta(hours=1), + "title": "Test Show", + "sub_title": f"Episode {i + 1}", + }) + + # Create initial ProgramData + for ep in episodes: + ProgramData.objects.create(epg=self.epg, **ep) + + # First evaluation: should create exactly 6 recordings + evaluate_series_rules_impl() + self.assertEqual(Recording.objects.count(), 6) + + # Simulate 5 EPG refreshes (the user saw count balloon to 25) + for refresh_num in range(5): + self._simulate_epg_refresh(episodes) + result = evaluate_series_rules_impl() + self.assertEqual( + Recording.objects.count(), 6, + f"After EPG refresh #{refresh_num + 1}, expected 6 recordings " + f"but got {Recording.objects.count()}" + ) + self.assertEqual(result["scheduled"], 0) + + @patch("apps.channels.tasks.acquire_task_lock", return_value=True) + @patch("apps.channels.tasks.release_task_lock") + def test_full_flow_recording_survives_program_removal_and_readd( + self, mock_release, mock_lock, mock_schedule, mock_artwork + ): + """Program temporarily disappears from EPG then reappears — no duplicate.""" + from apps.channels.tasks import evaluate_series_rules_impl + + prog = self._create_program(hours_from_now=2, sub_title="Episode 1") + evaluate_series_rules_impl() + self.assertEqual(Recording.objects.count(), 1) + + # EPG refresh removes the program entirely + self._simulate_epg_refresh([]) + evaluate_series_rules_impl() + self.assertEqual(Recording.objects.count(), 1, + "Existing recording preserved when program disappears from EPG") + + # EPG refresh adds the program back (new ID) + self._simulate_epg_refresh([self._program_data_for_refresh(prog)]) + evaluate_series_rules_impl() + self.assertEqual(Recording.objects.count(), 1, + "No duplicate when program reappears with new ID") + + @patch("apps.channels.tasks.acquire_task_lock", return_value=True) + @patch("apps.channels.tasks.release_task_lock") + def test_full_flow_celery_task_wrapper_calls_impl(self, mock_release, mock_lock, + mock_schedule, mock_artwork): + """The @shared_task evaluate_series_rules delegates to _impl correctly.""" + from apps.channels.tasks import evaluate_series_rules + + self._create_program(hours_from_now=2) + result = evaluate_series_rules() + self.assertEqual(result["scheduled"], 1) + self.assertEqual(Recording.objects.count(), 1) + + # Call again (simulating a second EPG refresh trigger) + result2 = evaluate_series_rules() + self.assertEqual(result2["scheduled"], 0) + self.assertEqual(Recording.objects.count(), 1) + + @patch("apps.channels.tasks.acquire_task_lock", return_value=True) + @patch("apps.channels.tasks.release_task_lock") + def test_full_flow_tvg_id_scoped_evaluation(self, mock_release, mock_lock, + mock_schedule, mock_artwork): + """Scoped evaluation (tvg_id parameter) still prevents duplicates.""" + from apps.channels.tasks import evaluate_series_rules_impl + + prog = self._create_program(hours_from_now=2) + result1 = evaluate_series_rules_impl(tvg_id="test.channel.1") + self.assertEqual(result1["scheduled"], 1) + + self._simulate_epg_refresh([self._program_data_for_refresh(prog)]) + result2 = evaluate_series_rules_impl(tvg_id="test.channel.1") + self.assertEqual(result2["scheduled"], 0) + self.assertEqual(Recording.objects.count(), 1) + + @patch("apps.channels.tasks.acquire_task_lock", return_value=True) + @patch("apps.channels.tasks.release_task_lock") + def test_full_flow_offset_change_between_refreshes(self, mock_release, mock_lock, + mock_schedule, mock_artwork): + """Changing DVR offsets between EPG refreshes doesn't create duplicates. + + Even though Recording.start_time/end_time change when offsets change, + the dedup key uses the original program times from custom_properties. + """ + from apps.channels.tasks import evaluate_series_rules_impl + + _set_dvr_offsets(pre_min=5, post_min=5) + prog = self._create_program(hours_from_now=2) + evaluate_series_rules_impl() + self.assertEqual(Recording.objects.count(), 1) + + rec = Recording.objects.first() + original_start = rec.start_time + original_end = rec.end_time + + # Change offsets + _set_dvr_offsets(pre_min=10, post_min=15) + + # EPG refresh + self._simulate_epg_refresh([self._program_data_for_refresh(prog)]) + result = evaluate_series_rules_impl() + self.assertEqual(Recording.objects.count(), 1, + "Changing offsets between refreshes should not create duplicates") + self.assertEqual(result["scheduled"], 0) + + +# --------------------------------------------------------------------------- +# Edge case tests: Redis unavailability, non-series recordings, robustness +# --------------------------------------------------------------------------- + +@patch("apps.channels.tasks.prefetch_recording_artwork") +@patch("apps.channels.signals.schedule_recording_task", return_value="mock-task-id") +class RedisUnavailabilityTests(SeriesRuleDedupBaseTestCase): + """Verify evaluation works when Redis is unavailable (lock cannot be acquired).""" + + def test_proceeds_when_redis_down(self, mock_schedule, mock_artwork): + """Evaluation succeeds (with dedup guards) when Redis raises on lock acquire.""" + from apps.channels.tasks import evaluate_series_rules_impl + + self._create_program(hours_from_now=2) + + with patch("apps.channels.tasks.acquire_task_lock", + side_effect=ConnectionError("Redis unavailable")): + result = evaluate_series_rules_impl() + self.assertEqual(result["scheduled"], 1) + self.assertEqual(Recording.objects.count(), 1) + + def test_dedup_still_works_without_lock(self, mock_schedule, mock_artwork): + """Dedup guards prevent duplicates even when the lock is unavailable.""" + from apps.channels.tasks import evaluate_series_rules_impl + + prog = self._create_program(hours_from_now=2) + + # First call: Redis down, proceeds without lock + with patch("apps.channels.tasks.acquire_task_lock", + side_effect=ConnectionError("Redis unavailable")): + evaluate_series_rules_impl() + self.assertEqual(Recording.objects.count(), 1) + + # EPG refresh + self._simulate_epg_refresh([self._program_data_for_refresh(prog)]) + + # Second call: Redis still down + with patch("apps.channels.tasks.acquire_task_lock", + side_effect=ConnectionError("Redis unavailable")): + result = evaluate_series_rules_impl() + self.assertEqual(Recording.objects.count(), 1, + "Dedup guards prevent duplicates even without lock") + self.assertEqual(result["scheduled"], 0) + + def test_lock_not_released_when_not_acquired(self, mock_schedule, mock_artwork): + """release_task_lock is not called if acquire raised an exception.""" + from apps.channels.tasks import evaluate_series_rules_impl + + self._create_program(hours_from_now=2) + + with patch("apps.channels.tasks.acquire_task_lock", + side_effect=ConnectionError("Redis unavailable")), \ + patch("apps.channels.tasks.release_task_lock") as mock_release: + evaluate_series_rules_impl() + mock_release.assert_not_called() + + +@patch("apps.channels.tasks.prefetch_recording_artwork") +@patch("apps.channels.signals.schedule_recording_task", return_value="mock-task-id") +class NonSeriesRecordingTests(SeriesRuleDedupBaseTestCase): + """Verify non-series recordings don't interfere with series rule dedup.""" + + @patch("apps.channels.tasks.acquire_task_lock", return_value=True) + @patch("apps.channels.tasks.release_task_lock") + def test_manual_recording_without_program_data_ignored(self, mock_release, mock_lock, + mock_schedule, mock_artwork): + """Recordings without custom_properties.program are skipped by dedup key builder.""" + from apps.channels.tasks import evaluate_series_rules_impl + + # Manual recording with no program metadata + Recording.objects.create( + channel=self.channel, + start_time=self.now + timedelta(hours=2), + end_time=self.now + timedelta(hours=3), + custom_properties={}, + ) + + prog = self._create_program(hours_from_now=2) + result = evaluate_series_rules_impl() + self.assertEqual(result["scheduled"], 1) + self.assertEqual(Recording.objects.count(), 2) + + @patch("apps.channels.tasks.acquire_task_lock", return_value=True) + @patch("apps.channels.tasks.release_task_lock") + def test_recurring_rule_recording_does_not_interfere(self, mock_release, mock_lock, + mock_schedule, mock_artwork): + """Recordings from recurring rules (custom_properties.rule) don't block series rules.""" + from apps.channels.tasks import evaluate_series_rules_impl + + Recording.objects.create( + channel=self.channel, + start_time=self.now + timedelta(hours=2), + end_time=self.now + timedelta(hours=3), + custom_properties={"rule": {"id": 1, "name": "Daily News"}}, + ) + + prog = self._create_program(hours_from_now=2) + result = evaluate_series_rules_impl() + self.assertEqual(result["scheduled"], 1) + self.assertEqual(Recording.objects.count(), 2) + + @patch("apps.channels.tasks.acquire_task_lock", return_value=True) + @patch("apps.channels.tasks.release_task_lock") + def test_recording_with_null_custom_properties_ignored(self, mock_release, mock_lock, + mock_schedule, mock_artwork): + """Recordings with None custom_properties don't crash the dedup key builder.""" + from apps.channels.tasks import evaluate_series_rules_impl + + Recording.objects.create( + channel=self.channel, + start_time=self.now + timedelta(hours=2), + end_time=self.now + timedelta(hours=3), + custom_properties=None, + ) + + prog = self._create_program(hours_from_now=2) + result = evaluate_series_rules_impl() + self.assertEqual(result["scheduled"], 1) diff --git a/apps/channels/tests/test_ts_proxy_ghost_clients.py b/apps/channels/tests/test_ts_proxy_ghost_clients.py new file mode 100644 index 0000000..6d494da --- /dev/null +++ b/apps/channels/tests/test_ts_proxy_ghost_clients.py @@ -0,0 +1,385 @@ +"""Tests for ghost client detection and cleanup. + +Covers: + - ClientManager.remove_ghost_clients() pipelined EXISTS logic + - channel_status detailed stats path removes ghost clients from Redis SET + - channel_status basic stats path removes ghost clients and corrects count + - _check_orphaned_metadata() validates client SET entries and cleans up + channels where all clients are ghosts +""" +from unittest.mock import MagicMock, patch, PropertyMock + +from django.test import TestCase + +from apps.proxy.ts_proxy.client_manager import ClientManager +from apps.proxy.ts_proxy.constants import ChannelMetadataField, ChannelState +from apps.proxy.ts_proxy.redis_keys import RedisKeys + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + +CHANNEL_ID = "00000000-0000-0000-0000-000000000001" + + +def _make_proxy_server(redis_client=None): + """Create a minimal mock ProxyServer with a redis_client.""" + server = MagicMock() + server.redis_client = redis_client or MagicMock() + server.stream_managers = {} + server.client_managers = {} + server.worker_id = "test-worker-1" + return server + + +def _metadata_for_channel(state="active"): + """Return a plausible channel metadata dict (bytes keys/values).""" + return { + ChannelMetadataField.STATE.encode(): state.encode(), + ChannelMetadataField.URL.encode(): b"http://example.com/stream", + ChannelMetadataField.STREAM_PROFILE.encode(): b"default", + ChannelMetadataField.OWNER.encode(): b"test-worker-1", + ChannelMetadataField.INIT_TIME.encode(): b"1773500000.0", + } + + +# --------------------------------------------------------------------------- +# Unit tests for ClientManager.remove_ghost_clients() +# --------------------------------------------------------------------------- + +class RemoveGhostClientsTests(TestCase): + """Directly exercises the static method that all callers rely on.""" + + def test_ghost_removed_and_returned(self): + """Client ID in SET with no metadata hash should be SREM'd.""" + redis = MagicMock() + redis.smembers.return_value = {b"ghost_001"} + + pipe = MagicMock() + redis.pipeline.return_value = pipe + pipe.execute.return_value = [False] # EXISTS → False + + result = ClientManager.remove_ghost_clients(redis, CHANNEL_ID) + + self.assertEqual(result, [b"ghost_001"]) + redis.srem.assert_called_once() + + def test_live_client_preserved(self): + """Client with valid metadata hash should NOT be removed.""" + redis = MagicMock() + redis.smembers.return_value = {b"live_001"} + + pipe = MagicMock() + redis.pipeline.return_value = pipe + pipe.execute.return_value = [True] # EXISTS → True + + result = ClientManager.remove_ghost_clients(redis, CHANNEL_ID) + + self.assertEqual(result, []) + redis.srem.assert_not_called() + + def test_mixed_ghost_and_live(self): + """Only ghost clients should be removed; live ones preserved.""" + redis = MagicMock() + redis.smembers.return_value = {b"ghost_001", b"live_001"} + + pipe = MagicMock() + redis.pipeline.return_value = pipe + # Order matches list(smembers), which is non-deterministic — + # map both IDs so the test is stable regardless of iteration order. + client_id_list = list(redis.smembers.return_value) + + def exists_results(): + return [ + b"ghost_001" not in cid.decode() == False + for cid in client_id_list + ] + + # Simpler: mock based on key content + def pipe_exists(key): + pass # just enqueued; results come from execute() + + pipe.exists.side_effect = pipe_exists + pipe.execute.return_value = [ + "live" in cid.decode() for cid in client_id_list + ] + + result = ClientManager.remove_ghost_clients(redis, CHANNEL_ID) + + self.assertEqual(len(result), 1) + self.assertTrue(any(b"ghost" in cid for cid in result)) + redis.srem.assert_called_once() + + def test_empty_set_returns_empty(self): + """No clients means nothing to clean.""" + redis = MagicMock() + redis.smembers.return_value = set() + + result = ClientManager.remove_ghost_clients(redis, CHANNEL_ID) + + self.assertEqual(result, []) + redis.pipeline.assert_not_called() + + def test_pre_fetched_client_ids_skips_smembers(self): + """When client_ids is passed, SMEMBERS should not be called.""" + redis = MagicMock() + pipe = MagicMock() + redis.pipeline.return_value = pipe + pipe.execute.return_value = [False] + + pre_fetched = {b"ghost_001"} + result = ClientManager.remove_ghost_clients( + redis, CHANNEL_ID, client_ids=pre_fetched + ) + + redis.smembers.assert_not_called() + self.assertEqual(len(result), 1) + + +# --------------------------------------------------------------------------- +# Detailed stats path: exercises get_detailed_channel_info() +# --------------------------------------------------------------------------- + +@patch("apps.proxy.ts_proxy.channel_status.ProxyServer") +class DetailedStatsGhostClientTests(TestCase): + """get_detailed_channel_info() should remove ghost clients whose metadata + hash has expired from the Redis client SET.""" + + def _setup_redis(self, mock_proxy_cls, client_ids, hgetall_side_effect): + """Wire up a mock ProxyServer with controlled Redis responses.""" + redis = MagicMock() + server = _make_proxy_server(redis) + mock_proxy_cls.get_instance.return_value = server + + redis.hgetall.side_effect = hgetall_side_effect + redis.smembers.return_value = client_ids + # buffer_index, ttl, exists all need safe defaults + redis.get.return_value = b"10" + redis.ttl.return_value = 300 + redis.exists.return_value = True + return redis + + def test_ghost_client_removed_from_set(self, mock_proxy_cls): + """Ghost client should be SREM'd and excluded from result.""" + from apps.proxy.ts_proxy.channel_status import ChannelStatus + + def hgetall_side_effect(key): + if "clients:" in key: + return {} # ghost — metadata expired + return _metadata_for_channel() + + redis = self._setup_redis( + mock_proxy_cls, {b"ghost_001"}, hgetall_side_effect + ) + + result = ChannelStatus.get_detailed_channel_info(CHANNEL_ID) + + self.assertEqual(result['client_count'], 0) + self.assertEqual(len(result['clients']), 0) + redis.srem.assert_called_once() + + def test_live_client_preserved(self, mock_proxy_cls): + """Client with valid metadata should appear in results.""" + from apps.proxy.ts_proxy.channel_status import ChannelStatus + + def hgetall_side_effect(key): + if "clients:" in key: + return { + b'user_agent': b'VLC/3.0', + b'worker_id': b'test-worker-1', + b'connected_at': b'1773500000.0', + } + return _metadata_for_channel() + + redis = self._setup_redis( + mock_proxy_cls, {b"live_001"}, hgetall_side_effect + ) + + result = ChannelStatus.get_detailed_channel_info(CHANNEL_ID) + + self.assertEqual(result['client_count'], 1) + self.assertEqual(len(result['clients']), 1) + redis.srem.assert_not_called() + + def test_mixed_ghost_and_live(self, mock_proxy_cls): + """Only ghost clients should be removed; live ones preserved.""" + from apps.proxy.ts_proxy.channel_status import ChannelStatus + + def hgetall_side_effect(key): + if "clients:" in key: + if "ghost" in key: + return {} + return { + b'user_agent': b'VLC/3.0', + b'worker_id': b'test-worker-1', + } + return _metadata_for_channel() + + redis = self._setup_redis( + mock_proxy_cls, {b"ghost_001", b"live_001"}, hgetall_side_effect + ) + + result = ChannelStatus.get_detailed_channel_info(CHANNEL_ID) + + self.assertEqual(result['client_count'], 1) + self.assertEqual(len(result['clients']), 1) + redis.srem.assert_called_once() + + +# --------------------------------------------------------------------------- +# Basic stats path: exercises get_basic_channel_info() +# --------------------------------------------------------------------------- + +@patch("apps.proxy.ts_proxy.channel_status.ProxyServer") +class BasicStatsGhostClientTests(TestCase): + """get_basic_channel_info() should call remove_ghost_clients(), skip + ghosts from display, and correct client_count.""" + + def _setup_redis(self, mock_proxy_cls, client_ids, ghost_ids): + """Wire up mock ProxyServer. ghost_ids controls which EXISTS return False.""" + redis = MagicMock() + server = _make_proxy_server(redis) + mock_proxy_cls.get_instance.return_value = server + + redis.hgetall.return_value = _metadata_for_channel() + redis.get.return_value = b"10" # buffer_index + redis.scard.return_value = len(client_ids) + redis.smembers.return_value = client_ids + redis.hget.return_value = None # individual field lookups + + # Pipeline for remove_ghost_clients + pipe = MagicMock() + redis.pipeline.return_value = pipe + client_id_list = list(client_ids) + pipe.execute.return_value = [ + cid not in ghost_ids for cid in client_id_list + ] + + return redis + + def test_ghost_removed_and_count_corrected(self, mock_proxy_cls): + """Ghost client should be cleaned and client_count decremented.""" + from apps.proxy.ts_proxy.channel_status import ChannelStatus + + redis = self._setup_redis( + mock_proxy_cls, + client_ids={b"ghost_001"}, + ghost_ids={b"ghost_001"}, + ) + + result = ChannelStatus.get_basic_channel_info(CHANNEL_ID) + + self.assertIsNotNone(result) + self.assertEqual(result['client_count'], 0) + redis.srem.assert_called_once() + + def test_live_client_count_preserved(self, mock_proxy_cls): + """Live clients should be counted correctly.""" + from apps.proxy.ts_proxy.channel_status import ChannelStatus + + redis = self._setup_redis( + mock_proxy_cls, + client_ids={b"live_001"}, + ghost_ids=set(), + ) + + result = ChannelStatus.get_basic_channel_info(CHANNEL_ID) + + self.assertIsNotNone(result) + self.assertEqual(result['client_count'], 1) + redis.srem.assert_not_called() + + +# --------------------------------------------------------------------------- +# Orphaned channel cleanup: exercises _check_orphaned_metadata() +# --------------------------------------------------------------------------- + +@patch("apps.proxy.ts_proxy.channel_status.ProxyServer") +class OrphanedChannelGhostValidationTests(TestCase): + """_check_orphaned_metadata() should validate client SET entries when + owner is dead and client_count > 0. If all clients are ghosts, it + should clean up the channel.""" + + def _make_server_for_orphan_check(self, mock_proxy_cls, channel_id, + client_ids, ghost_ids, owner="dead-worker"): + """Build a mock ProxyServer whose Redis state simulates an orphaned channel.""" + redis = MagicMock() + server = _make_proxy_server(redis) + mock_proxy_cls.get_instance.return_value = server + + metadata_key = RedisKeys.channel_metadata(channel_id) + metadata = _metadata_for_channel() + metadata[ChannelMetadataField.OWNER.encode()] = owner.encode() + + # scan returns the one channel metadata key + redis.scan.return_value = (0, [metadata_key.encode()]) + redis.hgetall.return_value = metadata + redis.scard.return_value = len(client_ids) + redis.smembers.return_value = client_ids + # Owner heartbeat is dead + redis.exists.side_effect = lambda key: ( + False if "heartbeat" in key else True + ) + + # Pipeline for remove_ghost_clients + pipe = MagicMock() + redis.pipeline.return_value = pipe + client_id_list = list(client_ids) + pipe.execute.return_value = [ + cid not in ghost_ids for cid in client_id_list + ] + + return server, redis + + def test_all_ghosts_triggers_cleanup(self, mock_proxy_cls): + """When all clients are ghosts, channel should be cleaned up.""" + from apps.proxy.ts_proxy.server import ProxyServer + + channel_id = "00000000-0000-0000-0000-000000000005" + server, redis = self._make_server_for_orphan_check( + mock_proxy_cls, channel_id, + client_ids={b"ghost_001", b"ghost_002"}, + ghost_ids={b"ghost_001", b"ghost_002"}, + ) + + # Call the real method on a real-ish ProxyServer + # The method lives on the server instance, so invoke it directly. + # We need to call _check_orphaned_metadata on the actual server mock, + # but it's a MagicMock. Instead, test via remove_ghost_clients directly + # and verify the cleanup decision logic. + stale_ids = ClientManager.remove_ghost_clients(redis, channel_id) + real_count = max(0, len({b"ghost_001", b"ghost_002"}) - len(stale_ids)) + + self.assertEqual(len(stale_ids), 2) + self.assertEqual(real_count, 0) + redis.srem.assert_called_once() + + def test_mixed_preserves_live_clients(self, mock_proxy_cls): + """When some clients are live, real_count should be > 0.""" + channel_id = "00000000-0000-0000-0000-000000000006" + server, redis = self._make_server_for_orphan_check( + mock_proxy_cls, channel_id, + client_ids={b"ghost_001", b"live_001"}, + ghost_ids={b"ghost_001"}, + ) + + stale_ids = ClientManager.remove_ghost_clients(redis, channel_id) + real_count = max(0, 2 - len(stale_ids)) + + self.assertEqual(len(stale_ids), 1) + self.assertEqual(real_count, 1) + + def test_no_ghosts_no_cleanup(self, mock_proxy_cls): + """When all clients are live, no SREM should be called.""" + channel_id = "00000000-0000-0000-0000-000000000007" + server, redis = self._make_server_for_orphan_check( + mock_proxy_cls, channel_id, + client_ids={b"live_001"}, + ghost_ids=set(), + ) + + stale_ids = ClientManager.remove_ghost_clients(redis, channel_id) + + self.assertEqual(len(stale_ids), 0) + redis.srem.assert_not_called() diff --git a/apps/channels/tests/test_ts_proxy_initializing.py b/apps/channels/tests/test_ts_proxy_initializing.py new file mode 100644 index 0000000..e4e8a67 --- /dev/null +++ b/apps/channels/tests/test_ts_proxy_initializing.py @@ -0,0 +1,231 @@ +"""Tests for stuck INITIALIZING state fix. + +Covers: + - stream_manager.run() finally block: ownership check + state guard fallback + - ChannelState.PRE_ACTIVE contains the correct states + - INITIALIZING is included in the cleanup task grace period check +""" +import time +import threading +from unittest.mock import MagicMock, patch + +from django.test import TestCase + +from apps.proxy.ts_proxy.constants import ChannelMetadataField, ChannelState +from apps.proxy.ts_proxy.redis_keys import RedisKeys +from apps.proxy.ts_proxy.stream_manager import StreamManager + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + +CHANNEL_ID = "00000000-0000-0000-0000-000000000001" + + +def _make_stream_manager(tried_stream_ids=None, max_retries=3): + """Build a StreamManager via __new__ (bypasses __init__) with the + minimum attributes required by the run() finally block.""" + sm = StreamManager.__new__(StreamManager) + sm.channel_id = CHANNEL_ID + sm.worker_id = "worker-1" + sm.max_retries = max_retries + sm.tried_stream_ids = tried_stream_ids if tried_stream_ids is not None else set() + sm.running = False # while-loop exits immediately + sm.connected = False + sm.transcode_process_active = False + sm._buffer_check_timers = [] + sm.url = "http://example.com/stream" + sm.url_switching = False + sm.url_switch_start_time = 0 + sm.url_switch_timeout = 30 + sm.stop_requested = False + sm.stopping = False + sm.socket = None + sm.transcode_process = None + sm.current_response = None + sm.current_session = None + sm.current_stream_id = None + + buffer = MagicMock() + buffer.redis_client = MagicMock() + buffer.channel_id = CHANNEL_ID + sm.buffer = buffer + + return sm + + +def _run_finally_block(sm, owner_value, current_state): + """Invoke StreamManager.run() so its finally block executes against real code. + + Patches threading.Thread and ConfigHelper so the try-block is inert + (self.running=False makes the while-loop exit immediately). + + Returns True if the finally block wrote ERROR to Redis. + """ + redis = sm.buffer.redis_client + + # Mock the owner key GET — the finally block calls redis.get(owner_key) + def get_side_effect(key): + if "owner" in key: + return owner_value + return None + + redis.get.side_effect = get_side_effect + + # Mock hget for state field lookup in the PRE_ACTIVE guard + if current_state is not None: + redis.hget.return_value = current_state.encode('utf-8') + else: + redis.hget.return_value = None + + # Reset hset so we can detect whether ERROR was written + redis.hset.reset_mock() + redis.setex.reset_mock() + + with patch.object(threading, 'Thread', return_value=MagicMock()): + with patch('apps.proxy.ts_proxy.stream_manager.ConfigHelper') as mock_cfg: + mock_cfg.max_stream_switches.return_value = 0 + mock_cfg.max_retries.return_value = sm.max_retries + sm.run() + + # Check if hset was called with ERROR state + if redis.hset.called: + mapping = redis.hset.call_args[1].get('mapping', {}) + return mapping.get(ChannelMetadataField.STATE) == ChannelState.ERROR + return False + + +# --------------------------------------------------------------------------- +# stream_manager.run() finally block: ownership + state guard behavior +# --------------------------------------------------------------------------- + +class StreamManagerFinallyBlockTests(TestCase): + """The run() finally block writes ERROR if the worker is still the owner + (normal case) OR if ownership expired and the channel is still in a + pre-active state (no new owner has taken over).""" + + # --- Owner still valid: always write ERROR --- + + def test_owner_writes_error_regardless_of_state(self): + """When we're still the owner, always write ERROR.""" + sm = _make_stream_manager() + owner = sm.worker_id.encode('utf-8') + self.assertTrue(_run_finally_block(sm, owner, ChannelState.ACTIVE)) + + def test_owner_writes_error_on_initializing(self): + """Owner + INITIALIZING = write ERROR.""" + sm = _make_stream_manager() + owner = sm.worker_id.encode('utf-8') + self.assertTrue(_run_finally_block(sm, owner, ChannelState.INITIALIZING)) + + mapping = sm.buffer.redis_client.hset.call_args[1]['mapping'] + self.assertEqual(mapping[ChannelMetadataField.STATE], ChannelState.ERROR) + + # --- Ownership expired, no new owner: use state guard --- + + def test_no_owner_initializing_writes_error(self): + """Ownership expired + INITIALIZING = write ERROR.""" + sm = _make_stream_manager() + self.assertTrue(_run_finally_block(sm, None, ChannelState.INITIALIZING)) + + def test_no_owner_connecting_writes_error(self): + """Ownership expired + CONNECTING = write ERROR.""" + sm = _make_stream_manager() + self.assertTrue(_run_finally_block(sm, None, ChannelState.CONNECTING)) + + def test_no_owner_buffering_writes_error(self): + """Ownership expired + BUFFERING = write ERROR.""" + sm = _make_stream_manager() + self.assertTrue(_run_finally_block(sm, None, ChannelState.BUFFERING)) + + def test_no_owner_waiting_for_clients_writes_error(self): + """Ownership expired + WAITING_FOR_CLIENTS = write ERROR.""" + sm = _make_stream_manager() + self.assertTrue(_run_finally_block(sm, None, ChannelState.WAITING_FOR_CLIENTS)) + + def test_no_owner_active_does_not_write(self): + """Ownership expired + ACTIVE = do NOT write ERROR.""" + sm = _make_stream_manager() + self.assertFalse(_run_finally_block(sm, None, ChannelState.ACTIVE)) + + def test_no_owner_error_does_not_write(self): + """Ownership expired + already ERROR = do NOT write again.""" + sm = _make_stream_manager() + self.assertFalse(_run_finally_block(sm, None, ChannelState.ERROR)) + + def test_no_owner_no_state_does_not_write(self): + """Ownership expired + no state metadata = do NOT write.""" + sm = _make_stream_manager() + self.assertFalse(_run_finally_block(sm, None, None)) + + # --- New owner took over: never clobber --- + + def test_new_owner_initializing_does_not_write(self): + """Another worker owns the channel — do NOT clobber.""" + sm = _make_stream_manager() + self.assertFalse(_run_finally_block(sm, b"other-worker", ChannelState.INITIALIZING)) + + def test_new_owner_active_does_not_write(self): + """Another worker owns the channel and is ACTIVE — do NOT write.""" + sm = _make_stream_manager() + self.assertFalse(_run_finally_block(sm, b"other-worker", ChannelState.ACTIVE)) + + # --- Stopping key and error messages --- + + def test_stopping_key_set_on_error_update(self): + """When ERROR is written, stopping key must also be set.""" + sm = _make_stream_manager() + _run_finally_block(sm, None, ChannelState.INITIALIZING) + + sm.buffer.redis_client.setex.assert_called_once() + args = sm.buffer.redis_client.setex.call_args[0] + self.assertIn("stopping", args[0]) + self.assertEqual(args[1], 60) + + def test_error_message_includes_stream_count(self): + """When multiple streams were tried, error message reflects that.""" + sm = _make_stream_manager(tried_stream_ids={1, 2, 3}) + _run_finally_block(sm, None, ChannelState.INITIALIZING) + + mapping = sm.buffer.redis_client.hset.call_args[1]['mapping'] + error_msg = mapping[ChannelMetadataField.ERROR_MESSAGE] + self.assertIn("3 stream options failed", error_msg) + + def test_error_message_with_no_streams_tried(self): + """When no alternate streams were tried, shows retry count.""" + sm = _make_stream_manager(tried_stream_ids=set(), max_retries=5) + _run_finally_block(sm, None, ChannelState.INITIALIZING) + + mapping = sm.buffer.redis_client.hset.call_args[1]['mapping'] + error_msg = mapping[ChannelMetadataField.ERROR_MESSAGE] + self.assertIn("5", error_msg) + + +# --------------------------------------------------------------------------- +# ChannelState.PRE_ACTIVE: verify contents and immutability +# --------------------------------------------------------------------------- + +class PreActiveStateTests(TestCase): + """Verify PRE_ACTIVE contains the correct states and is immutable.""" + + def test_initializing_in_pre_active(self): + self.assertIn(ChannelState.INITIALIZING, ChannelState.PRE_ACTIVE) + + def test_connecting_in_pre_active(self): + self.assertIn(ChannelState.CONNECTING, ChannelState.PRE_ACTIVE) + + def test_buffering_in_pre_active(self): + self.assertIn(ChannelState.BUFFERING, ChannelState.PRE_ACTIVE) + + def test_waiting_for_clients_in_pre_active(self): + self.assertIn(ChannelState.WAITING_FOR_CLIENTS, ChannelState.PRE_ACTIVE) + + def test_active_not_in_pre_active(self): + self.assertNotIn(ChannelState.ACTIVE, ChannelState.PRE_ACTIVE) + + def test_error_not_in_pre_active(self): + self.assertNotIn(ChannelState.ERROR, ChannelState.PRE_ACTIVE) + + def test_pre_active_is_frozenset(self): + self.assertIsInstance(ChannelState.PRE_ACTIVE, frozenset) diff --git a/apps/channels/tests/test_ts_proxy_keepalive.py b/apps/channels/tests/test_ts_proxy_keepalive.py new file mode 100644 index 0000000..6779389 --- /dev/null +++ b/apps/channels/tests/test_ts_proxy_keepalive.py @@ -0,0 +1,331 @@ +"""Tests for ts_proxy keepalive and stats-update behavior. + +Covers: + - stream_generator._should_send_keepalive() owner vs non-owner worker paths + - stream_generator._should_send_keepalive() Redis last_data health check + - client_manager._do_stats_update() error handling and WebSocket dispatch + - client_manager.remove_client() non-blocking stats update + - Keepalive/DVR-timeout timing invariants +""" +import threading +import time +from unittest.mock import MagicMock, patch + +from django.test import TestCase + + +# --------------------------------------------------------------------------- +# _should_send_keepalive: owner worker path +# --------------------------------------------------------------------------- + +class OwnerWorkerKeepaliveTests(TestCase): + """Owner worker has a stream_manager; keepalive logic uses it directly.""" + + def _make_generator(self, healthy, at_buffer_head, consecutive_empty): + from apps.proxy.ts_proxy.stream_generator import StreamGenerator + gen = StreamGenerator.__new__(StreamGenerator) + gen.channel_id = "00000000-0000-0000-0000-000000000001" + gen.client_id = "test-client" + + buffer = MagicMock() + buffer.index = 10 if at_buffer_head else 100 + gen.local_index = 10 + gen.buffer = buffer + + stream_manager = MagicMock() + stream_manager.healthy = healthy + gen.stream_manager = stream_manager + + gen.consecutive_empty = consecutive_empty + return gen + + def test_owner_healthy_returns_false(self): + """Owner worker, healthy stream -> no keepalive.""" + gen = self._make_generator(healthy=True, at_buffer_head=True, consecutive_empty=10) + self.assertFalse(gen._should_send_keepalive(gen.local_index)) + + def test_owner_unhealthy_at_head_returns_true(self): + """Owner worker, unhealthy stream, at buffer head -> send keepalive.""" + gen = self._make_generator(healthy=False, at_buffer_head=True, consecutive_empty=10) + self.assertTrue(gen._should_send_keepalive(gen.local_index)) + + def test_owner_unhealthy_not_at_head_returns_false(self): + """Owner worker, unhealthy stream, but NOT at buffer head -> no keepalive.""" + gen = self._make_generator(healthy=False, at_buffer_head=False, consecutive_empty=10) + self.assertFalse(gen._should_send_keepalive(gen.local_index)) + + def test_owner_insufficient_consecutive_empty_returns_false(self): + """Owner worker, unhealthy, at head but consecutive_empty < 5 -> no keepalive.""" + gen = self._make_generator(healthy=False, at_buffer_head=True, consecutive_empty=3) + self.assertFalse(gen._should_send_keepalive(gen.local_index)) + + def test_owner_exactly_5_consecutive_empty_returns_true(self): + """consecutive_empty == 5 is the minimum threshold.""" + gen = self._make_generator(healthy=False, at_buffer_head=True, consecutive_empty=5) + self.assertTrue(gen._should_send_keepalive(gen.local_index)) + + +# --------------------------------------------------------------------------- +# _should_send_keepalive: non-owner worker path +# --------------------------------------------------------------------------- + +class NonOwnerWorkerKeepaliveTests(TestCase): + """Non-owner worker has stream_manager=None; health determined from Redis.""" + + def _make_generator(self, consecutive_empty=10): + from apps.proxy.ts_proxy.stream_generator import StreamGenerator + gen = StreamGenerator.__new__(StreamGenerator) + gen.channel_id = "00000000-0000-0000-0000-000000000002" + gen.client_id = "test-client-nonowner" + + buffer = MagicMock() + buffer.index = 10 + gen.local_index = 10 + gen.buffer = buffer + + gen.stream_manager = None # non-owner worker + gen.consecutive_empty = consecutive_empty + + # Attributes added by health-check throttling (set in __init__) + gen._last_health_check_time = 0.0 + gen._last_health_check_result = False + gen._health_check_interval = 2.0 + gen.proxy_server = None + + return gen + + def _mock_proxy_server(self, last_data_value): + """Return a mock ProxyServer with a redis_client pre-configured.""" + server = MagicMock() + redis_client = MagicMock() + server.redis_client = redis_client + redis_client.get.return_value = last_data_value + return server + + def test_non_owner_fresh_data_returns_false(self): + """Non-owner, last_data < 10s ago -> stream healthy -> no keepalive.""" + gen = self._make_generator() + fresh_ts = str(time.time() - 2.0).encode() + server = self._mock_proxy_server(fresh_ts) + + with patch("apps.proxy.ts_proxy.stream_generator.ProxyServer") as MockPS: + MockPS.get_instance.return_value = server + result = gen._should_send_keepalive(gen.local_index) + + self.assertFalse(result, "Fresh data should NOT trigger keepalive") + + def test_non_owner_stale_data_returns_true(self): + """Non-owner, last_data >= 10s ago -> stream unhealthy -> send keepalive.""" + gen = self._make_generator() + stale_ts = str(time.time() - 12.0).encode() + server = self._mock_proxy_server(stale_ts) + + with patch("apps.proxy.ts_proxy.stream_generator.ProxyServer") as MockPS: + MockPS.get_instance.return_value = server + result = gen._should_send_keepalive(gen.local_index) + + self.assertTrue(result, "Stale data (12s) should trigger keepalive") + + def test_non_owner_exactly_at_timeout_returns_true(self): + """Data age exactly equal to CONNECTION_TIMEOUT (10s) -> send keepalive.""" + gen = self._make_generator() + ts = str(time.time() - 10.0).encode() + server = self._mock_proxy_server(ts) + + with patch("apps.proxy.ts_proxy.stream_generator.ProxyServer") as MockPS: + MockPS.get_instance.return_value = server + result = gen._should_send_keepalive(gen.local_index) + + self.assertTrue(result, "Data at exactly timeout threshold should trigger keepalive") + + def test_non_owner_no_redis_key_returns_true(self): + """Non-owner, last_data key missing from Redis -> assume unhealthy.""" + gen = self._make_generator() + server = self._mock_proxy_server(None) + + with patch("apps.proxy.ts_proxy.stream_generator.ProxyServer") as MockPS: + MockPS.get_instance.return_value = server + result = gen._should_send_keepalive(gen.local_index) + + self.assertTrue(result, "Missing last_data key should trigger keepalive") + + def test_non_owner_redis_client_none_returns_false(self): + """Non-owner, redis_client is None (disconnected) -> conservative, no keepalive.""" + gen = self._make_generator() + server = MagicMock() + server.redis_client = None + + with patch("apps.proxy.ts_proxy.stream_generator.ProxyServer") as MockPS: + MockPS.get_instance.return_value = server + result = gen._should_send_keepalive(gen.local_index) + + self.assertFalse(result, "No redis_client -> conservative, no keepalive") + + def test_non_owner_redis_exception_returns_false(self): + """Non-owner, Redis raises an exception -> conservative, no keepalive.""" + gen = self._make_generator() + + with patch("apps.proxy.ts_proxy.stream_generator.ProxyServer") as MockPS: + MockPS.get_instance.side_effect = Exception("Redis error") + result = gen._should_send_keepalive(gen.local_index) + + self.assertFalse(result, "Redis error -> conservative, no keepalive") + + def test_non_owner_not_at_buffer_head_returns_false(self): + """Non-owner, NOT at buffer head -> no keepalive regardless of Redis.""" + gen = self._make_generator() + gen.buffer.index = 100 # far ahead of local_index=10 + server = self._mock_proxy_server(None) + + with patch("apps.proxy.ts_proxy.stream_generator.ProxyServer") as MockPS: + MockPS.get_instance.return_value = server + result = gen._should_send_keepalive(gen.local_index) + + self.assertFalse(result) + + def test_non_owner_insufficient_consecutive_empty_returns_false(self): + """Non-owner, at head, but consecutive_empty < 5 -> no keepalive.""" + gen = self._make_generator(consecutive_empty=2) + stale_ts = str(time.time() - 30.0).encode() + server = self._mock_proxy_server(stale_ts) + + with patch("apps.proxy.ts_proxy.stream_generator.ProxyServer") as MockPS: + MockPS.get_instance.return_value = server + result = gen._should_send_keepalive(gen.local_index) + + self.assertFalse(result) + + +# --------------------------------------------------------------------------- +# _do_stats_update: error handling and WebSocket dispatch +# --------------------------------------------------------------------------- + +class DoStatsUpdateTests(TestCase): + """_do_stats_update runs the actual Redis scan + WebSocket call.""" + + def _make_client_manager(self): + from apps.proxy.ts_proxy.client_manager import ClientManager + cm = ClientManager.__new__(ClientManager) + cm.channel_id = "00000000-0000-0000-0000-000000000004" + cm._heartbeat_running = False + return cm + + def test_do_stats_update_calls_send_websocket_update(self): + """_do_stats_update must call send_websocket_update with channel_stats.""" + cm = self._make_client_manager() + + mock_redis = MagicMock() + mock_redis.scan.return_value = (0, []) + + with patch("apps.proxy.ts_proxy.client_manager.send_websocket_update") as mock_ws, \ + patch("redis.Redis.from_url", return_value=mock_redis): + cm._do_stats_update() + + mock_ws.assert_called_once() + event_type = mock_ws.call_args[0][1] + self.assertEqual(event_type, "update") + payload = mock_ws.call_args[0][2] + self.assertEqual(payload["type"], "channel_stats") + + def test_do_stats_update_does_not_raise_on_redis_error(self): + """Redis failure must be swallowed (logged), not propagated.""" + cm = self._make_client_manager() + + with patch("redis.Redis.from_url", side_effect=Exception("Redis down")): + try: + cm._do_stats_update() + except Exception as e: + self.fail(f"_do_stats_update raised an exception: {e}") + + def test_do_stats_update_scans_channel_client_keys(self): + """Must scan for ts_proxy:channel:*:clients pattern.""" + cm = self._make_client_manager() + + mock_redis = MagicMock() + mock_redis.scan.return_value = (0, []) + + with patch("apps.proxy.ts_proxy.client_manager.send_websocket_update"), \ + patch("redis.Redis.from_url", return_value=mock_redis): + cm._do_stats_update() + + scan_call = mock_redis.scan.call_args + self.assertIn("ts_proxy:channel:*:clients", str(scan_call)) + + +# --------------------------------------------------------------------------- +# Integration: remove_client must not block on WebSocket +# --------------------------------------------------------------------------- + +class ClientRemoveIntegrationTests(TestCase): + """When remove_client() fires, _trigger_stats_update must not block.""" + + def test_remove_client_does_not_block_on_websocket(self): + """remove_client() must return quickly even if WebSocket is slow.""" + from apps.proxy.ts_proxy.client_manager import ClientManager + + cm = ClientManager.__new__(ClientManager) + cm.channel_id = "00000000-0000-0000-0000-000000000005" + cm._heartbeat_running = False + cm.clients = {"test-client-1"} + cm.last_heartbeat_time = {"test-client-1": time.time()} + cm.last_active_time = time.time() + cm.client_set_key = f"ts_proxy:channel:{cm.channel_id}:clients" + cm.client_ttl = 60 + cm.worker_id = "worker-1" + cm.proxy_server = MagicMock() + cm.proxy_server.am_i_owner.return_value = False + cm.lock = threading.Lock() + + mock_redis = MagicMock() + mock_redis.hgetall.return_value = {b"ip_address": b"127.0.0.1"} + mock_redis.scard.return_value = 1 + cm.redis_client = mock_redis + + slow_ws_called = threading.Event() + + def slow_websocket(*args, **kwargs): + time.sleep(2.0) + slow_ws_called.set() + + start = time.time() + with patch("apps.proxy.ts_proxy.client_manager.send_websocket_update", side_effect=slow_websocket): + cm.remove_client("test-client-1") + elapsed = time.time() - start + + self.assertLess(elapsed, 1.0, + f"remove_client() blocked for {elapsed:.2f}s waiting for WebSocket " + f"(should dispatch to background thread and return immediately)") + + +# --------------------------------------------------------------------------- +# DVR timeout threshold vs keepalive timing +# --------------------------------------------------------------------------- + +class KeepaliveTimingTests(TestCase): + """Verify that keepalive threshold gives sufficient margin before DVR timeout.""" + + def test_keepalive_threshold_less_than_dvr_timeout(self): + """CONNECTION_TIMEOUT (keepalive trigger) must be < DVR read timeout (15s).""" + from apps.proxy.config import TSConfig as Config + connection_timeout = getattr(Config, "CONNECTION_TIMEOUT", 10) + dvr_read_timeout = 15 # hard-coded in run_recording: timeout=(10, 15) + self.assertLess( + connection_timeout, + dvr_read_timeout, + f"CONNECTION_TIMEOUT ({connection_timeout}s) must be < DVR timeout ({dvr_read_timeout}s) " + f"so keepalives fire before DVR times out", + ) + + def test_keepalive_interval_is_short(self): + """KEEPALIVE_INTERVAL must be short enough to send multiple keepalives in the gap.""" + from apps.proxy.config import TSConfig as Config + interval = getattr(Config, "KEEPALIVE_INTERVAL", 0.5) + connection_timeout = getattr(Config, "CONNECTION_TIMEOUT", 10) + remaining_window = 15 - connection_timeout + self.assertGreater( + remaining_window / interval, + 3, + f"KEEPALIVE_INTERVAL ({interval}s) is too long: only " + f"{remaining_window/interval:.1f} keepalives would fit in the " + f"{remaining_window}s window before DVR timeout", + ) diff --git a/apps/channels/tests/test_ts_proxy_keepalive_duration.py b/apps/channels/tests/test_ts_proxy_keepalive_duration.py new file mode 100644 index 0000000..7449413 --- /dev/null +++ b/apps/channels/tests/test_ts_proxy_keepalive_duration.py @@ -0,0 +1,195 @@ +""" +Unit tests for the keepalive duration cap in StreamGenerator._stream_data_generator. + +Verifies that a client held in keepalive mode is disconnected after +MAX_KEEPALIVE_DURATION seconds, and that the timer resets when real data resumes. +""" + +import time +from unittest.mock import MagicMock, patch, call +from django.test import TestCase + + +def _make_generator(consecutive_empty=10, local_index=10, buffer_index=10): + """Minimal StreamGenerator stub for testing _stream_data_generator logic.""" + from apps.proxy.ts_proxy.stream_generator import StreamGenerator + + gen = StreamGenerator.__new__(StreamGenerator) + gen.channel_id = "00000000-0000-0000-0000-000000000099" + gen.client_id = "test-client-duration" + gen.consecutive_empty = consecutive_empty + gen.empty_reads = 0 + gen.local_index = local_index + gen.bytes_sent = 0 + gen.chunks_sent = 0 + gen.last_yield_time = time.time() + gen.stream_start_time = time.time() + gen.last_stats_time = time.time() + gen.last_stats_bytes = 0 + gen.current_rate = 0.0 + gen.last_ttl_refresh = time.time() + gen.ttl_refresh_interval = 3 + gen.is_owner_worker = False + gen.stream_manager = None + gen._last_health_check_time = 0.0 + gen._last_health_check_result = False + gen._health_check_interval = 2.0 + gen.proxy_server = None + + buffer = MagicMock() + buffer.index = buffer_index + buffer.get_optimized_client_data.return_value = ([], local_index) + buffer.find_oldest_available_chunk.return_value = None + gen.buffer = buffer + + return gen + + +class KeepaliveDurationCapTests(TestCase): + """MAX_KEEPALIVE_DURATION cap disconnects clients stuck in keepalive mode.""" + + def _run_generator_to_break(self, gen, max_iterations=20): + """Drive _stream_data_generator until it breaks or hits iteration limit.""" + iterations = 0 + for _ in gen._stream_data_generator(): + iterations += 1 + if iterations >= max_iterations: + break + return iterations + + def test_cap_fires_after_max_duration_exceeded(self): + """Generator exits when keepalive has run longer than MAX_KEEPALIVE_DURATION.""" + gen = _make_generator() + + with patch.object(gen, '_check_resources', return_value=True), \ + patch.object(gen, '_should_send_keepalive', return_value=True), \ + patch.object(gen, '_is_ghost_client', return_value=False), \ + patch.object(gen, '_is_timeout', return_value=False), \ + patch('apps.proxy.ts_proxy.stream_generator.create_ts_packet', return_value=b'\x00' * 188), \ + patch('apps.proxy.ts_proxy.stream_generator.ProxyServer') as MockPS, \ + patch('apps.proxy.ts_proxy.stream_generator.Config') as MockConfig, \ + patch('apps.proxy.ts_proxy.stream_generator.gevent') as mock_gevent, \ + patch('apps.proxy.ts_proxy.stream_generator.time') as mock_time: + + MockPS.get_instance.return_value = None + MockConfig.KEEPALIVE_INTERVAL = 0 + MockConfig.MAX_KEEPALIVE_DURATION = 30 + + # First call: keepalive_start_time not yet set (returns current) + # Second call: inside the cap check — simulate time elapsed > 30s + mock_time.time.side_effect = [ + 1000.0, # keepalive_start_time assignment + 1031.0, # cap check: 31s elapsed > 30s limit + ] + + packets = list(gen._stream_data_generator()) + + # No packets should be yielded — cap fires before yield + self.assertEqual(len(packets), 0) + + def test_cap_does_not_fire_before_max_duration(self): + """Generator yields keepalive packets while within MAX_KEEPALIVE_DURATION.""" + gen = _make_generator() + + call_count = 0 + + def time_side_effect(): + nonlocal call_count + call_count += 1 + # keepalive_start_time set at t=1000; cap checks always see <30s elapsed + if call_count == 1: + return 1000.0 # keepalive_start_time + return 1010.0 # always 10s elapsed — under the 30s cap + + with patch.object(gen, '_check_resources', side_effect=[True, True, False]), \ + patch.object(gen, '_should_send_keepalive', return_value=True), \ + patch.object(gen, '_is_ghost_client', return_value=False), \ + patch.object(gen, '_is_timeout', return_value=False), \ + patch('apps.proxy.ts_proxy.stream_generator.create_ts_packet', return_value=b'\x00' * 188), \ + patch('apps.proxy.ts_proxy.stream_generator.ProxyServer') as MockPS, \ + patch('apps.proxy.ts_proxy.stream_generator.Config') as MockConfig, \ + patch('apps.proxy.ts_proxy.stream_generator.gevent'), \ + patch('apps.proxy.ts_proxy.stream_generator.time') as mock_time: + + MockPS.get_instance.return_value = None + MockConfig.KEEPALIVE_INTERVAL = 0 + MockConfig.MAX_KEEPALIVE_DURATION = 30 + mock_time.time.side_effect = time_side_effect + + packets = list(gen._stream_data_generator()) + + # Two iterations with _check_resources=True should yield two keepalive packets + self.assertGreater(len(packets), 0) + + def test_timer_resets_when_real_data_resumes(self): + """keepalive_start_time is cleared to None when real chunks are received.""" + gen = _make_generator() + + chunk = b'\x47' * 188 + real_chunks = ([chunk], gen.local_index + 1) + no_chunks = ([], gen.local_index) + + # Sequence: no data (keepalive), then real data, then stop + gen.buffer.get_optimized_client_data.side_effect = [ + no_chunks, # iteration 1: keepalive + real_chunks, # iteration 2: real data — should reset timer + no_chunks, # iteration 3: keepalive again — timer restarts fresh + ] + + captured_start_times = [] + + original_gen = gen + + with patch.object(gen, '_check_resources', side_effect=[True, True, True, False]), \ + patch.object(gen, '_should_send_keepalive', return_value=True), \ + patch.object(gen, '_is_ghost_client', return_value=False), \ + patch.object(gen, '_is_timeout', return_value=False), \ + patch.object(gen, '_process_chunks', return_value=iter([chunk])), \ + patch('apps.proxy.ts_proxy.stream_generator.create_ts_packet', return_value=b'\x00' * 188), \ + patch('apps.proxy.ts_proxy.stream_generator.ProxyServer') as MockPS, \ + patch('apps.proxy.ts_proxy.stream_generator.Config') as MockConfig, \ + patch('apps.proxy.ts_proxy.stream_generator.gevent'), \ + patch('apps.proxy.ts_proxy.stream_generator.time') as mock_time: + + MockPS.get_instance.return_value = None + MockConfig.KEEPALIVE_INTERVAL = 0 + MockConfig.MAX_KEEPALIVE_DURATION = 300 + mock_time.time.return_value = 1000.0 + + list(gen._stream_data_generator()) + + # Test passes if no exception and generator completes normally — + # if the timer were NOT reset, the second keepalive block would + # carry over the old start time rather than starting fresh. + + def test_cap_uses_config_value(self): + """Cap threshold reads MAX_KEEPALIVE_DURATION from Config, not a hardcoded value.""" + gen = _make_generator() + + with patch.object(gen, '_check_resources', return_value=True), \ + patch.object(gen, '_should_send_keepalive', return_value=True), \ + patch.object(gen, '_is_ghost_client', return_value=False), \ + patch.object(gen, '_is_timeout', return_value=False), \ + patch('apps.proxy.ts_proxy.stream_generator.create_ts_packet', return_value=b'\x00' * 188), \ + patch('apps.proxy.ts_proxy.stream_generator.ProxyServer') as MockPS, \ + patch('apps.proxy.ts_proxy.stream_generator.Config') as MockConfig, \ + patch('apps.proxy.ts_proxy.stream_generator.gevent'), \ + patch('apps.proxy.ts_proxy.stream_generator.time') as mock_time: + + MockPS.get_instance.return_value = None + MockConfig.KEEPALIVE_INTERVAL = 0 + # Set a custom cap of 60s + MockConfig.MAX_KEEPALIVE_DURATION = 60 + + mock_time.time.side_effect = [ + 1000.0, # keepalive_start_time + 1050.0, # cap check: 50s elapsed — under 60s, should NOT fire + 1000.0, # last_yield_time update + 1070.0, # cap check on next iteration: 70s elapsed — fires + ] + + packets = list(gen._stream_data_generator()) + + # First iteration: 50s < 60s cap — one keepalive yielded + # Second iteration: 70s > 60s cap — generator exits + self.assertEqual(len(packets), 1) diff --git a/apps/channels/tests/test_validate_url.py b/apps/channels/tests/test_validate_url.py new file mode 100644 index 0000000..e52c79f --- /dev/null +++ b/apps/channels/tests/test_validate_url.py @@ -0,0 +1,169 @@ +"""Tests for the _validate_url() helper in tasks.py. + +Covers: + - Rejection of None, empty, and non-string inputs + - Non-HTTP URLs pass through without network requests + - HTTP(S) URLs validated via HEAD request (2xx/3xx pass, 4xx/5xx fail) + - Network errors (timeout, connection) treated as failures + - Per-worker result cache: hits, expiry, eviction +""" +from unittest.mock import patch, MagicMock + +from django.test import TestCase + +from apps.channels.tasks import _validate_url, _url_validation_cache, _URL_CACHE_TTL + + +class ValidateUrlInputTests(TestCase): + """Input validation — no network requests should be made.""" + + def setUp(self): + _url_validation_cache.clear() + + def test_none_returns_false(self): + self.assertFalse(_validate_url(None)) + + def test_empty_string_returns_false(self): + self.assertFalse(_validate_url("")) + + def test_non_string_returns_false(self): + self.assertFalse(_validate_url(123)) + self.assertFalse(_validate_url(["http://example.com"])) + + @patch("apps.channels.tasks.requests.head") + def test_non_http_url_returns_true_without_request(self, mock_head): + """file:// and other non-HTTP schemes skip validation.""" + self.assertTrue(_validate_url("file:///local/path.jpg")) + self.assertTrue(_validate_url("/data/images/poster.jpg")) + mock_head.assert_not_called() + + +class ValidateUrlNetworkTests(TestCase): + """HTTP(S) URL validation via HEAD request.""" + + def setUp(self): + _url_validation_cache.clear() + + @patch("apps.channels.tasks.requests.head") + def test_200_returns_true(self, mock_head): + mock_head.return_value = MagicMock(status_code=200) + self.assertTrue(_validate_url("https://example.com/poster.jpg")) + + @patch("apps.channels.tasks.requests.head") + def test_302_redirect_returns_true(self, mock_head): + mock_head.return_value = MagicMock(status_code=302) + self.assertTrue(_validate_url("https://example.com/redirect")) + + @patch("apps.channels.tasks.requests.head") + def test_404_returns_false(self, mock_head): + mock_head.return_value = MagicMock(status_code=404) + self.assertFalse(_validate_url("https://dead-cdn.com/missing.jpg")) + + @patch("apps.channels.tasks.requests.head") + def test_500_returns_false(self, mock_head): + mock_head.return_value = MagicMock(status_code=500) + self.assertFalse(_validate_url("https://broken.com/error")) + + @patch("apps.channels.tasks.requests.head") + def test_timeout_returns_false(self, mock_head): + import requests + mock_head.side_effect = requests.Timeout("timed out") + self.assertFalse(_validate_url("https://slow-cdn.com/poster.jpg")) + + @patch("apps.channels.tasks.requests.head") + def test_connection_error_returns_false(self, mock_head): + import requests + mock_head.side_effect = requests.ConnectionError("refused") + self.assertFalse(_validate_url("https://unreachable.com/poster.jpg")) + + @patch("apps.channels.tasks.requests.head") + def test_custom_timeout_passed_to_head(self, mock_head): + mock_head.return_value = MagicMock(status_code=200) + _validate_url("https://example.com/img.jpg", timeout=10) + mock_head.assert_called_once_with( + "https://example.com/img.jpg", timeout=10, allow_redirects=True + ) + + @patch("apps.channels.tasks.requests.get") + @patch("apps.channels.tasks.requests.head") + def test_405_falls_back_to_get(self, mock_head, mock_get): + """When HEAD returns 405, fall back to a ranged GET request.""" + mock_head.return_value = MagicMock(status_code=405) + mock_resp = MagicMock(status_code=200) + mock_get.return_value = mock_resp + self.assertTrue(_validate_url("https://no-head.com/poster.jpg")) + mock_get.assert_called_once() + mock_resp.close.assert_called_once() + + @patch("apps.channels.tasks.requests.get") + @patch("apps.channels.tasks.requests.head") + def test_405_fallback_get_also_fails(self, mock_head, mock_get): + """When HEAD returns 405 and GET also fails, return False.""" + mock_head.return_value = MagicMock(status_code=405) + mock_get.return_value = MagicMock(status_code=403) + self.assertFalse(_validate_url("https://blocked.com/poster.jpg")) + + +class ValidateUrlCacheTests(TestCase): + """Per-worker result caching.""" + + def setUp(self): + _url_validation_cache.clear() + + @patch("apps.channels.tasks.requests.head") + def test_cache_hit_avoids_second_request(self, mock_head): + mock_head.return_value = MagicMock(status_code=200) + url = "https://cached.com/poster.jpg" + self.assertTrue(_validate_url(url)) + self.assertTrue(_validate_url(url)) + mock_head.assert_called_once() + + @patch("apps.channels.tasks.requests.head") + def test_cache_hit_returns_false_for_failed_url(self, mock_head): + mock_head.return_value = MagicMock(status_code=404) + url = "https://dead.com/missing.jpg" + self.assertFalse(_validate_url(url)) + self.assertFalse(_validate_url(url)) + mock_head.assert_called_once() + + @patch("apps.channels.tasks.time.monotonic") + @patch("apps.channels.tasks.requests.head") + def test_cache_expiry_triggers_new_request(self, mock_head, mock_time): + """After TTL expires, a new HEAD request is made.""" + mock_head.return_value = MagicMock(status_code=200) + url = "https://expiring.com/poster.jpg" + + mock_time.return_value = 1000.0 + self.assertTrue(_validate_url(url)) + self.assertEqual(mock_head.call_count, 1) + + # Within TTL — cache hit + mock_time.return_value = 1000.0 + _URL_CACHE_TTL - 1 + self.assertTrue(_validate_url(url)) + self.assertEqual(mock_head.call_count, 1) + + # Past TTL — new request + mock_time.return_value = 1000.0 + _URL_CACHE_TTL + 1 + self.assertTrue(_validate_url(url)) + self.assertEqual(mock_head.call_count, 2) + + @patch("apps.channels.tasks.time.monotonic") + @patch("apps.channels.tasks.requests.head") + def test_eviction_when_cache_exceeds_limit(self, mock_head, mock_time): + """Expired entries are evicted when cache grows past 512 entries.""" + mock_head.return_value = MagicMock(status_code=200) + + # Fill cache with 513 entries at time 0 + mock_time.return_value = 0.0 + for i in range(513): + _url_validation_cache[f"https://fill-{i}.com/img.jpg"] = (True, 0.0) + + # Advance past TTL and add one more — triggers eviction + mock_time.return_value = _URL_CACHE_TTL + 1 + _validate_url("https://trigger-eviction.com/img.jpg") + + # All 513 old entries expired and should be evicted + remaining = [k for k in _url_validation_cache if k.startswith("https://fill-")] + self.assertEqual(len(remaining), 0) + # The new entry should remain + self.assertIn("https://trigger-eviction.com/img.jpg", _url_validation_cache) diff --git a/apps/channels/urls.py b/apps/channels/urls.py new file mode 100644 index 0000000..f52d23b --- /dev/null +++ b/apps/channels/urls.py @@ -0,0 +1,12 @@ +from django.urls import path +from .views import StreamDashboardView, channels_dashboard_view + +app_name = 'channels_dashboard' + +urlpatterns = [ + # Example “dashboard” routes for streams + path('streams/', StreamDashboardView.as_view(), name='stream_dashboard'), + + # Example “dashboard” route for channels + path('channels/', channels_dashboard_view, name='channels_dashboard'), +] diff --git a/apps/channels/utils.py b/apps/channels/utils.py new file mode 100644 index 0000000..70358ce --- /dev/null +++ b/apps/channels/utils.py @@ -0,0 +1,25 @@ +import threading + +lock = threading.Lock() +# Dictionary to track usage: {account_id: current_usage} +active_streams_map = {} + +def increment_stream_count(account): + with lock: + current_usage = active_streams_map.get(account.id, 0) + current_usage += 1 + active_streams_map[account.id] = current_usage + account.active_streams = current_usage + account.save(update_fields=['active_streams']) + +def decrement_stream_count(account): + with lock: + current_usage = active_streams_map.get(account.id, 0) + if current_usage > 0: + current_usage -= 1 + if current_usage == 0: + del active_streams_map[account.id] + else: + active_streams_map[account.id] = current_usage + account.active_streams = current_usage + account.save(update_fields=['active_streams']) diff --git a/apps/channels/views.py b/apps/channels/views.py new file mode 100644 index 0000000..8af3fa3 --- /dev/null +++ b/apps/channels/views.py @@ -0,0 +1,41 @@ +from django.views import View +from django.http import JsonResponse +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.shortcuts import render + +from .models import Stream + +@method_decorator(csrf_exempt, name='dispatch') +@method_decorator(login_required, name='dispatch') +class StreamDashboardView(View): + """ + Example “dashboard” style view for Streams + """ + def get(self, request, *args, **kwargs): + streams = Stream.objects.values( + 'id', 'name', 'url', + 'channel_group', 'current_viewers' + ) + return JsonResponse({'data': list(streams)}, safe=False) + + def post(self, request, *args, **kwargs): + """ + Creates a new Stream from JSON data + """ + import json + try: + data = json.loads(request.body) + new_stream = Stream.objects.create(**data) + return JsonResponse({ + 'id': new_stream.id, + 'message': 'Stream created successfully!' + }, status=201) + except Exception as e: + return JsonResponse({'error': str(e)}, status=400) + + +@login_required +def channels_dashboard_view(request): + return render(request, 'channels/channels.html') diff --git a/apps/connect/__init__.py b/apps/connect/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/connect/api_urls.py b/apps/connect/api_urls.py new file mode 100644 index 0000000..664c437 --- /dev/null +++ b/apps/connect/api_urls.py @@ -0,0 +1,17 @@ +from django.urls import path +from rest_framework.routers import DefaultRouter +from .api_views import ( + IntegrationViewSet, + EventSubscriptionViewSet, + DeliveryLogViewSet, +) + +app_name = 'connect' + +router = DefaultRouter() +router.register(r'integrations', IntegrationViewSet, basename='integration') +router.register(r'subscriptions', EventSubscriptionViewSet, basename='subscription') +router.register(r'logs', DeliveryLogViewSet, basename='delivery-log') + +urlpatterns = [] +urlpatterns += router.urls diff --git a/apps/connect/api_views.py b/apps/connect/api_views.py new file mode 100644 index 0000000..fdb5d43 --- /dev/null +++ b/apps/connect/api_views.py @@ -0,0 +1,226 @@ +from rest_framework import viewsets, status, serializers +from rest_framework.pagination import PageNumberPagination +from django_filters.rest_framework import DjangoFilterBackend +from rest_framework.response import Response +from rest_framework.decorators import action +from django.utils import timezone +from drf_spectacular.utils import extend_schema, inline_serializer +from .models import Integration, EventSubscription, DeliveryLog +from .serializers import ( + IntegrationSerializer, + EventSubscriptionSerializer, + DeliveryLogSerializer, +) +from apps.accounts.permissions import ( + Authenticated, + permission_classes_by_action, + IsAdmin, +) +from .handlers.webhook import WebhookHandler +from .handlers.script import ScriptHandler + + +class IntegrationViewSet(viewsets.ModelViewSet): + queryset = Integration.objects.all() + serializer_class = IntegrationSerializer + + def get_permissions(self): + try: + perms = permission_classes_by_action[self.action] + except KeyError: + # Respect view/action-specific permission_classes if provided; fallback to Authenticated + perms = getattr(self, "permission_classes", [Authenticated]) + return [perm() for perm in perms] + + @action(detail=True, methods=["get"], url_path="subscriptions") + def list_subscriptions(self, request, pk=None): + qs = EventSubscription.objects.filter(integration_id=pk) + serializer = EventSubscriptionSerializer(qs, many=True) + return Response(serializer.data) + + @extend_schema( + methods=["PUT"], + description=( + "Replace the integration's event subscriptions with the provided list. " + "Accepts a JSON array of subscription objects. " + "Existing subscriptions not in the list will be deleted. " + "The 'payload_template' field is only relevant for webhook integrations." + ), + request=inline_serializer( + name="SetSubscriptionsRequest", + fields={ + "event": serializers.CharField(help_text="Event name (e.g. 'channel_start')."), + "enabled": serializers.BooleanField(required=False, default=True), + "payload_template": serializers.CharField(required=False, allow_blank=True, allow_null=True, help_text="Custom payload template (webhook integrations only)."), + }, + many=True, + ), + responses={200: inline_serializer( + name="SetSubscriptionsResponse", + fields={ + "event": serializers.CharField(), + "enabled": serializers.BooleanField(), + "payload_template": serializers.CharField(allow_null=True), + }, + many=True, + )}, + ) + @action(detail=True, methods=["put"], url_path=r"subscriptions/set") + def set_subscriptions(self, request, pk=None): + """ + Replace the integration's subscriptions with the provided list. + Body format: [{"event": "channel_start", "enabled": true, "payload_template": "..."}, ...] + Any existing subscriptions not in the list will be deleted; missing ones will be created/updated. + """ + try: + integration = Integration.objects.get(pk=pk) + except Integration.DoesNotExist: + return Response( + {"detail": "Integration not found"}, status=status.HTTP_404_NOT_FOUND + ) + + data = request.data + if not isinstance(data, list): + return Response( + {"detail": "Expected a list of subscriptions"}, + status=status.HTTP_400_BAD_REQUEST, + ) + + # Validate incoming items using serializer (without integration field) + # We'll attach the integration explicitly + valid_events = set(evt for evt, _ in EventSubscription.EVENT_CHOICES) + incoming = [] + for item in data: + if not isinstance(item, dict): + return Response( + {"detail": "Each subscription must be an object"}, + status=status.HTTP_400_BAD_REQUEST, + ) + event = item.get("event") + if event not in valid_events: + return Response( + {"detail": f"Invalid event: {event}"}, + status=status.HTTP_400_BAD_REQUEST, + ) + # Only accept payload_template when the integration is a webhook + payload_template = item.get("payload_template") if integration.type == "webhook" else None + incoming.append( + { + "event": event, + "enabled": bool(item.get("enabled", True)), + "payload_template": payload_template, + } + ) + + incoming_events = {s["event"] for s in incoming} + + # Delete subscriptions that are no longer present + EventSubscription.objects.filter(integration=integration).exclude( + event__in=incoming_events + ).delete() + + # Upsert incoming subscriptions + updated = [] + for sub in incoming: + obj, _created = EventSubscription.objects.update_or_create( + integration=integration, + event=sub["event"], + defaults={ + "enabled": sub["enabled"], + "payload_template": sub.get("payload_template"), + }, + ) + updated.append(obj) + + serializer = EventSubscriptionSerializer(updated, many=True) + return Response(serializer.data, status=status.HTTP_200_OK) + + @action(detail=True, methods=["post"], url_path="test", permission_classes=[IsAdmin]) + def test(self, request, pk=None): + """ + Execute a saved integration (connect) with a dummy payload to verify configuration. + """ + try: + integration = Integration.objects.get(pk=pk) + except Integration.DoesNotExist: + return Response({"detail": "Integration not found"}, status=status.HTTP_404_NOT_FOUND) + + # Build a dummy payload similar to system events + now = timezone.now().isoformat() + dummy_payload = { + "event": "test", + "timestamp": now, + "channel_name": "Test Channel", + "stream_name": "Test Stream", + "stream_url": "http://example.com/stream.m3u8", + "channel_url": "http://example.com/stream.m3u8", + "provider_name": "Test Provider", + "profile_used": "Default", + "test": True, + } + + # Choose handler based on saved type + if integration.type == "webhook": + handler = WebhookHandler(integration, None, dummy_payload) + elif integration.type == "script": + handler = ScriptHandler(integration, None, dummy_payload) + else: + return Response( + {"success": False, "error": f"Unsupported integration type: {integration.type}"}, + status=status.HTTP_400_BAD_REQUEST, + ) + + try: + result = handler.execute() + return Response( + { + "success": bool(result.get("success")), + "type": integration.type, + "request_payload": dummy_payload, + "result": result, + }, + status=status.HTTP_200_OK, + ) + except Exception as e: + return Response( + { + "success": False, + "type": integration.type, + "request_payload": dummy_payload, + "error": str(e), + }, + status=status.HTTP_502_BAD_GATEWAY, + ) + + +class EventSubscriptionViewSet(viewsets.ModelViewSet): + queryset = EventSubscription.objects.all() + serializer_class = EventSubscriptionSerializer + + +class DeliveryLogViewSet(viewsets.ReadOnlyModelViewSet): + queryset = DeliveryLog.objects.all().order_by("-created_at") + serializer_class = DeliveryLogSerializer + filter_backends = [DjangoFilterBackend] + + # Support server-side pagination with page_size query param + class ConnectLogsPagination(PageNumberPagination): + page_size = 50 + page_size_query_param = "page_size" + max_page_size = 250 + + pagination_class = ConnectLogsPagination + + def get_queryset(self): + qs = super().get_queryset() + + # Optional filters: integration id and type + integration_id = self.request.query_params.get("integration") + if integration_id: + qs = qs.filter(subscription__integration_id=integration_id) + + integration_type = self.request.query_params.get("type") + if integration_type: + qs = qs.filter(subscription__integration__type=integration_type) + + return qs diff --git a/apps/connect/apps.py b/apps/connect/apps.py new file mode 100644 index 0000000..db063fa --- /dev/null +++ b/apps/connect/apps.py @@ -0,0 +1,8 @@ +from django.apps import AppConfig + + +class ConnectConfig(AppConfig): + default_auto_field = 'django.db.models.BigAutoField' + name = 'apps.connect' + verbose_name = "Connect Integrations" + label = 'dispatcharr_connect' diff --git a/apps/connect/handlers/__init__.py b/apps/connect/handlers/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/connect/handlers/api.py b/apps/connect/handlers/api.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/connect/handlers/base.py b/apps/connect/handlers/base.py new file mode 100644 index 0000000..d61b7c5 --- /dev/null +++ b/apps/connect/handlers/base.py @@ -0,0 +1,12 @@ +# connect/handlers/base.py +import abc + +class IntegrationHandler(abc.ABC): + def __init__(self, integration, subscription, payload): + self.integration = integration + self.subscription = subscription + self.payload = payload + + @abc.abstractmethod + def execute(self): + pass diff --git a/apps/connect/handlers/script.py b/apps/connect/handlers/script.py new file mode 100644 index 0000000..b6aef79 --- /dev/null +++ b/apps/connect/handlers/script.py @@ -0,0 +1,81 @@ +# connect/handlers/script.py +import os +import stat +import subprocess +from django.conf import settings +from .base import IntegrationHandler + + +def _is_path_allowed(real_path: str) -> bool: + # Ensure path is within one of the allowed directories + for base in getattr(settings, "CONNECT_ALLOWED_SCRIPT_DIRS", []): + base_abs = os.path.abspath(base) + os.sep + if real_path.startswith(base_abs): + return True + return False + + +class ScriptHandler(IntegrationHandler): + def execute(self): + raw_path = self.integration.config.get("path") + if not raw_path: + raise ValueError("Missing 'path' in integration config") + + # Resolve and validate path + real_path = os.path.abspath(os.path.realpath(raw_path)) + + if not os.path.exists(real_path): + raise FileNotFoundError(f"Script not found: {real_path}") + + if not _is_path_allowed(real_path): + raise PermissionError( + f"Script path '{real_path}' not within allowed directories: " + f"{getattr(settings, 'CONNECT_ALLOWED_SCRIPT_DIRS', [])}" + ) + + if getattr(settings, "CONNECT_SCRIPT_REQUIRE_EXECUTABLE", True): + if not os.access(real_path, os.X_OK): + raise PermissionError(f"Script is not executable: {real_path}") + + if getattr(settings, "CONNECT_SCRIPT_DISALLOW_WORLD_WRITABLE", True): + st = os.stat(real_path) + if st.st_mode & stat.S_IWOTH: + raise PermissionError( + f"Refusing to execute world-writable script: {real_path}" + ) + + # Build a sanitized minimal environment; avoid inheriting secrets + env = { + "PATH": "/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin", + } + for key, value in (self.payload or {}).items(): + env_key = f"DISPATCHARR_{str(key).upper()}" + env[env_key] = "" if value is None else str(value) + + # Run with a timeout to prevent hanging scripts + timeout = getattr(settings, "CONNECT_SCRIPT_TIMEOUT", 10) + max_out = getattr(settings, "CONNECT_SCRIPT_MAX_OUTPUT", 65536) + + result = subprocess.run( + [real_path], + capture_output=True, + text=True, + env=env, + timeout=timeout, + cwd=os.path.dirname(real_path) or None, + ) + + # Truncate outputs to avoid excessive memory/logging + stdout = result.stdout or "" + stderr = result.stderr or "" + if len(stdout) > max_out: + stdout = stdout[:max_out] + "... [truncated]" + if len(stderr) > max_out: + stderr = stderr[:max_out] + "... [truncated]" + + return { + "exit_code": result.returncode, + "stdout": stdout, + "stderr": stderr, + "success": result.returncode == 0, + } diff --git a/apps/connect/handlers/webhook.py b/apps/connect/handlers/webhook.py new file mode 100644 index 0000000..511f0a0 --- /dev/null +++ b/apps/connect/handlers/webhook.py @@ -0,0 +1,21 @@ +# connect/handlers/webhook.py +import requests, json, logging +from .base import IntegrationHandler + +logger = logging.getLogger(__name__) + +class WebhookHandler(IntegrationHandler): + def execute(self): + url = self.integration.config.get("url") + headers = self.integration.config.get("headers", {}) + logger.info(self.payload) + + try: + parsed = json.loads(self.payload) + headers["Content-Type"] = "application/json" + except Exception: + pass + + response = requests.post(url, data=self.payload, headers=headers, timeout=10) + + return {"status_code": response.status_code, "body": response.text, "success": response.ok} diff --git a/apps/connect/migrations/0001_initial.py b/apps/connect/migrations/0001_initial.py new file mode 100644 index 0000000..cd8f6b4 --- /dev/null +++ b/apps/connect/migrations/0001_initial.py @@ -0,0 +1,52 @@ +# Generated by Django 5.2.9 on 2026-01-27 21:05 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ] + + operations = [ + migrations.CreateModel( + name='EventSubscription', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('event', models.CharField(choices=[('channel_start', 'Channel Started'), ('channel_stop', 'Channel Stopped'), ('movie_added', 'Movie Added'), ('series_added', 'Series Added'), ('download_complete', 'Download Complete')], max_length=100)), + ('enabled', models.BooleanField(default=True)), + ('payload_template', models.TextField(blank=True, help_text='Optional Jinja2/Django template for customizing payload', null=True)), + ], + ), + migrations.CreateModel( + name='Integration', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('name', models.CharField(max_length=255)), + ('type', models.CharField(choices=[('webhook', 'Webhook'), ('api', 'API'), ('script', 'Custom Script')], max_length=50)), + ('config', models.JSONField(default=dict)), + ('enabled', models.BooleanField(default=True)), + ('created_at', models.DateTimeField(auto_now_add=True)), + ], + ), + migrations.CreateModel( + name='DeliveryLog', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('status', models.CharField(choices=[('success', 'Success'), ('failed', 'Failed')], max_length=50)), + ('request_payload', models.JSONField(blank=True, default=dict)), + ('response_payload', models.JSONField(blank=True, default=dict)), + ('error_message', models.TextField(blank=True, null=True)), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('subscription', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='logs', to='dispatcharr_connect.eventsubscription')), + ], + ), + migrations.AddField( + model_name='eventsubscription', + name='integration', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='subscriptions', to='dispatcharr_connect.integration'), + ), + ] diff --git a/apps/connect/migrations/0002_alter_eventsubscription_event.py b/apps/connect/migrations/0002_alter_eventsubscription_event.py new file mode 100644 index 0000000..90eee90 --- /dev/null +++ b/apps/connect/migrations/0002_alter_eventsubscription_event.py @@ -0,0 +1,18 @@ +# Generated by Django 5.2.11 on 2026-02-26 19:24 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('dispatcharr_connect', '0001_initial'), + ] + + operations = [ + migrations.AlterField( + model_name='eventsubscription', + name='event', + field=models.CharField(choices=[('channel_start', 'Channel Started'), ('channel_stop', 'Channel Stopped'), ('channel_reconnect', 'Channel Reconnected'), ('channel_error', 'Channel Error'), ('channel_failover', 'Channel Failover'), ('stream_switch', 'Stream Switch'), ('recording_start', 'Recording Started'), ('recording_end', 'Recording Ended'), ('epg_refresh', 'EPG Refreshed'), ('m3u_refresh', 'M3U Refreshed'), ('client_connect', 'Client Connected'), ('client_disconnect', 'Client Disconnected'), ('login_failed', 'Login Failed'), ('epg_blocked', 'EPG Blocked'), ('m3u_blocked', 'M3U Blocked')], max_length=100), + ), + ] diff --git a/apps/connect/migrations/__init__.py b/apps/connect/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/connect/models.py b/apps/connect/models.py new file mode 100644 index 0000000..0b33a85 --- /dev/null +++ b/apps/connect/models.py @@ -0,0 +1,47 @@ +from django.db import models + +SUPPORTED_EVENTS = { + "channel_start": "Channel Started", + "channel_stop": "Channel Stopped", + "channel_reconnect": "Channel Reconnected", + "channel_error": "Channel Error", + "channel_failover": "Channel Failover", + "stream_switch": "Stream Switch", + "recording_start": "Recording Started", + "recording_end": "Recording Ended", + "epg_refresh": "EPG Refreshed", + "m3u_refresh": "M3U Refreshed", + "client_connect": "Client Connected", + "client_disconnect": "Client Disconnected", + "login_failed": "Login Failed", + "epg_blocked": "EPG Blocked", + "m3u_blocked": "M3U Blocked", +} + +class Integration(models.Model): + TYPE_CHOICES = [ + ("webhook", "Webhook"), + ("api", "API"), + ("script", "Custom Script"), + ] + name = models.CharField(max_length=255) + type = models.CharField(max_length=50, choices=TYPE_CHOICES) + config = models.JSONField(default=dict) + enabled = models.BooleanField(default=True) + created_at = models.DateTimeField(auto_now_add=True) + + +class EventSubscription(models.Model): + EVENT_CHOICES = list(SUPPORTED_EVENTS.items()) + event = models.CharField(max_length=100, choices=EVENT_CHOICES) + integration = models.ForeignKey(Integration, on_delete=models.CASCADE, related_name="subscriptions") + enabled = models.BooleanField(default=True) + payload_template = models.TextField(blank=True, null=True, help_text="Optional Jinja2/Django template for customizing payload") + +class DeliveryLog(models.Model): + subscription = models.ForeignKey(EventSubscription, on_delete=models.CASCADE, related_name="logs") + status = models.CharField(max_length=50, choices=[("success", "Success"), ("failed", "Failed")]) + request_payload = models.JSONField(default=dict, blank=True) + response_payload = models.JSONField(default=dict, blank=True) + error_message = models.TextField(blank=True, null=True) + created_at = models.DateTimeField(auto_now_add=True) diff --git a/apps/connect/serializers.py b/apps/connect/serializers.py new file mode 100644 index 0000000..832fc14 --- /dev/null +++ b/apps/connect/serializers.py @@ -0,0 +1,68 @@ +from rest_framework import serializers +from .models import Integration, EventSubscription, DeliveryLog +import os + + +class EventSubscriptionSerializer(serializers.ModelSerializer): + class Meta: + model = EventSubscription + fields = [ + "id", + "event", + "enabled", + "payload_template", + "integration", + ] + + +class IntegrationSerializer(serializers.ModelSerializer): + subscriptions = EventSubscriptionSerializer(many=True, read_only=True) + + class Meta: + model = Integration + fields = [ + "id", + "name", + "type", + "config", + "enabled", + "created_at", + "subscriptions", + ] + + def validate(self, attrs): + type = attrs.get("type") if "type" in attrs else getattr(self.instance, "type", None) + config = attrs.get("config") if "config" in attrs else getattr(self.instance, "config", {}) + + if type == "script": + path = (config or {}).get("path") + if not path or not isinstance(path, str): + raise serializers.ValidationError({"config": "Script config must include a 'path' string"}) + + real_path = os.path.abspath(os.path.realpath(path)) + if not os.path.exists(real_path): + raise serializers.ValidationError({"config": f"Script path does not exist: {path}"}) + elif type == "webhook": + url = (config or {}).get("url") + if not url or not isinstance(url, str): + raise serializers.ValidationError({"config": "Webhook config must include a 'url' string"}) + else: + raise serializers.ValidationError({"type": "Unsupported integration type"}) + + return attrs + + +class DeliveryLogSerializer(serializers.ModelSerializer): + subscription = EventSubscriptionSerializer(read_only=True) + + class Meta: + model = DeliveryLog + fields = [ + "id", + "subscription", + "status", + "request_payload", + "response_payload", + "error_message", + "created_at", + ] diff --git a/apps/connect/utils.py b/apps/connect/utils.py new file mode 100644 index 0000000..c3a0975 --- /dev/null +++ b/apps/connect/utils.py @@ -0,0 +1,116 @@ +# connect/utils.py +import logging, json +from django.template import Template, Context +from .models import EventSubscription, DeliveryLog, SUPPORTED_EVENTS +from .handlers.webhook import WebhookHandler +from .handlers.script import ScriptHandler +from apps.plugins.loader import PluginManager + +logger = logging.getLogger(__name__) + +HANDLERS = { + "webhook": WebhookHandler, + "script": ScriptHandler, +} + + +def trigger_event(event_name, payload): + if event_name not in SUPPORTED_EVENTS: + logger.debug(f"Unsupported event '{event_name}' - skipping") + return + + logger.debug( + f"Triggering connect event: {event_name} payload_keys={list((payload or {}).keys())}" + ) + subscriptions = EventSubscription.objects.filter( + event=event_name, enabled=True + ).select_related("integration") + + count = subscriptions.count() + logger.info(f"Found {count} connect subscription(s) for event '{event_name}'") + + # First, fetch all subscriptions and trigger + for sub in subscriptions: + integration = sub.integration + if not integration.enabled: + logger.debug( + f"Skipping disabled integration id={integration.id} name={integration.name}" + ) + continue + + # apply optional payload template (only for webhook integrations) + # If the rendered template is valid JSON, use that object as the payload. + # Otherwise, pass the rendered string as-is. + final_payload = payload + if integration.type == 'webhook' and sub.payload_template: + try: + template = Template(sub.payload_template) + final_payload = template.render(Context(payload)).strip() + except Exception as e: + logger.error( + f"Payload template render failed for subscription id={sub.id}: {e}" + ) + final_payload = payload + + handler_cls = HANDLERS.get(integration.type) + if not handler_cls: + DeliveryLog.objects.create( + subscription=sub, + status="failed", + request_payload=final_payload, + error_message=f"No handler for integration type '{integration.type}'", + ) + logger.error( + f"No handler for integration type '{integration.type}' (integration id={integration.id})" + ) + continue + + handler = handler_cls(integration, sub, final_payload) + logger.debug( + f"Executing handler type={integration.type} integration_id={integration.id} subscription_id={sub.id}" + ) + + try: + result = handler.execute() + DeliveryLog.objects.create( + subscription=sub, + status="success" if result.get("success") else "failed", + request_payload=final_payload, + response_payload=result, + ) + logger.info( + f"Connect delivery succeeded for subscription id={sub.id} integration '{integration.name}'" + ) + except Exception as e: + DeliveryLog.objects.create( + subscription=sub, + status="failed", + request_payload=final_payload, + error_message=str(e), + ) + logger.error( + f"Connect delivery failed for subscription id={sub.id} integration '{integration.name}': {e}" + ) + + pm = PluginManager.get() + pm.discover_plugins(sync_db=False, use_cache=True) + plugins = pm.list_plugins() + + logger.debug(f"Checking {len(plugins)} plugins for event '{event_name}'") + for plugin in plugins: + if not plugin["enabled"]: + logger.debug(f"Skipping disabled plugin id={plugin.key} name={plugin.name}") + continue + + logger.debug(json.dumps(plugin)) + for action in plugin["actions"]: + if "events" in action and event_name in action["events"]: + key = plugin["key"] + params = {"event": event_name, "payload": payload} + action_name = action.get("label") or action.get("id") + action_id = action.get("id") + logger.debug( + f"Triggering plugin action for event '{event_name}' on plugin id={key} action={action_name}" + ) + if action_id: + pm.run_action(key, action_id, params) diff --git a/apps/dashboard/__init__.py b/apps/dashboard/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/dashboard/admin.py b/apps/dashboard/admin.py new file mode 100644 index 0000000..c6fe108 --- /dev/null +++ b/apps/dashboard/admin.py @@ -0,0 +1,2 @@ +from django.contrib import admin + diff --git a/apps/dashboard/api_urls.py b/apps/dashboard/api_urls.py new file mode 100644 index 0000000..b367fc7 --- /dev/null +++ b/apps/dashboard/api_urls.py @@ -0,0 +1,8 @@ +from django.urls import path +from .views import dashboard_view, settings_view, live_dashboard_data + +app_name = 'dashboard' + +urlpatterns = [ + path('dashboard-data/', live_dashboard_data, name='dashboard_data'), +] diff --git a/apps/dashboard/apps.py b/apps/dashboard/apps.py new file mode 100644 index 0000000..e9209ba --- /dev/null +++ b/apps/dashboard/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class CoreConfig(AppConfig): + default_auto_field = 'django.db.models.BigAutoField' + name = 'apps.dashboard' diff --git a/apps/dashboard/migrations/0001_initial.py b/apps/dashboard/migrations/0001_initial.py new file mode 100644 index 0000000..ba3311b --- /dev/null +++ b/apps/dashboard/migrations/0001_initial.py @@ -0,0 +1,46 @@ +# Generated by Django 5.1.6 on 2025-03-05 22:07 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ] + + operations = [ + migrations.CreateModel( + name='Settings', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('server_name', models.CharField(default='Dispatcharr', max_length=255)), + ('time_zone', models.CharField(default='UTC', max_length=50)), + ('default_logo_url', models.URLField(blank=True, null=True)), + ('max_concurrent_streams', models.PositiveIntegerField(default=10)), + ('auto_backup_frequency', models.CharField(choices=[('daily', 'Daily'), ('weekly', 'Weekly'), ('monthly', 'Monthly')], default='weekly', max_length=50)), + ('enable_debug_logs', models.BooleanField(default=False)), + ('schedules_direct_username', models.CharField(blank=True, max_length=255, null=True)), + ('schedules_direct_password', models.CharField(blank=True, max_length=255, null=True)), + ('schedules_direct_update_frequency', models.CharField(choices=[('12h', 'Every 12 Hours'), ('daily', 'Daily')], default='daily', max_length=50)), + ('schedules_direct_api_key', models.CharField(blank=True, max_length=255, null=True)), + ('transcoding_bitrate', models.PositiveIntegerField(default=2000)), + ('transcoding_audio_codec', models.CharField(choices=[('aac', 'AAC'), ('mp3', 'MP3')], default='aac', max_length=50)), + ('transcoding_resolution', models.CharField(choices=[('720p', '720p'), ('1080p', '1080p')], default='1080p', max_length=50)), + ('failover_behavior', models.CharField(choices=[('sequential', 'Sequential'), ('random', 'Random')], default='sequential', max_length=50)), + ('stream_health_check_frequency', models.PositiveIntegerField(default=5)), + ('email_notifications', models.BooleanField(default=False)), + ('webhook_url', models.URLField(blank=True, null=True)), + ('cpu_alert_threshold', models.PositiveIntegerField(default=90)), + ('memory_alert_threshold', models.PositiveIntegerField(default=90)), + ('hdhr_integration', models.BooleanField(default=True)), + ('custom_api_endpoints', models.JSONField(blank=True, null=True)), + ('backup_path', models.CharField(default='backups/', max_length=255)), + ('backup_frequency', models.CharField(choices=[('daily', 'Daily'), ('weekly', 'Weekly'), ('monthly', 'Monthly')], default='weekly', max_length=50)), + ('ffmpeg_path', models.CharField(default='/usr/bin/ffmpeg', max_length=255)), + ('custom_transcoding_flags', models.TextField(blank=True, null=True)), + ('celery_worker_concurrency', models.PositiveIntegerField(default=4)), + ], + ), + ] diff --git a/apps/dashboard/migrations/__init__.py b/apps/dashboard/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/dashboard/models.py b/apps/dashboard/models.py new file mode 100644 index 0000000..3c1eaa1 --- /dev/null +++ b/apps/dashboard/models.py @@ -0,0 +1,66 @@ +from django.db import models + +class Settings(models.Model): + # General Settings + server_name = models.CharField(max_length=255, default="Dispatcharr") + time_zone = models.CharField(max_length=50, default="UTC") + default_logo_url = models.URLField(blank=True, null=True) + max_concurrent_streams = models.PositiveIntegerField(default=10) + auto_backup_frequency = models.CharField( + max_length=50, + choices=[("daily", "Daily"), ("weekly", "Weekly"), ("monthly", "Monthly")], + default="weekly" + ) + enable_debug_logs = models.BooleanField(default=False) + + # Schedules Direct Settings + schedules_direct_username = models.CharField(max_length=255, blank=True, null=True) + schedules_direct_password = models.CharField(max_length=255, blank=True, null=True) + schedules_direct_update_frequency = models.CharField( + max_length=50, + choices=[("12h", "Every 12 Hours"), ("daily", "Daily")], + default="daily" + ) + schedules_direct_api_key = models.CharField(max_length=255, blank=True, null=True) + + # Stream and Channel Settings + transcoding_bitrate = models.PositiveIntegerField(default=2000) # in kbps + transcoding_audio_codec = models.CharField( + max_length=50, + choices=[("aac", "AAC"), ("mp3", "MP3")], + default="aac" + ) + transcoding_resolution = models.CharField( + max_length=50, + choices=[("720p", "720p"), ("1080p", "1080p")], + default="1080p" + ) + failover_behavior = models.CharField( + max_length=50, + choices=[("sequential", "Sequential"), ("random", "Random")], + default="sequential" + ) + stream_health_check_frequency = models.PositiveIntegerField(default=5) # in minutes + + # Notifications + email_notifications = models.BooleanField(default=False) + webhook_url = models.URLField(blank=True, null=True) + cpu_alert_threshold = models.PositiveIntegerField(default=90) # Percentage + memory_alert_threshold = models.PositiveIntegerField(default=90) # Percentage + + # API Settings + hdhr_integration = models.BooleanField(default=True) + custom_api_endpoints = models.JSONField(blank=True, null=True) + + # Backup and Restore + backup_path = models.CharField(max_length=255, default="backups/") + backup_frequency = models.CharField( + max_length=50, + choices=[("daily", "Daily"), ("weekly", "Weekly"), ("monthly", "Monthly")], + default="weekly" + ) + + # Advanced + ffmpeg_path = models.CharField(max_length=255, default="/usr/bin/ffmpeg") + custom_transcoding_flags = models.TextField(blank=True, null=True) + celery_worker_concurrency = models.PositiveIntegerField(default=4) diff --git a/apps/dashboard/tests.py b/apps/dashboard/tests.py new file mode 100644 index 0000000..7ce503c --- /dev/null +++ b/apps/dashboard/tests.py @@ -0,0 +1,3 @@ +from django.test import TestCase + +# Create your tests here. diff --git a/apps/dashboard/urls.py b/apps/dashboard/urls.py new file mode 100644 index 0000000..d31678e --- /dev/null +++ b/apps/dashboard/urls.py @@ -0,0 +1,10 @@ +from django.urls import path +from .views import dashboard_view, settings_view, live_dashboard_data + +app_name = 'dashboard' + +urlpatterns = [ + path('', dashboard_view, name='dashboard'), + path('settings/', settings_view, name='settings'), + path('api/dashboard-data/', live_dashboard_data, name='dashboard_data'), +] diff --git a/apps/dashboard/views.py b/apps/dashboard/views.py new file mode 100644 index 0000000..2d14311 --- /dev/null +++ b/apps/dashboard/views.py @@ -0,0 +1,79 @@ +from django.shortcuts import render +from django.contrib.auth.decorators import login_required +from psutil import cpu_percent, virtual_memory, net_io_counters +from apps.channels.models import Stream +from django.http import JsonResponse # ADD THIS LINE + + +@login_required +def dashboard_view(request): + # Fetch system metrics + try: + cpu_usage = cpu_percent(interval=1) + ram = virtual_memory() + ram_usage = f"{ram.used / (1024 ** 3):.1f} GB / {ram.total / (1024 ** 3):.1f} GB" + network = net_io_counters() + network_traffic = f"{network.bytes_sent / (1024 ** 2):.1f} MB" + except Exception as e: + cpu_usage = "N/A" + ram_usage = "N/A" + network_traffic = "N/A" + print(f"Error fetching system metrics: {e}") + + # Fetch active streams and related channels + active_streams = Stream.objects.filter(current_viewers__gt=0).prefetch_related('channels') + active_streams_list = [ + f"Stream {i + 1}: {stream.url or 'Unknown'} ({stream.current_viewers} viewers)" + for i, stream in enumerate(active_streams) + ] + + # Pass data to the template + context = { + "cpu_usage": f"{cpu_usage}%", + "ram_usage": ram_usage, + "current_streams": active_streams.count(), + "network_traffic": network_traffic, + "active_streams": active_streams_list, + } + return render(request, "dashboard/dashboard.html", context) + +@login_required +def settings_view(request): + # Placeholder for settings functionality + return render(request, 'settings.html') + +@login_required +def live_dashboard_data(request): + try: + cpu_usage = cpu_percent(interval=1) + ram = virtual_memory() + network = net_io_counters() + ram_usage = f"{ram.used / (1024 ** 3):.1f} GB / {ram.total / (1024 ** 3):.1f} GB" + network_traffic = f"{network.bytes_sent / (1024 ** 2):.1f} MB" + + # Mocked example data for the charts + cpu_data = [45, 50, 60, 55, 70, 65] + ram_data = [6.5, 7.0, 7.5, 8.0, 8.5, 9.0] + network_data = [120, 125, 130, 128, 126, 124] + + active_streams = Stream.objects.filter(current_viewers__gt=0) + active_streams_list = [ + f"Stream {i + 1}: {stream.url or 'Unknown'} ({stream.current_viewers} viewers)" + for i, stream in enumerate(active_streams) + ] + + data = { + "cpu_usage": f"{cpu_usage}%", + "ram_usage": ram_usage, + "current_streams": active_streams.count(), + "network_traffic": network_traffic, + "active_streams": active_streams_list, + "cpu_data": cpu_data, + "ram_data": ram_data, + "network_data": network_data, + } + except Exception as e: + data = { + "error": str(e) + } + return JsonResponse(data) diff --git a/apps/epg/__init__.py b/apps/epg/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/epg/admin.py b/apps/epg/admin.py new file mode 100644 index 0000000..24cc282 --- /dev/null +++ b/apps/epg/admin.py @@ -0,0 +1,19 @@ +from django.contrib import admin +from .models import EPGSource, ProgramData + +@admin.register(EPGSource) +class EPGSourceAdmin(admin.ModelAdmin): + list_display = ['name', 'source_type', 'is_active'] + list_filter = ['source_type', 'is_active'] + search_fields = ['name'] + +@admin.register(ProgramData) +class ProgramAdmin(admin.ModelAdmin): + list_display = ['title', 'get_epg_tvg_id', 'start_time', 'end_time'] + list_filter = ['epg__tvg_id', 'tvg_id'] + search_fields = ['title', 'epg__name'] + + def get_epg_tvg_id(self, obj): + return obj.epg.tvg_id if obj.epg else '' + get_epg_tvg_id.short_description = 'Channel TVG ID' + get_epg_tvg_id.admin_order_field = 'epg__tvg_id' diff --git a/apps/epg/api_urls.py b/apps/epg/api_urls.py new file mode 100644 index 0000000..ed4b310 --- /dev/null +++ b/apps/epg/api_urls.py @@ -0,0 +1,18 @@ +from django.urls import path, include +from rest_framework.routers import DefaultRouter +from .api_views import EPGSourceViewSet, ProgramViewSet, EPGGridAPIView, EPGImportAPIView, EPGDataViewSet, CurrentProgramsAPIView + +app_name = 'epg' + +router = DefaultRouter() +router.register(r'sources', EPGSourceViewSet, basename='epg-source') +router.register(r'programs', ProgramViewSet, basename='program') +router.register(r'epgdata', EPGDataViewSet, basename='epgdata') + +urlpatterns = [ + path('grid/', EPGGridAPIView.as_view(), name='epg_grid'), + path('import/', EPGImportAPIView.as_view(), name='epg_import'), + path('current-programs/', CurrentProgramsAPIView.as_view(), name='current_programs'), +] + +urlpatterns += router.urls diff --git a/apps/epg/api_views.py b/apps/epg/api_views.py new file mode 100644 index 0000000..33483e6 --- /dev/null +++ b/apps/epg/api_views.py @@ -0,0 +1,553 @@ +import logging, os +from rest_framework import viewsets, status, serializers +from rest_framework.response import Response +from rest_framework.views import APIView +from rest_framework.decorators import action +from drf_spectacular.utils import extend_schema, OpenApiParameter, inline_serializer +from drf_spectacular.types import OpenApiTypes +from django.utils import timezone +from datetime import timedelta +from .models import EPGSource, ProgramData, EPGData +from .serializers import ( + ProgramDataSerializer, + ProgramDetailSerializer, + EPGSourceSerializer, + EPGDataSerializer, +) +from .tasks import refresh_epg_data +from apps.accounts.permissions import ( + Authenticated, + permission_classes_by_action, + permission_classes_by_method, +) + +logger = logging.getLogger(__name__) + + +# ───────────────────────────── +# 1) EPG Source API (CRUD) +# ───────────────────────────── +class EPGSourceViewSet(viewsets.ModelViewSet): + """ + API endpoint that allows EPG sources to be viewed or edited. + """ + + queryset = EPGSource.objects.select_related( + "refresh_task__crontab", "refresh_task__interval" + ).all() + serializer_class = EPGSourceSerializer + + def get_permissions(self): + try: + return [perm() for perm in permission_classes_by_action[self.action]] + except KeyError: + return [Authenticated()] + + def get_queryset(self): + from django.db.models import Exists, OuterRef + from apps.channels.models import Channel + return EPGSource.objects.select_related( + "refresh_task__crontab", "refresh_task__interval" + ).annotate( + has_channels=Exists( + Channel.objects.filter(epg_data__epg_source_id=OuterRef('pk')) + ) + ) + + def list(self, request, *args, **kwargs): + logger.debug("Listing all EPG sources.") + return super().list(request, *args, **kwargs) + + @action(detail=False, methods=["post"]) + def upload(self, request): + if "file" not in request.FILES: + return Response( + {"error": "No file uploaded"}, status=status.HTTP_400_BAD_REQUEST + ) + + file = request.FILES["file"] + file_name = file.name + file_path = os.path.join("/data/uploads/epgs", file_name) + + os.makedirs(os.path.dirname(file_path), exist_ok=True) + with open(file_path, "wb+") as destination: + for chunk in file.chunks(): + destination.write(chunk) + + new_obj_data = request.data.copy() + new_obj_data["file_path"] = file_path + + serializer = self.get_serializer(data=new_obj_data) + serializer.is_valid(raise_exception=True) + self.perform_create(serializer) + + return Response(serializer.data, status=status.HTTP_201_CREATED) + + def partial_update(self, request, *args, **kwargs): + """Handle partial updates with special logic for is_active field""" + instance = self.get_object() + + # Check if we're toggling is_active + if ( + "is_active" in request.data + and instance.is_active != request.data["is_active"] + ): + # Set appropriate status based on new is_active value + if request.data["is_active"]: + request.data["status"] = "idle" + else: + request.data["status"] = "disabled" + + # Continue with regular partial update + return super().partial_update(request, *args, **kwargs) + + +# ───────────────────────────── +# 2) Program API (CRUD) +# ───────────────────────────── +class ProgramViewSet(viewsets.ModelViewSet): + """Handles CRUD operations for EPG programs""" + + queryset = ProgramData.objects.select_related("epg").all() + serializer_class = ProgramDataSerializer + + def get_permissions(self): + try: + return [perm() for perm in permission_classes_by_action[self.action]] + except KeyError: + return [Authenticated()] + + def get_serializer_class(self): + if self.action == 'retrieve': + return ProgramDetailSerializer + return ProgramDataSerializer + + def retrieve(self, request, *args, **kwargs): + instance = self.get_object() + serializer = self.get_serializer(instance) + return Response(serializer.data) + + def list(self, request, *args, **kwargs): + logger.debug("Listing all EPG programs.") + return super().list(request, *args, **kwargs) + + +# ───────────────────────────── +# 3) EPG Grid View +# ───────────────────────────── +class EPGGridAPIView(APIView): + """Returns all programs airing in the next 24 hours including currently running ones and recent ones""" + + def get_permissions(self): + try: + return [ + perm() for perm in permission_classes_by_method[self.request.method] + ] + except KeyError: + return [Authenticated()] + + @extend_schema( + description="Retrieve programs from the previous hour, currently running and upcoming for the next 24 hours", + responses={200: ProgramDataSerializer(many=True)}, + ) + def get(self, request, format=None): + # Use current time instead of midnight + now = timezone.now() + one_hour_ago = now - timedelta(hours=1) + twenty_four_hours_later = now + timedelta(hours=24) + logger.debug( + f"EPGGridAPIView: Querying programs between {one_hour_ago} and {twenty_four_hours_later}." + ) + + programs = ProgramData.objects.filter( + end_time__gt=one_hour_ago, + start_time__lt=twenty_four_hours_later, + ) + + # 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 at all (standard dummy) + channels_without_epg = Channel.objects.filter(Q(epg_data__isnull=True)) + + # 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' + ).select_related('epg_data__epg_source').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.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 {without_count} channels needing standard dummy, {custom_count} needing custom dummy EPG." + ) + + # Serialize the regular programs using .values() to bypass DRF overhead + programs_qs = programs.values( + 'id', 'start_time', 'end_time', 'title', 'sub_title', + 'description', 'tvg_id', 'custom_properties', + ) + serialized_programs = [] + for p in programs_qs: + cp = p['custom_properties'] or {} + premiere_text = cp.get('premiere_text', '') + serialized_programs.append({ + 'id': p['id'], + 'start_time': p['start_time'], + 'end_time': p['end_time'], + 'title': p['title'], + 'sub_title': p['sub_title'], + 'description': p['description'], + 'tvg_id': p['tvg_id'], + 'season': cp.get('season'), + 'episode': cp.get('episode'), + 'is_new': bool(cp.get('new')), + 'is_live': bool(cp.get('live')), + 'is_premiere': bool(cp.get('premiere')), + 'is_finale': bool(premiere_text and 'finale' in premiere_text.lower()), + }) + logger.debug( + f"EPGGridAPIView: Found {len(serialized_programs)} program(s), including recently ended, currently running, and upcoming shows." + ) + + # Humorous program descriptions based on time of day - same as in output/views.py + time_descriptions = { + (0, 4): [ + "Late Night with {channel} - Where insomniacs unite!", + "The 'Why Am I Still Awake?' Show on {channel}", + "Counting Sheep - A {channel} production for the sleepless", + ], + (4, 8): [ + "Dawn Patrol - Rise and shine with {channel}!", + "Early Bird Special - Coffee not included", + "Morning Zombies - Before coffee viewing on {channel}", + ], + (8, 12): [ + "Mid-Morning Meetings - Pretend you're paying attention while watching {channel}", + "The 'I Should Be Working' Hour on {channel}", + "Productivity Killer - {channel}'s daytime programming", + ], + (12, 16): [ + "Lunchtime Laziness with {channel}", + "The Afternoon Slump - Brought to you by {channel}", + "Post-Lunch Food Coma Theater on {channel}", + ], + (16, 20): [ + "Rush Hour - {channel}'s alternative to traffic", + "The 'What's For Dinner?' Debate on {channel}", + "Evening Escapism - {channel}'s remedy for reality", + ], + (20, 24): [ + "Prime Time Placeholder - {channel}'s finest not-programming", + "The 'Netflix Was Too Complicated' Show on {channel}", + "Family Argument Avoider - Courtesy of {channel}", + ], + } + + # Generate and append dummy programs + dummy_programs = [] + + # 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: + # 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: + prog_custom = program.get('custom_properties') or {} + 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": program.get('sub_title'), + "custom_properties": prog_custom if prog_custom else None, + "season": None, + "episode": None, + "is_new": prog_custom.get('new', False), + "is_live": bool(prog_custom.get('live')), + "is_premiere": False, + "is_finale": False, + } + 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) + # Set minutes/seconds to zero for clean time blocks + start_time = start_time.replace(minute=0, second=0, microsecond=0) + end_time = start_time + timedelta(hours=4) + + # Get the hour for selecting a description + hour = start_time.hour + day = 0 # Use 0 as we're only doing 1 day + + # Find the appropriate time slot for description + for time_range, descriptions in time_descriptions.items(): + start_range, end_range = time_range + if start_range <= hour < end_range: + # Pick a description using the sum of the hour and day as seed + # This makes it somewhat random but consistent for the same timeslot + description = descriptions[ + (hour + day) % len(descriptions) + ].format(channel=channel.name) + break + else: + # Fallback description if somehow no range matches + description = f"Placeholder program for {channel.name} - EPG data went on vacation" + + # Create a dummy program in the same format as regular programs + dummy_program = { + "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(), + "title": f"{channel.name}", + "description": description, + "tvg_id": dummy_tvg_id, + "sub_title": None, + "custom_properties": None, + "season": None, + "episode": None, + "is_new": False, + "is_live": False, + "is_premiere": False, + "is_finale": False, + } + dummy_programs.append(dummy_program) + + except Exception as e: + logger.error( + f"Error creating standard dummy programs for channel {channel.name} (ID: {channel.id}): {str(e)}" + ) + + # Combine regular and dummy programs + all_programs = list(serialized_programs) + dummy_programs + logger.debug( + f"EPGGridAPIView: Returning {len(all_programs)} total programs (including {len(dummy_programs)} dummy programs)." + ) + + return Response({"data": all_programs}, status=status.HTTP_200_OK) + + +# ───────────────────────────── +# 4) EPG Import View +# ───────────────────────────── +class EPGImportAPIView(APIView): + """Triggers an EPG data refresh""" + + def get_permissions(self): + try: + return [ + perm() for perm in permission_classes_by_method[self.request.method] + ] + except KeyError: + return [Authenticated()] + + @extend_schema( + description="Triggers an EPG data refresh for the given source.", + request=inline_serializer( + name="EPGImportRequest", + fields={ + "id": serializers.IntegerField(help_text="ID of the EPG source to refresh."), + }, + ), + ) + def post(self, request, format=None): + logger.info("EPGImportAPIView: Received request to import EPG data.") + 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 refresh initiated."}, + status=status.HTTP_202_ACCEPTED, + ) + + +# ───────────────────────────── +# 5) EPG Data View +# ───────────────────────────── +class EPGDataViewSet(viewsets.ReadOnlyModelViewSet): + """ + API endpoint that allows EPGData objects to be viewed. + """ + + queryset = EPGData.objects.all() + serializer_class = EPGDataSerializer + + def get_permissions(self): + try: + return [perm() for perm in permission_classes_by_action[self.action]] + except KeyError: + return [Authenticated()] + + +# ───────────────────────────── +# 6) Current Programs API +# ───────────────────────────── +class CurrentProgramsAPIView(APIView): + """ + Lightweight endpoint that returns currently playing programs for specified channel IDs. + Accepts POST with JSON body containing channel_ids array, or null/empty to fetch all channels. + """ + + def get_permissions(self): + try: + return [ + perm() for perm in permission_classes_by_method[self.request.method] + ] + except KeyError: + return [Authenticated()] + + @extend_schema( + description="Get currently playing programs for specified channels or all channels", + request=inline_serializer( + name="CurrentProgramsRequest", + fields={ + "channel_uuids": serializers.ListField( + child=serializers.CharField(), + required=False, + allow_null=True, + help_text="Array of channel UUIDs. If null or omitted, returns all channels with current programs.", + ), + }, + ), + responses={200: ProgramDataSerializer(many=True)}, + ) + def post(self, request, format=None): + # Import Channel model + from apps.channels.models import Channel + + # Build query for channels with EPG data + query = Channel.objects.filter(epg_data__isnull=False) + + channel_uuids = request.data.get('channel_uuids', None) + + if channel_uuids is not None: + if not isinstance(channel_uuids, list): + return Response( + {"error": "channel_uuids must be an array of strings or null"}, + status=status.HTTP_400_BAD_REQUEST + ) + query = query.filter(uuid__in=channel_uuids) + + # Get channels with EPG data + channels = query.select_related('epg_data') + + # Get current time + now = timezone.now() + + # Build list of current programs + current_programs = [] + + for channel in channels: + # Query for current program + program = ProgramData.objects.select_related("epg").filter( + epg=channel.epg_data, + start_time__lte=now, + end_time__gt=now + ).first() + + if program: + program_data = ProgramDataSerializer(program).data + program_data['channel_uuid'] = str(channel.uuid) + current_programs.append(program_data) + + return Response(current_programs, status=status.HTTP_200_OK) + diff --git a/apps/epg/apps.py b/apps/epg/apps.py new file mode 100644 index 0000000..a6e7a8e --- /dev/null +++ b/apps/epg/apps.py @@ -0,0 +1,10 @@ +from django.apps import AppConfig + +class EpgConfig(AppConfig): + default_auto_field = 'django.db.models.BigAutoField' + name = 'apps.epg' + verbose_name = "EPG Management" + + def ready(self): + # Import signals to ensure they get registered + import apps.epg.signals diff --git a/apps/epg/migrations/0001_initial.py b/apps/epg/migrations/0001_initial.py new file mode 100644 index 0000000..fd49cb5 --- /dev/null +++ b/apps/epg/migrations/0001_initial.py @@ -0,0 +1,47 @@ +# Generated by Django 5.1.6 on 2025-03-05 22:07 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ] + + operations = [ + migrations.CreateModel( + name='EPGData', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('tvg_id', models.CharField(blank=True, max_length=255, null=True)), + ('name', models.CharField(max_length=255)), + ], + ), + migrations.CreateModel( + name='EPGSource', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('name', models.CharField(max_length=255, unique=True)), + ('source_type', models.CharField(choices=[('xmltv', 'XMLTV URL'), ('schedules_direct', 'Schedules Direct API')], max_length=20)), + ('url', models.URLField(blank=True, null=True)), + ('api_key', models.CharField(blank=True, max_length=255, null=True)), + ('is_active', models.BooleanField(default=True)), + ], + ), + migrations.CreateModel( + name='ProgramData', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('start_time', models.DateTimeField()), + ('end_time', models.DateTimeField()), + ('title', models.CharField(max_length=255)), + ('sub_title', models.CharField(blank=True, max_length=255, null=True)), + ('description', models.TextField(blank=True, null=True)), + ('tvg_id', models.CharField(blank=True, max_length=255, null=True)), + ('epg', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='programs', to='epg.epgdata')), + ], + ), + ] diff --git a/apps/epg/migrations/0002_epgsource_file_path.py b/apps/epg/migrations/0002_epgsource_file_path.py new file mode 100644 index 0000000..acc9c83 --- /dev/null +++ b/apps/epg/migrations/0002_epgsource_file_path.py @@ -0,0 +1,18 @@ +# Generated by Django 5.1.6 on 2025-03-19 16:43 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('epg', '0001_initial'), + ] + + operations = [ + migrations.AddField( + model_name='epgsource', + name='file_path', + field=models.CharField(blank=True, max_length=1024, null=True), + ), + ] diff --git a/apps/epg/migrations/0003_alter_epgdata_tvg_id.py b/apps/epg/migrations/0003_alter_epgdata_tvg_id.py new file mode 100644 index 0000000..f339b98 --- /dev/null +++ b/apps/epg/migrations/0003_alter_epgdata_tvg_id.py @@ -0,0 +1,18 @@ +# Generated by Django 5.1.6 on 2025-03-25 19:37 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('epg', '0002_epgsource_file_path'), + ] + + operations = [ + migrations.AlterField( + model_name='epgdata', + name='tvg_id', + field=models.CharField(blank=True, max_length=255, null=True, unique=True), + ), + ] diff --git a/apps/epg/migrations/0004_epgdata_epg_source_alter_epgdata_tvg_id.py b/apps/epg/migrations/0004_epgdata_epg_source_alter_epgdata_tvg_id.py new file mode 100644 index 0000000..ff8f7a1 --- /dev/null +++ b/apps/epg/migrations/0004_epgdata_epg_source_alter_epgdata_tvg_id.py @@ -0,0 +1,24 @@ +# Generated by Django 5.1.6 on 2025-03-26 12:44 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('epg', '0003_alter_epgdata_tvg_id'), + ] + + operations = [ + migrations.AddField( + model_name='epgdata', + name='epg_source', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='epgs', to='epg.epgsource'), + ), + migrations.AlterField( + model_name='epgdata', + name='tvg_id', + field=models.CharField(blank=True, db_index=True, max_length=255, null=True), + ), + ] diff --git a/apps/epg/migrations/0005_programdata_custom_properties_and_more.py b/apps/epg/migrations/0005_programdata_custom_properties_and_more.py new file mode 100644 index 0000000..35d8d1c --- /dev/null +++ b/apps/epg/migrations/0005_programdata_custom_properties_and_more.py @@ -0,0 +1,22 @@ +# Generated by Django 5.1.6 on 2025-03-27 17:01 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('epg', '0004_epgdata_epg_source_alter_epgdata_tvg_id'), + ] + + operations = [ + migrations.AddField( + model_name='programdata', + name='custom_properties', + field=models.TextField(blank=True, null=True), + ), + migrations.AlterUniqueTogether( + name='epgdata', + unique_together={('tvg_id', 'epg_source')}, + ), + ] diff --git a/apps/epg/migrations/0006_epgsource_refresh_interval_epgsource_refresh_task.py b/apps/epg/migrations/0006_epgsource_refresh_interval_epgsource_refresh_task.py new file mode 100644 index 0000000..10cf17b --- /dev/null +++ b/apps/epg/migrations/0006_epgsource_refresh_interval_epgsource_refresh_task.py @@ -0,0 +1,25 @@ +# Generated by Django 5.1.6 on 2025-03-29 17:31 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('django_celery_beat', '0019_alter_periodictasks_options'), + ('epg', '0005_programdata_custom_properties_and_more'), + ] + + operations = [ + migrations.AddField( + model_name='epgsource', + name='refresh_interval', + field=models.IntegerField(default=24), + ), + migrations.AddField( + model_name='epgsource', + name='refresh_task', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='django_celery_beat.periodictask'), + ), + ] diff --git a/apps/epg/migrations/0007_epgsource_status_epgsource_last_error.py b/apps/epg/migrations/0007_epgsource_status_epgsource_last_error.py new file mode 100644 index 0000000..050b1b4 --- /dev/null +++ b/apps/epg/migrations/0007_epgsource_status_epgsource_last_error.py @@ -0,0 +1,23 @@ +# Generated by Django + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('epg', '0006_epgsource_refresh_interval_epgsource_refresh_task'), + ] + + operations = [ + migrations.AddField( + model_name='epgsource', + name='status', + field=models.CharField(choices=[('idle', 'Idle'), ('fetching', 'Fetching'), ('parsing', 'Parsing'), ('error', 'Error'), ('success', 'Success')], default='idle', max_length=20), + ), + migrations.AddField( + model_name='epgsource', + name='last_error', + field=models.TextField(blank=True, null=True), + ), + ] diff --git a/apps/epg/migrations/0007_populate_periodic_tasks.py b/apps/epg/migrations/0007_populate_periodic_tasks.py new file mode 100644 index 0000000..edca8db --- /dev/null +++ b/apps/epg/migrations/0007_populate_periodic_tasks.py @@ -0,0 +1,52 @@ +from django.db import migrations +import json + +def create_default_refresh_tasks(apps, schema_editor): + """ + Creates a PeriodicTask for each existing EPGSource that doesn't have one. + """ + IntervalSchedule = apps.get_model("django_celery_beat", "IntervalSchedule") + PeriodicTask = apps.get_model("django_celery_beat", "PeriodicTask") + EPGSource = apps.get_model("epg", "EPGSource") + + default_interval, _ = IntervalSchedule.objects.get_or_create( + every=24, + period="hours", + ) + + for account in EPGSource.objects.all(): + if account.refresh_task: + continue + + task_name = f"epg_source-refresh-{account.id}" + + refresh_task = PeriodicTask.objects.create( + name=task_name, + interval=default_interval, + task="apps.epg.tasks.refresh_epg_data", + kwargs=json.dumps({"account_id": account.id}), + ) + + account.refresh_task = refresh_task + account.save(update_fields=["refresh_task"]) + +def reverse_migration(apps, schema_editor): + IntervalSchedule = apps.get_model("django_celery_beat", "IntervalSchedule") + PeriodicTask = apps.get_model("django_celery_beat", "PeriodicTask") + EPGSource = apps.get_model("epg", "EPGSource") + + for account in EPGSource.objects.all(): + IntervalSchedule.objects.all().delete() + PeriodicTask.objects.all().delete() + + +class Migration(migrations.Migration): + + dependencies = [ + ("epg", "0006_epgsource_refresh_interval_epgsource_refresh_task"), + ("django_celery_beat", "0019_alter_periodictasks_options"), + ] + + operations = [ + migrations.RunPython(create_default_refresh_tasks, reverse_migration), + ] diff --git a/apps/epg/migrations/0008_epgsource_created_at_epgsource_updated_at.py b/apps/epg/migrations/0008_epgsource_created_at_epgsource_updated_at.py new file mode 100644 index 0000000..1dcfeed --- /dev/null +++ b/apps/epg/migrations/0008_epgsource_created_at_epgsource_updated_at.py @@ -0,0 +1,24 @@ +# Generated by Django 5.1.6 on 2025-04-07 16:29 + +import django.utils.timezone +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('epg', '0007_populate_periodic_tasks'), + ] + + operations = [ + migrations.AddField( + model_name='epgsource', + name='created_at', + field=models.DateTimeField(default=django.utils.timezone.now, help_text='Time when this source was created'), + ), + migrations.AddField( + model_name='epgsource', + name='updated_at', + field=models.DateTimeField(default=django.utils.timezone.now, help_text='Time when this source was last updated'), + ), + ] diff --git a/apps/epg/migrations/0009_alter_epgsource_created_at_and_more.py b/apps/epg/migrations/0009_alter_epgsource_created_at_and_more.py new file mode 100644 index 0000000..cb8088e --- /dev/null +++ b/apps/epg/migrations/0009_alter_epgsource_created_at_and_more.py @@ -0,0 +1,23 @@ +# Generated by Django 5.1.6 on 2025-04-07 16:29 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('epg', '0008_epgsource_created_at_epgsource_updated_at'), + ] + + operations = [ + migrations.AlterField( + model_name='epgsource', + name='created_at', + field=models.DateTimeField(auto_now_add=True, help_text='Time when this source was created'), + ), + migrations.AlterField( + model_name='epgsource', + name='updated_at', + field=models.DateTimeField(auto_now=True, help_text='Time when this source was last updated'), + ), + ] diff --git a/apps/epg/migrations/0010_merge_20250503_2147.py b/apps/epg/migrations/0010_merge_20250503_2147.py new file mode 100644 index 0000000..a65117b --- /dev/null +++ b/apps/epg/migrations/0010_merge_20250503_2147.py @@ -0,0 +1,14 @@ +# Generated by Django 5.1.6 on 2025-05-03 21:47 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('epg', '0007_epgsource_status_epgsource_last_error'), + ('epg', '0009_alter_epgsource_created_at_and_more'), + ] + + operations = [ + ] diff --git a/apps/epg/migrations/0011_update_epgsource_fields.py b/apps/epg/migrations/0011_update_epgsource_fields.py new file mode 100644 index 0000000..44c38a7 --- /dev/null +++ b/apps/epg/migrations/0011_update_epgsource_fields.py @@ -0,0 +1,42 @@ +# Generated by Django 5.1.6 on 2025-05-04 21:43 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('epg', '0010_merge_20250503_2147'), + ] + + operations = [ + # Change updated_at field + migrations.AlterField( + model_name='epgsource', + name='updated_at', + field=models.DateTimeField(blank=True, help_text='Time when this source was last successfully refreshed', null=True), + ), + + # Add new last_message field + migrations.AddField( + model_name='epgsource', + name='last_message', + field=models.TextField(blank=True, help_text='Last status message, including success results or error information', null=True), + ), + + # Copy data from last_error to last_message + migrations.RunPython( + code=lambda apps, schema_editor: apps.get_model('epg', 'EPGSource').objects.all().update( + last_message=models.F('last_error') + ), + reverse_code=lambda apps, schema_editor: apps.get_model('epg', 'EPGSource').objects.all().update( + last_error=models.F('last_message') + ), + ), + + # Remove the old field + migrations.RemoveField( + model_name='epgsource', + name='last_error', + ), + ] diff --git a/apps/epg/migrations/0012_alter_epgsource_status.py b/apps/epg/migrations/0012_alter_epgsource_status.py new file mode 100644 index 0000000..39cce29 --- /dev/null +++ b/apps/epg/migrations/0012_alter_epgsource_status.py @@ -0,0 +1,18 @@ +# Generated by Django 5.1.6 on 2025-05-15 01:05 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('epg', '0011_update_epgsource_fields'), + ] + + operations = [ + migrations.AlterField( + model_name='epgsource', + name='status', + field=models.CharField(choices=[('idle', 'Idle'), ('fetching', 'Fetching'), ('parsing', 'Parsing'), ('error', 'Error'), ('success', 'Success'), ('disabled', 'Disabled')], default='idle', max_length=20), + ), + ] diff --git a/apps/epg/migrations/0013_alter_epgsource_refresh_interval.py b/apps/epg/migrations/0013_alter_epgsource_refresh_interval.py new file mode 100644 index 0000000..64be2c3 --- /dev/null +++ b/apps/epg/migrations/0013_alter_epgsource_refresh_interval.py @@ -0,0 +1,18 @@ +# Generated by Django 5.1.6 on 2025-05-21 19:58 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('epg', '0012_alter_epgsource_status'), + ] + + operations = [ + migrations.AlterField( + model_name='epgsource', + name='refresh_interval', + field=models.IntegerField(default=0), + ), + ] diff --git a/apps/epg/migrations/0014_epgsource_extracted_file_path.py b/apps/epg/migrations/0014_epgsource_extracted_file_path.py new file mode 100644 index 0000000..9ee1170 --- /dev/null +++ b/apps/epg/migrations/0014_epgsource_extracted_file_path.py @@ -0,0 +1,18 @@ +# Generated by Django 5.1.6 on 2025-05-26 15:48 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('epg', '0013_alter_epgsource_refresh_interval'), + ] + + operations = [ + migrations.AddField( + model_name='epgsource', + name='extracted_file_path', + field=models.CharField(blank=True, help_text='Path to extracted XML file after decompression', max_length=1024, null=True), + ), + ] diff --git a/apps/epg/migrations/0015_alter_programdata_custom_properties.py b/apps/epg/migrations/0015_alter_programdata_custom_properties.py new file mode 100644 index 0000000..f33aa97 --- /dev/null +++ b/apps/epg/migrations/0015_alter_programdata_custom_properties.py @@ -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), + ), + ] diff --git a/apps/epg/migrations/0016_epgdata_icon_url.py b/apps/epg/migrations/0016_epgdata_icon_url.py new file mode 100644 index 0000000..b934b02 --- /dev/null +++ b/apps/epg/migrations/0016_epgdata_icon_url.py @@ -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), + ), + ] diff --git a/apps/epg/migrations/0017_alter_epgsource_url.py b/apps/epg/migrations/0017_alter_epgsource_url.py new file mode 100644 index 0000000..dcb55e2 --- /dev/null +++ b/apps/epg/migrations/0017_alter_epgsource_url.py @@ -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), + ), + ] diff --git a/apps/epg/migrations/0018_epgsource_custom_properties_and_more.py b/apps/epg/migrations/0018_epgsource_custom_properties_and_more.py new file mode 100644 index 0000000..70ebb21 --- /dev/null +++ b/apps/epg/migrations/0018_epgsource_custom_properties_and_more.py @@ -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), + ), + ] diff --git a/apps/epg/migrations/0019_alter_programdata_sub_title.py b/apps/epg/migrations/0019_alter_programdata_sub_title.py new file mode 100644 index 0000000..5a53627 --- /dev/null +++ b/apps/epg/migrations/0019_alter_programdata_sub_title.py @@ -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), + ), + ] diff --git a/apps/epg/migrations/0020_migrate_time_to_starttime_placeholders.py b/apps/epg/migrations/0020_migrate_time_to_starttime_placeholders.py new file mode 100644 index 0000000..8f53bb0 --- /dev/null +++ b/apps/epg/migrations/0020_migrate_time_to_starttime_placeholders.py @@ -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), + ] diff --git a/apps/epg/migrations/0021_epgsource_priority.py b/apps/epg/migrations/0021_epgsource_priority.py new file mode 100644 index 0000000..f2696d6 --- /dev/null +++ b/apps/epg/migrations/0021_epgsource_priority.py @@ -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.'), + ), + ] diff --git a/apps/epg/migrations/__init__.py b/apps/epg/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/epg/models.py b/apps/epg/models.py new file mode 100644 index 0000000..b3696ed --- /dev/null +++ b/apps/epg/models.py @@ -0,0 +1,168 @@ +from django.db import models +from django.utils import timezone +from django_celery_beat.models import PeriodicTask +from django.conf import settings +import os + +class EPGSource(models.Model): + SOURCE_TYPE_CHOICES = [ + ('xmltv', 'XMLTV URL'), + ('schedules_direct', 'Schedules Direct API'), + ('dummy', 'Custom Dummy EPG'), + ] + + STATUS_IDLE = 'idle' + STATUS_FETCHING = 'fetching' + STATUS_PARSING = 'parsing' + STATUS_ERROR = 'error' + STATUS_SUCCESS = 'success' + STATUS_DISABLED = 'disabled' + + STATUS_CHOICES = [ + (STATUS_IDLE, 'Idle'), + (STATUS_FETCHING, 'Fetching'), + (STATUS_PARSING, 'Parsing'), + (STATUS_ERROR, 'Error'), + (STATUS_SUCCESS, 'Success'), + (STATUS_DISABLED, 'Disabled'), + ] + + name = models.CharField(max_length=255, unique=True) + source_type = models.CharField(max_length=20, choices=SOURCE_TYPE_CHOICES) + url = models.URLField(max_length=1000, blank=True, null=True) # For XMLTV + api_key = models.CharField(max_length=255, blank=True, null=True) # For Schedules Direct + is_active = models.BooleanField(default=True) + file_path = models.CharField(max_length=1024, blank=True, null=True) + extracted_file_path = models.CharField(max_length=1024, blank=True, null=True, + help_text="Path to extracted XML file after decompression") + refresh_interval = models.IntegerField(default=0) + 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, + default=STATUS_IDLE + ) + last_message = models.TextField( + null=True, + blank=True, + help_text="Last status message, including success results or error information" + ) + created_at = models.DateTimeField( + auto_now_add=True, + help_text="Time when this source was created" + ) + updated_at = models.DateTimeField( + null=True, blank=True, + help_text="Time when this source was last successfully refreshed" + ) + + def __str__(self): + return self.name + + def get_cache_file(self): + import mimetypes + + # Use a temporary extension for initial download + # The actual extension will be determined after content inspection + file_ext = ".tmp" + + # If file_path is already set and contains an extension, use that + # This handles cases where we've already detected the proper type + if self.file_path and os.path.exists(self.file_path): + _, existing_ext = os.path.splitext(self.file_path) + if existing_ext: + file_ext = existing_ext + else: + # Try to detect the MIME type and map to extension + mime_type, _ = mimetypes.guess_type(self.file_path) + if mime_type: + if mime_type == 'application/gzip' or mime_type == 'application/x-gzip': + file_ext = '.gz' + elif mime_type == 'application/zip': + file_ext = '.zip' + elif mime_type == 'application/xml' or mime_type == 'text/xml': + file_ext = '.xml' + # For files without mime type detection, try peeking at content + else: + try: + with open(self.file_path, 'rb') as f: + header = f.read(4) + # Check for gzip magic number (1f 8b) + if header[:2] == b'\x1f\x8b': + file_ext = '.gz' + # Check for zip magic number (PK..) + elif header[:2] == b'PK': + file_ext = '.zip' + # Check for XML + elif header[:5] == b'': + file_ext = '.xml' + except Exception as e: + # If we can't read the file, just keep the default extension + pass + + filename = f"{self.id}{file_ext}" + + # Build full path in MEDIA_ROOT/cached_epg + cache_dir = os.path.join(settings.MEDIA_ROOT, "cached_epg") + + # Create directory if it doesn't exist + os.makedirs(cache_dir, exist_ok=True) + + cache = os.path.join(cache_dir, filename) + + return cache + + def save(self, *args, **kwargs): + # Prevent auto_now behavior by handling updated_at manually + if 'update_fields' in kwargs and 'updated_at' not in kwargs['update_fields']: + # Don't modify updated_at for regular updates + kwargs.setdefault('update_fields', []) + if 'updated_at' in kwargs['update_fields']: + kwargs['update_fields'].remove('updated_at') + super().save(*args, **kwargs) + +class EPGData(models.Model): + # Removed the Channel foreign key. We now just store the original tvg_id + # and a name (which might simply be the tvg_id if no real channel exists). + tvg_id = models.CharField(max_length=255, null=True, blank=True, db_index=True) + name = models.CharField(max_length=255) + icon_url = models.URLField(max_length=500, null=True, blank=True) + epg_source = models.ForeignKey( + EPGSource, + on_delete=models.CASCADE, + null=True, + blank=True, + related_name="epgs", + ) + + class Meta: + unique_together = ('tvg_id', 'epg_source') + + def __str__(self): + return f"EPG Data for {self.name}" + +class ProgramData(models.Model): + # Each programme is associated with an EPGData record. + epg = models.ForeignKey(EPGData, on_delete=models.CASCADE, related_name="programs") + start_time = models.DateTimeField() + end_time = models.DateTimeField() + title = models.CharField(max_length=255) + 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.JSONField(default=dict, blank=True, null=True) + + def __str__(self): + return f"{self.title} ({self.start_time} - {self.end_time})" diff --git a/apps/epg/serializers.py b/apps/epg/serializers.py new file mode 100644 index 0000000..efbdcb6 --- /dev/null +++ b/apps/epg/serializers.py @@ -0,0 +1,172 @@ +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_count = serializers.SerializerMethodField() + has_channels = serializers.BooleanField(read_only=True, default=False) + read_only_fields = ['created_at', 'updated_at'] + url = serializers.CharField( + required=False, + allow_blank=True, + allow_null=True, + validators=[validate_flexible_url] + ) + cron_expression = serializers.CharField(required=False, allow_blank=True, default='') + + class Meta: + model = EPGSource + fields = [ + 'id', + 'name', + 'source_type', + 'url', + 'api_key', + 'is_active', + 'file_path', + 'refresh_interval', + 'cron_expression', + 'priority', + 'status', + 'last_message', + 'created_at', + 'updated_at', + 'custom_properties', + 'epg_data_count', + 'has_channels', + ] + + 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() + + def to_representation(self, instance): + data = super().to_representation(instance) + # Derive cron_expression from the linked PeriodicTask's crontab (single source of truth) + # But first check if we have a transient _cron_expression (from create/update before signal runs) + cron_expr = '' + if hasattr(instance, '_cron_expression'): + cron_expr = instance._cron_expression + elif instance.refresh_task_id and instance.refresh_task and instance.refresh_task.crontab: + ct = instance.refresh_task.crontab + cron_expr = f'{ct.minute} {ct.hour} {ct.day_of_month} {ct.month_of_year} {ct.day_of_week}' + data['cron_expression'] = cron_expr + return data + + def update(self, instance, validated_data): + # Pop cron_expression before it reaches model fields + # If not present (partial update), preserve the existing cron from the PeriodicTask + if 'cron_expression' in validated_data: + cron_expr = validated_data.pop('cron_expression') + else: + cron_expr = '' + if instance.refresh_task_id and instance.refresh_task and instance.refresh_task.crontab: + ct = instance.refresh_task.crontab + cron_expr = f'{ct.minute} {ct.hour} {ct.day_of_month} {ct.month_of_year} {ct.day_of_week}' + instance._cron_expression = cron_expr + for attr, value in validated_data.items(): + setattr(instance, attr, value) + instance.save() + return instance + + def create(self, validated_data): + cron_expr = validated_data.pop('cron_expression', '') + instance = EPGSource(**validated_data) + instance._cron_expression = cron_expr + instance.save() + return instance + +class ProgramDataSerializer(serializers.ModelSerializer): + + class Meta: + model = ProgramData + fields = ['id', 'start_time', 'end_time', 'title', 'sub_title', 'description', 'tvg_id'] + + def to_representation(self, obj): + data = super().to_representation(obj) + cp = obj.custom_properties or {} + data['season'] = cp.get('season') + data['episode'] = cp.get('episode') + data['is_new'] = bool(cp.get('new')) + data['is_live'] = bool(cp.get('live')) + data['is_premiere'] = bool(cp.get('premiere')) + premiere_text = cp.get('premiere_text', '') + data['is_finale'] = bool(premiere_text and 'finale' in premiere_text.lower()) + return data + +class ProgramDetailSerializer(ProgramDataSerializer): + """Rich serializer for program detail view — extends slim serializer with full custom_properties.""" + + def to_representation(self, obj): + data = super().to_representation(obj) + cp = obj.custom_properties or {} + + # Categories + data['categories'] = cp.get('categories') or [] + + # Content rating + data['rating'] = cp.get('rating') + data['rating_system'] = cp.get('rating_system') + + # Star ratings + data['star_ratings'] = cp.get('star_ratings') or [] + + # Credits — flatten from XMLTV structure + credits = cp.get('credits') or {} + data['credits'] = { + 'actors': credits.get('actor') or [], + 'directors': credits.get('director') or [], + 'writers': credits.get('writer') or [], + 'producers': credits.get('producer') or [], + 'presenters': credits.get('presenter') or [], + } + + # Video/audio quality + video = cp.get('video') or {} + data['video_quality'] = video.get('quality') + data['aspect_ratio'] = video.get('aspect') + + audio = cp.get('audio') or {} + data['stereo'] = audio.get('stereo') + + # Previously shown (rerun) + data['is_previously_shown'] = bool(cp.get('previously_shown')) + + # Geographic/language + data['country'] = cp.get('country') + data['language'] = cp.get('language') + + # Dates + data['production_date'] = cp.get('date') + previously_shown = cp.get('previously_shown_details') or {} + data['original_air_date'] = previously_shown.get('start') + + # External IDs + data['imdb_id'] = cp.get('imdb.com_id') + data['tmdb_id'] = cp.get('themoviedb.org_id') + data['tvdb_id'] = cp.get('thetvdb.com_id') + + # Images + data['icon'] = cp.get('icon') + data['images'] = cp.get('images') or [] + + return data + + +class EPGDataSerializer(serializers.ModelSerializer): + """ + Only returns the tvg_id and the 'name' field from EPGData. + We assume 'name' is effectively the channel name. + """ + read_only_fields = ['epg_source'] + + class Meta: + model = EPGData + fields = [ + 'id', + 'tvg_id', + 'name', + 'icon_url', + 'epg_source', + ] diff --git a/apps/epg/signals.py b/apps/epg/signals.py new file mode 100644 index 0000000..97992ef --- /dev/null +++ b/apps/epg/signals.py @@ -0,0 +1,206 @@ +from django.db.models.signals import post_save, post_delete, pre_save +from django.dispatch import receiver +from .models import EPGSource, EPGData +from .tasks import refresh_epg_data, delete_epg_refresh_task_by_id +from core.scheduling import create_or_update_periodic_task, delete_periodic_task +from core.utils import is_protected_path, send_websocket_update +import json +import logging +import os + +logger = logging.getLogger(__name__) + +@receiver(post_save, sender=EPGSource) +def trigger_refresh_on_new_epg_source(sender, instance, created, **kwargs): + # Trigger refresh only if the source is newly created, active, and not a dummy EPG + 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, created, update_fields=None, **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. + Supports both interval-based and cron-based scheduling via the shared utility. + """ + # 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 + + # Skip rescheduling when only non-schedule fields were saved (e.g. status/last_message + # updates from the refresh task itself). We only need to reschedule when schedule-relevant + # fields change or when _cron_expression was explicitly set by the serializer. + SCHEDULE_FIELDS = {'refresh_interval', 'is_active', 'refresh_task'} + if ( + not created + and update_fields is not None + and not (set(update_fields) & SCHEDULE_FIELDS) + and not hasattr(instance, '_cron_expression') + ): + return + + task_name = f"epg_source-refresh-{instance.id}" + should_be_enabled = instance.is_active + + # Read cron_expression from transient attribute set by the serializer. + # If not set (e.g. save came from a task updating status/last_message), + # preserve the existing crontab so we don't accidentally revert to interval. + if hasattr(instance, "_cron_expression"): + cron_expr = instance._cron_expression + else: + cron_expr = "" + try: + existing_task = instance.refresh_task + if existing_task and existing_task.crontab: + ct = existing_task.crontab + cron_expr = f"{ct.minute} {ct.hour} {ct.day_of_month} {ct.month_of_year} {ct.day_of_week}" + except Exception: + pass + + task = create_or_update_periodic_task( + task_name=task_name, + celery_task_path="apps.epg.tasks.refresh_epg_data", + kwargs={"source_id": instance.id}, + interval_hours=int(instance.refresh_interval), + cron_expression=cron_expr, + enabled=should_be_enabled, + ) + + if instance.refresh_task != task: + instance.refresh_task = task + instance.save(update_fields=["refresh_task"]) + +@receiver(post_delete, sender=EPGSource) +def delete_refresh_task(sender, instance, **kwargs): + """ + Delete the associated Celery Beat periodic task when an EPGSource is deleted. + """ + try: + # First try the foreign key relationship to find the task ID + task = None + if instance.refresh_task: + logger.info(f"Found task via foreign key: {instance.refresh_task.id} for EPGSource {instance.id}") + task = instance.refresh_task + + # Store task ID before deletion if we need to bypass the helper function + if task: + delete_epg_refresh_task_by_id(instance.id) + else: + # Otherwise use the helper function + delete_epg_refresh_task_by_id(instance.id) + except Exception as e: + logger.error(f"Error in delete_refresh_task signal handler: {str(e)}", exc_info=True) + +@receiver(pre_save, sender=EPGSource) +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 + old_instance = EPGSource.objects.get(pk=instance.pk) + + # If is_active changed, update the status + if old_instance.is_active != instance.is_active: + if instance.is_active: + # When activating, set status to idle + instance.status = 'idle' + else: + # When deactivating, set status to disabled + instance.status = 'disabled' + except EPGSource.DoesNotExist: + # New record, will use default status + pass + +@receiver(post_delete, sender=EPGSource) +def delete_cached_files(sender, instance, **kwargs): + """ + Delete cached files associated with an EPGSource when it's deleted. + Only deletes files that aren't in protected directories. + """ + # Check and delete the main file path if not protected + if instance.file_path and os.path.exists(instance.file_path): + if is_protected_path(instance.file_path): + logger.info(f"Skipping deletion of protected file: {instance.file_path}") + else: + try: + os.remove(instance.file_path) + logger.info(f"Deleted cached file: {instance.file_path}") + except OSError as e: + logger.error(f"Error deleting cached file {instance.file_path}: {e}") + + # Check and delete the extracted file path if it exists, is different from main path, and not protected + if instance.extracted_file_path and os.path.exists(instance.extracted_file_path) and instance.extracted_file_path != instance.file_path: + if is_protected_path(instance.extracted_file_path): + logger.info(f"Skipping deletion of protected extracted file: {instance.extracted_file_path}") + else: + try: + os.remove(instance.extracted_file_path) + logger.info(f"Deleted extracted file: {instance.extracted_file_path}") + except OSError as e: + logger.error(f"Error deleting extracted file {instance.extracted_file_path}: {e}") diff --git a/apps/epg/tasks.py b/apps/epg/tasks.py new file mode 100644 index 0000000..7700ad1 --- /dev/null +++ b/apps/epg/tasks.py @@ -0,0 +1,2336 @@ +# apps/epg/tasks.py + +import logging +import gzip +import html.entities +import os +import uuid +import requests +import time # Add import for tracking download progress +from datetime import datetime, timedelta, timezone as dt_timezone +import gc # Add garbage collection module +import json +from lxml import etree # Using lxml exclusively +import psutil # Add import for memory tracking +import zipfile + +from celery import shared_task +from django.conf import settings +from django.db import connection, transaction +from django.utils import timezone +from apps.channels.models import Channel +from core.models import UserAgent, CoreSettings + +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, TaskLockRenewer, send_websocket_update, cleanup_memory, log_system_event + +logger = logging.getLogger(__name__) + +# DOCTYPE internal subset for XMLTV files. Declares all 252 HTML 4 named +# entities so lxml/libxml2 can resolve references like é correctly +# instead of silently dropping them in recovery mode. +# The 5 XML-predefined entities (amp, lt, gt, quot, apos) are always +# recognised by the XML spec and must not be redeclared. +_XML_ENTITIES = frozenset({'amp', 'lt', 'gt', 'quot', 'apos'}) + + +def _build_html_entity_doctype() -> bytes: + """Build a DOCTYPE internal subset declaring all HTML 4 named entities.""" + lines = [b'\n'.encode('ascii')) + lines.append(b']>\n') + return b''.join(lines) + + +_HTML_ENTITY_DOCTYPE = _build_html_entity_doctype() + + +class _PrependStream: + """Wraps an open binary file and prepends a bytes prefix to its content. + + Used by _open_xmltv_file to inject a DOCTYPE entity block before the + file content reaches lxml's iterparse, with zero disk I/O. + """ + + __slots__ = ('_prefix', '_prefix_pos', '_file') + + def __init__(self, prefix: bytes, file_obj): + self._prefix = prefix + self._prefix_pos = 0 + self._file = file_obj + + def read(self, size=-1): + prefix_len = len(self._prefix) + if self._prefix_pos >= prefix_len: + return self._file.read(size) + remaining = prefix_len - self._prefix_pos + if size < 0: + chunk = self._prefix[self._prefix_pos:] + self._file.read() + self._prefix_pos = prefix_len + return chunk + if size <= remaining: + chunk = self._prefix[self._prefix_pos:self._prefix_pos + size] + self._prefix_pos += size + return chunk + chunk = self._prefix[self._prefix_pos:] + self._prefix_pos = prefix_len + return chunk + self._file.read(size - remaining) + + def close(self): + self._file.close() + + def __enter__(self): + return self + + def __exit__(self, *_): + self.close() + + +def _open_xmltv_file(file_path: str): + """Open an XMLTV file for lxml iterparse, injecting an HTML entity DOCTYPE. + + Prepends a block that declares all 252 HTML 4 named + entities so lxml/libxml2 resolves references like é correctly + instead of silently dropping them in recovery mode. This involves zero + disk I/O — the DOCTYPE is streamed in-memory before the file content. + + If the file already contains a declaration the file is returned + unchanged; a second DOCTYPE would be invalid XML. + + The caller is responsible for closing the returned object. + """ + f = open(file_path, 'rb') + start = f.read(512) + + # Do not inject if the file already declares a DOCTYPE. + if b'= 0: + decl_end = start.find(b'?>', xml_pos) + if decl_end >= 0: + xml_decl = start[:decl_end + 2] + f.seek(decl_end + 2) + return _PrependStream(xml_decl + b'\n' + _HTML_ENTITY_DOCTYPE, f) + + # No XML declaration — insert DOCTYPE at the very start of the file. + f.seek(0) + return _PrependStream(_HTML_ENTITY_DOCTYPE, f) + + +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 + data = { + "progress": progress, + "type": "epg_refresh", + "source": source_id, + "action": action, + } + + # Add the additional key-value pairs from kwargs + data.update(kwargs) + + # Use the standardized update function with garbage collection for program parsing + # This is a high-frequency operation that needs more aggressive memory management + collect_garbage = action == "parsing_programs" and progress % 10 == 0 + send_websocket_update('updates', 'update', data, collect_garbage=collect_garbage) + + # Explicitly clear references + data = None + + # For high-frequency parsing, occasionally force additional garbage collection + # to prevent memory buildup + if action == "parsing_programs" and progress % 50 == 0: + gc.collect() + + +def delete_epg_refresh_task_by_id(epg_id): + """ + Delete the periodic task associated with an EPG source ID. + Can be called directly or from the post_delete signal. + Returns True if a task was found and deleted, False otherwise. + """ + try: + task = None + task_name = f"epg_source-refresh-{epg_id}" + + # Look for task by name + try: + from django_celery_beat.models import PeriodicTask, IntervalSchedule + task = PeriodicTask.objects.get(name=task_name) + logger.info(f"Found task by name: {task.id} for EPGSource {epg_id}") + except PeriodicTask.DoesNotExist: + logger.warning(f"No PeriodicTask found with name {task_name}") + return False + + # Now delete the task and its interval + if task: + # Store interval info before deleting the task + interval_id = None + if hasattr(task, 'interval') and task.interval: + interval_id = task.interval.id + + # Count how many TOTAL tasks use this interval (including this one) + tasks_with_same_interval = PeriodicTask.objects.filter(interval_id=interval_id).count() + logger.info(f"Interval {interval_id} is used by {tasks_with_same_interval} tasks total") + + # Delete the task first + task_id = task.id + task.delete() + logger.info(f"Successfully deleted periodic task {task_id}") + + # Now check if we should delete the interval + # We only delete if it was the ONLY task using this interval + if interval_id and tasks_with_same_interval == 1: + try: + interval = IntervalSchedule.objects.get(id=interval_id) + logger.info(f"Deleting interval schedule {interval_id} (not shared with other tasks)") + interval.delete() + logger.info(f"Successfully deleted interval {interval_id}") + except IntervalSchedule.DoesNotExist: + logger.warning(f"Interval {interval_id} no longer exists") + elif interval_id: + logger.info(f"Not deleting interval {interval_id} as it's shared with {tasks_with_same_interval-1} other tasks") + + return True + return False + except Exception as e: + logger.error(f"Error deleting periodic task for EPGSource {epg_id}: {str(e)}", exc_info=True) + return False + + +@shared_task +def refresh_all_epg_data(): + logger.info("Starting refresh_epg_data task.") + # 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) + # Force garbage collection between sources + gc.collect() + + logger.info("Finished refresh_epg_data task.") + return "EPG data refreshed." + + +@shared_task(time_limit=14400) +def refresh_epg_data(source_id): + if not acquire_task_lock('refresh_epg_data', source_id): + logger.debug(f"EPG refresh for {source_id} already running") + return + + lock_renewer = TaskLockRenewer('refresh_epg_data', source_id) + lock_renewer.start() + + source = None + try: + # Try to get the EPG source + try: + source = EPGSource.objects.get(id=source_id) + except EPGSource.DoesNotExist: + # The EPG source doesn't exist, so delete the periodic task if it exists + logger.warning(f"EPG source with ID {source_id} not found, but task was triggered. Cleaning up orphaned task.") + + # Call the shared function to delete the task + if delete_epg_refresh_task_by_id(source_id): + logger.info(f"Successfully cleaned up orphaned task for EPG source {source_id}") + else: + logger.info(f"No orphaned task found for EPG source {source_id}") + + # Release the lock and exit + lock_renewer.stop() + release_task_lock('refresh_epg_data', source_id) + # Force garbage collection before exit + gc.collect() + return f"EPG source {source_id} does not exist, task cleaned up" + + # The source exists but is not active, just skip processing + if not source.is_active: + logger.info(f"EPG source {source_id} is not active. Skipping.") + lock_renewer.stop() + release_task_lock('refresh_epg_data', source_id) + # Force garbage collection before exit + 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})") + lock_renewer.stop() + 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': + fetch_success = fetch_xmltv(source) + if not fetch_success: + logger.error(f"Failed to fetch XMLTV for source {source.name}") + lock_renewer.stop() + release_task_lock('refresh_epg_data', source_id) + # Force garbage collection before exit + gc.collect() + return + + parse_channels_success = parse_channels_only(source) + if not parse_channels_success: + logger.error(f"Failed to parse channels for source {source.name}") + lock_renewer.stop() + release_task_lock('refresh_epg_data', source_id) + # Force garbage collection before exit + gc.collect() + return + + parse_programs_for_source(source) + + elif source.source_type == 'schedules_direct': + 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: + if source: + source.status = 'error' + source.last_message = f"Error refreshing EPG data: {str(e)}" + source.save(update_fields=['status', 'last_message']) + send_epg_update(source_id, "refresh", 100, status="error", error=str(e)) + except Exception as inner_e: + logger.error(f"Error updating source status: {inner_e}") + finally: + # Clear references to ensure proper garbage collection + source = None + # Force garbage collection before releasing the lock + gc.collect() + lock_renewer.stop() + release_task_lock('refresh_epg_data', source_id) + + +def fetch_xmltv(source): + # Handle cases with local file but no URL + if not source.url and source.file_path and os.path.exists(source.file_path): + logger.info(f"Using existing local file for EPG source: {source.name} at {source.file_path}") + + # Check if the existing file is compressed and we need to extract it + if source.file_path.endswith(('.gz', '.zip')) and not source.file_path.endswith('.xml'): + try: + # Define the path for the extracted file in the cache directory + cache_dir = os.path.join(settings.MEDIA_ROOT, "cached_epg") + os.makedirs(cache_dir, exist_ok=True) + xml_path = os.path.join(cache_dir, f"{source.id}.xml") + + # Extract to the cache location keeping the original + extracted_path = extract_compressed_file(source.file_path, xml_path, delete_original=False) + + if extracted_path: + logger.info(f"Extracted mapped compressed file to: {extracted_path}") + # Update to use extracted_file_path instead of changing file_path + source.extracted_file_path = extracted_path + source.save(update_fields=['extracted_file_path']) + else: + logger.error(f"Failed to extract mapped compressed file. Using original file: {source.file_path}") + except Exception as e: + logger.error(f"Failed to extract existing compressed file: {e}") + # Continue with the original file if extraction fails + + # Set the status to success in the database + source.status = 'success' + source.save(update_fields=['status']) + + # Send a download complete notification + send_epg_update(source.id, "downloading", 100, status="success") + + # Return True to indicate successful fetch, processing will continue with parse_channels_only + return True + + # Handle cases where no URL is provided and no valid file path exists + if not source.url: + # Update source status for missing URL + source.status = 'error' + source.last_message = "No URL provided and no valid local file exists" + source.save(update_fields=['status', 'last_message']) + send_epg_update(source.id, "downloading", 100, status="error", error="No URL provided and no valid local file exists") + return False + + logger.info(f"Fetching XMLTV data from source: {source.name}") + try: + # Get default user agent from settings + 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_id: + try: + 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}") + except (ValueError, Exception) as e: + logger.warning(f"Error retrieving default user agent, using fallback: {e}") + + headers = { + 'User-Agent': user_agent + } + + # Update status to fetching before starting download + source.status = 'fetching' + source.save(update_fields=['status']) + + # Send initial download notification + send_epg_update(source.id, "downloading", 0) + + # Use streaming response to track download progress + with requests.get(source.url, headers=headers, stream=True, timeout=60) as response: + # Handle 404 specifically + if response.status_code == 404: + logger.error(f"EPG URL not found (404): {source.url}") + # Update status to error in the database + source.status = 'error' + source.last_message = f"EPG source '{source.name}' returned 404 error - will retry on next scheduled run" + source.save(update_fields=['status', 'last_message']) + + # Notify users through the WebSocket about the EPG fetch failure + channel_layer = get_channel_layer() + async_to_sync(channel_layer.group_send)( + 'updates', + { + 'type': 'update', + 'data': { + "success": False, + "type": "epg_fetch_error", + "source_id": source.id, + "source_name": source.name, + "error_code": 404, + "message": f"EPG source '{source.name}' returned 404 error - will retry on next scheduled run" + } + } + ) + # Ensure we update the download progress to 100 with error status + send_epg_update(source.id, "downloading", 100, status="error", error="URL not found (404)") + return False + + # For all other error status codes + if response.status_code >= 400: + error_message = f"HTTP error {response.status_code}" + user_message = f"EPG source '{source.name}' encountered HTTP error {response.status_code}" + + # Update status to error in the database + source.status = 'error' + source.last_message = user_message + source.save(update_fields=['status', 'last_message']) + + # Notify users through the WebSocket + channel_layer = get_channel_layer() + async_to_sync(channel_layer.group_send)( + 'updates', + { + 'type': 'update', + 'data': { + "success": False, + "type": "epg_fetch_error", + "source_id": source.id, + "source_name": source.name, + "error_code": response.status_code, + "message": user_message + } + } + ) + # Update download progress + send_epg_update(source.id, "downloading", 100, status="error", error=user_message) + return False + + response.raise_for_status() + logger.debug("XMLTV data fetched successfully.") + + # Define base paths for consistent file naming + cache_dir = os.path.join(settings.MEDIA_ROOT, "cached_epg") + os.makedirs(cache_dir, exist_ok=True) + + # Create temporary download file with .tmp extension + temp_download_path = os.path.join(cache_dir, f"{source.id}.tmp") + + # Check if we have content length for progress tracking + total_size = int(response.headers.get('content-length', 0)) + downloaded = 0 + start_time = time.time() + last_update_time = start_time + update_interval = 0.5 # Only update every 0.5 seconds + + # Download to temporary file + with open(temp_download_path, 'wb') as f: + for chunk in response.iter_content(chunk_size=16384): + f.write(chunk) + + downloaded += len(chunk) + elapsed_time = time.time() - start_time + + # Calculate download speed in KB/s + speed = downloaded / elapsed_time / 1024 if elapsed_time > 0 else 0 + + # Calculate progress percentage + if total_size and total_size > 0: + progress = min(100, int((downloaded / total_size) * 100)) + else: + # If no content length header, estimate progress + progress = min(95, int((downloaded / (10 * 1024 * 1024)) * 100)) # Assume 10MB if unknown + + # Time remaining (in seconds) + time_remaining = (total_size - downloaded) / (speed * 1024) if speed > 0 and total_size > 0 else 0 + + # Only send updates at specified intervals to avoid flooding + current_time = time.time() + if current_time - last_update_time >= update_interval and progress > 0: + last_update_time = current_time + send_epg_update( + source.id, + "downloading", + progress, + speed=round(speed, 2), + elapsed_time=round(elapsed_time, 1), + time_remaining=round(time_remaining, 1), + downloaded=f"{downloaded / (1024 * 1024):.2f} MB" + ) + + # Explicitly delete the chunk to free memory immediately + del chunk + + # Send completion notification + send_epg_update(source.id, "downloading", 100) + + # Determine the appropriate file extension based on content detection + with open(temp_download_path, 'rb') as f: + content_sample = f.read(1024) # Just need the first 1KB to detect format + + # Use our helper function to detect the format + format_type, is_compressed, file_extension = detect_file_format( + file_path=source.url, # Original URL as a hint + content=content_sample # Actual file content for detection + ) + + logger.debug(f"File format detection results: type={format_type}, compressed={is_compressed}, extension={file_extension}") + + # Ensure consistent final paths + compressed_path = os.path.join(cache_dir, f"{source.id}{file_extension}" if is_compressed else f"{source.id}.compressed") + xml_path = os.path.join(cache_dir, f"{source.id}.xml") + + # Clean up old files before saving new ones + if os.path.exists(compressed_path): + try: + os.remove(compressed_path) + logger.debug(f"Removed old compressed file: {compressed_path}") + except OSError as e: + logger.warning(f"Failed to remove old compressed file: {e}") + + if os.path.exists(xml_path): + try: + os.remove(xml_path) + logger.debug(f"Removed old XML file: {xml_path}") + except OSError as e: + logger.warning(f"Failed to remove old XML file: {e}") + + # Rename the temp file to appropriate final path + if is_compressed: + try: + os.rename(temp_download_path, compressed_path) + logger.debug(f"Renamed temp file to compressed file: {compressed_path}") + current_file_path = compressed_path + except OSError as e: + logger.error(f"Failed to rename temp file to compressed file: {e}") + current_file_path = temp_download_path # Fall back to using temp file + else: + try: + os.rename(temp_download_path, xml_path) + logger.debug(f"Renamed temp file to XML file: {xml_path}") + current_file_path = xml_path + except OSError as e: + logger.error(f"Failed to rename temp file to XML file: {e}") + current_file_path = temp_download_path # Fall back to using temp file + + # Now extract the file if it's compressed + if is_compressed: + try: + logger.info(f"Extracting compressed file {current_file_path}") + send_epg_update(source.id, "extracting", 0, message="Extracting downloaded file") + + # Always extract to the standard XML path - set delete_original to True to clean up + extracted = extract_compressed_file(current_file_path, xml_path, delete_original=True) + + if extracted: + logger.info(f"Successfully extracted to {xml_path}, compressed file deleted") + send_epg_update(source.id, "extracting", 100, message=f"File extracted successfully, temporary file removed") + # Update to store only the extracted file path since the compressed file is now gone + source.file_path = xml_path + source.extracted_file_path = None + else: + logger.error("Extraction failed, using compressed file") + send_epg_update(source.id, "extracting", 100, status="error", message="Extraction failed, using compressed file") + # Use the compressed file + source.file_path = current_file_path + source.extracted_file_path = None + except Exception as e: + logger.error(f"Error extracting file: {str(e)}", exc_info=True) + send_epg_update(source.id, "extracting", 100, status="error", message=f"Error during extraction: {str(e)}") + # Use the compressed file if extraction fails + source.file_path = current_file_path + source.extracted_file_path = None + else: + # It's already an XML file + source.file_path = current_file_path + source.extracted_file_path = None + + # Update the source's file paths + source.save(update_fields=['file_path', 'status', 'extracted_file_path']) + + # Update status to parsing + source.status = 'parsing' + source.save(update_fields=['status']) + + logger.info(f"Cached EPG file saved to {source.file_path}") + + return True + + except requests.exceptions.HTTPError as e: + logger.error(f"HTTP Error fetching XMLTV from {source.name}: {e}", exc_info=True) + + # Get error details + status_code = e.response.status_code if hasattr(e, 'response') and e.response else 'unknown' + error_message = str(e) + + # Create a user-friendly message + user_message = f"EPG source '{source.name}' encountered HTTP error {status_code}" + + # Add specific handling for common HTTP errors + if status_code == 404: + user_message = f"EPG source '{source.name}' URL not found (404) - will retry on next scheduled run" + elif status_code == 401 or status_code == 403: + user_message = f"EPG source '{source.name}' access denied (HTTP {status_code}) - check credentials" + elif status_code == 429: + user_message = f"EPG source '{source.name}' rate limited (429) - try again later" + elif status_code >= 500: + user_message = f"EPG source '{source.name}' server error (HTTP {status_code}) - will retry later" + + # Update source status to error with the error message + source.status = 'error' + source.last_message = user_message + source.save(update_fields=['status', 'last_message']) + + # Notify users through the WebSocket about the EPG fetch failure + channel_layer = get_channel_layer() + async_to_sync(channel_layer.group_send)( + 'updates', + { + 'type': 'update', + 'data': { + "success": False, + "type": "epg_fetch_error", + "source_id": source.id, + "source_name": source.name, + "error_code": status_code, + "message": user_message, + "details": error_message + } + } + ) + + # Ensure we update the download progress to 100 with error status + send_epg_update(source.id, "downloading", 100, status="error", error=user_message) + return False + except requests.exceptions.ConnectionError as e: + # Handle connection errors separately + error_message = str(e) + user_message = f"Connection error: Unable to connect to EPG source '{source.name}'" + logger.error(f"Connection error fetching XMLTV from {source.name}: {e}", exc_info=True) + + # Update source status + source.status = 'error' + source.last_message = user_message + source.save(update_fields=['status', 'last_message']) + + # Send notifications + channel_layer = get_channel_layer() + async_to_sync(channel_layer.group_send)( + 'updates', + { + 'type': 'update', + 'data': { + "success": False, + "type": "epg_fetch_error", + "source_id": source.id, + "source_name": source.name, + "error_code": "connection_error", + "message": user_message + } + } + ) + send_epg_update(source.id, "downloading", 100, status="error", error=user_message) + return False + except requests.exceptions.Timeout as e: + # Handle timeout errors specifically + error_message = str(e) + user_message = f"Timeout error: EPG source '{source.name}' took too long to respond" + logger.error(f"Timeout error fetching XMLTV from {source.name}: {e}", exc_info=True) + + # Update source status + source.status = 'error' + source.last_message = user_message + source.save(update_fields=['status', 'last_message']) + + # Send notifications + send_epg_update(source.id, "downloading", 100, status="error", error=user_message) + return False + except Exception as e: + error_message = str(e) + logger.error(f"Error fetching XMLTV from {source.name}: {e}", exc_info=True) + + # Update source status for general exceptions too + source.status = 'error' + source.last_message = f"Error: {error_message}" + source.save(update_fields=['status', 'last_message']) + + # Ensure we update the download progress to 100 with error status + send_epg_update(source.id, "downloading", 100, status="error", error=f"Error: {error_message}") + return False + + +def extract_compressed_file(file_path, output_path=None, delete_original=False): + """ + Extracts a compressed file (.gz or .zip) to an XML file. + + Args: + file_path: Path to the compressed file + output_path: Specific path where the file should be extracted (optional) + delete_original: Whether to delete the original compressed file after successful extraction + + Returns: + Path to the extracted XML file, or None if extraction failed + """ + try: + if output_path is None: + base_path = os.path.splitext(file_path)[0] + extracted_path = f"{base_path}.xml" + else: + extracted_path = output_path + + # Make sure the output path doesn't already exist + if os.path.exists(extracted_path): + try: + os.remove(extracted_path) + logger.info(f"Removed existing extracted file: {extracted_path}") + except Exception as e: + logger.warning(f"Failed to remove existing extracted file: {e}") + # If we can't delete the existing file and no specific output was requested, + # create a unique filename instead + if output_path is None: + base_path = os.path.splitext(file_path)[0] + extracted_path = f"{base_path}_{uuid.uuid4().hex[:8]}.xml" + + # Use our detection helper to determine the file format instead of relying on extension + with open(file_path, 'rb') as f: + content_sample = f.read(4096) # Read a larger sample to ensure accurate detection + + format_type, is_compressed, _ = detect_file_format(file_path=file_path, content=content_sample) + + if format_type == 'gzip': + logger.debug(f"Extracting gzip file: {file_path}") + try: + # First check if the content is XML by reading a sample + with gzip.open(file_path, 'rb') as gz_file: + content_sample = gz_file.read(4096) # Read first 4KB for detection + detected_format, _, _ = detect_file_format(content=content_sample) + + if detected_format != 'xml': + logger.warning(f"GZIP file does not appear to contain XML content: {file_path} (detected as: {detected_format})") + # Continue anyway since GZIP only contains one file + + # Reset file pointer and extract the content + gz_file.seek(0) + with open(extracted_path, 'wb') as out_file: + 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 + + logger.info(f"Successfully extracted gzip file to: {extracted_path}") + + # Delete original compressed file if requested + if delete_original: + try: + os.remove(file_path) + logger.info(f"Deleted original compressed file: {file_path}") + except Exception as e: + logger.warning(f"Failed to delete original compressed file {file_path}: {e}") + + return extracted_path + + elif format_type == 'zip': + logger.debug(f"Extracting zip file: {file_path}") + with zipfile.ZipFile(file_path, 'r') as zip_file: + # Find the first XML file in the ZIP archive + xml_files = [f for f in zip_file.namelist() if f.lower().endswith('.xml')] + + if not xml_files: + logger.info("No files with .xml extension found in ZIP archive, checking content of all files") + # Check content of each file to see if any are XML without proper extension + for filename in zip_file.namelist(): + if not filename.endswith('/'): # Skip directories + try: + # Read a sample of the file content + content_sample = zip_file.read(filename, 4096) # Read up to 4KB for detection + format_type, _, _ = detect_file_format(content=content_sample) + if format_type == 'xml': + logger.info(f"Found XML content in file without .xml extension: {filename}") + xml_files = [filename] + break + except Exception as e: + logger.warning(f"Error reading file {filename} from ZIP: {e}") + + if not xml_files: + logger.error("No XML file found in ZIP archive") + return None + + # Extract the first XML file + with open(extracted_path, 'wb') as out_file: + 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}") + + # Delete original compressed file if requested + if delete_original: + try: + os.remove(file_path) + logger.info(f"Deleted original compressed file: {file_path}") + except Exception as e: + logger.warning(f"Failed to delete original compressed file {file_path}: {e}") + + return extracted_path + + else: + logger.error(f"Unsupported or unrecognized compressed file format: {file_path} (detected as: {format_type})") + return None + + except Exception as e: + logger.error(f"Error extracting {file_path}: {str(e)}", exc_info=True) + return None + + +def parse_channels_only(source): + # Use extracted file if available, otherwise use the original file path + file_path = source.extracted_file_path if source.extracted_file_path else source.file_path + if not file_path: + file_path = source.get_cache_file() + + # Send initial parsing notification + send_epg_update(source.id, "parsing_channels", 0) + + process = None + should_log_memory = False + + try: + # Check if the file exists + if not os.path.exists(file_path): + logger.error(f"EPG file does not exist at path: {file_path}") + + # Update the source's file_path to the default cache location + new_path = source.get_cache_file() + logger.info(f"Updating file_path from '{file_path}' to '{new_path}'") + source.file_path = new_path + source.save(update_fields=['file_path']) + + # If the source has a URL, fetch the data before continuing + if source.url: + logger.info(f"Fetching new EPG data from URL: {source.url}") + fetch_success = fetch_xmltv(source) # Store the result + + # Only proceed if fetch was successful AND file exists + if not fetch_success: + logger.error(f"Failed to fetch EPG data from URL: {source.url}") + # Update status to error + source.status = 'error' + source.last_message = f"Failed to fetch EPG data from URL" + source.save(update_fields=['status', 'last_message']) + # Send error notification + send_epg_update(source.id, "parsing_channels", 100, status="error", error="Failed to fetch EPG data") + return False + + # Verify the file was downloaded successfully + if not os.path.exists(source.file_path): + logger.error(f"Failed to fetch EPG data, file still missing at: {source.file_path}") + # Update status to error + source.status = 'error' + source.last_message = f"Failed to fetch EPG data, file missing after download" + source.save(update_fields=['status', 'last_message']) + send_epg_update(source.id, "parsing_channels", 100, status="error", error="File not found after download") + return False + + # Update file_path with the new location + file_path = source.file_path + else: + logger.error(f"No URL provided for EPG source {source.name}, cannot fetch new data") + # Update status to error + source.status = 'error' + source.last_message = f"No URL provided, cannot fetch EPG data" + source.save(update_fields=['updated_at']) + + # Initialize process variable for memory tracking only in debug mode + try: + process = None + # Get current log level as a number + current_log_level = logger.getEffectiveLevel() + + # Only track memory usage when log level is DEBUG (10) or more verbose + # This is more future-proof than string comparisons + should_log_memory = current_log_level <= logging.DEBUG or settings.DEBUG + + if should_log_memory: + process = psutil.Process() + initial_memory = process.memory_info().rss / 1024 / 1024 + logger.debug(f"[parse_channels_only] Initial memory usage: {initial_memory:.2f} MB") + except (ImportError, NameError): + process = None + should_log_memory = False + logger.warning("psutil not available for memory tracking") + + # Replace full dictionary load with more efficient lookup set + existing_tvg_ids = set() + existing_epgs = {} + scanned_tvg_ids = set() # Track tvg_ids seen in the current scan for stale cleanup + last_id = 0 + chunk_size = 5000 + + while True: + tvg_id_chunk = set(EPGData.objects.filter( + epg_source=source, + id__gt=last_id + ).order_by('id').values_list('tvg_id', flat=True)[:chunk_size]) + + if not tvg_id_chunk: + break + + existing_tvg_ids.update(tvg_id_chunk) + last_id = EPGData.objects.filter(tvg_id__in=tvg_id_chunk).order_by('-id')[0].id + # Update progress to show file read starting + send_epg_update(source.id, "parsing_channels", 10) + + # Stream parsing instead of loading entire file at once + # This can be simplified since we now always have XML files + epgs_to_create = [] + epgs_to_update = [] + total_channels = 0 + 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: + logger.debug(f"[parse_channels_only] Memory before opening file: {process.memory_info().rss / 1024 / 1024:.2f} MB") + + try: + # Attempt to count existing channels in the database + try: + total_channels = EPGData.objects.filter(epg_source=source).count() + logger.info(f"Found {total_channels} existing channels for this source") + except Exception as e: + logger.error(f"Error counting channels: {e}") + total_channels = 500 # Default estimate + if process: + logger.debug(f"[parse_channels_only] Memory after closing initial file: {process.memory_info().rss / 1024 / 1024:.2f} MB") + + # Update progress after counting + send_epg_update(source.id, "parsing_channels", 25, total_channels=total_channels) + + # Open the file - no need to check file type since it's always XML now + logger.debug(f"Opening file for channel parsing: {file_path}") + source_file = _open_xmltv_file(file_path) + + if process: + logger.debug(f"[parse_channels_only] Memory after opening file: {process.memory_info().rss / 1024 / 1024:.2f} MB") + + # 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, recover=True) + if process: + logger.debug(f"[parse_channels_only] Memory after creating iterparse: {process.memory_info().rss / 1024 / 1024:.2f} MB") + + channel_count = 0 + total_elements_processed = 0 # Track total elements processed, not just channels + for _, elem in channel_parser: + total_elements_processed += 1 + # Only process channel elements + if elem.tag == 'channel': + channel_count += 1 + tvg_id = elem.get('id', '').strip() + if tvg_id: + scanned_tvg_ids.add(tvg_id) + display_name = None + icon_url = None + for child in elem: + if display_name is None and child.tag == 'display-name' and child.text: + display_name = child.text.strip() + 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 + + # Use lazy loading approach to reduce memory usage + if tvg_id in existing_tvg_ids: + # Only fetch the object if we need to update it and it hasn't been loaded yet + if tvg_id not in existing_epgs: + try: + # This loads the full EPG object from the database and caches it + existing_epgs[tvg_id] = EPGData.objects.get(tvg_id=tvg_id, epg_source=source) + except EPGData.DoesNotExist: + # Handle race condition where record was deleted + existing_tvg_ids.remove(tvg_id) + 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 or icon_url has changed + epg_obj = existing_epgs[tvg_id] + needs_update = False + if epg_obj.name != display_name: + 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: + # No changes needed, just clear the element + logger.debug(f"[parse_channels_only] No changes needed for channel {tvg_id} - {display_name}") + else: + # This is a new channel that doesn't exist in our database + 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}") + + processed_channels += 1 + + # Batch processing + if len(epgs_to_create) >= batch_size: + logger.info(f"[parse_channels_only] Bulk creating {len(epgs_to_create)} EPG entries") + EPGData.objects.bulk_create(epgs_to_create, ignore_conflicts=True) + if process: + logger.info(f"[parse_channels_only] Memory after bulk_create: {process.memory_info().rss / 1024 / 1024:.2f} MB") + del epgs_to_create # Explicit deletion + epgs_to_create = [] + cleanup_memory(log_usage=should_log_memory, force_collection=True) + if process: + logger.info(f"[parse_channels_only] Memory after gc.collect(): {process.memory_info().rss / 1024 / 1024:.2f} MB") + + if len(epgs_to_update) >= batch_size: + 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", "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 = [] + # Force garbage collection + cleanup_memory(log_usage=should_log_memory, force_collection=True) + + # Periodically clear the existing_epgs cache to prevent memory buildup + if processed_channels % 1000 == 0: + logger.info(f"[parse_channels_only] Clearing existing_epgs cache at {processed_channels} channels") + existing_epgs.clear() + cleanup_memory(log_usage=should_log_memory, force_collection=True) + if process: + logger.info(f"[parse_channels_only] Memory after clearing cache: {process.memory_info().rss / 1024 / 1024:.2f} MB") + + # Send progress updates + if processed_channels % 100 == 0 or processed_channels == total_channels: + progress = 25 + int((processed_channels / total_channels) * 65) if total_channels > 0 else 90 + send_epg_update( + source.id, + "parsing_channels", + progress, + processed=processed_channels, + total=total_channels + ) + if processed_channels > total_channels: + logger.debug(f"[parse_channels_only] Processed channel {tvg_id} - processed {processed_channels - total_channels} additional channels") + else: + logger.debug(f"[parse_channels_only] Processed channel {tvg_id} - processed {processed_channels}/{total_channels}") + if process: + logger.debug(f"[parse_channels_only] Memory before elem cleanup: {process.memory_info().rss / 1024 / 1024:.2f} MB") + # Clear memory + try: + # First clear the element's content + clear_element(elem) + + except Exception as e: + # Just log the error and continue - don't let cleanup errors stop processing + logger.debug(f"[parse_channels_only] Non-critical error during XML element cleanup: {e}") + if process: + logger.debug(f"[parse_channels_only] Memory after elem cleanup: {process.memory_info().rss / 1024 / 1024:.2f} MB") + + logger.debug(f"[parse_channels_only] Total elements processed: {total_elements_processed}") + + else: + logger.trace(f"[parse_channels_only] Skipping non-channel element: {elem.get('channel', 'unknown')} - {elem.get('start', 'unknown')} {elem.tag}") + clear_element(elem) + continue + + except (etree.XMLSyntaxError, Exception) as xml_error: + logger.error(f"[parse_channels_only] XML parsing failed: {xml_error}") + # Update status to error + source.status = 'error' + source.last_message = f"Error parsing XML file: {str(xml_error)}" + source.save(update_fields=['status', 'last_message']) + send_epg_update(source.id, "parsing_channels", 100, status="error", error=str(xml_error)) + return False + if process: + logger.info(f"[parse_channels_only] Processed {processed_channels} channels current memory: {process.memory_info().rss / 1024 / 1024:.2f} MB") + else: + logger.info(f"[parse_channels_only] Processed {processed_channels} channels") + # Process any remaining items + if epgs_to_create: + EPGData.objects.bulk_create(epgs_to_create, ignore_conflicts=True) + 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", "icon_url"]) + logger.debug(f"[parse_channels_only] Updated final batch of {len(epgs_to_update)} EPG entries") + + # Clean up stale EPGData: entries that existed before the scan but weren't seen, and aren't mapped to any channel. + # Use existing_tvg_ids - scanned_tvg_ids to avoid a full-table scan with a large EXCLUDE list. + potentially_stale = existing_tvg_ids - scanned_tvg_ids + if potentially_stale: + stale_qs = EPGData.objects.filter(epg_source=source, tvg_id__in=potentially_stale, channels__isnull=True) + deleted_count, _ = stale_qs.delete() + if deleted_count: + logger.info(f"[parse_channels_only] Cleaned up {deleted_count} stale EPG entries not in current scan and unmapped to any channel") + + if process: + logger.debug(f"[parse_channels_only] Memory after final batch creation: {process.memory_info().rss / 1024 / 1024:.2f} MB") + + # Update source status with channel count + source.status = 'success' + source.last_message = f"Successfully parsed {processed_channels} channels" + source.save(update_fields=['status', 'last_message']) + + # Send completion notification + send_epg_update( + source.id, + "parsing_channels", + 100, + status="success", + channels_count=processed_channels + ) + + send_websocket_update('updates', 'update', {"success": True, "type": "epg_channels"}) + + logger.info(f"Finished parsing channel info. Found {processed_channels} channels.") + + return True + + except FileNotFoundError: + logger.error(f"EPG file not found at: {file_path}") + # Update status to error + source.status = 'error' + source.last_message = f"EPG file not found: {file_path}" + source.save(update_fields=['status', 'last_message']) + send_epg_update(source.id, "parsing_channels", 100, status="error", error="File not found") + return False + except Exception as e: + logger.error(f"Error reading EPG file {file_path}: {e}", exc_info=True) + # Update status to error + source.status = 'error' + source.last_message = f"Error parsing EPG file: {str(e)}" + source.save(update_fields=['status', 'last_message']) + send_epg_update(source.id, "parsing_channels", 100, status="error", error=str(e)) + return False + finally: + # Cleanup memory and close file + if process: + logger.debug(f"[parse_channels_only] Memory before cleanup: {process.memory_info().rss / 1024 / 1024:.2f} MB") + try: + # Output any errors in the channel_parser error log + if 'channel_parser' in locals() and hasattr(channel_parser, 'error_log') and len(channel_parser.error_log) > 0: + logger.debug(f"XML parser errors found ({len(channel_parser.error_log)} total):") + for i, error in enumerate(channel_parser.error_log): + logger.debug(f" Error {i+1}: {error}") + if 'channel_parser' in locals(): + del channel_parser + if 'elem' in locals(): + del elem + if 'parent' in locals(): + del parent + + if 'source_file' in locals(): + source_file.close() + del source_file + # Clear remaining large data structures + existing_epgs.clear() + epgs_to_create.clear() + epgs_to_update.clear() + existing_epgs = None + epgs_to_create = None + epgs_to_update = None + if 'scanned_tvg_ids' in locals() and scanned_tvg_ids is not None: + scanned_tvg_ids.clear() + scanned_tvg_ids = None + cleanup_memory(log_usage=should_log_memory, force_collection=True) + except Exception as e: + logger.warning(f"Cleanup error: {e}") + + try: + if process: + final_memory = process.memory_info().rss / 1024 / 1024 + logger.debug(f"[parse_channels_only] Final memory usage: {final_memory:.2f} MB") + process = None + except: + pass + + + +@shared_task(time_limit=3600, soft_time_limit=3500) +def parse_programs_for_tvg_id(epg_id): + if not acquire_task_lock('parse_epg_programs', epg_id): + logger.info(f"Program parse for {epg_id} already in progress, skipping duplicate task") + return "Task already running" + + lock_renewer = TaskLockRenewer('parse_epg_programs', epg_id) + lock_renewer.start() + + source_file = None + program_parser = None + programs_to_create = [] + programs_processed = 0 + try: + # Add memory tracking only in trace mode or higher + try: + process = None + # Get current log level as a number + current_log_level = logger.getEffectiveLevel() + + # Only track memory usage when log level is TRACE or more verbose or if running in DEBUG mode + should_log_memory = current_log_level <= 5 or settings.DEBUG + + if should_log_memory: + process = psutil.Process() + initial_memory = process.memory_info().rss / 1024 / 1024 + logger.info(f"[parse_programs_for_tvg_id] Initial memory usage: {initial_memory:.2f} MB") + mem_before = initial_memory + except ImportError: + process = None + should_log_memory = False + + 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})") + lock_renewer.stop() + 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}") + lock_renewer.stop() + release_task_lock('parse_epg_programs', epg_id) + return + + logger.info(f"Refreshing program data for tvg_id: {epg.tvg_id}") + + # Optimize deletion with a single delete query instead of chunking + # This is faster for most database engines + ProgramData.objects.filter(epg=epg).delete() + + 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() + + # Check if the file exists + if not os.path.exists(file_path): + logger.error(f"EPG file not found at: {file_path}") + + 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}") + else: + logger.info(f"EPG source does not have a URL, using existing file path: {file_path} to rebuild cache") + + # Fetch new data before continuing + if epg_source: + + # Properly check the return value from fetch_xmltv + fetch_success = fetch_xmltv(epg_source) + + # If fetch was not successful or the file still doesn't exist, abort + if not fetch_success: + logger.error(f"Failed to fetch EPG data, cannot parse programs for tvg_id: {epg.tvg_id}") + # Update status to error if not already set + epg_source.status = 'error' + epg_source.last_message = f"Failed to download EPG data, cannot parse programs" + 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") + lock_renewer.stop() + release_task_lock('parse_epg_programs', epg_id) + return + + # Also check if the file exists after download + if not os.path.exists(epg_source.file_path): + logger.error(f"Failed to fetch EPG data, file still missing at: {epg_source.file_path}") + epg_source.status = 'error' + epg_source.last_message = f"Failed to download EPG data, file missing after download" + epg_source.save(update_fields=['status', 'last_message']) + send_epg_update(epg_source.id, "parsing_programs", 100, status="error", error="File not found after download") + lock_renewer.stop() + release_task_lock('parse_epg_programs', epg_id) + return + + # Update file_path with the new location + if epg_source.extracted_file_path: + file_path = epg_source.extracted_file_path + else: + file_path = epg_source.file_path + else: + logger.error(f"No URL provided for EPG source {epg_source.name}, cannot fetch new data") + # Update status to error + 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") + lock_renewer.stop() + release_task_lock('parse_epg_programs', epg_id) + return + + # Use streaming parsing to reduce memory usage + # No need to check file type anymore since it's always XML + logger.debug(f"Parsing programs for tvg_id={epg.tvg_id} from {file_path}") + + # Memory usage tracking + if process: + try: + mem_before = process.memory_info().rss / 1024 / 1024 + logger.debug(f"[parse_programs_for_tvg_id] Memory before parsing {epg.tvg_id} - {mem_before:.2f} MB") + except Exception as e: + logger.warning(f"Error tracking memory: {e}") + mem_before = 0 + + programs_to_create = [] + batch_size = 1000 # Process in batches to limit memory usage + + try: + # Open the file directly - no need to check compression + logger.debug(f"Opening file for parsing: {file_path}") + source_file = _open_xmltv_file(file_path) + + # 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: + if elem.get('channel') == epg.tvg_id: + 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 = None + + if custom_props: + logger.trace(f"Number of custom properties: {len(custom_props)}") + custom_properties_json = custom_props + + # Fallback: extract S/E from description when episode-num + # elements didn't provide them + if desc: + has_season = (custom_properties_json or {}).get('season') is not None + has_episode = (custom_properties_json or {}).get('episode') is not None + if not has_season or not has_episode: + d_season, d_episode, cleaned_desc = extract_season_episode_from_description(desc) + if d_season is not None and d_episode is not None: + if custom_properties_json is None: + custom_properties_json = {} + if not has_season: + custom_properties_json['season'] = d_season + if not has_episode: + custom_properties_json['episode'] = d_episode + custom_properties_json['season_episode_source'] = 'description' + desc = cleaned_desc + + programs_to_create.append(ProgramData( + epg=epg, + start_time=start_time, + end_time=end_time, + title=title[:255], + description=desc, + sub_title=sub_title, + tvg_id=epg.tvg_id, + custom_properties=custom_properties_json + )) + programs_processed += 1 + # Clear the element to free memory + clear_element(elem) + # Batch processing + if len(programs_to_create) >= batch_size: + ProgramData.objects.bulk_create(programs_to_create) + logger.debug(f"Saved batch of {len(programs_to_create)} programs for {epg.tvg_id}") + programs_to_create = [] + # Only call gc.collect() every few batches + if programs_processed % (batch_size * 5) == 0: + gc.collect() + + except Exception as e: + logger.error(f"Error processing program for {epg.tvg_id}: {e}", exc_info=True) + else: + # Immediately clean up non-matching elements to reduce memory pressure + if elem is not None: + clear_element(elem) + continue + + # Make sure to close the file and release parser resources + if source_file: + source_file.close() + source_file = None + + if program_parser: + program_parser = None + + gc.collect() + + except zipfile.BadZipFile as zip_error: + logger.error(f"Bad ZIP file: {zip_error}") + raise + except etree.XMLSyntaxError as xml_error: + logger.error(f"XML syntax error parsing program data: {xml_error}") + raise + except Exception as e: + logger.error(f"Error parsing XML for programs: {e}", exc_info=True) + raise + finally: + # Ensure file is closed even if an exception occurs + if source_file: + source_file.close() + source_file = None + # Memory tracking after processing + if process: + try: + mem_after = process.memory_info().rss / 1024 / 1024 + logger.info(f"[parse_programs_for_tvg_id] Memory after parsing 1 {epg.tvg_id} - {programs_processed} programs: {mem_after:.2f} MB (change: {mem_after-mem_before:.2f} MB)") + except Exception as e: + logger.warning(f"Error tracking memory: {e}") + + # Process any remaining items + if programs_to_create: + ProgramData.objects.bulk_create(programs_to_create) + logger.debug(f"Saved final batch of {len(programs_to_create)} programs for {epg.tvg_id}") + programs_to_create = None + custom_props = None + custom_properties_json = None + + + logger.info(f"Completed program parsing for tvg_id={epg.tvg_id}.") + finally: + # Reset internal caches and pools that lxml might be keeping + try: + etree.clear_error_log() + except: + pass + # Explicit cleanup of all potentially large objects + if source_file: + try: + source_file.close() + except: + pass + source_file = None + program_parser = None + programs_to_create = None + + epg_source = None + # Add comprehensive cleanup before releasing lock + cleanup_memory(log_usage=should_log_memory, force_collection=True) + # Memory tracking after processing + if process: + try: + mem_after = process.memory_info().rss / 1024 / 1024 + logger.info(f"[parse_programs_for_tvg_id] Final memory usage {epg.tvg_id} - {programs_processed} programs: {mem_after:.2f} MB (change: {mem_after-mem_before:.2f} MB)") + except Exception as e: + logger.warning(f"Error tracking memory: {e}") + process = None + epg = None + programs_processed = None + lock_renewer.stop() + release_task_lock('parse_epg_programs', 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: + # Get current log level as a number + current_log_level = logger.getEffectiveLevel() + + # Only track memory usage when log level is TRACE or more verbose + should_log_memory = current_log_level <= 5 or settings.DEBUG # Assuming TRACE is level 5 or lower + + if should_log_memory: + process = psutil.Process() + initial_memory = process.memory_info().rss / 1024 / 1024 + logger.info(f"[parse_programs_for_source] Initial memory usage: {initial_memory:.2f} MB") + except ImportError: + logger.warning("psutil not available for memory tracking") + process = None + should_log_memory = False + + try: + # 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 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.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 + + # 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()) + + total_epg_count = EPGData.objects.filter(epg_source=epg_source).count() + mapped_count = len(mapped_tvg_ids) + + 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)") + + # 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() + + # Check if the file exists + if not os.path.exists(file_path): + logger.error(f"EPG file not found at: {file_path}") + + 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}") + + # 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_xmltv_file(file_path) + + # 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 + + # Fallback: extract S/E from description when episode-num + # elements didn't provide them + if desc: + has_season = (custom_properties_json or {}).get('season') is not None + has_episode = (custom_properties_json or {}).get('episode') is not None + if not has_season or not has_episode: + d_season, d_episode, cleaned_desc = extract_season_episode_from_description(desc) + if d_season is not None and d_episode is not None: + if custom_properties_json is None: + custom_properties_json = {} + if not has_season: + custom_properties_json['season'] = d_season + if not has_episode: + custom_properties_json['episode'] = d_episode + custom_properties_json['season_episode_source'] = 'description' + desc = cleaned_desc + + 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[:255], + 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(): + # Kill any individual statement that hangs longer than 10 minutes. + # SET LOCAL automatically resets when this transaction ends (commit or rollback). + with connection.cursor() as cursor: + cursor.execute("SET LOCAL statement_timeout = '10min'") + # 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() + + # Count channels that actually got programs + channels_with_programs = sum(1 for count in programs_by_channel.values() if count > 0) + + # Success message + epg_source.status = EPGSource.STATUS_SUCCESS + 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, + updated_at=epg_source.updated_at.isoformat()) + + 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: + logger.error(f"Error in parse_programs_for_source: {e}", exc_info=True) + # Update status to error + epg_source.status = EPGSource.STATUS_ERROR + epg_source.last_message = f"Error parsing programs: {str(e)}" + epg_source.save(update_fields=['status', 'last_message']) + send_epg_update(epg_source.id, "parsing_programs", 100, + status="error", + message=epg_source.last_message) + 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 + 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 + cleanup_memory(log_usage=should_log_memory, force_collection=True) + if process: + final_memory = process.memory_info().rss / 1024 / 1024 + logger.info(f"[parse_programs_for_source] Final memory usage: {final_memory:.2f} MB difference: {final_memory - initial_memory:.2f} MB") + # Explicitly clear the process object to prevent potential memory leaks + process = None +def fetch_schedules_direct(source): + logger.info(f"Fetching Schedules Direct data from source: {source.name}") + try: + # Get default user agent from settings + 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_id: + try: + 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}") + except (ValueError, Exception) as e: + logger.warning(f"Error retrieving default user agent, using fallback: {e}") + + api_url = '' + headers = { + 'Content-Type': 'application/json', + 'Authorization': f'Bearer {source.api_key}', + 'User-Agent': user_agent + } + logger.debug(f"Requesting subscriptions from Schedules Direct using URL: {api_url}") + response = requests.get(api_url, headers=headers, timeout=30) + response.raise_for_status() + subscriptions = response.json() + logger.debug(f"Fetched subscriptions: {subscriptions}") + + for sub in subscriptions: + tvg_id = sub.get('stationID') + logger.debug(f"Processing subscription for tvg_id: {tvg_id}") + schedules_url = f"/schedules/{tvg_id}" + logger.debug(f"Requesting schedules from URL: {schedules_url}") + sched_response = requests.get(schedules_url, headers=headers, timeout=30) + sched_response.raise_for_status() + schedules = sched_response.json() + logger.debug(f"Fetched schedules: {schedules}") + + epg_data, created = EPGData.objects.get_or_create( + tvg_id=tvg_id, + defaults={'name': tvg_id} + ) + if created: + logger.info(f"Created new EPGData for tvg_id '{tvg_id}'.") + else: + logger.debug(f"Found existing EPGData for tvg_id '{tvg_id}'.") + + for sched in schedules.get('schedules', []): + title = sched.get('title', 'No Title') + desc = sched.get('description', '') + start_time = parse_schedules_direct_time(sched.get('startTime')) + end_time = parse_schedules_direct_time(sched.get('endTime')) + obj, created = ProgramData.objects.update_or_create( + epg=epg_data, + start_time=start_time, + title=title, + defaults={ + 'end_time': end_time, + 'description': desc, + 'sub_title': '' + } + ) + if created: + logger.info(f"Created ProgramData '{title}' for tvg_id '{tvg_id}'.") + else: + logger.info(f"Updated ProgramData '{title}' for tvg_id '{tvg_id}'.") + except Exception as e: + logger.error(f"Error fetching Schedules Direct data from {source.name}: {e}", exc_info=True) + + +# ------------------------------- +# Helper parse functions +# ------------------------------- +def parse_xmltv_time(time_str): + try: + # Basic format validation + if len(time_str) < 14: + logger.warning(f"XMLTV timestamp too short: '{time_str}', using as-is") + dt_obj = datetime.strptime(time_str, '%Y%m%d%H%M%S') + return timezone.make_aware(dt_obj, timezone=dt_timezone.utc) + + # Parse base datetime + dt_obj = datetime.strptime(time_str[:14], '%Y%m%d%H%M%S') + + # Handle timezone if present + if len(time_str) >= 20: # Has timezone info + tz_sign = time_str[15] + tz_hours = int(time_str[16:18]) + tz_minutes = int(time_str[18:20]) + + # Create a timezone object + if tz_sign == '+': + tz_offset = dt_timezone(timedelta(hours=tz_hours, minutes=tz_minutes)) + elif tz_sign == '-': + tz_offset = dt_timezone(timedelta(hours=-tz_hours, minutes=-tz_minutes)) + else: + tz_offset = dt_timezone.utc + + # Make datetime aware with correct timezone + aware_dt = datetime.replace(dt_obj, tzinfo=tz_offset) + # Convert to UTC + aware_dt = aware_dt.astimezone(dt_timezone.utc) + + logger.trace(f"Parsed XMLTV time '{time_str}' to {aware_dt}") + return aware_dt + else: + # No timezone info, assume UTC + aware_dt = timezone.make_aware(dt_obj, timezone=dt_timezone.utc) + logger.trace(f"Parsed XMLTV time without timezone '{time_str}' as UTC: {aware_dt}") + return aware_dt + + except Exception as e: + logger.error(f"Error parsing XMLTV time '{time_str}': {e}", exc_info=True) + raise + + +def parse_schedules_direct_time(time_str): + try: + dt_obj = datetime.strptime(time_str, '%Y-%m-%dT%H:%M:%SZ') + aware_dt = timezone.make_aware(dt_obj, timezone=dt_timezone.utc) + logger.debug(f"Parsed Schedules Direct time '{time_str}' to {aware_dt}") + return aware_dt + except Exception as e: + logger.error(f"Error parsing Schedules Direct time '{time_str}': {e}", exc_info=True) + raise + + +# Re-export from utils to preserve backward compatibility for any callers +from apps.epg.utils import extract_season_episode_from_description, _ONSCREEN_RE # noqa: F401 + + +# Helper function to extract custom properties - moved to a separate function to clean up the code +def extract_custom_properties(prog): + # Create a new dictionary for each call + custom_props = {} + + # Extract categories with a single comprehension to reduce intermediate objects + categories = [cat.text.strip() for cat in prog.findall('category') if cat.text and cat.text.strip()] + if categories: + custom_props['categories'] = categories + + # Extract keywords (new) + keywords = [kw.text.strip() for kw in prog.findall('keyword') if kw.text and kw.text.strip()] + if keywords: + custom_props['keywords'] = keywords + + # Extract episode numbers + for ep_num in prog.findall('episode-num'): + system = ep_num.get('system', '') + if system == 'xmltv_ns' and ep_num.text: + # Parse XMLTV episode-num format (season.episode.part) + parts = ep_num.text.split('.') + if len(parts) >= 2: + if parts[0].strip() != '': + try: + season = int(parts[0]) + 1 # XMLTV format is zero-based + custom_props['season'] = season + except ValueError: + pass + if parts[1].strip() != '': + try: + episode = int(parts[1]) + 1 # XMLTV format is zero-based + custom_props['episode'] = episode + except ValueError: + pass + elif system == 'onscreen' and ep_num.text: + onscreen_text = ep_num.text.strip() + custom_props['onscreen_episode'] = onscreen_text + # Extract season/episode from onscreen format if not already set by xmltv_ns + if 'season' not in custom_props or 'episode' not in custom_props: + match = _ONSCREEN_RE.search(onscreen_text) + if match: + if 'season' not in custom_props: + custom_props['season'] = int(match.group(1)) + if 'episode' not in custom_props: + custom_props['episode'] = int(match.group(2)) + elif system == 'dd_progid' and ep_num.text: + # Store the dd_progid format + custom_props['dd_progid'] = ep_num.text.strip() + # Add support for other systems like thetvdb.com, themoviedb.org, imdb.com + elif system in ['thetvdb.com', 'themoviedb.org', 'imdb.com'] and ep_num.text: + custom_props[f'{system}_id'] = ep_num.text.strip() + + # Extract ratings more efficiently + rating_elem = prog.find('rating') + if rating_elem is not None: + value_elem = rating_elem.find('value') + if value_elem is not None and value_elem.text: + custom_props['rating'] = value_elem.text.strip() + if rating_elem.get('system'): + custom_props['rating_system'] = rating_elem.get('system') + + # Extract star ratings (new) + star_ratings = [] + for star_rating in prog.findall('star-rating'): + value_elem = star_rating.find('value') + if value_elem is not None and value_elem.text: + rating_data = {'value': value_elem.text.strip()} + if star_rating.get('system'): + rating_data['system'] = star_rating.get('system') + star_ratings.append(rating_data) + if star_ratings: + custom_props['star_ratings'] = star_ratings + + # Extract credits more efficiently + credits_elem = prog.find('credits') + if credits_elem is not None: + credits = {} + for credit_type in ['director', 'actor', 'writer', 'adapter', 'producer', 'composer', 'editor', 'presenter', 'commentator', 'guest']: + if credit_type == 'actor': + # Handle actors with roles and guest status + actors = [] + for actor_elem in credits_elem.findall('actor'): + if actor_elem.text and actor_elem.text.strip(): + actor_data = {'name': actor_elem.text.strip()} + if actor_elem.get('role'): + actor_data['role'] = actor_elem.get('role') + if actor_elem.get('guest') == 'yes': + actor_data['guest'] = True + actors.append(actor_data) + if actors: + credits['actor'] = actors + else: + names = [e.text.strip() for e in credits_elem.findall(credit_type) if e.text and e.text.strip()] + if names: + credits[credit_type] = names + if credits: + custom_props['credits'] = credits + + # Extract other common program metadata + date_elem = prog.find('date') + if date_elem is not None and date_elem.text: + custom_props['date'] = date_elem.text.strip() + + country_elem = prog.find('country') + if country_elem is not None and country_elem.text: + custom_props['country'] = country_elem.text.strip() + + # Extract language information (new) + language_elem = prog.find('language') + if language_elem is not None and language_elem.text: + custom_props['language'] = language_elem.text.strip() + + orig_language_elem = prog.find('orig-language') + if orig_language_elem is not None and orig_language_elem.text: + custom_props['original_language'] = orig_language_elem.text.strip() + + # Extract length (new) + length_elem = prog.find('length') + if length_elem is not None and length_elem.text: + try: + length_value = int(length_elem.text.strip()) + length_units = length_elem.get('units', 'minutes') + custom_props['length'] = {'value': length_value, 'units': length_units} + except ValueError: + pass + + # Extract video information (new) + video_elem = prog.find('video') + if video_elem is not None: + video_info = {} + for video_attr in ['present', 'colour', 'aspect', 'quality']: + attr_elem = video_elem.find(video_attr) + if attr_elem is not None and attr_elem.text: + video_info[video_attr] = attr_elem.text.strip() + if video_info: + custom_props['video'] = video_info + + # Extract audio information (new) + audio_elem = prog.find('audio') + if audio_elem is not None: + audio_info = {} + for audio_attr in ['present', 'stereo']: + attr_elem = audio_elem.find(audio_attr) + if attr_elem is not None and attr_elem.text: + audio_info[audio_attr] = attr_elem.text.strip() + if audio_info: + custom_props['audio'] = audio_info + + # Extract subtitles information (new) + subtitles = [] + for subtitle_elem in prog.findall('subtitles'): + subtitle_data = {} + if subtitle_elem.get('type'): + subtitle_data['type'] = subtitle_elem.get('type') + lang_elem = subtitle_elem.find('language') + if lang_elem is not None and lang_elem.text: + subtitle_data['language'] = lang_elem.text.strip() + if subtitle_data: + subtitles.append(subtitle_data) + + if subtitles: + custom_props['subtitles'] = subtitles + + # Extract reviews (new) + reviews = [] + for review_elem in prog.findall('review'): + if review_elem.text and review_elem.text.strip(): + review_data = {'content': review_elem.text.strip()} + if review_elem.get('type'): + review_data['type'] = review_elem.get('type') + if review_elem.get('source'): + review_data['source'] = review_elem.get('source') + if review_elem.get('reviewer'): + review_data['reviewer'] = review_elem.get('reviewer') + reviews.append(review_data) + if reviews: + custom_props['reviews'] = reviews + + # Extract images (new) + images = [] + for image_elem in prog.findall('image'): + if image_elem.text and image_elem.text.strip(): + image_data = {'url': image_elem.text.strip()} + for attr in ['type', 'size', 'orient', 'system']: + if image_elem.get(attr): + image_data[attr] = image_elem.get(attr) + images.append(image_data) + if images: + custom_props['images'] = images + + icon_elem = prog.find('icon') + if icon_elem is not None and icon_elem.get('src'): + custom_props['icon'] = icon_elem.get('src') + + # Simpler approach for boolean flags - expanded list + for kw in ['previously-shown', 'premiere', 'new', 'live', 'last-chance']: + if prog.find(kw) is not None: + custom_props[kw.replace('-', '_')] = True + + # Extract premiere and last-chance text content if available + premiere_elem = prog.find('premiere') + if premiere_elem is not None: + custom_props['premiere'] = True + if premiere_elem.text and premiere_elem.text.strip(): + custom_props['premiere_text'] = premiere_elem.text.strip() + + last_chance_elem = prog.find('last-chance') + if last_chance_elem is not None: + custom_props['last_chance'] = True + if last_chance_elem.text and last_chance_elem.text.strip(): + custom_props['last_chance_text'] = last_chance_elem.text.strip() + + # Extract previously-shown details + prev_shown_elem = prog.find('previously-shown') + if prev_shown_elem is not None: + custom_props['previously_shown'] = True + prev_shown_data = {} + if prev_shown_elem.get('start'): + prev_shown_data['start'] = prev_shown_elem.get('start') + if prev_shown_elem.get('channel'): + prev_shown_data['channel'] = prev_shown_elem.get('channel') + if prev_shown_data: + custom_props['previously_shown_details'] = prev_shown_data + + return custom_props + + +def clear_element(elem): + """Clear an XML element and its parent to free memory.""" + try: + elem.clear() + parent = elem.getparent() + if parent is not None: + while elem.getprevious() is not None: + del parent[0] + parent.remove(elem) + except Exception as e: + logger.warning(f"Error clearing XML element: {e}", exc_info=True) + + +def detect_file_format(file_path=None, content=None): + """ + Detect file format by examining content or file path. + + Args: + file_path: Path to file (optional) + content: Raw file content bytes (optional) + + Returns: + tuple: (format_type, is_compressed, file_extension) + format_type: 'gzip', 'zip', 'xml', or 'unknown' + is_compressed: Boolean indicating if the file is compressed + file_extension: Appropriate file extension including dot (.gz, .zip, .xml) + """ + # Default return values + format_type = 'unknown' + is_compressed = False + file_extension = '.tmp' + + # First priority: check content magic numbers as they're most reliable + if content: + # We only need the first few bytes for magic number detection + header = content[:20] if len(content) >= 20 else content + + # Check for gzip magic number (1f 8b) + if len(header) >= 2 and header[:2] == b'\x1f\x8b': + return 'gzip', True, '.gz' + + # Check for zip magic number (PK..) + if len(header) >= 2 and header[:2] == b'PK': + return 'zip', True, '.zip' + + # Check for XML - either standard XML header or XMLTV-specific tag + if len(header) >= 5 and (b'' in header): + return 'xml', False, '.xml' + + # Second priority: check file extension - focus on the final extension for compression + if file_path: + logger.debug(f"Detecting file format for: {file_path}") + + # Handle compound extensions like .xml.gz - prioritize compression extensions + lower_path = file_path.lower() + + # Check for compression extensions explicitly + if lower_path.endswith('.gz') or lower_path.endswith('.gzip'): + return 'gzip', True, '.gz' + elif lower_path.endswith('.zip'): + return 'zip', True, '.zip' + elif lower_path.endswith('.xml'): + return 'xml', False, '.xml' + + # Fallback to mimetypes only if direct extension check doesn't work + import mimetypes + mime_type, _ = mimetypes.guess_type(file_path) + logger.debug(f"Guessed MIME type: {mime_type}") + if mime_type: + if mime_type == 'application/gzip' or mime_type == 'application/x-gzip': + return 'gzip', True, '.gz' + elif mime_type == 'application/zip': + return 'zip', True, '.zip' + elif mime_type == 'application/xml' or mime_type == 'text/xml': + return 'xml', False, '.xml' + + # 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 diff --git a/apps/epg/tests/__init__.py b/apps/epg/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/epg/tests/test_entity_resolution.py b/apps/epg/tests/test_entity_resolution.py new file mode 100644 index 0000000..353d9b5 --- /dev/null +++ b/apps/epg/tests/test_entity_resolution.py @@ -0,0 +1,195 @@ +import os +import tempfile + +from django.test import TestCase + +from apps.epg.tasks import ( + _NAMED_ENTITY_RE, + _detect_xml_encoding, + _replace_html_entity, + _resolve_html_entities, +) + + +class ReplaceHtmlEntityTests(TestCase): + """Tests for the regex callback that resolves individual HTML entities.""" + + def _sub(self, text): + return _NAMED_ENTITY_RE.sub(_replace_html_entity, text) + + def test_french_accented(self): + self.assertEqual(self._sub("Chaîne Télé"), "Chaîne Télé") + + def test_german_umlauts(self): + self.assertEqual(self._sub("München Übersicht ß"), "München Übersicht ß") + + def test_spanish(self): + self.assertEqual(self._sub("España ¿Qué?"), "España ¿Qué?") + + def test_portuguese(self): + self.assertEqual(self._sub("Comunicação"), "Comunicação") + + def test_scandinavian(self): + self.assertEqual(self._sub("Norsk ø å æ"), "Norsk ø å æ") + + def test_greek_letters(self): + self.assertEqual(self._sub("αβγ"), "αβγ") + + def test_currency_and_symbols(self): + self.assertEqual(self._sub("© € £ ¥"), "© € £ ¥") + + def test_preserves_xml_amp(self): + self.assertEqual(self._sub("A & B"), "A & B") + + def test_preserves_xml_lt_gt(self): + self.assertEqual(self._sub("<tag>"), "<tag>") + + def test_preserves_xml_quot_apos(self): + self.assertEqual(self._sub(""hello'"), ""hello'") + + def test_preserves_uppercase_xml_entities(self): + """&, <, >, " resolve to XML-special chars; must not be replaced.""" + self.assertEqual(self._sub("&"), "&") + self.assertEqual(self._sub("<"), "<") + self.assertEqual(self._sub(">"), ">") + self.assertEqual(self._sub("""), """) + + def test_partial_entity_match_preserved(self): + """html.unescape can partially match & inside &ersand; — must not corrupt.""" + self.assertEqual(self._sub("&ersand;"), "&ersand;") + + def test_mixed_html_and_xml_entities(self): + self.assertEqual( + self._sub("Résumé & Co <test>"), + "Résumé & Co <test>", + ) + + def test_plain_ascii_unchanged(self): + self.assertEqual(self._sub("Plain ASCII text"), "Plain ASCII text") + + def test_direct_utf8_unchanged(self): + self.assertEqual(self._sub("日本語テレビ"), "日本語テレビ") + + def test_unknown_entity_preserved(self): + self.assertEqual(self._sub("&zzfakeentity;"), "&zzfakeentity;") + + +class ResolveHtmlEntitiesFileTests(TestCase): + """Tests for the file-level preprocessing function.""" + + def _make_file(self, content): + fd, path = tempfile.mkstemp(suffix=".xml") + with os.fdopen(fd, "w", encoding="utf-8") as f: + f.write(content) + self.addCleanup(lambda: os.unlink(path) if os.path.exists(path) else None) + return path + + def test_resolves_entities_in_file(self): + path = self._make_file( + '\nTélé' + ) + _resolve_html_entities(path) + with open(path, "r", encoding="utf-8") as f: + content = f.read() + self.assertIn("Télé", content) + self.assertNotIn("é", content) + + def test_preserves_xml_entities_in_file(self): + path = self._make_file("A & B <C>") + _resolve_html_entities(path) + with open(path, "r", encoding="utf-8") as f: + content = f.read() + self.assertIn("&", content) + self.assertIn("<", content) + self.assertIn(">", content) + + def test_no_temp_file_left_on_success(self): + path = self._make_file("test") + _resolve_html_entities(path) + self.assertFalse(os.path.exists(path + ".entity_tmp")) + + def test_plain_file_unchanged(self): + original = '\nPlain' + path = self._make_file(original) + _resolve_html_entities(path) + with open(path, "r", encoding="utf-8") as f: + content = f.read() + self.assertEqual(content, original) + + def test_utf8_content_preserved(self): + original = "日本語テレビ" + path = self._make_file(original) + _resolve_html_entities(path) + with open(path, "r", encoding="utf-8") as f: + content = f.read() + self.assertIn("日本語テレビ", content) + + def test_iso_8859_1_encoding(self): + """Files declaring ISO-8859-1 should be read in that encoding.""" + xml = '\nChaîne' + fd, path = tempfile.mkstemp(suffix=".xml") + with os.fdopen(fd, "wb") as f: + f.write(xml.encode("iso-8859-1")) + self.addCleanup(lambda: os.unlink(path) if os.path.exists(path) else None) + + _resolve_html_entities(path) + with open(path, "r", encoding="iso-8859-1") as f: + content = f.read() + self.assertIn("Cha\u00eene", content) + self.assertNotIn("î", content) + + def test_detect_encoding_utf8_default(self): + """Headers without an encoding declaration default to UTF-8.""" + self.assertEqual(_detect_xml_encoding(b''), "utf-8") + + def test_detect_encoding_iso_8859_1(self): + """Encoding is read from the XML declaration.""" + self.assertEqual( + _detect_xml_encoding(b''), + "ISO-8859-1", + ) + + def test_detect_encoding_single_quotes(self): + """Encoding detection works with single-quoted attributes.""" + self.assertEqual( + _detect_xml_encoding(b""), + "windows-1252", + ) + + def test_detect_encoding_unknown_falls_back(self): + """Unrecognized encoding falls back to UTF-8.""" + self.assertEqual( + _detect_xml_encoding(b''), + "utf-8", + ) + + def test_iso_8859_1_with_entities_roundtrip(self): + """ISO-8859-1 file with entities: resolved without corrupting existing accented chars.""" + # Mix of direct ISO-8859-1 chars and HTML entities + xml_str = '\nD\xe9j\xe0 émission' + fd, path = tempfile.mkstemp(suffix=".xml") + with os.fdopen(fd, "wb") as f: + f.write(xml_str.encode("iso-8859-1")) + self.addCleanup(lambda: os.unlink(path) if os.path.exists(path) else None) + + _resolve_html_entities(path) + with open(path, "r", encoding="iso-8859-1") as f: + content = f.read() + self.assertIn("D\xe9j\xe0", content, "Existing accented chars should be preserved") + self.assertIn("\xe9mission", content, "Entity should be resolved") + self.assertNotIn("é", content) + + def test_mismatched_encoding_leaves_file_untouched(self): + """File declaring UTF-8 but containing Latin-1 bytes is left alone.""" + # \xe9 is valid ISO-8859-1 but invalid as a standalone UTF-8 byte + raw = b'\n\xe9' + fd, path = tempfile.mkstemp(suffix=".xml") + with os.fdopen(fd, "wb") as f: + f.write(raw) + self.addCleanup(lambda: os.unlink(path) if os.path.exists(path) else None) + + original_bytes = raw # save for comparison + _resolve_html_entities(path) + with open(path, "rb") as f: + result_bytes = f.read() + self.assertEqual(result_bytes, original_bytes, "File should be untouched on decode error") diff --git a/apps/epg/tests/test_serializers.py b/apps/epg/tests/test_serializers.py new file mode 100644 index 0000000..1204619 --- /dev/null +++ b/apps/epg/tests/test_serializers.py @@ -0,0 +1,655 @@ +from django.test import TestCase +from django.utils import timezone +from apps.epg.models import EPGData, EPGSource, ProgramData +from apps.epg.serializers import ProgramDataSerializer, ProgramDetailSerializer +from apps.epg.utils import extract_season_episode, extract_season_episode_from_description + + +class ProgramDataSerializerTests(TestCase): + """Tests for ProgramDataSerializer season/episode extraction from custom_properties.""" + + def setUp(self): + self.epg_source = EPGSource.objects.create( + name="Test Source", source_type="xmltv" + ) + self.epg = EPGData.objects.create( + tvg_id="test-tvg", name="Test EPG", epg_source=self.epg_source + ) + self.now = timezone.now() + + def _create_program(self, **kwargs): + defaults = { + "epg": self.epg, + "start_time": self.now, + "end_time": self.now + timezone.timedelta(hours=1), + "title": "Test Program", + } + defaults.update(kwargs) + return ProgramData.objects.create(**defaults) + + def test_season_and_episode_from_custom_properties(self): + """Season and episode should be extracted from custom_properties.""" + program = self._create_program( + custom_properties={"season": 3, "episode": 5} + ) + data = ProgramDataSerializer(program).data + self.assertEqual(data["season"], 3) + self.assertEqual(data["episode"], 5) + + def test_season_only_from_custom_properties(self): + """Season should be returned even when episode is absent.""" + program = self._create_program(custom_properties={"season": 2}) + data = ProgramDataSerializer(program).data + self.assertEqual(data["season"], 2) + self.assertIsNone(data["episode"]) + + def test_episode_only_from_custom_properties(self): + """Episode should be returned even when season is absent.""" + program = self._create_program(custom_properties={"episode": 10}) + data = ProgramDataSerializer(program).data + self.assertIsNone(data["season"]) + self.assertEqual(data["episode"], 10) + + def test_season_episode_null_when_custom_properties_is_none(self): + """Both should be None when custom_properties is None.""" + program = self._create_program(custom_properties=None) + data = ProgramDataSerializer(program).data + self.assertIsNone(data["season"]) + self.assertIsNone(data["episode"]) + + def test_season_episode_null_when_custom_properties_is_empty(self): + """Both should be None when custom_properties is an empty dict.""" + program = self._create_program(custom_properties={}) + data = ProgramDataSerializer(program).data + self.assertIsNone(data["season"]) + self.assertIsNone(data["episode"]) + + def test_season_episode_null_when_keys_absent(self): + """Both should be None when custom_properties has other keys but no season/episode.""" + program = self._create_program( + custom_properties={"categories": ["Drama"], "rating": "TV-14"} + ) + data = ProgramDataSerializer(program).data + self.assertIsNone(data["season"]) + self.assertIsNone(data["episode"]) + + def test_sub_title_included_in_serialized_data(self): + """sub_title field should be present in serialized output.""" + program = self._create_program(sub_title="The Pilot") + data = ProgramDataSerializer(program).data + self.assertEqual(data["sub_title"], "The Pilot") + + def test_sub_title_null_when_not_set(self): + """sub_title should be None when not set.""" + program = self._create_program() + data = ProgramDataSerializer(program).data + self.assertIsNone(data["sub_title"]) + + def test_all_expected_fields_present(self): + """Serialized output should contain all expected fields.""" + program = self._create_program( + sub_title="Episode Title", + custom_properties={"season": 1, "episode": 1}, + ) + data = ProgramDataSerializer(program).data + expected_fields = { + "id", "start_time", "end_time", "title", "sub_title", + "description", "tvg_id", "season", "episode", + "is_new", "is_live", "is_premiere", "is_finale", + } + self.assertEqual(set(data.keys()), expected_fields) + + def test_season_episode_from_onscreen_episode(self): + """Season and episode should be parsed from onscreen_episode string.""" + program = self._create_program( + custom_properties={"onscreen_episode": "S12 E6"} + ) + data = ProgramDataSerializer(program).data + self.assertEqual(data["season"], 12) + self.assertEqual(data["episode"], 6) + + def test_onscreen_episode_no_space(self): + """Should parse onscreen_episode without space between S and E.""" + program = self._create_program( + custom_properties={"onscreen_episode": "S3E21"} + ) + data = ProgramDataSerializer(program).data + self.assertEqual(data["season"], 3) + self.assertEqual(data["episode"], 21) + + def test_onscreen_episode_with_part(self): + """Should parse season/episode even when part info follows.""" + program = self._create_program( + custom_properties={"onscreen_episode": "S8 E8 P2/2"} + ) + data = ProgramDataSerializer(program).data + self.assertEqual(data["season"], 8) + self.assertEqual(data["episode"], 8) + + def test_direct_season_episode_takes_priority_over_onscreen(self): + """Direct season/episode keys should take priority over onscreen parsing.""" + program = self._create_program( + custom_properties={ + "season": 1, "episode": 2, + "onscreen_episode": "S99 E99", + } + ) + data = ProgramDataSerializer(program).data + self.assertEqual(data["season"], 1) + self.assertEqual(data["episode"], 2) + + def test_onscreen_episode_invalid_format(self): + """Should return None for onscreen_episode that doesn't match S/E pattern.""" + program = self._create_program( + custom_properties={"onscreen_episode": "Episode 5"} + ) + data = ProgramDataSerializer(program).data + self.assertIsNone(data["season"]) + self.assertIsNone(data["episode"]) + + def test_bulk_serialization_with_mixed_data(self): + """Serializer should handle a mix of programs with and without metadata.""" + p1 = self._create_program( + title="Show A", + sub_title="Ep 1", + custom_properties={"season": 1, "episode": 1}, + ) + p2 = self._create_program( + title="Movie B", + custom_properties=None, + ) + p3 = self._create_program( + title="Show C", + custom_properties={}, + ) + data = ProgramDataSerializer([p1, p2, p3], many=True).data + self.assertEqual(len(data), 3) + self.assertEqual(data[0]["season"], 1) + self.assertEqual(data[0]["episode"], 1) + self.assertIsNone(data[1]["season"]) + self.assertIsNone(data[1]["episode"]) + self.assertIsNone(data[2]["season"]) + self.assertIsNone(data[2]["episode"]) + + def test_is_new_true_when_flag_set(self): + """is_new should be True when custom_properties has 'new' flag.""" + program = self._create_program(custom_properties={"new": True}) + data = ProgramDataSerializer(program).data + self.assertTrue(data["is_new"]) + + def test_is_live_true_when_flag_set(self): + """is_live should be True when custom_properties has 'live' flag.""" + program = self._create_program(custom_properties={"live": True}) + data = ProgramDataSerializer(program).data + self.assertTrue(data["is_live"]) + + def test_is_premiere_true_when_flag_set(self): + """is_premiere should be True when custom_properties has 'premiere' flag.""" + program = self._create_program(custom_properties={"premiere": True}) + data = ProgramDataSerializer(program).data + self.assertTrue(data["is_premiere"]) + + def test_flags_false_when_not_set(self): + """All boolean flags should be False when not in custom_properties.""" + program = self._create_program(custom_properties={"season": 1}) + data = ProgramDataSerializer(program).data + self.assertFalse(data["is_new"]) + self.assertFalse(data["is_live"]) + self.assertFalse(data["is_premiere"]) + + def test_flags_false_when_custom_properties_none(self): + """All boolean flags should be False when custom_properties is None.""" + program = self._create_program(custom_properties=None) + data = ProgramDataSerializer(program).data + self.assertFalse(data["is_new"]) + self.assertFalse(data["is_live"]) + self.assertFalse(data["is_premiere"]) + + def test_flags_false_when_custom_properties_empty(self): + """All boolean flags should be False when custom_properties is empty.""" + program = self._create_program(custom_properties={}) + data = ProgramDataSerializer(program).data + self.assertFalse(data["is_new"]) + self.assertFalse(data["is_live"]) + self.assertFalse(data["is_premiere"]) + + def test_multiple_flags_set(self): + """Multiple flags can be true simultaneously.""" + program = self._create_program( + custom_properties={"new": True, "live": True, "premiere": True} + ) + data = ProgramDataSerializer(program).data + self.assertTrue(data["is_new"]) + self.assertTrue(data["is_live"]) + self.assertTrue(data["is_premiere"]) + + def test_flags_with_season_episode(self): + """Flags should work alongside season/episode data.""" + program = self._create_program( + custom_properties={"season": 5, "episode": 1, "new": True, "premiere": True} + ) + data = ProgramDataSerializer(program).data + self.assertEqual(data["season"], 5) + self.assertEqual(data["episode"], 1) + self.assertTrue(data["is_new"]) + self.assertFalse(data["is_live"]) + self.assertTrue(data["is_premiere"]) + + def test_is_finale_from_premiere_text_season_finale(self): + """is_finale should be True when premiere_text contains 'Season Finale'.""" + program = self._create_program( + custom_properties={"premiere": True, "premiere_text": "Season Finale"} + ) + data = ProgramDataSerializer(program).data + self.assertTrue(data["is_finale"]) + + def test_is_finale_from_premiere_text_series_finale(self): + """is_finale should be True when premiere_text contains 'Series Finale'.""" + program = self._create_program( + custom_properties={"premiere": True, "premiere_text": "Series Finale"} + ) + data = ProgramDataSerializer(program).data + self.assertTrue(data["is_finale"]) + + def test_is_finale_case_insensitive(self): + """is_finale detection should be case-insensitive.""" + program = self._create_program( + custom_properties={"premiere": True, "premiere_text": "SEASON FINALE"} + ) + data = ProgramDataSerializer(program).data + self.assertTrue(data["is_finale"]) + + def test_is_finale_false_for_premiere_text(self): + """is_finale should be False when premiere_text is 'Season Premiere'.""" + program = self._create_program( + custom_properties={"premiere": True, "premiere_text": "Season Premiere"} + ) + data = ProgramDataSerializer(program).data + self.assertFalse(data["is_finale"]) + + def test_is_finale_false_when_no_premiere_text(self): + """is_finale should be False when premiere_text is absent.""" + program = self._create_program( + custom_properties={"premiere": True} + ) + data = ProgramDataSerializer(program).data + self.assertFalse(data["is_finale"]) + + def test_is_finale_false_when_custom_properties_none(self): + """is_finale should be False when custom_properties is None.""" + program = self._create_program(custom_properties=None) + data = ProgramDataSerializer(program).data + self.assertFalse(data["is_finale"]) + + +class ExtractSeasonEpisodeHelperTests(TestCase): + """Tests for the shared extract_season_episode helper function.""" + + def test_both_present(self): + season, episode = extract_season_episode({"season": 3, "episode": 5}) + self.assertEqual(season, 3) + self.assertEqual(episode, 5) + + def test_fallback_to_onscreen(self): + season, episode = extract_season_episode({"onscreen_episode": "S12 E6"}) + self.assertEqual(season, 12) + self.assertEqual(episode, 6) + + def test_direct_values_override_onscreen(self): + season, episode = extract_season_episode({ + "season": 1, "episode": 2, "onscreen_episode": "S99 E99" + }) + self.assertEqual(season, 1) + self.assertEqual(episode, 2) + + def test_empty_dict(self): + season, episode = extract_season_episode({}) + self.assertIsNone(season) + self.assertIsNone(episode) + + def test_partial_with_onscreen_fill(self): + """Direct season + onscreen episode fills the gap.""" + season, episode = extract_season_episode({ + "season": 5, "onscreen_episode": "S5E10" + }) + self.assertEqual(season, 5) + self.assertEqual(episode, 10) + + def test_description_fallback_s_e_format(self): + """S01E01 in description should be used as third-tier fallback.""" + season, episode = extract_season_episode({}, description="S2 E31 The Episode Title") + self.assertEqual(season, 2) + self.assertEqual(episode, 31) + + def test_description_fallback_season_episode_format(self): + season, episode = extract_season_episode({}, description="Season 3 Episode 12 Some Title") + self.assertEqual(season, 3) + self.assertEqual(episode, 12) + + def test_description_fallback_nxnn_format(self): + season, episode = extract_season_episode({}, description="5x03 Episode Name") + self.assertEqual(season, 5) + self.assertEqual(episode, 3) + + def test_description_not_used_when_cp_has_both(self): + """Description fallback should not override existing custom_properties values.""" + season, episode = extract_season_episode( + {"season": 1, "episode": 2}, description="S99 E99 Fake" + ) + self.assertEqual(season, 1) + self.assertEqual(episode, 2) + + def test_description_not_used_when_onscreen_provides_both(self): + season, episode = extract_season_episode( + {"onscreen_episode": "S3E5"}, description="S99 E99 Fake" + ) + self.assertEqual(season, 3) + self.assertEqual(episode, 5) + + def test_description_fills_gap_after_partial_onscreen(self): + """If onscreen provides only season, description can fill episode.""" + # onscreen_episode "S5" doesn't match the S/E pattern, so no values from onscreen + # description provides both + season, episode = extract_season_episode( + {"season": 5}, description="S5 E10 Title" + ) + self.assertEqual(season, 5) + self.assertEqual(episode, 10) + + def test_description_none_is_safe(self): + season, episode = extract_season_episode({}, description=None) + self.assertIsNone(season) + self.assertIsNone(episode) + + def test_description_empty_string_is_safe(self): + season, episode = extract_season_episode({}, description="") + self.assertIsNone(season) + self.assertIsNone(episode) + + +class ExtractSeasonEpisodeFromDescriptionTests(TestCase): + """Tests for extract_season_episode_from_description() in tasks.py.""" + + def test_s_e_compact(self): + s, e, cleaned = extract_season_episode_from_description("S2E31 The Kevin Episode") + self.assertEqual(s, 2) + self.assertEqual(e, 31) + self.assertEqual(cleaned, "The Kevin Episode") + + def test_s_e_with_space(self): + s, e, cleaned = extract_season_episode_from_description("S2 E31 The Kevin Episode") + self.assertEqual(s, 2) + self.assertEqual(e, 31) + self.assertEqual(cleaned, "The Kevin Episode") + + def test_season_episode_words(self): + s, e, cleaned = extract_season_episode_from_description("Season 3 Episode 12 Title Here") + self.assertEqual(s, 3) + self.assertEqual(e, 12) + self.assertEqual(cleaned, "Title Here") + + def test_nxnn_format(self): + s, e, cleaned = extract_season_episode_from_description("5x03 Episode Name") + self.assertEqual(s, 5) + self.assertEqual(e, 3) + self.assertEqual(cleaned, "Episode Name") + + def test_leading_dash(self): + s, e, cleaned = extract_season_episode_from_description("- S1E5 Title") + self.assertEqual(s, 1) + self.assertEqual(e, 5) + self.assertEqual(cleaned, "Title") + + def test_case_insensitive(self): + s, e, cleaned = extract_season_episode_from_description("s10e20 Lower Case") + self.assertEqual(s, 10) + self.assertEqual(e, 20) + self.assertEqual(cleaned, "Lower Case") + + def test_no_match_returns_original(self): + s, e, cleaned = extract_season_episode_from_description("Just a normal description") + self.assertIsNone(s) + self.assertIsNone(e) + self.assertEqual(cleaned, "Just a normal description") + + def test_none_input(self): + s, e, cleaned = extract_season_episode_from_description(None) + self.assertIsNone(s) + self.assertIsNone(e) + self.assertIsNone(cleaned) + + def test_empty_string(self): + s, e, cleaned = extract_season_episode_from_description("") + self.assertIsNone(s) + self.assertIsNone(e) + self.assertEqual(cleaned, "") + + def test_mid_string_s_e_not_matched(self): + """S/E in middle of description should NOT be matched (anchored to start).""" + s, e, cleaned = extract_season_episode_from_description("Some intro text S1E5 title") + self.assertIsNone(s) + self.assertIsNone(e) + self.assertEqual(cleaned, "Some intro text S1E5 title") + + +class ProgramDataSerializerDescriptionFallbackTests(TestCase): + """Integration tests: serializer uses description fallback for S/E.""" + + def setUp(self): + self.epg_source = EPGSource.objects.create( + name="Test Source", source_type="xmltv" + ) + self.epg = EPGData.objects.create( + tvg_id="test-tvg", name="Test EPG", epg_source=self.epg_source + ) + self.now = timezone.now() + + def _create_program(self, **kwargs): + defaults = { + "epg": self.epg, + "start_time": self.now, + "end_time": self.now + timezone.timedelta(hours=1), + "title": "Test Program", + } + defaults.update(kwargs) + return ProgramData.objects.create(**defaults) + + def test_se_from_description_when_no_cp(self): + program = self._create_program( + custom_properties={}, + description="S2 E5 The Episode Title", + ) + data = ProgramDataSerializer(program).data + self.assertEqual(data["season"], 2) + self.assertEqual(data["episode"], 5) + + def test_se_from_description_not_used_when_cp_has_values(self): + program = self._create_program( + custom_properties={"season": 1, "episode": 1}, + description="S99 E99 Fake", + ) + data = ProgramDataSerializer(program).data + self.assertEqual(data["season"], 1) + self.assertEqual(data["episode"], 1) + + +class ProgramDetailSerializerTests(TestCase): + """Tests for ProgramDetailSerializer — rich field extraction from custom_properties.""" + + def setUp(self): + self.epg_source = EPGSource.objects.create( + name="Test Source", source_type="xmltv" + ) + self.epg = EPGData.objects.create( + tvg_id="test-tvg", name="Test EPG", epg_source=self.epg_source + ) + self.now = timezone.now() + + def _create_program(self, **kwargs): + defaults = { + "epg": self.epg, + "start_time": self.now, + "end_time": self.now + timezone.timedelta(hours=1), + "title": "Test Program", + } + defaults.update(kwargs) + return ProgramData.objects.create(**defaults) + + def test_all_detail_fields_present(self): + """Detail serializer should include all expected fields.""" + program = self._create_program(custom_properties={"season": 1, "episode": 1}) + data = ProgramDetailSerializer(program).data + expected_fields = { + "id", "start_time", "end_time", "title", "sub_title", "description", "tvg_id", + "season", "episode", "is_new", "is_live", "is_premiere", "is_finale", + "categories", "rating", "rating_system", "star_ratings", + "credits", "video_quality", "aspect_ratio", "stereo", "is_previously_shown", + "country", "language", "production_date", "original_air_date", + "imdb_id", "tmdb_id", "tvdb_id", "icon", "images", + } + self.assertEqual(set(data.keys()), expected_fields) + + def test_categories_extraction(self): + program = self._create_program( + custom_properties={"categories": ["Drama", "Thriller"]} + ) + data = ProgramDetailSerializer(program).data + self.assertEqual(data["categories"], ["Drama", "Thriller"]) + + def test_categories_empty_when_absent(self): + program = self._create_program(custom_properties={}) + data = ProgramDetailSerializer(program).data + self.assertEqual(data["categories"], []) + + def test_rating_extraction(self): + program = self._create_program( + custom_properties={"rating": "TV-14", "rating_system": "VCHIP"} + ) + data = ProgramDetailSerializer(program).data + self.assertEqual(data["rating"], "TV-14") + self.assertEqual(data["rating_system"], "VCHIP") + + def test_star_ratings_extraction(self): + program = self._create_program( + custom_properties={"star_ratings": [{"value": "8.5/10", "system": "IMDB"}]} + ) + data = ProgramDetailSerializer(program).data + self.assertEqual(len(data["star_ratings"]), 1) + self.assertEqual(data["star_ratings"][0]["value"], "8.5/10") + + def test_credits_extraction(self): + program = self._create_program( + custom_properties={ + "credits": { + "actor": [{"name": "Bryan Cranston", "role": "Walter White"}], + "director": ["Rian Johnson"], + "writer": ["Moira Walley-Beckett"], + } + } + ) + data = ProgramDetailSerializer(program).data + self.assertEqual(len(data["credits"]["actors"]), 1) + self.assertEqual(data["credits"]["actors"][0]["name"], "Bryan Cranston") + self.assertEqual(data["credits"]["directors"], ["Rian Johnson"]) + self.assertEqual(data["credits"]["writers"], ["Moira Walley-Beckett"]) + + def test_credits_empty_when_absent(self): + program = self._create_program(custom_properties={}) + data = ProgramDetailSerializer(program).data + self.assertEqual(data["credits"]["actors"], []) + self.assertEqual(data["credits"]["directors"], []) + + def test_video_quality_extraction(self): + program = self._create_program( + custom_properties={"video": {"quality": "HDTV", "aspect": "16:9"}} + ) + data = ProgramDetailSerializer(program).data + self.assertEqual(data["video_quality"], "HDTV") + self.assertEqual(data["aspect_ratio"], "16:9") + + def test_audio_extraction(self): + program = self._create_program( + custom_properties={"audio": {"stereo": "Dolby Digital"}} + ) + data = ProgramDetailSerializer(program).data + self.assertEqual(data["stereo"], "Dolby Digital") + + def test_geographic_fields(self): + program = self._create_program( + custom_properties={"country": "US", "language": "en"} + ) + data = ProgramDetailSerializer(program).data + self.assertEqual(data["country"], "US") + self.assertEqual(data["language"], "en") + + def test_original_air_date(self): + program = self._create_program( + custom_properties={ + "previously_shown_details": {"start": "2013-09-15"} + } + ) + data = ProgramDetailSerializer(program).data + self.assertEqual(data["original_air_date"], "2013-09-15") + + def test_external_ids(self): + program = self._create_program( + custom_properties={ + "imdb.com_id": "tt0903747", + "themoviedb.org_id": "1396", + "thetvdb.com_id": "81189", + } + ) + data = ProgramDetailSerializer(program).data + self.assertEqual(data["imdb_id"], "tt0903747") + self.assertEqual(data["tmdb_id"], "1396") + self.assertEqual(data["tvdb_id"], "81189") + + def test_images_extraction(self): + program = self._create_program( + custom_properties={ + "icon": "https://example.com/icon.png", + "images": [{"url": "https://example.com/poster.jpg", "type": "poster"}], + } + ) + data = ProgramDetailSerializer(program).data + self.assertEqual(data["icon"], "https://example.com/icon.png") + self.assertEqual(len(data["images"]), 1) + + def test_null_custom_properties_returns_safe_defaults(self): + """All enriched fields should be null/empty when custom_properties is None.""" + program = self._create_program(custom_properties=None) + data = ProgramDetailSerializer(program).data + self.assertIsNone(data["season"]) + self.assertIsNone(data["episode"]) + self.assertEqual(data["categories"], []) + self.assertIsNone(data["rating"]) + self.assertEqual(data["star_ratings"], []) + self.assertEqual(data["credits"]["actors"], []) + self.assertIsNone(data["video_quality"]) + self.assertIsNone(data["country"]) + self.assertIsNone(data["imdb_id"]) + self.assertEqual(data["images"], []) + + def test_season_episode_uses_shared_helper(self): + """Detail serializer should use the same onscreen_episode fallback.""" + program = self._create_program( + custom_properties={"onscreen_episode": "S5E14"} + ) + data = ProgramDetailSerializer(program).data + self.assertEqual(data["season"], 5) + self.assertEqual(data["episode"], 14) + + def test_status_flags_match_slim_serializer(self): + """Status flags should produce identical results as ProgramDataSerializer.""" + program = self._create_program( + custom_properties={ + "new": True, "live": True, "premiere": True, + "premiere_text": "Season Finale", + } + ) + slim = ProgramDataSerializer(program).data + detail = ProgramDetailSerializer(program).data + self.assertEqual(slim["is_new"], detail["is_new"]) + self.assertEqual(slim["is_live"], detail["is_live"]) + self.assertEqual(slim["is_premiere"], detail["is_premiere"]) + self.assertEqual(slim["is_finale"], detail["is_finale"]) diff --git a/apps/epg/urls.py b/apps/epg/urls.py new file mode 100644 index 0000000..cbf8bbe --- /dev/null +++ b/apps/epg/urls.py @@ -0,0 +1,9 @@ +from django.urls import path +from .views import EPGDashboardView, epg_view + +app_name = 'epg_dashboard' + +urlpatterns = [ + path('dashboard/', EPGDashboardView.as_view(), name='epg_dashboard'), + path('guide/', epg_view, name='epg_guide'), +] diff --git a/apps/epg/utils.py b/apps/epg/utils.py new file mode 100644 index 0000000..db63a48 --- /dev/null +++ b/apps/epg/utils.py @@ -0,0 +1,61 @@ +""" +Shared EPG utilities — season/episode extraction. + +These live here (rather than in serializers.py or tasks.py) to avoid circular imports: +serializers → tasks and channels/tasks → serializers both need these functions. +""" + +import re + +# Matches patterns like "S12 E6", "S3E21", "S8 E8 P2/2" +_ONSCREEN_RE = re.compile(r'S(\d+)\s*E(\d+)', re.IGNORECASE) + +# Ordered patterns for extracting season/episode from the start of description text. +# Only used as a fallback when XML elements don't provide S/E. +_DESC_SE_PATTERNS = [ + # S01E01, S01 E01, S1E1, S1 E1 + re.compile(r'^[\s\-:]*S(\d+)\s*E(\d+)[\s\-:.]*', re.IGNORECASE), + # Season 1 Episode 1, Season1 Episode1, Season1Episode1 + re.compile(r'^[\s\-:]*Season\s*(\d+)\s*Episode\s*(\d+)[\s\-:.]*', re.IGNORECASE), + # 1x01 format (requires 2+ digit episode to avoid false positives) + re.compile(r'^[\s\-:]*(\d+)x(\d{2,})[\s\-:.]*'), +] + + +def extract_season_episode_from_description(desc): + """ + Extract season/episode from the beginning of description text. + Returns (season, episode, cleaned_desc). + Returns (None, None, desc) if no pattern matches. + """ + if not desc: + return None, None, desc + for pattern in _DESC_SE_PATTERNS: + match = pattern.match(desc) + if match: + season = int(match.group(1)) + episode = int(match.group(2)) + cleaned = desc[match.end():].strip() + return season, episode, cleaned + return None, None, desc + + +def extract_season_episode(cp, description=None): + """Extract season/episode from custom_properties with onscreen_episode and description fallbacks.""" + season = cp.get('season') + episode = cp.get('episode') + if (season is None or episode is None) and cp.get('onscreen_episode'): + match = _ONSCREEN_RE.search(cp['onscreen_episode']) + if match: + if season is None: + season = int(match.group(1)) + if episode is None: + episode = int(match.group(2)) + # Third fallback: extract S/E from description text + if (season is None or episode is None) and description: + d_season, d_episode, _ = extract_season_episode_from_description(description) + if season is None: + season = d_season + if episode is None: + episode = d_episode + return season, episode diff --git a/apps/epg/views.py b/apps/epg/views.py new file mode 100644 index 0000000..82ddb81 --- /dev/null +++ b/apps/epg/views.py @@ -0,0 +1,66 @@ +from django.views import View +from django.shortcuts import render +from django.http import JsonResponse +from rest_framework.parsers import JSONParser +from .models import EPGSource, ProgramData # Updated: import ProgramData instead of Program +from .serializers import EPGSourceSerializer +from django.utils import timezone +from datetime import timedelta + + +def epg_view(request): + """ + Renders the TV guide using programmes from the next 12 hours, + grouped by channel (via EPGData). + """ + now = timezone.now() + end_time = now + timedelta(hours=12) + print(f"[EPG VIEW] Now: {now} | End Time: {end_time}") + + # Query ProgramData within the time range + programmes = ProgramData.objects.filter( + start_time__gte=now, + start_time__lte=end_time + ).order_by('start_time') + print(f"[EPG VIEW] Found {programmes.count()} programme(s) between now and end_time.") + + # Group programmes by channel (retrieved via the EPGData parent) + channels = {} + for prog in programmes: + # Assume that the EPGData instance (prog.epg) has a link to a Channel. + channel = prog.epg.channel if prog.epg and prog.epg.channel else None + if not channel: + continue + channels.setdefault(channel, []).append(prog) + + if not channels: + print("[EPG VIEW] No channels with programmes found.") + else: + for channel, progs in channels.items(): + print(f"[EPG VIEW] Channel: {channel} has {len(progs)} programme(s).") + + context = { + 'channels': channels, + 'now': now, + 'end_time': end_time, + } + return render(request, 'epg/tvguide.html', context) + + +class EPGDashboardView(View): + def get(self, request, *args, **kwargs): + if request.headers.get('X-Requested-With') == 'XMLHttpRequest': + sources = EPGSource.objects.all() + serializer = EPGSourceSerializer(sources, many=True) + return JsonResponse({'data': serializer.data}, safe=False) + return render(request, 'epg/epg.html', {'epg_sources': EPGSource.objects.all()}) + + def post(self, request, *args, **kwargs): + if request.headers.get('X-Requested-With') == 'XMLHttpRequest': + data = JSONParser().parse(request) + serializer = EPGSourceSerializer(data=data) + if serializer.is_valid(): + serializer.save() + return JsonResponse({'success': True, 'data': serializer.data}, status=201) + return JsonResponse({'success': False, 'errors': serializer.errors}, status=400) + return JsonResponse({'success': False, 'error': 'Invalid request.'}, status=400) diff --git a/apps/hdhr/__init__.py b/apps/hdhr/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/hdhr/admin.py b/apps/hdhr/admin.py new file mode 100644 index 0000000..20344f6 --- /dev/null +++ b/apps/hdhr/admin.py @@ -0,0 +1,6 @@ +from django.contrib import admin +from .models import HDHRDevice + +@admin.register(HDHRDevice) +class HDHRDeviceAdmin(admin.ModelAdmin): + list_display = ('friendly_name', 'device_id', 'tuner_count') diff --git a/apps/hdhr/api_urls.py b/apps/hdhr/api_urls.py new file mode 100644 index 0000000..2a1a06e --- /dev/null +++ b/apps/hdhr/api_urls.py @@ -0,0 +1,19 @@ +from django.urls import path, include +from rest_framework.routers import DefaultRouter +from .api_views import HDHRDeviceViewSet, DiscoverAPIView, LineupAPIView, LineupStatusAPIView, HDHRDeviceXMLAPIView, hdhr_dashboard_view + +app_name = 'hdhr' + +router = DefaultRouter() +router.register(r'devices', HDHRDeviceViewSet, basename='hdhr-device') + +urlpatterns = [ + path('dashboard/', hdhr_dashboard_view, name='hdhr_dashboard'), + path('', hdhr_dashboard_view, name='hdhr_dashboard'), + path('discover.json', DiscoverAPIView.as_view(), name='discover'), + path('lineup.json', LineupAPIView.as_view(), name='lineup'), + path('lineup_status.json', LineupStatusAPIView.as_view(), name='lineup_status'), + path('device.xml', HDHRDeviceXMLAPIView.as_view(), name='device_xml'), +] + +urlpatterns += router.urls diff --git a/apps/hdhr/api_views.py b/apps/hdhr/api_views.py new file mode 100644 index 0000000..539adff --- /dev/null +++ b/apps/hdhr/api_views.py @@ -0,0 +1,182 @@ +from rest_framework import viewsets, status +from rest_framework.response import Response +from rest_framework.views import APIView +from rest_framework.permissions import AllowAny +from apps.accounts.permissions import Authenticated, permission_classes_by_action +from django.http import JsonResponse, HttpResponseForbidden, HttpResponse +import logging +from drf_spectacular.utils import extend_schema, OpenApiParameter +from drf_spectacular.types import OpenApiTypes +from django.shortcuts import get_object_or_404 +from django.db import models +from apps.channels.models import Channel, ChannelProfile, Stream +from .models import HDHRDevice +from .serializers import HDHRDeviceSerializer +from django.contrib.auth.decorators import login_required +from django.shortcuts import render +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 + +# Configure logger +logger = logging.getLogger(__name__) + + +@login_required +def hdhr_dashboard_view(request): + """Render the HDHR management page.""" + hdhr_devices = HDHRDevice.objects.all() + return render(request, "hdhr/hdhr.html", {"hdhr_devices": hdhr_devices}) + + +# 🔹 1) HDHomeRun Device API +class HDHRDeviceViewSet(viewsets.ModelViewSet): + """Handles CRUD operations for HDHomeRun devices""" + + queryset = HDHRDevice.objects.all() + serializer_class = HDHRDeviceSerializer + + def get_permissions(self): + try: + return [perm() for perm in permission_classes_by_action[self.action]] + except KeyError: + return [Authenticated()] + + +# 🔹 2) Discover API +class DiscoverAPIView(APIView): + """Returns device discovery information""" + permission_classes = [AllowAny] + + @extend_schema( + description="Retrieve HDHomeRun device discovery information", + ) + def get(self, request, profile=None): + uri_parts = ["hdhr"] + if profile is not None: + uri_parts.append(profile) + + base_url = request.build_absolute_uri(f'/{"/".join(uri_parts)}/').rstrip("/") + device = HDHRDevice.objects.first() + + # 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 + friendly_name = "Dispatcharr HDHomeRun" + if profile is not None: + device_ID = f"dispatcharr-hdhr-{profile}" + friendly_name = f"Dispatcharr HDHomeRun - {profile}" + if not device: + data = { + "FriendlyName": friendly_name, + "ModelNumber": "HDTC-2US", + "FirmwareName": "hdhomerun3_atsc", + "FirmwareVersion": "20200101", + "DeviceID": device_ID, + "DeviceAuth": "test_auth_token", + "BaseURL": base_url, + "LineupURL": f"{base_url}/lineup.json", + "TunerCount": tuner_count, + } + else: + data = { + "FriendlyName": device.friendly_name, + "ModelNumber": "HDTC-2US", + "FirmwareName": "hdhomerun3_atsc", + "FirmwareVersion": "20200101", + "DeviceID": device.device_id, + "DeviceAuth": "test_auth_token", + "BaseURL": base_url, + "LineupURL": f"{base_url}/lineup.json", + "TunerCount": tuner_count, + } + return JsonResponse(data) + + +# 🔹 3) Lineup API +class LineupAPIView(APIView): + """Returns available channel lineup""" + permission_classes = [AllowAny] + + @extend_schema( + description="Retrieve the available channel lineup", + ) + def get(self, request, profile=None): + if profile is not None: + channel_profile = ChannelProfile.objects.get(name=profile) + channels = Channel.objects.filter( + channelprofilemembership__channel_profile=channel_profile, + channelprofilemembership__enabled=True, + ).order_by("channel_number") + else: + channels = Channel.objects.all().order_by("channel_number") + + lineup = [] + for ch in channels: + # Format channel number as integer if it has no decimal component + if ch.channel_number is not None: + if ch.channel_number == int(ch.channel_number): + formatted_channel_number = str(int(ch.channel_number)) + else: + formatted_channel_number = str(ch.channel_number) + else: + formatted_channel_number = "" + + lineup.append( + { + "GuideNumber": formatted_channel_number, + "GuideName": ch.name, + "URL": request.build_absolute_uri(f"/proxy/ts/stream/{ch.uuid}"), + "Guide_ID": formatted_channel_number, + "Station": formatted_channel_number, + } + ) + return JsonResponse(lineup, safe=False) + + +# 🔹 4) Lineup Status API +class LineupStatusAPIView(APIView): + """Returns the current status of the HDHR lineup""" + permission_classes = [AllowAny] + + @extend_schema( + description="Retrieve the HDHomeRun lineup status", + ) + def get(self, request, profile=None): + data = { + "ScanInProgress": 0, + "ScanPossible": 0, + "Source": "Cable", + "SourceList": ["Cable"], + } + return JsonResponse(data) + + +# 🔹 5) Device XML API +class HDHRDeviceXMLAPIView(APIView): + """Returns HDHomeRun device configuration in XML""" + permission_classes = [AllowAny] + + @extend_schema( + description="Retrieve the HDHomeRun device XML configuration", + ) + def get(self, request): + base_url = request.build_absolute_uri("/hdhr/").rstrip("/") + + xml_response = f""" + + 12345678 + Dispatcharr HDHomeRun + HDTC-2US + hdhomerun3_atsc + 20200101 + test_auth_token + {base_url} + {base_url}/lineup.json + """ + + return HttpResponse(xml_response, content_type="application/xml") diff --git a/apps/hdhr/apps.py b/apps/hdhr/apps.py new file mode 100644 index 0000000..1843473 --- /dev/null +++ b/apps/hdhr/apps.py @@ -0,0 +1,10 @@ +from django.apps import AppConfig +from . import ssdp + +class HdhrConfig(AppConfig): + default_auto_field = 'django.db.models.BigAutoField' + name = 'apps.hdhr' + verbose_name = "HDHomeRun Emulation" + def ready(self): + # Start SSDP services when the app is ready + ssdp.start_ssdp() diff --git a/apps/hdhr/migrations/0001_initial.py b/apps/hdhr/migrations/0001_initial.py new file mode 100644 index 0000000..14b17ce --- /dev/null +++ b/apps/hdhr/migrations/0001_initial.py @@ -0,0 +1,23 @@ +# Generated by Django 5.1.6 on 2025-03-05 22:07 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ] + + operations = [ + migrations.CreateModel( + name='HDHRDevice', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('friendly_name', models.CharField(default='Dispatcharr HDHomeRun', max_length=100)), + ('device_id', models.CharField(max_length=32, unique=True)), + ('tuner_count', models.PositiveIntegerField(default=3)), + ], + ), + ] diff --git a/apps/hdhr/migrations/__init__.py b/apps/hdhr/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/hdhr/models.py b/apps/hdhr/models.py new file mode 100644 index 0000000..ccd0c69 --- /dev/null +++ b/apps/hdhr/models.py @@ -0,0 +1,9 @@ +from django.db import models + +class HDHRDevice(models.Model): + friendly_name = models.CharField(max_length=100, default='Dispatcharr HDHomeRun') + device_id = models.CharField(max_length=32, unique=True) + tuner_count = models.PositiveIntegerField(default=3) + + def __str__(self): + return self.friendly_name diff --git a/apps/hdhr/serializers.py b/apps/hdhr/serializers.py new file mode 100644 index 0000000..5e3359b --- /dev/null +++ b/apps/hdhr/serializers.py @@ -0,0 +1,10 @@ +from rest_framework import serializers +from .models import HDHRDevice + + +class HDHRDeviceSerializer(serializers.ModelSerializer): + """Serializer for HDHomeRun device information""" + + class Meta: + model = HDHRDevice + fields = ['id', 'friendly_name', 'device_id', 'tuner_count'] diff --git a/apps/hdhr/ssdp.py b/apps/hdhr/ssdp.py new file mode 100644 index 0000000..d794799 --- /dev/null +++ b/apps/hdhr/ssdp.py @@ -0,0 +1,69 @@ +import os +import socket +import threading +import time +import gevent # Add this import +from django.conf import settings + +# SSDP Multicast Address and Port +SSDP_MULTICAST = "239.255.255.250" +SSDP_PORT = 1900 + +DEVICE_TYPE = "urn:schemas-upnp-org:device:MediaServer:1" +SERVER_PORT = 8000 + +def get_host_ip(): + try: + # This relies on "host.docker.internal" being mapped to the host’s gateway IP. + return socket.gethostbyname("host.docker.internal") + except Exception: + return "127.0.0.1" + +def ssdp_response(addr, host_ip): + response = ( + f"HTTP/1.1 200 OK\r\n" + f"CACHE-CONTROL: max-age=1800\r\n" + f"EXT:\r\n" + f"LOCATION: http://{host_ip}:{SERVER_PORT}/hdhr/device.xml\r\n" + f"SERVER: Dispatcharr/1.0 UPnP/1.0 HDHomeRun/1.0\r\n" + f"ST: {DEVICE_TYPE}\r\n" + f"USN: uuid:device1-1::{DEVICE_TYPE}\r\n" + f"\r\n" + ) + sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM, socket.IPPROTO_UDP) + sock.sendto(response.encode("utf-8"), addr) + sock.close() + +def ssdp_listener(host_ip): + sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM, socket.IPPROTO_UDP) + sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) + sock.bind((SSDP_MULTICAST, SSDP_PORT)) + while True: + data, addr = sock.recvfrom(1024) + if b"M-SEARCH" in data and DEVICE_TYPE.encode("utf-8") in data: + print(f"Received M-SEARCH from {addr}") + ssdp_response(addr, host_ip) + +def ssdp_broadcaster(host_ip): + notify = ( + f"NOTIFY * HTTP/1.1\r\n" + f"HOST: {SSDP_MULTICAST}:{SSDP_PORT}\r\n" + f"CACHE-CONTROL: max-age=1800\r\n" + f"LOCATION: http://{host_ip}:{SERVER_PORT}/hdhr/device.xml\r\n" + f"SERVER: Dispatcharr/1.0 UPnP/1.0 HDHomeRun/1.0\r\n" + f"NT: {DEVICE_TYPE}\r\n" + f"NTS: ssdp:alive\r\n" + f"USN: uuid:device1-1::{DEVICE_TYPE}\r\n" + f"\r\n" + ) + sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM, socket.IPPROTO_UDP) + sock.setsockopt(socket.IPPROTO_IP, socket.IP_MULTICAST_TTL, 2) + while True: + sock.sendto(notify.encode("utf-8"), (SSDP_MULTICAST, SSDP_PORT)) + gevent.sleep(30) # Replace time.sleep with gevent.sleep + +def start_ssdp(): + host_ip = get_host_ip() + threading.Thread(target=ssdp_listener, args=(host_ip,), daemon=True).start() + threading.Thread(target=ssdp_broadcaster, args=(host_ip,), daemon=True).start() + print(f"SSDP services started on {host_ip}.") diff --git a/apps/hdhr/urls.py b/apps/hdhr/urls.py new file mode 100644 index 0000000..2659cd7 --- /dev/null +++ b/apps/hdhr/urls.py @@ -0,0 +1,22 @@ +from django.urls import path, include +from rest_framework.routers import DefaultRouter +from .api_views import HDHRDeviceViewSet, DiscoverAPIView, LineupAPIView, LineupStatusAPIView, HDHRDeviceXMLAPIView, hdhr_dashboard_view + +app_name = 'hdhr' + +router = DefaultRouter() +router.register(r'devices', HDHRDeviceViewSet, basename='hdhr-device') + +urlpatterns = [ + path('dashboard/', hdhr_dashboard_view, name='hdhr_dashboard'), + path('', hdhr_dashboard_view, name='hdhr_dashboard'), + path('/discover.json', DiscoverAPIView.as_view(), name='discover_with_profile'), + path('discover.json', DiscoverAPIView.as_view(), name='discover_no_profile'), + path('/lineup.json', LineupAPIView.as_view(), name='lineup_with_profile'), + path('lineup.json', LineupAPIView.as_view(), name='lineup_no_profile'), + path('/lineup_status.json', LineupStatusAPIView.as_view(), name='lineup_status_with_profile'), + path('lineup_status.json', LineupStatusAPIView.as_view(), name='lineup_status_no_profile'), + path('device.xml', HDHRDeviceXMLAPIView.as_view(), name='device_xml'), +] + +urlpatterns += router.urls diff --git a/apps/hdhr/views.py b/apps/hdhr/views.py new file mode 100644 index 0000000..f9dd42d --- /dev/null +++ b/apps/hdhr/views.py @@ -0,0 +1,138 @@ +from rest_framework import viewsets, status +from rest_framework.response import Response +from rest_framework.views import APIView +from apps.accounts.permissions import Authenticated, permission_classes_by_action +from django.http import JsonResponse, HttpResponseForbidden, HttpResponse +from drf_spectacular.utils import extend_schema, OpenApiParameter +from drf_spectacular.types import OpenApiTypes +from django.shortcuts import get_object_or_404 +from apps.channels.models import Channel +from .models import HDHRDevice +from .serializers import HDHRDeviceSerializer +from django.contrib.auth.decorators import login_required +from django.shortcuts import render +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 + + +@login_required +def hdhr_dashboard_view(request): + """Render the HDHR management page.""" + hdhr_devices = HDHRDevice.objects.all() + return render(request, "hdhr/hdhr.html", {"hdhr_devices": hdhr_devices}) + + +# 🔹 1) HDHomeRun Device API +class HDHRDeviceViewSet(viewsets.ModelViewSet): + """Handles CRUD operations for HDHomeRun devices""" + + queryset = HDHRDevice.objects.all() + serializer_class = HDHRDeviceSerializer + + def get_permissions(self): + try: + return [perm() for perm in permission_classes_by_action[self.action]] + except KeyError: + return [Authenticated()] + + +# 🔹 2) Discover API +class DiscoverAPIView(APIView): + """Returns device discovery information""" + + @extend_schema( + description="Retrieve HDHomeRun device discovery information", + ) + def get(self, request): + base_url = request.build_absolute_uri("/hdhr/").rstrip("/") + device = HDHRDevice.objects.first() + + if not device: + data = { + "FriendlyName": "Dispatcharr HDHomeRun", + "ModelNumber": "HDTC-2US", + "FirmwareName": "hdhomerun3_atsc", + "FirmwareVersion": "20200101", + "DeviceID": "12345678", + "DeviceAuth": "test_auth_token", + "BaseURL": base_url, + "LineupURL": f"{base_url}/lineup.json", + "TunerCount": 10, + } + else: + data = { + "FriendlyName": device.friendly_name, + "ModelNumber": "HDTC-2US", + "FirmwareName": "hdhomerun3_atsc", + "FirmwareVersion": "20200101", + "DeviceID": device.device_id, + "DeviceAuth": "test_auth_token", + "BaseURL": base_url, + "LineupURL": f"{base_url}/lineup.json", + "TunerCount": 10, + } + return JsonResponse(data) + + +# 🔹 3) Lineup API +class LineupAPIView(APIView): + """Returns available channel lineup""" + + @extend_schema( + description="Retrieve the available channel lineup", + ) + def get(self, request): + channels = Channel.objects.all().order_by("channel_number") + lineup = [ + { + "GuideNumber": str(ch.channel_number), + "GuideName": ch.name, + "URL": request.build_absolute_uri(f"/proxy/ts/stream/{ch.uuid}"), + } + for ch in channels + ] + return JsonResponse(lineup, safe=False) + + +# 🔹 4) Lineup Status API +class LineupStatusAPIView(APIView): + """Returns the current status of the HDHR lineup""" + + @extend_schema( + description="Retrieve the HDHomeRun lineup status", + ) + def get(self, request): + data = { + "ScanInProgress": 0, + "ScanPossible": 0, + "Source": "Cable", + "SourceList": ["Cable"], + } + return JsonResponse(data) + + +# 🔹 5) Device XML API +class HDHRDeviceXMLAPIView(APIView): + """Returns HDHomeRun device configuration in XML""" + + @extend_schema( + description="Retrieve the HDHomeRun device XML configuration", + ) + def get(self, request): + base_url = request.build_absolute_uri("/hdhr/").rstrip("/") + + xml_response = f""" + + 12345678 + Dispatcharr HDHomeRun + HDTC-2US + hdhomerun3_atsc + 20200101 + test_auth_token + {base_url} + {base_url}/lineup.json + """ + + return HttpResponse(xml_response, content_type="application/xml") diff --git a/apps/m3u/__init__.py b/apps/m3u/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/m3u/admin.py b/apps/m3u/admin.py new file mode 100644 index 0000000..c9b9ad0 --- /dev/null +++ b/apps/m3u/admin.py @@ -0,0 +1,190 @@ +from django.contrib import admin +from django.utils.html import format_html +from .models import M3UAccount, M3UFilter, ServerGroup, UserAgent, M3UAccountProfile +import json + + +class M3UFilterInline(admin.TabularInline): + model = M3UFilter + extra = 1 + 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", + "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"] + + # Handle both ForeignKey and ManyToManyField cases for UserAgent + def user_agent_display(self, obj): + 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 + 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( + "Download M3U", obj.uploaded_file.url + ) + return "No file uploaded" + + uploaded_file_link.short_description = "Uploaded File" + + @admin.action(description="Activate selected accounts") + def activate_accounts(self, request, queryset): + queryset.update(is_active=True) + + @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 + + +@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",) + + +@admin.register(ServerGroup) +class ServerGroupAdmin(admin.ModelAdmin): + 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( + '{}', + 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( + '{}', + expiration.strftime('%Y-%m-%d %H:%M') + ) + else: + return format_html( + '{}', + 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("

User Information:

") + html_parts.append("
    ") + 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"
  • {key}: {value}
  • ") + html_parts.append("
") + + # Server Info + server_info = obj.custom_properties.get('server_info', {}) + if server_info: + html_parts.append("

Server Information:

") + html_parts.append("
    ") + for key, value in server_info.items(): + html_parts.append(f"
  • {key}: {value}
  • ") + html_parts.append("
") + + # Last Refresh + last_refresh = obj.custom_properties.get('last_refresh') + if last_refresh: + html_parts.append(f"

Last Refresh: {last_refresh}

") + + return format_html(''.join(html_parts)) if html_parts else "No account information available" + + account_info_display.short_description = "Account Information" diff --git a/apps/m3u/api_urls.py b/apps/m3u/api_urls.py new file mode 100644 index 0000000..6a80a1f --- /dev/null +++ b/apps/m3u/api_urls.py @@ -0,0 +1,44 @@ +from django.urls import path, include +from rest_framework.routers import DefaultRouter +from .api_views import ( + M3UAccountViewSet, + M3UFilterViewSet, + ServerGroupViewSet, + RefreshM3UAPIView, + RefreshSingleM3UAPIView, + RefreshAccountInfoAPIView, + UserAgentViewSet, + M3UAccountProfileViewSet, +) + +app_name = "m3u" + +router = DefaultRouter() +router.register(r"accounts", M3UAccountViewSet, basename="m3u-account") +router.register( + r"accounts\/(?P\d+)\/profiles", + M3UAccountProfileViewSet, + basename="m3u-account-profiles", +) +router.register( + r"accounts\/(?P\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//", + RefreshSingleM3UAPIView.as_view(), + name="m3u_refresh_single", + ), + path( + "refresh-account-info//", + RefreshAccountInfoAPIView.as_view(), + name="m3u_refresh_account_info", + ), +] + +urlpatterns += router.urls diff --git a/apps/m3u/api_views.py b/apps/m3u/api_views.py new file mode 100644 index 0000000..b8cb099 --- /dev/null +++ b/apps/m3u/api_views.py @@ -0,0 +1,501 @@ +from rest_framework import viewsets, status +from rest_framework.response import Response +from rest_framework.views import APIView +from apps.accounts.permissions import ( + Authenticated, + permission_classes_by_action, + permission_classes_by_method, +) +from drf_spectacular.utils import extend_schema, OpenApiParameter +from drf_spectacular.types import OpenApiTypes +from django.db import transaction +from django.shortcuts import get_object_or_404 +from django.http import JsonResponse +from django.core.cache import cache +import os +from rest_framework.decorators import action +from django.conf import settings +from .tasks import refresh_m3u_groups +import json + +from .models import M3UAccount, M3UFilter, ServerGroup, M3UAccountProfile +from core.models import UserAgent +from core.utils import safe_upload_path +from apps.channels.models import ChannelGroupM3UAccount +from core.serializers import UserAgentSerializer +from apps.vod.models import M3UVODCategoryRelation + +from .serializers import ( + M3UAccountSerializer, + M3UFilterSerializer, + ServerGroupSerializer, + M3UAccountProfileSerializer, +) + +from .tasks import refresh_single_m3u_account, refresh_m3u_accounts, refresh_account_info +import json + + +class M3UAccountViewSet(viewsets.ModelViewSet): + """Handles CRUD operations for M3U accounts""" + + queryset = M3UAccount.objects.select_related( + "refresh_task__crontab", "refresh_task__interval" + ).prefetch_related("channel_group", "profiles") + serializer_class = M3UAccountSerializer + + def get_permissions(self): + try: + return [perm() for perm in permission_classes_by_action[self.action]] + except KeyError: + return [Authenticated()] + + def create(self, request, *args, **kwargs): + # Handle file upload first, if any + file_path = None + if "file" in request.FILES: + file = request.FILES["file"] + try: + file_path = safe_upload_path(file.name, "/data/uploads/m3us") + except ValueError: + return Response({"detail": "Invalid filename."}, status=status.HTTP_400_BAD_REQUEST) + + os.makedirs("/data/uploads/m3us", exist_ok=True) + with open(file_path, "wb+") as destination: + for chunk in file.chunks(): + destination.write(chunk) + + # Add file_path to the request data so it's available during creation + request.data._mutable = True # Allow modification of the request data + request.data["file_path"] = ( + file_path # Include the file path if a file was uploaded + ) + + # Handle the user_agent field - convert "null" string to None + if "user_agent" in request.data and request.data["user_agent"] == "null": + request.data["user_agent"] = None + + # Handle server_url appropriately + if "server_url" in request.data and not request.data["server_url"]: + request.data.pop("server_url") + + request.data._mutable = False # Make the request data immutable again + + # Now call super().create() to create the instance + response = super().create(request, *args, **kwargs) + + 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 + if "file" in request.FILES: + file = request.FILES["file"] + try: + file_path = safe_upload_path(file.name, "/data/uploads/m3us") + except ValueError: + return Response({"detail": "Invalid filename."}, status=status.HTTP_400_BAD_REQUEST) + + os.makedirs("/data/uploads/m3us", exist_ok=True) + with open(file_path, "wb+") as destination: + for chunk in file.chunks(): + destination.write(chunk) + + # Add file_path to the request data so it's available during creation + request.data._mutable = True # Allow modification of the request data + request.data["file_path"] = ( + file_path # Include the file path if a file was uploaded + ) + + # Handle the user_agent field - convert "null" string to None + if "user_agent" in request.data and request.data["user_agent"] == "null": + request.data["user_agent"] = None + + # Handle server_url appropriately + if "server_url" in request.data and not request.data["server_url"]: + request.data.pop("server_url") + + request.data._mutable = False # Make the request data immutable again + + if instance.file_path and os.path.exists(instance.file_path): + os.remove(instance.file_path) + + # 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 + + def partial_update(self, request, *args, **kwargs): + """Handle partial updates with special logic for is_active field""" + instance = self.get_object() + + # Check if we're toggling is_active + if ( + "is_active" in request.data + and instance.is_active != request.data["is_active"] + ): + # Set appropriate status based on new is_active value + if request.data["is_active"]: + request.data["status"] = M3UAccount.Status.IDLE + else: + request.data["status"] = M3UAccount.Status.DISABLED + + # 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: + with transaction.atomic(): + group_objects = [ + ChannelGroupM3UAccount( + channel_group_id=setting["channel_group"], + m3u_account=account, + enabled=setting.get("enabled", True), + auto_channel_sync=setting.get("auto_channel_sync", False), + auto_sync_channel_start=setting.get("auto_sync_channel_start"), + custom_properties=setting.get("custom_properties", {}), + ) + for setting in group_settings + if setting.get("channel_group") + ] + + if group_objects: + ChannelGroupM3UAccount.objects.bulk_create( + group_objects, + update_conflicts=True, + unique_fields=["channel_group", "m3u_account"], + update_fields=[ + "enabled", + "auto_channel_sync", + "auto_sync_channel_start", + "custom_properties", + ], + ) + + category_objects = [ + M3UVODCategoryRelation( + category_id=setting["id"], + m3u_account=account, + enabled=setting.get("enabled", True), + custom_properties=setting.get("custom_properties", {}), + ) + for setting in category_settings + if setting.get("id") + ] + + if category_objects: + M3UVODCategoryRelation.objects.bulk_create( + category_objects, + update_conflicts=True, + unique_fields=["m3u_account", "category"], + update_fields=["enabled", "custom_properties"], + ) + + return Response({"message": "Group settings updated successfully"}) + + except Exception as e: + return Response( + {"error": f"Failed to update group settings: {str(e)}"}, + status=status.HTTP_400_BAD_REQUEST, + ) + + +class M3UFilterViewSet(viewsets.ModelViewSet): + queryset = M3UFilter.objects.all() + serializer_class = M3UFilterSerializer + + def get_permissions(self): + try: + return [perm() for perm in permission_classes_by_action[self.action]] + 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""" + + queryset = ServerGroup.objects.all() + serializer_class = ServerGroupSerializer + + def get_permissions(self): + try: + return [perm() for perm in permission_classes_by_action[self.action]] + except KeyError: + return [Authenticated()] + + +class RefreshM3UAPIView(APIView): + """Triggers refresh for all active M3U accounts""" + + def get_permissions(self): + try: + return [ + perm() for perm in permission_classes_by_method[self.request.method] + ] + except KeyError: + return [Authenticated()] + + @extend_schema( + description="Triggers a refresh of all active M3U accounts", + ) + def post(self, request, format=None): + refresh_m3u_accounts.delay() + return Response( + {"success": True, "message": "M3U refresh initiated."}, + status=status.HTTP_202_ACCEPTED, + ) + + +class RefreshSingleM3UAPIView(APIView): + """Triggers 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()] + + @extend_schema( + description="Triggers a refresh of a single M3U account", + ) + def post(self, request, account_id, format=None): + refresh_single_m3u_account.delay(account_id) + return Response( + { + "success": True, + "message": f"M3U account {account_id} refresh initiated.", + }, + status=status.HTTP_202_ACCEPTED, + ) + + +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()] + + @extend_schema( + description="Triggers a refresh of account information for a specific M3U profile", + ) + 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""" + + queryset = UserAgent.objects.all() + serializer_class = UserAgentSerializer + + def get_permissions(self): + try: + return [perm() for perm in permission_classes_by_action[self.action]] + except KeyError: + return [Authenticated()] + + +class M3UAccountProfileViewSet(viewsets.ModelViewSet): + queryset = M3UAccountProfile.objects.all() + serializer_class = M3UAccountProfileSerializer + + def get_permissions(self): + try: + return [perm() for perm in permission_classes_by_action[self.action]] + except KeyError: + return [Authenticated()] + + def get_queryset(self): + m3u_account_id = self.kwargs["account_id"] + return M3UAccountProfile.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"] = m3u_account + + # Perform the actual save + serializer.save(m3u_account_id=m3u_account) diff --git a/apps/m3u/apps.py b/apps/m3u/apps.py new file mode 100644 index 0000000..8927dd1 --- /dev/null +++ b/apps/m3u/apps.py @@ -0,0 +1,10 @@ +# apps/m3u/apps.py +from django.apps import AppConfig + +class M3UConfig(AppConfig): + default_auto_field = 'django.db.models.BigAutoField' + name = 'apps.m3u' + verbose_name = "M3U Management" + + def ready(self): + import apps.m3u.signals # ensures M3U signals get registered diff --git a/apps/m3u/forms.py b/apps/m3u/forms.py new file mode 100644 index 0000000..cf6586c --- /dev/null +++ b/apps/m3u/forms.py @@ -0,0 +1,79 @@ +# apps/m3u/forms.py +from django import forms +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 = [ + 'name', + 'server_url', + 'uploaded_file', + '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: + if not uploaded_file.name.endswith('.m3u'): + raise forms.ValidationError("The uploaded file must be an M3U file.") + return uploaded_file + + def clean(self): + cleaned_data = super().clean() + url = cleaned_data.get('server_url') + file = cleaned_data.get('uploaded_file') + # Ensure either `server_url` or `uploaded_file` is provided + if not url and not file: + raise forms.ValidationError("Either an M3U URL or a file upload is required.") + return cleaned_data + + +class M3UFilterForm(forms.ModelForm): + class Meta: + model = M3UFilter + fields = ['m3u_account', 'filter_type', 'regex_pattern', 'exclude'] + + def clean_regex_pattern(self): + pattern = self.cleaned_data['regex_pattern'] + try: + re.compile(pattern) + except re.error: + raise forms.ValidationError("Invalid regex pattern") + return pattern diff --git a/apps/m3u/migrations/0001_initial.py b/apps/m3u/migrations/0001_initial.py new file mode 100644 index 0000000..58c1f47 --- /dev/null +++ b/apps/m3u/migrations/0001_initial.py @@ -0,0 +1,65 @@ +# Generated by Django 5.1.6 on 2025-03-05 22:07 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ('core', '0001_initial'), + ] + + operations = [ + migrations.CreateModel( + name='ServerGroup', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('name', models.CharField(help_text='Unique name for this server group.', max_length=100, unique=True)), + ], + ), + migrations.CreateModel( + name='M3UAccount', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('name', models.CharField(help_text='Unique name for this M3U account', max_length=255, unique=True)), + ('server_url', models.URLField(blank=True, help_text='The base URL of the M3U server (optional if a file is uploaded)', null=True)), + ('uploaded_file', models.FileField(blank=True, null=True, upload_to='m3u_uploads/')), + ('max_streams', models.PositiveIntegerField(default=0, help_text='Maximum number of concurrent streams (0 for unlimited)')), + ('is_active', models.BooleanField(default=True, help_text='Set to false to deactivate this M3U account')), + ('created_at', models.DateTimeField(auto_now_add=True, help_text='Time when this account was created')), + ('updated_at', models.DateTimeField(auto_now=True, help_text='Time when this account was last updated')), + ('user_agent', models.ForeignKey(blank=True, help_text='The User-Agent associated with this M3U account.', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='m3u_accounts', to='core.useragent')), + ('server_group', models.ForeignKey(blank=True, help_text='The server group this M3U account belongs to', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='m3u_accounts', to='m3u.servergroup')), + ], + ), + migrations.CreateModel( + name='M3UFilter', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('filter_type', models.CharField(choices=[('group', 'Group Title'), ('name', 'Stream Name')], default='group', help_text='Filter based on either group title or stream name.', max_length=50)), + ('regex_pattern', models.CharField(help_text='A regex pattern to match streams or groups.', max_length=200)), + ('exclude', models.BooleanField(default=True, help_text='If True, matching items are excluded; if False, only matches are included.')), + ('m3u_account', models.ForeignKey(help_text='The M3U account this filter is applied to.', on_delete=django.db.models.deletion.CASCADE, related_name='filters', to='m3u.m3uaccount')), + ], + ), + migrations.CreateModel( + name='M3UAccountProfile', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('name', models.CharField(help_text='Name for the M3U account profile', max_length=255)), + ('is_default', models.BooleanField(default=False, help_text='Set to false to deactivate this profile')), + ('max_streams', models.PositiveIntegerField(default=0, help_text='Maximum number of concurrent streams (0 for unlimited)')), + ('is_active', models.BooleanField(default=True, help_text='Set to false to deactivate this profile')), + ('search_pattern', models.CharField(max_length=255)), + ('replace_pattern', models.CharField(max_length=255)), + ('current_viewers', models.PositiveIntegerField(default=0)), + ('m3u_account', models.ForeignKey(help_text='The M3U account this profile belongs to.', on_delete=django.db.models.deletion.CASCADE, related_name='profiles', to='m3u.m3uaccount')), + ], + options={ + 'constraints': [models.UniqueConstraint(fields=('m3u_account', 'name'), name='unique_account_name')], + }, + ), + ] diff --git a/apps/m3u/migrations/0002_m3uaccount_locked.py b/apps/m3u/migrations/0002_m3uaccount_locked.py new file mode 100644 index 0000000..40a663c --- /dev/null +++ b/apps/m3u/migrations/0002_m3uaccount_locked.py @@ -0,0 +1,18 @@ +# Generated by Django 5.1.6 on 2025-03-17 20:43 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('m3u', '0001_initial'), + ] + + operations = [ + migrations.AddField( + model_name='m3uaccount', + name='locked', + field=models.BooleanField(default=False, help_text="Protected - can't be deleted or modified"), + ), + ] diff --git a/apps/m3u/migrations/0003_create_custom_account.py b/apps/m3u/migrations/0003_create_custom_account.py new file mode 100644 index 0000000..cdc40cd --- /dev/null +++ b/apps/m3u/migrations/0003_create_custom_account.py @@ -0,0 +1,40 @@ +# Generated by Django 5.1.6 on 2025-03-01 14:01 + +from django.db import migrations +from core.models import CoreSettings + + +def create_custom_account(apps, schema_editor): + default_user_agent_id = CoreSettings.get_default_user_agent_id() + + M3UAccount = apps.get_model("m3u", "M3UAccount") + m3u_account = M3UAccount.objects.create( + name="custom", + max_streams=0, + is_active=True, + user_agent_id=default_user_agent_id, + locked=True, + ) + + M3UAccountProfile = apps.get_model("m3u", "M3UAccountProfile") + M3UAccountProfile.objects.create( + m3u_account=m3u_account, + name=f"{m3u_account.name} Default", + max_streams=m3u_account.max_streams, + is_default=True, + is_active=True, + search_pattern="^(.*)$", + replace_pattern="$1", + ) + + +class Migration(migrations.Migration): + + dependencies = [ + ("m3u", "0002_m3uaccount_locked"), + ("core", "0004_preload_core_settings"), + ] + + operations = [ + migrations.RunPython(create_custom_account), + ] diff --git a/apps/m3u/migrations/0004_m3uaccount_stream_profile.py b/apps/m3u/migrations/0004_m3uaccount_stream_profile.py new file mode 100644 index 0000000..65f69fc --- /dev/null +++ b/apps/m3u/migrations/0004_m3uaccount_stream_profile.py @@ -0,0 +1,20 @@ +# Generated by Django 5.1.6 on 2025-03-19 16:33 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('core', '0009_m3u_hash_settings'), + ('m3u', '0003_create_custom_account'), + ] + + operations = [ + migrations.AddField( + model_name='m3uaccount', + name='stream_profile', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='m3u_accounts', to='core.streamprofile'), + ), + ] diff --git a/apps/m3u/migrations/0005_m3uaccount_custom_properties_and_more.py b/apps/m3u/migrations/0005_m3uaccount_custom_properties_and_more.py new file mode 100644 index 0000000..7a5f201 --- /dev/null +++ b/apps/m3u/migrations/0005_m3uaccount_custom_properties_and_more.py @@ -0,0 +1,35 @@ +# Generated by Django 5.1.6 on 2025-03-29 13:44 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("django_celery_beat", "0019_alter_periodictasks_options"), + ("m3u", "0004_m3uaccount_stream_profile"), + ] + + operations = [ + migrations.AddField( + model_name="m3uaccount", + name="custom_properties", + field=models.TextField(blank=True, null=True), + ), + migrations.AddField( + model_name="m3uaccount", + name="refresh_interval", + field=models.IntegerField(default=24), + ), + migrations.AddField( + model_name="m3uaccount", + name="refresh_task", + field=models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + to="django_celery_beat.periodictask", + ), + ), + ] diff --git a/apps/m3u/migrations/0006_populate_periodic_tasks.py b/apps/m3u/migrations/0006_populate_periodic_tasks.py new file mode 100644 index 0000000..bc38797 --- /dev/null +++ b/apps/m3u/migrations/0006_populate_periodic_tasks.py @@ -0,0 +1,52 @@ +from django.db import migrations +import json + +def create_default_refresh_tasks(apps, schema_editor): + """ + Creates a PeriodicTask for each existing M3UAccount that doesn't have one. + """ + IntervalSchedule = apps.get_model("django_celery_beat", "IntervalSchedule") + PeriodicTask = apps.get_model("django_celery_beat", "PeriodicTask") + M3UAccount = apps.get_model("m3u", "M3UAccount") + + default_interval, _ = IntervalSchedule.objects.get_or_create( + every=24, + period="hours", + ) + + for account in M3UAccount.objects.all(): + if account.refresh_task: + continue + + task_name = f"m3u_account-refresh-{account.id}" + + refresh_task = PeriodicTask.objects.create( + name=task_name, + interval=default_interval, + task="apps.m3u.tasks.refresh_single_m3u_account", + kwargs=json.dumps({"account_id": account.id}), + ) + + account.refresh_task = refresh_task + account.save(update_fields=["refresh_task"]) + +def reverse_migration(apps, schema_editor): + IntervalSchedule = apps.get_model("django_celery_beat", "IntervalSchedule") + PeriodicTask = apps.get_model("django_celery_beat", "PeriodicTask") + M3UAccount = apps.get_model("m3u", "M3UAccount") + + for account in M3UAccount.objects.all(): + IntervalSchedule.objects.filter(name=f"m3u_account-refresh-interval-{account.id}").delete() + PeriodicTask.objects.filter(name=f"m3u_account-refresh-{account.id}").delete() + + +class Migration(migrations.Migration): + + dependencies = [ + ("m3u", "0005_m3uaccount_custom_properties_and_more"), + ("django_celery_beat", "0019_alter_periodictasks_options"), + ] + + operations = [ + migrations.RunPython(create_default_refresh_tasks, reverse_migration), + ] diff --git a/apps/m3u/migrations/0007_remove_m3uaccount_uploaded_file_m3uaccount_file_path.py b/apps/m3u/migrations/0007_remove_m3uaccount_uploaded_file_m3uaccount_file_path.py new file mode 100644 index 0000000..086eff2 --- /dev/null +++ b/apps/m3u/migrations/0007_remove_m3uaccount_uploaded_file_m3uaccount_file_path.py @@ -0,0 +1,22 @@ +# Generated by Django 5.1.6 on 2025-04-06 19:09 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('m3u', '0006_populate_periodic_tasks'), + ] + + operations = [ + migrations.RemoveField( + model_name='m3uaccount', + name='uploaded_file', + ), + migrations.AddField( + model_name='m3uaccount', + name='file_path', + field=models.CharField(blank=True, max_length=255, null=True), + ), + ] diff --git a/apps/m3u/migrations/0008_m3uaccount_stale_stream_days.py b/apps/m3u/migrations/0008_m3uaccount_stale_stream_days.py new file mode 100644 index 0000000..69a1397 --- /dev/null +++ b/apps/m3u/migrations/0008_m3uaccount_stale_stream_days.py @@ -0,0 +1,18 @@ +# Generated by Django 5.1.6 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('m3u', '0007_remove_m3uaccount_uploaded_file_m3uaccount_file_path'), + ] + + operations = [ + migrations.AddField( + model_name='m3uaccount', + name='stale_stream_days', + field=models.PositiveIntegerField(default=7, help_text='Number of days after which a stream will be removed if not seen in the M3U source.'), + ), + ] diff --git a/apps/m3u/migrations/0009_m3uaccount_account_type_m3uaccount_password_and_more.py b/apps/m3u/migrations/0009_m3uaccount_account_type_m3uaccount_password_and_more.py new file mode 100644 index 0000000..d57f7cc --- /dev/null +++ b/apps/m3u/migrations/0009_m3uaccount_account_type_m3uaccount_password_and_more.py @@ -0,0 +1,28 @@ +# Generated by Django 5.1.6 on 2025-04-27 12:56 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('m3u', '0008_m3uaccount_stale_stream_days'), + ] + + operations = [ + migrations.AddField( + model_name='m3uaccount', + name='account_type', + field=models.CharField(choices=[('STD', 'Standard'), ('XC', 'Xtream Codes')], default='STD'), + ), + migrations.AddField( + model_name='m3uaccount', + name='password', + field=models.CharField(blank=True, max_length=255, null=True), + ), + migrations.AddField( + model_name='m3uaccount', + name='username', + field=models.CharField(blank=True, max_length=255, null=True), + ), + ] diff --git a/apps/m3u/migrations/0010_add_status_fields_and_remove_auto_now.py b/apps/m3u/migrations/0010_add_status_fields_and_remove_auto_now.py new file mode 100644 index 0000000..e989604 --- /dev/null +++ b/apps/m3u/migrations/0010_add_status_fields_and_remove_auto_now.py @@ -0,0 +1,28 @@ +# Generated by Django 5.1.6 on 2025-05-04 21:43 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('m3u', '0009_m3uaccount_account_type_m3uaccount_password_and_more'), + ] + + operations = [ + migrations.AddField( + model_name='m3uaccount', + name='last_message', + field=models.TextField(blank=True, null=True, help_text="Last status message, including success results or error information"), + ), + migrations.AddField( + model_name='m3uaccount', + name='status', + field=models.CharField(choices=[('idle', 'Idle'), ('fetching', 'Fetching'), ('parsing', 'Parsing'), ('error', 'Error'), ('success', 'Success')], default='idle', max_length=20), + ), + migrations.AlterField( + model_name='m3uaccount', + name='updated_at', + field=models.DateTimeField(blank=True, help_text='Time when this account was last successfully refreshed', null=True), + ), + ] diff --git a/apps/m3u/migrations/0011_alter_m3uaccount_status.py b/apps/m3u/migrations/0011_alter_m3uaccount_status.py new file mode 100644 index 0000000..7812f31 --- /dev/null +++ b/apps/m3u/migrations/0011_alter_m3uaccount_status.py @@ -0,0 +1,18 @@ +# Generated by Django 5.1.6 on 2025-05-15 01:05 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('m3u', '0010_add_status_fields_and_remove_auto_now'), + ] + + operations = [ + migrations.AlterField( + model_name='m3uaccount', + name='status', + field=models.CharField(choices=[('idle', 'Idle'), ('fetching', 'Fetching'), ('parsing', 'Parsing'), ('error', 'Error'), ('success', 'Success'), ('pending_setup', 'Pending Setup'), ('disabled', 'Disabled')], default='idle', max_length=20), + ), + ] diff --git a/apps/m3u/migrations/0012_alter_m3uaccount_refresh_interval.py b/apps/m3u/migrations/0012_alter_m3uaccount_refresh_interval.py new file mode 100644 index 0000000..7045810 --- /dev/null +++ b/apps/m3u/migrations/0012_alter_m3uaccount_refresh_interval.py @@ -0,0 +1,18 @@ +# Generated by Django 5.1.6 on 2025-05-21 19:58 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('m3u', '0011_alter_m3uaccount_status'), + ] + + operations = [ + migrations.AlterField( + model_name='m3uaccount', + name='refresh_interval', + field=models.IntegerField(default=0), + ), + ] diff --git a/apps/m3u/migrations/0013_alter_m3ufilter_filter_type.py b/apps/m3u/migrations/0013_alter_m3ufilter_filter_type.py new file mode 100644 index 0000000..0b0a8a1 --- /dev/null +++ b/apps/m3u/migrations/0013_alter_m3ufilter_filter_type.py @@ -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), + ), + ] diff --git a/apps/m3u/migrations/0014_alter_m3ufilter_options_m3ufilter_order.py b/apps/m3u/migrations/0014_alter_m3ufilter_options_m3ufilter_order.py new file mode 100644 index 0000000..3510bfc --- /dev/null +++ b/apps/m3u/migrations/0014_alter_m3ufilter_options_m3ufilter_order.py @@ -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), + ), + ] diff --git a/apps/m3u/migrations/0015_alter_m3ufilter_options_m3ufilter_custom_properties.py b/apps/m3u/migrations/0015_alter_m3ufilter_options_m3ufilter_custom_properties.py new file mode 100644 index 0000000..6b62c9a --- /dev/null +++ b/apps/m3u/migrations/0015_alter_m3ufilter_options_m3ufilter_custom_properties.py @@ -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), + ), + ] diff --git a/apps/m3u/migrations/0016_m3uaccount_priority.py b/apps/m3u/migrations/0016_m3uaccount_priority.py new file mode 100644 index 0000000..55e0e95 --- /dev/null +++ b/apps/m3u/migrations/0016_m3uaccount_priority.py @@ -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.'), + ), + ] diff --git a/apps/m3u/migrations/0017_alter_m3uaccount_custom_properties_and_more.py b/apps/m3u/migrations/0017_alter_m3uaccount_custom_properties_and_more.py new file mode 100644 index 0000000..84cb968 --- /dev/null +++ b/apps/m3u/migrations/0017_alter_m3uaccount_custom_properties_and_more.py @@ -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), + ), + ] diff --git a/apps/m3u/migrations/0018_add_profile_custom_properties.py b/apps/m3u/migrations/0018_add_profile_custom_properties.py new file mode 100644 index 0000000..d616c59 --- /dev/null +++ b/apps/m3u/migrations/0018_add_profile_custom_properties.py @@ -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), + ), + ] diff --git a/apps/m3u/migrations/0019_m3uaccountprofile_exp_date.py b/apps/m3u/migrations/0019_m3uaccountprofile_exp_date.py new file mode 100644 index 0000000..bae4d9d --- /dev/null +++ b/apps/m3u/migrations/0019_m3uaccountprofile_exp_date.py @@ -0,0 +1,57 @@ +# Generated by Django 6.0.3 on 2026-03-14 19:41 + +from datetime import datetime, timezone + +from django.db import migrations, models + + +def populate_exp_date_from_custom_properties(apps, schema_editor): + """Backfill exp_date from custom_properties['user_info']['exp_date'].""" + M3UAccountProfile = apps.get_model('m3u', 'M3UAccountProfile') + profiles_to_update = [] + + for profile in M3UAccountProfile.objects.filter( + custom_properties__isnull=False, + ).exclude(custom_properties={}): + user_info = profile.custom_properties.get('user_info', {}) + raw_exp = user_info.get('exp_date') + if raw_exp is None: + continue + + parsed = None + try: + if isinstance(raw_exp, (int, float)): + parsed = datetime.fromtimestamp(float(raw_exp), tz=timezone.utc) + elif isinstance(raw_exp, str): + try: + parsed = datetime.fromtimestamp(float(raw_exp), tz=timezone.utc) + except ValueError: + parsed = datetime.fromisoformat(raw_exp) + except (ValueError, TypeError, OSError): + pass + + if parsed is not None: + profile.exp_date = parsed + profiles_to_update.append(profile) + + if profiles_to_update: + M3UAccountProfile.objects.bulk_update(profiles_to_update, ['exp_date'], batch_size=500) + + +class Migration(migrations.Migration): + + dependencies = [ + ('m3u', '0018_add_profile_custom_properties'), + ] + + operations = [ + migrations.AddField( + model_name='m3uaccountprofile', + name='exp_date', + field=models.DateTimeField(blank=True, help_text='Account expiration date, auto-synced from custom_properties on save', null=True), + ), + migrations.RunPython( + populate_exp_date_from_custom_properties, + reverse_code=migrations.RunPython.noop, + ), + ] diff --git a/apps/m3u/migrations/__init__.py b/apps/m3u/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/m3u/models.py b/apps/m3u/models.py new file mode 100644 index 0000000..fbe22d8 --- /dev/null +++ b/apps/m3u/models.py @@ -0,0 +1,389 @@ +from datetime import datetime, timezone +from django.db import models +from django.core.exceptions import ValidationError +from core.models import UserAgent +import re +from django.dispatch import receiver +from apps.channels.models import StreamProfile +from django_celery_beat.models import PeriodicTask +from core.models import CoreSettings, UserAgent + +CUSTOM_M3U_ACCOUNT_NAME = "custom" + + +class M3UAccount(models.Model): + class Types(models.TextChoices): + STADNARD = "STD", "Standard" + XC = "XC", "Xtream Codes" + + class Status(models.TextChoices): + IDLE = "idle", "Idle" + FETCHING = "fetching", "Fetching" + PARSING = "parsing", "Parsing" + ERROR = "error", "Error" + SUCCESS = "success", "Success" + PENDING_SETUP = "pending_setup", "Pending Setup" + DISABLED = "disabled", "Disabled" + + """Represents an M3U Account for IPTV streams.""" + name = models.CharField( + 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)", + ) + file_path = models.CharField(max_length=255, blank=True, null=True) + server_group = models.ForeignKey( + "ServerGroup", + on_delete=models.SET_NULL, + null=True, + blank=True, + related_name="m3u_accounts", + help_text="The server group this M3U account belongs to", + ) + max_streams = models.PositiveIntegerField( + default=0, help_text="Maximum number of concurrent streams (0 for unlimited)" + ) + is_active = models.BooleanField( + default=True, help_text="Set to false to deactivate this M3U account" + ) + created_at = models.DateTimeField( + auto_now_add=True, help_text="Time when this account was created" + ) + updated_at = models.DateTimeField( + null=True, + blank=True, + help_text="Time when this account was last successfully refreshed", + ) + status = models.CharField( + max_length=20, choices=Status.choices, default=Status.IDLE + ) + last_message = models.TextField( + null=True, + blank=True, + help_text="Last status message, including success results or error information", + ) + user_agent = models.ForeignKey( + "core.UserAgent", + on_delete=models.SET_NULL, + null=True, + blank=True, + related_name="m3u_accounts", + help_text="The User-Agent associated with this M3U account.", + ) + locked = models.BooleanField( + default=False, help_text="Protected - can't be deleted or modified" + ) + stream_profile = models.ForeignKey( + StreamProfile, + on_delete=models.SET_NULL, + null=True, + blank=True, + related_name="m3u_accounts", + ) + 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.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 + ) + stale_stream_days = models.PositiveIntegerField( + 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 + + def clean(self): + if self.max_streams < 0: + raise ValidationError("Max streams cannot be negative.") + + def display_action(self): + return "Exclude" if self.exclude else "Include" + + def deactivate_streams(self): + """Deactivate all streams linked to this account.""" + for stream in self.streams.all(): + stream.is_active = False + stream.save() + + def reactivate_streams(self): + """Reactivate all streams linked to this account.""" + for stream in self.streams.all(): + stream.is_active = True + stream.save() + + @classmethod + def get_custom_account(cls): + return cls.objects.get(name=CUSTOM_M3U_ACCOUNT_NAME, locked=True) + + def get_user_agent(self): + user_agent = self.user_agent + if not user_agent: + user_agent = UserAgent.objects.get( + id=CoreSettings.get_default_user_agent_id() + ) + + return user_agent + + def save(self, *args, **kwargs): + # Prevent auto_now behavior by handling updated_at manually + if "update_fields" in kwargs and "updated_at" not in kwargs["update_fields"]: + # Don't modify updated_at for regular updates + kwargs.setdefault("update_fields", []) + if "updated_at" in kwargs["update_fields"]: + kwargs["update_fields"].remove("updated_at") + super().save(*args, **kwargs) + + # def get_channel_groups(self): + # return ChannelGroup.objects.filter(m3u_account__m3u_account=self) + + # def is_channel_group_enabled(self, channel_group): + # """Check if the specified ChannelGroup is enabled for this M3UAccount.""" + # return self.channel_group.filter(channel_group=channel_group, enabled=True).exists() + + # def get_enabled_streams(self): + # """Return all streams linked to this account with enabled ChannelGroups.""" + # return self.streams.filter(channel_group__in=ChannelGroup.objects.filter(m3u_account__enabled=True)) + + +class M3UFilter(models.Model): + """Defines filters for M3U accounts based on stream name or group title.""" + + FILTER_TYPE_CHOICES = ( + ("group", "Group"), + ("name", "Stream Name"), + ("url", "Stream URL"), + ) + + m3u_account = models.ForeignKey( + M3UAccount, + on_delete=models.CASCADE, + related_name="filters", + help_text="The M3U account this filter is applied to.", + ) + filter_type = models.CharField( + max_length=50, + choices=FILTER_TYPE_CHOICES, + default="group", + help_text="Filter based on either group title or stream name.", + ) + regex_pattern = models.CharField( + max_length=200, help_text="A regex pattern to match streams or groups." + ) + exclude = models.BooleanField( + 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 + return bool(re.search(self.regex_pattern, target, re.IGNORECASE)) + + def clean(self): + try: + re.compile(self.regex_pattern) + except re.error: + raise ValidationError(f"Invalid regex pattern: {self.regex_pattern}") + + def __str__(self): + filter_type_display = dict(self.FILTER_TYPE_CHOICES).get( + self.filter_type, "Unknown" + ) + exclude_status = "Exclude" if self.exclude else "Include" + return f"[{self.m3u_account.name}] {filter_type_display}: {self.regex_pattern} ({exclude_status})" + + @staticmethod + def filter_streams(streams, filters): + included_streams = set() + excluded_streams = set() + + for f in filters: + for stream in streams: + if f.applies_to(stream.name, stream.group_name): + if f.exclude: + excluded_streams.add(stream) + else: + included_streams.add(stream) + + # If no include filters exist, assume all non-excluded streams are valid + if not any(not f.exclude for f in filters): + return streams.exclude(id__in=[s.id for s in excluded_streams]) + + return streams.filter(id__in=[s.id for s in included_streams]) + + +class ServerGroup(models.Model): + """Represents a logical grouping of servers or channels.""" + + name = models.CharField( + max_length=100, unique=True, help_text="Unique name for this server group." + ) + + def __str__(self): + return self.name + + +class M3UAccountProfile(models.Model): + """Represents a profile associated with an M3U Account.""" + + m3u_account = models.ForeignKey( + "M3UAccount", + on_delete=models.CASCADE, + related_name="profiles", + help_text="The M3U account this profile belongs to.", + ) + name = models.CharField( + max_length=255, help_text="Name for the M3U account profile" + ) + is_default = models.BooleanField( + default=False, help_text="Set to false to deactivate this profile" + ) + max_streams = models.PositiveIntegerField( + default=0, help_text="Maximum number of concurrent streams (0 for unlimited)" + ) + is_active = models.BooleanField( + default=True, help_text="Set to false to deactivate this profile" + ) + search_pattern = models.CharField( + max_length=255, + ) + replace_pattern = models.CharField( + 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)" + ) + exp_date = models.DateTimeField( + null=True, + blank=True, + help_text="Account expiration date, auto-synced from custom_properties on save", + ) + + class Meta: + constraints = [ + models.UniqueConstraint( + fields=["m3u_account", "name"], name="unique_account_name" + ) + ] + + def __str__(self): + return f"{self.name} ({self.m3u_account.name})" + + def save(self, *args, **kwargs): + """Auto-sync exp_date from custom_properties for XC accounts on every save. + For non-XC accounts, exp_date is set directly and left untouched here.""" + parsed = self._parse_exp_date_from_custom_properties() + if parsed is not None: + # XC account with exp_date in custom_properties — always sync + self.exp_date = parsed + # else: keep whatever exp_date is already set (manual entry for non-XC) + super().save(*args, **kwargs) + + @staticmethod + def _parse_exp_date(raw_value): + """Parse a raw exp_date value (unix timestamp or ISO string) into a datetime.""" + if raw_value is None: + return None + try: + if isinstance(raw_value, (int, float)): + return datetime.fromtimestamp(float(raw_value), tz=timezone.utc) + elif isinstance(raw_value, str): + try: + return datetime.fromtimestamp(float(raw_value), tz=timezone.utc) + except ValueError: + return datetime.fromisoformat(raw_value) + except (ValueError, TypeError, OSError): + pass + return None + + def _parse_exp_date_from_custom_properties(self): + """Extract exp_date from custom_properties JSON.""" + if not self.custom_properties: + return None + user_info = self.custom_properties.get('user_info', {}) + return self._parse_exp_date(user_info.get('exp_date')) + + def get_account_expiration(self): + """Get account expiration date — uses the dedicated field if set, otherwise parses JSON.""" + if self.exp_date: + return self.exp_date + return self._parse_exp_date_from_custom_properties() + + 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): + """Automatically create an M3UAccountProfile when M3UAccount is created.""" + if created: + M3UAccountProfile.objects.create( + m3u_account=instance, + name=f"{instance.name} Default", + max_streams=instance.max_streams, + is_default=True, + is_active=True, + search_pattern="^(.*)$", + replace_pattern="$1", + ) + else: + profile = M3UAccountProfile.objects.get( + m3u_account=instance, + is_default=True, + ) + + profile.max_streams = instance.max_streams + profile.save() diff --git a/apps/m3u/serializers.py b/apps/m3u/serializers.py new file mode 100644 index 0000000..587d98c --- /dev/null +++ b/apps/m3u/serializers.py @@ -0,0 +1,384 @@ +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, +) +from datetime import timezone as dt_tz +import logging +import json + +logger = logging.getLogger(__name__) + + +class M3UFilterSerializer(serializers.ModelSerializer): + """Serializer for M3U Filters""" + + class Meta: + model = M3UFilter + 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 = [ + "id", + "name", + "max_streams", + "is_active", + "is_default", + "current_viewers", + "search_pattern", + "replace_pattern", + "custom_properties", + "exp_date", + "account", + ] + read_only_fields = ["id", "account"] + extra_kwargs = { + 'search_pattern': {'required': False, 'allow_blank': True}, + 'replace_pattern': {'required': False, 'allow_blank': True}, + 'exp_date': {'required': False, 'allow_null': True}, + } + + def create(self, validated_data): + m3u_account = self.context.get("m3u_account") + + # Use the m3u_account when creating the profile + validated_data["m3u_account_id"] = m3u_account.id + + 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: + # For default profiles, only allow updating name, custom_properties, and exp_date + allowed_fields = {'name', 'custom_properties', 'exp_date'} + + # 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, notes, and expiration. " + f"Cannot modify: {', '.join(disallowed_fields)}" + ) + + return super().update(instance, validated_data) + + def destroy(self, request, *args, **kwargs): + instance = self.get_object() + if instance.is_default: + return Response( + {"error": "Default profiles cannot be deleted."}, + status=status.HTTP_400_BAD_REQUEST, + ) + return super().destroy(request, *args, **kwargs) + + +class M3UAccountSerializer(serializers.ModelSerializer): + """Serializer for M3U Account""" + + filters = serializers.SerializerMethodField() + earliest_expiration = serializers.SerializerMethodField() + all_expirations = serializers.SerializerMethodField() + exp_date = serializers.DateTimeField( + required=False, allow_null=True, write_only=True, + help_text="Expiration date for the default profile (write-through)", + ) + # Include user_agent as a mandatory field using its primary key. + user_agent = serializers.PrimaryKeyRelatedField( + queryset=UserAgent.objects.all(), + required=False, + allow_null=True, + ) + profiles = M3UAccountProfileSerializer(many=True, read_only=True) + read_only_fields = ["locked", "created_at", "updated_at"] + # channel_groups = serializers.SerializerMethodField() + 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) + cron_expression = serializers.CharField(required=False, allow_blank=True, default="") + + class Meta: + model = M3UAccount + fields = [ + "id", + "name", + "server_url", + "file_path", + "server_group", + "max_streams", + "is_active", + "created_at", + "updated_at", + "filters", + "user_agent", + "profiles", + "locked", + "channel_groups", + "refresh_interval", + "cron_expression", + "custom_properties", + "account_type", + "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", + "earliest_expiration", + "all_expirations", + "exp_date", + ] + extra_kwargs = { + "password": { + "required": False, + "allow_blank": True, + }, + } + + 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) + + # Derive cron_expression from the linked PeriodicTask's crontab (single source of truth) + # But first check if we have a transient _cron_expression (from create/update before signal runs) + cron_expr = "" + if hasattr(instance, '_cron_expression'): + cron_expr = instance._cron_expression + elif instance.refresh_task_id and instance.refresh_task and instance.refresh_task.crontab: + ct = instance.refresh_task.crontab + cron_expr = f"{ct.minute} {ct.hour} {ct.day_of_month} {ct.month_of_year} {ct.day_of_week}" + data["cron_expression"] = cron_expr + + # Surface default profile's exp_date for the form. + # Use prefetch cache (obj.profiles.all()) to avoid an extra query per account. + # Always emit a Z-suffix UTC string so JS new Date() never misinterprets it as local. + default_profile = next((p for p in instance.profiles.all() if p.is_default), None) + exp = default_profile.exp_date if default_profile else None + if exp: + exp_utc = exp.astimezone(dt_tz.utc) if exp.tzinfo else exp.replace(tzinfo=dt_tz.utc) + data["exp_date"] = exp_utc.strftime('%Y-%m-%dT%H:%M:%SZ') + else: + data["exp_date"] = None + + return data + + def update(self, instance, validated_data): + # Pop exp_date — it's written to the default profile, not the account + exp_date = validated_data.pop("exp_date", "__NOT_SET__") + + # Pop cron_expression before it reaches model fields + # If not present (partial update), preserve the existing cron from the PeriodicTask + if "cron_expression" in validated_data: + cron_expr = validated_data.pop("cron_expression") + else: + cron_expr = "" + if instance.refresh_task_id and instance.refresh_task and instance.refresh_task.crontab: + ct = instance.refresh_task.crontab + cron_expr = f"{ct.minute} {ct.hour} {ct.day_of_month} {ct.month_of_year} {ct.day_of_week}" + instance._cron_expression = cron_expr + + # 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", []) + + # First, update the M3UAccount itself + for attr, value in validated_data.items(): + setattr(instance, attr, value) + instance.save() + + # Prepare a list of memberships to update + memberships_to_update = [] + for group_data in channel_group_data: + group = group_data.get("channel_group") + enabled = group_data.get("enabled") + + try: + membership = ChannelGroupM3UAccount.objects.get( + m3u_account=instance, channel_group=group + ) + membership.enabled = enabled + memberships_to_update.append(membership) + except ChannelGroupM3UAccount.DoesNotExist: + continue + + # Perform the bulk update + if memberships_to_update: + ChannelGroupM3UAccount.objects.bulk_update( + memberships_to_update, ["enabled"] + ) + + # Write exp_date through to the default profile. + # Use a fresh DB query (not the prefetch cache) so we get the profile + # object AFTER the post_save signal (create_profile_for_m3u_account) + # has already updated max_streams, avoiding a stale-value overwrite. + if exp_date != "__NOT_SET__": + default_profile = instance.profiles.filter(is_default=True).first() + if default_profile: + default_profile.exp_date = exp_date + default_profile.save(update_fields=['exp_date']) + # Invalidate the profiles prefetch cache so to_representation + # sees the updated exp_date rather than the pre-request snapshot. + if '_prefetched_objects_cache' in instance.__dict__: + instance._prefetched_objects_cache.pop('profiles', None) + + return instance + + def create(self, validated_data): + # Pop exp_date — it's written to the default profile after creation + exp_date = validated_data.pop("exp_date", None) + + # Pop cron_expression — it's not a model field + cron_expr = validated_data.pop("cron_expression", "") + + # 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 + + # Build instance manually so we can attach transient attr before save triggers signal + instance = M3UAccount(**validated_data) + instance._cron_expression = cron_expr + instance.save() + + # Write exp_date through to the default profile created by post_save signal + if exp_date is not None: + default_profile = instance.profiles.filter(is_default=True).first() + if default_profile: + default_profile.exp_date = exp_date + default_profile.save() + + return instance + + def get_filters(self, obj): + filters = obj.filters.order_by("order") + return M3UFilterSerializer(filters, many=True).data + + def get_earliest_expiration(self, obj): + """Return the soonest exp_date across all active profiles for this account.""" + # Filter in Python over the prefetch cache to avoid an extra query per account. + expiring = [p.exp_date for p in obj.profiles.all() if p.is_active and p.exp_date] + if not expiring: + return None + exp = min(expiring) + exp_utc = exp.astimezone(dt_tz.utc) if exp.tzinfo else exp.replace(tzinfo=dt_tz.utc) + return exp_utc.strftime('%Y-%m-%dT%H:%M:%SZ') + + def get_all_expirations(self, obj): + """Return exp_date info for every profile that has one (for tooltip).""" + # Filter in Python over the prefetch cache to avoid an extra query per account. + profiles = sorted( + (p for p in obj.profiles.all() if p.exp_date), + key=lambda p: p.exp_date, + ) + return [ + { + "profile_id": p.id, + "profile_name": p.name, + "exp_date": (p.exp_date.astimezone(dt_tz.utc) if p.exp_date.tzinfo else p.exp_date.replace(tzinfo=dt_tz.utc)).strftime('%Y-%m-%dT%H:%M:%SZ'), + "is_active": p.is_active, + } + for p in profiles + ] + + +class ServerGroupSerializer(serializers.ModelSerializer): + """Serializer for Server Group""" + + class Meta: + model = ServerGroup + fields = ["id", "name"] diff --git a/apps/m3u/signals.py b/apps/m3u/signals.py new file mode 100644 index 0000000..ce24d01 --- /dev/null +++ b/apps/m3u/signals.py @@ -0,0 +1,196 @@ +# apps/m3u/signals.py +from django.db.models.signals import post_save, post_delete, pre_save +from django.dispatch import receiver +from .models import M3UAccount, M3UAccountProfile +from .tasks import refresh_single_m3u_account, refresh_m3u_groups, delete_m3u_refresh_task_by_id +from core.scheduling import create_or_update_periodic_task, delete_periodic_task +import json +import logging + +logger = logging.getLogger(__name__) + +@receiver(post_save, sender=M3UAccount) +def refresh_account_on_save(sender, instance, created, **kwargs): + """ + When an M3UAccount is saved (created or updated), + call a Celery task that fetches & parses that single account + if it is active or newly created. + """ + if created and instance.account_type != M3UAccount.Types.XC: + refresh_m3u_groups.delay(instance.id) + +@receiver(post_save, sender=M3UAccount) +def create_or_update_refresh_task(sender, instance, created, update_fields=None, **kwargs): + """ + Create or update a Celery Beat periodic task when an M3UAccount is created/updated. + Supports both interval-based and cron-based scheduling via the shared utility. + """ + # Skip rescheduling when only non-schedule fields were saved (e.g. status/last_message + # updates from the refresh task itself). We only need to reschedule when schedule-relevant + # fields change or when _cron_expression was explicitly set by the serializer. + SCHEDULE_FIELDS = {'refresh_interval', 'is_active', 'refresh_task'} + if ( + not created + and update_fields is not None + and not (set(update_fields) & SCHEDULE_FIELDS) + and not hasattr(instance, '_cron_expression') + ): + return + + task_name = f"m3u_account-refresh-{instance.id}" + should_be_enabled = instance.is_active + + # Read cron_expression from transient attribute set by the serializer. + # If not set (e.g. save came from a task updating status/last_message), + # preserve the existing crontab so we don't accidentally revert to interval. + if hasattr(instance, "_cron_expression"): + cron_expr = instance._cron_expression + else: + cron_expr = "" + try: + existing_task = instance.refresh_task + if existing_task and existing_task.crontab: + ct = existing_task.crontab + cron_expr = f"{ct.minute} {ct.hour} {ct.day_of_month} {ct.month_of_year} {ct.day_of_week}" + except Exception: + pass + + task = create_or_update_periodic_task( + task_name=task_name, + celery_task_path="apps.m3u.tasks.refresh_single_m3u_account", + kwargs={"account_id": instance.id}, + interval_hours=int(instance.refresh_interval), + cron_expression=cron_expr, + enabled=should_be_enabled, + ) + + # Ensure instance has the task linked + if instance.refresh_task_id != task.id: + M3UAccount.objects.filter(id=instance.id).update(refresh_task=task) + +@receiver(post_save, sender=M3UAccountProfile) +def update_profile_expiration_notification(sender, instance, created, update_fields=None, **kwargs): + """ + When a profile's exp_date is set or changed, immediately update its expiration notification + so the frontend reflects the new state without waiting for the daily celery task. + """ + # Only act when exp_date was involved in the save + if not created and update_fields is not None and "exp_date" not in update_fields: + return + + try: + if not instance.exp_date: + # exp_date was cleared — remove any existing notifications immediately + from core.models import SystemNotification + from core.utils import send_notification_dismissed + + keys = [f"xc-exp-warning-{instance.id}", f"xc-exp-expired-{instance.id}"] + deleted_keys = list( + SystemNotification.objects.filter(notification_key__in=keys) + .values_list("notification_key", flat=True) + ) + SystemNotification.objects.filter(notification_key__in=deleted_keys).delete() + for key in deleted_keys: + send_notification_dismissed(key) + return + + from apps.m3u.tasks import evaluate_profile_expiration_notification + evaluate_profile_expiration_notification(instance) + except Exception as e: + logger.error(f"Error updating expiration notification for profile {instance.id}: {str(e)}") + + +@receiver(post_delete, sender=M3UAccountProfile) +def cleanup_profile_notifications(sender, instance, **kwargs): + """ + Delete expiration notifications for a profile when it is deleted. + Handles both direct deletion and cascade deletion from M3UAccount. + """ + try: + from core.models import SystemNotification + from core.utils import send_notification_dismissed + + keys = [f"xc-exp-warning-{instance.id}", f"xc-exp-expired-{instance.id}"] + deleted_keys = list( + SystemNotification.objects.filter(notification_key__in=keys) + .values_list("notification_key", flat=True) + ) + if deleted_keys: + SystemNotification.objects.filter(notification_key__in=deleted_keys).delete() + for key in deleted_keys: + send_notification_dismissed(key) + logger.debug(f"Cleaned up {len(deleted_keys)} notifications for deleted profile {instance.id}") + except Exception as e: + logger.error(f"Error cleaning up notifications for profile {instance.id}: {str(e)}") + + +@receiver(post_delete, sender=M3UAccount) +def delete_refresh_task(sender, instance, **kwargs): + """ + Delete the associated Celery Beat periodic task when a Channel is deleted. + """ + try: + # First try the foreign key relationship to find the task ID + task = None + if instance.refresh_task: + logger.info(f"Found task via foreign key: {instance.refresh_task.id} for M3UAccount {instance.id}") + task = instance.refresh_task + + # Use the helper function to delete the task + if task: + delete_m3u_refresh_task_by_id(instance.id) + else: + # Otherwise use the helper function + delete_m3u_refresh_task_by_id(instance.id) + except Exception as e: + logger.error(f"Error in delete_refresh_task signal handler: {str(e)}", exc_info=True) + +@receiver(pre_save, sender=M3UAccount) +def update_status_on_active_change(sender, instance, **kwargs): + """ + When an M3UAccount's is_active field changes, update the status accordingly. + """ + if instance.pk: # Only for existing records, not new ones + try: + # Get the current record from the database + old_instance = M3UAccount.objects.get(pk=instance.pk) + + # If is_active changed, update the status + if old_instance.is_active != instance.is_active: + if instance.is_active: + # When activating, set status to idle + instance.status = M3UAccount.Status.IDLE + else: + # When deactivating, set status to disabled + instance.status = M3UAccount.Status.DISABLED + # Clean up any expiration notifications for all profiles of this account + try: + from core.models import SystemNotification + from core.utils import send_notification_dismissed + + profile_ids = list( + M3UAccountProfile.objects.filter(m3u_account=instance) + .values_list("id", flat=True) + ) + keys = [ + key + for pid in profile_ids + for key in [f"xc-exp-warning-{pid}", f"xc-exp-expired-{pid}"] + ] + if keys: + deleted_keys = list( + SystemNotification.objects.filter(notification_key__in=keys) + .values_list("notification_key", flat=True) + ) + if deleted_keys: + SystemNotification.objects.filter(notification_key__in=deleted_keys).delete() + for key in deleted_keys: + send_notification_dismissed(key) + logger.debug( + f"Cleaned up {len(deleted_keys)} notifications for deactivated M3U account {instance.id}" + ) + except Exception as notify_err: + logger.error(f"Error cleaning up notifications on account deactivation: {notify_err}") + except M3UAccount.DoesNotExist: + # New record, will use default status + pass diff --git a/apps/m3u/tasks.py b/apps/m3u/tasks.py new file mode 100644 index 0000000..8f446b6 --- /dev/null +++ b/apps/m3u/tasks.py @@ -0,0 +1,3360 @@ +# apps/m3u/tasks.py +import logging +import re +import regex +import requests +import os +import gc +import gzip, zipfile +from concurrent.futures import ThreadPoolExecutor, as_completed +from celery.app.control import Inspect +from celery.result import AsyncResult +from celery import shared_task, current_app, group +from django.conf import settings +from django.core.cache import cache +from django.db import models, transaction +from .models import M3UAccount +from apps.channels.models import Stream, ChannelGroup, ChannelGroupM3UAccount +from asgiref.sync import async_to_sync +from channels.layers import get_channel_layer +from django.utils import timezone +import time +import json +from core.utils import ( + RedisClient, + acquire_task_lock, + release_task_lock, + TaskLockRenewer, + natural_sort_key, + log_system_event, +) +from core.models import CoreSettings, UserAgent +from asgiref.sync import async_to_sync +from core.xtream_codes import Client as XCClient +from core.utils import send_websocket_update +from .utils import normalize_stream_url + +logger = logging.getLogger(__name__) + +BATCH_SIZE = 1500 # Optimized batch size for threading +m3u_dir = os.path.join(settings.MEDIA_ROOT, "cached_m3u") + +_EXTINF_ATTR_RE = re.compile(r'([^\s=]+)\s*=\s*(["\'])(.*?)\2') + + +def fetch_m3u_lines(account, use_cache=False): + os.makedirs(m3u_dir, exist_ok=True) + file_path = os.path.join(m3u_dir, f"{account.id}.m3u") + + """Fetch M3U file lines efficiently.""" + if account.server_url: + if not use_cache or not os.path.exists(file_path): + try: + # Try to get account-specific user agent first + user_agent_obj = account.get_user_agent() + user_agent = ( + user_agent_obj.user_agent + if user_agent_obj + else "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36" + ) + + logger.debug( + f"Using user agent: {user_agent} for M3U account: {account.name}" + ) + headers = {"User-Agent": user_agent} + logger.info(f"Fetching from URL {account.server_url}") + + # Set account status to FETCHING before starting download + account.status = M3UAccount.Status.FETCHING + account.last_message = "Starting download..." + account.save(update_fields=["status", "last_message"]) + + response = requests.get( + account.server_url, headers=headers, stream=True, + timeout=(30, 60), # 30s connect, 60s read between chunks + ) + + # Log the actual response details for debugging + logger.debug(f"HTTP Response: {response.status_code} from {account.server_url}") + logger.debug(f"Content-Type: {response.headers.get('content-type', 'Not specified')}") + logger.debug(f"Content-Length: {response.headers.get('content-length', 'Not specified')}") + logger.debug(f"Response headers: {dict(response.headers)}") + + # Check if we've been redirected to a different URL + if hasattr(response, 'url') and response.url != account.server_url: + logger.warning(f"Request was redirected from {account.server_url} to {response.url}") + + # Check for ANY non-success status code FIRST (before raise_for_status) + if response.status_code < 200 or response.status_code >= 300: + # For error responses, read the content immediately (not streaming) + try: + response_content = response.text[:1000] # Capture up to 1000 characters + logger.error(f"Error response content: {response_content!r}") + except Exception as e: + logger.error(f"Could not read error response content: {e}") + response_content = "Could not read error response content" + + # Provide specific messages for known non-standard codes + if response.status_code == 884: + error_msg = f"Server returned HTTP 884 (authentication/authorization failure) from URL: {account.server_url}. Server message: {response_content}" + elif response.status_code >= 800: + error_msg = f"Server returned non-standard HTTP status {response.status_code} from URL: {account.server_url}. Server message: {response_content}" + elif response.status_code == 404: + error_msg = f"M3U file not found (404) at URL: {account.server_url}. Server message: {response_content}" + elif response.status_code == 403: + error_msg = f"Access forbidden (403) to M3U file at URL: {account.server_url}. Server message: {response_content}" + elif response.status_code == 401: + error_msg = f"Authentication required (401) for M3U file at URL: {account.server_url}. Server message: {response_content}" + elif response.status_code == 500: + error_msg = f"Server error (500) while fetching M3U file from URL: {account.server_url}. Server message: {response_content}" + else: + error_msg = f"HTTP error ({response.status_code}) while fetching M3U file from URL: {account.server_url}. Server message: {response_content}" + + logger.error(error_msg) + account.status = M3UAccount.Status.ERROR + account.last_message = error_msg + account.save(update_fields=["status", "last_message"]) + send_m3u_update( + account.id, + "downloading", + 100, + status="error", + error=error_msg, + ) + return [], False + + # Only call raise_for_status if we have a success code (this should not raise now) + response.raise_for_status() + + total_size = int(response.headers.get("Content-Length", 0)) + downloaded = 0 + start_time = time.time() + last_update_time = start_time + progress = 0 + has_content = False + + # Stream directly to a temp file to avoid holding the entire + # M3U in memory (large files can be 100MB+, which would use + # ~3x that in RAM in certain approaches). + temp_path = file_path + ".tmp" + try: + send_m3u_update(account.id, "downloading", 0) + with open(temp_path, "wb") as tmp_file: + for chunk in response.iter_content(chunk_size=8192): + if chunk: + tmp_file.write(chunk) + has_content = True + + downloaded += len(chunk) + elapsed_time = time.time() - start_time + + # Calculate download speed in KB/s + speed = downloaded / elapsed_time / 1024 # in KB/s + + # Calculate progress percentage + if total_size and total_size > 0: + progress = (downloaded / total_size) * 100 + + # Time remaining (in seconds) + time_remaining = ( + (total_size - downloaded) / (speed * 1024) + if speed > 0 + else 0 + ) + + current_time = time.time() + if current_time - last_update_time >= 0.5: + last_update_time = current_time + if progress > 0: + # Update the account's last_message with detailed progress info + progress_msg = f"Downloading: {progress:.1f}% - {speed:.1f} KB/s - {time_remaining:.1f}s remaining" + account.last_message = progress_msg + account.save(update_fields=["last_message"]) + + send_m3u_update( + account.id, + "downloading", + progress, + speed=speed, + elapsed_time=elapsed_time, + time_remaining=time_remaining, + message=progress_msg, + ) + + # Check if we actually received any content + logger.info(f"Download completed. Has content: {has_content}, Content length: {downloaded} bytes") + if not has_content or downloaded == 0: + error_msg = f"Server responded successfully (HTTP {response.status_code}) but provided empty M3U file from URL: {account.server_url}" + logger.error(error_msg) + account.status = M3UAccount.Status.ERROR + account.last_message = error_msg + account.save(update_fields=["status", "last_message"]) + send_m3u_update( + account.id, + "downloading", + 100, + status="error", + error=error_msg, + ) + return [], False + + # Validate the file by reading only the first portion from + # disk — no need to load the entire file into memory just + # to check the header. + VALIDATION_READ_SIZE = 32768 # 32KB covers headers comfortably + try: + with open(temp_path, "rb") as vf: + head_bytes = vf.read(VALIDATION_READ_SIZE) + head_str = head_bytes.decode('utf-8', errors='ignore') + head_lines = head_str.strip().split('\n') + + # Count total lines efficiently without loading full file + with open(temp_path, "rb") as vf: + total_lines = sum(1 for _ in vf) + + # Log first few lines for debugging (be careful not to log too much) + preview_lines = head_lines[:5] + logger.info(f"Content preview (first 5 lines): {preview_lines}") + logger.info(f"Total lines in content: {total_lines}") + + # Check if it's a valid M3U file (should start with #EXTM3U or contain M3U-like content) + is_valid_m3u = False + + # First, check if this looks like an error response disguised as 200 OK + head_lower = head_str.lower() + if any(error_indicator in head_lower for error_indicator in [ + ' dict: + """ + Parse an EXTINF line from an M3U file. + This function removes the "#EXTINF:" prefix, then extracts all key="value" attributes, + and treats everything after the last attribute as the display name. + + Returns a dictionary with: + - 'attributes': a dict of attribute key/value pairs (e.g. tvg-id, tvg-logo, group-title) + - 'display_name': the text after the attributes (the fallback display name) + - 'name': the value from tvg-name (if present) or the display name otherwise. + """ + if not line.startswith("#EXTINF:"): + return None + content = line[len("#EXTINF:") :].strip() + + # Single pass: extract all attributes AND track the last attribute position. + # Keys are normalised to lowercase so downstream code can use plain dict.get() + attrs = {} + last_attr_end = 0 + + for match in _EXTINF_ATTR_RE.finditer(content): + attrs[match.group(1).lower()] = match.group(3) + last_attr_end = match.end() + + # Everything after the last attribute (skipping leading comma and whitespace) is the display name + if last_attr_end > 0: + remaining = content[last_attr_end:].strip() + # Remove leading comma if present + if remaining.startswith(','): + remaining = remaining[1:].strip() + display_name = remaining + else: + # No attributes found, try the old comma-split method as fallback + parts = content.split(',', 1) + if len(parts) == 2: + display_name = parts[1].strip() + else: + display_name = content.strip() + + # Per the base #EXTINF spec, the comma text is the canonical human-readable title. + # Fall back to tvc-guide-title, then tvg-name (which some providers use as an EPG key, + # not a display label), and finally the raw content if everything else is empty. + name = display_name or attrs.get("tvc-guide-title") or attrs.get("tvg-name") or content.strip() + return {"attributes": attrs, "display_name": display_name, "name": name} + + +def iter_m3u_entries(lines): + """ + Generator that yields fully-assembled M3U stream entries from raw lines. + + Each yielded dict is guaranteed to contain a ``url`` key in addition to the + fields produced by :func:`parse_extinf_line` (``attributes``, ``display_name``, + ``name``). Recognised extended-tag lines that appear *between* an ``#EXTINF`` + and its URL are accumulated into the pending entry so they are available for + downstream processing: + + - ``#EXTGRP`` — sets ``attributes["group-title"]`` when no ``group-title`` + attribute was present on the ``#EXTINF`` line (explicit attribute wins). + - ``#EXTVLCOPT`` — stored as a list under the ``vlc_opts`` key. + + Unknown directives (``#KODIPROP``, etc.) and blank lines are + silently skipped while keeping the pending entry intact. A second ``#EXTINF`` + before a URL discards the first entry with a warning. A trailing ``#EXTINF`` + at end-of-file with no URL is also discarded. + + Adding support for a new directive requires only a new ``elif`` branch here; + no other code needs to change. + """ + pending = None + pending_line_num = None + for line_num, raw_line in enumerate(lines, 1): + line = raw_line.strip() + if not line: + continue + + if line.startswith("#EXTINF"): + if pending is not None: + logger.warning( + f"Line {pending_line_num}: #EXTINF had no URL (next #EXTINF at line {line_num}); " + f"discarding entry: {list(pending['attributes'].items())[:3]}" + ) + parsed = parse_extinf_line(line) + if parsed is None: + logger.warning(f"Line {line_num}: Failed to parse #EXTINF: {line[:200]}") + pending = parsed # None if malformed; URL branch guards on `pending is not None` + pending_line_num = line_num + + elif line.startswith("#EXTGRP:"): + # Only apply when group-title is absent — explicit attribute wins. + if pending is not None and "group-title" not in pending["attributes"]: + pending["attributes"]["group-title"] = line[len("#EXTGRP:"):].strip() + # else: #EXTGRP outside an entry, or group-title already set — silently skip + + elif line.startswith("#EXTVLCOPT:"): + if pending is not None: + pending.setdefault("vlc_opts", []).append(line[len("#EXTVLCOPT:"):]) + # else: #EXTVLCOPT outside an entry — silently skip + + elif pending is not None and line.startswith(("http", "rtsp", "rtp", "udp")): + pending["url"] = normalize_stream_url(line) if line.startswith("udp") else line + yield pending + pending = None + pending_line_num = None + + # else: unknown directive or bare content — skip, keeping pending intact + + if pending is not None: + logger.warning( + f"Line {pending_line_num}: #EXTINF at end of file had no URL; " + f"discarding entry: {list(pending['attributes'].items())[:3]}" + ) + + +@shared_task +def refresh_m3u_accounts(): + """Queue background parse for all active M3UAccounts.""" + active_accounts = M3UAccount.objects.filter(is_active=True) + count = 0 + for account in active_accounts: + refresh_single_m3u_account.delay(account.id) + count += 1 + + msg = f"Queued M3U refresh for {count} active account(s)." + logger.info(msg) + return msg + + +def check_field_lengths(streams_to_create): + for stream in streams_to_create: + for field, value in stream.__dict__.items(): + if isinstance(value, str) and len(value) > 255: + print(f"{field} --- {value}") + + print("") + print("") + + +@shared_task +def process_groups(account, groups, scan_start_time=None): + """Process groups and update their relationships with the M3U account. + + Args: + account: M3UAccount instance + groups: Dict of group names to custom properties + scan_start_time: Timestamp when the scan started (for consistent last_seen marking) + """ + # Use scan_start_time if provided, otherwise current time + # This ensures consistency with stream processing and cleanup logic + if scan_start_time is None: + scan_start_time = timezone.now() + + existing_groups = { + group.name: group + for group in ChannelGroup.objects.filter(name__in=groups.keys()) + } + logger.info(f"Currently {len(existing_groups)} existing groups") + + # Check if we should auto-enable new groups based on account settings + account_custom_props = account.custom_properties or {} + auto_enable_new_groups_live = account_custom_props.get("auto_enable_new_groups_live", True) + + # Separate existing groups from groups that need to be created + existing_group_objs = [] + groups_to_create = [] + + for group_name, custom_props in groups.items(): + if group_name in existing_groups: + existing_group_objs.append(existing_groups[group_name]) + else: + groups_to_create.append(ChannelGroup(name=group_name)) + + # Create new groups and fetch them back with IDs + newly_created_group_objs = [] + if groups_to_create: + logger.info(f"Creating {len(groups_to_create)} new groups for account {account.id}") + newly_created_group_objs = list(ChannelGroup.bulk_create_and_fetch(groups_to_create)) + logger.debug(f"Successfully created {len(newly_created_group_objs)} new groups") + + # Combine all groups + all_group_objs = existing_group_objs + newly_created_group_objs + + # Get existing relationships for this account + existing_relationships = { + rel.channel_group.name: rel + for rel in ChannelGroupM3UAccount.objects.filter( + m3u_account=account, + channel_group__name__in=groups.keys() + ).select_related('channel_group') + } + + relations_to_create = [] + relations_to_update = [] + + for group in all_group_objs: + custom_props = groups.get(group.name, {}) + + if group.name in existing_relationships: + # Update existing relationship if xc_id has changed (preserve other custom properties) + existing_rel = existing_relationships[group.name] + + # Get existing custom properties (now JSONB, no need to parse) + existing_custom_props = existing_rel.custom_properties or {} + + # Get the new xc_id from groups data + new_xc_id = custom_props.get("xc_id") + existing_xc_id = existing_custom_props.get("xc_id") + + # Only update if xc_id has changed + if new_xc_id != existing_xc_id: + # Merge new xc_id with existing custom properties to preserve user settings + updated_custom_props = existing_custom_props.copy() + if new_xc_id is not None: + updated_custom_props["xc_id"] = new_xc_id + elif "xc_id" in updated_custom_props: + # Remove xc_id if it's no longer provided (e.g., converting from XC to standard) + del updated_custom_props["xc_id"] + + existing_rel.custom_properties = updated_custom_props + existing_rel.last_seen = scan_start_time + existing_rel.is_stale = False + relations_to_update.append(existing_rel) + logger.debug(f"Updated xc_id for group '{group.name}' from '{existing_xc_id}' to '{new_xc_id}' - account {account.id}") + else: + # Update last_seen even if xc_id hasn't changed + existing_rel.last_seen = scan_start_time + existing_rel.is_stale = False + relations_to_update.append(existing_rel) + logger.debug(f"xc_id unchanged for group '{group.name}' - account {account.id}") + else: + # Create new relationship - this group is new to this M3U account + # Use the auto_enable setting to determine if it should start enabled + if not auto_enable_new_groups_live: + logger.info(f"Group '{group.name}' is new to account {account.id} - creating relationship but DISABLED (auto_enable_new_groups_live=False)") + + relations_to_create.append( + ChannelGroupM3UAccount( + channel_group=group, + m3u_account=account, + custom_properties=custom_props, + enabled=auto_enable_new_groups_live, + last_seen=scan_start_time, + is_stale=False, + ) + ) + + # Bulk create new relationships + if relations_to_create: + ChannelGroupM3UAccount.objects.bulk_create(relations_to_create, ignore_conflicts=True) + logger.debug(f"Created {len(relations_to_create)} new group relationships for account {account.id}") + + # Bulk update existing relationships + if relations_to_update: + ChannelGroupM3UAccount.objects.bulk_update(relations_to_update, ['custom_properties', 'last_seen', 'is_stale']) + logger.info(f"Updated {len(relations_to_update)} existing group relationships for account {account.id}") + + +def cleanup_stale_group_relationships(account, scan_start_time): + """ + Remove group relationships that haven't been seen since the stale retention period. + This follows the same logic as stream cleanup for consistency. + """ + # Calculate cutoff date for stale group relationships + stale_cutoff = scan_start_time - timezone.timedelta(days=account.stale_stream_days) + logger.info( + f"Removing group relationships not seen since {stale_cutoff} for M3U account {account.id}" + ) + + # Find stale relationships + stale_relationships = ChannelGroupM3UAccount.objects.filter( + m3u_account=account, + last_seen__lt=stale_cutoff + ).select_related('channel_group') + + relations_to_delete = list(stale_relationships) + deleted_count = len(relations_to_delete) + + if deleted_count > 0: + logger.info( + f"Found {deleted_count} stale group relationships for account {account.id}: " + f"{[rel.channel_group.name for rel in relations_to_delete]}" + ) + + # Delete the stale relationships + stale_relationships.delete() + + # Check if any of the deleted relationships left groups with no remaining associations + orphaned_group_ids = [] + for rel in relations_to_delete: + group = rel.channel_group + + # Check if this group has any remaining M3U account relationships + remaining_m3u_relationships = ChannelGroupM3UAccount.objects.filter( + channel_group=group + ).exists() + + # Check if this group has any direct channels (not through M3U accounts) + has_direct_channels = group.related_channels().exists() + + # If no relationships and no direct channels, it's safe to delete + if not remaining_m3u_relationships and not has_direct_channels: + orphaned_group_ids.append(group.id) + logger.debug(f"Group '{group.name}' has no remaining associations and will be deleted") + + # Delete truly orphaned groups + if orphaned_group_ids: + deleted_groups = list(ChannelGroup.objects.filter(id__in=orphaned_group_ids).values_list('name', flat=True)) + ChannelGroup.objects.filter(id__in=orphaned_group_ids).delete() + logger.info(f"Deleted {len(orphaned_group_ids)} orphaned groups that had no remaining associations: {deleted_groups}") + else: + logger.debug(f"No stale group relationships found for account {account.id}") + + return deleted_count + + +def collect_xc_streams(account_id, enabled_groups): + """Collect all XC streams in a single API call and filter by enabled groups.""" + account = M3UAccount.objects.get(id=account_id) + all_streams = [] + + # Create a mapping from category_id to group info for filtering + enabled_category_ids = {} + for group_name, props in enabled_groups.items(): + if "xc_id" in props: + enabled_category_ids[str(props["xc_id"])] = { + "name": group_name, + "props": props + } + + try: + with XCClient( + account.server_url, + account.username, + account.password, + account.get_user_agent(), + ) as xc_client: + + # Fetch ALL live streams in a single API call (much more efficient) + logger.info("Fetching ALL live streams from XC provider...") + all_xc_streams = xc_client.get_all_live_streams() # Get all streams without category filter + + if not all_xc_streams: + logger.warning("No live streams returned from XC provider") + return [] + + logger.info(f"Retrieved {len(all_xc_streams)} total live streams from provider") + + # Filter streams based on enabled categories + filtered_count = 0 + for stream in all_xc_streams: + # Fall back to a generated name if the provider returns null/empty + stream_name = stream.get("name") or f"{account.name} - {stream.get('stream_id', 'Unknown')}" + if not stream.get("name"): + logger.warning( + f"XC stream has null/empty name; using generated name '{stream_name}' " + f"(stream_id={stream.get('stream_id', 'unknown')})" + ) + + # Get the category_id for this stream + category_id = str(stream.get("category_id", "")) + + # Only include streams from enabled categories + if category_id in enabled_category_ids: + group_info = enabled_category_ids[category_id] + + # Convert XC stream to our standard format with all properties preserved + stream_data = { + "name": stream_name, + "url": xc_client.get_stream_url(stream["stream_id"]), + "attributes": { + "tvg-id": stream.get("epg_channel_id", ""), + "tvg-logo": stream.get("stream_icon", ""), + "group-title": group_info["name"], + # Preserve all XC stream properties as custom attributes + "stream_id": str(stream.get("stream_id", "")), + "num": stream.get("num"), + "category_id": category_id, + "stream_type": stream.get("stream_type", ""), + "added": stream.get("added", ""), + "is_adult": str(stream.get("is_adult", "0")), + "custom_sid": stream.get("custom_sid", ""), + # Include any other properties that might be present + **{k: str(v) for k, v in stream.items() if k not in [ + "name", "stream_id", "epg_channel_id", "stream_icon", + "category_id", "stream_type", "added", "is_adult", "custom_sid", "num" + ] and v is not None} + } + } + all_streams.append(stream_data) + filtered_count += 1 + + except Exception as e: + logger.error(f"Failed to fetch XC streams: {str(e)}") + return [] + + logger.info(f"Filtered {filtered_count} streams from {len(enabled_category_ids)} enabled categories") + return all_streams + +def process_xc_category_direct(account_id, batch, groups, hash_keys): + from django.db import connections + + # Ensure clean database connections for threading + connections.close_all() + + account = M3UAccount.objects.get(id=account_id) + + streams_to_create = [] + streams_to_update = [] + stream_hashes = {} + + try: + with XCClient( + account.server_url, + account.username, + account.password, + account.get_user_agent(), + ) as xc_client: + # Log the batch details to help with debugging + logger.debug(f"Processing XC batch: {batch}") + + for group_name, props in batch.items(): + # Check if we have a valid xc_id for this group + if "xc_id" not in props: + logger.error( + f"Missing xc_id for group {group_name} in batch {batch}" + ) + continue + + # Get actual group ID from the mapping + group_id = groups.get(group_name) + if not group_id: + logger.error(f"Group {group_name} not found in enabled groups") + continue + + try: + logger.debug( + f"Fetching streams for XC category: {group_name} (ID: {props['xc_id']})" + ) + streams = xc_client.get_live_category_streams(props["xc_id"]) + + if not streams: + logger.warning( + f"No streams found for XC category {group_name} (ID: {props['xc_id']})" + ) + continue + + logger.debug( + f"Found {len(streams)} streams for category {group_name}" + ) + + for stream in streams: + name = stream.get("name") or f"{account.name} - {stream.get('stream_id', 'Unknown')}" + if not stream.get("name"): + logger.warning( + f"XC stream has null/empty name in category {group_name}; " + f"using generated name '{name}' (stream_id={stream.get('stream_id', 'unknown')})" + ) + raw_stream_id = stream.get("stream_id", "") + provider_stream_id = None + if raw_stream_id: + try: + provider_stream_id = int(raw_stream_id) + except (ValueError, TypeError): + pass + url = xc_client.get_stream_url(stream["stream_id"]) + tvg_id = stream.get("epg_channel_id", "") + tvg_logo = stream.get("stream_icon", "") + group_title = group_name + stream_chno = stream.get("num") + # Convert stream_chno to float if valid, otherwise None + if stream_chno is not None: + try: + stream_chno = float(stream_chno) + except (ValueError, TypeError): + stream_chno = None + + stream_hash = Stream.generate_hash_key( + name, url, tvg_id, hash_keys, m3u_id=account_id, group=group_title, + account_type='XC', stream_id=provider_stream_id + ) + stream_props = { + "name": name, + "url": url, + "logo_url": tvg_logo, + "tvg_id": tvg_id, + "m3u_account": account, + "channel_group_id": int(group_id), + "stream_hash": stream_hash, + "custom_properties": stream, + "is_adult": parse_is_adult(stream.get("is_adult", 0)), + "is_stale": False, + "stream_id": provider_stream_id, + "stream_chno": stream_chno, + } + + if stream_hash not in stream_hashes: + stream_hashes[stream_hash] = stream_props + except Exception as e: + logger.error( + f"Error processing XC category {group_name} (ID: {props['xc_id']}): {str(e)}" + ) + continue + + # Process all found streams + existing_streams = { + s.stream_hash: s + for s in Stream.objects.filter(stream_hash__in=stream_hashes.keys()).select_related('m3u_account').only( + 'id', 'stream_hash', 'name', 'url', 'logo_url', 'tvg_id', 'custom_properties', 'last_seen', 'updated_at', 'm3u_account', 'stream_id', 'stream_chno' + ) + } + + for stream_hash, stream_props in stream_hashes.items(): + if stream_hash in existing_streams: + obj = existing_streams[stream_hash] + # Optimized field comparison for XC streams + changed = ( + obj.name != stream_props["name"] or + obj.url != stream_props["url"] or + obj.logo_url != stream_props["logo_url"] or + obj.tvg_id != stream_props["tvg_id"] or + obj.custom_properties != stream_props["custom_properties"] or + obj.is_adult != stream_props["is_adult"] or + obj.stream_id != stream_props["stream_id"] or + obj.stream_chno != stream_props["stream_chno"] + ) + + if changed: + for key, value in stream_props.items(): + setattr(obj, key, value) + obj.last_seen = timezone.now() + obj.updated_at = timezone.now() # Update timestamp only for changed streams + obj.is_stale = False + streams_to_update.append(obj) + else: + # Always update last_seen, even if nothing else changed + obj.last_seen = timezone.now() + obj.is_stale = False + # Don't update updated_at for unchanged streams + streams_to_update.append(obj) + + # Remove from existing_streams since we've processed it + del existing_streams[stream_hash] + else: + stream_props["last_seen"] = timezone.now() + stream_props["updated_at"] = ( + timezone.now() + ) # Set initial updated_at for new streams + stream_props["is_stale"] = False + streams_to_create.append(Stream(**stream_props)) + + try: + with transaction.atomic(): + if streams_to_create: + Stream.objects.bulk_create(streams_to_create, ignore_conflicts=True) + + if streams_to_update: + # Simplified bulk update for better performance + Stream.objects.bulk_update( + streams_to_update, + ['name', 'url', 'logo_url', 'tvg_id', 'custom_properties', 'is_adult', 'last_seen', 'updated_at', 'is_stale', 'stream_id', 'stream_chno'], + batch_size=150 # Smaller batch size for XC processing + ) + + # Update last_seen for any remaining existing streams that weren't processed + if len(existing_streams.keys()) > 0: + Stream.objects.bulk_update(existing_streams.values(), ["last_seen"]) + except Exception as e: + logger.error(f"Bulk operation failed for XC streams: {str(e)}") + + retval = f"Batch processed: {len(streams_to_create)} created, {len(streams_to_update)} updated." + + except Exception as e: + logger.error(f"XC category processing error: {str(e)}") + retval = f"Error processing XC batch: {str(e)}" + finally: + # Clean up database connections for threading + connections.close_all() + + # Aggressive garbage collection + del streams_to_create, streams_to_update, stream_hashes, existing_streams + gc.collect() + + return retval + + +def process_m3u_batch_direct(account_id, batch, groups, hash_keys): + """Processes a batch of M3U streams using bulk operations with thread-safe DB connections.""" + from django.db import connections + + # Ensure clean database connections for threading + connections.close_all() + + account = M3UAccount.objects.get(id=account_id) + + compiled_filters = [ + ( + re.compile( + f.regex_pattern, + ( + re.IGNORECASE + if (f.custom_properties or {}).get( + "case_sensitive", True + ) + == False + else 0 + ), + ), + f, + ) + for f in account.filters.order_by("order") + ] + + streams_to_create = [] + streams_to_update = [] + stream_hashes = {} + + logger.debug(f"Processing batch of {len(batch)} for M3U account {account_id}") + if compiled_filters: + logger.debug(f"Using compiled filters: {[f[1].regex_pattern for f in compiled_filters]}") + for stream_info in batch: + try: + name, url = stream_info["name"], stream_info["url"] + + # Validate URL length - maximum of 4096 characters + if url and len(url) > 4096: + logger.warning(f"Skipping stream '{name}': URL too long ({len(url)} characters, max 4096)") + continue + + tvg_id, tvg_logo = get_case_insensitive_attr( + stream_info["attributes"], "tvg-id", "" + ), get_case_insensitive_attr(stream_info["attributes"], "tvg-logo", "") + group_title = get_case_insensitive_attr( + stream_info["attributes"], "group-title", "Default Group" + ) + logger.debug(f"Processing stream: {name} - {url} in group {group_title}") + include = True + for pattern, filter in compiled_filters: + logger.trace(f"Checking filter pattern {pattern}") + target = name + if filter.filter_type == "url": + target = url + elif filter.filter_type == "group": + target = group_title + + if pattern.search(target or ""): + logger.debug( + f"Stream {name} - {url} matches filter pattern {filter.regex_pattern}" + ) + include = not filter.exclude + break + + if not include: + logger.debug(f"Stream excluded by filter, skipping.") + continue + + # Filter out disabled groups for this account + if group_title not in groups: + logger.debug( + f"Skipping stream in disabled or excluded group: {group_title}" + ) + continue + + # Determine provider-specific fields first + provider_stream_id = None + channel_num = None + account_type_for_hash = None + + if account.account_type == M3UAccount.Types.XC: + account_type_for_hash = 'XC' + raw_stream_id = stream_info["attributes"].get("stream_id", "") + if raw_stream_id: + try: + provider_stream_id = int(raw_stream_id) + except (ValueError, TypeError): + pass + raw_num = stream_info["attributes"].get("num") + if raw_num is not None: + try: + channel_num = float(raw_num) + except (ValueError, TypeError): + pass + else: + # For standard M3U accounts, check for tvg-chno or channel-number + tvg_chno = get_case_insensitive_attr(stream_info["attributes"], "tvg-chno", None) + if tvg_chno is None: + tvg_chno = get_case_insensitive_attr(stream_info["attributes"], "channel-number", None) + if tvg_chno is not None: + try: + channel_num = float(tvg_chno) + except (ValueError, TypeError): + pass + + # Generate hash once with all parameters + stream_hash = Stream.generate_hash_key( + name, url, tvg_id, hash_keys, m3u_id=account_id, group=group_title, + account_type=account_type_for_hash, stream_id=provider_stream_id + ) + + stream_props = { + "name": name, + "url": url, + "logo_url": tvg_logo, + "tvg_id": tvg_id, + "m3u_account": account, + "channel_group_id": int(groups.get(group_title)), + "stream_hash": stream_hash, + "custom_properties": {**stream_info["attributes"], "vlc_opts": stream_info["vlc_opts"]} if "vlc_opts" in stream_info else stream_info["attributes"], + "is_adult": parse_is_adult(stream_info["attributes"].get("is_adult", 0)), + "is_stale": False, + "stream_id": provider_stream_id, + "stream_chno": channel_num, + } + + if stream_hash not in stream_hashes: + stream_hashes[stream_hash] = stream_props + except Exception as e: + logger.error(f"Failed to process stream {name}: {e}") + logger.error(json.dumps(stream_info)) + + existing_streams = { + s.stream_hash: s + for s in Stream.objects.filter(stream_hash__in=stream_hashes.keys()).select_related('m3u_account').only( + 'id', 'stream_hash', 'name', 'url', 'logo_url', 'tvg_id', 'custom_properties', 'last_seen', 'updated_at', 'm3u_account', 'stream_id', 'stream_chno' + ) + } + + for stream_hash, stream_props in stream_hashes.items(): + if stream_hash in existing_streams: + obj = existing_streams[stream_hash] + # Optimized field comparison + changed = ( + obj.name != stream_props["name"] or + obj.url != stream_props["url"] or + obj.logo_url != stream_props["logo_url"] or + obj.tvg_id != stream_props["tvg_id"] or + obj.custom_properties != stream_props["custom_properties"] or + obj.is_adult != stream_props["is_adult"] or + obj.stream_id != stream_props["stream_id"] or + obj.stream_chno != stream_props["stream_chno"] + ) + + # Always update last_seen + obj.last_seen = timezone.now() + + if changed: + # Only update fields that changed and set updated_at + obj.name = stream_props["name"] + obj.url = stream_props["url"] + obj.logo_url = stream_props["logo_url"] + obj.tvg_id = stream_props["tvg_id"] + obj.custom_properties = stream_props["custom_properties"] + obj.is_adult = stream_props["is_adult"] + obj.stream_id = stream_props["stream_id"] + obj.stream_chno = stream_props["stream_chno"] + obj.updated_at = timezone.now() + + # Always mark as not stale since we saw it in this refresh + obj.is_stale = False + + streams_to_update.append(obj) + else: + # New stream + stream_props["last_seen"] = timezone.now() + stream_props["updated_at"] = timezone.now() + stream_props["is_stale"] = False + streams_to_create.append(Stream(**stream_props)) + + try: + with transaction.atomic(): + if streams_to_create: + Stream.objects.bulk_create(streams_to_create, ignore_conflicts=True) + + if streams_to_update: + # Update all streams in a single bulk operation + Stream.objects.bulk_update( + streams_to_update, + ['name', 'url', 'logo_url', 'tvg_id', 'custom_properties', 'is_adult', 'last_seen', 'updated_at', 'is_stale', 'stream_id', 'stream_chno'], + batch_size=200 + ) + except Exception as e: + logger.error(f"Bulk operation failed: {str(e)}") + + retval = f"M3U account: {account_id}, Batch processed: {len(streams_to_create)} created, {len(streams_to_update)} updated." + + # Clean up database connections for threading + connections.close_all() + + # Free batch data structures (reference-counted deallocation) + del streams_to_create, streams_to_update, stream_hashes, existing_streams + + return retval + + +def cleanup_streams(account_id, scan_start_time=timezone.now): + account = M3UAccount.objects.get(id=account_id, is_active=True) + existing_groups = ChannelGroup.objects.filter( + m3u_accounts__m3u_account=account, + m3u_accounts__enabled=True, + ).values_list("id", flat=True) + logger.info( + f"Found {len(existing_groups)} active groups for M3U account {account_id}" + ) + + # Calculate cutoff date for stale streams + stale_cutoff = scan_start_time - timezone.timedelta(days=account.stale_stream_days) + logger.info( + f"Removing streams not seen since {stale_cutoff} for M3U account {account_id}" + ) + + # Delete streams that are not in active groups + streams_to_delete = Stream.objects.filter(m3u_account=account).exclude( + channel_group__in=existing_groups + ) + + # Also delete streams that haven't been seen for longer than stale_stream_days + stale_streams = Stream.objects.filter( + m3u_account=account, last_seen__lt=stale_cutoff + ) + + deleted_count = streams_to_delete.count() + stale_count = stale_streams.count() + + streams_to_delete.delete() + stale_streams.delete() + + total_deleted = deleted_count + stale_count + logger.info( + f"Cleanup for M3U account {account_id} complete: {deleted_count} streams removed due to group filter, {stale_count} removed as stale" + ) + + # Return the total count of deleted streams + return total_deleted + + +@shared_task +def refresh_m3u_groups(account_id, use_cache=False, full_refresh=False, scan_start_time=None): + """Refresh M3U groups for an account. + + Args: + account_id: ID of the M3U account + use_cache: Whether to use cached M3U file + full_refresh: Whether this is part of a full refresh + scan_start_time: Timestamp when the scan started (for consistent last_seen marking) + """ + if not acquire_task_lock("refresh_m3u_account_groups", account_id): + return f"Task already running for account_id={account_id}.", None + + lock_renewer = TaskLockRenewer("refresh_m3u_account_groups", account_id) + lock_renewer.start() + + try: + account = M3UAccount.objects.get(id=account_id, is_active=True) + except M3UAccount.DoesNotExist: + lock_renewer.stop() + release_task_lock("refresh_m3u_account_groups", account_id) + return f"M3UAccount with ID={account_id} not found or inactive.", None + + extinf_data = [] + groups = {"Default Group": {}} + + if account.account_type == M3UAccount.Types.XC: + # Log detailed information about the account + logger.info( + f"Processing XC account {account_id} with URL: {account.server_url}" + ) + logger.debug( + f"Username: {account.username}, Has password: {'Yes' if account.password else 'No'}" + ) + + # Validate required fields + if not account.server_url: + error_msg = "Missing server URL for Xtream Codes account" + logger.error(error_msg) + account.status = M3UAccount.Status.ERROR + account.last_message = error_msg + account.save(update_fields=["status", "last_message"]) + send_m3u_update( + account_id, "processing_groups", 100, status="error", error=error_msg + ) + lock_renewer.stop() + release_task_lock("refresh_m3u_account_groups", account_id) + return error_msg, None + + if not account.username or not account.password: + error_msg = "Missing username or password for Xtream Codes account" + logger.error(error_msg) + account.status = M3UAccount.Status.ERROR + account.last_message = error_msg + account.save(update_fields=["status", "last_message"]) + send_m3u_update( + account_id, "processing_groups", 100, status="error", error=error_msg + ) + lock_renewer.stop() + release_task_lock("refresh_m3u_account_groups", account_id) + return error_msg, None + + try: + # Ensure server URL is properly formatted + server_url = account.server_url.rstrip("/") + if not ( + server_url.startswith("http://") or server_url.startswith("https://") + ): + server_url = f"http://{server_url}" + + # User agent handling - completely rewritten + try: + # Debug the user agent issue + logger.debug(f"Getting user agent for account {account.id}") + + # Use a hardcoded user agent string to avoid any issues with object structure + user_agent_string = ( + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36" + ) + + try: + # Try to get the user agent directly from the database + if account.user_agent_id: + ua_obj = UserAgent.objects.get(id=account.user_agent_id) + if ( + ua_obj + and hasattr(ua_obj, "user_agent") + and ua_obj.user_agent + ): + user_agent_string = ua_obj.user_agent + logger.debug( + f"Using user agent from account: {user_agent_string}" + ) + else: + # Get default user agent from CoreSettings + default_ua_id = CoreSettings.get_default_user_agent_id() + logger.debug( + f"Default user agent ID from settings: {default_ua_id}" + ) + if default_ua_id: + ua_obj = UserAgent.objects.get(id=default_ua_id) + if ( + ua_obj + and hasattr(ua_obj, "user_agent") + and ua_obj.user_agent + ): + user_agent_string = ua_obj.user_agent + logger.debug( + f"Using default user agent: {user_agent_string}" + ) + except Exception as e: + logger.warning( + f"Error getting user agent, using fallback: {str(e)}" + ) + + logger.debug(f"Final user agent string: {user_agent_string}") + except Exception as e: + user_agent_string = ( + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36" + ) + logger.warning( + f"Exception in user agent handling, using fallback: {str(e)}" + ) + + logger.info( + f"Creating XCClient with URL: {account.server_url}, Username: {account.username}, User-Agent: {user_agent_string}" + ) + + # Create XCClient with explicit error handling + try: + with XCClient( + account.server_url, account.username, account.password, user_agent_string + ) as xc_client: + logger.info(f"XCClient instance created successfully") + + # Queue async profile refresh task to run in background + # This prevents any delay in the main refresh process + try: + logger.info(f"Queueing background profile refresh for account {account.name}") + refresh_account_profiles.delay(account.id) + except Exception as e: + logger.warning(f"Failed to queue profile refresh task: {str(e)}") + # Don't fail the main refresh if profile refresh can't be queued + + # Get categories with detailed error handling + try: + logger.info(f"Getting live categories from XC server") + xc_categories = xc_client.get_live_categories() + logger.info( + f"Found {len(xc_categories)} categories: {xc_categories}" + ) + + # Validate response + if not isinstance(xc_categories, list): + error_msg = ( + f"Unexpected response from XC server: {xc_categories}" + ) + logger.error(error_msg) + account.status = M3UAccount.Status.ERROR + account.last_message = error_msg + account.save(update_fields=["status", "last_message"]) + send_m3u_update( + account_id, + "processing_groups", + 100, + status="error", + error=error_msg, + ) + lock_renewer.stop() + release_task_lock("refresh_m3u_account_groups", account_id) + return error_msg, None + + if len(xc_categories) == 0: + logger.warning("No categories found in XC server response") + + for category in xc_categories: + cat_name = category.get("category_name", "Unknown Category") + cat_id = category.get("category_id", "0") + logger.info(f"Adding category: {cat_name} (ID: {cat_id})") + groups[cat_name] = { + "xc_id": cat_id, + } + except Exception as e: + # Determine if this is an authentication error or category retrieval error + error_str = str(e).lower() + # Check for authentication-related keywords or HTTP status codes commonly used for auth failures + is_auth_error = any(keyword in error_str for keyword in [ + 'auth', 'credential', 'login', 'unauthorized', 'forbidden', + '401', '403', '512', '513' # HTTP status codes: 401 Unauthorized, 403 Forbidden, 512-513 (non-standard auth failure) + ]) + + if is_auth_error: + error_msg = f"Failed to authenticate with XC server: {str(e)}" + else: + error_msg = f"Failed to get categories from XC server: {str(e)}" + + logger.error(error_msg) + account.status = M3UAccount.Status.ERROR + account.last_message = error_msg + account.save(update_fields=["status", "last_message"]) + send_m3u_update( + account_id, + "processing_groups", + 100, + status="error", + error=error_msg, + ) + lock_renewer.stop() + release_task_lock("refresh_m3u_account_groups", account_id) + return error_msg, None + + except Exception as e: + error_msg = f"Failed to create XC Client: {str(e)}" + logger.error(error_msg) + account.status = M3UAccount.Status.ERROR + account.last_message = error_msg + account.save(update_fields=["status", "last_message"]) + send_m3u_update( + account_id, + "processing_groups", + 100, + status="error", + error=error_msg, + ) + lock_renewer.stop() + release_task_lock("refresh_m3u_account_groups", account_id) + return error_msg, None + except Exception as e: + error_msg = f"Unexpected error occurred in XC Client: {str(e)}" + logger.error(error_msg) + account.status = M3UAccount.Status.ERROR + account.last_message = error_msg + account.save(update_fields=["status", "last_message"]) + send_m3u_update( + account_id, "processing_groups", 100, status="error", error=error_msg + ) + lock_renewer.stop() + release_task_lock("refresh_m3u_account_groups", account_id) + return error_msg, None + else: + lines, success = fetch_m3u_lines(account, use_cache) + if not success: + # If fetch failed, don't continue processing + lock_renewer.stop() + release_task_lock("refresh_m3u_account_groups", account_id) + return f"Failed to fetch M3U data for account_id={account_id}.", None + + # Log basic file structure for debugging + logger.debug(f"Processing {len(lines)} lines from M3U file") + + valid_stream_count = 0 + + for entry in iter_m3u_entries(lines): + valid_stream_count += 1 + group_title_attr = get_case_insensitive_attr(entry["attributes"], "group-title", "") + if group_title_attr and group_title_attr not in groups: + logger.debug(f"Found new group for M3U account {account_id}: '{group_title_attr}'") + groups[group_title_attr] = {} + extinf_data.append(entry) + + if valid_stream_count % 1000 == 0: + logger.debug(f"Processed {valid_stream_count} valid streams so far for M3U account: {account_id}") + + logger.info(f"M3U parsing complete - Valid streams: {valid_stream_count}") + + # Log group statistics + logger.info( + f"Found {len(groups)} groups in M3U file: {', '.join(list(groups.keys())[:20])}" + + ("..." if len(groups) > 20 else "") + ) + + # Cache processed data + cache_path = os.path.join(m3u_dir, f"{account_id}.json") + with open(cache_path, "w", encoding="utf-8") as f: + json.dump( + { + "extinf_data": extinf_data, + "groups": groups, + }, + f, + ) + logger.debug(f"Cached parsed M3U data to {cache_path}") + + send_m3u_update(account_id, "processing_groups", 0) + + process_groups(account, groups, scan_start_time) + + lock_renewer.stop() + release_task_lock("refresh_m3u_account_groups", account_id) + + if not full_refresh: + # Use update() instead of save() to avoid triggering signals + M3UAccount.objects.filter(id=account_id).update( + status=M3UAccount.Status.PENDING_SETUP, + last_message="M3U groups loaded. Please select groups or refresh M3U to complete setup.", + ) + send_m3u_update( + account_id, + "processing_groups", + 100, + status="pending_setup", + message="M3U groups loaded. Please select groups or refresh M3U to complete setup.", + ) + + return extinf_data, groups + + +def delete_m3u_refresh_task_by_id(account_id): + """ + Delete the periodic task associated with an M3U account ID. + Can be called directly or from the post_delete signal. + Returns True if a task was found and deleted, False otherwise. + """ + try: + task = None + task_name = f"m3u_account-refresh-{account_id}" + + # Look for task by name + try: + from django_celery_beat.models import PeriodicTask, IntervalSchedule + + task = PeriodicTask.objects.get(name=task_name) + logger.debug(f"Found task by name: {task.id} for M3UAccount {account_id}") + except PeriodicTask.DoesNotExist: + logger.warning(f"No PeriodicTask found with name {task_name}") + return False + + # Now delete the task and its interval + if task: + # Store interval info before deleting the task + interval_id = None + if hasattr(task, "interval") and task.interval: + interval_id = task.interval.id + + # Count how many TOTAL tasks use this interval (including this one) + tasks_with_same_interval = PeriodicTask.objects.filter( + interval_id=interval_id + ).count() + logger.debug( + f"Interval {interval_id} is used by {tasks_with_same_interval} tasks total" + ) + + # Delete the task first + task_id = task.id + task.delete() + logger.debug(f"Successfully deleted periodic task {task_id}") + + # Now check if we should delete the interval + # We only delete if it was the ONLY task using this interval + if interval_id and tasks_with_same_interval == 1: + try: + interval = IntervalSchedule.objects.get(id=interval_id) + logger.debug( + f"Deleting interval schedule {interval_id} (not shared with other tasks)" + ) + interval.delete() + logger.debug(f"Successfully deleted interval {interval_id}") + except IntervalSchedule.DoesNotExist: + logger.warning(f"Interval {interval_id} no longer exists") + elif interval_id: + logger.debug( + f"Not deleting interval {interval_id} as it's shared with {tasks_with_same_interval-1} other tasks" + ) + + return True + return False + except Exception as e: + logger.error( + f"Error deleting periodic task for M3UAccount {account_id}: {str(e)}", + exc_info=True, + ) + return False + + +@shared_task +def sync_auto_channels(account_id, scan_start_time=None): + """ + Automatically create/update/delete channels to match streams in groups with auto_channel_sync enabled. + Preserves existing channel UUIDs to maintain M3U link integrity. + Called after M3U refresh completes successfully. + """ + from apps.channels.models import ( + Channel, + ChannelGroup, + ChannelGroupM3UAccount, + Stream, + ChannelStream, + ) + from apps.epg.models import EPGData + from django.utils import timezone + + try: + account = M3UAccount.objects.get(id=account_id) + logger.info(f"Starting auto channel sync for M3U account {account.name}") + + # Always use scan_start_time as the cutoff for last_seen + if scan_start_time is not None: + if isinstance(scan_start_time, str): + scan_start_time = timezone.datetime.fromisoformat(scan_start_time) + else: + scan_start_time = timezone.now() + + # Get groups with auto sync enabled for this account + auto_sync_groups = ChannelGroupM3UAccount.objects.filter( + m3u_account=account, enabled=True, auto_channel_sync=True + ).select_related("channel_group") + + channels_created = 0 + channels_updated = 0 + channels_deleted = 0 + + # Get all channel numbers that are already in use by other channels (not auto-created by this account) + used_numbers = set( + Channel.objects.exclude( + auto_created=True, auto_created_by=account + ).values_list("channel_number", flat=True) + ) + + for group_relation in auto_sync_groups: + channel_group = group_relation.channel_group + start_number = group_relation.auto_sync_channel_start or 1.0 + + # Get force_dummy_epg, group_override, and regex patterns from group custom_properties + group_custom_props = {} + force_dummy_epg = False # Backward compatibility: legacy option to disable EPG + override_group_id = None + name_regex_pattern = None + name_replace_pattern = None + name_match_regex = None + channel_profile_ids = None + channel_sort_order = None + channel_sort_reverse = False + stream_profile_id = None + custom_logo_id = None + custom_epg_id = None # New option: select specific EPG source (takes priority over force_dummy_epg) + channel_numbering_mode = "fixed" # Default mode + channel_numbering_fallback = 1 # Default fallback for provider mode + if group_relation.custom_properties: + group_custom_props = group_relation.custom_properties + force_dummy_epg = group_custom_props.get("force_dummy_epg", False) + override_group_id = group_custom_props.get("group_override") + name_regex_pattern = group_custom_props.get("name_regex_pattern") + name_replace_pattern = group_custom_props.get( + "name_replace_pattern" + ) + name_match_regex = group_custom_props.get("name_match_regex") + channel_profile_ids = group_custom_props.get("channel_profile_ids") + custom_epg_id = group_custom_props.get("custom_epg_id") + channel_sort_order = group_custom_props.get("channel_sort_order") + channel_sort_reverse = group_custom_props.get( + "channel_sort_reverse", False + ) + stream_profile_id = group_custom_props.get("stream_profile_id") + custom_logo_id = group_custom_props.get("custom_logo_id") + channel_numbering_mode = group_custom_props.get("channel_numbering_mode", "fixed") + channel_numbering_fallback = group_custom_props.get("channel_numbering_fallback", 1) + + # Determine which group to use for created channels + target_group = channel_group + if override_group_id: + try: + target_group = ChannelGroup.objects.get(id=override_group_id) + logger.info( + f"Using override group '{target_group.name}' instead of '{channel_group.name}' for auto-created channels" + ) + except ChannelGroup.DoesNotExist: + logger.warning( + f"Override group with ID {override_group_id} not found, using original group '{channel_group.name}'" + ) + + logger.info( + f"Processing auto sync for group: {channel_group.name} (mode: {channel_numbering_mode}, start: {start_number})" + ) + + # Get all current streams in this group for this M3U account, filter out stale streams + current_streams = Stream.objects.filter( + m3u_account=account, + channel_group=channel_group, + last_seen__gte=scan_start_time, + ) + + # --- FILTER STREAMS BY NAME MATCH REGEX IF SPECIFIED --- + if name_match_regex: + try: + current_streams = current_streams.filter( + name__iregex=name_match_regex + ) + except re.error as e: + logger.warning( + f"Invalid name_match_regex '{name_match_regex}' for group '{channel_group.name}': {e}. Skipping name filter." + ) + + # --- APPLY CHANNEL SORT ORDER --- + streams_is_list = False # Track if we converted to list + if channel_sort_order and channel_sort_order != "": + if channel_sort_order == "name": + # Use natural sorting for names to handle numbers correctly + current_streams = list(current_streams) + current_streams.sort( + key=lambda stream: natural_sort_key(stream.name), + reverse=channel_sort_reverse, + ) + streams_is_list = True + elif channel_sort_order == "tvg_id": + order_prefix = "-" if channel_sort_reverse else "" + current_streams = current_streams.order_by(f"{order_prefix}tvg_id") + elif channel_sort_order == "updated_at": + order_prefix = "-" if channel_sort_reverse else "" + current_streams = current_streams.order_by( + f"{order_prefix}updated_at" + ) + else: + logger.warning( + f"Unknown channel_sort_order '{channel_sort_order}' for group '{channel_group.name}'. Using provider order." + ) + order_prefix = "-" if channel_sort_reverse else "" + current_streams = current_streams.order_by(f"{order_prefix}id") + else: + # Provider order (default) - can still be reversed + order_prefix = "-" if channel_sort_reverse else "" + current_streams = current_streams.order_by(f"{order_prefix}id") + + # Get existing auto-created channels for this account (regardless of current group) + # We'll find them by their stream associations instead of just group location + existing_channels = Channel.objects.filter( + auto_created=True, auto_created_by=account + ).select_related("logo", "epg_data") + + # Create mapping of existing channels by their associated stream + # This approach finds channels even if they've been moved to different groups + existing_channel_map = {} + for channel in existing_channels: + # Get streams associated with this channel that belong to our M3U account and original group + channel_streams = ChannelStream.objects.filter( + channel=channel, + stream__m3u_account=account, + stream__channel_group=channel_group, # Match streams from the original group + ).select_related("stream") + + # Map each of our M3U account's streams to this channel + for channel_stream in channel_streams: + if channel_stream.stream: + existing_channel_map[channel_stream.stream.id] = channel + + # Track which streams we've processed + processed_stream_ids = set() + + # Check if we have streams - handle both QuerySet and list cases + has_streams = ( + len(current_streams) > 0 + if streams_is_list + else current_streams.exists() + ) + + if not has_streams: + logger.debug(f"No streams found in group {channel_group.name}") + # Delete all existing auto channels if no streams + channels_to_delete = [ch for ch in existing_channel_map.values()] + if channels_to_delete: + deleted_count = len(channels_to_delete) + Channel.objects.filter( + id__in=[ch.id for ch in channels_to_delete] + ).delete() + channels_deleted += deleted_count + logger.debug( + f"Deleted {deleted_count} auto channels (no streams remaining)" + ) + continue + + # Prepare profiles to assign to new channels + from apps.channels.models import ChannelProfile, ChannelProfileMembership + + if ( + channel_profile_ids + and isinstance(channel_profile_ids, list) + and len(channel_profile_ids) > 0 + ): + # Convert all to int (in case they're strings) + try: + profile_ids = [int(pid) for pid in channel_profile_ids] + except Exception: + profile_ids = [] + profiles_to_assign = list( + ChannelProfile.objects.filter(id__in=profile_ids) + ) + else: + profiles_to_assign = list(ChannelProfile.objects.all()) + + # Get stream profile to assign if specified + from core.models import StreamProfile + stream_profile_to_assign = None + if stream_profile_id: + try: + stream_profile_to_assign = StreamProfile.objects.get(id=int(stream_profile_id)) + logger.info( + f"Will assign stream profile '{stream_profile_to_assign.name}' to auto-synced streams in group '{channel_group.name}'" + ) + except (StreamProfile.DoesNotExist, ValueError, TypeError): + logger.warning( + f"Stream profile with ID {stream_profile_id} not found for group '{channel_group.name}', streams will use default profile" + ) + stream_profile_to_assign = None + + # Process each current stream + current_channel_number = start_number + + # Always renumber all existing channels to match current sort order + # This ensures channels are always in the correct sequence + channels_to_renumber = [] + temp_channel_number = start_number + + for stream in current_streams: + if stream.id in existing_channel_map: + channel = existing_channel_map[stream.id] + + # Determine target number based on numbering mode + if channel_numbering_mode == "provider": + # Use provider number if available, otherwise use fallback with next available logic + if stream.stream_chno is not None: + target_number = stream.stream_chno + # If provider number is already used, find next available + if target_number in used_numbers: + target_number = channel_numbering_fallback + while target_number in used_numbers: + target_number += 1 + else: + # No provider number, use fallback and find next available + target_number = channel_numbering_fallback + while target_number in used_numbers: + target_number += 1 + elif channel_numbering_mode == "next_available": + # Find next available starting from 1 + target_number = 1 + while target_number in used_numbers: + target_number += 1 + else: # fixed mode (default) + # Find next available number starting from temp_channel_number + target_number = temp_channel_number + while target_number in used_numbers: + target_number += 1 + + # Add this number to used_numbers so we don't reuse it in this batch + used_numbers.add(target_number) + + if channel.channel_number != target_number: + channel.channel_number = target_number + channels_to_renumber.append(channel) + logger.debug( + f"Will renumber channel '{channel.name}' to {target_number}" + ) + + # Only increment temp_channel_number in fixed mode + if channel_numbering_mode == "fixed": + temp_channel_number += 1.0 + if temp_channel_number % 1 != 0: # Has decimal + temp_channel_number = int(temp_channel_number) + 1.0 + + # Bulk update channel numbers if any need renumbering + if channels_to_renumber: + Channel.objects.bulk_update(channels_to_renumber, ["channel_number"]) + logger.info( + f"Renumbered {len(channels_to_renumber)} channels to maintain sort order" + ) + + # Reset channel number counter for processing new channels + current_channel_number = start_number + + for stream in current_streams: + processed_stream_ids.add(stream.id) + try: + # Parse custom properties for additional info + stream_custom_props = stream.custom_properties or {} + tvc_guide_stationid = stream_custom_props.get("tvc-guide-stationid") + + # --- REGEX FIND/REPLACE LOGIC --- + original_name = stream.name + new_name = original_name + if name_regex_pattern is not None: + # If replace is None, treat as empty string (remove match) + replace = ( + name_replace_pattern + if name_replace_pattern is not None + else "" + ) + try: + # Convert $1, $2, etc. to \1, \2, etc. for consistency with M3U profiles + safe_replace_pattern = re.sub(r'\$(\d+)', r'\\\1', replace) + new_name = re.sub( + name_regex_pattern, safe_replace_pattern, original_name + ) + except re.error as e: + logger.warning( + f"Regex error for group '{channel_group.name}': {e}. Using original name." + ) + new_name = original_name + + # Check if we already have a channel for this stream + existing_channel = existing_channel_map.get(stream.id) + + if existing_channel: + # Update existing channel if needed (channel number already handled above) + channel_updated = False + + # Use new_name instead of stream.name + if existing_channel.name != new_name: + existing_channel.name = new_name + channel_updated = True + + if existing_channel.tvg_id != stream.tvg_id: + existing_channel.tvg_id = stream.tvg_id + channel_updated = True + + if existing_channel.tvc_guide_stationid != tvc_guide_stationid: + existing_channel.tvc_guide_stationid = tvc_guide_stationid + channel_updated = True + + # Check if channel group needs to be updated (in case override was added/changed) + if existing_channel.channel_group != target_group: + existing_channel.channel_group = target_group + channel_updated = True + logger.info( + f"Moved auto channel '{existing_channel.name}' from '{existing_channel.channel_group.name if existing_channel.channel_group else 'None'}' to '{target_group.name}'" + ) + + # Handle logo updates + current_logo = None + if custom_logo_id: + # Use the custom logo specified in group settings + from apps.channels.models import Logo + try: + current_logo = Logo.objects.get(id=custom_logo_id) + except Logo.DoesNotExist: + logger.warning( + f"Custom logo with ID {custom_logo_id} not found for existing channel, falling back to stream logo" + ) + # Fall back to stream logo if custom logo not found + if stream.logo_url: + current_logo, _ = Logo.objects.get_or_create( + url=stream.logo_url, + defaults={ + "name": stream.name or stream.tvg_id or "Unknown" + }, + ) + elif stream.logo_url: + # No custom logo configured, use stream logo + from apps.channels.models import Logo + + current_logo, _ = Logo.objects.get_or_create( + url=stream.logo_url, + defaults={ + "name": stream.name or stream.tvg_id or "Unknown" + }, + ) + + if existing_channel.logo != current_logo: + existing_channel.logo = current_logo + channel_updated = True + + # Handle EPG data updates + current_epg_data = None + if custom_epg_id: + # Use the custom EPG specified in group settings (e.g., a dummy EPG) + from apps.epg.models import EPGSource + try: + epg_source = EPGSource.objects.get(id=custom_epg_id) + # For dummy EPGs, select the first (and typically only) EPGData entry from this source + if epg_source.source_type == 'dummy': + current_epg_data = EPGData.objects.filter( + epg_source=epg_source + ).first() + if not current_epg_data: + logger.warning( + f"No EPGData found for dummy EPG source {epg_source.name} (ID: {custom_epg_id})" + ) + else: + # For non-dummy sources, try to find existing EPGData by tvg_id + if stream.tvg_id: + current_epg_data = EPGData.objects.filter( + tvg_id=stream.tvg_id, + epg_source=epg_source + ).first() + except EPGSource.DoesNotExist: + logger.warning( + f"Custom EPG source with ID {custom_epg_id} not found for existing channel, falling back to auto-match" + ) + # Fall back to auto-match by tvg_id + if stream.tvg_id and not force_dummy_epg: + current_epg_data = EPGData.objects.filter( + tvg_id=stream.tvg_id + ).first() + elif stream.tvg_id and not force_dummy_epg: + # Auto-match EPG by tvg_id (original behavior) + current_epg_data = EPGData.objects.filter( + tvg_id=stream.tvg_id + ).first() + # If force_dummy_epg is True and no custom_epg_id, current_epg_data stays None + + if existing_channel.epg_data != current_epg_data: + existing_channel.epg_data = current_epg_data + channel_updated = True + + # Handle stream profile updates for the channel + if stream_profile_to_assign and existing_channel.stream_profile != stream_profile_to_assign: + existing_channel.stream_profile = stream_profile_to_assign + channel_updated = True + + if channel_updated: + existing_channel.save() + channels_updated += 1 + logger.debug( + f"Updated auto channel: {existing_channel.channel_number} - {existing_channel.name}" + ) + + # Update channel profile memberships for existing channels + current_memberships = set( + ChannelProfileMembership.objects.filter( + channel=existing_channel, enabled=True + ).values_list("channel_profile_id", flat=True) + ) + + target_profile_ids = set( + profile.id for profile in profiles_to_assign + ) + + # Only update if memberships have changed + if current_memberships != target_profile_ids: + # Disable all current memberships + ChannelProfileMembership.objects.filter( + channel=existing_channel + ).update(enabled=False) + + # Enable/create memberships for target profiles + for profile in profiles_to_assign: + membership, created = ( + ChannelProfileMembership.objects.get_or_create( + channel_profile=profile, + channel=existing_channel, + defaults={"enabled": True}, + ) + ) + if not created and not membership.enabled: + membership.enabled = True + membership.save() + + logger.debug( + f"Updated profile memberships for auto channel: {existing_channel.name}" + ) + + else: + # Create new channel + # Determine channel number based on numbering mode + if channel_numbering_mode == "provider": + # Use provider number if available, otherwise use fallback with next available logic + if stream.stream_chno is not None: + target_number = stream.stream_chno + # If provider number is already used, find next available from fallback + if target_number in used_numbers: + target_number = channel_numbering_fallback + while target_number in used_numbers: + target_number += 1 + else: + # No provider number, use fallback and find next available + target_number = channel_numbering_fallback + while target_number in used_numbers: + target_number += 1 + elif channel_numbering_mode == "next_available": + # Find next available starting from 1 + target_number = 1 + while target_number in used_numbers: + target_number += 1 + else: # fixed mode (default) + # Find next available channel number starting from current_channel_number + target_number = current_channel_number + while target_number in used_numbers: + target_number += 1 + + # Add this number to used_numbers + used_numbers.add(target_number) + + channel = Channel.objects.create( + channel_number=target_number, + name=new_name, + tvg_id=stream.tvg_id, + tvc_guide_stationid=tvc_guide_stationid, + channel_group=target_group, + user_level=0, + auto_created=True, + auto_created_by=account, + ) + + # Associate the stream with the channel + ChannelStream.objects.create( + channel=channel, stream=stream, order=0 + ) + + # Assign to correct profiles + memberships = [ + ChannelProfileMembership( + channel_profile=profile, channel=channel, enabled=True + ) + for profile in profiles_to_assign + ] + if memberships: + ChannelProfileMembership.objects.bulk_create(memberships) + + # Try to match EPG data + if custom_epg_id: + # Use the custom EPG specified in group settings (e.g., a dummy EPG) + from apps.epg.models import EPGSource + try: + epg_source = EPGSource.objects.get(id=custom_epg_id) + # For dummy EPGs, select the first (and typically only) EPGData entry from this source + if epg_source.source_type == 'dummy': + epg_data = EPGData.objects.filter( + epg_source=epg_source + ).first() + if epg_data: + channel.epg_data = epg_data + channel.save(update_fields=["epg_data"]) + else: + logger.warning( + f"No EPGData found for dummy EPG source {epg_source.name} (ID: {custom_epg_id})" + ) + else: + # For non-dummy sources, try to find existing EPGData by tvg_id + if stream.tvg_id: + epg_data = EPGData.objects.filter( + tvg_id=stream.tvg_id, + epg_source=epg_source + ).first() + if epg_data: + channel.epg_data = epg_data + channel.save(update_fields=["epg_data"]) + except EPGSource.DoesNotExist: + logger.warning( + f"Custom EPG source with ID {custom_epg_id} not found, falling back to auto-match" + ) + # Fall back to auto-match by tvg_id + if stream.tvg_id and not force_dummy_epg: + epg_data = EPGData.objects.filter( + tvg_id=stream.tvg_id + ).first() + if epg_data: + channel.epg_data = epg_data + channel.save(update_fields=["epg_data"]) + elif stream.tvg_id and not force_dummy_epg: + # Auto-match EPG by tvg_id (original behavior) + epg_data = EPGData.objects.filter( + tvg_id=stream.tvg_id + ).first() + if epg_data: + channel.epg_data = epg_data + channel.save(update_fields=["epg_data"]) + elif force_dummy_epg: + # Force dummy EPG with no custom EPG selected (set to None) + channel.epg_data = None + channel.save(update_fields=["epg_data"]) + + # Handle logo + if custom_logo_id: + # Use the custom logo specified in group settings + from apps.channels.models import Logo + try: + custom_logo = Logo.objects.get(id=custom_logo_id) + channel.logo = custom_logo + channel.save(update_fields=["logo"]) + except Logo.DoesNotExist: + logger.warning( + f"Custom logo with ID {custom_logo_id} not found, falling back to stream logo" + ) + # Fall back to stream logo if custom logo not found + if stream.logo_url: + logo, _ = Logo.objects.get_or_create( + url=stream.logo_url, + defaults={ + "name": stream.name or stream.tvg_id or "Unknown" + }, + ) + channel.logo = logo + channel.save(update_fields=["logo"]) + elif stream.logo_url: + from apps.channels.models import Logo + + logo, _ = Logo.objects.get_or_create( + url=stream.logo_url, + defaults={ + "name": stream.name or stream.tvg_id or "Unknown" + }, + ) + channel.logo = logo + channel.save(update_fields=["logo"]) + + # Handle stream profile assignment + if stream_profile_to_assign: + channel.stream_profile = stream_profile_to_assign + channel.save(update_fields=['stream_profile']) + channels_created += 1 + logger.debug( + f"Created auto channel: {channel.channel_number} - {channel.name}" + ) + + # Increment channel number for next iteration (only in fixed mode) + if channel_numbering_mode == "fixed": + current_channel_number += 1.0 + if current_channel_number % 1 != 0: # Has decimal + current_channel_number = int(current_channel_number) + 1.0 + + except Exception as e: + logger.error( + f"Error processing auto channel for stream {stream.name}: {str(e)}" + ) + continue + + # Delete channels for streams that no longer exist + channels_to_delete = [] + for stream_id, channel in existing_channel_map.items(): + if stream_id not in processed_stream_ids: + channels_to_delete.append(channel) + + if channels_to_delete: + deleted_count = len(channels_to_delete) + Channel.objects.filter( + id__in=[ch.id for ch in channels_to_delete] + ).delete() + channels_deleted += deleted_count + logger.debug( + f"Deleted {deleted_count} auto channels for removed streams" + ) + + # Additional cleanup: Remove auto-created channels that no longer have any valid streams + # This handles the case where streams were deleted due to stale retention policy + orphaned_channels = Channel.objects.filter( + auto_created=True, + auto_created_by=account + ).exclude( + # Exclude channels that still have valid stream associations + id__in=ChannelStream.objects.filter( + stream__m3u_account=account, + stream__isnull=False + ).values_list('channel_id', flat=True) + ) + + orphaned_count = orphaned_channels.count() + if orphaned_count > 0: + orphaned_channels.delete() + channels_deleted += orphaned_count + logger.info( + f"Deleted {orphaned_count} orphaned auto channels with no valid streams" + ) + + logger.info( + f"Auto channel sync complete for account {account.name}: {channels_created} created, {channels_updated} updated, {channels_deleted} deleted" + ) + return f"Auto sync: {channels_created} channels created, {channels_updated} updated, {channels_deleted} deleted" + + except Exception as e: + logger.error(f"Error in auto channel sync for account {account_id}: {str(e)}") + return f"Auto sync error: {str(e)}" + + +def get_transformed_credentials(account, profile=None): + """ + Get transformed credentials for XtreamCodes API calls. + + Args: + account: M3UAccount instance + profile: M3UAccountProfile instance (optional, if not provided will use primary profile) + + Returns: + tuple: (transformed_url, transformed_username, transformed_password) + """ + import re + import urllib.parse + + # If no profile is provided, find the primary active profile + if profile is None: + try: + from apps.m3u.models import M3UAccountProfile + profile = M3UAccountProfile.objects.filter( + m3u_account=account, + is_active=True + ).first() + if profile: + logger.debug(f"Using primary profile '{profile.name}' for URL transformation") + else: + logger.debug(f"No active profiles found for account {account.name}, using base credentials") + except Exception as e: + logger.warning(f"Could not get primary profile for account {account.name}: {e}") + profile = None + + base_url = account.server_url + base_username = account.username + base_password = account.password # Build a complete URL with credentials (similar to how IPTV URLs are structured) + # Format: http://server.com:port/live/username/password/1234.ts + if base_url and base_username and base_password: + # Remove trailing slash from server URL if present + clean_server_url = base_url.rstrip('/') + + # Build the complete URL with embedded credentials + complete_url = f"{clean_server_url}/live/{base_username}/{base_password}/1234.ts" + logger.debug(f"Built complete URL: {complete_url}") + + # Apply profile-specific transformations if profile is provided + if profile and profile.search_pattern and profile.replace_pattern: + try: + # Handle backreferences: convert JS-style $ -> \g, $1 -> \1 + # regex module accepts JS-style (?...) named groups natively + safe_replace_pattern = regex.sub(r'\$<([^>]+)>', r'\\g<\1>', profile.replace_pattern) + safe_replace_pattern = regex.sub(r'\$(\d+)', r'\\\1', safe_replace_pattern) + + # Apply transformation to the complete URL + transformed_complete_url = regex.sub(profile.search_pattern, safe_replace_pattern, complete_url) + logger.info(f"Transformed complete URL: {complete_url} -> {transformed_complete_url}") + + # Extract components from the transformed URL + # Pattern: http://server.com:port/live/username/password/1234.ts + parsed_url = urllib.parse.urlparse(transformed_complete_url) + path_parts = [part for part in parsed_url.path.split('/') if part] + + if len(path_parts) >= 4 and path_parts[-1] == '1234.ts': + # Extract username and password from the known structure: + # .../{live}/{username}/{password}/1234.ts + # Using negative indices so sub-paths in the server URL don't shift extraction + transformed_username = path_parts[-3] + transformed_password = path_parts[-2] + + # Rebuild server URL without the username/password path + transformed_url = f"{parsed_url.scheme}://{parsed_url.netloc}" + if parsed_url.port: + transformed_url = f"{parsed_url.scheme}://{parsed_url.hostname}:{parsed_url.port}" + + logger.debug(f"Extracted transformed credentials:") + logger.debug(f" Server URL: {transformed_url}") + logger.debug(f" Username: {transformed_username}") + logger.debug(f" Password: {transformed_password}") + + return transformed_url, transformed_username, transformed_password + else: + logger.warning(f"Could not extract credentials from transformed URL: {transformed_complete_url}") + return base_url, base_username, base_password + + except Exception as e: + logger.error(f"Error transforming URL for profile {profile.name if profile else 'unknown'}: {e}") + return base_url, base_username, base_password + else: + # No profile or no transformation patterns + return base_url, base_username, base_password + else: + logger.warning(f"Missing credentials for account {account.name}") + return base_url, base_username, base_password + + +@shared_task +def refresh_account_profiles(account_id): + """Refresh account information for all active profiles of an XC account. + + This task runs asynchronously in the background after account refresh completes. + It includes rate limiting delays between profile authentications to prevent provider bans. + """ + from django.conf import settings + import time + + try: + account = M3UAccount.objects.get(id=account_id, is_active=True) + + if account.account_type != M3UAccount.Types.XC: + logger.debug(f"Account {account_id} is not XC type, skipping profile refresh") + return f"Account {account_id} is not an XtreamCodes account" + + from apps.m3u.models import M3UAccountProfile + + profiles = M3UAccountProfile.objects.filter( + m3u_account=account, + is_active=True + ) + + if not profiles.exists(): + logger.info(f"No active profiles found for account {account.name}") + return f"No active profiles for account {account_id}" + + # Get user agent for this account + try: + user_agent_string = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36" + if account.user_agent_id: + from core.models import UserAgent + ua_obj = UserAgent.objects.get(id=account.user_agent_id) + if ua_obj and hasattr(ua_obj, "user_agent") and ua_obj.user_agent: + user_agent_string = ua_obj.user_agent + except Exception as e: + logger.warning(f"Error getting user agent, using fallback: {str(e)}") + logger.debug(f"Using user agent for profile refresh: {user_agent_string}") + # Get rate limiting delay from settings + profile_delay = getattr(settings, 'XC_PROFILE_REFRESH_DELAY', 2.5) + + profiles_updated = 0 + profiles_failed = 0 + + logger.info(f"Starting background refresh for {profiles.count()} profiles of account {account.name}") + + for idx, profile in enumerate(profiles): + try: + # Add delay between profiles to prevent rate limiting (except for first profile) + if idx > 0: + logger.info(f"Waiting {profile_delay}s before refreshing next profile to avoid rate limiting") + time.sleep(profile_delay) + + # Get transformed credentials for this specific profile + profile_url, profile_username, profile_password = get_transformed_credentials(account, profile) + + # Create a separate XC client for this profile's credentials + with XCClient( + profile_url, + profile_username, + profile_password, + user_agent_string + ) as profile_client: + # Authenticate with this profile's credentials + if profile_client.authenticate(): + # Get account information specific to this profile's credentials + profile_account_info = profile_client.get_account_info() + + # Merge with existing custom_properties if they exist + existing_props = profile.custom_properties or {} + existing_props.update(profile_account_info) + profile.custom_properties = existing_props + profile.save(update_fields=['custom_properties']) + + profiles_updated += 1 + logger.info(f"Updated account information for profile '{profile.name}' ({profiles_updated}/{profiles.count()})") + else: + profiles_failed += 1 + logger.warning(f"Failed to authenticate profile '{profile.name}' with transformed credentials") + + except Exception as profile_error: + profiles_failed += 1 + logger.error(f"Failed to update account information for profile '{profile.name}': {str(profile_error)}") + # Continue with other profiles even if one fails + + result_msg = f"Profile refresh complete for account {account.name}: {profiles_updated} updated, {profiles_failed} failed" + logger.info(result_msg) + return result_msg + + except M3UAccount.DoesNotExist: + error_msg = f"Account {account_id} not found" + logger.error(error_msg) + return error_msg + except Exception as e: + error_msg = f"Error refreshing profiles for account {account_id}: {str(e)}" + logger.error(error_msg) + return error_msg + + +@shared_task +def refresh_account_info(profile_id): + """Refresh only the account information for a specific M3U profile.""" + if not acquire_task_lock("refresh_account_info", profile_id): + return f"Account info refresh task already running for profile_id={profile_id}." + + try: + from apps.m3u.models import M3UAccountProfile + import re + + profile = M3UAccountProfile.objects.get(id=profile_id) + account = profile.m3u_account + + if account.account_type != M3UAccount.Types.XC: + release_task_lock("refresh_account_info", profile_id) + return f"Profile {profile_id} belongs to account {account.id} which is not an XtreamCodes account." + + # Get transformed credentials using the helper function + transformed_url, transformed_username, transformed_password = get_transformed_credentials(account, profile) + + # Initialize XtreamCodes client with extracted/transformed credentials + client = XCClient( + transformed_url, + transformed_username, + transformed_password, + account.get_user_agent(), + ) # Authenticate and get account info + auth_result = client.authenticate() + if not auth_result: + error_msg = f"Authentication failed for profile {profile.name} ({profile_id})" + logger.error(error_msg) + + # Send error notification to frontend via websocket + send_websocket_update( + "updates", + "update", + { + "type": "account_info_refresh_error", + "profile_id": profile_id, + "profile_name": profile.name, + "error": "Authentication failed with the provided credentials", + "message": f"Failed to authenticate profile '{profile.name}'. Please check the credentials." + } + ) + + release_task_lock("refresh_account_info", profile_id) + return error_msg + + # Get account information + account_info = client.get_account_info() + + # Update only this specific profile with the new account info + if not profile.custom_properties: + profile.custom_properties = {} + profile.custom_properties.update(account_info) + profile.save() + + # Send success notification to frontend via websocket + send_websocket_update( + "updates", + "update", + { + "type": "account_info_refresh_success", + "profile_id": profile_id, + "profile_name": profile.name, + "message": f"Account information successfully refreshed for profile '{profile.name}'" + } + ) + + release_task_lock("refresh_account_info", profile_id) + return f"Account info refresh completed for profile {profile_id} ({profile.name})." + + except M3UAccountProfile.DoesNotExist: + error_msg = f"Profile {profile_id} not found" + logger.error(error_msg) + + send_websocket_update( + "updates", + "update", + { + "type": "account_refresh_error", + "profile_id": profile_id, + "error": "Profile not found", + "message": f"Profile {profile_id} not found" + } + ) + + release_task_lock("refresh_account_info", profile_id) + return error_msg + except Exception as e: + error_msg = f"Error refreshing account info for profile {profile_id}: {str(e)}" + logger.error(error_msg) + + send_websocket_update( + "updates", + "update", + { + "type": "account_refresh_error", + "profile_id": profile_id, + "error": str(e), + "message": f"Failed to refresh account info: {str(e)}" + } + ) + + release_task_lock("refresh_account_info", profile_id) + return error_msg +@shared_task(time_limit=3600, soft_time_limit=3500) +def refresh_single_m3u_account(account_id): + """Splits M3U processing into chunks and dispatches them as parallel tasks.""" + if not acquire_task_lock("refresh_single_m3u_account", account_id): + return f"Task already running for account_id={account_id}." + + # Keep the lock alive while this long-running task is working. + # Without renewal, the 300s lock TTL can expire during large + # downloads/parses, allowing duplicate tasks to start. + lock_renewer = TaskLockRenewer("refresh_single_m3u_account", account_id) + lock_renewer.start() + + try: + return _refresh_single_m3u_account_impl(account_id) + finally: + # Guaranteed cleanup on all exit paths (success, exception, early return) + lock_renewer.stop() + release_task_lock("refresh_single_m3u_account", account_id) + + +def _refresh_single_m3u_account_impl(account_id): + """Implementation of M3U account refresh with guaranteed memory cleanup.""" + # Record start time + refresh_start_timestamp = timezone.now() # For the cleanup function + start_time = time.time() # For tracking elapsed time as float + streams_created = 0 + streams_updated = 0 + streams_deleted = 0 + + try: + account = M3UAccount.objects.get(id=account_id, is_active=True) + if not account.is_active: + logger.debug(f"Account {account_id} is not active, skipping.") + return + + # Set status to fetching + account.status = M3UAccount.Status.FETCHING + account.save(update_fields=['status']) + + filters = list(account.filters.all()) + + # Check if VOD is enabled for this account + vod_enabled = False + if account.custom_properties: + custom_props = account.custom_properties or {} + vod_enabled = custom_props.get('enable_vod', False) + + except M3UAccount.DoesNotExist: + # The M3U account doesn't exist, so delete the periodic task if it exists + logger.warning( + f"M3U account with ID {account_id} not found, but task was triggered. Cleaning up orphaned task." + ) + + # Call the helper function to delete the task + if delete_m3u_refresh_task_by_id(account_id): + logger.info( + f"Successfully cleaned up orphaned task for M3U account {account_id}" + ) + else: + logger.debug(f"No orphaned task found for M3U account {account_id}") + + return f"M3UAccount with ID={account_id} not found or inactive, task cleaned up" + + # Fetch M3U lines and handle potential issues + extinf_data = [] + groups = None + + cache_path = os.path.join(m3u_dir, f"{account_id}.json") + if os.path.exists(cache_path): + try: + with open(cache_path, "r") as file: + data = json.load(file) + + extinf_data = data["extinf_data"] + groups = data["groups"] + del data # Free top-level dict; extinf_data/groups retain their references + except json.JSONDecodeError as e: + # Handle corrupted JSON file + logger.error( + f"Error parsing cached M3U data for account {account_id}: {str(e)}" + ) + + # Backup the corrupted file for potential analysis + backup_path = f"{cache_path}.corrupted" + try: + os.rename(cache_path, backup_path) + logger.info(f"Renamed corrupted cache file to {backup_path}") + except OSError as rename_err: + logger.warning( + f"Failed to rename corrupted cache file: {str(rename_err)}" + ) + + # Reset the data to empty structures + extinf_data = [] + groups = None + except Exception as e: + logger.error(f"Unexpected error reading cached M3U data: {str(e)}") + extinf_data = [] + groups = None + + if not extinf_data: + try: + logger.info(f"Calling refresh_m3u_groups for account {account_id}") + result = refresh_m3u_groups(account_id, full_refresh=True, scan_start_time=refresh_start_timestamp) + logger.trace(f"refresh_m3u_groups result: {result}") + + # Check for completely empty result or missing groups + if not result or result[1] is None: + logger.error( + f"Failed to refresh M3U groups for account {account_id}: {result}" + ) + return "Failed to update m3u account - download failed or other error" + + extinf_data, groups = result + + # XC accounts can have empty extinf_data but valid groups + try: + account = M3UAccount.objects.get(id=account_id) + is_xc_account = account.account_type == M3UAccount.Types.XC + except M3UAccount.DoesNotExist: + is_xc_account = False + + # For XC accounts, empty extinf_data is normal at this stage + if not extinf_data and not is_xc_account: + logger.error(f"No streams found for non-XC account {account_id}") + account.status = M3UAccount.Status.ERROR + account.last_message = "No streams found in M3U source" + account.save(update_fields=["status", "last_message"]) + send_m3u_update( + account_id, "parsing", 100, status="error", error="No streams found" + ) + except Exception as e: + logger.error(f"Exception in refresh_m3u_groups: {str(e)}", exc_info=True) + account.status = M3UAccount.Status.ERROR + account.last_message = f"Error refreshing M3U groups: {str(e)}" + account.save(update_fields=["status", "last_message"]) + send_m3u_update( + account_id, + "parsing", + 100, + status="error", + error=f"Error refreshing M3U groups: {str(e)}", + ) + return "Failed to update m3u account" + + # Only proceed with parsing if we actually have data and no errors were encountered + # Get account type to handle XC accounts differently + try: + is_xc_account = account.account_type == M3UAccount.Types.XC + except Exception: + is_xc_account = False + + # Modified validation logic for different account types + if (not groups) or (not is_xc_account and not extinf_data): + logger.error(f"No data to process for account {account_id}") + account.status = M3UAccount.Status.ERROR + account.last_message = "No data available for processing" + account.save(update_fields=["status", "last_message"]) + send_m3u_update( + account_id, + "parsing", + 100, + status="error", + error="No data available for processing", + ) + return "Failed to update m3u account, no data available" + + hash_keys = CoreSettings.get_m3u_hash_key().split(",") + + existing_groups = { + group.name: group.id + for group in ChannelGroup.objects.filter( + m3u_accounts__m3u_account=account, # Filter by the M3UAccount + m3u_accounts__enabled=True, # Filter by the enabled flag in the join table + ) + } + + try: + # Set status to parsing + account.status = M3UAccount.Status.PARSING + account.save(update_fields=["status"]) + + # Commit any pending transactions before threading + from django.db import transaction + transaction.commit() + + # Initialize stream counters + streams_created = 0 + streams_updated = 0 + + if account.account_type == M3UAccount.Types.STADNARD: + logger.debug( + f"Processing Standard account ({account_id}) with groups: {existing_groups}" + ) + # Break into batches and process with threading - use global batch size + batches = [ + extinf_data[i : i + BATCH_SIZE] + for i in range(0, len(extinf_data), BATCH_SIZE) + ] + + logger.info(f"Processing {len(extinf_data)} streams in {len(batches)} thread batches") + + # Use 2 threads for optimal database connection handling + max_workers = min(2, len(batches)) + logger.debug(f"Using {max_workers} threads for processing") + + with ThreadPoolExecutor(max_workers=max_workers) as executor: + # Submit batch processing tasks using direct functions (now thread-safe) + future_to_batch = { + executor.submit(process_m3u_batch_direct, account_id, batch, existing_groups, hash_keys): i + for i, batch in enumerate(batches) + } + + completed_batches = 0 + total_batches = len(batches) + + # Process completed batches as they finish + for future in as_completed(future_to_batch): + batch_idx = future_to_batch[future] + try: + result = future.result() + completed_batches += 1 + + # Extract stream counts from result + if isinstance(result, str): + try: + created_match = re.search(r"(\d+) created", result) + updated_match = re.search(r"(\d+) updated", result) + if created_match and updated_match: + created_count = int(created_match.group(1)) + updated_count = int(updated_match.group(1)) + streams_created += created_count + streams_updated += updated_count + except (AttributeError, ValueError): + pass + + # Send progress update + progress = int((completed_batches / total_batches) * 100) + current_elapsed = time.time() - start_time + + if progress > 0: + estimated_total = (current_elapsed / progress) * 100 + time_remaining = max(0, estimated_total - current_elapsed) + else: + time_remaining = 0 + + send_m3u_update( + account_id, + "parsing", + progress, + elapsed_time=current_elapsed, + time_remaining=time_remaining, + streams_processed=streams_created + streams_updated, + ) + + logger.debug(f"Thread batch {completed_batches}/{total_batches} completed") + + except Exception as e: + logger.error(f"Error in thread batch {batch_idx}: {str(e)}") + completed_batches += 1 # Still count it to avoid hanging + + logger.info(f"Thread-based processing completed for account {account_id}") + else: + # For XC accounts, get the groups with their custom properties containing xc_id + logger.debug(f"Processing XC account with groups: {existing_groups}") + + # Get the ChannelGroupM3UAccount entries with their custom_properties + channel_group_relationships = ChannelGroupM3UAccount.objects.filter( + m3u_account=account, enabled=True + ).select_related("channel_group") + + filtered_groups = {} + for rel in channel_group_relationships: + group_name = rel.channel_group.name + group_id = rel.channel_group.id + + # Load the custom properties with the xc_id + custom_props = rel.custom_properties or {} + if "xc_id" in custom_props: + filtered_groups[group_name] = { + "xc_id": custom_props["xc_id"], + "channel_group_id": group_id, + } + logger.debug( + f"Added group {group_name} with xc_id {custom_props['xc_id']}" + ) + else: + logger.warning( + f"No xc_id found in custom properties for group {group_name}" + ) + + logger.info( + f"Filtered {len(filtered_groups)} groups for processing: {filtered_groups}" + ) + + # Collect all XC streams in a single API call and filter by enabled categories + logger.info("Fetching all XC streams from provider and filtering by enabled categories...") + all_xc_streams = collect_xc_streams(account_id, filtered_groups) + + if not all_xc_streams: + logger.warning("No streams collected from XC groups") + else: + # Now batch by stream count (like standard M3U processing) + batches = [ + all_xc_streams[i : i + BATCH_SIZE] + for i in range(0, len(all_xc_streams), BATCH_SIZE) + ] + + logger.info(f"Processing {len(all_xc_streams)} XC streams in {len(batches)} batches") + + # Free the original list; batches hold independent sliced copies + del all_xc_streams + + # Use threading for XC stream processing - now with consistent batch sizes + max_workers = min(4, len(batches)) + logger.debug(f"Using {max_workers} threads for XC stream processing") + + with ThreadPoolExecutor(max_workers=max_workers) as executor: + # Submit stream batch processing tasks (reuse standard M3U processing) + future_to_batch = { + executor.submit(process_m3u_batch_direct, account_id, batch, existing_groups, hash_keys): i + for i, batch in enumerate(batches) + } + + completed_batches = 0 + total_batches = len(batches) + + # Process completed batches as they finish + for future in as_completed(future_to_batch): + batch_idx = future_to_batch[future] + try: + result = future.result() + completed_batches += 1 + + # Extract stream counts from result + if isinstance(result, str): + try: + created_match = re.search(r"(\d+) created", result) + updated_match = re.search(r"(\d+) updated", result) + if created_match and updated_match: + created_count = int(created_match.group(1)) + updated_count = int(updated_match.group(1)) + streams_created += created_count + streams_updated += updated_count + except (AttributeError, ValueError): + pass + + # Send progress update + progress = int((completed_batches / total_batches) * 100) + current_elapsed = time.time() - start_time + + if progress > 0: + estimated_total = (current_elapsed / progress) * 100 + time_remaining = max(0, estimated_total - current_elapsed) + else: + time_remaining = 0 + + send_m3u_update( + account_id, + "parsing", + progress, + elapsed_time=current_elapsed, + time_remaining=time_remaining, + streams_processed=streams_created + streams_updated, + ) + + logger.debug(f"XC thread batch {completed_batches}/{total_batches} completed") + + except Exception as e: + logger.error(f"Error in XC thread batch {batch_idx}: {str(e)}") + completed_batches += 1 # Still count it to avoid hanging + + logger.info(f"XC thread-based processing completed for account {account_id}") + + # Ensure all database transactions are committed before cleanup + logger.info( + f"All thread processing completed, ensuring DB transactions are committed before cleanup" + ) + # Force a simple DB query to ensure connection sync + Stream.objects.filter( + id=-1 + ).exists() # This will never find anything but ensures DB sync + + # Mark streams that weren't seen in this refresh as stale (pending deletion) + stale_stream_count = Stream.objects.filter( + m3u_account=account, + last_seen__lt=refresh_start_timestamp + ).update(is_stale=True) + logger.info(f"Marked {stale_stream_count} streams as stale for account {account_id}") + + # Mark group relationships that weren't seen in this refresh as stale (pending deletion) + stale_group_count = ChannelGroupM3UAccount.objects.filter( + m3u_account=account, + last_seen__lt=refresh_start_timestamp + ).update(is_stale=True) + logger.info(f"Marked {stale_group_count} group relationships as stale for account {account_id}") + + # Now run cleanup + streams_deleted = cleanup_streams(account_id, refresh_start_timestamp) + + # Cleanup stale group relationships (follows same retention policy as streams) + cleanup_stale_group_relationships(account, refresh_start_timestamp) + + # Run auto channel sync after successful refresh + auto_sync_message = "" + try: + sync_result = sync_auto_channels( + account_id, scan_start_time=str(refresh_start_timestamp) + ) + logger.info( + f"Auto channel sync result for account {account_id}: {sync_result}" + ) + if sync_result and "created" in sync_result: + auto_sync_message = f" {sync_result}." + except Exception as e: + logger.error( + f"Error running auto channel sync for account {account_id}: {str(e)}" + ) + + # Calculate elapsed time + elapsed_time = time.time() - start_time + + # Calculate total streams processed + streams_processed = streams_created + streams_updated + + # Set status to success and update timestamp BEFORE sending the final update + account.status = M3UAccount.Status.SUCCESS + account.last_message = ( + f"Processing completed in {elapsed_time:.1f} seconds. " + f"Streams: {streams_created} created, {streams_updated} updated, {streams_deleted} removed. " + f"Total processed: {streams_processed}.{auto_sync_message}" + ) + account.updated_at = timezone.now() + account.save(update_fields=["status", "last_message", "updated_at"]) + + # Log system event for M3U refresh + log_system_event( + event_type='m3u_refresh', + account_name=account.name, + elapsed_time=round(elapsed_time, 2), + streams_created=streams_created, + streams_updated=streams_updated, + streams_deleted=streams_deleted, + total_processed=streams_processed, + ) + + # Send final update with complete metrics and explicitly include success status + send_m3u_update( + account_id, + "parsing", + 100, + status="success", # Explicitly set status to success + elapsed_time=elapsed_time, + time_remaining=0, + streams_processed=streams_processed, + streams_created=streams_created, + streams_updated=streams_updated, + streams_deleted=streams_deleted, + message=account.last_message, + ) + + # Trigger VOD refresh if enabled and account is XtreamCodes type + if vod_enabled and account.account_type == M3UAccount.Types.XC: + logger.info(f"VOD is enabled for account {account_id}, triggering VOD refresh") + try: + from apps.vod.tasks import refresh_vod_content + refresh_vod_content.delay(account_id) + logger.info(f"VOD refresh task queued for account {account_id}") + except Exception as e: + logger.error(f"Failed to queue VOD refresh for account {account_id}: {str(e)}") + + except Exception as e: + logger.error(f"Error processing M3U for account {account_id}: {str(e)}") + try: + account.status = M3UAccount.Status.ERROR + account.last_message = f"Error processing M3U: {str(e)}" + account.save(update_fields=["status", "last_message"]) + except Exception: + logger.debug(f"Failed to update account {account_id} status during error handling") + raise # Re-raise the exception for Celery to handle + finally: + # Free large data structures regardless of success or failure + if 'existing_groups' in locals(): + del existing_groups + if 'extinf_data' in locals(): + del extinf_data + if 'groups' in locals(): + del groups + if 'batches' in locals(): + del batches + if 'all_xc_streams' in locals(): + del all_xc_streams + if 'data' in locals(): + del data + if 'filtered_groups' in locals(): + del filtered_groups + if 'channel_group_relationships' in locals(): + del channel_group_relationships + + # Remove cache file after processing (success or failure) + cache_path = os.path.join(m3u_dir, f"{account_id}.json") + try: + os.remove(cache_path) + except OSError: + pass + + return f"Dispatched jobs complete." + + +def send_m3u_update(account_id, action, progress, **kwargs): + # Start with the base data dictionary + data = { + "progress": progress, + "type": "m3u_refresh", + "account": account_id, + "action": action, + } + + # Add the status and message if not already in kwargs + try: + account = M3UAccount.objects.get(id=account_id) + if account: + if "status" not in kwargs: + data["status"] = account.status + if "message" not in kwargs and account.last_message: + data["message"] = account.last_message + except: + pass # If account can't be retrieved, continue without these fields + + # Add the additional key-value pairs from kwargs + data.update(kwargs) + send_websocket_update("updates", "update", data, collect_garbage=False) + + # Explicitly clear data reference to help garbage collection + data = None + + +def evaluate_profile_expiration_notification(profile): + """ + Evaluate a single M3UAccountProfile's expiration date and create, update, + or delete the corresponding SystemNotification accordingly. + + Returns the notification key that should remain active (warning or expired), + or None if the profile is not expiring soon and any stale notifications were removed. + This return value is used by the bulk task to track active keys for stale cleanup. + """ + from core.models import SystemNotification + from core.utils import send_websocket_notification, send_notification_dismissed + + exp = profile.exp_date + if not exp: + return None + + now = timezone.now() + warning_threshold = now + timezone.timedelta(days=7) + warning_key = f"xc-exp-warning-{profile.id}" + expired_key = f"xc-exp-expired-{profile.id}" + + if exp <= now: + # Already expired — delete warning, create/update expired notification + deleted_warning = list( + SystemNotification.objects.filter(notification_key=warning_key) + .values_list("notification_key", flat=True) + ) + SystemNotification.objects.filter(notification_key=warning_key).delete() + for key in deleted_warning: + send_notification_dismissed(key) + + notification, created = SystemNotification.objects.update_or_create( + notification_key=expired_key, + defaults={ + "notification_type": SystemNotification.NotificationType.WARNING, + "priority": SystemNotification.Priority.HIGH, + "title": f"Account Expired: {profile.name}", + "message": ( + f'Profile "{profile.name}" on M3U account ' + f'"{profile.m3u_account.name}" has expired ' + f"(expired {exp.strftime('%Y-%m-%d %H:%M UTC')})." + ), + "action_data": { + "profile_id": profile.id, + "account_id": profile.m3u_account.id, + "account_name": profile.m3u_account.name, + "profile_name": profile.name, + "exp_date": exp.isoformat(), + }, + "is_active": True, + "admin_only": True, + }, + ) + send_websocket_notification(notification) + return expired_key + + elif exp <= warning_threshold: + # Expiring within 7 days — delete expired notification, create/update warning + deleted_expired = list( + SystemNotification.objects.filter(notification_key=expired_key) + .values_list("notification_key", flat=True) + ) + SystemNotification.objects.filter(notification_key=expired_key).delete() + for key in deleted_expired: + send_notification_dismissed(key) + + days_left = (exp - now).days + if days_left == 0: + expires_in_str = "today" + elif days_left == 1: + expires_in_str = "in 1 day" + else: + expires_in_str = f"in {days_left} days" + + notification, created = SystemNotification.objects.update_or_create( + notification_key=warning_key, + defaults={ + "notification_type": SystemNotification.NotificationType.WARNING, + "priority": SystemNotification.Priority.NORMAL, + "title": f"Account Expiring: {profile.name}", + "message": ( + f'Profile "{profile.name}" on M3U account ' + f'"{profile.m3u_account.name}" expires {expires_in_str} ' + f"(expires {exp.strftime('%Y-%m-%d %H:%M UTC')})." + ), + "action_data": { + "profile_id": profile.id, + "account_id": profile.m3u_account.id, + "account_name": profile.m3u_account.name, + "profile_name": profile.name, + "exp_date": exp.isoformat(), + }, + "is_active": True, + "admin_only": True, + }, + ) + send_websocket_notification(notification) + return warning_key + + else: + # Not expiring soon — delete any stale notifications + deleted_keys = list( + SystemNotification.objects.filter( + notification_key__in=[warning_key, expired_key] + ).values_list("notification_key", flat=True) + ) + SystemNotification.objects.filter( + notification_key__in=[warning_key, expired_key] + ).delete() + for key in deleted_keys: + send_notification_dismissed(key) + return None + + +@shared_task +def check_account_expirations(): + """ + Daily task: check all account profiles for upcoming expirations. + Creates/updates SystemNotifications for profiles expiring within 7 days. + Uses separate notification keys for warning vs expired so users can + dismiss the 7-day warning and still receive the expired notification. + """ + from apps.m3u.models import M3UAccountProfile + from core.models import SystemNotification + from core.utils import send_notification_dismissed + + # Find all active profiles with an exp_date that is set + expiring_profiles = ( + M3UAccountProfile.objects.filter( + m3u_account__is_active=True, + is_active=True, + exp_date__isnull=False, + ) + .select_related("m3u_account") + ) + + active_notification_keys = set() + + for profile in expiring_profiles: + active_key = evaluate_profile_expiration_notification(profile) + if active_key: + active_notification_keys.add(active_key) + + # Delete stale notifications for profiles whose expiration was extended + stale = SystemNotification.objects.filter( + is_active=True, + ).filter( + models.Q(notification_key__startswith="xc-exp-warning-") | + models.Q(notification_key__startswith="xc-exp-expired-") + ).exclude(notification_key__in=active_notification_keys) + stale_keys = list(stale.values_list("notification_key", flat=True)) + stale.delete() + for key in stale_keys: + send_notification_dismissed(key) + + logger.info( + f"Account expiration check complete: {len(active_notification_keys)} active notifications" + ) diff --git a/apps/m3u/tests/__init__.py b/apps/m3u/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/m3u/tests/test_expiration_notifications.py b/apps/m3u/tests/test_expiration_notifications.py new file mode 100644 index 0000000..c734d3d --- /dev/null +++ b/apps/m3u/tests/test_expiration_notifications.py @@ -0,0 +1,216 @@ +""" +Tests for evaluate_profile_expiration_notification. + +Covers all four branches: + - no exp_date → returns None, touches nothing + - already expired → creates/updates expired notification, removes warning + - expiring within 7d → creates/updates warning notification, removes expired + - not expiring soon → removes any stale notifications, returns None +""" +from datetime import timedelta +from unittest.mock import patch, MagicMock + +from django.test import SimpleTestCase +from django.utils import timezone + + +def _make_profile(exp_date, profile_id=1, profile_name="Test Profile", + account_id=10, account_name="Test Account"): + """Return a minimal mock M3UAccountProfile.""" + profile = MagicMock() + profile.id = profile_id + profile.name = profile_name + profile.exp_date = exp_date + profile.m3u_account.id = account_id + profile.m3u_account.name = account_name + return profile + + +class EvaluateProfileExpirationNotificationTests(SimpleTestCase): + + def setUp(self): + # These three names are local imports inside evaluate_profile_expiration_notification, + # so we must patch them at their source modules rather than on apps.m3u.tasks. + self.mock_sn = MagicMock() + self.mock_send_ws = patch("core.utils.send_websocket_notification").start() + self.mock_dismissed = patch("core.utils.send_notification_dismissed").start() + patch("core.models.SystemNotification", self.mock_sn).start() + + def tearDown(self): + patch.stopall() + + def _run(self, profile): + from apps.m3u.tasks import evaluate_profile_expiration_notification + return evaluate_profile_expiration_notification(profile) + + # ------------------------------------------------------------------ # + # No expiration date + # ------------------------------------------------------------------ # + + def test_no_exp_date_returns_none(self): + profile = _make_profile(exp_date=None) + result = self._run(profile) + self.assertIsNone(result) + self.mock_sn.objects.update_or_create.assert_not_called() + self.mock_send_ws.assert_not_called() + + # ------------------------------------------------------------------ # + # Already expired + # ------------------------------------------------------------------ # + + @patch("apps.m3u.tasks.timezone") + def test_expired_creates_expired_notification(self, mock_tz): + now = timezone.now() + mock_tz.now.return_value = now + mock_tz.timedelta = timedelta + + profile = _make_profile(exp_date=now - timedelta(days=1)) + # No existing warning notification to delete + self.mock_sn.objects.filter.return_value.values_list.return_value = [] + notification = MagicMock() + self.mock_sn.objects.update_or_create.return_value = (notification, True) + + result = self._run(profile) + + self.assertEqual(result, f"xc-exp-expired-{profile.id}") + self.mock_sn.objects.update_or_create.assert_called_once() + call_kwargs = self.mock_sn.objects.update_or_create.call_args + self.assertEqual(call_kwargs.kwargs["notification_key"], f"xc-exp-expired-{profile.id}") + self.assertTrue(call_kwargs.kwargs["defaults"]["admin_only"]) + self.mock_send_ws.assert_called_once_with(notification) + + @patch("apps.m3u.tasks.timezone") + def test_expired_removes_stale_warning_notification(self, mock_tz): + now = timezone.now() + mock_tz.now.return_value = now + mock_tz.timedelta = timedelta + + profile = _make_profile(exp_date=now - timedelta(hours=1)) + warning_key = f"xc-exp-warning-{profile.id}" + # Simulate an existing warning notification + self.mock_sn.objects.filter.return_value.values_list.return_value = [warning_key] + self.mock_sn.objects.update_or_create.return_value = (MagicMock(), False) + + self._run(profile) + + self.mock_dismissed.assert_any_call(warning_key) + + # ------------------------------------------------------------------ # + # Expiring within 7 days + # ------------------------------------------------------------------ # + + @patch("apps.m3u.tasks.timezone") + def test_warning_window_creates_warning_notification(self, mock_tz): + now = timezone.now() + mock_tz.now.return_value = now + mock_tz.timedelta = timedelta + + profile = _make_profile(exp_date=now + timedelta(days=3)) + self.mock_sn.objects.filter.return_value.values_list.return_value = [] + notification = MagicMock() + self.mock_sn.objects.update_or_create.return_value = (notification, True) + + result = self._run(profile) + + self.assertEqual(result, f"xc-exp-warning-{profile.id}") + call_kwargs = self.mock_sn.objects.update_or_create.call_args + self.assertEqual(call_kwargs.kwargs["notification_key"], f"xc-exp-warning-{profile.id}") + self.assertTrue(call_kwargs.kwargs["defaults"]["admin_only"]) + self.mock_send_ws.assert_called_once_with(notification) + + @patch("apps.m3u.tasks.timezone") + def test_warning_message_says_today_when_same_day(self, mock_tz): + now = timezone.now() + mock_tz.now.return_value = now + mock_tz.timedelta = timedelta + + profile = _make_profile(exp_date=now + timedelta(hours=2)) + self.mock_sn.objects.filter.return_value.values_list.return_value = [] + self.mock_sn.objects.update_or_create.return_value = (MagicMock(), True) + + self._run(profile) + + defaults = self.mock_sn.objects.update_or_create.call_args.kwargs["defaults"] + self.assertIn("today", defaults["message"]) + + @patch("apps.m3u.tasks.timezone") + def test_warning_message_says_1_day(self, mock_tz): + now = timezone.now() + mock_tz.now.return_value = now + mock_tz.timedelta = timedelta + + profile = _make_profile(exp_date=now + timedelta(hours=30)) + self.mock_sn.objects.filter.return_value.values_list.return_value = [] + self.mock_sn.objects.update_or_create.return_value = (MagicMock(), True) + + self._run(profile) + + defaults = self.mock_sn.objects.update_or_create.call_args.kwargs["defaults"] + self.assertIn("in 1 day", defaults["message"]) + + @patch("apps.m3u.tasks.timezone") + def test_warning_removes_stale_expired_notification(self, mock_tz): + now = timezone.now() + mock_tz.now.return_value = now + mock_tz.timedelta = timedelta + + profile = _make_profile(exp_date=now + timedelta(days=5)) + expired_key = f"xc-exp-expired-{profile.id}" + self.mock_sn.objects.filter.return_value.values_list.return_value = [expired_key] + self.mock_sn.objects.update_or_create.return_value = (MagicMock(), False) + + self._run(profile) + + self.mock_dismissed.assert_any_call(expired_key) + + # ------------------------------------------------------------------ # + # Not expiring soon (> 7 days away) + # ------------------------------------------------------------------ # + + @patch("apps.m3u.tasks.timezone") + def test_not_expiring_soon_returns_none(self, mock_tz): + now = timezone.now() + mock_tz.now.return_value = now + mock_tz.timedelta = timedelta + + profile = _make_profile(exp_date=now + timedelta(days=30)) + self.mock_sn.objects.filter.return_value.values_list.return_value = [] + + result = self._run(profile) + + self.assertIsNone(result) + self.mock_sn.objects.update_or_create.assert_not_called() + self.mock_send_ws.assert_not_called() + + @patch("apps.m3u.tasks.timezone") + def test_not_expiring_soon_removes_stale_notifications(self, mock_tz): + now = timezone.now() + mock_tz.now.return_value = now + mock_tz.timedelta = timedelta + + profile = _make_profile(exp_date=now + timedelta(days=30)) + warning_key = f"xc-exp-warning-{profile.id}" + self.mock_sn.objects.filter.return_value.values_list.return_value = [warning_key] + + self._run(profile) + + self.mock_dismissed.assert_called_once_with(warning_key) + + # ------------------------------------------------------------------ # + # Boundary: exactly at the 7-day warning threshold + # ------------------------------------------------------------------ # + + @patch("apps.m3u.tasks.timezone") + def test_exactly_7_days_away_triggers_warning(self, mock_tz): + now = timezone.now() + mock_tz.now.return_value = now + mock_tz.timedelta = timedelta + + # exp_date == now + 7 days → exp <= warning_threshold → warning + profile = _make_profile(exp_date=now + timedelta(days=7)) + self.mock_sn.objects.filter.return_value.values_list.return_value = [] + self.mock_sn.objects.update_or_create.return_value = (MagicMock(), True) + + result = self._run(profile) + + self.assertEqual(result, f"xc-exp-warning-{profile.id}") diff --git a/apps/m3u/tests/test_extinf_parsing.py b/apps/m3u/tests/test_extinf_parsing.py new file mode 100644 index 0000000..367f569 --- /dev/null +++ b/apps/m3u/tests/test_extinf_parsing.py @@ -0,0 +1,41 @@ +from django.test import SimpleTestCase + +from apps.m3u.tasks import parse_extinf_line + + +class ParseExtinfLineTests(SimpleTestCase): + def test_preserves_equals_padding_in_tvg_logo(self): + line = ( + '#EXTINF:-1 tvg-id="cp_891ee08a2cdfde210ec2c9137127103b" ' + 'tvg-chno="1001" ' + 'tvg-name="UK Sky Sports Premier League" ' + 'tvg-logo="https://e3.365dm.com/tvlogos/channels/1303-Logo.png?' + 'U2t5IFNwb3J0cyBQcmVtaWVyIExlYWd1ZQ==" ' + 'group-title="Team Games",UK Sky Sports Premier League' + ) + + parsed = parse_extinf_line(line) + + self.assertIsNotNone(parsed) + self.assertEqual( + parsed["attributes"]["tvg-logo"], + "https://e3.365dm.com/tvlogos/channels/1303-Logo.png?U2t5IFNwb3J0cyBQcmVtaWVyIExlYWd1ZQ==", + ) + self.assertEqual(parsed["attributes"]["group-title"], "Team Games") + self.assertEqual(parsed["name"], "UK Sky Sports Premier League") + + def test_supports_single_quoted_attributes(self): + line = ( + "#EXTINF:-1 tvg-name='Channel One' tvg-logo='https://example.com/logo==.png' " + "group-title='Sports',Channel One" + ) + + parsed = parse_extinf_line(line) + + self.assertIsNotNone(parsed) + self.assertEqual( + parsed["attributes"]["tvg-logo"], + "https://example.com/logo==.png", + ) + self.assertEqual(parsed["attributes"]["group-title"], "Sports") + self.assertEqual(parsed["display_name"], "Channel One") diff --git a/apps/m3u/tests/test_memory_cleanup.py b/apps/m3u/tests/test_memory_cleanup.py new file mode 100644 index 0000000..bd556e4 --- /dev/null +++ b/apps/m3u/tests/test_memory_cleanup.py @@ -0,0 +1,106 @@ +""" +Tests for memory cleanup behavior in M3U refresh tasks. + +Verifies that database connections are properly closed, task locks are +released on all exit paths, and garbage collection runs where expected. + +""" +from unittest.mock import patch, MagicMock + +from django.test import SimpleTestCase + +from apps.m3u.models import M3UAccount + + +class ProcessM3UBatchCleanupTests(SimpleTestCase): + """Verify process_m3u_batch_direct cleans up after processing.""" + + @patch("apps.m3u.tasks.Stream") + @patch("apps.m3u.tasks.M3UAccount") + def test_connections_closed_after_batch(self, mock_account_cls, mock_stream_cls): + """Database connections must be closed after batch processing (thread safety).""" + from apps.m3u.tasks import process_m3u_batch_direct + + mock_account = MagicMock() + mock_account.filters.order_by.return_value = [] + mock_account_cls.objects.get.return_value = mock_account + mock_stream_cls.objects.filter.return_value.select_related.return_value.only.return_value = ( + [] + ) + mock_stream_cls.generate_hash_key = MagicMock(return_value="hash123") + + with patch("django.db.connections") as mock_connections: + process_m3u_batch_direct(1, [], {}, ["name", "url"]) + mock_connections.close_all.assert_called() + + +class LockReleaseTests(SimpleTestCase): + """Verify task lock is released on all exit paths.""" + + @patch("apps.m3u.tasks.delete_m3u_refresh_task_by_id", return_value=False) + def test_lock_released_on_account_not_found(self, mock_delete): + """release_task_lock must be called when account does not exist.""" + with patch( + "apps.m3u.tasks.acquire_task_lock", return_value=True + ), patch("apps.m3u.tasks.release_task_lock") as mock_release, patch( + "apps.m3u.tasks.TaskLockRenewer" + ): + with patch( + "apps.m3u.tasks.M3UAccount.objects.get", + side_effect=M3UAccount.DoesNotExist, + ): + from apps.m3u.tasks import refresh_single_m3u_account + + refresh_single_m3u_account(99999) + + mock_release.assert_called_once_with( + "refresh_single_m3u_account", 99999 + ) + + def test_lock_released_on_exception(self): + """release_task_lock must be called when an exception is raised.""" + mock_account = MagicMock() + mock_account.is_active = True + mock_account.account_type = "STD" + mock_account.custom_properties = {} + mock_account.filters.all.return_value = [] + mock_account.status = MagicMock() + + with patch( + "apps.m3u.tasks.acquire_task_lock", return_value=True + ), patch("apps.m3u.tasks.release_task_lock") as mock_release, patch( + "apps.m3u.tasks.TaskLockRenewer" + ): + with patch( + "apps.m3u.tasks.M3UAccount.objects.get", return_value=mock_account + ): + with patch("os.path.exists", return_value=False): + with patch( + "apps.m3u.tasks.refresh_m3u_groups", + side_effect=RuntimeError("test"), + ): + from apps.m3u.tasks import refresh_single_m3u_account + + try: + refresh_single_m3u_account(1) + except RuntimeError: + pass + + mock_release.assert_called_once_with("refresh_single_m3u_account", 1) + + +class XCCategoryCleanupTests(SimpleTestCase): + """Regression guard: process_xc_category_direct must continue to clean up.""" + + @patch("apps.m3u.tasks.XCClient") + @patch("apps.m3u.tasks.M3UAccount") + def test_xc_category_calls_gc_collect(self, mock_account_cls, mock_xc_client): + """gc.collect() must be called after XC category processing.""" + from apps.m3u.tasks import process_xc_category_direct + + mock_account = MagicMock() + mock_account_cls.objects.get.return_value = mock_account + + with patch("gc.collect") as mock_gc, patch("django.db.connections"): + process_xc_category_direct(1, {}, {}, ["name", "url"]) + mock_gc.assert_called() diff --git a/apps/m3u/urls.py b/apps/m3u/urls.py new file mode 100644 index 0000000..55c3c45 --- /dev/null +++ b/apps/m3u/urls.py @@ -0,0 +1,6 @@ +from django.urls import path +from .views import M3UDashboardView + +urlpatterns = [ + path('dashboard', M3UDashboardView.as_view(), name='m3u_dashboard'), +] diff --git a/apps/m3u/utils.py b/apps/m3u/utils.py new file mode 100644 index 0000000..598ef71 --- /dev/null +++ b/apps/m3u/utils.py @@ -0,0 +1,118 @@ +# 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: + current_usage = active_streams_map.get(account.id, 0) + current_usage += 1 + active_streams_map[account.id] = current_usage + account.active_streams = current_usage + account.save(update_fields=['active_streams']) + +def decrement_stream_count(account): + with lock: + current_usage = active_streams_map.get(account.id, 0) + if current_usage > 0: + current_usage -= 1 + if current_usage == 0: + del active_streams_map[account.id] + else: + 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 diff --git a/apps/m3u/views.py b/apps/m3u/views.py new file mode 100644 index 0000000..0fab8c1 --- /dev/null +++ b/apps/m3u/views.py @@ -0,0 +1,35 @@ +from django.shortcuts import render +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 + + +@method_decorator(csrf_exempt, name='dispatch') +@method_decorator(login_required, name='dispatch') +class M3UDashboardView(View): + def get(self, request, *args, **kwargs): + """ + Handles GET requests for the M3U dashboard. + Renders the m3u.html template with M3U account data. + """ + m3u_accounts = M3UAccount.objects.all() + return render(request, 'm3u/m3u.html', {'m3u_accounts': m3u_accounts}) + + def post(self, request, *args, **kwargs): + """ + Handles POST requests to create a new M3U account. + Expects JSON data in the request body. + """ + try: + data = json.loads(request.body) + new_account = M3UAccount.objects.create(**data) + return JsonResponse({ + 'id': new_account.id, + 'message': 'M3U account created successfully!' + }, status=201) + except Exception as e: + return JsonResponse({'error': str(e)}, status=400) diff --git a/apps/output/__init__.py b/apps/output/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/output/apps.py b/apps/output/apps.py new file mode 100644 index 0000000..cdd1b46 --- /dev/null +++ b/apps/output/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + +class OutputConfig(AppConfig): + default_auto_field = 'django.db.models.BigAutoField' + name = 'apps.output' + verbose_name = "Output" diff --git a/apps/output/tests.py b/apps/output/tests.py new file mode 100644 index 0000000..b1c7d58 --- /dev/null +++ b/apps/output/tests.py @@ -0,0 +1,145 @@ +from django.test import TestCase, Client +from django.urls import reverse +from apps.channels.models import Channel, ChannelGroup +from apps.epg.models import EPGData, EPGSource +import xml.etree.ElementTree as ET + +class OutputM3UTest(TestCase): + def setUp(self): + self.client = Client() + + def test_generate_m3u_response(self): + """ + Test that the M3U endpoint returns a valid M3U file. + """ + url = reverse('output:generate_m3u') + response = self.client.get(url) + self.assertEqual(response.status_code, 200) + content = response.content.decode() + self.assertIn("#EXTM3U", content) + + def test_generate_m3u_response_post_empty_body(self): + """ + Test that a POST request with an empty body returns 200 OK. + """ + url = reverse('output:generate_m3u') + + response = self.client.post(url, data=None, content_type='application/x-www-form-urlencoded') + content = response.content.decode() + + self.assertEqual(response.status_code, 200, "POST with empty body should return 200 OK") + self.assertIn("#EXTM3U", content) + + def test_generate_m3u_response_post_with_body(self): + """ + Test that a POST request with a non-empty body returns 403 Forbidden. + """ + url = reverse('output:generate_m3u') + + response = self.client.post(url, data={'evilstring': 'muhahaha'}) + + self.assertEqual(response.status_code, 403, "POST with body should return 403 Forbidden") + self.assertIn("POST requests with body are not allowed, body is:", response.content.decode()) + + +class OutputEPGXMLEscapingTest(TestCase): + """Test XML escaping of channel_id attributes in EPG generation""" + + def setUp(self): + self.client = Client() + self.group = ChannelGroup.objects.create(name="Test Group") + + def test_channel_id_with_ampersand(self): + """Test channel ID with ampersand is properly escaped""" + channel = Channel.objects.create( + channel_number=1.0, + name="Test Channel", + tvg_id="News & Sports", + channel_group=self.group + ) + + url = reverse('output:generate_epg') + '?tvg_id_source=tvg_id' + response = self.client.get(url) + + self.assertEqual(response.status_code, 200) + content = response.content.decode() + + # Should contain escaped ampersand + self.assertIn('id="News & Sports"', content) + self.assertNotIn('id="News & Sports"', content) + + # Verify XML is parseable + try: + ET.fromstring(content) + except ET.ParseError as e: + self.fail(f"Generated EPG is not valid XML: {e}") + + def test_channel_id_with_angle_brackets(self): + """Test channel ID with < and > characters""" + channel = Channel.objects.create( + channel_number=2.0, + name="HD Channel", + tvg_id="Channel ", + channel_group=self.group + ) + + url = reverse('output:generate_epg') + '?tvg_id_source=tvg_id' + response = self.client.get(url) + + content = response.content.decode() + self.assertIn('id="Channel <HD>"', content) + + try: + ET.fromstring(content) + except ET.ParseError as e: + self.fail(f"Generated EPG with < > is not valid XML: {e}") + + def test_channel_id_with_all_special_chars(self): + """Test channel ID with all XML special characters""" + channel = Channel.objects.create( + channel_number=3.0, + name="Complex Channel", + tvg_id='Test & "Special" ', + channel_group=self.group + ) + + url = reverse('output:generate_epg') + '?tvg_id_source=tvg_id' + response = self.client.get(url) + + content = response.content.decode() + self.assertIn('id="Test & "Special" <Chars>"', content) + + try: + tree = ET.fromstring(content) + # Verify we can find the channel with correct ID in parsed tree + channel_elem = tree.find('.//channel[@id="Test & \\"Special\\" "]') + self.assertIsNotNone(channel_elem) + except ET.ParseError as e: + self.fail(f"Generated EPG with all special chars is not valid XML: {e}") + + def test_program_channel_attribute_escaping(self): + """Test that programme elements also have escaped channel attributes""" + epg_source = EPGSource.objects.create(name="Test EPG", source_type="dummy") + epg_data = EPGData.objects.create(name="Test EPG Data", epg_source=epg_source) + channel = Channel.objects.create( + channel_number=4.0, + name="Program Test", + tvg_id="News & Sports", + epg_data=epg_data, + channel_group=self.group + ) + + url = reverse('output:generate_epg') + '?tvg_id_source=tvg_id' + response = self.client.get(url) + + content = response.content.decode() + + # Check programme elements have escaped channel attributes + self.assertIn('channel="News & Sports"', content) + + try: + tree = ET.fromstring(content) + programmes = tree.findall('.//programme[@channel="News & Sports"]') + self.assertGreater(len(programmes), 0) + except ET.ParseError as e: + self.fail(f"Generated EPG with programme elements is not valid XML: {e}") diff --git a/apps/output/urls.py b/apps/output/urls.py new file mode 100644 index 0000000..dc023ed --- /dev/null +++ b/apps/output/urls.py @@ -0,0 +1,14 @@ +from django.urls import path, re_path, include +from .views import m3u_endpoint, epg_endpoint, xc_get, xc_movie_stream, xc_series_stream +from core.views import stream_view + +app_name = "output" + +urlpatterns = [ + # Allow `/m3u`, `/m3u/`, `/m3u/profile_name`, and `/m3u/profile_name/` + re_path(r"^m3u(?:/(?P[^/]+))?/?$", m3u_endpoint, name="m3u_endpoint"), + # Allow `/epg`, `/epg/`, `/epg/profile_name`, and `/epg/profile_name/` + re_path(r"^epg(?:/(?P[^/]+))?/?$", epg_endpoint, name="epg_endpoint"), + # Allow both `/stream/` and `/stream//` + re_path(r"^stream/(?P[0-9a-fA-F\-]+)/?$", stream_view, name="stream"), +] diff --git a/apps/output/views.py b/apps/output/views.py new file mode 100644 index 0000000..d3df1c7 --- /dev/null +++ b/apps/output/views.py @@ -0,0 +1,3140 @@ +from django.http import HttpResponse, JsonResponse, Http404, HttpResponseForbidden, StreamingHttpResponse +from rest_framework.response import Response +from django.urls import reverse +from apps.channels.models import Channel, ChannelProfile, ChannelGroup, Stream +from django.db.models import Prefetch +from django.views.decorators.csrf import csrf_exempt +from django.views.decorators.http import require_http_methods +from apps.epg.models import ProgramData +from apps.accounts.models import User +from dispatcharr.utils import network_access_allowed +from django.utils import timezone as django_timezone +from django.shortcuts import get_object_or_404 +from datetime import datetime, timedelta +import html +import time +from tzlocal import get_localzone +from urllib.parse import urlparse +import base64 +import logging +from django.db.models.functions import Lower +import os +from apps.m3u.utils import calculate_tuner_count +import regex +from core.utils import log_system_event +from apps.vod.catalog_settings import ( + get_vod_catalog_account_id, + filter_m3u_relation_queryset_for_playback, + filter_relation_qs_for_user_playback, + get_user_allowed_movie_category_ids, + get_user_allowed_series_category_ids, +) +import hashlib + +logger = logging.getLogger(__name__) + +def get_client_identifier(request): + """Get client information including IP, user agent, and a unique hash identifier + + Returns: + tuple: (client_id_hash, client_ip, user_agent) + """ + # Get client IP (handle proxies) + x_forwarded_for = request.META.get('HTTP_X_FORWARDED_FOR') + if x_forwarded_for: + client_ip = x_forwarded_for.split(',')[0].strip() + else: + client_ip = request.META.get('REMOTE_ADDR', 'unknown') + + # Get user agent + user_agent = request.META.get('HTTP_USER_AGENT', 'unknown') + + # Create a hash for a shorter cache key + client_str = f"{client_ip}:{user_agent}" + client_id_hash = hashlib.md5(client_str.encode()).hexdigest()[:12] + + return client_id_hash, client_ip, user_agent + +def m3u_endpoint(request, profile_name=None, user=None): + logger.debug("m3u_endpoint called: method=%s, profile=%s", request.method, profile_name) + if not network_access_allowed(request, "M3U_EPG"): + # Log blocked M3U download + 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='m3u_blocked', + profile=profile_name or 'all', + reason='Network access denied', + client_ip=client_ip, + user_agent=user_agent, + ) + return JsonResponse({"error": "Forbidden"}, status=403) + + # Handle HEAD requests efficiently without generating content + if request.method == "HEAD": + logger.debug("Handling HEAD request for M3U") + response = HttpResponse(content_type="audio/x-mpegurl") + response["Content-Disposition"] = 'attachment; filename="channels.m3u"' + return response + + return generate_m3u(request, profile_name, user) + +def epg_endpoint(request, profile_name=None, user=None): + logger.debug("epg_endpoint called: method=%s, profile=%s", request.method, profile_name) + if not network_access_allowed(request, "M3U_EPG"): + # Log blocked EPG download + 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='epg_blocked', + profile=profile_name or 'all', + reason='Network access denied', + client_ip=client_ip, + user_agent=user_agent, + ) + return JsonResponse({"error": "Forbidden"}, status=403) + + # Handle HEAD requests efficiently without generating content + if request.method == "HEAD": + logger.debug("Handling HEAD request for EPG") + response = HttpResponse(content_type="application/xml") + response["Content-Disposition"] = 'attachment; filename="Dispatcharr.xml"' + response["Cache-Control"] = "no-cache" + return response + + return generate_epg(request, profile_name, user) + +@csrf_exempt +@require_http_methods(["GET", "POST", "HEAD"]) +def generate_m3u(request, profile_name=None, user=None): + """ + Dynamically generate an M3U file from channels. + The stream URL now points to the new stream_view that uses StreamProfile. + Supports both GET and POST methods for compatibility with IPTVSmarters. + """ + # Check if this is a POST request and the body is not empty (which we don't want to allow) + logger.debug("Generating M3U for profile: %s, user: %s, method: %s", profile_name, user.username if user else "Anonymous", request.method) + + # Check cache for recent identical request (helps with double-GET from browsers) + from django.core.cache import cache + cache_params = f"{profile_name or 'all'}:{user.username if user else 'anonymous'}:{request.GET.urlencode()}" + content_cache_key = f"m3u_content:{cache_params}" + + cached_content = cache.get(content_cache_key) + if cached_content: + logger.debug("Serving M3U from cache") + response = HttpResponse(cached_content, content_type="audio/x-mpegurl") + response["Content-Disposition"] = 'attachment; filename="channels.m3u"' + return response + # Check if this is a POST request with data (which we don't want to allow) + if request.method == "POST" and request.body: + if request.body.decode() != '{}': + return HttpResponseForbidden("POST requests with body are not allowed, body is: {}".format(request.body.decode())) + + if user is not None: + if user.user_level < 10: + user_profile_count = user.channel_profiles.count() + + # 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 = {"user_level__lte": user.user_level} + # Hide adult content if user preference is set + if (user.custom_properties or {}).get('hide_adult_content', False): + filters["is_adult"] = False + channels = Channel.objects.filter(**filters).select_related('channel_group', 'logo').order_by("channel_number") + else: + # User has specific limited profiles assigned + filters = { + "channelprofilemembership__enabled": True, + "user_level__lte": user.user_level, + "channelprofilemembership__channel_profile__in": user.channel_profiles.all() + } + # Hide adult content if user preference is set + if (user.custom_properties or {}).get('hide_adult_content', False): + filters["is_adult"] = False + channels = Channel.objects.filter(**filters).select_related('channel_group', 'logo').distinct().order_by("channel_number") + else: + channels = Channel.objects.filter(user_level__lte=user.user_level).select_related('channel_group', 'logo').order_by( + "channel_number" + ) + + else: + if profile_name is not None: + try: + channel_profile = ChannelProfile.objects.get(name=profile_name) + except ChannelProfile.DoesNotExist: + logger.warning("Requested channel profile (%s) during m3u generation does not exist", profile_name) + raise Http404(f"Channel profile '{profile_name}' not found") + channels = Channel.objects.filter( + channelprofilemembership__channel_profile=channel_profile, + channelprofilemembership__enabled=True + ).select_related('channel_group', 'logo').order_by('channel_number') + else: + channels = Channel.objects.select_related('channel_group', 'logo').order_by("channel_number") + + # Check if the request wants to use direct logo URLs instead of cache + use_cached_logos = request.GET.get('cachedlogos', 'true').lower() != 'false' + + # Check if direct stream URLs should be used instead of proxy + use_direct_urls = request.GET.get('direct', 'false').lower() == 'true' + + # Prefetch streams only when direct URLs are requested (avoids N+1 per channel) + if use_direct_urls: + channels = channels.prefetch_related( + Prefetch('streams', queryset=Stream.objects.order_by('channelstream__order')) + ) + + # Get the source to use for tvg-id value + # Options: 'channel_number' (default), 'tvg_id', 'gracenote' + tvg_id_source = request.GET.get('tvg_id_source', 'channel_number').lower() + + # Build EPG URL with query parameters if needed + # Check if this is an XC API request (has username/password in GET params and user is authenticated) + xc_username = request.GET.get('username') + xc_password = request.GET.get('password') + is_xc_request = user is not None and xc_username and xc_password + + if is_xc_request: + # This is an XC API request - use XC-style EPG URL + base_url = build_absolute_uri_with_port(request, '') + epg_url = f"{base_url}/xmltv.php?username={xc_username}&password={xc_password}" + else: + # Regular request - use standard EPG endpoint + epg_base_url = build_absolute_uri_with_port(request, reverse('output:epg_endpoint', args=[profile_name]) if profile_name else reverse('output:epg_endpoint')) + + # Optionally preserve certain query parameters + preserved_params = ['tvg_id_source', 'cachedlogos', 'days', 'prev_days'] + query_params = {k: v for k, v in request.GET.items() if k in preserved_params} + if query_params: + from urllib.parse import urlencode + epg_url = f"{epg_base_url}?{urlencode(query_params)}" + else: + epg_url = epg_base_url + + # Add x-tvg-url and url-tvg attribute for EPG URL + m3u_content = f'#EXTM3U x-tvg-url="{epg_url}" url-tvg="{epg_url}"\n' + + # Start building M3U content + for channel in channels: + group_title = channel.channel_group.name if channel.channel_group else "Default" + + # Format channel number as integer if it has no decimal component + if channel.channel_number is not None: + if channel.channel_number == int(channel.channel_number): + formatted_channel_number = int(channel.channel_number) + else: + formatted_channel_number = channel.channel_number + else: + formatted_channel_number = "" + + # Determine the tvg-id based on the selected source + if tvg_id_source == 'tvg_id' and channel.tvg_id: + tvg_id = channel.tvg_id + elif tvg_id_source == 'gracenote' and channel.tvc_guide_stationid: + tvg_id = channel.tvc_guide_stationid + else: + # Default to channel number (original behavior) + tvg_id = str(formatted_channel_number) if formatted_channel_number != "" else str(channel.id) + + tvg_name = channel.name + + tvg_logo = "" + if channel.logo: + if use_cached_logos: + # Use cached logo as before + tvg_logo = build_absolute_uri_with_port(request, reverse('api:channels:logo-cache', args=[channel.logo.id])) + else: + # Try to find direct logo URL from channel's streams + direct_logo = channel.logo.url if channel.logo.url.startswith(('http://', 'https://')) else None + # If direct logo found, use it; otherwise fall back to cached version + if direct_logo: + tvg_logo = direct_logo + else: + tvg_logo = build_absolute_uri_with_port(request, reverse('api:channels:logo-cache', args=[channel.logo.id])) + + # create possible gracenote id insertion + tvc_guide_stationid = "" + if channel.tvc_guide_stationid: + tvc_guide_stationid = ( + f'tvc-guide-stationid="{channel.tvc_guide_stationid}" ' + ) + + extinf_line = ( + f'#EXTINF:-1 tvg-id="{tvg_id}" tvg-name="{tvg_name}" tvg-logo="{tvg_logo}" ' + f'tvg-chno="{formatted_channel_number}" {tvc_guide_stationid}group-title="{group_title}",{channel.name}\n' + ) + + # Determine the stream URL based on request type + if is_xc_request: + # XC API request - use XC-style stream URL format + base_url = build_absolute_uri_with_port(request, '') + stream_url = f"{base_url}/live/{xc_username}/{xc_password}/{channel.id}" + elif use_direct_urls: + # Try to get the first stream's direct URL + all_streams = channel.streams.all() + first_stream = all_streams[0] if all_streams else None + if first_stream and first_stream.url: + # Use the direct stream URL + stream_url = first_stream.url + else: + # Fall back to proxy URL if no direct URL available + stream_url = build_absolute_uri_with_port(request, f"/proxy/ts/stream/{channel.uuid}") + else: + # Standard behavior - use proxy URL + stream_url = build_absolute_uri_with_port(request, f"/proxy/ts/stream/{channel.uuid}") + + m3u_content += extinf_line + stream_url + "\n" + + # Cache the generated content for 2 seconds to handle double-GET requests + cache.set(content_cache_key, m3u_content, 2) + + # Log system event for M3U download (with deduplication based on client) + client_id, client_ip, user_agent = get_client_identifier(request) + event_cache_key = f"m3u_download:{user.username if user else 'anonymous'}:{profile_name or 'all'}:{client_id}" + if not cache.get(event_cache_key): + log_system_event( + event_type='m3u_download', + profile=profile_name or 'all', + user=user.username if user else 'anonymous', + channels=channels.count(), + client_ip=client_ip, + user_agent=user_agent, + ) + cache.set(event_cache_key, True, 2) # Prevent duplicate events for 2 seconds + + response = HttpResponse(m3u_content, content_type="audio/x-mpegurl") + response["Content-Disposition"] = 'attachment; filename="channels.m3u"' + return response + + +def generate_fallback_programs(channel_id, channel_name, now, num_days, program_length_hours, fallback_title, fallback_description): + """ + Generate dummy programs using custom fallback templates when patterns don't match. + + Args: + channel_id: Channel ID for the programs + channel_name: Channel name to use as fallback in templates + now: Current datetime (in UTC) + num_days: Number of days to generate programs for + program_length_hours: Length of each program in hours + fallback_title: Custom fallback title template (empty string if not provided) + fallback_description: Custom fallback description template (empty string if not provided) + + Returns: + List of program dictionaries + """ + programs = [] + + # Use custom fallback title or channel name as default + title = fallback_title if fallback_title else channel_name + + # Use custom fallback description or a simple default message + if fallback_description: + description = fallback_description + else: + description = f"EPG information is currently unavailable for {channel_name}" + + # Create programs for each day + for day in range(num_days): + day_start = now + timedelta(days=day) + + # Create programs with specified length throughout the day + for hour_offset in range(0, 24, program_length_hours): + # Calculate program start and end times + start_time = day_start + timedelta(hours=hour_offset) + end_time = start_time + timedelta(hours=program_length_hours) + + programs.append({ + "channel_id": channel_id, + "start_time": start_time, + "end_time": end_time, + "title": title, + "description": description, + }) + + return programs + + +def generate_dummy_programs(channel_id, channel_name, num_days=1, program_length_hours=4, epg_source=None): + """ + Generate dummy EPG programs for channels. + + If epg_source is provided and it's a custom dummy EPG with patterns, + use those patterns to generate programs from the channel title. + Otherwise, generate default dummy programs. + + Args: + channel_id: Channel ID for the programs + channel_name: Channel title/name + num_days: Number of days to generate programs for + program_length_hours: Length of each program in hours + epg_source: Optional EPGSource for custom dummy EPG with patterns + + Returns: + List of program dictionaries + """ + # Get current time rounded to hour + now = django_timezone.now() + now = now.replace(minute=0, second=0, microsecond=0) + + # Check if this is a custom dummy EPG with regex patterns + if epg_source and epg_source.source_type == 'dummy' and epg_source.custom_properties: + custom_programs = generate_custom_dummy_programs( + channel_id, channel_name, now, num_days, + epg_source.custom_properties + ) + # If custom generation succeeded, return those programs + # If it returned empty (pattern didn't match), check for custom fallback templates + if custom_programs: + return custom_programs + else: + logger.info(f"Custom pattern didn't match for '{channel_name}', checking for custom fallback templates") + + # Check if custom fallback templates are provided + custom_props = epg_source.custom_properties + fallback_title = custom_props.get('fallback_title_template', '').strip() + fallback_description = custom_props.get('fallback_description_template', '').strip() + + # If custom fallback templates exist, use them instead of default + if fallback_title or fallback_description: + logger.info(f"Using custom fallback templates for '{channel_name}'") + return generate_fallback_programs( + channel_id, channel_name, now, num_days, + program_length_hours, fallback_title, fallback_description + ) + else: + logger.info(f"No custom fallback templates found, using default dummy EPG") + + # Default humorous program descriptions based on time of day + time_descriptions = { + (0, 4): [ + f"Late Night with {channel_name} - Where insomniacs unite!", + f"The 'Why Am I Still Awake?' Show on {channel_name}", + f"Counting Sheep - A {channel_name} production for the sleepless", + ], + (4, 8): [ + f"Dawn Patrol - Rise and shine with {channel_name}!", + f"Early Bird Special - Coffee not included", + f"Morning Zombies - Before coffee viewing on {channel_name}", + ], + (8, 12): [ + f"Mid-Morning Meetings - Pretend you're paying attention while watching {channel_name}", + f"The 'I Should Be Working' Hour on {channel_name}", + f"Productivity Killer - {channel_name}'s daytime programming", + ], + (12, 16): [ + f"Lunchtime Laziness with {channel_name}", + f"The Afternoon Slump - Brought to you by {channel_name}", + f"Post-Lunch Food Coma Theater on {channel_name}", + ], + (16, 20): [ + f"Rush Hour - {channel_name}'s alternative to traffic", + f"The 'What's For Dinner?' Debate on {channel_name}", + f"Evening Escapism - {channel_name}'s remedy for reality", + ], + (20, 24): [ + f"Prime Time Placeholder - {channel_name}'s finest not-programming", + f"The 'Netflix Was Too Complicated' Show on {channel_name}", + f"Family Argument Avoider - Courtesy of {channel_name}", + ], + } + + programs = [] + + # Create programs for each day + for day in range(num_days): + day_start = now + timedelta(days=day) + + # Create programs with specified length throughout the day + for hour_offset in range(0, 24, program_length_hours): + # Calculate program start and end times + start_time = day_start + timedelta(hours=hour_offset) + end_time = start_time + timedelta(hours=program_length_hours) + + # Get the hour for selecting a description + hour = start_time.hour + + # Find the appropriate time slot for description + for time_range, descriptions in time_descriptions.items(): + start_range, end_range = time_range + if start_range <= hour < end_range: + # Pick a description using the sum of the hour and day as seed + # This makes it somewhat random but consistent for the same timeslot + description = descriptions[(hour + day) % len(descriptions)] + break + else: + # Fallback description if somehow no range matches + description = f"Placeholder program for {channel_name} - EPG data went on vacation" + + programs.append({ + "channel_id": channel_id, + "start_time": start_time, + "end_time": end_time, + "title": channel_name, + "description": description, + }) + + return programs + + +def generate_custom_dummy_programs(channel_id, channel_name, now, num_days, custom_properties): + """ + Generate programs using custom dummy EPG regex patterns. + + Extracts information from channel title using regex patterns and generates + programs based on the extracted data. + + TIMEZONE HANDLING: + ------------------ + The timezone parameter specifies the timezone of the event times in your channel + titles using standard timezone names (e.g., 'US/Eastern', 'US/Pacific', 'Europe/London'). + DST (Daylight Saving Time) is handled automatically by pytz. + + Examples: + - Channel: "NHL 01: Bruins VS Maple Leafs @ 8:00PM ET" + - Set timezone = "US/Eastern" + - In October (DST): 8:00PM EDT → 12:00AM UTC (automatically uses UTC-4) + - In January (no DST): 8:00PM EST → 1:00AM UTC (automatically uses UTC-5) + + Args: + channel_id: Channel ID for the programs + channel_name: Channel title to parse + now: Current datetime (in UTC) + num_days: Number of days to generate programs for + custom_properties: Dict with title_pattern, time_pattern, templates, etc. + - timezone: Timezone name (e.g., 'US/Eastern') + + Returns: + List of program dictionaries with start_time/end_time in UTC + """ + import pytz + + logger.info(f"Generating custom dummy programs for channel: {channel_name}") + + # Extract patterns from custom properties + title_pattern = custom_properties.get('title_pattern', '') + time_pattern = custom_properties.get('time_pattern', '') + date_pattern = custom_properties.get('date_pattern', '') + + # Get timezone name (e.g., 'US/Eastern', 'US/Pacific', 'Europe/London') + timezone_value = custom_properties.get('timezone', 'UTC') + output_timezone_value = custom_properties.get('output_timezone', '') # Optional: display times in different timezone + program_duration = custom_properties.get('program_duration', 180) # Minutes + title_template = custom_properties.get('title_template', '') + subtitle_template = custom_properties.get('subtitle_template', '') + description_template = custom_properties.get('description_template', '') + + # Templates for upcoming/ended programs + upcoming_title_template = custom_properties.get('upcoming_title_template', '') + upcoming_description_template = custom_properties.get('upcoming_description_template', '') + ended_title_template = custom_properties.get('ended_title_template', '') + ended_description_template = custom_properties.get('ended_description_template', '') + + # Image URL templates + channel_logo_url_template = custom_properties.get('channel_logo_url', '') + program_poster_url_template = custom_properties.get('program_poster_url', '') + + # EPG metadata options + category_string = custom_properties.get('category', '') + # Split comma-separated categories and strip whitespace, filter out empty strings + categories = [cat.strip() for cat in category_string.split(',') if cat.strip()] if category_string else [] + include_date = custom_properties.get('include_date', True) + include_live = custom_properties.get('include_live', False) + include_new = custom_properties.get('include_new', False) + + # Parse timezone name + try: + source_tz = pytz.timezone(timezone_value) + logger.debug(f"Using timezone: {timezone_value} (DST will be handled automatically)") + except pytz.exceptions.UnknownTimeZoneError: + logger.warning(f"Unknown timezone: {timezone_value}, defaulting to UTC") + source_tz = pytz.utc + + # Parse output timezone if provided (for display purposes) + output_tz = None + if output_timezone_value: + try: + output_tz = pytz.timezone(output_timezone_value) + logger.debug(f"Using output timezone for display: {output_timezone_value}") + except pytz.exceptions.UnknownTimeZoneError: + logger.warning(f"Unknown output timezone: {output_timezone_value}, will use source timezone") + output_tz = None + + if not title_pattern: + logger.warning(f"No title_pattern in custom_properties, falling back to default") + return [] # Return empty, will use default + + logger.debug(f"Title pattern from DB: {repr(title_pattern)}") + + # Convert PCRE/JavaScript named groups (?) to Python format (?P) + # This handles patterns created with JavaScript regex syntax + # Use negative lookahead to avoid matching lookbehind (?<=) and negative lookbehind (?]+)>', r'(?P<\1>', title_pattern) + logger.debug(f"Converted title pattern: {repr(title_pattern)}") + + # Compile regex patterns using the enhanced regex module + # (supports variable-width lookbehinds like JavaScript) + try: + title_regex = regex.compile(title_pattern) + except Exception as e: + logger.error(f"Invalid title regex pattern after conversion: {e}") + logger.error(f"Pattern was: {repr(title_pattern)}") + return [] + + time_regex = None + if time_pattern: + # Convert PCRE/JavaScript named groups to Python format + # Use negative lookahead to avoid matching lookbehind (?<=) and negative lookbehind (?]+)>', r'(?P<\1>', time_pattern) + logger.debug(f"Converted time pattern: {repr(time_pattern)}") + try: + time_regex = regex.compile(time_pattern) + except Exception as e: + logger.warning(f"Invalid time regex pattern after conversion: {e}") + logger.warning(f"Pattern was: {repr(time_pattern)}") + + # Compile date regex if provided + date_regex = None + if date_pattern: + # Convert PCRE/JavaScript named groups to Python format + # Use negative lookahead to avoid matching lookbehind (?<=) and negative lookbehind (?]+)>', r'(?P<\1>', date_pattern) + logger.debug(f"Converted date pattern: {repr(date_pattern)}") + try: + date_regex = regex.compile(date_pattern) + except Exception as e: + logger.warning(f"Invalid date regex pattern after conversion: {e}") + logger.warning(f"Pattern was: {repr(date_pattern)}") + + # Try to match the channel name with the title pattern + # Use search() instead of match() to match JavaScript behavior where .match() searches anywhere in the string + title_match = title_regex.search(channel_name) + if not title_match: + logger.debug(f"Channel name '{channel_name}' doesn't match title pattern") + return [] # Return empty, will use default + + groups = title_match.groupdict() + logger.debug(f"Title pattern matched. Groups: {groups}") + + # Helper function to format template with matched groups + def format_template(template, groups, url_encode=False): + """Replace {groupname} placeholders with matched group values + + Args: + template: Template string with {groupname} placeholders + groups: Dict of group names to values + url_encode: If True, URL encode the group values for safe use in URLs + """ + if not template: + return '' + result = template + for key, value in groups.items(): + if url_encode and value: + # URL encode the value to handle spaces and special characters + from urllib.parse import quote + encoded_value = quote(str(value), safe='') + result = result.replace(f'{{{key}}}', encoded_value) + else: + result = result.replace(f'{{{key}}}', str(value) if value else '') + return result + + # Extract time from title if time pattern exists + time_info = None + time_groups = {} + if time_regex: + time_match = time_regex.search(channel_name) + if time_match: + time_groups = time_match.groupdict() + try: + hour = int(time_groups.get('hour')) + # Handle optional minute group - could be None if not captured + minute_value = time_groups.get('minute') + minute = int(minute_value) if minute_value is not None else 0 + ampm = time_groups.get('ampm') + ampm = ampm.lower() if ampm else None + + # Determine if this is 12-hour or 24-hour format + if ampm in ('am', 'pm'): + # 12-hour format: convert to 24-hour + if ampm == 'pm' and hour != 12: + hour += 12 + elif ampm == 'am' and hour == 12: + hour = 0 + logger.debug(f"Extracted time (12-hour): {hour}:{minute:02d} {ampm}") + else: + # 24-hour format: hour is already in 24-hour format + # Validate that it's actually a 24-hour time (0-23) + if hour > 23: + logger.warning(f"Invalid 24-hour time: {hour}. Must be 0-23.") + hour = hour % 24 # Wrap around just in case + logger.debug(f"Extracted time (24-hour): {hour}:{minute:02d}") + + time_info = {'hour': hour, 'minute': minute} + except (ValueError, TypeError) as e: + logger.warning(f"Error parsing time: {e}") + + # Extract date from title if date pattern exists + date_info = None + date_groups = {} + if date_regex: + date_match = date_regex.search(channel_name) + if date_match: + date_groups = date_match.groupdict() + try: + # Support various date group names: month, day, year + month_str = date_groups.get('month', '') + day_str = date_groups.get('day', '') + year_str = date_groups.get('year', '') + + # Parse day - default to current day if empty or invalid + day = int(day_str) if day_str else now.day + + # Parse year - default to current year if empty or invalid (matches frontend behavior) + year = int(year_str) if year_str else now.year + + # Parse month - can be numeric (1-12) or text (Jan, January, etc.) + month = None + if month_str: + if month_str.isdigit(): + month = int(month_str) + else: + # Try to parse text month names + import calendar + month_str_lower = month_str.lower() + # Check full month names + for i, month_name in enumerate(calendar.month_name): + if month_name.lower() == month_str_lower: + month = i + break + # Check abbreviated month names if not found + if month is None: + for i, month_abbr in enumerate(calendar.month_abbr): + if month_abbr.lower() == month_str_lower: + month = i + break + + # Default to current month if not extracted or invalid + if month is None: + month = now.month + + if month and 1 <= month <= 12 and 1 <= day <= 31: + date_info = {'year': year, 'month': month, 'day': day} + logger.debug(f"Extracted date: {year}-{month:02d}-{day:02d}") + else: + logger.warning(f"Invalid date values: month={month}, day={day}, year={year}") + except (ValueError, TypeError) as e: + logger.warning(f"Error parsing date: {e}") + + # Merge title groups, time groups, and date groups for template formatting + all_groups = {**groups, **time_groups, **date_groups} + + # Add normalized versions of all groups for cleaner URLs + # These remove all non-alphanumeric characters and convert to lowercase + for key, value in list(all_groups.items()): + if value: + # Remove all non-alphanumeric characters (except spaces temporarily) + # then replace spaces with nothing, and convert to lowercase + normalized = regex.sub(r'[^a-zA-Z0-9\s]', '', str(value)) + normalized = regex.sub(r'\s+', '', normalized).lower() + all_groups[f'{key}_normalize'] = normalized + + # Format channel logo URL if template provided (with URL encoding) + channel_logo_url = None + if channel_logo_url_template: + channel_logo_url = format_template(channel_logo_url_template, all_groups, url_encode=True) + logger.debug(f"Formatted channel logo URL: {channel_logo_url}") + + # Format program poster URL if template provided (with URL encoding) + program_poster_url = None + if program_poster_url_template: + program_poster_url = format_template(program_poster_url_template, all_groups, url_encode=True) + logger.debug(f"Formatted program poster URL: {program_poster_url}") + + # Add formatted time strings for better display (handles minutes intelligently) + if time_info: + hour_24 = time_info['hour'] + minute = time_info['minute'] + + # Determine the base date to use for placeholders + # If date was extracted, use it; otherwise use current date + if date_info: + base_date = datetime(date_info['year'], date_info['month'], date_info['day']) + else: + base_date = datetime.now() + + # If output_timezone is specified, convert the display time to that timezone + if output_tz: + # Create a datetime in the source timezone using the base date + temp_date = source_tz.localize(base_date.replace(hour=hour_24, minute=minute, second=0, microsecond=0)) + # Convert to output timezone + temp_date_output = temp_date.astimezone(output_tz) + # Extract converted hour and minute for display + hour_24 = temp_date_output.hour + minute = temp_date_output.minute + logger.debug(f"Converted display time from {source_tz} to {output_tz}: {hour_24}:{minute:02d}") + + # Add date placeholders based on the OUTPUT timezone + # This ensures {date}, {month}, {day}, {year} reflect the converted timezone + all_groups['date'] = temp_date_output.strftime('%Y-%m-%d') + all_groups['month'] = str(temp_date_output.month) + all_groups['day'] = str(temp_date_output.day) + all_groups['year'] = str(temp_date_output.year) + logger.debug(f"Converted date placeholders to {output_tz}: {all_groups['date']}") + else: + # No output timezone conversion - use source timezone for date + # Create temp date to get proper date in source timezone using the base date + temp_date_source = source_tz.localize(base_date.replace(hour=hour_24, minute=minute, second=0, microsecond=0)) + all_groups['date'] = temp_date_source.strftime('%Y-%m-%d') + all_groups['month'] = str(temp_date_source.month) + all_groups['day'] = str(temp_date_source.day) + all_groups['year'] = str(temp_date_source.year) + + # Format 24-hour start time string - only include minutes if non-zero + if minute > 0: + all_groups['starttime24'] = f"{hour_24}:{minute:02d}" + else: + all_groups['starttime24'] = f"{hour_24:02d}:00" + + # Convert 24-hour to 12-hour format for {starttime} placeholder + # Note: hour_24 is ALWAYS in 24-hour format at this point (converted earlier if needed) + ampm = 'AM' if hour_24 < 12 else 'PM' + hour_12 = hour_24 + if hour_24 == 0: + hour_12 = 12 + elif hour_24 > 12: + hour_12 = hour_24 - 12 + + # Format 12-hour start time string - only include minutes if non-zero + if minute > 0: + all_groups['starttime'] = f"{hour_12}:{minute:02d} {ampm}" + else: + all_groups['starttime'] = f"{hour_12} {ampm}" + + # Format long version that always includes minutes (e.g., "9:00 PM" instead of "9 PM") + all_groups['starttime_long'] = f"{hour_12}:{minute:02d} {ampm}" + + # Calculate end time based on program duration + # Create a datetime for calculations + temp_start = datetime.now(source_tz).replace(hour=hour_24, minute=minute, second=0, microsecond=0) + temp_end = temp_start + timedelta(minutes=program_duration) + + # Extract end time components (already in correct timezone if output_tz was applied above) + end_hour_24 = temp_end.hour + end_minute = temp_end.minute + + # Format 24-hour end time string - only include minutes if non-zero + if end_minute > 0: + all_groups['endtime24'] = f"{end_hour_24}:{end_minute:02d}" + else: + all_groups['endtime24'] = f"{end_hour_24:02d}:00" + + # Convert 24-hour to 12-hour format for {endtime} placeholder + end_ampm = 'AM' if end_hour_24 < 12 else 'PM' + end_hour_12 = end_hour_24 + if end_hour_24 == 0: + end_hour_12 = 12 + elif end_hour_24 > 12: + end_hour_12 = end_hour_24 - 12 + + # Format 12-hour end time string - only include minutes if non-zero + if end_minute > 0: + all_groups['endtime'] = f"{end_hour_12}:{end_minute:02d} {end_ampm}" + else: + all_groups['endtime'] = f"{end_hour_12} {end_ampm}" + + # Format long version that always includes minutes (e.g., "9:00 PM" instead of "9 PM") + all_groups['endtime_long'] = f"{end_hour_12}:{end_minute:02d} {end_ampm}" + + # Generate programs + programs = [] + + # If we have extracted time AND date, the event happens on a SPECIFIC date + # If we have time but NO date, generate for multiple days (existing behavior) + # All other days and times show "Upcoming" before or "Ended" after + event_happened = False + + # Determine how many iterations we need + if date_info and time_info: + # Specific date extracted - only generate for that one date + iterations = 1 + logger.debug(f"Date extracted, generating single event for specific date") + else: + # No specific date - use num_days (existing behavior) + iterations = num_days + + for day in range(iterations): + # Start from current time (like standard dummy) instead of midnight + # This ensures programs appear in the guide's current viewing window + day_start = now + timedelta(days=day) + day_end = day_start + timedelta(days=1) + + if time_info: + # We have an extracted event time - this is when the MAIN event starts + # The extracted time is in the SOURCE timezone (e.g., 8PM ET) + # We need to convert it to UTC for storage + + # Determine which date to use + if date_info: + # Use the extracted date from the channel title + current_date = datetime( + date_info['year'], + date_info['month'], + date_info['day'] + ).date() + logger.debug(f"Using extracted date: {current_date}") + else: + # No date extracted, use day offset from current time in SOURCE timezone + # This ensures we calculate "today" in the event's timezone, not UTC + # For example: 8:30 PM Central (1:30 AM UTC next day) for a 10 PM ET event + # should use today's date in ET, not tomorrow's date in UTC + now_in_source_tz = now.astimezone(source_tz) + current_date = (now_in_source_tz + timedelta(days=day)).date() + logger.debug(f"No date extracted, using day offset in {source_tz}: {current_date}") + + # Create a naive datetime (no timezone info) representing the event in source timezone + event_start_naive = datetime.combine( + current_date, + datetime.min.time().replace( + hour=time_info['hour'], + minute=time_info['minute'] + ) + ) + + # Use pytz to localize the naive datetime to the source timezone + # This automatically handles DST! + try: + event_start_local = source_tz.localize(event_start_naive) + # Convert to UTC + event_start_utc = event_start_local.astimezone(pytz.utc) + logger.debug(f"Converted {event_start_local} to UTC: {event_start_utc}") + except Exception as e: + logger.error(f"Error localizing time to {source_tz}: {e}") + # Fallback: treat as UTC + event_start_utc = django_timezone.make_aware(event_start_naive, pytz.utc) + + event_end_utc = event_start_utc + timedelta(minutes=program_duration) + + # Pre-generate the main event title and description for reuse + if title_template: + main_event_title = format_template(title_template, all_groups) + else: + title_parts = [] + if 'league' in all_groups and all_groups['league']: + title_parts.append(all_groups['league']) + if 'team1' in all_groups and 'team2' in all_groups: + title_parts.append(f"{all_groups['team1']} vs {all_groups['team2']}") + elif 'title' in all_groups and all_groups['title']: + title_parts.append(all_groups['title']) + main_event_title = ' - '.join(title_parts) if title_parts else channel_name + + if subtitle_template: + main_event_subtitle = format_template(subtitle_template, all_groups) + else: + main_event_subtitle = None + + if description_template: + main_event_description = format_template(description_template, all_groups) + else: + main_event_description = main_event_title + + + + # Determine if this day is before, during, or after the event + # Event only happens on day 0 (first day) + is_event_day = (day == 0) + + if is_event_day and not event_happened: + # This is THE day the event happens + # Fill programs BEFORE the event + current_time = day_start + + while current_time < event_start_utc: + program_start_utc = current_time + program_end_utc = min(current_time + timedelta(minutes=program_duration), event_start_utc) + + # Use custom upcoming templates if provided, otherwise use defaults + if upcoming_title_template: + upcoming_title = format_template(upcoming_title_template, all_groups) + else: + upcoming_title = main_event_title + + if upcoming_description_template: + upcoming_description = format_template(upcoming_description_template, all_groups) + else: + upcoming_description = f"Upcoming: {main_event_description}" + + # Build custom_properties for upcoming programs (only date, no category/live) + program_custom_properties = {} + + # Add date if requested (YYYY-MM-DD format from start time in event timezone) + if include_date: + # Convert UTC time to event timezone for date calculation + local_time = program_start_utc.astimezone(source_tz) + date_str = local_time.strftime('%Y-%m-%d') + program_custom_properties['date'] = date_str + + # Add program poster URL if provided + if program_poster_url: + program_custom_properties['icon'] = program_poster_url + + programs.append({ + "channel_id": channel_id, + "start_time": program_start_utc, + "end_time": program_end_utc, + "title": upcoming_title, + "sub_title": None, # No subtitle for filler programs + "description": upcoming_description, + "custom_properties": program_custom_properties, + "channel_logo_url": channel_logo_url, # Pass channel logo for EPG generation + }) + + current_time += timedelta(minutes=program_duration) + + # Add the MAIN EVENT at the extracted time + # Build custom_properties for main event (includes category and live) + main_event_custom_properties = {} + + # Add categories if provided + if categories: + main_event_custom_properties['categories'] = categories + + # Add date if requested (YYYY-MM-DD format from start time in event timezone) + if include_date: + # Convert UTC time to event timezone for date calculation + local_time = event_start_utc.astimezone(source_tz) + date_str = local_time.strftime('%Y-%m-%d') + main_event_custom_properties['date'] = date_str + + # Add live flag if requested + if include_live: + main_event_custom_properties['live'] = True + + # Add new flag if requested + if include_new: + main_event_custom_properties['new'] = True + + # Add program poster URL if provided + if program_poster_url: + main_event_custom_properties['icon'] = program_poster_url + + programs.append({ + "channel_id": channel_id, + "start_time": event_start_utc, + "end_time": event_end_utc, + "title": main_event_title, + "sub_title": main_event_subtitle, + "description": main_event_description, + "custom_properties": main_event_custom_properties, + "channel_logo_url": channel_logo_url, # Pass channel logo for EPG generation + }) + + event_happened = True + + # Fill programs AFTER the event until end of day + current_time = event_end_utc + + while current_time < day_end: + program_start_utc = current_time + program_end_utc = min(current_time + timedelta(minutes=program_duration), day_end) + + # Use custom ended templates if provided, otherwise use defaults + if ended_title_template: + ended_title = format_template(ended_title_template, all_groups) + else: + ended_title = main_event_title + + if ended_description_template: + ended_description = format_template(ended_description_template, all_groups) + else: + ended_description = f"Ended: {main_event_description}" + + # Build custom_properties for ended programs (only date, no category/live) + program_custom_properties = {} + + # Add date if requested (YYYY-MM-DD format from start time in event timezone) + if include_date: + # Convert UTC time to event timezone for date calculation + local_time = program_start_utc.astimezone(source_tz) + date_str = local_time.strftime('%Y-%m-%d') + program_custom_properties['date'] = date_str + + # Add program poster URL if provided + if program_poster_url: + program_custom_properties['icon'] = program_poster_url + + programs.append({ + "channel_id": channel_id, + "start_time": program_start_utc, + "end_time": program_end_utc, + "title": ended_title, + "sub_title": None, # No subtitle for filler programs + "description": ended_description, + "custom_properties": program_custom_properties, + "channel_logo_url": channel_logo_url, # Pass channel logo for EPG generation + }) + + current_time += timedelta(minutes=program_duration) + else: + # This day is either before the event (future days) or after the event happened + # Fill entire day with appropriate message + current_time = day_start + + # If event already happened, all programs show "Ended" + # If event hasn't happened yet (shouldn't occur with day 0 logic), show "Upcoming" + is_ended = event_happened + + while current_time < day_end: + program_start_utc = current_time + program_end_utc = min(current_time + timedelta(minutes=program_duration), day_end) + + # Use custom templates based on whether event has ended or is upcoming + if is_ended: + if ended_title_template: + program_title = format_template(ended_title_template, all_groups) + else: + program_title = main_event_title + + if ended_description_template: + program_description = format_template(ended_description_template, all_groups) + else: + program_description = f"Ended: {main_event_description}" + else: + if upcoming_title_template: + program_title = format_template(upcoming_title_template, all_groups) + else: + program_title = main_event_title + + if upcoming_description_template: + program_description = format_template(upcoming_description_template, all_groups) + else: + program_description = f"Upcoming: {main_event_description}" + + # Build custom_properties (only date for upcoming/ended filler programs) + program_custom_properties = {} + + # Add date if requested (YYYY-MM-DD format from start time in event timezone) + if include_date: + # Convert UTC time to event timezone for date calculation + local_time = program_start_utc.astimezone(source_tz) + date_str = local_time.strftime('%Y-%m-%d') + program_custom_properties['date'] = date_str + + # Add program poster URL if provided + if program_poster_url: + program_custom_properties['icon'] = program_poster_url + + programs.append({ + "channel_id": channel_id, + "start_time": program_start_utc, + "end_time": program_end_utc, + "title": program_title, + "sub_title": None, # No subtitle for filler programs + "description": program_description, + "custom_properties": program_custom_properties, + "channel_logo_url": channel_logo_url, + }) + + current_time += timedelta(minutes=program_duration) + else: + # No extracted time - fill entire day with regular intervals + # day_start and day_end are already in UTC, so no conversion needed + programs_per_day = max(1, int(24 / (program_duration / 60))) + + for program_num in range(programs_per_day): + program_start_utc = day_start + timedelta(minutes=program_num * program_duration) + program_end_utc = program_start_utc + timedelta(minutes=program_duration) + + if title_template: + title = format_template(title_template, all_groups) + else: + title_parts = [] + if 'league' in all_groups and all_groups['league']: + title_parts.append(all_groups['league']) + if 'team1' in all_groups and 'team2' in all_groups: + title_parts.append(f"{all_groups['team1']} vs {all_groups['team2']}") + elif 'title' in all_groups and all_groups['title']: + title_parts.append(all_groups['title']) + title = ' - '.join(title_parts) if title_parts else channel_name + + if subtitle_template: + subtitle = format_template(subtitle_template, all_groups) + else: + subtitle = None + + if description_template: + description = format_template(description_template, all_groups) + else: + description = title + + # Build custom_properties for this program + program_custom_properties = {} + + # Add categories if provided + if categories: + program_custom_properties['categories'] = categories + + # Add date if requested (YYYY-MM-DD format from start time in event timezone) + if include_date: + # Convert UTC time to event timezone for date calculation + local_time = program_start_utc.astimezone(source_tz) + date_str = local_time.strftime('%Y-%m-%d') + program_custom_properties['date'] = date_str + + # Add live flag if requested + if include_live: + program_custom_properties['live'] = True + + # Add new flag if requested + if include_new: + program_custom_properties['new'] = True + + # Add program poster URL if provided + if program_poster_url: + program_custom_properties['icon'] = program_poster_url + + programs.append({ + "channel_id": channel_id, + "start_time": program_start_utc, + "end_time": program_end_utc, + "title": title, + "sub_title": subtitle, + "description": description, + "custom_properties": program_custom_properties, + "channel_logo_url": channel_logo_url, # Pass channel logo for EPG generation + }) + + logger.info(f"Generated {len(programs)} custom dummy programs for {channel_name}") + return programs + + +def generate_dummy_epg( + channel_id, channel_name, xml_lines=None, num_days=1, program_length_hours=4 +): + """ + Generate dummy EPG programs for channels without EPG data. + Creates program blocks for a specified number of days. + + Args: + channel_id: The channel ID to use in the program entries + channel_name: The name of the channel to use in program titles + xml_lines: Optional list to append lines to, otherwise returns new list + num_days: Number of days to generate EPG data for (default: 1) + program_length_hours: Length of each program block in hours (default: 4) + + Returns: + List of XML lines for the dummy EPG entries + """ + if xml_lines is None: + xml_lines = [] + + for program in generate_dummy_programs(channel_id, channel_name, num_days=1, program_length_hours=4): + # Format times in XMLTV format + start_str = program['start_time'].strftime("%Y%m%d%H%M%S %z") + stop_str = program['end_time'].strftime("%Y%m%d%H%M%S %z") + + # Create program entry with escaped channel name + xml_lines.append( + f' ' + ) + xml_lines.append(f" {html.escape(program['title'])}") + + # Add subtitle if available + if program.get('sub_title'): + xml_lines.append(f" {html.escape(program['sub_title'])}") + + xml_lines.append(f" {html.escape(program['description'])}") + + # Add custom_properties if present + custom_data = program.get('custom_properties', {}) + + # Categories + if 'categories' in custom_data: + for cat in custom_data['categories']: + xml_lines.append(f" {html.escape(cat)}") + + # Date tag + if 'date' in custom_data: + xml_lines.append(f" {html.escape(custom_data['date'])}") + + # Live tag + if custom_data.get('live', False): + xml_lines.append(f" ") + + # New tag + if custom_data.get('new', False): + xml_lines.append(f" ") + + xml_lines.append(f" ") + + return xml_lines + + +def generate_epg(request, profile_name=None, user=None): + """ + Dynamically generate an XMLTV (EPG) file using streaming response to handle keep-alives. + Since the EPG data is stored independently of Channels, we group programmes + by their associated EPGData record. + This version filters data based on the 'days' parameter and sends keep-alives during processing. + """ + # Check cache for recent identical request (helps with double-GET from browsers) + from django.core.cache import cache + # Resolve all effective parameter values once here so they are reused for both + # the cache key and inside epg_generator() via closure. + # The cache key is built from resolved values only — not from the raw query string — + # so equivalent requests (e.g. days=7 via URL param vs. user default of 7) share + # the same cache entry regardless of how the value was supplied. + user_custom = (user.custom_properties or {}) if user else {} + try: + num_days = int(request.GET.get('days', user_custom.get('epg_days', 0))) + num_days = max(0, min(num_days, 365)) + except (ValueError, TypeError): + num_days = 0 + try: + prev_days = int(request.GET.get('prev_days', user_custom.get('epg_prev_days', 0))) + prev_days = max(0, min(prev_days, 30)) + except (ValueError, TypeError): + prev_days = 0 + use_cached_logos = request.GET.get('cachedlogos', 'true').lower() != 'false' + tvg_id_source = request.GET.get('tvg_id_source', 'channel_number').lower() + cache_params = ( + f"{profile_name or 'all'}:{user.username if user else 'anonymous'}" + f":d={num_days}:p={prev_days}:logos={use_cached_logos}:tvgid={tvg_id_source}" + ) + content_cache_key = f"epg_content:{cache_params}" + + cached_content = cache.get(content_cache_key) + if cached_content: + logger.debug("Serving EPG from cache") + response = HttpResponse(cached_content, content_type="application/xml") + response["Content-Disposition"] = 'attachment; filename="Dispatcharr.xml"' + response["Cache-Control"] = "no-cache" + return response + + def epg_generator(): + """Generator function that yields EPG data with keep-alives during processing.""" + + xml_lines = [] + xml_lines.append('') + xml_lines.append( + '' + ) + + # Get channels based on user/profile + if user is not None: + if user.user_level < 10: + user_profile_count = user.channel_profiles.count() + + # 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 = {"user_level__lte": user.user_level} + # Hide adult content if user preference is set + if (user.custom_properties or {}).get('hide_adult_content', False): + filters["is_adult"] = False + channels = Channel.objects.filter(**filters).select_related('logo', 'epg_data__epg_source').order_by("channel_number") + else: + # User has specific limited profiles assigned + filters = { + "channelprofilemembership__enabled": True, + "user_level__lte": user.user_level, + "channelprofilemembership__channel_profile__in": user.channel_profiles.all() + } + # Hide adult content if user preference is set + if (user.custom_properties or {}).get('hide_adult_content', False): + filters["is_adult"] = False + channels = Channel.objects.filter(**filters).select_related('logo', 'epg_data__epg_source').distinct().order_by("channel_number") + else: + channels = Channel.objects.filter(user_level__lte=user.user_level).select_related('logo', 'epg_data__epg_source').order_by( + "channel_number" + ) + else: + if profile_name is not None: + try: + channel_profile = ChannelProfile.objects.get(name=profile_name) + except ChannelProfile.DoesNotExist: + logger.warning("Requested channel profile (%s) during epg generation does not exist", profile_name) + raise Http404(f"Channel profile '{profile_name}' not found") + channels = Channel.objects.filter( + channelprofilemembership__channel_profile=channel_profile, + channelprofilemembership__enabled=True, + ).select_related('logo', 'epg_data__epg_source').order_by("channel_number") + else: + channels = Channel.objects.all().select_related('logo', 'epg_data__epg_source').order_by("channel_number") + + + # For dummy EPG, use either the specified value or default to 3 days + dummy_days = num_days if num_days > 0 else 3 + + # Calculate cutoff dates for EPG data filtering + now = django_timezone.now() + cutoff_date = now + timedelta(days=num_days) if num_days > 0 else None + lookback_cutoff = now - timedelta(days=prev_days) + + # Build collision-free channel number mapping for XC clients (if user is authenticated) + # XC clients require integer channel numbers, so we need to ensure no conflicts + channel_num_map = {} + if user is not None: + # This is an XC client - build collision-free mapping + used_numbers = set() + + # First pass: assign integers for channels that already have integer numbers + for channel in channels: + if channel.channel_number == int(channel.channel_number): + num = int(channel.channel_number) + channel_num_map[channel.id] = num + used_numbers.add(num) + + # Second pass: assign integers for channels with float numbers + for channel in channels: + if channel.channel_number != int(channel.channel_number): + candidate = int(channel.channel_number) + while candidate in used_numbers: + candidate += 1 + channel_num_map[channel.id] = candidate + used_numbers.add(candidate) + + # Process channels for the section + for channel in channels: + # user is set only for XC clients, which require integer channel numbers + if user is not None: + formatted_channel_number = channel_num_map[channel.id] + else: + if channel.channel_number is not None: + if channel.channel_number == int(channel.channel_number): + formatted_channel_number = int(channel.channel_number) + else: + formatted_channel_number = channel.channel_number + else: + formatted_channel_number = "" + + # Determine the channel ID based on the selected source + if tvg_id_source == 'tvg_id' and channel.tvg_id: + channel_id = channel.tvg_id + elif tvg_id_source == 'gracenote' and channel.tvc_guide_stationid: + channel_id = channel.tvc_guide_stationid + else: + channel_id = str(formatted_channel_number) if formatted_channel_number != "" else str(channel.id) + + tvg_logo = "" + + # Check if this is a custom dummy EPG with channel logo URL template + if channel.epg_data and channel.epg_data.epg_source and channel.epg_data.epg_source.source_type == 'dummy': + epg_source = channel.epg_data.epg_source + if epg_source.custom_properties: + custom_props = epg_source.custom_properties + channel_logo_url_template = custom_props.get('channel_logo_url', '') + + if channel_logo_url_template: + # Determine which name to use for pattern matching (same logic as program generation) + pattern_match_name = channel.name + name_source = custom_props.get('name_source') + + if name_source == 'stream': + stream_index = custom_props.get('stream_index', 1) - 1 + 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] + pattern_match_name = stream.name + + # Try to extract groups from the channel/stream name and build the logo URL + title_pattern = custom_props.get('title_pattern', '') + if title_pattern: + try: + # Convert PCRE/JavaScript named groups to Python format + title_pattern = regex.sub(r'\(\?<(?![=!])([^>]+)>', r'(?P<\1>', title_pattern) + title_regex = regex.compile(title_pattern) + title_match = title_regex.search(pattern_match_name) + + if title_match: + groups = title_match.groupdict() + + # Add normalized versions of all groups for cleaner URLs + for key, value in list(groups.items()): + if value: + # Remove all non-alphanumeric characters and convert to lowercase + normalized = regex.sub(r'[^a-zA-Z0-9\s]', '', str(value)) + normalized = regex.sub(r'\s+', '', normalized).lower() + groups[f'{key}_normalize'] = normalized + + # Format the logo URL template with the matched groups (with URL encoding) + from urllib.parse import quote + for key, value in groups.items(): + if value: + encoded_value = quote(str(value), safe='') + channel_logo_url_template = channel_logo_url_template.replace(f'{{{key}}}', encoded_value) + else: + channel_logo_url_template = channel_logo_url_template.replace(f'{{{key}}}', '') + tvg_logo = channel_logo_url_template + logger.debug(f"Built channel logo URL from template: {tvg_logo}") + except Exception as e: + logger.warning(f"Failed to build channel logo URL for {channel.name}: {e}") + + # If no custom dummy logo, use regular logo logic + if not tvg_logo and channel.logo: + if use_cached_logos: + tvg_logo = build_absolute_uri_with_port(request, reverse('api:channels:logo-cache', args=[channel.logo.id])) + else: + # Use direct URL if available, otherwise fall back to cached version + direct_logo = channel.logo.url if channel.logo.url.startswith(('http://', 'https://')) else None + if direct_logo: + tvg_logo = direct_logo + else: + tvg_logo = build_absolute_uri_with_port(request, reverse('api:channels:logo-cache', args=[channel.logo.id])) + display_name = channel.name + xml_lines.append(f' ') + xml_lines.append(f' {html.escape(display_name)}') + xml_lines.append(f' ') + xml_lines.append(" ") + + # Send all channel definitions + channel_xml = '\n'.join(xml_lines) + '\n' + yield channel_xml + xml_lines = [] # Clear to save memory + + # Pre-pass: categorize channels into dummy and real EPG groups + dummy_program_list = [] # (channel_id, pattern_match_name, epg_source_or_None) + real_epg_map = {} # epg_data_id -> [channel_id, ...] + dummy_epg_checked = {} # epg_data_id -> bool (has stored programs) + + for channel in channels: + # Determine channel_id (same logic as channel section) + if tvg_id_source == 'tvg_id' and channel.tvg_id: + channel_id = channel.tvg_id + elif tvg_id_source == 'gracenote' and channel.tvc_guide_stationid: + channel_id = channel.tvc_guide_stationid + else: + if user is not None: + formatted_channel_number = channel_num_map[channel.id] + else: + if channel.channel_number is not None: + if channel.channel_number == int(channel.channel_number): + formatted_channel_number = int(channel.channel_number) + else: + formatted_channel_number = channel.channel_number + else: + formatted_channel_number = "" + channel_id = str(formatted_channel_number) if formatted_channel_number != "" else str(channel.id) + + display_name = channel.epg_data.name if channel.epg_data else channel.name + pattern_match_name = channel.name + + # Check if we should use stream name instead of channel name + if channel.epg_data and channel.epg_data.epg_source: + epg_source = channel.epg_data.epg_source + if epg_source.custom_properties: + custom_props = epg_source.custom_properties + name_source = custom_props.get('name_source') + + if name_source == 'stream': + stream_index = custom_props.get('stream_index', 1) - 1 + 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] + pattern_match_name = stream.name + logger.debug(f"Using stream name for parsing: {pattern_match_name} (stream index: {stream_index})") + else: + logger.warning(f"Stream index {stream_index} not found for channel {channel.name}, falling back to channel name") + + if not channel.epg_data: + dummy_program_list.append((channel_id, pattern_match_name, None)) + else: + if channel.epg_data.epg_source and channel.epg_data.epg_source.source_type == 'dummy': + epg_data_id = channel.epg_data_id + if epg_data_id not in dummy_epg_checked: + dummy_epg_checked[epg_data_id] = channel.epg_data.programs.exists() + if dummy_epg_checked[epg_data_id]: + real_epg_map.setdefault(epg_data_id, []).append(channel_id) + else: + dummy_program_list.append((channel_id, pattern_match_name, channel.epg_data.epg_source)) + continue + + real_epg_map.setdefault(channel.epg_data_id, []).append(channel_id) + + # Emit dummy programmes + for channel_id, pattern_match_name, epg_source in dummy_program_list: + program_length_hours = 4 + dummy_programs = generate_dummy_programs( + channel_id, pattern_match_name, + num_days=dummy_days, + program_length_hours=program_length_hours, + epg_source=epg_source + ) + for program in dummy_programs: + start_str = program['start_time'].strftime("%Y%m%d%H%M%S %z") + stop_str = program['end_time'].strftime("%Y%m%d%H%M%S %z") + yield f' \n' + yield f" {html.escape(program['title'])}\n" + if program.get('sub_title'): + yield f" {html.escape(program['sub_title'])}\n" + yield f" {html.escape(program['description'])}\n" + custom_data = program.get('custom_properties', {}) + if 'categories' in custom_data: + for cat in custom_data['categories']: + yield f" {html.escape(cat)}\n" + if 'date' in custom_data: + yield f" {html.escape(custom_data['date'])}\n" + if custom_data.get('live', False): + yield f" \n" + if custom_data.get('new', False): + yield f" \n" + if 'icon' in custom_data: + yield f' \n' + yield f" \n" + + # Emit real programmes: single bulk query, chunked to avoid server-side cursor issues. + all_epg_ids = list(real_epg_map.keys()) + if all_epg_ids: + if num_days > 0: + programs_qs = ProgramData.objects.filter( + epg_id__in=all_epg_ids, + end_time__gte=lookback_cutoff, + start_time__lt=cutoff_date, + ) + else: + programs_qs = ProgramData.objects.filter( + epg_id__in=all_epg_ids, + end_time__gte=lookback_cutoff, + ) + + programs_base_qs = programs_qs.order_by('epg_id', 'id').values( + 'id', 'epg_id', 'start_time', 'end_time', 'title', 'sub_title', + 'description', 'custom_properties', + ) + + current_epg_id = None + channel_ids_for_epg = None + is_multi = False + multi_buffer = [] + program_batch = [] + batch_size = 1000 + chunk_size = 5000 + # Keyset pagination: track last (epg_id, id) instead of OFFSET + # to avoid skipping/duplicating rows if the table changes mid-stream. + last_epg_id = 0 + last_id = 0 + + while True: + program_chunk = list( + programs_base_qs.filter(epg_id__gte=last_epg_id) + .exclude(epg_id=last_epg_id, id__lte=last_id)[:chunk_size] + ) + + if not program_chunk: + break + + # Advance keyset cursor to last row in this chunk + last_row = program_chunk[-1] + last_epg_id = last_row['epg_id'] + last_id = last_row['id'] + + for prog in program_chunk: + epg_id = prog['epg_id'] + + # When epg_id changes, flush multi-channel buffer for previous group + if epg_id != current_epg_id: + if is_multi and multi_buffer: + escaped_primary = html.escape(channel_ids_for_epg[0]) + for extra_cid in channel_ids_for_epg[1:]: + escaped_extra = html.escape(extra_cid) + for xml_text in multi_buffer: + program_batch.append(xml_text.replace( + f'channel="{escaped_primary}"', + f'channel="{escaped_extra}"', + 1, + )) + if len(program_batch) >= batch_size: + yield '\n'.join(program_batch) + '\n' + program_batch = [] + multi_buffer = [] + + current_epg_id = epg_id + channel_ids_for_epg = real_epg_map[epg_id] + is_multi = len(channel_ids_for_epg) > 1 + + # Build programme XML for primary channel_id + primary_cid = channel_ids_for_epg[0] + start_str = prog['start_time'].strftime("%Y%m%d%H%M%S %z") + stop_str = prog['end_time'].strftime("%Y%m%d%H%M%S %z") + + program_xml = [f' '] + program_xml.append(f' {html.escape(prog["title"])}') + + if prog['sub_title']: + program_xml.append(f" {html.escape(prog['sub_title'])}") + + if prog['description']: + program_xml.append(f" {html.escape(prog['description'])}") + + custom_data = prog['custom_properties'] or {} + if custom_data: + + if "categories" in custom_data and custom_data["categories"]: + for category in custom_data["categories"]: + program_xml.append(f" {html.escape(category)}") + + if "keywords" in custom_data and custom_data["keywords"]: + for keyword in custom_data["keywords"]: + program_xml.append(f" {html.escape(keyword)}") + + # onscreen_episode takes priority over episode for the onscreen system + if "onscreen_episode" in custom_data: + program_xml.append(f' {html.escape(custom_data["onscreen_episode"])}') + elif "episode" in custom_data: + program_xml.append(f' E{custom_data["episode"]}') + + # Handle dd_progid format + if 'dd_progid' in custom_data: + program_xml.append(f' {html.escape(custom_data["dd_progid"])}') + + # Handle external database IDs + for system in ['thetvdb.com', 'themoviedb.org', 'imdb.com']: + if f'{system}_id' in custom_data: + program_xml.append(f' {html.escape(custom_data[f"{system}_id"])}') + + # Add season and episode numbers in xmltv_ns format if available + if "season" in custom_data and "episode" in custom_data: + season = ( + int(custom_data["season"]) - 1 + if str(custom_data["season"]).isdigit() + else 0 + ) + episode = ( + int(custom_data["episode"]) - 1 + if str(custom_data["episode"]).isdigit() + else 0 + ) + program_xml.append(f' {season}.{episode}.') + + if "language" in custom_data: + program_xml.append(f' {html.escape(custom_data["language"])}') + + if "original_language" in custom_data: + program_xml.append(f' {html.escape(custom_data["original_language"])}') + + if "length" in custom_data and isinstance(custom_data["length"], dict): + length_value = custom_data["length"].get("value", "") + length_units = custom_data["length"].get("units", "minutes") + program_xml.append(f' {html.escape(str(length_value))}') + + if "video" in custom_data and isinstance(custom_data["video"], dict): + program_xml.append(" ") + + if "audio" in custom_data and isinstance(custom_data["audio"], dict): + program_xml.append(" ") + + if "subtitles" in custom_data and isinstance(custom_data["subtitles"], list): + for subtitle in custom_data["subtitles"]: + if isinstance(subtitle, dict): + subtitle_type = subtitle.get("type", "") + type_attr = f' type="{html.escape(subtitle_type)}"' if subtitle_type else "" + program_xml.append(f" ") + if "language" in subtitle: + program_xml.append(f" {html.escape(subtitle['language'])}") + program_xml.append(" ") + + if "rating" in custom_data: + rating_system = custom_data.get("rating_system", "TV Parental Guidelines") + program_xml.append(f' ') + program_xml.append(f' {html.escape(custom_data["rating"])}') + program_xml.append(f" ") + + if "star_ratings" in custom_data and isinstance(custom_data["star_ratings"], list): + for star_rating in custom_data["star_ratings"]: + if isinstance(star_rating, dict) and "value" in star_rating: + system_attr = f' system="{html.escape(star_rating["system"])}"' if "system" in star_rating else "" + program_xml.append(f" ") + program_xml.append(f" {html.escape(star_rating['value'])}") + program_xml.append(" ") + + if "reviews" in custom_data and isinstance(custom_data["reviews"], list): + for review in custom_data["reviews"]: + if isinstance(review, dict) and "content" in review: + review_type = review.get("type", "text") + attrs = [f'type="{html.escape(review_type)}"'] + if "source" in review: + attrs.append(f'source="{html.escape(review["source"])}"') + if "reviewer" in review: + attrs.append(f'reviewer="{html.escape(review["reviewer"])}"') + attr_str = " ".join(attrs) + program_xml.append(f' {html.escape(review["content"])}') + + if "images" in custom_data and isinstance(custom_data["images"], list): + for image in custom_data["images"]: + if isinstance(image, dict) and "url" in image: + attrs = [] + for attr in ['type', 'size', 'orient', 'system']: + if attr in image: + attrs.append(f'{attr}="{html.escape(image[attr])}"') + attr_str = " " + " ".join(attrs) if attrs else "" + program_xml.append(f' {html.escape(image["url"])}') + + # Add enhanced credits handling + if "credits" in custom_data: + program_xml.append(" ") + credits = custom_data["credits"] + + for role in ['director', 'writer', 'adapter', 'producer', 'composer', 'editor', 'presenter', 'commentator', 'guest']: + if role in credits: + people = credits[role] + if isinstance(people, list): + for person in people: + program_xml.append(f" <{role}>{html.escape(person)}") + else: + program_xml.append(f" <{role}>{html.escape(people)}") + + # Handle actors separately to include role and guest attributes + if "actor" in credits: + actors = credits["actor"] + if isinstance(actors, list): + for actor in actors: + if isinstance(actor, dict): + name = actor.get("name", "") + role_attr = f' role="{html.escape(actor["role"])}"' if "role" in actor else "" + guest_attr = ' guest="yes"' if actor.get("guest") else "" + program_xml.append(f" {html.escape(name)}") + else: + program_xml.append(f" {html.escape(actor)}") + else: + program_xml.append(f" {html.escape(actors)}") + + program_xml.append(" ") + + if "date" in custom_data: + program_xml.append(f' {html.escape(custom_data["date"])}') + + if "country" in custom_data: + program_xml.append(f' {html.escape(custom_data["country"])}') + + if "icon" in custom_data: + program_xml.append(f' ') + + # Add special flags as proper tags with enhanced handling + if custom_data.get("previously_shown", False): + prev_shown_details = custom_data.get("previously_shown_details", {}) + attrs = [] + if "start" in prev_shown_details: + attrs.append(f'start="{html.escape(prev_shown_details["start"])}"') + if "channel" in prev_shown_details: + attrs.append(f'channel="{html.escape(prev_shown_details["channel"])}"') + attr_str = " " + " ".join(attrs) if attrs else "" + program_xml.append(f" ") + + if custom_data.get("premiere", False): + premiere_text = custom_data.get("premiere_text", "") + if premiere_text: + program_xml.append(f" {html.escape(premiere_text)}") + else: + program_xml.append(" ") + + if custom_data.get("last_chance", False): + last_chance_text = custom_data.get("last_chance_text", "") + if last_chance_text: + program_xml.append(f" {html.escape(last_chance_text)}") + else: + program_xml.append(" ") + + if custom_data.get("new", False): + program_xml.append(" ") + + if custom_data.get('live', False): + program_xml.append(' ') + + program_xml.append(" ") + + xml_text = '\n'.join(program_xml) + program_batch.append(xml_text) + + if is_multi: + multi_buffer.append(xml_text) + + if len(program_batch) >= batch_size: + yield '\n'.join(program_batch) + '\n' + program_batch = [] + + # Final flush of multi-channel buffer + if is_multi and multi_buffer: + escaped_primary = html.escape(channel_ids_for_epg[0]) + for extra_cid in channel_ids_for_epg[1:]: + escaped_extra = html.escape(extra_cid) + for xml_text in multi_buffer: + program_batch.append(xml_text.replace( + f'channel="{escaped_primary}"', + f'channel="{escaped_extra}"', + 1, + )) + + if program_batch: + yield '\n'.join(program_batch) + '\n' + + # Send final closing tag and completion message + yield "\n" + + # Log system event for EPG download after streaming completes (with deduplication based on client) + client_id, client_ip, user_agent = get_client_identifier(request) + event_cache_key = f"epg_download:{user.username if user else 'anonymous'}:{profile_name or 'all'}:{client_id}" + if not cache.get(event_cache_key): + log_system_event( + event_type='epg_download', + profile=profile_name or 'all', + user=user.username if user else 'anonymous', + channels=channels.count(), + client_ip=client_ip, + user_agent=user_agent, + ) + cache.set(event_cache_key, True, 2) # Prevent duplicate events for 2 seconds + + # Wrapper generator that collects content for caching + def caching_generator(): + collected_content = [] + for chunk in epg_generator(): + collected_content.append(chunk) + yield chunk + # After streaming completes, cache the full content + full_content = ''.join(collected_content) + cache.set(content_cache_key, full_content, 300) + logger.debug("Cached EPG content (%d bytes)", len(full_content)) + + response = StreamingHttpResponse( + streaming_content=caching_generator(), + content_type="application/xml" + ) + response["Content-Disposition"] = 'attachment; filename="Dispatcharr.xml"' + response["Cache-Control"] = "no-cache" + return response + + +def xc_get_user(request): + username = request.GET.get("username") + password = request.GET.get("password") + + if not username or not password: + return None + + user = get_object_or_404(User, username=username) + + custom_properties = user.custom_properties or {} + + if "xc_password" not in custom_properties: + return None + + if custom_properties["xc_password"] != password: + return None + + return user + + +def xc_get_info(request, full=False): + if not network_access_allowed(request, 'XC_API'): + return JsonResponse({'error': 'Forbidden'}, status=403) + + user = xc_get_user(request) + + if user is None: + return JsonResponse({'error': 'Unauthorized'}, status=401) + + raw_host = request.get_host() + if ":" in raw_host: + hostname, port = raw_host.split(":", 1) + else: + hostname = raw_host + port = "443" if request.is_secure() else "80" + + info = { + "user_info": { + "username": request.GET.get("username"), + "password": request.GET.get("password"), + "message": "Dispatcharr XC API", + "auth": 1, + "status": "Active", + "exp_date": str(int(time.time()) + (90 * 24 * 60 * 60)), + "max_connections": str(calculate_tuner_count(minimum=1, unlimited_default=50)), + "allowed_output_formats": [ + "ts", + ], + }, + "server_info": { + "url": hostname, + "server_protocol": request.scheme, + "port": port, + "timezone": get_localzone().key, + "timestamp_now": int(time.time()), + "time_now": datetime.now().strftime("%Y-%m-%d %H:%M:%S"), + "process": True, + }, + } + + if full == True: + info['categories'] = { + "series": [], + "movie": [], + "live": xc_get_live_categories(user), + } + info['available_channels'] = {channel["stream_id"]: channel for channel in xc_get_live_streams(request, user, request.GET.get("category_id"))} + + return info + + +def xc_player_api(request, full=False): + if not network_access_allowed(request, 'XC_API'): + return JsonResponse({'error': 'Forbidden'}, status=403) + + action = request.GET.get("action") + user = xc_get_user(request) + + if user is None: + return JsonResponse({'error': 'Unauthorized'}, status=401) + + if action == "get_live_categories": + return JsonResponse(xc_get_live_categories(user), safe=False) + elif action == "get_live_streams": + return JsonResponse(xc_get_live_streams(request, user, request.GET.get("category_id")), safe=False) + elif action == "get_short_epg": + return JsonResponse(xc_get_epg(request, user, short=True), safe=False) + elif action == "get_simple_data_table": + return JsonResponse(xc_get_epg(request, user, short=False), safe=False) + elif action == "get_vod_categories": + return JsonResponse(xc_get_vod_categories(user), safe=False) + elif action == "get_vod_streams": + return JsonResponse(xc_get_vod_streams(request, user, request.GET.get("category_id")), safe=False) + elif action == "get_series_categories": + return JsonResponse(xc_get_series_categories(user), safe=False) + elif action == "get_series": + return JsonResponse(xc_get_series(request, user, request.GET.get("category_id")), safe=False) + elif action == "get_series_info": + return JsonResponse(xc_get_series_info(request, user, request.GET.get("series_id")), safe=False) + elif action == "get_vod_info": + return JsonResponse(xc_get_vod_info(request, user, request.GET.get("vod_id")), safe=False) + else: + # For any other action (including get_account_info or unknown actions), + # return server_info/account_info to match provider behavior + server_info = xc_get_info(request) + return JsonResponse(server_info, safe=False) + + +def xc_panel_api(request): + if not network_access_allowed(request, 'XC_API'): + return JsonResponse({'error': 'Forbidden'}, status=403) + + user = xc_get_user(request) + + if user is None: + return JsonResponse({'error': 'Unauthorized'}, status=401) + + return JsonResponse(xc_get_info(request, True)) + + +def xc_get(request): + if not network_access_allowed(request, 'XC_API'): + # Log blocked M3U download + 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='m3u_blocked', + user=request.GET.get('username', 'unknown'), + reason='Network access denied (XC API)', + client_ip=client_ip, + user_agent=user_agent, + ) + return JsonResponse({'error': 'Forbidden'}, status=403) + + action = request.GET.get("action") + user = xc_get_user(request) + + if user is None: + # Log blocked M3U download due to invalid credentials + 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='m3u_blocked', + user=request.GET.get('username', 'unknown'), + reason='Invalid XC credentials', + client_ip=client_ip, + user_agent=user_agent, + ) + return JsonResponse({'error': 'Unauthorized'}, status=401) + + return generate_m3u(request, None, user) + + +def xc_xmltv(request): + if not network_access_allowed(request, 'XC_API'): + # Log blocked EPG download + 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='epg_blocked', + user=request.GET.get('username', 'unknown'), + reason='Network access denied (XC API)', + client_ip=client_ip, + user_agent=user_agent, + ) + return JsonResponse({'error': 'Forbidden'}, status=403) + + user = xc_get_user(request) + + if user is None: + # Log blocked EPG download due to invalid credentials + 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='epg_blocked', + user=request.GET.get('username', 'unknown'), + reason='Invalid XC credentials', + client_ip=client_ip, + user_agent=user_agent, + ) + return JsonResponse({'error': 'Unauthorized'}, status=401) + + return generate_epg(request, None, user) + + +def xc_get_live_categories(user): + from django.db.models import Min + response = [] + + if user.user_level < 10: + user_profile_count = user.channel_profiles.count() + + # If user has ALL profiles or NO profiles, give unrestricted access + if user_profile_count == 0: + # No profile filtering - user sees all channel groups + channel_groups = ChannelGroup.objects.filter( + channels__isnull=False, channels__user_level__lte=user.user_level + ).distinct().annotate(min_channel_number=Min('channels__channel_number')).order_by('min_channel_number') + else: + # User has specific limited profiles assigned + filters = { + "channels__channelprofilemembership__enabled": True, + "channels__user_level": 0, + "channels__channelprofilemembership__channel_profile__in": user.channel_profiles.all() + } + channel_groups = ChannelGroup.objects.filter(**filters).distinct().annotate(min_channel_number=Min('channels__channel_number')).order_by('min_channel_number') + else: + channel_groups = ChannelGroup.objects.filter( + channels__isnull=False, channels__user_level__lte=user.user_level + ).distinct().annotate(min_channel_number=Min('channels__channel_number')).order_by('min_channel_number') + + for group in channel_groups: + response.append( + { + "category_id": str(group.id), + "category_name": group.name, + "parent_id": 0, + } + ) + + return response + + +def xc_get_live_streams(request, user, category_id=None): + streams = [] + + if user.user_level < 10: + user_profile_count = user.channel_profiles.count() + + # 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 = {"user_level__lte": user.user_level} + if category_id is not None: + filters["channel_group__id"] = category_id + # Hide adult content if user preference is set + if (user.custom_properties or {}).get('hide_adult_content', False): + filters["is_adult"] = False + channels = Channel.objects.filter(**filters).select_related('channel_group', 'logo').order_by("channel_number") + else: + # User has specific limited profiles assigned + filters = { + "channelprofilemembership__enabled": True, + "user_level__lte": user.user_level, + "channelprofilemembership__channel_profile__in": user.channel_profiles.all() + } + if category_id is not None: + filters["channel_group__id"] = category_id + # Hide adult content if user preference is set + if (user.custom_properties or {}).get('hide_adult_content', False): + filters["is_adult"] = False + channels = Channel.objects.filter(**filters).select_related('channel_group', 'logo').distinct().order_by("channel_number") + else: + if not category_id: + channels = Channel.objects.filter(user_level__lte=user.user_level).select_related('channel_group', 'logo').order_by("channel_number") + else: + channels = Channel.objects.filter( + channel_group__id=category_id, user_level__lte=user.user_level + ).select_related('channel_group', 'logo').order_by("channel_number") + + # Resolve the fallback group ID once to avoid a get_or_create query per null-group channel + _default_group_id = None + + def _get_default_group_id(): + nonlocal _default_group_id + if _default_group_id is None: + _default_group_id = ChannelGroup.objects.get_or_create(name="Default Group")[0].id + return _default_group_id + + # Build collision-free mapping for XC clients (which require integers) + # This ensures channels with float numbers don't conflict with existing integers + channel_num_map = {} # Maps channel.id -> integer channel number for XC + used_numbers = set() # Track all assigned integer channel numbers + + # First pass: assign integers for channels that already have integer numbers + for channel in channels: + if channel.channel_number == int(channel.channel_number): + # Already an integer, use it directly + num = int(channel.channel_number) + channel_num_map[channel.id] = num + used_numbers.add(num) + + # Second pass: assign integers for channels with float numbers + # Find next available number to avoid collisions + for channel in channels: + if channel.channel_number != int(channel.channel_number): + # Has decimal component, need to find available integer + # Start from truncated value and increment until we find an unused number + candidate = int(channel.channel_number) + while candidate in used_numbers: + candidate += 1 + channel_num_map[channel.id] = candidate + used_numbers.add(candidate) + + # Build the streams list with the collision-free channel numbers + for channel in channels: + channel_num_int = channel_num_map[channel.id] + + streams.append( + { + "num": channel_num_int, + "name": channel.name, + "stream_type": "live", + "stream_id": channel.id, + "stream_icon": ( + None + if not channel.logo + else build_absolute_uri_with_port( + request, + reverse("api:channels:logo-cache", args=[channel.logo.id]) + ) + ), + "epg_channel_id": str(channel_num_int), + "added": str(int(channel.created_at.timestamp())), + "is_adult": int(channel.is_adult), + "category_id": str(channel.channel_group.id if channel.channel_group else _get_default_group_id()), + "category_ids": [channel.channel_group.id if channel.channel_group else _get_default_group_id()], + "custom_sid": None, + "tv_archive": 0, + "direct_source": "", + "tv_archive_duration": 0, + } + ) + + return streams + + +def xc_get_epg(request, user, short=False): + channel_id = request.GET.get('stream_id') + if not channel_id: + raise Http404() + + channel = None + if user.user_level < 10: + user_profile_count = user.channel_profiles.count() + + # 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": channel_id, + "user_level__lte": user.user_level + } + # Hide adult content if user preference is set + if (user.custom_properties or {}).get('hide_adult_content', False): + filters["is_adult"] = False + channel = Channel.objects.filter(**filters).select_related('epg_data__epg_source').first() + else: + # User has specific limited profiles assigned + filters = { + "id": channel_id, + "channelprofilemembership__enabled": True, + "user_level__lte": user.user_level, + "channelprofilemembership__channel_profile__in": user.channel_profiles.all() + } + # Hide adult content if user preference is set + if (user.custom_properties or {}).get('hide_adult_content', False): + filters["is_adult"] = False + channel = Channel.objects.filter(**filters).select_related('epg_data__epg_source').distinct().first() + + if not channel: + raise Http404() + else: + channel = get_object_or_404(Channel.objects.select_related('epg_data__epg_source'), id=channel_id) + + if not channel: + raise Http404() + + # Calculate the collision-free integer channel number for this channel + # This must match the logic in xc_get_live_streams to ensure consistency + # Get all channels in the same category for collision detection + category_channels = Channel.objects.filter( + channel_group=channel.channel_group + ).order_by("channel_number") + + channel_num_map = {} + used_numbers = set() + + # First pass: assign integers for channels that already have integer numbers + for ch in category_channels: + if ch.channel_number == int(ch.channel_number): + num = int(ch.channel_number) + channel_num_map[ch.id] = num + used_numbers.add(num) + + # Second pass: assign integers for channels with float numbers + for ch in category_channels: + if ch.channel_number != int(ch.channel_number): + candidate = int(ch.channel_number) + while candidate in used_numbers: + candidate += 1 + channel_num_map[ch.id] = candidate + used_numbers.add(candidate) + + # Get the mapped integer for this specific channel + channel_num_int = channel_num_map.get(channel.id, int(channel.channel_number)) + + limit = int(request.GET.get('limit', 4)) + user_custom = user.custom_properties or {} + try: + num_days = int(request.GET.get('days', user_custom.get('epg_days', 0))) + num_days = max(0, min(num_days, 365)) + except (ValueError, TypeError): + num_days = 0 + try: + prev_days = int(request.GET.get('prev_days', user_custom.get('epg_prev_days', 0))) + prev_days = max(0, min(prev_days, 30)) + except (ValueError, TypeError): + prev_days = 0 + now = django_timezone.now() + lookback_cutoff = now - timedelta(days=prev_days) + forward_cutoff = now + timedelta(days=num_days) if num_days > 0 else None + if channel.epg_data: + # Check if this is a dummy EPG that generates on-demand + if channel.epg_data.epg_source and channel.epg_data.epg_source.source_type == 'dummy': + if not channel.epg_data.programs.exists(): + # Generate on-demand using custom patterns + programs = generate_dummy_programs( + channel_id=channel_id, + channel_name=channel.name, + epg_source=channel.epg_data.epg_source + ) + else: + # Has stored programs, use them + if short: + # Short EPG: current and upcoming only (never historical), limited count + programs = channel.epg_data.programs.filter( + end_time__gt=now + ).order_by('start_time')[:limit] + else: + qs = channel.epg_data.programs.filter(end_time__gt=lookback_cutoff) + if forward_cutoff: + qs = qs.filter(start_time__lt=forward_cutoff) + programs = qs.order_by('start_time') + else: + # Regular EPG with stored programs + if short: + # Short EPG: current and upcoming only (never historical), limited count + programs = channel.epg_data.programs.filter( + end_time__gt=now + ).order_by('start_time')[:limit] + else: + qs = channel.epg_data.programs.filter(end_time__gt=lookback_cutoff) + if forward_cutoff: + qs = qs.filter(start_time__lt=forward_cutoff) + programs = qs.order_by('start_time') + else: + # No EPG data assigned, generate default dummy + programs = generate_dummy_programs(channel_id=channel_id, channel_name=channel.name, epg_source=None) + + output = {"epg_listings": []} + + for program in programs: + title = program['title'] if isinstance(program, dict) else program.title + description = program['description'] if isinstance(program, dict) else program.description + + start = program["start_time"] if isinstance(program, dict) else program.start_time + end = program["end_time"] if isinstance(program, dict) else program.end_time + + # For database programs, use actual ID; for generated dummy programs, create synthetic ID + if isinstance(program, dict): + # Generated dummy program - create unique ID from channel + timestamp + program_id = str(abs(hash(f"{channel_id}_{int(start.timestamp())}"))) + else: + # Database program - use actual ID + program_id = str(program.id) + + # epg_id refers to the EPG source/channel mapping in XC panels + # Use the actual EPGData ID when available, otherwise fall back to 0 + epg_id = str(channel.epg_data.id) if channel.epg_data else "0" + + program_output = { + "id": program_id, + "epg_id": epg_id, + "title": base64.b64encode((title or "").encode()).decode(), + "lang": "", + "start": start.strftime("%Y-%m-%d %H:%M:%S"), + "end": end.strftime("%Y-%m-%d %H:%M:%S"), + "description": base64.b64encode((description or "").encode()).decode(), + "channel_id": str(channel_num_int), + "start_timestamp": str(int(start.timestamp())), + "stop_timestamp": str(int(end.timestamp())), + "stream_id": f"{channel_id}", + } + + if short == False: + program_output["now_playing"] = 1 if start <= django_timezone.now() <= end else 0 + program_output["has_archive"] = 0 + + output['epg_listings'].append(program_output) + + return output + + +def xc_get_vod_categories(user): + """Get VOD categories for XtreamCodes API""" + from apps.vod.models import VODCategory, M3UMovieRelation + + response = [] + + catalog_id = get_vod_catalog_account_id() + cat_filters = dict( + category_type='movie', + m3umovierelation__m3u_account__is_active=True, + ) + if catalog_id: + cat_filters["m3umovierelation__m3u_account_id"] = catalog_id + allowed_cats = get_user_allowed_movie_category_ids(user) + if allowed_cats: + cat_filters["id__in"] = allowed_cats + categories = VODCategory.objects.filter(**cat_filters).distinct().order_by(Lower("name")) + + for category in categories: + response.append({ + "category_id": str(category.id), + "category_name": category.name, + "parent_id": 0, + }) + + return response + + +def xc_get_vod_streams(request, user, category_id=None): + """Get VOD streams (movies) for XtreamCodes API""" + from apps.vod.models import Movie, M3UMovieRelation + from django.db.models import Prefetch + + streams = [] + + catalog_id = get_vod_catalog_account_id() + filters = {"m3u_relations__m3u_account__is_active": True} + if catalog_id: + filters["m3u_relations__m3u_account_id"] = catalog_id + + if category_id: + filters["m3u_relations__category_id"] = category_id + + allowed_cats = get_user_allowed_movie_category_ids(user) + if allowed_cats: + filters["m3u_relations__category_id__in"] = allowed_cats + + rel_q = M3UMovieRelation.objects.filter(m3u_account__is_active=True) + if catalog_id: + rel_q = rel_q.filter(m3u_account_id=catalog_id) + if allowed_cats: + rel_q = rel_q.filter(category_id__in=allowed_cats) + + movies = Movie.objects.filter(**filters).select_related('logo').prefetch_related( + Prefetch( + 'm3u_relations', + queryset=rel_q.select_related('m3u_account', 'category').order_by('-m3u_account__priority', 'id'), + to_attr='active_relations' + ) + ).distinct() + + for movie in movies: + # Get the first (highest priority) relation from prefetched data + # This avoids the N+1 query problem entirely + if hasattr(movie, 'active_relations') and movie.active_relations: + relation = movie.active_relations[0] + else: + # Fallback - should rarely be needed with proper prefetching + continue + + streams.append({ + "num": movie.id, + "name": movie.name, + "stream_type": "movie", + "stream_id": movie.id, + "stream_icon": ( + None if not movie.logo + else build_absolute_uri_with_port( + request, + reverse("api:vod:vodlogo-cache", args=[movie.logo.id]) + ) + ), + #'stream_icon': movie.logo.url if movie.logo else '', + "rating": movie.rating or "0", + "rating_5based": round(float(movie.rating or 0) / 2, 2) if movie.rating else 0, + "added": str(int(movie.created_at.timestamp())), + "is_adult": 0, + "tmdb_id": movie.tmdb_id or "", + "imdb_id": movie.imdb_id or "", + "trailer": (movie.custom_properties or {}).get('trailer') or "", + "category_id": str(relation.category.id) if relation.category else "0", + "category_ids": [int(relation.category.id)] if relation.category else [], + "container_extension": relation.container_extension or "mp4", + "custom_sid": None, + "direct_source": "", + }) + + return streams + + +def xc_get_series_categories(user): + """Get series categories for XtreamCodes API""" + from apps.vod.models import VODCategory, M3USeriesRelation + + response = [] + + catalog_id = get_vod_catalog_account_id() + cat_filters = dict( + category_type='series', + m3useriesrelation__m3u_account__is_active=True, + ) + if catalog_id: + cat_filters["m3useriesrelation__m3u_account_id"] = catalog_id + allowed_cats = get_user_allowed_series_category_ids(user) + if allowed_cats: + cat_filters["id__in"] = allowed_cats + categories = VODCategory.objects.filter(**cat_filters).distinct().order_by(Lower("name")) + + for category in categories: + response.append({ + "category_id": str(category.id), + "category_name": category.name, + "parent_id": 0, + }) + + return response + + +def xc_get_series(request, user, category_id=None): + """Get series list for XtreamCodes API""" + from apps.vod.models import M3USeriesRelation + + series_list = [] + + catalog_id = get_vod_catalog_account_id() + filters = {"m3u_account__is_active": True} + if catalog_id: + filters["m3u_account_id"] = catalog_id + + if category_id: + filters["category_id"] = category_id + + allowed_cats = get_user_allowed_series_category_ids(user) + if allowed_cats: + filters["category_id__in"] = allowed_cats + + series_relations = M3USeriesRelation.objects.filter(**filters).select_related( + 'series', 'series__logo', 'category', 'm3u_account' + ).order_by('series_id', '-m3u_account__priority', 'id') + + seen_series = set() if catalog_id else None + for relation in series_relations: + if catalog_id and relation.series_id in seen_series: + continue + if catalog_id: + seen_series.add(relation.series_id) + series = relation.series + series_list.append({ + "num": relation.id, # Use relation ID + "name": series.name, + "series_id": relation.id, # Use relation ID + "cover": ( + None if not series.logo + else build_absolute_uri_with_port( + request, + reverse("api:vod:vodlogo-cache", args=[series.logo.id]) + ) + ), + "plot": series.description or "", + "cast": series.custom_properties.get('cast', '') if series.custom_properties else "", + "director": series.custom_properties.get('director', '') if series.custom_properties else "", + "genre": series.genre or "", + "release_date": series.custom_properties.get('release_date', str(series.year) if series.year else "") if series.custom_properties else (str(series.year) if series.year else ""), + "releaseDate": series.custom_properties.get('release_date', str(series.year) if series.year else "") if series.custom_properties else (str(series.year) if series.year else ""), + "last_modified": str(int(relation.updated_at.timestamp())), + "rating": str(series.rating or "0"), + "rating_5based": str(round(float(series.rating or 0) / 2, 2)) if series.rating else "0", + "backdrop_path": series.custom_properties.get('backdrop_path', []) if series.custom_properties else [], + "youtube_trailer": series.custom_properties.get('youtube_trailer', '') if series.custom_properties else "", + "episode_run_time": series.custom_properties.get('episode_run_time', '') if series.custom_properties else "", + "category_id": str(relation.category.id) if relation.category else "0", + "category_ids": [int(relation.category.id)] if relation.category else [], + "tmdb_id": series.tmdb_id or "", + "imdb_id": series.imdb_id or "", + }) + + return series_list + + +def xc_get_series_info(request, user, series_id): + """Get detailed series information including episodes""" + from apps.vod.models import M3USeriesRelation, M3UEpisodeRelation + + if not series_id: + raise Http404() + + catalog_id = get_vod_catalog_account_id() + filters = {"id": series_id, "m3u_account__is_active": True} + if catalog_id: + filters["m3u_account_id"] = catalog_id + allowed_cats = get_user_allowed_series_category_ids(user) + if allowed_cats: + filters["category_id__in"] = allowed_cats + + try: + series_relation = M3USeriesRelation.objects.select_related('series', 'series__logo').get(**filters) + series = series_relation.series + except M3USeriesRelation.DoesNotExist: + raise Http404() + + # Check if we need to refresh detailed info (similar to vod api_views pattern) + try: + should_refresh = ( + not series_relation.last_episode_refresh or + series_relation.last_episode_refresh < django_timezone.now() - timedelta(hours=24) + ) + + # Check if detailed data has been fetched + custom_props = series_relation.custom_properties or {} + episodes_fetched = custom_props.get('episodes_fetched', False) + detailed_fetched = custom_props.get('detailed_fetched', False) + + acc_props = series_relation.m3u_account.custom_properties or {} + skip_xc_refresh = acc_props.get("vod_backend") == "jellyfin" + + # Force refresh if episodes/details have never been fetched or time interval exceeded + if ( + not skip_xc_refresh + and (not episodes_fetched or not detailed_fetched or should_refresh) + ): + from apps.vod.tasks import refresh_series_episodes + account = series_relation.m3u_account + if account and account.is_active: + refresh_series_episodes(account, series, series_relation.external_series_id) + # Refresh objects from database after task completion + series.refresh_from_db() + series_relation.refresh_from_db() + + except Exception as e: + logger.error(f"Error refreshing series data for relation {series_relation.id}: {str(e)}") + + # Get unique episodes for this series that have relations from any active M3U account + # We query episodes directly to avoid duplicates when multiple relations exist + # (e.g., same episode in different languages/qualities) + from apps.vod.models import Episode + episode_filter = dict( + series=series, + m3u_relations__m3u_account__is_active=True, + ) + if catalog_id: + episode_filter["m3u_relations__m3u_account_id"] = catalog_id + episodes = Episode.objects.filter(**episode_filter).distinct().order_by('season_number', 'episode_number') + + # Group episodes by season + seasons = {} + for episode in episodes: + # Season 0 is valid (Jellyfin specials/extras). "or 1" wrongly merged S0 into S1. + season_num = 1 if episode.season_number is None else episode.season_number + if season_num not in seasons: + seasons[season_num] = [] + + # Get the highest priority relation for this episode (for container_extension, video/audio/bitrate) + from apps.vod.models import M3UEpisodeRelation + br_filters = dict(episode=episode, m3u_account__is_active=True) + if catalog_id: + br_filters["m3u_account_id"] = catalog_id + best_relation = M3UEpisodeRelation.objects.filter(**br_filters).select_related('m3u_account').order_by('-m3u_account__priority', 'id').first() + + video = audio = bitrate = None + container_extension = "mp4" + added_timestamp = str(int(episode.created_at.timestamp())) + + if best_relation: + container_extension = best_relation.container_extension or "mp4" + added_timestamp = str(int(best_relation.created_at.timestamp())) + if best_relation.custom_properties: + info = best_relation.custom_properties.get('info') + if info and isinstance(info, dict): + info_info = info.get('info') + if info_info and isinstance(info_info, dict): + video = info_info.get('video', {}) + audio = info_info.get('audio', {}) + bitrate = info_info.get('bitrate', 0) + + if video is None: + video = episode.custom_properties.get('video', {}) if episode.custom_properties else {} + if audio is None: + audio = episode.custom_properties.get('audio', {}) if episode.custom_properties else {} + if bitrate is None: + bitrate = episode.custom_properties.get('bitrate', 0) if episode.custom_properties else 0 + + seasons[season_num].append({ + "id": episode.id, + "season": season_num, + "episode_num": episode.episode_number or 0, + "title": episode.name, + "container_extension": container_extension, + "added": added_timestamp, + "custom_sid": None, + "direct_source": "", + "info": { + "id": int(episode.id), + "name": episode.name, + "overview": episode.description or "", + "crew": str(episode.custom_properties.get('crew', "") if episode.custom_properties else ""), + "directed_by": episode.custom_properties.get('director', '') if episode.custom_properties else "", + "imdb_id": episode.imdb_id or "", + "air_date": f"{episode.air_date}" if episode.air_date else "", + "backdrop_path": episode.custom_properties.get('backdrop_path', []) if episode.custom_properties else [], + "movie_image": episode.custom_properties.get('movie_image', '') if episode.custom_properties else "", + "rating": float(episode.rating or 0), + "release_date": f"{episode.air_date}" if episode.air_date else "", + "duration_secs": (episode.duration_secs or 0), + "duration": format_duration_hms(episode.duration_secs), + "video": video, + "audio": audio, + "bitrate": bitrate, + } + }) + + # Build response using potentially refreshed data + series_data = { + 'name': series.name, + 'description': series.description or '', + 'year': series.year, + 'genre': series.genre or '', + 'rating': series.rating or '0', + 'cast': '', + 'director': '', + 'youtube_trailer': '', + 'episode_run_time': '', + 'backdrop_path': [], + } + + # Add detailed info from custom_properties if available + try: + if series.custom_properties: + custom_data = series.custom_properties + series_data.update({ + 'cast': custom_data.get('cast', ''), + 'director': custom_data.get('director', ''), + 'youtube_trailer': custom_data.get('youtube_trailer', ''), + 'episode_run_time': custom_data.get('episode_run_time', ''), + 'backdrop_path': custom_data.get('backdrop_path', []), + }) + + # Check relation custom_properties for detailed_info + if series_relation.custom_properties and 'detailed_info' in series_relation.custom_properties: + detailed_info = series_relation.custom_properties['detailed_info'] + + # Override with detailed_info values where available + for key in ['name', 'description', 'year', 'genre', 'rating']: + if detailed_info.get(key): + series_data[key] = detailed_info[key] + + # Handle plot vs description + if detailed_info.get('plot'): + series_data['description'] = detailed_info['plot'] + elif detailed_info.get('description'): + series_data['description'] = detailed_info['description'] + + # Update additional fields from detailed info + series_data.update({ + 'cast': detailed_info.get('cast', series_data['cast']), + 'director': detailed_info.get('director', series_data['director']), + 'youtube_trailer': detailed_info.get('youtube_trailer', series_data['youtube_trailer']), + 'episode_run_time': detailed_info.get('episode_run_time', series_data['episode_run_time']), + 'backdrop_path': detailed_info.get('backdrop_path', series_data['backdrop_path']), + }) + + except Exception as e: + logger.error(f"Error parsing series custom_properties: {str(e)}") + + seasons_list = [] + for season_num in sorted(seasons.keys(), key=lambda x: int(x)): + label = "Specials" if int(season_num) == 0 else f"Season {season_num}" + seasons_list.append({"season_number": int(season_num), "name": label}) + + info = { + 'seasons': seasons_list, + "info": { + "name": series_data['name'], + "cover": ( + None if not series.logo + else build_absolute_uri_with_port( + request, + reverse("api:vod:vodlogo-cache", args=[series.logo.id]) + ) + ), + "plot": series_data['description'], + "cast": series_data['cast'], + "director": series_data['director'], + "genre": series_data['genre'], + "release_date": series.custom_properties.get('release_date', str(series.year) if series.year else "") if series.custom_properties else (str(series.year) if series.year else ""), + "releaseDate": series.custom_properties.get('release_date', str(series.year) if series.year else "") if series.custom_properties else (str(series.year) if series.year else ""), + "added": str(int(series_relation.created_at.timestamp())), + "last_modified": str(int(series_relation.updated_at.timestamp())), + "rating": str(series_data['rating']), + "rating_5based": str(round(float(series_data['rating'] or 0) / 2, 2)) if series_data['rating'] else "0", + "backdrop_path": series_data['backdrop_path'], + "youtube_trailer": series_data['youtube_trailer'], + "imdb": str(series.imdb_id) if series.imdb_id else "", + "tmdb": str(series.tmdb_id) if series.tmdb_id else "", + "episode_run_time": str(series_data['episode_run_time']), + "category_id": str(series_relation.category.id) if series_relation.category else "0", + "category_ids": [int(series_relation.category.id)] if series_relation.category else [], + }, + "episodes": dict(seasons) + } + return info + + +def xc_get_vod_info(request, user, vod_id): + """Get detailed VOD (movie) information""" + from apps.vod.models import M3UMovieRelation + from django.utils import timezone + from datetime import timedelta + + if not vod_id: + raise Http404() + + catalog_id = get_vod_catalog_account_id() + filters = {"movie_id": vod_id, "m3u_account__is_active": True} + if catalog_id: + filters["m3u_account_id"] = catalog_id + allowed_cats = get_user_allowed_movie_category_ids(user) + if allowed_cats: + filters["category_id__in"] = allowed_cats + + try: + # Order by account priority to get the best relation when multiple exist + movie_relation = M3UMovieRelation.objects.select_related('movie', 'movie__logo').filter(**filters).order_by('-m3u_account__priority', 'id').first() + if not movie_relation: + raise Http404() + movie = movie_relation.movie + except (M3UMovieRelation.DoesNotExist, M3UMovieRelation.MultipleObjectsReturned): + raise Http404() + + # Initialize basic movie data first + movie_data = { + 'name': movie.name, + 'description': movie.description or '', + 'year': movie.year, + 'genre': movie.genre or '', + 'rating': movie.rating or 0, + 'tmdb_id': movie.tmdb_id or '', + 'imdb_id': movie.imdb_id or '', + 'director': '', + 'actors': '', + 'country': '', + 'release_date': '', + 'youtube_trailer': '', + 'backdrop_path': [], + 'cover_big': '', + 'bitrate': 0, + 'video': {}, + 'audio': {}, + } + + # Duplicate the provider_info logic for detailed information + try: + # Check if we need to refresh detailed info (same logic as provider_info) + should_refresh = ( + not movie_relation.last_advanced_refresh or + movie_relation.last_advanced_refresh < timezone.now() - timedelta(hours=24) + ) + + if should_refresh: + # Trigger refresh of detailed info + from apps.vod.tasks import refresh_movie_advanced_data + refresh_movie_advanced_data(movie_relation.id) + # Refresh objects from database after task completion + movie.refresh_from_db() + movie_relation.refresh_from_db() + + # Add detailed info from custom_properties if available + if movie.custom_properties: + custom_data = movie.custom_properties or {} + + # Extract detailed info + #detailed_info = custom_data.get('detailed_info', {}) + detailed_info = movie_relation.custom_properties.get('detailed_info', {}) + # Update movie_data with detailed info + movie_data.update({ + 'director': custom_data.get('director') or detailed_info.get('director', ''), + 'actors': custom_data.get('actors') or detailed_info.get('actors', ''), + 'country': custom_data.get('country') or detailed_info.get('country', ''), + 'release_date': custom_data.get('release_date') or detailed_info.get('release_date') or detailed_info.get('releasedate', ''), + 'youtube_trailer': custom_data.get('youtube_trailer') or detailed_info.get('youtube_trailer') or detailed_info.get('trailer', ''), + 'backdrop_path': custom_data.get('backdrop_path') or detailed_info.get('backdrop_path', []), + 'cover_big': detailed_info.get('cover_big', ''), + 'bitrate': detailed_info.get('bitrate', 0), + 'video': detailed_info.get('video', {}), + 'audio': detailed_info.get('audio', {}), + }) + + # Override with detailed_info values where available + for key in ['name', 'description', 'year', 'genre', 'rating', 'tmdb_id', 'imdb_id']: + if detailed_info.get(key): + movie_data[key] = detailed_info[key] + + # Handle plot vs description + if detailed_info.get('plot'): + movie_data['description'] = detailed_info['plot'] + elif detailed_info.get('description'): + movie_data['description'] = detailed_info['description'] + + except Exception as e: + logger.error(f"Failed to process movie data: {e}") + + # Transform API response to XtreamCodes format + info = { + "info": { + "name": movie_data.get('name', movie.name), + "o_name": movie_data.get('name', movie.name), + "cover_big": ( + None if not movie.logo + else build_absolute_uri_with_port( + request, + reverse("api:vod:vodlogo-cache", args=[movie.logo.id]) + ) + ), + "movie_image": ( + None if not movie.logo + else build_absolute_uri_with_port( + request, + reverse("api:vod:vodlogo-cache", args=[movie.logo.id]) + ) + ), + 'description': movie_data.get('description', ''), + 'plot': movie_data.get('description', ''), + 'year': movie_data.get('year', ''), + 'release_date': movie_data.get('release_date', ''), + 'genre': movie_data.get('genre', ''), + 'director': movie_data.get('director', ''), + 'actors': movie_data.get('actors', ''), + 'cast': movie_data.get('actors', ''), + 'country': movie_data.get('country', ''), + 'rating': movie_data.get('rating', 0), + 'imdb_id': movie_data.get('imdb_id', ''), + "tmdb_id": movie_data.get('tmdb_id', ''), + 'youtube_trailer': movie_data.get('youtube_trailer', ''), + 'backdrop_path': movie_data.get('backdrop_path', []), + 'cover': movie_data.get('cover_big', ''), + 'bitrate': movie_data.get('bitrate', 0), + 'video': movie_data.get('video', {}), + 'audio': movie_data.get('audio', {}), + }, + "movie_data": { + "stream_id": movie.id, + "name": movie.name, + "added": str(int(movie_relation.created_at.timestamp())), + "category_id": str(movie_relation.category.id) if movie_relation.category else "0", + "category_ids": [int(movie_relation.category.id)] if movie_relation.category else [], + "container_extension": movie_relation.container_extension or "mp4", + "custom_sid": None, + "direct_source": "", + } + } + + return info + + +def xc_movie_stream(request, username, password, stream_id, extension): + """Handle XtreamCodes movie streaming requests""" + from apps.vod.models import M3UMovieRelation + + user = get_object_or_404(User, username=username) + + custom_properties = user.custom_properties or {} + + if "xc_password" not in custom_properties: + return JsonResponse({"error": "Invalid credentials"}, status=401) + + if custom_properties["xc_password"] != password: + return JsonResponse({"error": "Invalid credentials"}, status=401) + + qs = filter_m3u_relation_queryset_for_playback( + M3UMovieRelation.objects.filter(movie_id=stream_id) + ) + qs, _user_pref = filter_relation_qs_for_user_playback(qs, user) + + try: + movie_relation = qs.select_related('movie').order_by('-m3u_account__priority', 'id').first() + if not movie_relation: + return JsonResponse({"error": "Movie not found"}, status=404) + except (M3UMovieRelation.DoesNotExist, M3UMovieRelation.MultipleObjectsReturned): + return JsonResponse({"error": "Movie not found"}, status=404) + + from django.http import HttpResponseRedirect + from django.urls import reverse + + vod_url = reverse('proxy:vod_proxy:vod_stream', kwargs={ + 'content_type': 'movie', + 'content_id': movie_relation.movie.uuid + }) + qstr = f"?m3u_account_id={movie_relation.m3u_account_id}&user_id={user.id}" + return HttpResponseRedirect(vod_url + qstr) + + +def xc_series_stream(request, username, password, stream_id, extension): + """Handle XtreamCodes series/episode streaming requests""" + from apps.vod.models import M3UEpisodeRelation + + user = get_object_or_404(User, username=username) + + custom_properties = user.custom_properties or {} + + if "xc_password" not in custom_properties: + return JsonResponse({"error": "Invalid credentials"}, status=401) + + if custom_properties["xc_password"] != password: + return JsonResponse({"error": "Invalid credentials"}, status=401) + + qs = filter_m3u_relation_queryset_for_playback( + M3UEpisodeRelation.objects.filter(episode_id=stream_id) + ) + qs, _user_pref = filter_relation_qs_for_user_playback(qs, user) + + try: + episode_relation = qs.select_related('episode').order_by('-m3u_account__priority', 'id').first() + if not episode_relation: + return JsonResponse({"error": "Episode not found"}, status=404) + except M3UEpisodeRelation.DoesNotExist: + return JsonResponse({"error": "Episode not found"}, status=404) + + from django.http import HttpResponseRedirect + from django.urls import reverse + + vod_url = reverse('proxy:vod_proxy:vod_stream', kwargs={ + 'content_type': 'episode', + 'content_id': episode_relation.episode.uuid + }) + qstr = f"?m3u_account_id={episode_relation.m3u_account_id}&user_id={user.id}" + return HttpResponseRedirect(vod_url + qstr) + + +def get_host_and_port(request): + """ + Returns (host, port) for building absolute URIs. + - Prefers X-Forwarded-Host/X-Forwarded-Port (nginx). + - Falls back to Host header. + - Returns None for port if using standard ports (80/443) to omit from URLs. + - In dev, uses 5656 as a guess if port cannot be determined. + """ + # Determine the scheme first - needed for standard port detection + scheme = request.META.get("HTTP_X_FORWARDED_PROTO", request.scheme) + standard_port = "443" if scheme == "https" else "80" + + # 1. Try X-Forwarded-Host (may include port) - set by our nginx + xfh = request.META.get("HTTP_X_FORWARDED_HOST") + if xfh: + if ":" in xfh: + host, port = xfh.split(":", 1) + # Omit standard ports from URLs + if port == standard_port: + return host, None + # Non-standard port in X-Forwarded-Host - return it + # This handles reverse proxies on non-standard ports (e.g., https://example.com:8443) + return host, port + else: + host = xfh + + # Check for X-Forwarded-Port header (if we didn't find a port in X-Forwarded-Host) + port = request.META.get("HTTP_X_FORWARDED_PORT") + if port: + # Omit standard ports from URLs + return host, None if port == standard_port else port + # If X-Forwarded-Proto is set but no valid port, assume standard + if request.META.get("HTTP_X_FORWARDED_PROTO"): + return host, None + + # 2. Try Host header + raw_host = request.get_host() + if ":" in raw_host: + host, port = raw_host.split(":", 1) + # Omit standard ports from URLs + return host, None if port == standard_port else port + else: + host = raw_host + + # 3. Check for X-Forwarded-Port (when Host header has no port but we're behind a reverse proxy) + port = request.META.get("HTTP_X_FORWARDED_PORT") + if port: + # Omit standard ports from URLs + return host, None if port == standard_port else port + + # 4. Check if we're behind a reverse proxy (X-Forwarded-Proto or X-Forwarded-For present) + # If so, assume standard port for the scheme (don't trust SERVER_PORT in this case) + if request.META.get("HTTP_X_FORWARDED_PROTO") or request.META.get("HTTP_X_FORWARDED_FOR"): + return host, None + + # 5. Try SERVER_PORT from META (only if NOT behind reverse proxy) + port = request.META.get("SERVER_PORT") + if port: + # Omit standard ports from URLs + return host, None if port == standard_port else port + + # 6. Dev fallback: guess port 5656 + if os.environ.get("DISPATCHARR_ENV") == "dev" or host in ("localhost", "127.0.0.1"): + return host, "5656" + + # 7. Final fallback: assume standard port for scheme (omit from URL) + return host, None + +def build_absolute_uri_with_port(request, path): + """ + Build an absolute URI with optional port. + Port is omitted from URL if None (standard port for scheme). + """ + host, port = get_host_and_port(request) + scheme = request.META.get("HTTP_X_FORWARDED_PROTO", request.scheme) + + if port: + return f"{scheme}://{host}:{port}{path}" + else: + return f"{scheme}://{host}{path}" + +def format_duration_hms(seconds): + """ + Format a duration in seconds as HH:MM:SS zero-padded string. + """ + seconds = int(seconds or 0) + return f"{seconds//3600:02}:{(seconds%3600)//60:02}:{seconds%60:02}" diff --git a/apps/plugins/__init__.py b/apps/plugins/__init__.py new file mode 100644 index 0000000..22c3539 --- /dev/null +++ b/apps/plugins/__init__.py @@ -0,0 +1,2 @@ +default_app_config = "apps.plugins.apps.PluginsConfig" + diff --git a/apps/plugins/api_urls.py b/apps/plugins/api_urls.py new file mode 100644 index 0000000..9935523 --- /dev/null +++ b/apps/plugins/api_urls.py @@ -0,0 +1,41 @@ +from django.urls import path +from .api_views import ( + PluginsListAPIView, + PluginReloadAPIView, + PluginSettingsAPIView, + PluginRunAPIView, + PluginEnabledAPIView, + PluginImportAPIView, + PluginDeleteAPIView, + PluginLogoAPIView, + PluginRepoListCreateAPIView, + PluginRepoPreviewAPIView, + PluginRepoDetailAPIView, + PluginRepoRefreshAPIView, + AvailablePluginsAPIView, + PluginDetailManifestAPIView, + PluginInstallFromRepoAPIView, + PluginRepoSettingsAPIView, +) + +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//delete/", PluginDeleteAPIView.as_view(), name="delete"), + path("plugins//settings/", PluginSettingsAPIView.as_view(), name="settings"), + path("plugins//run/", PluginRunAPIView.as_view(), name="run"), + path("plugins//enabled/", PluginEnabledAPIView.as_view(), name="enabled"), + path("plugins//logo/", PluginLogoAPIView.as_view(), name="logo"), + # Plugin repos (hub / store) - static paths first, then parametric + path("repos/", PluginRepoListCreateAPIView.as_view(), name="repo-list"), + path("repos/available/", AvailablePluginsAPIView.as_view(), name="available-plugins"), + path("repos/plugin-detail/", PluginDetailManifestAPIView.as_view(), name="plugin-detail-manifest"), + path("repos/install/", PluginInstallFromRepoAPIView.as_view(), name="repo-install"), + path("repos/settings/", PluginRepoSettingsAPIView.as_view(), name="repo-settings"), + path("repos/preview/", PluginRepoPreviewAPIView.as_view(), name="repo-preview"), + path("repos//", PluginRepoDetailAPIView.as_view(), name="repo-detail"), + path("repos//refresh/", PluginRepoRefreshAPIView.as_view(), name="repo-refresh"), +] diff --git a/apps/plugins/api_views.py b/apps/plugins/api_views.py new file mode 100644 index 0000000..8f7a2c7 --- /dev/null +++ b/apps/plugins/api_views.py @@ -0,0 +1,1302 @@ +import hashlib +import ipaddress +import logging +import io +import json +import re +import socket +from rest_framework.views import APIView +from rest_framework.response import Response +from rest_framework import status, serializers +from drf_spectacular.utils import extend_schema, inline_serializer +from django.conf import settings +from django.core.files.uploadedfile import UploadedFile +from django.http import FileResponse +from django.utils import timezone +import os +import zipfile +import shutil +import tempfile +import requests as http_requests +from urllib.parse import urlparse +from apps.accounts.permissions import ( + Authenticated, + permission_classes_by_method, +) +from dispatcharr.utils import network_access_allowed + +from .loader import PluginManager +from .models import PluginConfig, PluginRepo +from .serializers import PluginRepoSerializer + +logger = logging.getLogger(__name__) + + +def _compare_versions(a, b): + """Compare two semver-like version strings. + Returns negative if a < b, 0 if equal, positive if a > b. + + If either version is a prerelease (any dot-segment contains non-digit + characters), numeric ordering is meaningless. Falls back to exact string + equality: 0 if identical, 1 otherwise. + """ + if not a or not b: + return 0 + na = a.lstrip("v") + nb = b.lstrip("v") + if any(not p.isdigit() for p in na.split(".")) or any(not p.isdigit() for p in nb.split(".")): + return 0 if na == nb else 1 + pa = [int(x) for x in na.split(".")] + pb = [int(x) for x in nb.split(".")] + for i in range(max(len(pa), len(pb))): + diff = (pa[i] if i < len(pa) else 0) - (pb[i] if i < len(pb) else 0) + if diff != 0: + return diff + return 0 + + +MAX_PLUGIN_IMPORT_FILES = getattr(settings, "DISPATCHARR_PLUGIN_IMPORT_MAX_FILES", 2000) +MAX_PLUGIN_IMPORT_BYTES = getattr(settings, "DISPATCHARR_PLUGIN_IMPORT_MAX_BYTES", 200 * 1024 * 1024) +MAX_PLUGIN_IMPORT_FILE_BYTES = getattr(settings, "DISPATCHARR_PLUGIN_IMPORT_MAX_FILE_BYTES", 200 * 1024 * 1024) + + +def _parse_bool(value): + if isinstance(value, bool): + return value + if isinstance(value, int) and value in (0, 1): + return bool(value) + if isinstance(value, str): + normalized = value.strip().lower() + if normalized in ("true", "1", "yes", "y", "on"): + return True + if normalized in ("false", "0", "no", "n", "off"): + return False + return None + + +def _sanitize_plugin_key(value: str) -> str: + base = os.path.basename(value or "") + base = base.replace(" ", "_").replace("-", "_").lower() + base = re.sub(r"[^a-z0-9_]", "_", base) + base = base.strip("._ ") + return base or "plugin" + + +def _validate_fetch_url(url): + """Raise ValueError if the URL must not be fetched (SSRF prevention). + + Only http and https schemes are allowed. Hostnames that resolve to + loopback, private, link-local, or otherwise non-routable addresses + are rejected. + """ + parsed = urlparse(url) + if parsed.scheme not in ("http", "https"): + raise ValueError( + f"URL scheme '{parsed.scheme}' is not allowed; only http and https are permitted." + ) + hostname = parsed.hostname + if not hostname: + raise ValueError("URL has no hostname.") + try: + infos = socket.getaddrinfo(hostname, None) + except socket.gaierror as exc: + raise ValueError(f"Could not resolve hostname '{hostname}': {exc}") from exc + for _family, _type, _proto, _canon, sockaddr in infos: + addr_str = sockaddr[0] + try: + ip = ipaddress.ip_address(addr_str) + except ValueError: + continue + if ip.is_loopback or ip.is_link_local or ip.is_private or ip.is_reserved or ip.is_unspecified: + raise ValueError( + f"URL resolves to a non-routable address ({addr_str}) and cannot be fetched." + ) + + +def _absolutize_logo_url(request, url: str | None) -> str | None: + if not url or not request: + return url + parsed = urlparse(url) + if parsed.scheme: + return url + return request.build_absolute_uri(url) + + +class PluginAuthMixin: + """Mixin that routes permission resolution through permission_classes_by_method, + falling back to Authenticated() for any method not explicitly listed.""" + + def get_permissions(self): + try: + return [ + perm() for perm in permission_classes_by_method[self.request.method] + ] + except KeyError: + return [Authenticated()] + + +class PluginsListAPIView(PluginAuthMixin, APIView): + def get(self, request): + pm = PluginManager.get() + # Prefer cached registry; reload explicitly via the reload endpoint + pm.discover_plugins(sync_db=False, use_cache=True) + plugins = pm.list_plugins() + for plugin in plugins: + plugin["logo_url"] = _absolutize_logo_url(request, plugin.get("logo_url")) + return Response({"plugins": plugins}) + + +class PluginReloadAPIView(PluginAuthMixin, APIView): + def post(self, request): + pm = PluginManager.get() + pm.stop_all_plugins(reason="reload") + pm.discover_plugins(force_reload=True) + return Response({"success": True, "count": len(pm._registry)}) + + +def _install_plugin_from_zip(zip_file, plugins_dir, *, file_name="plugin.zip", allow_overwrite_key=None, allow_overwrite=False): + """Extract and install a plugin from a zip file-like object. + + Args: + zip_file: File-like object containing the zip. + plugins_dir: Path to the plugins directory. + file_name: Name hint for deriving plugin key when the zip has flat contents. + allow_overwrite_key: If set, allow overwriting this specific plugin directory. + allow_overwrite: If True, allow overwriting any existing plugin with the same key. + + Returns: + dict with "success" bool, and either "plugin_key" on success or "error" on failure. + """ + try: + zf = zipfile.ZipFile(zip_file) + except zipfile.BadZipFile: + return {"success": False, "error": "Invalid zip file"} + + 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: + return {"success": False, "error": "Archive is empty"} + if len(file_members) > MAX_PLUGIN_IMPORT_FILES: + return {"success": False, "error": "Archive has too many files"} + + total_size = 0 + for member in file_members: + total_size += member.file_size + if member.file_size > MAX_PLUGIN_IMPORT_FILE_BYTES: + return {"success": False, "error": "Archive contains a file that is too large"} + if total_size > MAX_PLUGIN_IMPORT_BYTES: + return {"success": False, "error": "Archive is too large"} + + for member in file_members: + name = member.filename + if not name or name.endswith("/"): + continue + norm = os.path.normpath(name) + if norm.startswith("..") or os.path.isabs(norm): + return {"success": False, "error": "Unsafe path in archive"} + 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) + + # Single walk: find candidate plugin dirs AND logo.png simultaneously + candidates = [] + logo_candidates_raw = [] + for dirpath, dirnames, filenames in os.walk(tmp_root): + depth = len(os.path.relpath(dirpath, tmp_root).split(os.sep)) + has_pluginpy = "plugin.py" in filenames + has_init = "__init__.py" in filenames + if has_pluginpy or has_init: + candidates.append((0 if has_pluginpy else 1, depth, dirpath)) + for filename in filenames: + if filename.lower() == "logo.png": + logo_candidates_raw.append((depth, os.path.join(dirpath, filename))) + if not candidates: + return {"success": False, "error": "Invalid plugin: missing plugin.py or package __init__.py"} + + candidates.sort() + chosen = candidates[0][2] + + # Determine plugin key + base_name = os.path.splitext(file_name)[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 = _sanitize_plugin_key(plugin_key) + if len(plugin_key) > 128: + plugin_key = plugin_key[:128] + + # Extract logo (prefer one inside the chosen plugin dir, then shallowest) + logo_bytes = None + try: + chosen_abs = os.path.abspath(chosen) + logo_candidates = [] + for depth, full_path in logo_candidates_raw: + full_abs = os.path.abspath(full_path) + try: + in_chosen = os.path.commonpath([chosen_abs, full_abs]) == chosen_abs + except Exception: + in_chosen = False + logo_candidates.append((0 if in_chosen else 1, depth, full_path)) + if logo_candidates: + logo_candidates.sort() + with open(logo_candidates[0][2], "rb") as fh: + logo_bytes = fh.read() + except Exception: + logo_bytes = None + + final_dir = os.path.join(plugins_dir, plugin_key) + should_overwrite = (allow_overwrite_key and plugin_key == allow_overwrite_key) or allow_overwrite + if os.path.exists(final_dir): + if should_overwrite: + # Atomic swap: rename old to backup, move new in, delete backup + backup_dir = final_dir + ".__backup__" + try: + if os.path.exists(backup_dir): + shutil.rmtree(backup_dir) + os.rename(final_dir, backup_dir) + except Exception as e: + return {"success": False, "error": f"Failed to back up existing plugin: {e}"} + try: + if chosen.rstrip(os.sep) == tmp_root.rstrip(os.sep): + 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) + if logo_bytes: + try: + with open(os.path.join(final_dir, "logo.png"), "wb") as fh: + fh.write(logo_bytes) + except Exception: + pass + # Success - remove backup + shutil.rmtree(backup_dir, ignore_errors=True) + return {"success": True, "plugin_key": plugin_key} + except Exception as e: + # Rollback: restore backup + logger.exception("Failed to install updated plugin; rolling back") + shutil.rmtree(final_dir, ignore_errors=True) + try: + os.rename(backup_dir, final_dir) + except Exception: + logger.exception("Failed to rollback plugin backup") + return {"success": False, "error": f"Failed to install updated plugin: {e}"} + elif os.path.exists(os.path.join(final_dir, "plugin.py")) or os.path.exists(os.path.join(final_dir, "__init__.py")): + return {"success": False, "error": f"Plugin '{plugin_key}' already exists"} + else: + try: + shutil.rmtree(final_dir) + except Exception: + pass + + # Move plugin files into final location + if chosen.rstrip(os.sep) == tmp_root.rstrip(os.sep): + 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) + + if logo_bytes: + try: + with open(os.path.join(final_dir, "logo.png"), "wb") as fh: + fh.write(logo_bytes) + except Exception: + pass + + return {"success": True, "plugin_key": plugin_key} + finally: + shutil.rmtree(tmp_root, ignore_errors=True) + + +def _save_fetched_manifest_to_repo(repo, data, verified): + """Validate and persist a freshly-fetched manifest onto a PluginRepo. + + Validates that 'registry_name' is present and not official-sounding (for + non-official repos). On success, updates repo fields and saves to DB. + + Returns an error string if validation fails, or None on success. + Does *not* call _unmanage_dropped_slugs — caller does that when needed. + """ + manifest_inner = data.get("manifest", data) + registry_name = (manifest_inner.get("registry_name") or "").strip() + if not registry_name: + return "Manifest is missing a 'registry_name'. The repo maintainer must set this field." + if not repo.is_official and _is_official_sounding(registry_name): + return f"The registry name '{registry_name}' is not allowed because it may be confused with an official repo." + repo.cached_manifest = data + repo.last_fetched = timezone.now() + repo.last_fetch_status = "200" + repo.name = registry_name + repo.signature_verified = verified + repo.save(update_fields=["name", "cached_manifest", "signature_verified", "last_fetched", "last_fetch_status", "updated_at"]) + return None + + +def _unmanage_dropped_slugs(repo, new_manifest_data): + """After a manifest refresh, clear source_repo on any installed plugins + whose slug is no longer listed in the repo's manifest. Also syncs the + 'deprecated' flag for all plugins that remain managed by this repo.""" + manifest = new_manifest_data.get("manifest", new_manifest_data) + plugin_entries = {p["slug"]: p for p in manifest.get("plugins", []) if p.get("slug")} + current_slugs = set(plugin_entries.keys()) + + dropped = PluginConfig.objects.filter(source_repo=repo).exclude(slug__in=current_slugs) + count = dropped.update(source_repo=None, slug="", deprecated=False) + if count: + logger.info( + "Unmanaged %d plugin(s) removed from repo '%s' manifest", + count, repo.name, + ) + + # Sync deprecated flag for retained managed plugins + for cfg in PluginConfig.objects.filter(source_repo=repo, slug__in=current_slugs): + new_deprecated = bool(plugin_entries.get(cfg.slug, {}).get("deprecated", False)) + if cfg.deprecated != new_deprecated: + cfg.deprecated = new_deprecated + cfg.save(update_fields=["deprecated", "updated_at"]) + + +class PluginImportAPIView(PluginAuthMixin, APIView): + 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) + + # Manual imports default to non-overwrite; require explicit flag to replace existing plugins + overwrite_flag = bool(request.data.get("overwrite")) + + pm = PluginManager.get() + result = _install_plugin_from_zip( + file, pm.plugins_dir, + file_name=getattr(file, "name", "plugin.zip"), + allow_overwrite=overwrite_flag, + ) + if not result["success"]: + return Response( + {"success": False, "error": result["error"]}, + status=status.HTTP_400_BAD_REQUEST, + ) + + plugin_key = result["plugin_key"] + + # Ensure DB config exists (untrusted plugins are registered without loading) + was_managed = False + try: + cfg, _ = PluginConfig.objects.get_or_create( + key=plugin_key, + defaults={ + "name": plugin_key, + "version": "", + "description": "", + "settings": {}, + }, + ) + # Manual install always breaks the managed relationship + if cfg and cfg.source_repo_id: + was_managed = True + cfg.source_repo = None + cfg.slug = "" + cfg.save(update_fields=["source_repo", "slug", "updated_at"]) + logger.info("Plugin '%s' manually replaced - cleared managed repo link", plugin_key) + except Exception: + cfg = None + + # Reload discovery to register the plugin (trusted plugins will load) + pm.discover_plugins(force_reload=True) + plugin_entry = None + try: + plugin_entry = next((p for p in pm.list_plugins() if p.get("key") == plugin_key), None) + except Exception: + plugin_entry = None + + if not plugin_entry: + logo_path = os.path.join(pm.plugins_dir, plugin_key, "logo.png") + logo_url = f"/api/plugins/plugins/{plugin_key}/logo/" if os.path.isfile(logo_path) else None + legacy = not os.path.isfile(os.path.join(pm.plugins_dir, plugin_key, "plugin.json")) + plugin_entry = { + "key": plugin_key, + "name": cfg.name if cfg else plugin_key, + "version": cfg.version if cfg else "", + "description": cfg.description if cfg else "", + "enabled": cfg.enabled if cfg else False, + "ever_enabled": getattr(cfg, "ever_enabled", False) if cfg else False, + "fields": [], + "actions": [], + "trusted": bool(cfg and (cfg.ever_enabled or cfg.enabled)), + "loaded": False, + "missing": False, + "legacy": legacy, + "logo_url": logo_url, + } + + plugin_entry["logo_url"] = _absolutize_logo_url(request, plugin_entry.get("logo_url")) + return Response({"success": True, "plugin": plugin_entry, "was_managed": was_managed}) + + +class PluginSettingsAPIView(PluginAuthMixin, APIView): + 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(PluginAuthMixin, APIView): + 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(PluginAuthMixin, APIView): + def post(self, request, key): + enabled_raw = request.data.get("enabled") + if enabled_raw is None: + return Response({"success": False, "error": "Missing 'enabled' boolean"}, status=status.HTTP_400_BAD_REQUEST) + enabled = _parse_bool(enabled_raw) + if enabled is None: + return Response({"success": False, "error": "Invalid 'enabled' boolean"}, status=status.HTTP_400_BAD_REQUEST) + try: + cfg = PluginConfig.objects.get(key=key) + pm = PluginManager.get() + if not enabled and cfg.enabled: + try: + pm.stop_plugin(key, reason="disable") + except Exception: + logger.exception("Failed to stop plugin '%s' on disable", key) + cfg.enabled = 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"]) + pm.discover_plugins(force_reload=True) + plugin_entry = None + try: + plugin_entry = next((p for p in pm.list_plugins() if p.get("key") == key), None) + except Exception: + plugin_entry = None + response = {"success": True, "enabled": cfg.enabled, "ever_enabled": cfg.ever_enabled} + if plugin_entry: + plugin_entry["logo_url"] = _absolutize_logo_url(request, plugin_entry.get("logo_url")) + response["plugin"] = plugin_entry + return Response(response) + except PluginConfig.DoesNotExist: + return Response({"success": False, "error": "Plugin not found"}, status=status.HTTP_404_NOT_FOUND) + + +class PluginLogoAPIView(APIView): + def get_permissions(self): + return [] + + def get(self, request, key): + if not network_access_allowed(request, "UI"): + return Response({"success": False, "error": "Network access denied"}, status=status.HTTP_403_FORBIDDEN) + pm = PluginManager.get() + pm.discover_plugins(use_cache=True) + plugins_dir = pm.plugins_dir + logo_path = os.path.join(plugins_dir, key, "logo.png") + lp = pm.get_plugin(key) + if lp and getattr(lp, "path", None): + logo_path = os.path.join(lp.path, "logo.png") + abs_plugins = os.path.abspath(plugins_dir) + os.sep + abs_target = os.path.abspath(logo_path) + if not abs_target.startswith(abs_plugins): + return Response({"success": False, "error": "Invalid plugin path"}, status=status.HTTP_400_BAD_REQUEST) + if not os.path.isfile(logo_path): + return Response({"success": False, "error": "Logo not found"}, status=status.HTTP_404_NOT_FOUND) + return FileResponse(open(logo_path, "rb"), content_type="image/png") + + +class PluginDeleteAPIView(PluginAuthMixin, APIView): + def delete(self, request, key): + pm = PluginManager.get() + try: + pm.stop_plugin(key, reason="delete") + except Exception: + logger.exception("Failed to stop plugin '%s' before delete", key) + 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(force_reload=True) + return Response({"success": True}) + + +# --------------------------------------------------------------------------- +# Plugin Repo (Hub / Store) views +# --------------------------------------------------------------------------- + +MANIFEST_FETCH_TIMEOUT = 15 + +OFFICIAL_KEY_PATH = os.path.join( + os.path.dirname(__file__), "keys", "dispatcharr-plugins.pub" +) + + +def _normalize_pgp_key(text): + """Ensure PGP public key text has armor header/footer.""" + if not text or not text.strip(): + return text + text = text.strip() + if "-----BEGIN PGP PUBLIC KEY BLOCK-----" not in text: + text = "-----BEGIN PGP PUBLIC KEY BLOCK-----\n\n" + text + if "-----END PGP PUBLIC KEY BLOCK-----" not in text: + text = text + "\n-----END PGP PUBLIC KEY BLOCK-----" + return text + + +def _verify_manifest_signature(manifest_obj, signature_armored, public_key_text=None): + """Verify a detached GPG signature over the canonical manifest JSON. + + *signature_armored* is the PGP armored signature string from the manifest. + *public_key_text* is an armored PGP public-key string (for third-party + repos). When *None* the bundled official key is used instead. + + Returns True if valid, False if invalid/error, None if verification + could not be attempted (no signature, no key, gnupg missing, etc.). + """ + if not signature_armored: + return None + + # Determine which key material to use + key_text = None + if public_key_text: + key_text = _normalize_pgp_key(public_key_text) + elif os.path.isfile(OFFICIAL_KEY_PATH): + with open(OFFICIAL_KEY_PATH, "r") as fh: + key_text = fh.read() + + if not key_text: + logger.debug("No GPG public key available; skipping verification") + return None + + try: + import gnupg + except ImportError: + logger.debug("python-gnupg not installed; skipping signature verification") + return None + + tmp_home = tempfile.mkdtemp(prefix="gpg_verify_") + try: + gpg = gnupg.GPG(gnupghome=tmp_home) + import_result = gpg.import_keys(key_text) + if not import_result.fingerprints: + logger.warning("Failed to import GPG public key") + return None + + # Must match what the signing script produces: jq -c '.manifest' + # which uses compact separators, preserves key order, and appends \n. + manifest_bytes = ( + json.dumps(manifest_obj, separators=(",", ":")) + "\n" + ).encode("utf-8") + + # Write the PGP armored signature directly to file + sig_path = os.path.join(tmp_home, "manifest.sig") + with open(sig_path, "w") as sf: + sf.write(signature_armored) + + verified = gpg.verify_data(sig_path, manifest_bytes) + return bool(verified) + except Exception: + logger.exception("GPG signature verification error") + return False + finally: + shutil.rmtree(tmp_home, ignore_errors=True) + + +_OFFICIAL_NAME_PATTERNS = [ + "official", + "official repo", + "dispatcharr plugins", + "dispatcharr repo", + "dispatcharr official", +] + + +def _is_official_sounding(name): + """Return True if the name could be mistaken for an official repo.""" + lower = (name or "").lower().strip() + return any(pat in lower for pat in _OFFICIAL_NAME_PATTERNS) + + +def _fetch_manifest(url, public_key_text=None): + """Fetch a remote manifest JSON, validate structure, return (data, verified).""" + _validate_fetch_url(url) + with http_requests.get(url, timeout=MANIFEST_FETCH_TIMEOUT, stream=True) as resp: + resp.raise_for_status() + body = b"".join(resp.iter_content(8192)) + data = json.loads(body) + # Accept both top-level {manifest: {plugins: [...]}} and {plugins: [...]} + if "manifest" in data and "plugins" in data["manifest"]: + signature = data.get("signature") + verified = _verify_manifest_signature( + data["manifest"], signature, public_key_text + ) + return data, verified + if "plugins" in data: + return {"manifest": data}, None + raise ValueError("Manifest JSON missing 'manifest.plugins' list") + + +class PluginRepoListCreateAPIView(PluginAuthMixin, APIView): + @extend_schema( + description="List all plugin repositories.", + responses={200: PluginRepoSerializer(many=True)}, + ) + def get(self, request): + repos = PluginRepo.objects.all() + return Response(PluginRepoSerializer(repos, many=True).data) + + @extend_schema( + description="Add a new plugin repository by manifest URL. Fetches and validates the manifest immediately.", + request=PluginRepoSerializer, + responses={201: PluginRepoSerializer, 400: inline_serializer(name="RepoAddError", fields={"error": serializers.CharField()})}, + ) + def post(self, request): + serializer = PluginRepoSerializer(data=request.data) + serializer.is_valid(raise_exception=True) + repo = serializer.save(name="") + # Fetch manifest and validate + save + try: + key_text = repo.public_key if not repo.is_official else None + data, verified = _fetch_manifest(repo.url, public_key_text=key_text) + except Exception as e: + logger.warning("Initial manifest fetch failed for %s: %s", repo.url, e) + repo.delete() + return Response( + {"error": "Failed to fetch manifest. Check the URL and try again."}, + status=status.HTTP_400_BAD_REQUEST, + ) + err = _save_fetched_manifest_to_repo(repo, data, verified) + if err: + repo.delete() + return Response({"error": err}, status=status.HTTP_400_BAD_REQUEST) + return Response( + PluginRepoSerializer(repo).data, status=status.HTTP_201_CREATED + ) + + +class PluginRepoPreviewAPIView(PluginAuthMixin, APIView): + """Fetch and validate a manifest URL without saving anything.""" + + @extend_schema( + description="Preview a manifest URL: fetch and validate without saving. Returns validity, repo name, signature status, and plugin count.", + request=inline_serializer(name="RepoPreviewRequest", fields={ + "url": serializers.URLField(), + "public_key": serializers.CharField(required=False, allow_blank=True), + }), + responses={200: inline_serializer(name="RepoPreviewResponse", fields={ + "valid": serializers.BooleanField(), + "registry_name": serializers.CharField(), + "registry_url": serializers.CharField(), + "signature_verified": serializers.BooleanField(allow_null=True), + "plugin_count": serializers.IntegerField(), + "errors": serializers.ListField(child=serializers.CharField()), + })}, + ) + def post(self, request): + url = (request.data.get("url") or "").strip() + public_key = (request.data.get("public_key") or "").strip() + if not url: + return Response( + {"error": "url is required"}, + status=status.HTTP_400_BAD_REQUEST, + ) + try: + key_text = public_key or None + data, verified = _fetch_manifest(url, public_key_text=key_text) + manifest_inner = data.get("manifest", data) + registry_name = (manifest_inner.get("registry_name") or "").strip() + registry_url = (manifest_inner.get("registry_url") or "").strip() + plugin_count = len(manifest_inner.get("plugins", [])) + errors = [] + if not registry_name: + errors.append("Manifest is missing a 'registry_name'.") + elif _is_official_sounding(registry_name): + errors.append(f"The registry name '{registry_name}' is not allowed because it may be confused with an official repo.") + if PluginRepo.objects.filter(url=url).exists(): + errors.append("This manifest URL has already been added.") + return Response({ + "valid": len(errors) == 0, + "registry_name": registry_name, + "registry_url": registry_url, + "signature_verified": verified, + "plugin_count": plugin_count, + "errors": errors, + }) + except http_requests.exceptions.Timeout: + return Response( + {"valid": False, "errors": ["The request timed out. Check the URL and try again."]}, + status=status.HTTP_200_OK, + ) + except http_requests.exceptions.ConnectionError: + return Response( + {"valid": False, "errors": ["Could not connect to the server. Check the URL and your network connection."]}, + status=status.HTTP_200_OK, + ) + except http_requests.exceptions.HTTPError as e: + code = e.response.status_code if e.response is not None else None + if code == 404: + msg = "Manifest not found (404). Check that the URL points to a valid manifest file." + elif code == 403: + msg = "Access denied (403). The server refused the request." + elif code is not None: + msg = f"The server returned an error ({code}). Check the URL and try again." + else: + msg = "The server returned an unexpected error. Check the URL and try again." + return Response( + {"valid": False, "errors": [msg]}, + status=status.HTTP_200_OK, + ) + except (json.JSONDecodeError, ValueError) as e: + msg = str(e) + # Pass through messages from _validate_fetch_url and _fetch_manifest + # as-is; only substitute the generic JSON message for actual parse errors. + if "missing" in msg.lower() and "plugins" in msg.lower(): + friendly = msg + elif any(kw in msg.lower() for kw in ("non-routable", "scheme", "hostname", "resolve")): + friendly = msg + else: + friendly = "The URL did not return valid JSON. Make sure it points directly to a manifest .json file." + return Response( + {"valid": False, "errors": [friendly]}, + status=status.HTTP_200_OK, + ) + except Exception as e: + return Response( + {"valid": False, "errors": ["An unexpected error occurred. Check the URL and try again."]}, + status=status.HTTP_200_OK, + ) + + +class PluginRepoDetailAPIView(PluginAuthMixin, APIView): + @extend_schema( + description="Update a plugin repository (e.g. public key).", + request=PluginRepoSerializer, + responses={200: PluginRepoSerializer, 404: inline_serializer(name="RepoNotFound", fields={"error": serializers.CharField()})}, + ) + def put(self, request, pk): + try: + repo = PluginRepo.objects.get(pk=pk) + except PluginRepo.DoesNotExist: + return Response( + {"error": "Repo not found"}, status=status.HTTP_404_NOT_FOUND + ) + # Only public_key and enabled are mutable after creation. + # url, is_official, name, etc. must not be changed via the API. + ALLOWED_FIELDS = {"public_key", "enabled"} + update_data = {k: v for k, v in request.data.items() if k in ALLOWED_FIELDS} + serializer = PluginRepoSerializer(repo, data=update_data, partial=True) + serializer.is_valid(raise_exception=True) + serializer.save() + return Response(PluginRepoSerializer(repo).data) + + @extend_schema( + description="Remove a plugin repository.", + responses={200: inline_serializer(name="RepoDeleteSuccess", fields={"success": serializers.BooleanField()}), 404: inline_serializer(name="RepoDeleteNotFound", fields={"error": serializers.CharField()})}, + ) + def delete(self, request, pk): + try: + repo = PluginRepo.objects.get(pk=pk) + except PluginRepo.DoesNotExist: + return Response( + {"error": "Repo not found"}, status=status.HTTP_404_NOT_FOUND + ) + if repo.is_official: + return Response( + {"error": "Cannot delete the official repository"}, + status=status.HTTP_400_BAD_REQUEST, + ) + repo.delete() + return Response({"success": True}) + + +class PluginRepoRefreshAPIView(PluginAuthMixin, APIView): + @extend_schema( + description="Re-fetch and update the cached manifest for a plugin repository.", + request=None, + responses={200: PluginRepoSerializer, 404: inline_serializer(name="RepoRefreshNotFound", fields={"error": serializers.CharField()}), 502: inline_serializer(name="RepoRefreshError", fields={"error": serializers.CharField()})}, + ) + def post(self, request, pk): + try: + repo = PluginRepo.objects.get(pk=pk) + except PluginRepo.DoesNotExist: + return Response( + {"error": "Repo not found"}, status=status.HTTP_404_NOT_FOUND + ) + try: + key_text = repo.public_key if not repo.is_official else None + data, verified = _fetch_manifest(repo.url, public_key_text=key_text) + except Exception as e: + logger.exception("Manifest fetch failed for %s", repo.url) + return Response( + {"error": "Failed to fetch manifest. Check the URL and try again."}, + status=status.HTTP_502_BAD_GATEWAY, + ) + err = _save_fetched_manifest_to_repo(repo, data, verified) + if err: + return Response({"error": err}, status=status.HTTP_400_BAD_REQUEST) + _unmanage_dropped_slugs(repo, data) + return Response(PluginRepoSerializer(repo).data) + + +class AvailablePluginsAPIView(PluginAuthMixin, APIView): + """Aggregate plugins from all enabled repo manifests.""" + + @extend_schema( + description="Return the aggregated list of available plugins from all enabled repositories, annotated with installation status.", + responses={200: inline_serializer(name="AvailablePluginsResponse", fields={ + "plugins": serializers.ListField(child=serializers.DictField()), + })}, + ) + def get(self, request): + repos = PluginRepo.objects.filter(enabled=True) + configs = list(PluginConfig.objects.select_related("source_repo").all()) + # Build lookup: slug -> (version, repo_id, repo_name) for managed plugins, + # plus key -> version for all plugins (legacy matching) + installed_by_slug = {} + installed_by_key = {} + # Secondary dict keyed by dash-to-underscore-normalized key, for backward compat + # with existing DB entries that were saved before normalization was enforced. + installed_by_key_norm = {} + for cfg in configs: + installed_by_key[cfg.key] = cfg.version + installed_by_key_norm[cfg.key.replace("-", "_")] = cfg.key + if cfg.slug: + installed_by_slug[cfg.slug] = { + "version": cfg.version, + "source_repo_id": cfg.source_repo_id, + "source_repo_name": cfg.source_repo.name if cfg.source_repo else None, + "is_prerelease": cfg.installed_version_is_prerelease, + } + # Also index by normalized slug so a dash-variant in the manifest still matches + norm_slug = cfg.slug.replace("-", "_") + if norm_slug not in installed_by_slug: + installed_by_slug[norm_slug] = installed_by_slug[cfg.slug] + + plugins = [] + for repo in repos: + manifest_data = repo.cached_manifest or {} + manifest = manifest_data.get("manifest", manifest_data) + root_url = manifest.get("root_url", "").rstrip("/") + registry_url = manifest.get("registry_url", "").rstrip("/") + repo_plugins = manifest.get("plugins", []) + for p in repo_plugins: + slug = p.get("slug", "") + plugin_data = {**p} + # Resolve relative URLs against root_url; absolute URLs pass through + if root_url: + for url_field in ("manifest_url", "latest_url", "icon_url"): + val = plugin_data.get(url_field, "") + if val and not val.startswith(("http://", "https://")): + plugin_data[url_field] = f"{root_url}/{val}" + # Fallback icon_url from main branch when not provided + if not plugin_data.get("icon_url") and registry_url: + # registry_url is e.g. https://github.com/Dispatcharr/Plugins + # Convert to raw.githubusercontent.com URL for the main branch + raw_base = registry_url.replace( + "https://github.com/", "https://raw.githubusercontent.com/" + ) + plugin_data["icon_url"] = f"{raw_base}/refs/heads/main/plugins/{slug}/logo.png" + # Determine install status + managed = installed_by_slug.get(slug) or installed_by_slug.get(slug.replace("-", "_")) + sanitized_slug = _sanitize_plugin_key(slug) + key_match = sanitized_slug in installed_by_key or sanitized_slug in installed_by_key_norm + if managed: + is_installed = True + installed_version = managed["version"] + latest = plugin_data.get("latest_version") + if managed["source_repo_id"] == repo.id: + if installed_version and latest and installed_version != latest and not managed.get("is_prerelease"): + install_status = "update_available" + else: + install_status = "installed" + else: + install_status = "different_repo" + elif key_match: + is_installed = True + installed_version = installed_by_key.get(sanitized_slug) or installed_by_key.get( + installed_by_key_norm.get(sanitized_slug, sanitized_slug) + ) + install_status = "unmanaged" + else: + is_installed = False + installed_version = None + install_status = "not_installed" + entry = { + **plugin_data, + "repo_id": repo.id, + "repo_name": repo.name, + "is_official_repo": repo.is_official, + "signature_verified": repo.signature_verified, + "installed": is_installed, + "installed_version": installed_version, + "installed_version_is_prerelease": managed.get("is_prerelease", False) if managed else False, + "install_status": install_status, + "key": _sanitize_plugin_key(slug), + } + if install_status == "different_repo": + entry["installed_source_repo_name"] = managed["source_repo_name"] + plugins.append(entry) + return Response({"plugins": plugins}) + + +class PluginDetailManifestAPIView(PluginAuthMixin, APIView): + """Fetch and verify a per-plugin manifest given repo_id and manifest_url.""" + + @extend_schema( + description="Fetch and GPG-verify a per-plugin manifest from a repo, resolving relative URLs against the repo root.", + request=inline_serializer(name="PluginDetailManifestRequest", fields={ + "repo_id": serializers.IntegerField(), + "manifest_url": serializers.URLField(), + }), + responses={200: inline_serializer(name="PluginDetailManifestResponse", fields={ + "manifest": serializers.DictField(), + "signature_verified": serializers.BooleanField(allow_null=True), + }), 502: inline_serializer(name="PluginDetailManifestError", fields={"error": serializers.CharField()})}, + ) + def post(self, request): + repo_id = request.data.get("repo_id") + manifest_url = request.data.get("manifest_url") + if not repo_id or not manifest_url: + return Response( + {"error": "repo_id and manifest_url are required"}, + status=status.HTTP_400_BAD_REQUEST, + ) + try: + repo = PluginRepo.objects.get(pk=repo_id) + except PluginRepo.DoesNotExist: + return Response( + {"error": "Repo not found"}, status=status.HTTP_404_NOT_FOUND + ) + try: + _validate_fetch_url(manifest_url) + except ValueError as e: + return Response({"error": str(e)}, status=status.HTTP_400_BAD_REQUEST) + try: + resp = http_requests.get(manifest_url, timeout=MANIFEST_FETCH_TIMEOUT) + resp.raise_for_status() + data = resp.json() + + signature = data.get("signature") + manifest_obj = data.get("manifest", data) + verified = _verify_manifest_signature( + manifest_obj, signature, + repo.public_key if not repo.is_official else None + ) + + # Resolve relative URLs in versions + repo_manifest = repo.cached_manifest or {} + inner = repo_manifest.get("manifest", repo_manifest) + root_url = inner.get("root_url", "").rstrip("/") + + if root_url and isinstance(manifest_obj.get("versions"), list): + for v in manifest_obj["versions"]: + url_val = v.get("url", "") + if url_val and not url_val.startswith(("http://", "https://")): + v["url"] = f"{root_url}/{url_val}" + if root_url and isinstance(manifest_obj.get("latest"), dict): + for url_field in ("url", "latest_url"): + url_val = manifest_obj["latest"].get(url_field, "") + if url_val and not url_val.startswith(("http://", "https://")): + manifest_obj["latest"][url_field] = f"{root_url}/{url_val}" + + return Response({ + "manifest": manifest_obj, + "signature_verified": verified, + }) + except Exception as e: + logger.exception("Failed to fetch plugin manifest from %s", manifest_url) + return Response( + {"error": f"Failed to fetch plugin manifest: {e}"}, + status=status.HTTP_502_BAD_GATEWAY, + ) + + +class PluginInstallFromRepoAPIView(PluginAuthMixin, APIView): + """Install a plugin from a managed repo by downloading its release zip.""" + + @extend_schema( + description="Download and install a plugin release zip from a managed repository. Verifies SHA256 if provided.", + request=inline_serializer(name="PluginInstallFromRepoRequest", fields={ + "repo_id": serializers.IntegerField(), + "slug": serializers.CharField(), + "version": serializers.CharField(), + "download_url": serializers.URLField(), + "sha256": serializers.CharField(required=False, allow_blank=True), + "min_dispatcharr_version": serializers.CharField(required=False, allow_blank=True), + "max_dispatcharr_version": serializers.CharField(required=False, allow_blank=True), + }), + responses={ + 200: inline_serializer(name="PluginInstallFromRepoResponse", fields={"success": serializers.BooleanField(), "plugin": serializers.DictField()}), + 201: inline_serializer(name="PluginInstallFromRepoCreated", fields={"success": serializers.BooleanField(), "plugin": serializers.DictField()}), + 400: inline_serializer(name="PluginInstallFromRepoError", fields={"error": serializers.CharField()}), + }, + ) + def post(self, request): + repo_id = request.data.get("repo_id") + slug = request.data.get("slug") + version = request.data.get("version") + download_url = request.data.get("download_url") + + if not all([repo_id, slug, version, download_url]): + return Response( + {"error": "repo_id, slug, version, and download_url are required"}, + status=status.HTTP_400_BAD_REQUEST, + ) + + try: + repo = PluginRepo.objects.get(pk=repo_id) + except PluginRepo.DoesNotExist: + return Response( + {"error": "Repo not found"}, status=status.HTTP_404_NOT_FOUND + ) + + # Resolve the plugin key and look up any existing install + plugin_key = _sanitize_plugin_key(slug) + if len(plugin_key) > 128: + plugin_key = plugin_key[:128] + + existing_cfg = PluginConfig.objects.filter(key=plugin_key).first() + # Backward compat: if no match, also try with dashes (legacy entries saved before + # normalization was enforced) so overwrite is still allowed on update. + if not existing_cfg: + dash_key = plugin_key.replace("_", "-") + if dash_key != plugin_key: + existing_cfg = PluginConfig.objects.filter(key=dash_key).first() + + # Version compatibility check against the running Dispatcharr version + min_version = request.data.get("min_dispatcharr_version") + max_version = request.data.get("max_dispatcharr_version") + if min_version or max_version: + from version import __version__ as app_version + try: + if min_version and _compare_versions(app_version, min_version) < 0: + return Response( + {"error": f"This plugin version requires Dispatcharr {min_version} or newer (you have {app_version})"}, + status=status.HTTP_400_BAD_REQUEST, + ) + if max_version and _compare_versions(app_version, max_version) > 0: + return Response( + {"error": f"This plugin version requires Dispatcharr {max_version} or older (you have {app_version})"}, + status=status.HTTP_400_BAD_REQUEST, + ) + except (ValueError, TypeError): + logger.warning("Failed to parse version constraints: min=%s, max=%s", min_version, max_version) + + # Download the zip + try: + _validate_fetch_url(download_url) + except ValueError as e: + return Response({"error": str(e)}, status=status.HTTP_400_BAD_REQUEST) + try: + resp = http_requests.get(download_url, timeout=60, stream=True) + resp.raise_for_status() + except Exception as e: + logger.exception("Failed to download plugin from %s", download_url) + return Response( + {"error": "Failed to download plugin. Check the URL and try again."}, + status=status.HTTP_502_BAD_GATEWAY, + ) + + # SHA256 integrity check (streamed) + expected_sha256 = request.data.get("sha256", "").lower().strip() + hasher = hashlib.sha256() if expected_sha256 else None + + # Stream the response to a temporary file to avoid buffering in memory + with tempfile.NamedTemporaryFile(suffix=".zip") as tmp_file: + total = 0 + for chunk in resp.iter_content(chunk_size=8192): + if not chunk: + continue + total += len(chunk) + if total > MAX_PLUGIN_IMPORT_BYTES: + return Response( + {"error": "Download is too large"}, + status=status.HTTP_400_BAD_REQUEST, + ) + if hasher is not None: + hasher.update(chunk) + tmp_file.write(chunk) + + if expected_sha256: + actual_sha256 = hasher.hexdigest() + if actual_sha256 != expected_sha256: + logger.warning( + "SHA256 mismatch for plugin '%s' from %s: expected %s, got %s", + slug, download_url, expected_sha256, actual_sha256, + ) + return Response( + { + "error": "SHA256 integrity check failed - download discarded. The file may be corrupted or tampered with." + }, + status=status.HTTP_400_BAD_REQUEST, + ) + + # Delegate to shared install logic (allow overwrite for managed updates) + tmp_file.flush() + tmp_file.seek(0) + pm = PluginManager.get() + result = _install_plugin_from_zip( + tmp_file, + pm.plugins_dir, + file_name=f"{slug}.zip", + allow_overwrite_key=plugin_key if existing_cfg else None, + ) + if not result["success"]: + return Response( + {"success": False, "error": result["error"]}, + status=status.HTTP_400_BAD_REQUEST, + ) + + actual_key = result["plugin_key"] + + # Create/update PluginConfig with managed fields + # Use defaults for creation only; explicitly update fields on existing records + # to preserve settings, enabled, and ever_enabled + is_prerelease = bool(request.data.get("prerelease", False)) + + # Determine deprecated status from the repo's cached manifest + is_deprecated = False + manifest_data = repo.cached_manifest or {} + manifest_inner = manifest_data.get("manifest", manifest_data) + for rp in manifest_inner.get("plugins", []): + if rp.get("slug") == slug: + is_deprecated = bool(rp.get("deprecated", False)) + break + + cfg, created = PluginConfig.objects.get_or_create( + key=actual_key, + defaults={ + "name": slug, + "version": version, + "slug": slug, + "source_repo": repo, + "installed_version_is_prerelease": is_prerelease, + "deprecated": is_deprecated, + }, + ) + if not created: + cfg.version = version + cfg.slug = slug + cfg.source_repo = repo + cfg.installed_version_is_prerelease = is_prerelease + cfg.deprecated = is_deprecated + cfg.save(update_fields=["version", "slug", "source_repo", "installed_version_is_prerelease", "deprecated", "updated_at"]) + + # Reload discovery + pm.discover_plugins(force_reload=True) + plugin_entry = None + try: + plugin_entry = next( + (p for p in pm.list_plugins() if p.get("key") == actual_key), + None, + ) + except Exception: + plugin_entry = None + + return Response( + { + "success": True, + "plugin": plugin_entry or {"key": actual_key, "slug": slug, "version": version}, + }, + status=status.HTTP_201_CREATED if created else status.HTTP_200_OK, + ) + + +class PluginRepoSettingsAPIView(PluginAuthMixin, APIView): + """Get/update plugin repo refresh settings (interval in hours, 0=disabled).""" + + @extend_schema( + description="Get the plugin repository refresh interval setting.", + responses={200: inline_serializer(name="PluginRepoSettingsResponse", fields={"refresh_interval_hours": serializers.IntegerField()})}, + ) + def get(self, request): + from core.models import CoreSettings + try: + obj = CoreSettings.objects.get(key="plugin_repo_settings") + return Response(obj.value) + except CoreSettings.DoesNotExist: + return Response({"refresh_interval_hours": 6}) + + @extend_schema( + description="Update the plugin repository refresh interval (hours). Set to 0 to disable automatic refresh.", + request=inline_serializer(name="PluginRepoSettingsRequest", fields={"refresh_interval_hours": serializers.IntegerField()}), + responses={200: inline_serializer(name="PluginRepoSettingsUpdated", fields={"refresh_interval_hours": serializers.IntegerField()})}, + ) + def put(self, request): + from core.models import CoreSettings + from core.scheduling import create_or_update_periodic_task, delete_periodic_task + from .tasks import PLUGIN_REPO_REFRESH_TASK_NAME + + interval = request.data.get("refresh_interval_hours", 6) + try: + interval = int(interval) + if interval < 0: + interval = 0 + except (TypeError, ValueError): + interval = 6 + + obj, _ = CoreSettings.objects.update_or_create( + key="plugin_repo_settings", + defaults={ + "name": "Plugin Repo Settings", + "value": {"refresh_interval_hours": interval}, + }, + ) + + if interval == 0: + delete_periodic_task(PLUGIN_REPO_REFRESH_TASK_NAME) + else: + create_or_update_periodic_task( + task_name=PLUGIN_REPO_REFRESH_TASK_NAME, + celery_task_path="apps.plugins.tasks.refresh_plugin_repos", + interval_hours=interval, + enabled=True, + ) + + return Response(obj.value) diff --git a/apps/plugins/apps.py b/apps/plugins/apps.py new file mode 100644 index 0000000..c2c0bce --- /dev/null +++ b/apps/plugins/apps.py @@ -0,0 +1,104 @@ +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") + + # Register periodic task for refreshing plugin repo manifests + self._setup_repo_refresh_schedule() + + # Refresh repo manifests once at startup so the UI always has current data + self._enqueue_startup_refresh() + + def _enqueue_startup_refresh(self): + from dispatcharr.app_initialization import should_skip_initialization + if should_skip_initialization(): + return + try: + from .tasks import refresh_plugin_repos + refresh_plugin_repos.apply_async(countdown=10) + except Exception: + import logging + logging.getLogger(__name__).debug( + "Could not enqueue startup plugin repo refresh (Celery may not be ready yet)" + ) + + def _setup_repo_refresh_schedule(self): + from dispatcharr.app_initialization import should_skip_initialization + if should_skip_initialization(): + return + try: + from core.scheduling import create_or_update_periodic_task, delete_periodic_task + from core.models import CoreSettings + from .tasks import PLUGIN_REPO_REFRESH_TASK_NAME + + interval = 6 + try: + obj = CoreSettings.objects.get(key="plugin_repo_settings") + interval = obj.value.get("refresh_interval_hours", 6) + except CoreSettings.DoesNotExist: + pass + + if interval == 0: + delete_periodic_task(PLUGIN_REPO_REFRESH_TASK_NAME) + else: + create_or_update_periodic_task( + task_name=PLUGIN_REPO_REFRESH_TASK_NAME, + celery_task_path="apps.plugins.tasks.refresh_plugin_repos", + interval_hours=interval, + enabled=True, + ) + except Exception: + import logging + logging.getLogger(__name__).debug( + "Could not set up plugin repo refresh schedule (migrations may not have run yet)" + ) diff --git a/apps/plugins/keys/dispatcharr-plugins.pub b/apps/plugins/keys/dispatcharr-plugins.pub new file mode 100644 index 0000000..1f6ba60 --- /dev/null +++ b/apps/plugins/keys/dispatcharr-plugins.pub @@ -0,0 +1,11 @@ +-----BEGIN PGP PUBLIC KEY BLOCK----- + +mDMEacgfABYJKwYBBAHaRw8BAQdAh1MuVNBxk+CExQPjOVDvAGvIk6BdGS2ce9/h +zB7lYtW0TERpc3BhdGNoYXJyIFBsdWdpbiBSZXBvIChkaXNwYXRjaGFyci1hdXRv +Z2VuZXJhdGVkKSA8cGx1Z2luc0BkaXNwYXRjaGFyci50dj6IrwQTFgoAVxYhBEap +MFaOD7nKg0zX+H7AOmtMIjTOBQJpyB8AGxSAAAAAAAQADm1hbnUyLDIuNSsxLjEy +LDAsMwIbAwULCQgHAgIiAgYVCgkICwIEFgIDAQIeBwIXgAAKCRB+wDprTCI0zvNZ +AP9r3TpMpiI8BCNo9B5M9lJ+QLRo9ihPWIcqBzJ9eFCoSQEAgguiZsNy6aJzKjIb +yDvGuoZi3I2/GNM/f2qVzFtgPQk= +=Zf/y +-----END PGP PUBLIC KEY BLOCK----- \ No newline at end of file diff --git a/apps/plugins/loader.py b/apps/plugins/loader.py new file mode 100644 index 0000000..6084b28 --- /dev/null +++ b/apps/plugins/loader.py @@ -0,0 +1,805 @@ +import importlib +import importlib.util +import json +import logging +import os +import re +import sys +import threading +import types +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 = "" + author: str = "" + help_url: 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) + trusted: bool = False + loaded: bool = False + path: Optional[str] = None + folder_name: Optional[str] = None + legacy: bool = False + + +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] = {} + self._package_names: Dict[str, str] = {} + self._alias_names: Dict[str, str] = {} + self._reload_token_path = os.path.join(self.plugins_dir, ".reload_token") + self._last_reload_token = 0.0 + self._lock = threading.RLock() + + # 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, + force_reload: bool = False, + use_cache: bool = False, + ) -> Dict[str, LoadedPlugin]: + token = self._get_reload_token() + if use_cache and not force_reload: + with self._lock: + if self._registry and token <= self._last_reload_token: + return self._registry + if token > self._last_reload_token: + force_reload = True + if force_reload: + self._touch_reload_token() + token = self._get_reload_token() + + 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}") + + with self._lock: + previous_packages = dict(self._package_names) + previous_aliases = dict(self._alias_names) + previous_paths = { + key: lp.path for key, lp in self._registry.items() if lp and lp.path + } + + try: + configs: Optional[Dict[str, PluginConfig]] = None + try: + configs = {c.key: c for c in PluginConfig.objects.all()} + except Exception: + # DB might not be ready; treat all plugins as untrusted + configs = None + + new_registry: Dict[str, LoadedPlugin] = {} + new_packages: Dict[str, str] = {} + new_aliases: Dict[str, str] = {} + for entry in sorted(os.listdir(self.plugins_dir)): + path = os.path.join(self.plugins_dir, entry) + if not os.path.isdir(path): + continue + + 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): + continue + + plugin_key = entry.replace(" ", "_").lower() + alias_name = self._resolve_alias_name(entry, path) + + if force_reload: + prev_alias = previous_aliases.get(plugin_key) + if prev_alias: + self._unload_alias(prev_alias) + prev_path = previous_paths.get(plugin_key) + if prev_path: + self._unload_path_modules(prev_path) + + cfg = configs.get(plugin_key) if configs else None + enabled = bool(cfg and cfg.enabled) + trusted = bool(cfg and (cfg.ever_enabled or cfg.enabled)) + + manifest, has_manifest = self._read_manifest(path) + legacy = not has_manifest + manifest_name = None + manifest_version = None + manifest_description = None + manifest_author = None + manifest_help_url = None + manifest_fields: List[Dict[str, Any]] = [] + manifest_actions: List[Dict[str, Any]] = [] + if has_manifest and isinstance(manifest, dict): + manifest_name = manifest.get("name") if isinstance(manifest.get("name"), str) else None + manifest_version = manifest.get("version") if isinstance(manifest.get("version"), str) else None + manifest_description = manifest.get("description") if isinstance(manifest.get("description"), str) else None + manifest_author = manifest.get("author") if isinstance(manifest.get("author"), str) else None + manifest_help_url = manifest.get("help_url") if isinstance(manifest.get("help_url"), str) else None + manifest_fields = self._normalize_fields(manifest.get("fields", [])) + manifest_actions = self._normalize_actions(manifest.get("actions", [])) + + display_name = manifest_name or entry + display_version = ( + manifest_version if manifest_version is not None else (cfg.version if cfg else "") + ) + display_description = ( + manifest_description if manifest_description is not None else (cfg.description if cfg else "") + ) + + def _make_placeholder() -> LoadedPlugin: + return LoadedPlugin( + key=plugin_key, + name=display_name, + version=display_version, + description=display_description, + author=manifest_author or "", + help_url=manifest_help_url or "", + fields=manifest_fields if has_manifest else [], + actions=manifest_actions if has_manifest else [], + trusted=trusted, + loaded=False, + path=path, + folder_name=entry, + legacy=legacy, + ) + + if not enabled: + new_registry[plugin_key] = _make_placeholder() + continue + + try: + lp, package_name = self._load_plugin( + plugin_key, + path, + folder_name=entry, + force_reload=force_reload, + previous_package=previous_packages.get(plugin_key), + ) + if lp: + if manifest_name and (not lp.name or lp.name == plugin_key): + lp.name = manifest_name + if manifest_version is not None and not lp.version: + lp.version = manifest_version + if manifest_description is not None and not lp.description: + lp.description = manifest_description + if manifest_author is not None and not lp.author: + lp.author = manifest_author + if manifest_help_url is not None and not lp.help_url: + lp.help_url = manifest_help_url + if manifest_fields and not lp.fields: + lp.fields = manifest_fields + if manifest_actions and not lp.actions: + lp.actions = manifest_actions + lp.trusted = trusted + lp.loaded = True + lp.path = path + lp.folder_name = entry + lp.legacy = legacy + new_registry[plugin_key] = lp + if package_name: + new_packages[plugin_key] = package_name + if alias_name: + new_aliases[plugin_key] = alias_name + else: + new_registry[plugin_key] = _make_placeholder() + except Exception: + logger.exception(f"Failed to load plugin '{plugin_key}' from {path}") + new_registry[plugin_key] = _make_placeholder() + + if force_reload: + # Remove stale modules for plugins that no longer exist + removed_keys = set(previous_packages.keys()) - set(new_packages.keys()) + for key in removed_keys: + self._unload_package(previous_packages[key]) + prev_alias = previous_aliases.get(key) + if prev_alias: + self._unload_alias(prev_alias) + prev_path = previous_paths.get(key) + if prev_path: + self._unload_path_modules(prev_path) + + with self._lock: + self._registry = new_registry + self._package_names = new_packages + self._alias_names = new_aliases + if token > self._last_reload_token: + self._last_reload_token = token + + logger.info(f"Discovered {len(new_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(new_registry if 'new_registry' in locals() else None) + 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, + *, + folder_name: str, + force_reload: bool, + previous_package: Optional[str], + ) -> tuple[Optional[LoadedPlugin], Optional[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 None, None + + package_name = self._resolve_package_name(key) + alias_name = self._resolve_alias_name(folder_name, path) + + if force_reload and previous_package: + self._unload_package(previous_package) + + module = None + plugin_cls = None + last_error = None + + # Ensure a package context exists for plugin.py (even without __init__.py) + if has_pluginpy: + self._ensure_namespace_package(package_name, path, alias=alias_name) + + module_name = f"{package_name}.plugin" + plugin_path = os.path.join(path, "plugin.py") + try: + logger.debug(f"Importing plugin module {module_name} from {plugin_path}") + module = self._load_module_from_path(module_name, plugin_path, is_package=False) + if alias_name: + self._register_alias_module(f"{alias_name}.plugin", module, path) + plugin_cls = getattr(module, "Plugin", None) + if plugin_cls is None: + 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 and has_pkg: + module_name = package_name + init_path = os.path.join(path, "__init__.py") + try: + logger.debug(f"Importing plugin package {module_name} from {init_path}") + module = self._load_module_from_path(module_name, init_path, is_package=True) + self._register_alias_module(alias_name, module, path) + plugin_cls = getattr(module, "Plugin", None) + if plugin_cls is None: + 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 + logger.warning(f"No Plugin class found for {key}; skipping") + return None, package_name + + instance = plugin_cls() + + name = getattr(instance, "name", key) + version = getattr(instance, "version", "") + description = getattr(instance, "description", "") + author = getattr(instance, "author", "") + help_url = getattr(instance, "help_url", "") + fields = getattr(instance, "fields", []) + actions = getattr(instance, "actions", []) + fields = self._normalize_fields(fields) + actions = self._normalize_actions(actions) + + lp = LoadedPlugin( + key=key, + name=name, + version=version, + description=description, + author=author or "", + help_url=help_url or "", + module=module, + instance=instance, + fields=fields, + actions=actions, + path=path, + folder_name=folder_name, + ) + return lp, package_name + + def _sync_db_with_registry(self, registry: Optional[Dict[str, LoadedPlugin]] = None): + if registry is None: + with self._lock: + registry = dict(self._registry) + with transaction.atomic(): + for key, lp in 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, PluginRepo + + plugins: List[Dict[str, Any]] = [] + with self._lock: + registry_snapshot = dict(self._registry) + try: + configs = {c.key: c for c in PluginConfig.objects.select_related("source_repo").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 = {} + + # Build repo latest-version lookup from cached manifests + repo_latest = {} # slug -> latest_version + try: + for repo in PluginRepo.objects.filter(enabled=True): + manifest_data = repo.cached_manifest or {} + manifest = manifest_data.get("manifest", manifest_data) + for rp in manifest.get("plugins", []): + s = rp.get("slug", "") + if s: + repo_latest[s] = rp.get("latest_version", "") + except Exception: + pass + + # First, include all discovered plugins + for key, lp in registry_snapshot.items(): + conf = configs.get(key) + conf_slug = conf.slug if conf else "" + trusted = bool(conf and (conf.ever_enabled or conf.enabled)) + logo_url = self._get_logo_url(key, path=lp.path) + plugins.append( + { + "key": key, + "name": lp.name, + "version": lp.version, + "description": lp.description, + "author": getattr(lp, "author", "") or "", + "help_url": getattr(lp, "help_url", "") or "", + "enabled": conf.enabled if conf else False, + "ever_enabled": conf.ever_enabled if conf else False, + "fields": lp.fields or [], + "settings": (conf.settings if conf else {}), + "actions": lp.actions or [], + "missing": False, + "trusted": trusted, + "loaded": bool(lp.loaded), + "legacy": bool(getattr(lp, "legacy", False)), + "logo_url": logo_url, + "source_repo": conf.source_repo_id if conf else None, + "source_repo_name": conf.source_repo.name if conf and conf.source_repo else None, + "is_official_repo": bool(conf and conf.source_repo and conf.source_repo.is_official), + "slug": conf_slug, + "is_managed": bool(conf and conf.source_repo_id), + "installed_version_is_prerelease": bool( + conf and conf.installed_version_is_prerelease + ), + "update_available": bool( + conf_slug and conf and conf.source_repo_id + and not (conf and conf.installed_version_is_prerelease) + and repo_latest.get(conf_slug) + and lp.version != repo_latest.get(conf_slug) + ), + "latest_version": repo_latest.get(conf_slug, ""), + "deprecated": conf.deprecated if conf else False, + } + ) + + # Then, include any DB-only configs (files missing or failed to load) + discovered_keys = set(registry_snapshot.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, + "author": "", + "help_url": "", + "enabled": conf.enabled, + "ever_enabled": getattr(conf, "ever_enabled", False), + "fields": [], + "settings": conf.settings or {}, + "actions": [], + "missing": True, + "trusted": bool(conf.ever_enabled or conf.enabled), + "loaded": False, + "legacy": False, + "logo_url": self._get_logo_url(key), + "source_repo": conf.source_repo_id, + "source_repo_name": conf.source_repo.name if conf.source_repo else None, + "is_official_repo": bool(conf.source_repo and conf.source_repo.is_official), + "slug": conf.slug, + "is_managed": bool(conf.source_repo_id), + "installed_version_is_prerelease": bool( + conf.installed_version_is_prerelease + ), + "update_available": bool( + conf.slug and conf.source_repo_id + and not conf.installed_version_is_prerelease + and repo_latest.get(conf.slug) + and conf.version != repo_latest.get(conf.slug) + ), + "latest_version": repo_latest.get(conf.slug or "", ""), + "deprecated": conf.deprecated, + } + ) + + return plugins + + def get_plugin(self, key: str) -> Optional[LoadedPlugin]: + with self._lock: + 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: + # Attempt a lightweight re-discovery in case the registry was rebuilt + self.discover_plugins(sync_db=False, force_reload=False, use_cache=False) + lp = self.get_plugin(key) + if not lp or not lp.instance: + raise ValueError(f"Plugin '{key}' not found") + + cfg = PluginConfig.objects.get(key=key) + if not cfg.enabled: + raise PermissionError(f"Plugin '{key}' is disabled") + params = params or {} + + context = self._build_context(lp, cfg) + + # Run either via Celery if plugin provides a delayed method, or inline + run_method = getattr(lp.instance, "run", None) + if not callable(run_method): + raise ValueError(f"Plugin '{key}' has no runnable 'run' method") + + 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} + + def stop_plugin(self, key: str, reason: Optional[str] = None) -> bool: + lp = self.get_plugin(key) + if not lp or not lp.instance: + return False + try: + cfg = PluginConfig.objects.get(key=key) + except PluginConfig.DoesNotExist: + return False + if not cfg.enabled: + return False + + context = self._build_context(lp, cfg) + if reason: + context["reason"] = reason + + stop_method = getattr(lp.instance, "stop", None) + if callable(stop_method): + try: + stop_method(context) + return True + except TypeError: + try: + stop_method() + return True + except Exception: + logger.exception("Plugin '%s' stop() failed", key) + return False + except Exception: + logger.exception("Plugin '%s' stop() failed", key) + return False + + run_method = getattr(lp.instance, "run", None) + if callable(run_method): + actions = {a.get("id") for a in (lp.actions or []) if isinstance(a, dict)} + if "stop" in actions: + try: + run_method("stop", {}, context) + return True + except Exception: + logger.exception("Plugin '%s' stop action failed", key) + return False + return False + + def stop_all_plugins(self, reason: Optional[str] = None) -> int: + stopped = 0 + with self._lock: + registry_snapshot = dict(self._registry) + for key in registry_snapshot.keys(): + if self.stop_plugin(key, reason=reason): + stopped += 1 + return stopped + + def _resolve_package_name(self, key: str) -> str: + safe_key = self._safe_module_name(key) + return f"_dispatcharr_plugin_{safe_key}" + + def _resolve_alias_name(self, folder_name: str, path: str) -> Optional[str]: + if not self._is_valid_identifier(folder_name): + return None + if self._is_reserved_module_name(folder_name, path): + return None + return folder_name + + def _is_valid_identifier(self, name: str) -> bool: + return re.match(r"^[A-Za-z_][A-Za-z0-9_]*$", name) is not None + + def _safe_module_name(self, value: str) -> str: + safe = re.sub(r"[^0-9A-Za-z_]", "_", value) + if not safe or safe[0].isdigit(): + safe = f"p_{safe}" + return safe + + def _normalize_fields(self, fields: Any) -> List[Dict[str, Any]]: + try: + from .serializers import PluginFieldSerializer + except Exception: + return fields if isinstance(fields, list) else [] + if not isinstance(fields, list): + return [] + serializer = PluginFieldSerializer(data=fields, many=True) + if serializer.is_valid(): + return serializer.validated_data + normalized: List[Dict[str, Any]] = [] + for item in fields: + item_ser = PluginFieldSerializer(data=item) + if item_ser.is_valid(): + normalized.append(item_ser.validated_data) + else: + logger.warning("Invalid plugin field entry ignored: %s", item_ser.errors) + return normalized + + def _normalize_actions(self, actions: Any) -> List[Dict[str, Any]]: + try: + from .serializers import PluginActionSerializer + except Exception: + return actions if isinstance(actions, list) else [] + if not isinstance(actions, list): + return [] + serializer = PluginActionSerializer(data=actions, many=True) + if serializer.is_valid(): + return serializer.validated_data + normalized: List[Dict[str, Any]] = [] + for item in actions: + item_ser = PluginActionSerializer(data=item) + if item_ser.is_valid(): + normalized.append(item_ser.validated_data) + else: + logger.warning("Invalid plugin action entry ignored: %s", item_ser.errors) + return normalized + + def _merge_settings_with_defaults(self, settings: Dict[str, Any], fields: List[Dict[str, Any]]) -> Dict[str, Any]: + merged = dict(settings or {}) + for field_def in fields or []: + field_id = field_def.get("id") + if not field_id: + continue + if field_id not in merged and "default" in field_def: + merged[field_id] = field_def.get("default") + return merged + + def _build_context(self, lp: LoadedPlugin, cfg: PluginConfig) -> Dict[str, Any]: + settings = self._merge_settings_with_defaults(cfg.settings or {}, lp.fields or []) + return { + "settings": settings, + "logger": logger, + "actions": {a.get("id"): a for a in (lp.actions or [])}, + } + + def _read_manifest(self, path: str) -> tuple[Optional[Dict[str, Any]], bool]: + manifest_path = os.path.join(path, "plugin.json") + if not os.path.isfile(manifest_path): + return None, False + try: + with open(manifest_path, "r", encoding="utf-8") as fh: + data = json.load(fh) + except Exception: + logger.warning("Invalid plugin.json for plugin at %s", path) + return None, False + if not isinstance(data, dict): + logger.warning("plugin.json must be an object for plugin at %s", path) + return None, False + return data, True + + def _get_logo_url(self, key: str, *, path: Optional[str] = None) -> Optional[str]: + logo_path = os.path.join(self.plugins_dir, key, "logo.png") + if path: + logo_path = os.path.join(path, "logo.png") + try: + if os.path.isfile(logo_path): + return f"/api/plugins/plugins/{key}/logo/" + except Exception: + return None + return None + + def _ensure_namespace_package(self, package_name: str, path: str, *, alias: Optional[str] = None) -> None: + existing = sys.modules.get(package_name) + if existing and getattr(existing, "__path__", None): + return + pkg = types.ModuleType(package_name) + pkg.__path__ = [path] + pkg.__package__ = package_name + sys.modules[package_name] = pkg + self._register_alias_module(alias, pkg, path) + + def _register_alias_module( + self, + alias_name: Optional[str], + module: Any, + path: str, + *, + force: bool = False, + ) -> None: + if not alias_name: + return + if self._is_reserved_module_name(alias_name, path): + return + if alias_name in sys.modules: + if not force: + return + self._unload_alias(alias_name) + sys.modules[alias_name] = module + + def _is_reserved_module_name(self, name: str, path: str) -> bool: + if name in sys.builtin_module_names: + return True + if hasattr(sys, "stdlib_module_names") and name in sys.stdlib_module_names: + return True + existing = sys.modules.get(name) + if existing: + origin = getattr(existing, "__file__", None) + if origin is None: + return True + try: + if not os.path.abspath(origin).startswith(os.path.abspath(path)): + return True + except Exception: + return True + try: + spec = importlib.util.find_spec(name) + except Exception: + spec = None + if spec: + if spec.origin is None: + return True + try: + if not os.path.abspath(spec.origin).startswith(os.path.abspath(path)): + return True + except Exception: + return True + return False + + def _load_module_from_path(self, module_name: str, path: str, *, is_package: bool) -> Any: + importlib.invalidate_caches() + spec = importlib.util.spec_from_file_location( + module_name, + path, + submodule_search_locations=[os.path.dirname(path)] if is_package else None, + ) + if spec is None or spec.loader is None: + raise ImportError(f"Could not load spec for {module_name} from {path}") + module = importlib.util.module_from_spec(spec) + sys.modules[module_name] = module + spec.loader.exec_module(module) + return module + + def _get_reload_token(self) -> float: + try: + return os.path.getmtime(self._reload_token_path) + except FileNotFoundError: + return 0.0 + except Exception: + return 0.0 + + def _touch_reload_token(self) -> None: + try: + os.makedirs(self.plugins_dir, exist_ok=True) + with open(self._reload_token_path, "a", encoding="utf-8"): + pass + os.utime(self._reload_token_path, None) + except Exception: + logger.debug("Failed to update plugin reload token", exc_info=True) + + def _unload_package(self, package_name: str) -> None: + if not package_name: + return + for name in list(sys.modules.keys()): + if name == package_name or name.startswith(f"{package_name}."): + sys.modules.pop(name, None) + + def _unload_alias(self, alias_name: str) -> None: + if not alias_name: + return + for name in list(sys.modules.keys()): + if name == alias_name or name.startswith(f"{alias_name}."): + sys.modules.pop(name, None) + + def _unload_path_modules(self, path: str) -> None: + if not path: + return + root = os.path.abspath(path) + for name, module in list(sys.modules.items()): + if not module: + continue + mod_path = getattr(module, "__file__", None) + if mod_path: + try: + abs_path = os.path.abspath(mod_path) + if abs_path == root or abs_path.startswith(f"{root}{os.sep}"): + sys.modules.pop(name, None) + continue + except Exception: + pass + mod_paths = getattr(module, "__path__", None) + if mod_paths: + try: + for pkg_path in mod_paths: + abs_pkg = os.path.abspath(pkg_path) + if abs_pkg == root or abs_pkg.startswith(f"{root}{os.sep}"): + sys.modules.pop(name, None) + break + except Exception: + continue diff --git a/apps/plugins/migrations/0001_initial.py b/apps/plugins/migrations/0001_initial.py new file mode 100644 index 0000000..6de1490 --- /dev/null +++ b/apps/plugins/migrations/0001_initial.py @@ -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)), + ], + ), + ] diff --git a/apps/plugins/migrations/0002_pluginrepo.py b/apps/plugins/migrations/0002_pluginrepo.py new file mode 100644 index 0000000..5c750fc --- /dev/null +++ b/apps/plugins/migrations/0002_pluginrepo.py @@ -0,0 +1,84 @@ +import django.db.models.deletion +from django.db import migrations, models + + +def seed_official_repo(apps, schema_editor): + PluginRepo = apps.get_model("plugins", "PluginRepo") + PluginRepo.objects.get_or_create( + url="https://raw.githubusercontent.com/Dispatcharr/Plugins/releases/manifest.json", + defaults={ + "name": "Dispatcharr Official", + "is_official": True, + "enabled": True, + }, + ) + + +def unseed_official_repo(apps, schema_editor): + PluginRepo = apps.get_model("plugins", "PluginRepo") + PluginRepo.objects.filter(is_official=True).delete() + + +class Migration(migrations.Migration): + + dependencies = [ + ("plugins", "0001_initial"), + ] + + operations = [ + migrations.CreateModel( + name="PluginRepo", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("name", models.CharField(max_length=255)), + ("url", models.URLField(unique=True)), + ("is_official", models.BooleanField(default=False)), + ("enabled", models.BooleanField(default=True)), + ("cached_manifest", models.JSONField(blank=True, default=dict)), + ("last_fetched", models.DateTimeField(blank=True, null=True)), + ("public_key", models.TextField(blank=True, default="")), + ("signature_verified", models.BooleanField(blank=True, default=None, null=True)), + ("last_fetch_status", models.CharField(blank=True, default="", max_length=255)), + ("created_at", models.DateTimeField(auto_now_add=True)), + ("updated_at", models.DateTimeField(auto_now=True)), + ], + options={ + "ordering": ["-is_official", "name"], + }, + ), + migrations.RunPython(seed_official_repo, unseed_official_repo), + migrations.AddField( + model_name="pluginconfig", + name="source_repo", + field=models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="installed_plugins", + to="plugins.pluginrepo", + ), + ), + migrations.AddField( + model_name="pluginconfig", + name="slug", + field=models.CharField(blank=True, default="", max_length=128), + ), + migrations.AddField( + model_name="pluginconfig", + name="installed_version_is_prerelease", + field=models.BooleanField(default=False), + ), + migrations.AddField( + model_name="pluginconfig", + name="deprecated", + field=models.BooleanField(default=False), + ), + ] diff --git a/apps/plugins/migrations/__init__.py b/apps/plugins/migrations/__init__.py new file mode 100644 index 0000000..ade076b --- /dev/null +++ b/apps/plugins/migrations/__init__.py @@ -0,0 +1 @@ +# This file marks the migrations package for the plugins app. diff --git a/apps/plugins/models.py b/apps/plugins/models.py new file mode 100644 index 0000000..f1960fd --- /dev/null +++ b/apps/plugins/models.py @@ -0,0 +1,63 @@ +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) + + # Managed plugin fields (populated when installed from a repo) + source_repo = models.ForeignKey( + "PluginRepo", + null=True, + blank=True, + on_delete=models.SET_NULL, + related_name="installed_plugins", + ) + slug = models.CharField(max_length=128, blank=True, default="") + installed_version_is_prerelease = models.BooleanField(default=False) + deprecated = models.BooleanField(default=False) + + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + + @property + def is_managed(self): + return bool(self.source_repo_id) + + def __str__(self) -> str: + return f"{self.name} ({self.key})" + + +OFFICIAL_REPO_URL = ( + "https://raw.githubusercontent.com/Dispatcharr/Plugins/releases/manifest.json" +) + + +class PluginRepo(models.Model): + """A remote plugin repository manifest URL.""" + + name = models.CharField(max_length=255) + url = models.URLField(unique=True) + is_official = models.BooleanField(default=False) + enabled = models.BooleanField(default=True) + cached_manifest = models.JSONField(default=dict, blank=True) + public_key = models.TextField(blank=True, default="") + signature_verified = models.BooleanField(null=True, blank=True, default=None) + last_fetched = models.DateTimeField(null=True, blank=True) + last_fetch_status = models.CharField(max_length=255, blank=True, default="") + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + + class Meta: + ordering = ["-is_official", "name"] + + def __str__(self) -> str: + return self.name diff --git a/apps/plugins/serializers.py b/apps/plugins/serializers.py new file mode 100644 index 0000000..ed2b57d --- /dev/null +++ b/apps/plugins/serializers.py @@ -0,0 +1,86 @@ +from rest_framework import serializers +from .models import PluginRepo + + +class PluginActionSerializer(serializers.Serializer): + id = serializers.CharField() + label = serializers.CharField() + description = serializers.CharField(required=False, allow_blank=True) + confirm = serializers.JSONField(required=False) + button_label = serializers.CharField(required=False, allow_blank=True) + button_variant = serializers.CharField(required=False, allow_blank=True) + button_color = serializers.CharField(required=False, allow_blank=True) + events = serializers.ListField( + child=serializers.CharField(), required=False, allow_empty=True + ) + + +class PluginFieldOptionSerializer(serializers.Serializer): + value = serializers.CharField() + label = serializers.CharField() + + +class PluginFieldSerializer(serializers.Serializer): + id = serializers.CharField() + label = serializers.CharField(required=False, allow_blank=True) + type = serializers.ChoiceField(choices=["string", "number", "boolean", "select", "text", "info"]) + default = serializers.JSONField(required=False) + help_text = serializers.CharField(required=False, allow_blank=True) + description = serializers.CharField(required=False, allow_blank=True) + placeholder = serializers.CharField(required=False, allow_blank=True) + input_type = serializers.CharField(required=False, allow_blank=True) + min = serializers.FloatField(required=False) + max = serializers.FloatField(required=False) + step = serializers.FloatField(required=False) + value = serializers.CharField(required=False, allow_blank=True) + options = PluginFieldOptionSerializer(many=True, required=False) + + +class PluginSerializer(serializers.Serializer): + key = serializers.CharField() + name = serializers.CharField() + version = serializers.CharField(allow_blank=True) + description = serializers.CharField(allow_blank=True) + author = serializers.CharField(required=False, allow_blank=True) + help_url = serializers.CharField(required=False, allow_blank=True) + enabled = serializers.BooleanField() + fields = PluginFieldSerializer(many=True) + settings = serializers.JSONField() + actions = PluginActionSerializer(many=True) + source_repo = serializers.IntegerField(required=False, allow_null=True) + slug = serializers.CharField(required=False, allow_blank=True) + is_managed = serializers.BooleanField(required=False) + deprecated = serializers.BooleanField(required=False) + + +class PluginRepoSerializer(serializers.ModelSerializer): + registry_url = serializers.SerializerMethodField() + plugin_count = serializers.SerializerMethodField() + + class Meta: + model = PluginRepo + fields = [ + "id", + "name", + "url", + "is_official", + "enabled", + "public_key", + "signature_verified", + "registry_url", + "plugin_count", + "last_fetched", + "last_fetch_status", + "created_at", + "updated_at", + ] + read_only_fields = ["id", "name", "is_official", "signature_verified", "registry_url", "plugin_count", "last_fetched", "last_fetch_status", "created_at", "updated_at"] + + def get_registry_url(self, obj): + manifest = (obj.cached_manifest or {}).get("manifest", obj.cached_manifest or {}) + return manifest.get("registry_url", "") or "" + + def get_plugin_count(self, obj): + manifest = (obj.cached_manifest or {}).get("manifest", obj.cached_manifest or {}) + plugins = manifest.get("plugins", []) + return len(plugins) if isinstance(plugins, list) else 0 diff --git a/apps/plugins/tasks.py b/apps/plugins/tasks.py new file mode 100644 index 0000000..6bd1544 --- /dev/null +++ b/apps/plugins/tasks.py @@ -0,0 +1,33 @@ +import logging +from celery import shared_task + +logger = logging.getLogger(__name__) + +PLUGIN_REPO_REFRESH_TASK_NAME = "plugin-repo-refresh-task" + + +@shared_task +def refresh_plugin_repos(): + """Refresh cached manifests for all enabled plugin repos.""" + from .models import PluginRepo + from .api_views import _fetch_manifest, _save_fetched_manifest_to_repo, _unmanage_dropped_slugs + from django.utils import timezone + + repos = PluginRepo.objects.filter(enabled=True) + for repo in repos: + try: + key_text = repo.public_key if not repo.is_official else None + data, verified = _fetch_manifest(repo.url, public_key_text=key_text) + err = _save_fetched_manifest_to_repo(repo, data, verified) + if err: + logger.warning("Skipping repo '%s': %s", repo.name, err) + continue + _unmanage_dropped_slugs(repo, data) + logger.info("Refreshed plugin repo '%s'", repo.name) + except Exception as e: + resp = getattr(e, 'response', None) + status_str = str(resp.status_code) if resp is not None and hasattr(resp, 'status_code') else type(e).__name__ + repo.last_fetch_status = status_str[:255] + repo.last_fetched = timezone.now() + repo.save(update_fields=["last_fetch_status", "last_fetched", "updated_at"]) + logger.warning("Failed to refresh plugin repo '%s': %s", repo.name, e) diff --git a/apps/proxy/__init__.py b/apps/proxy/__init__.py new file mode 100644 index 0000000..3310e88 --- /dev/null +++ b/apps/proxy/__init__.py @@ -0,0 +1 @@ +"""Proxy application package""" \ No newline at end of file diff --git a/apps/proxy/apps.py b/apps/proxy/apps.py new file mode 100644 index 0000000..b33d46c --- /dev/null +++ b/apps/proxy/apps.py @@ -0,0 +1,17 @@ +import sys +from django.apps import AppConfig + +class ProxyConfig(AppConfig): + default_auto_field = 'django.db.models.BigAutoField' + name = 'apps.proxy' + verbose_name = "Stream Proxies" + + def ready(self): + """Initialize proxy servers when Django starts""" + if 'manage.py' not in sys.argv: + from .hls_proxy.server import ProxyServer as HLSProxyServer + from .ts_proxy.server import ProxyServer as TSProxyServer + + # Initialize proxy servers (TS uses singleton to prevent duplicate instances) + self.hls_proxy = HLSProxyServer() + self.ts_proxy = TSProxyServer.get_instance() diff --git a/apps/proxy/config.py b/apps/proxy/config.py new file mode 100644 index 0000000..4de92fa --- /dev/null +++ b/apps/proxy/config.py @@ -0,0 +1,158 @@ +"""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 + CHUNK_SIZE = 8192 + CLIENT_POLL_INTERVAL = 0.1 + MAX_RETRIES = 3 + RETRY_WAIT_INTERVAL = 0.5 # seconds to wait between retries + CONNECTION_TIMEOUT = 10 # seconds to wait for initial connection + MAX_STREAM_SWITCHES = 10 # Maximum number of stream switch attempts before giving up + BUFFER_CHUNK_SIZE = 188 * 1361 # ~256KB + 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 (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 + 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, + "redis_chunk_ttl": 60, + "channel_shutdown_delay": 0, + "channel_init_grace_period": 5, + "new_client_behind_seconds": 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""" + settings = cls.get_proxy_settings() + return settings.get("redis_chunk_ttl", 60) + + @property + def REDIS_CHUNK_TTL(self): + return self.get_redis_chunk_ttl() + +class HLSConfig(BaseConfig): + MIN_SEGMENTS = 12 + MAX_SEGMENTS = 16 + WINDOW_SIZE = 12 + INITIAL_SEGMENTS = 3 + INITIAL_CONNECTION_WINDOW = 10 + CLIENT_TIMEOUT_FACTOR = 1.5 + CLIENT_CLEANUP_INTERVAL = 10 + FIRST_SEGMENT_TIMEOUT = 5.0 + INITIAL_BUFFER_SECONDS = 25.0 + MAX_INITIAL_SEGMENTS = 10 + BUFFER_READY_TIMEOUT = 30.0 + +class TSConfig(BaseConfig): + """Configuration settings for TS proxy""" + + # Buffer settings + INITIAL_BEHIND_CHUNKS = 4 # How many chunks behind to start a client (4 chunks = ~1MB) + CHUNK_BATCH_SIZE = 5 # How many chunks to fetch in one batch + NEW_CLIENT_BEHIND_SECONDS = 5 # Start new clients this many seconds behind live (0 = start at live) + KEEPALIVE_INTERVAL = 0.5 # Seconds between keepalive packets when at buffer head + # Chunk read timeout + CHUNK_TIMEOUT = 5 # Seconds to wait for each chunk read + + # Streaming settings + TARGET_BITRATE = 8000000 # Target bitrate (8 Mbps) + STREAM_TIMEOUT = 20 # Disconnect after this many seconds of no data + HEALTH_CHECK_INTERVAL = 5 # Check stream health every N seconds + + # Resource management + CLEANUP_INTERVAL = 60 # Check for inactive channels every 60 seconds + + # Client tracking settings + 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 = 5 # How often to send client heartbeats (seconds) + GHOST_CLIENT_MULTIPLIER = 10.0 # How many heartbeat intervals before client considered ghost (10 = 50s, must exceed STREAM_TIMEOUT + FAILOVER_GRACE_PERIOD = 40s) + CLIENT_WAIT_TIMEOUT = 30 # Seconds to wait for client to connect + + # Stream health and recovery settings + MAX_HEALTH_RECOVERY_ATTEMPTS = 2 # Maximum times to attempt recovery for a single stream + MAX_RECONNECT_ATTEMPTS = 3 # Maximum reconnects to try before switching streams + MIN_STABLE_TIME_BEFORE_RECONNECT = 30 # Minimum seconds a stream must be stable to try reconnect + FAILOVER_GRACE_PERIOD = 20 # Extra time (seconds) to allow for stream switching before disconnecting clients + URL_SWITCH_TIMEOUT = 20 # Max time allowed for a stream switch operation + MAX_KEEPALIVE_DURATION = 300 # Keepalive packets prevent _is_timeout() from firing, so without this a permanently failed stream holds clients open indefinitely. + + + + # Database-dependent settings with fallbacks + @classmethod + def get_channel_shutdown_delay(cls): + """Get channel shutdown delay from database or default""" + settings = cls.get_proxy_settings() + return settings.get("channel_shutdown_delay", 0) + + @classmethod + def get_buffering_timeout(cls): + """Get buffering timeout from database or default""" + settings = cls.get_proxy_settings() + return settings.get("buffering_timeout", 15) + + @classmethod + def get_buffering_speed(cls): + """Get buffering speed threshold from database or default""" + settings = cls.get_proxy_settings() + return settings.get("buffering_speed", 1.0) + + @classmethod + def get_channel_init_grace_period(cls): + """Get channel init grace period from database or default""" + settings = cls.get_proxy_settings() + return settings.get("channel_init_grace_period", 5) + + # Dynamic property access for these settings + @property + def CHANNEL_SHUTDOWN_DELAY(self): + return self.get_channel_shutdown_delay() + + @property + def BUFFERING_TIMEOUT(self): + return self.get_buffering_timeout() + + @property + def BUFFERING_SPEED(self): + return self.get_buffering_speed() + + @property + def CHANNEL_INIT_GRACE_PERIOD(self): + return self.get_channel_init_grace_period() + + + diff --git a/apps/proxy/hls_proxy/__init__.py b/apps/proxy/hls_proxy/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/proxy/hls_proxy/server.py b/apps/proxy/hls_proxy/server.py new file mode 100644 index 0000000..348dbaa --- /dev/null +++ b/apps/proxy/hls_proxy/server.py @@ -0,0 +1,1106 @@ +""" +HLS Proxy Server with Advanced Stream Switching Support +This proxy handles HLS live streams with support for: +- Stream switching with proper discontinuity handling +- Buffer management +- Segment validation +- Connection pooling and reuse +""" + +import requests +import threading +import logging +import m3u8 +import time +from urllib.parse import urlparse, urljoin +import argparse +from typing import Optional, Dict, List, Set, Deque +import sys +import os +from apps.proxy.config import HLSConfig as Config + +# Global state management +manifest_buffer = None # Stores current manifest content +segment_buffers = {} # Maps sequence numbers to segment data +buffer_lock = threading.Lock() # Synchronizes access to buffers + +class StreamBuffer: + """ + Manages buffering of stream segments with thread-safe access. + + Attributes: + buffer (Dict[int, bytes]): Maps sequence numbers to segment data + lock (threading.Lock): Thread safety for buffer access + + Features: + - Thread-safe segment storage and retrieval + - Automatic cleanup of old segments + - Sequence number based indexing + """ + + def __init__(self): + self.buffer: Dict[int, bytes] = {} # Maps sequence numbers to segment data + self.lock: threading.Lock = threading.Lock() + + def __getitem__(self, key: int) -> Optional[bytes]: + """Get segment data by sequence number""" + return self.buffer.get(key) + + def __setitem__(self, key: int, value: bytes): + """Store segment data by sequence number""" + self.buffer[key] = value + # Cleanup old segments if we exceed MAX_SEGMENTS + if len(self.buffer) > Config.MAX_SEGMENTS: + keys = sorted(self.buffer.keys()) + # Keep the most recent MAX_SEGMENTS + to_remove = keys[:-Config.MAX_SEGMENTS] + for k in to_remove: + del self.buffer[k] + + def __contains__(self, key: int) -> bool: + """Check if sequence number exists in buffer""" + return key in self.buffer + + def keys(self) -> List[int]: + """Get list of available sequence numbers""" + return list(self.buffer.keys()) + + def cleanup(self, keep_sequences: List[int]): + """Remove segments not in keep list""" + for seq in list(self.buffer.keys()): + if seq not in keep_sequences: + del self.buffer[seq] + +class ClientManager: + """Manages client connections and activity tracking""" + + def __init__(self): + self.last_activity = {} # Maps client IPs to last activity timestamp + self.lock = threading.Lock() + + def record_activity(self, client_ip: str): + """Record client activity timestamp""" + with self.lock: + prev_time = self.last_activity.get(client_ip) + current_time = time.time() + self.last_activity[client_ip] = current_time + if not prev_time: + logging.info(f"New client connected: {client_ip}") + else: + logging.debug(f"Client activity: {client_ip}") + + def cleanup_inactive(self, timeout: float) -> bool: + """Remove inactive clients""" + now = time.time() + with self.lock: + active_clients = { + ip: last_time + for ip, last_time in self.last_activity.items() + if (now - last_time) < timeout + } + + removed = set(self.last_activity.keys()) - set(active_clients.keys()) + if removed: + for ip in removed: + inactive_time = now - self.last_activity[ip] + logging.warning(f"Client {ip} inactive for {inactive_time:.1f}s, removing") + + self.last_activity = active_clients + if active_clients: + oldest = min(now - t for t in active_clients.values()) + logging.debug(f"Active clients: {len(active_clients)}, oldest activity: {oldest:.1f}s ago") + + return len(active_clients) == 0 + +class StreamManager: + """ + Manages HLS stream state and switching logic. + + Attributes: + current_url (str): Current stream URL + channel_id (str): Unique channel identifier + running (bool): Stream activity flag + next_sequence (int): Next sequence number to assign + buffered_sequences (set): Currently buffered sequence numbers + source_changes (set): Sequences where stream source changed + + Features: + - Stream URL management + - Sequence number assignment + - Discontinuity tracking + - Thread coordination + - Buffer state management + """ + def __init__(self, initial_url: str, channel_id: str, user_agent: Optional[str] = None): + # Stream state + self.current_url = initial_url + self.channel_id = channel_id + self.user_agent = user_agent or Config.DEFAULT_USER_AGENT + self.running = True + self.switching_stream = False + + # Sequence tracking + self.next_sequence = 0 + self.highest_sequence = 0 + self.buffered_sequences = set() + self.downloaded_sources = {} + self.segment_durations = {} + + # Source tracking + self.current_source = None + self.source_changes = set() + self.stream_switch_count = 0 + + # Threading + self.fetcher = None + self.fetch_thread = None + self.url_changed = threading.Event() + + # Add manifest info + self.target_duration = 10.0 # Default, will be updated from manifest + self.manifest_version = 3 # Default, will be updated from manifest + + self.cleanup_thread = None + self.cleanup_running = False # New flag to control cleanup thread + self.cleanup_enabled = False # New flag to control when cleanup starts + self.initialization_time = time.time() # Add initialization timestamp + self.first_client_connected = False + self.cleanup_started = False # New flag to track cleanup state + + # Add client manager reference + self.client_manager = None + self.proxy_server = None # Reference to proxy server for cleanup + self.cleanup_thread = None + self.cleanup_interval = Config.CLIENT_CLEANUP_INTERVAL + + logging.info(f"Initialized stream manager for channel {channel_id}") + + # Buffer state tracking + self.buffer_ready = threading.Event() + self.buffered_duration = 0.0 + self.initial_buffering = True + + def update_url(self, new_url: str) -> bool: + """ + Handle stream URL changes with proper discontinuity marking. + + Args: + new_url: New stream URL to switch to + + Returns: + bool: True if URL changed, False if unchanged + + Side effects: + - Sets switching_stream flag + - Updates current_url + - Maintains sequence numbering + - Marks discontinuity point + - Signals fetch thread + """ + if new_url != self.current_url: + with buffer_lock: + self.switching_stream = True + self.current_url = new_url + + # Continue sequence numbering from last sequence + if self.buffered_sequences: + self.next_sequence = max(self.buffered_sequences) + 1 + + # Mark discontinuity at next sequence + self.source_changes.add(self.next_sequence) + + logging.info(f"Stream switch - next sequence will start at {self.next_sequence}") + + # Clear state but maintain sequence numbers + self.downloaded_sources.clear() + self.segment_durations.clear() + self.current_source = None + + # Signal thread to switch URL + self.url_changed.set() + + return True + return False + + def get_next_sequence(self, source_id: str) -> Optional[int]: + """ + Assign sequence numbers to segments with source change detection. + + Args: + source_id: Unique identifier for segment source + + Returns: + int: Next available sequence number + None: If segment already downloaded + + Side effects: + - Updates buffered sequences set + - Tracks source changes for discontinuity + - Maintains sequence numbering + """ + if source_id in self.downloaded_sources: + return None + + seq = self.next_sequence + while (seq in self.buffered_sequences): + seq += 1 + + # Track source changes for discontinuity markers + source_prefix = source_id.split('_')[0] + if not self.switching_stream and self.current_source and self.current_source != source_prefix: + self.source_changes.add(seq) + logging.debug(f"Source change detected at sequence {seq}") + self.current_source = source_prefix + + # Update tracking + self.downloaded_sources[source_id] = seq + self.buffered_sequences.add(seq) + self.next_sequence = seq + 1 + self.highest_sequence = max(self.highest_sequence, seq) + + return seq + + def _fetch_loop(self): + """Background thread for continuous stream fetching""" + while self.running: + try: + fetcher = StreamFetcher(self, self.buffer) + fetch_stream(fetcher, self.url_changed, self.next_sequence) + except Exception as e: + logging.error(f"Stream error: {e}") + time.sleep(5) # Wait before retry + + self.url_changed.clear() + + def start(self): + """Start the background fetch thread""" + if not self.fetch_thread or not self.fetch_thread.is_alive(): + self.running = True + self.fetch_thread = threading.Thread( + target=self._fetch_loop, + name="StreamFetcher", + daemon=True # Thread will exit when main program does + ) + self.fetch_thread.start() + logging.info("Stream manager started") + + def stop(self): + """Stop the stream manager and cleanup resources""" + self.running = False + self.cleanup_running = False + if self.fetch_thread and self.fetch_thread.is_alive(): + self.url_changed.set() + self.fetch_thread.join(timeout=5) + logging.info(f"Stream manager stopped for channel {self.channel_id}") + + def enable_cleanup(self): + """Enable cleanup after first client connects""" + if not self.first_client_connected: + self.first_client_connected = True + logging.info(f"First client connected to channel {self.channel_id}") + + def start_cleanup_thread(self): + """Start background thread for client activity monitoring""" + def cleanup_loop(): + # Wait for initial connection window + start_time = time.time() + while self.cleanup_running and (time.time() - start_time) < Config.INITIAL_CONNECTION_WINDOW: + if self.first_client_connected: + break + time.sleep(1) + + if not self.first_client_connected: + logging.info(f"Channel {self.channel_id}: No clients connected within {Config.INITIAL_CONNECTION_WINDOW}s window") + self.proxy_server.stop_channel(self.channel_id) + return + + # Normal client activity monitoring + while self.cleanup_running and self.running: + try: + timeout = self.target_duration * Config.CLIENT_TIMEOUT_FACTOR + if self.client_manager.cleanup_inactive(timeout): + logging.info(f"Channel {self.channel_id}: All clients disconnected for {timeout:.1f}s") + self.proxy_server.stop_channel(self.channel_id) + break + except Exception as e: + logging.error(f"Cleanup error: {e}") + if "cannot join current thread" not in str(e): + time.sleep(Config.CLIENT_CLEANUP_INTERVAL) + time.sleep(Config.CLIENT_CLEANUP_INTERVAL) + + if not self.cleanup_started: + self.cleanup_started = True + self.cleanup_running = True + self.cleanup_thread = threading.Thread( + target=cleanup_loop, + name=f"Cleanup-{self.channel_id}", + daemon=True + ) + self.cleanup_thread.start() + logging.info(f"Started cleanup thread for channel {self.channel_id}") + +class StreamFetcher: + """ + Handles HTTP requests for stream segments with connection pooling. + + Attributes: + manager (StreamManager): Associated stream manager instance + buffer (StreamBuffer): Buffer for storing segments + session (requests.Session): Persistent HTTP session + redirect_cache (dict): Cache for redirect responses + + Features: + - Connection pooling and reuse + - Redirect caching + - Rate limiting + - Automatic retries + - Host fallback + """ + def __init__(self, manager: StreamManager, buffer: StreamBuffer): + self.manager = manager + self.buffer = buffer + self.stream_url = manager.current_url + self.session = requests.Session() + + # Configure session headers + self.session.headers.update({ + 'User-Agent': manager.user_agent, + 'Connection': 'keep-alive' + }) + + # Set up connection pooling + adapter = requests.adapters.HTTPAdapter( + pool_connections=2, # Number of connection pools + pool_maxsize=4, # Connections per pool + max_retries=3, # Auto-retry failed requests + pool_block=False # Don't block when pool is full + ) + + # Apply adapter to both HTTP and HTTPS + self.session.mount('http://', adapter) + self.session.mount('https://', adapter) + + # Request optimization + self.last_request_time = 0 + self.min_request_interval = 0.05 # Minimum time between requests + self.last_host = None # Cache last successful host + self.redirect_cache = {} # Cache redirect responses + self.redirect_cache_limit = 1000 + + def cleanup_redirect_cache(self): + """Remove old redirect cache entries""" + if len(self.redirect_cache) > self.redirect_cache_limit: + self.redirect_cache.clear() + + def get_base_host(self, url: str) -> str: + """ + Extract base host from URL. + + Args: + url: Full URL to parse + + Returns: + str: Base host in format 'scheme://hostname' + + Example: + 'http://example.com/path' -> 'http://example.com' + """ + try: + parsed = urlparse(url) + return f"{parsed.scheme}://{parsed.netloc}" + except Exception as e: + logging.error(f"Error extracting base host: {e}") + return url + + def download(self, url: str) -> tuple[bytes, str]: + """ + Download content with connection reuse and redirect handling. + + Args: + url: URL to download from + + Returns: + tuple containing: + bytes: Downloaded content + str: Final URL after any redirects + + Features: + - Connection pooling/reuse + - Redirect caching + - Rate limiting + - Host fallback on failure + - Automatic retries + """ + now = time.time() + wait_time = self.last_request_time + self.min_request_interval - now + if (wait_time > 0): + time.sleep(wait_time) + + try: + # Use cached redirect if available + if url in self.redirect_cache: + logging.debug(f"Using cached redirect for {url}") + final_url = self.redirect_cache[url] + response = self.session.get(final_url, timeout=10) + else: + response = self.session.get(url, allow_redirects=True, timeout=10) + if response.history: # Cache redirects + logging.debug(f"Caching redirect for {url} -> {response.url}") + self.redirect_cache[url] = response.url + + self.last_request_time = time.time() + + if response.status_code == 200: + self.last_host = self.get_base_host(response.url) + + return response.content, response.url + + except Exception as e: + logging.error(f"Download error: {e}") + if self.last_host and not url.startswith(self.last_host): + # Use urljoin to handle path resolution + new_url = urljoin(self.last_host + '/', url.split('://')[-1].split('/', 1)[-1]) + logging.debug(f"Retrying with last host: {new_url}") + return self.download(new_url) + raise + + def fetch_loop(self): + """Main fetch loop for stream data""" + retry_delay = 1 + max_retry_delay = 8 + last_manifest_time = 0 + downloaded_segments = set() # Track downloaded segment URIs + + while self.manager.running: + try: + current_time = time.time() + + # Check manifest update timing + if last_manifest_time: + time_since_last = current_time - last_manifest_time + if time_since_last < (self.manager.target_duration * 0.5): + time.sleep(self.manager.target_duration * 0.5 - time_since_last) + continue + + # Get manifest data + manifest_data, final_url = self.download(self.manager.current_url) + manifest = m3u8.loads(manifest_data.decode()) + + # Update manifest info + if manifest.target_duration: + self.manager.target_duration = float(manifest.target_duration) + if manifest.version: + self.manager.manifest_version = manifest.version + + if not manifest.segments: + continue + + if self.manager.initial_buffering: + segments_to_fetch = [] + current_duration = 0.0 + successful_downloads = 0 # Initialize counter here + + # Start from the end of the manifest + for segment in reversed(manifest.segments): + current_duration += float(segment.duration) + segments_to_fetch.append(segment) + + # Stop when we have enough duration or hit max segments + if (current_duration >= Config.INITIAL_BUFFER_SECONDS or + len(segments_to_fetch) >= Config.MAX_INITIAL_SEGMENTS): + break + + # Reverse back to chronological order + segments_to_fetch.reverse() + + # Download initial segments + for segment in segments_to_fetch: + try: + segment_url = urljoin(final_url, segment.uri) + segment_data, _ = self.download(segment_url) + + validation = verify_segment(segment_data) + if validation.get('valid', False): + with self.buffer.lock: + seq = self.manager.next_sequence + self.buffer[seq] = segment_data + duration = float(segment.duration) + self.manager.segment_durations[seq] = duration + self.manager.buffered_duration += duration + self.manager.next_sequence += 1 + successful_downloads += 1 + logging.debug(f"Buffered initial segment {seq} (source: {segment.uri}, duration: {duration}s)") + except Exception as e: + logging.error(f"Initial segment download error: {e}") + + # Only mark buffer ready if we got some segments + if successful_downloads > 0: + self.manager.initial_buffering = False + self.manager.buffer_ready.set() + logging.info(f"Initial buffer ready with {successful_downloads} segments " + f"({self.manager.buffered_duration:.1f}s of content)") + continue + + # Normal operation - get latest segment if we haven't already + latest_segment = manifest.segments[-1] + if (latest_segment.uri in downloaded_segments): + # Wait for next manifest update + time.sleep(self.manager.target_duration * 0.5) + continue + + try: + segment_url = urljoin(final_url, latest_segment.uri) + segment_data, _ = self.download(segment_url) + + # Try several times if segment validation fails + max_retries = 3 + retry_count = 0 + while retry_count < max_retries: + verification = verify_segment(segment_data) + if verification.get('valid', False): + break + logging.warning(f"Invalid segment, retry {retry_count + 1}/{max_retries}: {verification.get('error')}") + time.sleep(0.5) # Short delay before retry + segment_data, _ = self.download(segment_url) + retry_count += 1 + + if verification.get('valid', False): + with self.buffer.lock: + seq = self.manager.next_sequence + self.buffer[seq] = segment_data + self.manager.segment_durations[seq] = float(latest_segment.duration) + self.manager.next_sequence += 1 + downloaded_segments.add(latest_segment.uri) + logging.debug(f"Stored segment {seq} (source: {latest_segment.uri}, " + f"duration: {latest_segment.duration}s, " + f"size: {len(segment_data)})") + + # Update timing + last_manifest_time = time.time() + retry_delay = 1 # Reset retry delay on success + else: + logging.error(f"Segment validation failed after {max_retries} retries") + + except Exception as e: + logging.error(f"Segment download error: {e}") + continue + + # Cleanup old segment URIs from tracking + if len(downloaded_segments) > 100: + downloaded_segments.clear() + + except Exception as e: + logging.error(f"Fetch error: {e}") + time.sleep(retry_delay) + retry_delay = min(retry_delay * 2, max_retry_delay) + +def get_segment_sequence(segment_uri: str) -> Optional[int]: + """ + Extract sequence number from segment URI pattern. + + Args: + segment_uri: Segment filename or path + + Returns: + int: Extracted sequence number if found + None: If no valid sequence number can be extracted + + Handles common patterns like: + - Numerical sequences (e.g., segment_1234.ts) + - Complex patterns with stream IDs (e.g., stream_123_456.ts) + """ + + try: + # Try numerical sequence (e.g., 1038_3693.ts) + if '_' in segment_uri: + return int(segment_uri.split('_')[-1].split('.')[0]) + return None + except ValueError: + return None + +# Update verify_segment with more thorough checks +def verify_segment(data: bytes) -> dict: + """ + Verify MPEG-TS segment integrity and structure. + + Args: + data: Raw segment data bytes + + Returns: + dict containing: + valid (bool): True if segment passes all checks + packets (int): Number of valid packets found + size (int): Total segment size in bytes + error (str): Description if validation fails + + Checks: + - Minimum size requirements + - Packet size alignment + - Sync byte presence + - Transport error indicators + """ + + # Check minimum size + if len(data) < 188: + return {'valid': False, 'error': 'Segment too short'} + + # Verify segment size is multiple of packet size + if len(data) % 188 != 0: + return {'valid': False, 'error': 'Invalid segment size'} + + valid_packets = 0 + total_packets = len(data) // 188 + + # Scan all packets in segment + for i in range(0, len(data), 188): + packet = data[i:i+188] + + # Check packet completeness + if len(packet) != 188: + return {'valid': False, 'error': 'Incomplete packet'} + + # Verify sync byte + if packet[0] != 0x47: + return {'valid': False, 'error': f'Invalid sync byte at offset {i}'} + + # Check transport error indicator + if packet[1] & 0x80: + return {'valid': False, 'error': 'Transport error indicator set'} + + valid_packets += 1 + + return { + 'valid': True, + 'packets': valid_packets, + 'size': len(data) + } + +def fetch_stream(fetcher: StreamFetcher, stop_event: threading.Event, start_sequence: int = 0): + """ + Main streaming function that handles manifest updates and segment downloads. + + Args: + fetcher: StreamFetcher instance to handle HTTP requests + stop_event: Threading event to signal when to stop fetching + start_sequence: Initial sequence number to start from + + The function implements the core HLS fetching logic: + - Fetches and parses manifest files + - Downloads new segments when they become available + - Handles stream switches with proper discontinuity marking + - Maintains buffer state and segment sequence numbering + """ + # Remove global stream_manager reference + retry_delay = 1 + max_retry_delay = 8 + last_segment_time = 0 + buffer_initialized = False + manifest_update_needed = True + segment_duration = None + + while not stop_event.is_set(): + try: + now = time.time() + + # Only update manifest when it's time for next segment + should_update = ( + manifest_update_needed or + not segment_duration or + (last_segment_time and (now - last_segment_time) >= segment_duration * 0.8) + ) + + if should_update: + manifest_data, final_url = fetcher.download(fetcher.stream_url) + manifest = m3u8.loads(manifest_data.decode()) + + if not manifest.segments: + continue + + with buffer_lock: + manifest_content = manifest_data.decode() + new_segments = {} + + if fetcher.manager.switching_stream: # Use fetcher.manager instead of stream_manager + # Stream switch - only get latest segment + manifest_segments = [manifest.segments[-1]] + seq_start = fetcher.manager.next_sequence + max_segments = 1 + logging.debug(f"Processing stream switch - getting latest segment at sequence {seq_start}") + elif not buffer_initialized: + # Initial buffer + manifest_segments = manifest.segments[-Config.INITIAL_SEGMENTS:] + seq_start = fetcher.manager.next_sequence + max_segments = Config.INITIAL_SEGMENTS + logging.debug(f"Starting initial buffer at sequence {seq_start}") + else: + # Normal operation + manifest_segments = [manifest.segments[-1]] + seq_start = fetcher.manager.next_sequence + max_segments = 1 + + # Map segments + segments_mapped = 0 + for segment in manifest_segments: + if segments_mapped >= max_segments: + break + + source_id = segment.uri.split('/')[-1].split('.')[0] + next_seq = fetcher.manager.get_next_sequence(source_id) + + if next_seq is not None: + duration = float(segment.duration) + new_segments[next_seq] = { + 'uri': segment.uri, + 'duration': duration, + 'source_id': source_id + } + fetcher.manager.segment_durations[next_seq] = duration + segments_mapped += 1 + + manifest_buffer = manifest_content + + # Download segments + for sequence_id, segment_info in new_segments.items(): + try: + segment_url = f"{fetcher.last_host}{segment_info['uri']}" + logging.debug(f"Downloading {segment_info['uri']} as segment {sequence_id}.ts " + f"(source: {segment_info['source_id']}, duration: {segment_info['duration']:.3f}s)") + + segment_data, _ = fetcher.download(segment_url) + validation = verify_segment(segment_data) + + if validation.get('valid', False): + with buffer_lock: + segment_buffers[sequence_id] = segment_data + logging.debug(f"Downloaded and verified segment {sequence_id} (packets: {validation['packets']})") + + if fetcher.manager.switching_stream: + fetcher.manager.switching_stream = False + stop_event.set() # Force fetcher restart with new URL + break + elif not buffer_initialized and len(segment_buffers) >= Config.INITIAL_SEGMENTS: + buffer_initialized = True + manifest_update_needed = True + break + except Exception as e: + logging.error(f"Segment download error: {e}") + continue + + else: + # Short sleep to prevent CPU spinning + threading.Event().wait(0.1) + + except Exception as e: + logging.error(f"Manifest error: {e}") + threading.Event().wait(retry_delay) + retry_delay = min(retry_delay * 2, max_retry_delay) + manifest_update_needed = True + +class ProxyServer: + """Manages HLS proxy server instance""" + + def __init__(self, user_agent: Optional[str] = None): + self.stream_managers: Dict[str, StreamManager] = {} + self.stream_buffers: Dict[str, StreamBuffer] = {} + self.client_managers: Dict[str, ClientManager] = {} + self.fetch_threads: Dict[str, threading.Thread] = {} + self.user_agent: str = user_agent or Config.DEFAULT_USER_AGENT + + def initialize_channel(self, url: str, channel_id: str) -> None: + """Initialize a new channel stream""" + if channel_id in self.stream_managers: + self.stop_channel(channel_id) + + self.stream_managers[channel_id] = StreamManager( + url, + channel_id, + user_agent=self.user_agent + ) + self.stream_buffers[channel_id] = StreamBuffer() + self.client_managers[channel_id] = ClientManager() + + # Set up cleanup references + self.stream_managers[channel_id].client_manager = self.client_managers[channel_id] + self.stream_managers[channel_id].proxy_server = self + + fetcher = StreamFetcher( + self.stream_managers[channel_id], + self.stream_buffers[channel_id] + ) + + self.fetch_threads[channel_id] = threading.Thread( + target=fetcher.fetch_loop, + name=f"StreamFetcher-{channel_id}", + daemon=True + ) + self.fetch_threads[channel_id].start() + + # Start cleanup monitoring + self.stream_managers[channel_id].start_cleanup_thread() + logging.info(f"Initialized channel {channel_id} with URL {url}") + + def stop_channel(self, channel_id: str) -> None: + """Stop and cleanup a channel""" + if channel_id in self.stream_managers: + logging.info(f"Stopping channel {channel_id}") + try: + # Stop the stream manager + self.stream_managers[channel_id].stop() + + # Wait for fetch thread to finish + if channel_id in self.fetch_threads: + self.fetch_threads[channel_id].join(timeout=5) + if self.fetch_threads[channel_id].is_alive(): + logging.warning(f"Fetch thread for channel {channel_id} did not stop cleanly") + except Exception as e: + logging.error(f"Error stopping channel {channel_id}: {e}") + finally: + self._cleanup_channel(channel_id) + + def _cleanup_channel(self, channel_id: str) -> None: + """Remove channel resources""" + for collection in [self.stream_managers, self.stream_buffers, + self.client_managers, self.fetch_threads]: + collection.pop(channel_id, None) + + def shutdown(self) -> None: + """Stop all channels and cleanup""" + for channel_id in list(self.stream_managers.keys()): + self.stop_channel(channel_id) + + # Remove Flask-specific routing + def _setup_routes(self) -> None: + pass + + # Update methods to return data instead of Flask Response objects + def stream_endpoint(self, channel_id: str): + if channel_id not in self.stream_managers: + return 'Channel not found', 404 + + manager = self.stream_managers[channel_id] + + # Wait for initial buffer + if not manager.buffer_ready.wait(Config.BUFFER_READY_TIMEOUT): + logging.error(f"Timeout waiting for initial buffer for channel {channel_id}") + return 'Initial buffer not ready', 503 + + try: + if (channel_id not in self.stream_managers) or (not self.stream_managers[channel_id].running): + return 'Channel not found', 404 + + manager = self.stream_managers[channel_id] + buffer = self.stream_buffers[channel_id] + + # Record client activity and enable cleanup + client_ip = request.remote_addr + manager.enable_cleanup() + self.client_managers[channel_id].record_activity(client_ip) + + # Wait for first segment with timeout + start_time = time.time() + while True: + with buffer.lock: + available = sorted(buffer.keys()) + if available: + break + + if time.time() - start_time > Config.FIRST_SEGMENT_TIMEOUT: + logging.warning(f"Timeout waiting for first segment for channel {channel_id}") + return 'No segments available', 503 + + time.sleep(0.1) # Short sleep to prevent CPU spinning + + # Rest of manifest generation code... + with buffer.lock: + max_seq = max(available) + # Find the first segment after any discontinuity + discontinuity_start = min(available) + for seq in available: + if seq in manager.source_changes: + discontinuity_start = seq + break + + # Calculate window bounds starting from discontinuity + if len(available) <= Config.INITIAL_SEGMENTS: + min_seq = discontinuity_start + else: + min_seq = max( + discontinuity_start, + max_seq - Config.WINDOW_SIZE + 1 + ) + + # Build manifest with proper tags + new_manifest = ['#EXTM3U'] + new_manifest.append(f'#EXT-X-VERSION:{manager.manifest_version}') + new_manifest.append(f'#EXT-X-MEDIA-SEQUENCE:{min_seq}') + new_manifest.append(f'#EXT-X-TARGETDURATION:{int(manager.target_duration)}') + + # Filter segments within window + window_segments = [s for s in available if min_seq <= s <= max_seq] + + # Add segments with discontinuity handling + for seq in window_segments: + if seq in manager.source_changes: + new_manifest.append('#EXT-X-DISCONTINUITY') + logging.debug(f"Added discontinuity marker before segment {seq}") + + duration = manager.segment_durations.get(seq, 10.0) + new_manifest.append(f'#EXTINF:{duration},') + new_manifest.append(f'/stream/{channel_id}/segments/{seq}.ts') + + manifest_content = '\n'.join(new_manifest) + logging.debug(f"Serving manifest with segments {min_seq}-{max_seq} (window: {len(window_segments)})") + return manifest_content, 200 # Return content and status code + except ConnectionAbortedError: + logging.debug("Client disconnected") + return '', 499 + except Exception as e: + logging.error(f"Stream endpoint error: {e}") + return '', 500 + + def get_segment(self, channel_id: str, segment_name: str): + """ + Serve individual MPEG-TS segments to clients. + + Args: + channel_id: Unique identifier for the channel + segment_name: Segment filename (e.g., '123.ts') + + Returns: + Flask Response: + - MPEG-TS segment data with video/MP2T content type + - 404 if segment or channel not found + + Error Handling: + - Logs warning if segment not found + - Logs error on unexpected exceptions + - Returns 404 on any error + """ + if channel_id not in self.stream_managers: + return 'Channel not found', 404 + + try: + # Record client activity + client_ip = request.remote_addr + self.client_managers[channel_id].record_activity(client_ip) + + segment_id = int(segment_name.split('.')[0]) + buffer = self.stream_buffers[channel_id] + + with buffer_lock: + if segment_id in buffer: + return buffer[segment_id], 200 # Return content and status code + + logging.warning(f"Segment {segment_id} not found for channel {channel_id}") + except Exception as e: + logging.error(f"Error serving segment {segment_name}: {e}") + return '', 404 + + def change_stream(self, channel_id: str): + """ + Handle stream URL changes via POST request. + + Args: + channel_id: Channel to modify + + Expected JSON body: + { + "url": "new_stream_url" + } + + Returns: + JSON response with: + - Success/failure message + - Channel ID + - New/current URL + - HTTP 404 if channel not found + - HTTP 400 if URL missing from request + + Side effects: + - Updates stream manager URL + - Triggers stream switch sequence + - Maintains segment numbering + """ + if channel_id not in self.stream_managers: + return {'error': 'Channel not found'}, 404 + + new_url = request.json.get('url') + if not new_url: + return {'error': 'No URL provided'}, 400 + + manager = self.stream_managers[channel_id] + if manager.update_url(new_url): + return { + 'message': 'Stream URL updated', + 'channel': channel_id, + 'url': new_url + }, 200 + return { + 'message': 'URL unchanged', + 'channel': channel_id, + 'url': new_url + }, 200 + +# Main Application Setup +if __name__ == '__main__': + # Command line argument parsing + parser = argparse.ArgumentParser(description='HLS Proxy Server with Stream Switching') + parser.add_argument( + '--url', '-u', + required=True, + help='Initial HLS stream URL to proxy' + ) + parser.add_argument( + '--channel', '-c', + required=True, + help='Channel ID for the stream (default: default)' + ) + parser.add_argument( + '--port', '-p', + type=int, + default=5000, + help='Local port to serve proxy on (default: 5000)' + ) + parser.add_argument( + '--host', '-H', + default='0.0.0.0', + help='Interface to bind server to (default: all interfaces)' + ) + parser.add_argument( + '--user-agent', '-ua', + help='Custom User-Agent string' + ) + parser.add_argument( + '--debug', + action='store_true', + help='Enable debug logging' + ) + args = parser.parse_args() + + # Configure logging + logging.basicConfig( + level=logging.DEBUG if args.debug else logging.INFO, + format='%(asctime)s - %(levelname)s - %(message)s', + datefmt='%Y-%m-%d %H:%M:%S' + ) + + try: + # Initialize proxy server + proxy = ProxyServer(user_agent=args.user_agent) + + # Initialize channel with provided URL + proxy.initialize_channel(args.url, args.channel) + + logging.info(f"Starting HLS proxy server on {args.host}:{args.port}") + logging.info(f"Initial stream URL: {args.url}") + logging.info(f"Channel ID: {args.channel}") + + # Run Flask development server + proxy.run(host=args.host, port=args.port) + + except Exception as e: + logging.error(f"Failed to start server: {e}") + sys.exit(1) + finally: + if 'proxy' in locals(): + proxy.shutdown() diff --git a/apps/proxy/hls_proxy/urls.py b/apps/proxy/hls_proxy/urls.py new file mode 100644 index 0000000..5140d41 --- /dev/null +++ b/apps/proxy/hls_proxy/urls.py @@ -0,0 +1,11 @@ +from django.urls import path +from . import views + +app_name = 'hls_proxy' + +urlpatterns = [ + path('stream/', views.stream_endpoint, name='stream'), + path('initialize/', views.initialize_stream, name='initialize'), + path('segments/', views.get_segment, name='segment'), + path('change_stream/', views.change_stream, name='change_stream'), +] \ No newline at end of file diff --git a/apps/proxy/hls_proxy/views.py b/apps/proxy/hls_proxy/views.py new file mode 100644 index 0000000..835b431 --- /dev/null +++ b/apps/proxy/hls_proxy/views.py @@ -0,0 +1,101 @@ +import json +import threading +import logging +from django.http import StreamingHttpResponse, JsonResponse, HttpResponse +from django.views.decorators.csrf import csrf_exempt +from django.views.decorators.http import require_http_methods +from rest_framework.decorators import api_view, permission_classes +from apps.accounts.permissions import IsAdmin +from .server import ProxyServer, Config + +logger = logging.getLogger(__name__) +proxy_server = ProxyServer() + +@csrf_exempt +@require_http_methods(["GET"]) +def stream_endpoint(request, channel_id): + """Handle HLS manifest requests""" + if channel_id not in proxy_server.stream_managers: + return JsonResponse({'error': 'Channel not found'}, status=404) + + response = proxy_server.stream_endpoint(channel_id) + return StreamingHttpResponse( + response[0], + content_type='application/vnd.apple.mpegurl', + status=response[1] + ) + +@csrf_exempt +@require_http_methods(["GET"]) +def get_segment(request, segment_name): + """Serve MPEG-TS segments""" + try: + segment_num = int(segment_name.split('.')[0]) + buffer = proxy_server.stream_buffers.get(segment_num) + + if not buffer: + return JsonResponse({'error': 'Segment not found'}, status=404) + + return StreamingHttpResponse( + buffer, + content_type='video/MP2T' + ) + except ValueError: + return JsonResponse({'error': 'Invalid segment name'}, status=400) + except Exception as e: + logger.error(f"Error serving segment: {e}") + return JsonResponse({'error': str(e)}, status=500) + +@api_view(["POST"]) +@permission_classes([IsAdmin]) +def change_stream(request, channel_id): + """Change stream URL for existing channel""" + try: + if channel_id not in proxy_server.stream_managers: + return JsonResponse({'error': 'Channel not found'}, status=404) + + data = json.loads(request.body) + new_url = data.get('url') + if not new_url: + return JsonResponse({'error': 'No URL provided'}, status=400) + + manager = proxy_server.stream_managers[channel_id] + if manager.update_url(new_url): + return JsonResponse({ + 'message': 'Stream URL updated', + 'channel': channel_id, + 'url': new_url + }) + + return JsonResponse({ + 'message': 'URL unchanged', + 'channel': channel_id, + 'url': new_url + }) + except json.JSONDecodeError: + return JsonResponse({'error': 'Invalid JSON'}, status=400) + except Exception as e: + logger.error(f"Failed to change stream: {e}") + return JsonResponse({'error': str(e)}, status=500) + +@csrf_exempt +@require_http_methods(["POST"]) +def initialize_stream(request, channel_id): + """Initialize a new HLS stream channel""" + try: + data = json.loads(request.body) + url = data.get('url') + if not url: + return JsonResponse({'error': 'No URL provided'}, status=400) + + proxy_server.initialize_channel(url, channel_id) + return JsonResponse({ + 'message': 'Stream initialized', + 'channel': channel_id, + 'url': url + }) + except json.JSONDecodeError: + return JsonResponse({'error': 'Invalid JSON'}, status=400) + except Exception as e: + logger.error(f"Failed to initialize stream: {e}") + return JsonResponse({'error': str(e)}, status=500) \ No newline at end of file diff --git a/apps/proxy/management/commands/proxy.py b/apps/proxy/management/commands/proxy.py new file mode 100644 index 0000000..f82f08e --- /dev/null +++ b/apps/proxy/management/commands/proxy.py @@ -0,0 +1,67 @@ +from django.core.management.base import BaseCommand +from django.apps import apps +import logging + +logger = logging.getLogger(__name__) + +class Command(BaseCommand): + help = 'Manage proxy servers' + + def add_arguments(self, parser): + parser.add_argument( + 'action', + choices=['start', 'stop', 'restart'], + help='Action to perform' + ) + parser.add_argument( + '--type', + choices=['hls', 'ts', 'all'], + default='all', + help='Type of proxy to manage' + ) + parser.add_argument( + '--channel', + help='Channel ID to manage' + ) + parser.add_argument( + '--url', + help='Stream URL (required for start)' + ) + + def handle(self, *args, **options): + proxy_app = apps.get_app_config('proxy') + action = options['action'] + proxy_type = options['type'] + channel = options.get('channel') + url = options.get('url') + + try: + if action == 'start': + if not url: + raise ValueError("URL is required for start action") + if proxy_type in ('hls', 'all'): + proxy_app.hls_proxy.initialize_channel(url, channel or 'default') + if proxy_type in ('ts', 'all'): + proxy_app.ts_proxy.initialize_channel(url, channel or 'default') + self.stdout.write(self.style.SUCCESS('Started proxy servers')) + + elif action == 'stop': + if proxy_type in ('hls', 'all'): + if channel: + proxy_app.hls_proxy.stop_channel(channel) + else: + proxy_app.hls_proxy.shutdown() + if proxy_type in ('ts', 'all'): + if channel: + proxy_app.ts_proxy.stop_channel(channel) + else: + proxy_app.ts_proxy.shutdown() + self.stdout.write(self.style.SUCCESS('Stopped proxy servers')) + + elif action == 'restart': + self.handle(*args, **dict(options, action='stop')) + self.handle(*args, **dict(options, action='start')) + self.stdout.write(self.style.SUCCESS('Restarted proxy servers')) + + except Exception as e: + self.stderr.write(self.style.ERROR(f'Error: {e}')) \ No newline at end of file diff --git a/apps/proxy/signals.py b/apps/proxy/signals.py new file mode 100644 index 0000000..cce54b1 --- /dev/null +++ b/apps/proxy/signals.py @@ -0,0 +1,19 @@ +from django.db.models.signals import pre_delete +from django.dispatch import receiver +from django.apps import apps +import logging + +logger = logging.getLogger(__name__) + +@receiver(pre_delete) +def cleanup_proxy_servers(sender, **kwargs): + """Clean up proxy servers when Django shuts down""" + try: + proxy_app = apps.get_app_config('proxy') + for channel_id in list(proxy_app.hls_proxy.stream_managers.keys()): + proxy_app.hls_proxy.stop_channel(channel_id) + for channel_id in list(proxy_app.ts_proxy.stream_managers.keys()): + proxy_app.ts_proxy.stop_channel(channel_id) + logger.info("Proxy servers cleaned up successfully") + except Exception as e: + logger.error(f"Error during proxy server cleanup: {e}") \ No newline at end of file diff --git a/apps/proxy/tasks.py b/apps/proxy/tasks.py new file mode 100644 index 0000000..b376f99 --- /dev/null +++ b/apps/proxy/tasks.py @@ -0,0 +1,59 @@ +from celery import shared_task +import json +import logging +import re +import gc +from core.utils import RedisClient +from apps.proxy.ts_proxy.channel_status import ChannelStatus +from core.utils import send_websocket_update + +logger = logging.getLogger(__name__) + +# Store the last known value to compare with new data +last_known_data = {} + +@shared_task +def fetch_channel_stats(): + redis_client = RedisClient.get_client() + + try: + # Basic info for all channels + channel_pattern = "ts_proxy:channel:*:metadata" + all_channels = [] + + # Extract channel IDs from keys + cursor = 0 + while True: + cursor, keys = redis_client.scan(cursor, match=channel_pattern) + for key in keys: + channel_id_match = re.search(r"ts_proxy:channel:(.*):metadata", key) + if channel_id_match: + ch_id = channel_id_match.group(1) + channel_info = ChannelStatus.get_basic_channel_info(ch_id) + if channel_info: + all_channels.append(channel_info) + + if cursor == 0: + break + + except Exception as e: + logger.error(f"Error in channel_status: {e}", exc_info=True) + return + # return JsonResponse({'error': str(e)}, status=500) + + send_websocket_update( + "updates", + "update", + { + "success": True, + "type": "channel_stats", + "stats": json.dumps({'channels': all_channels, 'count': len(all_channels)}) + }, + collect_garbage=True + ) + + # Explicitly clean up large data structures + all_channels = None + gc.collect() + + diff --git a/apps/proxy/ts_proxy/__init__.py b/apps/proxy/ts_proxy/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/proxy/ts_proxy/apps.py b/apps/proxy/ts_proxy/apps.py new file mode 100644 index 0000000..116720d --- /dev/null +++ b/apps/proxy/ts_proxy/apps.py @@ -0,0 +1,13 @@ +import sys +from django.apps import AppConfig + +class TSProxyConfig(AppConfig): + default_auto_field = 'django.db.models.BigAutoField' + name = 'apps.proxy.ts_proxy' + verbose_name = "TS Stream Proxies" + + def ready(self): + """Initialize proxy servers when Django starts""" + if 'manage.py' not in sys.argv: + from .server import ProxyServer + ProxyServer.get_instance() diff --git a/apps/proxy/ts_proxy/channel_status.py b/apps/proxy/ts_proxy/channel_status.py new file mode 100644 index 0000000..c4a54cd --- /dev/null +++ b/apps/proxy/ts_proxy/channel_status.py @@ -0,0 +1,538 @@ +import logging +import time +import re +from .server import ProxyServer +from .redis_keys import RedisKeys +from .constants import TS_PACKET_SIZE, ChannelMetadataField +from redis.exceptions import ConnectionError, TimeoutError +from .utils import get_logger +from .client_manager import ClientManager +from django.db import DatabaseError # Add import for error handling + +logger = get_logger() + +class ChannelStatus: + + @staticmethod + def _calculate_bitrate(total_bytes, duration): + """Calculate bitrate in Kbps based on total bytes and duration in seconds""" + if duration <= 0: + return 0 + + # Convert bytes to bits (x8) and divide by duration to get bits per second + # Then divide by 1000 to get Kbps + return (total_bytes * 8) / duration / 1000 + + def get_detailed_channel_info(channel_id): + proxy_server = ProxyServer.get_instance() + + # Get channel metadata + metadata_key = RedisKeys.channel_metadata(channel_id) + metadata = proxy_server.redis_client.hgetall(metadata_key) + + if not metadata: + return None + + # Basic channel info + buffer_index_key = RedisKeys.buffer_index(channel_id) + buffer_index_value = proxy_server.redis_client.get(buffer_index_key) + + info = { + 'channel_id': channel_id, + 'state': metadata.get(ChannelMetadataField.STATE, 'unknown'), + 'url': metadata.get(ChannelMetadataField.URL, ''), + 'stream_profile': metadata.get(ChannelMetadataField.STREAM_PROFILE, ''), + 'started_at': metadata.get(ChannelMetadataField.INIT_TIME, '0'), + 'owner': metadata.get(ChannelMetadataField.OWNER, 'unknown'), + 'buffer_index': int(buffer_index_value) if buffer_index_value else 0, + } + + # Add stream ID and name information + stream_id_bytes = metadata.get(ChannelMetadataField.STREAM_ID) + if stream_id_bytes: + try: + stream_id = int(stream_id_bytes) + info['stream_id'] = stream_id + + # Look up stream name from database + try: + from apps.channels.models import Stream + stream = Stream.objects.filter(id=stream_id).first() + if stream: + info['stream_name'] = stream.name + except (ImportError, DatabaseError) as e: + logger.warning(f"Failed to get stream name for ID {stream_id}: {e}") + except ValueError: + logger.warning(f"Invalid stream_id format in Redis: {stream_id_bytes}") + + # Add M3U profile information + m3u_profile_id_bytes = metadata.get(ChannelMetadataField.M3U_PROFILE) + if m3u_profile_id_bytes: + try: + m3u_profile_id = int(m3u_profile_id_bytes) + info['m3u_profile_id'] = m3u_profile_id + + # Look up M3U profile name from database + try: + from apps.m3u.models import M3UAccountProfile + m3u_profile = M3UAccountProfile.objects.filter(id=m3u_profile_id).first() + if m3u_profile: + info['m3u_profile_name'] = m3u_profile.name + except (ImportError, DatabaseError) as e: + logger.warning(f"Failed to get M3U profile name for ID {m3u_profile_id}: {e}") + except ValueError: + logger.warning(f"Invalid m3u_profile_id format in Redis: {m3u_profile_id_bytes}") + + # Add timing information + state_changed_field = ChannelMetadataField.STATE_CHANGED_AT + if state_changed_field in metadata: + state_changed_at = float(metadata[state_changed_field]) + info['state_changed_at'] = state_changed_at + info['state_duration'] = time.time() - state_changed_at + + init_time_field = ChannelMetadataField.INIT_TIME + if init_time_field in metadata: + created_at = float(metadata[init_time_field]) + info['started_at'] = created_at + info['uptime'] = time.time() - created_at + + # Add data throughput information + total_bytes_field = ChannelMetadataField.TOTAL_BYTES + if total_bytes_field in metadata: + total_bytes = int(metadata[total_bytes_field]) + info['total_bytes'] = total_bytes + + # Format total bytes in human-readable form + if total_bytes < 1024: + info['total_data'] = f"{total_bytes} B" + elif total_bytes < 1024 * 1024: + info['total_data'] = f"{total_bytes / 1024:.2f} KB" + elif total_bytes < 1024 * 1024 * 1024: + info['total_data'] = f"{total_bytes / (1024 * 1024):.2f} MB" + else: + info['total_data'] = f"{total_bytes / (1024 * 1024 * 1024):.2f} GB" + + # Calculate average bitrate if we have uptime + if 'uptime' in info and info['uptime'] > 0: + avg_bitrate = ChannelStatus._calculate_bitrate(total_bytes, info['uptime']) + info['avg_bitrate_kbps'] = avg_bitrate + + # Format in Mbps if over 1000 Kbps + if avg_bitrate > 1000: + info['avg_bitrate'] = f"{avg_bitrate / 1000:.2f} Mbps" + else: + info['avg_bitrate'] = f"{avg_bitrate:.2f} Kbps" + + # Get client information + client_set_key = RedisKeys.clients(channel_id) + client_ids = proxy_server.redis_client.smembers(client_set_key) + clients = [] + + stale_client_ids = [] + for client_id in client_ids: + client_id_str = client_id + client_key = RedisKeys.client_metadata(channel_id, client_id_str) + client_data = proxy_server.redis_client.hgetall(client_key) + + if not client_data: + # Metadata hash expired but SET entry persists (ghost client). + stale_client_ids.append(client_id) + continue + + client_info = { + 'client_id': client_id_str, + 'user_agent': client_data.get('user_agent', 'unknown'), + 'worker_id': client_data.get('worker_id', 'unknown'), + 'ip_address': client_data.get('ip_address', 'unknown'), + 'user_id': client_data.get('user_id', '0'), + } + + if 'connected_at' in client_data: + connected_at = float(client_data['connected_at']) + client_info['connected_at'] = connected_at + client_info['connection_duration'] = time.time() - connected_at + + if 'last_active' in client_data: + last_active = float(client_data['last_active']) + client_info['last_active'] = last_active + client_info['last_active_ago'] = time.time() - last_active + + # Add transfer rate statistics + if 'bytes_sent' in client_data: + client_info['bytes_sent'] = int(client_data['bytes_sent']) + + # Add average transfer rate + if 'avg_rate_KBps' in client_data: + client_info['avg_rate_KBps'] = float(client_data['avg_rate_KBps']) + elif 'transfer_rate_KBps' in client_data: # For backward compatibility + client_info['avg_rate_KBps'] = float(client_data['transfer_rate_KBps']) + + # Add current transfer rate + if 'current_rate_KBps' in client_data: + client_info['current_rate_KBps'] = float(client_data['current_rate_KBps']) + + clients.append(client_info) + + # Clean up stale SET entries so SCARD stays accurate. + if stale_client_ids: + proxy_server.redis_client.srem(client_set_key, *stale_client_ids) + logger.info( + f"Removed {len(stale_client_ids)} ghost client(s) from " + f"channel {channel_id} client set" + ) + + info['clients'] = clients + info['client_count'] = len(clients) + + # Get buffer health with improved diagnostics + buffer_stats = { + 'chunks': info['buffer_index'], + 'diagnostics': {} + } + + # Sample a few recent chunks to check sizes with better error handling + if info['buffer_index'] > 0: + try: + sample_chunks = min(5, info['buffer_index']) + chunk_sizes = [] + chunk_keys_found = [] + chunk_keys_missing = [] + + # Check if the keys exist before getting + for i in range(info['buffer_index']-sample_chunks+1, info['buffer_index']+1): + chunk_key = RedisKeys.buffer_chunk(channel_id, i) + + # Check if key exists first + if proxy_server.redis_client.exists(chunk_key): + chunk_data = proxy_server.redis_client.get(chunk_key) + if chunk_data: + chunk_size = len(chunk_data) + chunk_sizes.append(chunk_size) + chunk_keys_found.append(i) + + # Check for TS alignment (packets are 188 bytes) + ts_packets = chunk_size // 188 + ts_aligned = chunk_size % 188 == 0 + + # Add for first chunk only to avoid too much data + if len(chunk_keys_found) == 1: + buffer_stats['diagnostics']['first_chunk'] = { + 'index': i, + 'size': chunk_size, + 'ts_packets': ts_packets, + 'aligned': ts_aligned, + 'first_byte': chunk_data[0] if chunk_size > 0 else None + } + else: + chunk_keys_missing.append(i) + + # Add detailed diagnostics + if chunk_sizes: + buffer_stats['avg_chunk_size'] = sum(chunk_sizes) / len(chunk_sizes) + buffer_stats['recent_chunk_sizes'] = chunk_sizes + buffer_stats['keys_found'] = chunk_keys_found + buffer_stats['keys_missing'] = chunk_keys_missing + + # Calculate data rate + total_data = sum(chunk_sizes) + buffer_stats['total_sample_bytes'] = total_data + + # Add TS packet analysis + total_ts_packets = total_data // TS_PACKET_SIZE + buffer_stats['estimated_ts_packets'] = total_ts_packets + buffer_stats['is_ts_aligned'] = all(size % TS_PACKET_SIZE == 0 for size in chunk_sizes) + else: + # If no chunks found, scan for keys to help debug + all_buffer_keys = [] + cursor = 0 + + buffer_key_pattern = f"ts_proxy:channel:{channel_id}:buffer:chunk:*" + + while True: + cursor, keys = proxy_server.redis_client.scan(cursor, match=buffer_key_pattern, count=100) + if keys: + all_buffer_keys.extend([k for k in keys]) + if cursor == 0 or len(all_buffer_keys) >= 20: # Limit to 20 keys + break + + buffer_stats['diagnostics']['all_buffer_keys'] = all_buffer_keys[:20] # First 20 keys + buffer_stats['diagnostics']['total_buffer_keys'] = len(all_buffer_keys) + + except Exception as e: + # Capture any errors for diagnostics + buffer_stats['error'] = str(e) + buffer_stats['diagnostics']['exception'] = str(e) + + # Add TTL information to see if chunks are expiring + chunk_ttl_key = RedisKeys.buffer_chunk(channel_id, info['buffer_index']) + chunk_ttl = proxy_server.redis_client.ttl(chunk_ttl_key) + buffer_stats['latest_chunk_ttl'] = chunk_ttl + + info['buffer_stats'] = buffer_stats + + # Get local worker info if available + if channel_id in proxy_server.stream_managers: + manager = proxy_server.stream_managers[channel_id] + info['local_manager'] = { + 'healthy': manager.healthy, + 'connected': manager.connected, + 'last_data_time': manager.last_data_time, + 'last_data_age': time.time() - manager.last_data_time + } + + # Add FFmpeg stream information + video_codec = metadata.get(ChannelMetadataField.VIDEO_CODEC) + if video_codec: + info['video_codec'] = video_codec + + resolution = metadata.get(ChannelMetadataField.RESOLUTION) + if resolution: + info['resolution'] = resolution + + source_fps = metadata.get(ChannelMetadataField.SOURCE_FPS) + if source_fps: + info['source_fps'] = source_fps + + pixel_format = metadata.get(ChannelMetadataField.PIXEL_FORMAT) + if pixel_format: + info['pixel_format'] = pixel_format + + source_bitrate = metadata.get(ChannelMetadataField.SOURCE_BITRATE) + if source_bitrate: + info['source_bitrate'] = source_bitrate + + audio_codec = metadata.get(ChannelMetadataField.AUDIO_CODEC) + if audio_codec: + info['audio_codec'] = audio_codec + + sample_rate = metadata.get(ChannelMetadataField.SAMPLE_RATE) + if sample_rate: + info['sample_rate'] = sample_rate + + audio_channels = metadata.get(ChannelMetadataField.AUDIO_CHANNELS) + if audio_channels: + info['audio_channels'] = audio_channels + + audio_bitrate = metadata.get(ChannelMetadataField.AUDIO_BITRATE) + if audio_bitrate: + info['audio_bitrate'] = audio_bitrate + + + # Add FFmpeg performance stats + ffmpeg_speed = metadata.get(ChannelMetadataField.FFMPEG_SPEED) + if ffmpeg_speed: + info['ffmpeg_speed'] = ffmpeg_speed + + ffmpeg_fps = metadata.get(ChannelMetadataField.FFMPEG_FPS) + if ffmpeg_fps: + info['ffmpeg_fps'] = ffmpeg_fps + + actual_fps = metadata.get(ChannelMetadataField.ACTUAL_FPS) + if actual_fps: + info['actual_fps'] = actual_fps + + ffmpeg_bitrate = metadata.get(ChannelMetadataField.FFMPEG_BITRATE) + if ffmpeg_bitrate: + info['ffmpeg_bitrate'] = ffmpeg_bitrate + + stream_type = metadata.get(ChannelMetadataField.STREAM_TYPE) + if stream_type: + info['stream_type'] = stream_type + + + return info + + @staticmethod + def _execute_redis_command(command_func): + """Execute Redis command with error handling""" + proxy_server = ProxyServer.get_instance() + + if not proxy_server.redis_client: + return None + + try: + return command_func() + except (ConnectionError, TimeoutError) as e: + logger.warning(f"Redis connection error in ChannelStatus: {e}") + return None + except Exception as e: + logger.error(f"Redis command error in ChannelStatus: {e}") + return None + + @staticmethod + def get_basic_channel_info(channel_id): + """Get basic channel information with Redis error handling""" + proxy_server = ProxyServer.get_instance() + + try: + # Use _execute_redis_command for Redis operations + metadata_key = RedisKeys.channel_metadata(channel_id) + metadata = ChannelStatus._execute_redis_command( + lambda: proxy_server.redis_client.hgetall(metadata_key) + ) + + if not metadata: + return None + + # Basic channel info only - omit diagnostics and details + buffer_index_key = RedisKeys.buffer_index(channel_id) + buffer_index_value = proxy_server.redis_client.get(buffer_index_key) + + # Count clients (using efficient count method) + client_set_key = RedisKeys.clients(channel_id) + client_count = proxy_server.redis_client.scard(client_set_key) or 0 + + # Calculate uptime + init_time_bytes = metadata.get(ChannelMetadataField.INIT_TIME, '0') + created_at = float(init_time_bytes) + uptime = time.time() - created_at if created_at > 0 else 0 + + # Simplified info + info = { + 'channel_id': channel_id, + 'state': metadata.get(ChannelMetadataField.STATE), + 'url': metadata.get(ChannelMetadataField.URL, ""), + 'stream_profile': metadata.get(ChannelMetadataField.STREAM_PROFILE, ""), + 'owner': metadata.get(ChannelMetadataField.OWNER), + 'buffer_index': int(buffer_index_value) if buffer_index_value else 0, + 'client_count': client_count, + 'uptime': uptime + } + + # Add stream ID and name information + stream_id_bytes = metadata.get(ChannelMetadataField.STREAM_ID) + if stream_id_bytes: + try: + stream_id = int(stream_id_bytes) + info['stream_id'] = stream_id + + # Look up stream name from database + try: + from apps.channels.models import Stream + stream = Stream.objects.filter(id=stream_id).first() + if stream: + info['stream_name'] = stream.name + except (ImportError, DatabaseError) as e: + logger.warning(f"Failed to get stream name for ID {stream_id}: {e}") + except ValueError: + logger.warning(f"Invalid stream_id format in Redis: {stream_id_bytes}") + + # Add data throughput information to basic info + total_bytes_bytes = proxy_server.redis_client.hget(metadata_key, ChannelMetadataField.TOTAL_BYTES) + if total_bytes_bytes: + total_bytes = int(total_bytes_bytes) + info['total_bytes'] = total_bytes + + # Calculate and add bitrate + if uptime > 0: + avg_bitrate = ChannelStatus._calculate_bitrate(total_bytes, uptime) + info['avg_bitrate_kbps'] = avg_bitrate + + # Format for display + if avg_bitrate > 1000: + info['avg_bitrate'] = f"{avg_bitrate / 1000:.2f} Mbps" + else: + info['avg_bitrate'] = f"{avg_bitrate:.2f} Kbps" + + # Quick health check if available locally + if channel_id in proxy_server.stream_managers: + manager = proxy_server.stream_managers[channel_id] + info['healthy'] = manager.healthy + + # Get concise client information + clients = [] + client_ids = proxy_server.redis_client.smembers(client_set_key) + + # Remove ghost SET entries before building the client list. + # Pass the already-fetched client_ids to avoid a redundant SMEMBERS. + stale_client_ids = ClientManager.remove_ghost_clients( + proxy_server.redis_client, channel_id, client_ids=client_ids + ) + if stale_client_ids: + client_count = max(0, client_count - len(stale_client_ids)) + + # Build concise client list (up to 10) from remaining live clients. + if client_ids: + for client_id in list(client_ids)[:10]: + if client_id in stale_client_ids: + continue + + client_key = RedisKeys.client_metadata(channel_id, client_id) + + client_info = { + 'client_id': client_id, + } + + user_agent_bytes = proxy_server.redis_client.hget(client_key, 'user_agent') + client_info['user_agent'] = user_agent_bytes + + ip_address_bytes = proxy_server.redis_client.hget(client_key, 'ip_address') + if ip_address_bytes: + client_info['ip_address'] = ip_address_bytes + + connected_at_bytes = proxy_server.redis_client.hget(client_key, 'connected_at') + if connected_at_bytes: + connected_at = float(connected_at_bytes) + client_info['connected_since'] = time.time() - connected_at + + user_id_bytes = proxy_server.redis_client.hget(client_key, 'user_id') + if user_id_bytes: + client_info['user_id'] = user_id_bytes + + clients.append(client_info) + + # Add clients to info + info['clients'] = clients + info['client_count'] = client_count + + # Add M3U profile information + m3u_profile_id = metadata.get(ChannelMetadataField.M3U_PROFILE) + if m3u_profile_id: + try: + m3u_profile_id = int(m3u_profile_id) + info['m3u_profile_id'] = m3u_profile_id + + # Look up M3U profile name from database + try: + from apps.m3u.models import M3UAccountProfile + m3u_profile = M3UAccountProfile.objects.filter(id=m3u_profile_id).first() + if m3u_profile: + info['m3u_profile_name'] = m3u_profile.name + except (ImportError, DatabaseError) as e: + logger.warning(f"Failed to get M3U profile name for ID {m3u_profile_id}: {e}") + except ValueError: + logger.warning(f"Invalid m3u_profile_id format in Redis: {m3u_profile_id}") + + # Add stream info to basic info as well + video_codec = metadata.get(ChannelMetadataField.VIDEO_CODEC) + if video_codec: + info['video_codec'] = video_codec + + resolution = metadata.get(ChannelMetadataField.RESOLUTION) + if resolution: + info['resolution'] = resolution + + source_fps = metadata.get(ChannelMetadataField.SOURCE_FPS) + if source_fps: + info['source_fps'] = float(source_fps) + + ffmpeg_speed = metadata.get(ChannelMetadataField.FFMPEG_SPEED) + if ffmpeg_speed: + info['ffmpeg_speed'] = float(ffmpeg_speed) + + audio_codec = metadata.get(ChannelMetadataField.AUDIO_CODEC) + if audio_codec: + info['audio_codec'] = audio_codec + + audio_channels = metadata.get(ChannelMetadataField.AUDIO_CHANNELS) + if audio_channels: + info['audio_channels'] = audio_channels + + stream_type = metadata.get(ChannelMetadataField.STREAM_TYPE) + if stream_type: + info['stream_type'] = stream_type + + return info + except Exception as e: + logger.error(f"Error getting channel info: {e}", exc_info=True) # Added exc_info for better debugging + return None diff --git a/apps/proxy/ts_proxy/client_manager.py b/apps/proxy/ts_proxy/client_manager.py new file mode 100644 index 0000000..af7eb7d --- /dev/null +++ b/apps/proxy/ts_proxy/client_manager.py @@ -0,0 +1,451 @@ +"""Client connection management for TS streams""" + +import threading +import time +import json +from typing import Set, Optional +from apps.proxy.config import TSConfig as Config +from redis.exceptions import ConnectionError, TimeoutError +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() + +class ClientManager: + """Manages client connections with no duplicates""" + + def __init__(self, channel_id=None, redis_client=None, heartbeat_interval=1, worker_id=None): + self.channel_id = channel_id + self.redis_client = redis_client + self.clients = set() + self.lock = threading.RLock() + 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) + self.client_ttl = ConfigHelper.get('CLIENT_RECORD_TTL', 60) + 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 _trigger_stats_update(self): + """Trigger a channel stats update via WebSocket in a background thread. + + Offloaded so the caller is not blocked. send_websocket_update is + gevent-safe (offloads async_to_sync to a native OS thread). + """ + threading.Thread(target=self._do_stats_update, daemon=True).start() + + def _do_stats_update(self): + """Perform the stats update in the background.""" + try: + from apps.proxy.ts_proxy.channel_status import ChannelStatus + import redis + from django.conf import settings + + redis_url = getattr(settings, 'REDIS_URL', 'redis://localhost:6379/0') + ssl_params = getattr(settings, 'REDIS_SSL_PARAMS', {}) + redis_client = redis.Redis.from_url(redis_url, decode_responses=True, **ssl_params) + all_channels = [] + cursor = 0 + + while True: + cursor, keys = redis_client.scan(cursor, match="ts_proxy:channel:*:clients", count=100) + for key in keys: + 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( + "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, 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: + # 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() + clients_to_remove = set() + + # First identify clients that should be removed + for client_id in self.clients: + client_key = f"ts_proxy:channel:{self.channel_id}:clients:{client_id}" + + # Check if client exists in Redis at all + exists = self.redis_client.exists(client_key) + if not exists: + logger.debug(f"Client {client_id} no longer exists in Redis, removing locally") + clients_to_remove.add(client_id) + continue + + # Check for stale activity using last_active field + last_active = self.redis_client.hget(client_key, "last_active") + if last_active: + last_active_time = float(last_active) + ghost_timeout = self.heartbeat_interval * getattr(Config, 'GHOST_CLIENT_MULTIPLIER', 5.0) + + if current_time - last_active_time > ghost_timeout: + logger.debug(f"Client {client_id} inactive for {current_time - last_active_time:.1f}s, removing as ghost") + clients_to_remove.add(client_id) + + # Remove ghost clients in a separate step + for client_id in clients_to_remove: + self.remove_client(client_id) + + if clients_to_remove: + logger.info(f"Removed {len(clients_to_remove)} ghost clients from channel {self.channel_id}") + + # Now send heartbeats only for remaining clients + pipe = self.redis_client.pipeline() + current_time = time.time() + + for client_id in self.clients: + # Skip clients we just marked for removal + if client_id in clients_to_remove: + continue + + # Skip if we just sent a heartbeat recently + if client_id in self.last_heartbeat_time: + time_since_heartbeat = current_time - self.last_heartbeat_time[client_id] + if time_since_heartbeat < self.heartbeat_interval * 0.5: # Only heartbeat at half interval minimum + continue + + # Only refresh TTL - do NOT update last_active + client_key = f"ts_proxy:channel:{self.channel_id}:clients:{client_id}" + pipe.expire(client_key, self.client_ttl) + + # Keep client in the set with TTL + pipe.sadd(self.client_set_key, client_id) + pipe.expire(self.client_set_key, self.client_ttl) + + # Track last heartbeat locally + self.last_heartbeat_time[client_id] = current_time + + # Execute all commands atomically + pipe.execute() + + # Only notify if we have real clients + if self.clients and not all(c in clients_to_remove for c in self.clients): + self._notify_owner_of_activity() + + 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: + return None + + try: + return command_func() + except (ConnectionError, TimeoutError) as e: + logger.warning(f"Redis connection error in ClientManager: {e}") + return None + except Exception as e: + logger.error(f"Redis command error in ClientManager: {e}") + return None + + def _notify_owner_of_activity(self): + """Notify channel owner that clients are active on this worker""" + if not self.redis_client or not self.clients: + return + + try: + worker_id = self.worker_id or "unknown" + + # STANDARDIZED KEY: Worker info under channel namespace + worker_key = f"ts_proxy:channel:{self.channel_id}:worker:{worker_id}" + self._execute_redis_command( + lambda: self.redis_client.setex(worker_key, self.client_ttl, str(len(self.clients))) + ) + + # STANDARDIZED KEY: Activity timestamp under channel namespace + activity_key = f"ts_proxy:channel:{self.channel_id}:activity" + self._execute_redis_command( + lambda: self.redis_client.setex(activity_key, self.client_ttl, str(time.time())) + ) + except Exception as e: + logger.error(f"Error notifying owner of client activity: {e}") + + def add_client(self, client_id, client_ip, user_agent=None, user=None): + """Add a client with duplicate prevention""" + if client_id in self._registered_clients: + logger.debug(f"Client {client_id} already registered, skipping") + return False + + self._registered_clients.add(client_id) + + # Use a function to get the client key + client_key = f"ts_proxy:channel:{self.channel_id}:clients:{client_id}" + + # Prepare client data + current_time = str(time.time()) + client_data = { + "user_agent": user_agent or "unknown", + "ip_address": client_ip, + "connected_at": current_time, + "last_active": current_time, + "worker_id": self.worker_id or "unknown", + "user_id": str(user.id) if user is not None else "0", + # "user_level": user.user_level if user is not None else 100, # default to a high value since no user means the non-user specific M3U/HDHR + } + + try: + with self.lock: + # Store client in local set + self.clients.add(client_id) + + # Store in Redis + if self.redis_client: + # FIXED: Store client data just once with proper key + self.redis_client.hset(client_key, mapping=client_data) + self.redis_client.expire(client_key, self.client_ttl) + + # Add to the client set + self.redis_client.sadd(self.client_set_key, client_id) + self.redis_client.expire(self.client_set_key, self.client_ttl) + + # Clear any initialization timer + init_key = f"ts_proxy:channel:{self.channel_id}:init_time" + self.redis_client.delete(init_key) + + self._notify_owner_of_activity() + + # Publish client connected event with user agent + event_data = { + "event": EventType.CLIENT_CONNECTED, # Use constant instead of string + "channel_id": self.channel_id, + "client_id": client_id, + "worker_id": self.worker_id or "unknown", + "timestamp": time.time(), + "username": user.username if user is not None else "unknown" + } + + if user_agent: + event_data["user_agent"] = user_agent + logger.debug(f"Storing user agent '{user_agent}' for client {client_id}") + else: + logger.debug(f"No user agent provided for client {client_id}") + + self.redis_client.publish( + RedisKeys.events_channel(self.channel_id), # Use RedisKeys instead of string + 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})") + + self.last_heartbeat_time[client_id] = time.time() + + return len(self.clients) + + except Exception as e: + logger.error(f"Error adding client {client_id}: {e}") + return False + + def remove_client(self, client_id): + """Remove a client from this channel and Redis""" + with self.lock: + if client_id in self.clients: + self.clients.remove(client_id) + + if client_id in self.last_heartbeat_time: + del self.last_heartbeat_time[client_id] + + self.last_active_time = time.time() + + if self.redis_client: + # Get client data before removing the data + client_key = f"ts_proxy:channel:{self.channel_id}:clients:{client_id}" + client_username = self.redis_client.hget(client_key, "username") or "unknown" + if isinstance(client_username, bytes): + client_username = client_username.decode("utf-8") + + # Remove from channel's client set + self.redis_client.srem(self.client_set_key, client_id) + + # STANDARDIZED KEY: Delete individual client keys + client_key = f"ts_proxy:channel:{self.channel_id}:clients:{client_id}" + self.redis_client.delete(client_key) + + # Check if this was the last client + remaining = self.redis_client.scard(self.client_set_key) or 0 + if remaining == 0: + logger.warning(f"Last client removed: {client_id} - channel may shut down soon") + + # Trigger disconnect time tracking even if we're not the owner + disconnect_key = RedisKeys.last_client_disconnect(self.channel_id) + self.redis_client.setex(disconnect_key, 60, str(time.time())) + + self._notify_owner_of_activity() + + # 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, + "username": client_username + }) + 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})") + + return len(self.clients) + + def get_client_count(self): + """Get local client count""" + with self.lock: + return len(self.clients) + + def get_total_client_count(self): + """Get total client count across all workers""" + if not self.redis_client: + return len(self.clients) + + try: + # Count members in the client set + return self.redis_client.scard(self.client_set_key) or 0 + except Exception as e: + logger.error(f"Error getting total client count: {e}") + return len(self.clients) # Fall back to local count + + def refresh_client_ttl(self): + """Refresh TTL for active clients to prevent expiration""" + if not self.redis_client: + return + + try: + # Refresh TTL for all clients belonging to this worker + for client_id in self.clients: + # STANDARDIZED: Use channel namespace for client keys + client_key = f"ts_proxy:channel:{self.channel_id}:clients:{client_id}" + self.redis_client.expire(client_key, self.client_ttl) + + # Refresh TTL on the set itself + self.redis_client.expire(self.client_set_key, self.client_ttl) + except Exception as e: + logger.error(f"Error refreshing client TTL: {e}") + + @staticmethod + def remove_ghost_clients(redis_client, channel_id, client_ids=None): + """Remove client SET entries whose metadata hash has expired. + + Returns the list of removed (stale) client IDs, or an empty list + if none were found. Uses a pipelined EXISTS check for efficiency. + + Args: + client_ids: Optional pre-fetched result of SMEMBERS for this + channel. Pass this to avoid a redundant SMEMBERS + call when the caller has already fetched it. + """ + client_set_key = RedisKeys.clients(channel_id) + if client_ids is None: + client_ids = redis_client.smembers(client_set_key) + if not client_ids: + return [] + + client_id_list = list(client_ids) + pipe = redis_client.pipeline() + for cid in client_id_list: + pipe.exists(RedisKeys.client_metadata(channel_id, cid)) + results = pipe.execute() + + stale_ids = [ + cid for cid, exists in zip(client_id_list, results) + if not exists + ] + + if stale_ids: + redis_client.srem(client_set_key, *stale_ids) + logger.info( + f"Removed {len(stale_ids)} ghost client(s) from " + f"channel {channel_id} client set" + ) + + return stale_ids diff --git a/apps/proxy/ts_proxy/config_helper.py b/apps/proxy/ts_proxy/config_helper.py new file mode 100644 index 0000000..f9a0418 --- /dev/null +++ b/apps/proxy/ts_proxy/config_helper.py @@ -0,0 +1,120 @@ +""" +Helper module to access configuration values with proper defaults. +""" + +from apps.proxy.config import TSConfig as Config + +class ConfigHelper: + """ + Helper class for accessing configuration values with sensible defaults. + This simplifies code and ensures consistent defaults across the application. + """ + + @staticmethod + def get(name, default=None): + """Get a configuration value with a default fallback""" + return getattr(Config, name, default) + + # Commonly used configuration values + @staticmethod + def connection_timeout(): + """Get connection timeout in seconds""" + return ConfigHelper.get('CONNECTION_TIMEOUT', 10) + + @staticmethod + def client_wait_timeout(): + """Get client wait timeout in seconds""" + return ConfigHelper.get('CLIENT_WAIT_TIMEOUT', 30) + + @staticmethod + def stream_timeout(): + """Get stream timeout in seconds""" + return ConfigHelper.get('STREAM_TIMEOUT', 60) + + @staticmethod + def channel_shutdown_delay(): + """Get channel shutdown delay in seconds""" + return Config.get_channel_shutdown_delay() + + @staticmethod + def initial_behind_chunks(): + """Get number of chunks to start behind""" + return ConfigHelper.get('INITIAL_BEHIND_CHUNKS', 4) + + @staticmethod + def new_client_behind_seconds(): + """Get number of seconds behind live to start new clients. + 0 means start at live (buffer head). + Loaded from DB proxy_settings so users can change it at runtime.""" + from apps.proxy.config import TSConfig + settings = TSConfig.get_proxy_settings() + return settings.get('new_client_behind_seconds', 5) + + @staticmethod + def keepalive_interval(): + """Get keepalive interval in seconds""" + return ConfigHelper.get('KEEPALIVE_INTERVAL', 0.5) + + @staticmethod + def cleanup_check_interval(): + """Get cleanup check interval in seconds""" + return ConfigHelper.get('CLEANUP_CHECK_INTERVAL', 3) + + @staticmethod + def redis_chunk_ttl(): + """Get Redis chunk TTL in seconds""" + return Config.get_redis_chunk_ttl() + + @staticmethod + def chunk_size(): + """Get chunk size in bytes""" + return ConfigHelper.get('CHUNK_SIZE', 8192) + + @staticmethod + def max_retries(): + """Get maximum retry attempts""" + return ConfigHelper.get('MAX_RETRIES', 3) + + @staticmethod + def max_stream_switches(): + """Get maximum number of stream switch attempts""" + return ConfigHelper.get('MAX_STREAM_SWITCHES', 10) + + @staticmethod + def retry_wait_interval(): + """Get wait interval between connection retries in seconds""" + return ConfigHelper.get('RETRY_WAIT_INTERVAL', 0.5) # Default to 0.5 second + + @staticmethod + def url_switch_timeout(): + """Get URL switch timeout in seconds (max time allowed for a stream switch operation)""" + return ConfigHelper.get('URL_SWITCH_TIMEOUT', 20) # Default to 20 seconds + + @staticmethod + def failover_grace_period(): + """Get extra time (in seconds) to allow for stream switching before disconnecting clients""" + return ConfigHelper.get('FAILOVER_GRACE_PERIOD', 20) # Default to 20 seconds + + @staticmethod + def buffering_timeout(): + """Get buffering timeout in seconds""" + return Config.get_buffering_timeout() + + @staticmethod + def buffering_speed(): + """Get buffering speed threshold""" + return Config.get_buffering_speed() + + @staticmethod + 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 diff --git a/apps/proxy/ts_proxy/constants.py b/apps/proxy/ts_proxy/constants.py new file mode 100644 index 0000000..d134c8e --- /dev/null +++ b/apps/proxy/ts_proxy/constants.py @@ -0,0 +1,117 @@ +""" +Constants used throughout the TS Proxy application. +Centralizing constants makes it easier to maintain and modify them. +""" + +# Redis related constants +REDIS_KEY_PREFIX = "ts_proxy" +REDIS_TTL_DEFAULT = 3600 # 1 hour +REDIS_TTL_SHORT = 60 # 1 minute +REDIS_TTL_MEDIUM = 300 # 5 minutes + +# Channel states +class ChannelState: + INITIALIZING = "initializing" + CONNECTING = "connecting" + WAITING_FOR_CLIENTS = "waiting_for_clients" + ACTIVE = "active" + ERROR = "error" + STOPPING = "stopping" + STOPPED = "stopped" + BUFFERING = "buffering" + + # States before a channel is fully active. Used by the stream manager + # finally block to decide whether a failed stream can write ERROR. + PRE_ACTIVE = frozenset([INITIALIZING, CONNECTING, BUFFERING, WAITING_FOR_CLIENTS]) + +# Event types +class EventType: + STREAM_SWITCH = "stream_switch" + STREAM_SWITCHED = "stream_switched" + CHANNEL_STOP = "channel_stop" + CHANNEL_STOPPED = "channel_stopped" + CLIENT_CONNECTED = "client_connected" + CLIENT_DISCONNECTED = "client_disconnected" + CLIENT_STOP = "client_stop" + +# Stream types +class StreamType: + HLS = "hls" + RTSP = "rtsp" + UDP = "udp" + TS = "ts" + UNKNOWN = "unknown" + +# Channel metadata field names stored in Redis +class ChannelMetadataField: + # Basic fields + URL = "url" + USER_AGENT = "user_agent" + STATE = "state" + OWNER = "owner" + STREAM_ID = "stream_id" + + # Profile fields + STREAM_PROFILE = "stream_profile" + M3U_PROFILE = "m3u_profile" + + # Status and error fields + ERROR_MESSAGE = "error_message" + ERROR_TIME = "error_time" + STATE_CHANGED_AT = "state_changed_at" + INIT_TIME = "init_time" + CONNECTION_READY_TIME = "connection_ready_time" + + # Buffer and data tracking + BUFFER_CHUNKS = "buffer_chunks" + TOTAL_BYTES = "total_bytes" + + # Stream switching + STREAM_SWITCH_TIME = "stream_switch_time" + STREAM_SWITCH_REASON = "stream_switch_reason" + + # FFmpeg performance metrics + FFMPEG_SPEED = "ffmpeg_speed" + FFMPEG_FPS = "ffmpeg_fps" + ACTUAL_FPS = "actual_fps" + FFMPEG_OUTPUT_BITRATE = "ffmpeg_output_bitrate" + FFMPEG_BITRATE = "ffmpeg_bitrate" + FFMPEG_STATS_UPDATED = "ffmpeg_stats_updated" + + # Video stream info + VIDEO_CODEC = "video_codec" + RESOLUTION = "resolution" + WIDTH = "width" + HEIGHT = "height" + SOURCE_FPS = "source_fps" + PIXEL_FORMAT = "pixel_format" + VIDEO_BITRATE = "video_bitrate" + SOURCE_BITRATE = "source_bitrate" + + # Audio stream info + AUDIO_CODEC = "audio_codec" + SAMPLE_RATE = "sample_rate" + AUDIO_CHANNELS = "audio_channels" + AUDIO_BITRATE = "audio_bitrate" + + # Stream format info + STREAM_TYPE = "stream_type" + # Stream info timestamp + STREAM_INFO_UPDATED = "stream_info_updated" + + # Client metadata fields + CONNECTED_AT = "connected_at" + LAST_ACTIVE = "last_active" + BYTES_SENT = "bytes_sent" + AVG_RATE_KBPS = "avg_rate_KBps" + CURRENT_RATE_KBPS = "current_rate_KBps" + IP_ADDRESS = "ip_address" + WORKER_ID = "worker_id" + CHUNKS_SENT = "chunks_sent" + STATS_UPDATED_AT = "stats_updated_at" + +# TS packet constants +TS_PACKET_SIZE = 188 +TS_SYNC_BYTE = 0x47 +NULL_PID_HIGH = 0x1F +NULL_PID_LOW = 0xFF diff --git a/apps/proxy/ts_proxy/http_streamer.py b/apps/proxy/ts_proxy/http_streamer.py new file mode 100644 index 0000000..147d2c9 --- /dev/null +++ b/apps/proxy/ts_proxy/http_streamer.py @@ -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) diff --git a/apps/proxy/ts_proxy/redis_keys.py b/apps/proxy/ts_proxy/redis_keys.py new file mode 100644 index 0000000..29846f9 --- /dev/null +++ b/apps/proxy/ts_proxy/redis_keys.py @@ -0,0 +1,96 @@ +""" +Defines Redis key patterns used throughout the TS proxy service. +Centralizing these key patterns makes it easier to maintain and change them if needed. +""" + +class RedisKeys: + @staticmethod + def channel_metadata(channel_id): + """Key for channel metadata hash""" + return f"ts_proxy:channel:{channel_id}:metadata" + + @staticmethod + def buffer_index(channel_id): + """Key for tracking buffer index""" + return f"ts_proxy:channel:{channel_id}:buffer:index" + + @staticmethod + def buffer_chunk(channel_id, chunk_index): + """Key for specific buffer chunk""" + return f"ts_proxy:channel:{channel_id}:buffer:chunk:{chunk_index}" + + @staticmethod + def buffer_chunk_prefix(channel_id): + """Prefix for buffer chunks""" + return f"ts_proxy:channel:{channel_id}:buffer:chunk:" + + @staticmethod + def channel_stopping(channel_id): + """Key indicating channel is stopping""" + return f"ts_proxy:channel:{channel_id}:stopping" + + @staticmethod + def client_stop(channel_id, client_id): + """Key requesting client stop""" + return f"ts_proxy:channel:{channel_id}:client:{client_id}:stop" + + @staticmethod + def events_channel(channel_id): + """PubSub channel for events""" + return f"ts_proxy:events:{channel_id}" + + @staticmethod + def switch_request(channel_id): + """Key for stream switch request""" + return f"ts_proxy:channel:{channel_id}:switch_request" + + @staticmethod + def channel_owner(channel_id): + """Key for storing channel owner worker ID""" + return f"ts_proxy:channel:{channel_id}:owner" + + @staticmethod + def clients(channel_id): + """Key for set of client IDs""" + return f"ts_proxy:channel:{channel_id}:clients" + + @staticmethod + def last_client_disconnect(channel_id): + """Key for last client disconnect timestamp""" + return f"ts_proxy:channel:{channel_id}:last_client_disconnect_time" + + @staticmethod + def connection_attempt(channel_id): + """Key for connection attempt timestamp""" + return f"ts_proxy:channel:{channel_id}:connection_attempt_time" + + @staticmethod + def last_data(channel_id): + """Key for last data timestamp""" + return f"ts_proxy:channel:{channel_id}:last_data" + + @staticmethod + def switch_status(channel_id): + """Key for stream switch status""" + return f"ts_proxy:channel:{channel_id}:switch_status" + + @staticmethod + def worker_heartbeat(worker_id): + """Key for worker heartbeat""" + return f"ts_proxy:worker:{worker_id}:heartbeat" + + @staticmethod + def chunk_timestamps(channel_id): + """Sorted set mapping chunk receive-timestamps (score) to chunk indices (member). + Used for time-based client positioning.""" + return f"ts_proxy:channel:{channel_id}:buffer:chunk_timestamps" + + @staticmethod + def transcode_active(channel_id): + """Key indicating active transcode process""" + return f"ts_proxy:channel:{channel_id}:transcode_active" + + @staticmethod + def client_metadata(channel_id, client_id): + """Key for client metadata hash""" + return f"ts_proxy:channel:{channel_id}:clients:{client_id}" diff --git a/apps/proxy/ts_proxy/server.py b/apps/proxy/ts_proxy/server.py new file mode 100644 index 0000000..b5820ca --- /dev/null +++ b/apps/proxy/ts_proxy/server.py @@ -0,0 +1,1558 @@ +""" +Transport Stream (TS) Proxy Server +Handles live TS stream proxying with support for: +- Stream switching +- Buffer management +- Multiple client connections +- Connection state tracking +""" + +import threading +import logging +import socket +import random +import time +import sys +import os +import json +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, log_system_event +from redis.exceptions import ConnectionError, TimeoutError +from .stream_manager import StreamManager +from .stream_buffer import StreamBuffer +from .client_manager import ClientManager +from .redis_keys import RedisKeys +from .constants import ChannelState, EventType, StreamType +from .config_helper import ConfigHelper +from .utils import get_logger + +logger = get_logger() + +class ProxyServer: + """Manages TS proxy server instance with worker coordination""" + _instance = None + _INITIALIZING = object() # sentinel for gevent-safe singleton + + @classmethod + def get_instance(cls): + inst = cls._instance + if inst is not None and inst is not cls._INITIALIZING: + return inst + if inst is None: + cls._instance = cls._INITIALIZING + try: + from .server import ProxyServer + from .stream_manager import StreamManager + from .stream_buffer import StreamBuffer + from .client_manager import ClientManager + real_instance = ProxyServer() + cls._instance = real_instance + return real_instance + except Exception: + cls._instance = None # Reset so next call can retry + raise + # Another greenlet is initializing — wait for completion + while True: + inst = cls._instance + if inst is not None and inst is not cls._INITIALIZING: + return inst + gevent.sleep(0.05) + + def __init__(self): + """Initialize proxy server with worker identification""" + self.stream_managers = {} + self.stream_buffers = {} + self.client_managers = {} + + # Generate a unique worker ID + import socket + import os + pid = os.getpid() + hostname = socket.gethostname() + self.worker_id = f"{hostname}:{pid}" + + # Connect to Redis - use dedicated client for proxy + self.redis_client = None + self.redis_connection_attempts = 0 + self.redis_max_retries = 3 + self.redis_retry_interval = 5 # seconds + + try: + # Use dedicated Redis client for proxy + self.redis_client = RedisClient.get_client() + if self.redis_client is not None: + logger.info(f"Using dedicated Redis client for proxy server") + logger.info(f"Worker ID: {self.worker_id}") + else: + # Fall back to direct connection with retry + self._setup_redis_connection() + + except Exception as e: + logger.error(f"Failed to initialize Redis: {e}") + self.redis_client = None + + # Start cleanup thread + self.cleanup_interval = getattr(Config, 'CLEANUP_INTERVAL', 60) + self._start_cleanup_thread() + + # Start event listener for Redis pubsub messages + self._start_event_listener() + + def _setup_redis_connection(self): + """Setup Redis connection with retry logic""" + # Try to use get_redis_client utility instead of direct connection + self.redis_client = RedisClient.get_client(max_retries=self.redis_max_retries, + retry_interval=self.redis_retry_interval) + if self.redis_client: + logger.info(f"Successfully connected to Redis using utility function") + logger.info(f"Worker ID: {self.worker_id}") + else: + logger.error(f"Failed to connect to Redis after {self.redis_max_retries} attempts") + + def _execute_redis_command(self, command_func, *args, **kwargs): + """Execute Redis command with error handling and reconnection logic""" + if not self.redis_client: + return None + + try: + return command_func(*args, **kwargs) + except (ConnectionError, TimeoutError) as e: + logger.warning(f"Redis connection lost: {e}. Attempting to reconnect...") + try: + # Try to reconnect + self.redis_connection_attempts = 0 + self._setup_redis_connection() + if self.redis_client: + # Retry the command once + return command_func(*args, **kwargs) + except Exception as reconnect_error: + logger.error(f"Failed to reconnect to Redis: {reconnect_error}") + return None + except Exception as e: + logger.error(f"Redis command error: {e}") + return None + + def _start_event_listener(self): + """Listen for events from other workers""" + if not self.redis_client: + return + + def event_listener(): + retry_count = 0 + 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: + # Use dedicated PubSub client for event listener + pubsub_client = RedisClient.get_pubsub_client() + if pubsub_client: + logger.info("Using dedicated Redis PubSub client for event listener") + else: + # Fall back to creating a dedicated client if utility fails + logger.warning("Utility function for PubSub client failed, creating direct connection") + from django.conf import settings + import redis + + redis_host = os.environ.get("REDIS_HOST", getattr(settings, 'REDIS_HOST', 'localhost')) + redis_port = int(os.environ.get("REDIS_PORT", getattr(settings, 'REDIS_PORT', 6379))) + redis_db = int(os.environ.get("REDIS_DB", getattr(settings, 'REDIS_DB', 0))) + redis_password = os.environ.get("REDIS_PASSWORD", getattr(settings, 'REDIS_PASSWORD', '')) + redis_user = os.environ.get("REDIS_USER", getattr(settings, 'REDIS_USER', '')) + + ssl_params = getattr(settings, 'REDIS_SSL_PARAMS', {}) + pubsub_client = redis.Redis( + host=redis_host, + port=redis_port, + db=redis_db, + password=redis_password if redis_password else None, + username=redis_user if redis_user else None, + socket_timeout=60, + socket_connect_timeout=10, + socket_keepalive=True, + health_check_interval=30, + decode_responses=True, + **ssl_params + ) + logger.info("Created fallback Redis PubSub client for event listener") + + # Test connection before subscribing + pubsub_client.ping() + + # Create a pubsub instance from the client + pubsub = pubsub_client.pubsub() + pubsub.psubscribe("ts_proxy:events:*") + + logger.info(f"Started Redis event listener for client activity") + + # Reset retry count on successful connection + retry_count = 0 + + for message in pubsub.listen(): + if message["type"] != "pmessage": + continue + + try: + channel = message["channel"] + data = json.loads(message["data"]) + + event_type = data.get("event") + channel_id = data.get("channel_id") + + if channel_id and event_type: + # For owner, update client status immediately + if self.am_i_owner(channel_id): + if event_type == EventType.CLIENT_CONNECTED: + logger.debug(f"Owner received {EventType.CLIENT_CONNECTED} event for channel {channel_id}") + # Reset any disconnect timer + disconnect_key = RedisKeys.last_client_disconnect(channel_id) + self.redis_client.delete(disconnect_key) + + elif event_type == EventType.CLIENT_DISCONNECTED: + 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: + logger.info(f"Owner received {EventType.STREAM_SWITCH} request for channel {channel_id}") + # Handle stream switch request + new_url = data.get("url") + user_agent = data.get("user_agent") + event_stream_id = data.get("stream_id") + event_m3u_profile_id = data.get("m3u_profile_id") + + if new_url and channel_id in self.stream_managers: + # Mark the switch as in-progress in Redis so other workers know to wait + if self.redis_client: + status_key = RedisKeys.switch_status(channel_id) + self.redis_client.set(status_key, "switching") + + # Perform the stream switch, forwarding stream_id and m3u_profile_id + stream_manager = self.stream_managers[channel_id] + success = stream_manager.update_url(new_url, event_stream_id, event_m3u_profile_id) + + if success: + logger.info(f"Stream switch initiated for channel {channel_id}") + + # Confirm the URL in metadata now that the switch happened + if self.redis_client: + metadata_key = RedisKeys.channel_metadata(channel_id) + self.redis_client.hset(metadata_key, "url", new_url) + if user_agent: + self.redis_client.hset(metadata_key, "user_agent", user_agent) + + # Publish confirmation + switch_result = { + "event": EventType.STREAM_SWITCHED, # Use constant instead of string + "channel_id": channel_id, + "success": True, + "url": new_url, + "timestamp": time.time() + } + self.redis_client.publish( + f"ts_proxy:events:{channel_id}", + json.dumps(switch_result) + ) + + # Update status + if self.redis_client: + self.redis_client.set(status_key, "switched") + else: + logger.error(f"Failed to switch stream for channel {channel_id}") + + # Roll back the URL in metadata to what the manager will + # actually reconnect to. The non-owner may have pre-written + # the desired URL; use stream_manager.url (the ground truth) + # so Redis is consistent with the live stream. + if self.redis_client: + metadata_key = RedisKeys.channel_metadata(channel_id) + self.redis_client.hset(metadata_key, "url", stream_manager.url) + + # Publish failure + switch_result = { + "event": EventType.STREAM_SWITCHED, + "channel_id": channel_id, + "success": False, + "url": new_url, + "timestamp": time.time() + } + self.redis_client.publish( + f"ts_proxy:events:{channel_id}", + json.dumps(switch_result) + ) + elif event_type == EventType.CHANNEL_STOP: + logger.info(f"Received {EventType.CHANNEL_STOP} event for channel {channel_id}") + # First mark channel as stopping in Redis + if self.redis_client: + # Set stopping state in metadata + metadata_key = RedisKeys.channel_metadata(channel_id) + if self.redis_client.exists(metadata_key): + self.redis_client.hset(metadata_key, mapping={ + "state": ChannelState.STOPPING, + "state_changed_at": str(time.time()) + }) + + # If we have local resources for this channel, clean them up + if channel_id in self.stream_buffers or channel_id in self.client_managers: + # Use existing stop_channel method + logger.info(f"Stopping local resources for channel {channel_id}") + self.stop_channel(channel_id) + + # Acknowledge stop by publishing a response + stop_response = { + "event": EventType.CHANNEL_STOPPED, + "channel_id": channel_id, + "worker_id": self.worker_id, + "timestamp": time.time() + } + self.redis_client.publish( + f"ts_proxy:events:{channel_id}", + json.dumps(stop_response) + ) + elif event_type == EventType.CLIENT_STOP: + client_id = data.get("client_id") + if client_id and channel_id: + logger.info(f"Received request to stop client {client_id} on channel {channel_id}") + + # Both remove from client manager AND set a key for the generator to detect + if channel_id in self.client_managers: + client_manager = self.client_managers[channel_id] + if client_id in client_manager.clients: + client_manager.remove_client(client_id) + logger.info(f"Removed client {client_id} from client manager") + + # Set a Redis key for the generator to detect + if self.redis_client: + stop_key = RedisKeys.client_stop(channel_id, client_id) + self.redis_client.setex(stop_key, 30, "true") # 30 second TTL + logger.info(f"Set stop key for client {client_id}") + except Exception as e: + logger.error(f"Error processing event message: {e}") + + except (ConnectionError, TimeoutError) as e: + # Calculate exponential backoff with jitter + retry_count += 1 + delay = min(base_retry_delay * (2 ** (retry_count - 1)), max_retry_delay) + # Add some randomness to prevent thundering herd + jitter = random.uniform(0, 0.5 * delay) + final_delay = delay + jitter + + 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) + + 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() + + def get_channel_owner(self, channel_id): + """Get the worker ID that owns this channel with proper error handling""" + if not self.redis_client: + return None + + try: + lock_key = RedisKeys.channel_owner(channel_id) + result = self._execute_redis_command( + lambda: self.redis_client.get(lock_key) + ) + if result is None: + return None + try: + return result + except (AttributeError, UnicodeDecodeError) as e: + logger.error(f"Error decoding channel owner for {channel_id}: {e}, raw={result!r}") + return None + except Exception as e: + logger.error(f"Error getting channel owner: {e}") + return None + + def am_i_owner(self, channel_id): + """Check if this worker is the owner of the channel""" + owner = self.get_channel_owner(channel_id) + return owner == self.worker_id + + def try_acquire_ownership(self, channel_id, ttl=30): + """Try to become the owner of this channel using proper locking""" + if not self.redis_client: + return True # If no Redis, always become owner + + try: + # Create a lock key with proper namespace + lock_key = RedisKeys.channel_owner(channel_id) + + # Use atomic SET NX EX for locking with error handling + acquired = self._execute_redis_command( + lambda: self.redis_client.set(lock_key, self.worker_id, nx=True, ex=ttl) + ) + + if acquired is None: # Redis command failed + logger.warning(f"Redis command failed during ownership acquisition - assuming ownership") + return True + + if acquired: + logger.info(f"Worker {self.worker_id} acquired ownership of channel {channel_id}") + return True + + # If not acquired, check if we already own it (might be a retry) + current_owner = self._execute_redis_command( + lambda: self.redis_client.get(lock_key) + ) + if current_owner and current_owner == self.worker_id: + # Refresh TTL + self._execute_redis_command( + lambda: self.redis_client.expire(lock_key, ttl) + ) + logger.info(f"Worker {self.worker_id} refreshed ownership of channel {channel_id}") + return True + + # Someone else owns it + return False + + except Exception as e: + logger.error(f"Error acquiring channel ownership: {e}") + return False + + def release_ownership(self, channel_id): + """Release ownership of this channel safely""" + if not self.redis_client: + return + + try: + lock_key = RedisKeys.channel_owner(channel_id) + + # Only delete if we're the current owner to prevent race conditions + current = self.redis_client.get(lock_key) + if current and current == self.worker_id: + self.redis_client.delete(lock_key) + logger.info(f"Released ownership of channel {channel_id}") + + # Also ensure channel stopping key is set to signal clients + stop_key = RedisKeys.channel_stopping(channel_id) + self.redis_client.setex(stop_key, 30, "true") + logger.info(f"Set stopping signal for channel {channel_id} clients") + + except Exception as e: + logger.error(f"Error releasing channel ownership: {e}") + + def extend_ownership(self, channel_id, ttl=30): + """Extend ownership lease, re-acquiring if key expired""" + if not self.redis_client: + return False + + try: + lock_key = RedisKeys.channel_owner(channel_id) + current = self.redis_client.get(lock_key) + + if current is None: + # Key expired — re-acquire if we have the stream_manager + if channel_id in self.stream_managers: + acquired = self.redis_client.set(lock_key, self.worker_id, nx=True, ex=ttl) + if acquired: + logger.warning(f"Re-acquired expired ownership for channel {channel_id}") + return True + else: + new_owner = self.redis_client.get(lock_key) + logger.warning(f"Could not re-acquire ownership for {channel_id}, new owner: {new_owner}") + return False + return False + + if current == self.worker_id: + self.redis_client.expire(lock_key, ttl) + return True + + return False + except Exception as e: + logger.error(f"Error extending ownership: {e}") + return False + + def initialize_channel(self, url, channel_id, user_agent=None, transcode=False, stream_id=None): + """Initialize a channel without redundant active key""" + try: + # IMPROVED: First check if channel is already being initialized by another process + if self.redis_client: + metadata_key = RedisKeys.channel_metadata(channel_id) + if self.redis_client.exists(metadata_key): + metadata = self.redis_client.hgetall(metadata_key) + if 'state' in metadata: + state = metadata['state'] + active_states = [ChannelState.INITIALIZING, ChannelState.CONNECTING, + ChannelState.WAITING_FOR_CLIENTS, ChannelState.ACTIVE, ChannelState.BUFFERING] + if state in active_states: + logger.info(f"Channel {channel_id} already being initialized with state {state}") + # Create buffer and client manager only if we don't have them + if channel_id not in self.stream_buffers: + self.stream_buffers[channel_id] = StreamBuffer(channel_id, redis_client=RedisClient.get_buffer()) + if channel_id not in self.client_managers: + self.client_managers[channel_id] = ClientManager( + channel_id, + redis_client=self.redis_client, + worker_id=self.worker_id + ) + return True + + # 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=RedisClient.get_buffer()) + self.stream_buffers[channel_id] = buffer + + 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: + # Set early initialization state to prevent race conditions + metadata_key = RedisKeys.channel_metadata(channel_id) + initial_metadata = { + "state": ChannelState.INITIALIZING, + "init_time": str(time.time()), + "owner": self.worker_id + } + if stream_id: + initial_metadata["stream_id"] = str(stream_id) + self.redis_client.hset(metadata_key, mapping=initial_metadata) + logger.info(f"Set early initializing state for channel {channel_id}") + + # Get channel URL from Redis if available + channel_url = url + channel_user_agent = user_agent + channel_stream_id = stream_id # Store the stream ID + + # First check if channel metadata already exists + existing_metadata = None + metadata_key = RedisKeys.channel_metadata(channel_id) + + if self.redis_client: + existing_metadata = self.redis_client.hgetall(metadata_key) + + # If no url was passed, try to get from Redis + if not url and existing_metadata: + url_bytes = existing_metadata.get('url') + if url_bytes: + channel_url = url_bytes + + ua_bytes = existing_metadata.get('user_agent') + if ua_bytes: + channel_user_agent = ua_bytes + + # Get stream ID from metadata if not provided + if not channel_stream_id and 'stream_id' in existing_metadata: + try: + channel_stream_id = int(existing_metadata['stream_id']) + logger.debug(f"Found stream_id {channel_stream_id} in metadata for channel {channel_id}") + except (ValueError, TypeError) as e: + logger.debug(f"Could not parse stream_id from metadata: {e}") + + # Check if channel is already owned + current_owner = self.get_channel_owner(channel_id) + + # Exit early if another worker owns the channel + if current_owner and current_owner != self.worker_id: + 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 (only if not already exists) + if channel_id not in self.stream_buffers: + buffer = StreamBuffer(channel_id=channel_id, redis_client=RedisClient.get_buffer()) + self.stream_buffers[channel_id] = buffer + + # 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 + + # Only continue with full initialization if URL is provided + # or we can get it from Redis + if not channel_url: + logger.error(f"No URL available for channel {channel_id}") + return False + + # Try to acquire ownership with Redis locking + if not self.try_acquire_ownership(channel_id): + # Another worker just acquired ownership + logger.info(f"Another worker just acquired ownership of channel {channel_id}") + + # 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=RedisClient.get_buffer()) + self.stream_buffers[channel_id] = buffer + + # 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 + + # We now own the channel - ONLY NOW should we set metadata with initializing state + logger.info(f"Worker {self.worker_id} is now the owner of channel {channel_id}") + + if self.redis_client: + # NOW create or update metadata with initializing state + metadata = { + "url": channel_url, + "init_time": str(time.time()), + "last_active": str(time.time()), + "owner": self.worker_id, + "state": ChannelState.INITIALIZING # Use constant instead of string literal + } + if channel_user_agent: + metadata["user_agent"] = channel_user_agent + + # 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}") + else: + logger.warning(f"No stream_id provided for channel {channel_id} during initialization") + + # Set channel metadata BEFORE creating the StreamManager + self.redis_client.hset(metadata_key, mapping=metadata) + self.redis_client.expire(metadata_key, 3600) # Increased TTL from 30 seconds to 1 hour + + # Verify the stream_id was set correctly in Redis + stream_id_value = self.redis_client.hget(metadata_key, "stream_id") + if stream_id_value: + logger.info(f"Verified stream_id {stream_id_value} is set in Redis for channel {channel_id}") + else: + logger.warning(f"Failed to set stream_id in Redis for channel {channel_id}") + + # Create stream buffer + buffer = StreamBuffer(channel_id=channel_id, redis_client=RedisClient.get_buffer()) + logger.debug(f"Created StreamBuffer for channel {channel_id}") + self.stream_buffers[channel_id] = buffer + + # Only the owner worker creates the actual stream manager + stream_manager = StreamManager( + channel_id, + channel_url, + buffer, + user_agent=channel_user_agent, + transcode=transcode, + stream_id=channel_stream_id, # Pass stream ID to the manager + worker_id=self.worker_id # Pass worker_id explicitly to eliminate circular dependency + ) + logger.info(f"Created StreamManager for channel {channel_id} with stream ID {channel_stream_id}") + self.stream_managers[channel_id] = stream_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) + thread.name = f"stream-{channel_id}" + thread.start() + logger.info(f"Started stream manager thread for channel {channel_id}") + + # If we're the owner, we need to set the channel state rather than starting a grace period immediately + if self.am_i_owner(channel_id): + self.update_channel_state(channel_id, ChannelState.CONNECTING, { + "init_time": str(time.time()), + "owner": self.worker_id + }) + + # Set connection attempt start time + attempt_key = RedisKeys.connection_attempt(channel_id) + self.redis_client.setex(attempt_key, 60, str(time.time())) + + logger.info(f"Channel {channel_id} in {ChannelState.CONNECTING} state - will start grace period after connection") + return True + + except Exception as e: + logger.error(f"Error initializing channel {channel_id}: {e}", exc_info=True) + # Release ownership on failure + self.release_ownership(channel_id) + return False + + def check_if_channel_exists(self, channel_id): + """ + Check if a channel exists and is in a valid state. + Enhanced to detect zombie channels after server restarts. + """ + # Check local memory first + if channel_id in self.stream_managers or channel_id in self.stream_buffers: + return True + + # Check Redis using the standard key pattern + if self.redis_client: + # Primary check - look for channel metadata + metadata_key = RedisKeys.channel_metadata(channel_id) + + # If metadata exists, validate it's in a healthy state + if self.redis_client.exists(metadata_key): + metadata = self.redis_client.hgetall(metadata_key) + + # Get channel state and owner + state = metadata.get('state', 'unknown') + owner = metadata.get('owner', '') + + # 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.STOPPING] + + # If the channel is in a valid state, check if the owner is still active + if state in valid_states: + # Check if owner still exists by checking heartbeat + owner_heartbeat_key = f"ts_proxy:worker:{owner}:heartbeat" + owner_alive = self.redis_client.exists(owner_heartbeat_key) + + if owner_alive: + return True + 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.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 'state_changed_at' in metadata: + state_changed_at = float(metadata['state_changed_at']) + state_age = time.time() - state_changed_at + + # If in initializing state for too long, consider it stale + if state_age > 60: # 60 seconds threshold + logger.warning(f"Channel {channel_id} stuck in {state} state for {state_age:.1f}s - treating as zombie") + self._clean_zombie_channel(channel_id, metadata) + return False + + # Otherwise assume it's still in progress + return True + + # Additional checks if metadata doesn't exist + additional_keys = [ + RedisKeys.clients(channel_id), + RedisKeys.buffer_index(channel_id), + RedisKeys.channel_owner(channel_id) + ] + + for key in additional_keys: + if self.redis_client.exists(key): + # Found orphaned keys without metadata - clean them up + logger.warning(f"Found orphaned keys for channel {channel_id} without metadata - cleaning up") + try: + self._clean_redis_keys(channel_id) + except Exception as e: + logger.error(f"Error cleaning redis keys for channel {channel_id}: {e}") + return False + + return False + + def _clean_zombie_channel(self, channel_id, metadata=None): + """Clean up a zombie channel (channel with Redis keys but no active owner)""" + try: + logger.info(f"Cleaning up zombie channel {channel_id}") + + # If we have metadata, log details for debugging + if metadata: + state = metadata.get('state', 'unknown') + owner = metadata.get('owner', 'unknown') + logger.info(f"Zombie channel details - state: {state}, owner: {owner}") + + # Clean up Redis keys + self._clean_redis_keys(channel_id) + + # Force release resources in the Channel model + try: + channel = Channel.objects.get(uuid=channel_id) + if not channel.release_stream(): + logger.warning(f"Failed to release stream for zombie channel {channel_id}") + else: + logger.info(f"Released stream allocation for zombie channel {channel_id}") + except Exception as e: + try: + stream = Stream.objects.get(stream_hash=channel_id) + if not stream.release_stream(): + logger.warning(f"Failed to release stream for zombie channel {channel_id}") + else: + logger.info(f"Released stream allocation for zombie channel {channel_id}") + except Exception as e: + logger.error(f"Error releasing stream for zombie channel {channel_id}: {e}") + + return True + except Exception as e: + 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: + logger.info(f"Stopping channel {channel_id}") + + # First set a stopping key that clients will check + if self.redis_client: + stop_key = RedisKeys.channel_stopping(channel_id) + self.redis_client.setex(stop_key, 10, "true") + + # Only stop the actual stream manager if we're the owner + if self.am_i_owner(channel_id): + logger.info(f"This worker ({self.worker_id}) is the owner - closing provider connection") + if channel_id in self.stream_managers: + stream_manager = self.stream_managers[channel_id] + + # Signal thread to stop and close resources + if hasattr(stream_manager, 'stop'): + stream_manager.stop() + else: + stream_manager.running = False + if hasattr(stream_manager, '_close_socket'): + stream_manager._close_socket() + + # Wait for stream thread to finish + stream_thread_name = f"stream-{channel_id}" + stream_thread = None + + for thread in threading.enumerate(): + if thread.name == stream_thread_name: + stream_thread = thread + break + + if stream_thread and stream_thread.is_alive(): + logger.info(f"Waiting for stream thread to terminate") + try: + # Very short timeout to prevent hanging the app + stream_thread.join(timeout=2.0) + if stream_thread.is_alive(): + logger.warning(f"Stream thread did not terminate within timeout") + except RuntimeError: + logger.debug(f"Could not join stream thread (may be current thread)") + + # Release ownership + 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 'init_time' in metadata: + try: + init_time = float(metadata['init_time']) + runtime = round(time.time() - init_time, 2) + except Exception: + pass + # Get total bytes transferred + if 'total_bytes' in metadata: + try: + total_bytes = int(metadata['total_bytes']) + 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] + logger.info(f"Removed stream manager for channel {channel_id}") + + # Stop buffer and ensure all its timers are cancelled - SAFE CHECK HERE + if channel_id in self.stream_buffers: + buffer = self.stream_buffers[channel_id] + # Call stop on buffer to properly shut it down + if hasattr(buffer, 'stop'): + try: + buffer.stop() + logger.debug(f"Buffer for channel {channel_id} properly stopped") + except Exception as e: + logger.error(f"Error stopping buffer: {e}") + + # Save reference and check again before deleting + try: + if channel_id in self.stream_buffers: # Check again to prevent race conditions + del self.stream_buffers[channel_id] + logger.info(f"Removed stream buffer for channel {channel_id}") + except KeyError: + logger.debug(f"Buffer for channel {channel_id} already removed") + + # 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: + logger.debug(f"Client manager for channel {channel_id} already removed") + + # Clean up Redis keys + self._clean_redis_keys(channel_id) + + return True + except Exception as e: + logger.error(f"Error stopping channel {channel_id}: {e}") + return False + + def check_inactive_channels(self): + """Check for inactive channels (no clients) and stop them""" + channels_to_stop = [] + + for channel_id, client_manager in self.client_managers.items(): + if client_manager.get_client_count() == 0: + channels_to_stop.append(channel_id) + + for channel_id in channels_to_stop: + logger.info(f"Auto-stopping inactive channel {channel_id}") + self.stop_channel(channel_id) + + def _cleanup_channel(self, channel_id: str) -> None: + """Remove channel resources""" + # Removed reference to non-existent fetch_threads collection + for collection in [self.stream_managers, self.stream_buffers, self.client_managers]: + collection.pop(channel_id, None) + + def shutdown(self) -> None: + """Stop all channels and cleanup""" + for channel_id in list(self.stream_managers.keys()): + self.stop_channel(channel_id) + + def _start_cleanup_thread(self): + """Start background thread to maintain ownership and clean up resources""" + def cleanup_task(): + while True: + try: + # Send worker heartbeat first + if self.redis_client: + worker_heartbeat_key = f"ts_proxy:worker:{self.worker_id}:heartbeat" + self._execute_redis_command( + lambda: self.redis_client.setex(worker_heartbeat_key, 30, str(time.time())) + ) + + # Refresh channel registry + self.refresh_channel_registry() + + # Create a unified list of all channels we have locally + all_local_channels = set(self.stream_managers.keys()) | set(self.client_managers.keys()) + + # Single loop through all channels - process each exactly once + for channel_id in list(all_local_channels): + if self.am_i_owner(channel_id): + # === OWNER CHANNEL HANDLING === + # Extend ownership lease + self.extend_ownership(channel_id) + + # Get channel state from metadata hash + channel_state = "unknown" + if self.redis_client: + metadata_key = RedisKeys.channel_metadata(channel_id) + metadata = self.redis_client.hgetall(metadata_key) + if metadata and 'state' in metadata: + channel_state = metadata['state'] + + # Check if channel has any clients left + total_clients = 0 + 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 + logger.info(f"Channel {channel_id} has {total_clients} clients, state: {channel_state}") + + # If in connecting or waiting_for_clients state, check grace period + if channel_state in [ChannelState.INITIALIZING, ChannelState.CONNECTING, ChannelState.WAITING_FOR_CLIENTS]: + # 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 'connection_ready_time' in metadata: + try: + connection_ready_time = float(metadata['connection_ready_time']) + except (ValueError, TypeError): + pass + + 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) + except (ValueError, TypeError): + pass + + # Also get init time as a fallback + init_time = None + if metadata and 'init_time' in metadata: + try: + init_time = float(metadata['init_time']) + 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 + + 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}") + + if time_since_ready <= grace_period: + # Still within grace period + logger.debug(f"Channel {channel_id} in grace period - {time_since_ready:.1f}s of {grace_period}s elapsed") + continue + else: + # 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") + if self.update_channel_state(channel_id, ChannelState.ACTIVE, { + "grace_period_ended_at": str(time.time()), + "clients_at_activation": str(total_clients) + }): + 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 + + if self.redis_client: + disconnect_value = self.redis_client.get(disconnect_key) + if disconnect_value: + try: + disconnect_time = float(disconnect_value) + except (ValueError, TypeError) as e: + logger.error(f"Invalid disconnect time for channel {channel_id}: {e}") + + current_time = time.time() + + if not disconnect_time: + # First time seeing zero clients, set timestamp + if self.redis_client: + self.redis_client.setex(disconnect_key, 60, str(current_time)) + logger.warning(f"No clients detected for channel {channel_id}, starting shutdown timer") + elif current_time - disconnect_time > ConfigHelper.channel_shutdown_delay(): + # We've had no clients for the shutdown delay period + logger.warning(f"No clients for {current_time - disconnect_time:.1f}s, stopping channel {channel_id}") + self.stop_channel(channel_id) + else: + # Still in shutdown delay period + logger.debug(f"Channel {channel_id} shutdown timer: " + f"{current_time - disconnect_time:.1f}s of " + f"{ConfigHelper.channel_shutdown_delay()}s elapsed") + else: + # There are clients or we're still connecting - clear any disconnect timestamp + if self.redis_client: + self.redis_client.delete(f"ts_proxy:channel:{channel_id}:last_client_disconnect_time") + + else: + # === NON-OWNER CHANNEL HANDLING === + # Safety: if we have a stream_manager, we ARE the real owner + # but the Redis key may have expired. Try to re-acquire. + if channel_id in self.stream_managers: + logger.warning( + f"Ownership gap for {channel_id}: this worker has stream_manager " + f"but am_i_owner returned False. Attempting re-acquisition." + ) + reacquired = self.extend_ownership(channel_id) + if reacquired: + logger.info(f"Successfully re-acquired ownership for {channel_id}") + continue + else: + # Defer cleanup if we still have active clients — give the + # new owner time to spin up its own stream before we tear + # ours down, so viewers don't get disconnected. + has_clients = ( + channel_id in self.client_managers + and self.client_managers[channel_id].get_client_count() > 0 + ) + if has_clients: + logger.warning( + f"Ownership lost for {channel_id} but {self.client_managers[channel_id].get_client_count()} " + f"client(s) still connected — deferring cleanup to next cycle" + ) + continue + logger.error(f"Failed to re-acquire ownership for {channel_id}, will clean up") + + # For channels we don't own, check if they've been stopped/cleaned up in Redis + if self.redis_client: + # Method 1: Check for stopping key + stop_key = RedisKeys.channel_stopping(channel_id) + if self.redis_client.exists(stop_key): + logger.debug(f"Non-owner cleanup: Channel {channel_id} has stopping flag in Redis, cleaning up local resources") + self._cleanup_local_resources(channel_id) + continue + + # Method 2: Check if owner still exists + owner_key = RedisKeys.channel_owner(channel_id) + if not self.redis_client.exists(owner_key): + logger.debug(f"Non-owner cleanup: Channel {channel_id} has no owner in Redis, cleaning up local resources") + self._cleanup_local_resources(channel_id) + continue + + # Method 3: Check if metadata still exists + metadata_key = RedisKeys.channel_metadata(channel_id) + if not self.redis_client.exists(metadata_key): + logger.debug(f"Non-owner cleanup: Channel {channel_id} has no metadata in Redis, cleaning up local resources") + self._cleanup_local_resources(channel_id) + continue + + # Check for local client count - if zero, clean up our 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) + thread.name = "ts-proxy-cleanup" + thread.start() + logger.info(f"Started TS proxy cleanup thread (interval: {ConfigHelper.cleanup_check_interval()}s)") + + def _check_orphaned_channels(self): + """Check for orphaned channels in Redis (owner worker crashed)""" + if not self.redis_client: + return + + try: + # Get all active channel keys + channel_pattern = "ts_proxy:channel:*:metadata" + channel_keys = self.redis_client.keys(channel_pattern) + + for key in channel_keys: + try: + channel_id = key.split(':')[2] + + # Check if this channel has an owner + owner = self.get_channel_owner(channel_id) + + if not owner: + # Check if there are any clients + client_set_key = RedisKeys.clients(channel_id) + client_count = self.redis_client.scard(client_set_key) or 0 + + if client_count > 0: + # Orphaned channel with clients - we could take ownership + logger.info(f"Found orphaned channel {channel_id} with {client_count} clients") + else: + # Orphaned channel with no clients - clean it up + logger.info(f"Cleaning up orphaned channel {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.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('owner', '') if '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('state', '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: + # SCARD may include ghost entries from a dead worker's + # expired metadata hashes. Validate before deciding. + stale_ids = ClientManager.remove_ghost_clients( + self.redis_client, channel_id + ) + real_count = max(0, client_count - len(stale_ids)) + if real_count <= 0: + # No real clients remain — safe to clean up. + state = metadata.get('state', 'unknown') + logger.warning( + f"Orphaned channel {channel_id} (state: {state}, " + f"owner: {owner}) had {client_count} ghost client(s) " + f"- cleaning up" + ) + 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) + else: + logger.warning( + f"Orphaned channel {channel_id} still has " + f"{real_count} live client(s) after ghost removal " + f"- 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 + try: + channel = Channel.objects.get(uuid=channel_id) + if not channel.release_stream(): + logger.debug(f"Channel {channel_id}: release_stream found no keys to clean") + except (Channel.DoesNotExist, Exception): + try: + stream = Stream.objects.get(stream_hash=channel_id) + if not stream.release_stream(): + logger.debug(f"Stream {channel_id}: release_stream found no keys to clean") + except (Stream.DoesNotExist, Exception): + logger.debug(f"No Channel or Stream found for {channel_id}") + + if not self.redis_client: + return 0 + + try: + # Define key patterns to scan for + patterns = [ + f"ts_proxy:channel:{channel_id}:*", # All channel keys + RedisKeys.events_channel(channel_id) # Event channel + ] + + total_deleted = 0 + + for pattern in patterns: + cursor = 0 + while True: + cursor, keys = self.redis_client.scan(cursor, match=pattern, count=100) + if keys: + self.redis_client.delete(*keys) + total_deleted += len(keys) + + # Exit when cursor returns to 0 + if cursor == 0: + break + + logger.info(f"Cleaned up {total_deleted} Redis keys for channel {channel_id}") + return total_deleted + + except Exception as e: + logger.error(f"Error cleaning Redis keys for channel {channel_id}: {e}") + return 0 + + def refresh_channel_registry(self): + """Refresh TTL for active channels using standard keys""" + if not self.redis_client: + return + + # Refresh registry entries for channels we own + for channel_id in list(self.stream_buffers.keys()): + # Use standard key pattern + metadata_key = RedisKeys.channel_metadata(channel_id) + + # Update activity timestamp in metadata only + self.redis_client.hset(metadata_key, "last_active", str(time.time())) + self.redis_client.expire(metadata_key, 30) # Reset TTL on metadata hash + logger.debug(f"Refreshed metadata TTL for channel {channel_id}") + + def update_channel_state(self, channel_id, new_state, additional_fields=None): + """Update channel state with proper history tracking and logging""" + if not self.redis_client: + return False + + try: + metadata_key = RedisKeys.channel_metadata(channel_id) + + # Get current state for logging + current_state = None + metadata = self.redis_client.hgetall(metadata_key) + if metadata and 'state' in metadata: + current_state = metadata['state'] + + # Only update if state is actually changing + if current_state == new_state: + logger.debug(f"Channel {channel_id} state unchanged: {current_state}") + return True + + # Prepare update data + update_data = { + "state": new_state, + "state_changed_at": str(time.time()) + } + + # Add optional additional fields + if additional_fields: + update_data.update(additional_fields) + + # Update the metadata + self.redis_client.hset(metadata_key, mapping=update_data) + + # Log the transition + logger.info(f"Channel {channel_id} state transition: {current_state or 'None'} -> {new_state}") + return True + except Exception as e: + logger.error(f"Error updating channel state: {e}") + return False + + def _cleanup_local_resources(self, channel_id): + """Clean up local resources for a channel without affecting Redis keys""" + try: + # Clean up local objects only + if channel_id in self.stream_managers: + if hasattr(self.stream_managers[channel_id], 'stop'): + self.stream_managers[channel_id].stop() + del self.stream_managers[channel_id] + logger.info(f"Non-owner cleanup: Removed stream manager for channel {channel_id}") + + if channel_id in self.stream_buffers: + del self.stream_buffers[channel_id] + logger.info(f"Non-owner cleanup: Removed stream buffer for channel {channel_id}") + + if channel_id in self.client_managers: + del self.client_managers[channel_id] + logger.info(f"Non-owner cleanup: Removed client manager for channel {channel_id}") + + return True + except Exception as e: + logger.error(f"Error cleaning up local resources: {e}", exc_info=True) + return False diff --git a/apps/proxy/ts_proxy/services/channel_service.py b/apps/proxy/ts_proxy/services/channel_service.py new file mode 100644 index 0000000..ff6eb41 --- /dev/null +++ b/apps/proxy/ts_proxy/services/channel_service.py @@ -0,0 +1,693 @@ +""" +Channel service layer for handling business logic related to channel operations. +This separates business logic from HTTP handling in views. +""" + +import logging +import time +import json +import re +from django.shortcuts import get_object_or_404 +from apps.channels.models import Channel, Stream +from apps.proxy.config import TSConfig as Config +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") + +class ChannelService: + """Service class for channel operations""" + + @staticmethod + def initialize_channel(channel_id, stream_url, user_agent, transcode=False, stream_profile_value=None, stream_id=None, m3u_profile_id=None): + """ + Initialize a channel with the given parameters. + + Args: + channel_id: UUID of the channel + stream_url: URL of the stream + user_agent: User agent for the stream connection + transcode: Whether to transcode the stream + stream_profile_value: Stream profile value to store in metadata + stream_id: ID of the stream being used + m3u_profile_id: ID of the M3U profile being used + + Returns: + bool: Success status + """ + proxy_server = ProxyServer.get_instance() + # FIXED: First, ensure that Redis metadata including stream_id is set BEFORE channel initialization + # This ensures the stream ID is available when the StreamManager looks it up + if stream_id and proxy_server.redis_client: + metadata_key = RedisKeys.channel_metadata(channel_id) + # Check if metadata already exists + if proxy_server.redis_client.exists(metadata_key): + # Just update the existing metadata with stream_id + proxy_server.redis_client.hset(metadata_key, ChannelMetadataField.STREAM_ID, str(stream_id)) + logger.info(f"Pre-set stream ID {stream_id} in Redis for channel {channel_id}") + else: + # Create initial metadata with essential values + initial_metadata = { + ChannelMetadataField.STREAM_ID: str(stream_id), + "temp_init": str(time.time()) + } + proxy_server.redis_client.hset(metadata_key, mapping=initial_metadata) + logger.info(f"Created initial metadata with stream_id {stream_id} for channel {channel_id}") + + # Verify the stream_id was set + stream_id_value = proxy_server.redis_client.hget(metadata_key, ChannelMetadataField.STREAM_ID) + if stream_id_value: + logger.debug(f"Verified stream_id {stream_id_value} is now set in Redis") + else: + logger.error(f"Failed to set stream_id {stream_id} in Redis before initialization") + + # Now proceed with channel initialization + success = proxy_server.initialize_channel(stream_url, channel_id, user_agent, transcode, stream_id) + + # Store additional metadata if initialization was successful + if success and proxy_server.redis_client: + metadata_key = RedisKeys.channel_metadata(channel_id) + update_data = {} + if stream_profile_value: + update_data[ChannelMetadataField.STREAM_PROFILE] = stream_profile_value + if stream_id: + update_data[ChannelMetadataField.STREAM_ID] = str(stream_id) + if m3u_profile_id: + update_data[ChannelMetadataField.M3U_PROFILE] = str(m3u_profile_id) + + if update_data: + proxy_server.redis_client.hset(metadata_key, mapping=update_data) + + return success + + @staticmethod + def change_stream_url(channel_id, new_url=None, user_agent=None, target_stream_id=None, m3u_profile_id=None): + """ + Change the URL of an existing stream. + + Args: + channel_id: UUID of the channel + new_url: New stream URL (optional if target_stream_id is provided) + user_agent: Optional user agent to update + target_stream_id: Optional target stream ID to switch to + m3u_profile_id: Optional M3U profile ID to update + + Returns: + dict: Result information including success status and diagnostics + """ + proxy_server = ProxyServer.get_instance() + + # If no direct URL is provided but a target stream is, get URL from target stream + stream_id = None + if not new_url and target_stream_id: + stream_info = get_stream_info_for_switch(channel_id, target_stream_id) + if 'error' in stream_info: + return { + 'status': 'error', + 'message': stream_info['error'] + } + new_url = stream_info['url'] + user_agent = stream_info['user_agent'] + stream_id = target_stream_id + # Extract M3U profile ID from stream info if available + if 'm3u_profile_id' in stream_info: + m3u_profile_id = stream_info['m3u_profile_id'] + logger.info(f"Found M3U profile ID {m3u_profile_id} for stream ID {stream_id}") + elif target_stream_id: + # If we have both URL and target_stream_id, use the target_stream_id + stream_id = target_stream_id + + # Check if channel exists + in_local_managers = channel_id in proxy_server.stream_managers + in_local_buffers = channel_id in proxy_server.stream_buffers + + # Check Redis for keys + redis_keys = None + if proxy_server.redis_client: + try: + # This is inefficient but used for diagnostics - in production would use more targeted checks + redis_keys = proxy_server.redis_client.keys(f"ts_proxy:*:{channel_id}*") + redis_keys = [k for k in redis_keys] if redis_keys else [] + except Exception as e: + logger.error(f"Error checking Redis keys: {e}") + + # Check if channel exists using standard method + channel_exists = proxy_server.check_if_channel_exists(channel_id) + + # Log detailed diagnostics + logger.info(f"Channel {channel_id} diagnostics: " + f"in_local_managers={in_local_managers}, " + f"in_local_buffers={in_local_buffers}, " + f"redis_keys_count={len(redis_keys) if redis_keys else 0}, " + f"channel_exists={channel_exists}") + + if not channel_exists: + # Try to recover if Redis keys exist but channel check failed + if redis_keys: + logger.warning(f"Channel {channel_id} not detected but Redis keys exist. Forcing initialization.") + proxy_server.initialize_channel(new_url, channel_id, user_agent) + result = { + 'status': 'recovered', + 'message': 'Channel was recovered and initialized' + } + else: + logger.error(f"Channel {channel_id} not found in any worker or Redis") + return { + 'status': 'error', + 'message': 'Channel not found', + 'diagnostics': { + 'in_local_managers': in_local_managers, + 'in_local_buffers': in_local_buffers, + 'redis_keys': redis_keys, + } + } + else: + result = {'status': 'success'} + + # If we're the owner, update directly + if proxy_server.am_i_owner(channel_id) and channel_id in proxy_server.stream_managers: + logger.info(f"This worker is the owner, changing stream URL for channel {channel_id}") + manager = proxy_server.stream_managers[channel_id] + old_url = manager.url + + # Update the stream + success = manager.update_url(new_url, stream_id, m3u_profile_id) + logger.info(f"Stream URL changed from {old_url} to {new_url}, result: {success}") + + # Update Redis metadata based on the actual outcome. + # On success, write the new values. On failure, restore whatever URL + # the manager will actually reconnect to (may be old_url if the + # exception happened before self.url was reassigned, or new_url if it + # happened after) so Redis never describes a URL that isn't in use. + if proxy_server.redis_client: + try: + if success: + ChannelService._update_channel_metadata(channel_id, new_url, user_agent, stream_id, m3u_profile_id) + else: + ChannelService._update_channel_metadata(channel_id, manager.url, user_agent) + result['metadata_updated'] = True + except Exception as e: + logger.error(f"Error updating Redis metadata: {e}", exc_info=True) + result['metadata_updated'] = False + + result.update({ + 'direct_update': True, + 'success': success, + 'worker_id': proxy_server.worker_id + }) + else: + # Not the owner: publish the switch event. The owner will update metadata + # after the actual switch attempt succeeds (or roll back on failure). + # All needed info (url, user_agent, stream_id, m3u_profile_id) is carried + # in the pubsub message, so there is no reason to pre-write metadata here. + logger.debug(f"This worker is not the owner, publishing stream switch event for channel {channel_id}") + if proxy_server.redis_client: + ChannelService._publish_stream_switch_event(channel_id, new_url, user_agent, stream_id, m3u_profile_id) + result.update({ + 'direct_update': False, + 'event_published': True, + 'worker_id': proxy_server.worker_id + }) + else: + result.update({ + 'direct_update': False, + 'event_published': False, + 'error': 'Redis not available for pubsub' + }) + + return result + + @staticmethod + def stop_channel(channel_id): + """ + Stop a channel and release all resources. + + Args: + channel_id: UUID of the channel + + Returns: + dict: Result information including previous state if available + """ + proxy_server = ProxyServer.get_instance() + + # Check if channel exists + channel_exists = proxy_server.check_if_channel_exists(channel_id) + if not channel_exists: + logger.warning(f"Channel {channel_id} not found in any worker or Redis") + return {'status': 'error', 'message': 'Channel not found'} + + # Get channel state information for result + channel_info = None + if proxy_server.redis_client: + metadata_key = RedisKeys.channel_metadata(channel_id) + try: + metadata = proxy_server.redis_client.hgetall(metadata_key) + if metadata and 'state' in metadata: + state = metadata['state'] + channel_info = {"state": state} + + # Immediately mark as stopping in metadata so clients detect it faster + proxy_server.redis_client.hset(metadata_key, ChannelMetadataField.STATE, ChannelState.STOPPING) + proxy_server.redis_client.hset(metadata_key, ChannelMetadataField.STATE_CHANGED_AT, str(time.time())) + except Exception as e: + logger.error(f"Error fetching channel state: {e}") + + # Set stopping flag with higher TTL to ensure it persists + if proxy_server.redis_client: + stop_key = RedisKeys.channel_stopping(channel_id) + proxy_server.redis_client.setex(stop_key, 60, "true") # Higher TTL of 60 seconds + logger.info(f"Set channel stopping flag with 60s TTL for channel {channel_id}") + + # Broadcast stop event to all workers via PubSub + if proxy_server.redis_client: + ChannelService._publish_channel_stop_event(channel_id) + + # Also stop locally to ensure this worker cleans up right away + local_result = proxy_server.stop_channel(channel_id) + else: + # No Redis, just stop locally + local_result = proxy_server.stop_channel(channel_id) + + # Release the channel in the channel model if applicable + try: + channel = Channel.objects.get(uuid=channel_id) + model_released = channel.release_stream() + if model_released: + logger.info(f"Released channel {channel_id} stream allocation") + else: + logger.warning(f"Channel {channel_id}: release_stream found no keys to clean") + except (Channel.DoesNotExist, Exception): + logger.warning(f"Could not find Channel model for UUID {channel_id}, attempting stream hash") + try: + stream = Stream.objects.get(stream_hash=channel_id) + model_released = stream.release_stream() + if model_released: + logger.info(f"Released stream {channel_id} stream allocation") + else: + logger.warning(f"Stream {channel_id}: release_stream found no keys to clean") + except (Stream.DoesNotExist, Exception) as e: + logger.error(f"No Channel or Stream found for {channel_id}: {e}") + model_released = False + + return { + 'status': 'success', + 'message': 'Channel stop request sent', + 'channel_id': channel_id, + 'previous_state': channel_info, + 'model_released': model_released, + 'local_stop_result': local_result + } + + @staticmethod + def stop_client(channel_id, client_id): + """ + Stop a specific client connection. + + Args: + channel_id: UUID of the channel + client_id: ID of the client to stop + + Returns: + dict: Result information + """ + logger.info(f"Request to stop client {client_id} on channel {channel_id}") + proxy_server = ProxyServer.get_instance() + + # Set a Redis key for immediate detection + key_set = False + if proxy_server.redis_client: + stop_key = RedisKeys.client_stop(channel_id, client_id) + try: + proxy_server.redis_client.setex(stop_key, 30, "true") # 30 second TTL + logger.info(f"Set stop key for client {client_id}") + key_set = True + except Exception as e: + logger.error(f"Error setting client stop key: {e}") + + # Check if channel exists + channel_exists = proxy_server.check_if_channel_exists(channel_id) + if not channel_exists: + logger.warning(f"Channel {channel_id} not found") + return { + 'status': 'error', + 'message': 'Channel not found', + 'stop_key_set': key_set + } + + # Try to stop locally if client is on this worker + local_client_stopped = False + if channel_id in proxy_server.client_managers: + client_manager = proxy_server.client_managers[channel_id] + with client_manager.lock: + if client_id in client_manager.clients: + client_manager.remove_client(client_id) + local_client_stopped = True + logger.info(f"Client {client_id} stopped locally on channel {channel_id}") + + # If client wasn't found locally, broadcast stop event for other workers + event_published = False + if not local_client_stopped and proxy_server.redis_client: + try: + ChannelService._publish_client_stop_event(channel_id, client_id) + event_published = True + logger.info(f"Published stop request for client {client_id} on channel {channel_id}") + except Exception as e: + logger.error(f"Error publishing client stop event: {e}") + + return { + 'status': 'success', + 'message': 'Client stop request processed', + 'channel_id': channel_id, + 'client_id': client_id, + 'locally_processed': local_client_stopped, + 'stop_key_set': key_set, + 'event_published': event_published + } + + @staticmethod + def validate_channel_state(channel_id): + """ + Validate if a channel is in a healthy state and has an active owner. + + Args: + channel_id: UUID of the channel + + Returns: + tuple: (valid, state, owner, details) - validity status, current state, owner, and diagnostic info + """ + proxy_server = ProxyServer.get_instance() + + if not proxy_server.redis_client: + return False, None, None, {"error": "Redis not available"} + + try: + metadata_key = RedisKeys.channel_metadata(channel_id) + if not proxy_server.redis_client.exists(metadata_key): + return False, None, None, {"error": "No channel metadata"} + + metadata = proxy_server.redis_client.hgetall(metadata_key) + + # Extract state and owner + state = metadata.get(ChannelMetadataField.STATE, 'unknown') + owner = metadata.get(ChannelMetadataField.OWNER, 'unknown') + + # Valid states indicate channel is running properly + valid_states = [ChannelState.ACTIVE, ChannelState.WAITING_FOR_CLIENTS, ChannelState.CONNECTING] + + if state not in valid_states: + return False, state, owner, {"error": f"Invalid state: {state}"} + + # Check if owner is still active + owner_heartbeat_key = RedisKeys.worker_heartbeat(owner) + owner_alive = proxy_server.redis_client.exists(owner_heartbeat_key) + + if not owner_alive: + return False, state, owner, {"error": "Owner not active"} + + # Check for recent activity + last_data_key = RedisKeys.last_data(channel_id) + last_data = proxy_server.redis_client.get(last_data_key) + + details = { + "state": state, + "owner": owner, + "owner_alive": owner_alive + } + + if last_data: + last_data_time = float(last_data) + data_age = time.time() - last_data_time + details["last_data_age"] = data_age + + # If no data for too long, consider invalid + if data_age > 30: # 30 seconds threshold + return False, state, owner, {"error": f"No data for {data_age:.1f}s", **details} + + return True, state, owner, details + + except Exception as e: + logger.error(f"Error validating channel state: {e}", exc_info=True) + return False, None, None, {"error": f"Exception: {str(e)}"} + + @staticmethod + 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: + # Use factory to parse the line based on stream type + parsed_data = LogParserFactory.parse(stream_type, stream_info_line) + + if not parsed_data: + return + + # 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') + ) + + 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 {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): + """Update stream info in Redis metadata""" + try: + proxy_server = ProxyServer.get_instance() + if not proxy_server.redis_client: + return False + + metadata_key = RedisKeys.channel_metadata(channel_id) + update_data = { + ChannelMetadataField.STREAM_INFO_UPDATED: str(time.time()) + } + + # Video info + if codec is not None: + update_data[ChannelMetadataField.VIDEO_CODEC] = str(codec) + + if resolution is not None: + update_data[ChannelMetadataField.RESOLUTION] = str(resolution) + + if width is not None: + update_data[ChannelMetadataField.WIDTH] = str(width) + + if height is not None: + update_data[ChannelMetadataField.HEIGHT] = str(height) + + if fps is not None: + update_data[ChannelMetadataField.SOURCE_FPS] = str(round(fps, 2)) + + if pixel_format is not None: + update_data[ChannelMetadataField.PIXEL_FORMAT] = str(pixel_format) + + if video_bitrate is not None: + update_data[ChannelMetadataField.VIDEO_BITRATE] = str(round(video_bitrate, 1)) + + # Audio info + if audio_codec is not None: + update_data[ChannelMetadataField.AUDIO_CODEC] = str(audio_codec) + + if sample_rate is not None: + update_data[ChannelMetadataField.SAMPLE_RATE] = str(sample_rate) + + if channels is not None: + update_data[ChannelMetadataField.AUDIO_CHANNELS] = str(channels) + + if audio_bitrate is not None: + update_data[ChannelMetadataField.AUDIO_BITRATE] = str(round(audio_bitrate, 1)) + if input_format is not None: + update_data[ChannelMetadataField.STREAM_TYPE] = str(input_format) + + proxy_server.redis_client.hset(metadata_key, mapping=update_data) + return True + + except Exception as e: + 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 + def _update_channel_metadata(channel_id, url, user_agent=None, stream_id=None, m3u_profile_id=None): + """Update channel metadata in Redis""" + proxy_server = ProxyServer.get_instance() + + if not proxy_server.redis_client: + return False + + metadata_key = RedisKeys.channel_metadata(channel_id) + + # First check if the key exists and what type it is + key_type = proxy_server.redis_client.type(metadata_key) + logger.debug(f"Redis key {metadata_key} is of type: {key_type}") + + # Build metadata update dict + metadata = {ChannelMetadataField.URL: url} + if user_agent: + metadata[ChannelMetadataField.USER_AGENT] = user_agent + if stream_id: + metadata[ChannelMetadataField.STREAM_ID] = str(stream_id) + if m3u_profile_id: + metadata[ChannelMetadataField.M3U_PROFILE] = str(m3u_profile_id) + + # Also update the stream switch time field + metadata[ChannelMetadataField.STREAM_SWITCH_TIME] = str(time.time()) + + # Use the appropriate method based on the key type + if key_type == 'hash': + proxy_server.redis_client.hset(metadata_key, mapping=metadata) + elif key_type == 'none': # Key doesn't exist yet + proxy_server.redis_client.hset(metadata_key, mapping=metadata) + else: + # If key exists with wrong type, delete it and recreate + proxy_server.redis_client.delete(metadata_key) + proxy_server.redis_client.hset(metadata_key, mapping=metadata) + + # Set switch request flag to ensure all workers see it + switch_key = RedisKeys.switch_request(channel_id) + proxy_server.redis_client.setex(switch_key, 30, url) # 30 second TTL + + logger.debug(f"Updated metadata for channel {channel_id} in Redis") + return True + + @staticmethod + def _publish_stream_switch_event(channel_id, new_url, user_agent=None, stream_id=None, m3u_profile_id=None): + """Publish a stream switch event to Redis pubsub""" + proxy_server = ProxyServer.get_instance() + + if not proxy_server.redis_client: + return False + + switch_request = { + "event": EventType.STREAM_SWITCH, + "channel_id": str(channel_id), + "url": new_url, + "user_agent": user_agent, + "stream_id": stream_id, + "m3u_profile_id": m3u_profile_id, + "requester": proxy_server.worker_id, + "timestamp": time.time() + } + + proxy_server.redis_client.publish( + RedisKeys.events_channel(channel_id), + json.dumps(switch_request) + ) + + return True + + @staticmethod + def _publish_channel_stop_event(channel_id): + """Publish a channel stop event to Redis pubsub""" + proxy_server = ProxyServer.get_instance() + + if not proxy_server.redis_client: + return False + + stop_request = { + "event": EventType.CHANNEL_STOP, + "channel_id": str(channel_id), + "requester_worker_id": proxy_server.worker_id, + "timestamp": time.time() + } + + proxy_server.redis_client.publish( + RedisKeys.events_channel(channel_id), + json.dumps(stop_request) + ) + + logger.info(f"Published channel stop event for {channel_id}") + return True + + @staticmethod + def _publish_client_stop_event(channel_id, client_id): + """Publish a client stop event to Redis pubsub""" + proxy_server = ProxyServer.get_instance() + + if not proxy_server.redis_client: + return False + + stop_request = { + "event": EventType.CLIENT_STOP, + "channel_id": str(channel_id), + "client_id": client_id, + "requester_worker_id": proxy_server.worker_id, + "timestamp": time.time() + } + + proxy_server.redis_client.publish( + RedisKeys.events_channel(channel_id), + json.dumps(stop_request) + ) + return True diff --git a/apps/proxy/ts_proxy/services/log_parsers.py b/apps/proxy/ts_proxy/services/log_parsers.py new file mode 100644 index 0000000..95ee7a0 --- /dev/null +++ b/apps/proxy/ts_proxy/services/log_parsers.py @@ -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 diff --git a/apps/proxy/ts_proxy/stream_buffer.py b/apps/proxy/ts_proxy/stream_buffer.py new file mode 100644 index 0000000..b61e779 --- /dev/null +++ b/apps/proxy/ts_proxy/stream_buffer.py @@ -0,0 +1,546 @@ +"""Buffer management for TS streams""" + +import threading +import logging +import time +from collections import deque +from typing import Optional, Deque +import random +from apps.proxy.config import TSConfig as Config +from .redis_keys import RedisKeys +from .config_helper import ConfigHelper +from .constants import TS_PACKET_SIZE +from .utils import get_logger +import gevent.event +import gevent # Make sure this import is at the top + +logger = get_logger() + +class StreamBuffer: + """Manages stream data buffering with optimized chunk storage""" + + def __init__(self, channel_id=None, redis_client=None): + self.channel_id = channel_id + self.redis_client = redis_client + self.lock = threading.Lock() + self.index = 0 + self.TS_PACKET_SIZE = TS_PACKET_SIZE + + # STANDARDIZED KEYS: Use RedisKeys class instead of hardcoded patterns + self.buffer_index_key = RedisKeys.buffer_index(channel_id) if channel_id else "" + self.buffer_prefix = RedisKeys.buffer_chunk_prefix(channel_id) if channel_id else "" + + self.chunk_ttl = ConfigHelper.redis_chunk_ttl() + + # Initialize from Redis if available + if self.redis_client and channel_id: + try: + current_index = self.redis_client.get(self.buffer_index_key) + if current_index: + self.index = int(current_index) + logger.info(f"Initialized buffer from Redis with index {self.index}") + except Exception as e: + logger.error(f"Error initializing buffer from Redis: {e}") + + self._write_buffer = bytearray() + self.target_chunk_size = ConfigHelper.get('BUFFER_CHUNK_SIZE', TS_PACKET_SIZE * 5644) # ~1MB default + + # Sorted-set key for chunk receive-timestamps (time-based positioning) + self.chunk_timestamps_key = RedisKeys.chunk_timestamps(channel_id) if channel_id else "" + + # Register Lua scripts once — subsequent calls use EVALSHA (just the + # SHA hash) instead of sending the full script text on every invocation. + if self.redis_client: + self._find_oldest_chunk_sha = self.redis_client.register_script( + self._FIND_OLDEST_CHUNK_LUA + ) + self._find_chunk_by_time_sha = self.redis_client.register_script( + self._FIND_CHUNK_BY_TIME_LUA + ) + else: + self._find_oldest_chunk_sha = None + self._find_chunk_by_time_sha = None + + # Track timers for proper cleanup + self.stopping = False + self.fill_timers = [] + self.chunk_available = gevent.event.Event() + + def add_chunk(self, chunk): + """Add data with optimized Redis storage and TS packet alignment""" + if not chunk: + return False + + try: + # Accumulate partial packets between chunks + if not hasattr(self, '_partial_packet'): + self._partial_packet = bytearray() + + # Lock the full operation to prevent race with reset_buffer_position + writes_done = 0 + with self.lock: + # Combine with any previous partial packet + combined_data = bytearray(self._partial_packet) + bytearray(chunk) + + # Calculate complete packets + complete_packets_size = (len(combined_data) // self.TS_PACKET_SIZE) * self.TS_PACKET_SIZE + + if complete_packets_size == 0: + # Not enough data for a complete packet + self._partial_packet = combined_data + return True + + # Split into complete packets and remainder + complete_packets = combined_data[:complete_packets_size] + self._partial_packet = combined_data[complete_packets_size:] + + # Add completed packets to write buffer + self._write_buffer.extend(complete_packets) + + # Only write to Redis when we have enough data for an optimized chunk + while len(self._write_buffer) >= self.target_chunk_size: + # Extract a full chunk + chunk_data = self._write_buffer[:self.target_chunk_size] + self._write_buffer = self._write_buffer[self.target_chunk_size:] + + # Write optimized chunk to Redis + if self.redis_client: + chunk_index = self.redis_client.incr(self.buffer_index_key) + chunk_key = RedisKeys.buffer_chunk(self.channel_id, chunk_index) + self.redis_client.setex(chunk_key, self.chunk_ttl, bytes(chunk_data)) + + # Record receive timestamp for time-based client positioning + if self.chunk_timestamps_key: + now = time.time() + self.redis_client.zadd(self.chunk_timestamps_key, {str(chunk_index): now}) + # Prune entries whose chunks have expired from Redis + self.redis_client.zremrangebyscore(self.chunk_timestamps_key, '-inf', now - self.chunk_ttl) + self.redis_client.expire(self.chunk_timestamps_key, self.chunk_ttl) + + # Update local tracking + self.index = chunk_index + writes_done += 1 + + if writes_done > 0: + logger.debug(f"Added {writes_done} chunks ({self.target_chunk_size} bytes each) to Redis for channel {self.channel_id} at index {self.index}") + + self.chunk_available.set() # Signal that new data is available + self.chunk_available.clear() # Reset for next notification + + return True + + except Exception as e: + logger.error(f"Error adding chunk to buffer: {e}") + return False + + def reset_buffer_position(self): + """ + Reset internal buffers for a clean stream transition (failover). + + Called by stream_manager.update_url() when switching between FFmpeg + processes. Without this, _partial_packet from the old FFmpeg gets + concatenated with the first bytes from the new FFmpeg, creating + corrupted TS packets that break audio decoder sync in the client. + """ + try: + with self.lock: + old_write_size = len(self._write_buffer) + old_partial_size = len(getattr(self, '_partial_packet', b'')) + + self._write_buffer = bytearray() + if hasattr(self, '_partial_packet'): + self._partial_packet = bytearray() + + if old_write_size > 0 or old_partial_size > 0: + logger.info( + f"Reset buffer position for channel {self.channel_id}: " + f"cleared {old_write_size} bytes from write buffer, " + f"{old_partial_size} bytes from partial packet" + ) + else: + logger.debug( + f"Reset buffer position for channel {self.channel_id}: " + f"buffers were already clean" + ) + except Exception as e: + logger.error( + f"Error resetting buffer position for channel {self.channel_id}: {e}" + ) + + def get_chunks(self, start_index=None): + """Get chunks from the buffer with detailed logging""" + try: + request_id = f"req_{random.randint(1000, 9999)}" + logger.debug(f"[{request_id}] get_chunks called with start_index={start_index}") + + if not self.redis_client: + logger.error("Redis not available, cannot retrieve chunks") + return [] + + # If no start_index provided, use most recent chunks + if start_index is None: + start_index = max(0, self.index - 10) # Start closer to current position + logger.debug(f"[{request_id}] No start_index provided, using {start_index}") + + # Get current index from Redis + current_index = int(self.redis_client.get(self.buffer_index_key) or 0) + + # Calculate range of chunks to retrieve + start_id = start_index + 1 + chunks_behind = current_index - start_id + + # Adaptive chunk retrieval based on how far behind + if chunks_behind > 100: + fetch_count = 15 + logger.debug(f"[{request_id}] Client very behind ({chunks_behind} chunks), fetching {fetch_count}") + elif chunks_behind > 50: + fetch_count = 10 + logger.debug(f"[{request_id}] Client moderately behind ({chunks_behind} chunks), fetching {fetch_count}") + elif chunks_behind > 20: + fetch_count = 5 + logger.debug(f"[{request_id}] Client slightly behind ({chunks_behind} chunks), fetching {fetch_count}") + else: + fetch_count = 3 + logger.debug(f"[{request_id}] Client up-to-date (only {chunks_behind} chunks behind), fetching {fetch_count}") + + end_id = min(current_index + 1, start_id + fetch_count) + + if start_id >= end_id: + logger.debug(f"[{request_id}] No new chunks to fetch (start_id={start_id}, end_id={end_id})") + return [] + + # Log the range we're retrieving + logger.debug(f"[{request_id}] Retrieving chunks {start_id} to {end_id-1} (total: {end_id-start_id})") + + # Directly fetch from Redis using pipeline for efficiency + pipe = self.redis_client.pipeline() + for idx in range(start_id, end_id): + chunk_key = RedisKeys.buffer_chunk(self.channel_id, idx) + pipe.get(chunk_key) + + results = pipe.execute() + + # Process results + chunks = [result for result in results if result is not None] + + # Count non-None results + found_chunks = len(chunks) + missing_chunks = len(results) - found_chunks + + if missing_chunks > 0: + logger.debug(f"[{request_id}] Missing {missing_chunks}/{len(results)} chunks in Redis") + + # Update local tracking + if chunks: + self.index = end_id - 1 + + # Final log message + chunk_sizes = [len(c) for c in chunks] + total_bytes = sum(chunk_sizes) if chunks else 0 + logger.debug(f"[{request_id}] Returning {len(chunks)} chunks ({total_bytes} bytes)") + + return chunks + + except Exception as e: + logger.error(f"Error getting chunks from buffer: {e}", exc_info=True) + return [] + + def get_chunks_exact(self, start_index, count): + """Get exactly the requested number of chunks from given index""" + try: + if not self.redis_client: + logger.error("Redis not available, cannot retrieve chunks") + return [] + + # Calculate range to retrieve + start_id = start_index + 1 + end_id = start_id + count + + # Get current buffer position + current_index = int(self.redis_client.get(self.buffer_index_key) or 0) + + # If requesting beyond current buffer, return what we have + if start_id > current_index: + return [] + + # Cap end at current buffer position + end_id = min(end_id, current_index + 1) + + # Directly fetch from Redis using pipeline + pipe = self.redis_client.pipeline() + for idx in range(start_id, end_id): + chunk_key = RedisKeys.buffer_chunk(self.channel_id, idx) + pipe.get(chunk_key) + + results = pipe.execute() + + # Filter out None results + chunks = [result for result in results if result is not None] + + # Update local index if needed + if chunks and start_id + len(chunks) - 1 > self.index: + self.index = start_id + len(chunks) - 1 + + return chunks + + except Exception as e: + logger.error(f"Error getting exact chunks: {e}", exc_info=True) + return [] + + def stop(self): + """Stop the buffer and cancel all timers""" + # Set stopping flag first to prevent new timer creation + self.stopping = True + + # Cancel all pending timers + timers_cancelled = 0 + for timer in list(self.fill_timers): + try: + if timer and not timer.dead: # Changed from timer.is_alive() + timer.kill() # Changed from timer.cancel() + timers_cancelled += 1 + except Exception as e: + logger.error(f"Error canceling timer: {e}") + + if timers_cancelled: + logger.info(f"Cancelled {timers_cancelled} buffer timers for channel {self.channel_id}") + + # Clear timer list + self.fill_timers.clear() + + try: + # Flush any remaining data in the write buffer + if hasattr(self, '_write_buffer') and len(self._write_buffer) > 0: + # Ensure remaining data is aligned to TS packets + complete_size = (len(self._write_buffer) // 188) * 188 + + if complete_size > 0: + final_chunk = self._write_buffer[:complete_size] + + # Write final chunk to Redis + with self.lock: + if self.redis_client: + try: + chunk_index = self.redis_client.incr(self.buffer_index_key) + chunk_key = f"{self.buffer_prefix}{chunk_index}" + self.redis_client.setex(chunk_key, self.chunk_ttl, bytes(final_chunk)) + self.index = chunk_index + logger.info(f"Flushed final chunk of {len(final_chunk)} bytes to Redis") + except Exception as e: + logger.error(f"Error flushing final chunk: {e}") + + # Clear buffers + self._write_buffer = bytearray() + if hasattr(self, '_partial_packet'): + self._partial_packet = bytearray() + + # Clean up the chunk timestamps sorted set + if self.redis_client and self.chunk_timestamps_key: + try: + self.redis_client.delete(self.chunk_timestamps_key) + except Exception as e: + logger.error(f"Error deleting chunk timestamps key: {e}") + + except Exception as e: + logger.error(f"Error during buffer stop: {e}") + + def get_optimized_client_data(self, client_index): + """Get optimal amount of data for client streaming based on position and target size""" + # Define limits + MIN_CHUNKS = 3 # Minimum chunks to read for efficiency + MAX_CHUNKS = 20 # Safety limit to prevent memory spikes + TARGET_SIZE = 1024 * 1024 # Target ~1MB per response (typical media buffer) + MAX_SIZE = 2 * 1024 * 1024 # Hard cap at 2MB + + # Calculate how far behind we are + chunks_behind = self.index - client_index + + # Determine optimal chunk count + if chunks_behind <= MIN_CHUNKS: + # Not much data, retrieve what's available + chunk_count = max(1, chunks_behind) + elif chunks_behind <= MAX_CHUNKS: + # Reasonable amount behind, catch up completely + chunk_count = chunks_behind + else: + # Way behind, retrieve MAX_CHUNKS to avoid memory pressure + chunk_count = MAX_CHUNKS + + # 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) + + # If we're under target and have more chunks available, get more + if total_size < TARGET_SIZE and chunks_behind > chunk_count: + # Calculate how many more chunks we can get + additional = min(MAX_CHUNKS - chunk_count, chunks_behind - chunk_count) + more_chunks = self.get_chunks_exact(client_index + chunk_count, additional) + + # Check if adding more would exceed MAX_SIZE + additional_size = sum(len(c) for c in more_chunks) + if total_size + additional_size <= MAX_SIZE: + chunks.extend(more_chunks) + chunk_count += len(more_chunks) # Fixed: count actual additional chunks retrieved + + return chunks, client_index + chunk_count + + # Lua script that runs an atomic binary search on the Redis server. + # Chunks expire in FIFO order (same TTL, sequential writes), so the + # alive range is contiguous: [oldest_surviving .. buffer_head]. + # Binary search finds the boundary in O(log N) EXISTS calls with zero + # round-trips between steps and no TOCTOU races (Lua scripts are atomic). + # + # ARGV[1] = key prefix (e.g. "ts_proxy:channel::buffer:chunk:") + # ARGV[2] = low index (client_index + 1, first chunk the client needs) + # ARGV[3] = high index (buffer head, most recent chunk) + # + # Returns: the index of the oldest existing chunk, or -1 if none exist. + _FIND_OLDEST_CHUNK_LUA = """ + local prefix = ARGV[1] + local low = tonumber(ARGV[2]) + local high = tonumber(ARGV[3]) + + if redis.call('EXISTS', prefix .. high) == 0 then + return -1 + end + + local result = high + while low <= high do + local mid = math.floor((low + high) / 2) + if redis.call('EXISTS', prefix .. mid) == 1 then + result = mid + high = mid - 1 + else + low = mid + 1 + end + end + return result + """ + + def find_oldest_available_chunk(self, client_index): + """Find the oldest (lowest-index) chunk that still exists in Redis. + + Executes an atomic Lua binary search on the Redis server — one + round-trip, ~log2(N) EXISTS calls, no TOCTOU between steps. + + The actual read attempt (get_optimized_client_data) is what + authoritatively detects expiration; this method is best-effort + positioning that self-corrects on the next iteration if the found + chunk also expires before the client can read it. + + Args: + client_index: The client's current local_index (last consumed chunk). + + Returns: + int or None: The local_index value the client should jump to + (one before the first available chunk), or None if no + chunks are available at all. + """ + if not self.redis_client: + return None + + low = client_index + 1 # First chunk the client needs + high = self.index # Latest chunk written + + if low > high: + return None + + try: + # Uses EVALSHA under the hood — sends only the SHA hash, + # not the full script text, on every call after the first. + result = self._find_oldest_chunk_sha( + args=[ + RedisKeys.buffer_chunk_prefix(self.channel_id), + low, + high, + ], + ) + + if result == -1: + return None + + # Return result - 1 so local_index points to one before the + # first available chunk (matching the "last consumed" convention). + return int(result) - 1 + + except Exception as e: + logger.error(f"Error running find_oldest_chunk Lua script for channel {self.channel_id}: {e}") + return None + + # ------------------------------------------------------------------ + # Lua script: atomic reverse-scan of the chunk_timestamps sorted set. + # Finds the chunk whose receive-timestamp is closest to (but <=) a + # target wall-clock time. Returns the chunk index or -1. + # + # KEYS[1] = chunk_timestamps sorted-set key + # ARGV[1] = target timestamp (time.time() - desired_seconds_behind) + # ------------------------------------------------------------------ + _FIND_CHUNK_BY_TIME_LUA = """ + local ts_key = KEYS[1] + local target = tonumber(ARGV[1]) + + -- ZREVRANGEBYSCORE returns members with score <= target, highest first. + local result = redis.call('ZREVRANGEBYSCORE', ts_key, target, '-inf', 'LIMIT', 0, 1) + if #result == 0 then + return -1 + end + return tonumber(result[1]) + """ + + def find_chunk_index_by_time(self, seconds_behind): + """Find the chunk index that was received approximately *seconds_behind* + seconds ago. + + Uses an atomic Lua script against the chunk_timestamps sorted set so + no data can expire between the lookup and the read. + + Returns: + int or None: The chunk index to position the client at (this is + the *last consumed* convention, so the next read + starts at index+1). None if no suitable chunk + exists. + """ + if not self.redis_client or not self.chunk_timestamps_key: + return None + + target_time = time.time() - seconds_behind + + try: + result = self._find_chunk_by_time_sha( + keys=[self.chunk_timestamps_key], + args=[target_time], + ) + if result is None or int(result) == -1: + # No chunk old enough — fall back to the oldest available chunk + oldest = self.redis_client.zrange(self.chunk_timestamps_key, 0, 0) + if oldest: + return max(0, int(oldest[0]) - 1) # "last consumed" convention + return None + + # Return index - 1 so next read starts at that chunk + return max(0, int(result) - 1) + + except Exception as e: + logger.error(f"Error in find_chunk_index_by_time for channel {self.channel_id}: {e}") + return None + + # Add a new method to safely create timers + def schedule_timer(self, delay, callback, *args, **kwargs): + """Schedule a timer and track it for proper cleanup""" + if self.stopping: + return None + + # Replace threading.Timer with gevent.spawn_later for better compatibility + timer = gevent.spawn_later(delay, callback, *args, **kwargs) + self.fill_timers.append(timer) + return timer diff --git a/apps/proxy/ts_proxy/stream_generator.py b/apps/proxy/ts_proxy/stream_generator.py new file mode 100644 index 0000000..0036be5 --- /dev/null +++ b/apps/proxy/ts_proxy/stream_generator.py @@ -0,0 +1,641 @@ +""" +Stream generation and client-side handling for TS streams. +This module handles generating and delivering video streams to clients. +""" + +import time +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, Stream +from core.utils import log_system_event +from .server import ProxyServer +from .utils import create_ts_packet, get_logger +from .redis_keys import RedisKeys +from .utils import get_logger +from .constants import ChannelMetadataField +from .config_helper import ConfigHelper # Add this import + +logger = get_logger() + +class StreamGenerator: + """ + Handles generating streams for clients, including initialization, + data delivery, and cleanup. + """ + + def __init__(self, channel_id, client_id, client_ip, client_user_agent, channel_initializing=False, user=None): + """ + Initialize the stream generator with client and channel details. + + Args: + channel_id: The UUID of the channel to stream + client_id: Unique ID for this client connection + client_ip: Client's IP address + client_user_agent: User agent string from client + channel_initializing: Whether the channel is still initializing + user: Authenticated user making the request + """ + self.channel_id = channel_id + self.client_id = client_id + self.client_ip = client_ip + self.client_user_agent = client_user_agent + self.channel_initializing = channel_initializing + self.user = user + + # Performance and state tracking + self.stream_start_time = time.time() + self.bytes_sent = 0 + self.chunks_sent = 0 + self.local_index = 0 + self.consecutive_empty = 0 + + # Add tracking for current transfer rate calculation + self.last_stats_time = time.time() + 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 + + # Cached proxy server reference + self.proxy_server = None + + # Non-owner health check throttle: avoid Redis GET on every loop iteration + self._last_health_check_time = 0.0 + self._last_health_check_result = False + self._health_check_interval = 2.0 # seconds + + # Resource check throttle: Redis stop/state checks are expensive; throttle + # them while allowing cheap in-memory checks to run every iteration. + self._last_resource_check_time = 0.0 + self._resource_check_interval = 1.0 # seconds + + def generate(self): + """ + Generator function that produces the stream content for the client. + Handles initialization state, data delivery, and client disconnection. + + Yields: + bytes: Chunks of TS stream data + """ + self.stream_start_time = time.time() + self.bytes_sent = 0 + self.chunks_sent = 0 + + try: + logger.info(f"[{self.client_id}] Stream generator started, channel_ready={not self.channel_initializing}") + + # First handle initialization if needed + if self.channel_initializing: + channel_ready = self._wait_for_initialization() + if not channel_ready: + # If initialization failed or timed out, we've already sent error packets + return + + # Channel is now ready - start normal streaming + logger.info(f"[{self.client_id}] Channel {self.channel_id} ready, starting normal streaming") + + # Reset start time for real streaming + self.stream_start_time = time.time() + + # Setup streaming parameters and verify resources + 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, + username=self.user.username if self.user 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 + + except Exception as e: + logger.error(f"[{self.client_id}] Stream error: {e}", exc_info=True) + finally: + self._cleanup() + + def _wait_for_initialization(self): + """Wait for channel initialization to complete, sending keepalive packets.""" + initialization_start = time.time() + max_init_wait = ConfigHelper.client_wait_timeout() + keepalive_interval = 0.5 + last_keepalive = 0 + proxy_server = ProxyServer.get_instance() + + # While init is happening, send keepalive packets + while time.time() - initialization_start < max_init_wait: + # Check if initialization has completed + if proxy_server.redis_client: + metadata_key = RedisKeys.channel_metadata(self.channel_id) + metadata = proxy_server.redis_client.hgetall(metadata_key) + + if metadata and 'state' in metadata: + state = metadata['state'] + if state in ['waiting_for_clients', 'active']: + logger.info(f"[{self.client_id}] Channel {self.channel_id} now ready (state={state})") + return True + elif state in ['error', 'stopped', 'stopping']: # Added 'stopping' to error states + error_message = metadata.get('error_message', 'Unknown error') + logger.error(f"[{self.client_id}] Channel {self.channel_id} in error state: {state}, message: {error_message}") + # Send error packet before giving up + yield create_ts_packet('error', f"Error: {error_message}") + return False + else: + # Improved logging to track initialization progress + init_time = "unknown" + if 'init_time' in metadata: + try: + init_time_float = float(metadata['init_time']) + init_duration = time.time() - init_time_float + init_time = f"{init_duration:.1f}s ago" + except: + pass + + # Still initializing - send keepalive if needed + if time.time() - last_keepalive >= keepalive_interval: + status_msg = f"Initializing: {state} (started {init_time})" + keepalive_packet = create_ts_packet('keepalive', status_msg) + logger.debug(f"[{self.client_id}] Sending keepalive packet during initialization, state={state}") + yield keepalive_packet + self.bytes_sent += len(keepalive_packet) + last_keepalive = time.time() + + # Also check stopping key directly + stop_key = RedisKeys.channel_stopping(self.channel_id) + if proxy_server.redis_client.exists(stop_key): + logger.error(f"[{self.client_id}] Channel {self.channel_id} stopping flag detected during initialization") + yield create_ts_packet('error', "Error: Channel is stopping") + return False + + # Wait a bit before checking again + gevent.sleep(0.1) + + # Timed out waiting + logger.warning(f"[{self.client_id}] Timed out waiting for initialization") + yield create_ts_packet('error', "Error: Initialization timeout") + return False + + def _setup_streaming(self): + """Setup streaming parameters and check resources.""" + proxy_server = ProxyServer.get_instance() + + # Get buffer - stream manager may not exist in this worker + buffer = proxy_server.stream_buffers.get(self.channel_id) + stream_manager = proxy_server.stream_managers.get(self.channel_id) + + if not buffer: + logger.error(f"[{self.client_id}] No buffer found for channel {self.channel_id}") + return False + + # Client state tracking — determine start position + # When behind_seconds > 0, use time-based positioning to start + # the client that many seconds behind live. + # When behind_seconds == 0, start at live (buffer head). + behind_seconds = ConfigHelper.new_client_behind_seconds() + current_buffer_index = buffer.index + + if behind_seconds > 0: + time_index = buffer.find_chunk_index_by_time(behind_seconds) + if time_index is not None: + self.local_index = max(0, time_index) + logger.info( + f"[{self.client_id}] Time-based positioning: " + f"{behind_seconds}s behind -> index {self.local_index} " + f"(buffer head at {current_buffer_index})" + ) + else: + # Not enough buffer for the requested time — start as far + # back as possible (oldest available chunk). + oldest = buffer.find_oldest_available_chunk(0) + if oldest is not None: + self.local_index = max(0, oldest) + logger.info( + f"[{self.client_id}] Buffer shorter than {behind_seconds}s, " + f"starting at oldest available chunk {self.local_index} " + f"(buffer head at {current_buffer_index})" + ) + else: + # No timestamp data at all — start at live + self.local_index = current_buffer_index + logger.info( + f"[{self.client_id}] No timestamp data, starting at live: " + f"index {self.local_index} (buffer head at {current_buffer_index})" + ) + else: + # 0 = start at live (buffer head) + self.local_index = current_buffer_index + logger.info( + f"[{self.client_id}] Starting at live (behind_seconds=0): " + f"index {self.local_index} (buffer head at {current_buffer_index})" + ) + + # Store important objects as instance variables + self.proxy_server = proxy_server + self.buffer = buffer + self.stream_manager = stream_manager + self.last_yield_time = time.time() + self.empty_reads = 0 + self.consecutive_empty = 0 + self.is_owner_worker = proxy_server.am_i_owner(self.channel_id) if hasattr(proxy_server, 'am_i_owner') else True + + logger.info(f"[{self.client_id}] Starting stream at index {self.local_index} (buffer at {buffer.index})") + return True + + def _stream_data_generator(self): + """Generate stream data chunks based on buffer contents.""" + # Keepalive packets refresh last_yield_time, so _is_timeout() never fires + # during sustained stream failure. This timer enforces a wall-clock cap. + keepalive_start_time = None + + # Main streaming loop + while True: + # Check if resources still exist + if not self._check_resources(): + break + + # Get chunks at client's position using improved strategy + chunks, next_index = self.buffer.get_optimized_client_data(self.local_index) + + if chunks: + keepalive_start_time = None # Each recovery restarts the cap independently. + yield from self._process_chunks(chunks, next_index) + self.local_index = next_index + self.last_yield_time = time.time() + self.empty_reads = 0 + self.consecutive_empty = 0 + else: + # Handle no data condition (with possible keepalive packets) + self.empty_reads += 1 + self.consecutive_empty += 1 + + # We got no data despite being behind the buffer head. + # The read itself is the authoritative signal — no separate + # existence check needed, avoiding TOCTOU races with Redis TTL. + chunks_behind = self.buffer.index - self.local_index + if chunks_behind > 0: + # Next chunk has expired — find the oldest chunk still in Redis + new_index = self.buffer.find_oldest_available_chunk(self.local_index) + + if new_index is not None: + skipped = new_index - self.local_index + logger.warning( + f"[{self.client_id}] Next chunk expired (index {self.local_index + 1}), " + f"jumping to oldest available: {new_index + 1} " + f"(skipped {skipped} chunks, buffer head at {self.buffer.index})" + ) + self.local_index = new_index + else: + # No chunks available at all — jump to near the buffer head + initial_behind = ConfigHelper.initial_behind_chunks() + new_index = max(self.local_index, self.buffer.index - initial_behind) + logger.warning( + f"[{self.client_id}] No chunks available in buffer, " + f"jumping to near buffer head: {new_index} " + f"(buffer head at {self.buffer.index})" + ) + self.local_index = new_index + + self.consecutive_empty = 0 + continue # Retry immediately with the new position + + if self._should_send_keepalive(self.local_index): + if keepalive_start_time is None: + keepalive_start_time = time.time() + + max_keepalive = getattr(Config, 'MAX_KEEPALIVE_DURATION', 300) + if time.time() - keepalive_start_time > max_keepalive: + logger.warning( + f"[{self.client_id}] Keepalive duration exceeded {max_keepalive}s " + f"with no stream recovery, disconnecting" + ) + break + + keepalive_packet = create_ts_packet('keepalive') + logger.debug(f"[{self.client_id}] Sending keepalive packet while waiting at buffer head") + yield keepalive_packet + self.bytes_sent += len(keepalive_packet) + self.last_yield_time = time.time() + self.consecutive_empty = 0 # Reset consecutive counter but keep total empty_reads + # Update last_active so clients waiting during failover aren't flagged as ghosts + proxy_server = ProxyServer.get_instance() + if proxy_server and proxy_server.redis_client: + client_key = RedisKeys.client_metadata(self.channel_id, self.client_id) + proxy_server.redis_client.hset(client_key, "last_active", str(time.time())) + gevent.sleep(Config.KEEPALIVE_INTERVAL) # Replace time.sleep + else: + # Standard wait with backoff + sleep_time = min(0.1 * self.consecutive_empty, 1.0) + gevent.sleep(sleep_time) # Replace time.sleep + + # Log empty reads periodically + if self.empty_reads % 50 == 0: + stream_status = "healthy" if (self.stream_manager and self.stream_manager.healthy) else "unknown" + logger.debug(f"[{self.client_id}] Waiting for chunks beyond {self.local_index} for channel: {self.channel_id} (buffer at {self.buffer.index}, stream: {stream_status})") + + # Check for ghost clients + if self._is_ghost_client(self.local_index): + logger.warning(f"[{self.client_id}] Possible ghost client: buffer has advanced {self.buffer.index - self.local_index} chunks ahead but client stuck at {self.local_index}") + break + + # Check for timeouts + if self._is_timeout(): + break + + def _check_resources(self): + """Check if required resources still exist.""" + proxy_server = self.proxy_server or ProxyServer.get_instance() + if self.channel_id not in proxy_server.stream_buffers: + logger.info(f"[{self.client_id}] Channel buffer no longer exists, terminating stream") + return False + + if self.channel_id not in proxy_server.client_managers: + logger.info(f"[{self.client_id}] Client manager no longer exists, terminating stream") + return False + + client_manager = proxy_server.client_managers[self.channel_id] + if self.client_id not in client_manager.clients: + logger.info(f"[{self.client_id}] Client no longer in client manager, terminating stream") + return False + + # --- Redis checks: throttled to _resource_check_interval (default 1s) --- + # 3 Redis round-trips on every iteration is expensive at stream rates; + # stop/state signals change infrequently so a 1-second poll is sufficient. + if not proxy_server.redis_client: + return True + + now = time.time() + if now - self._last_resource_check_time < self._resource_check_interval: + return True + + self._last_resource_check_time = now + + # Channel stop check + stop_key = RedisKeys.channel_stopping(self.channel_id) + if proxy_server.redis_client.exists(stop_key): + logger.info(f"[{self.client_id}] Detected channel stop signal, terminating stream") + return False + + # Channel state in metadata + metadata_key = RedisKeys.channel_metadata(self.channel_id) + metadata = proxy_server.redis_client.hgetall(metadata_key) + if metadata and 'state' in metadata: + state = metadata['state'] + if state in ['error', 'stopped', 'stopping']: + logger.info(f"[{self.client_id}] Channel in {state} state, terminating stream") + return False + + # Client stop check + client_stop_key = RedisKeys.client_stop(self.channel_id, self.client_id) + if proxy_server.redis_client.exists(client_stop_key): + logger.info(f"[{self.client_id}] Detected client stop signal, terminating stream") + return False + + return True + + def _process_chunks(self, chunks, next_index): + """Process and yield chunks to the client.""" + # Process and send chunks + total_size = sum(len(c) for c in chunks) + logger.debug(f"[{self.client_id}] Retrieved {len(chunks)} chunks ({total_size} bytes) from index {self.local_index+1} to {next_index}") + proxy_server = self.proxy_server or ProxyServer.get_instance() + + # Send the chunks to the client + for chunk in chunks: + try: + yield chunk + self.bytes_sent += len(chunk) + self.chunks_sent += 1 + logger.debug(f"[{self.client_id}] Sent chunk {self.chunks_sent} ({len(chunk)} bytes) for channel {self.channel_id} to client") + + current_time = time.time() + + # Calculate average rate (since stream start) + elapsed_total = current_time - self.stream_start_time + avg_rate = self.bytes_sent / elapsed_total / 1024 if elapsed_total > 0 else 0 + + # Calculate current rate (since last measurement) + elapsed_current = current_time - self.last_stats_time + bytes_since_last = self.bytes_sent - self.last_stats_bytes + + if elapsed_current > 0: + self.current_rate = bytes_since_last / elapsed_current / 1024 + + # Update last stats values + self.last_stats_time = current_time + self.last_stats_bytes = self.bytes_sent + # Log every 10 chunks + if self.chunks_sent % 10 == 0: + logger.debug(f"[{self.client_id}] Stats: {self.chunks_sent} chunks, {self.bytes_sent/1024:.1f} KB, " + f"avg: {avg_rate:.1f} KB/s, current: {self.current_rate:.1f} KB/s") + + # Store stats in Redis client metadata + if proxy_server.redis_client: + try: + client_key = RedisKeys.client_metadata(self.channel_id, self.client_id) + stats = { + ChannelMetadataField.CHUNKS_SENT: str(self.chunks_sent), + ChannelMetadataField.BYTES_SENT: str(self.bytes_sent), + ChannelMetadataField.AVG_RATE_KBPS: str(round(avg_rate, 1)), + ChannelMetadataField.CURRENT_RATE_KBPS: str(round(self.current_rate, 1)), + ChannelMetadataField.STATS_UPDATED_AT: str(current_time), + "last_active": str(current_time) + } + proxy_server.redis_client.hset(client_key, mapping=stats) + + # 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}") + + except Exception as e: + logger.error(f"[{self.client_id}] Error sending chunk to client: {e}") + raise # Re-raise to exit the generator + + def _should_send_keepalive(self, local_index): + """Determine if a keepalive packet should be sent.""" + # Check if we're caught up to buffer head + at_buffer_head = local_index >= self.buffer.index + if not at_buffer_head or self.consecutive_empty < 5: + return False + + if self.stream_manager is not None: + # Owner worker: use the in-memory health flag directly. + return not self.stream_manager.healthy + else: + # Non-owner worker: stream_manager only exists in the owner process. + # Approximate health from the Redis last_data timestamp; if stale + # beyond CONNECTION_TIMEOUT, send keepalives to prevent DVR timeout. + # Throttled: only re-query Redis every _health_check_interval seconds + # to avoid a Redis GET on every loop iteration during sustained waits. + now = time.time() + if now - self._last_health_check_time < self._health_check_interval: + return self._last_health_check_result + try: + proxy_server = self.proxy_server or ProxyServer.get_instance() + if proxy_server.redis_client: + raw = proxy_server.redis_client.get(RedisKeys.last_data(self.channel_id)) + if raw: + age = now - float(raw) + timeout_threshold = getattr(Config, 'CONNECTION_TIMEOUT', 10) + result = age >= timeout_threshold + else: + # No timestamp in Redis → key missing or expired → unhealthy + result = True + self._last_health_check_time = now + self._last_health_check_result = result + return result + except Exception: + pass + return False + + def _is_ghost_client(self, local_index): + """Check if this appears to be a ghost client (stuck but buffer advancing).""" + return self.consecutive_empty > 100 and self.buffer.index > local_index + 50 + + def _is_timeout(self): + """Check if the stream has timed out.""" + # Get a more generous timeout for stream switching + stream_timeout = ConfigHelper.stream_timeout() + failover_grace_period = ConfigHelper.failover_grace_period() + total_timeout = stream_timeout + failover_grace_period + + # Disconnect after long inactivity + if time.time() - self.last_yield_time > total_timeout: + if self.stream_manager and not self.stream_manager.healthy: + # Check if stream manager is actively switching or reconnecting + if (hasattr(self.stream_manager, 'url_switching') and self.stream_manager.url_switching): + logger.info(f"[{self.client_id}] Stream switching in progress, giving more time") + return False + + logger.warning(f"[{self.client_id}] No data for {total_timeout}s and stream unhealthy, disconnecting") + return True + elif not self.is_owner_worker and self.consecutive_empty > 100: + # Non-owner worker without data for too long + logger.warning(f"[{self.client_id}] Non-owner worker with no data for {total_timeout}s, disconnecting") + return True + return False + + def _cleanup(self): + """Clean up resources and report final statistics.""" + # Client cleanup + elapsed = time.time() - self.stream_start_time + local_clients = 0 + total_clients = 0 + proxy_server = ProxyServer.get_instance() + + # Release M3U profile stream allocation if this is the last client + stream_released = False + if proxy_server.redis_client: + try: + metadata_key = RedisKeys.channel_metadata(self.channel_id) + metadata = proxy_server.redis_client.hgetall(metadata_key) + if metadata: + stream_id_bytes = proxy_server.redis_client.hget(metadata_key, ChannelMetadataField.STREAM_ID) + if stream_id_bytes: + # Check if we're the last client + if self.channel_id in proxy_server.client_managers: + client_count = proxy_server.client_managers[self.channel_id].get_total_client_count() + # Only the last client or owner should release the stream + if client_count <= 1 and proxy_server.am_i_owner(self.channel_id): + try: + # Try Channel first (normal flow), fall back to Stream (preview flow) + try: + obj = Channel.objects.get(uuid=self.channel_id) + except (Channel.DoesNotExist, Exception): + obj = Stream.objects.get(stream_hash=self.channel_id) + stream_released = obj.release_stream() + if stream_released: + logger.debug(f"[{self.client_id}] Released stream for channel {self.channel_id}") + else: + logger.warning(f"[{self.client_id}] release_stream found no keys for channel {self.channel_id}") + except Exception as e: + logger.error(f"[{self.client_id}] Error releasing stream for channel {self.channel_id}: {e}") + except Exception as e: + logger.error(f"[{self.client_id}] Error checking stream data for release: {e}") + + if self.channel_id in proxy_server.client_managers: + client_manager = proxy_server.client_managers[self.channel_id] + local_clients = client_manager.remove_client(self.client_id) + total_clients = client_manager.get_total_client_count() + logger.info(f"[{self.client_id}] Disconnected after {elapsed:.2f}s (local: {local_clients}, total: {total_clients})") + + # 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, + username=self.user.username if self.user else None + ) + except Exception as e: + logger.error(f"Could not log client disconnect event: {e}") + + # Schedule channel shutdown if no clients left + self._schedule_channel_shutdown_if_needed(local_clients) + + def _schedule_channel_shutdown_if_needed(self, local_clients): + """ + Schedule channel shutdown if there are no clients left and we're the owner. + """ + proxy_server = ProxyServer.get_instance() + + # If no clients left and we're the owner, schedule shutdown using the config value + if local_clients == 0 and proxy_server.am_i_owner(self.channel_id): + logger.info(f"No local clients left for channel {self.channel_id}, scheduling shutdown") + + def delayed_shutdown(): + # Use the config setting instead of hardcoded value + shutdown_delay = ConfigHelper.channel_shutdown_delay() # Use ConfigHelper + logger.info(f"Waiting {shutdown_delay}s before checking if channel should be stopped") + gevent.sleep(shutdown_delay) # Replace time.sleep + + # After delay, check global client count + if self.channel_id in proxy_server.client_managers: + total = proxy_server.client_managers[self.channel_id].get_total_client_count() + if total == 0: + logger.info(f"Shutting down channel {self.channel_id} as no clients connected") + proxy_server.stop_channel(self.channel_id) + else: + logger.info(f"Not shutting down channel {self.channel_id}, {total} clients still connected") + + gevent.spawn(delayed_shutdown) + +def create_stream_generator(channel_id, client_id, client_ip, client_user_agent, channel_initializing=False, user=None): + """ + Factory function to create a new stream generator. + Returns a function that can be passed to StreamingHttpResponse. + """ + generator = StreamGenerator(channel_id, client_id, client_ip, client_user_agent, channel_initializing, user=user) + return generator.generate diff --git a/apps/proxy/ts_proxy/stream_manager.py b/apps/proxy/ts_proxy/stream_manager.py new file mode 100644 index 0000000..d22fc7d --- /dev/null +++ b/apps/proxy/ts_proxy/stream_manager.py @@ -0,0 +1,1750 @@ +"""Stream connection management for TS proxy""" + +import threading +import logging +import time +import socket +import requests +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 +from .constants import ChannelState, EventType, StreamType, ChannelMetadataField, TS_PACKET_SIZE +from .config_helper import ConfigHelper +from .url_utils import get_alternate_streams, get_stream_info_for_switch, get_stream_object + +logger = get_logger() + +class StreamManager: + """Manages a connection to a TS stream without using raw sockets""" + + def __init__(self, channel_id, url, buffer, user_agent=None, transcode=False, stream_id=None, worker_id=None): + # Basic properties + self.channel_id = channel_id + self.url = url + self.buffer = buffer + self.running = True + self.connected = False + self.retry_count = 0 + self.max_retries = ConfigHelper.max_retries() + self.current_response = None + self.current_session = None + self.url_switching = False + self.url_switch_start_time = 0 + self.url_switch_timeout = ConfigHelper.url_switch_timeout() + self.buffering = False + self.buffering_timeout = ConfigHelper.buffering_timeout() + self.buffering_speed = ConfigHelper.buffering_speed() + self.buffering_start_time = None + # Store worker_id for ownership checks + self.worker_id = worker_id + + # Sockets used for transcode jobs + self.socket = None + self.transcode = transcode + self.transcode_process = None + + # User agent for connection + self.user_agent = user_agent or Config.DEFAULT_USER_AGENT + + # Stream health monitoring + self.last_data_time = time.time() + self.healthy = True + self.health_check_interval = ConfigHelper.get('HEALTH_CHECK_INTERVAL', 5) + self.chunk_size = ConfigHelper.chunk_size() + + # Add to your __init__ method + self._buffer_check_timers = [] + self.stopping = False + + # Add tracking for tried streams and current stream + self.current_stream_id = stream_id + self.tried_stream_ids = set() + + # IMPROVED LOGGING: Better handle and track stream ID + if stream_id: + self.tried_stream_ids.add(stream_id) + logger.info(f"Initialized stream manager for channel {buffer.channel_id} with stream ID {stream_id}") + else: + # Try to get stream ID from Redis metadata if available + if hasattr(buffer, 'redis_client') and buffer.redis_client: + try: + metadata_key = RedisKeys.channel_metadata(channel_id) + + # Log all metadata for debugging purposes + metadata = buffer.redis_client.hgetall(metadata_key) + if metadata: + logger.debug(f"Redis metadata for channel {channel_id}: {metadata}") + + # Try to get stream_id specifically + stream_id_bytes = buffer.redis_client.hget(metadata_key, "stream_id") + if stream_id_bytes: + self.current_stream_id = int(stream_id_bytes) + 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}. " + 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}. " + 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() + self.bytes_update_interval = 5 # Update Redis every 5 seconds + + # Add stderr reader thread property + 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 + + # Output bitrate smoothing / throttled DB persistence + self._smoothed_output_bitrate = None + self._last_bitrate_db_save_time = 0 + self._bitrate_db_save_interval = 30 # seconds between DB writes + self._bitrate_warmup_samples = 10 # discard first N samples while EMA stabilizes (~5s) + + def _create_session(self): + """Create and configure requests session with optimal settings""" + session = requests.Session() + + # Configure session headers + session.headers.update({ + 'User-Agent': self.user_agent, + 'Connection': 'keep-alive' + }) + + # Set up connection pooling for better performance + adapter = requests.adapters.HTTPAdapter( + pool_connections=1, # Single connection for this stream + pool_maxsize=1, # Max size of connection pool + max_retries=3, # Auto-retry for failed requests + pool_block=False # Don't block when pool is full + ) + + # Apply adapter to both HTTP and HTTPS + session.mount('http://', adapter) + session.mount('https://', adapter) + + return session + + def _wait_for_existing_processes_to_close(self, timeout=5.0): + """Wait for existing processes/connections to fully close before establishing new ones""" + start_time = time.time() + + while time.time() - start_time < timeout: + # Check if transcode process is still running + if self.transcode_process and self.transcode_process.poll() is None: + logger.debug(f"Waiting for existing transcode process to terminate for channel {self.channel_id}") + gevent.sleep(0.1) + continue + + # Check if HTTP connections are still active + if self.current_response or self.current_session: + logger.debug(f"Waiting for existing HTTP connections to close for channel {self.channel_id}") + gevent.sleep(0.1) + continue + + # Check if socket is still active + if self.socket: + logger.debug(f"Waiting for existing socket to close for channel {self.channel_id}") + gevent.sleep(0.1) + continue + + # All processes/connections are closed + logger.debug(f"All existing processes closed for channel {self.channel_id}") + return True + + # Timeout reached + logger.warning(f"Timeout waiting for existing processes to close for channel {self.channel_id} after {timeout}s") + return False + + def run(self): + """Main execution loop using HTTP streaming with improved connection handling and stream switching""" + # Add a stop flag to the class properties + self.stop_requested = False + # Add tracking for stream switching attempts + stream_switch_attempts = 0 + # Get max stream switches from config using the helper method + max_stream_switches = ConfigHelper.max_stream_switches() # Prevent infinite switching loops + + try: + + + # Start health monitor thread + health_thread = threading.Thread(target=self._monitor_health, daemon=True) + health_thread.start() + + logger.info(f"Starting stream for URL: {self.url} for channel {self.channel_id}") + + # Main stream switching loop - we'll try different streams if needed + while self.running and stream_switch_attempts <= max_stream_switches: + # Check for stuck switching state + if self.url_switching and time.time() - self.url_switch_start_time > self.url_switch_timeout: + logger.warning(f"URL switching state appears stuck for channel {self.channel_id} " + f"({time.time() - self.url_switch_start_time:.1f}s > {self.url_switch_timeout}s timeout). " + f"Resetting switching state.") + self._reset_url_switching_state() + + # NEW: Check for health monitor recovery requests + if hasattr(self, 'needs_reconnect') and self.needs_reconnect and not self.url_switching: + logger.info(f"Health monitor requested reconnect for channel {self.channel_id}") + self.needs_reconnect = False + + # Attempt reconnect without changing streams + if self._attempt_reconnect(): + logger.info(f"Health-requested reconnect successful for channel {self.channel_id}") + continue # Go back to main loop + else: + logger.warning(f"Health-requested reconnect failed, will try stream switch for channel {self.channel_id}") + self.needs_stream_switch = True + + if hasattr(self, 'needs_stream_switch') and self.needs_stream_switch and not self.url_switching: + logger.info(f"Health monitor requested stream switch for channel {self.channel_id}") + self.needs_stream_switch = False + + if self._try_next_stream(): + logger.info(f"Health-requested stream switch successful for channel {self.channel_id}") + stream_switch_attempts += 1 + self.retry_count = 0 # Reset retries for new stream + continue # Go back to main loop with new stream + else: + logger.error(f"Health-requested stream switch failed for channel {self.channel_id}") + # Continue with normal flow + + # Check stream type before connecting + 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 + # Reset connection retry count for this specific URL + self.retry_count = 0 + url_failed = False + if self.url_switching: + logger.debug(f"Skipping connection attempt during URL switch for channel {self.channel_id}") + gevent.sleep(0.1) # REPLACE time.sleep(0.1) + continue + # Connection retry loop for current URL + while self.running and self.retry_count < self.max_retries and not url_failed and not self.needs_stream_switch: + + logger.info(f"Connection attempt {self.retry_count + 1}/{self.max_retries} for URL: {self.url} for channel {self.channel_id}") + + # Handle connection based on whether we transcode or not + connection_result = False + try: + if self.transcode: + connection_result = self._establish_transcode_connection() + else: + connection_result = self._establish_http_connection() + + if connection_result: + # 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 + + # Reset stream switch attempts if the connection lasted longer than threshold + # This indicates we had a stable connection for a while before failing + connection_duration = time.time() - connection_start_time + stable_connection_threshold = 30 # 30 seconds threshold + + if self.needs_stream_switch: + logger.info(f"Stream needs to switch after {connection_duration:.1f} seconds for channel: {self.channel_id}") + break # Exit to switch streams + if connection_duration > stable_connection_threshold: + logger.info(f"Stream was stable for {connection_duration:.1f} seconds, resetting switch attempts counter for channel: {self.channel_id}") + stream_switch_attempts = 0 + + # Connection failed or ended - decide what to do next + if self.stop_requested or not self.running: + # Normal shutdown requested + return + + # Connection failed, increment retry count + self.retry_count += 1 + self.connected = False + + # If we've reached max retries, mark this URL as failed + 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 + logger.info(f"Reconnecting in {timeout} seconds... (attempt {self.retry_count}/{self.max_retries}) for channel: {self.channel_id}") + gevent.sleep(timeout) # REPLACE time.sleep(timeout) + + except Exception as e: + logger.error(f"Connection error on channel: {self.channel_id}: {e}", exc_info=True) + self.retry_count += 1 + self.connected = False + + 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 + logger.info(f"Reconnecting in {timeout} seconds after error... (attempt {self.retry_count}/{self.max_retries}) for channel: {self.channel_id}") + gevent.sleep(timeout) # REPLACE time.sleep(timeout) + + # If URL failed and we're still running, try switching to another stream + if url_failed and self.running: + logger.info(f"URL {self.url} failed after {self.retry_count} attempts, trying next stream for channel: {self.channel_id}") + + # Try to switch to next stream + switch_result = self._try_next_stream() + if switch_result: + # Successfully switched to a new stream, continue with the new URL + stream_switch_attempts += 1 + logger.info(f"Successfully switched to new URL: {self.url} (switch attempt {stream_switch_attempts}/{max_stream_switches}) for channel: {self.channel_id}") + # Reset retry count for the new stream - important for the loop to work correctly + self.retry_count = 0 + # Continue outer loop with new URL - DON'T add a break statement here + else: + # No more streams to try + logger.error(f"Failed to find alternative streams after {stream_switch_attempts} attempts for channel: {self.channel_id}") + break + elif not self.running: + # Normal shutdown was requested + break + + except Exception as e: + logger.error(f"Stream error: {e}", exc_info=True) + finally: + # Enhanced cleanup in the finally block + self.connected = False + + # Explicitly cancel all timers + for timer in list(self._buffer_check_timers): + try: + if timer and timer.is_alive(): + timer.cancel() + except Exception: + pass + + self._buffer_check_timers.clear() + + # Make sure transcode process is terminated + if self.transcode_process_active: + logger.info(f"Ensuring transcode process is terminated in finally block for channel: {self.channel_id}") + self._close_socket() + + # Close all connections + self._close_all_connections() + + # Transition to ERROR so clients stop waiting. Ownership may have + # expired during retries, so fall back to a state guard when no + # owner exists — but never clobber a new owner's active stream. + if hasattr(self.buffer, 'redis_client') and self.buffer.redis_client: + try: + metadata_key = RedisKeys.channel_metadata(self.channel_id) + owner_key = RedisKeys.channel_owner(self.channel_id) + current_owner = self.buffer.redis_client.get(owner_key) + + is_owner = ( + current_owner + and self.worker_id + and current_owner == self.worker_id + ) + no_owner = current_owner is None + + should_update = is_owner + if not should_update and no_owner: + current_state_bytes = self.buffer.redis_client.hget( + metadata_key, ChannelMetadataField.STATE + ) + current_state = ( + current_state_bytes + if current_state_bytes else None + ) + should_update = current_state in ChannelState.PRE_ACTIVE + if not should_update and current_state: + logger.info( + f"Channel {self.channel_id} has no owner but " + f"state is {current_state} — skipping ERROR update" + ) + + if should_update: + if self.tried_stream_ids and len(self.tried_stream_ids) > 0: + error_message = f"All {len(self.tried_stream_ids)} stream options failed" + else: + error_message = f"Connection failed after {self.max_retries} attempts" + + update_data = { + ChannelMetadataField.STATE: ChannelState.ERROR, + ChannelMetadataField.STATE_CHANGED_AT: str(time.time()), + ChannelMetadataField.ERROR_MESSAGE: error_message, + ChannelMetadataField.ERROR_TIME: str(time.time()) + } + self.buffer.redis_client.hset(metadata_key, mapping=update_data) + logger.info( + f"Updated channel {self.channel_id} state to ERROR " + f"in Redis after stream failure " + f"(owner={'self' if is_owner else 'expired'})" + ) + + # Signal clients to disconnect + stop_key = RedisKeys.channel_stopping(self.channel_id) + self.buffer.redis_client.setex(stop_key, 60, "true") + 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): + """Establish a connection using transcoding""" + try: + logger.debug(f"Building transcode command for channel {self.channel_id}") + + # Check if we already have a running transcode process + if self.transcode_process and self.transcode_process.poll() is None: + logger.info(f"Existing transcode process found for channel {self.channel_id}, closing before establishing new connection") + self._close_socket() + + # Wait for the process to fully terminate + if not self._wait_for_existing_processes_to_close(): + logger.error(f"Failed to close existing transcode process for channel {self.channel_id}") + return False + + # Also check for any lingering HTTP connections + if self.current_response or self.current_session: + logger.debug(f"Closing existing HTTP connections before establishing transcode connection for channel {self.channel_id}") + self._close_connection() + + channel = get_stream_object(self.channel_id) + + # Use FFmpeg specifically for HLS streams + if hasattr(self, 'force_ffmpeg') and self.force_ffmpeg: + from core.models import StreamProfile + try: + stream_profile = StreamProfile.objects.get(name='ffmpeg', locked=True) + logger.info("Using FFmpeg stream profile for unsupported proxy content (HLS/RTSP/UDP)") + except StreamProfile.DoesNotExist: + # Fall back to channel's profile if FFmpeg not found + stream_profile = channel.get_stream_profile() + logger.warning(f"FFmpeg profile not found, using channel default profile for channel: {self.channel_id}") + else: + stream_profile = channel.get_stream_profile() + + # 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 + self.transcode_process = subprocess.Popen( + self.transcode_cmd, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, # Capture stderr instead of discarding it + bufsize=188 * 64 # Buffer optimized for TS packets + ) + + # Start a thread to read stderr + self._start_stderr_reader() + + # Set flag that transcoding process is active + self.transcode_process_active = True + + self.socket = self.transcode_process.stdout # Read from std output + self.connected = True + + # Set 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 + except Exception as e: + logger.error(f"Error establishing transcode connection for channel: {self.channel_id}: {e}", exc_info=True) + self._close_socket() + return False + + def _start_stderr_reader(self): + """Start a thread to read stderr from the transcode process""" + if self.transcode_process and self.transcode_process.stderr: + self.stderr_reader_thread = threading.Thread( + target=self._read_stderr, + daemon=True # Use daemon thread so it doesn't block program exit + ) + self.stderr_reader_thread.start() + logger.debug(f"Started stderr reader thread for channel {self.channel_id}") + + def _read_stderr(self): + """Read and log ffmpeg stderr output with real-time stats parsing""" + try: + buffer = b"" + last_stats_line = b"" + + # Read byte by byte for immediate detection + while self.transcode_process and self.transcode_process.stderr: + try: + # Read one byte at a time for immediate processing + byte = self.transcode_process.stderr.read(1) + if not byte: + break + + buffer += byte + + # Check for frame= at the start of buffer (new stats line) + if buffer == b"frame=": + # We detected the start of a stats line, read until we get a complete line + # or hit a carriage return (which overwrites the previous stats) + while True: + next_byte = self.transcode_process.stderr.read(1) + if not next_byte: + break + + buffer += next_byte + + # Break on carriage return (stats overwrite) or newline + if next_byte in (b'\r', b'\n'): + break + + # Also break if we have enough data for a typical stats line + if len(buffer) > 200: # Typical stats line length + break + + # Process the stats line immediately + if buffer.strip(): + try: + stats_text = buffer.decode('utf-8', errors='ignore').strip() + if stats_text and "frame=" in stats_text: + self._parse_ffmpeg_stats(stats_text) + self._log_stderr_content(stats_text) + except Exception as e: + logger.debug(f"Error parsing immediate stats line: {e}") + + # Clear buffer after processing + buffer = b"" + continue + + # Handle regular line breaks for non-stats content + elif byte == b'\n': + if buffer.strip(): + line_text = buffer.decode('utf-8', errors='ignore').strip() + if line_text and not line_text.startswith("frame="): + self._log_stderr_content(line_text) + buffer = b"" + + # Handle carriage returns (potential stats overwrite) + elif byte == b'\r': + # Check if this might be a stats line + if b"frame=" in buffer: + try: + stats_text = buffer.decode('utf-8', errors='ignore').strip() + if stats_text and "frame=" in stats_text: + self._parse_ffmpeg_stats(stats_text) + self._log_stderr_content(stats_text) + except Exception as e: + logger.debug(f"Error parsing stats on carriage return: {e}") + elif buffer.strip(): + # Regular content with carriage return + line_text = buffer.decode('utf-8', errors='ignore').strip() + if line_text: + self._log_stderr_content(line_text) + buffer = b"" + + # Prevent buffer from growing too large for non-stats content + elif len(buffer) > 1024 and b"frame=" not in buffer: + # Process whatever we have if it's not a stats line + if buffer.strip(): + line_text = buffer.decode('utf-8', errors='ignore').strip() + if line_text: + self._log_stderr_content(line_text) + buffer = b"" + + except Exception as e: + logger.error(f"Error reading stderr byte: {e}") + break + + # Process any remaining buffer content + if buffer.strip(): + try: + remaining_text = buffer.decode('utf-8', errors='ignore').strip() + if remaining_text: + if "frame=" in remaining_text: + self._parse_ffmpeg_stats(remaining_text) + self._log_stderr_content(remaining_text) + except Exception as e: + logger.debug(f"Error processing remaining buffer: {e}") + + except Exception as e: + # Catch any other exceptions in the thread to prevent crashes + try: + logger.error(f"Error in stderr reader thread for channel {self.channel_id}: {e}") + except: + pass + + def _log_stderr_content(self, content): + """Log stderr content from FFmpeg with appropriate log levels""" + try: + content = content.strip() + if not content: + return + + # Convert to lowercase for easier matching + content_lower = content.lower() + # Check if we are still in the input phase + if content_lower.startswith('input #') or 'decoder' in content_lower: + self.ffmpeg_input_phase = True + # Track FFmpeg phases - once we see output info, we're past input phase + if content_lower.startswith('output #') or 'encoder' in content_lower: + self.ffmpeg_input_phase = False + + # Route to appropriate parser based on known command type + from .services.log_parsers import LogParserFactory + from .services.channel_service import ChannelService + + 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"Stream process error for channel {self.channel_id}: {content}") + elif any(keyword in content_lower for keyword in ['warning', 'deprecated', 'ignoring']): + 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"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"Stream info for channel {self.channel_id}: {content}") + else: + # Everything else at debug level + 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}") + + def _parse_ffmpeg_stats(self, stats_line): + """Parse FFmpeg stats line and extract speed, fps, and bitrate""" + try: + # Example FFmpeg stats line: + # frame= 1234 fps= 30 q=28.0 size= 2048kB time=00:00:41.33 bitrate= 406.1kbits/s speed=1.02x + + # Extract speed (e.g., "speed=1.02x") + speed_match = re.search(r'speed=\s*([0-9.]+)x?', stats_line) + ffmpeg_speed = float(speed_match.group(1)) if speed_match else None + + # Extract fps (e.g., "fps= 30") + fps_match = re.search(r'fps=\s*([0-9.]+)', stats_line) + ffmpeg_fps = float(fps_match.group(1)) if fps_match else None + + # Extract bitrate (e.g., "bitrate= 406.1kbits/s") + bitrate_match = re.search(r'bitrate=\s*([0-9.]+(?:\.[0-9]+)?)\s*([kmg]?)bits/s', stats_line, re.IGNORECASE) + ffmpeg_output_bitrate = None + if bitrate_match: + bitrate_value = float(bitrate_match.group(1)) + unit = bitrate_match.group(2).lower() + # Convert to kbps + if unit == 'm': + bitrate_value *= 1000 + elif unit == 'g': + bitrate_value *= 1000000 + # If no unit or 'k', it's already in kbps + ffmpeg_output_bitrate = bitrate_value + + # Calculate actual FPS + actual_fps = None + if ffmpeg_fps is not None and ffmpeg_speed is not None and ffmpeg_speed > 0: + actual_fps = ffmpeg_fps / ffmpeg_speed + # Store in Redis if we have valid data + 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) + + # Update local EMA and periodically flush to database + if ffmpeg_output_bitrate is not None and self.current_stream_id: + if self._bitrate_warmup_samples > 0: + # Discard early samples from the EMA + self._bitrate_warmup_samples -= 1 + else: + if self._smoothed_output_bitrate is None: + self._smoothed_output_bitrate = ffmpeg_output_bitrate + else: + self._smoothed_output_bitrate = 0.9 * self._smoothed_output_bitrate + 0.1 * ffmpeg_output_bitrate + + now = time.time() + if now - self._last_bitrate_db_save_time >= self._bitrate_db_save_interval: + from .services.channel_service import ChannelService + ChannelService._update_stream_stats_in_db( + self.current_stream_id, + ffmpeg_output_bitrate=round(self._smoothed_output_bitrate, 1) + ) + self._last_bitrate_db_save_time = now + + # 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" + # Log the stats + logger.debug(f"FFmpeg stats for channel {self.channel_id}: - Speed: {ffmpeg_speed}x, FFmpeg FPS: {ffmpeg_fps}, " + f"Actual FPS: {actual_fps_str}, " + f"Output Bitrate: {ffmpeg_output_bitrate_str} kbps") + # If we have a valid speed, check for buffering + if ffmpeg_speed is not None and ffmpeg_speed < self.buffering_speed: + if self.buffering: + # Buffering is still ongoing, check for how long + if self.buffering_start_time is None: + self.buffering_start_time = time.time() + else: + buffering_duration = time.time() - self.buffering_start_time + if buffering_duration > self.buffering_timeout: + # Buffering timeout reached, log error and try next stream + logger.error(f"Buffering timeout reached for channel {self.channel_id} after {buffering_duration:.1f} seconds") + # Send next stream request + if self._try_next_stream(): + logger.info(f"Switched to next stream for channel {self.channel_id} after buffering timeout") + # 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: + # Buffering just started, set the flag and start timer + 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 + 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, ChannelMetadataField.STATE, ChannelState.BUFFERING) + elif ffmpeg_speed is not None and ffmpeg_speed >= self.buffering_speed: + # Speed is good, check if we were buffering + if self.buffering: + # Reset buffering state + logger.info(f"Buffering ended for channel {self.channel_id} - speed: {ffmpeg_speed}x") + self.buffering = False + self.buffering_start_time = None + # Set channel state to active if speed is good + 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, ChannelMetadataField.STATE, ChannelState.ACTIVE) + + except Exception as e: + logger.debug(f"Error parsing FFmpeg stats: {e}") + + def _update_ffmpeg_stats_in_redis(self, speed, fps, actual_fps, output_bitrate): + """Update FFmpeg performance stats in Redis metadata""" + try: + if hasattr(self.buffer, 'redis_client') and self.buffer.redis_client: + metadata_key = RedisKeys.channel_metadata(self.channel_id) + update_data = { + ChannelMetadataField.FFMPEG_STATS_UPDATED: str(time.time()) + } + + if speed is not None: + update_data[ChannelMetadataField.FFMPEG_SPEED] = str(round(speed, 3)) + + if fps is not None: + update_data[ChannelMetadataField.FFMPEG_FPS] = str(round(fps, 1)) + + if actual_fps is not None: + update_data[ChannelMetadataField.ACTUAL_FPS] = str(round(actual_fps, 1)) + + if output_bitrate is not None: + update_data[ChannelMetadataField.FFMPEG_OUTPUT_BITRATE] = str(round(output_bitrate, 1)) + + self.buffer.redis_client.hset(metadata_key, mapping=update_data) + + except Exception as e: + logger.error(f"Error updating FFmpeg stats in Redis: {e}") + + + def _establish_http_connection(self): + """Establish HTTP connection using thread-based reader (same as transcode path)""" + try: + 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: + logger.info(f"Existing HTTP connection found for channel {self.channel_id}, closing before establishing new connection") + self._close_connection() + + # Wait for connections to fully close + if not self._wait_for_existing_processes_to_close(): + logger.error(f"Failed to close existing HTTP connections for channel {self.channel_id}") + return False + + # Also check for any lingering transcode processes + if self.transcode_process and self.transcode_process.poll() is None: + logger.debug(f"Closing existing transcode process before establishing HTTP connection for channel {self.channel_id}") + self._close_socket() + + # 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 + + # Create and start the HTTP stream reader + self.http_reader = HTTPStreamReader( + url=self.url, + user_agent=self.user_agent, + chunk_size=self.chunk_size + ) + + # Start the reader thread and get the read end of the pipe + pipe_fd = self.http_reader.start() + + # 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 + + 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 + + except Exception as e: + logger.error(f"Error establishing HTTP connection for channel {self.channel_id}: {e}", exc_info=True) + self._close_socket() + return False + + def _update_bytes_processed(self, chunk_size): + """Update the total bytes processed in Redis metadata""" + try: + # Update local counter + self.bytes_processed += chunk_size + + # Only update Redis periodically to reduce overhead + now = time.time() + if now - self.last_bytes_update >= self.bytes_update_interval: + if hasattr(self.buffer, 'redis_client') and self.buffer.redis_client: + # Update channel metadata with total bytes + metadata_key = RedisKeys.channel_metadata(self.channel_id) + + # Use hincrby to atomically increment the total_bytes field + self.buffer.redis_client.hincrby(metadata_key, ChannelMetadataField.TOTAL_BYTES, self.bytes_processed) + + # Reset local counter after updating Redis + self.bytes_processed = 0 + self.last_bytes_update = now + + logger.debug(f"Updated {ChannelMetadataField.TOTAL_BYTES} in Redis for channel {self.channel_id}") + except Exception as e: + logger.error(f"Error updating bytes processed: {e}") + + def _process_stream_data(self): + """Process stream data until disconnect or error - unified path for both transcode and HTTP""" + try: + # 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) + + # If we exit the loop, connection is closed or failed + self.connected = False + + def _close_all_connections(self): + """Close all connection resources""" + if self.socket or self.transcode_process: + try: + self._close_socket() + except Exception as e: + logger.debug(f"Error closing socket for channel {self.channel_id}: {e}") + + if self.current_response: + try: + self.current_response.close() + except Exception as e: + logger.debug(f"Error closing response for channel {self.channel_id}: {e}") + + if self.current_session: + try: + self.current_session.close() + except Exception as e: + logger.debug(f"Error closing session for channel {self.channel_id}: {e}") + + # Clear references + self.socket = None + self.current_response = None + self.current_session = None + self.transcode_process = None + + def stop(self): + """Stop the stream manager and cancel all timers""" + logger.info(f"Stopping stream manager for channel {self.channel_id}") + + # Add at the beginning of your stop method + self.stopping = True + + # Release stream resources if we're the owner + if self.current_stream_id and hasattr(self, 'worker_id') and self.worker_id: + if hasattr(self.buffer, 'redis_client') and self.buffer.redis_client: + owner_key = RedisKeys.channel_owner(self.channel_id) + current_owner = self.buffer.redis_client.get(owner_key) + + # Cancel all buffer check timers + for timer in list(self._buffer_check_timers): + try: + if timer and timer.is_alive(): + timer.cancel() + except Exception as e: + logger.error(f"Error canceling buffer check timer for channel {self.channel_id}: {e}") + + self._buffer_check_timers.clear() + + # Set the flag first + self.stop_requested = True + + # Close any active response connection + if hasattr(self, 'current_response') and self.current_response: # CORRECT NAME + try: + self.current_response.close() # CORRECT NAME + except Exception: + pass + + # Also close the session + if hasattr(self, 'current_session') and self.current_session: + try: + self.current_session.close() + except Exception: + pass + + # Explicitly close socket/transcode resources + self._close_socket() + + # Set running to false to ensure thread exits + self.running = False + + # Flush the final bitrate to DB on stop only if warmup completed and we have + # a meaningful EMA. Short previews / channel hops that die during warmup do NOT + # write anything, preserving any previously correct value in the database. + if self._smoothed_output_bitrate is not None and self.current_stream_id: + final_bitrate = self._smoothed_output_bitrate + try: + from .services.channel_service import ChannelService + ChannelService._update_stream_stats_in_db( + self.current_stream_id, + ffmpeg_output_bitrate=round(final_bitrate, 1) + ) + except Exception as e: + logger.debug(f"Error flushing final bitrate to DB for channel {self.channel_id}: {e}") + + def update_url(self, new_url, stream_id=None, m3u_profile_id=None): + """Update stream URL and reconnect with proper cleanup for both HTTP and transcode sessions""" + if new_url == self.url: + logger.info(f"URL unchanged: {new_url}") + return False + + logger.info(f"Switching stream URL from {self.url} to {new_url} for channel {self.channel_id}") + + # 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: + try: + # Get the channel by UUID + channel = Channel.objects.get(uuid=self.channel_id) + + # Get stream to find its profile + #new_stream = Stream.objects.get(pk=stream_id) + + # Use the new method to update the profile and manage connection counts + if m3u_profile_id: + success = channel.update_stream_profile(m3u_profile_id) + if success: + 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() + + try: + # Check which type of connection we're using and close it properly + if self.transcode or self.socket: + logger.debug(f"Closing transcode process before URL change for channel {self.channel_id}") + self._close_socket() + else: + logger.debug(f"Closing HTTP connection before URL change for channel {self.channel_id}") + self._close_connection() + + # Update URL and reset connection state + old_url = self.url + self.url = new_url + self.connected = False + + # Reset bitrate EMA on every URL change so stale data never carries over + self._smoothed_output_bitrate = None + self._last_bitrate_db_save_time = 0 + self._bitrate_warmup_samples = 10 + + # Update stream ID if provided + if stream_id: + old_stream_id = self.current_stream_id + self.current_stream_id = stream_id + # Add stream ID to tried streams for proper tracking + self.tried_stream_ids.add(stream_id) + logger.info(f"Updated stream ID from {old_stream_id} to {stream_id} for channel {self.channel_id}") + + # Reset retry counter to allow immediate reconnect + self.retry_count = 0 + + # Also reset buffer position to prevent stale data after URL change + if hasattr(self.buffer, 'reset_buffer_position'): + try: + self.buffer.reset_buffer_position() + logger.debug("Reset buffer position for clean URL switch") + 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) + return False + finally: + # Always reset the URL switching flag when done, whether successful or not + self.url_switching = False + logger.info(f"Stream switch completed for channel {self.channel_id}") + + def should_retry(self) -> bool: + """Check if connection retry is allowed""" + return self.retry_count < self.max_retries + + def _monitor_health(self): + """Monitor stream health and set flags for the main loop to handle recovery""" + consecutive_unhealthy_checks = 0 + max_unhealthy_checks = 3 + + # Add flags for the main loop to check + self.needs_reconnect = False + self.needs_stream_switch = False + self.last_health_action_time = 0 + action_cooldown = 30 # Prevent rapid recovery attempts + + while self.running: + try: + now = time.time() + inactivity_duration = now - self.last_data_time + timeout_threshold = getattr(Config, 'CONNECTION_TIMEOUT', 10) + + if inactivity_duration > timeout_threshold and self.connected: + if self.healthy: + logger.warning(f"Stream unhealthy for channel {self.channel_id} - no data for {inactivity_duration:.1f}s") + self.healthy = False + + consecutive_unhealthy_checks += 1 + + # Only set flags if enough time has passed since last action + if (consecutive_unhealthy_checks >= max_unhealthy_checks and + now - self.last_health_action_time > action_cooldown): + + # Calculate stability to decide on action type + connection_start_time = getattr(self, 'connection_start_time', 0) + stable_time = self.last_data_time - connection_start_time if connection_start_time > 0 else 0 + + if stable_time >= 30: # Stream was stable, try reconnect first + if not self.needs_reconnect: + logger.info(f"Setting reconnect flag for stable stream (stable for {stable_time:.1f}s) for channel {self.channel_id}") + self.needs_reconnect = True + self.last_health_action_time = now + else: + # Stream wasn't stable, suggest stream switch + if not self.needs_stream_switch: + logger.info(f"Setting stream switch flag for unstable stream (stable for {stable_time:.1f}s) for channel {self.channel_id}") + self.needs_stream_switch = True + self.last_health_action_time = now + + consecutive_unhealthy_checks = 0 # Reset after setting flag + + elif self.connected and not self.healthy: + # Auto-recover health when data resumes + logger.info(f"Stream health restored for channel {self.channel_id} - data resumed after {inactivity_duration:.1f}s") + self.healthy = True + consecutive_unhealthy_checks = 0 + # Clear recovery flags when healthy again + self.needs_reconnect = False + self.needs_stream_switch = False + + if self.healthy: + consecutive_unhealthy_checks = 0 + + except Exception as e: + logger.error(f"Error in health monitor: {e}") + + gevent.sleep(self.health_check_interval) # REPLACE time.sleep(self.health_check_interval) + + def _attempt_reconnect(self): + """Attempt to reconnect to the current stream""" + try: + logger.info(f"Attempting reconnect to current stream for channel {self.channel_id}") + + # Don't try to reconnect if we're already switching URLs + if self.url_switching: + logger.info(f"URL switching already in progress, skipping reconnect for channel {self.channel_id}") + return False + + # Set a flag to prevent concurrent operations + if hasattr(self, 'reconnecting') and self.reconnecting: + logger.info(f"Reconnect already in progress, skipping for channel {self.channel_id}") + return False + + self.reconnecting = True + + try: + # Close existing connection and wait for it to fully terminate + if self.transcode or self.socket: + logger.debug(f"Closing transcode process before reconnect for channel {self.channel_id}") + self._close_socket() + else: + logger.debug(f"Closing HTTP connection before reconnect for channel {self.channel_id}") + self._close_connection() + + # Wait for all processes to fully close before attempting reconnect + if not self._wait_for_existing_processes_to_close(): + logger.warning(f"Some processes may still be running during reconnect for channel {self.channel_id}") + + self.connected = False + + # Attempt to establish a new connection using the same URL + connection_result = False + if self.transcode: + connection_result = self._establish_transcode_connection() + else: + connection_result = self._establish_http_connection() + + 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}") + return False + + finally: + self.reconnecting = False + + except Exception as e: + logger.error(f"Error in reconnect attempt for channel {self.channel_id}: {e}", exc_info=True) + self.reconnecting = False + return False + + def _attempt_health_recovery(self): + """Attempt to recover stream health by switching to another stream""" + try: + logger.info(f"Attempting health recovery for channel {self.channel_id}") + + # Don't try to switch if we're already in the process of switching URLs + if self.url_switching: + logger.info(f"URL switching already in progress, skipping health recovery for channel {self.channel_id}") + return + + # Try to switch to next stream + switch_result = self._try_next_stream() + if switch_result: + logger.info(f"Health recovery successful - switched to new stream for channel {self.channel_id}") + return True + else: + logger.warning(f"Health recovery failed - no alternative streams available for channel {self.channel_id}") + return False + + except Exception as e: + logger.error(f"Error in health recovery attempt for channel {self.channel_id}: {e}", exc_info=True) + return False + + def _close_connection(self): + """Close HTTP connection resources""" + # Close response if it exists + if hasattr(self, 'current_response') and self.current_response: + try: + self.current_response.close() + except Exception as e: + logger.debug(f"Error closing response for channel {self.channel_id}: {e}") + self.current_response = None + + # Close session if it exists + if hasattr(self, 'current_session') and self.current_session: + try: + self.current_session.close() + except Exception as e: + logger.debug(f"Error closing session for channel {self.channel_id}: {e}") + self.current_session = None + + def _close_socket(self): + """Close socket and transcode resources as needed""" + # First try to use _close_connection for HTTP resources + 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: + self.socket.close() + except Exception as e: + logger.debug(f"Error closing socket for channel {self.channel_id}: {e}") + pass + + # Enhanced transcode process cleanup with immediate termination + if self.transcode_process: + try: + logger.debug(f"Killing transcode process for channel {self.channel_id}") + self.transcode_process.kill() + + # Give it a very short time to die + try: + self.transcode_process.wait(timeout=0.5) + except subprocess.TimeoutExpired: + 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}") + + # Final attempt: try to kill directly + try: + self.transcode_process.kill() + 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 + + # Clear transcode active key in Redis if available + if hasattr(self.buffer, 'redis_client') and self.buffer.redis_client: + try: + transcode_key = RedisKeys.transcode_active(self.channel_id) + self.buffer.redis_client.delete(transcode_key) + logger.debug(f"Cleared transcode active flag for channel {self.channel_id}") + except Exception as e: + logger.debug(f"Error clearing transcode flag for channel {self.channel_id}: {e}") + self.socket = None + self.connected = False + # Cancel any remaining buffer check timers + for timer in list(self._buffer_check_timers): + try: + if timer and timer.is_alive(): + timer.cancel() + logger.debug(f"Cancelled buffer check timer during socket close for channel {self.channel_id}") + except Exception as e: + logger.debug(f"Error canceling timer during socket close for channel {self.channel_id}: {e}") + + self._buffer_check_timers = [] + + def fetch_chunk(self): + """Fetch data from socket with timeout handling""" + if not self.connected or not self.socket: + return False + + try: + # Set timeout for chunk reads + chunk_timeout = ConfigHelper.chunk_timeout() # Use centralized timeout configuration + + try: + # Handle different socket types with timeout + if hasattr(self.socket, 'recv'): + # Standard socket - set timeout + original_timeout = self.socket.gettimeout() + self.socket.settimeout(chunk_timeout) + chunk = self.socket.recv(Config.CHUNK_SIZE) + self.socket.settimeout(original_timeout) # Restore original timeout + else: + # SocketIO object (transcode process stdout) - use select for timeout + import select + ready, _, _ = select.select([self.socket], [], [], chunk_timeout) + + if not ready: + # Timeout occurred + logger.debug(f"Chunk read timeout ({chunk_timeout}s) for channel {self.channel_id}") + return False + + chunk = self.socket.read(Config.CHUNK_SIZE) + + except socket.timeout: + # Socket timeout occurred + logger.debug(f"Socket timeout ({chunk_timeout}s) for channel {self.channel_id}") + return False + + if not chunk: + # Connection closed by server + logger.warning(f"Server closed connection for channel {self.channel_id}") + self._close_socket() + self.connected = False + return False + + # Track chunk size before adding to buffer + chunk_size = len(chunk) + self._update_bytes_processed(chunk_size) + + # Add directly to buffer without TS-specific processing + success = self.buffer.add_chunk(chunk) + + # Update last data timestamp in Redis if successful + if success and hasattr(self.buffer, 'redis_client') and self.buffer.redis_client: + last_data_key = RedisKeys.last_data(self.buffer.channel_id) + self.buffer.redis_client.set(last_data_key, str(time.time()), ex=60) + + return True + + except (socket.timeout, socket.error) as e: + # Socket error + logger.error(f"Socket error: {e}") + self._close_socket() + self.connected = False + return False + + except Exception as e: + logger.error(f"Error in fetch_chunk: {e}") + return False + + def _set_waiting_for_clients(self): + """Set channel state to waiting for clients AFTER buffer has enough chunks""" + try: + if hasattr(self.buffer, 'channel_id') and hasattr(self.buffer, 'redis_client'): + channel_id = self.buffer.channel_id + redis_client = self.buffer.redis_client + + if channel_id and redis_client: + current_time = str(time.time()) + metadata_key = RedisKeys.channel_metadata(channel_id) + + # Check current state first + current_state = None + try: + metadata = redis_client.hgetall(metadata_key) + state_field = ChannelMetadataField.STATE + if metadata and state_field in metadata: + current_state = metadata[state_field] + except Exception as e: + logger.error(f"Error checking current state: {e}") + + # 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 + # 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: + # Not enough buffer yet - set to connecting state if not already + if current_state != ChannelState.CONNECTING: + update_data = { + ChannelMetadataField.STATE: ChannelState.CONNECTING, + ChannelMetadataField.STATE_CHANGED_AT: current_time + } + redis_client.hset(metadata_key, mapping=update_data) + logger.info(f"Channel {channel_id} connected but waiting for buffer to fill: {current_buffer_index}/{initial_chunks_needed} chunks") + + # Schedule a retry to check buffer status again + timer = threading.Timer(0.5, self._check_buffer_and_set_state) + timer.daemon = True + timer.start() + return False + + # We have enough buffer, proceed with state change + update_data = { + ChannelMetadataField.STATE: ChannelState.WAITING_FOR_CLIENTS, + ChannelMetadataField.CONNECTION_READY_TIME: current_time, + ChannelMetadataField.STATE_CHANGED_AT: current_time, + ChannelMetadataField.BUFFER_CHUNKS: str(current_buffer_index) + } + redis_client.hset(metadata_key, mapping=update_data) + + # Get configured grace period or default + grace_period = ConfigHelper.channel_init_grace_period() + logger.info(f"STREAM MANAGER: Updated channel {channel_id} state: {current_state or 'None'} -> {ChannelState.WAITING_FOR_CLIENTS} with {current_buffer_index} buffer chunks") + logger.info(f"Started initial connection grace period ({grace_period}s) for channel {channel_id}") + else: + logger.debug(f"Not changing state: channel {channel_id} already in {current_state} state") + except Exception as e: + logger.error(f"Error setting waiting for clients state for channel {channel_id}: {e}") + + def _check_buffer_and_set_state(self): + """Check buffer size and set state to waiting_for_clients when ready""" + try: + # Enhanced stop detection with short-circuit return + if not self.running or getattr(self, 'stopping', False) or getattr(self, 'reconnecting', False): + logger.debug(f"Buffer check aborted - channel {self.buffer.channel_id} is stopping or reconnecting") + return False # Return value to indicate check was aborted + + # Clean up completed timers + self._buffer_check_timers = [t for t in self._buffer_check_timers if t.is_alive()] + + 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 + logger.info(f"Buffer threshold reached for channel {channel_id}: {current_buffer_index}/{initial_chunks_needed} chunks") + self._set_waiting_for_clients() + else: + # Still waiting, log progress and schedule another check + logger.debug(f"Buffer filling for channel {channel_id}: {current_buffer_index}/{initial_chunks_needed} chunks") + + # Schedule another check - NOW WITH STOPPING CHECK + if self.running and not getattr(self, 'stopping', False): + timer = threading.Timer(0.5, self._check_buffer_and_set_state) + timer.daemon = True + timer.start() + self._buffer_check_timers.append(timer) + + return True # Return value to indicate check was successful + except Exception as e: + logger.error(f"Error in buffer check for channel {self.channel_id}: {e}") + return False + + 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 + """ + try: + logger.info(f"Trying to find alternative stream for channel {self.channel_id}, current stream ID: {self.current_stream_id}") + + # Get alternate streams excluding the current one + alternate_streams = get_alternate_streams(self.channel_id, self.current_stream_id) + logger.info(f"Found {len(alternate_streams)} potential alternate streams for channel {self.channel_id}") + + # Filter out streams we've already tried + untried_streams = [s for s in alternate_streams if s['stream_id'] not in self.tried_stream_ids] + if untried_streams: + ids_to_try = ', '.join([str(s['stream_id']) for s in untried_streams]) + logger.info(f"Found {len(untried_streams)} untried streams for channel {self.channel_id}: [{ids_to_try}]") + else: + logger.warning(f"No untried streams available for channel {self.channel_id}, tried: {self.tried_stream_ids}") + + if not untried_streams: + # Check if we have streams but they've all been tried + if alternate_streams and len(self.tried_stream_ids) > 0: + logger.warning(f"All {len(alternate_streams)} alternate streams have been tried for channel {self.channel_id}") + return False + + # 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) + + # 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')}") + 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'] + + # 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 + + logger.info(f"Switching from URL {self.url} to {new_url} for channel {self.channel_id}") + + # 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 + + # Update stream ID tracking + self.current_stream_id = stream_id + + # Store the new user agent and transcode settings + self.user_agent = new_user_agent + self.transcode = new_transcode + + # 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" + }) + + # 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) + return False + + # Add a new helper method to safely reset the URL switching state + def _reset_url_switching_state(self): + """Safely reset the URL switching state if it gets stuck""" + self.url_switching = False + self.url_switch_start_time = 0 + logger.info(f"Reset URL switching state for channel {self.channel_id}") diff --git a/apps/proxy/ts_proxy/url_utils.py b/apps/proxy/ts_proxy/url_utils.py new file mode 100644 index 0000000..5f4615a --- /dev/null +++ b/apps/proxy/ts_proxy/url_utils.py @@ -0,0 +1,566 @@ +""" +Utilities for handling stream URLs and transformations. +""" + +import logging +import regex +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, StreamProfile +from .utils import get_logger +from uuid import UUID +import requests + +logger = get_logger() + +def get_stream_object(id: str): + try: + logger.info(f"Fetching channel ID {id}") + return get_object_or_404(Channel, uuid=id) + except: + # UUID check failed, assume stream hash + logger.info(f"Fetching stream hash {id}") + return get_object_or_404(Stream, stream_hash=id) + +def generate_stream_url(channel_id: str) -> Tuple[str, str, bool, Optional[int]]: + """ + Generate the appropriate stream URL for a channel or stream based on its profile settings. + + Args: + channel_id: The UUID of the channel or stream hash + + Returns: + Tuple[str, str, bool, Optional[int]]: (stream_url, user_agent, transcode_flag, profile_id) + """ + try: + channel_or_stream = get_stream_object(channel_id) + + # Handle direct stream preview (custom streams) + if isinstance(channel_or_stream, Stream): + stream = channel_or_stream + logger.info(f"Previewing stream directly: {stream.id} ({stream.name})") + + if not stream.m3u_account: + logger.error(f"Stream {stream.id} has no M3U account") + return None, None, False, None + + # Use get_stream() to atomically reserve a slot and write the + # channel_stream / stream_profile Redis keys, matching the channel + # path so stream_name and stream_stats work correctly. + stream_id, profile_id, error_reason = stream.get_stream() + if not stream_id or not profile_id: + logger.error(f"No profile available for stream {stream.id}: {error_reason}") + return None, None, False, None + + try: + profile = M3UAccountProfile.objects.get(id=profile_id) + m3u_account = stream.m3u_account + + 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}") + + stream_url = transform_url(stream.url, profile.search_pattern, profile.replace_pattern) + + stream_profile = stream.get_stream_profile() + logger.debug(f"Using stream profile: {stream_profile.name}") + + transcode = not stream_profile.is_proxy() + stream_profile_id = stream_profile.id + + return stream_url, stream_user_agent, transcode, stream_profile_id + except Exception as e: + logger.error(f"Error generating stream URL for stream {stream.id}: {e}") + stream.release_stream() + return None, None, False, None + + + # Handle channel preview (existing logic) + channel = channel_or_stream + + # Get stream and profile for this channel + # Note: get_stream now returns 3 values (stream_id, profile_id, error_reason) + stream_id, profile_id, error_reason = channel.get_stream() + + if not stream_id or not profile_id: + logger.error(f"No stream available for channel {channel_id}: {error_reason}") + return None, None, False, None + + # get_stream() allocated a connection slot - ensure it's released on any error + try: + # Look up the Stream and Profile objects + stream = Stream.objects.get(id=stream_id) + profile = M3UAccountProfile.objects.get(id=profile_id) + + # Get the M3U account profile for URL pattern + m3u_profile = profile + + # Get the appropriate user agent + m3u_account = M3UAccount.objects.get(id=m3u_profile.m3u_account.id) + 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}") + + # Generate stream URL based on the selected profile + input_url = stream.url + stream_url = transform_url(input_url, m3u_profile.search_pattern, m3u_profile.replace_pattern) + + # Check if transcoding is needed + stream_profile = channel.get_stream_profile() + 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 + except Exception as e: + logger.error(f"Error generating stream URL for channel {channel_id}: {e}") + if not channel.release_stream(): + logger.warning(f"Failed to release stream for channel {channel_id} after URL generation error") + return None, None, False, None + except Exception as e: + logger.error(f"Error generating stream URL: {e}") + return None, None, False, None + +def transform_url(input_url: str, search_pattern: str, replace_pattern: str) -> str: + """ + Transform a URL using regex pattern replacement. + + Args: + input_url: The base URL to transform + search_pattern: The regex search pattern + replace_pattern: The replacement pattern + + Returns: + str: The transformed URL + """ + try: + logger.debug("Executing URL pattern replacement:") + logger.debug(f" base URL: {input_url}") + logger.debug(f" search: {search_pattern}") + + # Convert JS-style backreferences in replace pattern: $ -> \g, $1 -> \1 + safe_replace_pattern = regex.sub(r'\$<([^>]+)>', r'\\g<\1>', replace_pattern) + safe_replace_pattern = regex.sub(r'\$(\d+)', r'\\\1', safe_replace_pattern) + logger.debug(f" replace: {replace_pattern}") + logger.debug(f" safe replace: {safe_replace_pattern}") + + # Apply the transformation (regex module accepts JS-style (?...) natively) + stream_url = regex.sub(search_pattern, safe_replace_pattern, input_url) + logger.info(f"Generated stream url: {stream_url}") + + return stream_url + except Exception as e: + logger.error(f"Error transforming URL: {e}") + return input_url # Return original URL on error + +def get_stream_info_for_switch(channel_id: str, target_stream_id: Optional[int] = None) -> dict: + """ + Get stream information for a channel switch, optionally to a specific stream ID. + + Args: + channel_id: The UUID of the channel + target_stream_id: Optional specific stream ID to switch to + + Returns: + dict: Stream information including URL, user agent and transcode flag + """ + try: + from core.utils import RedisClient + + channel = get_object_or_404(Channel, uuid=channel_id) + redis_client = RedisClient.get_client() + + # Use the target stream if specified, otherwise use current stream + if target_stream_id: + stream_id = target_stream_id + + # Get the stream object + stream = get_object_or_404(Stream, pk=stream_id) + + # Find compatible profile for this stream with connection availability check + m3u_account = stream.m3u_account + if not m3u_account: + return {'error': 'Stream has no 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: + return {'error': 'M3U account has no default profile'} + + # Check profiles in order: default first, then others + profiles = [default_profile] + [obj for obj in m3u_profiles if not obj.is_default] + + selected_profile = None + for profile in profiles: + + # Check connection availability + if redis_client: + profile_connections_key = f"profile_connections:{profile.id}" + current_connections = int(redis_client.get(profile_connections_key) or 0) + + # Check if this channel is already using this profile + channel_using_profile = False + existing_stream_id = redis_client.get(f"channel_stream:{channel.id}") + if existing_stream_id: + # Decode bytes to string/int for proper Redis key lookup + existing_stream_id = existing_stream_id + existing_profile_id = redis_client.get(f"stream_profile:{existing_stream_id}") + if existing_profile_id and int(existing_profile_id) == profile.id: + channel_using_profile = True + logger.debug(f"Channel {channel.id} already using profile {profile.id}") + + # Calculate effective connections (subtract 1 if channel already using this profile) + effective_connections = current_connections - (1 if channel_using_profile else 0) + + # Check if profile has available slots + if profile.max_streams == 0 or effective_connections < profile.max_streams: + selected_profile = profile + logger.debug(f"Selected profile {profile.id} with {effective_connections}/{profile.max_streams} effective connections (current: {current_connections}, already using: {channel_using_profile})") + break + else: + logger.debug(f"Profile {profile.id} at max connections: {effective_connections}/{profile.max_streams} (current: {current_connections}, already using: {channel_using_profile})") + else: + # No Redis available, assume first active profile is okay + selected_profile = profile + break + + if not selected_profile: + return {'error': 'No profiles available with connection capacity'} + + m3u_profile_id = selected_profile.id + else: + stream_id, m3u_profile_id, error_reason = channel.get_stream() + if stream_id is None or m3u_profile_id is None: + return {'error': error_reason or 'No stream assigned to channel'} + + # Get the stream and profile objects directly + stream = get_object_or_404(Stream, pk=stream_id) + profile = get_object_or_404(M3UAccountProfile, pk=m3u_profile_id) + + # Check connections left + m3u_account = M3UAccount.objects.get(id=profile.m3u_account.id) + #connections_left = get_connections_left(m3u_profile_id) + + #if connections_left <= 0: + #logger.warning(f"No connections left for M3U account {m3u_account.id}") + #return {'error': 'No connections left'} + + # Get the user agent from the M3U account + user_agent = m3u_account.get_user_agent().user_agent + + # Generate URL using the transform function directly + stream_url = transform_url(stream.url, profile.search_pattern, profile.replace_pattern) + + # Get transcode info from the channel's stream profile + stream_profile = channel.get_stream_profile() + transcode = not (stream_profile.is_proxy() or stream_profile is None) + profile_value = stream_profile.id + + return { + 'url': stream_url, + 'user_agent': user_agent, + 'transcode': transcode, + 'stream_profile': profile_value, + 'stream_id': stream_id, + 'm3u_profile_id': m3u_profile_id + } + except Exception as e: + logger.error(f"Error getting stream info for switch: {e}", exc_info=True) + return {'error': f'Error: {str(e)}'} + +def get_alternate_streams(channel_id: str, current_stream_id: Optional[int] = None) -> List[dict]: + """ + Get alternative streams for a channel when the current stream fails. + + Args: + channel_id: The UUID of the channel + current_stream_id: The currently failing stream ID to exclude + + Returns: + List[dict]: List of stream information dictionaries with stream_id and profile_id + """ + try: + from core.utils import RedisClient + + # Get channel object + channel = get_stream_object(channel_id) + if isinstance(channel, Stream): + logger.error(f"Stream is not a channel") + return [] + + redis_client = RedisClient.get_client() + logger.debug(f"Looking for alternate streams for channel {channel_id}, current stream ID: {current_stream_id}") + + # Get all assigned streams for this channel using the correct ordering + streams = channel.streams.all().order_by('channelstream__order') + logger.debug(f"Channel {channel_id} has {streams.count()} total assigned streams") + + if not streams.exists(): + logger.warning(f"No streams assigned to channel {channel_id}") + return [] + + alternate_streams = [] + + # Process each stream in the user-defined order + for stream in streams: + logger.debug(f"Checking stream ID {stream.id} ({stream.name}) for channel {channel_id}") + + # Skip the current failing stream + if current_stream_id and stream.id == current_stream_id: + logger.debug(f"Skipping current stream ID {current_stream_id}") + continue + + # Find compatible profiles for this stream with connection checking + try: + m3u_account = stream.m3u_account + 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.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") + continue + + # Check profiles in order with connection availability + profiles = [default_profile] + [obj for obj in m3u_profiles if not obj.is_default] + + selected_profile = None + for profile in profiles: + # Check connection availability + if redis_client: + profile_connections_key = f"profile_connections:{profile.id}" + current_connections = int(redis_client.get(profile_connections_key) or 0) + + # Check if this channel is already using this profile + channel_using_profile = False + existing_stream_id = redis_client.get(f"channel_stream:{channel.id}") + if existing_stream_id: + # Decode bytes to string/int for proper Redis key lookup + existing_stream_id = existing_stream_id + existing_profile_id = redis_client.get(f"stream_profile:{existing_stream_id}") + if existing_profile_id and int(existing_profile_id) == profile.id: + channel_using_profile = True + logger.debug(f"Channel {channel.id} already using profile {profile.id}") + + # Calculate effective connections (subtract 1 if channel already using this profile) + effective_connections = current_connections - (1 if channel_using_profile else 0) + + # Check if profile has available slots + if profile.max_streams == 0 or effective_connections < profile.max_streams: + selected_profile = profile + logger.debug(f"Found available profile {profile.id} for stream {stream.id}: {effective_connections}/{profile.max_streams} effective (current: {current_connections}, already using: {channel_using_profile})") + break + else: + logger.debug(f"Profile {profile.id} at max connections: {effective_connections}/{profile.max_streams} (current: {current_connections}, already using: {channel_using_profile})") + else: + # No Redis available, assume first active profile is okay + selected_profile = profile + break + + if selected_profile: + alternate_streams.append({ + 'stream_id': stream.id, + 'profile_id': selected_profile.id, + 'name': stream.name + }) + else: + logger.debug(f"No available profiles for stream ID {stream.id}") + + except Exception as inner_e: + logger.error(f"Error finding profiles for stream {stream.id}: {inner_e}") + continue + + if alternate_streams: + stream_ids = ', '.join([str(s['stream_id']) for s in alternate_streams]) + logger.info(f"Found {len(alternate_streams)} alternate streams with available connections for channel {channel_id}: [{stream_ids}]") + else: + logger.warning(f"No alternate streams with available connections found for channel {channel_id}") + + return alternate_streams + except Exception as e: + logger.error(f"Error getting alternate streams for channel {channel_id}: {e}", exc_info=True) + return [] + +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 + timeout (tuple): Connection and read timeout in seconds + + 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() + headers = { + 'User-Agent': user_agent, + 'Connection': 'close' # Don't keep connection alive + } + session.headers.update(headers) + + # Make HEAD request first as it's faster and doesn't download content + 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 head_request_success and (200 <= head_response.status_code < 300): + # HEAD request successful + 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( + url, + stream=True, + timeout=timeout, + allow_redirects=True + ) + + # 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, url, get_response.status_code, f"Invalid HTTP status: {get_response.status_code}" + + # Only check content if status code is valid + try: + chunk = next(get_response.iter_content(chunk_size=188*10)) + is_valid = len(chunk) > 0 + message = f"Valid (GET request, received {len(chunk)} bytes)" + except StopIteration: + is_valid = False + message = "Empty response from server" + + # Check content type for additional validation + content_type = get_response.headers.get('Content-Type', '').lower() + + # Expanded list of valid content types for streaming media + valid_content_types = [ + 'video/', + 'audio/', + 'mpegurl', + 'octet-stream', + 'mp2t', + 'mp4', + 'mpeg', + 'dash+xml', + 'application/mp4', + 'application/mpeg', + 'application/x-mpegurl', + 'application/vnd.apple.mpegurl', + 'application/ogg', + 'm3u', + 'playlist', + 'binary/', + 'rtsp', + 'rtmp', + 'hls', + 'ts' + ] + + content_type_valid = any(type_str in content_type for type_str in valid_content_types) + + # Always consider the stream valid if we got data, regardless of content type + # But add content type info to the message for debugging + if content_type: + content_type_msg = f" (Content-Type: {content_type}" + if content_type_valid: + content_type_msg += ", recognized as valid stream format)" + else: + content_type_msg += ", unrecognized but may still work)" + message += content_type_msg + + # Clean up connection + get_response.close() + + # If we have content, consider it valid even with unrecognized content type + return is_valid, url, get_response.status_code, message + + except requests.exceptions.Timeout: + return False, url, 0, "Timeout connecting to stream" + except requests.exceptions.TooManyRedirects: + return False, url, 0, "Too many redirects" + except requests.exceptions.RequestException as e: + return False, url, 0, f"Request error: {str(e)}" + except Exception as e: + return False, url, 0, f"Validation error: {str(e)}" + finally: + if 'session' in locals(): + session.close() + +def get_connections_left(m3u_profile_id: int) -> int: + """ + Get the number of available connections left for an M3U profile. + + Args: + m3u_profile_id: The ID of the M3U profile + + Returns: + int: Number of connections available (0 if none available) + """ + try: + from core.utils import RedisClient + + # Get the M3U profile + m3u_profile = M3UAccountProfile.objects.get(id=m3u_profile_id) + + # If max_streams is 0, it means unlimited + if m3u_profile.max_streams == 0: + return 999999 # Return a large number to indicate unlimited + + # Get Redis client + redis_client = RedisClient.get_client() + if not redis_client: + logger.warning("Redis not available, assuming connections available") + return max(0, m3u_profile.max_streams - 1) # Conservative estimate + + # Check current connections for this specific profile + profile_connections_key = f"profile_connections:{m3u_profile_id}" + current_connections = int(redis_client.get(profile_connections_key) or 0) + + # Calculate available connections + connections_left = max(0, m3u_profile.max_streams - current_connections) + + logger.debug(f"M3U profile {m3u_profile_id}: {current_connections}/{m3u_profile.max_streams} used, {connections_left} available") + + return connections_left + + except M3UAccountProfile.DoesNotExist: + logger.error(f"M3U profile {m3u_profile_id} not found") + return 0 + except Exception as e: + logger.error(f"Error getting connections left for M3U profile {m3u_profile_id}: {e}") + return 0 diff --git a/apps/proxy/ts_proxy/urls.py b/apps/proxy/ts_proxy/urls.py new file mode 100644 index 0000000..cb236aa --- /dev/null +++ b/apps/proxy/ts_proxy/urls.py @@ -0,0 +1,14 @@ +from django.urls import path +from . import views + +app_name = 'ts_proxy' + +urlpatterns = [ + path('stream/', views.stream_ts, name='stream'), + path('change_stream/', views.change_stream, name='change_stream'), + path('status', views.channel_status, name='channel_status'), + path('status/', views.channel_status, name='channel_status_detail'), + path('stop/', views.stop_channel, name='stop_channel'), + path('stop_client/', views.stop_client, name='stop_client'), + path('next_stream/', views.next_stream, name='next_stream'), +] diff --git a/apps/proxy/ts_proxy/utils.py b/apps/proxy/ts_proxy/utils.py new file mode 100644 index 0000000..ff9e178 --- /dev/null +++ b/apps/proxy/ts_proxy/utils.py @@ -0,0 +1,116 @@ +import logging +import re +from urllib.parse import urlparse +import inspect + +logger = logging.getLogger("ts_proxy") + +def detect_stream_type(url): + """ + Detect if stream URL is HLS, RTSP/RTP, UDP, or TS format. + + Args: + url (str): The stream URL to analyze + + Returns: + 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 + '/playlist.m3u' in url_lower): + return 'hls' + + # Additional HLS patterns + parsed = urlparse(url) + path = parsed.path.lower() + if ('playlist' in path and ('.m3u' in path or '.m3u8' in path)) or \ + ('manifest' in path and ('.m3u' in path or '.m3u8' in path)) or \ + ('master' in path and ('.m3u' in path or '.m3u8' in path)): + return 'hls' + + # Default to TS + return 'ts' + +def get_client_ip(request): + """ + Extract client IP address from request. + Handles cases where request is behind a proxy by checking X-Forwarded-For. + """ + x_forwarded_for = request.META.get('HTTP_X_FORWARDED_FOR') + if x_forwarded_for: + ip = x_forwarded_for.split(',')[0] + else: + ip = request.META.get('REMOTE_ADDR') + return ip + +def create_ts_packet(packet_type='null', message=None): + """ + Create a Transport Stream (TS) packet for various purposes. + + Args: + packet_type (str): Type of packet - 'null', 'error', 'keepalive', etc. + message (str): Optional message to include in packet payload + + Returns: + bytes: A properly formatted 188-byte TS packet + """ + packet = bytearray(188) + + # TS packet header + packet[0] = 0x47 # Sync byte + + # PID - Use different PIDs based on packet type + if packet_type == 'error': + packet[1] = 0x1F # PID high bits + packet[2] = 0xFF # PID low bits + else: # null/keepalive packets + packet[1] = 0x1F # PID high bits (null packet) + packet[2] = 0xFF # PID low bits (null packet) + + # Add message to payload if provided + if message: + msg_bytes = message + packet[4:4+min(len(msg_bytes), 180)] = msg_bytes[:180] + + return bytes(packet) + +def get_logger(component_name=None): + """ + Get a standardized logger with ts_proxy prefix and optional component name. + + Args: + component_name (str, optional): Name of the component. If not provided, + will try to detect from the calling module. + + Returns: + logging.Logger: A configured logger with standardized naming. + """ + if component_name: + logger_name = f"ts_proxy.{component_name}" + else: + # Try to get the calling module name if not explicitly specified + frame = inspect.currentframe().f_back + module = inspect.getmodule(frame) + if module: + # Extract just the filename without extension + module_name = module.__name__.split('.')[-1] + logger_name = f"ts_proxy.{module_name}" + else: + # Default if detection fails + logger_name = "ts_proxy" + + return logging.getLogger(logger_name) diff --git a/apps/proxy/ts_proxy/views.py b/apps/proxy/ts_proxy/views.py new file mode 100644 index 0000000..da1c29f --- /dev/null +++ b/apps/proxy/ts_proxy/views.py @@ -0,0 +1,962 @@ +import json +import threading +import time +import random +import re +import pathlib +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 +from .server import ProxyServer +from .channel_status import ChannelStatus +from .stream_generator import create_stream_generator +from .utils import get_client_ip +from .redis_keys import RedisKeys +import logging +from apps.channels.models import Channel, Stream +from apps.m3u.models import M3UAccount, M3UAccountProfile +from apps.accounts.models import User +from core.models import UserAgent, CoreSettings, PROXY_PROFILE_NAME +from rest_framework.decorators import api_view, permission_classes +from rest_framework.permissions import AllowAny +from rest_framework.response import Response +from apps.accounts.permissions import ( + IsAdmin, + permission_classes_by_method, + permission_classes_by_action, +) +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, + get_stream_info_for_switch, + get_stream_object, + get_alternate_streams, +) +from .utils import get_logger +from uuid import UUID +import gevent +from dispatcharr.utils import network_access_allowed +from apps.proxy.utils import check_user_stream_limits + +logger = get_logger() + + +@api_view(["GET"]) +@permission_classes([AllowAny]) +def stream_ts(request, channel_id, user=None): + if not network_access_allowed(request, "STREAMS"): + return JsonResponse({"error": "Forbidden"}, status=403) + + """Stream TS data to client with immediate response and keep-alive packets during initialization""" + if user is None and hasattr(request, 'user') and request.user.is_authenticated: + user = request.user + + channel = get_stream_object(channel_id) + + client_user_agent = None + proxy_server = ProxyServer.get_instance() + connection_allocated = False # Track if connection slot was allocated via get_stream() + + try: + # Generate a unique client ID + client_id = f"client_{int(time.time() * 1000)}_{random.randint(1000, 9999)}" + client_ip = get_client_ip(request) + logger.info(f"[{client_id}] Requested stream for channel {channel_id}") + + # Extract client user agent early + for header in ["HTTP_USER_AGENT", "User-Agent", "user-agent"]: + if header in request.META: + client_user_agent = request.META[header] + logger.debug( + f"[{client_id}] Client connected with user agent: {client_user_agent}" + ) + break + + if user: + if not check_user_stream_limits(user, client_id, media_id=channel_id): + return JsonResponse( + {"error": f"Stream limit exceeded ({user.stream_limit} concurrent streams allowed)"}, + status=429 + ) + + # Check if we need to reinitialize the channel + needs_initialization = True + channel_state = None + channel_initializing = False + + # Get current channel state from Redis if available + if proxy_server.redis_client: + metadata_key = RedisKeys.channel_metadata(channel_id) + if proxy_server.redis_client.exists(metadata_key): + metadata = proxy_server.redis_client.hgetall(metadata_key) + state_field = ChannelMetadataField.STATE + if state_field in metadata: + channel_state = metadata[state_field] + + # 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} in state {channel_state}, skipping initialization" + ) + + # Special handling for initializing/connecting states + if channel_state in [ + ChannelState.INITIALIZING, + ChannelState.CONNECTING, + ]: + channel_initializing = True + logger.debug( + 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: + owner_field = ChannelMetadataField.OWNER + if owner_field in metadata: + owner = metadata[owner_field] + owner_heartbeat_key = f"ts_proxy:worker:{owner}:heartbeat" + if proxy_server.redis_client.exists(owner_heartbeat_key): + # 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}, 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 + if needs_initialization or not proxy_server.check_if_channel_exists(channel_id): + logger.info(f"[{client_id}] Starting channel {channel_id} initialization") + # Force cleanup of any previous instance if in terminal state + if channel_state in [ + ChannelState.ERROR, + ChannelState.STOPPING, + ChannelState.STOPPED, + ]: + logger.warning( + f"[{client_id}] Channel {channel_id} in state {channel_state}, forcing cleanup" + ) + ChannelService.stop_channel(channel_id) + + # 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 + stream_user_agent = None + transcode = False + profile_value = None + error_reason = None + attempt = 0 + should_retry = True + + # 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} after {attempt} attempts" + ) + break + + # 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 + + # Wait before retrying + logger.info( + f"[{client_id}] Waiting {retry_interval*1000:.0f}ms for a connection to become available (attempt {attempt}, {remaining_time:.1f}s remaining)" + ) + 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: + # Release any connection slot that may have been allocated + # by the error-checking get_stream() call during retries + if not channel.release_stream(): + logger.debug(f"[{client_id}] release_stream found no keys during failed init cleanup") + + # Get the specific error message if available + wait_duration = f"{int(time.time() - wait_start_time)}s" + error_msg = ( + error_reason + 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 + + # generate_stream_url() called get_stream() which allocated a connection + # slot (INCR'd profile_connections) - track this for cleanup on error + if needs_initialization: + connection_allocated = True + + # Read stream assignment from Redis (already set by generate_stream_url → get_stream). + # Avoid calling get_stream() again — (INCR profile counter) + # It could double-allocate if the keys were cleared by a concurrent release. + stream_id = None + m3u_profile_id = None + if proxy_server.redis_client: + stream_id_bytes = proxy_server.redis_client.get(f"channel_stream:{channel.id}") + if stream_id_bytes: + stream_id = int(stream_id_bytes) + profile_id_bytes = proxy_server.redis_client.get(f"stream_profile:{stream_id}") + if profile_id_bytes: + m3u_profile_id = int(profile_id_bytes) + logger.info( + f"Channel {channel_id} using stream ID {stream_id}, m3u account profile ID {m3u_profile_id}" + ) + + # Generate transcode command if needed + stream_profile = channel.get_stream_profile() + if stream_profile.is_redirect(): + # Validate the stream URL before redirecting + from .url_utils import ( + validate_stream_url, + get_alternate_streams, + get_stream_info_for_switch, + ) + + # Try initial URL + logger.info(f"[{client_id}] Validating redirect URL: {stream_url}") + is_valid, final_url, status_code, message = validate_stream_url( + stream_url, user_agent=stream_user_agent, timeout=(5, 5) + ) + + # If first URL doesn't validate, try alternates + if not is_valid: + logger.warning( + f"[{client_id}] Primary stream URL failed validation: {message}" + ) + + # Track tried streams to avoid loops + tried_streams = {stream_id} + + # Get alternate streams + alternates = get_alternate_streams(channel_id, stream_id) + + # Try each alternate until one works + for alt in alternates: + if alt["stream_id"] in tried_streams: + continue + + tried_streams.add(alt["stream_id"]) + + # Get stream info + alt_info = get_stream_info_for_switch( + channel_id, alt["stream_id"] + ) + if "error" in alt_info: + logger.warning( + f"[{client_id}] Error getting alternate stream info: {alt_info['error']}" + ) + continue + + # Validate the alternate URL + logger.info( + f"[{client_id}] Trying alternate stream #{alt['stream_id']}: {alt_info['url']}" + ) + is_valid, final_url, status_code, message = validate_stream_url( + alt_info["url"], + user_agent=alt_info["user_agent"], + timeout=(5, 5), + ) + + if is_valid: + logger.info( + f"[{client_id}] Alternate stream #{alt['stream_id']} validated successfully" + ) + break + else: + logger.warning( + f"[{client_id}] Alternate stream #{alt['stream_id']} failed validation: {message}" + ) + # Release stream lock before redirecting + if not channel.release_stream(): + logger.warning(f"[{client_id}] Failed to release stream before redirect") + connection_allocated = False + # Final decision based on validation results + if is_valid: + 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( + f"[{client_id}] All available redirect URLs failed validation" + ) + return JsonResponse( + {"error": "All available streams failed validation"}, status=502 + ) # 502 Bad Gateway + + # Initialize channel with the stream's user agent (not the client's) + success = ChannelService.initialize_channel( + channel_id, + stream_url, + stream_user_agent, + transcode, + profile_value, + stream_id, + m3u_profile_id, + ) + + if not success: + if connection_allocated: + if not channel.release_stream(): + logger.warning(f"[{client_id}] Failed to release stream after init failure") + connection_allocated = False + return JsonResponse( + {"error": "Failed to initialize channel"}, status=500 + ) + + # Channel initialized - cleanup lifecycle now owns the connection release + connection_allocated = False + + # If we're the owner, wait for connection to establish + if proxy_server.am_i_owner(channel_id): + manager = proxy_server.stream_managers.get(channel_id) + if manager: + wait_start = time.time() + timeout = ConfigHelper.connection_timeout() + while not manager.connected: + if time.time() - wait_start > timeout: + proxy_server.stop_channel(channel_id) + return JsonResponse( + {"error": "Connection timeout"}, status=504 + ) + + # Check if this manager should keep retrying or stop + if not manager.should_retry(): + # Check channel state in Redis to make a better decision + metadata_key = RedisKeys.channel_metadata(channel_id) + current_state = None + + if proxy_server.redis_client: + try: + state_bytes = proxy_server.redis_client.hget( + metadata_key, ChannelMetadataField.STATE + ) + if state_bytes: + current_state = state_bytes + logger.debug( + f"[{client_id}] Current state of channel {channel_id}: {current_state}" + ) + except Exception as e: + logger.warning( + f"[{client_id}] Error getting channel state: {e}" + ) + + # Allow normal transitional states to continue + if current_state in [ + ChannelState.INITIALIZING, + ChannelState.CONNECTING, + ]: + logger.info( + f"[{client_id}] Channel {channel_id} is in {current_state} state, continuing to wait" + ) + # Reset wait timer to allow the transition to complete + wait_start = time.time() + continue + + # Check if we're switching URLs + if ( + hasattr(manager, "url_switching") + and manager.url_switching + ): + logger.info( + f"[{client_id}] Stream manager is currently switching URLs for channel {channel_id}" + ) + # Reset wait timer to give the switch a chance + wait_start = time.time() + continue + + # If we reach here, we've exhausted retries and the channel isn't in a valid transitional state + logger.warning( + f"[{client_id}] Channel {channel_id} failed to connect and is not in transitional state" + ) + proxy_server.stop_channel(channel_id) + return JsonResponse( + {"error": "Failed to connect"}, status=502 + ) + + gevent.sleep( + 0.1 + ) # FIXED: Using gevent.sleep instead of time.sleep + + logger.info(f"[{client_id}] Successfully initialized channel {channel_id}") + channel_initializing = True + + # Register client - can do this regardless of initialization state + # Create local resources if needed + if ( + channel_id not in proxy_server.stream_buffers + or channel_id not in proxy_server.client_managers + ): + logger.debug( + f"[{client_id}] Channel {channel_id} exists in Redis but not initialized in this worker - initializing now" + ) + + # Get URL from Redis metadata + url = None + stream_user_agent = None # Initialize the variable + + if proxy_server.redis_client: + metadata_key = RedisKeys.channel_metadata(channel_id) + url_bytes = proxy_server.redis_client.hget( + metadata_key, ChannelMetadataField.URL + ) + ua_bytes = proxy_server.redis_client.hget( + metadata_key, ChannelMetadataField.USER_AGENT + ) + profile_bytes = proxy_server.redis_client.hget( + metadata_key, ChannelMetadataField.STREAM_PROFILE + ) + + if url_bytes: + url = url_bytes + if ua_bytes: + stream_user_agent = ua_bytes + # Extract transcode setting from Redis + if profile_bytes: + profile_str = profile_bytes + use_transcode = ( + profile_str == PROXY_PROFILE_NAME or profile_str == "None" + ) + logger.debug( + f"Using profile '{profile_str}' for channel {channel_id}, transcode={use_transcode}" + ) + else: + # Default settings when profile not found in Redis + profile_str = "None" # Default profile name + use_transcode = ( + False # Default to direct streaming without transcoding + ) + logger.debug( + f"No profile found in Redis for channel {channel_id}, defaulting to transcode={use_transcode}" + ) + + # Use client_user_agent as fallback if stream_user_agent is None + success = proxy_server.initialize_channel( + url, channel_id, stream_user_agent or client_user_agent, use_transcode + ) + if not success: + logger.error( + f"[{client_id}] Failed to initialize channel {channel_id} locally" + ) + return JsonResponse( + {"error": "Failed to initialize channel locally"}, status=500 + ) + + logger.info( + f"[{client_id}] Successfully initialized channel {channel_id} locally" + ) + + # Register client + buffer = proxy_server.stream_buffers[channel_id] + client_manager = proxy_server.client_managers[channel_id] + client_manager.add_client(client_id, client_ip, client_user_agent, user) + logger.info(f"[{client_id}] Client registered with channel {channel_id}") + + # Create a stream generator for this client + generate = create_stream_generator( + channel_id, client_id, client_ip, client_user_agent, channel_initializing, user=user + ) + + # Return the StreamingHttpResponse from the main function + response = StreamingHttpResponse( + streaming_content=generate(), content_type="video/mp2t" + ) + response["Cache-Control"] = "no-cache" + return response + + except Exception as e: + logger.error(f"Error in stream_ts: {e}", exc_info=True) + if connection_allocated: + try: + if not channel.release_stream(): + logger.warning(f"[{client_id}] Failed to release stream in exception handler") + except Exception: + pass + return JsonResponse({"error": str(e)}, status=500) + + +@api_view(["GET"]) +@permission_classes([AllowAny]) +def stream_xc(request, username, password, channel_id): + user = get_object_or_404(User, username=username) + + extension = pathlib.Path(channel_id).suffix + channel_id = pathlib.Path(channel_id).stem + + custom_properties = user.custom_properties or {} + + if "xc_password" not in custom_properties: + return Response({"error": "Invalid credentials"}, status=401) + + if custom_properties["xc_password"] != password: + return Response({"error": "Invalid credentials"}, status=401) + + if user.user_level < 10: + user_profile_count = user.channel_profiles.count() + + # 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() + + 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, str(channel.uuid), user) + + +@csrf_exempt +@api_view(["POST"]) +@permission_classes([IsAdmin]) +def change_stream(request, channel_id): + """Change stream URL for existing channel with enhanced diagnostics""" + proxy_server = ProxyServer.get_instance() + + try: + data = json.loads(request.body) + new_url = data.get("url") + user_agent = data.get("user_agent") + stream_id = data.get("stream_id") + + # If stream_id is provided, get the URL and user_agent from it + if stream_id: + logger.info( + f"Stream ID {stream_id} provided, looking up stream info for channel {channel_id}" + ) + stream_info = get_stream_info_for_switch(channel_id, stream_id) + + if "error" in stream_info: + return JsonResponse( + {"error": stream_info["error"], "stream_id": stream_id}, status=404 + ) + + # Use the info from the stream + new_url = stream_info["url"] + user_agent = stream_info["user_agent"] + m3u_profile_id = stream_info.get("m3u_profile_id") + # Stream ID will be passed to change_stream_url later + elif not new_url: + return JsonResponse( + {"error": "Either url or stream_id must be provided"}, status=400 + ) + + logger.info( + f"Attempting to change stream for channel {channel_id} to {new_url}" + ) + + # Use the service layer instead of direct implementation + # Pass stream_id to ensure proper connection tracking + result = ChannelService.change_stream_url( + channel_id, new_url, user_agent, stream_id, m3u_profile_id + ) + + # Get the stream manager before updating URL + stream_manager = proxy_server.stream_managers.get(channel_id) + + # If we have a stream manager, reset its tried_stream_ids when manually changing streams + if stream_manager: + # Reset tried streams when manually switching URL via API + stream_manager.tried_stream_ids = set() + logger.debug( + f"Reset tried stream IDs for channel {channel_id} during manual stream change" + ) + + if result.get("status") == "error": + return JsonResponse( + { + "error": result.get("message", "Unknown error"), + "diagnostics": result.get("diagnostics", {}), + }, + status=404, + ) + + # Format response based on whether it was a direct update or event-based + response_data = { + "message": "Stream changed successfully", + "channel": channel_id, + "url": new_url, + "owner": result.get("direct_update", False), + "worker_id": proxy_server.worker_id, + } + + # Include stream_id in response if it was used + if stream_id: + response_data["stream_id"] = stream_id + + return JsonResponse(response_data) + + except json.JSONDecodeError: + return JsonResponse({"error": "Invalid JSON"}, status=400) + except Exception as e: + logger.error(f"Failed to change stream: {e}", exc_info=True) + return JsonResponse({"error": str(e)}, status=500) + + +@api_view(["GET"]) +@permission_classes([IsAdmin]) +def channel_status(request, channel_id=None): + """ + Returns status information about channels with detail level based on request: + - /status/ returns basic summary of all channels + - /status/{channel_id} returns detailed info about specific channel + """ + proxy_server = ProxyServer.get_instance() + + try: + # Check if Redis is available + if not proxy_server.redis_client: + return JsonResponse({"error": "Redis connection not available"}, status=500) + + # Handle single channel or all channels + if channel_id: + # Detailed info for specific channel + channel_info = ChannelStatus.get_detailed_channel_info(channel_id) + if channel_info: + return JsonResponse(channel_info) + else: + return JsonResponse( + {"error": f"Channel {channel_id} not found"}, status=404 + ) + else: + # Basic info for all channels + channel_pattern = "ts_proxy:channel:*:metadata" + all_channels = [] + + # Extract channel IDs from keys + cursor = 0 + while True: + cursor, keys = proxy_server.redis_client.scan( + cursor, match=channel_pattern + ) + for key in keys: + channel_id_match = re.search( + r"ts_proxy:channel:(.*):metadata", key + ) + if channel_id_match: + ch_id = channel_id_match.group(1) + channel_info = ChannelStatus.get_basic_channel_info(ch_id) + if channel_info: + all_channels.append(channel_info) + + 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: + logger.error(f"Error in channel_status: {e}", exc_info=True) + return JsonResponse({"error": str(e)}, status=500) + + +@csrf_exempt +@api_view(["POST", "DELETE"]) +@permission_classes([IsAdmin]) +def stop_channel(request, channel_id): + """Stop a channel and release all associated resources using PubSub events""" + try: + logger.info(f"Request to stop channel {channel_id} received") + + # Use the service layer instead of direct implementation + result = ChannelService.stop_channel(channel_id) + + if result.get("status") == "error": + return JsonResponse( + {"error": result.get("message", "Unknown error")}, status=404 + ) + + return JsonResponse( + { + "message": "Channel stop request sent", + "channel_id": channel_id, + "previous_state": result.get("previous_state"), + } + ) + + except Exception as e: + logger.error(f"Failed to stop channel: {e}", exc_info=True) + return JsonResponse({"error": str(e)}, status=500) + + +@csrf_exempt +@api_view(["POST"]) +@permission_classes([IsAdmin]) +def stop_client(request, channel_id): + """Stop a specific client connection using existing client management""" + try: + # Parse request body to get client ID + data = json.loads(request.body) + client_id = data.get("client_id") + + if not client_id: + return JsonResponse({"error": "No client_id provided"}, status=400) + + # Use the service layer instead of direct implementation + result = ChannelService.stop_client(channel_id, client_id) + + if result.get("status") == "error": + return JsonResponse({"error": result.get("message")}, status=404) + + return JsonResponse( + { + "message": "Client stop request processed", + "channel_id": channel_id, + "client_id": client_id, + "locally_processed": result.get("locally_processed", False), + } + ) + + except json.JSONDecodeError: + return JsonResponse({"error": "Invalid JSON"}, status=400) + except Exception as e: + logger.error(f"Failed to stop client: {e}", exc_info=True) + return JsonResponse({"error": str(e)}, status=500) + + +@csrf_exempt +@api_view(["POST"]) +@permission_classes([IsAdmin]) +def next_stream(request, channel_id): + """Switch to the next available stream for a channel""" + proxy_server = ProxyServer.get_instance() + + try: + logger.info( + f"Request to switch to next stream for channel {channel_id} received" + ) + + # Check if the channel exists + channel = get_stream_object(channel_id) + + # First check if channel is active in Redis + current_stream_id = None + profile_id = None + + if proxy_server.redis_client: + metadata_key = RedisKeys.channel_metadata(channel_id) + if proxy_server.redis_client.exists(metadata_key): + # Get current stream ID from Redis + stream_id_bytes = proxy_server.redis_client.hget( + metadata_key, ChannelMetadataField.STREAM_ID + ) + if stream_id_bytes: + current_stream_id = int(stream_id_bytes) + logger.info( + f"Found current stream ID {current_stream_id} in Redis for channel {channel_id}" + ) + + # Get M3U profile from Redis if available + profile_id_bytes = proxy_server.redis_client.hget( + metadata_key, ChannelMetadataField.M3U_PROFILE + ) + if profile_id_bytes: + profile_id = int(profile_id_bytes) + logger.info( + f"Found M3U profile ID {profile_id} in Redis for channel {channel_id}" + ) + + if not current_stream_id: + # Channel is not running + return JsonResponse( + {"error": "No current stream found for channel"}, status=404 + ) + + # Get all streams for this channel in their defined order + streams = list(channel.streams.all().order_by("channelstream__order")) + + if len(streams) <= 1: + return JsonResponse( + { + "error": "No alternate streams available for this channel", + "current_stream_id": current_stream_id, + }, + status=404, + ) + + # Find the current stream's position in the list + current_index = None + for i, stream in enumerate(streams): + if stream.id == current_stream_id: + current_index = i + break + + if current_index is None: + logger.warning( + f"Current stream ID {current_stream_id} not found in channel's streams list" + ) + # Fall back to the first stream that's not the current one + next_stream = next((s for s in streams if s.id != current_stream_id), None) + if not next_stream: + return JsonResponse( + { + "error": "Could not find current stream in channel list", + "current_stream_id": current_stream_id, + }, + status=404, + ) + else: + # Get the next stream in the rotation (with wrap-around) + next_index = (current_index + 1) % len(streams) + next_stream = streams[next_index] + + next_stream_id = next_stream.id + logger.info( + f"Rotating to next stream ID {next_stream_id} for channel {channel_id}" + ) + + # Get full stream info including URL for the next stream + stream_info = get_stream_info_for_switch(channel_id, next_stream_id) + + if "error" in stream_info: + return JsonResponse( + { + "error": stream_info["error"], + "current_stream_id": current_stream_id, + "next_stream_id": next_stream_id, + }, + status=404, + ) + + # Now use the ChannelService to change the stream URL + result = ChannelService.change_stream_url( + channel_id, + stream_info["url"], + stream_info["user_agent"], + next_stream_id, + stream_info.get("m3u_profile_id"), + ) + + if result.get("status") == "error": + return JsonResponse( + { + "error": result.get("message", "Unknown error"), + "diagnostics": result.get("diagnostics", {}), + "current_stream_id": current_stream_id, + "next_stream_id": next_stream_id, + }, + status=404, + ) + + # Format success response + response_data = { + "message": "Stream switched to next available", + "channel": channel_id, + "previous_stream_id": current_stream_id, + "new_stream_id": next_stream_id, + "new_url": stream_info["url"], + "owner": result.get("direct_update", False), + "worker_id": proxy_server.worker_id, + } + + return JsonResponse(response_data) + + except Exception as e: + logger.error(f"Failed to switch to next stream: {e}", exc_info=True) + return JsonResponse({"error": str(e)}, status=500) diff --git a/apps/proxy/urls.py b/apps/proxy/urls.py new file mode 100644 index 0000000..3a32004 --- /dev/null +++ b/apps/proxy/urls.py @@ -0,0 +1,9 @@ +from django.urls import path, include + +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')), +] diff --git a/apps/proxy/utils.py b/apps/proxy/utils.py new file mode 100644 index 0000000..6503d1d --- /dev/null +++ b/apps/proxy/utils.py @@ -0,0 +1,185 @@ +import logging +from core.utils import RedisClient +from apps.proxy.vod_proxy.multi_worker_connection_manager import MultiWorkerVODConnectionManager, get_vod_client_stop_key +from core.models import CoreSettings +from apps.proxy.ts_proxy.services.channel_service import ChannelService + +logger = logging.getLogger("proxy") + + +def attempt_stream_termination(user_id, requesting_client_id, active_connections): + try: + logger.info("[stream limits]" f"[{requesting_client_id}] User {user_id} has {len(active_connections)} active connections, checking termination candidates") + + user_limit_settings = CoreSettings.get_user_limits_settings() + terminate_oldest = user_limit_settings.get("terminate_oldest", True) + prioritize_single = user_limit_settings.get("prioritize_single_client_channels", True) + ignore_same_channel = user_limit_settings.get("ignore_same_channel_connections", False) + + channel_counts = {} + for connection in active_connections: + media_id = connection['media_id'] + channel_counts[media_id] = channel_counts.get(media_id, 0) + 1 + + def prioritize(connection): + is_multi = channel_counts[connection['media_id']] > 1 + + # if we're ignoring same-channel connections, put them at the end + same_ch_key = 1 if (ignore_same_channel and is_multi) else 0 + + # key for prioritizing single-client channels + single_key = 0 if (prioritize_single and not is_multi) else 1 + + # sort by age setting + time_key = connection['connected_at'] if terminate_oldest else -connection['connected_at'] + + return (same_ch_key, single_key, time_key) + + termination_candidates = sorted(active_connections, key=prioritize) + + if not termination_candidates: + logger.warning("[stream limits]" f"[{requesting_client_id}] No termination candidates found for user {user_id}") + return False + + target = termination_candidates[0] + logger.info("[stream limits]" + f"[{requesting_client_id}] Terminating client {target['client_id']} " + f"on media {target['media_id']} (connected_at={target['connected_at']})" + ) + + # When counting by unique channel, freeing one connection from a multi-connection + # channel doesn't free a slot — terminate all connections to that channel so the + # unique-channel count actually drops by one. + targets = ( + [c for c in active_connections if c['media_id'] == target['media_id']] + if ignore_same_channel + else [target] + ) + + for t in targets: + if t['type'] == 'live': + result = ChannelService.stop_client(t['media_id'], t['client_id']) + if result.get("status") == "error": + logger.warning(f"[stream limits][{requesting_client_id}] Failed to stop client {t['client_id']} on channel {t['media_id']}") + else: + connection_manager = MultiWorkerVODConnectionManager.get_instance() + redis_client = connection_manager.redis_client + + if not redis_client: + return False + + connection_key = f"vod_persistent_connection:{t['client_id']}" + connection_data = redis_client.hgetall(connection_key) + if not connection_data: + logger.warning(f"VOD connection not found: {t['client_id']}") + continue + + stop_key = get_vod_client_stop_key(t['client_id']) + redis_client.setex(stop_key, 60, "true") # 60 second TTL + + return True + except Exception as e: + logger.error("[stream limits]" f"[{requesting_client_id}] Error during stream termination for user {user_id}: {e}") + return False + +def get_user_active_connections(user_id): + redis_client = RedisClient.get_client() + connections = [] + + try: + # Grab live streams + for key in redis_client.scan_iter(match="ts_proxy:channel:*:clients:*", count=1000): + parts = key.split(':') + if len(parts) >= 5: + channel_id = parts[2] + client_id = parts[4] + + client_user_id = redis_client.hget(key, 'user_id') + connected_at = redis_client.hget(key, 'connected_at') + + logger.debug(f"[stream limits] user_id = {user_id}") + logger.debug(f"[stream limits] channel_id = {channel_id}") + logger.debug(f"[stream limits] client_id = {client_id}") + + if client_user_id and int(client_user_id) == user_id: + try: + logger.debug(f"[stream limits] Found LIVE connection for user {user_id} on channel {channel_id} with client ID {client_id}") + connected_at = float(connected_at) if connected_at else 0 + connections.append({ + 'media_id': channel_id, + 'client_id': client_id, + 'connected_at': connected_at, + 'type': 'live', + }) + except (ValueError, TypeError): + pass + + # Grab VOD + for key in redis_client.scan_iter(match="vod_persistent_connection:*", count=1000): + parts = key.split(':') + if len(parts) >= 2: + client_id = parts[1] + + client_user_id = redis_client.hget(key, 'user_id') + connected_at = redis_client.hget(key, 'created_at') + content_uuid = redis_client.hget(key, 'content_uuid') + + logger.debug(f"[stream limits] user_id = {user_id}") + logger.debug(f"[stream limits] client_id = {client_id}") + + if client_user_id and int(client_user_id) == user_id: + try: + logger.debug(f"[stream limits] Found VOD connection for user {user_id} on content {content_uuid} with client ID {client_id}") + connected_at = float(connected_at) if connected_at else 0 + connections.append({ + 'media_id': content_uuid or client_id, + 'client_id': client_id, + 'connected_at': connected_at, + 'type': 'vod', + }) + except (ValueError, TypeError): + pass + + return connections + + except Exception as e: + logger.warning(f"Error getting active channel details for user {user_id}: {e}") + return [] + + +def check_user_stream_limits(user, client_id, media_id=None): + # Check user stream limits + if user and user.stream_limit > 0: + logger.debug("[stream limits]" f"[{client_id}] User {user.username} (ID: {user.id}) is requesting a stream (stream_limit: {user.stream_limit})") + user_limit_settings = CoreSettings.get_user_limits_settings() + ignore_same_channel = user_limit_settings.get("ignore_same_channel_connections", False) + + active_connections = get_user_active_connections(user.id) + unique_channel_count = set([conn['media_id'] for conn in active_connections]) + user_stream_count = len(unique_channel_count) if ignore_same_channel else len(active_connections) + + logger.debug(f"[stream limits]" f"[{client_id}] User {user.username} currently has {len(active_connections)} active connections across {len(unique_channel_count)} unique channels (counting method: {'unique channels' if ignore_same_channel else 'total connections'})") + + # If ignore_same_channel is enabled and this request is for a live channel the user + # is already watching, allow it through without counting against the limit. + # VOD is excluded: connections aren't shared so multiple VOD connections to the + # same content would mean multiple upstream connections. + live_channel_ids = {str(conn['media_id']) for conn in active_connections if conn['type'] == 'live'} + if ignore_same_channel and media_id and str(media_id) in live_channel_ids: + logger.debug(f"[stream limits][{client_id}] Same-channel reconnect for {media_id} allowed (ignore_same_channel=True)") + return True + + if user_stream_count >= user.stream_limit: + if user_limit_settings.get("terminate_on_limit_exceeded", True) == False: + return False + + if user_stream_count >= user.stream_limit: + logger.warning("[stream limits]" + f"[{client_id}] User {user.username} (ID: {user.id}) has reached stream limit " + f"({user_stream_count}/{user.stream_limit} streams), attempting to free up slot" + ) + + if not attempt_stream_termination(user.id, client_id, active_connections): + return False + + return True diff --git a/apps/proxy/views.py b/apps/proxy/views.py new file mode 100644 index 0000000..d816992 --- /dev/null +++ b/apps/proxy/views.py @@ -0,0 +1,64 @@ +from rest_framework import viewsets, status +from rest_framework.decorators import action +from rest_framework.response import Response +from django.apps import apps +import logging + +logger = logging.getLogger(__name__) + +class ProxyViewSet(viewsets.ViewSet): + """ViewSet for managing proxy servers""" + + @action(detail=False, methods=['post']) + def start(self, request): + """Start a proxy server for a channel""" + try: + proxy_type = request.data.get('type', 'hls') + channel_id = request.data.get('channel', 'default') + url = request.data.get('url') + + if not url: + return Response( + {'error': 'URL is required'}, + status=status.HTTP_400_BAD_REQUEST + ) + + proxy_app = apps.get_app_config('proxy') + proxy_server = getattr(proxy_app, f'{proxy_type}_proxy') + proxy_server.initialize_channel(url, channel_id) + + return Response({ + 'message': f'{proxy_type.upper()} proxy started', + 'channel': channel_id, + 'url': url + }) + + except Exception as e: + logger.error(f"Error starting proxy: {e}") + return Response( + {'error': str(e)}, + status=status.HTTP_500_INTERNAL_SERVER_ERROR + ) + + @action(detail=False, methods=['post']) + def stop(self, request): + """Stop a proxy server for a channel""" + try: + proxy_type = request.data.get('type', 'hls') + channel_id = request.data.get('channel', 'default') + + proxy_app = apps.get_app_config('proxy') + proxy_server = getattr(proxy_app, f'{proxy_type}_proxy') + proxy_server.stop_channel(channel_id) + + return Response({ + 'message': f'{proxy_type.upper()} proxy stopped', + 'channel': channel_id + }) + + except Exception as e: + logger.error(f"Error stopping proxy: {e}") + return Response( + {'error': str(e)}, + status=status.HTTP_500_INTERNAL_SERVER_ERROR + ) \ No newline at end of file diff --git a/apps/proxy/vod_proxy/__init__.py b/apps/proxy/vod_proxy/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/proxy/vod_proxy/multi_worker_connection_manager.py b/apps/proxy/vod_proxy/multi_worker_connection_manager.py new file mode 100644 index 0000000..073df2c --- /dev/null +++ b/apps/proxy/vod_proxy/multi_worker_connection_manager.py @@ -0,0 +1,1617 @@ +""" +Enhanced VOD Connection Manager with Redis-based connection sharing for multi-worker environments +""" + +import time +import json +import logging +import threading +import random +import re +import requests +import pickle +import base64 +import os +import socket +import mimetypes +from urllib.parse import urlparse +from typing import Optional, Dict, Any +from django.http import StreamingHttpResponse, HttpResponse +from core.utils import RedisClient +from apps.vod.models import Movie, Episode +from apps.m3u.models import M3UAccountProfile + +logger = logging.getLogger("vod_proxy") + + +def get_vod_client_stop_key(client_id): + """Get the Redis key for signaling a VOD client to stop""" + return f"vod_proxy:client:{client_id}:stop" + + +def infer_content_type_from_url(url: str) -> Optional[str]: + """ + Infer MIME type from file extension in URL + + Args: + url: The stream URL + + Returns: + MIME type string or None if cannot be determined + """ + try: + parsed_url = urlparse(url) + path = parsed_url.path + + # Extract file extension + _, ext = os.path.splitext(path) + ext = ext.lower() + + # Common video format mappings + video_mime_types = { + '.mp4': 'video/mp4', + '.mkv': 'video/x-matroska', + '.avi': 'video/x-msvideo', + '.mov': 'video/quicktime', + '.wmv': 'video/x-ms-wmv', + '.flv': 'video/x-flv', + '.webm': 'video/webm', + '.m4v': 'video/x-m4v', + '.3gp': 'video/3gpp', + '.ts': 'video/mp2t', + '.m3u8': 'application/x-mpegURL', + '.mpg': 'video/mpeg', + '.mpeg': 'video/mpeg', + } + + if ext in video_mime_types: + logger.debug(f"Inferred content type '{video_mime_types[ext]}' from extension '{ext}' in URL: {url}") + return video_mime_types[ext] + + # Fallback to mimetypes module + mime_type, _ = mimetypes.guess_type(path) + if mime_type and mime_type.startswith('video/'): + logger.debug(f"Inferred content type '{mime_type}' using mimetypes for URL: {url}") + return mime_type + + logger.debug(f"Could not infer content type from URL: {url}") + return None + + except Exception as e: + logger.warning(f"Error inferring content type from URL '{url}': {e}") + return None + + +class SerializableConnectionState: + """Serializable connection state that can be stored in Redis""" + + def __init__(self, session_id: str, stream_url: str, headers: dict, + content_length: str = None, content_type: str = None, + final_url: str = None, m3u_profile_id: int = None, + # Session metadata fields (previously stored in vod_session key) + content_obj_type: str = None, content_uuid: str = None, + content_name: str = None, client_ip: str = None, + client_user_agent: str = None, utc_start: str = None, + utc_end: str = None, offset: str = None, + worker_id: str = None, connection_type: str = "redis_backed", user_id: str = "unknown"): + self.session_id = session_id + self.stream_url = stream_url + self.headers = headers + self.content_length = content_length + self.content_type = content_type + self.final_url = final_url + self.m3u_profile_id = m3u_profile_id # Store M3U profile ID for connection counting + self.last_activity = time.time() + self.request_count = 0 + self.active_streams = 0 + self.user_id = user_id + + # Session metadata (consolidated from vod_session key) + self.content_obj_type = content_obj_type + self.content_uuid = content_uuid + self.content_name = content_name + self.client_ip = client_ip + self.client_user_agent = client_user_agent + self.utc_start = utc_start or "" + self.utc_end = utc_end or "" + self.offset = offset or "" + self.worker_id = worker_id + self.connection_type = connection_type + self.created_at = time.time() + + # Additional tracking fields + self.bytes_sent = 0 + self.position_seconds = 0 + + # Range/seek tracking for position calculation + self.last_seek_byte = 0 + self.last_seek_percentage = 0.0 + self.total_content_size = 0 + self.last_seek_timestamp = 0.0 + + def to_dict(self): + """Convert to dictionary for Redis storage""" + return { + 'session_id': self.session_id or '', + 'stream_url': self.stream_url or '', + 'headers': json.dumps(self.headers or {}), + 'content_length': str(self.content_length) if self.content_length is not None else '', + 'content_type': self.content_type or '', + 'final_url': self.final_url or '', + 'm3u_profile_id': str(self.m3u_profile_id) if self.m3u_profile_id is not None else '', + 'last_activity': str(self.last_activity), + 'request_count': str(self.request_count), + 'active_streams': str(self.active_streams), + # Session metadata + 'content_obj_type': self.content_obj_type or '', + 'content_uuid': self.content_uuid or '', + 'content_name': self.content_name or '', + 'client_ip': self.client_ip or '', + 'client_user_agent': self.client_user_agent or '', + 'utc_start': self.utc_start or '', + 'utc_end': self.utc_end or '', + 'offset': self.offset or '', + 'worker_id': self.worker_id or '', + 'connection_type': self.connection_type or 'redis_backed', + 'created_at': str(self.created_at), + # Additional tracking fields + 'bytes_sent': str(self.bytes_sent), + 'position_seconds': str(self.position_seconds), + # Range/seek tracking + 'last_seek_byte': str(self.last_seek_byte), + 'last_seek_percentage': str(self.last_seek_percentage), + 'total_content_size': str(self.total_content_size), + 'last_seek_timestamp': str(self.last_seek_timestamp), + 'user_id': str(self.user_id), + } + + @classmethod + def from_dict(cls, data: dict): + """Create from dictionary loaded from Redis""" + obj = cls( + session_id=data['session_id'], + stream_url=data['stream_url'], + headers=json.loads(data['headers']) if data['headers'] else {}, + content_length=data.get('content_length') if data.get('content_length') else None, + content_type=data.get('content_type') or None, + final_url=data.get('final_url') if data.get('final_url') else None, + m3u_profile_id=int(data.get('m3u_profile_id')) if data.get('m3u_profile_id') else None, + # Session metadata + content_obj_type=data.get('content_obj_type') or None, + content_uuid=data.get('content_uuid') or None, + content_name=data.get('content_name') or None, + client_ip=data.get('client_ip') or None, + client_user_agent=data.get('client_user_agent') or data.get('user_agent') or None, + utc_start=data.get('utc_start') or '', + utc_end=data.get('utc_end') or '', + offset=data.get('offset') or '', + worker_id=data.get('worker_id') or None, + connection_type=data.get('connection_type', 'redis_backed'), + user_id=data.get('user_id', 'unknown') + ) + obj.last_activity = float(data.get('last_activity', time.time())) + obj.request_count = int(data.get('request_count', 0)) + obj.active_streams = int(data.get('active_streams', 0)) + obj.created_at = float(data.get('created_at', time.time())) + # Additional tracking fields + obj.bytes_sent = int(data.get('bytes_sent', 0)) + obj.position_seconds = int(data.get('position_seconds', 0)) + # Range/seek tracking + obj.last_seek_byte = int(data.get('last_seek_byte', 0)) + obj.last_seek_percentage = float(data.get('last_seek_percentage', 0.0)) + obj.total_content_size = int(data.get('total_content_size', 0)) + obj.last_seek_timestamp = float(data.get('last_seek_timestamp', 0.0)) + return obj + + +class RedisBackedVODConnection: + """Redis-backed VOD connection that can be accessed from any worker""" + + def __init__(self, session_id: str, redis_client=None): + self.session_id = session_id + self.redis_client = redis_client or RedisClient.get_client() + self.connection_key = f"vod_persistent_connection:{session_id}" + self.lock_key = f"vod_connection_lock:{session_id}" + self.local_session = None # Local requests session + self.local_response = None # Local current response + + def _get_connection_state(self) -> Optional[SerializableConnectionState]: + """Get connection state from Redis""" + if not self.redis_client: + return None + + try: + data = self.redis_client.hgetall(self.connection_key) + if not data: + return None + + # Convert bytes keys/values to strings if needed + if isinstance(list(data.keys())[0], bytes): + data = {k: v for k, v in data.items()} + + return SerializableConnectionState.from_dict(data) + except Exception as e: + logger.error(f"[{self.session_id}] Error getting connection state from Redis: {e}") + return None + + def _save_connection_state(self, state: SerializableConnectionState): + """Save connection state to Redis""" + if not self.redis_client: + return False + + try: + data = state.to_dict() + # Log the data being saved for debugging + logger.trace(f"[{self.session_id}] Saving connection state: {data}") + + # Verify all values are valid for Redis + for key, value in data.items(): + if value is None: + logger.error(f"[{self.session_id}] None value found for key '{key}' - this should not happen") + return False + + self.redis_client.hset(self.connection_key, mapping=data) + self.redis_client.expire(self.connection_key, 3600) # 1 hour TTL + return True + except Exception as e: + logger.error(f"[{self.session_id}] Error saving connection state to Redis: {e}") + return False + + def _acquire_lock(self, timeout: int = 10) -> bool: + """Acquire distributed lock for connection operations""" + if not self.redis_client: + return False + + try: + return self.redis_client.set(self.lock_key, "locked", nx=True, ex=timeout) + except Exception as e: + logger.error(f"[{self.session_id}] Error acquiring lock: {e}") + return False + + def _release_lock(self): + """Release distributed lock""" + if not self.redis_client: + return + + try: + self.redis_client.delete(self.lock_key) + except Exception as e: + logger.error(f"[{self.session_id}] Error releasing lock: {e}") + + def create_connection(self, stream_url: str, headers: dict, m3u_profile_id: int = None, + # Session metadata (consolidated from vod_session key) + content_obj_type: str = None, content_uuid: str = None, + content_name: str = None, client_ip: str = None, + client_user_agent: str = None, utc_start: str = None, + utc_end: str = None, offset: str = None, + worker_id: str = None, user=None) -> bool: + """Create a new connection state in Redis with consolidated session metadata""" + if not self._acquire_lock(): + logger.warning(f"[{self.session_id}] Could not acquire lock for connection creation") + return False + + try: + # Check if connection already exists + existing_state = self._get_connection_state() + if existing_state: + logger.info(f"[{self.session_id}] Connection already exists in Redis") + return True + + # Create new connection state with consolidated session metadata + state = SerializableConnectionState( + session_id=self.session_id, + stream_url=stream_url, + headers=headers, + m3u_profile_id=m3u_profile_id, + # Session metadata + content_obj_type=content_obj_type, + content_uuid=content_uuid, + content_name=content_name, + client_ip=client_ip, + client_user_agent=client_user_agent, + utc_start=utc_start, + utc_end=utc_end, + offset=offset, + worker_id=worker_id, + user_id=user.id if user else "unknown" + ) + success = self._save_connection_state(state) + + if success: + logger.info(f"[{self.session_id}] Created new connection state in Redis with consolidated session metadata") + + return success + finally: + self._release_lock() + + def get_stream(self, range_header: str = None): + """Get stream with optional range header - works across workers""" + # Get connection state from Redis + state = self._get_connection_state() + if not state: + logger.error(f"[{self.session_id}] No connection state found in Redis") + return None + + # Update activity and increment request count + state.last_activity = time.time() + state.request_count += 1 + + try: + # Create local session if needed + if not self.local_session: + self.local_session = requests.Session() + + # Prepare headers + headers = state.headers.copy() + if range_header: + # Validate range against content length if available + if state.content_length: + validated_range = self._validate_range_header(range_header, int(state.content_length)) + if validated_range is None: + logger.warning(f"[{self.session_id}] Range not satisfiable: {range_header}") + return None + range_header = validated_range + + headers['Range'] = range_header + logger.info(f"[{self.session_id}] Setting Range header: {range_header}") + + # Use final URL if available, otherwise original URL + target_url = state.final_url if state.final_url else state.stream_url + allow_redirects = not state.final_url # Only follow redirects if we don't have final URL + + logger.info(f"[{self.session_id}] Making request #{state.request_count} to {'final' if state.final_url else 'original'} URL") + + # Make request (10s connect, 10s read timeout - keeps lock time reasonable if client disconnects) + response = self.local_session.get( + target_url, + headers=headers, + stream=True, + timeout=(10, 10), + allow_redirects=allow_redirects + ) + + # If the cached final_url returned an error (e.g. an ephemeral dispatcharr session + # that has since expired), clear it and retry from the original stream_url. + if response.status_code >= 400 and state.final_url: + logger.warning( + f"[{self.session_id}] Cached final_url returned {response.status_code}, " + f"clearing and retrying from stream_url" + ) + response.close() + state.final_url = None + response = self.local_session.get( + state.stream_url, + headers=headers, + stream=True, + timeout=(10, 10), + allow_redirects=True + ) + + response.raise_for_status() + + # Update state with response info on first request + if state.request_count == 1: + if not state.content_length: + # Try to get full file size from Content-Range header first (for range requests) + content_range = response.headers.get('content-range') + if content_range and '/' in content_range: + try: + # Parse "bytes 0-1023/12653476926" to get total size + total_size = content_range.split('/')[-1] + if total_size.isdigit(): + state.content_length = total_size + logger.debug(f"[{self.session_id}] Got full file size from Content-Range: {total_size}") + else: + # Fallback to Content-Length for partial size + state.content_length = response.headers.get('content-length') + except Exception as e: + logger.warning(f"[{self.session_id}] Error parsing Content-Range: {e}") + state.content_length = response.headers.get('content-length') + else: + # No Content-Range, use Content-Length (for non-range requests) + state.content_length = response.headers.get('content-length') + + logger.debug(f"[{self.session_id}] Response headers received: {dict(response.headers)}") + + if not state.content_type: # This will be True for None, '', or any falsy value + # Get content type from provider response headers + provider_content_type = (response.headers.get('content-type') or + response.headers.get('Content-Type') or + response.headers.get('CONTENT-TYPE')) + + if provider_content_type: + logger.debug(f"[{self.session_id}] Using provider Content-Type: '{provider_content_type}'") + state.content_type = provider_content_type + else: + # Provider didn't send Content-Type, infer from URL extension + inferred_content_type = infer_content_type_from_url(state.stream_url) + if inferred_content_type: + logger.info(f"[{self.session_id}] Provider missing Content-Type, inferred from URL: '{inferred_content_type}'") + state.content_type = inferred_content_type + else: + logger.debug(f"[{self.session_id}] No Content-Type from provider and could not infer from URL, using default: 'video/mp4'") + state.content_type = 'video/mp4' + else: + logger.debug(f"[{self.session_id}] Content-Type already set in state: {state.content_type}") + if not state.final_url: + state.final_url = response.url + + logger.info(f"[{self.session_id}] Updated connection state: length={state.content_length}, type={state.content_type}") + + # Save updated state under lock to avoid overwriting concurrent + # active_streams changes (e.g., another stream's GeneratorExit decrement) + if self._acquire_lock(): + try: + current = self._get_connection_state() + if current: + # Preserve the current active_streams value — it may have been + # modified by concurrent increment/decrement operations while + # waiting for the upstream HTTP response. + state.active_streams = current.active_streams + self._save_connection_state(state) + finally: + self._release_lock() + else: + # Fallback: save without lock but skip active_streams to avoid overwrite + logger.warning(f"[{self.session_id}] Could not acquire lock for get_stream state save") + + self.local_response = response + return response + + except Exception as e: + logger.error(f"[{self.session_id}] Error establishing connection: {e}") + self.cleanup() + raise + + def _validate_range_header(self, range_header: str, content_length: int): + """Validate range header against content length""" + try: + if not range_header or not range_header.startswith('bytes='): + return range_header + + range_part = range_header.replace('bytes=', '') + if '-' not in range_part: + return range_header + + start_str, end_str = range_part.split('-', 1) + + # Parse start byte + if start_str: + start_byte = int(start_str) + if start_byte >= content_length: + return None # Not satisfiable + else: + start_byte = 0 + + # Parse end byte + if end_str: + end_byte = int(end_str) + if end_byte >= content_length: + end_byte = content_length - 1 + else: + end_byte = content_length - 1 + + # Ensure start <= end + if start_byte > end_byte: + return None + + return f"bytes={start_byte}-{end_byte}" + + except (ValueError, IndexError) as e: + logger.warning(f"[{self.session_id}] Could not validate range header {range_header}: {e}") + return range_header + + def increment_active_streams(self): + """Increment active streams count in Redis. Returns new active_streams count, or 0 on failure.""" + if not self._acquire_lock(): + logger.warning(f"[{self.session_id}] INCR-AS failed: could not acquire lock") + return 0 + + try: + state = self._get_connection_state() + if state: + old = state.active_streams + state.active_streams += 1 + state.last_activity = time.time() + self._save_connection_state(state) + logger.debug(f"[{self.session_id}] INCR-AS {old} -> {state.active_streams}") + return state.active_streams + logger.warning(f"[{self.session_id}] INCR-AS failed: no state") + return 0 + finally: + self._release_lock() + + def decrement_active_streams(self): + """Decrement active streams count in Redis""" + if not self._acquire_lock(): + logger.warning(f"[{self.session_id}] DECR-AS failed: could not acquire lock") + return False + + try: + state = self._get_connection_state() + if state and state.active_streams > 0: + old = state.active_streams + state.active_streams -= 1 + state.last_activity = time.time() + self._save_connection_state(state) + logger.debug(f"[{self.session_id}] DECR-AS {old} -> {state.active_streams}") + return True + if not state: + logger.warning(f"[{self.session_id}] DECR-AS failed: no state") + else: + logger.warning(f"[{self.session_id}] DECR-AS failed: active_streams already {state.active_streams}") + return False + finally: + self._release_lock() + + def decrement_active_streams_and_check(self): + """Atomically decrement active streams and return (success, has_remaining_streams). + + Combines decrement + check under a single lock to eliminate the race window + between separate decrement_active_streams() and has_active_streams() calls. + + Returns: + (True, False) - decremented successfully, no streams remain + (True, True) - decremented successfully, other streams still active + (False, True) - lock contention, assume streams remain (safe default) + """ + if not self._acquire_lock(): + logger.warning(f"[{self.session_id}] DECR-AS-CHECK failed: could not acquire lock") + return False, True # Assume remaining to avoid skipping profile decrement + + try: + state = self._get_connection_state() + if state and state.active_streams > 0: + old = state.active_streams + state.active_streams -= 1 + state.last_activity = time.time() + self._save_connection_state(state) + logger.debug(f"[{self.session_id}] DECR-AS {old} -> {state.active_streams}") + return True, state.active_streams > 0 + if not state: + logger.warning(f"[{self.session_id}] DECR-AS-CHECK failed: no state") + return False, False + logger.warning(f"[{self.session_id}] DECR-AS-CHECK failed: active_streams already {state.active_streams}") + return False, False + finally: + self._release_lock() + + def has_active_streams(self) -> bool: + """Check if connection has any active streams""" + state = self._get_connection_state() + return state.active_streams > 0 if state else False + + def get_headers(self): + """Get headers for response""" + state = self._get_connection_state() + if state: + return { + 'content_length': state.content_length, + 'content_type': state.content_type or 'video/mp4', + 'final_url': state.final_url + } + return {} + + def get_session_metadata(self): + """Get session metadata from consolidated connection state""" + state = self._get_connection_state() + if state: + return { + 'content_obj_type': state.content_obj_type, + 'content_uuid': state.content_uuid, + 'content_name': state.content_name, + 'client_ip': state.client_ip, + 'client_user_agent': state.client_user_agent, + 'utc_start': state.utc_start, + 'utc_end': state.utc_end, + 'offset': state.offset, + 'worker_id': state.worker_id, + 'connection_type': state.connection_type, + 'created_at': state.created_at, + 'last_activity': state.last_activity, + 'm3u_profile_id': state.m3u_profile_id, + 'bytes_sent': state.bytes_sent, + 'position_seconds': state.position_seconds, + 'active_streams': state.active_streams, + 'request_count': state.request_count, + # Range/seek tracking + 'last_seek_byte': state.last_seek_byte, + 'last_seek_percentage': state.last_seek_percentage, + 'total_content_size': state.total_content_size, + 'last_seek_timestamp': state.last_seek_timestamp + } + return {} + + def cleanup(self, connection_manager=None, current_worker_id=None): + """Smart cleanup based on worker ownership and active streams""" + # Always clean up local resources first + if self.local_response: + self.local_response.close() + self.local_response = None + if self.local_session: + self.local_session.close() + self.local_session = None + + # Get current connection state to check ownership and active streams + state = self._get_connection_state() + + if not state: + logger.info(f"[{self.session_id}] No connection state found - local cleanup only") + return + + # Check if there are active streams + if state.active_streams > 0: + # There are active streams - check ownership + if current_worker_id and state.worker_id == current_worker_id: + logger.info(f"[{self.session_id}] Active streams present ({state.active_streams}) and we own them - local cleanup only") + else: + logger.info(f"[{self.session_id}] Active streams present ({state.active_streams}) but owned by worker {state.worker_id} - local cleanup only") + return + + # No active streams - we can clean up Redis state + if not self.redis_client: + logger.info(f"[{self.session_id}] No Redis client - local cleanup only") + return + + # Acquire lock and do final check before cleanup to prevent race conditions + if not self._acquire_lock(): + logger.warning(f"[{self.session_id}] Could not acquire lock for cleanup - skipping") + return + + try: + # Re-check active streams with lock held to prevent race conditions + current_state = self._get_connection_state() + if not current_state: + logger.info(f"[{self.session_id}] Connection state no longer exists - cleanup already done") + return + + if current_state.active_streams > 0: + logger.info(f"[{self.session_id}] Active streams now present ({current_state.active_streams}) - skipping cleanup") + return + + # Use pipeline for atomic cleanup operations + pipe = self.redis_client.pipeline() + + # 1. Remove main connection state (contains consolidated data) + pipe.delete(self.connection_key) + + # 2. Remove distributed lock (will be released below anyway) + pipe.delete(self.lock_key) + + # Execute all cleanup operations + pipe.execute() + + logger.info(f"[{self.session_id}] Cleaned up Redis keys (verified no active streams)") + + # Decrement profile connections if we have the state and connection manager + if state.m3u_profile_id and connection_manager: + connection_manager._decrement_profile_connections(state.m3u_profile_id) + logger.info(f"[{self.session_id}] Profile connection count decremented for profile {state.m3u_profile_id}") + else: + if not state.m3u_profile_id: + logger.warning(f"[{self.session_id}] No profile ID in connection state - cannot decrement profile connections") + elif not connection_manager: + logger.warning(f"[{self.session_id}] No connection manager provided - cannot decrement profile connections") + + except Exception as e: + logger.error(f"[{self.session_id}] Error cleaning up Redis state: {e}") + finally: + # Always release the lock + self._release_lock() + + +# Modify the VODConnectionManager to use Redis-backed connections +class MultiWorkerVODConnectionManager: + """Enhanced VOD Connection Manager that works across multiple uwsgi workers""" + + _instance = None + + @classmethod + def get_instance(cls): + """Get the singleton instance""" + if cls._instance is None: + cls._instance = cls() + return cls._instance + + def __init__(self): + self.redis_client = RedisClient.get_client() + self.connection_ttl = 3600 # 1 hour TTL for connections + self.session_ttl = 1800 # 30 minutes TTL for sessions + self.worker_id = self._get_worker_id() + logger.info(f"MultiWorkerVODConnectionManager initialized for worker {self.worker_id}") + + def _get_worker_id(self): + """Get unique worker ID for this process""" + import os + import socket + try: + # Use combination of hostname and PID for unique worker ID + return f"{socket.gethostname()}-{os.getpid()}" + except: + import random + return f"worker-{random.randint(1000, 9999)}" + + def _get_profile_connections_key(self, profile_id: int) -> str: + """Get Redis key for tracking connections per profile - STANDARDIZED with TS proxy""" + return f"profile_connections:{profile_id}" + + def _check_profile_limits(self, m3u_profile) -> bool: + """Check if profile has available connection slots""" + if m3u_profile.max_streams == 0: # Unlimited + return True + + try: + profile_connections_key = self._get_profile_connections_key(m3u_profile.id) + current_connections = int(self.redis_client.get(profile_connections_key) or 0) + + logger.info(f"[PROFILE-CHECK] Profile {m3u_profile.id} has {current_connections}/{m3u_profile.max_streams} connections") + return current_connections < m3u_profile.max_streams + + except Exception as e: + logger.error(f"Error checking profile limits: {e}") + return False + + def _check_and_reserve_profile_slot(self, m3u_profile) -> bool: + """ + Atomically check and reserve a connection slot for the given profile. + + Uses an INCR-first-then-check pattern to eliminate the TOCTOU race + condition where separate GET > check > INCR operations could allow + concurrent requests to both pass the capacity check. + + For profiles with max_streams=0 (unlimited), no reservation is needed. + + Returns: + bool: True if slot was reserved (or unlimited), False if at capacity + """ + if m3u_profile.max_streams == 0: # Unlimited + return True + + try: + profile_connections_key = self._get_profile_connections_key(m3u_profile.id) + + # Atomically increment first — single Redis command eliminates race window + new_count = self.redis_client.incr(profile_connections_key) + + if new_count <= m3u_profile.max_streams: + logger.info(f"[PROFILE-RESERVE] Profile {m3u_profile.id} slot reserved: {new_count}/{m3u_profile.max_streams}") + return True + + # Over capacity — roll back the increment + self.redis_client.decr(profile_connections_key) + logger.info(f"[PROFILE-RESERVE] Profile {m3u_profile.id} at capacity: {new_count - 1}/{m3u_profile.max_streams}") + return False + + except Exception as e: + logger.error(f"Error reserving profile slot: {e}") + return False + + def _increment_profile_connections(self, m3u_profile): + """Increment profile connection count""" + try: + profile_connections_key = self._get_profile_connections_key(m3u_profile.id) + new_count = self.redis_client.incr(profile_connections_key) + logger.info(f"[PROFILE-INCR] Profile {m3u_profile.id} connections: {new_count}") + return new_count + except Exception as e: + logger.error(f"Error incrementing profile connections: {e}") + return None + + def _decrement_profile_connections(self, m3u_profile_id: int): + """Decrement profile connection count. + + Uses a single atomic DECR (no GET-before-DECR) to avoid the race condition + where two concurrent decrements both pass a >0 guard and both fire, sending + the counter negative. If the counter would go below zero it is clamped to 0. + """ + try: + profile_connections_key = self._get_profile_connections_key(m3u_profile_id) + new_count = self.redis_client.decr(profile_connections_key) + if new_count < 0: + self.redis_client.set(profile_connections_key, 0) + new_count = 0 + logger.warning(f"[PROFILE-DECR] Profile {m3u_profile_id} counter went negative, clamped to 0") + else: + logger.info(f"[PROFILE-DECR] Profile {m3u_profile_id} connections: {new_count}") + return new_count + except Exception as e: + logger.error(f"Error decrementing profile connections: {e}") + return None + + def stream_content_with_session(self, session_id, content_obj, stream_url, m3u_profile, + client_ip, client_user_agent, request, + utc_start=None, utc_end=None, offset=None, range_header=None, user=None): + """Stream content with Redis-backed persistent connection""" + + # Generate client ID + content_type = "movie" if isinstance(content_obj, Movie) else "episode" + content_uuid = str(content_obj.uuid) + content_name = content_obj.name if hasattr(content_obj, 'name') else str(content_obj) + client_id = session_id + + # Track whether we incremented profile connections (for cleanup on error) + profile_connections_incremented = False + redis_connection = None + + logger.info(f"[{client_id}] Worker {self.worker_id} - Redis-backed streaming request for {content_type} {content_name}") + + try: + # First, try to find an existing idle session that matches our criteria + matching_session_id = self.find_matching_idle_session( + content_type=content_type, + content_uuid=content_uuid, + client_ip=client_ip, + client_user_agent=client_user_agent, + utc_start=utc_start, + utc_end=utc_end, + offset=offset + ) + + # Use matching session if found, otherwise use the provided session_id + if matching_session_id: + logger.info(f"[{client_id}] Worker {self.worker_id} - Found matching idle session: {matching_session_id}") + effective_session_id = matching_session_id + client_id = matching_session_id # Update client_id for logging consistency + + # IMMEDIATELY reserve this session by incrementing active streams to prevent cleanup + temp_connection = RedisBackedVODConnection(effective_session_id, self.redis_client) + if temp_connection.increment_active_streams(): + logger.info(f"[{client_id}] Reserved idle session - incremented active streams") + else: + logger.warning(f"[{client_id}] Failed to reserve idle session - falling back to new session") + effective_session_id = session_id + matching_session_id = None # Clear the match so we create a new connection + else: + logger.info(f"[{client_id}] Worker {self.worker_id} - No matching idle session found, using new session") + effective_session_id = session_id + + # Create Redis-backed connection + redis_connection = RedisBackedVODConnection(effective_session_id, self.redis_client) + + # Check if connection exists, create if not + existing_state = redis_connection._get_connection_state() + if not existing_state: + logger.info(f"[{client_id}] Worker {self.worker_id} - Creating new Redis-backed connection") + + # Atomically check and reserve a profile connection slot (INCR-first) + if not self._check_and_reserve_profile_slot(m3u_profile): + logger.warning(f"[{client_id}] Profile {m3u_profile.name} connection limit exceeded") + return HttpResponse("Connection limit exceeded for profile", status=429) + profile_connections_incremented = True + + # Apply timeshift parameters + modified_stream_url = self._apply_timeshift_parameters(stream_url, utc_start, utc_end, offset) + + # Prepare headers for provider request + headers = {} + # Use M3U account's user-agent for provider requests, not client's user-agent + m3u_user_agent = m3u_profile.m3u_account.get_user_agent() + if m3u_user_agent: + headers['User-Agent'] = m3u_user_agent.user_agent + logger.info(f"[{client_id}] Using M3U account user-agent: {m3u_user_agent.user_agent}") + elif client_user_agent: + # Fallback to client's user-agent if M3U doesn't have one + headers['User-Agent'] = client_user_agent + logger.info(f"[{client_id}] Using client user-agent (M3U fallback): {client_user_agent}") + else: + logger.warning(f"[{client_id}] No user-agent available (neither M3U nor client)") + + # Forward important headers from request + important_headers = ['authorization', 'referer', 'origin', 'accept'] + for header_name in important_headers: + django_header = f'HTTP_{header_name.upper().replace("-", "_")}' + if hasattr(request, 'META') and django_header in request.META: + headers[header_name] = request.META[django_header] + + # Create connection state in Redis with consolidated session metadata + if not redis_connection.create_connection( + stream_url=modified_stream_url, + headers=headers, + m3u_profile_id=m3u_profile.id, + # Session metadata (consolidated from separate vod_session key) + content_obj_type=content_type, + content_uuid=content_uuid, + content_name=content_name, + client_ip=client_ip, + client_user_agent=client_user_agent, + utc_start=utc_start, + utc_end=utc_end, + offset=str(offset) if offset else None, + worker_id=self.worker_id, + user=user + ): + logger.error(f"[{client_id}] Worker {self.worker_id} - Failed to create Redis connection") + # Roll back the profile slot reservation since connection failed + self._decrement_profile_connections(m3u_profile.id) + profile_connections_incremented = False + return HttpResponse("Failed to create connection", status=500) + + logger.info(f"[{client_id}] Worker {self.worker_id} - Created consolidated connection with session metadata") + else: + logger.info(f"[{client_id}] Worker {self.worker_id} - Using existing Redis-backed connection") + + # Immediately increment active_streams to prevent cleanup race condition. + # Without this, stream's GeneratorExit can see active_streams=0 + # and DECR the profile counter before the new generator starts. + if matching_session_id: + # Idle session reuse: active_streams already incremented at line 776 + # Always need to re-reserve profile slot (GeneratorExit DECRed it) + if not self._check_and_reserve_profile_slot(m3u_profile): + logger.warning(f"[{client_id}] Profile {m3u_profile.name} connection limit exceeded on session reuse") + redis_connection.decrement_active_streams() + return HttpResponse("Connection limit exceeded for profile", status=429) + profile_connections_incremented = True + else: + # Concurrent/reconnect: increment active_streams now (not in generator) + new_count = redis_connection.increment_active_streams() + if new_count == 1: + # 0→1 transition: previous stream's GeneratorExit already DECRed + # the profile counter, need to re-reserve the slot + if not self._check_and_reserve_profile_slot(m3u_profile): + logger.warning(f"[{client_id}] Profile {m3u_profile.name} connection limit exceeded on reconnect") + redis_connection.decrement_active_streams() + return HttpResponse("Connection limit exceeded for profile", status=429) + profile_connections_incremented = True + elif new_count == 0: + logger.error(f"[{client_id}] Failed to increment active streams") + return HttpResponse("Failed to reserve stream", status=500) + # else: new_count > 1, another stream is already active and profile + # counter already reflects it — no INCR needed + + # Transfer ownership to current worker and update session activity + if redis_connection._acquire_lock(): + try: + state = redis_connection._get_connection_state() + if state: + old_worker = state.worker_id + state.last_activity = time.time() + state.worker_id = self.worker_id # Transfer ownership to current worker + redis_connection._save_connection_state(state) + + if old_worker != self.worker_id: + logger.info(f"[{client_id}] Ownership transferred from worker {old_worker} to {self.worker_id}") + else: + logger.debug(f"[{client_id}] Worker {self.worker_id} retaining ownership") + finally: + redis_connection._release_lock() + + # Get stream from Redis-backed connection + upstream_response = redis_connection.get_stream(range_header) + + if upstream_response is None: + logger.warning(f"[{client_id}] Worker {self.worker_id} - Range not satisfiable") + if existing_state: + # Roll back the active_streams increment from the else branch + redis_connection.decrement_active_streams() + if profile_connections_incremented: + self._decrement_profile_connections(m3u_profile.id) + profile_connections_incremented = False + return HttpResponse("Requested Range Not Satisfiable", status=416) + + # Get connection headers + connection_headers = redis_connection.get_headers() + + # Create streaming generator + def stream_generator(): + stream_decremented = False + profile_decremented = False + stop_signal_detected = False + try: + logger.info(f"[{client_id}] Worker {self.worker_id} - Starting Redis-backed stream") + + # Increment active streams only for brand-new connections. + # For existing connections (session reuse or concurrent requests), + # active_streams was already incremented in the else branch above + # to prevent cleanup race conditions with GeneratorExit. + if not existing_state: + redis_connection.increment_active_streams() + else: + logger.debug(f"[{client_id}] Active streams already incremented in connection reuse path") + + bytes_sent = 0 + chunk_count = 0 + + # Get the stop signal key for this client + stop_key = get_vod_client_stop_key(client_id) + + for chunk in upstream_response.iter_content(chunk_size=8192): + if chunk: + yield chunk + bytes_sent += len(chunk) + chunk_count += 1 + + # Check for stop signal every 100 chunks + if chunk_count % 100 == 0: + # Check if stop signal has been set + if self.redis_client and self.redis_client.exists(stop_key): + logger.info(f"[{client_id}] Worker {self.worker_id} - Stop signal detected, terminating stream") + # Delete the stop key + self.redis_client.delete(stop_key) + stop_signal_detected = True + break + + # Update the connection state + logger.debug(f"Client: [{client_id}] Worker: {self.worker_id} sent {chunk_count} chunks for VOD: {content_name}") + if redis_connection._acquire_lock(): + try: + state = redis_connection._get_connection_state() + if state: + state.last_activity = time.time() + # Store cumulative bytes sent in connection state + state.bytes_sent = bytes_sent # Use cumulative bytes_sent, not chunk size + redis_connection._save_connection_state(state) + finally: + redis_connection._release_lock() + + if stop_signal_detected: + logger.info(f"[{client_id}] Worker {self.worker_id} - Stream stopped by signal: {bytes_sent} bytes sent") + else: + logger.info(f"[{client_id}] Worker {self.worker_id} - Redis-backed stream completed: {bytes_sent} bytes sent") + stream_decremented, has_remaining = redis_connection.decrement_active_streams_and_check() + + # Schedule smart cleanup if no active streams after normal completion + if stream_decremented and not has_remaining and not profile_decremented: + # Decrement profile counter immediately — don't defer to daemon thread + state = redis_connection._get_connection_state() + profile_id = state.m3u_profile_id if state else m3u_profile.id + if profile_id: + self._decrement_profile_connections(profile_id) + profile_decremented = True + logger.info(f"[{client_id}] Profile counter decremented for profile {profile_id} on normal completion") + + def delayed_cleanup(): + time.sleep(1) # Wait 1 second + # Smart cleanup: check active streams and ownership + logger.info(f"[{client_id}] Worker {self.worker_id} - Checking for smart cleanup after normal completion") + # No connection_manager — profile already decremented above + redis_connection.cleanup(current_worker_id=self.worker_id) + + import threading + cleanup_thread = threading.Thread(target=delayed_cleanup) + cleanup_thread.daemon = True + cleanup_thread.start() + + except GeneratorExit: + logger.info(f"[{client_id}] Worker {self.worker_id} - Client disconnected from Redis-backed stream") + if not stream_decremented: + stream_decremented, has_remaining = redis_connection.decrement_active_streams_and_check() + else: + has_remaining = redis_connection.has_active_streams() + + # Schedule smart cleanup if no active streams + if not has_remaining and not profile_decremented: + # Decrement profile counter immediately — don't defer to daemon thread + state = redis_connection._get_connection_state() + profile_id = state.m3u_profile_id if state else m3u_profile.id + if profile_id: + self._decrement_profile_connections(profile_id) + profile_decremented = True + logger.info(f"[{client_id}] Profile counter decremented for profile {profile_id} on client disconnect") + + def delayed_cleanup(): + time.sleep(1) # Wait 1 second + # Smart cleanup: check active streams and ownership + logger.info(f"[{client_id}] Worker {self.worker_id} - Checking for smart cleanup after client disconnect") + # No connection_manager — profile already decremented above + redis_connection.cleanup(current_worker_id=self.worker_id) + + import threading + cleanup_thread = threading.Thread(target=delayed_cleanup) + cleanup_thread.daemon = True + cleanup_thread.start() + + except Exception as e: + logger.error(f"[{client_id}] Worker {self.worker_id} - Error in Redis-backed stream: {e}") + if not stream_decremented: + stream_decremented, has_remaining = redis_connection.decrement_active_streams_and_check() + else: + has_remaining = redis_connection.has_active_streams() + + # Decrement profile counter immediately if no other active streams + if not has_remaining and not profile_decremented: + state = redis_connection._get_connection_state() + profile_id = state.m3u_profile_id if state else m3u_profile.id + if profile_id: + self._decrement_profile_connections(profile_id) + profile_decremented = True + logger.info(f"[{client_id}] Profile counter decremented for profile {profile_id} on stream error") + # Smart cleanup on error - immediate cleanup since we're in error state + # No connection_manager — profile already decremented above + redis_connection.cleanup(current_worker_id=self.worker_id) + yield b"Error: Stream interrupted" + + finally: + if not stream_decremented: + stream_decremented, has_remaining = redis_connection.decrement_active_streams_and_check() + if stream_decremented and not has_remaining and not profile_decremented: + state = redis_connection._get_connection_state() + profile_id = state.m3u_profile_id if state else m3u_profile.id + if profile_id: + self._decrement_profile_connections(profile_id) + profile_decremented = True + logger.info(f"[{client_id}] Profile counter decremented for profile {profile_id} in finally block") + + # Delayed cleanup: wait 1s for seeking clients to reconnect + # before closing the provider connection and Redis keys. + # cleanup() re-checks active_streams under lock, so a + # reconnecting client that increments active_streams in + # time will prevent Redis key deletion. + def delayed_cleanup(): + time.sleep(1) + logger.info(f"[{client_id}] Worker {self.worker_id} - Checking for smart cleanup in finally block") + # No connection_manager — profile already decremented above + redis_connection.cleanup(current_worker_id=self.worker_id) + + import threading + cleanup_thread = threading.Thread(target=delayed_cleanup) + cleanup_thread.daemon = True + cleanup_thread.start() + + # Create streaming response + response = StreamingHttpResponse( + streaming_content=stream_generator(), + content_type=connection_headers.get('content_type', 'video/mp4') + ) + + # Set appropriate status code + response.status_code = 206 if range_header else 200 + + # Set required headers + response['Cache-Control'] = 'no-cache' + response['Pragma'] = 'no-cache' + response['X-Content-Type-Options'] = 'nosniff' + response['Connection'] = 'keep-alive' + response['X-Worker-ID'] = self.worker_id # Identify which worker served this + + if connection_headers.get('content_length'): + response['Accept-Ranges'] = 'bytes' + + # For range requests, Content-Length should be the partial content size, not full file size + if range_header and 'bytes=' in range_header: + try: + range_part = range_header.replace('bytes=', '') + if '-' in range_part: + start_byte, end_byte = range_part.split('-', 1) + start = int(start_byte) if start_byte else 0 + + # Get the FULL content size from the connection state (from initial request) + state = redis_connection._get_connection_state() + if state and state.content_length: + full_content_size = int(state.content_length) + end = int(end_byte) if end_byte else full_content_size - 1 + + # Calculate partial content size for Content-Length header + partial_content_size = end - start + 1 + response['Content-Length'] = str(partial_content_size) + + # Content-Range should show full file size per HTTP standards + content_range = f"bytes {start}-{end}/{full_content_size}" + response['Content-Range'] = content_range + logger.info(f"[{client_id}] Worker {self.worker_id} - Set Content-Range: {content_range}, Content-Length: {partial_content_size}") + + # Store range information for the VOD stats API to calculate position + if start > 0: + try: + position_percentage = (start / full_content_size) * 100 + current_timestamp = time.time() + + # Update the Redis connection state with seek information + if redis_connection._acquire_lock(): + try: + # Refresh state in case it changed + state = redis_connection._get_connection_state() + if state: + # Store range/seek information for stats API + state.last_seek_byte = start + state.last_seek_percentage = position_percentage + state.total_content_size = full_content_size + state.last_seek_timestamp = current_timestamp + state.last_activity = current_timestamp + redis_connection._save_connection_state(state) + logger.info(f"[{client_id}] *** SEEK INFO STORED *** {position_percentage:.1f}% at byte {start:,}/{full_content_size:,} (timestamp: {current_timestamp})") + finally: + redis_connection._release_lock() + else: + logger.warning(f"[{client_id}] Could not acquire lock to update seek info") + except Exception as pos_e: + logger.error(f"[{client_id}] Error storing seek info: {pos_e}") + else: + # Fallback to partial content size if full size not available + partial_size = int(connection_headers['content_length']) + end = int(end_byte) if end_byte else partial_size - 1 + content_range = f"bytes {start}-{end}/{partial_size}" + response['Content-Range'] = content_range + response['Content-Length'] = str(end - start + 1) + logger.warning(f"[{client_id}] Using partial content size for Content-Range (full size not available): {content_range}") + except Exception as e: + logger.warning(f"[{client_id}] Worker {self.worker_id} - Could not set Content-Range: {e}") + response['Content-Length'] = connection_headers['content_length'] + else: + # For non-range requests, use the full content length + response['Content-Length'] = connection_headers['content_length'] + + logger.info(f"[{client_id}] Worker {self.worker_id} - Redis-backed response ready (status: {response.status_code})") + return response + + except Exception as e: + logger.error(f"[{client_id}] Worker {self.worker_id} - Error in Redis-backed stream_content_with_session: {e}", exc_info=True) + + # Decrement profile connections if we incremented them but failed before streaming started + if profile_connections_incremented: + logger.info(f"[{client_id}] Connection error occurred after profile increment - decrementing profile connections") + self._decrement_profile_connections(m3u_profile.id) + + # Also clean up the Redis connection state since we won't be using it + # Pass connection_manager=None since we already decremented above + if redis_connection: + try: + redis_connection.cleanup(current_worker_id=self.worker_id) + except Exception as cleanup_error: + logger.error(f"[{client_id}] Error during cleanup after connection failure: {cleanup_error}") + + return HttpResponse(f"Streaming error: {str(e)}", status=500) + + def _apply_timeshift_parameters(self, original_url, utc_start=None, utc_end=None, offset=None): + """Apply timeshift parameters to URL""" + if not any([utc_start, utc_end, offset]): + return original_url + + try: + from urllib.parse import urlparse, urlunparse, parse_qs, urlencode + + parsed_url = urlparse(original_url) + query_params = parse_qs(parsed_url.query) + path = parsed_url.path + + logger.info(f"Applying timeshift parameters: utc_start={utc_start}, utc_end={utc_end}, offset={offset}") + + # Add timeshift parameters + if utc_start: + query_params['utc_start'] = [utc_start] + query_params['start'] = [utc_start] + logger.info(f"Added utc_start/start parameter: {utc_start}") + + if utc_end: + query_params['utc_end'] = [utc_end] + query_params['end'] = [utc_end] + logger.info(f"Added utc_end/end parameter: {utc_end}") + + if offset: + try: + offset_seconds = int(offset) + query_params['offset'] = [str(offset_seconds)] + query_params['seek'] = [str(offset_seconds)] + query_params['t'] = [str(offset_seconds)] + logger.info(f"Added offset/seek/t parameter: {offset_seconds}") + except ValueError: + logger.warning(f"Invalid offset value: {offset}") + + # Handle special catchup URL patterns + if utc_start: + try: + from datetime import datetime + import re + + # Parse the UTC start time + start_dt = datetime.fromisoformat(utc_start.replace('Z', '+00:00')) + + # Check for catchup URL patterns like /catchup/YYYY-MM-DD/HH-MM-SS/ + catchup_pattern = r'/catchup/\d{4}-\d{2}-\d{2}/\d{2}-\d{2}-\d{2}/' + if re.search(catchup_pattern, path): + # Replace the date/time in the path + date_part = start_dt.strftime('%Y-%m-%d') + time_part = start_dt.strftime('%H-%M-%S') + + path = re.sub(catchup_pattern, f'/catchup/{date_part}/{time_part}/', path) + logger.info(f"Modified catchup path: {path}") + except Exception as e: + logger.warning(f"Could not parse timeshift date: {e}") + + # Reconstruct URL + new_query = urlencode(query_params, doseq=True) + modified_url = urlunparse(( + parsed_url.scheme, + parsed_url.netloc, + path, + parsed_url.params, + new_query, + parsed_url.fragment + )) + + logger.info(f"Modified URL: {modified_url}") + return modified_url + + except Exception as e: + logger.error(f"Error applying timeshift parameters: {e}") + return original_url + + def cleanup_persistent_connection(self, session_id: str): + """Clean up a specific Redis-backed persistent connection""" + logger.info(f"[{session_id}] Cleaning up Redis-backed persistent connection") + + redis_connection = RedisBackedVODConnection(session_id, self.redis_client) + redis_connection.cleanup(connection_manager=self) + + # The cleanup method now handles all Redis keys including session data + + def cleanup_stale_persistent_connections(self, max_age_seconds: int = 1800): + """Clean up stale Redis-backed persistent connections""" + if not self.redis_client: + return + + try: + logger.info(f"Cleaning up Redis-backed connections older than {max_age_seconds} seconds") + + # Find all persistent connection keys + pattern = "vod_persistent_connection:*" + cursor = 0 + cleanup_count = 0 + current_time = time.time() + + while True: + cursor, keys = self.redis_client.scan(cursor, match=pattern, count=100) + + for key in keys: + try: + # Get connection state + data = self.redis_client.hgetall(key) + if not data: + continue + + # Convert bytes to strings if needed + if isinstance(list(data.keys())[0], bytes): + data = {k: v for k, v in data.items()} + + last_activity = float(data.get('last_activity', 0)) + active_streams = int(data.get('active_streams', 0)) + + # Clean up if stale and no active streams + if (current_time - last_activity > max_age_seconds) and active_streams == 0: + session_id = key.replace('vod_persistent_connection:', '') + logger.info(f"Cleaning up stale connection: {session_id}") + + # Clean up connection and related keys + redis_connection = RedisBackedVODConnection(session_id, self.redis_client) + redis_connection.cleanup(connection_manager=self) + cleanup_count += 1 + + except Exception as e: + logger.error(f"Error processing connection key {key}: {e}") + continue + + if cursor == 0: + break + + if cleanup_count > 0: + logger.info(f"Cleaned up {cleanup_count} stale Redis-backed connections") + else: + logger.debug("No stale Redis-backed connections found") + + except Exception as e: + logger.error(f"Error during Redis-backed connection cleanup: {e}") + + def create_connection(self, content_type: str, content_uuid: str, content_name: str, + client_id: str, client_ip: str, user_agent: str, + m3u_profile: M3UAccountProfile) -> bool: + """Create connection tracking in Redis (same as original but for Redis-backed connections)""" + if not self.redis_client: + logger.error("Redis client not available for VOD connection tracking") + return False + + try: + # Check profile connection limits + profile_connections_key = f"profile_connections:{m3u_profile.id}" + current_connections = self.redis_client.get(profile_connections_key) + max_connections = getattr(m3u_profile, 'max_connections', 3) # Default to 3 + + if current_connections and int(current_connections) >= max_connections: + logger.warning(f"Profile {m3u_profile.name} connection limit exceeded ({current_connections}/{max_connections})") + return False + + # Create connection tracking + connection_key = f"vod_proxy:connection:{content_type}:{content_uuid}:{client_id}" + content_connections_key = f"vod_proxy:content:{content_type}:{content_uuid}:connections" + + # Check if connection already exists + if self.redis_client.exists(connection_key): + logger.info(f"Connection already exists for {client_id} - {content_type} {content_name}") + self.redis_client.hset(connection_key, "last_activity", str(time.time())) + return True + + # Connection data + connection_data = { + "content_type": content_type, + "content_uuid": content_uuid, + "content_name": content_name, + "client_id": client_id, + "client_ip": client_ip, + "user_agent": user_agent, + "m3u_profile_id": m3u_profile.id, + "m3u_profile_name": m3u_profile.name, + "connected_at": str(time.time()), + "last_activity": str(time.time()), + "bytes_sent": "0", + "position_seconds": "0" + } + + # Use pipeline for atomic operations + pipe = self.redis_client.pipeline() + pipe.hset(connection_key, mapping=connection_data) + pipe.expire(connection_key, self.connection_ttl) + pipe.incr(profile_connections_key) + pipe.sadd(content_connections_key, client_id) + pipe.expire(content_connections_key, self.connection_ttl) + pipe.execute() + + logger.info(f"Created Redis-backed VOD connection: {client_id} for {content_type} {content_name}") + return True + + except Exception as e: + logger.error(f"Error creating Redis-backed connection: {e}") + return False + + def remove_connection(self, content_type: str, content_uuid: str, client_id: str): + """Remove connection tracking from Redis""" + if not self.redis_client: + return + + try: + connection_key = f"vod_proxy:connection:{content_type}:{content_uuid}:{client_id}" + content_connections_key = f"vod_proxy:content:{content_type}:{content_uuid}:connections" + + # Get connection data to find profile + connection_data = self.redis_client.hgetall(connection_key) + if connection_data: + # Convert bytes to strings if needed + if isinstance(list(connection_data.keys())[0], bytes): + connection_data = {k: v for k, v in connection_data.items()} + + profile_id = connection_data.get('m3u_profile_id') + if profile_id: + profile_connections_key = f"profile_connections:{profile_id}" + current_count = int(self.redis_client.get(profile_connections_key) or 0) + + # Use pipeline for atomic operations + pipe = self.redis_client.pipeline() + pipe.delete(connection_key) + pipe.srem(content_connections_key, client_id) + if current_count > 0: + pipe.decr(profile_connections_key) + pipe.execute() + + logger.info(f"Removed Redis-backed connection: {client_id}") + + except Exception as e: + logger.error(f"Error removing Redis-backed connection: {e}") + + def update_connection_activity(self, content_type: str, content_uuid: str, + client_id: str, bytes_sent: int): + """Update connection activity in Redis""" + if not self.redis_client: + return + + try: + connection_key = f"vod_proxy:connection:{content_type}:{content_uuid}:{client_id}" + pipe = self.redis_client.pipeline() + pipe.hset(connection_key, mapping={ + "last_activity": str(time.time()), + "bytes_sent": str(bytes_sent) + }) + pipe.expire(connection_key, self.connection_ttl) + pipe.execute() + except Exception as e: + logger.error(f"Error updating connection activity: {e}") + + def find_matching_idle_session(self, content_type: str, content_uuid: str, + client_ip: str, client_user_agent: str, + utc_start=None, utc_end=None, offset=None) -> Optional[str]: + """Find existing Redis-backed session that matches criteria using consolidated connection state""" + if not self.redis_client: + return None + + try: + # Search for connections with consolidated session data + pattern = "vod_persistent_connection:*" + cursor = 0 + matching_sessions = [] + + while True: + cursor, keys = self.redis_client.scan(cursor, match=pattern, count=100) + + for key in keys: + try: + connection_data = self.redis_client.hgetall(key) + if not connection_data: + continue + + # Convert bytes keys/values to strings if needed + if isinstance(list(connection_data.keys())[0], bytes): + connection_data = {k: v for k, v in connection_data.items()} + + # Check if content matches (using consolidated data) + stored_content_type = connection_data.get('content_obj_type', '') + stored_content_uuid = connection_data.get('content_uuid', '') + + if stored_content_type != content_type or stored_content_uuid != content_uuid: + continue + + # Extract session ID + session_id = key.replace('vod_persistent_connection:', '') + + # Check if Redis-backed connection exists and has no active streams + redis_connection = RedisBackedVODConnection(session_id, self.redis_client) + if redis_connection.has_active_streams(): + continue + + # Calculate match score + score = 10 # Content match + match_reasons = ["content"] + + # Check other criteria (using consolidated data) + stored_client_ip = connection_data.get('client_ip', '') + stored_user_agent = connection_data.get('client_user_agent', '') or connection_data.get('user_agent', '') + + if stored_client_ip and stored_client_ip == client_ip: + score += 5 + match_reasons.append("ip") + + if stored_user_agent and stored_user_agent == client_user_agent: + score += 3 + match_reasons.append("user-agent") + + # Check timeshift parameters (using consolidated data) + stored_utc_start = connection_data.get('utc_start', '') + stored_utc_end = connection_data.get('utc_end', '') + stored_offset = connection_data.get('offset', '') + + current_utc_start = utc_start or "" + current_utc_end = utc_end or "" + current_offset = str(offset) if offset else "" + + if (stored_utc_start == current_utc_start and + stored_utc_end == current_utc_end and + stored_offset == current_offset): + score += 7 + match_reasons.append("timeshift") + + if score >= 13: # Good match threshold + matching_sessions.append({ + 'session_id': session_id, + 'score': score, + 'reasons': match_reasons, + 'last_activity': float(connection_data.get('last_activity', '0')) + }) + + except Exception as e: + logger.debug(f"Error processing connection key {key}: {e}") + continue + + if cursor == 0: + break + + # Sort by score and last activity + matching_sessions.sort(key=lambda x: (x['score'], x['last_activity']), reverse=True) + + if matching_sessions: + best_match = matching_sessions[0] + logger.info(f"Found matching Redis-backed idle session: {best_match['session_id']} " + f"(score: {best_match['score']}, reasons: {', '.join(best_match['reasons'])})") + return best_match['session_id'] + + return None + + except Exception as e: + logger.error(f"Error finding matching idle session: {e}") + return None + + def get_session_info(self, session_id: str) -> Optional[dict]: + """Get session information from consolidated connection state (compatibility method)""" + if not self.redis_client: + return None + + try: + redis_connection = RedisBackedVODConnection(session_id, self.redis_client) + return redis_connection.get_session_metadata() + except Exception as e: + logger.error(f"Error getting session info for {session_id}: {e}") + return None diff --git a/apps/proxy/vod_proxy/tests/__init__.py b/apps/proxy/vod_proxy/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/proxy/vod_proxy/tests/test_profile_connections.py b/apps/proxy/vod_proxy/tests/test_profile_connections.py new file mode 100644 index 0000000..8e63547 --- /dev/null +++ b/apps/proxy/vod_proxy/tests/test_profile_connections.py @@ -0,0 +1,253 @@ +""" +Tests for VOD proxy profile connection counter fixes. + +Covers three race conditions in multi_worker_connection_manager: + 1. decrement_active_streams() return value was ignored — counter stuck on lock contention + 2. Non-atomic GET-then-DECR in _decrement_profile_connections() — counter could go negative + 3. has_active_streams() read without lock — race between decrement and check +""" + +from unittest.mock import MagicMock, patch, call +from django.test import TestCase + + +class FakeRedis: + """Minimal in-memory Redis stand-in for counter tests.""" + + def __init__(self): + self._data = {} + + def get(self, key): + val = self._data.get(key) + return str(val).encode() if val is not None else None + + def set(self, key, value, ex=None): + self._data[key] = int(value) + + def incr(self, key): + self._data[key] = self._data.get(key, 0) + 1 + return self._data[key] + + def decr(self, key): + self._data[key] = self._data.get(key, 0) - 1 + return self._data[key] + + def delete(self, key): + self._data.pop(key, None) + + def exists(self, key): + return key in self._data + + def pipeline(self): + return FakePipeline(self) + + +class FakePipeline: + def __init__(self, redis): + self._redis = redis + self._cmds = [] + + def incr(self, key): + self._cmds.append(('incr', key)) + return self + + def decr(self, key): + self._cmds.append(('decr', key)) + return self + + def execute(self): + results = [] + for cmd, key in self._cmds: + results.append(getattr(self._redis, cmd)(key)) + self._cmds = [] + return results + + +class MultiWorkerManagerImportMixin: + """Mixin to import the manager class with patched Django/Redis deps.""" + + @classmethod + def get_manager_class(cls): + import importlib + import sys + + # Stub out heavy Django deps so we can import the module standalone + for mod in ['apps.vod.models', 'apps.m3u.models', 'core.utils']: + if mod not in sys.modules: + sys.modules[mod] = MagicMock() + + from apps.proxy.vod_proxy.multi_worker_connection_manager import ( + MultiWorkerVODConnectionManager, + RedisBackedVODConnection, + ) + return MultiWorkerVODConnectionManager, RedisBackedVODConnection + + +class TestDecrementProfileConnectionsAtomic(TestCase): + """Bug 2: _decrement_profile_connections must be atomic (no GET-then-DECR).""" + + def _make_manager(self, redis): + _, _ = MultiWorkerManagerImportMixin.get_manager_class() + from apps.proxy.vod_proxy.multi_worker_connection_manager import MultiWorkerVODConnectionManager + mgr = MultiWorkerVODConnectionManager.__new__(MultiWorkerVODConnectionManager) + mgr.redis_client = redis + mgr.worker_id = 'test-worker' + return mgr + + def test_decrement_does_not_go_negative(self): + """Counter must be clamped to 0, never go negative.""" + redis = FakeRedis() + redis.set('profile_connections:1', 0) + mgr = self._make_manager(redis) + + result = mgr._decrement_profile_connections(1) + + self.assertEqual(result, 0) + self.assertEqual(int(redis._data.get('profile_connections:1', 0)), 0) + + def test_decrement_from_one_reaches_zero(self): + """Normal single decrement should reach 0.""" + redis = FakeRedis() + redis.set('profile_connections:1', 1) + mgr = self._make_manager(redis) + + result = mgr._decrement_profile_connections(1) + + self.assertEqual(result, 0) + + def test_concurrent_decrements_clamp_to_zero(self): + """Two concurrent decrements of a counter at 1 must not leave it at -1.""" + redis = FakeRedis() + redis.set('profile_connections:1', 1) + mgr = self._make_manager(redis) + + # Simulate two concurrent decrements (both fire before either reads back) + mgr._decrement_profile_connections(1) + mgr._decrement_profile_connections(1) + + final = int(redis._data.get('profile_connections:1', 0)) + self.assertGreaterEqual(final, 0, "Counter must not go negative after concurrent decrements") + + +class TestDecrementActiveStreamsAndCheck(TestCase): + """Bug 1 & 3: decrement_active_streams_and_check() must be atomic.""" + + def _make_connection(self, redis, session_id='test-session'): + from apps.proxy.vod_proxy.multi_worker_connection_manager import RedisBackedVODConnection + conn = RedisBackedVODConnection.__new__(RedisBackedVODConnection) + conn.session_id = session_id + conn.redis_client = redis + conn.connection_key = f'vod_connection:{session_id}' + conn.lock_key = f'vod_lock:{session_id}' + conn.local_session = None + conn._lock_acquired = False + return conn + + def _make_state(self, active_streams=1, profile_id=7): + from apps.proxy.vod_proxy.multi_worker_connection_manager import SerializableConnectionState + state = SerializableConnectionState.__new__(SerializableConnectionState) + state.session_id = 'test-session' + state.stream_url = 'http://example.com/stream.mkv' + state.headers = {} + state.m3u_profile_id = profile_id + state.active_streams = active_streams + state.last_activity = 0 + state.worker_id = 'test-worker' + state.content_type = None + state.content_length = None + state.final_url = None + state.request_count = 0 + state.bytes_sent = 0 + state.content_obj_type = None + state.content_uuid = None + state.content_name = None + state.client_ip = None + state.client_user_agent = None + state.utc_start = None + state.utc_end = None + state.offset = None + state.connection_type = 'redis' + state.created_at = 0 + return state + + def test_returns_success_and_no_remaining_when_last_stream(self): + """When active_streams goes 1->0, should return (True, False).""" + from apps.proxy.vod_proxy.multi_worker_connection_manager import RedisBackedVODConnection + conn = MagicMock(spec=RedisBackedVODConnection) + conn.session_id = 'test' + + state = MagicMock() + state.active_streams = 1 + + conn._acquire_lock.return_value = True + conn._get_connection_state.return_value = state + conn._save_connection_state.return_value = True + conn._release_lock.return_value = None + + # Call the real method on the mock instance + result = RedisBackedVODConnection.decrement_active_streams_and_check(conn) + + self.assertEqual(result, (True, False)) + self.assertEqual(state.active_streams, 0) + + def test_returns_success_and_remaining_when_other_streams_active(self): + """When active_streams goes 2->1, should return (True, True).""" + from apps.proxy.vod_proxy.multi_worker_connection_manager import RedisBackedVODConnection + conn = MagicMock(spec=RedisBackedVODConnection) + conn.session_id = 'test' + + state = MagicMock() + state.active_streams = 2 + + conn._acquire_lock.return_value = True + conn._get_connection_state.return_value = state + conn._save_connection_state.return_value = True + conn._release_lock.return_value = None + + result = RedisBackedVODConnection.decrement_active_streams_and_check(conn) + + self.assertEqual(result, (True, True)) + self.assertEqual(state.active_streams, 1) + + def test_returns_failure_and_assumes_remaining_on_lock_contention(self): + """Lock contention must return (False, True) — assume streams remain to be safe.""" + from apps.proxy.vod_proxy.multi_worker_connection_manager import RedisBackedVODConnection + conn = MagicMock(spec=RedisBackedVODConnection) + conn.session_id = 'test' + conn._acquire_lock.return_value = False + + result = RedisBackedVODConnection.decrement_active_streams_and_check(conn) + + self.assertEqual(result, (False, True)) + conn._get_connection_state.assert_not_called() + + def test_returns_failure_when_already_at_zero(self): + """When active_streams is already 0, should return (False, False).""" + from apps.proxy.vod_proxy.multi_worker_connection_manager import RedisBackedVODConnection + conn = MagicMock(spec=RedisBackedVODConnection) + conn.session_id = 'test' + + state = MagicMock() + state.active_streams = 0 + + conn._acquire_lock.return_value = True + conn._get_connection_state.return_value = state + conn._release_lock.return_value = None + + result = RedisBackedVODConnection.decrement_active_streams_and_check(conn) + + self.assertEqual(result, (False, False)) + conn._save_connection_state.assert_not_called() + + def test_lock_always_released_even_on_exception(self): + """Lock must be released even if an exception occurs inside.""" + from apps.proxy.vod_proxy.multi_worker_connection_manager import RedisBackedVODConnection + conn = MagicMock(spec=RedisBackedVODConnection) + conn.session_id = 'test' + conn._acquire_lock.return_value = True + conn._get_connection_state.side_effect = RuntimeError("Redis exploded") + + with self.assertRaises(RuntimeError): + RedisBackedVODConnection.decrement_active_streams_and_check(conn) + + conn._release_lock.assert_called_once() diff --git a/apps/proxy/vod_proxy/urls.py b/apps/proxy/vod_proxy/urls.py new file mode 100644 index 0000000..aca503e --- /dev/null +++ b/apps/proxy/vod_proxy/urls.py @@ -0,0 +1,22 @@ +from django.urls import path +from . import views +from .views import stream_vod + +app_name = 'vod_proxy' + +urlpatterns = [ + # Generic VOD streaming with session ID in path (for compatibility) + path('//', stream_vod, name='vod_stream_with_session'), + path('////', stream_vod, name='vod_stream_with_session_and_profile'), + + # Generic VOD streaming (supports movies, episodes, series) - legacy patterns + path('/', stream_vod, name='vod_stream'), + path('///', stream_vod, name='vod_stream_with_profile'), + + # VOD Stats + path('stats/', views.vod_stats, name='vod_stats'), + + # Stop VOD client connection + path('stop_client/', views.stop_vod_client, name='stop_vod_client'), + path('monitor/', views.vod_monitor, name='vod_monitor'), +] diff --git a/apps/proxy/vod_proxy/utils.py b/apps/proxy/vod_proxy/utils.py new file mode 100644 index 0000000..7ccf08b --- /dev/null +++ b/apps/proxy/vod_proxy/utils.py @@ -0,0 +1,58 @@ +""" +Utility functions for VOD proxy operations. +""" + +import logging +from django.http import HttpResponse + +logger = logging.getLogger(__name__) + + +def get_client_info(request): + """ + Extract client IP and User-Agent from request. + + Args: + request: Django HttpRequest object + + Returns: + tuple: (client_ip, user_agent) + """ + # Get client IP, checking for proxy headers + client_ip = request.META.get('HTTP_X_FORWARDED_FOR') + if client_ip: + # Take the first IP if there are multiple (comma-separated) + client_ip = client_ip.split(',')[0].strip() + else: + client_ip = request.META.get('HTTP_X_REAL_IP') or request.META.get('REMOTE_ADDR', 'unknown') + + # Get User-Agent + user_agent = request.META.get('HTTP_USER_AGENT', 'unknown') + + return client_ip, user_agent + + +def create_vod_response(content, content_type='video/mp4', filename=None): + """ + Create a streaming HTTP response for VOD content. + + Args: + content: Content to stream (file-like object or bytes) + content_type: MIME type of the content + filename: Optional filename for Content-Disposition header + + Returns: + HttpResponse: Configured HTTP response for streaming + """ + response = HttpResponse(content, content_type=content_type) + + if filename: + response['Content-Disposition'] = f'attachment; filename="{filename}"' + + # Add headers for streaming + response['Accept-Ranges'] = 'bytes' + response['Cache-Control'] = 'no-cache, no-store, must-revalidate' + response['Pragma'] = 'no-cache' + response['Expires'] = '0' + + return response diff --git a/apps/proxy/vod_proxy/views.py b/apps/proxy/vod_proxy/views.py new file mode 100644 index 0000000..cbf5674 --- /dev/null +++ b/apps/proxy/vod_proxy/views.py @@ -0,0 +1,1571 @@ +""" +VOD (Video on Demand) proxy views for handling movie and series streaming. +Supports M3U profiles for authentication and URL transformation. +""" + +import time +import random +import logging +import requests +from django.http import JsonResponse, Http404, HttpResponse +from django.shortcuts import get_object_or_404 +from django.views.decorators.csrf import csrf_exempt +from apps.vod.models import Movie, Series, Episode +from apps.vod.catalog_settings import get_vod_catalog_account_id, get_vod_playback_allowed_account_ids, filter_m3u_relation_queryset_for_playback, filter_relation_qs_for_user_playback +from apps.m3u.models import M3UAccountProfile +from apps.proxy.vod_proxy.multi_worker_connection_manager import MultiWorkerVODConnectionManager, infer_content_type_from_url, get_vod_client_stop_key +from .utils import get_client_info +from rest_framework.decorators import api_view, permission_classes +from rest_framework.permissions import AllowAny +from apps.accounts.models import User +from apps.accounts.permissions import IsAdmin +from apps.proxy.utils import check_user_stream_limits +from dispatcharr.utils import network_access_allowed +from core.utils import RedisClient + +logger = logging.getLogger(__name__) + + +def _playback_relations_queryset(content_obj, user=None): + """ + M3U relations eligible for streaming. Optional DISPATCHARR_VOD_PLAYBACK_ACCOUNT_IDS=10,11,12 + limits failover to those accounts while catalog listings can stay single-account. + Per-user `vod_playback_mode=strict` further restricts to the user's accounts. + """ + allowed = get_vod_playback_allowed_account_ids() + qs = content_obj.m3u_relations.all() + if allowed is not None: + qs = qs.filter(m3u_account_id__in=allowed, m3u_account__is_active=True) + else: + qs = qs.filter(m3u_account__is_active=True) + qs, _ = filter_relation_qs_for_user_playback(qs, user) + return qs.select_related("m3u_account") + + + +_request_times = {} + + +def _account_active_connections(account_ids): + """ + Return active connection counts per M3U account using shared profile counters. + Counts include both live and VOD sessions. + """ + counts = {int(aid): 0 for aid in account_ids} + if not account_ids: + return counts + try: + redis_client = RedisClient.get_client() + if not redis_client: + return counts + profiles = M3UAccountProfile.objects.filter( + m3u_account_id__in=account_ids, is_active=True + ).values("id", "m3u_account_id") + for p in profiles: + counts[int(p["m3u_account_id"])] += int( + redis_client.get(f"profile_connections:{p['id']}") or 0 + ) + except Exception: + return counts + return counts + + +def _preempt_oldest_vod_session_for_account(m3u_account_id: int) -> bool: + """ + Stop the oldest active VOD session for the given account. + Returns True if a stop signal was sent. + """ + try: + redis_client = RedisClient.get_client() + if not redis_client: + return False + + profile_ids = set( + M3UAccountProfile.objects.filter( + m3u_account_id=m3u_account_id, is_active=True + ).values_list("id", flat=True) + ) + if not profile_ids: + return False + + oldest_session_id = None + oldest_created_at = None + + for key in redis_client.scan_iter(match="vod_persistent_connection:*", count=1000): + key_str = key.decode() if isinstance(key, bytes) else str(key) + session_id = key_str.split(":", 1)[1] if ":" in key_str else None + if not session_id: + continue + + data = redis_client.hgetall(key) + if not data: + continue + + profile_id = data.get("m3u_profile_id") + try: + profile_id = int(profile_id) if profile_id is not None else None + except (TypeError, ValueError): + profile_id = None + if profile_id not in profile_ids: + continue + + created_raw = data.get("created_at") + try: + created_at = float(created_raw) if created_raw is not None else time.time() + except (TypeError, ValueError): + created_at = time.time() + + if oldest_created_at is None or created_at < oldest_created_at: + oldest_created_at = created_at + oldest_session_id = session_id + + if not oldest_session_id: + return False + + redis_client.setex(get_vod_client_stop_key(oldest_session_id), 60, "true") + logger.warning( + f"[VOD-PREEMPT] Stopping oldest VOD session {oldest_session_id} for m3u account {m3u_account_id}" + ) + return True + except Exception as e: + logger.warning(f"[VOD-PREEMPT] Could not preempt VOD session for account {m3u_account_id}: {e}") + return False + + +def _pick_relation_by_account_load(relations_query, prefer_m3u_account_id=None, user=None): + """ + Select relation by least-loaded account first, then user-preferred account, then catalog tie-break, + then highest account priority. If user has playback prefs (mode=prefer/strict) the user's first + account becomes the preferred one for tie-breaking; strict mode is already enforced upstream. + """ + relations = list(relations_query.select_related("m3u_account")) + if not relations: + return None + user_prefs = filter_relation_qs_for_user_playback(relations_query.none(), user)[1] + account_ids = {int(r.m3u_account_id) for r in relations if r.m3u_account_id} + load_map = _account_active_connections(account_ids) + pref_id = int(prefer_m3u_account_id) if prefer_m3u_account_id is not None else None + user_pref_id = int(user_prefs) if user_prefs is not None else None + + def sort_key(r): + aid = int(r.m3u_account_id or 0) + load = load_map.get(aid, 0) + user_rank = 0 if (user_pref_id is not None and aid == user_pref_id) else 1 + pref_rank = 0 if (pref_id is not None and aid == pref_id) else 1 + return (load, user_rank, pref_rank, -(r.m3u_account.priority or 0), r.id) + + return sorted(relations, key=sort_key)[0] + + +def _relations_by_account_load(content_obj, prefer_m3u_account_id=None, user=None): + """ + Return all active relations for a content object ordered by account load, + user-preferred account, catalog tie-break, then priority. + """ + try: + relations = list(_playback_relations_queryset(content_obj, user=user)) + except Exception: + return [] + if not relations: + return [] + + user_prefs = filter_relation_qs_for_user_playback(content_obj.m3u_relations.none(), user)[1] + user_pref_id = int(user_prefs) if user_prefs is not None else None + + account_ids = {int(r.m3u_account_id) for r in relations if r.m3u_account_id} + load_map = _account_active_connections(account_ids) + pref_id = int(prefer_m3u_account_id) if prefer_m3u_account_id is not None else None + + def sort_key(r): + aid = int(r.m3u_account_id or 0) + load = load_map.get(aid, 0) + user_rank = 0 if (user_pref_id is not None and aid == user_pref_id) else 1 + pref_rank = 0 if (pref_id is not None and aid == pref_id) else 1 + return (load, user_rank, pref_rank, -(r.m3u_account.priority or 0), r.id) + + return sorted(relations, key=sort_key) + + +def _get_content_and_relation(content_type, content_id, preferred_m3u_account_id=None, preferred_stream_id=None, user=None): + """Get the content object and its M3U relation""" + try: + logger.info(f"[CONTENT-LOOKUP] Looking up {content_type} with UUID {content_id}") + if preferred_m3u_account_id: + logger.info(f"[CONTENT-LOOKUP] Preferred M3U account ID: {preferred_m3u_account_id}") + if preferred_stream_id: + logger.info(f"[CONTENT-LOOKUP] Preferred stream ID: {preferred_stream_id}") + + if content_type == 'movie': + content_obj = get_object_or_404(Movie, uuid=content_id) + logger.info(f"[CONTENT-FOUND] Movie: {content_obj.name} (ID: {content_obj.id})") + + # Filter by preferred stream ID first (most specific) + relations_query = _playback_relations_queryset(content_obj, user=user) + if preferred_stream_id: + specific_relation = relations_query.filter(stream_id=preferred_stream_id).first() + if specific_relation: + logger.info(f"[STREAM-SELECTED] Using specific stream: {specific_relation.stream_id} from provider: {specific_relation.m3u_account.name}") + return content_obj, specific_relation + else: + logger.warning(f"[STREAM-FALLBACK] Preferred stream ID {preferred_stream_id} not found, falling back to account/priority selection") + + # Filter by preferred M3U account if specified + if preferred_m3u_account_id: + specific_relation = relations_query.filter(m3u_account__id=preferred_m3u_account_id).first() + if specific_relation: + logger.info(f"[PROVIDER-SELECTED] Using preferred provider: {specific_relation.m3u_account.name}") + return content_obj, specific_relation + else: + logger.warning(f"[PROVIDER-FALLBACK] Preferred M3U account {preferred_m3u_account_id} not found, using highest priority") + + # Fallback: choose least-loaded account (live + VOD), then priority + relation = _pick_relation_by_account_load(relations_query, prefer_m3u_account_id=get_vod_catalog_account_id(), user=user) + + if relation: + logger.info(f"[PROVIDER-SELECTED] Using provider: {relation.m3u_account.name} (priority: {relation.m3u_account.priority})") + + return content_obj, relation + + elif content_type == 'episode': + content_obj = get_object_or_404(Episode, uuid=content_id) + logger.info(f"[CONTENT-FOUND] Episode: {content_obj.name} (ID: {content_obj.id}, Series: {content_obj.series.name})") + + # Filter by preferred stream ID first (most specific) + relations_query = _playback_relations_queryset(content_obj, user=user) + if preferred_stream_id: + specific_relation = relations_query.filter(stream_id=preferred_stream_id).first() + if specific_relation: + logger.info(f"[STREAM-SELECTED] Using specific stream: {specific_relation.stream_id} from provider: {specific_relation.m3u_account.name}") + return content_obj, specific_relation + else: + logger.warning(f"[STREAM-FALLBACK] Preferred stream ID {preferred_stream_id} not found, falling back to account/priority selection") + + # Filter by preferred M3U account if specified + if preferred_m3u_account_id: + specific_relation = relations_query.filter(m3u_account__id=preferred_m3u_account_id).first() + if specific_relation: + logger.info(f"[PROVIDER-SELECTED] Using preferred provider: {specific_relation.m3u_account.name}") + return content_obj, specific_relation + else: + logger.warning(f"[PROVIDER-FALLBACK] Preferred M3U account {preferred_m3u_account_id} not found, using highest priority") + + # Fallback: choose least-loaded account (live + VOD), then priority + relation = _pick_relation_by_account_load(relations_query, prefer_m3u_account_id=get_vod_catalog_account_id(), user=user) + + if relation: + logger.info(f"[PROVIDER-SELECTED] Using provider: {relation.m3u_account.name} (priority: {relation.m3u_account.priority})") + + return content_obj, relation + + elif content_type == 'series': + # For series, get the first episode + series = get_object_or_404(Series, uuid=content_id) + logger.info(f"[CONTENT-FOUND] Series: {series.name} (ID: {series.id})") + episode = series.episodes.first() + if not episode: + logger.error(f"[CONTENT-ERROR] No episodes found for series {series.name}") + return None, None + + logger.info(f"[CONTENT-FOUND] First episode: {episode.name} (ID: {episode.id})") + + # Filter by preferred stream ID first (most specific) + relations_query = _playback_relations_queryset(episode, user=user) + if preferred_stream_id: + specific_relation = relations_query.filter(stream_id=preferred_stream_id).first() + if specific_relation: + logger.info(f"[STREAM-SELECTED] Using specific stream: {specific_relation.stream_id} from provider: {specific_relation.m3u_account.name}") + return episode, specific_relation + else: + logger.warning(f"[STREAM-FALLBACK] Preferred stream ID {preferred_stream_id} not found, falling back to account/priority selection") + + # Filter by preferred M3U account if specified + if preferred_m3u_account_id: + specific_relation = relations_query.filter(m3u_account__id=preferred_m3u_account_id).first() + if specific_relation: + logger.info(f"[PROVIDER-SELECTED] Using preferred provider: {specific_relation.m3u_account.name}") + return episode, specific_relation + else: + logger.warning(f"[PROVIDER-FALLBACK] Preferred M3U account {preferred_m3u_account_id} not found, using highest priority") + + # Fallback: choose least-loaded account (live + VOD), then priority + relation = _pick_relation_by_account_load(relations_query, prefer_m3u_account_id=get_vod_catalog_account_id(), user=user) + + if relation: + logger.info(f"[PROVIDER-SELECTED] Using provider: {relation.m3u_account.name} (priority: {relation.m3u_account.priority})") + + return episode, relation + else: + logger.error(f"[CONTENT-ERROR] Invalid content type: {content_type}") + return None, None + + except Exception as e: + logger.error(f"Error getting content object: {e}") + return None, None + +def _get_stream_url_from_relation(relation): + """Get stream URL from the M3U relation""" + try: + # Log the relation type and available attributes + logger.info(f"[VOD-URL] Relation type: {type(relation).__name__}") + logger.info(f"[VOD-URL] Account type: {relation.m3u_account.account_type}") + logger.info(f"[VOD-URL] Stream ID: {getattr(relation, 'stream_id', 'N/A')}") + + # First try the get_stream_url method (this should build URLs dynamically) + if hasattr(relation, 'get_stream_url'): + url = relation.get_stream_url() + if url: + logger.info(f"[VOD-URL] Built URL from get_stream_url(): {url}") + return url + else: + logger.warning(f"[VOD-URL] get_stream_url() returned None") + + logger.error(f"[VOD-URL] Relation has no get_stream_url method or it failed") + return None + except Exception as e: + logger.error(f"[VOD-URL] Error getting stream URL from relation: {e}", exc_info=True) + return None + +def _get_m3u_profile(m3u_account, profile_id, session_id=None): + """Get appropriate M3U profile for streaming using Redis-based viewer counts + + Args: + m3u_account: M3UAccount instance + profile_id: Optional specific profile ID requested + session_id: Optional session ID to check for existing connections + + Returns: + tuple: (M3UAccountProfile, current_connections) or None if no profile found + """ + try: + from core.utils import RedisClient + redis_client = RedisClient.get_client() + + if not redis_client: + logger.warning("Redis not available, falling back to default profile") + default_profile = M3UAccountProfile.objects.filter( + m3u_account=m3u_account, + is_active=True, + is_default=True + ).first() + return (default_profile, 0) if default_profile else None + + # Check if this session already has an active connection + if session_id: + persistent_connection_key = f"vod_persistent_connection:{session_id}" + connection_data = redis_client.hgetall(persistent_connection_key) + + if connection_data: + existing_profile_id = connection_data.get('m3u_profile_id') + if existing_profile_id: + try: + existing_profile = M3UAccountProfile.objects.get( + id=int(existing_profile_id), + m3u_account=m3u_account, + is_active=True + ) + # Get current connections for logging + profile_connections_key = f"profile_connections:{existing_profile.id}" + current_connections = int(redis_client.get(profile_connections_key) or 0) + + logger.info(f"[PROFILE-SELECTION] Session {session_id} reusing existing profile {existing_profile.id}: {current_connections}/{existing_profile.max_streams} connections") + return (existing_profile, current_connections) + except (M3UAccountProfile.DoesNotExist, ValueError): + logger.warning(f"[PROFILE-SELECTION] Session {session_id} has invalid profile ID {existing_profile_id}, selecting new profile") + except Exception as e: + logger.warning(f"[PROFILE-SELECTION] Error checking existing profile for session {session_id}: {e}") + else: + logger.debug(f"[PROFILE-SELECTION] Session {session_id} exists but has no profile ID stored") # If specific profile requested, try to use it + if profile_id: + try: + profile = M3UAccountProfile.objects.get( + id=profile_id, + m3u_account=m3u_account, + is_active=True + ) + # Check Redis-based current connections + profile_connections_key = f"profile_connections:{profile.id}" + current_connections = int(redis_client.get(profile_connections_key) or 0) + + if profile.max_streams == 0 or current_connections < profile.max_streams: + logger.info(f"[PROFILE-SELECTION] Using requested profile {profile.id}: {current_connections}/{profile.max_streams} connections") + return (profile, current_connections) + else: + logger.warning(f"[PROFILE-SELECTION] Requested profile {profile.id} is at capacity: {current_connections}/{profile.max_streams}") + except M3UAccountProfile.DoesNotExist: + logger.warning(f"[PROFILE-SELECTION] Requested profile {profile_id} not found") + + # Get active profiles ordered by priority (default first) + m3u_profiles = M3UAccountProfile.objects.filter( + m3u_account=m3u_account, + is_active=True + ) + + default_profile = m3u_profiles.filter(is_default=True).first() + if not default_profile: + logger.error(f"[PROFILE-SELECTION] No default profile found for M3U account {m3u_account.id}") + return None + + # Check profiles in order: default first, then others + profiles = [default_profile] + list(m3u_profiles.filter(is_default=False)) + + for profile in profiles: + profile_connections_key = f"profile_connections:{profile.id}" + current_connections = int(redis_client.get(profile_connections_key) or 0) + + # Check if profile has available connection slots + if profile.max_streams == 0 or current_connections < profile.max_streams: + logger.info(f"[PROFILE-SELECTION] Selected profile {profile.id} ({profile.name}): {current_connections}/{profile.max_streams} connections") + return (profile, current_connections) + else: + logger.debug(f"[PROFILE-SELECTION] Profile {profile.id} at capacity: {current_connections}/{profile.max_streams}") + + # All profiles are at capacity: preempt oldest VOD session on this account once, then retry + logger.error(f"[PROFILE-SELECTION] All profiles at capacity for M3U account {m3u_account.id}, rejecting request") + if _preempt_oldest_vod_session_for_account(int(m3u_account.id)): + # Give workers a brief chance to release counters + time.sleep(0.2) + for profile in profiles: + profile_connections_key = f"profile_connections:{profile.id}" + current_connections = int(redis_client.get(profile_connections_key) or 0) + if profile.max_streams == 0 or current_connections < profile.max_streams: + logger.info( + f"[PROFILE-SELECTION] Recovered slot after preemption with profile {profile.id}: {current_connections}/{profile.max_streams}" + ) + return (profile, current_connections) + return None + + except Exception as e: + logger.error(f"Error getting M3U profile: {e}") + return None + +def _transform_url(original_url, m3u_profile): + """Transform URL based on M3U profile settings""" + try: + import regex + + if not original_url: + return None + + search_pattern = m3u_profile.search_pattern + replace_pattern = m3u_profile.replace_pattern + # Convert JS-style backreferences in replace: $ -> \g, $1 -> \1 + safe_replace_pattern = regex.sub(r'\$<([^>]+)>', r'\\g<\1>', replace_pattern) + safe_replace_pattern = regex.sub(r'\$(\d+)', r'\\\1', safe_replace_pattern) + + if search_pattern and replace_pattern: + # regex module accepts JS-style (?...) named groups natively + transformed_url = regex.sub(search_pattern, safe_replace_pattern, original_url) + return transformed_url + + return original_url + + except Exception as e: + logger.error(f"Error transforming URL: {e}") + return original_url + +@api_view(["GET"]) +@permission_classes([AllowAny]) +def stream_vod(request, content_type, content_id, session_id=None, profile_id=None, user=None): + """ + Stream VOD content (movies or series episodes) with session-based connection reuse + + Args: + content_type: 'movie', 'series', or 'episode' + content_id: ID of the content + session_id: Optional session ID from URL path (for persistent connections) + profile_id: Optional M3U profile ID for authentication + """ + if not network_access_allowed(request, "STREAMS"): + return JsonResponse({"error": "Forbidden"}, status=403) + + logger.info(f"[VOD-REQUEST] Starting VOD stream request: {content_type}/{content_id}, session: {session_id}, profile: {profile_id}") + logger.info(f"[VOD-REQUEST] Full request path: {request.get_full_path()}") + logger.info(f"[VOD-REQUEST] Request method: {request.method}") + logger.info(f"[VOD-REQUEST] Request headers: {dict(request.headers)}") + + try: + if user is None: + req_user = getattr(request, "user", None) + if req_user is not None and getattr(req_user, "is_authenticated", False): + user = req_user + else: + qs_user_id = request.GET.get("user_id") + if qs_user_id and str(qs_user_id).isdigit(): + try: + user = User.objects.filter(id=int(qs_user_id)).first() + except Exception: + user = None + client_ip, client_user_agent = get_client_info(request) + + # Extract timeshift parameters from query string + # Support multiple timeshift parameter formats + utc_start = request.GET.get('utc_start') or request.GET.get('start') or request.GET.get('playliststart') + utc_end = request.GET.get('utc_end') or request.GET.get('end') or request.GET.get('playlistend') + offset = request.GET.get('offset') or request.GET.get('seek') or request.GET.get('t') + + # VLC specific timeshift parameters + if not utc_start and not offset: + # Check for VLC-style timestamp parameters + if 'timestamp' in request.GET: + offset = request.GET.get('timestamp') + elif 'time' in request.GET: + offset = request.GET.get('time') + + # Session ID now comes from URL path parameter + # Remove legacy query parameter extraction since we're using path-based routing + + # Extract Range header for seeking support + range_header = request.META.get('HTTP_RANGE') + + logger.info(f"[VOD-TIMESHIFT] Timeshift params - utc_start: {utc_start}, utc_end: {utc_end}, offset: {offset}") + logger.info(f"[VOD-SESSION] Session ID: {session_id}") + + # Log all query parameters for debugging + if request.GET: + logger.debug(f"[VOD-PARAMS] All query params: {dict(request.GET)}") + + if range_header: + logger.info(f"[VOD-RANGE] Range header: {range_header}") + + # Parse the range to understand what position VLC is seeking to + try: + if 'bytes=' in range_header: + range_part = range_header.replace('bytes=', '') + if '-' in range_part: + start_byte, end_byte = range_part.split('-', 1) + if start_byte: + start_pos_mb = int(start_byte) / (1024 * 1024) + logger.info(f"[VOD-SEEK] Seeking to byte position: {start_byte} (~{start_pos_mb:.1f} MB)") + if int(start_byte) > 0: + logger.info(f"[VOD-SEEK] *** ACTUAL SEEK DETECTED *** Position: {start_pos_mb:.1f} MB") + else: + logger.info(f"[VOD-SEEK] Open-en`ded range request (from start)") + if end_byte: + end_pos_mb = int(end_byte) / (1024 * 1024) + logger.info(f"[VOD-SEEK] End position: {end_byte} bytes (~{end_pos_mb:.1f} MB)") + except Exception as e: + logger.warning(f"[VOD-SEEK] Could not parse range header: {e}") + + # Simple seek detection - track rapid requests + current_time = time.time() + request_key = f"{client_ip}:{content_type}:{content_id}" + + if request_key in _request_times: + time_diff = current_time - _request_times[request_key] + if time_diff < 5.0: + logger.info(f"[VOD-SEEK] Rapid request detected ({time_diff:.1f}s) - likely seeking") + + _request_times[request_key] = current_time + else: + logger.info(f"[VOD-RANGE] No Range header - full content request") + + logger.info(f"[VOD-CLIENT] Client info - IP: {client_ip}, User-Agent: {client_user_agent[:50]}...") + + # If no session ID, create one and redirect to path-based URL + if not session_id: + new_session_id = f"vod_{int(time.time() * 1000)}_{random.randint(1000, 9999)}" + logger.info(f"[VOD-SESSION] Creating new session: {new_session_id}") + + # Preserve any query parameters (except session_id) + query_params = dict(request.GET) + query_params.pop('session_id', None) # Remove if present + + if user: + redirect_url = f"{request.path}?session_id={new_session_id}" + if query_params: + query_string = urlencode(query_params, doseq=True) + redirect_url = f"{redirect_url}&{query_string}" + else: + # Build redirect URL with session ID in path, preserve query parameters + path_parts = request.path.rstrip('/').split('/') + + # Construct new path: /vod/movie/UUID/SESSION_ID or /vod/movie/UUID/SESSION_ID/PROFILE_ID/ + if profile_id: + new_path = f"{'/'.join(path_parts)}/{new_session_id}/{profile_id}/" + else: + new_path = f"{'/'.join(path_parts)}/{new_session_id}" + + if query_params: + from urllib.parse import urlencode + query_string = urlencode(query_params, doseq=True) + redirect_url = f"{new_path}?{query_string}" + else: + redirect_url = new_path + + logger.info(f"[VOD-SESSION] Redirecting to path-based URL: {redirect_url}") + + return HttpResponse( + status=301, + headers={'Location': redirect_url} + ) + + if user: + if not check_user_stream_limits(user, session_id, media_id=content_id): + return JsonResponse( + {"error": f"Stream limit exceeded ({user.stream_limit} concurrent streams allowed)"}, + status=429 + ) + + # Extract preferred M3U account ID and stream ID from query parameters + preferred_m3u_account_id = request.GET.get('m3u_account_id') + preferred_stream_id = request.GET.get('stream_id') + + if preferred_m3u_account_id: + try: + preferred_m3u_account_id = int(preferred_m3u_account_id) + except (ValueError, TypeError): + logger.warning(f"[VOD-PARAM] Invalid m3u_account_id parameter: {preferred_m3u_account_id}") + preferred_m3u_account_id = None + + if preferred_stream_id: + logger.info(f"[VOD-PARAM] Preferred stream ID: {preferred_stream_id}") + + # Get the content object and its relation + content_obj, relation = _get_content_and_relation(content_type, content_id, preferred_m3u_account_id, preferred_stream_id, user=user) + if not content_obj or not relation: + logger.error(f"[VOD-ERROR] Content or relation not found: {content_type} {content_id}") + raise Http404(f"Content not found: {content_type} {content_id}") + + logger.info(f"[VOD-CONTENT] Found content: {getattr(content_obj, 'name', 'Unknown')}") + + # Get M3U account from relation + m3u_account = relation.m3u_account + logger.info(f"[VOD-ACCOUNT] Using M3U account: {m3u_account.name}") + + # Get stream URL from relation + stream_url = _get_stream_url_from_relation(relation) + logger.info(f"[VOD-CONTENT] Content URL: {stream_url or 'No URL found'}") + + if not stream_url: + logger.error(f"[VOD-ERROR] No stream URL available for {content_type} {content_id}") + return HttpResponse("No stream URL available", status=503) + + # Get M3U profile (returns profile and current connection count) + profile_result = _get_m3u_profile(m3u_account, profile_id, session_id) + + # Auto-mode fallback: if selected account is at capacity, try next accounts + if (not profile_result or not profile_result[0]) and not preferred_stream_id: + logger.warning( + f"[VOD-FALLBACK] Initial account {m3u_account.name} at capacity, trying alternate accounts" + ) + for alt_relation in _relations_by_account_load(content_obj, prefer_m3u_account_id=get_vod_catalog_account_id(), user=user): + if alt_relation.id == relation.id: + continue + alt_account = alt_relation.m3u_account + alt_profile_result = _get_m3u_profile(alt_account, profile_id, session_id) + if not alt_profile_result or not alt_profile_result[0]: + continue + alt_stream_url = _get_stream_url_from_relation(alt_relation) + if not alt_stream_url: + continue + logger.info( + f"[VOD-FALLBACK] Switched to account {alt_account.name} for {content_type} {content_id}" + ) + relation = alt_relation + m3u_account = alt_account + stream_url = alt_stream_url + profile_result = alt_profile_result + break + + if not profile_result or not profile_result[0]: + logger.error(f"[VOD-ERROR] No suitable M3U profile found for {content_type} {content_id}") + return HttpResponse("No available stream", status=503) + + m3u_profile, current_connections = profile_result + logger.info(f"[VOD-PROFILE] Using M3U profile: {m3u_profile.id} (max_streams: {m3u_profile.max_streams}, current: {current_connections})") + + # Connection tracking is handled by the connection manager + # Transform URL based on profile + final_stream_url = _transform_url(stream_url, m3u_profile) + logger.info(f"[VOD-URL] Final stream URL: {final_stream_url}") + + # Validate stream URL + if not final_stream_url or not final_stream_url.startswith(('http://', 'https://')): + logger.error(f"[VOD-ERROR] Invalid stream URL: {final_stream_url}") + return HttpResponse("Invalid stream URL", status=500) + + # Get connection manager (Redis-backed for multi-worker support) + connection_manager = MultiWorkerVODConnectionManager.get_instance() + + # Stream the content with session-based connection reuse + logger.info("[VOD-STREAM] Calling connection manager to stream content") + response = connection_manager.stream_content_with_session( + session_id=session_id, + content_obj=content_obj, + stream_url=final_stream_url, + m3u_profile=m3u_profile, + client_ip=client_ip, + client_user_agent=client_user_agent, + request=request, + utc_start=utc_start, + utc_end=utc_end, + offset=offset, + range_header=range_header, + user=user, + ) + + logger.info(f"[VOD-SUCCESS] Stream response created successfully, type: {type(response)}") + return response + + except Exception as e: + logger.error(f"[VOD-EXCEPTION] Error streaming {content_type} {content_id}: {e}", exc_info=True) + return HttpResponse(f"Streaming error: {str(e)}", status=500) + +@api_view(["HEAD"]) +@permission_classes([AllowAny]) +def head_vod(request, content_type, content_id, session_id=None, profile_id=None): + """ + Handle HEAD requests for FUSE filesystem integration + + Returns content length and session URL header for subsequent GET requests + """ + if not network_access_allowed(request, "STREAMS"): + return JsonResponse({"error": "Forbidden"}, status=403) + + logger.info(f"[VOD-HEAD] HEAD request: {content_type}/{content_id}, session: {session_id}, profile: {profile_id}") + + try: + user = request.user if getattr(request, 'user', None) and getattr(request.user, 'is_authenticated', False) else None + # Get client info for M3U profile selection + client_ip, client_user_agent = get_client_info(request) + logger.info(f"[VOD-HEAD] Client info - IP: {client_ip}, User-Agent: {client_user_agent[:50] if client_user_agent else 'None'}...") + + # If no session ID, create one (same logic as GET) + if not session_id: + new_session_id = f"vod_{int(time.time() * 1000)}_{random.randint(1000, 9999)}" + logger.info(f"[VOD-HEAD] Creating new session for HEAD: {new_session_id}") + + # Build session URL for response header + path_parts = request.path.rstrip('/').split('/') + if profile_id: + session_url = f"{'/'.join(path_parts)}/{new_session_id}/{profile_id}/" + else: + session_url = f"{'/'.join(path_parts)}/{new_session_id}" + + session_id = new_session_id + else: + # Session already in URL, construct the current session URL + session_url = request.path + logger.info(f"[VOD-HEAD] Using existing session: {session_id}") + + # Extract preferred M3U account ID and stream ID from query parameters + preferred_m3u_account_id = request.GET.get('m3u_account_id') + preferred_stream_id = request.GET.get('stream_id') + + if preferred_m3u_account_id: + try: + preferred_m3u_account_id = int(preferred_m3u_account_id) + except (ValueError, TypeError): + logger.warning(f"[VOD-HEAD] Invalid m3u_account_id parameter: {preferred_m3u_account_id}") + preferred_m3u_account_id = None + + if preferred_stream_id: + logger.info(f"[VOD-HEAD] Preferred stream ID: {preferred_stream_id}") + + # Get content and relation (same as GET) + content_obj, relation = _get_content_and_relation(content_type, content_id, preferred_m3u_account_id, preferred_stream_id, user=user) + if not content_obj or not relation: + logger.error(f"[VOD-HEAD] Content or relation not found: {content_type} {content_id}") + return HttpResponse("Content not found", status=404) + + # Get M3U account and stream URL + m3u_account = relation.m3u_account + stream_url = _get_stream_url_from_relation(relation) + if not stream_url: + logger.error(f"[VOD-HEAD] No stream URL available for {content_type} {content_id}") + return HttpResponse("No stream URL available", status=503) + + # Get M3U profile (returns profile and current connection count) + profile_result = _get_m3u_profile(m3u_account, profile_id, session_id) + if (not profile_result or not profile_result[0]) and not preferred_stream_id: + logger.warning( + f"[VOD-HEAD] Initial account {m3u_account.name} at capacity, trying alternate accounts" + ) + for alt_relation in _relations_by_account_load(content_obj, prefer_m3u_account_id=get_vod_catalog_account_id(), user=user): + if alt_relation.id == relation.id: + continue + alt_account = alt_relation.m3u_account + alt_profile_result = _get_m3u_profile(alt_account, profile_id, session_id) + if not alt_profile_result or not alt_profile_result[0]: + continue + alt_stream_url = _get_stream_url_from_relation(alt_relation) + if not alt_stream_url: + continue + relation = alt_relation + m3u_account = alt_account + stream_url = alt_stream_url + profile_result = alt_profile_result + logger.info(f"[VOD-HEAD] Fallback switched to account {alt_account.name}") + break + + if not profile_result or not profile_result[0]: + logger.error(f"[VOD-HEAD] No M3U profile found or all profiles at capacity") + return HttpResponse("No available stream", status=503) + + m3u_profile, current_connections = profile_result + + # Transform URL if needed + final_stream_url = _transform_url(stream_url, m3u_profile) + + # Make a small range GET request to get content length since providers don't support HEAD + # We'll use a tiny range to minimize data transfer but get the headers we need + # Use M3U account's user agent as primary, client user agent as fallback + m3u_user_agent = m3u_account.get_user_agent().user_agent if m3u_account.get_user_agent() else None + headers = { + 'User-Agent': m3u_user_agent or client_user_agent or 'Dispatcharr/1.0', + 'Accept': '*/*', + 'Range': 'bytes=0-1' # Request only first 2 bytes + } + + logger.info(f"[VOD-HEAD] Making small range GET request to provider: {final_stream_url}") + response = requests.get(final_stream_url, headers=headers, timeout=30, allow_redirects=True, stream=True) + + # Check for range support - should be 206 for partial content + if response.status_code == 206: + # Parse Content-Range header to get total file size + content_range = response.headers.get('Content-Range', '') + if content_range: + # Content-Range: bytes 0-1/1234567890 + total_size = content_range.split('/')[-1] + logger.info(f"[VOD-HEAD] Got file size from Content-Range: {total_size}") + else: + logger.warning(f"[VOD-HEAD] No Content-Range header in 206 response") + total_size = response.headers.get('Content-Length', '0') + elif response.status_code == 200: + # Server doesn't support range requests, use Content-Length from full response + total_size = response.headers.get('Content-Length', '0') + logger.info(f"[VOD-HEAD] Server doesn't support ranges, got Content-Length: {total_size}") + else: + logger.error(f"[VOD-HEAD] Provider GET request failed: {response.status_code}") + return HttpResponse("Provider error", status=response.status_code) + + # Close the small range request - we don't need to keep this connection + response.close() + + # Store the total content length in Redis for the persistent connection to use + try: + import redis + from django.conf import settings + redis_host = getattr(settings, 'REDIS_HOST', 'localhost') + redis_port = int(getattr(settings, 'REDIS_PORT', 6379)) + redis_db = int(getattr(settings, 'REDIS_DB', 0)) + redis_password = getattr(settings, 'REDIS_PASSWORD', '') + redis_user = getattr(settings, 'REDIS_USER', '') + ssl_params = getattr(settings, 'REDIS_SSL_PARAMS', {}) + r = redis.StrictRedis( + host=redis_host, + port=redis_port, + db=redis_db, + password=redis_password if redis_password else None, + username=redis_user if redis_user else None, + decode_responses=True, + **ssl_params + ) + content_length_key = f"vod_content_length:{session_id}" + r.set(content_length_key, total_size, ex=1800) # Store for 30 minutes + logger.info(f"[VOD-HEAD] Stored total content length {total_size} for session {session_id}") + except Exception as e: + logger.error(f"[VOD-HEAD] Failed to store content length in Redis: {e}") + + # Now create a persistent connection for the session (if one doesn't exist) + # This ensures the FUSE GET requests will reuse the same connection + + connection_manager = MultiWorkerVODConnectionManager.get_instance() + + logger.info(f"[VOD-HEAD] Pre-creating persistent connection for session: {session_id}") + + # We don't actually stream content here, just ensure connection is ready + # The actual GET requests from FUSE will use the persistent connection + + # Use the total_size we extracted from the range response + provider_content_type = response.headers.get('Content-Type') + + if provider_content_type: + content_type_header = provider_content_type + logger.info(f"[VOD-HEAD] Using provider Content-Type: {content_type_header}") + else: + # Provider didn't send Content-Type, infer from URL + inferred_content_type = infer_content_type_from_url(final_stream_url) + if inferred_content_type: + content_type_header = inferred_content_type + logger.info(f"[VOD-HEAD] Provider missing Content-Type, inferred from URL: {content_type_header}") + else: + content_type_header = 'video/mp4' + logger.info(f"[VOD-HEAD] No Content-Type from provider and could not infer from URL, using default: {content_type_header}") + + logger.info(f"[VOD-HEAD] Provider response - Total Size: {total_size}, Type: {content_type_header}") + + # Create response with content length and session URL header + head_response = HttpResponse() + head_response['Content-Length'] = total_size + head_response['Content-Type'] = content_type_header + head_response['Accept-Ranges'] = 'bytes' + + # Custom header with session URL for FUSE + head_response['X-Session-URL'] = session_url + head_response['X-Dispatcharr-Session'] = session_id + + logger.info(f"[VOD-HEAD] Returning HEAD response with session URL: {session_url}") + return head_response + + except Exception as e: + logger.error(f"[VOD-HEAD] Error in HEAD request: {e}", exc_info=True) + return HttpResponse(f"HEAD error: {str(e)}", status=500) + +@api_view(["GET"]) +@permission_classes([IsAdmin]) +def vod_stats(request): + """Get current VOD connection statistics""" + try: + connection_manager = MultiWorkerVODConnectionManager.get_instance() + redis_client = connection_manager.redis_client + + if not redis_client: + return JsonResponse({'error': 'Redis not available'}, status=500) + + # Get all VOD persistent connections (consolidated data) + pattern = "vod_persistent_connection:*" + cursor = 0 + connections = [] + current_time = time.time() + + while True: + cursor, keys = redis_client.scan(cursor, match=pattern, count=100) + + for key in keys: + try: + connection_data = redis_client.hgetall(key) + + if connection_data: + # Extract session ID from key + session_id = key.replace('vod_persistent_connection:', '') + + # Decode Redis hash data + combined_data = {} + for k, v in connection_data.items(): + combined_data[k] = v + + # Get content info from the connection data (using correct field names) + content_type = combined_data.get('content_obj_type', 'unknown') + content_uuid = combined_data.get('content_uuid', 'unknown') + client_id = session_id + + # Get content info with enhanced metadata + content_name = "Unknown" + content_metadata = {} + try: + if content_type == 'movie': + content_obj = Movie.objects.select_related('logo').get(uuid=content_uuid) + content_name = content_obj.name + + # Get duration from content object + duration_secs = None + if hasattr(content_obj, 'duration_secs') and content_obj.duration_secs: + duration_secs = content_obj.duration_secs + + # If we don't have duration_secs, try to calculate it from file size and position data + if not duration_secs: + file_size_bytes = int(combined_data.get('total_content_size', 0)) + last_seek_byte = int(combined_data.get('last_seek_byte', 0)) + last_seek_percentage = float(combined_data.get('last_seek_percentage', 0.0)) + + # Calculate position if we have the required data + if file_size_bytes and file_size_bytes > 0 and last_seek_percentage > 0: + # If we know the seek percentage and current time position, we can estimate duration + # But we need to know the current time position in seconds first + # For now, let's use a rough estimate based on file size and typical bitrates + # This is a fallback - ideally duration should be in the database + estimated_duration = 6000 # 100 minutes as default for movies + duration_secs = estimated_duration + + content_metadata = { + 'year': content_obj.year, + 'rating': content_obj.rating, + 'genre': content_obj.genre, + 'duration_secs': duration_secs, + 'description': content_obj.description, + 'logo_url': content_obj.logo.url if content_obj.logo else None, + 'tmdb_id': content_obj.tmdb_id, + 'imdb_id': content_obj.imdb_id + } + elif content_type == 'episode': + content_obj = Episode.objects.select_related('series', 'series__logo').get(uuid=content_uuid) + content_name = f"{content_obj.series.name} - {content_obj.name}" + + # Get duration from content object + duration_secs = None + if hasattr(content_obj, 'duration_secs') and content_obj.duration_secs: + duration_secs = content_obj.duration_secs + + # If we don't have duration_secs, estimate for episodes + if not duration_secs: + estimated_duration = 2400 # 40 minutes as default for episodes + duration_secs = estimated_duration + + content_metadata = { + 'series_name': content_obj.series.name, + 'episode_name': content_obj.name, + 'season_number': content_obj.season_number, + 'episode_number': content_obj.episode_number, + 'air_date': content_obj.air_date.isoformat() if content_obj.air_date else None, + 'rating': content_obj.rating, + 'duration_secs': duration_secs, + 'description': content_obj.description, + 'logo_url': content_obj.series.logo.url if content_obj.series.logo else None, + 'series_year': content_obj.series.year, + 'series_genre': content_obj.series.genre, + 'tmdb_id': content_obj.tmdb_id, + 'imdb_id': content_obj.imdb_id + } + except: + pass + + # Get M3U profile information + m3u_profile_info = {} + m3u_profile_id = combined_data.get('m3u_profile_id') + if m3u_profile_id: + try: + from apps.m3u.models import M3UAccountProfile + profile = M3UAccountProfile.objects.select_related('m3u_account').get(id=m3u_profile_id) + m3u_profile_info = { + 'profile_name': profile.name, + 'account_name': profile.m3u_account.name, + 'account_id': profile.m3u_account.id, + 'max_streams': profile.m3u_account.max_streams, + 'm3u_profile_id': int(m3u_profile_id) + } + except Exception as e: + logger.warning(f"Could not fetch M3U profile {m3u_profile_id}: {e}") + + # Also try to get profile info from stored data if database lookup fails + if not m3u_profile_info and combined_data.get('m3u_profile_name'): + m3u_profile_info = { + 'profile_name': combined_data.get('m3u_profile_name', 'Unknown Profile'), + 'm3u_profile_id': combined_data.get('m3u_profile_id'), + 'account_name': 'Unknown Account' # We don't store account name directly + } + + # Calculate estimated current position based on seek percentage or last known position + last_known_position = int(combined_data.get('position_seconds', 0)) + last_position_update = combined_data.get('last_position_update') + last_seek_percentage = float(combined_data.get('last_seek_percentage', 0.0)) + last_seek_timestamp = float(combined_data.get('last_seek_timestamp', 0.0)) + estimated_position = last_known_position + + # If we have seek percentage and content duration, calculate position from that + if last_seek_percentage > 0 and content_metadata.get('duration_secs'): + try: + duration_secs = int(content_metadata['duration_secs']) + # Calculate position from seek percentage + seek_position = int((last_seek_percentage / 100) * duration_secs) + + # If we have a recent seek timestamp, add elapsed time since seek + if last_seek_timestamp > 0: + elapsed_since_seek = current_time - last_seek_timestamp + # Add elapsed time but don't exceed content duration + estimated_position = min( + seek_position + int(elapsed_since_seek), + duration_secs + ) + else: + estimated_position = seek_position + except (ValueError, TypeError): + pass + elif last_position_update and content_metadata.get('duration_secs'): + # Fallback: use time-based estimation from position_seconds + try: + update_timestamp = float(last_position_update) + elapsed_since_update = current_time - update_timestamp + # Add elapsed time to last known position, but don't exceed content duration + estimated_position = min( + last_known_position + int(elapsed_since_update), + int(content_metadata['duration_secs']) + ) + except (ValueError, TypeError): + # If timestamp parsing fails, fall back to last known position + estimated_position = last_known_position + + connection_info = { + 'content_type': content_type, + 'content_uuid': content_uuid, + 'content_name': content_name, + 'content_metadata': content_metadata, + 'm3u_profile': m3u_profile_info, + 'client_id': client_id, + 'client_ip': combined_data.get('client_ip', 'Unknown'), + 'user_id': combined_data.get('user_id', '0'), + 'user_agent': combined_data.get('client_user_agent', 'Unknown'), + 'connected_at': combined_data.get('created_at'), + 'last_activity': combined_data.get('last_activity'), + 'm3u_profile_id': m3u_profile_id, + 'position_seconds': estimated_position, # Use estimated position + 'last_known_position': last_known_position, # Include raw position for debugging + 'last_position_update': last_position_update, # Include timestamp for frontend use + 'bytes_sent': int(combined_data.get('bytes_sent', 0)), + # Seek/range information for position calculation and frontend display + 'last_seek_byte': int(combined_data.get('last_seek_byte', 0)), + 'last_seek_percentage': float(combined_data.get('last_seek_percentage', 0.0)), + 'total_content_size': int(combined_data.get('total_content_size', 0)), + 'last_seek_timestamp': float(combined_data.get('last_seek_timestamp', 0.0)) + } + + # Calculate connection duration + duration_calculated = False + if connection_info['connected_at']: + try: + connected_time = float(connection_info['connected_at']) + duration = current_time - connected_time + connection_info['duration'] = int(duration) + duration_calculated = True + except: + pass + + # Fallback: use last_activity if connected_at is not available + if not duration_calculated and connection_info['last_activity']: + try: + last_activity_time = float(connection_info['last_activity']) + # Estimate connection duration using client_id timestamp if available + if connection_info['client_id'].startswith('vod_'): + # Extract timestamp from client_id (format: vod_timestamp_random) + parts = connection_info['client_id'].split('_') + if len(parts) >= 2: + client_start_time = float(parts[1]) / 1000.0 # Convert ms to seconds + duration = current_time - client_start_time + connection_info['duration'] = int(duration) + duration_calculated = True + except: + pass + + # Final fallback + if not duration_calculated: + connection_info['duration'] = 0 + + connections.append(connection_info) + + except Exception as e: + logger.error(f"Error processing connection key {key}: {e}") + + if cursor == 0: + break + + # Group connections by content + content_stats = {} + for conn in connections: + content_key = f"{conn['content_type']}:{conn['content_uuid']}" + if content_key not in content_stats: + content_stats[content_key] = { + 'content_type': conn['content_type'], + 'content_name': conn['content_name'], + 'content_uuid': conn['content_uuid'], + 'content_metadata': conn['content_metadata'], + 'connection_count': 0, + 'connections': [] + } + content_stats[content_key]['connection_count'] += 1 + content_stats[content_key]['connections'].append(conn) + + return JsonResponse({ + 'vod_connections': list(content_stats.values()), + 'total_connections': len(connections), + 'timestamp': current_time + }) + + except Exception as e: + logger.error(f"Error getting VOD stats: {e}") + return JsonResponse({'error': str(e)}, status=500) + + +@csrf_exempt +@api_view(["POST"]) +@permission_classes([IsAdmin]) +def stop_vod_client(request): + """Stop a specific VOD client connection using stop signal mechanism""" + try: + # Parse request body + import json + try: + data = json.loads(request.body) + except json.JSONDecodeError: + return JsonResponse({'error': 'Invalid JSON'}, status=400) + + client_id = data.get('client_id') + if not client_id: + return JsonResponse({'error': 'No client_id provided'}, status=400) + + logger.info(f"Request to stop VOD client: {client_id}") + + # Get Redis client + connection_manager = MultiWorkerVODConnectionManager.get_instance() + redis_client = connection_manager.redis_client + + if not redis_client: + return JsonResponse({'error': 'Redis not available'}, status=500) + + # Check if connection exists + connection_key = f"vod_persistent_connection:{client_id}" + connection_data = redis_client.hgetall(connection_key) + if not connection_data: + logger.warning(f"VOD connection not found: {client_id}") + return JsonResponse({'error': 'Connection not found'}, status=404) + + # Set a stop signal key that the worker will check + stop_key = get_vod_client_stop_key(client_id) + redis_client.setex(stop_key, 60, "true") # 60 second TTL + + logger.info(f"Set stop signal for VOD client: {client_id}") + + return JsonResponse({ + 'message': 'VOD client stop signal sent', + 'client_id': client_id, + 'stop_key': stop_key + }) + + except Exception as e: + logger.error(f"Error stopping VOD client: {e}", exc_info=True) + return JsonResponse({'error': str(e)}, status=500) + +@api_view(["GET"]) +@permission_classes([AllowAny]) +def stream_xc_movie(request, username, password, stream_id, extension): + if not network_access_allowed(request, "STREAMS"): + return JsonResponse({"error": "Forbidden"}, status=403) + + from apps.vod.models import M3UMovieRelation + + session_id = request.GET.get('session_id') + profile_id = request.GET.get('profile_id') + + user = get_object_or_404(User, username=username) + + custom_properties = user.custom_properties or {} + + if "xc_password" not in custom_properties: + return Response({"error": "Invalid credentials"}, status=401) + + if custom_properties["xc_password"] != password: + return Response({"error": "Invalid credentials"}, status=401) + + qs = filter_m3u_relation_queryset_for_playback(M3UMovieRelation.objects.filter(movie_id=stream_id)) + qs, _user_pref = filter_relation_qs_for_user_playback(qs, user) + + try: + relations = list(qs.select_related('movie', 'm3u_account')) + if not relations: + return JsonResponse({"error": "Movie not found"}, status=404) + # Pick by least-loaded, then user-preferred, catalog tie-break, then priority + from apps.vod.models import M3UMovieRelation as _MMR # noqa + movie_relation = _pick_relation_by_account_load(qs, prefer_m3u_account_id=get_vod_catalog_account_id(), user=user) + if not movie_relation: + return JsonResponse({"error": "Movie not found"}, status=404) + except (M3UMovieRelation.DoesNotExist, M3UMovieRelation.MultipleObjectsReturned): + return JsonResponse({"error": "Movie not found"}, status=404) + + return stream_vod(request._request, 'movie', movie_relation.movie.uuid, session_id, profile_id, user) + +@api_view(["GET"]) +@permission_classes([AllowAny]) +def stream_xc_episode(request, username, password, stream_id, extension): + if not network_access_allowed(request, "STREAMS"): + return JsonResponse({"error": "Forbidden"}, status=403) + + from apps.vod.models import M3UEpisodeRelation + + session_id = request.GET.get('session_id') + profile_id = request.GET.get('profile_id') + + user = get_object_or_404(User, username=username) + + custom_properties = user.custom_properties or {} + + if "xc_password" not in custom_properties: + return Response({"error": "Invalid credentials"}, status=401) + + if custom_properties["xc_password"] != password: + return Response({"error": "Invalid credentials"}, status=401) + + qs = filter_m3u_relation_queryset_for_playback(M3UEpisodeRelation.objects.filter(episode_id=stream_id)) + qs, _user_pref = filter_relation_qs_for_user_playback(qs, user) + + try: + episode_relation = _pick_relation_by_account_load(qs, prefer_m3u_account_id=get_vod_catalog_account_id(), user=user) + if not episode_relation: + return JsonResponse({"error": "Episode not found"}, status=404) + except M3UEpisodeRelation.DoesNotExist: + return JsonResponse({"error": "Episode not found"}, status=404) + + return stream_vod(request._request, 'episode', episode_relation.episode.uuid, session_id, profile_id, user) + + + +@api_view(["GET"]) +@permission_classes([IsAdmin]) +def vod_monitor(request): + """Combined monitor view: M3U accounts/profiles + Redis profile_connections + active VOD sessions + live channels. + + Computes REAL connections per profile from vod_persistent_connection:* and + ts_proxy:channel:*:clients (via per-client hashes that hold m3u_profile_id). + + Optional `?reset_zombies=1` resets the Redis profile counters down to the real value + when the counter is higher than what we can find in active sessions. + + Returns JSON consumable by Gestion Dispatcharr UI. + """ + try: + from apps.m3u.models import M3UAccount, M3UAccountProfile + from apps.channels.models import Channel + from apps.vod.catalog_settings import ( + get_vod_catalog_account_id, + get_vod_playback_allowed_account_ids, + ) + + connection_manager = MultiWorkerVODConnectionManager.get_instance() + redis_client = connection_manager.redis_client + + catalog_account_id = get_vod_catalog_account_id() + playback_allowlist = get_vod_playback_allowed_account_ids() + + # --- Compute REAL connections per profile_id from active sessions --- + real_per_profile: dict[int, int] = {} + if redis_client: + try: + for key in redis_client.scan_iter(match="vod_persistent_connection:*", count=500): + data = redis_client.hgetall(key) + if not data: + continue + pid = data.get("m3u_profile_id") + try: + pid = int(pid) if pid is not None else None + except (TypeError, ValueError): + pid = None + if pid is None: + continue + real_per_profile[pid] = real_per_profile.get(pid, 0) + 1 + except Exception: + pass + try: + for key in redis_client.scan_iter(match="ts_proxy:channel:*:clients:*", count=500): + cdata = redis_client.hgetall(key) + if not cdata: + continue + pid = cdata.get("m3u_profile_id") or cdata.get("profile_id") + try: + pid = int(pid) if pid is not None else None + except (TypeError, ValueError): + pid = None + if pid is None: + continue + real_per_profile[pid] = real_per_profile.get(pid, 0) + 1 + except Exception: + pass + + reset_zombies = request.GET.get("reset_zombies") in ("1", "true", "yes") + zombie_actions: list[dict] = [] + + # Accounts + profiles + connection counts (with reconciliation) + accounts_payload = [] + for acc in M3UAccount.objects.filter(is_active=True).order_by("id"): + cp = acc.custom_properties or {} + profiles = [] + total_used = 0 + total_max = 0 + unlimited = False + for prof in M3UAccountProfile.objects.filter(m3u_account=acc, is_active=True).order_by("id"): + redis_counter = 0 + if redis_client: + try: + redis_counter = int(redis_client.get(f"profile_connections:{prof.id}") or 0) + except Exception: + redis_counter = 0 + real_count = int(real_per_profile.get(int(prof.id), 0)) + zombie_delta = max(0, redis_counter - real_count) + + if zombie_delta > 0 and reset_zombies and redis_client: + try: + redis_client.set(f"profile_connections:{prof.id}", real_count) + zombie_actions.append({ + "profile_id": prof.id, + "old": redis_counter, + "new": real_count, + }) + redis_counter = real_count + zombie_delta = 0 + except Exception: + pass + + effective = real_count if zombie_delta > 0 else redis_counter + total_used += effective + if prof.max_streams == 0: + unlimited = True + else: + total_max += prof.max_streams + profiles.append( + { + "id": prof.id, + "name": prof.name, + "is_default": prof.is_default, + "max_streams": prof.max_streams, + "current_connections": effective, + "redis_counter": redis_counter, + "real_active": real_count, + "zombie_delta": zombie_delta, + } + ) + accounts_payload.append( + { + "id": acc.id, + "name": acc.name, + "type": acc.account_type, + "priority": acc.priority, + "enable_vod": bool(cp.get("enable_vod", False)), + "is_catalog": (catalog_account_id is not None and acc.id == catalog_account_id), + "in_playback_allowlist": (playback_allowlist is None) or (acc.id in playback_allowlist), + "profiles": profiles, + "total_used": total_used, + "total_max": 0 if unlimited else total_max, + "unlimited": unlimited, + } + ) + + # Active VOD sessions + vod_sessions = [] + if redis_client: + cursor = 0 + current_time = time.time() + while True: + cursor, keys = redis_client.scan(cursor, match="vod_persistent_connection:*", count=200) + for key in keys: + try: + data = redis_client.hgetall(key) + if not data: + continue + key_str = key.decode() if isinstance(key, bytes) else str(key) + session_id = key_str.split(":", 1)[1] if ":" in key_str else key_str + ctype = data.get("content_obj_type") or "unknown" + cuuid = data.get("content_uuid") or "" + title = "Unknown" + try: + if ctype == "movie" and cuuid: + m = Movie.objects.filter(uuid=cuuid).only("name").first() + if m: + title = m.name + elif ctype == "episode" and cuuid: + e = Episode.objects.select_related("series").filter(uuid=cuuid).first() + if e: + title = f"{e.series.name} - S{(e.season_number or 0):02d}E{(e.episode_number or 0):02d} - {e.name}" + except Exception: + pass + + prof_id = data.get("m3u_profile_id") + prof_info = {} + try: + if prof_id: + prof = M3UAccountProfile.objects.select_related("m3u_account").get(id=int(prof_id)) + prof_info = { + "profile_id": prof.id, + "profile_name": prof.name, + "account_id": prof.m3u_account_id, + "account_name": prof.m3u_account.name, + } + except Exception: + pass + + try: + created_at = float(data.get("created_at") or 0) + duration = int(current_time - created_at) if created_at else 0 + except Exception: + duration = 0 + + vod_sessions.append( + { + "session_id": session_id, + "content_type": ctype, + "content_uuid": cuuid, + "title": title, + "client_ip": data.get("client_ip") or "", + "user_agent": (data.get("client_user_agent") or "")[:160], + "user_id": data.get("user_id") or "", + "created_at": data.get("created_at") or "", + "last_activity": data.get("last_activity") or "", + "duration_seconds": duration, + "bytes_sent": int(data.get("bytes_sent") or 0), + **prof_info, + } + ) + except Exception as exc: + logger.debug(f"vod_monitor session parse failed: {exc}") + if cursor == 0: + break + + # Live (TS proxy) clients per channel + live_clients = [] + if redis_client: + cursor = 0 + channel_clients = {} + while True: + cursor, keys = redis_client.scan(cursor, match="ts_proxy:channel:*:clients", count=200) + for key in keys: + key_str = key.decode() if isinstance(key, bytes) else str(key) + try: + parts = key_str.split(":") + channel_id = parts[2] + except Exception: + continue + try: + count = int(redis_client.scard(key) or 0) + except Exception: + count = 0 + if count <= 0: + continue + client_ids = [] + try: + client_ids = list(redis_client.smembers(key) or [])[:25] + client_ids = [c.decode() if isinstance(c, bytes) else c for c in client_ids] + except Exception: + pass + sample_clients = [] + for cid in client_ids: + try: + cdata = redis_client.hgetall(f"ts_proxy:channel:{channel_id}:clients:{cid}") + if not cdata: + continue + sample_clients.append( + { + "client_id": cid, + "ip": cdata.get("ip_address") or cdata.get("ip") or "", + "user_agent": (cdata.get("user_agent") or "")[:120], + "username": cdata.get("username") or "", + "connected_since": cdata.get("connected_since") or cdata.get("connect_time") or "", + } + ) + except Exception: + continue + channel_clients[channel_id] = {"count": count, "clients": sample_clients} + if cursor == 0: + break + + channel_ids = [int(cid) for cid in channel_clients.keys() if str(cid).isdigit()] + channel_names = {str(c.id): c.name for c in Channel.objects.filter(id__in=channel_ids).only("id", "name")} + for cid, info in channel_clients.items(): + live_clients.append( + { + "channel_id": cid, + "channel_name": channel_names.get(str(cid), f"Canal {cid}"), + "client_count": info["count"], + "clients": info["clients"], + } + ) + + return JsonResponse( + { + "timestamp": time.time(), + "catalog_account_id": catalog_account_id, + "playback_allowed_account_ids": playback_allowlist, + "accounts": accounts_payload, + "vod_sessions": vod_sessions, + "live_clients": live_clients, + "zombies_reset": zombie_actions if reset_zombies else None, + } + ) + except Exception as exc: + logger.error(f"vod_monitor error: {exc}", exc_info=True) + return JsonResponse({"error": str(exc)}, status=500) diff --git a/apps/proxy/vod_proxy/views.py.bak.1778276755 b/apps/proxy/vod_proxy/views.py.bak.1778276755 new file mode 100644 index 0000000..a1dcf9a --- /dev/null +++ b/apps/proxy/vod_proxy/views.py.bak.1778276755 @@ -0,0 +1,1062 @@ +""" +VOD (Video on Demand) proxy views for handling movie and series streaming. +Supports M3U profiles for authentication and URL transformation. +""" + +import time +import random +import logging +import requests +from django.http import JsonResponse, Http404, HttpResponse +from django.shortcuts import get_object_or_404 +from django.views.decorators.csrf import csrf_exempt +from apps.vod.models import Movie, Series, Episode +from apps.m3u.models import M3UAccountProfile +from apps.proxy.vod_proxy.multi_worker_connection_manager import MultiWorkerVODConnectionManager, infer_content_type_from_url, get_vod_client_stop_key +from .utils import get_client_info +from rest_framework.decorators import api_view, permission_classes +from rest_framework.permissions import AllowAny +from apps.accounts.models import User +from apps.accounts.permissions import IsAdmin +from apps.proxy.utils import check_user_stream_limits +from dispatcharr.utils import network_access_allowed + +logger = logging.getLogger(__name__) + +_request_times = {} + + +def _get_content_and_relation(content_type, content_id, preferred_m3u_account_id=None, preferred_stream_id=None): + """Get the content object and its M3U relation""" + try: + logger.info(f"[CONTENT-LOOKUP] Looking up {content_type} with UUID {content_id}") + if preferred_m3u_account_id: + logger.info(f"[CONTENT-LOOKUP] Preferred M3U account ID: {preferred_m3u_account_id}") + if preferred_stream_id: + logger.info(f"[CONTENT-LOOKUP] Preferred stream ID: {preferred_stream_id}") + + if content_type == 'movie': + content_obj = get_object_or_404(Movie, uuid=content_id) + logger.info(f"[CONTENT-FOUND] Movie: {content_obj.name} (ID: {content_obj.id})") + + # Filter by preferred stream ID first (most specific) + relations_query = content_obj.m3u_relations.filter(m3u_account__is_active=True) + if preferred_stream_id: + specific_relation = relations_query.filter(stream_id=preferred_stream_id).first() + if specific_relation: + logger.info(f"[STREAM-SELECTED] Using specific stream: {specific_relation.stream_id} from provider: {specific_relation.m3u_account.name}") + return content_obj, specific_relation + else: + logger.warning(f"[STREAM-FALLBACK] Preferred stream ID {preferred_stream_id} not found, falling back to account/priority selection") + + # Filter by preferred M3U account if specified + if preferred_m3u_account_id: + specific_relation = relations_query.filter(m3u_account__id=preferred_m3u_account_id).first() + if specific_relation: + logger.info(f"[PROVIDER-SELECTED] Using preferred provider: {specific_relation.m3u_account.name}") + return content_obj, specific_relation + else: + logger.warning(f"[PROVIDER-FALLBACK] Preferred M3U account {preferred_m3u_account_id} not found, using highest priority") + + # Get the highest priority active relation (fallback or default) + relation = relations_query.select_related('m3u_account').order_by('-m3u_account__priority', 'id').first() + + if relation: + logger.info(f"[PROVIDER-SELECTED] Using provider: {relation.m3u_account.name} (priority: {relation.m3u_account.priority})") + + return content_obj, relation + + elif content_type == 'episode': + content_obj = get_object_or_404(Episode, uuid=content_id) + logger.info(f"[CONTENT-FOUND] Episode: {content_obj.name} (ID: {content_obj.id}, Series: {content_obj.series.name})") + + # Filter by preferred stream ID first (most specific) + relations_query = content_obj.m3u_relations.filter(m3u_account__is_active=True) + if preferred_stream_id: + specific_relation = relations_query.filter(stream_id=preferred_stream_id).first() + if specific_relation: + logger.info(f"[STREAM-SELECTED] Using specific stream: {specific_relation.stream_id} from provider: {specific_relation.m3u_account.name}") + return content_obj, specific_relation + else: + logger.warning(f"[STREAM-FALLBACK] Preferred stream ID {preferred_stream_id} not found, falling back to account/priority selection") + + # Filter by preferred M3U account if specified + if preferred_m3u_account_id: + specific_relation = relations_query.filter(m3u_account__id=preferred_m3u_account_id).first() + if specific_relation: + logger.info(f"[PROVIDER-SELECTED] Using preferred provider: {specific_relation.m3u_account.name}") + return content_obj, specific_relation + else: + logger.warning(f"[PROVIDER-FALLBACK] Preferred M3U account {preferred_m3u_account_id} not found, using highest priority") + + # Get the highest priority active relation (fallback or default) + relation = relations_query.select_related('m3u_account').order_by('-m3u_account__priority', 'id').first() + + if relation: + logger.info(f"[PROVIDER-SELECTED] Using provider: {relation.m3u_account.name} (priority: {relation.m3u_account.priority})") + + return content_obj, relation + + elif content_type == 'series': + # For series, get the first episode + series = get_object_or_404(Series, uuid=content_id) + logger.info(f"[CONTENT-FOUND] Series: {series.name} (ID: {series.id})") + episode = series.episodes.first() + if not episode: + logger.error(f"[CONTENT-ERROR] No episodes found for series {series.name}") + return None, None + + logger.info(f"[CONTENT-FOUND] First episode: {episode.name} (ID: {episode.id})") + + # Filter by preferred stream ID first (most specific) + relations_query = episode.m3u_relations.filter(m3u_account__is_active=True) + if preferred_stream_id: + specific_relation = relations_query.filter(stream_id=preferred_stream_id).first() + if specific_relation: + logger.info(f"[STREAM-SELECTED] Using specific stream: {specific_relation.stream_id} from provider: {specific_relation.m3u_account.name}") + return episode, specific_relation + else: + logger.warning(f"[STREAM-FALLBACK] Preferred stream ID {preferred_stream_id} not found, falling back to account/priority selection") + + # Filter by preferred M3U account if specified + if preferred_m3u_account_id: + specific_relation = relations_query.filter(m3u_account__id=preferred_m3u_account_id).first() + if specific_relation: + logger.info(f"[PROVIDER-SELECTED] Using preferred provider: {specific_relation.m3u_account.name}") + return episode, specific_relation + else: + logger.warning(f"[PROVIDER-FALLBACK] Preferred M3U account {preferred_m3u_account_id} not found, using highest priority") + + # Get the highest priority active relation (fallback or default) + relation = relations_query.select_related('m3u_account').order_by('-m3u_account__priority', 'id').first() + + if relation: + logger.info(f"[PROVIDER-SELECTED] Using provider: {relation.m3u_account.name} (priority: {relation.m3u_account.priority})") + + return episode, relation + else: + logger.error(f"[CONTENT-ERROR] Invalid content type: {content_type}") + return None, None + + except Exception as e: + logger.error(f"Error getting content object: {e}") + return None, None + +def _get_stream_url_from_relation(relation): + """Get stream URL from the M3U relation""" + try: + # Log the relation type and available attributes + logger.info(f"[VOD-URL] Relation type: {type(relation).__name__}") + logger.info(f"[VOD-URL] Account type: {relation.m3u_account.account_type}") + logger.info(f"[VOD-URL] Stream ID: {getattr(relation, 'stream_id', 'N/A')}") + + # First try the get_stream_url method (this should build URLs dynamically) + if hasattr(relation, 'get_stream_url'): + url = relation.get_stream_url() + if url: + logger.info(f"[VOD-URL] Built URL from get_stream_url(): {url}") + return url + else: + logger.warning(f"[VOD-URL] get_stream_url() returned None") + + logger.error(f"[VOD-URL] Relation has no get_stream_url method or it failed") + return None + except Exception as e: + logger.error(f"[VOD-URL] Error getting stream URL from relation: {e}", exc_info=True) + return None + +def _get_m3u_profile(m3u_account, profile_id, session_id=None): + """Get appropriate M3U profile for streaming using Redis-based viewer counts + + Args: + m3u_account: M3UAccount instance + profile_id: Optional specific profile ID requested + session_id: Optional session ID to check for existing connections + + Returns: + tuple: (M3UAccountProfile, current_connections) or None if no profile found + """ + try: + from core.utils import RedisClient + redis_client = RedisClient.get_client() + + if not redis_client: + logger.warning("Redis not available, falling back to default profile") + default_profile = M3UAccountProfile.objects.filter( + m3u_account=m3u_account, + is_active=True, + is_default=True + ).first() + return (default_profile, 0) if default_profile else None + + # Check if this session already has an active connection + if session_id: + persistent_connection_key = f"vod_persistent_connection:{session_id}" + connection_data = redis_client.hgetall(persistent_connection_key) + + if connection_data: + existing_profile_id = connection_data.get('m3u_profile_id') + if existing_profile_id: + try: + existing_profile = M3UAccountProfile.objects.get( + id=int(existing_profile_id), + m3u_account=m3u_account, + is_active=True + ) + # Get current connections for logging + profile_connections_key = f"profile_connections:{existing_profile.id}" + current_connections = int(redis_client.get(profile_connections_key) or 0) + + logger.info(f"[PROFILE-SELECTION] Session {session_id} reusing existing profile {existing_profile.id}: {current_connections}/{existing_profile.max_streams} connections") + return (existing_profile, current_connections) + except (M3UAccountProfile.DoesNotExist, ValueError): + logger.warning(f"[PROFILE-SELECTION] Session {session_id} has invalid profile ID {existing_profile_id}, selecting new profile") + except Exception as e: + logger.warning(f"[PROFILE-SELECTION] Error checking existing profile for session {session_id}: {e}") + else: + logger.debug(f"[PROFILE-SELECTION] Session {session_id} exists but has no profile ID stored") # If specific profile requested, try to use it + if profile_id: + try: + profile = M3UAccountProfile.objects.get( + id=profile_id, + m3u_account=m3u_account, + is_active=True + ) + # Check Redis-based current connections + profile_connections_key = f"profile_connections:{profile.id}" + current_connections = int(redis_client.get(profile_connections_key) or 0) + + if profile.max_streams == 0 or current_connections < profile.max_streams: + logger.info(f"[PROFILE-SELECTION] Using requested profile {profile.id}: {current_connections}/{profile.max_streams} connections") + return (profile, current_connections) + else: + logger.warning(f"[PROFILE-SELECTION] Requested profile {profile.id} is at capacity: {current_connections}/{profile.max_streams}") + except M3UAccountProfile.DoesNotExist: + logger.warning(f"[PROFILE-SELECTION] Requested profile {profile_id} not found") + + # Get active profiles ordered by priority (default first) + m3u_profiles = M3UAccountProfile.objects.filter( + m3u_account=m3u_account, + is_active=True + ) + + default_profile = m3u_profiles.filter(is_default=True).first() + if not default_profile: + logger.error(f"[PROFILE-SELECTION] No default profile found for M3U account {m3u_account.id}") + return None + + # Check profiles in order: default first, then others + profiles = [default_profile] + list(m3u_profiles.filter(is_default=False)) + + for profile in profiles: + profile_connections_key = f"profile_connections:{profile.id}" + current_connections = int(redis_client.get(profile_connections_key) or 0) + + # Check if profile has available connection slots + if profile.max_streams == 0 or current_connections < profile.max_streams: + logger.info(f"[PROFILE-SELECTION] Selected profile {profile.id} ({profile.name}): {current_connections}/{profile.max_streams} connections") + return (profile, current_connections) + else: + logger.debug(f"[PROFILE-SELECTION] Profile {profile.id} at capacity: {current_connections}/{profile.max_streams}") + + # All profiles are at capacity - return None to trigger error response + logger.error(f"[PROFILE-SELECTION] All profiles at capacity for M3U account {m3u_account.id}, rejecting request") + return None + + except Exception as e: + logger.error(f"Error getting M3U profile: {e}") + return None + +def _transform_url(original_url, m3u_profile): + """Transform URL based on M3U profile settings""" + try: + import regex + + if not original_url: + return None + + search_pattern = m3u_profile.search_pattern + replace_pattern = m3u_profile.replace_pattern + # Convert JS-style backreferences in replace: $ -> \g, $1 -> \1 + safe_replace_pattern = regex.sub(r'\$<([^>]+)>', r'\\g<\1>', replace_pattern) + safe_replace_pattern = regex.sub(r'\$(\d+)', r'\\\1', safe_replace_pattern) + + if search_pattern and replace_pattern: + # regex module accepts JS-style (?...) named groups natively + transformed_url = regex.sub(search_pattern, safe_replace_pattern, original_url) + return transformed_url + + return original_url + + except Exception as e: + logger.error(f"Error transforming URL: {e}") + return original_url + +@api_view(["GET"]) +@permission_classes([AllowAny]) +def stream_vod(request, content_type, content_id, session_id=None, profile_id=None, user=None): + """ + Stream VOD content (movies or series episodes) with session-based connection reuse + + Args: + content_type: 'movie', 'series', or 'episode' + content_id: ID of the content + session_id: Optional session ID from URL path (for persistent connections) + profile_id: Optional M3U profile ID for authentication + """ + if not network_access_allowed(request, "STREAMS"): + return JsonResponse({"error": "Forbidden"}, status=403) + + logger.info(f"[VOD-REQUEST] Starting VOD stream request: {content_type}/{content_id}, session: {session_id}, profile: {profile_id}") + logger.info(f"[VOD-REQUEST] Full request path: {request.get_full_path()}") + logger.info(f"[VOD-REQUEST] Request method: {request.method}") + logger.info(f"[VOD-REQUEST] Request headers: {dict(request.headers)}") + + try: + client_ip, client_user_agent = get_client_info(request) + + # Extract timeshift parameters from query string + # Support multiple timeshift parameter formats + utc_start = request.GET.get('utc_start') or request.GET.get('start') or request.GET.get('playliststart') + utc_end = request.GET.get('utc_end') or request.GET.get('end') or request.GET.get('playlistend') + offset = request.GET.get('offset') or request.GET.get('seek') or request.GET.get('t') + + # VLC specific timeshift parameters + if not utc_start and not offset: + # Check for VLC-style timestamp parameters + if 'timestamp' in request.GET: + offset = request.GET.get('timestamp') + elif 'time' in request.GET: + offset = request.GET.get('time') + + # Session ID now comes from URL path parameter + # Remove legacy query parameter extraction since we're using path-based routing + + # Extract Range header for seeking support + range_header = request.META.get('HTTP_RANGE') + + logger.info(f"[VOD-TIMESHIFT] Timeshift params - utc_start: {utc_start}, utc_end: {utc_end}, offset: {offset}") + logger.info(f"[VOD-SESSION] Session ID: {session_id}") + + # Log all query parameters for debugging + if request.GET: + logger.debug(f"[VOD-PARAMS] All query params: {dict(request.GET)}") + + if range_header: + logger.info(f"[VOD-RANGE] Range header: {range_header}") + + # Parse the range to understand what position VLC is seeking to + try: + if 'bytes=' in range_header: + range_part = range_header.replace('bytes=', '') + if '-' in range_part: + start_byte, end_byte = range_part.split('-', 1) + if start_byte: + start_pos_mb = int(start_byte) / (1024 * 1024) + logger.info(f"[VOD-SEEK] Seeking to byte position: {start_byte} (~{start_pos_mb:.1f} MB)") + if int(start_byte) > 0: + logger.info(f"[VOD-SEEK] *** ACTUAL SEEK DETECTED *** Position: {start_pos_mb:.1f} MB") + else: + logger.info(f"[VOD-SEEK] Open-en`ded range request (from start)") + if end_byte: + end_pos_mb = int(end_byte) / (1024 * 1024) + logger.info(f"[VOD-SEEK] End position: {end_byte} bytes (~{end_pos_mb:.1f} MB)") + except Exception as e: + logger.warning(f"[VOD-SEEK] Could not parse range header: {e}") + + # Simple seek detection - track rapid requests + current_time = time.time() + request_key = f"{client_ip}:{content_type}:{content_id}" + + if request_key in _request_times: + time_diff = current_time - _request_times[request_key] + if time_diff < 5.0: + logger.info(f"[VOD-SEEK] Rapid request detected ({time_diff:.1f}s) - likely seeking") + + _request_times[request_key] = current_time + else: + logger.info(f"[VOD-RANGE] No Range header - full content request") + + logger.info(f"[VOD-CLIENT] Client info - IP: {client_ip}, User-Agent: {client_user_agent[:50]}...") + + # If no session ID, create one and redirect to path-based URL + if not session_id: + new_session_id = f"vod_{int(time.time() * 1000)}_{random.randint(1000, 9999)}" + logger.info(f"[VOD-SESSION] Creating new session: {new_session_id}") + + # Preserve any query parameters (except session_id) + query_params = dict(request.GET) + query_params.pop('session_id', None) # Remove if present + + if user: + redirect_url = f"{request.path}?session_id={new_session_id}" + if query_params: + query_string = urlencode(query_params, doseq=True) + redirect_url = f"{redirect_url}&{query_string}" + else: + # Build redirect URL with session ID in path, preserve query parameters + path_parts = request.path.rstrip('/').split('/') + + # Construct new path: /vod/movie/UUID/SESSION_ID or /vod/movie/UUID/SESSION_ID/PROFILE_ID/ + if profile_id: + new_path = f"{'/'.join(path_parts)}/{new_session_id}/{profile_id}/" + else: + new_path = f"{'/'.join(path_parts)}/{new_session_id}" + + if query_params: + from urllib.parse import urlencode + query_string = urlencode(query_params, doseq=True) + redirect_url = f"{new_path}?{query_string}" + else: + redirect_url = new_path + + logger.info(f"[VOD-SESSION] Redirecting to path-based URL: {redirect_url}") + + return HttpResponse( + status=301, + headers={'Location': redirect_url} + ) + + if user: + if not check_user_stream_limits(user, session_id, media_id=content_id): + return JsonResponse( + {"error": f"Stream limit exceeded ({user.stream_limit} concurrent streams allowed)"}, + status=429 + ) + + # Extract preferred M3U account ID and stream ID from query parameters + preferred_m3u_account_id = request.GET.get('m3u_account_id') + preferred_stream_id = request.GET.get('stream_id') + + if preferred_m3u_account_id: + try: + preferred_m3u_account_id = int(preferred_m3u_account_id) + except (ValueError, TypeError): + logger.warning(f"[VOD-PARAM] Invalid m3u_account_id parameter: {preferred_m3u_account_id}") + preferred_m3u_account_id = None + + if preferred_stream_id: + logger.info(f"[VOD-PARAM] Preferred stream ID: {preferred_stream_id}") + + # Get the content object and its relation + content_obj, relation = _get_content_and_relation(content_type, content_id, preferred_m3u_account_id, preferred_stream_id) + if not content_obj or not relation: + logger.error(f"[VOD-ERROR] Content or relation not found: {content_type} {content_id}") + raise Http404(f"Content not found: {content_type} {content_id}") + + logger.info(f"[VOD-CONTENT] Found content: {getattr(content_obj, 'name', 'Unknown')}") + + # Get M3U account from relation + m3u_account = relation.m3u_account + logger.info(f"[VOD-ACCOUNT] Using M3U account: {m3u_account.name}") + + # Get stream URL from relation + stream_url = _get_stream_url_from_relation(relation) + logger.info(f"[VOD-CONTENT] Content URL: {stream_url or 'No URL found'}") + + if not stream_url: + logger.error(f"[VOD-ERROR] No stream URL available for {content_type} {content_id}") + return HttpResponse("No stream URL available", status=503) + + # Get M3U profile (returns profile and current connection count) + profile_result = _get_m3u_profile(m3u_account, profile_id, session_id) + + if not profile_result or not profile_result[0]: + logger.error(f"[VOD-ERROR] No suitable M3U profile found for {content_type} {content_id}") + return HttpResponse("No available stream", status=503) + + m3u_profile, current_connections = profile_result + logger.info(f"[VOD-PROFILE] Using M3U profile: {m3u_profile.id} (max_streams: {m3u_profile.max_streams}, current: {current_connections})") + + # Connection tracking is handled by the connection manager + # Transform URL based on profile + final_stream_url = _transform_url(stream_url, m3u_profile) + logger.info(f"[VOD-URL] Final stream URL: {final_stream_url}") + + # Validate stream URL + if not final_stream_url or not final_stream_url.startswith(('http://', 'https://')): + logger.error(f"[VOD-ERROR] Invalid stream URL: {final_stream_url}") + return HttpResponse("Invalid stream URL", status=500) + + # Get connection manager (Redis-backed for multi-worker support) + connection_manager = MultiWorkerVODConnectionManager.get_instance() + + # Stream the content with session-based connection reuse + logger.info("[VOD-STREAM] Calling connection manager to stream content") + response = connection_manager.stream_content_with_session( + session_id=session_id, + content_obj=content_obj, + stream_url=final_stream_url, + m3u_profile=m3u_profile, + client_ip=client_ip, + client_user_agent=client_user_agent, + request=request, + utc_start=utc_start, + utc_end=utc_end, + offset=offset, + range_header=range_header, + user=user, + ) + + logger.info(f"[VOD-SUCCESS] Stream response created successfully, type: {type(response)}") + return response + + except Exception as e: + logger.error(f"[VOD-EXCEPTION] Error streaming {content_type} {content_id}: {e}", exc_info=True) + return HttpResponse(f"Streaming error: {str(e)}", status=500) + +@api_view(["HEAD"]) +@permission_classes([AllowAny]) +def head_vod(request, content_type, content_id, session_id=None, profile_id=None): + """ + Handle HEAD requests for FUSE filesystem integration + + Returns content length and session URL header for subsequent GET requests + """ + if not network_access_allowed(request, "STREAMS"): + return JsonResponse({"error": "Forbidden"}, status=403) + + logger.info(f"[VOD-HEAD] HEAD request: {content_type}/{content_id}, session: {session_id}, profile: {profile_id}") + + try: + # Get client info for M3U profile selection + client_ip, client_user_agent = get_client_info(request) + logger.info(f"[VOD-HEAD] Client info - IP: {client_ip}, User-Agent: {client_user_agent[:50] if client_user_agent else 'None'}...") + + # If no session ID, create one (same logic as GET) + if not session_id: + new_session_id = f"vod_{int(time.time() * 1000)}_{random.randint(1000, 9999)}" + logger.info(f"[VOD-HEAD] Creating new session for HEAD: {new_session_id}") + + # Build session URL for response header + path_parts = request.path.rstrip('/').split('/') + if profile_id: + session_url = f"{'/'.join(path_parts)}/{new_session_id}/{profile_id}/" + else: + session_url = f"{'/'.join(path_parts)}/{new_session_id}" + + session_id = new_session_id + else: + # Session already in URL, construct the current session URL + session_url = request.path + logger.info(f"[VOD-HEAD] Using existing session: {session_id}") + + # Extract preferred M3U account ID and stream ID from query parameters + preferred_m3u_account_id = request.GET.get('m3u_account_id') + preferred_stream_id = request.GET.get('stream_id') + + if preferred_m3u_account_id: + try: + preferred_m3u_account_id = int(preferred_m3u_account_id) + except (ValueError, TypeError): + logger.warning(f"[VOD-HEAD] Invalid m3u_account_id parameter: {preferred_m3u_account_id}") + preferred_m3u_account_id = None + + if preferred_stream_id: + logger.info(f"[VOD-HEAD] Preferred stream ID: {preferred_stream_id}") + + # Get content and relation (same as GET) + content_obj, relation = _get_content_and_relation(content_type, content_id, preferred_m3u_account_id, preferred_stream_id) + if not content_obj or not relation: + logger.error(f"[VOD-HEAD] Content or relation not found: {content_type} {content_id}") + return HttpResponse("Content not found", status=404) + + # Get M3U account and stream URL + m3u_account = relation.m3u_account + stream_url = _get_stream_url_from_relation(relation) + if not stream_url: + logger.error(f"[VOD-HEAD] No stream URL available for {content_type} {content_id}") + return HttpResponse("No stream URL available", status=503) + + # Get M3U profile (returns profile and current connection count) + profile_result = _get_m3u_profile(m3u_account, profile_id, session_id) + if not profile_result or not profile_result[0]: + logger.error(f"[VOD-HEAD] No M3U profile found or all profiles at capacity") + return HttpResponse("No available stream", status=503) + + m3u_profile, current_connections = profile_result + + # Transform URL if needed + final_stream_url = _transform_url(stream_url, m3u_profile) + + # Make a small range GET request to get content length since providers don't support HEAD + # We'll use a tiny range to minimize data transfer but get the headers we need + # Use M3U account's user agent as primary, client user agent as fallback + m3u_user_agent = m3u_account.get_user_agent().user_agent if m3u_account.get_user_agent() else None + headers = { + 'User-Agent': m3u_user_agent or client_user_agent or 'Dispatcharr/1.0', + 'Accept': '*/*', + 'Range': 'bytes=0-1' # Request only first 2 bytes + } + + logger.info(f"[VOD-HEAD] Making small range GET request to provider: {final_stream_url}") + response = requests.get(final_stream_url, headers=headers, timeout=30, allow_redirects=True, stream=True) + + # Check for range support - should be 206 for partial content + if response.status_code == 206: + # Parse Content-Range header to get total file size + content_range = response.headers.get('Content-Range', '') + if content_range: + # Content-Range: bytes 0-1/1234567890 + total_size = content_range.split('/')[-1] + logger.info(f"[VOD-HEAD] Got file size from Content-Range: {total_size}") + else: + logger.warning(f"[VOD-HEAD] No Content-Range header in 206 response") + total_size = response.headers.get('Content-Length', '0') + elif response.status_code == 200: + # Server doesn't support range requests, use Content-Length from full response + total_size = response.headers.get('Content-Length', '0') + logger.info(f"[VOD-HEAD] Server doesn't support ranges, got Content-Length: {total_size}") + else: + logger.error(f"[VOD-HEAD] Provider GET request failed: {response.status_code}") + return HttpResponse("Provider error", status=response.status_code) + + # Close the small range request - we don't need to keep this connection + response.close() + + # Store the total content length in Redis for the persistent connection to use + try: + import redis + from django.conf import settings + redis_host = getattr(settings, 'REDIS_HOST', 'localhost') + redis_port = int(getattr(settings, 'REDIS_PORT', 6379)) + redis_db = int(getattr(settings, 'REDIS_DB', 0)) + redis_password = getattr(settings, 'REDIS_PASSWORD', '') + redis_user = getattr(settings, 'REDIS_USER', '') + ssl_params = getattr(settings, 'REDIS_SSL_PARAMS', {}) + r = redis.StrictRedis( + host=redis_host, + port=redis_port, + db=redis_db, + password=redis_password if redis_password else None, + username=redis_user if redis_user else None, + decode_responses=True, + **ssl_params + ) + content_length_key = f"vod_content_length:{session_id}" + r.set(content_length_key, total_size, ex=1800) # Store for 30 minutes + logger.info(f"[VOD-HEAD] Stored total content length {total_size} for session {session_id}") + except Exception as e: + logger.error(f"[VOD-HEAD] Failed to store content length in Redis: {e}") + + # Now create a persistent connection for the session (if one doesn't exist) + # This ensures the FUSE GET requests will reuse the same connection + + connection_manager = MultiWorkerVODConnectionManager.get_instance() + + logger.info(f"[VOD-HEAD] Pre-creating persistent connection for session: {session_id}") + + # We don't actually stream content here, just ensure connection is ready + # The actual GET requests from FUSE will use the persistent connection + + # Use the total_size we extracted from the range response + provider_content_type = response.headers.get('Content-Type') + + if provider_content_type: + content_type_header = provider_content_type + logger.info(f"[VOD-HEAD] Using provider Content-Type: {content_type_header}") + else: + # Provider didn't send Content-Type, infer from URL + inferred_content_type = infer_content_type_from_url(final_stream_url) + if inferred_content_type: + content_type_header = inferred_content_type + logger.info(f"[VOD-HEAD] Provider missing Content-Type, inferred from URL: {content_type_header}") + else: + content_type_header = 'video/mp4' + logger.info(f"[VOD-HEAD] No Content-Type from provider and could not infer from URL, using default: {content_type_header}") + + logger.info(f"[VOD-HEAD] Provider response - Total Size: {total_size}, Type: {content_type_header}") + + # Create response with content length and session URL header + head_response = HttpResponse() + head_response['Content-Length'] = total_size + head_response['Content-Type'] = content_type_header + head_response['Accept-Ranges'] = 'bytes' + + # Custom header with session URL for FUSE + head_response['X-Session-URL'] = session_url + head_response['X-Dispatcharr-Session'] = session_id + + logger.info(f"[VOD-HEAD] Returning HEAD response with session URL: {session_url}") + return head_response + + except Exception as e: + logger.error(f"[VOD-HEAD] Error in HEAD request: {e}", exc_info=True) + return HttpResponse(f"HEAD error: {str(e)}", status=500) + +@api_view(["GET"]) +@permission_classes([IsAdmin]) +def vod_stats(request): + """Get current VOD connection statistics""" + try: + connection_manager = MultiWorkerVODConnectionManager.get_instance() + redis_client = connection_manager.redis_client + + if not redis_client: + return JsonResponse({'error': 'Redis not available'}, status=500) + + # Get all VOD persistent connections (consolidated data) + pattern = "vod_persistent_connection:*" + cursor = 0 + connections = [] + current_time = time.time() + + while True: + cursor, keys = redis_client.scan(cursor, match=pattern, count=100) + + for key in keys: + try: + connection_data = redis_client.hgetall(key) + + if connection_data: + # Extract session ID from key + session_id = key.replace('vod_persistent_connection:', '') + + # Decode Redis hash data + combined_data = {} + for k, v in connection_data.items(): + combined_data[k] = v + + # Get content info from the connection data (using correct field names) + content_type = combined_data.get('content_obj_type', 'unknown') + content_uuid = combined_data.get('content_uuid', 'unknown') + client_id = session_id + + # Get content info with enhanced metadata + content_name = "Unknown" + content_metadata = {} + try: + if content_type == 'movie': + content_obj = Movie.objects.select_related('logo').get(uuid=content_uuid) + content_name = content_obj.name + + # Get duration from content object + duration_secs = None + if hasattr(content_obj, 'duration_secs') and content_obj.duration_secs: + duration_secs = content_obj.duration_secs + + # If we don't have duration_secs, try to calculate it from file size and position data + if not duration_secs: + file_size_bytes = int(combined_data.get('total_content_size', 0)) + last_seek_byte = int(combined_data.get('last_seek_byte', 0)) + last_seek_percentage = float(combined_data.get('last_seek_percentage', 0.0)) + + # Calculate position if we have the required data + if file_size_bytes and file_size_bytes > 0 and last_seek_percentage > 0: + # If we know the seek percentage and current time position, we can estimate duration + # But we need to know the current time position in seconds first + # For now, let's use a rough estimate based on file size and typical bitrates + # This is a fallback - ideally duration should be in the database + estimated_duration = 6000 # 100 minutes as default for movies + duration_secs = estimated_duration + + content_metadata = { + 'year': content_obj.year, + 'rating': content_obj.rating, + 'genre': content_obj.genre, + 'duration_secs': duration_secs, + 'description': content_obj.description, + 'logo_url': content_obj.logo.url if content_obj.logo else None, + 'tmdb_id': content_obj.tmdb_id, + 'imdb_id': content_obj.imdb_id + } + elif content_type == 'episode': + content_obj = Episode.objects.select_related('series', 'series__logo').get(uuid=content_uuid) + content_name = f"{content_obj.series.name} - {content_obj.name}" + + # Get duration from content object + duration_secs = None + if hasattr(content_obj, 'duration_secs') and content_obj.duration_secs: + duration_secs = content_obj.duration_secs + + # If we don't have duration_secs, estimate for episodes + if not duration_secs: + estimated_duration = 2400 # 40 minutes as default for episodes + duration_secs = estimated_duration + + content_metadata = { + 'series_name': content_obj.series.name, + 'episode_name': content_obj.name, + 'season_number': content_obj.season_number, + 'episode_number': content_obj.episode_number, + 'air_date': content_obj.air_date.isoformat() if content_obj.air_date else None, + 'rating': content_obj.rating, + 'duration_secs': duration_secs, + 'description': content_obj.description, + 'logo_url': content_obj.series.logo.url if content_obj.series.logo else None, + 'series_year': content_obj.series.year, + 'series_genre': content_obj.series.genre, + 'tmdb_id': content_obj.tmdb_id, + 'imdb_id': content_obj.imdb_id + } + except: + pass + + # Get M3U profile information + m3u_profile_info = {} + m3u_profile_id = combined_data.get('m3u_profile_id') + if m3u_profile_id: + try: + from apps.m3u.models import M3UAccountProfile + profile = M3UAccountProfile.objects.select_related('m3u_account').get(id=m3u_profile_id) + m3u_profile_info = { + 'profile_name': profile.name, + 'account_name': profile.m3u_account.name, + 'account_id': profile.m3u_account.id, + 'max_streams': profile.m3u_account.max_streams, + 'm3u_profile_id': int(m3u_profile_id) + } + except Exception as e: + logger.warning(f"Could not fetch M3U profile {m3u_profile_id}: {e}") + + # Also try to get profile info from stored data if database lookup fails + if not m3u_profile_info and combined_data.get('m3u_profile_name'): + m3u_profile_info = { + 'profile_name': combined_data.get('m3u_profile_name', 'Unknown Profile'), + 'm3u_profile_id': combined_data.get('m3u_profile_id'), + 'account_name': 'Unknown Account' # We don't store account name directly + } + + # Calculate estimated current position based on seek percentage or last known position + last_known_position = int(combined_data.get('position_seconds', 0)) + last_position_update = combined_data.get('last_position_update') + last_seek_percentage = float(combined_data.get('last_seek_percentage', 0.0)) + last_seek_timestamp = float(combined_data.get('last_seek_timestamp', 0.0)) + estimated_position = last_known_position + + # If we have seek percentage and content duration, calculate position from that + if last_seek_percentage > 0 and content_metadata.get('duration_secs'): + try: + duration_secs = int(content_metadata['duration_secs']) + # Calculate position from seek percentage + seek_position = int((last_seek_percentage / 100) * duration_secs) + + # If we have a recent seek timestamp, add elapsed time since seek + if last_seek_timestamp > 0: + elapsed_since_seek = current_time - last_seek_timestamp + # Add elapsed time but don't exceed content duration + estimated_position = min( + seek_position + int(elapsed_since_seek), + duration_secs + ) + else: + estimated_position = seek_position + except (ValueError, TypeError): + pass + elif last_position_update and content_metadata.get('duration_secs'): + # Fallback: use time-based estimation from position_seconds + try: + update_timestamp = float(last_position_update) + elapsed_since_update = current_time - update_timestamp + # Add elapsed time to last known position, but don't exceed content duration + estimated_position = min( + last_known_position + int(elapsed_since_update), + int(content_metadata['duration_secs']) + ) + except (ValueError, TypeError): + # If timestamp parsing fails, fall back to last known position + estimated_position = last_known_position + + connection_info = { + 'content_type': content_type, + 'content_uuid': content_uuid, + 'content_name': content_name, + 'content_metadata': content_metadata, + 'm3u_profile': m3u_profile_info, + 'client_id': client_id, + 'client_ip': combined_data.get('client_ip', 'Unknown'), + 'user_id': combined_data.get('user_id', '0'), + 'user_agent': combined_data.get('client_user_agent', 'Unknown'), + 'connected_at': combined_data.get('created_at'), + 'last_activity': combined_data.get('last_activity'), + 'm3u_profile_id': m3u_profile_id, + 'position_seconds': estimated_position, # Use estimated position + 'last_known_position': last_known_position, # Include raw position for debugging + 'last_position_update': last_position_update, # Include timestamp for frontend use + 'bytes_sent': int(combined_data.get('bytes_sent', 0)), + # Seek/range information for position calculation and frontend display + 'last_seek_byte': int(combined_data.get('last_seek_byte', 0)), + 'last_seek_percentage': float(combined_data.get('last_seek_percentage', 0.0)), + 'total_content_size': int(combined_data.get('total_content_size', 0)), + 'last_seek_timestamp': float(combined_data.get('last_seek_timestamp', 0.0)) + } + + # Calculate connection duration + duration_calculated = False + if connection_info['connected_at']: + try: + connected_time = float(connection_info['connected_at']) + duration = current_time - connected_time + connection_info['duration'] = int(duration) + duration_calculated = True + except: + pass + + # Fallback: use last_activity if connected_at is not available + if not duration_calculated and connection_info['last_activity']: + try: + last_activity_time = float(connection_info['last_activity']) + # Estimate connection duration using client_id timestamp if available + if connection_info['client_id'].startswith('vod_'): + # Extract timestamp from client_id (format: vod_timestamp_random) + parts = connection_info['client_id'].split('_') + if len(parts) >= 2: + client_start_time = float(parts[1]) / 1000.0 # Convert ms to seconds + duration = current_time - client_start_time + connection_info['duration'] = int(duration) + duration_calculated = True + except: + pass + + # Final fallback + if not duration_calculated: + connection_info['duration'] = 0 + + connections.append(connection_info) + + except Exception as e: + logger.error(f"Error processing connection key {key}: {e}") + + if cursor == 0: + break + + # Group connections by content + content_stats = {} + for conn in connections: + content_key = f"{conn['content_type']}:{conn['content_uuid']}" + if content_key not in content_stats: + content_stats[content_key] = { + 'content_type': conn['content_type'], + 'content_name': conn['content_name'], + 'content_uuid': conn['content_uuid'], + 'content_metadata': conn['content_metadata'], + 'connection_count': 0, + 'connections': [] + } + content_stats[content_key]['connection_count'] += 1 + content_stats[content_key]['connections'].append(conn) + + return JsonResponse({ + 'vod_connections': list(content_stats.values()), + 'total_connections': len(connections), + 'timestamp': current_time + }) + + except Exception as e: + logger.error(f"Error getting VOD stats: {e}") + return JsonResponse({'error': str(e)}, status=500) + + +@csrf_exempt +@api_view(["POST"]) +@permission_classes([IsAdmin]) +def stop_vod_client(request): + """Stop a specific VOD client connection using stop signal mechanism""" + try: + # Parse request body + import json + try: + data = json.loads(request.body) + except json.JSONDecodeError: + return JsonResponse({'error': 'Invalid JSON'}, status=400) + + client_id = data.get('client_id') + if not client_id: + return JsonResponse({'error': 'No client_id provided'}, status=400) + + logger.info(f"Request to stop VOD client: {client_id}") + + # Get Redis client + connection_manager = MultiWorkerVODConnectionManager.get_instance() + redis_client = connection_manager.redis_client + + if not redis_client: + return JsonResponse({'error': 'Redis not available'}, status=500) + + # Check if connection exists + connection_key = f"vod_persistent_connection:{client_id}" + connection_data = redis_client.hgetall(connection_key) + if not connection_data: + logger.warning(f"VOD connection not found: {client_id}") + return JsonResponse({'error': 'Connection not found'}, status=404) + + # Set a stop signal key that the worker will check + stop_key = get_vod_client_stop_key(client_id) + redis_client.setex(stop_key, 60, "true") # 60 second TTL + + logger.info(f"Set stop signal for VOD client: {client_id}") + + return JsonResponse({ + 'message': 'VOD client stop signal sent', + 'client_id': client_id, + 'stop_key': stop_key + }) + + except Exception as e: + logger.error(f"Error stopping VOD client: {e}", exc_info=True) + return JsonResponse({'error': str(e)}, status=500) + +@api_view(["GET"]) +@permission_classes([AllowAny]) +def stream_xc_movie(request, username, password, stream_id, extension): + if not network_access_allowed(request, "STREAMS"): + return JsonResponse({"error": "Forbidden"}, status=403) + + from apps.vod.models import M3UMovieRelation + + session_id = request.GET.get('session_id') + profile_id = request.GET.get('profile_id') + + user = get_object_or_404(User, username=username) + + custom_properties = user.custom_properties or {} + + if "xc_password" not in custom_properties: + return Response({"error": "Invalid credentials"}, status=401) + + if custom_properties["xc_password"] != password: + return Response({"error": "Invalid credentials"}, status=401) + + # All authenticated users get access to VOD from all active M3U accounts + filters = {"movie_id": stream_id, "m3u_account__is_active": True} + + try: + # Order by account priority to get the best relation when multiple exist + movie_relation = M3UMovieRelation.objects.select_related('movie').filter(**filters).order_by('-m3u_account__priority', 'id').first() + if not movie_relation: + return JsonResponse({"error": "Movie not found"}, status=404) + except (M3UMovieRelation.DoesNotExist, M3UMovieRelation.MultipleObjectsReturned): + return JsonResponse({"error": "Movie not found"}, status=404) + + return stream_vod(request._request, 'movie', movie_relation.movie.uuid, session_id, profile_id, user) + +@api_view(["GET"]) +@permission_classes([AllowAny]) +def stream_xc_episode(request, username, password, stream_id, extension): + if not network_access_allowed(request, "STREAMS"): + return JsonResponse({"error": "Forbidden"}, status=403) + + from apps.vod.models import M3UEpisodeRelation + + session_id = request.GET.get('session_id') + profile_id = request.GET.get('profile_id') + + user = get_object_or_404(User, username=username) + + custom_properties = user.custom_properties or {} + + if "xc_password" not in custom_properties: + return Response({"error": "Invalid credentials"}, status=401) + + if custom_properties["xc_password"] != password: + return Response({"error": "Invalid credentials"}, status=401) + + # All authenticated users get access to series/episodes from all active M3U accounts + filters = {"episode_id": stream_id, "m3u_account__is_active": True} + + try: + episode_relation = M3UEpisodeRelation.objects.select_related('episode').filter(**filters).order_by('-m3u_account__priority', 'id').first() + except M3UEpisodeRelation.DoesNotExist: + return JsonResponse({"error": "Episode not found"}, status=404) + + return stream_vod(request._request, 'episode', episode_relation.episode.uuid, session_id, profile_id, user) diff --git a/apps/vod/__init__.py b/apps/vod/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/vod/admin.py b/apps/vod/admin.py new file mode 100644 index 0000000..6dadab3 --- /dev/null +++ b/apps/vod/admin.py @@ -0,0 +1,67 @@ +from django.contrib import admin +from .models import ( + Series, VODCategory, Movie, Episode, + M3USeriesRelation, M3UMovieRelation, M3UEpisodeRelation +) + + +@admin.register(VODCategory) +class VODCategoryAdmin(admin.ModelAdmin): + list_display = ['name', 'category_type', 'created_at'] + list_filter = ['category_type', 'created_at'] + search_fields = ['name'] + + +@admin.register(Series) +class SeriesAdmin(admin.ModelAdmin): + list_display = ['name', 'year', 'genre', 'created_at'] + list_filter = ['year', 'created_at'] + search_fields = ['name', 'description', 'tmdb_id', 'imdb_id'] + readonly_fields = ['uuid', 'created_at', 'updated_at'] + + +@admin.register(Movie) +class MovieAdmin(admin.ModelAdmin): + list_display = ['name', 'year', 'genre', 'duration_secs', 'created_at'] + list_filter = ['year', 'created_at'] + search_fields = ['name', 'description', 'tmdb_id', 'imdb_id'] + readonly_fields = ['uuid', 'created_at', 'updated_at'] + + def get_queryset(self, request): + return super().get_queryset(request).select_related('logo') + + +@admin.register(Episode) +class EpisodeAdmin(admin.ModelAdmin): + list_display = ['name', 'series', 'season_number', 'episode_number', 'duration_secs', 'created_at'] + list_filter = ['series', 'season_number', 'created_at'] + search_fields = ['name', 'description', 'series__name'] + readonly_fields = ['uuid', 'created_at', 'updated_at'] + + def get_queryset(self, request): + return super().get_queryset(request).select_related('series') + + +@admin.register(M3UMovieRelation) +class M3UMovieRelationAdmin(admin.ModelAdmin): + list_display = ['movie', 'm3u_account', 'category', 'stream_id', 'created_at'] + list_filter = ['m3u_account', 'category', 'created_at'] + search_fields = ['movie__name', 'm3u_account__name', 'stream_id'] + readonly_fields = ['created_at', 'updated_at'] + + +@admin.register(M3USeriesRelation) +class M3USeriesRelationAdmin(admin.ModelAdmin): + list_display = ['series', 'm3u_account', 'category', 'external_series_id', 'created_at'] + list_filter = ['m3u_account', 'category', 'created_at'] + search_fields = ['series__name', 'm3u_account__name', 'external_series_id'] + readonly_fields = ['created_at', 'updated_at'] + + +@admin.register(M3UEpisodeRelation) +class M3UEpisodeRelationAdmin(admin.ModelAdmin): + list_display = ['episode', 'm3u_account', 'series_relation', 'stream_id', 'created_at'] + list_filter = ['m3u_account', 'created_at'] + search_fields = ['episode__name', 'episode__series__name', 'm3u_account__name', 'stream_id'] + readonly_fields = ['created_at', 'updated_at'] + diff --git a/apps/vod/api_urls.py b/apps/vod/api_urls.py new file mode 100644 index 0000000..e897bd2 --- /dev/null +++ b/apps/vod/api_urls.py @@ -0,0 +1,22 @@ +from django.urls import path, include +from rest_framework.routers import DefaultRouter +from .api_views import ( + MovieViewSet, + EpisodeViewSet, + SeriesViewSet, + VODCategoryViewSet, + UnifiedContentViewSet, + VODLogoViewSet, +) + +app_name = 'vod' + +router = DefaultRouter() +router.register(r'movies', MovieViewSet, basename='movie') +router.register(r'episodes', EpisodeViewSet, basename='episode') +router.register(r'series', SeriesViewSet, basename='series') +router.register(r'categories', VODCategoryViewSet, basename='vodcategory') +router.register(r'all', UnifiedContentViewSet, basename='unified-content') +router.register(r'vodlogos', VODLogoViewSet, basename='vodlogo') + +urlpatterns = router.urls diff --git a/apps/vod/api_views.py b/apps/vod/api_views.py new file mode 100644 index 0000000..03be7fb --- /dev/null +++ b/apps/vod/api_views.py @@ -0,0 +1,1000 @@ +from rest_framework import viewsets, status +from rest_framework.response import Response +from rest_framework.decorators import action +from rest_framework.filters import SearchFilter, OrderingFilter +from rest_framework.pagination import PageNumberPagination +from rest_framework.permissions import AllowAny +from django_filters.rest_framework import DjangoFilterBackend +from django.shortcuts import get_object_or_404 +from django.http import StreamingHttpResponse, HttpResponse, FileResponse +from django.db.models import Q +import django_filters +import logging +import os +import time +import requests +from apps.accounts.permissions import ( + Authenticated, + permission_classes_by_action, +) +from .models import ( + Series, VODCategory, Movie, Episode, VODLogo, + M3USeriesRelation, M3UMovieRelation, M3UEpisodeRelation, M3UVODCategoryRelation +) +from .serializers import ( + MovieSerializer, + EpisodeSerializer, + SeriesSerializer, + VODCategorySerializer, + VODLogoSerializer, + M3UMovieRelationSerializer, + M3USeriesRelationSerializer, + M3UEpisodeRelationSerializer +) +from .tasks import refresh_series_episodes, refresh_movie_advanced_data +from .catalog_settings import ( + get_vod_catalog_account_id, + get_user_allowed_movie_category_ids, + get_user_allowed_series_category_ids, +) +from django.utils import timezone +from datetime import timedelta + +logger = logging.getLogger(__name__) + +# Negative cache for remote VOD logo URLs that failed to fetch. +# Prevents repeated blocking requests to unreachable hosts. +_vod_logo_fetch_failures = {} +_VOD_LOGO_FAIL_TTL = 300 # seconds + + +class VODPagination(PageNumberPagination): + page_size = 20 # Default page size to match frontend default + page_size_query_param = "page_size" # Allow clients to specify page size + max_page_size = 100 # Prevent excessive page sizes for VOD content + + +class MovieFilter(django_filters.FilterSet): + name = django_filters.CharFilter(lookup_expr="icontains") + m3u_account = django_filters.NumberFilter(field_name="m3u_relations__m3u_account__id") + category = django_filters.CharFilter(method='filter_category') + year = django_filters.NumberFilter() + year_gte = django_filters.NumberFilter(field_name="year", lookup_expr="gte") + year_lte = django_filters.NumberFilter(field_name="year", lookup_expr="lte") + + class Meta: + model = Movie + fields = ['name', 'm3u_account', 'category', 'year'] + + def filter_category(self, queryset, name, value): + """Custom category filter that handles 'name|type' format""" + if not value: + return queryset + + # Handle the format 'category_name|category_type' + if '|' in value: + category_name, category_type = value.rsplit('|', 1) + return queryset.filter( + m3u_relations__category__name=category_name, + m3u_relations__category__category_type=category_type + ) + else: + # Fallback: treat as category name only + return queryset.filter(m3u_relations__category__name=value) + + +class MovieViewSet(viewsets.ReadOnlyModelViewSet): + """ViewSet for Movie content""" + queryset = Movie.objects.all() + serializer_class = MovieSerializer + pagination_class = VODPagination + + filter_backends = [DjangoFilterBackend, SearchFilter, OrderingFilter] + filterset_class = MovieFilter + search_fields = ['name', 'description', 'genre'] + ordering_fields = ['name', 'year', 'created_at'] + ordering = ['name'] + + def get_permissions(self): + try: + return [perm() for perm in permission_classes_by_action[self.action]] + except KeyError: + return [Authenticated()] + + def get_queryset(self): + # Only return movies that have active M3U relations + qs = Movie.objects.filter( + m3u_relations__m3u_account__is_active=True + ).distinct().select_related('logo').prefetch_related('m3u_relations__m3u_account') + cid = get_vod_catalog_account_id() + if cid: + qs = qs.filter(m3u_relations__m3u_account_id=cid) + allowed_cats = get_user_allowed_movie_category_ids(self.request.user) + if allowed_cats: + qs = qs.filter(m3u_relations__category_id__in=allowed_cats).distinct() + return qs + + @action(detail=True, methods=['get'], url_path='providers') + def get_providers(self, request, pk=None): + """Get all providers (M3U accounts) that have this movie""" + movie = self.get_object() + relations = M3UMovieRelation.objects.filter( + movie=movie, + m3u_account__is_active=True + ).select_related('m3u_account', 'category') + + serializer = M3UMovieRelationSerializer(relations, many=True) + return Response(serializer.data) + + + @action(detail=True, methods=['get'], url_path='provider-info') + def provider_info(self, request, pk=None): + """Get detailed movie information from the original provider, throttled to 24h.""" + movie = self.get_object() + + # Get the highest priority active relation + relation = M3UMovieRelation.objects.filter( + movie=movie, + m3u_account__is_active=True + ).select_related('m3u_account').order_by('-m3u_account__priority', 'id').first() + + if not relation: + return Response( + {'error': 'No active M3U account associated with this movie'}, + status=status.HTTP_400_BAD_REQUEST + ) + + force_refresh = request.query_params.get('force_refresh', 'false').lower() == 'true' + now = timezone.now() + needs_refresh = ( + force_refresh or + not relation.last_advanced_refresh or + (now - relation.last_advanced_refresh).total_seconds() > 86400 + ) + + if needs_refresh: + # Trigger advanced data refresh + logger.debug(f"Refreshing advanced data for movie {movie.id} (relation ID: {relation.id})") + refresh_movie_advanced_data(relation.id, force_refresh=force_refresh) + + # Refresh objects from database after task completion + movie.refresh_from_db() + relation.refresh_from_db() + + # Use refreshed data from database + custom_props = relation.custom_properties or {} + info = custom_props.get('detailed_info', {}) + movie_data = custom_props.get('movie_data', {}) + + # Build response with available data + response_data = { + 'id': movie.id, + 'uuid': movie.uuid, + 'stream_id': relation.stream_id, + 'name': info.get('name', movie.name), + 'o_name': info.get('o_name', ''), + 'description': info.get('description', info.get('plot', movie.description)), + 'plot': info.get('plot', info.get('description', movie.description)), + 'year': movie.year or info.get('year'), + 'release_date': (movie.custom_properties or {}).get('release_date') or info.get('release_date') or info.get('releasedate', ''), + 'genre': movie.genre or info.get('genre', ''), + 'director': (movie.custom_properties or {}).get('director') or info.get('director', ''), + 'actors': (movie.custom_properties or {}).get('actors') or info.get('actors', ''), + 'country': (movie.custom_properties or {}).get('country') or info.get('country', ''), + 'rating': movie.rating or info.get('rating', movie.rating or 0), + 'tmdb_id': movie.tmdb_id or info.get('tmdb_id', ''), + 'imdb_id': movie.imdb_id or info.get('imdb_id', ''), + 'youtube_trailer': (movie.custom_properties or {}).get('youtube_trailer') or info.get('youtube_trailer') or info.get('trailer', ''), + 'duration_secs': movie.duration_secs or info.get('duration_secs'), + 'age': info.get('age', ''), + 'backdrop_path': (movie.custom_properties or {}).get('backdrop_path') or info.get('backdrop_path', []), + 'cover': info.get('cover_big', ''), + 'cover_big': info.get('cover_big', ''), + 'movie_image': movie.logo.url if movie.logo else info.get('movie_image', ''), + 'bitrate': info.get('bitrate', 0), + 'video': info.get('video', {}), + 'audio': info.get('audio', {}), + 'container_extension': movie_data.get('container_extension', 'mp4'), + 'direct_source': movie_data.get('direct_source', ''), + 'category_id': movie_data.get('category_id', ''), + 'added': movie_data.get('added', ''), + 'm3u_account': { + 'id': relation.m3u_account.id, + 'name': relation.m3u_account.name, + 'account_type': relation.m3u_account.account_type + } + } + return Response(response_data) + +class EpisodeFilter(django_filters.FilterSet): + name = django_filters.CharFilter(lookup_expr="icontains") + series = django_filters.NumberFilter(field_name="series__id") + m3u_account = django_filters.NumberFilter(field_name="m3u_relations__m3u_account__id") + season_number = django_filters.NumberFilter() + episode_number = django_filters.NumberFilter() + + class Meta: + model = Episode + fields = ['name', 'series', 'm3u_account', 'season_number', 'episode_number'] + + +class SeriesFilter(django_filters.FilterSet): + name = django_filters.CharFilter(lookup_expr="icontains") + m3u_account = django_filters.NumberFilter(field_name="m3u_relations__m3u_account__id") + category = django_filters.CharFilter(method='filter_category') + year = django_filters.NumberFilter() + year_gte = django_filters.NumberFilter(field_name="year", lookup_expr="gte") + year_lte = django_filters.NumberFilter(field_name="year", lookup_expr="lte") + + class Meta: + model = Series + fields = ['name', 'm3u_account', 'category', 'year'] + + def filter_category(self, queryset, name, value): + """Custom category filter that handles 'name|type' format""" + if not value: + return queryset + + # Handle the format 'category_name|category_type' + if '|' in value: + category_name, category_type = value.rsplit('|', 1) + return queryset.filter( + m3u_relations__category__name=category_name, + m3u_relations__category__category_type=category_type + ) + else: + # Fallback: treat as category name only + return queryset.filter(m3u_relations__category__name=value) + + +class EpisodeViewSet(viewsets.ReadOnlyModelViewSet): + """ViewSet for Episode content""" + queryset = Episode.objects.all() + serializer_class = EpisodeSerializer + pagination_class = VODPagination + + filter_backends = [DjangoFilterBackend, SearchFilter, OrderingFilter] + filterset_class = EpisodeFilter + search_fields = ['name', 'description'] + ordering_fields = ['name', 'season_number', 'episode_number', 'created_at'] + ordering = ['series__name', 'season_number', 'episode_number'] + + def get_permissions(self): + try: + return [perm() for perm in permission_classes_by_action[self.action]] + except KeyError: + return [Authenticated()] + + def get_queryset(self): + qs = Episode.objects.select_related('series').filter( + m3u_relations__m3u_account__is_active=True + ).distinct() + cid = get_vod_catalog_account_id() + if cid: + qs = qs.filter(m3u_relations__m3u_account_id=cid) + allowed_cats = get_user_allowed_series_category_ids(self.request.user) + if allowed_cats: + qs = qs.filter(series__m3u_relations__category_id__in=allowed_cats).distinct() + return qs + + +class SeriesViewSet(viewsets.ReadOnlyModelViewSet): + """ViewSet for Series management""" + queryset = Series.objects.all() + serializer_class = SeriesSerializer + pagination_class = VODPagination + + filter_backends = [DjangoFilterBackend, SearchFilter, OrderingFilter] + filterset_class = SeriesFilter + search_fields = ['name', 'description', 'genre'] + ordering_fields = ['name', 'year', 'created_at'] + ordering = ['name'] + + def get_permissions(self): + try: + return [perm() for perm in permission_classes_by_action[self.action]] + except KeyError: + return [Authenticated()] + + def get_queryset(self): + # Only return series that have active M3U relations + qs = Series.objects.filter( + m3u_relations__m3u_account__is_active=True + ).distinct().select_related('logo').prefetch_related('episodes', 'm3u_relations__m3u_account') + cid = get_vod_catalog_account_id() + if cid: + qs = qs.filter(m3u_relations__m3u_account_id=cid) + allowed_cats = get_user_allowed_series_category_ids(self.request.user) + if allowed_cats: + qs = qs.filter(m3u_relations__category_id__in=allowed_cats).distinct() + return qs + + @action(detail=True, methods=['get'], url_path='providers') + def get_providers(self, request, pk=None): + """Get all providers (M3U accounts) that have this series""" + series = self.get_object() + relations = M3USeriesRelation.objects.filter( + series=series, + m3u_account__is_active=True + ).select_related('m3u_account', 'category') + + serializer = M3USeriesRelationSerializer(relations, many=True) + return Response(serializer.data) + + @action(detail=True, methods=['get'], url_path='episodes') + def get_episodes(self, request, pk=None): + """Get episodes for this series with provider information""" + series = self.get_object() + episodes = Episode.objects.filter(series=series).prefetch_related( + 'm3u_relations__m3u_account' + ).order_by('season_number', 'episode_number') + + episodes_data = [] + for episode in episodes: + episode_serializer = EpisodeSerializer(episode) + episode_data = episode_serializer.data + + # Add provider information + relations = M3UEpisodeRelation.objects.filter( + episode=episode, + m3u_account__is_active=True + ).select_related('m3u_account') + + episode_data['providers'] = M3UEpisodeRelationSerializer(relations, many=True).data + episodes_data.append(episode_data) + + return Response(episodes_data) + + @action(detail=True, methods=['get'], url_path='provider-info') + def series_info(self, request, pk=None): + """Get detailed series information, refreshing from provider if needed""" + logger.debug(f"SeriesViewSet.series_info called for series ID: {pk}") + series = self.get_object() + logger.debug(f"Retrieved series: {series.name} (ID: {series.id})") + + # Get the highest priority active relation + relation = M3USeriesRelation.objects.filter( + series=series, + m3u_account__is_active=True + ).select_related('m3u_account').order_by('-m3u_account__priority', 'id').first() + + if not relation: + return Response( + {'error': 'No active M3U account associated with this series'}, + status=status.HTTP_400_BAD_REQUEST + ) + + try: + # Check if we should refresh data (optional force refresh parameter) + force_refresh = request.query_params.get('force_refresh', 'false').lower() == 'true' + refresh_interval_hours = int(request.query_params.get("refresh_interval", 24)) # Default to 24 hours + + now = timezone.now() + last_refreshed = relation.last_episode_refresh + + # Check if detailed data has been fetched + custom_props = relation.custom_properties or {} + episodes_fetched = custom_props.get('episodes_fetched', False) + detailed_fetched = custom_props.get('detailed_fetched', False) + + # Force refresh if episodes have never been fetched or if forced + if not episodes_fetched or not detailed_fetched or force_refresh: + force_refresh = True + logger.debug(f"Series {series.id} needs detailed/episode refresh, forcing refresh") + elif last_refreshed is None or (now - last_refreshed) > timedelta(hours=refresh_interval_hours): + force_refresh = True + logger.debug(f"Series {series.id} refresh interval exceeded or never refreshed, forcing refresh") + + if force_refresh: + logger.debug(f"Refreshing series {series.id} data from provider") + # Use existing refresh logic with external_series_id + from .tasks import refresh_series_episodes + account = relation.m3u_account + if account and account.is_active: + refresh_series_episodes(account, series, relation.external_series_id) + series.refresh_from_db() # Reload from database after refresh + relation.refresh_from_db() # Reload relation too + + # Return the database data (which should now be fresh) + custom_props = relation.custom_properties or {} + response_data = { + 'id': series.id, + 'series_id': relation.external_series_id, + 'name': series.name, + 'description': series.description, + 'year': series.year, + 'genre': series.genre, + 'rating': series.rating, + 'tmdb_id': series.tmdb_id, + 'imdb_id': series.imdb_id, + 'category_id': relation.category.id if relation.category else None, + 'category_name': relation.category.name if relation.category else None, + 'cover': { + 'id': series.logo.id, + 'url': series.logo.url, + 'name': series.logo.name, + } if series.logo else None, + 'last_refreshed': series.updated_at, + 'custom_properties': series.custom_properties, + 'm3u_account': { + 'id': relation.m3u_account.id, + 'name': relation.m3u_account.name, + 'account_type': relation.m3u_account.account_type + }, + 'episodes_fetched': custom_props.get('episodes_fetched', False), + 'detailed_fetched': custom_props.get('detailed_fetched', False) + } + + # Always include episodes for series info if they've been fetched + include_episodes = request.query_params.get('include_episodes', 'true').lower() == 'true' + if include_episodes and custom_props.get('episodes_fetched', False): + logger.debug(f"Including episodes for series {series.id}") + episodes_by_season = {} + for episode in series.episodes.all().order_by('season_number', 'episode_number'): + season_key = str(episode.season_number or 0) + if season_key not in episodes_by_season: + episodes_by_season[season_key] = [] + + # Get episode relation for additional data + episode_relation = M3UEpisodeRelation.objects.filter( + episode=episode, + m3u_account=relation.m3u_account + ).first() + + episode_data = { + 'id': episode.id, + 'uuid': episode.uuid, + 'name': episode.name, + 'title': episode.name, + 'episode_number': episode.episode_number, + 'season_number': episode.season_number, + 'description': episode.description, + 'air_date': episode.air_date, + 'plot': episode.description, + 'duration_secs': episode.duration_secs, + 'rating': episode.rating, + 'tmdb_id': episode.tmdb_id, + 'imdb_id': episode.imdb_id, + 'movie_image': episode.custom_properties.get('movie_image', '') if episode.custom_properties else '', + 'container_extension': episode_relation.container_extension if episode_relation else 'mp4', + 'type': 'episode', + 'series': { + 'id': series.id, + 'name': series.name + } + } + episodes_by_season[season_key].append(episode_data) + + response_data['episodes'] = episodes_by_season + logger.debug(f"Added {len(episodes_by_season)} seasons of episodes to response") + elif include_episodes: + # Episodes not yet fetched, include empty episodes list + response_data['episodes'] = {} + + logger.debug(f"Returning series info response for series {series.id}") + return Response(response_data) + + except Exception as e: + logger.error(f"Error fetching series info for series {pk}: {str(e)}") + return Response( + {'error': f'Failed to fetch series information: {str(e)}'}, + status=status.HTTP_500_INTERNAL_SERVER_ERROR + ) + + +class VODCategoryFilter(django_filters.FilterSet): + name = django_filters.CharFilter(lookup_expr="icontains") + category_type = django_filters.ChoiceFilter(choices=VODCategory.CATEGORY_TYPE_CHOICES) + m3u_account = django_filters.NumberFilter(field_name="m3u_relations__m3u_account__id") + + class Meta: + model = VODCategory + fields = ['name', 'category_type', 'm3u_account'] + + +class VODCategoryViewSet(viewsets.ReadOnlyModelViewSet): + """ViewSet for VOD Categories""" + queryset = VODCategory.objects.all() + serializer_class = VODCategorySerializer + + filter_backends = [DjangoFilterBackend, SearchFilter, OrderingFilter] + filterset_class = VODCategoryFilter + search_fields = ['name'] + ordering = ['name'] + + def get_permissions(self): + try: + return [perm() for perm in permission_classes_by_action[self.action]] + except KeyError: + return [Authenticated()] + + def list(self, request, *args, **kwargs): + """Override list to ensure Uncategorized categories and relations exist for all XC accounts with VOD enabled""" + from apps.m3u.models import M3UAccount + + # Ensure Uncategorized categories exist + movie_category, _ = VODCategory.objects.get_or_create( + name="Uncategorized", + category_type="movie", + defaults={} + ) + + series_category, _ = VODCategory.objects.get_or_create( + name="Uncategorized", + category_type="series", + defaults={} + ) + + # Get all active XC accounts with VOD enabled + xc_accounts = M3UAccount.objects.filter( + account_type=M3UAccount.Types.XC, + is_active=True + ) + + for account in xc_accounts: + if account.custom_properties: + custom_props = account.custom_properties or {} + vod_enabled = custom_props.get("enable_vod", False) + + if vod_enabled: + # Ensure relations exist for this account + auto_enable_new = custom_props.get("auto_enable_new_groups_vod", True) + + M3UVODCategoryRelation.objects.get_or_create( + category=movie_category, + m3u_account=account, + defaults={ + 'enabled': auto_enable_new, + 'custom_properties': {} + } + ) + + M3UVODCategoryRelation.objects.get_or_create( + category=series_category, + m3u_account=account, + defaults={ + 'enabled': auto_enable_new, + 'custom_properties': {} + } + ) + + # Now proceed with normal list operation + return super().list(request, *args, **kwargs) + + +class UnifiedContentViewSet(viewsets.ReadOnlyModelViewSet): + """ViewSet that combines Movies and Series for unified 'All' view""" + queryset = Movie.objects.none() # Empty queryset, we override list method + serializer_class = MovieSerializer # Default serializer, overridden in list + pagination_class = VODPagination + + filter_backends = [DjangoFilterBackend, SearchFilter, OrderingFilter] + search_fields = ['name', 'description', 'genre'] + ordering_fields = ['name', 'year', 'created_at'] + ordering = ['name'] + + def get_permissions(self): + try: + return [perm() for perm in permission_classes_by_action[self.action]] + except KeyError: + return [Authenticated()] + + def list(self, request, *args, **kwargs): + """Override list to handle unified content properly - database-level approach""" + import logging + from django.db import connection + + logger = logging.getLogger(__name__) + logger.error("=== UnifiedContentViewSet.list() called ===") + + try: + # Get pagination parameters + page_size = int(request.query_params.get('page_size', 24)) + page_number = int(request.query_params.get('page', 1)) + + logger.error(f"Page {page_number}, page_size {page_size}") + + # Calculate offset for unified pagination + offset = (page_number - 1) * page_size + + # For high page numbers, use raw SQL for efficiency + # This avoids loading and sorting massive amounts of data in Python + + search = request.query_params.get('search', '') + category = request.query_params.get('category', '') + + # Build WHERE clauses (optional single catalog account) + catalog_account_id = get_vod_catalog_account_id() + if catalog_account_id: + where_conditions = [ + "movies.id IN (SELECT DISTINCT movie_id FROM vod_m3umovierelation mmr JOIN m3u_m3uaccount ma ON mmr.m3u_account_id = ma.id WHERE ma.is_active = true AND mmr.m3u_account_id = %s)", + "series.id IN (SELECT DISTINCT series_id FROM vod_m3useriesrelation msr JOIN m3u_m3uaccount ma ON msr.m3u_account_id = ma.id WHERE ma.is_active = true AND msr.m3u_account_id = %s)", + ] + movie_params = [catalog_account_id] + series_params = [catalog_account_id] + else: + where_conditions = [ + "movies.id IN (SELECT DISTINCT movie_id FROM vod_m3umovierelation mmr JOIN m3u_m3uaccount ma ON mmr.m3u_account_id = ma.id WHERE ma.is_active = true)", + "series.id IN (SELECT DISTINCT series_id FROM vod_m3useriesrelation msr JOIN m3u_m3uaccount ma ON msr.m3u_account_id = ma.id WHERE ma.is_active = true)", + ] + movie_params = [] + series_params = [] + + allowed_movie_cats = get_user_allowed_movie_category_ids(request.user) + allowed_series_cats = get_user_allowed_series_category_ids(request.user) + if allowed_movie_cats: + placeholders = ",".join(["%s"] * len(allowed_movie_cats)) + where_conditions[0] += f" AND movies.id IN (SELECT DISTINCT movie_id FROM vod_m3umovierelation WHERE category_id IN ({placeholders}))" + movie_params.extend(allowed_movie_cats) + if allowed_series_cats: + placeholders = ",".join(["%s"] * len(allowed_series_cats)) + where_conditions[1] += f" AND series.id IN (SELECT DISTINCT series_id FROM vod_m3useriesrelation WHERE category_id IN ({placeholders}))" + series_params.extend(allowed_series_cats) + + if search: + where_conditions[0] += " AND LOWER(movies.name) LIKE %s" + where_conditions[1] += " AND LOWER(series.name) LIKE %s" + search_param = f"%{search.lower()}%" + movie_params.append(search_param) + series_params.append(search_param) + + if category: + if '|' in category: + cat_name, cat_type = category.rsplit('|', 1) + if cat_type == 'movie': + where_conditions[0] += " AND movies.id IN (SELECT movie_id FROM vod_m3umovierelation mmr JOIN vod_vodcategory c ON mmr.category_id = c.id WHERE c.name = %s)" + where_conditions[1] = "1=0" # Exclude series + movie_params.append(cat_name) + series_params = [] # no params needed for "1=0" + elif cat_type == 'series': + where_conditions[1] += " AND series.id IN (SELECT series_id FROM vod_m3useriesrelation msr JOIN vod_vodcategory c ON msr.category_id = c.id WHERE c.name = %s)" + where_conditions[0] = "1=0" # Exclude movies + series_params.append(cat_name) + movie_params = [] # no params needed for "1=0" + else: + where_conditions[0] += " AND movies.id IN (SELECT movie_id FROM vod_m3umovierelation mmr JOIN vod_vodcategory c ON mmr.category_id = c.id WHERE c.name = %s)" + where_conditions[1] += " AND series.id IN (SELECT series_id FROM vod_m3useriesrelation msr JOIN vod_vodcategory c ON msr.category_id = c.id WHERE c.name = %s)" + movie_params.append(category) + series_params.append(category) + + params = movie_params + series_params + + # Use UNION ALL with ORDER BY and LIMIT/OFFSET for true unified pagination + # This is much more efficient than Python sorting + sql = f""" + WITH unified_content AS ( + SELECT + movies.id, + movies.uuid, + movies.name, + movies.description, + movies.year, + movies.rating, + movies.genre, + movies.duration_secs as duration, + movies.created_at, + movies.updated_at, + movies.custom_properties, + movies.logo_id, + logo.name as logo_name, + logo.url as logo_url, + 'movie' as content_type + FROM vod_movie movies + LEFT JOIN vod_vodlogo logo ON movies.logo_id = logo.id + WHERE {where_conditions[0]} + + UNION ALL + + SELECT + series.id, + series.uuid, + series.name, + series.description, + series.year, + series.rating, + series.genre, + NULL as duration, + series.created_at, + series.updated_at, + series.custom_properties, + series.logo_id, + logo.name as logo_name, + logo.url as logo_url, + 'series' as content_type + FROM vod_series series + LEFT JOIN vod_vodlogo logo ON series.logo_id = logo.id + WHERE {where_conditions[1]} + ) + SELECT * FROM unified_content + ORDER BY LOWER(name), id + LIMIT %s OFFSET %s + """ + + params.extend([page_size, offset]) + + logger.error(f"Executing SQL with LIMIT {page_size} OFFSET {offset}") + + with connection.cursor() as cursor: + cursor.execute(sql, params) + columns = [col[0] for col in cursor.description] + results = [] + + for row in cursor.fetchall(): + item_dict = dict(zip(columns, row)) + + # Build logo object in the format expected by frontend + logo_data = None + if item_dict['logo_id']: + logo_data = { + 'id': item_dict['logo_id'], + 'name': item_dict['logo_name'], + 'url': item_dict['logo_url'], + 'cache_url': f"/api/vod/vodlogos/{item_dict['logo_id']}/cache/", + 'movie_count': 0, # We don't calculate this in raw SQL + 'series_count': 0, # We don't calculate this in raw SQL + 'is_used': True + } + + # Convert to the format expected by frontend + formatted_item = { + 'id': item_dict['id'], + 'uuid': str(item_dict['uuid']), + 'name': item_dict['name'], + 'description': item_dict['description'] or '', + 'year': item_dict['year'], + 'rating': float(item_dict['rating']) if item_dict['rating'] else 0.0, + 'genre': item_dict['genre'] or '', + 'duration': item_dict['duration'], + 'created_at': item_dict['created_at'].isoformat() if item_dict['created_at'] else None, + 'updated_at': item_dict['updated_at'].isoformat() if item_dict['updated_at'] else None, + 'custom_properties': item_dict['custom_properties'] or {}, + 'logo': logo_data, + 'content_type': item_dict['content_type'] + } + results.append(formatted_item) + + logger.error(f"Retrieved {len(results)} results via SQL") + + # Get total count estimate (for pagination info) + # Use a separate efficient count query + count_sql = f""" + SELECT COUNT(*) FROM ( + SELECT 1 FROM vod_movie movies WHERE {where_conditions[0]} + UNION ALL + SELECT 1 FROM vod_series series WHERE {where_conditions[1]} + ) as total_count + """ + + count_params = params[:-2] # Remove LIMIT and OFFSET params + + with connection.cursor() as cursor: + cursor.execute(count_sql, count_params) + total_count = cursor.fetchone()[0] + + response_data = { + 'count': total_count, + 'next': offset + page_size < total_count, + 'previous': page_number > 1, + 'results': results + } + + return Response(response_data) + + except Exception as e: + logger.error(f"Error in UnifiedContentViewSet.list(): {e}") + import traceback + logger.error(traceback.format_exc()) + return Response({'error': str(e)}, status=500) + + +class VODLogoPagination(PageNumberPagination): + page_size = 100 + page_size_query_param = "page_size" + max_page_size = 1000 + + +class VODLogoViewSet(viewsets.ModelViewSet): + """ViewSet for VOD Logo management""" + queryset = VODLogo.objects.all() + serializer_class = VODLogoSerializer + pagination_class = VODLogoPagination + filter_backends = [SearchFilter, OrderingFilter] + search_fields = ['name', 'url'] + ordering_fields = ['name', 'id'] + ordering = ['name'] + + def get_permissions(self): + try: + return [perm() for perm in permission_classes_by_action[self.action]] + except KeyError: + if self.action == 'cache': + return [AllowAny()] + return [Authenticated()] + + def get_queryset(self): + """Optimize queryset with prefetch and add filtering""" + queryset = VODLogo.objects.prefetch_related('movie', 'series').order_by('name') + + # Filter by specific IDs + ids = self.request.query_params.getlist('ids') + if ids: + try: + id_list = [int(id_str) for id_str in ids if id_str.isdigit()] + if id_list: + queryset = queryset.filter(id__in=id_list) + except (ValueError, TypeError): + queryset = VODLogo.objects.none() + + # Filter by usage + used_filter = self.request.query_params.get('used', None) + if used_filter == 'true': + # Return logos that are used by movies OR series + queryset = queryset.filter( + Q(movie__isnull=False) | Q(series__isnull=False) + ).distinct() + elif used_filter == 'false': + # Return logos that are NOT used by either + queryset = queryset.filter( + movie__isnull=True, + series__isnull=True + ) + elif used_filter == 'movies': + # Return logos that are used by movies (may also be used by series) + queryset = queryset.filter(movie__isnull=False).distinct() + elif used_filter == 'series': + # Return logos that are used by series (may also be used by movies) + queryset = queryset.filter(series__isnull=False).distinct() + + + # Filter by name + name_query = self.request.query_params.get('name', None) + if name_query: + queryset = queryset.filter(name__icontains=name_query) + + # No pagination mode + if self.request.query_params.get('no_pagination', 'false').lower() == 'true': + self.pagination_class = None + + return queryset + + @action(detail=True, methods=["get"], permission_classes=[AllowAny]) + def cache(self, request, pk=None): + """Streams the VOD logo file, whether it's local or remote.""" + logo = self.get_object() + + if not logo.url: + return HttpResponse(status=404) + + # Check if this is a local file path + if logo.url.startswith('/data/'): + # It's a local file + file_path = logo.url + if not os.path.exists(file_path): + logger.error(f"VOD logo file not found: {file_path}") + return HttpResponse(status=404) + + try: + return FileResponse(open(file_path, 'rb'), content_type='image/png') + except Exception as e: + logger.error(f"Error serving VOD logo file {file_path}: {str(e)}") + return HttpResponse(status=500) + else: + # It's a remote URL - proxy it + # Skip URLs that recently failed to avoid blocking workers + fail_expiry = _vod_logo_fetch_failures.get(logo.url) + if fail_expiry and time.monotonic() < fail_expiry: + return HttpResponse(status=404) + + try: + _LOGO_TOTAL_TIMEOUT = 10 # seconds + _LOGO_MAX_BYTES = 5 * 1024 * 1024 # 5 MB + + remote_response = requests.get( + logo.url, + stream=True, + timeout=(3, 5), # (connect_timeout, read_timeout per chunk) + ) + + if remote_response.status_code != 200: + now = time.monotonic() + _vod_logo_fetch_failures[logo.url] = now + _VOD_LOGO_FAIL_TTL + return HttpResponse(status=404) + + # Eagerly read the full image with a total time + size cap + # so the greenlet is released quickly. + chunks = [] + total = 0 + deadline = time.monotonic() + _LOGO_TOTAL_TIMEOUT + for chunk in remote_response.iter_content(chunk_size=8192): + total += len(chunk) + if total > _LOGO_MAX_BYTES: + remote_response.close() + return HttpResponse(status=404) + if time.monotonic() > deadline: + remote_response.close() + now = time.monotonic() + _vod_logo_fetch_failures[logo.url] = now + _VOD_LOGO_FAIL_TTL + return HttpResponse(status=404) + chunks.append(chunk) + body = b"".join(chunks) + + # Full read succeeded, clear any previous failure entry + _vod_logo_fetch_failures.pop(logo.url, None) + + content_type = remote_response.headers.get('Content-Type', 'image/png') + + response = HttpResponse(body, content_type=content_type) + response["Content-Length"] = str(len(body)) + if remote_response.headers.get("Cache-Control"): + response["Cache-Control"] = remote_response.headers.get("Cache-Control") + if remote_response.headers.get("Last-Modified"): + response["Last-Modified"] = remote_response.headers.get("Last-Modified") + response["Content-Disposition"] = 'inline; filename="{}"'.format( + os.path.basename(logo.url) + ) + return response + except requests.exceptions.RequestException as e: + now = time.monotonic() + _vod_logo_fetch_failures[logo.url] = now + _VOD_LOGO_FAIL_TTL + logger.error(f"Error fetching remote VOD logo {logo.url}: {str(e)}") + return HttpResponse(status=404) + + @action(detail=False, methods=["delete"], url_path="bulk-delete") + def bulk_delete(self, request): + """Delete multiple VOD logos at once""" + logo_ids = request.data.get('logo_ids', []) + + if not logo_ids: + return Response( + {"error": "No logo IDs provided"}, + status=status.HTTP_400_BAD_REQUEST + ) + + try: + # Get logos to delete + logos = VODLogo.objects.filter(id__in=logo_ids) + deleted_count = logos.count() + + # Delete them + logos.delete() + + return Response({ + "deleted_count": deleted_count, + "message": f"Successfully deleted {deleted_count} VOD logo(s)" + }) + except Exception as e: + logger.error(f"Error during bulk VOD logo deletion: {str(e)}") + return Response( + {"error": str(e)}, + status=status.HTTP_500_INTERNAL_SERVER_ERROR + ) + + @action(detail=False, methods=["post"]) + def cleanup(self, request): + """Delete all VOD logos that are not used by any movies or series""" + try: + # Find unused logos + unused_logos = VODLogo.objects.filter( + movie__isnull=True, + series__isnull=True + ) + + deleted_count = unused_logos.count() + logo_names = list(unused_logos.values_list('name', flat=True)) + + # Delete them + unused_logos.delete() + + logger.info(f"Cleaned up {deleted_count} unused VOD logos: {logo_names}") + + return Response({ + "deleted_count": deleted_count, + "deleted_logos": logo_names, + "message": f"Successfully deleted {deleted_count} unused VOD logo(s)" + }) + except Exception as e: + logger.error(f"Error during VOD logo cleanup: {str(e)}") + return Response( + {"error": str(e)}, + status=status.HTTP_500_INTERNAL_SERVER_ERROR + ) diff --git a/apps/vod/apps.py b/apps/vod/apps.py new file mode 100644 index 0000000..0e2af56 --- /dev/null +++ b/apps/vod/apps.py @@ -0,0 +1,12 @@ +from django.apps import AppConfig + + +class VODConfig(AppConfig): + default_auto_field = 'django.db.models.BigAutoField' + name = 'apps.vod' + verbose_name = 'Video on Demand' + + def ready(self): + """Initialize VOD app when Django is ready""" + # Import models to ensure they're registered + from . import models diff --git a/apps/vod/catalog_settings.py b/apps/vod/catalog_settings.py new file mode 100644 index 0000000..9691d0e --- /dev/null +++ b/apps/vod/catalog_settings.py @@ -0,0 +1,149 @@ +"""VOD catalog: optional single M3U account for library listings (env DISPATCHARR_VOD_CATALOG_ACCOUNT_ID). + +Plus per-user playback / catalog policies stored in User.custom_properties: + + custom_properties = { + "vod_playback_mode": "strict" | "prefer" | "auto", + "vod_playback_account_ids": [10, 11], + "allowed_movie_category_ids": [3, 7, 12], # empty/missing = all + "allowed_series_category_ids": [4, 9], # empty/missing = all + } +""" + +import os + + +def get_vod_catalog_account_id(): + raw = (os.environ.get("DISPATCHARR_VOD_CATALOG_ACCOUNT_ID") or "").strip() + if raw.isdigit(): + return int(raw) + return None + + +def get_vod_playback_allowed_account_ids(): + """ + Optional comma-separated M3U account IDs used only for VOD stream selection (failover). + Example: DISPATCHARR_VOD_PLAYBACK_ACCOUNT_IDS=10,11,12 + When unset, any active M3U account with a relation may be used (default). + """ + raw = (os.environ.get("DISPATCHARR_VOD_PLAYBACK_ACCOUNT_IDS") or "").strip() + if not raw: + return None + out = [] + for part in raw.split(","): + part = part.strip() + if part.isdigit(): + out.append(int(part)) + return out if out else None + + +def filter_m3u_relation_queryset_for_playback(qs): + """ + Apply DISPATCHARR_VOD_PLAYBACK_ACCOUNT_IDS allowlist to an M3U relation queryset. + Used by VOD proxy and Xtream stream redirects so failover matches playback policy. + """ + allowed = get_vod_playback_allowed_account_ids() + if allowed is not None: + return qs.filter(m3u_account_id__in=allowed, m3u_account__is_active=True) + return qs.filter(m3u_account__is_active=True) + + +def _coerce_int_list(raw): + out = [] + if not raw: + return out + if isinstance(raw, (list, tuple)): + items = raw + elif isinstance(raw, str): + items = raw.split(",") + else: + return out + for x in items: + try: + out.append(int(str(x).strip())) + except (TypeError, ValueError): + continue + return out + + +def get_user_vod_prefs(user): + """Return a normalized dict of per-user VOD policies. Safe with None / anonymous user.""" + if not user or not getattr(user, "is_authenticated", False): + return { + "playback_mode": "auto", + "playback_account_ids": [], + "allowed_movie_category_ids": None, + "allowed_series_category_ids": None, + } + cp = getattr(user, "custom_properties", None) or {} + accounts = _coerce_int_list(cp.get("vod_playback_account_ids")) + mode = (cp.get("vod_playback_mode") or "").strip().lower() + if mode not in ("strict", "prefer", "auto"): + mode = "auto" + movie_cats = _coerce_int_list(cp.get("allowed_movie_category_ids")) + series_cats = _coerce_int_list(cp.get("allowed_series_category_ids")) + return { + "playback_mode": mode, + "playback_account_ids": accounts, + "allowed_movie_category_ids": movie_cats or None, + "allowed_series_category_ids": series_cats or None, + } + + +def filter_relation_qs_for_user_playback(qs, user): + """Restrict a relation queryset based on the user's playback prefs. + + Returns (queryset, prefer_account_id). + - mode=strict: only the user's accounts (queryset further restricted). + - mode=prefer: queryset unchanged, returns the first account as preference for tie-breaking. + - mode=auto (or no accounts): no per-user filter; the global allowlist already applies upstream. + """ + prefs = get_user_vod_prefs(user) + accounts = prefs.get("playback_account_ids") or [] + mode = prefs.get("playback_mode") or "auto" + if accounts and mode == "strict": + qs = qs.filter(m3u_account_id__in=accounts) + prefer = accounts[0] + elif accounts and mode == "prefer": + prefer = accounts[0] + else: + prefer = None + return qs, prefer + + +def get_user_allowed_movie_category_ids(user): + return get_user_vod_prefs(user).get("allowed_movie_category_ids") + + +def get_user_allowed_series_category_ids(user): + return get_user_vod_prefs(user).get("allowed_series_category_ids") + + +def filter_movie_qs_for_user(qs, user): + """Restrict a Movie queryset to only categories the user is allowed to see.""" + cats = get_user_allowed_movie_category_ids(user) + if cats: + return qs.filter(m3u_relations__category_id__in=cats).distinct() + return qs + + +def filter_series_qs_for_user(qs, user): + """Restrict a Series queryset to only categories the user is allowed to see.""" + cats = get_user_allowed_series_category_ids(user) + if cats: + return qs.filter(m3u_relations__category_id__in=cats).distinct() + return qs + + +def filter_m3u_movie_relation_qs_for_user(qs, user): + cats = get_user_allowed_movie_category_ids(user) + if cats: + return qs.filter(category_id__in=cats) + return qs + + +def filter_m3u_series_relation_qs_for_user(qs, user): + cats = get_user_allowed_series_category_ids(user) + if cats: + return qs.filter(category_id__in=cats) + return qs diff --git a/apps/vod/migrations/0001_initial.py b/apps/vod/migrations/0001_initial.py new file mode 100644 index 0000000..02c6ae2 --- /dev/null +++ b/apps/vod/migrations/0001_initial.py @@ -0,0 +1,201 @@ +# Generated by Django 5.2.4 on 2025-08-28 18:16 + +import django.db.models.deletion +import uuid +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ('dispatcharr_channels', '0024_alter_channelgroupm3uaccount_channel_group'), + ('m3u', '0016_m3uaccount_priority'), + ] + + operations = [ + migrations.CreateModel( + name='Movie', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('uuid', models.UUIDField(default=uuid.uuid4, editable=False, unique=True)), + ('name', models.CharField(max_length=255)), + ('description', models.TextField(blank=True, null=True)), + ('year', models.IntegerField(blank=True, null=True)), + ('rating', models.CharField(blank=True, max_length=10, null=True)), + ('genre', models.CharField(blank=True, max_length=255, null=True)), + ('duration_secs', models.IntegerField(blank=True, help_text='Duration in seconds', null=True)), + ('tmdb_id', models.CharField(blank=True, help_text='TMDB ID for metadata', max_length=50, null=True, unique=True)), + ('imdb_id', models.CharField(blank=True, help_text='IMDB ID for metadata', max_length=50, null=True, unique=True)), + ('custom_properties', models.JSONField(blank=True, help_text='Additional metadata and properties for the movie', null=True)), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('logo', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='movie', to='dispatcharr_channels.logo')), + ], + options={ + 'verbose_name': 'Movie', + 'verbose_name_plural': 'Movies', + 'ordering': ['name'], + }, + ), + migrations.CreateModel( + name='Series', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('uuid', models.UUIDField(default=uuid.uuid4, editable=False, unique=True)), + ('name', models.CharField(max_length=255)), + ('description', models.TextField(blank=True, null=True)), + ('year', models.IntegerField(blank=True, null=True)), + ('rating', models.CharField(blank=True, max_length=10, null=True)), + ('genre', models.CharField(blank=True, max_length=255, null=True)), + ('tmdb_id', models.CharField(blank=True, help_text='TMDB ID for metadata', max_length=50, null=True, unique=True)), + ('imdb_id', models.CharField(blank=True, help_text='IMDB ID for metadata', max_length=50, null=True, unique=True)), + ('custom_properties', models.JSONField(blank=True, help_text='Additional metadata and properties for the series', null=True)), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('logo', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='series', to='dispatcharr_channels.logo')), + ], + options={ + 'verbose_name': 'Series', + 'verbose_name_plural': 'Series', + 'ordering': ['name'], + }, + ), + migrations.CreateModel( + name='Episode', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('uuid', models.UUIDField(default=uuid.uuid4, editable=False, unique=True)), + ('name', models.CharField(max_length=255)), + ('description', models.TextField(blank=True, null=True)), + ('air_date', models.DateField(blank=True, null=True)), + ('rating', models.CharField(blank=True, max_length=10, null=True)), + ('duration_secs', models.IntegerField(blank=True, help_text='Duration in seconds', null=True)), + ('season_number', models.IntegerField(blank=True, null=True)), + ('episode_number', models.IntegerField(blank=True, null=True)), + ('tmdb_id', models.CharField(blank=True, db_index=True, help_text='TMDB ID for metadata', max_length=50, null=True)), + ('imdb_id', models.CharField(blank=True, db_index=True, help_text='IMDB ID for metadata', max_length=50, null=True)), + ('custom_properties', models.JSONField(blank=True, help_text='Custom properties for this episode', null=True)), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('series', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='episodes', to='vod.series')), + ], + options={ + 'verbose_name': 'Episode', + 'verbose_name_plural': 'Episodes', + 'ordering': ['series__name', 'season_number', 'episode_number'], + }, + ), + migrations.CreateModel( + name='VODCategory', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('name', models.CharField(max_length=255)), + ('category_type', models.CharField(choices=[('movie', 'Movie'), ('series', 'Series')], default='movie', help_text='Type of content this category contains', max_length=10)), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ], + options={ + 'verbose_name': 'VOD Category', + 'verbose_name_plural': 'VOD Categories', + 'ordering': ['name'], + 'unique_together': {('name', 'category_type')}, + }, + ), + migrations.CreateModel( + name='M3UVODCategoryRelation', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('enabled', models.BooleanField(default=False, help_text='Set to false to deactivate this category for the M3U account')), + ('custom_properties', models.JSONField(blank=True, help_text='Provider-specific data like quality, language, etc.', null=True)), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('m3u_account', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='category_relations', to='m3u.m3uaccount')), + ('category', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='m3u_relations', to='vod.vodcategory')), + ], + options={ + 'verbose_name': 'M3U VOD Category Relation', + 'verbose_name_plural': 'M3U VOD Category Relations', + }, + ), + migrations.CreateModel( + name='M3USeriesRelation', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('external_series_id', models.CharField(help_text='External series ID from M3U provider', max_length=255)), + ('custom_properties', models.JSONField(blank=True, help_text='Provider-specific data', null=True)), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('last_episode_refresh', models.DateTimeField(blank=True, help_text='Last time episodes were refreshed', null=True)), + ('m3u_account', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='series_relations', to='m3u.m3uaccount')), + ('series', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='m3u_relations', to='vod.series')), + ('category', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='vod.vodcategory')), + ], + options={ + 'verbose_name': 'M3U Series Relation', + 'verbose_name_plural': 'M3U Series Relations', + }, + ), + migrations.CreateModel( + name='M3UMovieRelation', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('stream_id', models.CharField(help_text='External stream ID from M3U provider', max_length=255)), + ('container_extension', models.CharField(blank=True, max_length=10, null=True)), + ('custom_properties', models.JSONField(blank=True, help_text='Provider-specific data like quality, language, etc.', null=True)), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('last_advanced_refresh', models.DateTimeField(blank=True, help_text='Last time advanced data was fetched from provider', null=True)), + ('m3u_account', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='movie_relations', to='m3u.m3uaccount')), + ('movie', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='m3u_relations', to='vod.movie')), + ('category', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='vod.vodcategory')), + ], + options={ + 'verbose_name': 'M3U Movie Relation', + 'verbose_name_plural': 'M3U Movie Relations', + }, + ), + migrations.CreateModel( + name='M3UEpisodeRelation', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('stream_id', models.CharField(help_text='External stream ID from M3U provider', max_length=255)), + ('container_extension', models.CharField(blank=True, max_length=10, null=True)), + ('custom_properties', models.JSONField(blank=True, help_text='Provider-specific data like quality, language, etc.', null=True)), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('episode', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='m3u_relations', to='vod.episode')), + ('m3u_account', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='episode_relations', to='m3u.m3uaccount')), + ], + options={ + 'verbose_name': 'M3U Episode Relation', + 'verbose_name_plural': 'M3U Episode Relations', + 'unique_together': {('m3u_account', 'stream_id')}, + }, + ), + migrations.AddConstraint( + model_name='movie', + constraint=models.UniqueConstraint(condition=models.Q(('tmdb_id__isnull', True), ('imdb_id__isnull', True)), fields=('name', 'year'), name='unique_movie_name_year_no_external_id'), + ), + migrations.AddConstraint( + model_name='series', + constraint=models.UniqueConstraint(condition=models.Q(('tmdb_id__isnull', True), ('imdb_id__isnull', True)), fields=('name', 'year'), name='unique_series_name_year_no_external_id'), + ), + migrations.AlterUniqueTogether( + name='episode', + unique_together={('series', 'season_number', 'episode_number')}, + ), + migrations.AlterUniqueTogether( + name='m3uvodcategoryrelation', + unique_together={('m3u_account', 'category')}, + ), + migrations.AlterUniqueTogether( + name='m3useriesrelation', + unique_together={('m3u_account', 'external_series_id')}, + ), + migrations.AlterUniqueTogether( + name='m3umovierelation', + unique_together={('m3u_account', 'stream_id')}, + ), + ] diff --git a/apps/vod/migrations/0002_add_last_seen_with_default.py b/apps/vod/migrations/0002_add_last_seen_with_default.py new file mode 100644 index 0000000..6ece988 --- /dev/null +++ b/apps/vod/migrations/0002_add_last_seen_with_default.py @@ -0,0 +1,29 @@ +# Generated by Django 5.2.4 on 2025-09-04 21:12 + +import django.utils.timezone +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('vod', '0001_initial'), + ] + + operations = [ + migrations.AddField( + model_name='m3uepisoderelation', + name='last_seen', + field=models.DateTimeField(default=django.utils.timezone.now, help_text='Last time this relation was seen during VOD scan'), + ), + migrations.AddField( + model_name='m3umovierelation', + name='last_seen', + field=models.DateTimeField(default=django.utils.timezone.now, help_text='Last time this relation was seen during VOD scan'), + ), + migrations.AddField( + model_name='m3useriesrelation', + name='last_seen', + field=models.DateTimeField(default=django.utils.timezone.now, help_text='Last time this relation was seen during VOD scan'), + ), + ] diff --git a/apps/vod/migrations/0003_vodlogo_alter_movie_logo_alter_series_logo.py b/apps/vod/migrations/0003_vodlogo_alter_movie_logo_alter_series_logo.py new file mode 100644 index 0000000..1bd2c41 --- /dev/null +++ b/apps/vod/migrations/0003_vodlogo_alter_movie_logo_alter_series_logo.py @@ -0,0 +1,264 @@ +# Generated by Django 5.2.4 on 2025-11-06 23:01 + +import django.db.models.deletion +from django.db import migrations, models + + +def migrate_vod_logos_forward(apps, schema_editor): + """ + Migrate VOD logos from the Logo table to the new VODLogo table. + This copies all logos referenced by movies or series to VODLogo. + Uses pure SQL for maximum performance. + """ + from django.db import connection + + print("\n" + "="*80) + print("Starting VOD logo migration...") + print("="*80) + + with connection.cursor() as cursor: + # Step 1: Copy unique logos from Logo table to VODLogo table + # Only copy logos that are used by movies or series + print("Copying logos to VODLogo table...") + cursor.execute(""" + INSERT INTO vod_vodlogo (name, url) + SELECT DISTINCT l.name, l.url + FROM dispatcharr_channels_logo l + WHERE l.id IN ( + SELECT DISTINCT logo_id FROM vod_movie WHERE logo_id IS NOT NULL + UNION + SELECT DISTINCT logo_id FROM vod_series WHERE logo_id IS NOT NULL + ) + ON CONFLICT (url) DO NOTHING + """) + print(f"Created VODLogo entries") + + # Step 2: Update movies to point to VODLogo IDs using JOIN + print("Updating movie references...") + cursor.execute(""" + UPDATE vod_movie m + SET logo_id = v.id + FROM dispatcharr_channels_logo l + INNER JOIN vod_vodlogo v ON l.url = v.url + WHERE m.logo_id = l.id + AND m.logo_id IS NOT NULL + """) + movie_count = cursor.rowcount + print(f"Updated {movie_count} movies with new VOD logo references") + + # Step 3: Update series to point to VODLogo IDs using JOIN + print("Updating series references...") + cursor.execute(""" + UPDATE vod_series s + SET logo_id = v.id + FROM dispatcharr_channels_logo l + INNER JOIN vod_vodlogo v ON l.url = v.url + WHERE s.logo_id = l.id + AND s.logo_id IS NOT NULL + """) + series_count = cursor.rowcount + print(f"Updated {series_count} series with new VOD logo references") + + print("="*80) + print("VOD logo migration completed successfully!") + print(f"Summary: Updated {movie_count} movies and {series_count} series") + print("="*80 + "\n") + + +def migrate_vod_logos_backward(apps, schema_editor): + """ + Reverse migration - moves VODLogos back to Logo table. + This recreates Logo entries for all VODLogos and updates Movie/Series references. + """ + Logo = apps.get_model('dispatcharr_channels', 'Logo') + VODLogo = apps.get_model('vod', 'VODLogo') + Movie = apps.get_model('vod', 'Movie') + Series = apps.get_model('vod', 'Series') + + print("\n" + "="*80) + print("REVERSE: Moving VOD logos back to Logo table...") + print("="*80) + + # Get all VODLogos + vod_logos = VODLogo.objects.all() + print(f"Found {vod_logos.count()} VOD logos to reverse migrate") + + # Create Logo entries for each VODLogo + logos_to_create = [] + vod_to_logo_mapping = {} # VODLogo ID -> Logo ID + + for vod_logo in vod_logos: + # Check if a Logo with this URL already exists + existing_logo = Logo.objects.filter(url=vod_logo.url).first() + + if existing_logo: + # Logo already exists, just map to it + vod_to_logo_mapping[vod_logo.id] = existing_logo.id + print(f"Logo already exists for URL: {vod_logo.url[:50]}... (using existing)") + else: + # Create new Logo entry + new_logo = Logo(name=vod_logo.name, url=vod_logo.url) + logos_to_create.append(new_logo) + + # Bulk create new Logo entries + if logos_to_create: + print(f"Creating {len(logos_to_create)} new Logo entries...") + Logo.objects.bulk_create(logos_to_create, ignore_conflicts=True) + print("Logo entries created") + + # Get the created Logo instances with their IDs + for vod_logo in vod_logos: + if vod_logo.id not in vod_to_logo_mapping: + try: + logo = Logo.objects.get(url=vod_logo.url) + vod_to_logo_mapping[vod_logo.id] = logo.id + except Logo.DoesNotExist: + print(f"Warning: Could not find Logo for URL: {vod_logo.url[:100]}...") + + print(f"Created mapping for {len(vod_to_logo_mapping)} VOD logos -> Logos") + + # Update movies to point back to Logo table + movie_count = 0 + for movie in Movie.objects.exclude(logo__isnull=True): + if movie.logo_id in vod_to_logo_mapping: + movie.logo_id = vod_to_logo_mapping[movie.logo_id] + movie.save(update_fields=['logo_id']) + movie_count += 1 + print(f"Updated {movie_count} movies to use Logo table") + + # Update series to point back to Logo table + series_count = 0 + for series in Series.objects.exclude(logo__isnull=True): + if series.logo_id in vod_to_logo_mapping: + series.logo_id = vod_to_logo_mapping[series.logo_id] + series.save(update_fields=['logo_id']) + series_count += 1 + print(f"Updated {series_count} series to use Logo table") + + # Delete VODLogos (they're now redundant) + vod_logo_count = vod_logos.count() + vod_logos.delete() + print(f"Deleted {vod_logo_count} VOD logos") + + print("="*80) + print("Reverse migration completed!") + print(f"Summary: Created/reused {len(vod_to_logo_mapping)} logos, updated {movie_count} movies and {series_count} series") + print("="*80 + "\n") + + +def cleanup_migrated_logos(apps, schema_editor): + """ + Delete Logo entries that were successfully migrated to VODLogo. + + Uses efficient JOIN-based approach with LEFT JOIN to exclude channel usage. + """ + from django.db import connection + + print("\n" + "="*80) + print("Cleaning up migrated Logo entries...") + print("="*80) + + with connection.cursor() as cursor: + # Single efficient query using JOINs: + # - JOIN with vod_vodlogo to find migrated logos + # - LEFT JOIN with channels to find which aren't used + cursor.execute(""" + DELETE FROM dispatcharr_channels_logo + WHERE id IN ( + SELECT l.id + FROM dispatcharr_channels_logo l + INNER JOIN vod_vodlogo v ON l.url = v.url + LEFT JOIN dispatcharr_channels_channel c ON c.logo_id = l.id + WHERE c.id IS NULL + ) + """) + deleted_count = cursor.rowcount + + print(f"✓ Deleted {deleted_count} migrated Logo entries (not used by channels)") + print("="*80 + "\n") + + +class Migration(migrations.Migration): + + dependencies = [ + ('vod', '0002_add_last_seen_with_default'), + ('dispatcharr_channels', '0013_alter_logo_url'), # Ensure Logo table exists + ] + + operations = [ + # Step 1: Create the VODLogo model + migrations.CreateModel( + name='VODLogo', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('name', models.CharField(max_length=255)), + ('url', models.TextField(unique=True)), + ], + options={ + 'verbose_name': 'VOD Logo', + 'verbose_name_plural': 'VOD Logos', + }, + ), + + # Step 2: Remove foreign key constraints temporarily (so we can change the IDs) + # We need to find and drop the actual constraint names dynamically + migrations.RunSQL( + sql=[ + # Drop movie logo constraint (find it dynamically) + """ + DO $$ + DECLARE + constraint_name text; + BEGIN + SELECT conname INTO constraint_name + FROM pg_constraint + WHERE conrelid = 'vod_movie'::regclass + AND conname LIKE '%logo_id%fk%'; + + IF constraint_name IS NOT NULL THEN + EXECUTE 'ALTER TABLE vod_movie DROP CONSTRAINT ' || constraint_name; + END IF; + END $$; + """, + # Drop series logo constraint (find it dynamically) + """ + DO $$ + DECLARE + constraint_name text; + BEGIN + SELECT conname INTO constraint_name + FROM pg_constraint + WHERE conrelid = 'vod_series'::regclass + AND conname LIKE '%logo_id%fk%'; + + IF constraint_name IS NOT NULL THEN + EXECUTE 'ALTER TABLE vod_series DROP CONSTRAINT ' || constraint_name; + END IF; + END $$; + """, + ], + reverse_sql=[ + # The AlterField operations will recreate the constraints pointing to VODLogo, + # so we don't need to manually recreate them in reverse + migrations.RunSQL.noop, + ], + ), + + # Step 3: Migrate the data (this copies logos and updates references) + migrations.RunPython(migrate_vod_logos_forward, migrate_vod_logos_backward), + + # Step 4: Now we can safely alter the foreign keys to point to VODLogo + migrations.AlterField( + model_name='movie', + name='logo', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='movie', to='vod.vodlogo'), + ), + migrations.AlterField( + model_name='series', + name='logo', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='series', to='vod.vodlogo'), + ), + + # Step 5: Clean up migrated Logo entries + migrations.RunPython(cleanup_migrated_logos, migrations.RunPython.noop), + ] diff --git a/apps/vod/migrations/0004_m3uepisoderelation_series_relation.py b/apps/vod/migrations/0004_m3uepisoderelation_series_relation.py new file mode 100644 index 0000000..e497362 --- /dev/null +++ b/apps/vod/migrations/0004_m3uepisoderelation_series_relation.py @@ -0,0 +1,19 @@ +# Generated by Django 5.2.11 on 2026-02-24 23:53 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('vod', '0003_vodlogo_alter_movie_logo_alter_series_logo'), + ] + + operations = [ + migrations.AddField( + model_name='m3uepisoderelation', + name='series_relation', + field=models.ForeignKey(blank=True, help_text='The series relation this episode relation belongs to. CASCADE ensures cleanup when the series relation is removed.', null=True, on_delete=django.db.models.deletion.CASCADE, related_name='episode_relations', to='vod.m3useriesrelation'), + ), + ] diff --git a/apps/vod/migrations/__init__.py b/apps/vod/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/vod/models.py b/apps/vod/models.py new file mode 100644 index 0000000..0256f92 --- /dev/null +++ b/apps/vod/models.py @@ -0,0 +1,361 @@ +from django.db import models +from django.db.models import Q +from django.utils import timezone +from django.contrib.contenttypes.fields import GenericForeignKey +from django.contrib.contenttypes.models import ContentType +from apps.m3u.models import M3UAccount +import os +import uuid +from urllib.parse import quote + + +class VODLogo(models.Model): + """Logo model specifically for VOD content (movies and series)""" + name = models.CharField(max_length=255) + url = models.TextField(unique=True) + + def __str__(self): + return self.name + + class Meta: + verbose_name = 'VOD Logo' + verbose_name_plural = 'VOD Logos' + + +class VODCategory(models.Model): + """Categories for organizing VODs (e.g., Action, Comedy, Drama)""" + + CATEGORY_TYPE_CHOICES = [ + ('movie', 'Movie'), + ('series', 'Series'), + ] + + name = models.CharField(max_length=255) + category_type = models.CharField( + max_length=10, + choices=CATEGORY_TYPE_CHOICES, + default='movie', + help_text="Type of content this category contains" + ) + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + + class Meta: + verbose_name = 'VOD Category' + verbose_name_plural = 'VOD Categories' + ordering = ['name'] + unique_together = [('name', 'category_type')] + + @classmethod + def bulk_create_and_fetch(cls, objects, ignore_conflicts=False): + # Perform the bulk create operation + cls.objects.bulk_create(objects, ignore_conflicts=ignore_conflicts) + + # Use the unique fields to fetch the created objects + # Since we have unique_together on ('name', 'category_type'), we need both fields + filter_conditions = [] + for obj in objects: + filter_conditions.append( + Q(name=obj.name, category_type=obj.category_type) + ) + + if filter_conditions: + # Combine all conditions with OR + combined_condition = filter_conditions[0] + for condition in filter_conditions[1:]: + combined_condition |= condition + + created_objects = cls.objects.filter(combined_condition) + else: + created_objects = cls.objects.none() + + return created_objects + + def __str__(self): + return f"{self.name} ({self.get_category_type_display()})" + + +class Series(models.Model): + """Series information for TV shows""" + uuid = models.UUIDField(default=uuid.uuid4, editable=False, unique=True) + name = models.CharField(max_length=255) + description = models.TextField(blank=True, null=True) + year = models.IntegerField(blank=True, null=True) + rating = models.CharField(max_length=10, blank=True, null=True) + genre = models.CharField(max_length=255, blank=True, null=True) + logo = models.ForeignKey(VODLogo, on_delete=models.SET_NULL, null=True, blank=True, related_name='series') + + # Metadata IDs for deduplication - these should be globally unique when present + tmdb_id = models.CharField(max_length=50, blank=True, null=True, unique=True, help_text="TMDB ID for metadata") + imdb_id = models.CharField(max_length=50, blank=True, null=True, unique=True, help_text="IMDB ID for metadata") + + # Additional metadata and properties + custom_properties = models.JSONField(blank=True, null=True, help_text='Additional metadata and properties for the series') + + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + + class Meta: + verbose_name = 'Series' + verbose_name_plural = 'Series' + ordering = ['name'] + # Only enforce name+year uniqueness when no external IDs are present + constraints = [ + models.UniqueConstraint( + fields=['name', 'year'], + condition=models.Q(tmdb_id__isnull=True) & models.Q(imdb_id__isnull=True), + name='unique_series_name_year_no_external_id' + ), + ] + + def __str__(self): + year_str = f" ({self.year})" if self.year else "" + return f"{self.name}{year_str}" + + +class Movie(models.Model): + """Movie content""" + uuid = models.UUIDField(default=uuid.uuid4, editable=False, unique=True) + name = models.CharField(max_length=255) + description = models.TextField(blank=True, null=True) + year = models.IntegerField(blank=True, null=True) + rating = models.CharField(max_length=10, blank=True, null=True) + genre = models.CharField(max_length=255, blank=True, null=True) + duration_secs = models.IntegerField(blank=True, null=True, help_text="Duration in seconds") + logo = models.ForeignKey(VODLogo, on_delete=models.SET_NULL, null=True, blank=True, related_name='movie') + + # Metadata IDs for deduplication - these should be globally unique when present + tmdb_id = models.CharField(max_length=50, blank=True, null=True, unique=True, help_text="TMDB ID for metadata") + imdb_id = models.CharField(max_length=50, blank=True, null=True, unique=True, help_text="IMDB ID for metadata") + + # Additional metadata and properties + custom_properties = models.JSONField(blank=True, null=True, help_text='Additional metadata and properties for the movie') + + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + + class Meta: + verbose_name = 'Movie' + verbose_name_plural = 'Movies' + ordering = ['name'] + # Only enforce name+year uniqueness when no external IDs are present + constraints = [ + models.UniqueConstraint( + fields=['name', 'year'], + condition=models.Q(tmdb_id__isnull=True) & models.Q(imdb_id__isnull=True), + name='unique_movie_name_year_no_external_id' + ), + ] + + def __str__(self): + year_str = f" ({self.year})" if self.year else "" + return f"{self.name}{year_str}" + + +class Episode(models.Model): + """Episode content for TV series""" + uuid = models.UUIDField(default=uuid.uuid4, editable=False, unique=True) + name = models.CharField(max_length=255) + description = models.TextField(blank=True, null=True) + air_date = models.DateField(blank=True, null=True) + rating = models.CharField(max_length=10, blank=True, null=True) + duration_secs = models.IntegerField(blank=True, null=True, help_text="Duration in seconds") + + # Episode specific fields + series = models.ForeignKey(Series, on_delete=models.CASCADE, related_name='episodes') + season_number = models.IntegerField(blank=True, null=True) + episode_number = models.IntegerField(blank=True, null=True) + + # Metadata IDs + tmdb_id = models.CharField(max_length=50, blank=True, null=True, help_text="TMDB ID for metadata", db_index=True) + imdb_id = models.CharField(max_length=50, blank=True, null=True, help_text="IMDB ID for metadata", db_index=True) + + # Custom properties for episode + custom_properties = models.JSONField(blank=True, null=True, help_text="Custom properties for this episode") + + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + + class Meta: + verbose_name = 'Episode' + verbose_name_plural = 'Episodes' + ordering = ['series__name', 'season_number', 'episode_number'] + unique_together = [ + ('series', 'season_number', 'episode_number'), + ] + + def __str__(self): + season_ep = f"S{self.season_number or 0:02d}E{self.episode_number or 0:02d}" + return f"{self.series.name} - {season_ep} - {self.name}" + + +# New relation models to link M3U accounts with VOD content + +class M3USeriesRelation(models.Model): + """Links M3U accounts to Series with provider-specific information""" + m3u_account = models.ForeignKey(M3UAccount, on_delete=models.CASCADE, related_name='series_relations') + series = models.ForeignKey(Series, on_delete=models.CASCADE, related_name='m3u_relations') + category = models.ForeignKey(VODCategory, on_delete=models.SET_NULL, null=True, blank=True) + + # Provider-specific fields - renamed to avoid clash with series ForeignKey + external_series_id = models.CharField(max_length=255, help_text="External series ID from M3U provider") + custom_properties = models.JSONField(blank=True, null=True, help_text="Provider-specific data") + + # Timestamps + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + last_episode_refresh = models.DateTimeField(blank=True, null=True, help_text="Last time episodes were refreshed") + last_seen = models.DateTimeField(default=timezone.now, help_text="Last time this relation was seen during VOD scan") + + class Meta: + verbose_name = 'M3U Series Relation' + verbose_name_plural = 'M3U Series Relations' + unique_together = [('m3u_account', 'external_series_id')] + + def __str__(self): + return f"{self.m3u_account.name} - {self.series.name}" + + + + +def _jellyfin_direct_stream_url(m3u_account, stream_id: str, container_extension: str | None) -> str | None: + """URL reproducción directa Jellyfin (Static=true) para cuentas vod_backend=jellyfin.""" + cp = m3u_account.custom_properties or {} + if cp.get("vod_backend") != "jellyfin": + return None + base = (m3u_account.server_url or "").rstrip("/") + if not base: + return None + api_key = ( + os.environ.get("DISPATCHARR_JELLYFIN_API_KEY") + or cp.get("jellyfin_api_key") + or os.environ.get("JELLYFIN_API_KEY") + or os.environ.get("JELLYFIN_SYNC_API_KEY") + ) + if not api_key: + return None + raw = (container_extension or "mkv").strip().lstrip(".").lower() + ext = (raw[:10] if raw else "mkv") or "mkv" + item_id = stream_id.strip() + if not item_id: + return None + key_q = quote(str(api_key), safe="") + return f"{base}/Videos/{item_id}/stream.{ext}?Static=true&api_key={key_q}" + +class M3UMovieRelation(models.Model): + """Links M3U accounts to Movies with provider-specific information""" + m3u_account = models.ForeignKey(M3UAccount, on_delete=models.CASCADE, related_name='movie_relations') + movie = models.ForeignKey(Movie, on_delete=models.CASCADE, related_name='m3u_relations') + category = models.ForeignKey(VODCategory, on_delete=models.SET_NULL, null=True, blank=True) + + # Streaming information (provider-specific) + stream_id = models.CharField(max_length=255, help_text="External stream ID from M3U provider") + container_extension = models.CharField(max_length=10, blank=True, null=True) + + # Provider-specific data + custom_properties = models.JSONField(blank=True, null=True, help_text="Provider-specific data like quality, language, etc.") + + # Timestamps + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + last_advanced_refresh = models.DateTimeField(blank=True, null=True, help_text="Last time advanced data was fetched from provider") + last_seen = models.DateTimeField(default=timezone.now, help_text="Last time this relation was seen during VOD scan") + + class Meta: + verbose_name = 'M3U Movie Relation' + verbose_name_plural = 'M3U Movie Relations' + unique_together = [('m3u_account', 'stream_id')] + + def __str__(self): + return f"{self.m3u_account.name} - {self.movie.name}" + + def get_stream_url(self): + """Get the full stream URL for this movie from this provider""" + # Build URL dynamically for XtreamCodes accounts + if self.m3u_account.account_type == 'XC': + from core.xtream_codes import Client as XCClient + # Use XC client's URL normalization to handle malformed URLs + # (e.g., URLs with /player_api.php or query parameters) + normalized_url = XCClient(self.m3u_account.server_url, '', '')._normalize_url(self.m3u_account.server_url) + username = self.m3u_account.username + password = self.m3u_account.password + return f"{normalized_url}/movie/{username}/{password}/{self.stream_id}.{self.container_extension or 'mp4'}" + jf = _jellyfin_direct_stream_url(self.m3u_account, self.stream_id, self.container_extension) + if jf: + return jf + # For other account types, we would need another way to build URLs + return None + + +class M3UEpisodeRelation(models.Model): + """Links M3U accounts to Episodes with provider-specific information""" + m3u_account = models.ForeignKey(M3UAccount, on_delete=models.CASCADE, related_name='episode_relations') + episode = models.ForeignKey(Episode, on_delete=models.CASCADE, related_name='m3u_relations') + series_relation = models.ForeignKey( + 'M3USeriesRelation', + on_delete=models.CASCADE, + related_name='episode_relations', + null=True, + blank=True, + help_text="The series relation this episode relation belongs to. CASCADE ensures cleanup when the series relation is removed." + ) + + # Streaming information (provider-specific) + stream_id = models.CharField(max_length=255, help_text="External stream ID from M3U provider") + container_extension = models.CharField(max_length=10, blank=True, null=True) + + # Provider-specific data + custom_properties = models.JSONField(blank=True, null=True, help_text="Provider-specific data like quality, language, etc.") + + # Timestamps + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + last_seen = models.DateTimeField(default=timezone.now, help_text="Last time this relation was seen during VOD scan") + + class Meta: + verbose_name = 'M3U Episode Relation' + verbose_name_plural = 'M3U Episode Relations' + unique_together = [('m3u_account', 'stream_id')] + + def __str__(self): + return f"{self.m3u_account.name} - {self.episode}" + + def get_stream_url(self): + """Get the full stream URL for this episode from this provider""" + from core.xtream_codes import Client as XtreamCodesClient + + if self.m3u_account.account_type == 'XC': + # For XtreamCodes accounts, build the URL dynamically + # Use XC client's URL normalization to handle malformed URLs + # (e.g., URLs with /player_api.php or query parameters) + normalized_url = XtreamCodesClient(self.m3u_account.server_url, '', '')._normalize_url(self.m3u_account.server_url) + username = self.m3u_account.username + password = self.m3u_account.password + return f"{normalized_url}/series/{username}/{password}/{self.stream_id}.{self.container_extension or 'mp4'}" + jf = _jellyfin_direct_stream_url(self.m3u_account, self.stream_id, self.container_extension) + if jf: + return jf + return None + +class M3UVODCategoryRelation(models.Model): + """Links M3U accounts to categories with provider-specific information""" + m3u_account = models.ForeignKey(M3UAccount, on_delete=models.CASCADE, related_name='category_relations') + category = models.ForeignKey(VODCategory, on_delete=models.CASCADE, related_name='m3u_relations') + + enabled = models.BooleanField( + default=False, help_text="Set to false to deactivate this category for the M3U account" + ) + + custom_properties = models.JSONField(blank=True, null=True, help_text="Provider-specific data like quality, language, etc.") + + # Timestamps + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + + class Meta: + verbose_name = 'M3U VOD Category Relation' + verbose_name_plural = 'M3U VOD Category Relations' + unique_together = [('m3u_account', 'category')] + + def __str__(self): + return f"{self.m3u_account.name} - {self.category.name}" diff --git a/apps/vod/models.py.bak.jellyfin-stream.1777823495 b/apps/vod/models.py.bak.jellyfin-stream.1777823495 new file mode 100644 index 0000000..dd7d175 --- /dev/null +++ b/apps/vod/models.py.bak.jellyfin-stream.1777823495 @@ -0,0 +1,331 @@ +from django.db import models +from django.db.models import Q +from django.utils import timezone +from django.contrib.contenttypes.fields import GenericForeignKey +from django.contrib.contenttypes.models import ContentType +from apps.m3u.models import M3UAccount +import uuid + + +class VODLogo(models.Model): + """Logo model specifically for VOD content (movies and series)""" + name = models.CharField(max_length=255) + url = models.TextField(unique=True) + + def __str__(self): + return self.name + + class Meta: + verbose_name = 'VOD Logo' + verbose_name_plural = 'VOD Logos' + + +class VODCategory(models.Model): + """Categories for organizing VODs (e.g., Action, Comedy, Drama)""" + + CATEGORY_TYPE_CHOICES = [ + ('movie', 'Movie'), + ('series', 'Series'), + ] + + name = models.CharField(max_length=255) + category_type = models.CharField( + max_length=10, + choices=CATEGORY_TYPE_CHOICES, + default='movie', + help_text="Type of content this category contains" + ) + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + + class Meta: + verbose_name = 'VOD Category' + verbose_name_plural = 'VOD Categories' + ordering = ['name'] + unique_together = [('name', 'category_type')] + + @classmethod + def bulk_create_and_fetch(cls, objects, ignore_conflicts=False): + # Perform the bulk create operation + cls.objects.bulk_create(objects, ignore_conflicts=ignore_conflicts) + + # Use the unique fields to fetch the created objects + # Since we have unique_together on ('name', 'category_type'), we need both fields + filter_conditions = [] + for obj in objects: + filter_conditions.append( + Q(name=obj.name, category_type=obj.category_type) + ) + + if filter_conditions: + # Combine all conditions with OR + combined_condition = filter_conditions[0] + for condition in filter_conditions[1:]: + combined_condition |= condition + + created_objects = cls.objects.filter(combined_condition) + else: + created_objects = cls.objects.none() + + return created_objects + + def __str__(self): + return f"{self.name} ({self.get_category_type_display()})" + + +class Series(models.Model): + """Series information for TV shows""" + uuid = models.UUIDField(default=uuid.uuid4, editable=False, unique=True) + name = models.CharField(max_length=255) + description = models.TextField(blank=True, null=True) + year = models.IntegerField(blank=True, null=True) + rating = models.CharField(max_length=10, blank=True, null=True) + genre = models.CharField(max_length=255, blank=True, null=True) + logo = models.ForeignKey(VODLogo, on_delete=models.SET_NULL, null=True, blank=True, related_name='series') + + # Metadata IDs for deduplication - these should be globally unique when present + tmdb_id = models.CharField(max_length=50, blank=True, null=True, unique=True, help_text="TMDB ID for metadata") + imdb_id = models.CharField(max_length=50, blank=True, null=True, unique=True, help_text="IMDB ID for metadata") + + # Additional metadata and properties + custom_properties = models.JSONField(blank=True, null=True, help_text='Additional metadata and properties for the series') + + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + + class Meta: + verbose_name = 'Series' + verbose_name_plural = 'Series' + ordering = ['name'] + # Only enforce name+year uniqueness when no external IDs are present + constraints = [ + models.UniqueConstraint( + fields=['name', 'year'], + condition=models.Q(tmdb_id__isnull=True) & models.Q(imdb_id__isnull=True), + name='unique_series_name_year_no_external_id' + ), + ] + + def __str__(self): + year_str = f" ({self.year})" if self.year else "" + return f"{self.name}{year_str}" + + +class Movie(models.Model): + """Movie content""" + uuid = models.UUIDField(default=uuid.uuid4, editable=False, unique=True) + name = models.CharField(max_length=255) + description = models.TextField(blank=True, null=True) + year = models.IntegerField(blank=True, null=True) + rating = models.CharField(max_length=10, blank=True, null=True) + genre = models.CharField(max_length=255, blank=True, null=True) + duration_secs = models.IntegerField(blank=True, null=True, help_text="Duration in seconds") + logo = models.ForeignKey(VODLogo, on_delete=models.SET_NULL, null=True, blank=True, related_name='movie') + + # Metadata IDs for deduplication - these should be globally unique when present + tmdb_id = models.CharField(max_length=50, blank=True, null=True, unique=True, help_text="TMDB ID for metadata") + imdb_id = models.CharField(max_length=50, blank=True, null=True, unique=True, help_text="IMDB ID for metadata") + + # Additional metadata and properties + custom_properties = models.JSONField(blank=True, null=True, help_text='Additional metadata and properties for the movie') + + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + + class Meta: + verbose_name = 'Movie' + verbose_name_plural = 'Movies' + ordering = ['name'] + # Only enforce name+year uniqueness when no external IDs are present + constraints = [ + models.UniqueConstraint( + fields=['name', 'year'], + condition=models.Q(tmdb_id__isnull=True) & models.Q(imdb_id__isnull=True), + name='unique_movie_name_year_no_external_id' + ), + ] + + def __str__(self): + year_str = f" ({self.year})" if self.year else "" + return f"{self.name}{year_str}" + + +class Episode(models.Model): + """Episode content for TV series""" + uuid = models.UUIDField(default=uuid.uuid4, editable=False, unique=True) + name = models.CharField(max_length=255) + description = models.TextField(blank=True, null=True) + air_date = models.DateField(blank=True, null=True) + rating = models.CharField(max_length=10, blank=True, null=True) + duration_secs = models.IntegerField(blank=True, null=True, help_text="Duration in seconds") + + # Episode specific fields + series = models.ForeignKey(Series, on_delete=models.CASCADE, related_name='episodes') + season_number = models.IntegerField(blank=True, null=True) + episode_number = models.IntegerField(blank=True, null=True) + + # Metadata IDs + tmdb_id = models.CharField(max_length=50, blank=True, null=True, help_text="TMDB ID for metadata", db_index=True) + imdb_id = models.CharField(max_length=50, blank=True, null=True, help_text="IMDB ID for metadata", db_index=True) + + # Custom properties for episode + custom_properties = models.JSONField(blank=True, null=True, help_text="Custom properties for this episode") + + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + + class Meta: + verbose_name = 'Episode' + verbose_name_plural = 'Episodes' + ordering = ['series__name', 'season_number', 'episode_number'] + unique_together = [ + ('series', 'season_number', 'episode_number'), + ] + + def __str__(self): + season_ep = f"S{self.season_number or 0:02d}E{self.episode_number or 0:02d}" + return f"{self.series.name} - {season_ep} - {self.name}" + + +# New relation models to link M3U accounts with VOD content + +class M3USeriesRelation(models.Model): + """Links M3U accounts to Series with provider-specific information""" + m3u_account = models.ForeignKey(M3UAccount, on_delete=models.CASCADE, related_name='series_relations') + series = models.ForeignKey(Series, on_delete=models.CASCADE, related_name='m3u_relations') + category = models.ForeignKey(VODCategory, on_delete=models.SET_NULL, null=True, blank=True) + + # Provider-specific fields - renamed to avoid clash with series ForeignKey + external_series_id = models.CharField(max_length=255, help_text="External series ID from M3U provider") + custom_properties = models.JSONField(blank=True, null=True, help_text="Provider-specific data") + + # Timestamps + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + last_episode_refresh = models.DateTimeField(blank=True, null=True, help_text="Last time episodes were refreshed") + last_seen = models.DateTimeField(default=timezone.now, help_text="Last time this relation was seen during VOD scan") + + class Meta: + verbose_name = 'M3U Series Relation' + verbose_name_plural = 'M3U Series Relations' + unique_together = [('m3u_account', 'external_series_id')] + + def __str__(self): + return f"{self.m3u_account.name} - {self.series.name}" + + +class M3UMovieRelation(models.Model): + """Links M3U accounts to Movies with provider-specific information""" + m3u_account = models.ForeignKey(M3UAccount, on_delete=models.CASCADE, related_name='movie_relations') + movie = models.ForeignKey(Movie, on_delete=models.CASCADE, related_name='m3u_relations') + category = models.ForeignKey(VODCategory, on_delete=models.SET_NULL, null=True, blank=True) + + # Streaming information (provider-specific) + stream_id = models.CharField(max_length=255, help_text="External stream ID from M3U provider") + container_extension = models.CharField(max_length=10, blank=True, null=True) + + # Provider-specific data + custom_properties = models.JSONField(blank=True, null=True, help_text="Provider-specific data like quality, language, etc.") + + # Timestamps + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + last_advanced_refresh = models.DateTimeField(blank=True, null=True, help_text="Last time advanced data was fetched from provider") + last_seen = models.DateTimeField(default=timezone.now, help_text="Last time this relation was seen during VOD scan") + + class Meta: + verbose_name = 'M3U Movie Relation' + verbose_name_plural = 'M3U Movie Relations' + unique_together = [('m3u_account', 'stream_id')] + + def __str__(self): + return f"{self.m3u_account.name} - {self.movie.name}" + + def get_stream_url(self): + """Get the full stream URL for this movie from this provider""" + # Build URL dynamically for XtreamCodes accounts + if self.m3u_account.account_type == 'XC': + from core.xtream_codes import Client as XCClient + # Use XC client's URL normalization to handle malformed URLs + # (e.g., URLs with /player_api.php or query parameters) + normalized_url = XCClient(self.m3u_account.server_url, '', '')._normalize_url(self.m3u_account.server_url) + username = self.m3u_account.username + password = self.m3u_account.password + return f"{normalized_url}/movie/{username}/{password}/{self.stream_id}.{self.container_extension or 'mp4'}" + else: + # For other account types, we would need another way to build URLs + return None + + +class M3UEpisodeRelation(models.Model): + """Links M3U accounts to Episodes with provider-specific information""" + m3u_account = models.ForeignKey(M3UAccount, on_delete=models.CASCADE, related_name='episode_relations') + episode = models.ForeignKey(Episode, on_delete=models.CASCADE, related_name='m3u_relations') + series_relation = models.ForeignKey( + 'M3USeriesRelation', + on_delete=models.CASCADE, + related_name='episode_relations', + null=True, + blank=True, + help_text="The series relation this episode relation belongs to. CASCADE ensures cleanup when the series relation is removed." + ) + + # Streaming information (provider-specific) + stream_id = models.CharField(max_length=255, help_text="External stream ID from M3U provider") + container_extension = models.CharField(max_length=10, blank=True, null=True) + + # Provider-specific data + custom_properties = models.JSONField(blank=True, null=True, help_text="Provider-specific data like quality, language, etc.") + + # Timestamps + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + last_seen = models.DateTimeField(default=timezone.now, help_text="Last time this relation was seen during VOD scan") + + class Meta: + verbose_name = 'M3U Episode Relation' + verbose_name_plural = 'M3U Episode Relations' + unique_together = [('m3u_account', 'stream_id')] + + def __str__(self): + return f"{self.m3u_account.name} - {self.episode}" + + def get_stream_url(self): + """Get the full stream URL for this episode from this provider""" + from core.xtream_codes import Client as XtreamCodesClient + + if self.m3u_account.account_type == 'XC': + # For XtreamCodes accounts, build the URL dynamically + # Use XC client's URL normalization to handle malformed URLs + # (e.g., URLs with /player_api.php or query parameters) + normalized_url = XtreamCodesClient(self.m3u_account.server_url, '', '')._normalize_url(self.m3u_account.server_url) + username = self.m3u_account.username + password = self.m3u_account.password + return f"{normalized_url}/series/{username}/{password}/{self.stream_id}.{self.container_extension or 'mp4'}" + else: + # We might support non XC accounts in the future + # For now, return None + return None + +class M3UVODCategoryRelation(models.Model): + """Links M3U accounts to categories with provider-specific information""" + m3u_account = models.ForeignKey(M3UAccount, on_delete=models.CASCADE, related_name='category_relations') + category = models.ForeignKey(VODCategory, on_delete=models.CASCADE, related_name='m3u_relations') + + enabled = models.BooleanField( + default=False, help_text="Set to false to deactivate this category for the M3U account" + ) + + custom_properties = models.JSONField(blank=True, null=True, help_text="Provider-specific data like quality, language, etc.") + + # Timestamps + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + + class Meta: + verbose_name = 'M3U VOD Category Relation' + verbose_name_plural = 'M3U VOD Category Relations' + unique_together = [('m3u_account', 'category')] + + def __str__(self): + return f"{self.m3u_account.name} - {self.category.name}" diff --git a/apps/vod/serializers.py b/apps/vod/serializers.py new file mode 100644 index 0000000..7747cb8 --- /dev/null +++ b/apps/vod/serializers.py @@ -0,0 +1,304 @@ +from rest_framework import serializers +from django.urls import reverse +from .models import ( + Series, VODCategory, Movie, Episode, VODLogo, + M3USeriesRelation, M3UMovieRelation, M3UEpisodeRelation, M3UVODCategoryRelation +) +from apps.m3u.serializers import M3UAccountSerializer + + +class VODLogoSerializer(serializers.ModelSerializer): + cache_url = serializers.SerializerMethodField() + movie_count = serializers.SerializerMethodField() + series_count = serializers.SerializerMethodField() + is_used = serializers.SerializerMethodField() + item_names = serializers.SerializerMethodField() + + class Meta: + model = VODLogo + fields = ["id", "name", "url", "cache_url", "movie_count", "series_count", "is_used", "item_names"] + + def validate_url(self, value): + """Validate that the URL is unique for creation or update""" + if self.instance and self.instance.url == value: + return value + + if VODLogo.objects.filter(url=value).exists(): + raise serializers.ValidationError("A VOD logo with this URL already exists.") + + return value + + def create(self, validated_data): + """Handle logo creation with proper URL validation""" + return VODLogo.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): + request = self.context.get("request") + if request: + return request.build_absolute_uri( + reverse("api:vod:vodlogo-cache", args=[obj.id]) + ) + return reverse("api:vod:vodlogo-cache", args=[obj.id]) + + def get_movie_count(self, obj): + """Get the number of movies using this logo""" + return obj.movie.count() if hasattr(obj, 'movie') else 0 + + def get_series_count(self, obj): + """Get the number of series using this logo""" + return obj.series.count() if hasattr(obj, 'series') else 0 + + def get_is_used(self, obj): + """Check if this logo is used by any movies or series""" + return (hasattr(obj, 'movie') and obj.movie.exists()) or (hasattr(obj, 'series') and obj.series.exists()) + + def get_item_names(self, obj): + """Get the list of movies and series using this logo""" + names = [] + + if hasattr(obj, 'movie'): + for movie in obj.movie.all()[:10]: # Limit to 10 items for performance + names.append(f"Movie: {movie.name}") + + if hasattr(obj, 'series'): + for series in obj.series.all()[:10]: # Limit to 10 items for performance + names.append(f"Series: {series.name}") + + return names + + +class M3UVODCategoryRelationSerializer(serializers.ModelSerializer): + category = serializers.IntegerField(source="category.id") + m3u_account = serializers.IntegerField(source="m3u_account.id") + + class Meta: + model = M3UVODCategoryRelation + fields = ["category", "m3u_account", "enabled"] + + +class VODCategorySerializer(serializers.ModelSerializer): + category_type_display = serializers.CharField(source='get_category_type_display', read_only=True) + m3u_accounts = M3UVODCategoryRelationSerializer(many=True, source="m3u_relations", read_only=True) + + class Meta: + model = VODCategory + fields = [ + "id", + "name", + "category_type", + "category_type_display", + "m3u_accounts", + ] + +class SeriesSerializer(serializers.ModelSerializer): + logo = VODLogoSerializer(read_only=True) + episode_count = serializers.SerializerMethodField() + + class Meta: + model = Series + fields = '__all__' + + def get_episode_count(self, obj): + return obj.episodes.count() + + +class MovieSerializer(serializers.ModelSerializer): + logo = VODLogoSerializer(read_only=True) + + class Meta: + model = Movie + fields = '__all__' + + +class EpisodeSerializer(serializers.ModelSerializer): + series = SeriesSerializer(read_only=True) + + class Meta: + model = Episode + fields = '__all__' + + +class M3USeriesRelationSerializer(serializers.ModelSerializer): + series = SeriesSerializer(read_only=True) + category = VODCategorySerializer(read_only=True) + m3u_account = M3UAccountSerializer(read_only=True) + + class Meta: + model = M3USeriesRelation + fields = '__all__' + + +class M3UMovieRelationSerializer(serializers.ModelSerializer): + movie = MovieSerializer(read_only=True) + category = VODCategorySerializer(read_only=True) + m3u_account = M3UAccountSerializer(read_only=True) + quality_info = serializers.SerializerMethodField() + + class Meta: + model = M3UMovieRelation + fields = '__all__' + + def get_quality_info(self, obj): + """Extract quality information from various sources""" + quality_info = {} + + # 1. Check custom_properties first + if obj.custom_properties: + if obj.custom_properties.get('quality'): + quality_info['quality'] = obj.custom_properties['quality'] + return quality_info + elif obj.custom_properties.get('resolution'): + quality_info['resolution'] = obj.custom_properties['resolution'] + return quality_info + + # 2. Try to get detailed info from the movie if available + movie = obj.movie + if hasattr(movie, 'video') and movie.video: + video_data = movie.video + if isinstance(video_data, dict) and 'width' in video_data and 'height' in video_data: + width = video_data['width'] + height = video_data['height'] + quality_info['resolution'] = f"{width}x{height}" + + # Convert to common quality names (prioritize width for ultrawide/cinematic content) + if width >= 3840: + quality_info['quality'] = '4K' + elif width >= 1920: + quality_info['quality'] = '1080p' + elif width >= 1280: + quality_info['quality'] = '720p' + elif width >= 854: + quality_info['quality'] = '480p' + else: + quality_info['quality'] = f"{width}x{height}" + return quality_info + + # 3. Extract from movie name/title + if movie and movie.name: + name = movie.name + if '4K' in name or '2160p' in name: + quality_info['quality'] = '4K' + return quality_info + elif '1080p' in name or 'FHD' in name: + quality_info['quality'] = '1080p' + return quality_info + elif '720p' in name or 'HD' in name: + quality_info['quality'] = '720p' + return quality_info + elif '480p' in name: + quality_info['quality'] = '480p' + return quality_info + + # 4. Try bitrate as last resort + if hasattr(movie, 'bitrate') and movie.bitrate and movie.bitrate > 0: + bitrate = movie.bitrate + if bitrate >= 6000: + quality_info['quality'] = '4K' + elif bitrate >= 3000: + quality_info['quality'] = '1080p' + elif bitrate >= 1500: + quality_info['quality'] = '720p' + else: + quality_info['bitrate'] = f"{round(bitrate/1000)}Mbps" + return quality_info + + # 5. Fallback - no quality info available + return None + + +class M3UEpisodeRelationSerializer(serializers.ModelSerializer): + episode = EpisodeSerializer(read_only=True) + m3u_account = M3UAccountSerializer(read_only=True) + quality_info = serializers.SerializerMethodField() + + class Meta: + model = M3UEpisodeRelation + fields = '__all__' + + def get_quality_info(self, obj): + """Extract quality information from various sources""" + quality_info = {} + + # 1. Check custom_properties first + if obj.custom_properties: + if obj.custom_properties.get('quality'): + quality_info['quality'] = obj.custom_properties['quality'] + return quality_info + elif obj.custom_properties.get('resolution'): + quality_info['resolution'] = obj.custom_properties['resolution'] + return quality_info + + # 2. Try to get detailed info from the episode if available + episode = obj.episode + if hasattr(episode, 'video') and episode.video: + video_data = episode.video + if isinstance(video_data, dict) and 'width' in video_data and 'height' in video_data: + width = video_data['width'] + height = video_data['height'] + quality_info['resolution'] = f"{width}x{height}" + + # Convert to common quality names (prioritize width for ultrawide/cinematic content) + if width >= 3840: + quality_info['quality'] = '4K' + elif width >= 1920: + quality_info['quality'] = '1080p' + elif width >= 1280: + quality_info['quality'] = '720p' + elif width >= 854: + quality_info['quality'] = '480p' + else: + quality_info['quality'] = f"{width}x{height}" + return quality_info + + # 3. Extract from episode name/title + if episode and episode.name: + name = episode.name + if '4K' in name or '2160p' in name: + quality_info['quality'] = '4K' + return quality_info + elif '1080p' in name or 'FHD' in name: + quality_info['quality'] = '1080p' + return quality_info + elif '720p' in name or 'HD' in name: + quality_info['quality'] = '720p' + return quality_info + elif '480p' in name: + quality_info['quality'] = '480p' + return quality_info + + # 4. Try bitrate as last resort + if hasattr(episode, 'bitrate') and episode.bitrate and episode.bitrate > 0: + bitrate = episode.bitrate + if bitrate >= 6000: + quality_info['quality'] = '4K' + elif bitrate >= 3000: + quality_info['quality'] = '1080p' + elif bitrate >= 1500: + quality_info['quality'] = '720p' + else: + quality_info['bitrate'] = f"{round(bitrate/1000)}Mbps" + return quality_info + + # 5. Fallback - no quality info available + return None + + +class EnhancedSeriesSerializer(serializers.ModelSerializer): + """Enhanced serializer for series with provider information""" + logo = VODLogoSerializer(read_only=True) + providers = M3USeriesRelationSerializer(source='m3u_relations', many=True, read_only=True) + episode_count = serializers.SerializerMethodField() + + class Meta: + model = Series + fields = '__all__' + + def get_episode_count(self, obj): + return obj.episodes.count() diff --git a/apps/vod/tasks.py b/apps/vod/tasks.py new file mode 100644 index 0000000..ff195fc --- /dev/null +++ b/apps/vod/tasks.py @@ -0,0 +1,2224 @@ +from celery import shared_task, current_app, group +from django.utils import timezone +from django.db import transaction, IntegrityError +from django.db.models import Q +from apps.m3u.models import M3UAccount +from core.xtream_codes import Client as XtreamCodesClient +from .models import ( + VODCategory, Series, Movie, Episode, VODLogo, + M3USeriesRelation, M3UMovieRelation, M3UEpisodeRelation, M3UVODCategoryRelation +) +from datetime import datetime +import logging +import json +import re + +logger = logging.getLogger(__name__) + + +@shared_task +def refresh_vod_content(account_id): + """Refresh VOD content for an M3U account with batch processing for improved performance""" + # Import here to avoid circular import + from apps.m3u.tasks import send_m3u_update + + try: + account = M3UAccount.objects.get(id=account_id, is_active=True) + + if account.account_type != M3UAccount.Types.XC: + logger.warning(f"VOD refresh called for non-XC account {account_id}") + return "VOD refresh only available for XtreamCodes accounts" + + logger.info(f"Starting batch VOD refresh for account {account.name}") + start_time = timezone.now() + + # Send start notification + send_m3u_update(account_id, "vod_refresh", 0, status="processing") + + with XtreamCodesClient( + account.server_url, + account.username, + account.password, + account.get_user_agent().user_agent + ) as client: + + movie_categories, series_categories = refresh_categories(account.id, client) + + logger.debug("Fetching relations for filtering category filtering") + relations = { rel.category_id: rel for rel in M3UVODCategoryRelation.objects + .filter(m3u_account=account) + .select_related("category", "m3u_account") + } + + # Refresh movies with batch processing (pass scan start time) + refresh_movies(client, account, movie_categories, relations, scan_start_time=start_time) + + # Refresh series with batch processing (pass scan start time) + refresh_series(client, account, series_categories, relations, scan_start_time=start_time) + + end_time = timezone.now() + duration = (end_time - start_time).total_seconds() + + logger.info(f"Batch VOD refresh completed for account {account.name} in {duration:.2f} seconds") + + # Cleanup orphaned VOD content after refresh (scoped to this account only) + logger.info(f"Starting cleanup of orphaned VOD content for account {account.name}") + cleanup_result = cleanup_orphaned_vod_content(account_id=account_id, scan_start_time=start_time) + logger.info(f"VOD cleanup completed: {cleanup_result}") + + # Send completion notification + send_m3u_update(account_id, "vod_refresh", 100, status="success", + message=f"VOD refresh completed in {duration:.2f} seconds") + + return f"Batch VOD refresh completed for account {account.name} in {duration:.2f} seconds" + + except Exception as e: + import traceback + logger.error(f"Error refreshing VOD for account {account_id}: {str(e)}") + logger.error(f"Full traceback:\n{traceback.format_exc()}") + + # Send error notification + send_m3u_update(account_id, "vod_refresh", 100, status="error", + message=f"VOD refresh failed: {str(e)}") + + return f"VOD refresh failed: {str(e)}" + +def refresh_categories(account_id, client=None): + account = M3UAccount.objects.get(id=account_id, is_active=True) + + if not client: + client = XtreamCodesClient( + account.server_url, + account.username, + account.password, + account.get_user_agent().user_agent + ) + logger.info(f"Refreshing movie categories for account {account.name}") + + # First, get the category list to properly map category IDs and names + logger.info("Fetching movie categories from provider...") + categories_data = client.get_vod_categories() + category_map = batch_create_categories(categories_data, 'movie', account) + + # Create a mapping from provider category IDs to our category objects + movies_category_id_map = {} + for cat_data in categories_data: + cat_name = cat_data.get('category_name', 'Unknown') + provider_cat_id = cat_data.get('category_id') + our_category = category_map.get(cat_name) + if provider_cat_id and our_category: + movies_category_id_map[str(provider_cat_id)] = our_category + + # Get the category list to properly map category IDs and names + logger.info("Fetching series categories from provider...") + categories_data = client.get_series_categories() + category_map = batch_create_categories(categories_data, 'series', account) + + # Create a mapping from provider category IDs to our category objects + series_category_id_map = {} + for cat_data in categories_data: + cat_name = cat_data.get('category_name', 'Unknown') + provider_cat_id = cat_data.get('category_id') + our_category = category_map.get(cat_name) + if provider_cat_id and our_category: + series_category_id_map[str(provider_cat_id)] = our_category + + return movies_category_id_map, series_category_id_map + +def refresh_movies(client, account, categories_by_provider, relations, scan_start_time=None): + """Refresh movie content using single API call for all movies""" + logger.info(f"Refreshing movies for account {account.name}") + + # Ensure "Uncategorized" category exists for movies without a category + uncategorized_category, created = VODCategory.objects.get_or_create( + name="Uncategorized", + category_type="movie", + defaults={} + ) + + # Ensure there's a relation for the Uncategorized category + account_custom_props = account.custom_properties or {} + auto_enable_new = account_custom_props.get("auto_enable_new_groups_vod", True) + + uncategorized_relation, rel_created = M3UVODCategoryRelation.objects.get_or_create( + category=uncategorized_category, + m3u_account=account, + defaults={ + 'enabled': auto_enable_new, + 'custom_properties': {} + } + ) + + if created: + logger.info(f"Created 'Uncategorized' category for movies") + if rel_created: + logger.info(f"Created relation for 'Uncategorized' category (enabled={auto_enable_new})") + + # Add uncategorized category to relations dict for easy access + relations[uncategorized_category.id] = uncategorized_relation + + # Add to categories_by_provider with a special key for items without category + categories_by_provider['__uncategorized__'] = uncategorized_category + + # Get all movies in a single API call + logger.info("Fetching all movies from provider...") + all_movies_data = client.get_vod_streams() # No category_id = get all movies + + # Process movies in chunks using the simple approach + chunk_size = 1000 + total_movies = len(all_movies_data) + total_chunks = (total_movies + chunk_size - 1) // chunk_size if total_movies > 0 else 0 + + for i in range(0, total_movies, chunk_size): + chunk = all_movies_data[i:i + chunk_size] + chunk_num = (i // chunk_size) + 1 + + logger.info(f"Processing movie chunk {chunk_num}/{total_chunks} ({len(chunk)} movies)") + process_movie_batch(account, chunk, categories_by_provider, relations, scan_start_time) + + logger.info(f"Completed processing all {total_movies} movies in {total_chunks} chunks") + + +def refresh_series(client, account, categories_by_provider, relations, scan_start_time=None): + """Refresh series content using single API call for all series""" + logger.info(f"Refreshing series for account {account.name}") + + # Ensure "Uncategorized" category exists for series without a category + uncategorized_category, created = VODCategory.objects.get_or_create( + name="Uncategorized", + category_type="series", + defaults={} + ) + + # Ensure there's a relation for the Uncategorized category + account_custom_props = account.custom_properties or {} + auto_enable_new = account_custom_props.get("auto_enable_new_groups_series", True) + + uncategorized_relation, rel_created = M3UVODCategoryRelation.objects.get_or_create( + category=uncategorized_category, + m3u_account=account, + defaults={ + 'enabled': auto_enable_new, + 'custom_properties': {} + } + ) + + if created: + logger.info(f"Created 'Uncategorized' category for series") + if rel_created: + logger.info(f"Created relation for 'Uncategorized' category (enabled={auto_enable_new})") + + # Add uncategorized category to relations dict for easy access + relations[uncategorized_category.id] = uncategorized_relation + + # Add to categories_by_provider with a special key for items without category + categories_by_provider['__uncategorized__'] = uncategorized_category + + # Get all series in a single API call + logger.info("Fetching all series from provider...") + all_series_data = client.get_series() # No category_id = get all series + + # Process series in chunks using the simple approach + chunk_size = 1000 + total_series = len(all_series_data) + total_chunks = (total_series + chunk_size - 1) // chunk_size if total_series > 0 else 0 + + for i in range(0, total_series, chunk_size): + chunk = all_series_data[i:i + chunk_size] + chunk_num = (i // chunk_size) + 1 + + logger.info(f"Processing series chunk {chunk_num}/{total_chunks} ({len(chunk)} series)") + process_series_batch(account, chunk, categories_by_provider, relations, scan_start_time) + + logger.info(f"Completed processing all {total_series} series in {total_chunks} chunks") + + +def batch_create_categories(categories_data, category_type, account): + """Create categories in batch and return a mapping""" + category_names = [cat.get('category_name', 'Unknown') for cat in categories_data] + + relations_to_create = [] + + # Get existing categories + logger.debug(f"Starting VOD {category_type} category refresh") + existing_categories = { + cat.name: cat for cat in VODCategory.objects.filter( + name__in=category_names, + category_type=category_type + ) + } + + logger.debug(f"Found {len(existing_categories)} existing categories") + + # Check if we should auto-enable new categories based on account settings + account_custom_props = account.custom_properties or {} + if category_type == 'movie': + auto_enable_new = account_custom_props.get("auto_enable_new_groups_vod", True) + else: # series + auto_enable_new = account_custom_props.get("auto_enable_new_groups_series", True) + + # Create missing categories in batch + new_categories = [] + + for name in category_names: + if name not in existing_categories: + # Always create new categories + new_categories.append(VODCategory(name=name, category_type=category_type)) + else: + # Existing category - create relationship with enabled based on auto_enable setting + # (category exists globally but is new to this account) + relations_to_create.append(M3UVODCategoryRelation( + category=existing_categories[name], + m3u_account=account, + custom_properties={}, + enabled=auto_enable_new, + )) + + logger.debug(f"{len(new_categories)} new categories found") + logger.debug(f"{len(relations_to_create)} existing categories found for account") + + if new_categories: + logger.debug("Creating new categories...") + created_categories = list(VODCategory.bulk_create_and_fetch(new_categories, ignore_conflicts=True)) + + # Create relations for newly created categories with enabled based on auto_enable setting + for cat in created_categories: + if not auto_enable_new: + logger.info(f"New {category_type} category '{cat.name}' created but DISABLED - auto_enable_new_groups is disabled for account {account.id}") + + relations_to_create.append( + M3UVODCategoryRelation( + category=cat, + m3u_account=account, + custom_properties={}, + enabled=auto_enable_new, + ) + ) + + # Convert to dictionary for easy lookup + newly_created = {cat.name: cat for cat in created_categories} + existing_categories.update(newly_created) + + # Create missing relations + logger.debug("Updating category account relations...") + M3UVODCategoryRelation.objects.bulk_create(relations_to_create, ignore_conflicts=True) + + # Delete orphaned category relationships (categories no longer in the M3U source) + # Exclude "Uncategorized" from cleanup as it's a special category we manage + current_category_ids = set(existing_categories[name].id for name in category_names) + existing_relations = M3UVODCategoryRelation.objects.filter( + m3u_account=account, + category__category_type=category_type + ).select_related('category') + + relations_to_delete = [ + rel for rel in existing_relations + if rel.category_id not in current_category_ids and rel.category.name != "Uncategorized" + ] + + if relations_to_delete: + M3UVODCategoryRelation.objects.filter( + id__in=[rel.id for rel in relations_to_delete] + ).delete() + logger.info(f"Deleted {len(relations_to_delete)} orphaned {category_type} category relationships for account {account.id}: {[rel.category.name for rel in relations_to_delete]}") + + # Check if any of the deleted relationships left categories with no remaining associations + orphaned_category_ids = [] + for rel in relations_to_delete: + category = rel.category + + # Check if this category has any remaining M3U account relationships + remaining_relationships = M3UVODCategoryRelation.objects.filter( + category=category + ).exists() + + # If no relationships remain, it's safe to delete the category + if not remaining_relationships: + orphaned_category_ids.append(category.id) + logger.debug(f"Category '{category.name}' has no remaining associations and will be deleted") + + # Delete orphaned categories + if orphaned_category_ids: + VODCategory.objects.filter(id__in=orphaned_category_ids).delete() + logger.info(f"Deleted {len(orphaned_category_ids)} orphaned {category_type} categories with no remaining associations") + + # 🔑 Fetch all relations for this account, for all categories + # relations = { rel.id: rel for rel in M3UVODCategoryRelation.objects + # .filter(category__in=existing_categories.values(), m3u_account=account) + # .select_related("category", "m3u_account") + # } + + # Attach relations to category objects + # for rel in relations: + # existing_categories[rel.category.name]['relation'] = { + # "relation_id": rel.id, + # "category_id": rel.category_id, + # "account_id": rel.m3u_account_id, + # } + + + return existing_categories + + + +@shared_task +def process_movie_batch(account, batch, categories, relations, scan_start_time=None): + """Process a batch of movies using simple bulk operations like M3U processing""" + logger.info(f"Processing movie batch of {len(batch)} movies for account {account.name}") + + movies_to_create = [] + movies_to_update = [] + relations_to_create = [] + relations_to_update = [] + movie_keys = {} # For deduplication like M3U stream_hashes + + # Process each movie in the batch + for movie_data in batch: + try: + stream_id = str(movie_data.get('stream_id')) + name = movie_data.get('name', 'Unknown') + + # Get category with proper error handling + category = None + + provider_cat_id = str(movie_data.get('category_id', '')) if movie_data.get('category_id') else None + movie_data['_provider_category_id'] = provider_cat_id + movie_data['_category_id'] = None + + logger.debug(f"Checking for existing provider category ID {provider_cat_id}") + if provider_cat_id in categories: + category = categories[provider_cat_id] + movie_data['_category_id'] = category.id + logger.debug(f"Found category {category.name} (ID: {category.id}) for movie {name}") + + relation = relations.get(category.id, None) + if relation and not relation.enabled: + logger.debug("Skipping disabled category") + continue + else: + # Assign to Uncategorized category if no category_id provided + logger.debug(f"No category ID provided for movie {name}, assigning to 'Uncategorized'") + category = categories.get('__uncategorized__') + if category: + movie_data['_category_id'] = category.id + # Check if uncategorized is disabled + relation = relations.get(category.id, None) + if relation and not relation.enabled: + logger.debug("Skipping disabled 'Uncategorized' category") + continue + + # Extract metadata + year = extract_year_from_data(movie_data, 'name') + tmdb_id = movie_data.get('tmdb_id') or movie_data.get('tmdb') + imdb_id = movie_data.get('imdb_id') or movie_data.get('imdb') + + # Clean empty string IDs and zero values (some providers use 0 to indicate no ID) + if tmdb_id == '' or tmdb_id == 0 or tmdb_id == '0': + tmdb_id = None + if imdb_id == '' or imdb_id == 0 or imdb_id == '0': + imdb_id = None + + # Create a unique key for this movie (priority: TMDB > IMDB > name+year) + if tmdb_id: + movie_key = f"tmdb_{tmdb_id}" + elif imdb_id: + movie_key = f"imdb_{imdb_id}" + else: + movie_key = f"name_{name}_{year or 'None'}" + + # Skip duplicates in this batch + if movie_key in movie_keys: + continue + + # Prepare movie properties + description = movie_data.get('description') or movie_data.get('plot') or '' + rating = normalize_rating(movie_data.get('rating') or movie_data.get('vote_average')) + genre = movie_data.get('genre') or movie_data.get('category_name') or '' + duration_secs = extract_duration_from_data(movie_data) + trailer_raw = movie_data.get('trailer') or movie_data.get('youtube_trailer') or '' + trailer = extract_string_from_array_or_string(trailer_raw) if trailer_raw else None + logo_url = movie_data.get('stream_icon') or '' + + movie_props = { + 'name': name, + 'year': year, + 'tmdb_id': tmdb_id, + 'imdb_id': imdb_id, + 'description': description, + 'rating': rating, + 'genre': genre, + 'duration_secs': duration_secs, + 'custom_properties': {'trailer': trailer} if trailer else None, + } + + movie_keys[movie_key] = { + 'props': movie_props, + 'stream_id': stream_id, + 'category': category, + 'movie_data': movie_data, + 'logo_url': logo_url # Keep logo URL for later processing + } + + except Exception as e: + logger.error(f"Error preparing movie {movie_data.get('name', 'Unknown')}: {str(e)}") + + # Collect all logo URLs and create logos in batch + logo_urls = set() + logo_url_to_name = {} # Map logo URLs to movie names + for data in movie_keys.values(): + logo_url = data.get('logo_url') + if logo_url and len(logo_url) <= 500: # Ignore overly long URLs (likely embedded image data) + logo_urls.add(logo_url) + # Map this logo URL to the movie name (use first occurrence if multiple movies share same logo) + if logo_url not in logo_url_to_name: + movie_name = data['props'].get('name', 'Unknown Movie') + logo_url_to_name[logo_url] = movie_name + + # Get existing logos + existing_logos = { + logo.url: logo for logo in VODLogo.objects.filter(url__in=logo_urls) + } if logo_urls else {} + + # Create missing logos + logos_to_create = [] + for logo_url in logo_urls: + if logo_url not in existing_logos: + movie_name = logo_url_to_name.get(logo_url, 'Unknown Movie') + logos_to_create.append(VODLogo(url=logo_url, name=movie_name)) + + if logos_to_create: + try: + VODLogo.objects.bulk_create(logos_to_create, ignore_conflicts=True) + # Refresh existing_logos with newly created ones + new_logo_urls = [logo.url for logo in logos_to_create] + newly_created = { + logo.url: logo for logo in VODLogo.objects.filter(url__in=new_logo_urls) + } + existing_logos.update(newly_created) + logger.info(f"Created {len(newly_created)} new VOD logos for movies") + except Exception as e: + logger.warning(f"Failed to create VOD logos: {e}") + + # Get existing movies based on our keys + existing_movies = {} + + # Query by TMDB IDs + tmdb_keys = [k for k in movie_keys.keys() if k.startswith('tmdb_')] + tmdb_ids = [k.replace('tmdb_', '') for k in tmdb_keys] + if tmdb_ids: + for movie in Movie.objects.filter(tmdb_id__in=tmdb_ids): + existing_movies[f"tmdb_{movie.tmdb_id}"] = movie + + # Query by IMDB IDs + imdb_keys = [k for k in movie_keys.keys() if k.startswith('imdb_')] + imdb_ids = [k.replace('imdb_', '') for k in imdb_keys] + if imdb_ids: + for movie in Movie.objects.filter(imdb_id__in=imdb_ids): + existing_movies[f"imdb_{movie.imdb_id}"] = movie + + # Query by name+year for movies without external IDs + name_year_keys = [k for k in movie_keys.keys() if k.startswith('name_')] + if name_year_keys: + for movie in Movie.objects.filter(tmdb_id__isnull=True, imdb_id__isnull=True): + key = f"name_{movie.name}_{movie.year or 'None'}" + if key in name_year_keys: + existing_movies[key] = movie + + # Get existing relations + stream_ids = [data['stream_id'] for data in movie_keys.values()] + existing_relations = { + rel.stream_id: rel for rel in M3UMovieRelation.objects.filter( + m3u_account=account, + stream_id__in=stream_ids + ).select_related('movie') + } + + # Process each movie + for movie_key, data in movie_keys.items(): + movie_props = data['props'] + stream_id = data['stream_id'] + category = data['category'] + movie_data = data['movie_data'] + logo_url = data.get('logo_url') + + if movie_key in existing_movies: + # Update existing movie + movie = existing_movies[movie_key] + updated = False + + for field, value in movie_props.items(): + if field == 'custom_properties': + if value != movie.custom_properties: + movie.custom_properties = value + updated = True + elif getattr(movie, field) != value: + setattr(movie, field, value) + updated = True + + # Handle logo assignment for existing movies + logo_updated = False + if logo_url and len(logo_url) <= 500: + if logo_url in existing_logos: + new_logo = existing_logos[logo_url] + if movie.logo_id != new_logo.id: + movie._logo_to_update = new_logo + logo_updated = True + elif movie.logo_id: + # Logo URL exists but logo creation failed or logo not found + # Clear the orphaned logo reference + logger.warning(f"Logo URL provided but logo not found in database for movie '{movie.name}', clearing logo reference") + movie._logo_to_update = None + logo_updated = True + elif (not logo_url or len(logo_url) > 500) and movie.logo_id: + # Clear logo if no logo URL provided or URL is too long + movie._logo_to_update = None + logo_updated = True + + if updated or logo_updated: + movies_to_update.append(movie) + else: + # Create new movie + movie = Movie(**movie_props) + + # Assign logo if available + if logo_url and len(logo_url) <= 500 and logo_url in existing_logos: + movie.logo = existing_logos[logo_url] + + movies_to_create.append(movie) + + # Handle relation + if stream_id in existing_relations: + # Update existing relation + relation = existing_relations[stream_id] + relation.movie = movie + relation.category = category + relation.container_extension = movie_data.get('container_extension', 'mp4') + relation.custom_properties = { + 'basic_data': movie_data, + 'detailed_fetched': False + } + relation.last_seen = scan_start_time or timezone.now() # Mark as seen during this scan + relations_to_update.append(relation) + else: + # Create new relation + relation = M3UMovieRelation( + m3u_account=account, + movie=movie, + category=category, + stream_id=stream_id, + container_extension=movie_data.get('container_extension', 'mp4'), + custom_properties={ + 'basic_data': movie_data, + 'detailed_fetched': False + }, + last_seen=scan_start_time or timezone.now() # Mark as seen during this scan + ) + relations_to_create.append(relation) + + # Execute batch operations + logger.info(f"Executing batch operations: {len(movies_to_create)} movies to create, {len(movies_to_update)} to update") + + try: + with transaction.atomic(): + # First, create new movies and get their IDs + created_movies = {} + if movies_to_create: + # Bulk query to check which movies already exist + tmdb_ids = [m.tmdb_id for m in movies_to_create if m.tmdb_id] + imdb_ids = [m.imdb_id for m in movies_to_create if m.imdb_id] + name_year_pairs = [(m.name, m.year) for m in movies_to_create if not m.tmdb_id and not m.imdb_id] + + existing_by_tmdb = {m.tmdb_id: m for m in Movie.objects.filter(tmdb_id__in=tmdb_ids)} if tmdb_ids else {} + existing_by_imdb = {m.imdb_id: m for m in Movie.objects.filter(imdb_id__in=imdb_ids)} if imdb_ids else {} + + existing_by_name_year = {} + if name_year_pairs: + for movie in Movie.objects.filter(tmdb_id__isnull=True, imdb_id__isnull=True): + key = (movie.name, movie.year) + if key in name_year_pairs: + existing_by_name_year[key] = movie + + # Check each movie against the bulk query results + movies_actually_created = [] + for movie in movies_to_create: + existing = None + if movie.tmdb_id and movie.tmdb_id in existing_by_tmdb: + existing = existing_by_tmdb[movie.tmdb_id] + elif movie.imdb_id and movie.imdb_id in existing_by_imdb: + existing = existing_by_imdb[movie.imdb_id] + elif not movie.tmdb_id and not movie.imdb_id: + existing = existing_by_name_year.get((movie.name, movie.year)) + + if existing: + created_movies[id(movie)] = existing + else: + movies_actually_created.append(movie) + created_movies[id(movie)] = movie + + # Bulk create only movies that don't exist + if movies_actually_created: + Movie.objects.bulk_create(movies_actually_created) + + # Update existing movies + if movies_to_update: + # First, update all fields except logo to avoid unsaved related object issues + Movie.objects.bulk_update(movies_to_update, [ + 'description', 'rating', 'genre', 'year', 'tmdb_id', 'imdb_id', + 'duration_secs', 'custom_properties' + ]) + + # Handle logo updates separately to avoid bulk_update issues + for movie in movies_to_update: + if hasattr(movie, '_logo_to_update'): + movie.logo = movie._logo_to_update + movie.save(update_fields=['logo']) + + # Update relations to reference the correct movie objects (with PKs) + for relation in relations_to_create: + if id(relation.movie) in created_movies: + relation.movie = created_movies[id(relation.movie)] + + for relation in relations_to_update: + if id(relation.movie) in created_movies: + relation.movie = created_movies[id(relation.movie)] + + # All movies now have PKs, safe to bulk create/update relations + if relations_to_create: + M3UMovieRelation.objects.bulk_create(relations_to_create, ignore_conflicts=True) + + if relations_to_update: + M3UMovieRelation.objects.bulk_update(relations_to_update, [ + 'movie', 'category', 'container_extension', 'custom_properties', 'last_seen' + ]) + + logger.info("Movie batch processing completed successfully!") + return f"Movie batch processed: {len(movies_to_create)} created, {len(movies_to_update)} updated" + + except Exception as e: + logger.error(f"Movie batch processing failed: {str(e)}") + return f"Movie batch processing failed: {str(e)}" + + +@shared_task +def process_series_batch(account, batch, categories, relations, scan_start_time=None): + """Process a batch of series using simple bulk operations like M3U processing""" + logger.info(f"Processing series batch of {len(batch)} series for account {account.name}") + + series_to_create = [] + series_to_update = [] + relations_to_create = [] + relations_to_update = [] + series_keys = {} # For deduplication like M3U stream_hashes + + # Process each series in the batch + for series_data in batch: + try: + series_id = str(series_data.get('series_id')) + name = series_data.get('name', 'Unknown') + + # Get category with proper error handling + category = None + + provider_cat_id = str(series_data.get('category_id', '')) if series_data.get('category_id') else None + series_data['_provider_category_id'] = provider_cat_id + series_data['_category_id'] = None + + if provider_cat_id in categories: + category = categories[provider_cat_id] + series_data['_category_id'] = category.id + logger.debug(f"Found category {category.name} (ID: {category.id}) for series {name}") + relation = relations.get(category.id, None) + + if relation and not relation.enabled: + logger.debug("Skipping disabled category") + continue + else: + # Assign to Uncategorized category if no category_id provided + logger.debug(f"No category ID provided for series {name}, assigning to 'Uncategorized'") + category = categories.get('__uncategorized__') + if category: + series_data['_category_id'] = category.id + # Check if uncategorized is disabled + relation = relations.get(category.id, None) + if relation and not relation.enabled: + logger.debug("Skipping disabled 'Uncategorized' category") + continue + + # Extract metadata + year = extract_year(series_data.get('releaseDate', '')) + if not year and series_data.get('release_date'): + year = extract_year(series_data.get('release_date')) + + tmdb_id = series_data.get('tmdb') or series_data.get('tmdb_id') + imdb_id = series_data.get('imdb') or series_data.get('imdb_id') + + # Clean empty string IDs and zero values (some providers use 0 to indicate no ID) + if tmdb_id == '' or tmdb_id == 0 or tmdb_id == '0': + tmdb_id = None + if imdb_id == '' or imdb_id == 0 or imdb_id == '0': + imdb_id = None + + # Create a unique key for this series (priority: TMDB > IMDB > name+year) + if tmdb_id: + series_key = f"tmdb_{tmdb_id}" + elif imdb_id: + series_key = f"imdb_{imdb_id}" + else: + series_key = f"name_{name}_{year or 'None'}" + + # Skip duplicates in this batch + if series_key in series_keys: + continue + + # Prepare series properties + description = series_data.get('plot', '') + rating = normalize_rating(series_data.get('rating')) + genre = series_data.get('genre', '') + logo_url = series_data.get('cover') or '' + + # Extract additional metadata for custom_properties + additional_metadata = {} + for key in ['backdrop_path', 'poster_path', 'original_name', 'first_air_date', 'last_air_date', + 'episode_run_time', 'status', 'type', 'cast', 'director', 'country', 'language', + 'releaseDate', 'youtube_trailer', 'category_id', 'age', 'seasons']: + value = series_data.get(key) + if value: + # For string-like fields that might be arrays, extract clean strings + if key in ['poster_path', 'youtube_trailer', 'cast', 'director']: + clean_value = extract_string_from_array_or_string(value) + if clean_value: + additional_metadata[key] = clean_value + elif key == 'backdrop_path': + clean_value = extract_string_from_array_or_string(value) + if clean_value: + additional_metadata[key] = [clean_value] + else: + # For other fields, keep as-is if not null/empty + if value is not None and value != '' and value != []: + additional_metadata[key] = value + + series_props = { + 'name': name, + 'year': year, + 'tmdb_id': tmdb_id, + 'imdb_id': imdb_id, + 'description': description, + 'rating': rating, + 'genre': genre, + 'custom_properties': additional_metadata if additional_metadata else None, + } + + series_keys[series_key] = { + 'props': series_props, + 'series_id': series_id, + 'category': category, + 'series_data': series_data, + 'logo_url': logo_url # Keep logo URL for later processing + } + + except Exception as e: + logger.error(f"Error preparing series {series_data.get('name', 'Unknown')}: {str(e)}") + + # Collect all logo URLs and create logos in batch + logo_urls = set() + logo_url_to_name = {} # Map logo URLs to series names + for data in series_keys.values(): + logo_url = data.get('logo_url') + if logo_url and len(logo_url) <= 500: # Ignore overly long URLs (likely embedded image data) + logo_urls.add(logo_url) + # Map this logo URL to the series name (use first occurrence if multiple series share same logo) + if logo_url not in logo_url_to_name: + series_name = data['props'].get('name', 'Unknown Series') + logo_url_to_name[logo_url] = series_name + + # Get existing logos + existing_logos = { + logo.url: logo for logo in VODLogo.objects.filter(url__in=logo_urls) + } if logo_urls else {} + + # Create missing logos + logos_to_create = [] + for logo_url in logo_urls: + if logo_url not in existing_logos: + series_name = logo_url_to_name.get(logo_url, 'Unknown Series') + logos_to_create.append(VODLogo(url=logo_url, name=series_name)) + + if logos_to_create: + try: + VODLogo.objects.bulk_create(logos_to_create, ignore_conflicts=True) + # Refresh existing_logos with newly created ones + new_logo_urls = [logo.url for logo in logos_to_create] + newly_created = { + logo.url: logo for logo in VODLogo.objects.filter(url__in=new_logo_urls) + } + existing_logos.update(newly_created) + logger.info(f"Created {len(newly_created)} new VOD logos for series") + except Exception as e: + logger.warning(f"Failed to create VOD logos: {e}") + + # Get existing series based on our keys - same pattern as movies + existing_series = {} + + # Query by TMDB IDs + tmdb_keys = [k for k in series_keys.keys() if k.startswith('tmdb_')] + tmdb_ids = [k.replace('tmdb_', '') for k in tmdb_keys] + if tmdb_ids: + for series in Series.objects.filter(tmdb_id__in=tmdb_ids): + existing_series[f"tmdb_{series.tmdb_id}"] = series + + # Query by IMDB IDs + imdb_keys = [k for k in series_keys.keys() if k.startswith('imdb_')] + imdb_ids = [k.replace('imdb_', '') for k in imdb_keys] + if imdb_ids: + for series in Series.objects.filter(imdb_id__in=imdb_ids): + existing_series[f"imdb_{series.imdb_id}"] = series + + # Query by name+year for series without external IDs + name_year_keys = [k for k in series_keys.keys() if k.startswith('name_')] + if name_year_keys: + for series in Series.objects.filter(tmdb_id__isnull=True, imdb_id__isnull=True): + key = f"name_{series.name}_{series.year or 'None'}" + if key in name_year_keys: + existing_series[key] = series + + # Get existing relations + series_ids = [data['series_id'] for data in series_keys.values()] + existing_relations = { + rel.external_series_id: rel for rel in M3USeriesRelation.objects.filter( + m3u_account=account, + external_series_id__in=series_ids + ).select_related('series') + } + + # Process each series + for series_key, data in series_keys.items(): + series_props = data['props'] + series_id = data['series_id'] + category = data['category'] + series_data = data['series_data'] + logo_url = data.get('logo_url') + + if series_key in existing_series: + # Update existing series + series = existing_series[series_key] + updated = False + + for field, value in series_props.items(): + if field == 'custom_properties': + if value != series.custom_properties: + series.custom_properties = value + updated = True + elif getattr(series, field) != value: + setattr(series, field, value) + updated = True + + # Handle logo assignment for existing series + logo_updated = False + if logo_url and len(logo_url) <= 500: + if logo_url in existing_logos: + new_logo = existing_logos[logo_url] + if series.logo_id != new_logo.id: + series._logo_to_update = new_logo + logo_updated = True + elif series.logo_id: + # Logo URL exists but logo creation failed or logo not found + # Clear the orphaned logo reference + logger.warning(f"Logo URL provided but logo not found in database for series '{series.name}', clearing logo reference") + series._logo_to_update = None + logo_updated = True + elif (not logo_url or len(logo_url) > 500) and series.logo_id: + # Clear logo if no logo URL provided or URL is too long + series._logo_to_update = None + logo_updated = True + + if updated or logo_updated: + series_to_update.append(series) + else: + # Create new series + series = Series(**series_props) + + # Assign logo if available + if logo_url and len(logo_url) <= 500 and logo_url in existing_logos: + series.logo = existing_logos[logo_url] + + series_to_create.append(series) + + # Handle relation + if series_id in existing_relations: + # Update existing relation + relation = existing_relations[series_id] + relation.series = series + relation.category = category + relation.custom_properties = { + 'basic_data': series_data, + 'detailed_fetched': False, + 'episodes_fetched': False + } + relation.last_seen = scan_start_time or timezone.now() # Mark as seen during this scan + relations_to_update.append(relation) + else: + # Create new relation + relation = M3USeriesRelation( + m3u_account=account, + series=series, + category=category, + external_series_id=series_id, + custom_properties={ + 'basic_data': series_data, + 'detailed_fetched': False, + 'episodes_fetched': False + }, + last_seen=scan_start_time or timezone.now() # Mark as seen during this scan + ) + relations_to_create.append(relation) + + # Execute batch operations + logger.info(f"Executing batch operations: {len(series_to_create)} series to create, {len(series_to_update)} to update") + + try: + with transaction.atomic(): + # First, create new series and get their IDs + created_series = {} + if series_to_create: + # Bulk query to check which series already exist + tmdb_ids = [s.tmdb_id for s in series_to_create if s.tmdb_id] + imdb_ids = [s.imdb_id for s in series_to_create if s.imdb_id] + name_year_pairs = [(s.name, s.year) for s in series_to_create if not s.tmdb_id and not s.imdb_id] + + existing_by_tmdb = {s.tmdb_id: s for s in Series.objects.filter(tmdb_id__in=tmdb_ids)} if tmdb_ids else {} + existing_by_imdb = {s.imdb_id: s for s in Series.objects.filter(imdb_id__in=imdb_ids)} if imdb_ids else {} + + existing_by_name_year = {} + if name_year_pairs: + for series in Series.objects.filter(tmdb_id__isnull=True, imdb_id__isnull=True): + key = (series.name, series.year) + if key in name_year_pairs: + existing_by_name_year[key] = series + + # Check each series against the bulk query results + series_actually_created = [] + for series in series_to_create: + existing = None + if series.tmdb_id and series.tmdb_id in existing_by_tmdb: + existing = existing_by_tmdb[series.tmdb_id] + elif series.imdb_id and series.imdb_id in existing_by_imdb: + existing = existing_by_imdb[series.imdb_id] + elif not series.tmdb_id and not series.imdb_id: + existing = existing_by_name_year.get((series.name, series.year)) + + if existing: + created_series[id(series)] = existing + else: + series_actually_created.append(series) + created_series[id(series)] = series + + # Bulk create only series that don't exist + if series_actually_created: + Series.objects.bulk_create(series_actually_created) + + # Update existing series + if series_to_update: + # First, update all fields except logo to avoid unsaved related object issues + Series.objects.bulk_update(series_to_update, [ + 'description', 'rating', 'genre', 'year', 'tmdb_id', 'imdb_id', + 'custom_properties' + ]) + + # Handle logo updates separately to avoid bulk_update issues + for series in series_to_update: + if hasattr(series, '_logo_to_update'): + series.logo = series._logo_to_update + series.save(update_fields=['logo']) + + # Update relations to reference the correct series objects (with PKs) + for relation in relations_to_create: + if id(relation.series) in created_series: + relation.series = created_series[id(relation.series)] + + for relation in relations_to_update: + if id(relation.series) in created_series: + relation.series = created_series[id(relation.series)] + + # All series now have PKs, safe to bulk create/update relations + if relations_to_create: + M3USeriesRelation.objects.bulk_create(relations_to_create, ignore_conflicts=True) + + if relations_to_update: + M3USeriesRelation.objects.bulk_update(relations_to_update, [ + 'series', 'category', 'custom_properties', 'last_seen' + ]) + + logger.info("Series batch processing completed successfully!") + return f"Series batch processed: {len(series_to_create)} created, {len(series_to_update)} updated" + + except Exception as e: + logger.error(f"Series batch processing failed: {str(e)}") + return f"Series batch processing failed: {str(e)}" + + +# Helper functions for year and date extraction + +def extract_duration_from_data(movie_data): + """Extract duration in seconds from movie data""" + duration_secs = None + + # Try to extract duration from various possible fields + if movie_data.get('duration_secs'): + duration_secs = int(movie_data.get('duration_secs')) + elif movie_data.get('duration'): + # Handle duration that might be in different formats + duration_str = str(movie_data.get('duration')) + if duration_str.isdigit(): + duration_secs = int(duration_str) * 60 # Assume minutes if just a number + else: + # Try to parse time format like "01:30:00" + try: + time_parts = duration_str.split(':') + if len(time_parts) == 3: + hours, minutes, seconds = map(int, time_parts) + duration_secs = (hours * 3600) + (minutes * 60) + seconds + elif len(time_parts) == 2: + minutes, seconds = map(int, time_parts) + duration_secs = minutes * 60 + seconds + except (ValueError, AttributeError): + pass + + return duration_secs + + +def normalize_rating(rating_value): + """Normalize rating value by converting commas to decimals and validating as float""" + if not rating_value: + return None + + try: + # Convert to string for processing + rating_str = str(rating_value).strip() + + if not rating_str or rating_str == '': + return None + + # Replace comma with decimal point (European format) + rating_str = rating_str.replace(',', '.') + + # Try to convert to float + rating_float = float(rating_str) + + # Return as string to maintain compatibility with existing code + # but ensure it's a valid numeric format + return str(rating_float) + except (ValueError, TypeError, AttributeError): + # If conversion fails, discard the rating + logger.debug(f"Invalid rating value discarded: {rating_value}") + return None + + +def extract_year(date_string): + """Extract year from date string""" + if not date_string: + return None + try: + return int(date_string.split('-')[0]) + except (ValueError, IndexError): + return None + + +def extract_year_from_title(title): + """Extract year from movie title if present""" + if not title: + return None + + # Pattern for (YYYY) format + pattern1 = r'\((\d{4})\)' + # Pattern for - YYYY format + pattern2 = r'\s-\s(\d{4})' + # Pattern for YYYY at the end + pattern3 = r'\s(\d{4})$' + + for pattern in [pattern1, pattern2, pattern3]: + match = re.search(pattern, title) + if match: + year = int(match.group(1)) + # Validate year is reasonable (between 1900 and current year + 5) + if 1900 <= year <= 2030: + return year + + return None + + +def extract_year_from_data(data, title_key='name'): + """Extract year from various data sources with fallback options""" + try: + # First try the year field + year = data.get('year') + if year and str(year).strip() and str(year).strip() != '': + try: + year_int = int(year) + if 1900 <= year_int <= 2030: + return year_int + except (ValueError, TypeError): + pass + + # Try releaseDate or release_date fields + for date_field in ['releaseDate', 'release_date']: + date_value = data.get(date_field) + if date_value and isinstance(date_value, str) and date_value.strip(): + # Extract year from date format like "2011-09-19" + try: + year_str = date_value.split('-')[0].strip() + if year_str: + year = int(year_str) + if 1900 <= year <= 2030: + return year + except (ValueError, IndexError): + continue + + # Finally try extracting from title + title = data.get(title_key, '') + if title and title.strip(): + return extract_year_from_title(title) + + except Exception: + # Don't fail processing if year extraction fails + pass + + return None + + +def extract_date_from_data(data): + """Extract date from various data sources with fallback options""" + try: + for date_field in ['air_date', 'releasedate', 'release_date']: + date_value = data.get(date_field) + if date_value and isinstance(date_value, str) and date_value.strip(): + parsed = parse_date(date_value) + if parsed: + return parsed + except Exception: + # Don't fail processing if date extraction fails + pass + return None + + +def parse_date(date_string): + """Parse date string into a datetime object""" + if not date_string: + return None + try: + # Try to parse ISO format first + return datetime.fromisoformat(date_string) + except ValueError: + # Fallback to parsing with strptime for common formats + try: + return datetime.strptime(date_string, '%Y-%m-%d') + except ValueError: + return None # Return None if parsing fails + + +# Episode processing and other advanced features + +def refresh_series_episodes(account, series, external_series_id, episodes_data=None): + """Refresh episodes for a series - only called on-demand""" + try: + if not episodes_data: + # Fetch detailed series info including episodes + with XtreamCodesClient( + account.server_url, + account.username, + account.password, + account.get_user_agent().user_agent + ) as client: + series_info = client.get_series_info(external_series_id) + if series_info: + # Update series with detailed info + info = series_info.get('info', {}) + if info: + # Only update fields if new value is non-empty and either no existing value or existing value is empty + updated = False + if should_update_field(series.description, info.get('plot')): + series.description = extract_string_from_array_or_string(info.get('plot')) + updated = True + normalized_rating = normalize_rating(info.get('rating')) + if normalized_rating and (not series.rating or not str(series.rating).strip()): + series.rating = normalized_rating + updated = True + if should_update_field(series.genre, info.get('genre')): + series.genre = extract_string_from_array_or_string(info.get('genre')) + updated = True + + year = extract_year_from_data(info) + if year and not series.year: + series.year = year + updated = True + + if updated: + series.save() + + episodes_data = series_info.get('episodes', {}) + else: + episodes_data = {} + + # Fetch the series relation once — used both to pass into batch_process_episodes + # (so episode relations get the FK set) and to update metadata afterwards. + series_relation = M3USeriesRelation.objects.filter( + m3u_account=account, + external_series_id=external_series_id + ).first() + + # Process all episodes in batch + batch_process_episodes(account, series, episodes_data, series_relation=series_relation) + + if series_relation: + custom_props = series_relation.custom_properties or {} + custom_props['episodes_fetched'] = True + custom_props['detailed_fetched'] = True + series_relation.custom_properties = custom_props + series_relation.last_episode_refresh = timezone.now() + series_relation.save() + + except Exception as e: + logger.error(f"Error refreshing episodes for series {series.name}: {str(e)}") + + +def batch_process_episodes(account, series, episodes_data, scan_start_time=None, series_relation=None): + """Process episodes in batches for better performance. + + Note: Multiple streams can represent the same episode (e.g., different languages + or qualities). Each stream has a unique stream_id, but they share the same + season/episode number. We create one Episode record per (series, season, episode) + and multiple M3UEpisodeRelation records pointing to it. + + series_relation, when provided, is stored as a FK on each M3UEpisodeRelation so + that CASCADE correctly removes episode relations when their parent series relation + is deleted, and so that stale-stream cleanup is scoped precisely to relations that + came from this specific provider query. + """ + if not episodes_data: + return + + # Flatten episodes data + all_episodes_data = [] + for season_num, season_episodes in episodes_data.items(): + for episode_data in season_episodes: + episode_data['_season_number'] = int(season_num) + all_episodes_data.append(episode_data) + + if not all_episodes_data: + return + + logger.info(f"Batch processing {len(all_episodes_data)} episodes for series {series.name}") + + # Extract episode identifiers + # Note: episode_keys may have duplicates when multiple streams represent same episode + episode_keys = set() # Use set to track unique episode keys + episode_ids = [] + for episode_data in all_episodes_data: + season_num = episode_data['_season_number'] + episode_num = episode_data.get('episode_num', 0) + episode_keys.add((series.id, season_num, episode_num)) + episode_ids.append(str(episode_data.get('id'))) + + # Pre-fetch existing episodes + existing_episodes = {} + for episode in Episode.objects.filter(series=series): + key = (episode.series_id, episode.season_number, episode.episode_number) + existing_episodes[key] = episode + + # Pre-fetch existing episode relations + existing_relations = { + rel.stream_id: rel for rel in M3UEpisodeRelation.objects.filter( + m3u_account=account, + stream_id__in=episode_ids + ).select_related('episode') + } + + # Prepare batch operations + episodes_to_create = [] + episodes_to_update = [] + relations_to_create = [] + relations_to_update = [] + + # Track episodes we're creating in this batch to avoid duplicates + # Key: (series_id, season_number, episode_number) -> Episode object + episodes_pending_creation = {} + + for episode_data in all_episodes_data: + try: + episode_id = str(episode_data.get('id')) + episode_name = episode_data.get('title', 'Unknown Episode') + # Ensure season and episode numbers are integers (API may return strings) + try: + season_number = int(episode_data['_season_number']) + except (ValueError, TypeError) as e: + logger.warning(f"Invalid season_number '{episode_data.get('_season_number')}' for episode '{episode_name}': {e}") + season_number = 0 + try: + episode_number = int(episode_data.get('episode_num', 0)) + except (ValueError, TypeError) as e: + logger.warning(f"Invalid episode_num '{episode_data.get('episode_num')}' for episode '{episode_name}': {e}") + episode_number = 0 + info = episode_data.get('info', {}) + + # Extract episode metadata + description = info.get('plot') or info.get('overview', '') if info else '' + rating = normalize_rating(info.get('rating')) if info else None + air_date = extract_date_from_data(info) if info else None + duration_secs = info.get('duration_secs') if info else None + tmdb_id = info.get('tmdb_id') if info else None + imdb_id = info.get('imdb_id') if info else None + + # Prepare custom properties + custom_props = {} + if info: + if info.get('crew'): + custom_props['crew'] = info.get('crew') + if info.get('movie_image'): + movie_image = extract_string_from_array_or_string(info.get('movie_image')) + if movie_image: + custom_props['movie_image'] = movie_image + backdrop = extract_string_from_array_or_string(info.get('backdrop_path')) + if backdrop: + custom_props['backdrop_path'] = [backdrop] + + # Find existing episode - check DB first, then pending creations + episode_key = (series.id, season_number, episode_number) + episode = existing_episodes.get(episode_key) + + # Check if we already have this episode pending creation (multiple streams for same episode) + if not episode and episode_key in episodes_pending_creation: + episode = episodes_pending_creation[episode_key] + logger.debug(f"Reusing pending episode for S{season_number}E{episode_number} (stream_id: {episode_id})") + + if episode: + # Update existing episode + updated = False + if episode_name != episode.name: + episode.name = episode_name + updated = True + if description != episode.description: + episode.description = description + updated = True + if rating != episode.rating: + episode.rating = rating + updated = True + if air_date != episode.air_date: + episode.air_date = air_date + updated = True + if duration_secs != episode.duration_secs: + episode.duration_secs = duration_secs + updated = True + if tmdb_id != episode.tmdb_id: + episode.tmdb_id = tmdb_id + updated = True + if imdb_id != episode.imdb_id: + episode.imdb_id = imdb_id + updated = True + if custom_props != episode.custom_properties: + episode.custom_properties = custom_props if custom_props else None + updated = True + + # Only add to update list if episode has a PK (exists in DB) and isn't already in list + # Episodes pending creation don't have PKs yet and will be created via bulk_create + if updated and episode.pk and episode not in episodes_to_update: + episodes_to_update.append(episode) + else: + # Create new episode + episode = Episode( + series=series, + name=episode_name, + description=description, + air_date=air_date, + rating=rating, + duration_secs=duration_secs, + season_number=season_number, + episode_number=episode_number, + tmdb_id=tmdb_id, + imdb_id=imdb_id, + custom_properties=custom_props if custom_props else None + ) + episodes_to_create.append(episode) + # Track this episode so subsequent streams with same season/episode can reuse it + episodes_pending_creation[episode_key] = episode + + # Handle episode relation + if episode_id in existing_relations: + # Update existing relation + relation = existing_relations[episode_id] + relation.episode = episode + relation.series_relation = series_relation + relation.container_extension = episode_data.get('container_extension', 'mp4') + relation.custom_properties = { + 'info': episode_data, + 'season_number': season_number, + } + relation.last_seen = scan_start_time or timezone.now() # Mark as seen during this scan + relations_to_update.append(relation) + else: + # Create new relation + relation = M3UEpisodeRelation( + m3u_account=account, + episode=episode, + series_relation=series_relation, + stream_id=episode_id, + container_extension=episode_data.get('container_extension', 'mp4'), + custom_properties={ + 'info': episode_data, + 'season_number': season_number, + }, + last_seen=scan_start_time or timezone.now() # Mark as seen during this scan + ) + relations_to_create.append(relation) + + except Exception as e: + logger.error(f"Error preparing episode {episode_data.get('title', 'Unknown')}: {str(e)}") + + # Execute batch operations + with transaction.atomic(): + # Create new episodes - use ignore_conflicts in case of race conditions + if episodes_to_create: + Episode.objects.bulk_create(episodes_to_create, ignore_conflicts=True) + + # Re-fetch the created episodes to get their PKs + # We need to do this because bulk_create with ignore_conflicts doesn't set PKs + created_episode_keys = [ + (ep.series_id, ep.season_number, ep.episode_number) + for ep in episodes_to_create + ] + db_episodes = Episode.objects.filter(series=series) + episode_pk_map = { + (ep.series_id, ep.season_number, ep.episode_number): ep + for ep in db_episodes + } + + # Update relations to point to the actual DB episodes with PKs + for relation in relations_to_create: + ep = relation.episode + key = (ep.series_id, ep.season_number, ep.episode_number) + if key in episode_pk_map: + relation.episode = episode_pk_map[key] + + # Filter out relations with unsaved episodes (no PK) + # This can happen if bulk_create had a conflict and ignore_conflicts=True didn't save the episode + valid_relations_to_create = [] + for relation in relations_to_create: + if relation.episode.pk is not None: + valid_relations_to_create.append(relation) + else: + season_num = relation.episode.season_number + episode_num = relation.episode.episode_number + logger.warning( + f"Skipping relation for episode S{season_num}E{episode_num} " + f"- episode not saved to database" + ) + relations_to_create = valid_relations_to_create + + # Update existing episodes + if episodes_to_update: + Episode.objects.bulk_update(episodes_to_update, [ + 'name', 'description', 'air_date', 'rating', 'duration_secs', + 'tmdb_id', 'imdb_id', 'custom_properties' + ]) + + # Create new episode relations - use ignore_conflicts for stream_id duplicates + if relations_to_create: + M3UEpisodeRelation.objects.bulk_create(relations_to_create, ignore_conflicts=True) + + # Update existing episode relations + if relations_to_update: + M3UEpisodeRelation.objects.bulk_update(relations_to_update, [ + 'episode', 'series_relation', 'container_extension', 'custom_properties', 'last_seen' + ]) + + # Delete relations for streams no longer returned by the provider. + # Scope to this series_relation FK (post-migration rows) plus any legacy NULL rows + # for the same account+series (pre-migration rows whose stream is now gone — the + # update path only backfills the FK for streams still present in the response). + # Falls back to account+series scope when series_relation is None (shouldn't occur). + if series_relation is not None: + stale_qs = M3UEpisodeRelation.objects.filter( + Q(series_relation=series_relation) | + Q(series_relation__isnull=True, m3u_account=account, episode__series=series) + ) + else: + stale_qs = M3UEpisodeRelation.objects.filter( + m3u_account=account, + episode__series=series + ) + removed_count = stale_qs.exclude(stream_id__in=episode_ids).delete()[0] + if removed_count: + logger.info(f"Removed {removed_count} episode relations no longer present in provider for series {series.name}") + + logger.info(f"Batch processed episodes: {len(episodes_to_create)} new, {len(episodes_to_update)} updated, " + f"{len(relations_to_create)} new relations, {len(relations_to_update)} updated relations") + + +@shared_task +def batch_refresh_series_episodes(account_id, series_ids=None): + """ + Batch refresh episodes for multiple series. + If series_ids is None, refresh all series that haven't been refreshed recently. + """ + try: + account = M3UAccount.objects.get(id=account_id, is_active=True) + + if account.account_type != M3UAccount.Types.XC: + logger.warning(f"Episode refresh called for non-XC account {account_id}") + return "Episode refresh only available for XtreamCodes accounts" + + # Determine which series to refresh + if series_ids: + series_relations = M3USeriesRelation.objects.filter( + m3u_account=account, + series__id__in=series_ids + ).select_related('series') + else: + # Refresh series that haven't been refreshed in the last 24 hours + cutoff_time = timezone.now() - timezone.timedelta(hours=24) + series_relations = M3USeriesRelation.objects.filter( + m3u_account=account, + last_episode_refresh__lt=cutoff_time + ).select_related('series') + + logger.info(f"Batch refreshing episodes for {series_relations.count()} series") + + with XtreamCodesClient( + account.server_url, + account.username, + account.password, + account.get_user_agent().user_agent + ) as client: + + refreshed_count = 0 + for relation in series_relations: + try: + refresh_series_episodes( + account, + relation.series, + relation.external_series_id + ) + refreshed_count += 1 + except Exception as e: + logger.error(f"Error refreshing episodes for series {relation.series.name}: {str(e)}") + + logger.info(f"Batch episode refresh completed for {refreshed_count} series") + return f"Batch episode refresh completed for {refreshed_count} series" + + except Exception as e: + logger.error(f"Error in batch episode refresh for account {account_id}: {str(e)}") + return f"Batch episode refresh failed: {str(e)}" + + +@shared_task +def cleanup_orphaned_vod_content(stale_days=0, scan_start_time=None, account_id=None): + """Clean up VOD content that has no M3U relations or has stale relations""" + from datetime import timedelta + + # Use scan start time as reference, or current time if not provided + reference_time = scan_start_time or timezone.now() + + # Calculate cutoff date for stale relations + cutoff_date = reference_time - timedelta(days=stale_days) + + # Build base query filters + base_filters = {'last_seen__lt': cutoff_date} + if account_id: + base_filters['m3u_account_id'] = account_id + logger.info(f"Cleaning up stale VOD content for account {account_id}") + else: + logger.info("Cleaning up stale VOD content across all accounts") + + # Clean up stale movie relations (haven't been seen in the specified days) + stale_movie_relations = M3UMovieRelation.objects.filter(**base_filters) + stale_movie_count = stale_movie_relations.count() + stale_movie_relations.delete() + + # Clean up stale series relations. + # Episode relations are removed via CASCADE on the series_relation FK. + stale_series_relations = M3USeriesRelation.objects.filter(**base_filters) + stale_series_count = stale_series_relations.count() + stale_series_relations.delete() + + # Clean up movies with no relations (orphaned) + # Safe to delete even during account-specific cleanup because if ANY account + # has a relation, m3u_relations will not be null + orphaned_movies = Movie.objects.filter(m3u_relations__isnull=True) + orphaned_movie_count = orphaned_movies.count() + if orphaned_movie_count > 0: + logger.info(f"Deleting {orphaned_movie_count} orphaned movies with no M3U relations") + try: + orphaned_movies.delete() + except IntegrityError: + # A concurrent refresh task created a new relation for one of these movies + # between our query and the DELETE. Skip and let the next cleanup run handle it. + logger.warning( + "Skipped some orphaned movie deletions due to concurrent modifications; " + "they will be retried on the next cleanup run." + ) + orphaned_movie_count = 0 + + # Clean up series with no relations (orphaned) + orphaned_series = Series.objects.filter(m3u_relations__isnull=True) + orphaned_series_count = orphaned_series.count() + if orphaned_series_count > 0: + logger.info(f"Deleting {orphaned_series_count} orphaned series with no M3U relations") + try: + orphaned_series.delete() + except IntegrityError: + logger.warning( + "Skipped some orphaned series deletions due to concurrent modifications; " + "they will be retried on the next cleanup run." + ) + orphaned_series_count = 0 + + result = (f"Cleaned up {stale_movie_count} stale movie relations, " + f"{stale_series_count} stale series relations, " + f"{orphaned_movie_count} orphaned movies, and " + f"{orphaned_series_count} orphaned series") + + logger.info(result) + return result + + +def handle_movie_id_conflicts(current_movie, relation, tmdb_id_to_set, imdb_id_to_set): + """ + Handle potential duplicate key conflicts when setting tmdb_id or imdb_id. + + Since this is called when a user is actively accessing movie details, we always + preserve the current movie (user's selection) and merge the existing one into it. + This prevents breaking the user's current viewing experience. + + Returns: + tuple: (movie_to_use, relation_was_updated) + """ + from django.db import IntegrityError + + existing_movie_with_tmdb = None + existing_movie_with_imdb = None + + # Check for existing movies with these IDs + if tmdb_id_to_set: + try: + existing_movie_with_tmdb = Movie.objects.get(tmdb_id=tmdb_id_to_set) + except Movie.DoesNotExist: + pass + + if imdb_id_to_set: + try: + existing_movie_with_imdb = Movie.objects.get(imdb_id=imdb_id_to_set) + except Movie.DoesNotExist: + pass + + # If no conflicts, proceed normally + if not existing_movie_with_tmdb and not existing_movie_with_imdb: + return current_movie, False + + # Determine which existing movie has the conflicting ID (prefer TMDB match) + existing_movie = existing_movie_with_tmdb or existing_movie_with_imdb + + # CRITICAL: Check if the existing movie is actually the same as the current movie + # This can happen if the current movie already has the ID we're trying to set + if existing_movie.id == current_movie.id: + logger.debug(f"Current movie {current_movie.id} already has the target ID, no conflict resolution needed") + return current_movie, False + + logger.info(f"ID conflict detected: Merging existing movie '{existing_movie.name}' (ID: {existing_movie.id}) into current movie '{current_movie.name}' (ID: {current_movie.id}) to preserve user selection") + + # FIRST: Clear the conflicting ID from the existing movie before any merging + if existing_movie_with_tmdb and tmdb_id_to_set: + logger.info(f"Clearing tmdb_id from existing movie {existing_movie.id} to avoid constraint violation") + existing_movie.tmdb_id = None + existing_movie.save(update_fields=['tmdb_id']) + + if existing_movie_with_imdb and imdb_id_to_set: + logger.info(f"Clearing imdb_id from existing movie {existing_movie.id} to avoid constraint violation") + existing_movie.imdb_id = None + existing_movie.save(update_fields=['imdb_id']) + + # THEN: Merge data from existing movie into current movie (now safe to set IDs) + merge_movie_data(source_movie=existing_movie, target_movie=current_movie, + tmdb_id_to_set=tmdb_id_to_set, imdb_id_to_set=imdb_id_to_set) + + # Transfer all relations from existing movie to current movie + existing_relations = existing_movie.m3u_relations.all() + if existing_relations.exists(): + logger.info(f"Transferring {existing_relations.count()} relations from existing movie {existing_movie.id} to current movie {current_movie.id}") + existing_relations.update(movie=current_movie) + + # Now safe to delete the existing movie since all its relations have been transferred + logger.info(f"Deleting existing movie {existing_movie.id} '{existing_movie.name}' after merging data and transferring relations") + existing_movie.delete() + + return current_movie, False # No relation update needed since we kept current movie + + +def merge_movie_data(source_movie, target_movie, tmdb_id_to_set=None, imdb_id_to_set=None): + """ + Merge valuable data from source_movie into target_movie. + Only overwrites target fields that are empty/None with non-empty source values. + + Args: + source_movie: Movie to copy data from + target_movie: Movie to copy data to + tmdb_id_to_set: TMDB ID to set on target (overrides source tmdb_id) + imdb_id_to_set: IMDB ID to set on target (overrides source imdb_id) + """ + updated = False + + # Basic fields - only fill if target is empty + if not target_movie.description and source_movie.description: + target_movie.description = source_movie.description + updated = True + logger.debug(f"Merged description from movie {source_movie.id} to {target_movie.id}") + + if not target_movie.year and source_movie.year: + target_movie.year = source_movie.year + updated = True + logger.debug(f"Merged year from movie {source_movie.id} to {target_movie.id}") + + if not target_movie.rating and source_movie.rating: + target_movie.rating = source_movie.rating + updated = True + logger.debug(f"Merged rating from movie {source_movie.id} to {target_movie.id}") + + if not target_movie.genre and source_movie.genre: + target_movie.genre = source_movie.genre + updated = True + logger.debug(f"Merged genre from movie {source_movie.id} to {target_movie.id}") + + if not target_movie.duration_secs and source_movie.duration_secs: + target_movie.duration_secs = source_movie.duration_secs + updated = True + logger.debug(f"Merged duration_secs from movie {source_movie.id} to {target_movie.id}") + + if not target_movie.logo and source_movie.logo: + target_movie.logo = source_movie.logo + updated = True + logger.debug(f"Merged logo from movie {source_movie.id} to {target_movie.id}") + + # Handle external IDs - use the specific IDs we want to set, or fall back to source + if not target_movie.tmdb_id: + if tmdb_id_to_set: + target_movie.tmdb_id = tmdb_id_to_set + updated = True + logger.debug(f"Set tmdb_id {tmdb_id_to_set} on movie {target_movie.id}") + elif source_movie.tmdb_id: + target_movie.tmdb_id = source_movie.tmdb_id + updated = True + logger.debug(f"Merged tmdb_id from movie {source_movie.id} to {target_movie.id}") + + if not target_movie.imdb_id: + if imdb_id_to_set: + target_movie.imdb_id = imdb_id_to_set + updated = True + logger.debug(f"Set imdb_id {imdb_id_to_set} on movie {target_movie.id}") + elif source_movie.imdb_id: + target_movie.imdb_id = source_movie.imdb_id + updated = True + logger.debug(f"Merged imdb_id from movie {source_movie.id} to {target_movie.id}") + + # Merge custom properties + target_props = target_movie.custom_properties or {} + source_props = source_movie.custom_properties or {} + + for key, value in source_props.items(): + if value and not target_props.get(key): + target_props[key] = value + updated = True + logger.debug(f"Merged custom property '{key}' from movie {source_movie.id} to {target_movie.id}") + + if updated: + target_movie.custom_properties = target_props + target_movie.save() + logger.info(f"Successfully merged data from movie {source_movie.id} into {target_movie.id}") + + +def handle_series_id_conflicts(current_series, relation, tmdb_id_to_set, imdb_id_to_set): + """ + Handle potential duplicate key conflicts when setting tmdb_id or imdb_id for series. + + Since this is called when a user is actively accessing series details, we always + preserve the current series (user's selection) and merge the existing one into it. + This prevents breaking the user's current viewing experience. + + Returns: + tuple: (series_to_use, relation_was_updated) + """ + from django.db import IntegrityError + + existing_series_with_tmdb = None + existing_series_with_imdb = None + + # Check for existing series with these IDs + if tmdb_id_to_set: + try: + existing_series_with_tmdb = Series.objects.get(tmdb_id=tmdb_id_to_set) + except Series.DoesNotExist: + pass + + if imdb_id_to_set: + try: + existing_series_with_imdb = Series.objects.get(imdb_id=imdb_id_to_set) + except Series.DoesNotExist: + pass + + # If no conflicts, proceed normally + if not existing_series_with_tmdb and not existing_series_with_imdb: + return current_series, False + + # Determine which existing series has the conflicting ID (prefer TMDB match) + existing_series = existing_series_with_tmdb or existing_series_with_imdb + + # CRITICAL: Check if the existing series is actually the same as the current series + # This can happen if the current series already has the ID we're trying to set + if existing_series.id == current_series.id: + logger.debug(f"Current series {current_series.id} already has the target ID, no conflict resolution needed") + return current_series, False + + logger.info(f"ID conflict detected: Merging existing series '{existing_series.name}' (ID: {existing_series.id}) into current series '{current_series.name}' (ID: {current_series.id}) to preserve user selection") + + # FIRST: Clear the conflicting ID from the existing series before any merging + if existing_series_with_tmdb and tmdb_id_to_set: + logger.info(f"Clearing tmdb_id from existing series {existing_series.id} to avoid constraint violation") + existing_series.tmdb_id = None + existing_series.save(update_fields=['tmdb_id']) + + if existing_series_with_imdb and imdb_id_to_set: + logger.info(f"Clearing imdb_id from existing series {existing_series.id} to avoid constraint violation") + existing_series.imdb_id = None + existing_series.save(update_fields=['imdb_id']) + + # THEN: Merge data from existing series into current series (now safe to set IDs) + merge_series_data(source_series=existing_series, target_series=current_series, + tmdb_id_to_set=tmdb_id_to_set, imdb_id_to_set=imdb_id_to_set) + + # Transfer all relations from existing series to current series + existing_relations = existing_series.m3u_relations.all() + if existing_relations.exists(): + logger.info(f"Transferring {existing_relations.count()} relations from existing series {existing_series.id} to current series {current_series.id}") + existing_relations.update(series=current_series) + + # Now safe to delete the existing series since all its relations have been transferred + logger.info(f"Deleting existing series {existing_series.id} '{existing_series.name}' after merging data and transferring relations") + existing_series.delete() + + return current_series, False # No relation update needed since we kept current series + + +def merge_series_data(source_series, target_series, tmdb_id_to_set=None, imdb_id_to_set=None): + """ + Merge valuable data from source_series into target_series. + Only overwrites target fields that are empty/None with non-empty source values. + + Args: + source_series: Series to copy data from + target_series: Series to copy data to + tmdb_id_to_set: TMDB ID to set on target (overrides source tmdb_id) + imdb_id_to_set: IMDB ID to set on target (overrides source imdb_id) + """ + updated = False + + # Basic fields - only fill if target is empty + if not target_series.description and source_series.description: + target_series.description = source_series.description + updated = True + logger.debug(f"Merged description from series {source_series.id} to {target_series.id}") + + if not target_series.year and source_series.year: + target_series.year = source_series.year + updated = True + logger.debug(f"Merged year from series {source_series.id} to {target_series.id}") + + if not target_series.rating and source_series.rating: + target_series.rating = source_series.rating + updated = True + logger.debug(f"Merged rating from series {source_series.id} to {target_series.id}") + + if not target_series.genre and source_series.genre: + target_series.genre = source_series.genre + updated = True + logger.debug(f"Merged genre from series {source_series.id} to {target_series.id}") + + if not target_series.logo and source_series.logo: + target_series.logo = source_series.logo + updated = True + logger.debug(f"Merged logo from series {source_series.id} to {target_series.id}") + + # Handle external IDs - use the specific IDs we want to set, or fall back to source + if not target_series.tmdb_id: + if tmdb_id_to_set: + target_series.tmdb_id = tmdb_id_to_set + updated = True + logger.debug(f"Set tmdb_id {tmdb_id_to_set} on series {target_series.id}") + elif source_series.tmdb_id: + target_series.tmdb_id = source_series.tmdb_id + updated = True + logger.debug(f"Merged tmdb_id from series {source_series.id} to {target_series.id}") + + if not target_series.imdb_id: + if imdb_id_to_set: + target_series.imdb_id = imdb_id_to_set + updated = True + logger.debug(f"Set imdb_id {imdb_id_to_set} on series {target_series.id}") + elif source_series.imdb_id: + target_series.imdb_id = source_series.imdb_id + updated = True + logger.debug(f"Merged imdb_id from series {source_series.id} to {target_series.id}") + + # Merge custom properties + target_props = target_series.custom_properties or {} + source_props = source_series.custom_properties or {} + + for key, value in source_props.items(): + if value and not target_props.get(key): + target_props[key] = value + updated = True + logger.debug(f"Merged custom property '{key}' from series {source_series.id} to {target_series.id}") + + if updated: + target_series.custom_properties = target_props + target_series.save() + logger.info(f"Successfully merged data from series {source_series.id} into {target_series.id}") + + +def is_non_empty_string(value): + """ + Helper function to safely check if a value is a non-empty string. + Returns True only if value is a string and has non-whitespace content. + """ + return isinstance(value, str) and value.strip() + + +def extract_string_from_array_or_string(value): + """ + Helper function to extract a string value from either a string or array. + Returns the first non-null string from an array, or the string itself. + Returns None if no valid string is found. + """ + if isinstance(value, str): + return value.strip() if value.strip() else None + elif isinstance(value, list) and value: + # Find first non-null, non-empty string in the array + for item in value: + if isinstance(item, str) and item.strip(): + return item.strip() + elif item is not None and str(item).strip(): + return str(item).strip() + return None + + +def clean_custom_properties(custom_props): + """ + Remove null, empty, or invalid values from custom_properties dict. + Only keeps properties that have meaningful values. + """ + if not custom_props: + return None + + cleaned = {} + for key, value in custom_props.items(): + # Handle fields that should extract clean strings + if key in ['youtube_trailer', 'actors', 'director', 'cast']: + clean_value = extract_string_from_array_or_string(value) + if clean_value: + cleaned[key] = clean_value + # Handle backdrop_path which should remain as array format + elif key == 'backdrop_path': + clean_value = extract_string_from_array_or_string(value) + if clean_value: + cleaned[key] = [clean_value] + else: + # For other properties, keep them if they're not None and not empty + if value is not None and value != '' and value != []: + # If it's a list with only null values, skip it + if isinstance(value, list) and all(item is None for item in value): + continue + cleaned[key] = value + + return cleaned if cleaned else None + + +def should_update_field(existing_value, new_value): + """ + Helper function to determine if we should update a field. + Returns True if: + - new_value is a non-empty string (or contains one if it's an array) AND + - existing_value is None, empty string, array with null/empty values, or non-string + """ + # Extract actual string values from arrays if needed + new_string = extract_string_from_array_or_string(new_value) + existing_string = extract_string_from_array_or_string(existing_value) + + return new_string is not None and (existing_string is None or not existing_string) + + +@shared_task +def refresh_movie_advanced_data(m3u_movie_relation_id, force_refresh=False): + """ + Fetch advanced movie data from provider and update Movie and M3UMovieRelation. + Only fetch if last_advanced_refresh > 24h ago, unless force_refresh is True. + """ + try: + relation = M3UMovieRelation.objects.select_related('movie', 'm3u_account').get(id=m3u_movie_relation_id) + now = timezone.now() + if not force_refresh and relation.last_advanced_refresh and (now - relation.last_advanced_refresh).total_seconds() < 86400: + return "Advanced data recently fetched, skipping." + + account = relation.m3u_account + movie = relation.movie + + from core.xtream_codes import Client as XtreamCodesClient + + with XtreamCodesClient( + server_url=account.server_url, + username=account.username, + password=account.password, + user_agent=account.get_user_agent().user_agent + ) as client: + vod_info = client.get_vod_info(relation.stream_id) + if vod_info and 'info' in vod_info: + info_raw = vod_info.get('info', {}) + + # Handle case where 'info' might be a list instead of dict + if isinstance(info_raw, list): + # If it's a list, try to use the first item or create empty dict + info = info_raw[0] if info_raw and isinstance(info_raw[0], dict) else {} + logger.warning(f"VOD info for stream {relation.stream_id} returned list instead of dict, using first item") + elif isinstance(info_raw, dict): + info = info_raw + else: + info = {} + logger.warning(f"VOD info for stream {relation.stream_id} returned unexpected type: {type(info_raw)}") + + movie_data_raw = vod_info.get('movie_data', {}) + + # Handle case where 'movie_data' might be a list instead of dict + if isinstance(movie_data_raw, list): + movie_data = movie_data_raw[0] if movie_data_raw and isinstance(movie_data_raw[0], dict) else {} + logger.warning(f"VOD movie_data for stream {relation.stream_id} returned list instead of dict, using first item") + elif isinstance(movie_data_raw, dict): + movie_data = movie_data_raw + else: + movie_data = {} + logger.warning(f"VOD movie_data for stream {relation.stream_id} returned unexpected type: {type(movie_data_raw)}") + + # Update Movie fields if changed + updated = False + custom_props = movie.custom_properties or {} + if info.get('plot') and info.get('plot') != movie.description: + movie.description = info.get('plot') + updated = True + normalized_rating = normalize_rating(info.get('rating')) + if normalized_rating and normalized_rating != movie.rating: + movie.rating = normalized_rating + updated = True + if info.get('genre') and info.get('genre') != movie.genre: + movie.genre = info.get('genre') + updated = True + if info.get('duration_secs'): + duration_secs = int(info.get('duration_secs')) + if duration_secs != movie.duration_secs: + movie.duration_secs = duration_secs + updated = True + # Check for releasedate or release_date + release_date_value = info.get('releasedate') or info.get('release_date') + if release_date_value: + try: + year = int(str(release_date_value).split('-')[0]) + if year != movie.year: + movie.year = year + updated = True + except Exception: + pass + # Handle TMDB/IMDB ID updates with duplicate key protection + tmdb_id_to_set = info.get('tmdb_id') if info.get('tmdb_id') and info.get('tmdb_id') != movie.tmdb_id else None + imdb_id_to_set = info.get('imdb_id') if info.get('imdb_id') and info.get('imdb_id') != movie.imdb_id else None + + logger.debug(f"Movie {movie.id} current IDs: tmdb_id={movie.tmdb_id}, imdb_id={movie.imdb_id}") + logger.debug(f"IDs to set: tmdb_id={tmdb_id_to_set}, imdb_id={imdb_id_to_set}") + + if tmdb_id_to_set or imdb_id_to_set: + # Check for existing movies with these IDs and handle duplicates + updated_movie, relation_updated = handle_movie_id_conflicts( + movie, relation, tmdb_id_to_set, imdb_id_to_set + ) + if relation_updated: + # If the relation was updated to point to a different movie, + # we need to update our reference and continue with that movie + movie = updated_movie + logger.info(f"Relation updated, now working with movie {movie.id}") + else: + # No relation update, safe to set the IDs + if tmdb_id_to_set: + movie.tmdb_id = tmdb_id_to_set + updated = True + logger.debug(f"Set tmdb_id {tmdb_id_to_set} on movie {movie.id}") + if imdb_id_to_set: + movie.imdb_id = imdb_id_to_set + updated = True + logger.debug(f"Set imdb_id {imdb_id_to_set} on movie {movie.id}") + # Only update trailer if we have a non-empty value and either no existing value or existing value is empty + if should_update_field(custom_props.get('youtube_trailer'), info.get('trailer')): + custom_props['youtube_trailer'] = extract_string_from_array_or_string(info.get('trailer')) + updated = True + if should_update_field(custom_props.get('youtube_trailer'), info.get('youtube_trailer')): + custom_props['youtube_trailer'] = extract_string_from_array_or_string(info.get('youtube_trailer')) + updated = True + # Only update backdrop_path if we have a non-empty value and either no existing value or existing value is empty + if should_update_field(custom_props.get('backdrop_path'), info.get('backdrop_path')): + backdrop_url = extract_string_from_array_or_string(info.get('backdrop_path')) + custom_props['backdrop_path'] = [backdrop_url] if backdrop_url else None + updated = True + # Only update actors if we have a non-empty value and either no existing value or existing value is empty + if should_update_field(custom_props.get('actors'), info.get('actors')): + custom_props['actors'] = extract_string_from_array_or_string(info.get('actors')) + updated = True + if should_update_field(custom_props.get('actors'), info.get('cast')): + custom_props['actors'] = extract_string_from_array_or_string(info.get('cast')) + updated = True + # Only update director if we have a non-empty value and either no existing value or existing value is empty + if should_update_field(custom_props.get('director'), info.get('director')): + custom_props['director'] = extract_string_from_array_or_string(info.get('director')) + updated = True + if updated: + # Clean custom_properties before saving to remove null/empty values + movie.custom_properties = clean_custom_properties(custom_props) + try: + movie.save() + except Exception as save_error: + # If we still get an integrity error after our conflict resolution, + # log it and try to save without the problematic IDs + logger.error(f"Failed to save movie {movie.id} after conflict resolution: {str(save_error)}") + if 'tmdb_id' in str(save_error) and movie.tmdb_id: + logger.warning(f"Clearing tmdb_id {movie.tmdb_id} from movie {movie.id} due to save error") + movie.tmdb_id = None + if 'imdb_id' in str(save_error) and movie.imdb_id: + logger.warning(f"Clearing imdb_id {movie.imdb_id} from movie {movie.id} due to save error") + movie.imdb_id = None + try: + movie.save() + logger.info(f"Successfully saved movie {movie.id} after clearing problematic IDs") + except Exception as final_error: + logger.error(f"Final save attempt failed for movie {movie.id}: {str(final_error)}") + raise + + # Update relation custom_properties and last_advanced_refresh + relation_custom_props = relation.custom_properties or {} + + # Clean the detailed_info before saving to avoid storing null/empty arrays + cleaned_info = clean_custom_properties(info) if info else None + cleaned_movie_data = clean_custom_properties(movie_data) if movie_data else None + + if cleaned_info: + relation_custom_props['detailed_info'] = cleaned_info + if cleaned_movie_data: + relation_custom_props['movie_data'] = cleaned_movie_data + relation_custom_props['detailed_fetched'] = True + + relation.custom_properties = relation_custom_props + relation.last_advanced_refresh = now + relation.save(update_fields=['custom_properties', 'last_advanced_refresh']) + + return "Advanced data refreshed." + except Exception as e: + logger.error(f"Error refreshing advanced movie data for relation {m3u_movie_relation_id}: {str(e)}") + return f"Error: {str(e)}" diff --git a/apps/vod/urls.py b/apps/vod/urls.py new file mode 100644 index 0000000..3cea96a --- /dev/null +++ b/apps/vod/urls.py @@ -0,0 +1,16 @@ +from django.urls import path, include +from rest_framework.routers import DefaultRouter +from .api_views import MovieViewSet, EpisodeViewSet, SeriesViewSet, VODCategoryViewSet, UnifiedContentViewSet + +app_name = 'vod' + +router = DefaultRouter() +router.register(r'movies', MovieViewSet) +router.register(r'episodes', EpisodeViewSet) +router.register(r'series', SeriesViewSet) +router.register(r'categories', VODCategoryViewSet) +router.register(r'all', UnifiedContentViewSet, basename='unified-content') + +urlpatterns = [ + path('api/', include(router.urls)), +] diff --git a/core/__init__.py b/core/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/core/admin.py b/core/admin.py new file mode 100644 index 0000000..45bb7a2 --- /dev/null +++ b/core/admin.py @@ -0,0 +1,41 @@ +# core/admin.py + +from django.contrib import admin +from .models import UserAgent, StreamProfile, CoreSettings + +@admin.register(UserAgent) +class UserAgentAdmin(admin.ModelAdmin): + list_display = ( + "name", + "user_agent", + "description", + "is_active", + "created_at", + "updated_at", + ) + search_fields = ("name", "user_agent", "description") + list_filter = ("is_active",) + readonly_fields = ("created_at", "updated_at") + +@admin.register(StreamProfile) +class StreamProfileAdmin(admin.ModelAdmin): + list_display = ( + "name", + "command", + "is_active", + "user_agent", + ) + search_fields = ("name", "command", "user_agent") + list_filter = ("is_active",) + +@admin.register(CoreSettings) +class CoreSettingsAdmin(admin.ModelAdmin): + """ + Because CoreSettings is typically a single 'singleton' row, + you can either allow multiple or restrict it. For now, we + just list and allow editing of any instance. + """ + list_display = ( + "key", + "value", + ) diff --git a/core/api_urls.py b/core/api_urls.py new file mode 100644 index 0000000..1ac5845 --- /dev/null +++ b/core/api_urls.py @@ -0,0 +1,29 @@ +# core/api_urls.py + +from django.urls import path, include +from rest_framework.routers import DefaultRouter +from .api_views import ( + UserAgentViewSet, + StreamProfileViewSet, + CoreSettingsViewSet, + SystemNotificationViewSet, + environment, + version, + rehash_streams_endpoint, + TimezoneListView, + get_system_events +) + +router = DefaultRouter() +router.register(r'useragents', UserAgentViewSet, basename='useragent') +router.register(r'streamprofiles', StreamProfileViewSet, basename='streamprofile') +router.register(r'settings', CoreSettingsViewSet, basename='coresettings') +router.register(r'notifications', SystemNotificationViewSet, basename='systemnotification') +urlpatterns = [ + path('settings/env/', environment, name='token_refresh'), + path('version/', version, name='version'), + path('rehash-streams/', rehash_streams_endpoint, name='rehash_streams'), + path('timezones/', TimezoneListView.as_view(), name='timezones'), + path('system-events/', get_system_events, name='system_events'), + path('', include(router.urls)), +] diff --git a/core/api_views.py b/core/api_views.py new file mode 100644 index 0000000..596da87 --- /dev/null +++ b/core/api_views.py @@ -0,0 +1,665 @@ +# core/api_views.py + +import json +import ipaddress +import logging +from django.conf import settings as django_settings +from django.db import models +from rest_framework import viewsets, status +from rest_framework.response import Response +from rest_framework.views import APIView +from django.shortcuts import get_object_or_404 +from rest_framework.permissions import IsAuthenticated, AllowAny +from rest_framework.decorators import api_view, permission_classes, action +from drf_spectacular.utils import extend_schema, OpenApiParameter +from drf_spectacular.types import OpenApiTypes +from .models import ( + UserAgent, + StreamProfile, + CoreSettings, + STREAM_SETTINGS_KEY, + DVR_SETTINGS_KEY, + NETWORK_ACCESS_KEY, + PROXY_SETTINGS_KEY, +) +from .serializers import ( + UserAgentSerializer, + StreamProfileSerializer, + CoreSettingsSerializer, + ProxySettingsSerializer, +) + +import socket +import requests +import os +from core.tasks import rehash_streams +from apps.accounts.permissions import ( + Authenticated, + IsAdmin, + IsStandardUser, + permission_classes_by_action, +) +from dispatcharr.utils import get_client_ip + + +logger = logging.getLogger(__name__) + + +class UserAgentViewSet(viewsets.ModelViewSet): + """ + API endpoint that allows user agents to be viewed, created, edited, or deleted. + """ + + queryset = UserAgent.objects.all() + serializer_class = UserAgentSerializer + + def get_permissions(self): + try: + return [perm() for perm in permission_classes_by_action[self.action]] + except KeyError: + return [Authenticated()] + + +class StreamProfileViewSet(viewsets.ModelViewSet): + """ + API endpoint that allows stream profiles to be viewed, created, edited, or deleted. + """ + + queryset = StreamProfile.objects.all() + serializer_class = StreamProfileSerializer + + def get_permissions(self): + try: + return [perm() for perm in permission_classes_by_action[self.action]] + except KeyError: + return [Authenticated()] + + +class CoreSettingsViewSet(viewsets.ModelViewSet): + """ + API endpoint for editing core settings. + This is treated as a singleton: only one instance should exist. + """ + + queryset = CoreSettings.objects.all() + serializer_class = CoreSettingsSerializer + + def get_permissions(self): + try: + return [perm() for perm in permission_classes_by_action[self.action]] + except KeyError: + return [Authenticated()] + + def update(self, request, *args, **kwargs): + instance = self.get_object() + old_value = instance.value + response = super().update(request, *args, **kwargs) + + # If stream settings changed and m3u_hash_key is different, rehash streams + if instance.key == STREAM_SETTINGS_KEY: + new_value = request.data.get("value", {}) + if isinstance(new_value, dict) and isinstance(old_value, dict): + old_hash = old_value.get("m3u_hash_key", "") + new_hash = new_value.get("m3u_hash_key", "") + if old_hash != new_hash: + hash_keys = new_hash.split(",") if isinstance(new_hash, str) else new_hash + rehash_streams.delay(hash_keys) + + # If DVR settings changed and pre/post offsets are different, reschedule upcoming recordings + if instance.key == DVR_SETTINGS_KEY: + new_value = request.data.get("value", {}) + if isinstance(new_value, dict) and isinstance(old_value, dict): + old_pre = old_value.get("pre_offset_minutes") + new_pre = new_value.get("pre_offset_minutes") + old_post = old_value.get("post_offset_minutes") + new_post = new_value.get("post_offset_minutes") + if old_pre != new_pre or old_post != new_post: + try: + # Prefer async task if Celery is available + from apps.channels.tasks import reschedule_upcoming_recordings_for_offset_change + reschedule_upcoming_recordings_for_offset_change.delay() + except Exception: + # Fallback to synchronous implementation + from apps.channels.tasks import reschedule_upcoming_recordings_for_offset_change_impl + reschedule_upcoming_recordings_for_offset_change_impl() + + return response + + def create(self, request, *args, **kwargs): + response = super().create(request, *args, **kwargs) + # If creating DVR settings with offset values, reschedule upcoming recordings + try: + key = request.data.get("key") + if key == DVR_SETTINGS_KEY: + value = request.data.get("value", {}) + if isinstance(value, dict) and ("pre_offset_minutes" in value or "post_offset_minutes" in value): + try: + from apps.channels.tasks import reschedule_upcoming_recordings_for_offset_change + reschedule_upcoming_recordings_for_offset_change.delay() + except Exception: + from apps.channels.tasks import reschedule_upcoming_recordings_for_offset_change_impl + reschedule_upcoming_recordings_for_offset_change_impl() + except Exception: + pass + return response + @action(detail=False, methods=["post"], url_path="check") + def check(self, request, *args, **kwargs): + data = request.data + + if data.get("key") == NETWORK_ACCESS_KEY: + client_ip = ipaddress.ip_address(get_client_ip(request)) + + in_network = {} + invalid = [] + + value = data.get("value", {}) + for key, val in value.items(): + in_network[key] = [] + cidrs = val.split(",") + for cidr in cidrs: + try: + network = ipaddress.ip_network(cidr) + + if client_ip in network: + in_network[key] = [] + break + + in_network[key].append(cidr) + except: + invalid.append(cidr) + + if len(invalid) > 0: + return Response( + { + "error": True, + "message": "Invalid CIDR(s)", + "data": invalid, + }, + status=status.HTTP_200_OK, + ) + + response_data = { + **in_network, + "client_ip": str(client_ip) + } + return Response(response_data, status=status.HTTP_200_OK) + + return Response({}, status=status.HTTP_200_OK) + +class ProxySettingsViewSet(viewsets.ViewSet): + """ + API endpoint for proxy settings stored as JSON in CoreSettings. + """ + serializer_class = ProxySettingsSerializer + + def get_permissions(self): + if self.action in ('list', 'retrieve'): + return [IsStandardUser()] + return [IsAdmin()] + + def _get_or_create_settings(self): + """Get or create the proxy settings CoreSettings entry""" + try: + settings_obj = CoreSettings.objects.get(key=PROXY_SETTINGS_KEY) + settings_data = settings_obj.value + except CoreSettings.DoesNotExist: + # Create default settings + settings_data = { + "buffering_timeout": 15, + "buffering_speed": 1.0, + "redis_chunk_ttl": 60, + "channel_shutdown_delay": 0, + "channel_init_grace_period": 5, + "new_client_behind_seconds": 5, + } + settings_obj, created = CoreSettings.objects.get_or_create( + key=PROXY_SETTINGS_KEY, + defaults={ + "name": "Proxy Settings", + "value": settings_data + } + ) + return settings_obj, settings_data + + def list(self, request): + """Return proxy settings""" + settings_obj, settings_data = self._get_or_create_settings() + return Response(settings_data) + + def retrieve(self, request, pk=None): + """Return proxy settings regardless of ID""" + settings_obj, settings_data = self._get_or_create_settings() + return Response(settings_data) + + def update(self, request, pk=None): + """Update proxy settings""" + settings_obj, current_data = self._get_or_create_settings() + + serializer = ProxySettingsSerializer(data=request.data) + serializer.is_valid(raise_exception=True) + + # Update the JSON data - store as dict directly + settings_obj.value = serializer.validated_data + settings_obj.save() + + return Response(serializer.validated_data) + + def partial_update(self, request, pk=None): + """Partially update proxy settings""" + settings_obj, current_data = self._get_or_create_settings() + + # Merge current data with new data + updated_data = {**current_data, **request.data} + + serializer = ProxySettingsSerializer(data=updated_data) + serializer.is_valid(raise_exception=True) + + # Update the JSON data - store as dict directly + settings_obj.value = serializer.validated_data + settings_obj.save() + + return Response(serializer.validated_data) + + @action(detail=False, methods=['get', 'patch']) + def settings(self, request): + """Get or update the proxy settings.""" + if request.method == 'GET': + return self.list(request) + elif request.method == 'PATCH': + return self.partial_update(request) + + + +@extend_schema( + description="Endpoint for environment details", +) +@api_view(["GET"]) +@permission_classes([Authenticated]) +def environment(request): + public_ip = None + local_ip = None + country_code = None + country_name = None + + # 1) Get the public IP from ipify.org API + try: + r = requests.get("https://api64.ipify.org?format=json", timeout=5) + r.raise_for_status() + public_ip = r.json().get("ip") + except requests.RequestException as e: + public_ip = f"Error: {e}" + + # 2) Get the local IP by connecting to a public DNS server + try: + s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) + # connect to a "public" address so the OS can determine our local interface + s.connect(("8.8.8.8", 80)) + local_ip = s.getsockname()[0] + s.close() + except Exception as e: + local_ip = f"Error: {e}" + + # 3) Get geolocation data from ipapi.co or ip-api.com + if public_ip and "Error" not in public_ip: + try: + # Attempt to get geo information from ipapi.co first + r = requests.get(f"https://ipapi.co/{public_ip}/json/", timeout=5) + + if r.status_code == requests.codes.ok: + geo = r.json() + country_code = geo.get("country_code") # e.g. "US" + country_name = geo.get("country_name") # e.g. "United States" + + else: + # If ipapi.co fails, fallback to ip-api.com + # only supports http requests for free tier + r = requests.get("http://ip-api.com/json/", timeout=5) + + if r.status_code == requests.codes.ok: + geo = r.json() + country_code = geo.get("countryCode") # e.g. "US" + country_name = geo.get("country") # e.g. "United States" + + else: + raise Exception("Geo lookup failed with both services") + + except Exception as e: + logger.error(f"Error during geo lookup: {e}") + country_code = None + country_name = None + + # 4) Get environment mode and TLS status from settings + postgres_ssl = getattr(django_settings, "POSTGRES_SSL", False) + + return Response( + { + "authenticated": True, + "public_ip": public_ip, + "local_ip": local_ip, + "country_code": country_code, + "country_name": country_name, + "env_mode": os.getenv("DISPATCHARR_ENV", "aio"), + "redis_tls": { + "enabled": getattr(django_settings, "REDIS_SSL", False), + "verify": getattr(django_settings, "REDIS_SSL_VERIFY", True), + "mtls": bool(getattr(django_settings, "REDIS_SSL_CERT", "") and getattr(django_settings, "REDIS_SSL_KEY", "")), + }, + "postgres_tls": { + "enabled": postgres_ssl, + "ssl_mode": getattr(django_settings, "POSTGRES_SSL_MODE", "verify-full") if postgres_ssl else None, + "mtls": bool(getattr(django_settings, "POSTGRES_SSL_CERT", "") and getattr(django_settings, "POSTGRES_SSL_KEY", "")), + }, + } + ) + + +@extend_schema( + description="Get application version information", +) +@api_view(["GET"]) +@permission_classes([AllowAny]) +def version(request): + # Import version information + from version import __version__, __timestamp__ + + return Response( + { + "version": __version__, + "timestamp": __timestamp__, + } + ) + + +@extend_schema( + description="Trigger rehashing of all streams", +) +@api_view(["POST"]) +@permission_classes([Authenticated]) +def rehash_streams_endpoint(request): + """Trigger the rehash streams task""" + try: + # Get the current hash keys from settings + hash_key = CoreSettings.get_m3u_hash_key() + hash_keys = hash_key.split(",") if isinstance(hash_key, str) else hash_key + + # Queue the rehash task + task = rehash_streams.delay(hash_keys) + + return Response({ + "success": True, + "message": "Stream rehashing task has been queued", + "task_id": task.id + }, status=status.HTTP_200_OK) + + except Exception as e: + return Response({ + "success": False, + "message": f"Error triggering rehash: {str(e)}" + }, status=status.HTTP_400_BAD_REQUEST) + + except Exception as e: + logger.error(f"Error triggering rehash streams: {e}") + return Response({ + "success": False, + "message": "Failed to trigger rehash task" + }, status=status.HTTP_500_INTERNAL_SERVER_ERROR) + + +# ───────────────────────────── +# Timezone List API +# ───────────────────────────── +class TimezoneListView(APIView): + """ + API endpoint that returns all available timezones supported by pytz. + Returns a list of timezone names grouped by region for easy selection. + This is a general utility endpoint that can be used throughout the application. + """ + + def get_permissions(self): + return [Authenticated()] + + @extend_schema( + description="Get list of all supported timezones", + ) + def get(self, request): + import pytz + + # Get all common timezones (excludes deprecated ones) + all_timezones = sorted(pytz.common_timezones) + + # Group by region for better UX + grouped = {} + for tz in all_timezones: + if '/' in tz: + region = tz.split('/')[0] + if region not in grouped: + grouped[region] = [] + grouped[region].append(tz) + else: + # Handle special zones like UTC, GMT, etc. + if 'Other' not in grouped: + grouped['Other'] = [] + grouped['Other'].append(tz) + + return Response({ + 'timezones': all_timezones, + 'grouped': grouped, + 'count': len(all_timezones) + }) + + +# ───────────────────────────── +# System Events API +# ───────────────────────────── +@api_view(['GET']) +@permission_classes([IsAuthenticated]) +def get_system_events(request): + """ + Get recent system events (channel start/stop, buffering, client connections, etc.) + + Query Parameters: + limit: Number of events to return per page (default: 100, max: 1000) + offset: Number of events to skip (for pagination, default: 0) + event_type: Filter by specific event type (optional) + """ + from core.models import SystemEvent + + try: + # Get pagination params + limit = min(int(request.GET.get('limit', 100)), 1000) + offset = int(request.GET.get('offset', 0)) + + # Start with all events + events = SystemEvent.objects.all() + + # Filter by event_type if provided + event_type = request.GET.get('event_type') + if event_type: + events = events.filter(event_type=event_type) + + # Get total count before applying pagination + total_count = events.count() + + # Apply offset and limit for pagination + events = events[offset:offset + limit] + + # Serialize the data + events_data = [{ + 'id': event.id, + 'event_type': event.event_type, + 'event_type_display': event.get_event_type_display(), + 'timestamp': event.timestamp.isoformat(), + 'channel_id': str(event.channel_id) if event.channel_id else None, + 'channel_name': event.channel_name, + 'details': event.details + } for event in events] + + return Response({ + 'events': events_data, + 'count': len(events_data), + 'total': total_count, + 'offset': offset, + 'limit': limit + }) + + except Exception as e: + logger.error(f"Error fetching system events: {e}") + return Response({ + 'error': 'Failed to fetch system events' + }, status=status.HTTP_500_INTERNAL_SERVER_ERROR) + + +# ───────────────────────────── +# System Notifications API +# ───────────────────────────── +from .models import SystemNotification, NotificationDismissal +from .serializers import SystemNotificationSerializer, NotificationDismissalSerializer +from django.utils import timezone as dj_timezone + + +class SystemNotificationViewSet(viewsets.ModelViewSet): + """ + API endpoint for system notifications. + Users can view active notifications and dismiss them. + Admins can create and manage notifications. + """ + serializer_class = SystemNotificationSerializer + permission_classes = [IsAuthenticated] + + def get_queryset(self): + """ + Return notifications based on user permissions. + Filter out expired and dismissed notifications for regular users. + Evaluate conditions for developer notifications. + """ + from core.developer_notifications import evaluate_conditions + from django.core.cache import cache + + user = self.request.user + now = dj_timezone.now() + + queryset = SystemNotification.objects.filter(is_active=True) + + # Filter out expired notifications + queryset = queryset.filter( + models.Q(expires_at__isnull=True) | models.Q(expires_at__gt=now) + ) + + # Filter admin-only notifications for non-admins + if getattr(user, 'user_level', 0) < 10: + queryset = queryset.filter(admin_only=False) + + # For developer notifications, evaluate conditions + # Cache the evaluation per notification to avoid repeated condition checks + notifications_to_exclude = [] + developer_notifications = queryset.filter(source=SystemNotification.Source.DEVELOPER) + + for notification in developer_notifications: + action_data = notification.action_data or {} + conditions = action_data.get('condition', []) + + if not conditions: + continue + + # Cache key based on notification ID and current settings + # Cache for 5 minutes to balance freshness with performance + cache_key = f'dev_notif_condition_{notification.id}_{user.id}' + should_show = cache.get(cache_key) + + if should_show is None: + should_show = evaluate_conditions(conditions, user) + cache.set(cache_key, should_show, timeout=300) # 5 minutes + + if not should_show: + notifications_to_exclude.append(notification.id) + + if notifications_to_exclude: + queryset = queryset.exclude(id__in=notifications_to_exclude) + + return queryset + + def get_serializer_context(self): + context = super().get_serializer_context() + context['request'] = self.request + return context + + def list(self, request): + """ + List all active notifications for the current user. + Optionally filter by dismissed status. + """ + queryset = self.get_queryset() + + # Optional: filter out already dismissed notifications + include_dismissed = request.query_params.get('include_dismissed', 'false').lower() == 'true' + if not include_dismissed: + dismissed_ids = NotificationDismissal.objects.filter( + user=request.user + ).values_list('notification_id', flat=True) + queryset = queryset.exclude(id__in=dismissed_ids) + + serializer = self.get_serializer(queryset, many=True) + return Response({ + 'notifications': serializer.data, + 'count': len(serializer.data), + 'unread_count': queryset.count() + }) + + @action(detail=True, methods=['post'], url_path='dismiss') + def dismiss(self, request, pk=None): + """Dismiss a notification for the current user.""" + notification = self.get_object() + action_taken = request.data.get('action_taken', None) + + dismissal, created = NotificationDismissal.objects.get_or_create( + user=request.user, + notification=notification, + defaults={'action_taken': action_taken} + ) + + if not created and action_taken: + dismissal.action_taken = action_taken + dismissal.save() + + return Response({ + 'success': True, + 'message': 'Notification dismissed', + 'notification_key': notification.notification_key + }) + + @action(detail=False, methods=['post'], url_path='dismiss-all') + def dismiss_all(self, request): + """Dismiss all notifications for the current user.""" + notifications = self.get_queryset() + + # Get notifications not yet dismissed + dismissed_ids = NotificationDismissal.objects.filter( + user=request.user + ).values_list('notification_id', flat=True) + to_dismiss = notifications.exclude(id__in=dismissed_ids) + + # Create dismissals for all + dismissals = [ + NotificationDismissal(user=request.user, notification=n) + for n in to_dismiss + ] + NotificationDismissal.objects.bulk_create(dismissals, ignore_conflicts=True) + + return Response({ + 'success': True, + 'dismissed_count': len(dismissals) + }) + + @action(detail=False, methods=['get'], url_path='count') + def unread_count(self, request): + """Get count of unread notifications.""" + queryset = self.get_queryset() + dismissed_ids = NotificationDismissal.objects.filter( + user=request.user + ).values_list('notification_id', flat=True) + unread_count = queryset.exclude(id__in=dismissed_ids).count() + + return Response({ + 'unread_count': unread_count + }) + diff --git a/core/apps.py b/core/apps.py new file mode 100644 index 0000000..ee182e8 --- /dev/null +++ b/core/apps.py @@ -0,0 +1,48 @@ +from django.apps import AppConfig +from django.conf import settings +import logging + +# Define TRACE level (5 is below DEBUG which is 10) +TRACE = 5 +logging.addLevelName(TRACE, "TRACE") + +# Add trace method to the Logger class +def trace(self, message, *args, **kwargs): + """Log a message with TRACE level (more detailed than DEBUG)""" + if self.isEnabledFor(TRACE): + self._log(TRACE, message, args, **kwargs) + +# Add the trace method to the Logger class +logging.Logger.trace = trace + + +class CoreConfig(AppConfig): + default_auto_field = 'django.db.models.BigAutoField' + name = 'core' + + def ready(self): + # Import signals to ensure they get registered + import core.signals + from dispatcharr.app_initialization import should_skip_initialization + + # Sync developer notifications and check for version updates on startup + # Only run in the main process (not in management commands, migrations, or workers) + if should_skip_initialization(): + return + + self._sync_developer_notifications() + + def _sync_developer_notifications(self): + """Sync developer notifications from JSON file to database.""" + from django.db import connection + import logging + + logger = logging.getLogger(__name__) + + + try: + from core.developer_notifications import sync_developer_notifications + sync_developer_notifications() + except Exception as e: + logger.warning(f"Failed to sync developer notifications on startup: {e}") + diff --git a/core/command_utils.py b/core/command_utils.py new file mode 100644 index 0000000..f6adfd8 --- /dev/null +++ b/core/command_utils.py @@ -0,0 +1,34 @@ +import sys +import os + +def is_management_command(excluded_commands=None): + """ + Detect if we're running a Django management command like migrate, collectstatic, etc. + + Args: + excluded_commands: List of commands that should still use Redis (e.g. runserver) + + Returns: + bool: True if we're running a management command + """ + # First check if we're in build mode + if os.environ.get("DISPATCHARR_BUILD") == "1": + return True + + if excluded_commands is None: + excluded_commands = ['runserver', 'runworker', 'daphne'] + + # Check if we're running via manage.py + if not ('manage.py' in sys.argv[0]): + return False + + # Check if we have a command argument + if len(sys.argv) > 1: + command = sys.argv[1] + # Return False if command is in excluded list - these commands DO need Redis + if command in excluded_commands: + return False + # Otherwise it's a command that should work without Redis + return True + + return False diff --git a/core/developer_notifications.py b/core/developer_notifications.py new file mode 100644 index 0000000..f4214b4 --- /dev/null +++ b/core/developer_notifications.py @@ -0,0 +1,412 @@ +""" +Developer Notification Sync Service + +Handles syncing developer-defined notifications from the JSON file to the database. +This ensures users receive important notifications from the development team +about recommended settings, security updates, and other announcements. + +JSON Schema (see fixtures/developer_notifications.json): + { + "id": str, # REQUIRED - Unique identifier (notification_key) + "title": str, # REQUIRED - Notification heading + "message": str, # REQUIRED - Notification body text + + "notification_type": str, # OPTIONAL - 'version_update', 'setting_recommendation', 'announcement', 'warning', 'info' (default: 'info') + "priority": str, # OPTIONAL - 'low', 'normal', 'high', 'critical' (default: 'normal') + "min_version": str | null, # OPTIONAL - Minimum version (inclusive), e.g., "0.17.0" (default: null) + "max_version": str | null, # OPTIONAL - Maximum version (inclusive), e.g., "0.18.1" (default: null) + "created_at": str, # OPTIONAL - ISO timestamp for tracking + "expires_at": str | null, # OPTIONAL - ISO timestamp when notification expires (default: null) + "condition": list[str], # OPTIONAL - List of condition check names, AND logic (default: []) + "user_level": str, # OPTIONAL - 'all' or 'admin' (default: 'all') + "action_url": str | null, # OPTIONAL - Internal navigation URL (e.g., "/settings#network-access") + "action_text": str | null, # OPTIONAL - Text for action button (required if action_url is set) + } + +Condition Checks: + Conditions are function names from CONDITION_CHECKS registry that evaluate + whether a notification should be shown. All conditions must pass (AND logic). + + Available conditions: + - 'm3u_epg_network_insecure': M3U/EPG endpoint allows access from anywhere + + To add new conditions: + 1. Define a function: check_your_condition(user) -> bool + 2. Add to CONDITION_CHECKS registry + 3. Reference in JSON: "condition": ["your_condition"] + +Sync Behavior: + - Runs on startup (see apps.py) + - Runs when relevant settings change (see signals.py) + - Adds new notifications if in version range and not expired + - Updates existing notifications with latest data + - Removes notifications that are: + * No longer in JSON file + * Out of current version range + * Past expiration date + - Sends websocket event to refresh frontend + - Cache invalidated when triggering settings change +""" +import json +import logging +from datetime import datetime +from pathlib import Path +from typing import Any, Callable + +from django.conf import settings +from django.db import models +from django.utils import timezone +from packaging import version + +from version import __version__ + +logger = logging.getLogger(__name__) + +# Path to developer notifications JSON file +NOTIFICATIONS_FILE = Path(__file__).parent / 'fixtures' / 'developer_notifications.json' + + +# ───────────────────────────── +# Condition Checks +# ───────────────────────────── +# Each condition function receives (user) and returns True if the notification should show + +def check_network_access_is_default(user, endpoint: str = 'M3U_EPG') -> bool: + """ + Check if network access settings for a specific endpoint are insecure (allow all). + + Args: + user: The user object (unused but required for condition check signature) + endpoint: The endpoint to check (e.g., 'M3U_EPG', 'XC_API') + + Returns: + True if the notification should show (insecure settings detected) + """ + from core.models import CoreSettings, NETWORK_ACCESS_KEY + + try: + network_settings = CoreSettings._get_group(NETWORK_ACCESS_KEY, {}) + + # Empty settings are secure (defaults to local network only) + if not network_settings: + return False + + # Get the specific endpoint's allowed networks (stored as comma-separated string) + allowed_networks_str = network_settings.get(endpoint, '') + if not allowed_networks_str: + return False + + # Parse comma-separated network addresses + allowed_networks = [net.strip() for net in allowed_networks_str.split(',')] + + # Check if settings allow access from anywhere (insecure) + if '0.0.0.0/0' in allowed_networks or '::/0' in allowed_networks: + return True + + return False + except Exception as e: + logger.warning(f"Error checking network_access_is_default condition for {endpoint}: {e}") + return False + + +# Registry of all available condition checks +CONDITION_CHECKS: dict[str, Callable] = { + 'm3u_epg_network_insecure': lambda user: check_network_access_is_default(user, 'M3U_EPG'), + # Add more conditions here as needed + # 'transcode_not_configured': check_transcode_not_configured, + # 'no_backup_configured': check_no_backup_configured, +} + + +# ───────────────────────────── +# Version Utilities +# ───────────────────────────── + +def parse_version(version_str: str | None) -> version.Version | None: + """Parse a version string, returning None if invalid or empty.""" + if not version_str: + return None + try: + return version.parse(version_str) + except Exception: + return None + + +def is_version_in_range( + current_version: str, + min_version: str | None, + max_version: str | None +) -> bool: + """Check if current version is within the specified range.""" + current = parse_version(current_version) + if not current: + return True # If we can't parse version, show notification + + min_ver = parse_version(min_version) + max_ver = parse_version(max_version) + + if min_ver and current < min_ver: + return False + if max_ver and current > max_ver: + return False + + return True + + +# ───────────────────────────── +# Notification Evaluation +# ───────────────────────────── + +def evaluate_conditions(conditions: list[str] | str | None, user) -> bool: + """ + Evaluate notification conditions for a user. + All conditions must pass (AND logic). + """ + if not conditions: + return True + + # Normalize to list + if isinstance(conditions, str): + conditions = [conditions] + + for condition in conditions: + if condition not in CONDITION_CHECKS: + logger.warning(f"Unknown condition: {condition}") + continue + + try: + if not CONDITION_CHECKS[condition](user): + return False + except Exception as e: + logger.error(f"Error evaluating condition {condition}: {e}") + # On error, skip this condition (fail open) + continue + + return True + + +def should_show_notification(notification_data: dict, user) -> bool: + """ + Determine if a notification should be shown to a specific user. + Checks version range, user level, and conditions. + """ + # Check version range + if not is_version_in_range( + __version__, + notification_data.get('min_version'), + notification_data.get('max_version') + ): + return False + + # Check user level + user_level = notification_data.get('user_level', 'all') + if user_level == 'admin' and getattr(user, 'user_level', 0) < 10: + return False + + # Check conditions + conditions = notification_data.get('condition', []) + if not evaluate_conditions(conditions, user): + return False + + return True + + +# ───────────────────────────── +# Sync Service +# ───────────────────────────── + +def load_developer_notifications() -> list[dict]: + """Load notifications from the JSON file.""" + if not NOTIFICATIONS_FILE.exists(): + logger.warning(f"Developer notifications file not found: {NOTIFICATIONS_FILE}") + return [] + + try: + with open(NOTIFICATIONS_FILE, 'r', encoding='utf-8') as f: + data = json.load(f) + return data.get('notifications', []) + except json.JSONDecodeError as e: + logger.error(f"Error parsing developer notifications JSON: {e}") + return [] + except Exception as e: + logger.error(f"Error loading developer notifications: {e}") + return [] + + +def sync_developer_notifications() -> dict[str, int]: + """ + Sync developer notifications from JSON file to database. + + - Adds new notifications that don't exist in the DB + - Removes DB notifications that are no longer in the JSON file + - Updates existing notifications if they've changed + + Returns a dict with counts of added, updated, and removed notifications. + """ + from core.models import SystemNotification + + results = {'added': 0, 'updated': 0, 'removed': 0, 'skipped': 0} + + notifications = load_developer_notifications() + json_notification_keys = set() + notifications_to_remove = set() # Track notifications to remove (out of range or expired) + + for notif_data in notifications: + notification_id = notif_data.get('id') + if not notification_id: + logger.warning("Notification missing 'id' field, skipping") + results['skipped'] += 1 + continue + + json_notification_keys.add(notification_id) + + # Check version constraints (only add if current version is in range) + if not is_version_in_range( + __version__, + notif_data.get('min_version'), + notif_data.get('max_version') + ): + logger.debug(f"Notification {notification_id} not in version range, marking for removal") + results['skipped'] += 1 + notifications_to_remove.add(notification_id) + continue + + # Parse expires_at if provided + expires_at = None + if notif_data.get('expires_at'): + try: + expires_at = datetime.fromisoformat( + notif_data['expires_at'].replace('Z', '+00:00') + ) + # Skip if already expired and mark for removal + if expires_at < timezone.now(): + logger.debug(f"Notification {notification_id} has expired, marking for removal") + results['skipped'] += 1 + notifications_to_remove.add(notification_id) + continue + except (ValueError, TypeError) as e: + logger.warning(f"Invalid expires_at for {notification_id}: {e}") + + # Map notification_type from JSON to model choices + type_mapping = { + 'version_update': SystemNotification.NotificationType.VERSION_UPDATE, + 'setting_recommendation': SystemNotification.NotificationType.SETTING_RECOMMENDATION, + 'announcement': SystemNotification.NotificationType.ANNOUNCEMENT, + 'warning': SystemNotification.NotificationType.WARNING, + 'info': SystemNotification.NotificationType.INFO, + } + notification_type = type_mapping.get( + notif_data.get('notification_type', 'info'), + SystemNotification.NotificationType.INFO + ) + + # Map priority + priority_mapping = { + 'low': SystemNotification.Priority.LOW, + 'normal': SystemNotification.Priority.NORMAL, + 'high': SystemNotification.Priority.HIGH, + 'critical': SystemNotification.Priority.CRITICAL, + } + priority = priority_mapping.get( + notif_data.get('priority', 'normal'), + SystemNotification.Priority.NORMAL + ) + + # Prepare action_data + action_data = { + 'action_url': notif_data.get('action_url'), + 'action_text': notif_data.get('action_text'), + 'condition': notif_data.get('condition', []), + 'min_version': notif_data.get('min_version'), + 'max_version': notif_data.get('max_version'), + 'user_level': notif_data.get('user_level', 'all'), + } + + # Determine if admin-only based on user_level + admin_only = notif_data.get('user_level', 'all') == 'admin' + + # Create or update the notification + notification, created = SystemNotification.objects.update_or_create( + notification_key=notification_id, + defaults={ + 'notification_type': notification_type, + 'priority': priority, + 'source': SystemNotification.Source.DEVELOPER, + 'title': notif_data.get('title', 'Notification'), + 'message': notif_data.get('message', ''), + 'action_data': action_data, + 'is_active': True, + 'admin_only': admin_only, + 'expires_at': expires_at, + } + ) + + if created: + logger.info(f"Added developer notification: {notification_id}") + results['added'] += 1 + else: + logger.debug(f"Updated developer notification: {notification_id}") + results['updated'] += 1 + + # Remove developer notifications that are: + # - No longer in the JSON file, OR + # - Out of version range for the current version, OR + # - Expired + removed_count, _ = SystemNotification.objects.filter( + source=SystemNotification.Source.DEVELOPER + ).filter( + models.Q(notification_key__in=notifications_to_remove) | + ~models.Q(notification_key__in=json_notification_keys) + ).delete() + + if removed_count: + logger.info(f"Removed {removed_count} obsolete/expired/out-of-range developer notification(s)") + results['removed'] = removed_count + + logger.info( + f"Developer notification sync complete: " + f"{results['added']} added, {results['updated']} updated, " + f"{results['removed']} removed, {results['skipped']} skipped" + ) + + # Send websocket notification to frontend to refresh notifications + try: + from core.utils import send_websocket_update + send_websocket_update('updates', 'update', { + 'type': 'notifications_cleared', + }) + logger.debug("Sent websocket notification for notifications refresh") + except Exception as e: + logger.warning(f"Failed to send websocket update: {e}") + + return results + + +def get_user_developer_notifications(user) -> list: + """ + Get all developer notifications that should be shown to a specific user. + Evaluates conditions and user_level for each notification. + """ + from core.models import SystemNotification + + # Get all active developer notifications + notifications = SystemNotification.objects.filter( + source=SystemNotification.Source.DEVELOPER, + is_active=True + ) + + # Filter by admin_only based on user + if getattr(user, 'user_level', 0) < 10: + notifications = notifications.filter(admin_only=False) + + # Filter by conditions + result = [] + for notification in notifications: + action_data = notification.action_data or {} + + # Evaluate conditions + conditions = action_data.get('condition', []) + if evaluate_conditions(conditions, user): + result.append(notification) + + return result diff --git a/core/fixtures/developer_notifications.json b/core/fixtures/developer_notifications.json new file mode 100644 index 0000000..0040682 --- /dev/null +++ b/core/fixtures/developer_notifications.json @@ -0,0 +1,43 @@ +{ + "_schema_documentation": { + "description": "Developer notification definitions. Each notification is evaluated at sync time.", + "fields": { + "id": "[REQUIRED] Unique identifier (notification_key in database).", + "notification_type": "[OPTIONAL] Type: 'version_update', 'setting_recommendation', 'announcement', 'warning', 'info'. Default: 'info'", + "priority": "[OPTIONAL] Priority level: 'low', 'normal', 'high', 'critical'. Default: 'normal'", + "title": "[REQUIRED] Notification title/heading.", + "message": "[REQUIRED] Detailed notification message body.", + "min_version": "[OPTIONAL] Minimum version (inclusive). null = no minimum. Example: '0.17.0'", + "max_version": "[OPTIONAL] Maximum version (inclusive). null = no maximum. Example: '0.18.1'", + "created_at": "[OPTIONAL] ISO timestamp when notification was created. For tracking only.", + "expires_at": "[OPTIONAL] ISO timestamp when notification expires. null = never expires. Example: '2026-12-31T23:59:59Z'", + "condition": "[OPTIONAL] Array of condition check names that must all pass. Empty/null = always show. Example: ['m3u_epg_network_insecure']", + "user_level": "[OPTIONAL] User level required: 'all' or 'admin'. Default: 'all'", + "action_url": "[OPTIONAL] Internal URL to navigate to when action button clicked. Example: '/settings#network-access'", + "action_text": "[OPTIONAL] Text for action button. Required if action_url is set. Example: 'Review Settings'" + }, + "notes": [ + "Notifications are synced from this file to database on startup and when relevant settings change", + "Out-of-range versions are automatically removed from database", + "Expired notifications are automatically removed from database", + "Conditions are evaluated per-user at display time (see CONDITION_CHECKS in developer_notifications.py)" + ] + }, + "notifications": [ + { + "id": "network_security_m3u_epg", + "notification_type": "warning", + "priority": "high", + "title": "Network Access Security Warning", + "message": "Your EPG/M3U output is accessible from any network. Consider restricting access to improve security.", + "min_version": null, + "max_version": null, + "created_at": "2026-02-02T00:00:00Z", + "expires_at": null, + "condition": ["m3u_epg_network_insecure"], + "user_level": "admin", + "action_url": "/settings#network-access", + "action_text": "Review Settings" + } + ] +} \ No newline at end of file diff --git a/core/fixtures/initial_data.json b/core/fixtures/initial_data.json new file mode 100644 index 0000000..889f0d2 --- /dev/null +++ b/core/fixtures/initial_data.json @@ -0,0 +1,55 @@ +[ + { + "model": "core.useragent", + "pk": 1, + "fields": { + "name": "TiviMate", + "user_agent": "TiviMate/5.1.6 (Android 12)", + "description": "", + "is_active": true + } + }, + { + "model": "core.useragent", + "pk": 2, + "fields": { + "name": "VLC", + "user_agent": "VLC/3.0.21 LibVLC 3.0.21", + "description": "", + "is_active": true + } + }, + { + "model": "core.streamprofile", + "pk": 1, + "fields": { + "name": "FFmpeg", + "command": "ffmpeg", + "parameters": "-i {streamUrl} -c:v copy -c:a copy -f mpegts pipe:1", + "is_active": true, + "user_agent": "1" + } + }, + { + "model": "core.streamprofile", + "pk": 2, + "fields": { + "name": "Streamlink", + "command": "streamlink", + "parameters": "{streamUrl} best --stdout", + "is_active": true, + "user_agent": "1" + } + }, + { + "model": "core.streamprofile", + "pk": 3, + "fields": { + "name": "VLC", + "command": "cvlc", + "parameters": "-vv -I dummy --no-video-title-show --http-user-agent {userAgent} {streamUrl} --sout #standard{access=file,mux=ts,dst=-}", + "is_active": true, + "user_agent": "1" + } + } +] diff --git a/core/management/commands/__init__.py b/core/management/commands/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/core/management/commands/dropdb.py b/core/management/commands/dropdb.py new file mode 100644 index 0000000..d61b989 --- /dev/null +++ b/core/management/commands/dropdb.py @@ -0,0 +1,54 @@ +import sys +import psycopg2 +from psycopg2 import sql +from django.core.management.base import BaseCommand +from django.conf import settings +from django.db import connection + +class Command(BaseCommand): + help = "Drop the entire database (schema and data) and recreate it. (PostgreSQL only)" + + def handle(self, *args, **options): + db_settings = settings.DATABASES['default'] + db_name = db_settings['NAME'] + user = db_settings['USER'] + password = db_settings['PASSWORD'] + host = db_settings.get('HOST', 'localhost') + port = db_settings.get('PORT', 5432) + + # Read TLS parameters from Django OPTIONS (populated when POSTGRES_SSL=true) + db_options = db_settings.get('OPTIONS', {}) + ssl_kwargs = {} + for key in ('sslmode', 'sslrootcert', 'sslcert', 'sslkey'): + if key in db_options: + ssl_kwargs[key] = db_options[key] + + self.stdout.write(self.style.WARNING( + f"WARNING: This will irreversibly drop the entire database '{db_name}'!" + )) + confirm = input("Type 'yes' to proceed: ") + if confirm.lower() != 'yes': + self.stdout.write("Aborted. No changes made.") + return + + # Close Django's current connection to the target DB + connection.close() + + # For PostgreSQL, we need to connect to a different database (e.g. the maintenance database "postgres") + maintenance_db = 'postgres' + try: + self.stdout.write("Connecting to maintenance database...") + conn = psycopg2.connect(dbname=maintenance_db, user=user, password=password, host=host, port=port, **ssl_kwargs) + conn.autocommit = True + cur = conn.cursor() + self.stdout.write(f"Dropping database '{db_name}'...") + cur.execute(sql.SQL("DROP DATABASE IF EXISTS {}").format(sql.Identifier(db_name))) + self.stdout.write(f"Creating database '{db_name}'...") + cur.execute(sql.SQL("CREATE DATABASE {}").format(sql.Identifier(db_name))) + cur.close() + conn.close() + self.stdout.write(self.style.SUCCESS(f"Database '{db_name}' has been dropped and recreated.")) + self.stdout.write("Now run 'python manage.py migrate' to reapply your migrations.") + except Exception as e: + self.stderr.write(self.style.ERROR(f"Error dropping/creating database: {e}")) + sys.exit(1) diff --git a/core/management/commands/kill_processes.py b/core/management/commands/kill_processes.py new file mode 100644 index 0000000..f4b186a --- /dev/null +++ b/core/management/commands/kill_processes.py @@ -0,0 +1,27 @@ +# core/management/commands/kill_processes.py + +import psutil +from django.core.management.base import BaseCommand + +class Command(BaseCommand): + help = "Kills all processes with 'ffmpeg' or 'streamlink' in their name or command line." + + def handle(self, *args, **options): + kill_count = 0 + + for proc in psutil.process_iter(['pid', 'name', 'cmdline']): + try: + name = proc.info.get('name') or '' + cmdline = ' '.join(proc.info.get('cmdline') or []) + lower_name = name.lower() + lower_cmdline = cmdline.lower() + + if ('ffmpeg' in lower_name or 'ffmpeg' in lower_cmdline or + 'streamlink' in lower_name or 'streamlink' in lower_cmdline): + self.stdout.write(f"Killing PID {proc.pid}: {name} {cmdline}") + proc.kill() + kill_count += 1 + except (psutil.NoSuchProcess, psutil.AccessDenied, psutil.ZombieProcess): + continue + + self.stdout.write(self.style.SUCCESS(f"Killed {kill_count} processes.")) diff --git a/core/management/commands/reset_network_access.py b/core/management/commands/reset_network_access.py new file mode 100644 index 0000000..a31d247 --- /dev/null +++ b/core/management/commands/reset_network_access.py @@ -0,0 +1,13 @@ +# your_app/management/commands/update_column.py + +from django.core.management.base import BaseCommand +from core.models import CoreSettings, NETWORK_ACCESS_KEY + + +class Command(BaseCommand): + help = "Reset network access settings" + + def handle(self, *args, **options): + setting = CoreSettings.objects.get(key=NETWORK_ACCESS_KEY) + setting.value = {} + setting.save() diff --git a/core/migrations/0001_initial.py b/core/migrations/0001_initial.py new file mode 100644 index 0000000..b9290f9 --- /dev/null +++ b/core/migrations/0001_initial.py @@ -0,0 +1,46 @@ +# Generated by Django 5.1.6 on 2025-03-05 22:07 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ] + + operations = [ + migrations.CreateModel( + name='CoreSettings', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('key', models.CharField(max_length=255, unique=True)), + ('name', models.CharField(max_length=255)), + ('value', models.CharField(max_length=255)), + ], + ), + migrations.CreateModel( + name='StreamProfile', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('profile_name', models.CharField(help_text='Name of the stream profile', max_length=255)), + ('command', models.CharField(help_text="Command to execute (e.g., 'yt.sh', 'streamlink', or 'vlc')", max_length=255)), + ('parameters', models.TextField(help_text='Command-line parameters. Use {userAgent} and {streamUrl} as placeholders.')), + ('is_active', models.BooleanField(default=True, help_text='Whether this profile is active')), + ('user_agent', models.CharField(blank=True, help_text='Optional user agent to use. If not set, you can fall back to a default.', max_length=512, null=True)), + ], + ), + migrations.CreateModel( + name='UserAgent', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('user_agent_name', models.CharField(help_text='The User-Agent name.', max_length=512, unique=True)), + ('user_agent', models.CharField(help_text='The complete User-Agent string sent by the client.', max_length=512, unique=True)), + ('description', models.CharField(blank=True, help_text='An optional description of the client or device type.', max_length=255)), + ('is_active', models.BooleanField(default=True, help_text='Whether this user agent is currently allowed/recognized.')), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ], + ), + ] diff --git a/core/migrations/0002_preload_user_agents.py b/core/migrations/0002_preload_user_agents.py new file mode 100644 index 0000000..f7d495a --- /dev/null +++ b/core/migrations/0002_preload_user_agents.py @@ -0,0 +1,36 @@ +# Generated by Django 5.1.6 on 2025-03-01 14:01 + +from django.db import migrations + +def preload_user_agent(apps, schema_editor): + UserAgent = apps.get_model("core", "UserAgent") + UserAgent.objects.create( + user_agent_name="TiviMate", + user_agent="TiviMate/5.16 (Android 12)", + description="", + is_active=True, + ) + + UserAgent.objects.create( + user_agent_name="VLC", + user_agent="VLC/3.0.21 LibVLC/3.0.21", + description="", + is_active=True, + ) + + UserAgent.objects.create( + user_agent_name="Chrome", + user_agent="Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/132.0.0.0 Safari/537.3", + description="", + is_active=True, + ) + +class Migration(migrations.Migration): + + dependencies = [ + ('core', '0001_initial'), + ] + + operations = [ + migrations.RunPython(preload_user_agent), + ] diff --git a/core/migrations/0003_preload_stream_profiles.py b/core/migrations/0003_preload_stream_profiles.py new file mode 100644 index 0000000..6e7836c --- /dev/null +++ b/core/migrations/0003_preload_stream_profiles.py @@ -0,0 +1,31 @@ +# Generated by Django 5.1.6 on 2025-03-01 14:01 + +from django.db import migrations + +def preload_stream_profiles(apps, schema_editor): + StreamProfile = apps.get_model("core", "StreamProfile") + StreamProfile.objects.create( + profile_name="ffmpeg", + command="ffmpeg", + parameters="-i {streamUrl} -c:v copy -c:a copy -f mpegts pipe:1", + is_active=True, + user_agent="1", + ) + + StreamProfile.objects.create( + profile_name="streamlink", + command="streamlink", + parameters="{streamUrl} best --stdout", + is_active=True, + user_agent="1", + ) + +class Migration(migrations.Migration): + + dependencies = [ + ('core', '0002_preload_user_agents'), + ] + + operations = [ + migrations.RunPython(preload_stream_profiles), + ] diff --git a/core/migrations/0004_preload_core_settings.py b/core/migrations/0004_preload_core_settings.py new file mode 100644 index 0000000..cc6f392 --- /dev/null +++ b/core/migrations/0004_preload_core_settings.py @@ -0,0 +1,28 @@ +# Generated by Django 5.1.6 on 2025-03-01 14:01 + +from django.db import migrations +from django.utils.text import slugify + +def preload_core_settings(apps, schema_editor): + CoreSettings = apps.get_model("core", "CoreSettings") + CoreSettings.objects.create( + key=slugify("Default Stream Profile"), + name="Default Stream Profile", + value=1, + ) + + CoreSettings.objects.create( + key=slugify("Default User-Agent"), + name="Default User-Agent", + value=1, + ) + +class Migration(migrations.Migration): + + dependencies = [ + ('core', '0003_preload_stream_profiles'), + ] + + operations = [ + migrations.RunPython(preload_core_settings), + ] diff --git a/core/migrations/0005_streamprofile_locked_alter_streamprofile_command_and_more.py b/core/migrations/0005_streamprofile_locked_alter_streamprofile_command_and_more.py new file mode 100644 index 0000000..71f7de0 --- /dev/null +++ b/core/migrations/0005_streamprofile_locked_alter_streamprofile_command_and_more.py @@ -0,0 +1,34 @@ +# Generated by Django 5.1.6 on 2025-03-14 18:12 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('core', '0004_preload_core_settings'), + ] + + operations = [ + migrations.AddField( + model_name='streamprofile', + name='locked', + field=models.BooleanField(default=False, help_text="Protected - can't be deleted or modified"), + ), + migrations.AlterField( + model_name='streamprofile', + name='command', + field=models.CharField(blank=True, help_text="Command to execute (e.g., 'yt.sh', 'streamlink', or 'vlc')", max_length=255), + ), + migrations.AlterField( + model_name='streamprofile', + name='parameters', + field=models.TextField(blank=True, help_text='Command-line parameters. Use {userAgent} and {streamUrl} as placeholders.'), + ), + migrations.AlterField( + model_name='streamprofile', + name='user_agent', + field=models.ForeignKey(blank=True, help_text='Optional user agent to use. If not set, you can fall back to a default.', null=True, on_delete=django.db.models.deletion.SET_NULL, to='core.useragent'), + ), + ] diff --git a/core/migrations/0006_set_locked_stream_profiles.py b/core/migrations/0006_set_locked_stream_profiles.py new file mode 100644 index 0000000..1b525dc --- /dev/null +++ b/core/migrations/0006_set_locked_stream_profiles.py @@ -0,0 +1,59 @@ +# Generated by Django 5.1.6 on 2025-03-14 17:46 + +from django.db import migrations, models +from core.models import CoreSettings, StreamProfile + +def lock_or_create_profiles(apps, schema_editor): + StreamProfile = apps.get_model("core", "StreamProfile") + UserAgent = apps.get_model("core", "UserAgent") + + # Define the system profiles that should exist + system_profiles = [ + { + "name": "ffmpeg", + "command": "ffmpeg", + "parameters": "-i {streamUrl} -c:v copy -c:a copy -f mpegts pipe:1", + "new_parameters": "-user_agent {userAgent} -i {streamUrl} -c copy -f mpegts pipe:1", + }, + { + "name": "streamlink", + "command": "streamlink", + "parameters": "{streamUrl} best --stdout", + "new_parameters": "{streamUrl} --http-header {userAgent} best --stdout", + + }, + ] + + for profile_data in system_profiles: + existing_profile = StreamProfile.objects.filter( + profile_name=profile_data["name"], + command=profile_data["command"], + parameters=profile_data["parameters"], + ).first() + + if existing_profile: + # Lock existing profile + existing_profile.locked = True + existing_profile.parameters = profile_data["new_parameters"] + existing_profile.save() + else: + # Create a new locked profile + new_profile = StreamProfile.objects.create( + profile_name=profile_data["name"], + command=profile_data["command"], + parameters=profile_data["new_parameters"], + locked=True, + ) + +def reverse_migration(apps, schema_editor): + # No need to reverse changes + pass + +class Migration(migrations.Migration): + dependencies = [ + ('core', '0005_streamprofile_locked_alter_streamprofile_command_and_more'), + ] + + operations = [ + migrations.RunPython(lock_or_create_profiles, reverse_code=reverse_migration), + ] diff --git a/core/migrations/0007_create_proxy_and_redirect_stream_profiles.py b/core/migrations/0007_create_proxy_and_redirect_stream_profiles.py new file mode 100644 index 0000000..41ca0ee --- /dev/null +++ b/core/migrations/0007_create_proxy_and_redirect_stream_profiles.py @@ -0,0 +1,36 @@ +# Generated by Django 5.1.6 on 2025-03-14 17:16 + +from django.db import migrations +from core.models import CoreSettings + +def create_proxy_stream_profile(apps, schema_editor): + default_user_agent_id = CoreSettings.get_default_user_agent_id() + + StreamProfile = apps.get_model("core", "StreamProfile") + StreamProfile.objects.create( + profile_name="Proxy", + command="", + parameters="", + locked=True, + is_active=True, + user_agent_id=default_user_agent_id, + ) + + StreamProfile.objects.create( + profile_name="Redirect", + command="", + parameters="", + locked=True, + is_active=True, + user_agent_id=default_user_agent_id, + ) + +class Migration(migrations.Migration): + + dependencies = [ + ('core', '0006_set_locked_stream_profiles'), + ] + + operations = [ + migrations.RunPython(create_proxy_stream_profile) + ] diff --git a/core/migrations/0008_rename_profile_name_streamprofile_name_and_more.py b/core/migrations/0008_rename_profile_name_streamprofile_name_and_more.py new file mode 100644 index 0000000..6f0b282 --- /dev/null +++ b/core/migrations/0008_rename_profile_name_streamprofile_name_and_more.py @@ -0,0 +1,23 @@ +# Generated by Django 5.1.6 on 2025-03-16 12:21 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('core', '0007_create_proxy_and_redirect_stream_profiles'), + ] + + operations = [ + migrations.RenameField( + model_name='streamprofile', + old_name='profile_name', + new_name='name', + ), + migrations.RenameField( + model_name='useragent', + old_name='user_agent_name', + new_name='name', + ), + ] diff --git a/core/migrations/0009_m3u_hash_settings.py b/core/migrations/0009_m3u_hash_settings.py new file mode 100644 index 0000000..3c6283f --- /dev/null +++ b/core/migrations/0009_m3u_hash_settings.py @@ -0,0 +1,22 @@ +# Generated by Django 5.1.6 on 2025-03-01 14:01 + +from django.db import migrations +from django.utils.text import slugify + +def preload_core_settings(apps, schema_editor): + CoreSettings = apps.get_model("core", "CoreSettings") + CoreSettings.objects.create( + key=slugify("M3U Hash Key"), + name="M3U Hash Key", + value="url", + ) + +class Migration(migrations.Migration): + + dependencies = [ + ('core', '0008_rename_profile_name_streamprofile_name_and_more'), + ] + + operations = [ + migrations.RunPython(preload_core_settings), + ] diff --git a/core/migrations/0010_reload_additional_settings.py b/core/migrations/0010_reload_additional_settings.py new file mode 100644 index 0000000..5395b89 --- /dev/null +++ b/core/migrations/0010_reload_additional_settings.py @@ -0,0 +1,22 @@ +# Generated by Django 5.1.6 on 2025-03-01 14:01 + +from django.db import migrations +from django.utils.text import slugify + +def preload_core_settings(apps, schema_editor): + CoreSettings = apps.get_model("core", "CoreSettings") + CoreSettings.objects.create( + key=slugify("Preferred Region"), + name="Preferred Region", + value="us", + ) + +class Migration(migrations.Migration): + + dependencies = [ + ('core', '0009_m3u_hash_settings'), + ] + + operations = [ + migrations.RunPython(preload_core_settings), + ] diff --git a/core/migrations/0011_fix_stream_profiles_and_user_agents.py b/core/migrations/0011_fix_stream_profiles_and_user_agents.py new file mode 100644 index 0000000..c582f96 --- /dev/null +++ b/core/migrations/0011_fix_stream_profiles_and_user_agents.py @@ -0,0 +1,27 @@ +# Generated by Django 5.1.6 on 2025-04-04 + +from django.db import migrations + +def fix_stream_profiles_and_user_agents(apps, schema_editor): + # Get the model + StreamProfile = apps.get_model("core", "StreamProfile") + + streamlink_profile = StreamProfile.objects.get(name="streamlink", locked=True) + streamlink_profile.parameters = "{streamUrl} --http-header User-Agent={userAgent} best --stdout" + streamlink_profile.save() + + UserAgent = apps.get_model("core", "UserAgent") + tivimate = UserAgent.objects.get(name="TiviMate", user_agent="TiviMate/5.16 (Android 12)") + if tivimate: + tivimate.user_agent = "TiviMate/5.1.6 (Android 12)" + tivimate.save() + +class Migration(migrations.Migration): + + dependencies = [ + ('core', '0010_reload_additional_settings'), + ] + + operations = [ + migrations.RunPython(fix_stream_profiles_and_user_agents), + ] diff --git a/core/migrations/0012_default_active_m3u_accounts.py b/core/migrations/0012_default_active_m3u_accounts.py new file mode 100644 index 0000000..1188877 --- /dev/null +++ b/core/migrations/0012_default_active_m3u_accounts.py @@ -0,0 +1,22 @@ +# Generated by Django 5.1.6 on 2025-03-01 14:01 + +from django.db import migrations +from django.utils.text import slugify + +def preload_core_settings(apps, schema_editor): + CoreSettings = apps.get_model("core", "CoreSettings") + CoreSettings.objects.create( + key=slugify("Auto-Import Mapped Files"), + name="Auto-Import Mapped Files", + value=True, + ) + +class Migration(migrations.Migration): + + dependencies = [ + ('core', '0011_fix_stream_profiles_and_user_agents'), + ] + + operations = [ + migrations.RunPython(preload_core_settings), + ] diff --git a/core/migrations/0013_default_network_access_settings.py b/core/migrations/0013_default_network_access_settings.py new file mode 100644 index 0000000..be53ba0 --- /dev/null +++ b/core/migrations/0013_default_network_access_settings.py @@ -0,0 +1,24 @@ +# Generated by Django 5.1.6 on 2025-03-01 14:01 + +from django.db import migrations +from django.utils.text import slugify + + +def preload_network_access_settings(apps, schema_editor): + CoreSettings = apps.get_model("core", "CoreSettings") + CoreSettings.objects.create( + key=slugify("Network Access"), + name="Network Access", + value="{}", + ) + + +class Migration(migrations.Migration): + + dependencies = [ + ("core", "0012_default_active_m3u_accounts"), + ] + + operations = [ + migrations.RunPython(preload_network_access_settings), + ] diff --git a/core/migrations/0014_default_proxy_settings.py b/core/migrations/0014_default_proxy_settings.py new file mode 100644 index 0000000..f4a61a9 --- /dev/null +++ b/core/migrations/0014_default_proxy_settings.py @@ -0,0 +1,35 @@ +# Generated by Django 5.1.6 on 2025-03-01 14:01 + +import json +from django.db import migrations +from django.utils.text import slugify + + +def preload_proxy_settings(apps, schema_editor): + CoreSettings = apps.get_model("core", "CoreSettings") + + # Default proxy settings + default_proxy_settings = { + "buffering_timeout": 15, + "buffering_speed": 1.0, + "redis_chunk_ttl": 60, + "channel_shutdown_delay": 0, + "channel_init_grace_period": 5, + } + + CoreSettings.objects.create( + key=slugify("Proxy Settings"), + name="Proxy Settings", + value=json.dumps(default_proxy_settings), + ) + + +class Migration(migrations.Migration): + + dependencies = [ + ("core", "0013_default_network_access_settings"), + ] + + operations = [ + migrations.RunPython(preload_proxy_settings), + ] diff --git a/core/migrations/0015_dvr_templates.py b/core/migrations/0015_dvr_templates.py new file mode 100644 index 0000000..130a80d --- /dev/null +++ b/core/migrations/0015_dvr_templates.py @@ -0,0 +1,30 @@ +# Generated by Django 5.1.6 on 2025-03-01 14:10 + +from django.db import migrations +from django.utils.text import slugify + + +def add_dvr_defaults(apps, schema_editor): + CoreSettings = apps.get_model("core", "CoreSettings") + + defaults = [ + (slugify("DVR TV Template"), "DVR TV Template", "Recordings/TV_Shows/{show}/S{season:02d}E{episode:02d}.mkv"), + (slugify("DVR Movie Template"), "DVR Movie Template", "Recordings/Movies/{title} ({year}).mkv"), + (slugify("DVR TV Fallback Template"), "DVR TV Fallback Template", "Recordings/TV_Shows/{show}/{start}.mkv"), + (slugify("DVR Movie Fallback Template"), "DVR Movie Fallback Template", "Recordings/Movies/{start}.mkv"), + (slugify("DVR Comskip Enabled"), "DVR Comskip Enabled", "false"), + ] + + for key, name, value in defaults: + CoreSettings.objects.get_or_create(key=key, defaults={"name": name, "value": value}) + + +class Migration(migrations.Migration): + + dependencies = [ + ("core", "0014_default_proxy_settings"), + ] + + operations = [ + migrations.RunPython(add_dvr_defaults), + ] diff --git a/core/migrations/0016_update_dvr_template_paths.py b/core/migrations/0016_update_dvr_template_paths.py new file mode 100644 index 0000000..5e729c4 --- /dev/null +++ b/core/migrations/0016_update_dvr_template_paths.py @@ -0,0 +1,61 @@ +# Generated manually to update DVR template paths + +from django.db import migrations +from django.utils.text import slugify + + +def update_dvr_template_paths(apps, schema_editor): + """Remove 'Recordings/' prefix from DVR template paths""" + CoreSettings = apps.get_model("core", "CoreSettings") + + # Define the updates needed + updates = [ + (slugify("DVR TV Template"), "TV_Shows/{show}/S{season:02d}E{episode:02d}.mkv"), + (slugify("DVR Movie Template"), "Movies/{title} ({year}).mkv"), + (slugify("DVR TV Fallback Template"), "TV_Shows/{show}/{start}.mkv"), + (slugify("DVR Movie Fallback Template"), "Movies/{start}.mkv"), + ] + + # Update each setting + for key, new_value in updates: + try: + setting = CoreSettings.objects.get(key=key) + setting.value = new_value + setting.save() + print(f"Updated {setting.name}: {new_value}") + except CoreSettings.DoesNotExist: + print(f"Setting with key '{key}' not found - skipping") + + +def reverse_dvr_template_paths(apps, schema_editor): + """Add back 'Recordings/' prefix to DVR template paths""" + CoreSettings = apps.get_model("core", "CoreSettings") + + # Define the reverse updates (add back Recordings/ prefix) + updates = [ + (slugify("DVR TV Template"), "Recordings/TV_Shows/{show}/S{season:02d}E{episode:02d}.mkv"), + (slugify("DVR Movie Template"), "Recordings/Movies/{title} ({year}).mkv"), + (slugify("DVR TV Fallback Template"), "Recordings/TV_Shows/{show}/{start}.mkv"), + (slugify("DVR Movie Fallback Template"), "Recordings/Movies/{start}.mkv"), + ] + + # Update each setting back to original + for key, original_value in updates: + try: + setting = CoreSettings.objects.get(key=key) + setting.value = original_value + setting.save() + print(f"Reverted {setting.name}: {original_value}") + except CoreSettings.DoesNotExist: + print(f"Setting with key '{key}' not found - skipping") + + +class Migration(migrations.Migration): + + dependencies = [ + ("core", "0015_dvr_templates"), + ] + + operations = [ + migrations.RunPython(update_dvr_template_paths, reverse_dvr_template_paths), + ] \ No newline at end of file diff --git a/core/migrations/0017_systemevent.py b/core/migrations/0017_systemevent.py new file mode 100644 index 0000000..9b97213 --- /dev/null +++ b/core/migrations/0017_systemevent.py @@ -0,0 +1,28 @@ +# Generated by Django 5.2.4 on 2025-11-20 20:47 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('core', '0016_update_dvr_template_paths'), + ] + + operations = [ + migrations.CreateModel( + name='SystemEvent', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('event_type', models.CharField(choices=[('channel_start', 'Channel Started'), ('channel_stop', 'Channel Stopped'), ('channel_buffering', 'Channel Buffering'), ('channel_failover', 'Channel Failover'), ('channel_reconnect', 'Channel Reconnected'), ('channel_error', 'Channel Error'), ('client_connect', 'Client Connected'), ('client_disconnect', 'Client Disconnected'), ('recording_start', 'Recording Started'), ('recording_end', 'Recording Ended'), ('stream_switch', 'Stream Switched'), ('m3u_refresh', 'M3U Refreshed'), ('m3u_download', 'M3U Downloaded'), ('epg_refresh', 'EPG Refreshed'), ('epg_download', 'EPG Downloaded')], db_index=True, max_length=50)), + ('timestamp', models.DateTimeField(auto_now_add=True, db_index=True)), + ('channel_id', models.UUIDField(blank=True, db_index=True, null=True)), + ('channel_name', models.CharField(blank=True, max_length=255, null=True)), + ('details', models.JSONField(blank=True, default=dict)), + ], + options={ + 'ordering': ['-timestamp'], + 'indexes': [models.Index(fields=['-timestamp'], name='core_system_timesta_c6c3d1_idx'), models.Index(fields=['event_type', '-timestamp'], name='core_system_event_t_4267d9_idx')], + }, + ), + ] diff --git a/core/migrations/0018_alter_systemevent_event_type.py b/core/migrations/0018_alter_systemevent_event_type.py new file mode 100644 index 0000000..3fe4eec --- /dev/null +++ b/core/migrations/0018_alter_systemevent_event_type.py @@ -0,0 +1,18 @@ +# Generated by Django 5.2.4 on 2025-11-21 15:59 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('core', '0017_systemevent'), + ] + + operations = [ + migrations.AlterField( + model_name='systemevent', + name='event_type', + field=models.CharField(choices=[('channel_start', 'Channel Started'), ('channel_stop', 'Channel Stopped'), ('channel_buffering', 'Channel Buffering'), ('channel_failover', 'Channel Failover'), ('channel_reconnect', 'Channel Reconnected'), ('channel_error', 'Channel Error'), ('client_connect', 'Client Connected'), ('client_disconnect', 'Client Disconnected'), ('recording_start', 'Recording Started'), ('recording_end', 'Recording Ended'), ('stream_switch', 'Stream Switched'), ('m3u_refresh', 'M3U Refreshed'), ('m3u_download', 'M3U Downloaded'), ('epg_refresh', 'EPG Refreshed'), ('epg_download', 'EPG Downloaded'), ('login_success', 'Login Successful'), ('login_failed', 'Login Failed'), ('logout', 'User Logged Out'), ('m3u_blocked', 'M3U Download Blocked'), ('epg_blocked', 'EPG Download Blocked')], db_index=True, max_length=50), + ), + ] diff --git a/core/migrations/0019_add_vlc_stream_profile.py b/core/migrations/0019_add_vlc_stream_profile.py new file mode 100644 index 0000000..c3f7259 --- /dev/null +++ b/core/migrations/0019_add_vlc_stream_profile.py @@ -0,0 +1,42 @@ +# Generated migration to add VLC stream profile + +from django.db import migrations + +def add_vlc_profile(apps, schema_editor): + StreamProfile = apps.get_model("core", "StreamProfile") + UserAgent = apps.get_model("core", "UserAgent") + + # Check if VLC profile already exists + if not StreamProfile.objects.filter(name="VLC").exists(): + # Get the TiviMate user agent (should be pk=1) + try: + tivimate_ua = UserAgent.objects.get(pk=1) + except UserAgent.DoesNotExist: + # Fallback: get first available user agent + tivimate_ua = UserAgent.objects.first() + if not tivimate_ua: + # No user agents exist, skip creating profile + return + + StreamProfile.objects.create( + name="VLC", + command="cvlc", + parameters="-vv -I dummy --no-video-title-show --http-user-agent {userAgent} {streamUrl} --sout #standard{access=file,mux=ts,dst=-}", + is_active=True, + user_agent=tivimate_ua, + locked=True, # Make it read-only like ffmpeg/streamlink + ) + +def remove_vlc_profile(apps, schema_editor): + StreamProfile = apps.get_model("core", "StreamProfile") + StreamProfile.objects.filter(name="VLC").delete() + +class Migration(migrations.Migration): + + dependencies = [ + ('core', '0018_alter_systemevent_event_type'), + ] + + operations = [ + migrations.RunPython(add_vlc_profile, remove_vlc_profile), + ] diff --git a/core/migrations/0020_change_coresettings_value_to_jsonfield.py b/core/migrations/0020_change_coresettings_value_to_jsonfield.py new file mode 100644 index 0000000..ac6ad08 --- /dev/null +++ b/core/migrations/0020_change_coresettings_value_to_jsonfield.py @@ -0,0 +1,267 @@ +# Generated migration to change CoreSettings value field to JSONField and consolidate settings + +import json +from django.db import migrations, models + + +def convert_string_to_json(apps, schema_editor): + """Convert existing string values to appropriate JSON types before changing column type""" + CoreSettings = apps.get_model("core", "CoreSettings") + + for setting in CoreSettings.objects.all(): + value = setting.value + + if not value: + # Empty strings become empty string in JSON + setting.value = json.dumps("") + setting.save(update_fields=['value']) + continue + + # Try to parse as JSON if it looks like JSON (objects/arrays) + if value.startswith('{') or value.startswith('['): + try: + parsed = json.loads(value) + # Store as JSON string temporarily (column is still CharField) + setting.value = json.dumps(parsed) + setting.save(update_fields=['value']) + continue + except (json.JSONDecodeError, ValueError): + pass + + # Try to parse as number + try: + # Check if it's an integer + if '.' not in value and value.lstrip('-').isdigit(): + setting.value = json.dumps(int(value)) + setting.save(update_fields=['value']) + continue + # Check if it's a float + float_val = float(value) + setting.value = json.dumps(float_val) + setting.save(update_fields=['value']) + continue + except (ValueError, AttributeError): + pass + + # Check for booleans + if value.lower() in ('true', 'false', '1', '0', 'yes', 'no', 'on', 'off'): + bool_val = value.lower() in ('true', '1', 'yes', 'on') + setting.value = json.dumps(bool_val) + setting.save(update_fields=['value']) + continue + + # Default: store as JSON string + setting.value = json.dumps(value) + setting.save(update_fields=['value']) + + +def consolidate_settings(apps, schema_editor): + """Consolidate individual setting rows into grouped JSON objects.""" + CoreSettings = apps.get_model("core", "CoreSettings") + + # Helper to get setting value + def get_value(key, default=None): + try: + obj = CoreSettings.objects.get(key=key) + return obj.value if obj.value is not None else default + except CoreSettings.DoesNotExist: + return default + + # STREAM SETTINGS + stream_settings = { + "default_user_agent": get_value("default-user-agent"), + "default_stream_profile": get_value("default-stream-profile"), + "m3u_hash_key": get_value("m3u-hash-key", ""), + "preferred_region": get_value("preferred-region"), + "auto_import_mapped_files": get_value("auto-import-mapped-files"), + } + CoreSettings.objects.update_or_create( + key="stream_settings", + defaults={"name": "Stream Settings", "value": stream_settings} + ) + + # DVR SETTINGS + dvr_settings = { + "tv_template": get_value("dvr-tv-template", "TV_Shows/{show}/S{season:02d}E{episode:02d}.mkv"), + "movie_template": get_value("dvr-movie-template", "Movies/{title} ({year}).mkv"), + "tv_fallback_dir": get_value("dvr-tv-fallback-dir", "TV_Shows"), + "tv_fallback_template": get_value("dvr-tv-fallback-template", "TV_Shows/{show}/{start}.mkv"), + "movie_fallback_template": get_value("dvr-movie-fallback-template", "Movies/{start}.mkv"), + "comskip_enabled": bool(get_value("dvr-comskip-enabled", False)), + "comskip_custom_path": get_value("dvr-comskip-custom-path", ""), + "pre_offset_minutes": int(get_value("dvr-pre-offset-minutes", 0) or 0), + "post_offset_minutes": int(get_value("dvr-post-offset-minutes", 0) or 0), + "series_rules": get_value("dvr-series-rules", []), + } + CoreSettings.objects.update_or_create( + key="dvr_settings", + defaults={"name": "DVR Settings", "value": dvr_settings} + ) + + # BACKUP SETTINGS - using underscore keys (not dashes) + backup_settings = { + "schedule_enabled": get_value("backup_schedule_enabled") if get_value("backup_schedule_enabled") is not None else True, + "schedule_frequency": get_value("backup_schedule_frequency") or "daily", + "schedule_time": get_value("backup_schedule_time") or "03:00", + "schedule_day_of_week": get_value("backup_schedule_day_of_week") if get_value("backup_schedule_day_of_week") is not None else 0, + "retention_count": get_value("backup_retention_count") if get_value("backup_retention_count") is not None else 3, + "schedule_cron_expression": get_value("backup_schedule_cron_expression") or "", + } + CoreSettings.objects.update_or_create( + key="backup_settings", + defaults={"name": "Backup Settings", "value": backup_settings} + ) + + # SYSTEM SETTINGS + system_settings = { + "time_zone": get_value("system-time-zone", "UTC"), + "max_system_events": int(get_value("max-system-events", 100) or 100), + } + CoreSettings.objects.update_or_create( + key="system_settings", + defaults={"name": "System Settings", "value": system_settings} + ) + + # Rename proxy-settings to proxy_settings (if it exists with old name) + try: + old_proxy = CoreSettings.objects.get(key="proxy-settings") + old_proxy.key = "proxy_settings" + old_proxy.save() + except CoreSettings.DoesNotExist: + pass + + # Ensure proxy_settings exists with defaults if not present + proxy_obj, proxy_created = CoreSettings.objects.get_or_create( + key="proxy_settings", + defaults={ + "name": "Proxy Settings", + "value": { + "buffering_timeout": 15, + "buffering_speed": 1.0, + "redis_chunk_ttl": 60, + "channel_shutdown_delay": 0, + "channel_init_grace_period": 5, + } + } + ) + + # Rename network-access to network_access (if it exists with old name) + try: + old_network = CoreSettings.objects.get(key="network-access") + old_network.key = "network_access" + old_network.save() + except CoreSettings.DoesNotExist: + pass + + # Ensure network_access exists with defaults if not present + network_obj, network_created = CoreSettings.objects.get_or_create( + key="network_access", + defaults={ + "name": "Network Access", + "value": {} + } + ) + # Delete old individual setting rows (keep only the new grouped settings) + grouped_keys = ["stream_settings", "dvr_settings", "backup_settings", "system_settings", "proxy_settings", "network_access"] + CoreSettings.objects.exclude(key__in=grouped_keys).delete() + + +def reverse_migration(apps, schema_editor): + """Reverse migration: split grouped settings and convert JSON back to strings""" + CoreSettings = apps.get_model("core", "CoreSettings") + + # Helper to create individual setting + def create_setting(key, name, value): + # Convert value back to string representation for CharField + if isinstance(value, str): + str_value = value + elif isinstance(value, bool): + str_value = "true" if value else "false" + elif isinstance(value, (int, float)): + str_value = str(value) + elif isinstance(value, (dict, list)): + str_value = json.dumps(value) + elif value is None: + str_value = "" + else: + str_value = str(value) + + CoreSettings.objects.update_or_create( + key=key, + defaults={"name": name, "value": str_value} + ) + + # Split stream_settings + try: + stream = CoreSettings.objects.get(key="stream_settings") + if isinstance(stream.value, dict): + create_setting("default_user_agent", "Default User Agent", stream.value.get("default_user_agent")) + create_setting("default_stream_profile", "Default Stream Profile", stream.value.get("default_stream_profile")) + create_setting("stream_hash_key", "Stream Hash Key", stream.value.get("m3u_hash_key", "")) + create_setting("preferred_region", "Preferred Region", stream.value.get("preferred_region")) + create_setting("auto_import_mapped_files", "Auto Import Mapped Files", stream.value.get("auto_import_mapped_files")) + stream.delete() + except CoreSettings.DoesNotExist: + pass + + # Split dvr_settings + try: + dvr = CoreSettings.objects.get(key="dvr_settings") + if isinstance(dvr.value, dict): + create_setting("dvr_tv_template", "DVR TV Template", dvr.value.get("tv_template", "TV_Shows/{show}/S{season:02d}E{episode:02d}.mkv")) + create_setting("dvr_movie_template", "DVR Movie Template", dvr.value.get("movie_template", "Movies/{title} ({year}).mkv")) + create_setting("dvr_tv_fallback_dir", "DVR TV Fallback Dir", dvr.value.get("tv_fallback_dir", "TV_Shows")) + create_setting("dvr_tv_fallback_template", "DVR TV Fallback Template", dvr.value.get("tv_fallback_template", "TV_Shows/{show}/{start}.mkv")) + create_setting("dvr_movie_fallback_template", "DVR Movie Fallback Template", dvr.value.get("movie_fallback_template", "Movies/{start}.mkv")) + create_setting("dvr_comskip_enabled", "DVR Comskip Enabled", dvr.value.get("comskip_enabled", False)) + create_setting("dvr_comskip_custom_path", "DVR Comskip Custom Path", dvr.value.get("comskip_custom_path", "")) + create_setting("dvr_pre_offset_minutes", "DVR Pre Offset Minutes", dvr.value.get("pre_offset_minutes", 0)) + create_setting("dvr_post_offset_minutes", "DVR Post Offset Minutes", dvr.value.get("post_offset_minutes", 0)) + create_setting("dvr_series_rules", "DVR Series Rules", dvr.value.get("series_rules", [])) + dvr.delete() + except CoreSettings.DoesNotExist: + pass + + # Split backup_settings + try: + backup = CoreSettings.objects.get(key="backup_settings") + if isinstance(backup.value, dict): + create_setting("backup_schedule_enabled", "Backup Schedule Enabled", backup.value.get("schedule_enabled", False)) + create_setting("backup_schedule_frequency", "Backup Schedule Frequency", backup.value.get("schedule_frequency", "weekly")) + create_setting("backup_schedule_time", "Backup Schedule Time", backup.value.get("schedule_time", "02:00")) + create_setting("backup_schedule_day_of_week", "Backup Schedule Day of Week", backup.value.get("schedule_day_of_week", 0)) + create_setting("backup_retention_count", "Backup Retention Count", backup.value.get("retention_count", 7)) + create_setting("backup_schedule_cron_expression", "Backup Schedule Cron Expression", backup.value.get("schedule_cron_expression", "")) + backup.delete() + except CoreSettings.DoesNotExist: + pass + + # Split system_settings + try: + system = CoreSettings.objects.get(key="system_settings") + if isinstance(system.value, dict): + create_setting("system_time_zone", "System Time Zone", system.value.get("time_zone", "UTC")) + create_setting("max_system_events", "Max System Events", system.value.get("max_system_events", 100)) + system.delete() + except CoreSettings.DoesNotExist: + pass + + +class Migration(migrations.Migration): + + dependencies = [ + ('core', '0019_add_vlc_stream_profile'), + ] + + operations = [ + # First, convert all data to valid JSON strings while column is still CharField + migrations.RunPython(convert_string_to_json, migrations.RunPython.noop), + # Then change the field type to JSONField + migrations.AlterField( + model_name='coresettings', + name='value', + field=models.JSONField(blank=True, default=dict), + ), + # Finally, consolidate individual settings into grouped JSON objects + migrations.RunPython(consolidate_settings, reverse_migration), + ] diff --git a/core/migrations/0021_systemnotification_notificationdismissal.py b/core/migrations/0021_systemnotification_notificationdismissal.py new file mode 100644 index 0000000..66eb9b1 --- /dev/null +++ b/core/migrations/0021_systemnotification_notificationdismissal.py @@ -0,0 +1,52 @@ +# Generated by Django 5.2.9 on 2026-02-02 20:38 + +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('core', '0020_change_coresettings_value_to_jsonfield'), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name='SystemNotification', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('notification_key', models.CharField(db_index=True, max_length=255, unique=True)), + ('notification_type', models.CharField(choices=[('version_update', 'Version Update Available'), ('setting_recommendation', 'Recommended Setting Change'), ('announcement', 'System Announcement'), ('warning', 'Warning'), ('info', 'Information')], db_index=True, default='info', max_length=50)), + ('priority', models.CharField(choices=[('low', 'Low'), ('normal', 'Normal'), ('high', 'High'), ('critical', 'Critical')], default='normal', max_length=20)), + ('source', models.CharField(choices=[('system', 'System Generated'), ('developer', 'Developer Notification')], db_index=True, default='system', max_length=20)), + ('title', models.CharField(max_length=255)), + ('message', models.TextField()), + ('action_data', models.JSONField(blank=True, default=dict)), + ('is_active', models.BooleanField(db_index=True, default=True)), + ('admin_only', models.BooleanField(default=False)), + ('expires_at', models.DateTimeField(blank=True, db_index=True, null=True)), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ], + options={ + 'ordering': ['-priority', '-created_at'], + 'indexes': [models.Index(fields=['is_active', '-created_at'], name='core_system_is_acti_afab03_idx'), models.Index(fields=['notification_type', 'is_active'], name='core_system_notific_2179e3_idx'), models.Index(fields=['source', 'is_active'], name='core_system_source_a35829_idx')], + }, + ), + migrations.CreateModel( + name='NotificationDismissal', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('dismissed_at', models.DateTimeField(auto_now_add=True)), + ('action_taken', models.CharField(blank=True, max_length=50, null=True)), + ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='dismissed_notifications', to=settings.AUTH_USER_MODEL)), + ('notification', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='dismissals', to='core.systemnotification')), + ], + options={ + 'indexes': [models.Index(fields=['user', 'notification'], name='core_notifi_user_id_93e02e_idx')], + 'unique_together': {('user', 'notification')}, + }, + ), + ] diff --git a/core/migrations/022_default_user_limit_settings.py b/core/migrations/022_default_user_limit_settings.py new file mode 100644 index 0000000..4c2b3c3 --- /dev/null +++ b/core/migrations/022_default_user_limit_settings.py @@ -0,0 +1,24 @@ +# Generated by Django 5.1.6 on 2025-03-01 14:01 + +from django.db import migrations +from django.utils.text import slugify + + +def preload_user_limit_settings(apps, schema_editor): + CoreSettings = apps.get_model("core", "CoreSettings") + CoreSettings.objects.create( + key="user_limit_settings", + name="User Limit Settings", + value={}, + ) + + +class Migration(migrations.Migration): + + dependencies = [ + ("core", "0021_systemnotification_notificationdismissal"), + ] + + operations = [ + migrations.RunPython(preload_user_limit_settings), + ] diff --git a/core/migrations/__init__.py b/core/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/core/models.py b/core/models.py new file mode 100644 index 0000000..5e86e98 --- /dev/null +++ b/core/models.py @@ -0,0 +1,568 @@ +# core/models.py + +from shlex import split as shlex_split + +from django.conf import settings +from django.db import models +from django.utils.text import slugify +from django.core.exceptions import ValidationError + + +class UserAgent(models.Model): + name = models.CharField( + max_length=512, unique=True, help_text="The User-Agent name." + ) + user_agent = models.CharField( + max_length=512, + unique=True, + help_text="The complete User-Agent string sent by the client.", + ) + description = models.CharField( + max_length=255, + blank=True, + help_text="An optional description of the client or device type.", + ) + is_active = models.BooleanField( + default=True, + help_text="Whether this user agent is currently allowed/recognized.", + ) + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + + def __str__(self): + return self.name + + +PROXY_PROFILE_NAME = "Proxy" +REDIRECT_PROFILE_NAME = "Redirect" + + +class StreamProfile(models.Model): + name = models.CharField(max_length=255, help_text="Name of the stream profile") + command = models.CharField( + max_length=255, + help_text="Command to execute (e.g., 'yt.sh', 'streamlink', or 'vlc')", + blank=True, + ) + parameters = models.TextField( + help_text="Command-line parameters. Use {userAgent} and {streamUrl} as placeholders.", + blank=True, + ) + locked = models.BooleanField( + default=False, help_text="Protected - can't be deleted or modified" + ) + is_active = models.BooleanField( + default=True, help_text="Whether this profile is active" + ) + user_agent = models.ForeignKey( + "UserAgent", + on_delete=models.SET_NULL, + null=True, + blank=True, + help_text="Optional user agent to use. If not set, you can fall back to a default.", + ) + + def __str__(self): + return self.name + + def save(self, *args, **kwargs): + if self.pk: # Only check existing records + orig = StreamProfile.objects.get(pk=self.pk) + if orig.locked: + allowed_fields = {"user_agent_id"} # Only allow this field to change + for field in self._meta.fields: + field_name = field.name + + # Convert user_agent to user_agent_id for comparison + orig_value = getattr(orig, field_name) + new_value = getattr(self, field_name) + + # Ensure that ForeignKey fields compare their ID values + if isinstance(orig_value, models.Model): + orig_value = orig_value.pk + if isinstance(new_value, models.Model): + new_value = new_value.pk + + if field_name not in allowed_fields and orig_value != new_value: + raise ValidationError( + f"Cannot modify {field_name} on a protected profile." + ) + + super().save(*args, **kwargs) + + @classmethod + def update(cls, pk, **kwargs): + instance = cls.objects.get(pk=pk) + + if instance.locked: + allowed_fields = {"user_agent_id"} # Only allow updating this field + + for field_name, new_value in kwargs.items(): + if field_name not in allowed_fields: + raise ValidationError( + f"Cannot modify {field_name} on a protected profile." + ) + + # Ensure user_agent ForeignKey updates correctly + if field_name == "user_agent" and isinstance( + new_value, cls._meta.get_field("user_agent").related_model + ): + new_value = new_value.pk # Convert object to ID if needed + + setattr(instance, field_name, new_value) + + instance.save() + return instance + + def is_proxy(self): + if self.locked and self.name == PROXY_PROFILE_NAME: + return True + return False + + def is_redirect(self): + if self.locked and self.name == REDIRECT_PROFILE_NAME: + return True + return False + + def build_command(self, stream_url, user_agent): + if self.is_proxy(): + return [] + + replacements = { + "{streamUrl}": stream_url, + "{userAgent}": user_agent, + } + + # Split the command and iterate through each part to apply replacements + cmd = [self.command] + [ + self._replace_in_part(part, replacements) + for part in shlex_split(self.parameters) # use shlex to handle quoted strings + ] + + return cmd + + def _replace_in_part(self, part, replacements): + # Iterate through the replacements and replace each part of the string + for key, value in replacements.items(): + part = part.replace(key, value) + return part + + +# Setting group keys +STREAM_SETTINGS_KEY = "stream_settings" +DVR_SETTINGS_KEY = "dvr_settings" +BACKUP_SETTINGS_KEY = "backup_settings" +PROXY_SETTINGS_KEY = "proxy_settings" +NETWORK_ACCESS_KEY = "network_access" +SYSTEM_SETTINGS_KEY = "system_settings" +EPG_SETTINGS_KEY = "epg_settings" +USER_LIMITS_SETTINGS_KEY = "user_limit_settings" + + +class CoreSettings(models.Model): + key = models.CharField( + max_length=255, + unique=True, + ) + name = models.CharField( + max_length=255, + ) + value = models.JSONField( + default=dict, + blank=True, + ) + + def __str__(self): + return "Core Settings" + + # Helper methods to get/set grouped settings + @classmethod + def _get_group(cls, key, defaults=None): + """Get a settings group, returning defaults if not found.""" + try: + return cls.objects.get(key=key).value or (defaults or {}) + except cls.DoesNotExist: + return defaults or {} + + @classmethod + def _update_group(cls, key, name, updates): + """Update specific fields in a settings group.""" + obj, created = cls.objects.get_or_create( + key=key, + defaults={"name": name, "value": {}} + ) + current = obj.value if isinstance(obj.value, dict) else {} + current.update(updates) + obj.value = current + obj.save() + return current + + # Stream Settings + @classmethod + def get_stream_settings(cls): + """Get all stream-related settings.""" + return cls._get_group(STREAM_SETTINGS_KEY, { + "default_user_agent": None, + "default_stream_profile": None, + "m3u_hash_key": "", + "preferred_region": None, + "auto_import_mapped_files": None, + }) + + @classmethod + def get_default_user_agent_id(cls): + return cls.get_stream_settings().get("default_user_agent") + + @classmethod + def get_default_stream_profile_id(cls): + return cls.get_stream_settings().get("default_stream_profile") + + @classmethod + def get_m3u_hash_key(cls): + return cls.get_stream_settings().get("m3u_hash_key", "") + + @classmethod + def get_preferred_region(cls): + return cls.get_stream_settings().get("preferred_region") + + @classmethod + def get_auto_import_mapped_files(cls): + return cls.get_stream_settings().get("auto_import_mapped_files") + + # EPG Settings + @classmethod + def get_epg_settings(cls): + """Get all EPG-related settings.""" + return cls._get_group(EPG_SETTINGS_KEY, { + "epg_match_mode": "default", + "epg_match_ignore_prefixes": [], + "epg_match_ignore_suffixes": [], + "epg_match_ignore_custom": [], + }) + + @classmethod + def _safe_string_list(cls, value): + """Return a list of strings, filtering out non-list or non-string values.""" + if not isinstance(value, list): + return [] + return [v for v in value if isinstance(v, str)] + + @classmethod + def get_epg_match_ignore_prefixes(cls): + return cls._safe_string_list(cls.get_epg_settings().get("epg_match_ignore_prefixes", [])) + + @classmethod + def get_epg_match_ignore_suffixes(cls): + return cls._safe_string_list(cls.get_epg_settings().get("epg_match_ignore_suffixes", [])) + + @classmethod + def get_epg_match_ignore_custom(cls): + return cls._safe_string_list(cls.get_epg_settings().get("epg_match_ignore_custom", [])) + + # DVR Settings + @classmethod + def get_dvr_settings(cls): + """Get all DVR-related settings.""" + return cls._get_group(DVR_SETTINGS_KEY, { + "tv_template": "TV_Shows/{show}/S{season:02d}E{episode:02d}.mkv", + "movie_template": "Movies/{title} ({year}).mkv", + "tv_fallback_dir": "TV_Shows", + "tv_fallback_template": "TV_Shows/{show}/{start}.mkv", + "movie_fallback_template": "Movies/{start}.mkv", + "comskip_enabled": False, + "comskip_custom_path": "", + "pre_offset_minutes": 0, + "post_offset_minutes": 0, + "series_rules": [], + }) + + @classmethod + def get_dvr_tv_template(cls): + return cls.get_dvr_settings().get("tv_template", "TV_Shows/{show}/S{season:02d}E{episode:02d}.mkv") + + @classmethod + def get_dvr_movie_template(cls): + return cls.get_dvr_settings().get("movie_template", "Movies/{title} ({year}).mkv") + + @classmethod + def get_dvr_tv_fallback_dir(cls): + return cls.get_dvr_settings().get("tv_fallback_dir", "TV_Shows") + + @classmethod + def get_dvr_tv_fallback_template(cls): + return cls.get_dvr_settings().get("tv_fallback_template", "TV_Shows/{show}/{start}.mkv") + + @classmethod + def get_dvr_movie_fallback_template(cls): + return cls.get_dvr_settings().get("movie_fallback_template", "Movies/{start}.mkv") + + @classmethod + def get_dvr_comskip_enabled(cls): + return bool(cls.get_dvr_settings().get("comskip_enabled", False)) + + @classmethod + def get_dvr_comskip_custom_path(cls): + return cls.get_dvr_settings().get("comskip_custom_path", "") + + @classmethod + def set_dvr_comskip_custom_path(cls, path: str | None): + value = (path or "").strip() + cls._update_group(DVR_SETTINGS_KEY, "DVR Settings", {"comskip_custom_path": value}) + return value + + @classmethod + def get_dvr_pre_offset_minutes(cls): + return int(cls.get_dvr_settings().get("pre_offset_minutes", 0) or 0) + + @classmethod + def get_dvr_post_offset_minutes(cls): + return int(cls.get_dvr_settings().get("post_offset_minutes", 0) or 0) + + @classmethod + def get_dvr_series_rules(cls): + rules = cls.get_dvr_settings().get("series_rules", []) + if not isinstance(rules, list): + return [] + return [r for r in rules if isinstance(r, dict)] + + @classmethod + def set_dvr_series_rules(cls, rules): + clean = [r for r in rules if isinstance(r, dict)] if isinstance(rules, list) else [] + cls._update_group(DVR_SETTINGS_KEY, "DVR Settings", {"series_rules": clean}) + return clean + + # Proxy Settings + @classmethod + def get_proxy_settings(cls): + """Get proxy settings.""" + return cls._get_group(PROXY_SETTINGS_KEY, { + "buffering_timeout": 15, + "buffering_speed": 1.0, + "redis_chunk_ttl": 60, + "channel_shutdown_delay": 0, + "channel_init_grace_period": 5, + "new_client_behind_seconds": 5, + }) + + # System Settings + @classmethod + def get_system_settings(cls): + """Get all system-related settings.""" + return cls._get_group(SYSTEM_SETTINGS_KEY, { + "time_zone": getattr(settings, "TIME_ZONE", "UTC") or "UTC", + "max_system_events": 100, + }) + + @classmethod + def get_system_time_zone(cls): + return cls.get_system_settings().get("time_zone") or getattr(settings, "TIME_ZONE", "UTC") or "UTC" + + @classmethod + def set_system_time_zone(cls, tz_name: str | None): + value = (tz_name or "").strip() or getattr(settings, "TIME_ZONE", "UTC") or "UTC" + cls._update_group(SYSTEM_SETTINGS_KEY, "System Settings", {"time_zone": value}) + return value + + @classmethod + def get_user_limits_settings(cls): + return cls._get_group(USER_LIMITS_SETTINGS_KEY, { + "terminate_on_limit_exceeded": True, + "prioritize_single_client_channels": True, + "ignore_same_channel_connections": False, + "terminate_oldest": True, + }) + + +class SystemEvent(models.Model): + """ + Tracks system events like channel start/stop, buffering, failover, client connections. + Maintains a rolling history based on max_system_events setting. + """ + EVENT_TYPES = [ + ('channel_start', 'Channel Started'), + ('channel_stop', 'Channel Stopped'), + ('channel_buffering', 'Channel Buffering'), + ('channel_failover', 'Channel Failover'), + ('channel_reconnect', 'Channel Reconnected'), + ('channel_error', 'Channel Error'), + ('client_connect', 'Client Connected'), + ('client_disconnect', 'Client Disconnected'), + ('recording_start', 'Recording Started'), + ('recording_end', 'Recording Ended'), + ('stream_switch', 'Stream Switched'), + ('m3u_refresh', 'M3U Refreshed'), + ('m3u_download', 'M3U Downloaded'), + ('epg_refresh', 'EPG Refreshed'), + ('epg_download', 'EPG Downloaded'), + ('login_success', 'Login Successful'), + ('login_failed', 'Login Failed'), + ('logout', 'User Logged Out'), + ('m3u_blocked', 'M3U Download Blocked'), + ('epg_blocked', 'EPG Download Blocked'), + ] + + event_type = models.CharField(max_length=50, choices=EVENT_TYPES, db_index=True) + timestamp = models.DateTimeField(auto_now_add=True, db_index=True) + channel_id = models.UUIDField(null=True, blank=True, db_index=True) + channel_name = models.CharField(max_length=255, null=True, blank=True) + details = models.JSONField(default=dict, blank=True) + + class Meta: + ordering = ['-timestamp'] + indexes = [ + models.Index(fields=['-timestamp']), + models.Index(fields=['event_type', '-timestamp']), + ] + + def __str__(self): + return f"{self.event_type} - {self.channel_name or 'N/A'} @ {self.timestamp}" + + +class SystemNotification(models.Model): + """ + Stores system notifications that users can view and dismiss. + Used for version updates, recommended settings, announcements, etc. + """ + class NotificationType(models.TextChoices): + VERSION_UPDATE = 'version_update', 'Version Update Available' + SETTING_RECOMMENDATION = 'setting_recommendation', 'Recommended Setting Change' + ANNOUNCEMENT = 'announcement', 'System Announcement' + WARNING = 'warning', 'Warning' + INFO = 'info', 'Information' + + class Priority(models.TextChoices): + LOW = 'low', 'Low' + NORMAL = 'normal', 'Normal' + HIGH = 'high', 'High' + CRITICAL = 'critical', 'Critical' + + class Source(models.TextChoices): + SYSTEM = 'system', 'System Generated' + DEVELOPER = 'developer', 'Developer Notification' + + # Unique identifier for the notification (e.g., 'version-0.19.0', 'setting-proxy-buffer') + # This allows deduplication and targeted dismissals + notification_key = models.CharField(max_length=255, unique=True, db_index=True) + + notification_type = models.CharField( + max_length=50, + choices=NotificationType.choices, + default=NotificationType.INFO, + db_index=True + ) + priority = models.CharField( + max_length=20, + choices=Priority.choices, + default=Priority.NORMAL + ) + + # Source of the notification (system-generated vs developer-defined) + source = models.CharField( + max_length=20, + choices=Source.choices, + default=Source.SYSTEM, + db_index=True + ) + + title = models.CharField(max_length=255) + message = models.TextField() + + # Optional action data (e.g., setting key/value for recommendations, release URL for versions) + action_data = models.JSONField(default=dict, blank=True) + + # Whether this notification is currently active + is_active = models.BooleanField(default=True, db_index=True) + + # Admin-only notifications require admin privileges to view + admin_only = models.BooleanField(default=False) + + # Auto-expire after this date (null = never expires) + expires_at = models.DateTimeField(null=True, blank=True, db_index=True) + + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + + class Meta: + ordering = ['-priority', '-created_at'] + indexes = [ + models.Index(fields=['is_active', '-created_at']), + models.Index(fields=['notification_type', 'is_active']), + models.Index(fields=['source', 'is_active']), + ] + + def __str__(self): + return f"[{self.notification_type}] {self.title}" + + @classmethod + def create_version_notification(cls, version, release_url=None, release_notes=None): + """Create or update a version update notification. Returns (notification, created) tuple.""" + key = f"version-{version}" + notification, created = cls.objects.update_or_create( + notification_key=key, + defaults={ + 'notification_type': cls.NotificationType.VERSION_UPDATE, + 'priority': cls.Priority.HIGH, + 'title': f'Version {version} Available', + 'message': f'A new version of Dispatcharr ({version}) is available.', + 'action_data': { + 'version': version, + 'release_url': release_url, + 'release_notes': release_notes, + }, + 'is_active': True, + 'admin_only': True, + } + ) + return notification, created + + @classmethod + def create_setting_recommendation(cls, setting_key, recommended_value, reason, current_value=None): + """Create a setting recommendation notification. Returns (notification, created) tuple.""" + key = f"setting-{setting_key}" + notification, created = cls.objects.update_or_create( + notification_key=key, + defaults={ + 'notification_type': cls.NotificationType.SETTING_RECOMMENDATION, + 'priority': cls.Priority.NORMAL, + 'title': f'Recommended Setting: {setting_key}', + 'message': reason, + 'action_data': { + 'setting_key': setting_key, + 'recommended_value': recommended_value, + 'current_value': current_value, + }, + 'is_active': True, + 'admin_only': True, + } + ) + return notification, created + + +class NotificationDismissal(models.Model): + """ + Tracks which users have dismissed which notifications. + Allows users to dismiss notifications once without seeing them again. + """ + user = models.ForeignKey( + settings.AUTH_USER_MODEL, + on_delete=models.CASCADE, + related_name='dismissed_notifications' + ) + notification = models.ForeignKey( + SystemNotification, + on_delete=models.CASCADE, + related_name='dismissals' + ) + dismissed_at = models.DateTimeField(auto_now_add=True) + + # Optional: track if user accepted/applied the recommendation + action_taken = models.CharField(max_length=50, blank=True, null=True) + + class Meta: + unique_together = ['user', 'notification'] + indexes = [ + models.Index(fields=['user', 'notification']), + ] + + def __str__(self): + return f"{self.user.username} dismissed {self.notification.notification_key}" diff --git a/core/redis_pubsub.py b/core/redis_pubsub.py new file mode 100644 index 0000000..b1f48b1 --- /dev/null +++ b/core/redis_pubsub.py @@ -0,0 +1,263 @@ +""" +Redis PubSub utilities for maintaining long-lived connections. +""" +import threading +import time +import logging +import json +from redis import Redis +from redis.exceptions import ConnectionError, TimeoutError + +logger = logging.getLogger(__name__) + +class DummyPubSub: + """Dummy PubSub implementation when Redis isn't available""" + def __init__(self): + pass + + def subscribe(self, *args, **kwargs): + pass + + def psubscribe(self, *args, **kwargs): + pass + + def get_message(self, *args, **kwargs): + return None + + def close(self): + pass + +class RedisPubSubManager: + """ + A robust Redis PubSub manager that handles disconnections and reconnections. + """ + + def __init__(self, redis_client=None, auto_reconnect=True): + """ + Initialize the PubSub manager. + + Args: + redis_client: An existing Redis client to use + auto_reconnect: Whether to automatically reconnect on failure + """ + self.redis_client = redis_client + self.pubsub = None + self.subscriptions = set() + self.pattern_subscriptions = set() + self.auto_reconnect = auto_reconnect + self.running = True + self.lock = threading.RLock() + self.message_handlers = {} # Map of channels to handler functions + self.message_thread = None + self.is_dummy = redis_client is None + + def subscribe(self, channel, handler=None): + """ + Subscribe to a channel. + + Args: + channel: The channel to subscribe to + handler: Optional function to call when messages are received + """ + if self.is_dummy: + return + + with self.lock: + self.subscriptions.add(channel) + if handler: + self.message_handlers[channel] = handler + + if self.pubsub: + self.pubsub.subscribe(channel) + logger.info(f"Subscribed to channel: {channel}") + + def psubscribe(self, pattern, handler=None): + """ + Subscribe to a channel pattern. + + Args: + pattern: The pattern to subscribe to + handler: Optional function to call when messages are received + """ + if self.is_dummy: + return + + with self.lock: + self.pattern_subscriptions.add(pattern) + if handler: + self.message_handlers[pattern] = handler + + if self.pubsub: + self.pubsub.psubscribe(pattern) + logger.info(f"Subscribed to pattern: {pattern}") + + def publish(self, channel, message): + """ + Publish a message to a channel. + + Args: + channel: The channel to publish to + message: The message to publish (will be JSON-encoded if not a string) + + Returns: + Number of clients that received the message + """ + if self.is_dummy: + return 0 + + try: + if not isinstance(message, str): + message = json.dumps(message) + return self.redis_client.publish(channel, message) + except Exception as e: + logger.error(f"Error publishing to {channel}: {e}") + return 0 + + def start_listening(self): + """ + Start listening for messages in a background thread. + """ + if self.is_dummy: + logger.debug("Running with dummy Redis client - not starting listener") + return + + if not self.message_thread: + self._connect() + self.message_thread = threading.Thread( + target=self._listen_for_messages, + daemon=True, + name="redis-pubsub-listener" + ) + self.message_thread.start() + logger.info("Started Redis PubSub listener thread") + + def stop(self): + """ + Stop listening and clean up resources. + """ + if self.is_dummy: + return + + self.running = False + if self.pubsub: + try: + self.pubsub.close() + except: + pass + self.pubsub = None + + def _connect(self): + """ + Establish a new PubSub connection and subscribe to all channels. + """ + if self.is_dummy: + self.pubsub = DummyPubSub() + return + + with self.lock: + # Close any existing connection + if self.pubsub: + try: + self.pubsub.close() + except: + pass + + # Create a new PubSub instance - critical: no timeout for subscribe operations + # This prevents the connection from timing out while waiting for messages + self.pubsub = self.redis_client.pubsub() + + # Resubscribe to all channels + if self.subscriptions: + self.pubsub.subscribe(*self.subscriptions) + logger.info(f"Resubscribed to channels: {self.subscriptions}") + + # Resubscribe to all patterns + if self.pattern_subscriptions: + self.pubsub.psubscribe(*self.pattern_subscriptions) + logger.info(f"Resubscribed to patterns: {self.pattern_subscriptions}") + + def _listen_for_messages(self): + """ + Background thread that listens for messages and handles reconnections. + """ + if self.is_dummy: + return + + consecutive_errors = 0 + + while self.running: + try: + # Check if we need to connect + if not self.pubsub: + self._connect() + + # Listen for messages with NO timeout - this is critical! + message = self.pubsub.get_message(timeout=None) + + if message: + # Don't process subscription confirmation messages + if message['type'] in ('subscribe', 'psubscribe'): + continue + + channel = message.get('channel') + if channel: + # Find and call the appropriate handler + handler = self.message_handlers.get(channel) + if handler: + try: + handler(message) + except Exception as e: + logger.error(f"Error in message handler for {channel}: {e}") + + # Reset error counter on success + consecutive_errors = 0 + + # Small sleep to prevent excessive CPU usage + time.sleep(0.01) + + except (ConnectionError, TimeoutError) as e: + consecutive_errors += 1 + + if not self.auto_reconnect: + logger.error(f"PubSub connection error and auto_reconnect is disabled: {e}") + break + + # Exponential backoff for reconnection attempts + backoff = min(consecutive_errors * 0.5, 5) + logger.warning(f"PubSub connection error, reconnecting in {backoff} seconds: {e}") + time.sleep(backoff) + + # Reconnect + self._connect() + + except Exception as e: + logger.error(f"Unexpected error in PubSub listener: {e}") + time.sleep(1) # Prevent tight loop in case of persistent errors + + logger.info("PubSub listener thread stopping") + +# Create a singleton instance +pubsub_manager = None + +def get_pubsub_manager(redis_client=None): + """ + Get or create the PubSub manager singleton. + + Args: + redis_client: Optional Redis client to use + + Returns: + The PubSub manager instance + """ + global pubsub_manager + + if pubsub_manager is None: + pubsub_manager = RedisPubSubManager(redis_client) + # Only start if redis_client is not None + if redis_client is not None: + try: + pubsub_manager.start_listening() + except Exception as e: + logger.error(f"Failed to start PubSub listener: {e}") + + return pubsub_manager diff --git a/core/scheduling.py b/core/scheduling.py new file mode 100644 index 0000000..0b4b78c --- /dev/null +++ b/core/scheduling.py @@ -0,0 +1,203 @@ +""" +Reusable scheduling utilities for creating/updating/deleting +Celery Beat periodic tasks with interval or cron-based schedules. +""" + +import json +import logging + +from django_celery_beat.models import CrontabSchedule, IntervalSchedule, PeriodicTask + +from core.models import CoreSettings + +logger = logging.getLogger(__name__) + + +def parse_cron_expression(cron_expression): + """ + Parse a 5-part cron expression into its components. + + Args: + cron_expression: A string like "0 3 * * *" + + Returns: + dict with keys: minute, hour, day_of_month, month_of_year, day_of_week + + Raises: + ValueError: If the expression is not valid 5-part cron. + """ + parts = cron_expression.strip().split() + if len(parts) != 5: + raise ValueError( + "Cron expression must have 5 parts: minute hour day month weekday" + ) + return { + "minute": parts[0], + "hour": parts[1], + "day_of_month": parts[2], + "month_of_year": parts[3], + "day_of_week": parts[4], + } + + +def create_or_update_periodic_task( + task_name, + celery_task_path, + kwargs=None, + interval_hours=0, + cron_expression="", + enabled=True, +): + """ + Create or update a Celery Beat PeriodicTask. Supports both interval + (hours) and cron-based scheduling. + + When *cron_expression* is provided and non-empty it takes precedence + over *interval_hours*. An interval_hours of 0 (with no cron) means + the task is disabled. + + Args: + task_name: Unique PeriodicTask name. + celery_task_path: Dotted path to the Celery task function. + kwargs: dict of keyword arguments passed to the task. + interval_hours: Interval in hours (0 = disabled when no cron). + cron_expression: 5-part cron string (empty = use interval). + enabled: Whether the task should be enabled. + + Returns: + The PeriodicTask instance (created or updated). + """ + task_kwargs = json.dumps(kwargs or {}) + + # Determine effective enabled state + use_cron = bool(cron_expression and cron_expression.strip()) + should_be_enabled = enabled and (use_cron or interval_hours > 0) + + # Retrieve existing task (if any) to track old schedule objects + old_interval = None + old_crontab = None + try: + existing = PeriodicTask.objects.get(name=task_name) + old_interval = existing.interval + old_crontab = existing.crontab + except PeriodicTask.DoesNotExist: + existing = None + + if use_cron: + # ---- Cron-based schedule ---- + cron_parts = parse_cron_expression(cron_expression) + system_tz = CoreSettings.get_system_time_zone() + + crontab, _ = CrontabSchedule.objects.get_or_create( + minute=cron_parts["minute"], + hour=cron_parts["hour"], + day_of_week=cron_parts["day_of_week"], + day_of_month=cron_parts["day_of_month"], + month_of_year=cron_parts["month_of_year"], + timezone=system_tz, + ) + + defaults = { + "task": celery_task_path, + "crontab": crontab, + "interval": None, + "enabled": should_be_enabled, + "kwargs": task_kwargs, + } + + task, created = PeriodicTask.objects.update_or_create( + name=task_name, defaults=defaults + ) + + # Clean up old interval if we switched from interval → cron + if old_interval: + _cleanup_orphaned_interval(old_interval) + # Clean up old crontab if it changed + if old_crontab and old_crontab.id != crontab.id: + _cleanup_orphaned_crontab(old_crontab) + + else: + # ---- Interval-based schedule ---- + interval, _ = IntervalSchedule.objects.get_or_create( + every=max(int(interval_hours), 1) if interval_hours else 1, + period=IntervalSchedule.HOURS, + ) + + defaults = { + "task": celery_task_path, + "interval": interval, + "crontab": None, + "enabled": should_be_enabled, + "kwargs": task_kwargs, + } + + task, created = PeriodicTask.objects.update_or_create( + name=task_name, defaults=defaults + ) + + # Clean up old crontab if we switched from cron → interval + if old_crontab: + _cleanup_orphaned_crontab(old_crontab) + # Clean up old interval if it changed + if old_interval and old_interval.id != interval.id: + _cleanup_orphaned_interval(old_interval) + + action = "Created" if created else "Updated" + mode = "cron" if use_cron else "interval" + logger.info(f"{action} periodic task '{task_name}' ({mode}, enabled={should_be_enabled})") + return task + + +def delete_periodic_task(task_name): + """ + Delete a PeriodicTask by name and clean up orphaned schedules. + + Args: + task_name: The unique name of the PeriodicTask. + + Returns: + True if a task was found and deleted, False otherwise. + """ + try: + task = PeriodicTask.objects.get(name=task_name) + except PeriodicTask.DoesNotExist: + logger.warning(f"No PeriodicTask found with name '{task_name}'") + return False + + old_interval = task.interval + old_crontab = task.crontab + task_id = task.id + + task.delete() + logger.info(f"Deleted periodic task '{task_name}' (id={task_id})") + + if old_interval: + _cleanup_orphaned_interval(old_interval) + if old_crontab: + _cleanup_orphaned_crontab(old_crontab) + + return True + + +# --------------------------------------------------------------------------- +# Internal helpers +# --------------------------------------------------------------------------- + +def _cleanup_orphaned_interval(interval_schedule): + """Delete an IntervalSchedule if no PeriodicTasks reference it.""" + if interval_schedule is None: + return + if PeriodicTask.objects.filter(interval=interval_schedule).exists(): + return + logger.debug(f"Cleaning up orphaned IntervalSchedule {interval_schedule.id}") + interval_schedule.delete() + + +def _cleanup_orphaned_crontab(crontab_schedule): + """Delete a CrontabSchedule if no PeriodicTasks reference it.""" + if crontab_schedule is None: + return + if PeriodicTask.objects.filter(crontab=crontab_schedule).exists(): + return + logger.debug(f"Cleaning up orphaned CrontabSchedule {crontab_schedule.id}") + crontab_schedule.delete() diff --git a/core/serializers.py b/core/serializers.py new file mode 100644 index 0000000..0df656b --- /dev/null +++ b/core/serializers.py @@ -0,0 +1,166 @@ +# core/serializers.py +import json +import ipaddress + +from rest_framework import serializers +from .models import CoreSettings, UserAgent, StreamProfile, DVR_SETTINGS_KEY, NETWORK_ACCESS_KEY + + +class UserAgentSerializer(serializers.ModelSerializer): + class Meta: + model = UserAgent + fields = [ + "id", + "name", + "user_agent", + "description", + "is_active", + "created_at", + "updated_at", + ] + + +class StreamProfileSerializer(serializers.ModelSerializer): + class Meta: + model = StreamProfile + fields = [ + "id", + "name", + "command", + "parameters", + "is_active", + "user_agent", + "locked", + ] + + +class CoreSettingsSerializer(serializers.ModelSerializer): + class Meta: + model = CoreSettings + fields = "__all__" + + def update(self, instance, validated_data): + if instance.key == NETWORK_ACCESS_KEY: + errors = False + invalid = {} + value = validated_data.get("value") + for key, val in value.items(): + cidrs = val.split(",") + for cidr in cidrs: + try: + ipaddress.ip_network(cidr) + except: + errors = True + if key not in invalid: + invalid[key] = [] + invalid[key].append(cidr) + + if errors: + # Perform CIDR validation + raise serializers.ValidationError( + { + "message": "Invalid CIDRs", + "value": invalid, + } + ) + + # Sanitize series_rules when DVR settings are saved through the + # generic settings API (e.g. Settings page round-trip) to prevent + # corrupted non-dict entries from persisting. + if instance.key == DVR_SETTINGS_KEY: + value = validated_data.get("value") + if isinstance(value, dict) and "series_rules" in value: + rules = value["series_rules"] + value["series_rules"] = ( + [r for r in rules if isinstance(r, dict)] + if isinstance(rules, list) + else [] + ) + + result = super().update(instance, validated_data) + + # Note: Cache invalidation and notification sync is handled by post_save signal + # in core/signals.py to ensure it happens even if settings are updated elsewhere + + return result + +class ProxySettingsSerializer(serializers.Serializer): + """Serializer for proxy settings stored as JSON in CoreSettings""" + buffering_timeout = serializers.IntegerField(min_value=0, max_value=300) + buffering_speed = serializers.FloatField(min_value=0.1, max_value=10.0) + redis_chunk_ttl = serializers.IntegerField(min_value=10, max_value=3600) + channel_shutdown_delay = serializers.IntegerField(min_value=0, max_value=300) + channel_init_grace_period = serializers.IntegerField(min_value=0, max_value=60) + new_client_behind_seconds = serializers.IntegerField(min_value=0, max_value=120, required=False, default=5) + + def validate_buffering_timeout(self, value): + if value < 0 or value > 300: + raise serializers.ValidationError("Buffering timeout must be between 0 and 300 seconds") + return value + + def validate_buffering_speed(self, value): + if value < 0.1 or value > 10.0: + raise serializers.ValidationError("Buffering speed must be between 0.1 and 10.0") + return value + + def validate_redis_chunk_ttl(self, value): + if value < 10 or value > 3600: + raise serializers.ValidationError("Redis chunk TTL must be between 10 and 3600 seconds") + return value + + def validate_channel_shutdown_delay(self, value): + if value < 0 or value > 300: + raise serializers.ValidationError("Channel shutdown delay must be between 0 and 300 seconds") + return value + + def validate_channel_init_grace_period(self, value): + if value < 0 or value > 60: + raise serializers.ValidationError("Channel init grace period must be between 0 and 60 seconds") + return value + + def validate_new_client_behind_seconds(self, value): + if value < 0 or value > 120: + raise serializers.ValidationError("New client buffer must be between 0 and 120 seconds") + return value + + +class SystemNotificationSerializer(serializers.ModelSerializer): + """Serializer for system notifications.""" + is_dismissed = serializers.SerializerMethodField() + + class Meta: + from .models import SystemNotification + model = SystemNotification + fields = [ + 'id', + 'notification_key', + 'notification_type', + 'priority', + 'title', + 'message', + 'action_data', + 'is_active', + 'admin_only', + 'expires_at', + 'created_at', + 'is_dismissed', + 'source', + ] + read_only_fields = ['created_at'] + + def get_is_dismissed(self, obj): + """Check if the current user has dismissed this notification.""" + request = self.context.get('request') + if request and request.user.is_authenticated: + return obj.dismissals.filter(user=request.user).exists() + return False + + +class NotificationDismissalSerializer(serializers.ModelSerializer): + """Serializer for notification dismissals.""" + + class Meta: + from .models import NotificationDismissal + model = NotificationDismissal + fields = ['id', 'notification', 'dismissed_at', 'action_taken'] + read_only_fields = ['dismissed_at'] diff --git a/core/signals.py b/core/signals.py new file mode 100644 index 0000000..bbb73dd --- /dev/null +++ b/core/signals.py @@ -0,0 +1,39 @@ +from django.db.models.signals import pre_delete, post_save +from django.dispatch import receiver +from django.core.exceptions import ValidationError +from .models import StreamProfile, CoreSettings, NETWORK_ACCESS_KEY + +@receiver(pre_delete, sender=StreamProfile) +def prevent_deletion_if_locked(sender, instance, **kwargs): + if instance.locked: + raise ValidationError("This profile is locked and cannot be deleted.") + +@receiver(post_save, sender=CoreSettings) +def handle_network_access_update(sender, instance, **kwargs): + """Invalidate cache and sync notifications when network access settings change.""" + if instance.key == NETWORK_ACCESS_KEY: + from django.core.cache import cache + from core.developer_notifications import sync_developer_notifications + import logging + + logger = logging.getLogger(__name__) + + # Invalidate all notification condition caches + try: + cache.delete_pattern('dev_notif_condition_*') + logger.info("Invalidated notification condition cache due to network access settings update") + except Exception as e: + logger.warning(f"Failed to delete cache pattern: {e}") + # Fallback: try to clear entire cache (if delete_pattern not supported) + try: + cache.clear() + except Exception: + pass + + # Re-sync developer notifications to re-evaluate conditions + # (websocket notification is sent by sync_developer_notifications) + try: + sync_developer_notifications() + logger.info("Re-synced developer notifications after network access settings update") + except Exception as e: + logger.error(f"Failed to sync developer notifications: {e}") diff --git a/core/tasks.py b/core/tasks.py new file mode 100644 index 0000000..042d63e --- /dev/null +++ b/core/tasks.py @@ -0,0 +1,992 @@ +from celery import shared_task +from channels.layers import get_channel_layer +from asgiref.sync import async_to_sync +import json +import logging +import re +import time +import os +from core.utils import RedisClient, send_websocket_update, acquire_task_lock, release_task_lock +from apps.proxy.ts_proxy.channel_status import ChannelStatus +from apps.m3u.models import M3UAccount +from apps.epg.models import EPGSource +from apps.m3u.tasks import refresh_single_m3u_account +from apps.epg.tasks import refresh_epg_data +from .models import CoreSettings +from apps.channels.models import ChannelStream +from django.db import transaction + +logger = logging.getLogger(__name__) + +EPG_WATCH_DIR = '/data/epgs' +M3U_WATCH_DIR = '/data/m3us' +LOGO_WATCH_DIR = '/data/logos' +MIN_AGE_SECONDS = 6 +STARTUP_SKIP_AGE = 30 +REDIS_PREFIX = "processed_file:" +REDIS_TTL = 60 * 60 * 24 * 3 # expire keys after 3 days (optional) +SUPPORTED_LOGO_FORMATS = ['.jpg', '.jpeg', '.png', '.gif', '.webp', '.bmp', '.svg'] + +# Store the last known value to compare with new data +last_known_data = {} +# Store when we last logged certain recurring messages +_last_log_times = {} +# Don't repeat similar log messages more often than this (in seconds) +LOG_THROTTLE_SECONDS = 300 # 5 minutes +# Track if this is the first scan since startup +_first_scan_completed = False + +def throttled_log(logger_method, message, key=None, *args, **kwargs): + """Only log messages with the same key once per throttle period""" + if key is None: + # Use message as key if no explicit key provided + key = message + + now = time.time() + if key not in _last_log_times or (now - _last_log_times[key]) >= LOG_THROTTLE_SECONDS: + logger_method(message, *args, **kwargs) + _last_log_times[key] = now + +@shared_task +def beat_periodic_task(): + fetch_channel_stats() + scan_and_process_files() + +@shared_task +def scan_and_process_files(): + global _first_scan_completed + redis_client = RedisClient.get_client() + now = time.time() + + # Check if directories exist + dirs_exist = all(os.path.exists(d) for d in [M3U_WATCH_DIR, EPG_WATCH_DIR, LOGO_WATCH_DIR]) + if not dirs_exist: + throttled_log(logger.warning, f"Watch directories missing: M3U ({os.path.exists(M3U_WATCH_DIR)}), EPG ({os.path.exists(EPG_WATCH_DIR)}), LOGO ({os.path.exists(LOGO_WATCH_DIR)})", "watch_dirs_missing") + + # Process M3U files + m3u_files = [f for f in os.listdir(M3U_WATCH_DIR) + if os.path.isfile(os.path.join(M3U_WATCH_DIR, f)) and + (f.endswith('.m3u') or f.endswith('.m3u8'))] + + m3u_processed = 0 + m3u_skipped = 0 + + for filename in m3u_files: + filepath = os.path.join(M3U_WATCH_DIR, filename) + mtime = os.path.getmtime(filepath) + age = now - mtime + redis_key = REDIS_PREFIX + filepath + stored_mtime = redis_client.get(redis_key) + + # Instead of assuming old files were processed, check if they exist in the database + if not stored_mtime and age > STARTUP_SKIP_AGE: + # Check if this file is already in the database + existing_m3u = M3UAccount.objects.filter(file_path=filepath).exists() + if existing_m3u: + # Use trace level if not first scan + if _first_scan_completed: + logger.trace(f"Skipping {filename}: Already exists in database") + else: + logger.debug(f"Skipping {filename}: Already exists in database") + redis_client.set(redis_key, mtime, ex=REDIS_TTL) + m3u_skipped += 1 + continue + else: + logger.debug(f"Processing {filename} despite age: Not found in database") + # Continue processing this file even though it's old + + # File too new — probably still being written + if age < MIN_AGE_SECONDS: + logger.debug(f"Skipping {filename}: Too new (age={age}s)") + m3u_skipped += 1 + continue + + # Skip if we've already processed this mtime + if stored_mtime and float(stored_mtime) >= mtime: + # Use trace level if not first scan + if _first_scan_completed: + logger.trace(f"Skipping {filename}: Already processed this version") + else: + logger.debug(f"Skipping {filename}: Already processed this version") + m3u_skipped += 1 + continue + + m3u_account, created = M3UAccount.objects.get_or_create(file_path=filepath, defaults={ + "name": filename, + "is_active": CoreSettings.get_auto_import_mapped_files() in [True, "true", "True"], + }) + + redis_client.set(redis_key, mtime, ex=REDIS_TTL) + + # More descriptive creation logging that includes active status + if created: + if m3u_account.is_active: + logger.info(f"Created new M3U account '{filename}' (active)") + else: + logger.info(f"Created new M3U account '{filename}' (inactive due to auto-import setting)") + + if not m3u_account.is_active: + # Use trace level if not first scan + if _first_scan_completed: + logger.trace(f"Skipping {filename}: M3U account is inactive") + else: + logger.debug(f"Skipping {filename}: M3U account is inactive") + m3u_skipped += 1 + continue + + # Log update for existing files (we've already logged creation above) + if not created: + logger.info(f"Detected update to existing M3U file: {filename}") + + logger.info(f"Queueing refresh for M3U file: {filename}") + refresh_single_m3u_account.delay(m3u_account.id) + m3u_processed += 1 + + channel_layer = get_channel_layer() + async_to_sync(channel_layer.group_send)( + "updates", + { + "type": "update", + "data": {"success": True, "type": "m3u_file", "filename": filename} + }, + ) + + logger.trace(f"M3U processing complete: {m3u_processed} processed, {m3u_skipped} skipped, {len(m3u_files)} total") + + # Process EPG files + try: + epg_files = os.listdir(EPG_WATCH_DIR) + logger.trace(f"Found {len(epg_files)} files in EPG directory") + except Exception as e: + logger.error(f"Error listing EPG directory: {e}") + epg_files = [] + + epg_processed = 0 + epg_skipped = 0 + epg_errors = 0 + + for filename in epg_files: + filepath = os.path.join(EPG_WATCH_DIR, filename) + + if not os.path.isfile(filepath): + # Use trace level if not first scan + if _first_scan_completed: + logger.trace(f"Skipping {filename}: Not a file") + else: + logger.debug(f"Skipping {filename}: Not a file") + epg_skipped += 1 + continue + + if not filename.endswith('.xml') and not filename.endswith('.gz') and not filename.endswith('.zip'): + # Use trace level if not first scan + if _first_scan_completed: + logger.trace(f"Skipping {filename}: Not an XML, GZ or zip file") + else: + logger.debug(f"Skipping {filename}: Not an XML, GZ or zip file") + epg_skipped += 1 + continue + + mtime = os.path.getmtime(filepath) + age = now - mtime + redis_key = REDIS_PREFIX + filepath + stored_mtime = redis_client.get(redis_key) + + # Instead of assuming old files were processed, check if they exist in the database + if not stored_mtime and age > STARTUP_SKIP_AGE: + # Check if this file is already in the database + existing_epg = EPGSource.objects.filter(file_path=filepath).exists() + if existing_epg: + # Use trace level if not first scan + if _first_scan_completed: + logger.trace(f"Skipping {filename}: Already exists in database") + else: + logger.debug(f"Skipping {filename}: Already exists in database") + redis_client.set(redis_key, mtime, ex=REDIS_TTL) + epg_skipped += 1 + continue + else: + logger.debug(f"Processing {filename} despite age: Not found in database") + # Continue processing this file even though it's old + + # File too new — probably still being written + if age < MIN_AGE_SECONDS: + # Use trace level if not first scan + if _first_scan_completed: + logger.trace(f"Skipping {filename}: Too new, possibly still being written (age={age}s)") + else: + logger.debug(f"Skipping {filename}: Too new, possibly still being written (age={age}s)") + epg_skipped += 1 + continue + + # Skip if we've already processed this mtime + if stored_mtime and float(stored_mtime) >= mtime: + # Use trace level if not first scan + if _first_scan_completed: + logger.trace(f"Skipping {filename}: Already processed this version") + else: + logger.debug(f"Skipping {filename}: Already processed this version") + epg_skipped += 1 + continue + + try: + epg_source, created = EPGSource.objects.get_or_create(file_path=filepath, defaults={ + "name": filename, + "source_type": "xmltv", + "is_active": CoreSettings.get_auto_import_mapped_files() in [True, "true", "True"], + }) + + redis_client.set(redis_key, mtime, ex=REDIS_TTL) + + # More descriptive creation logging that includes active status + if created: + if epg_source.is_active: + logger.info(f"Created new EPG source '{filename}' (active)") + else: + logger.info(f"Created new EPG source '{filename}' (inactive due to auto-import setting)") + + if not epg_source.is_active: + # Use trace level if not first scan + if _first_scan_completed: + logger.trace(f"Skipping {filename}: EPG source is marked as inactive") + else: + logger.debug(f"Skipping {filename}: EPG source is marked as inactive") + epg_skipped += 1 + continue + + # Log update for existing files (we've already logged creation above) + if not created: + logger.info(f"Detected update to existing EPG file: {filename}") + + logger.info(f"Queueing refresh for EPG file: {filename}") + refresh_epg_data.delay(epg_source.id) # Trigger Celery task + epg_processed += 1 + + except Exception as e: + logger.error(f"Error processing EPG file {filename}: {str(e)}", exc_info=True) + epg_errors += 1 + continue + + logger.trace(f"EPG processing complete: {epg_processed} processed, {epg_skipped} skipped, {epg_errors} errors") + + # Process Logo files (including subdirectories) + try: + logo_files = [] + if os.path.exists(LOGO_WATCH_DIR): + for root, dirs, files in os.walk(LOGO_WATCH_DIR): + for filename in files: + logo_files.append(os.path.join(root, filename)) + logger.trace(f"Found {len(logo_files)} files in LOGO directory (including subdirectories)") + except Exception as e: + logger.error(f"Error listing LOGO directory: {e}") + logo_files = [] + + logo_processed = 0 + logo_skipped = 0 + logo_errors = 0 + + for filepath in logo_files: + filename = os.path.basename(filepath) + + if not os.path.isfile(filepath): + if _first_scan_completed: + logger.trace(f"Skipping {filename}: Not a file") + else: + logger.debug(f"Skipping {filename}: Not a file") + logo_skipped += 1 + continue + + # Check if file has supported logo extension + file_ext = os.path.splitext(filename)[1].lower() + if file_ext not in SUPPORTED_LOGO_FORMATS: + if _first_scan_completed: + logger.trace(f"Skipping {filename}: Not a supported logo format") + else: + logger.debug(f"Skipping {filename}: Not a supported logo format") + logo_skipped += 1 + continue + + mtime = os.path.getmtime(filepath) + age = now - mtime + redis_key = REDIS_PREFIX + filepath + stored_mtime = redis_client.get(redis_key) + + # Check if logo already exists in database + if not stored_mtime and age > STARTUP_SKIP_AGE: + from apps.channels.models import Logo + existing_logo = Logo.objects.filter(url=filepath).exists() + if existing_logo: + if _first_scan_completed: + logger.trace(f"Skipping {filename}: Already exists in database") + else: + logger.debug(f"Skipping {filename}: Already exists in database") + redis_client.set(redis_key, mtime, ex=REDIS_TTL) + logo_skipped += 1 + continue + else: + logger.debug(f"Processing {filename} despite age: Not found in database") + + # File too new — probably still being written + if age < MIN_AGE_SECONDS: + if _first_scan_completed: + logger.trace(f"Skipping {filename}: Too new, possibly still being written (age={age}s)") + else: + logger.debug(f"Skipping {filename}: Too new, possibly still being written (age={age}s)") + logo_skipped += 1 + continue + + # Skip if we've already processed this mtime + if stored_mtime and float(stored_mtime) >= mtime: + if _first_scan_completed: + logger.trace(f"Skipping {filename}: Already processed this version") + else: + logger.debug(f"Skipping {filename}: Already processed this version") + logo_skipped += 1 + continue + + try: + from apps.channels.models import Logo + + # Create logo entry with just the filename (without extension) as name + logo_name = os.path.splitext(filename)[0] + + logo, created = Logo.objects.get_or_create( + url=filepath, + defaults={ + "name": logo_name, + } + ) + + redis_client.set(redis_key, mtime, ex=REDIS_TTL) + + if created: + logger.info(f"Created new logo entry: {logo_name}") + else: + logger.debug(f"Logo entry already exists: {logo_name}") + + logo_processed += 1 + + except Exception as e: + logger.error(f"Error processing logo file {filename}: {str(e)}", exc_info=True) + logo_errors += 1 + continue + + logger.trace(f"LOGO processing complete: {logo_processed} processed, {logo_skipped} skipped, {logo_errors} errors") + + # Send summary websocket update for logo processing + if logo_processed > 0 or logo_errors > 0: + send_websocket_update( + "updates", + "update", + { + "success": True, + "type": "logo_processing_summary", + "processed": logo_processed, + "skipped": logo_skipped, + "errors": logo_errors, + "total_files": len(logo_files), + "message": f"Logo processing complete: {logo_processed} processed, {logo_skipped} skipped, {logo_errors} errors" + } + ) + + # Mark that the first scan is complete + _first_scan_completed = True + +def fetch_channel_stats(): + redis_client = RedisClient.get_client() + + try: + # Basic info for all channels + channel_pattern = "ts_proxy:channel:*:metadata" + all_channels = [] + + # Extract channel IDs from keys + cursor = 0 + while True: + cursor, keys = redis_client.scan(cursor, match=channel_pattern) + for key in keys: + channel_id_match = re.search(r"ts_proxy:channel:(.*):metadata", key) + if channel_id_match: + ch_id = channel_id_match.group(1) + channel_info = ChannelStatus.get_basic_channel_info(ch_id) + if channel_info: + all_channels.append(channel_info) + + if cursor == 0: + break + + send_websocket_update( + "updates", + "update", + { + "success": True, + "type": "channel_stats", + "stats": json.dumps({'channels': all_channels, 'count': len(all_channels)}) + }, + collect_garbage=True + ) + + # Explicitly clean up large data structures + all_channels = None + + except Exception as e: + logger.error(f"Error in channel_status: {e}", exc_info=True) + return + +@shared_task +def rehash_streams(keys): + """ + Regenerate stream hashes for all streams based on current hash key configuration. + This task checks for and blocks M3U refresh tasks to prevent conflicts. + """ + from apps.channels.models import Stream + from apps.m3u.models import M3UAccount + + logger.info("Starting stream rehash process") + + # Get all M3U account IDs for locking + m3u_account_ids = list(M3UAccount.objects.filter(is_active=True).values_list('id', flat=True)) + + # Check if any M3U refresh tasks are currently running + blocked_accounts = [] + for account_id in m3u_account_ids: + if not acquire_task_lock('refresh_single_m3u_account', account_id): + blocked_accounts.append(account_id) + + if blocked_accounts: + # Release any locks we did acquire + for account_id in m3u_account_ids: + if account_id not in blocked_accounts: + release_task_lock('refresh_single_m3u_account', account_id) + + logger.warning(f"Rehash blocked: M3U refresh tasks running for accounts: {blocked_accounts}") + + # Send WebSocket notification to inform user + send_websocket_update( + 'updates', + 'update', + { + "success": False, + "type": "stream_rehash", + "action": "blocked", + "blocked_accounts": len(blocked_accounts), + "total_accounts": len(m3u_account_ids), + "message": f"Stream rehash blocked: M3U refresh tasks are currently running for {len(blocked_accounts)} accounts. Please try again later." + } + ) + + return f"Rehash blocked: M3U refresh tasks running for {len(blocked_accounts)} accounts" + + acquired_locks = m3u_account_ids.copy() + + try: + batch_size = 1000 + + # Track statistics + total_processed = 0 + duplicates_merged = 0 + # hash_keys maps new_hash -> stream_id for streams we've already processed + hash_keys = {} + # Track IDs of streams that have been deleted to avoid stale references + deleted_stream_ids = set() + + # Get initial count for progress reporting + initial_total_records = Stream.objects.count() + logger.info(f"Starting rehash of {initial_total_records} streams with keys: {keys}") + + # Send initial WebSocket update + send_websocket_update( + 'updates', + 'update', + { + "success": True, + "type": "stream_rehash", + "action": "starting", + "progress": 0, + "total_records": initial_total_records, + "message": f"Starting rehash of {initial_total_records} streams" + } + ) + + # Use ID-based pagination to handle deletions correctly + # This ensures we don't skip records when items are deleted + last_processed_id = 0 + batch_number = 0 + + while True: + batch_number += 1 + batch_processed = 0 + batch_duplicates = 0 + + with transaction.atomic(): + # Fetch batch by ID ordering, using select_for_update to lock records + # This prevents race conditions and ensures we process each record exactly once + batch = list( + Stream.objects.filter(id__gt=last_processed_id) + .select_for_update(skip_locked=True, of=('self',)) + .select_related('channel_group', 'm3u_account') + .order_by('id')[:batch_size] + ) + + if not batch: + # No more records to process + break + + for obj in batch: + # Update the last processed ID for next batch + last_processed_id = obj.id + + # Generate new hash - handle XC accounts differently + group_name = obj.channel_group.name if obj.channel_group else None + account_type = obj.m3u_account.account_type if obj.m3u_account else None + stream_id_val = obj.stream_id if hasattr(obj, 'stream_id') else None + + new_hash = Stream.generate_hash_key( + obj.name, obj.url, obj.tvg_id, keys, + m3u_id=obj.m3u_account_id, group=group_name, + account_type=account_type, stream_id=stream_id_val + ) + + # Check if this hash already exists in our tracking dict + if new_hash in hash_keys: + existing_stream_id = hash_keys[new_hash] + + # Verify the target stream still exists and hasn't been deleted + if existing_stream_id in deleted_stream_ids: + # The target was deleted, so this stream becomes the new canonical one + obj.stream_hash = new_hash + obj.save(update_fields=['stream_hash']) + hash_keys[new_hash] = obj.id + batch_processed += 1 + continue + + try: + existing_stream = Stream.objects.get(id=existing_stream_id) + except Stream.DoesNotExist: + # Target stream was deleted externally, make this the canonical one + deleted_stream_ids.add(existing_stream_id) + obj.stream_hash = new_hash + obj.save(update_fields=['stream_hash']) + hash_keys[new_hash] = obj.id + batch_processed += 1 + continue + + # Determine which stream to keep based on channel ordering + stream_to_keep, stream_to_delete = _determine_stream_to_keep(existing_stream, obj) + + # Move channel relationships from the stream being deleted to the one being kept + _merge_stream_relationships(stream_to_delete, stream_to_keep) + + # Delete the duplicate FIRST to free up the unique hash constraint + deleted_stream_ids.add(stream_to_delete.id) + stream_to_delete.delete() + batch_duplicates += 1 + + # Now safely set the hash on the kept stream (after deletion freed it up) + if stream_to_keep.stream_hash != new_hash: + stream_to_keep.stream_hash = new_hash + stream_to_keep.save(update_fields=['stream_hash']) + + # Update hash_keys to point to the kept stream + hash_keys[new_hash] = stream_to_keep.id + else: + # Check if hash already exists in database (from streams not yet processed) + existing_stream = Stream.objects.filter(stream_hash=new_hash).exclude(id=obj.id).first() + if existing_stream: + # Found duplicate in database - determine which to keep based on channel ordering + stream_to_keep, stream_to_delete = _determine_stream_to_keep(existing_stream, obj) + + # Move channel relationships from the stream being deleted to the one being kept + _merge_stream_relationships(stream_to_delete, stream_to_keep) + + # Delete the duplicate FIRST to free up the unique hash constraint + deleted_stream_ids.add(stream_to_delete.id) + stream_to_delete.delete() + batch_duplicates += 1 + + # Now safely set the hash on the kept stream (after deletion freed it up) + if stream_to_keep.stream_hash != new_hash: + stream_to_keep.stream_hash = new_hash + stream_to_keep.save(update_fields=['stream_hash']) + + hash_keys[new_hash] = stream_to_keep.id + else: + # No duplicate - update hash for this stream + obj.stream_hash = new_hash + obj.save(update_fields=['stream_hash']) + hash_keys[new_hash] = obj.id + + batch_processed += 1 + + total_processed += batch_processed + duplicates_merged += batch_duplicates + + # Calculate progress percentage based on initial count + # Cap at 99% until we're actually done to avoid showing 100% prematurely + progress_percent = min(99, int((total_processed / max(initial_total_records, 1)) * 100)) + + # Send progress update via WebSocket + send_websocket_update( + 'updates', + 'update', + { + "success": True, + "type": "stream_rehash", + "action": "processing", + "progress": progress_percent, + "batch": batch_number, + "processed": total_processed, + "duplicates_merged": duplicates_merged, + "message": f"Processed batch {batch_number}: {batch_processed} streams, {batch_duplicates} duplicates merged" + } + ) + + logger.info(f"Rehashed batch {batch_number}: " + f"{batch_processed} processed, {batch_duplicates} duplicates merged") + + logger.info(f"Rehashing complete: {total_processed} streams processed, " + f"{duplicates_merged} duplicates merged") + + # Send completion update via WebSocket + send_websocket_update( + 'updates', + 'update', + { + "success": True, + "type": "stream_rehash", + "action": "completed", + "progress": 100, + "total_processed": total_processed, + "duplicates_merged": duplicates_merged, + "final_count": total_processed - duplicates_merged, + "message": f"Rehashing complete: {total_processed} streams processed, {duplicates_merged} duplicates merged" + }, + collect_garbage=True # Force garbage collection after completion + ) + + logger.info("Stream rehash completed successfully") + return f"Successfully rehashed {total_processed} streams" + + except Exception as e: + logger.error(f"Error during stream rehash: {e}", exc_info=True) + raise + finally: + # Always release all acquired M3U locks + for account_id in acquired_locks: + release_task_lock('refresh_single_m3u_account', account_id) + logger.info(f"Released M3U task locks for {len(acquired_locks)} accounts") + + +def _merge_stream_relationships(source_stream, target_stream): + """ + Move channel relationships from source_stream to target_stream. + Handles unique constraint violations by preserving existing relationships. + Preserves the best ordering when merging relationships. + """ + for channel_stream in ChannelStream.objects.filter(stream_id=source_stream.id): + # Check if this channel already has a relationship with the target stream + existing_relationship = ChannelStream.objects.filter( + channel_id=channel_stream.channel_id, + stream_id=target_stream.id + ).first() + + if existing_relationship: + # Relationship already exists - keep the one with better ordering (lower order value) + if channel_stream.order < existing_relationship.order: + existing_relationship.order = channel_stream.order + existing_relationship.save(update_fields=['order']) + # Delete the duplicate relationship + channel_stream.delete() + else: + # Safe to update the relationship + channel_stream.stream_id = target_stream.id + channel_stream.save() + + +def _get_best_channel_order(stream): + """ + Get the best (lowest) channel order for a stream. + Returns None if stream has no channel relationships. + Lower order value = better/higher position in the channel list. + """ + best_order = ChannelStream.objects.filter(stream_id=stream.id).order_by('order').values_list('order', flat=True).first() + return best_order + + +def _determine_stream_to_keep(stream_a, stream_b): + """ + Determine which stream should be kept when merging duplicates. + + Priority: + 1. Stream with better (lower) channel order wins + 2. If both have same order or neither has channel relationships, + keep the one with more recent updated_at + 3. If still tied, keep the one with the lower ID (more stable) + + Returns: (stream_to_keep, stream_to_delete) + """ + order_a = _get_best_channel_order(stream_a) + order_b = _get_best_channel_order(stream_b) + + # If one has channel relationships and the other doesn't, keep the one with relationships + if order_a is not None and order_b is None: + return (stream_a, stream_b) + if order_b is not None and order_a is None: + return (stream_b, stream_a) + + # If both have channel relationships, keep the one with better (lower) order + if order_a is not None and order_b is not None: + if order_a < order_b: + return (stream_a, stream_b) + elif order_b < order_a: + return (stream_b, stream_a) + # Same order, fall through to other criteria + + # Neither has relationships, or same order - use updated_at + if stream_a.updated_at > stream_b.updated_at: + return (stream_a, stream_b) + elif stream_b.updated_at > stream_a.updated_at: + return (stream_b, stream_a) + + # Same updated_at - keep lower ID for stability + if stream_a.id < stream_b.id: + return (stream_a, stream_b) + return (stream_b, stream_a) + + +@shared_task +def check_for_version_update(): + """ + Check for new Dispatcharr versions on GitHub and create a notification if available. + This task should be run periodically (e.g., daily) via Celery Beat. + + For dev builds (identified by __timestamp__), checks for stable releases only. + For production builds, checks for stable releases. + + Note: Dev builds are container images from the dev branch and don't have GitHub releases. + This checks if a stable release is available so dev users know when to upgrade. + """ + import requests + from datetime import datetime, timezone + from packaging import version as pkg_version + from version import __version__, __timestamp__ + from core.models import SystemNotification + from core.utils import send_websocket_notification + + try: + is_dev_build = __timestamp__ is not None + DISPATCHARR_HEADERS = {'User-Agent': f'Dispatcharr/{__version__}'} + + if is_dev_build: + # Check Docker Hub for newer dev builds + docker_hub_url = "https://hub.docker.com/v2/repositories/dispatcharr/dispatcharr/tags/dev" + + response = requests.get(docker_hub_url, headers=DISPATCHARR_HEADERS, timeout=10) + + if response.status_code != 200: + logger.warning(f"Failed to check Docker Hub for dev updates: HTTP {response.status_code}") + return + + dev_tag_data = response.json() + docker_last_updated = dev_tag_data.get("last_updated") + + if not docker_last_updated: + logger.warning("No last_updated timestamp found in Docker Hub response") + return + + # Parse timestamps for comparison + local_dt = datetime.strptime(__timestamp__, "%Y%m%d%H%M%S").replace(tzinfo=timezone.utc) + docker_dt = datetime.fromisoformat(docker_last_updated.replace('Z', '+00:00')) + + # Calculate difference in minutes + diff_minutes = (docker_dt - local_dt).total_seconds() / 60 + + # Threshold to account for build/push time differences + THRESHOLD_MINUTES = 10 + + if diff_minutes > THRESHOLD_MINUTES: + logger.info(f"New dev build available on Docker Hub (updated {int(diff_minutes)} minutes after current build)") + + # Delete any old version update notifications (both dev and stable, in case user switched) + deleted_count = SystemNotification.objects.filter( + notification_type='version_update' + ).delete()[0] + if deleted_count > 0: + logger.debug(f"Deleted {deleted_count} old dev build notification(s)") + send_websocket_update( + 'updates', + 'update', + { + 'success': True, + 'type': 'notifications_cleared', + 'count': deleted_count + } + ) + + # Create notification for new dev build + notification, created = SystemNotification.objects.get_or_create( + notification_key=f'version-dev-{docker_last_updated}', + defaults={ + 'notification_type': 'version_update', + 'title': 'New Dev Build Available', + 'message': f'A newer development build is available on Docker Hub (v{__version__}-dev)', + 'priority': 'medium', + 'action_data': { + 'current_version': __version__, + 'current_timestamp': __timestamp__, + 'docker_updated': docker_last_updated, + 'update_url': 'https://hub.docker.com/r/dispatcharr/dispatcharr/tags' + }, + 'is_active': True, + 'admin_only': True, + } + ) + + if created: + # Only send WebSocket for newly created notifications + send_websocket_notification(notification) + logger.info(f"New dev build notification created and sent via WebSocket") + else: + logger.debug(f"Dev build is up to date (Docker Hub image is {abs(int(diff_minutes))} minutes {'newer' if diff_minutes > 0 else 'older'})") + + # Delete all version update notifications when up to date (both dev and stable) + deleted_count = SystemNotification.objects.filter( + notification_type='version_update' + ).delete()[0] + + if deleted_count > 0: + logger.info(f"Deleted {deleted_count} outdated dev build notification(s)") + send_websocket_update( + 'updates', + 'update', + { + 'success': True, + 'type': 'notifications_cleared', + 'count': deleted_count + } + ) + else: + # Production build - check GitHub for stable releases. + # Delete any stale notification for the currently running version upfront; + # a "vX is available" notification is meaningless once the user is already on vX. + # Notify the frontend immediately so the badge clears without waiting for the API call. + deleted_count = SystemNotification.objects.filter( + notification_key=f"version-{__version__}", + notification_type='version_update', + ).delete()[0] + if deleted_count > 0: + send_websocket_update( + 'updates', + 'update', + {'success': True, 'type': 'notifications_cleared', 'count': deleted_count} + ) + + github_api_url = "https://api.github.com/repos/Dispatcharr/Dispatcharr/releases/latest" + headers = {"Accept": "application/vnd.github.v3+json", **DISPATCHARR_HEADERS} + response = requests.get( + github_api_url, + headers=headers, + timeout=10 + ) + + if response.status_code != 200: + logger.warning(f"Failed to check for updates: HTTP {response.status_code}") + return + + release_data = response.json() + latest_version = release_data.get("tag_name", "").lstrip("v") + release_url = release_data.get("html_url", "") + + if not latest_version: + logger.warning("No version tag found in GitHub release") + return + + # Compare versions + current = pkg_version.parse(__version__) + latest = pkg_version.parse(latest_version) + if latest > current: + logger.info(f"New stable version available: {latest_version} (current: {__version__})") + + # Delete any old version update notifications (superseded by this one) + deleted_count = SystemNotification.objects.filter( + notification_type='version_update' + ).exclude( + notification_key=f"version-{latest_version}" + ).delete()[0] + if deleted_count > 0: + logger.debug(f"Deleted {deleted_count} old version notification(s)") + send_websocket_update( + 'updates', + 'update', + { + 'success': True, + 'type': 'notifications_cleared', + 'count': deleted_count + } + ) + + # Create or update the notification for the new version + notification, created = SystemNotification.create_version_notification( + version=latest_version, + release_url=release_url, + ) + + if created: + # Only send WebSocket for newly created notifications + send_websocket_notification(notification) + logger.info(f"New version notification created and sent via WebSocket") + else: + logger.debug(f"Dispatcharr is up to date (v{__version__})") + + # Delete ALL version update notifications when up to date (no longer needed) + deleted_count = SystemNotification.objects.filter( + notification_type='version_update' + ).delete()[0] + + if deleted_count > 0: + logger.info(f"Deleted {deleted_count} outdated version notification(s)") + send_websocket_update( + 'updates', + 'update', + { + 'success': True, + 'type': 'notifications_cleared', + 'count': deleted_count + } + ) + + except requests.RequestException as e: + logger.warning(f"Network error checking for updates: {e}") + except Exception as e: + logger.error(f"Error checking for version updates: {e}") + + +def create_setting_recommendation(setting_key, recommended_value, reason, current_value=None): + """ + Create a setting recommendation notification. + This is a helper function that can be called from anywhere in the codebase. + + Args: + setting_key: The setting key (e.g., 'proxy_settings.buffering_timeout') + recommended_value: The recommended value for the setting + reason: Why this setting is recommended + current_value: The current value (optional) + + Returns: + The created SystemNotification instance + """ + from core.models import SystemNotification + from core.utils import send_websocket_notification + + notification, created = SystemNotification.create_setting_recommendation( + setting_key=setting_key, + recommended_value=recommended_value, + reason=reason, + current_value=current_value + ) + + # Only send via WebSocket for newly created notifications + if created: + send_websocket_notification(notification) + + return notification + diff --git a/core/tests.py b/core/tests.py new file mode 100644 index 0000000..b4770f9 --- /dev/null +++ b/core/tests.py @@ -0,0 +1,223 @@ +from unittest.mock import patch, MagicMock + +from django.test import TestCase + +from core.models import CoreSettings, DVR_SETTINGS_KEY, EPG_SETTINGS_KEY + + +class GetDvrSeriesRulesTest(TestCase): + """Verify get_dvr_series_rules handles corrupted stored data.""" + + def _set_series_rules_raw(self, raw_value): + """Write a raw series_rules value into the DB, bypassing set_dvr_series_rules.""" + obj, _ = CoreSettings.objects.get_or_create( + key=DVR_SETTINGS_KEY, + defaults={"name": "DVR Settings", "value": {}}, + ) + current = obj.value if isinstance(obj.value, dict) else {} + current["series_rules"] = raw_value + obj.value = current + obj.save() + + def test_valid_rules_returned_as_is(self): + rules = [{"tvg_id": "abc", "mode": "all", "title": "Show"}] + self._set_series_rules_raw(rules) + result = CoreSettings.get_dvr_series_rules() + self.assertEqual(result, rules) + + def test_non_dict_elements_filtered(self): + """Strings in the list cause 'str' has no attribute 'get'.""" + self._set_series_rules_raw(["bad_string", {"tvg_id": "abc", "mode": "all", "title": ""}]) + result = CoreSettings.get_dvr_series_rules() + self.assertEqual(len(result), 1) + self.assertEqual(result[0]["tvg_id"], "abc") + + def test_non_list_value_returns_empty(self): + """If series_rules is a JSON string instead of a list, return empty.""" + self._set_series_rules_raw("[]") + result = CoreSettings.get_dvr_series_rules() + self.assertEqual(result, []) + + def test_none_value_returns_empty(self): + self._set_series_rules_raw(None) + result = CoreSettings.get_dvr_series_rules() + self.assertEqual(result, []) + + def test_mixed_corrupt_elements(self): + self._set_series_rules_raw([42, None, True, {"tvg_id": "x", "mode": "new", "title": "T"}]) + result = CoreSettings.get_dvr_series_rules() + self.assertEqual(len(result), 1) + self.assertEqual(result[0]["tvg_id"], "x") + + +class SetDvrSeriesRulesTest(TestCase): + """Verify set_dvr_series_rules sanitizes input before persisting.""" + + def test_valid_rules_persisted(self): + rules = [{"tvg_id": "abc", "mode": "all", "title": "Show"}] + result = CoreSettings.set_dvr_series_rules(rules) + self.assertEqual(result, rules) + self.assertEqual(CoreSettings.get_dvr_series_rules(), rules) + + def test_non_dict_elements_stripped_on_write(self): + dirty = ["bad", 42, {"tvg_id": "abc", "mode": "all", "title": ""}] + result = CoreSettings.set_dvr_series_rules(dirty) + self.assertEqual(len(result), 1) + self.assertEqual(result[0]["tvg_id"], "abc") + self.assertEqual(CoreSettings.get_dvr_series_rules(), result) + + def test_non_list_input_stores_empty(self): + result = CoreSettings.set_dvr_series_rules("not a list") + self.assertEqual(result, []) + self.assertEqual(CoreSettings.get_dvr_series_rules(), []) + + +class CoreSettingsSerializerDvrTest(TestCase): + """Verify the generic settings API sanitizes series_rules on save.""" + + def test_serializer_strips_corrupt_series_rules(self): + """Settings page round-trip must not persist corrupt series_rules.""" + from core.serializers import CoreSettingsSerializer + + obj, _ = CoreSettings.objects.get_or_create( + key=DVR_SETTINGS_KEY, + defaults={"name": "DVR Settings", "value": {"series_rules": []}}, + ) + dirty_value = { + **obj.value, + "series_rules": ["bad", {"tvg_id": "ok", "mode": "all", "title": ""}], + } + serializer = CoreSettingsSerializer(obj, data={"value": dirty_value}, partial=True) + serializer.is_valid(raise_exception=True) + serializer.save() + obj.refresh_from_db() + rules = obj.value.get("series_rules", []) + self.assertEqual(len(rules), 1) + self.assertEqual(rules[0]["tvg_id"], "ok") + + def test_serializer_handles_non_list_series_rules(self): + from core.serializers import CoreSettingsSerializer + + obj, _ = CoreSettings.objects.get_or_create( + key=DVR_SETTINGS_KEY, + defaults={"name": "DVR Settings", "value": {"series_rules": []}}, + ) + dirty_value = {**obj.value, "series_rules": "not a list"} + serializer = CoreSettingsSerializer(obj, data={"value": dirty_value}, partial=True) + serializer.is_valid(raise_exception=True) + serializer.save() + obj.refresh_from_db() + self.assertEqual(obj.value.get("series_rules"), []) + + +class EpgIgnoreListsTest(TestCase): + """Verify EPG ignore list getters handle corrupted stored data.""" + + def _set_epg_field_raw(self, field, raw_value): + obj, _ = CoreSettings.objects.get_or_create( + key=EPG_SETTINGS_KEY, + defaults={"name": "EPG Settings", "value": {}}, + ) + current = obj.value if isinstance(obj.value, dict) else {} + current[field] = raw_value + obj.value = current + obj.save() + + def test_valid_string_lists_returned(self): + for field, getter in [ + ("epg_match_ignore_prefixes", CoreSettings.get_epg_match_ignore_prefixes), + ("epg_match_ignore_suffixes", CoreSettings.get_epg_match_ignore_suffixes), + ("epg_match_ignore_custom", CoreSettings.get_epg_match_ignore_custom), + ]: + self._set_epg_field_raw(field, ["HD", "SD"]) + self.assertEqual(getter(), ["HD", "SD"]) + + def test_non_string_elements_filtered(self): + for field, getter in [ + ("epg_match_ignore_prefixes", CoreSettings.get_epg_match_ignore_prefixes), + ("epg_match_ignore_suffixes", CoreSettings.get_epg_match_ignore_suffixes), + ("epg_match_ignore_custom", CoreSettings.get_epg_match_ignore_custom), + ]: + self._set_epg_field_raw(field, [42, None, "HD", True, "SD"]) + result = getter() + self.assertEqual(result, ["HD", "SD"]) + + def test_non_list_value_returns_empty(self): + for field, getter in [ + ("epg_match_ignore_prefixes", CoreSettings.get_epg_match_ignore_prefixes), + ("epg_match_ignore_suffixes", CoreSettings.get_epg_match_ignore_suffixes), + ("epg_match_ignore_custom", CoreSettings.get_epg_match_ignore_custom), + ]: + self._set_epg_field_raw(field, "not a list") + self.assertEqual(getter(), []) + + +class DropDBCommandTlsTest(TestCase): + """Verify dropdb management command passes TLS parameters to psycopg2.""" + databases = [] + + _DB_WITH_TLS = { + 'default': { + 'ENGINE': 'django.db.backends.postgresql', + 'NAME': 'testdb', + 'USER': 'testuser', + 'PASSWORD': 'testpass', + 'HOST': 'localhost', + 'PORT': 5432, + 'OPTIONS': { + 'sslmode': 'verify-full', + 'sslrootcert': '/certs/ca.crt', + 'sslcert': '/certs/client.crt', + 'sslkey': '/certs/client.key', + }, + } + } + + _DB_NO_TLS = { + 'default': { + 'ENGINE': 'django.db.backends.postgresql', + 'NAME': 'testdb', + 'USER': 'testuser', + 'PASSWORD': 'testpass', + 'HOST': 'localhost', + 'PORT': 5432, + } + } + + @patch('core.management.commands.dropdb.psycopg2.connect') + @patch('core.management.commands.dropdb.connection') + @patch('builtins.input', return_value='yes') + def test_dropdb_passes_ssl_kwargs_when_tls_enabled(self, _inp, _conn, mock_connect): + mock_pg = MagicMock() + mock_connect.return_value = mock_pg + mock_pg.cursor.return_value = MagicMock() + + with self.settings(DATABASES=self._DB_WITH_TLS): + from django.core.management import call_command + call_command('dropdb') + + mock_connect.assert_called_once_with( + dbname='postgres', user='testuser', password='testpass', + host='localhost', port=5432, + sslmode='verify-full', + sslrootcert='/certs/ca.crt', + sslcert='/certs/client.crt', + sslkey='/certs/client.key', + ) + + @patch('core.management.commands.dropdb.psycopg2.connect') + @patch('core.management.commands.dropdb.connection') + @patch('builtins.input', return_value='yes') + def test_dropdb_no_ssl_kwargs_when_tls_disabled(self, _inp, _conn, mock_connect): + mock_pg = MagicMock() + mock_connect.return_value = mock_pg + mock_pg.cursor.return_value = MagicMock() + + with self.settings(DATABASES=self._DB_NO_TLS): + from django.core.management import call_command + call_command('dropdb') + + mock_connect.assert_called_once_with( + dbname='postgres', user='testuser', password='testpass', + host='localhost', port=5432, + ) diff --git a/core/urls.py b/core/urls.py new file mode 100644 index 0000000..d90dde1 --- /dev/null +++ b/core/urls.py @@ -0,0 +1,6 @@ +from django.urls import path +from .views import settings_view + +urlpatterns = [ + path('', settings_view, name='settings'), +] diff --git a/core/utils.py b/core/utils.py new file mode 100644 index 0000000..ba2458e --- /dev/null +++ b/core/utils.py @@ -0,0 +1,707 @@ +import redis +import logging +import time +import os +import threading +from pathlib import Path +import re +from django.conf import settings +from redis.exceptions import ConnectionError, TimeoutError +from django.core.cache import cache +from asgiref.sync import async_to_sync +from channels.layers import get_channel_layer +from django.core.validators import URLValidator +from django.core.exceptions import ValidationError +import gc + +_REDIS_TLS_HINT = " (TLS is enabled — verify certificate paths and that Redis is configured for TLS)" + +logger = logging.getLogger(__name__) + +# Import the command detector +from .command_utils import is_management_command + +def natural_sort_key(text): + """ + Convert a string into a list of string and number chunks for natural sorting. + "PPV 10" becomes ['PPV ', 10] so it sorts correctly with "PPV 2". + + This function enables natural/alphanumeric sorting where numbers within strings + are treated as actual numbers rather than strings. + + Args: + text (str): The text to convert for sorting + + Returns: + list: A list of strings and integers for proper sorting + + Example: + >>> sorted(['PPV 1', 'PPV 10', 'PPV 2'], key=natural_sort_key) + ['PPV 1', 'PPV 2', 'PPV 10'] + """ + def convert(chunk): + return int(chunk) if chunk.isdigit() else chunk.lower() + + return [convert(c) for c in re.split('([0-9]+)', text)] + +class RedisClient: + _client = None + _buffer = None + _pubsub_client = None + + @classmethod + def _init_client(cls, decode_responses=True, max_retries=5, retry_interval=1): + retry_count = 0 + while retry_count < max_retries: + try: + # Get connection parameters from settings or environment + redis_host = os.environ.get("REDIS_HOST", getattr(settings, 'REDIS_HOST', 'localhost')) + redis_port = int(os.environ.get("REDIS_PORT", getattr(settings, 'REDIS_PORT', 6379))) + redis_db = int(os.environ.get("REDIS_DB", getattr(settings, 'REDIS_DB', 0))) + redis_password = os.environ.get("REDIS_PASSWORD", getattr(settings, 'REDIS_PASSWORD', '')) + redis_user = os.environ.get("REDIS_USER", getattr(settings, 'REDIS_USER', '')) + + # Use standardized settings + socket_timeout = getattr(settings, 'REDIS_SOCKET_TIMEOUT', 5) + socket_connect_timeout = getattr(settings, 'REDIS_SOCKET_CONNECT_TIMEOUT', 5) + health_check_interval = getattr(settings, 'REDIS_HEALTH_CHECK_INTERVAL', 30) + socket_keepalive = getattr(settings, 'REDIS_SOCKET_KEEPALIVE', True) + retry_on_timeout = getattr(settings, 'REDIS_RETRY_ON_TIMEOUT', True) + + # TLS params from settings (empty dict when TLS is disabled) + ssl_params = getattr(settings, 'REDIS_SSL_PARAMS', {}) + + # Create Redis client with better defaults + client = redis.Redis( + host=redis_host, + port=redis_port, + db=redis_db, + password=redis_password if redis_password else None, + username=redis_user if redis_user else None, + socket_timeout=socket_timeout, + socket_connect_timeout=socket_connect_timeout, + socket_keepalive=socket_keepalive, + health_check_interval=health_check_interval, + retry_on_timeout=retry_on_timeout, + decode_responses=decode_responses, + **ssl_params + ) + + # Validate connection with ping + client.ping() + + # Disable persistence on first connection - improves performance + # Only try to disable if not in a read-only environment + try: + client.config_set('save', '') # Disable RDB snapshots + client.config_set('appendonly', 'no') # Disable AOF logging + + # Disable protected mode when in debug mode + if os.environ.get('DISPATCHARR_DEBUG', '').lower() == 'true': + client.config_set('protected-mode', 'no') # Disable protected mode in debug + logger.warning("Redis protected mode disabled for debug environment") + + logger.trace("Redis persistence disabled for better performance") + except redis.exceptions.ResponseError as e: + # Improve error handling for Redis configuration errors + if "OOM" in str(e): + logger.error(f"Redis OOM during configuration: {e}") + # Try to increase maxmemory as an emergency measure + try: + client.config_set('maxmemory', '768mb') + logger.warning("Applied emergency Redis memory increase to 768MB") + except: + pass + else: + logger.error(f"Redis configuration error: {e}") + + logger.info(f"Connected to Redis at {redis_host}:{redis_port}/{redis_db}") + + return client + + except (ConnectionError, TimeoutError) as e: + retry_count += 1 + _tls_hint = _REDIS_TLS_HINT if ssl_params else "" + if retry_count >= max_retries: + logger.error(f"Failed to connect to Redis after {max_retries} attempts: {e}{_tls_hint}") + return None + else: + # Use exponential backoff for retries + wait_time = retry_interval * (2 ** (retry_count - 1)) + logger.warning(f"Redis connection failed. Retrying in {wait_time}s... ({retry_count}/{max_retries})") + time.sleep(wait_time) + + except Exception as e: + _tls_hint = "" + try: + _tls_hint = _REDIS_TLS_HINT if ssl_params else "" + except NameError: + pass + logger.error(f"Unexpected error connecting to Redis: {e}{_tls_hint}") + return None + + return None + + @classmethod + def get_client(cls, max_retries=5, retry_interval=1): + """Get Redis client optimized for non-binary data (decoded responses)""" + if cls._client is None: + cls._client = cls._init_client(decode_responses=True, max_retries=max_retries, retry_interval=retry_interval) + return cls._client + + @classmethod + def get_buffer(cls, max_retries=5, retry_interval=1): + """Get Redis client optimized for binary data (no decoding)""" + if cls._buffer is None: + cls._buffer = cls._init_client(decode_responses=False, max_retries=max_retries, retry_interval=retry_interval) + return cls._buffer + + @classmethod + def get_pubsub_client(cls, max_retries=5, retry_interval=1): + """Get Redis client optimized for PubSub operations""" + if cls._pubsub_client is None: + retry_count = 0 + while retry_count < max_retries: + try: + # Get connection parameters from settings or environment + redis_host = os.environ.get("REDIS_HOST", getattr(settings, 'REDIS_HOST', 'localhost')) + redis_port = int(os.environ.get("REDIS_PORT", getattr(settings, 'REDIS_PORT', 6379))) + redis_db = int(os.environ.get("REDIS_DB", getattr(settings, 'REDIS_DB', 0))) + redis_password = os.environ.get("REDIS_PASSWORD", getattr(settings, 'REDIS_PASSWORD', '')) + redis_user = os.environ.get("REDIS_USER", getattr(settings, 'REDIS_USER', '')) + + # Use standardized settings but without socket timeouts for PubSub + # Important: socket_timeout is None for PubSub operations + socket_connect_timeout = getattr(settings, 'REDIS_SOCKET_CONNECT_TIMEOUT', 5) + socket_keepalive = getattr(settings, 'REDIS_SOCKET_KEEPALIVE', True) + health_check_interval = getattr(settings, 'REDIS_HEALTH_CHECK_INTERVAL', 30) + retry_on_timeout = getattr(settings, 'REDIS_RETRY_ON_TIMEOUT', True) + + ssl_params = getattr(settings, 'REDIS_SSL_PARAMS', {}) + + # Create Redis client with PubSub-optimized settings - no timeout + client = redis.Redis( + host=redis_host, + port=redis_port, + db=redis_db, + password=redis_password if redis_password else None, + username=redis_user if redis_user else None, + socket_timeout=None, # Critical: No timeout for PubSub operations + socket_connect_timeout=socket_connect_timeout, + socket_keepalive=socket_keepalive, + health_check_interval=health_check_interval, + retry_on_timeout=retry_on_timeout, + decode_responses=True, + **ssl_params + ) + + # Validate connection with ping + client.ping() + logger.info(f"Connected to Redis for PubSub at {redis_host}:{redis_port}/{redis_db}") + + # We don't need the keepalive thread anymore since we're using proper PubSub handling + cls._pubsub_client = client + break + + except (ConnectionError, TimeoutError) as e: + retry_count += 1 + _tls_hint = _REDIS_TLS_HINT if ssl_params else "" + if retry_count >= max_retries: + logger.error(f"Failed to connect to Redis for PubSub after {max_retries} attempts: {e}{_tls_hint}") + return None + else: + # Use exponential backoff for retries + wait_time = retry_interval * (2 ** (retry_count - 1)) + logger.warning(f"Redis PubSub connection failed. Retrying in {wait_time}s... ({retry_count}/{max_retries})") + time.sleep(wait_time) + + except Exception as e: + _tls_hint = _REDIS_TLS_HINT if ssl_params else "" + logger.error(f"Unexpected error connecting to Redis for PubSub: {e}{_tls_hint}") + return None + + return cls._pubsub_client + +def acquire_task_lock(task_name, id): + """Acquire a lock to prevent concurrent task execution.""" + redis_client = RedisClient.get_client() + lock_id = f"task_lock_{task_name}_{id}" + + # Use the Redis SET command with NX (only set if not exists) and EX (set expiration) + lock_acquired = redis_client.set(lock_id, "locked", ex=300, nx=True) + + if not lock_acquired: + logger.warning(f"Lock for {task_name} and id={id} already acquired. Task will not proceed.") + + return lock_acquired + +def release_task_lock(task_name, id): + """Release the lock after task execution.""" + redis_client = RedisClient.get_client() + lock_id = f"task_lock_{task_name}_{id}" + + # Remove the lock + redis_client.delete(lock_id) + + +class TaskLockRenewer: + """Periodically renews a Redis task lock to prevent expiry during long-running tasks. + + Use as a context manager after acquiring a lock: + + if acquire_task_lock("my_task", task_id): + with TaskLockRenewer("my_task", task_id): + # ... long-running work ... + release_task_lock("my_task", task_id) + + A daemon thread extends the lock TTL at regular intervals so that + slow downloads or large parsing jobs don't lose their lock mid-operation. + """ + + def __init__(self, task_name, id, ttl=300, renewal_interval=120): + self.task_name = task_name + self.id = id + self.ttl = ttl + self.renewal_interval = renewal_interval + self.lock_id = f"task_lock_{task_name}_{id}" + self._stop_event = threading.Event() + self._thread = None + + def _renew_loop(self): + """Background loop that extends the lock TTL until stopped.""" + while not self._stop_event.wait(self.renewal_interval): + try: + redis_client = RedisClient.get_client() + if redis_client.exists(self.lock_id): + redis_client.expire(self.lock_id, self.ttl) + logger.debug( + f"Renewed lock {self.lock_id} TTL to {self.ttl}s" + ) + else: + # Lock was deleted externally (e.g. manual release) — stop renewing + logger.warning( + f"Lock {self.lock_id} no longer exists, stopping renewal" + ) + break + except Exception as e: + logger.error(f"Error renewing lock {self.lock_id}: {e}") + + def start(self): + """Start the background renewal thread.""" + self._stop_event.clear() + self._thread = threading.Thread( + target=self._renew_loop, daemon=True, + name=f"lock-renew-{self.task_name}-{self.id}" + ) + self._thread.start() + return self + + def stop(self): + """Stop the renewal thread.""" + self._stop_event.set() + if self._thread and self._thread.is_alive(): + self._thread.join(timeout=5) + self._thread = None + + def __enter__(self): + self.start() + return self + + def __exit__(self, exc_type, exc_val, exc_tb): + self.stop() + return False + + +def send_websocket_update(group_name, event_type, data, collect_garbage=False): + """ + Standardized function to send WebSocket updates with proper memory management. + + In uWSGI + gevent deployments, async_to_sync creates an asyncio event loop + whose native EpollSelector blocks the entire OS thread, freezing all gevent + greenlets in the worker. To avoid this, the actual send is offloaded to a + real OS thread from gevent's native threadpool when monkey-patching is active. + """ + channel_layer = get_channel_layer() + message = {'type': event_type, 'data': data} + + def _do_send(): + try: + async_to_sync(channel_layer.group_send)(group_name, message) + except Exception as e: + logger.warning(f"Failed to send WebSocket update: {e}") + + try: + import gevent.monkey + if gevent.monkey.is_module_patched('threading'): + from gevent import get_hub + get_hub().threadpool.spawn(_do_send) + return + except Exception: + pass + + # Not in a gevent-patched environment — call directly + _do_send() + + if collect_garbage: + gc.collect() + +def send_websocket_event(event, success, data): + """Acquire a lock to prevent concurrent task execution.""" + data_payload = {"success": success, "type": event} + if data: + # Make a copy to avoid modifying the original + data_payload.update(data) + + # Use the standardized function + send_websocket_update('updates', 'update', data_payload) + + # Help garbage collection by clearing references + data_payload = None + +# Add memory monitoring utilities +def get_memory_usage(): + """Returns current memory usage in MB""" + import psutil + process = psutil.Process(os.getpid()) + return process.memory_info().rss / (1024 * 1024) + +def monitor_memory_usage(func): + """Decorator to monitor memory usage before and after function execution""" + def wrapper(*args, **kwargs): + import gc + # Force garbage collection before measuring + gc.collect() + + # Get initial memory usage + start_mem = get_memory_usage() + logger.debug(f"Memory usage before {func.__name__}: {start_mem:.2f} MB") + + # Call the original function + result = func(*args, **kwargs) + + # Force garbage collection before measuring again + gc.collect() + + # Get final memory usage + end_mem = get_memory_usage() + logger.debug(f"Memory usage after {func.__name__}: {end_mem:.2f} MB (Change: {end_mem - start_mem:.2f} MB)") + + return result + return wrapper + +def cleanup_memory(log_usage=False, force_collection=True): + """ + Comprehensive memory cleanup function to reduce memory footprint + + Args: + log_usage: Whether to log memory usage before and after cleanup + force_collection: Whether to force garbage collection + """ + logger.trace("Starting memory cleanup django memory cleanup") + # Skip logging if log level is not set to debug or more verbose (like trace) + current_log_level = logger.getEffectiveLevel() + if not current_log_level <= logging.DEBUG: + log_usage = False + if log_usage: + try: + import psutil + process = psutil.Process() + before_mem = process.memory_info().rss / (1024 * 1024) + logger.debug(f"Memory before cleanup: {before_mem:.2f} MB") + except (ImportError, Exception) as e: + logger.debug(f"Error getting memory usage: {e}") + + # Clear any object caches from Django ORM + from django.db import connection, reset_queries + reset_queries() + + # Force garbage collection + if force_collection: + # Run full collection + gc.collect(generation=2) + # Clear cyclic references + gc.collect(generation=0) + + if log_usage: + try: + import psutil + process = psutil.Process() + after_mem = process.memory_info().rss / (1024 * 1024) + logger.debug(f"Memory after cleanup: {after_mem:.2f} MB (change: {after_mem-before_mem:.2f} MB)") + except (ImportError, Exception): + pass + logger.trace("Memory cleanup complete for django") + +def safe_upload_path(filename: str, base_dir) -> str: + """Return a safe absolute path for an uploaded file within base_dir. + + Strips all directory components from *filename* and verifies the resolved + path stays inside *base_dir*. Raises ValueError on path traversal attempts. + """ + safe_name = Path(filename).name + base = Path(base_dir).resolve() + file_path = (base / safe_name).resolve() + if not file_path.is_relative_to(base): + raise ValueError("Invalid filename.") + return str(file_path) + + +def is_protected_path(file_path): + """ + Determine if a file path is in a protected directory that shouldn't be deleted. + + Args: + file_path (str): The file path to check + + Returns: + bool: True if the path is protected, False otherwise + """ + if not file_path: + return False + + # List of protected directory prefixes + protected_dirs = [ + '/data/epgs', # EPG files mapped from host + '/data/uploads', # User uploaded files + '/data/m3us' # M3U files mapped from host + ] + + # Check if the path starts with any protected directory + for protected_dir in protected_dirs: + if file_path.startswith(protected_dir): + return True + + return False + +def validate_flexible_url(value): + """ + Custom URL validator that accepts URLs with hostnames that aren't FQDNs. + This allows URLs like "http://hostname/" which + Django's standard URLValidator rejects. + """ + if not value: + return # Allow empty values since the field is nullable + + # Create a standard Django URL validator + url_validator = URLValidator() + + try: + # First try the standard validation + url_validator(value) + except ValidationError as e: + # If standard validation fails, check if it's a non-FQDN hostname + import re + + # More flexible pattern for non-FQDN hostnames with paths + # Matches: http://hostname, https://hostname/, http://hostname:port/path/to/file.xml, rtp://192.168.2.1, rtsp://192.168.178.1, udp://239.0.0.1:1234 + # Also matches FQDNs for rtsp/rtp/udp protocols: rtsp://FQDN/path?query=value + # Also supports authentication: rtsp://user:pass@hostname/path + non_fqdn_pattern = r'^(rts?p|https?|udp)://([a-zA-Z0-9_\-\.]+:[^\s@]+@)?([a-zA-Z0-9]([a-zA-Z0-9\-\.]{0,61}[a-zA-Z0-9])?|[0-9.]+)?(\:[0-9]+)?(/[^\s]*)?$' + non_fqdn_match = re.match(non_fqdn_pattern, value) + + if non_fqdn_match: + return # Accept non-FQDN hostnames and rtsp/rtp/udp URLs with optional authentication + + # If it doesn't match our flexible patterns, raise the original error + raise ValidationError("Enter a valid URL.") + +def dispatch_event_system(event_type, channel_id=None, channel_name=None, **details): + try: + from apps.connect.utils import trigger_event + from apps.channels.models import Channel, Stream + from core.models import StreamProfile + from core.utils import RedisClient + + payload = dict(details) + + channel_obj = None + if channel_id: + try: + channel_obj = Channel.objects.get(uuid=channel_id) + payload["channel_name"] = channel_obj.name + except Exception: + payload["channel_name"] = channel_name or None + else: + payload["channel_name"] = channel_name or None + + # Resolve current stream info + stream_id = details.get("stream_id") + stream_obj = None + if not stream_id and channel_obj: + try: + redis = RedisClient.get_client() + sid = redis.get(f"channel_stream:{channel_obj.id}") + if sid: + stream_id = int(sid) + except Exception: + stream_id = None + + if stream_id: + try: + stream_obj = Stream.objects.get(id=stream_id) + except Exception: + stream_obj = None + + # Populate stream details + payload["stream_name"] = getattr(stream_obj, "name", None) + payload["stream_url"] = getattr(stream_obj, "url", None) + + # Channel URL: use stream URL as best-effort + payload["channel_url"] = payload.get("stream_url") + + # Provider name from M3U account + provider_name = None + try: + if stream_obj and stream_obj.m3u_account: + provider_name = stream_obj.m3u_account.name + except Exception: + provider_name = None + payload["provider_name"] = provider_name + + # Profile used + profile_used = None + try: + if stream_id: + redis = RedisClient.get_client() + pid = redis.get(f"stream_profile:{stream_id}") + if pid: + profile = StreamProfile.objects.filter(id=int(pid)).first() + profile_used = profile.name if profile else None + except Exception: + profile_used = None + + payload["profile_used"] = profile_used + + # remove empty keys + for k in list(payload.keys()): + if not payload[k]: + del payload[k] + + trigger_event(event_type, payload) + + except Exception as e: + # Don't fail main path if connect dispatch fails + pass + +def log_system_event(event_type, channel_id=None, channel_name=None, **details): + """ + Log a system event and maintain the configured max history. + + Args: + event_type: Type of event (e.g., 'channel_start', 'client_connect') + channel_id: Optional UUID of the channel + channel_name: Optional name of the channel + **details: Additional details to store in the event (stored as JSON) + + Example: + log_system_event('channel_start', channel_id=uuid, channel_name='CNN', + stream_url='http://...', user='admin') + """ + from core.models import SystemEvent, CoreSettings + + try: + # Create the event + SystemEvent.objects.create( + event_type=event_type, + channel_id=channel_id, + channel_name=channel_name, + details=details + ) + + # Trigger connect integrations for specific events + dispatch_event_system(event_type, channel_id=channel_id, channel_name=channel_name, **details) + + # Get max events from settings (default 100) + try: + from .models import CoreSettings + system_settings = CoreSettings.objects.filter(key='system_settings').first() + if system_settings and isinstance(system_settings.value, dict): + max_events = int(system_settings.value.get('max_system_events', 100)) + else: + max_events = 100 + except Exception: + max_events = 100 + + # Delete old events beyond the limit (keep it efficient with a single query) + total_count = SystemEvent.objects.count() + if total_count > max_events: + # Get the ID of the event at the cutoff point + cutoff_event = SystemEvent.objects.values_list('id', flat=True)[max_events] + # Delete all events with ID less than cutoff (older events) + SystemEvent.objects.filter(id__lt=cutoff_event).delete() + + except Exception as e: + # Don't let event logging break the main application + logger.error(f"Failed to log system event {event_type}: {e}") + + +def send_websocket_notification(notification): + """ + Send a system notification to all connected WebSocket clients. + + Args: + notification: A SystemNotification model instance or dict with notification data + + Example: + from core.models import SystemNotification + notification = SystemNotification.create_version_notification('0.19.0', 'https://...') + send_websocket_notification(notification) + """ + try: + channel_layer = get_channel_layer() + + # Convert model instance to dict if needed + if hasattr(notification, 'id'): + notification_data = { + 'id': notification.id, + 'notification_key': notification.notification_key, + 'notification_type': notification.notification_type, + 'priority': notification.priority, + 'title': notification.title, + 'message': notification.message, + 'action_data': notification.action_data, + 'is_active': notification.is_active, + 'admin_only': notification.admin_only, + 'created_at': notification.created_at.isoformat() if notification.created_at else None, + } + else: + notification_data = notification + + async_to_sync(channel_layer.group_send)( + 'updates', + { + 'type': 'update', + 'data': { + 'type': 'system_notification', + 'notification': notification_data, + } + } + ) + logger.debug(f"Sent WebSocket notification: {notification_data.get('title', 'Unknown')}") + except Exception as e: + logger.error(f"Failed to send WebSocket notification: {e}") + + +def send_notification_dismissed(notification_key): + """ + Notify all connected clients that a notification was dismissed. + Useful for syncing dismissal state across multiple browser tabs/sessions. + + Args: + notification_key: The unique key of the dismissed notification + """ + try: + channel_layer = get_channel_layer() + + async_to_sync(channel_layer.group_send)( + 'updates', + { + 'type': 'update', + 'data': { + 'type': 'notification_dismissed', + 'notification_key': notification_key, + } + } + ) + except Exception as e: + logger.error(f"Failed to send notification dismissed event: {e}") diff --git a/core/views.py b/core/views.py new file mode 100644 index 0000000..7321367 --- /dev/null +++ b/core/views.py @@ -0,0 +1,195 @@ +# core/views.py +import os +from shlex import split as shlex_split +import sys +import subprocess +import logging +import regex +import redis + +from django.conf import settings +from django.http import StreamingHttpResponse, HttpResponseServerError +from django.shortcuts import render + +from apps.channels.models import Channel, Stream +from apps.m3u.models import M3UAccountProfile +from core.models import StreamProfile, CoreSettings + +# Import the persistent lock (the “real” lock) +from dispatcharr.persistent_lock import PersistentLock + +# Configure logging to output to the console. +logging.basicConfig(stream=sys.stdout, level=logging.DEBUG) +logger = logging.getLogger(__name__) + + +def settings_view(request): + """ + Renders the settings page. + """ + return render(request, 'settings.html') + + +def stream_view(request, channel_uuid): + """ + Streams the first available stream for the given channel. + It uses the channel’s assigned StreamProfile. + A persistent Redis lock is used to prevent concurrent streaming on the same channel. + """ + try: + redis_host = getattr(settings, "REDIS_HOST", "localhost") + redis_port = int(getattr(settings, "REDIS_PORT", 6379)) + redis_db = int(getattr(settings, "REDIS_DB", "0")) + redis_password = getattr(settings, "REDIS_PASSWORD", "") + redis_user = getattr(settings, "REDIS_USER", "") + ssl_params = getattr(settings, "REDIS_SSL_PARAMS", {}) + redis_client = redis.Redis( + host=redis_host, + port=redis_port, + db=redis_db, + password=redis_password if redis_password else None, + username=redis_user if redis_user else None, + **ssl_params + ) + + # Retrieve the channel by the provided stream_id. + channel = Channel.objects.get(uuid=channel_uuid) + logger.debug("Channel retrieved: ID=%s, Name=%s", channel.id, channel.name) + + # Ensure the channel has at least one stream. + if not channel.streams.exists(): + logger.error("No streams found for channel ID=%s", channel.id) + return HttpResponseServerError("No stream found for this channel.") + + active_stream = None + m3u_account = None + active_profile = None + lock_key = None + persistent_lock = None + + streams = channel.streams.all().order_by('channelstream__order') + logger.debug(f'Found {len(streams)} streams for channel {channel.channel_number}') + for stream in streams: + # Get the first available stream. + logger.debug("Checking stream: ID=%s, Name=%s", stream.id, stream.name) + + # Retrieve the M3U account associated with the stream. + m3u_account = stream.m3u_account + logger.debug("Stream M3U account ID=%s, Name=%s", m3u_account.id, m3u_account.name) + + # Use the custom URL if available; otherwise, use the standard URL. + input_url = stream.url + logger.debug("Input URL: %s", input_url) + + # Determine which profile we can use. + m3u_profiles = m3u_account.profiles.all() + default_profile = next((obj for obj in m3u_profiles if obj.is_default), None) + profiles = [obj for obj in m3u_profiles if not obj.is_default] + + # -- Loop through profiles and pick the first active one -- + for profile in [default_profile] + profiles: + logger.debug(f'Checking profile {profile.name}...') + if not profile.is_active: + logger.debug('Profile is not active, skipping.') + continue + + logger.debug(f'Profile has a max streams of {profile.max_streams}, checking if any are available') + stream_index = 0 + max_streams = profile.max_streams + if max_streams == 0: + max_streams = 999999 # maybe a better way than just hardcoding a high number... + while stream_index < max_streams: + stream_index += 1 + + lock_key = f"lock:{profile.id}:{stream_index}" + persistent_lock = PersistentLock(redis_client, lock_key, lock_timeout=120) + logger.debug(f'Attempting to acquire lock: {lock_key}') + + if not persistent_lock.acquire(): + logger.error(f"Could not acquire persistent lock for profile {profile.id} index {stream_index}, currently in use.") + persistent_lock = None + continue + + break + + if persistent_lock is not None: + logger.debug(f'Successfully acquired lock: {lock_key}') + active_profile = M3UAccountProfile.objects.get(id=profile.id) + break + + if active_profile is None or persistent_lock is None: + logger.exception("No available profiles for the stream") + continue + + logger.debug(f"Found available stream profile: stream={stream.name}, profile={profile.name}") + break + + if not active_profile: + logger.exception("No available streams for this channel") + return HttpResponseServerError("No available streams for this channel") + + logger.debug(f"Using M3U profile ID={active_profile.id} (ignoring viewer count limits)") + # Prepare the pattern replacement. + logger.debug("Executing the following pattern replacement:") + logger.debug(f" search: {active_profile.search_pattern}") + # Convert JS-style backreferences in replace: $ -> \g, $1 -> \1 + safe_replace_pattern = regex.sub(r'\$<([^>]+)>', r'\\g<\1>', active_profile.replace_pattern) + safe_replace_pattern = regex.sub(r'\$(\d+)', r'\\\1', safe_replace_pattern) + logger.debug(f" replace: {active_profile.replace_pattern}") + logger.debug(f" safe replace: {safe_replace_pattern}") + # regex module accepts JS-style (?...) named groups natively + stream_url = regex.sub(active_profile.search_pattern, safe_replace_pattern, input_url) + logger.debug(f"Generated stream url: {stream_url}") + + # Get the stream profile set on the channel. + stream_profile = channel.stream_profile + if not stream_profile: + logger.error("No stream profile set for channel ID=%s, using default", channel.id) + stream_profile = StreamProfile.objects.get(id=CoreSettings.get_default_stream_profile_id()) + + logger.debug("Stream profile used: %s", stream_profile.name) + + # Determine the user agent to use. + user_agent = stream_profile.user_agent or getattr(settings, "DEFAULT_USER_AGENT", "Mozilla/5.0") + logger.debug("User agent: %s", user_agent) + + # Substitute placeholders in the parameters template. + parameters = stream_profile.parameters.format(userAgent=user_agent, streamUrl=stream_url) + logger.debug("Formatted parameters: %s", parameters) + + # Build the final command. + cmd = [stream_profile.command] + shlex_split(parameters) + logger.debug("Executing command: %s", cmd) + + try: + # Start the streaming process. + process = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE) + except Exception as e: + persistent_lock.release() # Ensure the lock is released on error. + logger.exception("Error starting stream for channel ID=%s", stream_id) + return HttpResponseServerError(f"Error starting stream: {e}") + + except Exception as e: + logger.exception("Error preparing stream for channel ID=%s", stream_id) + return HttpResponseServerError(f"Error preparing stream: {e}") + + def stream_generator(proc, s, persistent_lock): + try: + while True: + chunk = proc.stdout.read(8192) + if not chunk: + break + yield chunk + finally: + try: + proc.terminate() + logger.debug("Streaming process terminated for stream ID=%s", s.id) + except Exception as e: + logger.error("Error terminating process for stream ID=%s: %s", s.id, e) + persistent_lock.release() + logger.debug("Persistent lock released for channel ID=%s", channel.id) + + return StreamingHttpResponse( + stream_generator(process, stream, persistent_lock), + content_type="video/MP2T" + ) diff --git a/core/xtream_codes.py b/core/xtream_codes.py new file mode 100644 index 0000000..822b37f --- /dev/null +++ b/core/xtream_codes.py @@ -0,0 +1,465 @@ +import requests +import logging +import traceback +import json + +logger = logging.getLogger(__name__) + +class Client: + """Xtream Codes API Client with robust error handling""" + + def __init__(self, server_url, username, password, user_agent=None): + self.server_url = self._normalize_url(server_url) + self.username = username + self.password = password + self.user_agent = user_agent + + # Fix: Properly handle all possible user_agent input types + if user_agent: + if isinstance(user_agent, str): + user_agent_string = user_agent + elif hasattr(user_agent, 'user_agent'): + user_agent_string = user_agent.user_agent + else: + logger.warning(f"Unexpected user_agent type: {type(user_agent)}, using default") + user_agent_string = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64)' + else: + user_agent_string = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64)' + + # Create persistent session + self.session = requests.Session() + self.session.headers.update({'User-Agent': user_agent_string}) + + # Configure connection pooling + adapter = requests.adapters.HTTPAdapter( + pool_connections=1, + pool_maxsize=2, + max_retries=3, + pool_block=False + ) + self.session.mount('http://', adapter) + self.session.mount('https://', adapter) + + self.server_info = None + + def _normalize_url(self, url): + """Normalize server URL by removing trailing slashes and paths""" + if not url: + raise ValueError("Server URL cannot be empty") + + url = url.rstrip('/') + # Remove any path after domain - we'll construct proper API URLs + # Split by protocol first to preserve it + if '://' in url: + protocol, rest = url.split('://', 1) + domain = rest.split('/', 1)[0] + return f"{protocol}://{domain}" + return url + + def _make_request(self, endpoint, params=None): + """Make request with detailed error handling""" + try: + url = f"{self.server_url}/{endpoint}" + logger.debug(f"XC API Request: {url} with params: {params}") + + response = self.session.get(url, params=params, timeout=60) + response.raise_for_status() + + # Check if response is empty + if not response.content: + error_msg = f"XC API returned empty response from {url}" + logger.error(error_msg) + raise ValueError(error_msg) + + # Check for common blocking responses before trying to parse JSON + response_text = response.text.strip() + if response_text.lower() in ['blocked', 'forbidden', 'access denied', 'unauthorized']: + error_msg = f"XC API request blocked by server from {url}. Response: {response_text}" + logger.error(error_msg) + logger.error(f"This may indicate IP blocking, User-Agent filtering, or rate limiting") + raise ValueError(error_msg) + + try: + data = response.json() + except requests.exceptions.JSONDecodeError as json_err: + error_msg = f"XC API returned invalid JSON from {url}. Response: {response.text[:1000]}" + logger.error(error_msg) + logger.error(f"JSON decode error: {str(json_err)}") + + # Check if it looks like an HTML error page + if response_text.startswith('<'): + logger.error("Response appears to be HTML - server may be returning an error page") + + raise ValueError(error_msg) + + # Check for XC-specific error responses + if isinstance(data, dict) and data.get('user_info') is None and 'error' in data: + error_msg = f"XC API Error: {data.get('error', 'Unknown error')}" + logger.error(error_msg) + raise ValueError(error_msg) + + return data + except requests.RequestException as e: + error_msg = f"XC API Request failed: {str(e)}" + logger.error(error_msg) + logger.error(f"Request details: URL={url}, Params={params}") + raise + except ValueError as e: + # This could be from JSON parsing or our explicit raises + logger.error(f"XC API Invalid response: {str(e)}") + raise + except Exception as e: + logger.error(f"XC API Unexpected error: {str(e)}") + logger.error(traceback.format_exc()) + raise + + def authenticate(self): + """Authenticate and validate server response""" + try: + endpoint = "player_api.php" + params = { + 'username': self.username, + 'password': self.password + } + + self.server_info = self._make_request(endpoint, params) + + if not self.server_info or not self.server_info.get('user_info'): + error_msg = "Authentication failed: Invalid response from server" + logger.error(f"{error_msg}. Response: {self.server_info}") + raise ValueError(error_msg) + + logger.info(f"XC Authentication successful for user {self.username}") + return self.server_info + except Exception as e: + logger.error(f"XC Authentication failed: {str(e)}") + logger.error(traceback.format_exc()) + raise + + def get_account_info(self): + """Get account information from the last authentication response""" + if not self.server_info: + raise ValueError("Not authenticated. Call authenticate() first.") + + from datetime import datetime + + # Extract relevant account information + user_info = self.server_info.get('user_info', {}) + server_info = self.server_info.get('server_info', {}) + + account_info = { + 'last_refresh': datetime.utcnow().isoformat() + 'Z', # Explicit UTC with Z suffix + 'auth_timestamp': datetime.utcnow().timestamp(), + 'user_info': { + 'username': user_info.get('username'), + 'password': user_info.get('password'), + 'message': user_info.get('message'), + 'auth': user_info.get('auth'), + 'status': user_info.get('status'), + 'exp_date': user_info.get('exp_date'), + 'is_trial': user_info.get('is_trial'), + 'active_cons': user_info.get('active_cons'), + 'created_at': user_info.get('created_at'), + 'max_connections': user_info.get('max_connections'), + 'allowed_output_formats': user_info.get('allowed_output_formats', []) + }, + 'server_info': { + 'url': server_info.get('url'), + 'port': server_info.get('port'), + 'https_port': server_info.get('https_port'), + 'server_protocol': server_info.get('server_protocol'), + 'rtmp_port': server_info.get('rtmp_port'), + 'timezone': server_info.get('timezone'), + 'timestamp_now': server_info.get('timestamp_now'), + 'time_now': server_info.get('time_now') + } + } + + return account_info + + def get_live_categories(self): + """Get live TV categories""" + try: + if not self.server_info: + self.authenticate() + + endpoint = "player_api.php" + params = { + 'username': self.username, + 'password': self.password, + 'action': 'get_live_categories' + } + + categories = self._make_request(endpoint, params) + + if not isinstance(categories, list): + error_msg = f"Invalid categories response: {categories}" + logger.error(error_msg) + raise ValueError(error_msg) + + logger.info(f"Successfully retrieved {len(categories)} live categories") + logger.debug(f"Categories: {json.dumps(categories[:5])}...") + return categories + except Exception as e: + logger.error(f"Failed to get live categories: {str(e)}") + logger.error(traceback.format_exc()) + raise + + def get_live_category_streams(self, category_id): + """Get streams for a specific category""" + try: + if not self.server_info: + self.authenticate() + + endpoint = "player_api.php" + params = { + 'username': self.username, + 'password': self.password, + 'action': 'get_live_streams', + 'category_id': category_id + } + + streams = self._make_request(endpoint, params) + + if not isinstance(streams, list): + error_msg = f"Invalid streams response for category {category_id}: {streams}" + logger.error(error_msg) + raise ValueError(error_msg) + + logger.info(f"Successfully retrieved {len(streams)} streams for category {category_id}") + return streams + except Exception as e: + logger.error(f"Failed to get streams for category {category_id}: {str(e)}") + logger.error(traceback.format_exc()) + raise + + def get_all_live_streams(self): + """Get all live streams (no category filter)""" + try: + if not self.server_info: + self.authenticate() + + endpoint = "player_api.php" + params = { + 'username': self.username, + 'password': self.password, + 'action': 'get_live_streams' + # No category_id = get all streams + } + + streams = self._make_request(endpoint, params) + + if not isinstance(streams, list): + error_msg = f"Invalid streams response for all live streams: {streams}" + logger.error(error_msg) + raise ValueError(error_msg) + + logger.info(f"Successfully retrieved {len(streams)} total live streams") + return streams + except Exception as e: + logger.error(f"Failed to get all live streams: {str(e)}") + logger.error(traceback.format_exc()) + raise + + def get_stream_url(self, stream_id): + """Get the playback URL for a stream""" + return f"{self.server_url}/live/{self.username}/{self.password}/{stream_id}.ts" + + def get_episode_stream_url(self, stream_id, container_extension='mp4'): + """Get the playback URL for an episode stream""" + return f"{self.server_url}/series/{self.username}/{self.password}/{stream_id}.{container_extension}" + + def get_vod_stream_url(self, stream_id, container_extension='mp4'): + """Get the playback URL for a VOD stream""" + return f"{self.server_url}/movie/{self.username}/{self.password}/{stream_id}.{container_extension}" + + def get_vod_categories(self): + """Get VOD categories""" + try: + if not self.server_info: + self.authenticate() + + endpoint = "player_api.php" + params = { + 'username': self.username, + 'password': self.password, + 'action': 'get_vod_categories' + } + + categories = self._make_request(endpoint, params) + + if not isinstance(categories, list): + error_msg = f"Invalid VOD categories response: {categories}" + logger.error(error_msg) + raise ValueError(error_msg) + + logger.info(f"Successfully retrieved {len(categories)} VOD categories") + return categories + except Exception as e: + logger.error(f"Failed to get VOD categories: {str(e)}") + logger.error(traceback.format_exc()) + raise + + def get_vod_streams(self, category_id=None): + """Get VOD streams for a specific category""" + try: + if not self.server_info: + self.authenticate() + + endpoint = "player_api.php" + params = { + 'username': self.username, + 'password': self.password, + 'action': 'get_vod_streams' + } + + if category_id: + params['category_id'] = category_id + + streams = self._make_request(endpoint, params) + + if not isinstance(streams, list): + error_msg = f"Invalid VOD streams response for category {category_id}: {streams}" + logger.error(error_msg) + raise ValueError(error_msg) + + logger.info(f"Successfully retrieved {len(streams)} VOD streams for category {category_id}") + return streams + except Exception as e: + logger.error(f"Failed to get VOD streams for category {category_id}: {str(e)}") + logger.error(traceback.format_exc()) + raise + + def get_vod_info(self, vod_id): + """Get detailed information for a specific VOD""" + try: + if not self.server_info: + self.authenticate() + + endpoint = "player_api.php" + params = { + 'username': self.username, + 'password': self.password, + 'action': 'get_vod_info', + 'vod_id': vod_id + } + + vod_info = self._make_request(endpoint, params) + + if not isinstance(vod_info, dict): + error_msg = f"Invalid VOD info response for vod_id {vod_id}: {vod_info}" + logger.error(error_msg) + raise ValueError(error_msg) + + logger.info(f"Successfully retrieved VOD info for vod_id {vod_id}") + return vod_info + except Exception as e: + logger.error(f"Failed to get VOD info for vod_id {vod_id}: {str(e)}") + logger.error(traceback.format_exc()) + raise + + def get_series_categories(self): + """Get series categories""" + try: + if not self.server_info: + self.authenticate() + + endpoint = "player_api.php" + params = { + 'username': self.username, + 'password': self.password, + 'action': 'get_series_categories' + } + + categories = self._make_request(endpoint, params) + + if not isinstance(categories, list): + error_msg = f"Invalid series categories response: {categories}" + logger.error(error_msg) + raise ValueError(error_msg) + + logger.info(f"Successfully retrieved {len(categories)} series categories") + return categories + except Exception as e: + logger.error(f"Failed to get series categories: {str(e)}") + logger.error(traceback.format_exc()) + raise + + def get_series(self, category_id=None): + """Get series for a specific category""" + try: + if not self.server_info: + self.authenticate() + + endpoint = "player_api.php" + params = { + 'username': self.username, + 'password': self.password, + 'action': 'get_series' + } + + if category_id: + params['category_id'] = category_id + + series = self._make_request(endpoint, params) + + if not isinstance(series, list): + error_msg = f"Invalid series response for category {category_id}: {series}" + logger.error(error_msg) + raise ValueError(error_msg) + + logger.info(f"Successfully retrieved {len(series)} series for category {category_id}") + return series + except Exception as e: + logger.error(f"Failed to get series for category {category_id}: {str(e)}") + logger.error(traceback.format_exc()) + raise + + def get_series_info(self, series_id): + """Get detailed information for a specific series including episodes""" + try: + if not self.server_info: + self.authenticate() + + endpoint = "player_api.php" + params = { + 'username': self.username, + 'password': self.password, + 'action': 'get_series_info', + 'series_id': series_id + } + + series_info = self._make_request(endpoint, params) + + if not isinstance(series_info, dict): + error_msg = f"Invalid series info response for series_id {series_id}: {series_info}" + logger.error(error_msg) + raise ValueError(error_msg) + + logger.info(f"Successfully retrieved series info for series_id {series_id}") + return series_info + except Exception as e: + logger.error(f"Failed to get series info for series_id {series_id}: {str(e)}") + logger.error(traceback.format_exc()) + raise + + def close(self): + """Close the session and cleanup resources""" + if hasattr(self, 'session') and self.session: + try: + self.session.close() + except Exception as e: + logger.debug(f"Error closing XC session: {e}") + + def __enter__(self): + """Enter the context manager""" + return self + + def __exit__(self, exc_type, exc_val, exc_tb): + """Exit the context manager and cleanup resources""" + self.close() + return False # Don't suppress exceptions + + def __del__(self): + """Ensure session is closed when object is destroyed""" + self.close() diff --git a/debian_install.sh b/debian_install.sh new file mode 100755 index 0000000..305424f --- /dev/null +++ b/debian_install.sh @@ -0,0 +1,505 @@ +#!/usr/bin/env bash + +set -euo pipefail +IFS=$'\n\t' + +# Root check +if [[ $EUID -ne 0 ]]; then + echo "[ERROR] This script must be run as root." >&2 + exit 1 +fi + +trap 'echo -e "\n[ERROR] Line $LINENO failed. Exiting." >&2; exit 1' ERR + +############################################################################## +# 0) Locales & Warning / Disclaimer +############################################################################## + +setup_locales() { + echo ">>> Setting up locales..." + apt-get update + apt-get install -y locales + sed -i '/en_US.UTF-8 UTF-8/s/^# //g' /etc/locale.gen + locale-gen + update-locale LANG=en_US.UTF-8 + export LANG=en_US.UTF-8 + export LC_ALL=en_US.UTF-8 +} + +show_disclaimer() { + echo "**************************************************************" + echo "WARNING: While we do not anticipate any problems, we disclaim all" + echo "responsibility for anything that happens to your machine." + echo "" + echo "This script is intended for **Debian-based operating systems only**." + echo "Running it on other distributions WILL cause unexpected issues." + echo "" + echo "This script is **NOT RECOMMENDED** for use on your primary machine." + echo "For safety and best results, we strongly advise running this inside a" + echo "clean virtual machine (VM) or LXC container environment." + echo "" + echo "Additionally, there is NO SUPPORT for this method; Docker is the only" + echo "officially supported way to run Dispatcharr." + echo "**************************************************************" + echo "" + echo "If you wish to proceed, type \"I understand\" and press Enter." + read user_input + if [ "$user_input" != "I understand" ]; then + echo "Exiting script..." + exit 1 + fi +} + +############################################################################## +# 1) Configuration +############################################################################## + +configure_variables() { + DISPATCH_USER="dispatcharr" + DISPATCH_GROUP="dispatcharr" + APP_DIR="/opt/dispatcharr" + DISPATCH_BRANCH="main" + POSTGRES_DB="dispatcharr" + POSTGRES_USER="dispatch" + POSTGRES_PASSWORD="secret" + NGINX_HTTP_PORT="9191" + WEBSOCKET_PORT="8001" + GUNICORN_RUNTIME_DIR="dispatcharr" + GUNICORN_SOCKET="/run/${GUNICORN_RUNTIME_DIR}/dispatcharr.sock" + PYTHON_BIN=$(command -v python3) + SYSTEMD_DIR="/etc/systemd/system" + NGINX_SITE="/etc/nginx/sites-available/dispatcharr" +} + +############################################################################## +# 2) Install System Packages +############################################################################## + +install_packages() { + echo ">>> Installing system packages..." + apt-get update + declare -a packages=( + git curl wget build-essential gcc libpq-dev + python3-dev python3-venv python3-pip nginx redis-server + postgresql postgresql-contrib ffmpeg procps streamlink + sudo + ) + apt-get install -y --no-install-recommends "${packages[@]}" + + if ! command -v node >/dev/null 2>&1; then + echo ">>> Installing Node.js..." + curl -sL https://deb.nodesource.com/setup_24.x | bash - + apt-get install -y nodejs + fi + + systemctl enable --now postgresql redis-server +} + +############################################################################## +# 3) Create User/Group +############################################################################## + +create_dispatcharr_user() { + if ! getent group "$DISPATCH_GROUP" >/dev/null; then + groupadd "$DISPATCH_GROUP" + fi + if ! id -u "$DISPATCH_USER" >/dev/null; then + useradd -m -g "$DISPATCH_GROUP" -s /bin/bash "$DISPATCH_USER" + fi +} + +############################################################################## +# 4) PostgreSQL Setup +############################################################################## + +setup_postgresql() { + echo ">>> Checking PostgreSQL database and user..." + + db_exists=$(sudo -u postgres psql -tAc "SELECT 1 FROM pg_database WHERE datname='$POSTGRES_DB'") + if [[ "$db_exists" != "1" ]]; then + echo ">>> Creating database '${POSTGRES_DB}' with UTF8 encoding..." + sudo -u postgres createdb -E UTF8 "$POSTGRES_DB" + else + echo ">>> Database '${POSTGRES_DB}' already exists, skipping creation." + fi + + user_exists=$(sudo -u postgres psql -tAc "SELECT 1 FROM pg_roles WHERE rolname='$POSTGRES_USER'") + if [[ "$user_exists" != "1" ]]; then + echo ">>> Creating user '${POSTGRES_USER}'..." + sudo -u postgres psql -c "CREATE USER $POSTGRES_USER WITH PASSWORD '$POSTGRES_PASSWORD';" + else + echo ">>> User '${POSTGRES_USER}' already exists, skipping creation." + fi + + echo ">>> Granting privileges..." + sudo -u postgres psql -c "GRANT ALL PRIVILEGES ON DATABASE $POSTGRES_DB TO $POSTGRES_USER;" + sudo -u postgres psql -c "ALTER DATABASE $POSTGRES_DB OWNER TO $POSTGRES_USER;" + sudo -u postgres psql -d "$POSTGRES_DB" -c "ALTER SCHEMA public OWNER TO $POSTGRES_USER;" +} + +############################################################################## +# 5) Clone Dispatcharr Repository +############################################################################## + +clone_dispatcharr_repo() { + echo ">>> Installing or updating Dispatcharr in ${APP_DIR} ..." + + if [ ! -d "$APP_DIR" ]; then + mkdir -p "$APP_DIR" + chown "$DISPATCH_USER:$DISPATCH_GROUP" "$APP_DIR" + fi + + if [ -d "$APP_DIR/.git" ]; then + echo ">>> Updating existing Dispatcharr repo..." + su - "$DISPATCH_USER" <>> Cloning Dispatcharr repo into ${APP_DIR}..." + rm -rf "$APP_DIR"/* + chown "$DISPATCH_USER:$DISPATCH_GROUP" "$APP_DIR" + su - "$DISPATCH_USER" -c "git clone -b $DISPATCH_BRANCH https://github.com/Dispatcharr/Dispatcharr.git $APP_DIR" + fi +} + +############################################################################## +# 6) Setup Python Environment +############################################################################## + +install_uv() { + echo ">>> Installing UV package manager..." + curl -LsSf https://astral.sh/uv/install.sh | sh + export PATH="$HOME/.local/bin:$PATH" +} + +setup_python_env() { + echo ">>> Setting up Python virtual environment with UV..." + + su - "$DISPATCH_USER" </dev/null 2>&1 || curl -LsSf https://astral.sh/uv/install.sh | sh + + rm -rf env + $PYTHON_BIN -m venv env + env/bin/python -m ensurepip --upgrade + + export UV_PROJECT_ENVIRONMENT="$APP_DIR/env" + uv sync --no-dev + + env/bin/python -m pip install -q gunicorn +EOSU + + ln -sf /usr/bin/ffmpeg "$APP_DIR/env/bin/ffmpeg" +} + + +############################################################################## +# 6.1) Ensure Environment File +############################################################################## + +ensure_env_file() { + echo ">>> Ensuring DJANGO_SECRET_KEY exists in ${APP_DIR}/.env..." + su - "$DISPATCH_USER" <> .env +fi +EOSU +} + + +############################################################################## +# 7) Build Frontend +############################################################################## + +build_frontend() { + echo ">>> Building frontend..." + su - "$DISPATCH_USER" <>> Running Django migrations & collectstatic..." + su - "$DISPATCH_USER" <>> Creating systemd service files..." + + # Gunicorn + cat <${SYSTEMD_DIR}/dispatcharr.service +[Unit] +Description=Gunicorn for Dispatcharr +After=network.target postgresql.service redis-server.service + +[Service] +User=${DISPATCH_USER} +Group=${DISPATCH_GROUP} +WorkingDirectory=${APP_DIR} +RuntimeDirectory=${GUNICORN_RUNTIME_DIR} +RuntimeDirectoryMode=0775 +EnvironmentFile=/opt/dispatcharr/.env +Environment="PATH=${APP_DIR}/env/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin" +Environment="POSTGRES_DB=${POSTGRES_DB}" +Environment="POSTGRES_USER=${POSTGRES_USER}" +Environment="POSTGRES_PASSWORD=${POSTGRES_PASSWORD}" +Environment="POSTGRES_HOST=localhost" +ExecStartPre=/usr/bin/bash -c 'until pg_isready -h localhost -U ${POSTGRES_USER}; do sleep 1; done' +ExecStart=${APP_DIR}/env/bin/gunicorn \\ + --workers=4 \\ + --worker-class=gevent \\ + --timeout=300 \\ + --bind unix:${GUNICORN_SOCKET} \\ + dispatcharr.wsgi:application +Restart=always +KillMode=mixed +SyslogIdentifier=dispatcharr +StandardOutput=journal +StandardError=journal +[Install] +WantedBy=multi-user.target +EOF + + # Celery + cat <${SYSTEMD_DIR}/dispatcharr-celery.service +[Unit] +Description=Celery Worker for Dispatcharr +After=network.target redis-server.service +Requires=dispatcharr.service + +[Service] +User=${DISPATCH_USER} +Group=${DISPATCH_GROUP} +WorkingDirectory=${APP_DIR} +EnvironmentFile=/opt/dispatcharr/.env +Environment="PATH=${APP_DIR}/env/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin" +Environment="POSTGRES_DB=${POSTGRES_DB}" +Environment="POSTGRES_USER=${POSTGRES_USER}" +Environment="POSTGRES_PASSWORD=${POSTGRES_PASSWORD}" +Environment="POSTGRES_HOST=localhost" +Environment="CELERY_BROKER_URL=redis://localhost:6379/0" +ExecStart=${APP_DIR}/env/bin/celery -A dispatcharr worker -l info +Restart=always +KillMode=mixed +SyslogIdentifier=dispatcharr-celery +StandardOutput=journal +StandardError=journal +[Install] +WantedBy=multi-user.target +EOF + + # Celery Beat + cat <${SYSTEMD_DIR}/dispatcharr-celerybeat.service +[Unit] +Description=Celery Beat Scheduler for Dispatcharr +After=network.target redis-server.service +Requires=dispatcharr.service + +[Service] +User=${DISPATCH_USER} +Group=${DISPATCH_GROUP} +WorkingDirectory=${APP_DIR} +EnvironmentFile=/opt/dispatcharr/.env +Environment="PATH=${APP_DIR}/env/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin" +Environment="POSTGRES_DB=${POSTGRES_DB}" +Environment="POSTGRES_USER=${POSTGRES_USER}" +Environment="POSTGRES_PASSWORD=${POSTGRES_PASSWORD}" +Environment="POSTGRES_HOST=localhost" +Environment="CELERY_BROKER_URL=redis://localhost:6379/0" +ExecStart=${APP_DIR}/env/bin/celery -A dispatcharr beat -l info +Restart=always +KillMode=mixed +SyslogIdentifier=dispatcharr-celerybeat +StandardOutput=journal +StandardError=journal +[Install] +WantedBy=multi-user.target +EOF + + # Daphne + cat <${SYSTEMD_DIR}/dispatcharr-daphne.service +[Unit] +Description=Daphne for Dispatcharr (ASGI/WebSockets) +After=network.target +Requires=dispatcharr.service + +[Service] +User=${DISPATCH_USER} +Group=${DISPATCH_GROUP} +WorkingDirectory=${APP_DIR} +EnvironmentFile=/opt/dispatcharr/.env +Environment="PATH=${APP_DIR}/env/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin" +Environment="POSTGRES_DB=${POSTGRES_DB}" +Environment="POSTGRES_USER=${POSTGRES_USER}" +Environment="POSTGRES_PASSWORD=${POSTGRES_PASSWORD}" +Environment="POSTGRES_HOST=localhost" +ExecStart=${APP_DIR}/env/bin/daphne -b 0.0.0.0 -p ${WEBSOCKET_PORT} dispatcharr.asgi:application +Restart=always +KillMode=mixed +SyslogIdentifier=dispatcharr-daphne +StandardOutput=journal +StandardError=journal +[Install] +WantedBy=multi-user.target +EOF + + echo ">>> Creating Nginx config..." + cat </etc/nginx/sites-available/dispatcharr.conf +server { + listen ${NGINX_HTTP_PORT}; + location / { + include proxy_params; + proxy_pass http://unix:${GUNICORN_SOCKET}; + } + location /static/ { + alias ${APP_DIR}/static/; + } + location /assets/ { + alias ${APP_DIR}/frontend/dist/assets/; + } + location /media/ { + alias ${APP_DIR}/media/; + } + location /ws/ { + proxy_pass http://127.0.0.1:${WEBSOCKET_PORT}; + proxy_http_version 1.1; + proxy_set_header Upgrade \$http_upgrade; + proxy_set_header Connection "Upgrade"; + proxy_set_header X-Real-IP \$remote_addr; + proxy_set_header X-Forwarded-For \$proxy_add_x_forwarded_for; + proxy_set_header Host \$host; + } +} +EOF + + ln -sf /etc/nginx/sites-available/dispatcharr.conf /etc/nginx/sites-enabled/dispatcharr.conf + [ -f /etc/nginx/sites-enabled/default ] && rm /etc/nginx/sites-enabled/default + nginx -t + systemctl restart nginx + systemctl enable nginx +} + +############################################################################## +# 11) Start Services +############################################################################## + +start_services() { + echo ">>> Enabling and starting services..." + systemctl daemon-reexec + systemctl daemon-reload + systemctl enable --now dispatcharr dispatcharr-celery dispatcharr-celerybeat dispatcharr-daphne +} + +############################################################################## +# 12) Summary +############################################################################## + +show_summary() { + server_ip=$(ip route get 1 | awk '{print $7; exit}') + cat < 500: # Only log if using more than 500MB + print(f"High memory usage detected in {task_name}: {mem:.2f} MB") + except (ImportError, Exception): + pass + +@app.on_after_configure.connect +def setup_celery_logging(**kwargs): + # Use our directly determined log level + log_level = effective_log_level + print(f"Celery configuring loggers with level: {log_level}") + + # Get the specific loggers that output potentially noisy messages + for logger_name in ['celery.app.trace', 'celery.beat', 'celery.worker.strategy', 'celery.beat.Scheduler', 'celery.pool']: + logger = logging.getLogger(logger_name) + + # Remove any existing filters first (in case this runs multiple times) + for filter in logger.filters[:]: + if hasattr(filter, '__class__') and filter.__class__.__name__ == 'SuppressFilter': + logger.removeFilter(filter) + + # Add filtering for both INFO and DEBUG levels - only TRACE will show full logging + if log_level not in ['TRACE']: + # Add a custom filter to completely filter out the repetitive messages + class SuppressFilter(logging.Filter): + def filter(self, record): + # Return False to completely suppress these specific patterns + if ( + "succeeded in" in getattr(record, 'msg', '') or + "Scheduler: Sending due task" in getattr(record, 'msg', '') or + "received" in getattr(record, 'msg', '') or + (logger_name == 'celery.pool' and "Apply" in getattr(record, 'msg', '')) + ): + return False # Don't log these messages at all + return True # Log all other messages + + # Add the filter to each logger + logger.addFilter(SuppressFilter()) + + # Set all Celery loggers to the configured level + # This ensures they respect TRACE/DEBUG when set + try: + numeric_level = getattr(logging, log_level) + logger.setLevel(numeric_level) + except (AttributeError, TypeError): + # If the log level string is invalid, default to DEBUG + logger.setLevel(logging.DEBUG) + + +@worker_ready.connect +def on_worker_ready(**kwargs): + """Tasks to run once the worker is fully connected and ready.""" + from apps.channels.tasks import recover_recordings_on_startup + recover_recordings_on_startup.delay() + + from core.tasks import check_for_version_update + check_for_version_update.delay() diff --git a/dispatcharr/consumers.py b/dispatcharr/consumers.py new file mode 100644 index 0000000..7b62d95 --- /dev/null +++ b/dispatcharr/consumers.py @@ -0,0 +1,72 @@ +import json +from channels.generic.websocket import AsyncWebsocketConsumer +import regex, logging + +logger = logging.getLogger(__name__) + +class MyWebSocketConsumer(AsyncWebsocketConsumer): + async def connect(self): + self.room_name = "updates" + + user = self.scope["user"] + if not user.is_authenticated: + await self.close() + return + + try: + await self.accept() + await self.channel_layer.group_add(self.room_name, self.channel_name) + # Send a connection confirmation to the client with consistent format + await self.send(text_data=json.dumps({ + 'type': 'connection_established', + 'data': { + 'success': True, + 'message': 'WebSocket connection established successfully' + } + })) + except Exception as e: + import logging + logger = logging.getLogger(__name__) + logger.error(f"Error in WebSocket connect: {str(e)}") + # If an error occurs during connection, attempt to close + try: + await self.close(code=1011) # Internal server error + except: + pass + + async def disconnect(self, close_code): + try: + await self.channel_layer.group_discard(self.room_name, self.channel_name) + except Exception as e: + import logging + logger = logging.getLogger(__name__) + logger.error(f"Error in WebSocket disconnect: {str(e)}") + + async def receive(self, text_data): + data = json.loads(text_data) + + if data["type"] == "m3u_profile_test": + from apps.proxy.ts_proxy.url_utils import transform_url + + def replace_with_mark(match): + # Wrap the match in tags + return f"{match.group(0)}" + + # Apply the transformation using the replace_with_mark function + try: + search_preview = regex.sub(data["search"], replace_with_mark, data["url"]) + except Exception as e: + search_preview = data["url"] + logger.error(f"Failed to generate replace preview: {e}") + + result = transform_url(data["url"], data["search"], data["replace"]) + await self.send(text_data=json.dumps({ + "data": { + 'type': 'm3u_profile_test', + 'search_preview': search_preview, + 'result': result, + } + })) + + async def update(self, event): + await self.send(text_data=json.dumps(event)) diff --git a/dispatcharr/jwt_ws_auth.py b/dispatcharr/jwt_ws_auth.py new file mode 100644 index 0000000..b478cd6 --- /dev/null +++ b/dispatcharr/jwt_ws_auth.py @@ -0,0 +1,47 @@ +from urllib.parse import parse_qs +from channels.middleware import BaseMiddleware +from channels.db import database_sync_to_async +from rest_framework_simplejwt.tokens import UntypedToken +from django.contrib.auth.models import AnonymousUser +from django.contrib.auth import get_user_model +from rest_framework_simplejwt.exceptions import InvalidToken, TokenError +from rest_framework_simplejwt.authentication import JWTAuthentication +import logging + +logger = logging.getLogger(__name__) +User = get_user_model() + +@database_sync_to_async +def get_user(validated_token): + try: + jwt_auth = JWTAuthentication() + user = jwt_auth.get_user(validated_token) + return user + except User.DoesNotExist: + logger.warning(f"User from token does not exist. User ID: {validated_token.get('user_id', 'unknown')}") + return AnonymousUser() + except Exception as e: + logger.error(f"Error getting user from token: {str(e)}") + return AnonymousUser() + +class JWTAuthMiddleware(BaseMiddleware): + async def __call__(self, scope, receive, send): + try: + # Extract the token from the query string + query_string = parse_qs(scope["query_string"].decode()) + token = query_string.get("token", [None])[0] + + if token is not None: + try: + validated_token = JWTAuthentication().get_validated_token(token) + scope["user"] = await get_user(validated_token) + except (InvalidToken, TokenError) as e: + logger.warning(f"Invalid token: {str(e)}") + scope["user"] = AnonymousUser() + else: + scope["user"] = AnonymousUser() + except Exception as e: + logger.error(f"Error in JWT authentication: {str(e)}") + scope["user"] = AnonymousUser() + + return await super().__call__(scope, receive, send) diff --git a/dispatcharr/persistent_lock.py b/dispatcharr/persistent_lock.py new file mode 100644 index 0000000..4562585 --- /dev/null +++ b/dispatcharr/persistent_lock.py @@ -0,0 +1,126 @@ +# dispatcharr/persistent_lock.py +import uuid +import redis + +class PersistentLock: + """ + A persistent, auto-expiring lock that uses Redis. + + Usage: + 1. Instantiate with a Redis client, a unique lock key (e.g. "lock:account:123"), + and an optional timeout (in seconds). + 2. Call acquire() to try to obtain the lock. + 3. Optionally, periodically call refresh() to extend the lock's lifetime. + 4. When finished, call release() to free the lock. + """ + def __init__(self, redis_client: redis.Redis, lock_key: str, lock_timeout: int = 120): + """ + Initialize the lock. + + :param redis_client: An instance of redis.Redis. + :param lock_key: The unique key for the lock. + :param lock_timeout: Time-to-live for the lock in seconds. + """ + self.redis_client = redis_client + self.lock_key = lock_key + self.lock_timeout = lock_timeout + self.lock_token = None + self.has_lock = False + + def has_lock(self) -> bool: + return self.has_lock + + def acquire(self) -> bool: + """ + Attempt to acquire the lock. Returns True if successful. + """ + self.lock_token = str(uuid.uuid4()) + # Set the lock with NX (only if not exists) and EX (expire time) + result = self.redis_client.set(self.lock_key, self.lock_token, nx=True, ex=self.lock_timeout) + if result is not None: + self.has_lock = True + + return result is not None + + def refresh(self) -> bool: + """ + Refresh the lock's expiration time if this instance owns the lock. + Returns True if the expiration was successfully extended. + """ + current_value = self.redis_client.get(self.lock_key) + if current_value and current_value == self.lock_token: + self.redis_client.expire(self.lock_key, self.lock_timeout) + self.has_lock = False + return True + return False + + def release(self) -> bool: + """ + Release the lock only if owned by this instance. + Returns True if the lock was successfully released. + """ + # Use a Lua script for atomicity: only delete if the token matches. + lua_script = """ + if redis.call("get", KEYS[1]) == ARGV[1] then + return redis.call("del", KEYS[1]) + else + return 0 + end + """ + release_lock = self.redis_client.register_script(lua_script) + result = release_lock(keys=[self.lock_key], args=[self.lock_token]) + return result == 1 + +# Example usage (for testing purposes only): +if __name__ == "__main__": + import os + import sys + # Connect to Redis using environment variables; adjust connection parameters as needed. + redis_host = os.environ.get("REDIS_HOST", "localhost") + redis_port = int(os.environ.get("REDIS_PORT", 6379)) + redis_db = int(os.environ.get("REDIS_DB", 0)) + redis_password = os.environ.get("REDIS_PASSWORD", "") + redis_user = os.environ.get("REDIS_USER", "") + ssl_kwargs = {} + if os.environ.get("REDIS_SSL", "false").lower() == "true": + import ssl as _ssl + ssl_kwargs["ssl"] = True + ssl_kwargs["ssl_cert_reqs"] = ( + _ssl.CERT_REQUIRED if os.environ.get("REDIS_SSL_VERIFY", "true").lower() == "true" + else _ssl.CERT_NONE + ) + for env_var, key in [ + ("REDIS_SSL_CA_CERT", "ssl_ca_certs"), + ("REDIS_SSL_CERT", "ssl_certfile"), + ("REDIS_SSL_KEY", "ssl_keyfile"), + ]: + path = os.environ.get(env_var, "") + if path: + if not os.path.isfile(path): + print(f"Redis TLS: {env_var}={path!r} — file not found.") + sys.exit(1) + ssl_kwargs[key] = path + + client = redis.Redis( + host=redis_host, + port=redis_port, + db=redis_db, + password=redis_password if redis_password else None, + username=redis_user if redis_user else None, + **ssl_kwargs + ) + lock = PersistentLock(client, "lock:example_account", lock_timeout=120) + + if lock.acquire(): + print("Lock acquired successfully!") + # Do work here... + # Optionally refresh the lock periodically: + if lock.refresh(): + print("Lock refreshed.") + # Finally, release the lock: + if lock.release(): + print("Lock released.") + else: + print("Failed to release lock.") + else: + print("Failed to acquire lock.") diff --git a/dispatcharr/routing.py b/dispatcharr/routing.py new file mode 100644 index 0000000..7624e21 --- /dev/null +++ b/dispatcharr/routing.py @@ -0,0 +1,6 @@ +from django.urls import path +from dispatcharr.consumers import MyWebSocketConsumer + +websocket_urlpatterns = [ + path("ws/", MyWebSocketConsumer.as_asgi()), +] diff --git a/dispatcharr/settings.py b/dispatcharr/settings.py new file mode 100644 index 0000000..66dbf35 --- /dev/null +++ b/dispatcharr/settings.py @@ -0,0 +1,570 @@ +import os +import ssl +from pathlib import Path +from datetime import timedelta +from urllib.parse import quote_plus +from django.core.exceptions import ImproperlyConfigured + + +def _validate_tls_cert_paths(paths, service_name): + """Validate that configured TLS certificate file paths exist on disk. + + Raises ImproperlyConfigured with a clear message identifying the + service and missing file so operators can fix their environment. + """ + for env_var, file_path in paths: + if file_path and not Path(file_path).is_file(): + raise ImproperlyConfigured( + f"{service_name} TLS: {env_var}={file_path!r} — file not found. " + f"Check that the certificate file exists and the volume is mounted correctly." + ) + +BASE_DIR = Path(__file__).resolve().parent.parent + +SECRET_KEY = os.environ.get("DJANGO_SECRET_KEY") +REDIS_HOST = os.environ.get("REDIS_HOST", "localhost") +REDIS_PORT = int(os.environ.get("REDIS_PORT", 6379)) +REDIS_DB = os.environ.get("REDIS_DB", "0") +REDIS_USER = os.environ.get("REDIS_USER", "") +REDIS_PASSWORD = os.environ.get("REDIS_PASSWORD", "") + +# Redis TLS configuration +REDIS_SSL = os.environ.get("REDIS_SSL", "false").lower() == "true" +REDIS_SSL_VERIFY = os.environ.get("REDIS_SSL_VERIFY", "true").lower() == "true" +REDIS_SSL_CA_CERT = os.environ.get("REDIS_SSL_CA_CERT", "") +REDIS_SSL_CERT = os.environ.get("REDIS_SSL_CERT", "") +REDIS_SSL_KEY = os.environ.get("REDIS_SSL_KEY", "") + +# Reusable dict of SSL kwargs for redis.Redis() constructors +REDIS_SSL_PARAMS = {} +if REDIS_SSL: + _validate_tls_cert_paths([ + ("REDIS_SSL_CA_CERT", REDIS_SSL_CA_CERT), + ("REDIS_SSL_CERT", REDIS_SSL_CERT), + ("REDIS_SSL_KEY", REDIS_SSL_KEY), + ], "Redis") + + REDIS_SSL_PARAMS["ssl"] = True + REDIS_SSL_PARAMS["ssl_cert_reqs"] = ssl.CERT_REQUIRED if REDIS_SSL_VERIFY else ssl.CERT_NONE + if REDIS_SSL_CA_CERT: + REDIS_SSL_PARAMS["ssl_ca_certs"] = REDIS_SSL_CA_CERT + if REDIS_SSL_CERT: + REDIS_SSL_PARAMS["ssl_certfile"] = REDIS_SSL_CERT + if REDIS_SSL_KEY: + REDIS_SSL_PARAMS["ssl_keyfile"] = REDIS_SSL_KEY + + _mtls = "enabled" if REDIS_SSL_CERT and REDIS_SSL_KEY else "disabled" + _verify = "on" if REDIS_SSL_VERIFY else "off" + print(f"Redis TLS: enabled (verify={_verify}, mTLS={_mtls})") +else: + print("Redis TLS: disabled") + +# Set DEBUG to True for development, False for production +if os.environ.get("DISPATCHARR_DEBUG", "False").lower() == "true": + DEBUG = True +else: + DEBUG = False + +ALLOWED_HOSTS = ["*"] +SECURE_PROXY_SSL_HEADER = ("HTTP_X_FORWARDED_PROTO", "https") + +INSTALLED_APPS = [ + "apps.api", + "apps.accounts", + "apps.backups.apps.BackupsConfig", + "apps.channels.apps.ChannelsConfig", + "apps.dashboard", + "apps.epg", + "apps.hdhr", + "apps.m3u", + "apps.output", + "apps.proxy.apps.ProxyConfig", + "apps.proxy.ts_proxy", + "apps.vod.apps.VODConfig", + "apps.connect.apps.ConnectConfig", + "core", + "daphne", + "drf_spectacular", + "channels", + "django.contrib.admin", + "django.contrib.auth", + "django.contrib.contenttypes", + "django.contrib.sessions", + "django.contrib.messages", + "django.contrib.staticfiles", + "rest_framework", + "corsheaders", + "django_filters", + "django_celery_beat", + "apps.plugins", +] + +# EPG Processing optimization settings +EPG_BATCH_SIZE = 1000 # Number of records to process in a batch +EPG_MEMORY_LIMIT = 512 # Memory limit in MB before forcing garbage collection +EPG_ENABLE_MEMORY_MONITORING = True # Whether to monitor memory usage during processing + +# XtreamCodes Rate Limiting Settings +# Delay between profile authentications when refreshing multiple profiles +# This prevents providers from temporarily banning users with many profiles +XC_PROFILE_REFRESH_DELAY = float(os.environ.get('XC_PROFILE_REFRESH_DELAY', '2.5')) # seconds between profile refreshes + +# Database optimization settings +DATABASE_STATEMENT_TIMEOUT = 300 # Seconds before timing out long-running queries +DATABASE_CONN_MAX_AGE = ( + 60 # Connection max age in seconds, helps with frequent reconnects +) + +# Disable atomic requests for performance-sensitive views +ATOMIC_REQUESTS = False + +# Cache settings - add caching for EPG operations +CACHES = { + "default": { + "BACKEND": "django.core.cache.backends.locmem.LocMemCache", + "LOCATION": "dispatcharr-epg-cache", + "TIMEOUT": 3600, # 1 hour cache timeout + "OPTIONS": { + "MAX_ENTRIES": 10000, + "CULL_FREQUENCY": 3, # Purge 1/3 of entries when max is reached + }, + } +} + +# Timeouts for external connections +REQUESTS_TIMEOUT = 30 # Seconds for external API requests + +MIDDLEWARE = [ + "django.middleware.security.SecurityMiddleware", + "django.contrib.sessions.middleware.SessionMiddleware", + "django.middleware.common.CommonMiddleware", + "django.middleware.csrf.CsrfViewMiddleware", + "django.contrib.auth.middleware.AuthenticationMiddleware", + "django.contrib.messages.middleware.MessageMiddleware", + "django.middleware.clickjacking.XFrameOptionsMiddleware", + "corsheaders.middleware.CorsMiddleware", +] + + +ROOT_URLCONF = "dispatcharr.urls" + +TEMPLATES = [ + { + "BACKEND": "django.template.backends.django.DjangoTemplates", + "DIRS": [os.path.join(BASE_DIR, "frontend/dist"), BASE_DIR / "templates"], + "APP_DIRS": True, + "OPTIONS": { + "context_processors": [ + "django.template.context_processors.debug", + "django.template.context_processors.request", + "django.contrib.auth.context_processors.auth", + "django.contrib.messages.context_processors.messages", + ], + }, + }, +] + +WSGI_APPLICATION = "dispatcharr.wsgi.application" +ASGI_APPLICATION = "dispatcharr.asgi.application" + +_redis_scheme = "rediss" if REDIS_SSL else "redis" + +# URL-encoded auth string shared by CHANNEL_LAYERS and Celery broker URLs +if REDIS_PASSWORD: + _encoded_password = quote_plus(REDIS_PASSWORD) + if REDIS_USER: + _redis_auth = f"{quote_plus(REDIS_USER)}:{_encoded_password}@" + else: + _redis_auth = f":{_encoded_password}@" +else: + _redis_auth = "" + +_channels_redis_url = f"{_redis_scheme}://{_redis_auth}{REDIS_HOST}:{REDIS_PORT}/{REDIS_DB}" +# channels_redis accepts either a URL string or a dict with "address" + kwargs. +# When TLS is enabled, pass SSL params alongside the URL so the connection pool +# uses the correct CA cert and verification settings. +if REDIS_SSL: + # Filter out "ssl" key — the rediss:// scheme already enables SSL. + # Passing ssl=True as a kwarg to aioredis from_url causes an error. + _channels_ssl = {k: v for k, v in REDIS_SSL_PARAMS.items() if k != "ssl"} + _channels_host = {"address": _channels_redis_url, **_channels_ssl} +else: + _channels_host = _channels_redis_url + +CHANNEL_LAYERS = { + "default": { + "BACKEND": "channels_redis.core.RedisChannelLayer", + "CONFIG": { + "hosts": [_channels_host], + }, + }, +} + +# PostgreSQL TLS configuration (defined before DATABASES for module-level access) +POSTGRES_SSL = os.environ.get("POSTGRES_SSL", "false").lower() == "true" +POSTGRES_SSL_MODE = os.environ.get("POSTGRES_SSL_MODE", "verify-full") +POSTGRES_SSL_CA_CERT = os.environ.get("POSTGRES_SSL_CA_CERT", "") +POSTGRES_SSL_CERT = os.environ.get("POSTGRES_SSL_CERT", "") +POSTGRES_SSL_KEY = os.environ.get("POSTGRES_SSL_KEY", "") + +if os.getenv("DB_ENGINE", None) == "sqlite": + DATABASES = { + "default": { + "ENGINE": "django.db.backends.sqlite3", + "NAME": "/data/dispatcharr.db", + } + } +else: + DATABASES = { + "default": { + "ENGINE": "django.db.backends.postgresql", + "NAME": os.environ.get("POSTGRES_DB", "dispatcharr"), + "USER": os.environ.get("POSTGRES_USER", "dispatch"), + "PASSWORD": os.environ.get("POSTGRES_PASSWORD", "secret"), + "HOST": os.environ.get("POSTGRES_HOST", "localhost"), + "PORT": int(os.environ.get("POSTGRES_PORT", 5432)), + "CONN_MAX_AGE": DATABASE_CONN_MAX_AGE, + } + } + + if POSTGRES_SSL: + _validate_tls_cert_paths([ + ("POSTGRES_SSL_CA_CERT", POSTGRES_SSL_CA_CERT), + ("POSTGRES_SSL_CERT", POSTGRES_SSL_CERT), + ("POSTGRES_SSL_KEY", POSTGRES_SSL_KEY), + ], "PostgreSQL") + + DATABASES["default"]["OPTIONS"] = { + "sslmode": POSTGRES_SSL_MODE, + } + if POSTGRES_SSL_CA_CERT: + DATABASES["default"]["OPTIONS"]["sslrootcert"] = POSTGRES_SSL_CA_CERT + if POSTGRES_SSL_CERT: + DATABASES["default"]["OPTIONS"]["sslcert"] = POSTGRES_SSL_CERT + if POSTGRES_SSL_KEY: + DATABASES["default"]["OPTIONS"]["sslkey"] = POSTGRES_SSL_KEY + + _mtls = "enabled" if POSTGRES_SSL_CERT and POSTGRES_SSL_KEY else "disabled" + print(f"PostgreSQL TLS: enabled (sslmode={POSTGRES_SSL_MODE}, mTLS={_mtls})") + else: + print("PostgreSQL TLS: disabled") + +AUTH_PASSWORD_VALIDATORS = [ + { + "NAME": "django.contrib.auth.password_validation.UserAttributeSimilarityValidator", + }, +] + +REST_FRAMEWORK = { + "DEFAULT_SCHEMA_CLASS": "drf_spectacular.openapi.AutoSchema", + "DEFAULT_RENDERER_CLASSES": [ + "rest_framework.renderers.JSONRenderer", + "rest_framework.renderers.BrowsableAPIRenderer", + ], + "DEFAULT_AUTHENTICATION_CLASSES": [ + "rest_framework_simplejwt.authentication.JWTAuthentication", + "apps.accounts.authentication.ApiKeyAuthentication", + ], + "DEFAULT_PERMISSION_CLASSES": [ + "apps.accounts.permissions.IsAdmin", + ], + "DEFAULT_FILTER_BACKENDS": ["django_filters.rest_framework.DjangoFilterBackend"], + "DEFAULT_THROTTLE_CLASSES": [], + "DEFAULT_THROTTLE_RATES": { + "login": "1000/minute", + }, +} + +SPECTACULAR_SETTINGS = { + "TITLE": "Dispatcharr API", + "DESCRIPTION": "API documentation for Dispatcharr", + "VERSION": "1.0.0", + "SERVE_INCLUDE_SCHEMA": False, +} + +LANGUAGE_CODE = "en-us" +TIME_ZONE = "UTC" +USE_I18N = True +USE_TZ = True + +STATIC_URL = "/static/" +STATIC_ROOT = BASE_DIR / "static" # Directory where static files will be collected + +# Adjust STATICFILES_DIRS to include the paths to the directories that contain your static files. +STATICFILES_DIRS = [ + os.path.join(BASE_DIR, "frontend/dist"), # React build static files +] + + +DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField" +AUTH_USER_MODEL = "accounts.User" + +_default_redis_url = f"{_redis_scheme}://{_redis_auth}{REDIS_HOST}:{REDIS_PORT}/{REDIS_DB}" +# Celery/Kombu require SSL parameters in the URL query string because +# internal URL parsing can overwrite the CELERY_BROKER_USE_SSL dict. +if REDIS_SSL: + _celery_ssl_params = [ + f"ssl_cert_reqs={'CERT_REQUIRED' if REDIS_SSL_VERIFY else 'CERT_NONE'}", + ] + if REDIS_SSL_CA_CERT: + _celery_ssl_params.append(f"ssl_ca_certs={REDIS_SSL_CA_CERT}") + if REDIS_SSL_CERT: + _celery_ssl_params.append(f"ssl_certfile={REDIS_SSL_CERT}") + if REDIS_SSL_KEY: + _celery_ssl_params.append(f"ssl_keyfile={REDIS_SSL_KEY}") + _default_celery_url = f"{_default_redis_url}?{'&'.join(_celery_ssl_params)}" +else: + _default_celery_url = _default_redis_url +CELERY_BROKER_URL = os.environ.get("CELERY_BROKER_URL", _default_celery_url) +CELERY_RESULT_BACKEND = os.environ.get("CELERY_RESULT_BACKEND", CELERY_BROKER_URL) + +# Validate that URL overrides don't conflict with TLS settings +for _url_var, _url_val in [ + ("CELERY_BROKER_URL", CELERY_BROKER_URL), + ("CELERY_RESULT_BACKEND", CELERY_RESULT_BACKEND), +]: + _is_override = os.environ.get(_url_var) is not None + if not _is_override: + continue + _url_is_ssl = _url_val.startswith("rediss://") + if REDIS_SSL and not _url_is_ssl: + raise ImproperlyConfigured( + f"REDIS_SSL is enabled but {_url_var} uses redis:// (plaintext). " + f"Change the URL scheme to rediss:// or remove the {_url_var} override." + ) + if not REDIS_SSL and _url_is_ssl: + raise ImproperlyConfigured( + f"{_url_var} uses rediss:// (TLS) but REDIS_SSL is not enabled. " + f"Set REDIS_SSL=true and configure the TLS certificate settings." + ) + +# Celery TLS configuration — required in addition to the rediss:// URL scheme. +# Uses the same cert params as REDIS_SSL_PARAMS, minus the "ssl" key that +# redis-py needs but Celery/Kombu does not. +if REDIS_SSL: + CELERY_BROKER_USE_SSL = {k: v for k, v in REDIS_SSL_PARAMS.items() if k != "ssl"} + CELERY_RESULT_BACKEND_USE_SSL = CELERY_BROKER_USE_SSL + +# Configure Redis key prefix +CELERY_RESULT_BACKEND_TRANSPORT_OPTIONS = { + "global_keyprefix": "celery-tasks:", # Set the Redis key prefix for Celery +} + +# Set TTL (Time-to-Live) for task results (in seconds) +CELERY_RESULT_EXPIRES = 3600 # 1 hour TTL for task results + +# Optionally, set visibility timeout for task retries (if using Redis) +CELERY_BROKER_TRANSPORT_OPTIONS = { + "visibility_timeout": 3600, # Time in seconds that a task remains invisible during retries +} + +CELERY_ACCEPT_CONTENT = ["json"] +CELERY_TASK_SERIALIZER = "json" + +# Worker memory safety net: recycle prefork workers exceeding 512MB RSS. +# Prevents unbounded growth from memory fragmentation or unexpected leaks. +CELERY_WORKER_MAX_MEMORY_PER_CHILD = 524_288 # 512 MB in KB + +CELERY_BEAT_SCHEDULER = "django_celery_beat.schedulers.DatabaseScheduler" +CELERY_BEAT_SCHEDULE = { + # Explicitly disable the old fetch-channel-statuses task + # This ensures it gets disabled when DatabaseScheduler syncs + "fetch-channel-statuses": { + "task": "apps.proxy.tasks.fetch_channel_stats", + "schedule": 2.0, # Original schedule (doesn't matter since disabled) + "enabled": False, # Explicitly disabled + }, + # Keep the file scanning task + "scan-files": { + "task": "core.tasks.scan_and_process_files", # Direct task call + "schedule": 20.0, # Every 20 seconds + }, + "maintain-recurring-recordings": { + "task": "apps.channels.tasks.maintain_recurring_recordings", + "schedule": 3600.0, # Once an hour ensure recurring schedules stay ahead + }, + # Check for version updates daily + "check-version-updates": { + "task": "core.tasks.check_for_version_update", + "schedule": 86400.0, # Once every 24 hours + }, + # Check for account expirations daily + "check-account-expirations": { + "task": "apps.m3u.tasks.check_account_expirations", + "schedule": 86400.0, # Once every 24 hours + }, +} + +MEDIA_ROOT = BASE_DIR / "media" +MEDIA_URL = "/media/" + +# Backup settings +BACKUP_ROOT = os.environ.get("BACKUP_ROOT", "/data/backups") +BACKUP_DATA_DIRS = [ + os.environ.get("LOGOS_DIR", "/data/logos"), + os.environ.get("UPLOADS_DIR", "/data/uploads"), + os.environ.get("PLUGINS_DIR", "/data/plugins"), +] + +SERVER_IP = "127.0.0.1" + +CORS_ALLOW_ALL_ORIGINS = True +CSRF_TRUSTED_ORIGINS = ["http://*", "https://*"] +APPEND_SLASH = True + +SIMPLE_JWT = { + "ACCESS_TOKEN_LIFETIME": timedelta(minutes=30), + "REFRESH_TOKEN_LIFETIME": timedelta(days=1), + "ROTATE_REFRESH_TOKENS": False, # Optional: Whether to rotate refresh tokens + "BLACKLIST_AFTER_ROTATION": True, # Optional: Whether to blacklist refresh tokens +} + +# Redis connection settings — _default_redis_url uses rediss:// when REDIS_SSL is enabled +REDIS_URL = os.environ.get("REDIS_URL", _default_redis_url) +if os.environ.get("REDIS_URL") is not None: + if REDIS_SSL and not REDIS_URL.startswith("rediss://"): + raise ImproperlyConfigured( + "REDIS_SSL is enabled but REDIS_URL uses redis:// (plaintext). " + "Change the URL scheme to rediss:// or remove the REDIS_URL override." + ) + if not REDIS_SSL and REDIS_URL.startswith("rediss://"): + raise ImproperlyConfigured( + "REDIS_URL uses rediss:// (TLS) but REDIS_SSL is not enabled. " + "Set REDIS_SSL=true and configure the TLS certificate settings." + ) +REDIS_SOCKET_TIMEOUT = 60 # Socket timeout in seconds +REDIS_SOCKET_CONNECT_TIMEOUT = 5 # Connection timeout in seconds +REDIS_HEALTH_CHECK_INTERVAL = 15 # Health check every 15 seconds +REDIS_SOCKET_KEEPALIVE = True # Enable socket keepalive +REDIS_RETRY_ON_TIMEOUT = True # Retry on timeout +REDIS_MAX_RETRIES = 10 # Maximum number of retries +REDIS_RETRY_INTERVAL = 1 # Initial retry interval in seconds + +# Proxy Settings +PROXY_SETTINGS = { + "HLS": { + "DEFAULT_URL": "", # Default HLS stream URL if needed + "BUFFER_SIZE": 1000, + "USER_AGENT": "VLC/3.0.20 LibVLC/3.0.20", + "CHUNK_SIZE": 8192, + "CLIENT_POLL_INTERVAL": 0.1, + "MAX_RETRIES": 3, + "MIN_SEGMENTS": 12, + "MAX_SEGMENTS": 16, + "WINDOW_SIZE": 12, + "INITIAL_SEGMENTS": 3, + }, + "TS": { + "DEFAULT_URL": "", # Default TS stream URL if needed + "BUFFER_SIZE": 1000, + "RECONNECT_DELAY": 5, + "USER_AGENT": "VLC/3.0.20 LibVLC/3.0.20", + }, +} + +# Map log level names to their numeric values +LOG_LEVEL_MAP = { + "TRACE": 5, + "DEBUG": 10, + "INFO": 20, + "WARNING": 30, + "ERROR": 40, + "CRITICAL": 50, +} + +# Get log level from environment variable, default to INFO if not set +# Add debugging output to see exactly what's being detected +env_log_level = os.environ.get("DISPATCHARR_LOG_LEVEL", "") +print(f"Environment DISPATCHARR_LOG_LEVEL detected as: '{env_log_level}'") + +if not env_log_level: + print("No DISPATCHARR_LOG_LEVEL found in environment, using default INFO") + LOG_LEVEL_NAME = "INFO" +else: + LOG_LEVEL_NAME = env_log_level.upper() + print(f"Setting log level to: {LOG_LEVEL_NAME}") + +LOG_LEVEL = LOG_LEVEL_MAP.get(LOG_LEVEL_NAME, 20) # Default to INFO (20) if invalid + +# Add this to your existing LOGGING configuration or create one if it doesn't exist +LOGGING = { + "version": 1, + "disable_existing_loggers": False, + "formatters": { + "verbose": { + "format": "{asctime} {levelname} {name} {message}", + "style": "{", + }, + }, + "handlers": { + "console": { + "class": "logging.StreamHandler", + "formatter": "verbose", + "level": 5, # Always allow TRACE level messages through the handler + }, + }, + "loggers": { + "core.tasks": { + "handlers": ["console"], + "level": LOG_LEVEL, # Use environment-configured level + "propagate": False, # Don't propagate to root logger to avoid duplicate logs + }, + "core.utils": { + "handlers": ["console"], + "level": LOG_LEVEL, + "propagate": False, + }, + "apps.proxy": { + "handlers": ["console"], + "level": LOG_LEVEL, # Use environment-configured level + "propagate": False, # Don't propagate to root logger + }, + # Add parent logger for all app modules + "apps": { + "handlers": ["console"], + "level": LOG_LEVEL, + "propagate": False, + }, + # Celery loggers to capture task execution messages + "celery": { + "handlers": ["console"], + "level": LOG_LEVEL, # Use configured log level for Celery logs + "propagate": False, + }, + "celery.task": { + "handlers": ["console"], + "level": LOG_LEVEL, # Use configured log level for task-specific logs + "propagate": False, + }, + "celery.worker": { + "handlers": ["console"], + "level": LOG_LEVEL, # Use configured log level for worker logs + "propagate": False, + }, + "celery.beat": { + "handlers": ["console"], + "level": LOG_LEVEL, # Use configured log level for scheduler logs + "propagate": False, + }, + # Add any other loggers you need to capture TRACE logs from + }, + "root": { + "handlers": ["console"], + "level": LOG_LEVEL, # Use user-configured level instead of hardcoded 'INFO' + }, +} + +# Connect script execution safety settings +# Allowed base directories for custom scripts; real paths must be inside +_allowed_dirs_env = os.environ.get("DISPATCHARR_ALLOWED_SCRIPT_DIRS", "/data/scripts") +CONNECT_ALLOWED_SCRIPT_DIRS = [p for p in _allowed_dirs_env.split(":") if p] + +# Max execution time (seconds) for scripts +CONNECT_SCRIPT_TIMEOUT = int(os.environ.get("DISPATCHARR_SCRIPT_TIMEOUT", "10")) + +# Truncate stdout/stderr to this many characters to avoid large outputs +CONNECT_SCRIPT_MAX_OUTPUT = int(os.environ.get("DISPATCHARR_SCRIPT_MAX_OUTPUT", "65536")) + +# Require executable bit and disallow world-writable files +CONNECT_SCRIPT_REQUIRE_EXECUTABLE = True +CONNECT_SCRIPT_DISALLOW_WORLD_WRITABLE = True diff --git a/dispatcharr/urls.py b/dispatcharr/urls.py new file mode 100644 index 0000000..1319f31 --- /dev/null +++ b/dispatcharr/urls.py @@ -0,0 +1,70 @@ +from django.contrib import admin +from django.urls import path, include, re_path +from django.conf import settings +from django.conf.urls.static import static +from django.views.generic import TemplateView, RedirectView +from .routing import websocket_urlpatterns +from apps.output.views import xc_player_api, xc_panel_api, xc_get, xc_xmltv +from apps.proxy.ts_proxy.views import stream_xc +from apps.proxy.vod_proxy.views import stream_xc_movie, stream_xc_episode + +urlpatterns = [ + # API Routes + path("api/", include(("apps.api.urls", "api"), namespace="api")), + path("api", RedirectView.as_view(url="/api/", permanent=True)), + # Swagger redirects (Swagger UI is served at /api/swagger/) + path("swagger/", RedirectView.as_view(url="/api/swagger/", permanent=True)), + path("swagger", RedirectView.as_view(url="/api/swagger/", permanent=True)), + path("redoc/", RedirectView.as_view(url="/api/redoc/", permanent=True)), + path("redoc", RedirectView.as_view(url="/api/redoc/", permanent=True)), + # Outputs + path("output", RedirectView.as_view(url="/output/", permanent=True)), + path("output/", include(("apps.output.urls", "output"), namespace="output")), + # HDHR + path("hdhr", RedirectView.as_view(url="/hdhr/", permanent=True)), + path("hdhr/", include(("apps.hdhr.urls", "hdhr"), namespace="hdhr")), + # Add proxy apps - Move these before the catch-all + path("proxy/", include(("apps.proxy.urls", "proxy"), namespace="proxy")), + path("proxy", RedirectView.as_view(url="/proxy/", permanent=True)), + # xc + re_path("player_api.php", xc_player_api, name="xc_player_api"), + re_path("panel_api.php", xc_panel_api, name="xc_panel_api"), + re_path("get.php", xc_get, name="xc_get"), + re_path("xmltv.php", xc_xmltv, name="xc_xmltv"), + path( + "live///", + stream_xc, + name="xc_live_stream_endpoint", + ), + path( + "//", + stream_xc, + name="xc_stream_endpoint", + ), + # XC VOD endpoints + path( + "movie///.", + stream_xc_movie, + name="stream_xc_movie", + ), + path( + "series///.", + stream_xc_episode, + name="stream_xc_episode", + ), + # Admin + path("admin", RedirectView.as_view(url="/admin/", permanent=True)), + path("admin/", admin.site.urls), + + # VOD proxy is now handled by the main proxy URLs above + # Catch-all routes should always be last + path("", TemplateView.as_view(template_name="index.html")), # React entry point + path("", TemplateView.as_view(template_name="index.html")), +] + static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT) + +urlpatterns += websocket_urlpatterns + +# Serve static files for development (React's JS, CSS, etc.) +if settings.DEBUG: + urlpatterns += static(settings.STATIC_URL, document_root=settings.STATIC_ROOT) + urlpatterns += static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT) diff --git a/dispatcharr/utils.py b/dispatcharr/utils.py new file mode 100644 index 0000000..c8fe58a --- /dev/null +++ b/dispatcharr/utils.py @@ -0,0 +1,69 @@ +# dispatcharr/utils.py +import json +import ipaddress +from django.http import JsonResponse +from django.core.exceptions import ValidationError +from core.models import CoreSettings, NETWORK_ACCESS_KEY + + +def json_error_response(message, status=400): + """Return a standardized error JSON response.""" + return JsonResponse({"success": False, "error": message}, status=status) + + +def json_success_response(data=None, status=200): + """Return a standardized success JSON response.""" + response = {"success": True} + if data is not None: + response.update(data) + return JsonResponse(response, status=status) + + +def validate_logo_file(file): + """Validate uploaded logo file size and MIME type.""" + valid_mime_types = ["image/jpeg", "image/png", "image/gif", "image/webp", "image/svg+xml"] + if file.content_type not in valid_mime_types: + raise ValidationError("Unsupported file type. Allowed types: JPEG, PNG, GIF, WebP, SVG.") + if file.size > 5 * 1024 * 1024: # 5MB + raise ValidationError("File too large. Max 5MB.") + + +def get_client_ip(request): + x_forwarded_for = request.META.get("HTTP_X_REAL_IP") + if x_forwarded_for: + # X-Forwarded-For can be a comma-separated list of IPs + ip = x_forwarded_for.split(",")[0].strip() + else: + ip = request.META.get("REMOTE_ADDR") + return ip + + +def network_access_allowed(request, settings_key): + try: + network_access = CoreSettings.objects.get(key=NETWORK_ACCESS_KEY).value + except CoreSettings.DoesNotExist: + network_access = {} + local_cidrs = ["127.0.0.0/8", "10.0.0.0/8", "172.16.0.0/12", "192.168.0.0/16", "::1/128", "fc00::/7", "fe80::/10"] + # Set defaults based on endpoint type + if settings_key == "M3U_EPG": + # M3U/EPG endpoints: local IPv4 and IPv6 only by default + default_cidrs = local_cidrs + else: + # Other endpoints: allow all by default + default_cidrs = ["0.0.0.0/0", "::/0"] + + cidrs = ( + network_access[settings_key].split(",") + if settings_key in network_access + else default_cidrs + ) + + network_allowed = False + client_ip = ipaddress.ip_address(get_client_ip(request)) + for cidr in cidrs: + network = ipaddress.ip_network(cidr) + if client_ip in network: + network_allowed = True + break + + return network_allowed diff --git a/dispatcharr/wsgi.py b/dispatcharr/wsgi.py new file mode 100644 index 0000000..29c2ba0 --- /dev/null +++ b/dispatcharr/wsgi.py @@ -0,0 +1,8 @@ +""" +WSGI config for dispatcharr project. +""" +import os +from django.core.wsgi import get_wsgi_application + +os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'dispatcharr.settings') +application = get_wsgi_application() diff --git a/docker/DispatcharrBase b/docker/DispatcharrBase new file mode 100644 index 0000000..3040e18 --- /dev/null +++ b/docker/DispatcharrBase @@ -0,0 +1,69 @@ +FROM lscr.io/linuxserver/ffmpeg:latest + +ENV DEBIAN_FRONTEND=noninteractive +ENV UV_PROJECT_ENVIRONMENT=/dispatcharrpy +ENV VIRTUAL_ENV=/dispatcharrpy +ENV PATH="$VIRTUAL_ENV/bin:$PATH" +ENV UV_COMPILE_BYTECODE=1 +ENV UV_LINK_MODE=copy + +# --- Install Python 3.13 and build dependencies --- +# Note: Hardware acceleration (VA-API, VDPAU, NVENC) already included in base ffmpeg image +RUN apt-get update && apt-get install --no-install-recommends -y \ + ca-certificates software-properties-common gnupg2 curl wget \ + && add-apt-repository ppa:deadsnakes/ppa \ + && apt-get update \ + && apt-get install --no-install-recommends -y \ + python3.13 python3.13-dev python3.13-venv libpython3.13 \ + libpcre3 libpcre3-dev libpq-dev procps pciutils \ + nginx comskip \ + vlc-bin vlc-plugin-base \ + build-essential gcc g++ gfortran libopenblas-dev libopenblas0 ninja-build + +# --- Install UV --- +COPY --from=ghcr.io/astral-sh/uv:latest /uv /uvx /bin/ + +# --- Create Python virtual environment and install dependencies --- +WORKDIR /tmp/build +COPY pyproject.toml /tmp/build/ +COPY version.py /tmp/build/ +COPY README.md /tmp/build/ +RUN uv sync --python 3.13 --no-cache --no-install-project --no-dev && \ + rm -rf /tmp/build +WORKDIR / + +# --- Build legacy NumPy wheel for old hardware (store for runtime switching) --- +RUN uv pip install --python $UV_PROJECT_ENVIRONMENT/bin/python --no-cache build pip && \ + cd /tmp && \ + $UV_PROJECT_ENVIRONMENT/bin/python -m pip download --no-binary numpy --no-deps numpy && \ + tar -xzf numpy-*.tar.gz && \ + cd numpy-*/ && \ + $UV_PROJECT_ENVIRONMENT/bin/python -m build --wheel -Csetup-args=-Dcpu-baseline="none" -Csetup-args=-Dcpu-dispatch="none" && \ + mv dist/*.whl /opt/ && \ + cd / && rm -rf /tmp/numpy-* /tmp/*.tar.gz && \ + uv pip uninstall --python $UV_PROJECT_ENVIRONMENT/bin/python build pip + +# --- Clean up build dependencies to reduce image size --- +RUN apt-get remove -y build-essential gcc g++ gfortran libopenblas-dev libpcre3-dev python3.13-dev ninja-build && \ + apt-get autoremove -y --purge && \ + apt-get clean && \ + rm -rf /var/lib/apt/lists/* /root/.cache /tmp/* + +# --- Set up Redis 7.x --- +RUN curl -fsSL https://packages.redis.io/gpg | gpg --dearmor -o /usr/share/keyrings/redis-archive-keyring.gpg && \ + echo "deb [signed-by=/usr/share/keyrings/redis-archive-keyring.gpg] https://packages.redis.io/deb $(lsb_release -cs) main" | \ + tee /etc/apt/sources.list.d/redis.list && \ + apt-get update && apt-get install -y redis-server && \ + apt-get clean && rm -rf /var/lib/apt/lists/* + +# --- Set up PostgreSQL 17.x --- +RUN curl -fsSL https://www.postgresql.org/media/keys/ACCC4CF8.asc | gpg --dearmor -o /usr/share/keyrings/postgresql-keyring.gpg && \ + echo "deb [signed-by=/usr/share/keyrings/postgresql-keyring.gpg] http://apt.postgresql.org/pub/repos/apt $(lsb_release -cs)-pgdg main" | \ + tee /etc/apt/sources.list.d/pgdg.list && \ + apt-get update && apt-get install -y postgresql-17 postgresql-contrib-17 && \ + apt-get clean && rm -rf /var/lib/apt/lists/* + +# Create render group for hardware acceleration support with GID 109 +RUN groupadd -r -g 109 render || true + +ENTRYPOINT ["/app/docker/entrypoint.sh"] diff --git a/docker/Dockerfile b/docker/Dockerfile new file mode 100644 index 0000000..596c402 --- /dev/null +++ b/docker/Dockerfile @@ -0,0 +1,54 @@ +# Define base image build arguments (must be before any FROM) +ARG REPO_OWNER=dispatcharr +ARG REPO_NAME=dispatcharr +ARG BASE_TAG=base + +# --- Build frontend --- + +FROM node:24 AS frontend-builder + +WORKDIR /app/frontend +COPY ./frontend /app/frontend +# remove any node_modules that may have been copied from the host (x86) +RUN rm -rf node_modules || true; \ + npm install --no-audit --progress=false; +RUN npm run build && \ + rm -rf node_modules .cache + +# --- Redeclare build arguments for the next stage --- +ARG REPO_OWNER +ARG REPO_NAME +ARG BASE_TAG + +# --- Final image based on the dynamic base --- +FROM ghcr.io/${REPO_OWNER}/${REPO_NAME}:${BASE_TAG} AS final +ENV VIRTUAL_ENV=/dispatcharrpy +ENV PATH="$VIRTUAL_ENV/bin:$PATH" +WORKDIR /app + +# Copy application code +COPY . /app +# Copy nginx configuration +COPY ./docker/nginx.conf /etc/nginx/sites-enabled/default +# Fix line endings and make entrypoint scripts executable +RUN for f in /app/docker/entrypoint*.sh; do \ + if [ -f "$f" ]; then \ + sed -i 's/\r$//' "$f" && chmod +x "$f"; \ + fi; \ + done +# Clean out existing frontend folder +RUN rm -rf /app/frontend +# Copy built frontend assets +COPY --from=frontend-builder /app/frontend/dist /app/frontend/dist + +# Add timestamp argument +ARG TIMESTAMP + +# Update version.py with build timestamp if provided +RUN if [ -n "$TIMESTAMP" ]; then \ + echo "Updating timestamp to ${TIMESTAMP} in version.py" && \ + sed -i "s|__timestamp__ = None.*|__timestamp__ = '${TIMESTAMP}' # Set during CI/CD build process|" /app/version.py && \ + cat /app/version.py; \ + fi + +ENTRYPOINT ["/app/docker/entrypoint.sh"] \ No newline at end of file diff --git a/docker/build-dev.sh b/docker/build-dev.sh new file mode 100755 index 0000000..703e06c --- /dev/null +++ b/docker/build-dev.sh @@ -0,0 +1,69 @@ +#!/bin/bash +set -e + +# Default values +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +ROOT_DIR="$(cd "${SCRIPT_DIR}/.." && pwd)" +VERSION=$(python3 -c "import sys; sys.path.append('${ROOT_DIR}'); import version; print(version.__version__)") +REGISTRY="dispatcharr" # Registry or private repo to push to +IMAGE="dispatcharr" # Image that we're building +BRANCH="dev" +ARCH="" # Architectures to build for, e.g. linux/amd64,linux/arm64 +PUSH=false + +usage() { + cat <<-EOF + To test locally: + ./build-dev.sh + + To build and push to registry: + ./build-dev.sh -p + + To build and push to a private registry: + ./build-dev.sh -p -r myregistry:5000 + + To build for -both- x86_64 and arm_64: + ./build-dev.sh -p -a linux/amd64,linux/arm64 + + Do it all: + ./build-dev.sh -p -r myregistry:5000 -a linux/amd64,linux/arm64 + EOF + exit 0 +} + +# Parse options +while getopts "pr:a:b:i:h" opt; do + case $opt in + r) REGISTRY="$OPTARG" ;; + a) ARCH="$OPTARG" ;; + b) BRANCH="$OPTARG" ;; + i) IMAGE="$OPTARG" ;; + p) PUSH=true ;; + h) usage ;; + \?) + echo "Invalid option: -$OPTARG" >&2 + exit 1 + ;; + esac +done + +BUILD_ARGS="BRANCH=$BRANCH" +ARCH_ARGS=() +if [ -n "$ARCH" ]; then + ARCH_ARGS=(--platform "$ARCH") +fi + +echo docker build --build-arg "$BUILD_ARGS" "${ARCH_ARGS[@]}" -t "$IMAGE" +docker build -f "${SCRIPT_DIR}/Dockerfile" --build-arg "$BUILD_ARGS" "${ARCH_ARGS[@]}" -t "$IMAGE" "$ROOT_DIR" +docker tag "$IMAGE" "$IMAGE":"$BRANCH" +docker tag "$IMAGE" "$IMAGE":"$VERSION" + +if [ "$PUSH" = "true" ]; then + for TAG in latest "$VERSION" "$BRANCH"; do + docker tag "$IMAGE" "$REGISTRY/$IMAGE:$TAG" + docker push -q "$REGISTRY/$IMAGE:$TAG" + done + echo "Images pushed successfully." +else + echo "Please run 'docker push $IMAGE:$BRANCH' and 'docker push $IMAGE:${VERSION}' when ready" +fi diff --git a/docker/comskip.ini b/docker/comskip.ini new file mode 100644 index 0000000..5dc94fd --- /dev/null +++ b/docker/comskip.ini @@ -0,0 +1,6 @@ +; Minimal default comskip config +edl_out=1 +output_edl=1 +verbose=0 +thread_count=0 + diff --git a/docker/docker-compose.aio.yml b/docker/docker-compose.aio.yml new file mode 100644 index 0000000..d24ee04 --- /dev/null +++ b/docker/docker-compose.aio.yml @@ -0,0 +1,46 @@ +services: + dispatcharr: + # build: + # context: . + # dockerfile: Dockerfile + image: ghcr.io/dispatcharr/dispatcharr:latest + restart: unless-stopped + container_name: dispatcharr + ports: + - 9191:9191 + volumes: + - dispatcharr_data:/data + environment: + - DISPATCHARR_ENV=aio + - REDIS_HOST=localhost + - CELERY_BROKER_URL=redis://localhost:6379/0 + - DISPATCHARR_LOG_LEVEL=info + # Legacy CPU Support (Optional) + # Uncomment to enable legacy NumPy build for older CPUs (circa 2009) + # that lack support for newer baseline CPU features + #- USE_LEGACY_NUMPY=true + # Process Priority Configuration (Optional) + # Lower values = higher priority. Range: -20 (highest) to 19 (lowest) + # Negative values require cap_add: SYS_NICE (uncomment below) + #- UWSGI_NICE_LEVEL=-5 # uWSGI/FFmpeg/Streaming (default: 0, recommended: -5 for high priority) + #- CELERY_NICE_LEVEL=5 # Celery/EPG/Background tasks (default: 5, low priority) + # + # Uncomment to enable high priority for streaming (required if UWSGI_NICE_LEVEL < 0) + #cap_add: + # - SYS_NICE + # Optional for hardware acceleration + #devices: + # - /dev/dri:/dev/dri # For Intel/AMD GPU acceleration (VA-API) + # Uncomment the following lines for NVIDIA GPU support + # NVidia GPU support (requires NVIDIA Container Toolkit) + #deploy: + # resources: + # reservations: + # devices: + # - driver: nvidia + # count: all + # capabilities: [gpu] + + +volumes: + dispatcharr_data: diff --git a/docker/docker-compose.debug.yml b/docker/docker-compose.debug.yml new file mode 100644 index 0000000..c576cfd --- /dev/null +++ b/docker/docker-compose.debug.yml @@ -0,0 +1,33 @@ +services: + dispatcharr: + # build: + # context: .. + # dockerfile: docker/Dockerfile.dev + image: ghcr.io/dispatcharr/dispatcharr:base + container_name: dispatcharr_debug + ports: + - 5656:5656 # API port + - 9193:9191 # Web UI port + - 8001:8001 # Socket port + - 5678:5678 # Debugging port + volumes: + - ../:/app + environment: + - DISPATCHARR_ENV=dev + - DISPATCHARR_DEBUG=true + - REDIS_HOST=localhost + - CELERY_BROKER_URL=redis://localhost:6379/0 + - DISPATCHARR_LOG_LEVEL=trace + # Legacy CPU Support (Optional) + # Uncomment to enable legacy NumPy build for older CPUs (circa 2009) + # that lack support for newer baseline CPU features + #- USE_LEGACY_NUMPY=true + # Process Priority Configuration (Optional) + # Lower values = higher priority. Range: -20 (highest) to 19 (lowest) + # Negative values require cap_add: SYS_NICE (uncomment below) + #- UWSGI_NICE_LEVEL=-5 # uWSGI/FFmpeg/Streaming (default: 0, recommended: -5 for high priority) + #- CELERY_NICE_LEVEL=5 # Celery/EPG/Background tasks (default: 5, low priority) + # + # Uncomment to enable high priority for streaming (required if UWSGI_NICE_LEVEL < 0) + #cap_add: + # - SYS_NICE diff --git a/docker/docker-compose.dev.yml b/docker/docker-compose.dev.yml new file mode 100644 index 0000000..e640e84 --- /dev/null +++ b/docker/docker-compose.dev.yml @@ -0,0 +1,57 @@ +services: + dispatcharr: + # build: + # context: .. + # dockerfile: docker/Dockerfile.dev + image: ghcr.io/dispatcharr/dispatcharr:base + container_name: dispatcharr_dev + restart: unless-stopped + ports: + - 5656:5656 + - 9191:9191 + - 8001:8001 + volumes: + - ../:/app + - ./data:/data + environment: + - DISPATCHARR_ENV=dev + - REDIS_HOST=localhost + - CELERY_BROKER_URL=redis://localhost:6379/0 + - DISPATCHARR_LOG_LEVEL=debug + # Legacy CPU Support (Optional) + # Uncomment to enable legacy NumPy build for older CPUs (circa 2009) + # that lack support for newer baseline CPU features + #- USE_LEGACY_NUMPY=true + # Process Priority Configuration (Optional) + # Lower values = higher priority. Range: -20 (highest) to 19 (lowest) + # Negative values require cap_add: SYS_NICE (uncomment below) + #- UWSGI_NICE_LEVEL=-5 # uWSGI/FFmpeg/Streaming (default: 0, recommended: -5 for high priority) + #- CELERY_NICE_LEVEL=5 # Celery/EPG/Background tasks (default: 5, low priority) + # + # Uncomment to enable high priority for streaming (required if UWSGI_NICE_LEVEL < 0) + #cap_add: + # - SYS_NICE + + pgadmin: + image: dpage/pgadmin4 + restart: unless-stopped + environment: + PGADMIN_DEFAULT_EMAIL: admin@admin.com + PGADMIN_DEFAULT_PASSWORD: admin + volumes: + - dispatcharr_dev_pgadmin:/var/lib/pgadmin + ports: + - 8082:80 + + redis-commander: + image: rediscommander/redis-commander:latest + restart: unless-stopped + environment: + - REDIS_HOSTS=dispatcharr:dispatcharr:6379:0 + - TRUST_PROXY=true + - ADDRESS=0.0.0.0 + ports: + - 8081:8081 + +volumes: + dispatcharr_dev_pgadmin: diff --git a/docker/docker-compose.yml b/docker/docker-compose.yml new file mode 100644 index 0000000..547ac24 --- /dev/null +++ b/docker/docker-compose.yml @@ -0,0 +1,229 @@ +# Dispatcharr - Modular Deployment Configuration +# This compose file runs Dispatcharr in modular mode with separate containers +# for web, celery workers, PostgreSQL database, and Redis cache. + +services: + # ============================================================================ + # Web Service + # ============================================================================ + web: + image: ghcr.io/dispatcharr/dispatcharr:latest + container_name: dispatcharr_web + restart: unless-stopped + ports: + - 9191:9191 + volumes: + - ./data:/data + #- ./certs:/certs:ro # TLS certificates (optional) + depends_on: + db: + condition: service_healthy + redis: + condition: service_healthy + extra_hosts: + - "host.docker.internal:host-gateway" + + # --- Environment Configuration --- + environment: + # Deployment Mode + - DISPATCHARR_ENV=modular + + # PostgreSQL Connection + - POSTGRES_HOST=db + - POSTGRES_PORT=5432 + - POSTGRES_DB=dispatcharr + - POSTGRES_USER=dispatch + - POSTGRES_PASSWORD=secret + # PostgreSQL TLS (optional) — mount certs via the volume above + #- POSTGRES_SSL=true # required to enable TLS + #- POSTGRES_SSL_MODE=verify-full # optional: verify-full (default) | verify-ca | require + #- POSTGRES_SSL_CA_CERT=/certs/postgres/ca.crt # optional: CA cert to verify the server + #- POSTGRES_SSL_CERT=/certs/postgres/client.crt # optional: client cert (only if server requires client auth) + #- POSTGRES_SSL_KEY=/certs/postgres/client.key # optional: client key (only if server requires client auth) + + # Redis Connection + - REDIS_HOST=redis + - REDIS_PORT=6379 + # Redis Authentication (Optional) + # Uncomment and set if your Redis requires authentication: + #- REDIS_PASSWORD=your_strong_redis_password + #- REDIS_USER=your_redis_username # For Redis 6+ ACL - see Redis service below + # Redis TLS (optional) — mount certs via the volume above + #- REDIS_SSL=true # required to enable TLS + #- REDIS_SSL_VERIFY=true # optional: set false for self-signed certs without a CA + #- REDIS_SSL_CA_CERT=/certs/redis/ca.crt # optional: CA cert to verify the server + #- REDIS_SSL_CERT=/certs/redis/client.crt # optional: client cert (only if server requires client auth) + #- REDIS_SSL_KEY=/certs/redis/client.key # optional: client key (only if server requires client auth) + + # Logging + - DISPATCHARR_LOG_LEVEL=info + + # Legacy CPU Support (Optional) + # Uncomment to enable legacy NumPy build for older CPUs (circa 2009) + # that lack support for newer baseline CPU features: + #- USE_LEGACY_NUMPY=true + + # Process Priority Configuration (Optional) + # Lower values = higher priority. Range: -20 (highest) to 19 (lowest) + # Negative values require cap_add: SYS_NICE (see below) + #- UWSGI_NICE_LEVEL=-5 # uWSGI/FFmpeg/Streaming (default: 0, recommended: -5 for high priority) + + # --- Advanced Configuration --- + # Uncomment to enable high priority for streaming (required if UWSGI_NICE_LEVEL < 0) + #cap_add: + # - SYS_NICE + + # --- Hardware Acceleration (Optional) --- + # Uncomment for GPU access (transcoding acceleration) + #group_add: + # - video + # #- render # Uncomment if your GPU requires it + #devices: + # - /dev/dri:/dev/dri # For Intel/AMD GPU acceleration (VA-API) + + # NVIDIA GPU Support (requires NVIDIA Container Toolkit) + # Uncomment the following lines for NVIDIA GPU support + #deploy: + # resources: + # reservations: + # devices: + # - driver: nvidia + # count: all + # capabilities: [gpu] + + # ============================================================================ + # Celery Service - Background task worker + # ============================================================================ + celery: + image: ghcr.io/dispatcharr/dispatcharr:latest + container_name: dispatcharr_celery + restart: unless-stopped + depends_on: + db: + condition: service_healthy + redis: + condition: service_healthy + web: + condition: service_started + volumes: + - ./data:/data + #- ./certs:/certs:ro # TLS certificates (optional) + extra_hosts: + - "host.docker.internal:host-gateway" + entrypoint: ["/app/docker/entrypoint.celery.sh"] + + # --- Environment Configuration --- + environment: + # Deployment Mode + - DISPATCHARR_ENV=modular + + # Internal Service Communication + # Must match the web service port for DVR recording and internal API calls + - DISPATCHARR_PORT=9191 + + # PostgreSQL — must match web service settings + - POSTGRES_HOST=db + - POSTGRES_PORT=5432 + - POSTGRES_DB=dispatcharr + - POSTGRES_USER=dispatch + - POSTGRES_PASSWORD=secret + # PostgreSQL TLS — must match web service + #- POSTGRES_SSL=true + #- POSTGRES_SSL_MODE=verify-full + #- POSTGRES_SSL_CA_CERT=/certs/postgres/ca.crt + #- POSTGRES_SSL_CERT=/certs/postgres/client.crt + #- POSTGRES_SSL_KEY=/certs/postgres/client.key + + # Redis — must match web service settings + - REDIS_HOST=redis + - REDIS_PORT=6379 + #- REDIS_PASSWORD=your_strong_redis_password + #- REDIS_USER=your_redis_username + # Redis TLS — must match web service + #- REDIS_SSL=true + #- REDIS_SSL_VERIFY=true + #- REDIS_SSL_CA_CERT=/certs/redis/ca.crt + #- REDIS_SSL_CERT=/certs/redis/client.crt + #- REDIS_SSL_KEY=/certs/redis/client.key + + # Logging + - DISPATCHARR_LOG_LEVEL=info + + # Process Priority Configuration (Optional) + #- CELERY_NICE_LEVEL=5 # Celery/EPG/Background tasks (default: 5, low priority; Range: -20 to 19) + + # Legacy CPU Support (Optional) + # Uncomment to enable legacy NumPy build for older CPUs (circa 2009) + # that lack support for newer baseline CPU features: + #- USE_LEGACY_NUMPY=true + + # Django Configuration + - DJANGO_SETTINGS_MODULE=dispatcharr.settings + - PYTHONUNBUFFERED=1 + + # --- Advanced Configuration --- + # Uncomment to enable high priority for Celery (required if CELERY_NICE_LEVEL < 0) + #cap_add: + # - SYS_NICE + + # ============================================================================ + # PostgreSQL + # ============================================================================ + db: + image: postgres:17 + container_name: dispatcharr_db + restart: unless-stopped + ports: + - "5436:5432" + environment: + - POSTGRES_DB=dispatcharr + - POSTGRES_USER=dispatch + - POSTGRES_PASSWORD=secret + volumes: + - postgres_data:/var/lib/postgresql/data + healthcheck: + test: ["CMD-SHELL", "pg_isready -U dispatch -d dispatcharr"] + interval: 5s + timeout: 5s + retries: 5 + + # ============================================================================ + # Redis + # ============================================================================ + redis: + image: redis:latest + container_name: dispatcharr_redis + restart: unless-stopped + + # --- Authentication Configuration (Optional) --- + # By default, Redis runs without authentication. + # Choose ONE of the following options if authentication is required: + + # Option 1: Password-only authentication (Redis <6 or default user) + #command: ["redis-server", "--requirepass", "your_strong_redis_password"] + + # Option 2: Redis 6+ ACL with username + password (requires custom config file - see Redis documentation for configuration) + #command: ["redis-server", "/etc/redis/redis.conf"] + #volumes: + # - ./redis.conf:/etc/redis/redis.conf:ro + + # --- Health Check Configuration --- + healthcheck: + # Default: No authentication + test: ["CMD", "redis-cli", "ping"] + + # If using Option 1 (password-only), uncomment this instead: + #test: ["CMD", "redis-cli", "-a", "your_strong_redis_password", "ping"] + + # If using Option 2 (Redis 6+ ACL), uncomment this instead: + #test: ["CMD", "redis-cli", "--user", "your_redis_username", "-a", "your_strong_redis_password", "ping"] + + interval: 5s + timeout: 5s + retries: 5 + +# ============================================================================== +# Volumes +# ============================================================================== +volumes: + postgres_data: diff --git a/docker/entrypoint.aio.sh b/docker/entrypoint.aio.sh new file mode 100755 index 0000000..244df3a --- /dev/null +++ b/docker/entrypoint.aio.sh @@ -0,0 +1,105 @@ +#!/bin/bash + +# Run Django migrations and collect static files +python manage.py collectstatic --noinput +python manage.py migrate --noinput + +# Function to clean up only running processes +cleanup() { + echo "🔥 Cleanup triggered! Stopping services..." + for pid in "${pids[@]}"; do + if [ -n "$pid" ] && kill -0 "$pid" 2>/dev/null; then + echo "⛔ Stopping process (PID: $pid)..." + kill -TERM "$pid" 2>/dev/null + else + echo "✅ Process (PID: $pid) already stopped." + fi + done + wait +} + +# Catch termination signals (CTRL+C, Docker Stop, etc.) +trap cleanup TERM INT + +# Initialize an array to store PIDs +pids=() + +GUNICORN_PORT=9191 + +# If running in development mode, install and start frontend +if [ "$DISPATCHARR_ENV" = "dev" ]; then + echo "🚀 Development Mode - Setting up Frontend..." + GUNICORN_PORT=5656 + + # Install Node.js + apt-get update && apt-get install -y nodejs + + # Install frontend dependencies + cd /app/frontend && npm install + cd /app + + # Start React development server + echo "🚀 Starting React Dev Server..." + cd /app/frontend + PORT=9191 ./node_modules/pm2/bin/pm2 --name test start npm -- start + ./node_modules/pm2/bin/pm2 logs & + react_pid=$! + echo "✅ React started with PID $react_pid" + pids+=("$react_pid") + cd /app +fi + +# If running in `dev` or `aio`, start Redis and Celery +if [ "$DISPATCHARR_ENV" = "dev" ] || [ "$DISPATCHARR_ENV" = "aio" ]; then + echo "🚀 Running Redis and Celery for '$DISPATCHARR_ENV'..." + + # Start Redis + echo "🚀 Starting Redis..." + redis-server --daemonize no & + sleep 1 # Give Redis time to start + redis_pid=$(pgrep -x redis-server) + if [ -n "$redis_pid" ]; then + echo "✅ Redis started with PID $redis_pid" + pids+=("$redis_pid") + else + echo "❌ Redis failed to start!" + fi + + # Start Celery + echo "🚀 Starting Celery..." + celery -A dispatcharr worker -l info & + celery_pid=$! + echo "✅ Celery started with PID $celery_pid" + pids+=("$celery_pid") +fi + +# Always start Gunicorn +echo "🚀 Starting Gunicorn..." +gunicorn --workers=4 --worker-class=gevent --timeout=300 --bind 0.0.0.0:${GUNICORN_PORT} dispatcharr.wsgi:application & +gunicorn_pid=$! +echo "✅ Gunicorn started with PID $gunicorn_pid" +pids+=("$gunicorn_pid") + +# Log PIDs +echo "📝 Process PIDs: ${pids[*]}" + +# Wait for at least one process to exit and log the process that exited first +if [ ${#pids[@]} -gt 0 ]; then + echo "⏳ Waiting for processes to exit..." + ps -aux | grep -E 'redis-server|celery|gunicorn|npm' + wait -n "${pids[@]}" + echo "🚨 One of the processes exited! Checking which one..." + + for pid in "${pids[@]}"; do + if ! kill -0 "$pid" 2>/dev/null; then + process_name=$(ps -p "$pid" -o comm=) + echo "❌ Process $process_name (PID: $pid) has exited!" + fi + done +else + echo "❌ No processes started. Exiting." + exit 1 +fi + +# Cleanup and stop remaining processes +cleanup diff --git a/docker/entrypoint.celery.sh b/docker/entrypoint.celery.sh new file mode 100644 index 0000000..b38b6ac --- /dev/null +++ b/docker/entrypoint.celery.sh @@ -0,0 +1,70 @@ +#!/bin/bash +set -e + +cd /app +source /dispatcharrpy/bin/activate + +# Function to echo with timestamp +echo_with_timestamp() { + echo "$(date '+%Y-%m-%d %H:%M:%S') - $1" +} + +# Wait for Django secret key (generated by the web container on startup) +JWT_TIMEOUT=120 +JWT_WAITED=0 +echo 'Waiting for Django secret key...' +while [ ! -f /data/jwt ]; do + if [ $JWT_WAITED -ge $JWT_TIMEOUT ]; then + echo "❌ ERROR: Timed out waiting for /data/jwt after ${JWT_TIMEOUT}s." + echo " Is the web container running? Does it have the /data volume mounted?" + exit 1 + fi + sleep 1 + JWT_WAITED=$((JWT_WAITED + 1)) +done +export DJANGO_SECRET_KEY="$(tr -d '\r\n' < /data/jwt)" + +# --- NumPy version switching for legacy hardware --- +if [ "$USE_LEGACY_NUMPY" = "true" ]; then + # Check if NumPy was compiled with baseline support + if "$VIRTUAL_ENV/bin/python" -c "import numpy; numpy.show_config()" 2>&1 | grep -qi "baseline" || [ $? -ne 0 ]; then + echo_with_timestamp "🔧 Switching to legacy NumPy (no CPU baseline)..." + uv pip install --python "$VIRTUAL_ENV/bin/python" --no-cache --force-reinstall --no-deps /opt/numpy-*.whl + echo_with_timestamp "✅ Legacy NumPy installed" + else + echo_with_timestamp "✅ Legacy NumPy (no baseline) already installed, skipping reinstallation" + fi +fi + +# Fix TLS client key permissions/ownership for PostgreSQL. +FIXED_KEY_PATH="/data/.pg-client-celery.key" +. /app/docker/init/00-fix-pg-ssl-key.sh + +# Wait for migrations to complete +# Uses 'migrate --check' which exits 0 only when all migrations are applied, +# and exits 1 on unapplied migrations OR connection errors (safe either way) +MIG_TIMEOUT=300 +MIG_WAITED=0 +echo 'Waiting for migrations to complete...' +until python manage.py migrate --check >/dev/null 2>&1; do + if [ $MIG_WAITED -ge $MIG_TIMEOUT ]; then + echo "❌ ERROR: Timed out waiting for migrations after ${MIG_TIMEOUT}s." + echo " Check web container logs for migration errors." + exit 1 + fi + echo_with_timestamp 'Migrations not ready yet, waiting...' + sleep 2 + MIG_WAITED=$((MIG_WAITED + 2)) +done + +# Start Celery +echo 'Migrations complete, starting Celery...' +celery -A dispatcharr beat -l info & + +# Default to nice level 5 (lower priority) - safe for unprivileged containers +# Negative values require SYS_NICE capability +NICE_LEVEL="${CELERY_NICE_LEVEL:-5}" +if [ "$NICE_LEVEL" -lt 0 ] 2>/dev/null; then + echo "Warning: CELERY_NICE_LEVEL=$NICE_LEVEL is negative, requires SYS_NICE capability" +fi +nice -n "$NICE_LEVEL" celery -A dispatcharr worker -l info --autoscale=6,1 diff --git a/docker/entrypoint.sh b/docker/entrypoint.sh new file mode 100755 index 0000000..e543365 --- /dev/null +++ b/docker/entrypoint.sh @@ -0,0 +1,396 @@ +#!/bin/bash + +set -e # Exit immediately if a command exits with a non-zero status + +# Guard flag to prevent cleanup running twice (trap + explicit call) +_cleanup_done=false + +# Function to clean up only running processes +cleanup() { + if $_cleanup_done; then return; fi + _cleanup_done=true + set +e # Disable exit-on-error so cleanup always runs fully + echo "🔥 Cleanup triggered! Stopping services..." + + # Explicitly stop uwsgi workers - children of 'su' wrapper, not tracked in pids[] + echo "⛔ Stopping uwsgi workers..." + pkill -TERM -f uwsgi 2>/dev/null || true + + # Stop celery, daphne, redis - also not tracked in pids[] + echo "⛔ Stopping celery, daphne, redis..." + pkill -TERM -f "celery" 2>/dev/null || true + pkill -TERM -f "daphne" 2>/dev/null || true + pkill -TERM -f "redis-server" 2>/dev/null || true + + # Stop tracked processes (postgres, nginx, su/uwsgi wrapper) + for pid in "${pids[@]}"; do + if [ -n "$pid" ] && kill -0 "$pid" 2>/dev/null; then + echo "⛔ Stopping process (PID: $pid)..." + kill -TERM "$pid" 2>/dev/null + else + echo "✅ Process (PID: $pid) already stopped." + fi + done + + # Wait up to 8 s for graceful shutdown, exit early once all are gone + # (leaves headroom within Docker's default 10 s stop_grace_period) + _shutdown_timeout=8 + _shutdown_elapsed=0 + while [ "$_shutdown_elapsed" -lt "$_shutdown_timeout" ]; do + pgrep -f "uwsgi|celery|daphne|redis-server|postgres" >/dev/null 2>&1 || break + sleep 1 + _shutdown_elapsed=$((_shutdown_elapsed + 1)) + done + + # Force kill anything still lingering + pkill -KILL -f uwsgi 2>/dev/null || true + pkill -KILL -f "celery" 2>/dev/null || true + pkill -KILL -f "daphne" 2>/dev/null || true + pkill -KILL -f "redis-server" 2>/dev/null || true + # Use pg_ctl immediate stop rather than SIGKILL. Avoids data corruption + # while still forcing a fast exit (crash recovery runs on next startup) + if pgrep -f "postgres" >/dev/null 2>&1; then + su - "$POSTGRES_USER" -c "$PG_BINDIR/pg_ctl -D ${POSTGRES_DIR} stop -m immediate" 2>/dev/null || true + fi + + wait + echo "✅ All processes stopped cleanly." +} + +# Catch termination signals (CTRL+C, Docker Stop, etc.) +trap cleanup TERM INT + +# Initialize an array to store PIDs and a map of PID->name +pids=() +declare -A pid_names + +# Function to echo with timestamp +echo_with_timestamp() { + echo "$(date '+%Y-%m-%d %H:%M:%S') - $1" +} + +# Set PostgreSQL environment variables +export POSTGRES_DB=${POSTGRES_DB:-dispatcharr} +export POSTGRES_USER=${POSTGRES_USER:-dispatch} +# AIO mode: default to 'secret' for internal DB. +# Modular mode + TLS: no default — cert-only auth (mTLS) uses no password. +# Modular mode + no TLS: preserve 'secret' default for backward compatibility. +if [[ "${DISPATCHARR_ENV:-}" == "modular" && "${POSTGRES_SSL:-}" == "true" ]]; then + export POSTGRES_PASSWORD="${POSTGRES_PASSWORD:-}" +else + export POSTGRES_PASSWORD="${POSTGRES_PASSWORD:-secret}" +fi +export DISPATCHARR_ENV=${DISPATCHARR_ENV:-aio} +if [[ "$DISPATCHARR_ENV" == "aio" ]]; then + # Use Unix socket for loopback values (unset, localhost, 127.0.0.1) + if [[ -z "$POSTGRES_HOST" || "$POSTGRES_HOST" == "localhost" || "$POSTGRES_HOST" == "127.0.0.1" ]]; then + export POSTGRES_HOST=/var/run/postgresql + fi +else + export POSTGRES_HOST=${POSTGRES_HOST:-localhost} +fi +export POSTGRES_PORT=${POSTGRES_PORT:-5432} +export PG_VERSION=$(ls /usr/lib/postgresql/ | sort -V | tail -n 1) +export PG_BINDIR="/usr/lib/postgresql/${PG_VERSION}/bin" +export REDIS_HOST=${REDIS_HOST:-localhost} +export REDIS_PORT=${REDIS_PORT:-6379} +export REDIS_DB=${REDIS_DB:-0} +export REDIS_PASSWORD=${REDIS_PASSWORD:-} +export REDIS_USER=${REDIS_USER:-} +export DISPATCHARR_PORT=${DISPATCHARR_PORT:-9191} +export LIBVA_DRIVERS_PATH='/usr/local/lib/x86_64-linux-gnu/dri' +export LD_LIBRARY_PATH='/usr/local/lib' +export SECRET_FILE="/data/jwt" +# Ensure Django secret key exists or generate a new one +if [ ! -f "$SECRET_FILE" ]; then + echo "Generating new Django secret key..." + old_umask=$(umask) + umask 077 + tmpfile="$(mktemp "${SECRET_FILE}.XXXXXX")" || { echo "mktemp failed"; exit 1; } + python3 - <<'PY' >"$tmpfile" || { echo "secret generation failed"; rm -f "$tmpfile"; exit 1; } +import secrets +print(secrets.token_urlsafe(64)) +PY + mv -f "$tmpfile" "$SECRET_FILE" || { echo "move failed"; rm -f "$tmpfile"; exit 1; } + umask $old_umask +fi +export DJANGO_SECRET_KEY="$(tr -d '\r\n' < "$SECRET_FILE")" + +# Process priority configuration +# UWSGI_NICE_LEVEL: Absolute nice value for uWSGI/streaming (default: 0 = normal priority) +# CELERY_NICE_LEVEL: Absolute nice value for Celery/background tasks (default: 5 = low priority) +# Note: The script will automatically calculate the relative offset for Celery since it's spawned by uWSGI +export UWSGI_NICE_LEVEL=${UWSGI_NICE_LEVEL:-0} +CELERY_NICE_ABSOLUTE=${CELERY_NICE_LEVEL:-5} + +# Calculate relative nice value for Celery (since nice is relative to parent process) +# Celery is spawned by uWSGI, so we need to add the offset to reach the desired absolute value +export CELERY_NICE_LEVEL=$((CELERY_NICE_ABSOLUTE - UWSGI_NICE_LEVEL)) + +# Set LIBVA_DRIVER_NAME if user has specified it +if [ -v LIBVA_DRIVER_NAME ]; then + export LIBVA_DRIVER_NAME +fi +# Extract version information from version.py +export DISPATCHARR_VERSION=$(python -c "import sys; sys.path.append('/app'); import version; print(version.__version__)") +export DISPATCHARR_TIMESTAMP=$(python -c "import sys; sys.path.append('/app'); import version; print(version.__timestamp__ or '')") + +# Display version information with timestamp if available +if [ -n "$DISPATCHARR_TIMESTAMP" ]; then + echo "📦 Dispatcharr version: ${DISPATCHARR_VERSION} (build: ${DISPATCHARR_TIMESTAMP})" +else + echo "📦 Dispatcharr version: ${DISPATCHARR_VERSION}" +fi +export DISPATCHARR_LOG_LEVEL +# Set log level with default if not provided +DISPATCHARR_LOG_LEVEL=${DISPATCHARR_LOG_LEVEL:-INFO} +# Convert to uppercase +DISPATCHARR_LOG_LEVEL=${DISPATCHARR_LOG_LEVEL^^} + + +echo "Environment DISPATCHARR_LOG_LEVEL set to: '${DISPATCHARR_LOG_LEVEL}'" + +# Also make the log level available in /etc/environment for all login shells +#grep -q "DISPATCHARR_LOG_LEVEL" /etc/environment || echo "DISPATCHARR_LOG_LEVEL=${DISPATCHARR_LOG_LEVEL}" >> /etc/environment + +# Translate Dispatcharr POSTGRES_SSL_* env vars into libpq-recognized PGSSL* +# env vars. Called once before any external PostgreSQL connection; all child +# processes (psql, pg_dump, pg_isready, createdb, dropdb) inherit these +# automatically. No-op when POSTGRES_SSL is not "true". +setup_pg_ssl_env() { + if [ "${POSTGRES_SSL:-false}" != "true" ]; then + return 0 + fi + export PGSSLMODE="${POSTGRES_SSL_MODE:-verify-full}" + if [ -n "${POSTGRES_SSL_CA_CERT:-}" ]; then export PGSSLROOTCERT="$POSTGRES_SSL_CA_CERT"; fi + if [ -n "${POSTGRES_SSL_CERT:-}" ]; then export PGSSLCERT="$POSTGRES_SSL_CERT"; fi + if [ -n "${POSTGRES_SSL_KEY:-}" ]; then export PGSSLKEY="$POSTGRES_SSL_KEY"; fi +} + +# READ-ONLY - don't let users change these +export POSTGRES_DIR=/data/db + +# Global variables, stored so other users inherit them. +# Rewritten every startup so that container restarts with changed env vars +# pick up the new values (not stale ones from a previous run). +# Define all variables to process +variables=( + PATH VIRTUAL_ENV DJANGO_SETTINGS_MODULE PYTHONUNBUFFERED PYTHONDONTWRITEBYTECODE + POSTGRES_DB POSTGRES_USER POSTGRES_PASSWORD POSTGRES_HOST POSTGRES_PORT + DISPATCHARR_ENV DISPATCHARR_DEBUG DISPATCHARR_LOG_LEVEL + REDIS_HOST REDIS_PORT REDIS_DB REDIS_PASSWORD REDIS_USER POSTGRES_DIR DISPATCHARR_PORT + DISPATCHARR_VERSION DISPATCHARR_TIMESTAMP LIBVA_DRIVERS_PATH LIBVA_DRIVER_NAME LD_LIBRARY_PATH + CELERY_NICE_LEVEL UWSGI_NICE_LEVEL DJANGO_SECRET_KEY +) + +# TLS variables are optional — only propagate when set to avoid noisy warnings +for _tls_var in POSTGRES_SSL POSTGRES_SSL_MODE POSTGRES_SSL_CA_CERT POSTGRES_SSL_CERT POSTGRES_SSL_KEY \ + REDIS_SSL REDIS_SSL_VERIFY REDIS_SSL_CA_CERT REDIS_SSL_CERT REDIS_SSL_KEY; do + if [ -n "${!_tls_var+x}" ]; then + variables+=("$_tls_var") + fi +done + +# Truncate files before rewriting +> /etc/profile.d/dispatcharr.sh + +# Process each variable for both profile.d and environment +for var in "${variables[@]}"; do + # Check if the variable is set in the environment + if [ -n "${!var+x}" ]; then + # Add to profile.d (quoted to handle special characters in values) + echo "export ${var}='${!var}'" >> /etc/profile.d/dispatcharr.sh + # Add/update in /etc/environment + sed -i "/^${var}=/d" /etc/environment + echo "${var}='${!var}'" >> /etc/environment + else + echo "Warning: Environment variable $var is not set" + fi +done + +chmod +x /etc/profile.d/dispatcharr.sh + +# Ensure root's .bashrc sources the profile.d scripts for interactive non-login shells +if ! grep -q "profile.d/dispatcharr.sh" /root/.bashrc 2>/dev/null; then + cat >> /root/.bashrc << 'EOF' + +# Source Dispatcharr environment variables +if [ -f /etc/profile.d/dispatcharr.sh ]; then + . /etc/profile.d/dispatcharr.sh +fi +EOF +fi + +# Run init scripts +echo "Starting user setup..." +. /app/docker/init/01-user-setup.sh + +# Fix TLS client key permissions/ownership BEFORE any external PG connections. +# Must run after 01-user-setup.sh (user exists for chown) and before +# 02-postgres.sh / pg_isready (which make the first external PG connections). +FIXED_KEY_PATH="/data/.pg-client.key" +. /app/docker/init/00-fix-pg-ssl-key.sh +# Propagate the fixed path to login shells (su - strips env vars) +if [ "${POSTGRES_SSL_KEY:-}" = "$FIXED_KEY_PATH" ]; then + sed -i "/^POSTGRES_SSL_KEY=/d" /etc/environment + echo "POSTGRES_SSL_KEY='$FIXED_KEY_PATH'" >> /etc/environment + sed -i "s|export POSTGRES_SSL_KEY=.*|export POSTGRES_SSL_KEY='$FIXED_KEY_PATH'|" /etc/profile.d/dispatcharr.sh +fi + +# Export libpq TLS env vars so all subsequent psql/pg_dump/pg_isready calls +# (in 02-postgres.sh, modular-mode checks, etc.) use TLS automatically. +setup_pg_ssl_env + +# Initialize PostgreSQL (script handles modular vs internal mode internally) +echo "Setting up PostgreSQL..." +. /app/docker/init/02-postgres.sh + +echo "Starting init process..." +. /app/docker/init/03-init-dispatcharr.sh + +# Start PostgreSQL if NOT in modular mode (using external database) +if [[ "$DISPATCHARR_ENV" != "modular" ]]; then + echo "Starting Postgres..." + prepare_pg_socket_dir + su - "$POSTGRES_USER" -c "$PG_BINDIR/pg_ctl -D ${POSTGRES_DIR} start -w -t 300 -o '-c port=${POSTGRES_PORT}'" + # Wait for PostgreSQL to be ready + until su - "$POSTGRES_USER" -c "$PG_BINDIR/pg_isready -h ${POSTGRES_HOST} -p ${POSTGRES_PORT}" >/dev/null 2>&1; do + echo_with_timestamp "Waiting for PostgreSQL to be ready..." + sleep 1 + done + postgres_pid=$(su - "$POSTGRES_USER" -c "$PG_BINDIR/pg_ctl -D ${POSTGRES_DIR} status" | sed -n 's/.*PID: \([0-9]\+\).*/\1/p') + echo "✅ Postgres started with PID $postgres_pid" + if [ -n "$postgres_pid" ]; then pids+=("$postgres_pid"); pid_names[$postgres_pid]="postgres"; fi + + # Unconditional startup guarantees — run on every AIO startup. + # Each is idempotent and handles all scenarios (fresh, upgrade, restart). + promote_app_role + ensure_app_database +else + echo "🔗 Modular mode: Using external PostgreSQL at ${POSTGRES_HOST}:${POSTGRES_PORT}" + # Wait for external PostgreSQL to be ready using pg_isready (checks actual protocol readiness) + echo_with_timestamp "Waiting for external PostgreSQL to be ready..." + until $PG_BINDIR/pg_isready -h "${POSTGRES_HOST}" -p "${POSTGRES_PORT}" -q >/dev/null 2>&1; do + echo_with_timestamp "Waiting for PostgreSQL at ${POSTGRES_HOST}:${POSTGRES_PORT}..." + sleep 1 + done + echo "✅ External PostgreSQL is ready" + + # Check PostgreSQL version compatibility + check_external_postgres_version || exit 1 +fi + +# Wait for Redis to be ready and flush stale state. +# In modular mode Redis is external — call wait_for_redis.py here +# because uWSGI's exec-pre runs under 'su -' which strips env vars +# (DISPATCHARR_ENV, REDIS_HOST, etc.). +# In AIO mode Redis is started by uWSGI (attach-daemon), so the +# exec-pre in uwsgi.ini handles the wait + flush there instead. +if [[ "$DISPATCHARR_ENV" == "modular" ]]; then + echo "🔗 Modular mode: Using external Redis at ${REDIS_HOST}:${REDIS_PORT}" + echo_with_timestamp "Waiting for Redis to be ready..." + python3 /app/scripts/wait_for_redis.py + echo "✅ Redis is ready" +fi + +# Ensure database encoding is UTF8 (handles both internal and external databases) +ensure_utf8_encoding + +if [[ "$DISPATCHARR_ENV" = "dev" ]]; then + . /app/docker/init/99-init-dev.sh + echo "Starting frontend dev environment" + su - "$POSTGRES_USER" -c "cd /app/frontend && npm run dev &" + npm_pid=$(pgrep vite | sort | head -n1) + echo "✅ vite started with PID $npm_pid" + if [ -n "$npm_pid" ]; then pids+=("$npm_pid"); pid_names[$npm_pid]="vite"; fi +else + echo "🚀 Starting nginx..." + nginx + nginx_pid=$(pgrep nginx | sort | head -n1) + echo "✅ nginx started with PID $nginx_pid" + if [ -n "$nginx_pid" ]; then pids+=("$nginx_pid"); pid_names[$nginx_pid]="nginx"; fi +fi + + +# --- NumPy version switching for legacy hardware --- +if [ "$USE_LEGACY_NUMPY" = "true" ]; then + # Check if NumPy was compiled with baseline support + if "$VIRTUAL_ENV/bin/python" -c "import numpy; numpy.show_config()" 2>&1 | grep -qi "baseline" || [ $? -ne 0 ]; then + echo_with_timestamp "🔧 Switching to legacy NumPy (no CPU baseline)..." + uv pip install --python "$VIRTUAL_ENV/bin/python" --no-cache --force-reinstall --no-deps /opt/numpy-*.whl + echo_with_timestamp "✅ Legacy NumPy installed" + else + echo_with_timestamp "✅ Legacy NumPy (no baseline) already installed, skipping reinstallation" + fi +fi + +# Run Django commands as non-root user to prevent permission issues +su - "$POSTGRES_USER" -c "cd /app && python manage.py migrate --noinput" +su - "$POSTGRES_USER" -c "cd /app && python manage.py collectstatic --noinput" + +# Select proper uwsgi config based on environment +if [ "$DISPATCHARR_ENV" = "dev" ] && [ "$DISPATCHARR_DEBUG" != "true" ]; then + echo "🚀 Starting uwsgi in dev mode..." + uwsgi_file="/app/docker/uwsgi.dev.ini" +elif [ "$DISPATCHARR_DEBUG" = "true" ]; then + echo "🚀 Starting uwsgi in debug mode..." + uwsgi_file="/app/docker/uwsgi.debug.ini" +elif [ "$DISPATCHARR_ENV" = "modular" ]; then + echo "🚀 Starting uwsgi in modular mode..." + uwsgi_file="/app/docker/uwsgi.modular.ini" +else + echo "🚀 Starting uwsgi in production mode..." + uwsgi_file="/app/docker/uwsgi.ini" +fi + +# Set base uwsgi args +uwsgi_args="--ini $uwsgi_file" + +# Conditionally disable logging if not in debug mode +if [ "$DISPATCHARR_DEBUG" != "true" ]; then + uwsgi_args+=" --disable-logging" +fi + +# Launch uwsgi with configurable nice level (default: 0 for normal priority) +# Users can override via UWSGI_NICE_LEVEL environment variable in docker-compose +# Start with nice as root, then use setpriv to drop privileges to dispatch user +# This preserves both the nice value and environment variables +nice -n "$UWSGI_NICE_LEVEL" su - "$POSTGRES_USER" -c "cd /app && exec $VIRTUAL_ENV/bin/uwsgi $uwsgi_args" & uwsgi_pid=$! +echo "✅ uwsgi started with PID $uwsgi_pid (nice $UWSGI_NICE_LEVEL)" +pids+=("$uwsgi_pid"); pid_names[$uwsgi_pid]="uwsgi" + +# Wait for services to fully initialize before checking hardware +echo "⏳ Waiting for services to fully initialize before hardware check..." +sleep 5 + +# Run hardware check +echo "🔍 Running hardware acceleration check..." +. /app/docker/init/04-check-hwaccel.sh + +# Wait for at least one process to exit and log the process that exited first +if [ ${#pids[@]} -gt 0 ]; then + echo "⏳ Dispatcharr is running. Monitoring processes..." + set +e + while kill -0 "${pids[@]}" 2>/dev/null; do + sleep 1 # Wait for a second before checking again + done + + # Only report unexpected exits — skip if cleanup was already triggered by + # the trap (i.e. docker stop sent SIGTERM and we shut down intentionally) + if ! $_cleanup_done; then + echo "🚨 One of the processes exited unexpectedly! Checking which one..." + + for pid in "${pids[@]}"; do + if ! kill -0 "$pid" 2>/dev/null; then + process_name=${pid_names[$pid]:-unknown} + echo "❌ Process $process_name (PID: $pid) has exited!" + fi + done + fi +else + echo "❌ No processes started. Exiting." + exit 1 +fi + +# Cleanup and stop remaining processes +cleanup diff --git a/docker/init/00-fix-pg-ssl-key.sh b/docker/init/00-fix-pg-ssl-key.sh new file mode 100644 index 0000000..d706581 --- /dev/null +++ b/docker/init/00-fix-pg-ssl-key.sh @@ -0,0 +1,44 @@ +#!/bin/bash +# +# Fix TLS client key permissions and ownership for PostgreSQL. +# libpq requires the client key to be 0600 or stricter. +# +# Triggers on: +# - Permissions too open (Docker Desktop mounts files as 0777) +# - Wrong ownership (Kubernetes secrets / Docker volumes mount as root; +# the application user can't read a root-owned 0600 key) +# - Read-only source (volume mounted :ro — can't chmod in place) +# +# Usage: source this script with FIXED_KEY_PATH set to the destination. +# FIXED_KEY_PATH="/data/.pg-client.key" +# . /app/docker/init/00-fix-pg-ssl-key.sh +# +# After sourcing, POSTGRES_SSL_KEY is updated to the fixed path if a copy +# was needed. The caller is responsible for propagating the new value to +# /etc/environment or profile.d if required. + +: "${FIXED_KEY_PATH:?FIXED_KEY_PATH must be set before sourcing fix-pg-ssl-key.sh}" + +if [ -n "${POSTGRES_SSL_KEY:-}" ] && [ -f "$POSTGRES_SSL_KEY" ]; then + _key_perms=$(stat -c '%a' "$POSTGRES_SSL_KEY" 2>/dev/null) + _key_owner=$(stat -c '%u' "$POSTGRES_SSL_KEY" 2>/dev/null) + _needs_fix=false + + if [ "$_key_perms" != "600" ] && [ "$_key_perms" != "640" ]; then + _needs_fix=true + elif [ "$(id -u)" = "0" ] && [ -n "${PUID:-}" ] && [ "$_key_owner" != "$PUID" ]; then + _needs_fix=true + fi + + if [ "$_needs_fix" = true ]; then + cp "$POSTGRES_SSL_KEY" "$FIXED_KEY_PATH" + chmod 600 "$FIXED_KEY_PATH" + if [ "$(id -u)" = "0" ] && [ -n "${PUID:-}" ]; then + chown "${PUID}:${PGID:-$PUID}" "$FIXED_KEY_PATH" + fi + export POSTGRES_SSL_KEY="$FIXED_KEY_PATH" + echo "Fixed PostgreSQL client key (perms: ${_key_perms}, owner: ${_key_owner} → ${PUID:-root}:600)" + fi + + unset _key_perms _key_owner _needs_fix +fi diff --git a/docker/init/01-user-setup.sh b/docker/init/01-user-setup.sh new file mode 100644 index 0000000..522fcd8 --- /dev/null +++ b/docker/init/01-user-setup.sh @@ -0,0 +1,127 @@ +#!/bin/bash + +# NOTE: PUID/PGID values matching internal system UIDs (e.g. 102 for the +# postgres package user) will cause that OS user/group to be renamed to +# $POSTGRES_USER inside the container. This is cosmetic and does not affect +# runtime behavior since all postgres operations run as $POSTGRES_USER +# rather than the postgres system user. + +# Default PUID/PGID to 1000 when not explicitly set. +# The old image ran Django as UID 1000 and PostgreSQL as UID 102. Since +# DATA_DIRS and host-side files are owned by 1000, defaulting to 1000 +# preserves access for upgrading users without requiring configuration. +# The DB ownership migration (102 → 1000) is handled by 02-postgres.sh. +export PUID=${PUID:-1000} +export PGID=${PGID:-1000} + +# Validate PUID/PGID are positive integers before any user/group operations. +# Non-numeric values would cause useradd/groupadd to fail with confusing errors. +if ! [[ "$PUID" =~ ^[0-9]+$ ]] || ! [[ "$PGID" =~ ^[0-9]+$ ]]; then + echo "" + echo "================================================================" + echo "ERROR: PUID and PGID must be positive integers." + echo " PUID=$PUID PGID=$PGID" + echo " Please set valid numeric values (default: 1000)." + echo "================================================================" + echo "" + exit 1 +fi + +# PostgreSQL refuses to run as root (UID 0). Block early — before any +# user/group manipulation — to prevent renaming the root user/group, +# which would break the container. +if [ "$PUID" = "0" ] || [ "$PGID" = "0" ]; then + echo "" + echo "================================================================" + echo "ERROR: PUID=0 or PGID=0 is not supported." + echo " PostgreSQL cannot run as root (UID 0)." + echo " Please set PUID and PGID to a non-zero value (default: 1000)." + echo "================================================================" + echo "" + exit 1 +fi + +# Check if group with PGID exists +if getent group "$PGID" >/dev/null 2>&1; then + # Group exists, check if it's named correctly (should match POSTGRES_USER) + existing_group=$(getent group "$PGID" | cut -d: -f1) + if [ "$existing_group" != "$POSTGRES_USER" ]; then + # Rename the existing group to match POSTGRES_USER + groupmod -n "$POSTGRES_USER" "$existing_group" + echo "Group $existing_group with GID $PGID renamed to $POSTGRES_USER" + fi +else + # Group doesn't exist, create it with same name as POSTGRES_USER + groupadd -g "$PGID" "$POSTGRES_USER" + echo "Group $POSTGRES_USER with GID $PGID created" +fi + +# Create user if it doesn't exist +if ! getent passwd "$PUID" > /dev/null 2>&1; then + useradd -u "$PUID" -g "$PGID" -m "$POSTGRES_USER" +else + existing_user=$(getent passwd "$PUID" | cut -d: -f1) + if [ "$existing_user" != "$POSTGRES_USER" ]; then + usermod -l "$POSTGRES_USER" -g "$PGID" "$existing_user" + fi +fi + +# Get the GID of /dev/dri/renderD128 on the host (must be mounted into container) +if [ -e "/dev/dri/renderD128" ]; then + HOST_RENDER_GID=$(stat -c '%g' /dev/dri/renderD128) + + # Check if this GID belongs to the video group + VIDEO_GID=$(getent group video 2>/dev/null | cut -d: -f3) + + if [ "$HOST_RENDER_GID" = "$VIDEO_GID" ]; then + echo "RenderD128 GID ($HOST_RENDER_GID) matches video group GID. Using video group for GPU access." + # Make sure POSTGRES_USER is in video group + if ! id -nG "$POSTGRES_USER" | grep -qw "video"; then + usermod -a -G video "$POSTGRES_USER" + echo "Added user $POSTGRES_USER to video group for GPU access" + fi + else + # We need to ensure render group exists with correct GID + if getent group render >/dev/null; then + CURRENT_RENDER_GID=$(getent group render | cut -d: -f3) + if [ "$CURRENT_RENDER_GID" != "$HOST_RENDER_GID" ]; then + # Check if another group already has the target GID + if getent group "$HOST_RENDER_GID" >/dev/null 2>&1; then + EXISTING_GROUP=$(getent group "$HOST_RENDER_GID" | cut -d: -f1) + echo "Warning: Cannot change render group GID to $HOST_RENDER_GID as it's already used by group '$EXISTING_GROUP'" + # Add user to the existing group with the target GID to ensure device access + if ! id -nG "$POSTGRES_USER" | grep -qw "$EXISTING_GROUP"; then + usermod -a -G "$EXISTING_GROUP" "$POSTGRES_USER" || echo "Warning: Failed to add user to $EXISTING_GROUP group" + echo "Added user $POSTGRES_USER to $EXISTING_GROUP group for GPU access" + fi + else + echo "Changing render group GID from $CURRENT_RENDER_GID to $HOST_RENDER_GID" + groupmod -g "$HOST_RENDER_GID" render || echo "Warning: Failed to change render group GID. Continuing anyway..." + fi + fi + else + echo "Creating render group with GID $HOST_RENDER_GID" + groupadd -g "$HOST_RENDER_GID" render + fi + + # Make sure POSTGRES_USER is in render group + if ! id -nG "$POSTGRES_USER" | grep -qw "render"; then + usermod -a -G render "$POSTGRES_USER" + echo "Added user $POSTGRES_USER to render group for GPU access" + fi + fi +else + echo "Warning: /dev/dri/renderD128 not found. GPU acceleration may not be available." +fi + +# Always add user to video group for hardware acceleration if it exists +# (some systems use video group for general GPU access) +if getent group video >/dev/null 2>&1; then + if ! id -nG "$POSTGRES_USER" | grep -qw "video"; then + usermod -a -G video "$POSTGRES_USER" + echo "Added user $POSTGRES_USER to video group for hardware acceleration access" + fi +fi + +# Run nginx as specified user (replace any existing user directive on line 1) +sed -i "1s/^user .*/user $POSTGRES_USER;/" /etc/nginx/nginx.conf diff --git a/docker/init/02-postgres.sh b/docker/init/02-postgres.sh new file mode 100644 index 0000000..2afb3f8 --- /dev/null +++ b/docker/init/02-postgres.sh @@ -0,0 +1,507 @@ +#!/bin/bash + +# Skip internal PostgreSQL setup in modular mode (using external database) +if [[ "$DISPATCHARR_ENV" != "modular" ]]; then + + # Record PUID:PGID in a sentinel file so subsequent startups can skip + # the expensive recursive chown when ownership is already correct. + write_ownership_sentinel() { + echo "$PUID:$PGID" > "${POSTGRES_DIR}/.owner_puid" + chown "$PUID:$PGID" "${POSTGRES_DIR}/.owner_puid" + } + + # Ensure the PostgreSQL socket directory exists, is owned by PUID:PGID, + # and has no stale lock/socket files from an unclean previous shutdown. + # Called immediately before every pg_ctl start so it runs after any apt + # post-remove scripts that might reset the directory's ownership. + prepare_pg_socket_dir() { + mkdir -p /var/run/postgresql + chown "$PUID:$PGID" /var/run/postgresql + chmod 755 /var/run/postgresql + rm -f "/var/run/postgresql/.s.PGSQL.${POSTGRES_PORT}" \ + "/var/run/postgresql/.s.PGSQL.${POSTGRES_PORT}.lock" 2>/dev/null || true + } + + # Write standard pg_hba.conf and enable network listening. + # Local (Unix socket): trust — safe for single-app containers where only + # authorized processes connect. Network: password required via md5. + # Idempotent: safe to call on every startup. + configure_pg_network() { + local datadir="$1" + cat > "${datadir}/pg_hba.conf" <> "${datadir}/postgresql.conf" + } + + # Legacy migration: move data from /data root into $POSTGRES_DIR. + # Safe to remove once all deployments have upgraded past this layout. + if [ -e "/data/postgresql.conf" ]; then + echo "Migrating PostgreSQL data from /data to $POSTGRES_DIR..." + + # Create a temporary directory outside of /data + mkdir -p /tmp/postgres_migration + + # Move the PostgreSQL files to the temporary directory + mv /data/* /tmp/postgres_migration/ + + # Create the target directory + mkdir -p "$POSTGRES_DIR" + + # Move the files from temporary directory to the final location + mv /tmp/postgres_migration/* "$POSTGRES_DIR/" + + # Clean up the temporary directory + rmdir /tmp/postgres_migration + + # Set proper ownership and permissions for PostgreSQL data directory + chown -R "$PUID:$PGID" "$POSTGRES_DIR" + chmod 700 "$POSTGRES_DIR" + + echo "Migration completed successfully." + fi + + PG_VERSION_FILE="${POSTGRES_DIR}/PG_VERSION" + + # Detect current version from data directory, if present + if [ -f "$PG_VERSION_FILE" ]; then + CURRENT_VERSION=$(cat "$PG_VERSION_FILE") + else + CURRENT_VERSION="" + fi + + # ========================================================================= + # Existing data: ensure ownership, auth, and permissions are correct. + # These guarantees run on EVERY startup with existing data — not just + # upgrades. This eliminates conditional edge cases and ensures the + # container always reaches a known-good state regardless of how the + # data was originally created. + # ========================================================================= + if [ -n "$CURRENT_VERSION" ] && [ -d "$POSTGRES_DIR" ]; then + + # --- 1. Ownership reconciliation (conditional — only when needed) --- + # Two triggers cause a recursive chown: + # a) PG_VERSION owner doesn't match PUID (obvious mismatch) + # b) Sentinel file (.owner_puid) missing or stale — catches partial + # chown from a previous interrupted startup where early files + # (including PG_VERSION) got the new owner but deeper files didn't. + # After a successful chown, the sentinel records PUID:PGID so + # subsequent startups skip the expensive recursive operation. + OWNERSHIP_SENTINEL="${POSTGRES_DIR}/.owner_puid" + CURRENT_OWNER=$(stat -c '%u' "$PG_VERSION_FILE") + _needs_chown=false + if [ "$CURRENT_OWNER" != "$PUID" ]; then + _needs_chown=true + elif [ ! -f "$OWNERSHIP_SENTINEL" ] || [ "$(cat "$OWNERSHIP_SENTINEL" 2>/dev/null)" != "$PUID:$PGID" ]; then + # Sentinel missing or stale. Could be: + # a) First startup with sentinel code (pre-existing data) — benign + # b) Interrupted chown from a previous startup — needs re-chown + # Spot-check a deeper directory to distinguish: if base/ also + # matches PUID:PGID, ownership is likely consistent (case a). + _deeper_check=$(stat -c '%u:%g' "${POSTGRES_DIR}/base" 2>/dev/null) + if [ "$_deeper_check" != "$PUID:$PGID" ]; then + _needs_chown=true + else + # Spot-check passed — ownership is consistent, record sentinel + # so future startups skip the spot-check entirely. + write_ownership_sentinel + fi + fi + + if [ "$_needs_chown" = true ]; then + echo "Migrating PostgreSQL data ownership from UID $CURRENT_OWNER to $PUID:$PGID..." + echo " This may take several minutes for large databases. Do not stop the container." + if ! chown -R "$PUID:$PGID" "$POSTGRES_DIR" 2>/dev/null; then + echo "" + echo "================================================================" + echo "ERROR: Cannot update ownership of $POSTGRES_DIR" + echo " Current owner: UID $CURRENT_OWNER" + echo " Target owner: UID $PUID (GID $PGID)" + echo "" + echo " This typically occurs with rootless Docker or restricted" + echo " filesystems (NFS with root_squash, CIFS/SMB)." + echo "" + echo " To fix:" + echo " - Local/NFS: sudo chown -R $PUID:$PGID /db" + echo " - CIFS/SMB: set the mount uid=$PUID,gid=$PGID option instead" + echo " Then restart the container." + echo "================================================================" + echo "" + exit 1 + fi + chmod 700 "$POSTGRES_DIR" + # Write sentinel LAST — if chown was interrupted, the sentinel + # won't exist and next startup will re-run the full chown. + write_ownership_sentinel + echo "Ownership migration complete." + fi + + # --- 2. Authentication guarantee (unconditional) --- + # Always rewrite pg_hba.conf to the known-good state. This replaces + # any auth method (peer, ident, md5, scram) left by previous images + # or initdb defaults. Eliminates the class of bugs where the OS user + # name doesn't match any PG role under peer/ident auth. + configure_pg_network "${POSTGRES_DIR}" + fi + + # Only run upgrade if current version is set and not the target + if [ -n "$CURRENT_VERSION" ] && [ "$CURRENT_VERSION" != "$PG_VERSION" ]; then + echo "Detected PostgreSQL data directory version $CURRENT_VERSION, upgrading to $PG_VERSION..." + # Set binary paths for upgrade if needed + OLD_BINDIR="/usr/lib/postgresql/${CURRENT_VERSION}/bin" + NEW_BINDIR="/usr/lib/postgresql/${PG_VERSION}/bin" + PG_INSTALLED_BY_SCRIPT=0 + if [ ! -d "$OLD_BINDIR" ]; then + echo "PostgreSQL binaries for version $CURRENT_VERSION not found. Installing..." + apt update && apt install -y postgresql-$CURRENT_VERSION postgresql-contrib-$CURRENT_VERSION + if [ $? -ne 0 ]; then + echo "Failed to install PostgreSQL version $CURRENT_VERSION. Exiting." + exit 1 + fi + PG_INSTALLED_BY_SCRIPT=1 + fi + + # Prepare the old cluster for pg_upgrade: + # 1. Promote $POSTGRES_USER to superuser (needed for post-upgrade ops) + # 2. Detect the bootstrap superuser (install user) — pg_upgrade + # requires -U to match this role exactly. + # The old cluster's install user is "postgres" (pre-PUID images) + # or $POSTGRES_USER (post-PUID images, future upgrades). + echo "Preparing old cluster for upgrade..." + prepare_pg_socket_dir + su - "$POSTGRES_USER" -c "$OLD_BINDIR/pg_ctl -D $POSTGRES_DIR start -w -o '-c port=${POSTGRES_PORT}'" + _promoted=false + for _role in "postgres" "$POSTGRES_USER"; do + if su - "$POSTGRES_USER" -c "psql -U $_role -d template1 -p ${POSTGRES_PORT} -tAc 'SELECT 1;'" 2>/dev/null | grep -q 1; then + if su - "$POSTGRES_USER" -c "psql -U $_role -d template1 -p ${POSTGRES_PORT} -v ON_ERROR_STOP=1" </dev/null | tr -d '[:space:]') + if [ -z "$_install_user" ]; then + _install_user="postgres" + fi + + su - "$POSTGRES_USER" -c "$OLD_BINDIR/pg_ctl -D $POSTGRES_DIR stop -w" + if [ "$_promoted" != true ]; then + echo "❌ Failed to prepare old cluster for upgrade." + echo " Could not promote '$POSTGRES_USER' to superuser in PG $CURRENT_VERSION." + exit 1 + fi + echo "Old cluster install user: $_install_user" + + # Prepare new data directory + NEW_POSTGRES_DIR="${POSTGRES_DIR}_$PG_VERSION" + + # Remove new data directory if it already exists (from a failed/partial upgrade) + if [ -d "$NEW_POSTGRES_DIR" ]; then + echo "Warning: $NEW_POSTGRES_DIR already exists. Removing it to avoid upgrade issues." + rm -rf "$NEW_POSTGRES_DIR" + fi + + mkdir -p "$NEW_POSTGRES_DIR" + chown -R "$PUID:$PGID" "$NEW_POSTGRES_DIR" + chmod 700 "$NEW_POSTGRES_DIR" + + # Initialize new data directory with the same install user as the old + # cluster. pg_upgrade requires the -U user to match both clusters. + echo "Initializing new PostgreSQL data directory at $NEW_POSTGRES_DIR..." + su - "$POSTGRES_USER" -c "$NEW_BINDIR/initdb -U $_install_user -D $NEW_POSTGRES_DIR" + echo "Running pg_upgrade from $OLD_BINDIR to $NEW_BINDIR..." + su - "$POSTGRES_USER" -c "$NEW_BINDIR/pg_upgrade -U $_install_user -b $OLD_BINDIR -B $NEW_BINDIR -d $POSTGRES_DIR -D $NEW_POSTGRES_DIR" + + # Move old data directory for backup, move new into place + mv "$POSTGRES_DIR" "${POSTGRES_DIR}_backup_${CURRENT_VERSION}_$(date +%s)" + mv "$NEW_POSTGRES_DIR" "$POSTGRES_DIR" + + # Apply standard connection configuration to the upgraded data directory. + configure_pg_network "${POSTGRES_DIR}" + + # Record ownership sentinel for the newly upgraded data directory. + write_ownership_sentinel + + echo "Upgrade complete. Old data directory backed up." + + # Uninstall PostgreSQL if we installed it just for upgrade + if [ "$PG_INSTALLED_BY_SCRIPT" -eq 1 ]; then + echo "Uninstalling temporary PostgreSQL $CURRENT_VERSION packages..." + apt remove -y postgresql-$CURRENT_VERSION postgresql-contrib-$CURRENT_VERSION + apt autoremove -y + fi + fi + + # Initialize PostgreSQL data directory (fresh install only). + # Only runs initdb + configure_pg_network here. Database creation, + # role setup, and password configuration are handled by the + # unconditional guarantees (promote_app_role, ensure_app_database) + # after PostgreSQL starts in entrypoint.sh. + if [ -z "$(ls -A "$POSTGRES_DIR")" ]; then + echo "Initializing PostgreSQL database..." + mkdir -p "$POSTGRES_DIR" + chown -R "$PUID:$PGID" "$POSTGRES_DIR" + chmod 700 "$POSTGRES_DIR" + + # Initialize PostgreSQL as the application user. + # The superuser role is automatically named $POSTGRES_USER. + su - "$POSTGRES_USER" -c "$PG_BINDIR/initdb -D ${POSTGRES_DIR}" + + # Configure authentication and network access. + configure_pg_network "${POSTGRES_DIR}" + + # Record ownership sentinel for the freshly initialized data directory. + write_ownership_sentinel + fi + +fi # End of DISPATCHARR_ENV != modular check + +# ========================================================================= +# 3. Role guarantee (unconditional — runs after PostgreSQL starts) +# +# Ensures the application role ($POSTGRES_USER) exists with superuser +# privileges and the correct password. Called from entrypoint.sh after +# PostgreSQL starts on every AIO startup. +# +# Idempotent: checks before altering. Handles all scenarios: +# - Fresh install: role exists from initdb, just verifies +# - Upgrade from postgres-user: creates dispatch role, promotes to superuser +# - PUID change: verifies existing role, updates password +# - Normal restart: no-op (role already correct) +# +# Tries multiple database/role combinations to handle incomplete data +# (e.g., interrupted initialization from a previous image version). +# ========================================================================= +promote_app_role() { + if [[ "$DISPATCHARR_ENV" == "modular" ]]; then + return 0 + fi + + echo "Ensuring application role is configured..." + + # Find a connectable superuser role. Try multiple databases in case + # the default 'postgres' database doesn't exist (e.g., incomplete + # initialization from a crashed previous container). + # Single query per candidate: if connection fails, output is empty; + # if connected but not superuser, output is 'f'. Only 't' passes. + local CONNECT_ROLE="" + local CONNECT_DB="" + for try_db in "postgres" "template1"; do + for try_role in "postgres" "$POSTGRES_USER"; do + local _super + _super=$(su - "$POSTGRES_USER" -c "psql -U $try_role -d $try_db -p ${POSTGRES_PORT} -tAc \ + \"SELECT rolsuper FROM pg_roles WHERE rolname='$try_role';\"" 2>/dev/null | tr -d '[:space:]') + if [ "$_super" = "t" ]; then + CONNECT_ROLE="$try_role" + CONNECT_DB="$try_db" + break 2 + fi + done + done + + if [ -z "$CONNECT_ROLE" ]; then + echo "❌ Role setup failed: no connectable superuser role found." + echo " To recover manually:" + echo " su - "$POSTGRES_USER" -c \"psql -d template1 -p $POSTGRES_PORT\"" + echo " CREATE ROLE $POSTGRES_USER WITH SUPERUSER LOGIN PASSWORD '';" + exit 1 + fi + + # Escape single quotes for safe SQL interpolation + local _sql_pw="${POSTGRES_PASSWORD//\'/\'\'}" + + if ! su - "$POSTGRES_USER" -c "psql -U $CONNECT_ROLE -d $CONNECT_DB -p ${POSTGRES_PORT} -v ON_ERROR_STOP=1" <';" + exit 1 + fi + + echo "✅ Application role configured." +} + +# ========================================================================= +# 4. Database guarantee (unconditional — runs after role setup) +# +# Ensures the application database ($POSTGRES_DB) exists. Handles +# incomplete data from interrupted previous initializations where +# PG_VERSION exists but the application database was never created. +# ========================================================================= +ensure_app_database() { + if [[ "$DISPATCHARR_ENV" == "modular" ]]; then + return 0 + fi + + # Connect to template1 (always exists) to check pg_database catalog. + if su - "$POSTGRES_USER" -c "psql -d template1 -p ${POSTGRES_PORT} -tAc \ + \"SELECT 1 FROM pg_database WHERE datname = '$POSTGRES_DB';\"" 2>/dev/null | grep -q 1; then + return 0 + fi + + echo "Application database '$POSTGRES_DB' not found — creating..." + if ! su - "$POSTGRES_USER" -c "createdb -p ${POSTGRES_PORT} --encoding=UTF8 ${POSTGRES_DB}" 2>/dev/null; then + # Might already exist if the check failed for a transient reason. + if su - "$POSTGRES_USER" -c "psql -d template1 -p ${POSTGRES_PORT} -tAc \ + \"SELECT 1 FROM pg_database WHERE datname = '$POSTGRES_DB';\"" 2>/dev/null | grep -q 1; then + return 0 + fi + echo "❌ Failed to create database '$POSTGRES_DB'" + exit 1 + fi + echo "✅ Database '$POSTGRES_DB' created." +} + +ensure_utf8_encoding() { + # Check encoding of existing database + # Supports both internal (Unix socket) and external (TCP) PostgreSQL + echo "Checking database encoding..." + + if [[ "$DISPATCHARR_ENV" == "modular" ]]; then + # External database: use TCP connection with password + CURRENT_ENCODING=$(PGPASSWORD="$POSTGRES_PASSWORD" psql -w -h "$POSTGRES_HOST" -p "$POSTGRES_PORT" -U "$POSTGRES_USER" -d "$POSTGRES_DB" -tAc "SELECT pg_encoding_to_char(encoding) FROM pg_database WHERE datname = current_database();" 2>/dev/null | tr -d ' ') + else + # Internal database: use Unix socket as application user + CURRENT_ENCODING=$(su - "$POSTGRES_USER" -c "psql -p ${POSTGRES_PORT} -d ${POSTGRES_DB} -tAc \"SELECT pg_encoding_to_char(encoding) FROM pg_database WHERE datname = current_database();\"" | tr -d ' ') + fi + + if [ "$CURRENT_ENCODING" != "UTF8" ]; then + echo "Database $POSTGRES_DB encoding is $CURRENT_ENCODING, converting to UTF8..." + DUMP_FILE="/tmp/${POSTGRES_DB}_utf8_dump_$(date +%s).sql" + + if [[ "$DISPATCHARR_ENV" == "modular" ]]; then + # External database: use TCP connection with password + # Dump database (include permissions and ownership) + PGPASSWORD="$POSTGRES_PASSWORD" pg_dump -w -h "$POSTGRES_HOST" -p "$POSTGRES_PORT" -U "$POSTGRES_USER" "$POSTGRES_DB" > "$DUMP_FILE" || { echo "Dump failed"; return 1; } + # Drop and recreate database with UTF8 encoding using template0 + PGPASSWORD="$POSTGRES_PASSWORD" dropdb -w -h "$POSTGRES_HOST" -p "$POSTGRES_PORT" -U "$POSTGRES_USER" "$POSTGRES_DB" || { echo "Drop failed"; return 1; } + # Recreate database with UTF8 encoding + PGPASSWORD="$POSTGRES_PASSWORD" createdb -w -h "$POSTGRES_HOST" -p "$POSTGRES_PORT" -U "$POSTGRES_USER" --encoding=UTF8 --template=template0 "$POSTGRES_DB" || { echo "Create failed"; return 1; } + # Restore data + PGPASSWORD="$POSTGRES_PASSWORD" psql -w -h "$POSTGRES_HOST" -p "$POSTGRES_PORT" -U "$POSTGRES_USER" -d "$POSTGRES_DB" < "$DUMP_FILE" || { echo "Restore failed"; return 1; } + else + # Internal database: use Unix socket as application user + # Dump database (include permissions and ownership) + su - "$POSTGRES_USER" -c "pg_dump -p ${POSTGRES_PORT} ${POSTGRES_DB}" > "$DUMP_FILE" || { echo "Dump failed"; return 1; } + # Drop and recreate database with UTF8 encoding using template0 + su - "$POSTGRES_USER" -c "dropdb -p ${POSTGRES_PORT} ${POSTGRES_DB}" || { echo "Drop failed"; return 1; } + # Recreate database with UTF8 encoding and correct owner + su - "$POSTGRES_USER" -c "createdb -p ${POSTGRES_PORT} --encoding=UTF8 --template=template0 --owner=${POSTGRES_USER} ${POSTGRES_DB}" || { echo "Create failed"; return 1; } + # Restore data + cat "$DUMP_FILE" | su - "$POSTGRES_USER" -c "psql -p ${POSTGRES_PORT} -d ${POSTGRES_DB}" || { echo "Restore failed"; return 1; } + fi + + rm -f "$DUMP_FILE" + echo "✅ Database $POSTGRES_DB converted to UTF8." + else + echo "✅ Database encoding is UTF8" + fi +} + +check_external_postgres_version() { + # Only check for modular deployments + if [[ "$DISPATCHARR_ENV" != "modular" ]]; then + return 0 + fi + + echo "🔍 Checking external PostgreSQL version compatibility..." + + # Get minimum required version from base image (set in entrypoint.sh) + # PG_VERSION is from DispatcharrBase + MIN_REQUIRED_VERSION=$PG_VERSION + + # Query external PostgreSQL version + # Use $POSTGRES_DB — restricted users may not have access to the default 'postgres' database + PG_VERSION_ERR=$(mktemp) + EXTERNAL_VERSION=$(PGPASSWORD="$POSTGRES_PASSWORD" psql -w -h "$POSTGRES_HOST" -p "$POSTGRES_PORT" -U "$POSTGRES_USER" -d "$POSTGRES_DB" -tAc "SHOW server_version;" 2>"$PG_VERSION_ERR" | grep -oE '^[0-9]+') + + if [ -z "$EXTERNAL_VERSION" ]; then + echo "❌ ERROR: Unable to determine external PostgreSQL version" + echo " Could not connect to database '$POSTGRES_DB' at ${POSTGRES_HOST}:${POSTGRES_PORT} as user '$POSTGRES_USER'" + echo " Error: $(cat "$PG_VERSION_ERR")" + echo " Please verify your database connection settings." + rm -f "$PG_VERSION_ERR" + return 1 + fi + rm -f "$PG_VERSION_ERR" + + # Compare versions + if [[ "$EXTERNAL_VERSION" -lt "$MIN_REQUIRED_VERSION" ]]; then + # FAIL: Version too old + echo "" + echo "❌ ERROR: PostgreSQL version mismatch" + echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" + echo " External Database: PostgreSQL $EXTERNAL_VERSION" + echo " Required Version: PostgreSQL $MIN_REQUIRED_VERSION or higher" + echo "" + echo " Your external PostgreSQL database is too old for Dispatcharr." + echo " Please upgrade to PostgreSQL $MIN_REQUIRED_VERSION or higher." + echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" + echo "" + return 1 + + elif [[ "$EXTERNAL_VERSION" -eq "$MIN_REQUIRED_VERSION" ]]; then + # MATCH: Exact version match + echo "✅ PostgreSQL version check passed" + echo " External Database: PostgreSQL $EXTERNAL_VERSION (matches target version)" + + else + # HIGHER: Newer version + echo "✅ PostgreSQL version check passed" + echo " External Database: PostgreSQL $EXTERNAL_VERSION" + echo " Target Version: PostgreSQL $MIN_REQUIRED_VERSION" + echo " ℹ️ Your database is newer than the target version." + echo " PostgreSQL version should be compatible with Dispatcharr." + fi + + return 0 +} diff --git a/docker/init/03-init-dispatcharr.sh b/docker/init/03-init-dispatcharr.sh new file mode 100644 index 0000000..05bcb19 --- /dev/null +++ b/docker/init/03-init-dispatcharr.sh @@ -0,0 +1,130 @@ +#!/bin/bash + +# Define directories that need to exist and be owned by PUID:PGID. +# DATA_DIRS may reside on external mounts (NFS, SMB/CIFS, FUSE) where +# mkdir and chown can fail. Failures are collected and reported as a +# single consolidated warning so the container still starts. +DATA_DIRS=( + "/data/backups" + "/data/logos" + "/data/recordings" + "/data/uploads/m3us" + "/data/uploads/epgs" + "/data/m3us" + "/data/epgs" + "/data/plugins" + "/data/models" + "/data/scripts" +) + +# APP_DIRS live on the image layer and are always locally writable. +APP_DIRS=( + "/app/logo_cache" + "/app/media" + "/app/static" +) + +# Create app directories (image layer — always writable) +for dir in "${APP_DIRS[@]}"; do + mkdir -p "$dir" +done + +# Create data directories, tolerating failures on external mounts +_failed_mkdir=() +_failed_chown=() +for dir in "${DATA_DIRS[@]}"; do + _mkdir_err=$(mkdir -p "$dir" 2>&1) || _failed_mkdir+=("$dir ($_mkdir_err)") +done + +# Ensure /app itself is owned by PUID:PGID (needed for uwsgi socket creation) +if [ "$(id -u)" = "0" ] && [ -d "/app" ]; then + if [ "$(stat -c '%u:%g' /app)" != "$PUID:$PGID" ]; then + echo "Fixing ownership for /app (non-recursive)" + chown "$PUID:$PGID" /app + fi +fi +# Configure nginx port +if ! [[ "$DISPATCHARR_PORT" =~ ^[0-9]+$ ]]; then + echo "⚠️ Warning: DISPATCHARR_PORT is not a valid integer, using default port 9191" + DISPATCHARR_PORT=9191 +fi +sed -i "s/NGINX_PORT/${DISPATCHARR_PORT}/g" /etc/nginx/sites-enabled/default + +# Configure nginx based on IPv6 availability +if ip -6 addr show | grep -q "inet6"; then + echo "✅ IPv6 is available, enabling IPv6 in nginx" +else + echo "⚠️ IPv6 not available, disabling IPv6 in nginx" + sed -i '/listen \[::\]:/d' /etc/nginx/sites-enabled/default +fi + +# NOTE: mac doesn't run as root, so only manage permissions +# if this script is running as root +if [ "$(id -u)" = "0" ]; then + # Fix data directories (non-recursive to avoid touching user files). + # Failures are collected rather than fatal — directories may be on + # external mounts (NFS, SMB/CIFS, FUSE) that reject chown. + for dir in "${DATA_DIRS[@]}"; do + if [ -d "$dir" ] && [ "$(stat -c '%u:%g' "$dir" 2>/dev/null)" != "$PUID:$PGID" ]; then + _chown_err=$(chown "$PUID:$PGID" "$dir" 2>&1) || { + _current_owner=$(stat -c '%u:%g' "$dir" 2>/dev/null || echo "unknown") + _failed_chown+=("$dir (current: $_current_owner, error: $_chown_err)") + } + fi + done + + # Fix app directories (recursive since they're managed by the app) + for dir in "${APP_DIRS[@]}"; do + if [ -d "$dir" ] && [ "$(stat -c '%u:%g' "$dir")" != "$PUID:$PGID" ]; then + echo "Fixing ownership for $dir (recursive)" + chown -R "$PUID:$PGID" "$dir" + fi + done + + # /data/db ownership is handled by 02-postgres.sh (sentinel-based reconciliation). + # No secondary check needed here — duplicating it could chown without updating + # the sentinel, creating inconsistent state. + + # Fix /data directory ownership (non-recursive). + # Tolerates failure for the same external-mount reasons as DATA_DIRS. + if [ -d "/data" ] && [ "$(stat -c '%u:%g' /data 2>/dev/null)" != "$PUID:$PGID" ]; then + _chown_err=$(chown "$PUID:$PGID" /data 2>&1) || { + _current_owner=$(stat -c '%u:%g' /data 2>/dev/null || echo "unknown") + _failed_chown+=("/data (current: $_current_owner, error: $_chown_err)") + } + fi + + chmod +x /data 2>/dev/null || true +fi + +# Consolidated warning for all mkdir/chown failures. +# Emitted outside the root guard so non-root mkdir failures are also reported. +if [ ${#_failed_mkdir[@]} -gt 0 ] || [ ${#_failed_chown[@]} -gt 0 ]; then + echo "" + echo "================================================================" + echo "WARNING: Some data directories could not be created or updated." + echo " This typically occurs with NFS, SMB/CIFS, or other external" + echo " mounts that restrict ownership changes." + echo "" + if [ ${#_failed_mkdir[@]} -gt 0 ]; then + echo " Could not create:" + for entry in "${_failed_mkdir[@]}"; do + echo " - $entry" + done + echo "" + fi + if [ ${#_failed_chown[@]} -gt 0 ]; then + echo " Could not set ownership to $PUID:$PGID:" + for entry in "${_failed_chown[@]}"; do + echo " - $entry" + done + echo "" + fi + echo " To fix, either:" + echo " 1. Set PUID/PGID to match your mount's owner" + echo " 2. Fix ownership on the host/NAS:" + echo " sudo chown $PUID:$PGID " + echo " 3. For SMB/CIFS: set uid=$PUID,gid=$PGID in mount options" + echo "================================================================" + echo "" +fi diff --git a/docker/init/04-check-hwaccel.sh b/docker/init/04-check-hwaccel.sh new file mode 100644 index 0000000..ee1662d --- /dev/null +++ b/docker/init/04-check-hwaccel.sh @@ -0,0 +1,715 @@ +#!/bin/bash + +echo "🔍 Checking for GPU acceleration devices..." + +# Helper function for device access checks +check_dev() { + local dev=$1 + if [ -e "$dev" ]; then + if [ -r "$dev" ] && [ -w "$dev" ]; then + echo "✅ Device $dev is accessible." + else + echo "⚠️ Device $dev exists but is not accessible. Check permissions or container runtime options." + fi + else + echo "ℹ️ Device $dev does not exist." + fi +} + +# Initialize device detection flags +ANY_GPU_DEVICES_FOUND=false +DRI_DEVICES_FOUND=false +NVIDIA_FOUND=false +NVIDIA_GPU_IN_LSPCI=false +INTEL_GPU_IN_LSPCI=false +AMD_GPU_IN_LSPCI=false + +# Check for all GPU types in hardware via lspci +if command -v lspci >/dev/null 2>&1; then + # Check for NVIDIA GPUs + if lspci | grep -i "NVIDIA" | grep -i "VGA\|3D\|Display" >/dev/null; then + NVIDIA_GPU_IN_LSPCI=true + NVIDIA_MODEL=$(lspci | grep -i "NVIDIA" | grep -i "VGA\|3D\|Display" | head -1 | sed -E 's/.*: (.*) \[.*/\1/' | sed 's/Corporation //') + fi + + # Check for Intel GPUs - making sure it's not already detected as NVIDIA + if lspci | grep -i "Intel" | grep -v "NVIDIA" | grep -i "VGA\|3D\|Display" >/dev/null; then + INTEL_GPU_IN_LSPCI=true + INTEL_MODEL=$(lspci | grep -i "Intel" | grep -v "NVIDIA" | grep -i "VGA\|3D\|Display" | head -1 | sed -E 's/.*: (.*) \[.*/\1/' | sed 's/Corporation //') + fi + + # Check for AMD GPUs - making sure it's not already detected as NVIDIA or Intel + if lspci | grep -i "AMD\|ATI\|Advanced Micro Devices" | grep -v "NVIDIA\|Intel" | grep -i "VGA\|3D\|Display" >/dev/null; then + AMD_GPU_IN_LSPCI=true + AMD_MODEL=$(lspci | grep -i "AMD\|ATI\|Advanced Micro Devices" | grep -v "NVIDIA\|Intel" | grep -i "VGA\|3D\|Display" | head -1 | sed -E 's/.*: (.*) \[.*/\1/' | sed 's/Corporation //' | sed 's/Technologies //') + fi + + # Display detected GPU hardware + if [ "$NVIDIA_GPU_IN_LSPCI" = true ]; then + echo "🔍 Hardware detection: NVIDIA GPU ($NVIDIA_MODEL)" + fi + if [ "$INTEL_GPU_IN_LSPCI" = true ]; then + echo "🔍 Hardware detection: Intel GPU ($INTEL_MODEL)" + fi + if [ "$AMD_GPU_IN_LSPCI" = true ]; then + echo "🔍 Hardware detection: AMD GPU ($AMD_MODEL)" + fi +fi + +# Silently check for any GPU devices first +for dev in /dev/dri/renderD* /dev/dri/card* /dev/nvidia*; do + if [ -e "$dev" ]; then + ANY_GPU_DEVICES_FOUND=true + break + fi +done + +# Only if devices might exist, show detailed checks +if [ "$ANY_GPU_DEVICES_FOUND" = true ]; then + # Check Intel/AMD VAAPI devices + echo "🔍 Checking for VAAPI device nodes (Intel/AMD)..." + for dev in /dev/dri/renderD* /dev/dri/card*; do + if [ -e "$dev" ]; then + DRI_DEVICES_FOUND=true + check_dev "$dev" + fi + done + + # Check NVIDIA device nodes + echo "🔍 Checking for NVIDIA device nodes..." + for dev in /dev/nvidia*; do + if [ -e "$dev" ]; then + NVIDIA_FOUND=true + check_dev "$dev" + fi + done + + # Show GPU device availability messages + if [ "$NVIDIA_FOUND" = false ] && [ "$NVIDIA_GPU_IN_LSPCI" = true ]; then + echo "⚠️ No NVIDIA device nodes available despite hardware detection." + echo " You may be able to use VAAPI for hardware acceleration, but NVENC/CUDA won't be available." + echo " For optimal performance, configure proper NVIDIA container runtime." + elif [ "$NVIDIA_FOUND" = false ]; then + echo "ℹ️ No NVIDIA device nodes found under /dev." + fi + + # Check for Intel/AMD GPUs that might not be fully accessible + if [ "$DRI_DEVICES_FOUND" = false ] && [ "$INTEL_GPU_IN_LSPCI" = true ]; then + echo "⚠️ Intel GPU detected in hardware but no DRI devices found." + echo " Hardware acceleration will not be available." + echo " Make sure /dev/dri/ devices are properly mapped to the container." + elif [ "$DRI_DEVICES_FOUND" = false ] && [ "$AMD_GPU_IN_LSPCI" = true ]; then + echo "⚠️ AMD GPU detected in hardware but no DRI devices found." + echo " Hardware acceleration will not be available." + echo " Make sure /dev/dri/ devices are properly mapped to the container." + fi +else + # No GPU devices found, skip the detailed checks + echo "❌ No GPU acceleration devices detected in this container." + echo "ℹ️ Checking for potential configuration issues..." + + # Check if the host might have GPUs that aren't passed to the container + if command -v lspci >/dev/null 2>&1; then + if lspci | grep -i "VGA\|3D\|Display" | grep -i "NVIDIA\|Intel\|AMD" >/dev/null; then + echo "⚠️ Host system appears to have GPU hardware, but no devices are accessible to the container." + echo " - For NVIDIA GPUs: Ensure NVIDIA Container Runtime is configured properly" + echo " - For Intel/AMD GPUs: Verify that /dev/dri/ devices are passed to the container" + echo " - Check your Docker run command or docker-compose.yml for proper device mapping" + else + echo "ℹ️ No GPU hardware detected on the host system. CPU-only transcoding will be used." + fi + else + echo "ℹ️ Unable to check host GPU hardware (lspci not available). CPU-only transcoding will be used." + fi + + echo "📋 ==================================================" + echo "✅ GPU detection script complete. No GPUs available for hardware acceleration." + # Don't exit the container - just return from this script + return 0 2>/dev/null || true +fi + +# Check group membership for GPU access - context-aware based on hardware +echo "🔍 Checking user group memberships and device access..." +VIDEO_GID=$(getent group video | cut -d: -f3) +RENDER_GID=$(getent group render | cut -d: -f3) +NVIDIA_CONTAINER_TOOLKIT_FOUND=false +NVIDIA_ENV_MISMATCH=false + +# Improved device access check function +check_user_device_access() { + local device=$1 + local user=$2 + if [ -e "$device" ];then + if su -c "test -r '$device' && test -w '$device'" - "$user" 2>/dev/null; then + echo "✅ User $user has full access to $device" + return 0 + else + echo "⚠️ User $user cannot access $device (permission denied)" + return 1 + fi + else + # Device doesn't exist, no need to report here + return 2 + fi +} + +# Direct device access verification for DRI (Intel/AMD) +echo "🔍 Verifying if $POSTGRES_USER has direct access to GPU devices..." +HAS_DRI_ACCESS=false +DRI_ACCESS_COUNT=0 +DRI_DEVICE_COUNT=0 + +for dev in /dev/dri/renderD* /dev/dri/card*; do + if [ -e "$dev" ]; then + DRI_DEVICE_COUNT=$((DRI_DEVICE_COUNT + 1)) + if check_user_device_access "$dev" "$POSTGRES_USER"; then + DRI_ACCESS_COUNT=$((DRI_ACCESS_COUNT + 1)) + HAS_DRI_ACCESS=true + fi + fi +done + +# Direct device access verification for NVIDIA +HAS_NVIDIA_ACCESS=false +NVIDIA_ACCESS_COUNT=0 +NVIDIA_DEVICE_COUNT=0 + +for dev in /dev/nvidia*; do + if [ -e "$dev" ]; then + NVIDIA_DEVICE_COUNT=$((NVIDIA_DEVICE_COUNT + 1)) + if check_user_device_access "$dev" "$POSTGRES_USER"; then + NVIDIA_ACCESS_COUNT=$((NVIDIA_ACCESS_COUNT + 1)) + HAS_NVIDIA_ACCESS=true + fi + fi +done + +# Summary of device access +if [ $DRI_DEVICE_COUNT -gt 0 ]; then + if [ $DRI_ACCESS_COUNT -eq $DRI_DEVICE_COUNT ]; then + echo "✅ User $POSTGRES_USER has access to all DRI devices ($DRI_ACCESS_COUNT/$DRI_DEVICE_COUNT)" + echo " VAAPI hardware acceleration should work properly." + else + echo "⚠️ User $POSTGRES_USER has limited access to DRI devices ($DRI_ACCESS_COUNT/$DRI_DEVICE_COUNT)" + echo " VAAPI hardware acceleration may not work properly." + echo " Consider adding $POSTGRES_USER to the 'video' and/or 'render' groups." + fi +fi + +if [ $NVIDIA_DEVICE_COUNT -gt 0 ]; then + if [ $NVIDIA_ACCESS_COUNT -eq $NVIDIA_DEVICE_COUNT ]; then + echo "✅ User $POSTGRES_USER has access to all NVIDIA devices ($NVIDIA_ACCESS_COUNT/$NVIDIA_DEVICE_COUNT)" + echo " NVIDIA hardware acceleration should work properly." + else + echo "⚠️ User $POSTGRES_USER has limited access to NVIDIA devices ($NVIDIA_ACCESS_COUNT/$NVIDIA_DEVICE_COUNT)" + echo " NVIDIA hardware acceleration may not work properly." + if [ "$NVIDIA_CONTAINER_TOOLKIT_FOUND" = false ]; then + echo " Consider adding $POSTGRES_USER to the 'video' group or use NVIDIA Container Toolkit." + fi + fi +fi + +# Check for traditional group memberships (as additional information) +USER_IN_VIDEO_GROUP=false +USER_IN_RENDER_GROUP=false + +if [ -n "$VIDEO_GID" ]; then + if id -nG "$POSTGRES_USER" 2>/dev/null | grep -qw "video"; then + USER_IN_VIDEO_GROUP=true + echo "ℹ️ User $POSTGRES_USER is in the 'video' group (GID $VIDEO_GID)." + fi +fi + +if [ -n "$RENDER_GID" ]; then + if id -nG "$POSTGRES_USER" 2>/dev/null | grep -qw "render"; then + USER_IN_RENDER_GROUP=true + echo "ℹ️ User $POSTGRES_USER is in the 'render' group (GID $RENDER_GID)." + fi +fi + +# Check if NVIDIA Container Toolkit is present through environment or CLI tool +# IMPORTANT: Only mark as found if both env vars AND actual NVIDIA devices exist +if [ "$NVIDIA_FOUND" = true ] && command -v nvidia-container-cli >/dev/null 2>&1; then + NVIDIA_CONTAINER_TOOLKIT_FOUND=true +# Check for environment variables set by NVIDIA Container Runtime, but only if NVIDIA hardware exists +elif [ "$NVIDIA_FOUND" = true ] && [ -n "$NVIDIA_VISIBLE_DEVICES" ] && [ -n "$NVIDIA_DRIVER_CAPABILITIES" ]; then + NVIDIA_CONTAINER_TOOLKIT_FOUND=true + echo "✅ NVIDIA Container Toolkit detected (via environment variables)." + echo " The container is properly configured with Docker Compose's 'driver: nvidia' syntax." +elif [ -n "$NVIDIA_VISIBLE_DEVICES" ] && [ -n "$NVIDIA_DRIVER_CAPABILITIES" ] && [ "$NVIDIA_FOUND" = false ]; then + NVIDIA_ENV_MISMATCH=true +fi + +# Removed duplicate video group checks here - consolidated into the earlier checks that include GID + +# Check NVIDIA Container Toolkit support +echo "🔍 Checking NVIDIA container runtime support..." + +# More reliable detection of NVIDIA Container Runtime +NVIDIA_RUNTIME_ACTIVE=false + +# Method 1: Check for nvidia-container-cli tool +if command -v nvidia-container-cli >/dev/null 2>&1; then + NVIDIA_RUNTIME_ACTIVE=true + echo "✅ NVIDIA Container Runtime detected (nvidia-container-cli found)." + + if nvidia-container-cli info >/dev/null 2>&1; then + echo "✅ NVIDIA container runtime is functional." + else + echo "⚠️ nvidia-container-cli found, but 'info' command failed. Runtime may be misconfigured." + fi +fi + +# Method 2: Check for NVIDIA Container Runtime specific files +if [ -e "/dev/.nv" ] || [ -e "/.nv" ] || [ -e "/.nvidia-container-runtime" ]; then + NVIDIA_RUNTIME_ACTIVE=true + echo "✅ NVIDIA Container Runtime files detected." +fi + +# Method 3: Check cgroup information for NVIDIA +if grep -q "nvidia" /proc/self/cgroup 2>/dev/null; then + NVIDIA_RUNTIME_ACTIVE=true + echo "✅ NVIDIA Container Runtime cgroups detected." +fi + +# Final verdict based on hardware AND runtime with improved messaging +if [ "$NVIDIA_FOUND" = true ] && ([ "$NVIDIA_RUNTIME_ACTIVE" = true ] || [ "$NVIDIA_CONTAINER_TOOLKIT_FOUND" = true ]); then + echo "✅ NVIDIA Container Runtime is properly configured with hardware access." +elif [ "$NVIDIA_FOUND" = true ] && [ "$NVIDIA_RUNTIME_ACTIVE" = false ] && [ "$NVIDIA_CONTAINER_TOOLKIT_FOUND" = false ]; then + echo "ℹ️ NVIDIA devices accessible via direct passthrough instead of Container Runtime." + echo " This works but consider using the 'deploy: resources: reservations: devices:' method in docker-compose." +elif [ "$NVIDIA_FOUND" = false ] && [ "$NVIDIA_RUNTIME_ACTIVE" = true ]; then + echo "⚠️ NVIDIA Container Runtime appears to be configured, but no NVIDIA devices found." + echo " Check that your host has NVIDIA drivers installed and GPUs are properly passed to the container." +elif [ "$DRI_DEVICES_FOUND" = true ] && [ "$NVIDIA_GPU_IN_LSPCI" = true ]; then + echo "ℹ️ Limited GPU access: Only DRI devices available for NVIDIA hardware." + echo " VAAPI acceleration may work but NVENC/CUDA won't be available." + echo " For full NVIDIA capabilities, configure the NVIDIA Container Runtime." +elif [ "$DRI_DEVICES_FOUND" = true ]; then + echo "ℹ️ Using Intel/AMD GPU hardware for acceleration via VAAPI." +else + echo "⚠️ No GPU acceleration devices detected. CPU-only transcoding will be used." +fi + +# Run nvidia-smi if available +if command -v nvidia-smi >/dev/null 2>&1; then + echo "🔍 Running nvidia-smi to verify GPU visibility..." + if nvidia-smi >/dev/null 2>&1; then + echo "✅ nvidia-smi successful - GPU is accessible to container!" + echo " This confirms hardware acceleration should be available to FFmpeg." + else + echo "⚠️ nvidia-smi command failed. GPU may not be properly mapped into container." + fi +else + echo "ℹ️ nvidia-smi not installed or not in PATH." +fi + +# Show relevant environment variables with contextual suggestions +echo "🔍 Checking GPU-related environment variables..." + +# Set flags based on device detection +DRI_DEVICES_FOUND=false +for dev in /dev/dri/renderD* /dev/dri/card*; do + if [ -e "$dev" ];then + DRI_DEVICES_FOUND=true + break + fi +done + +# Give contextual suggestions based on detected hardware +if [ "$DRI_DEVICES_FOUND" = true ]; then + # Detect Intel/AMD GPU model - skip this if we already detected GPUs earlier + if [ "$NVIDIA_GPU_IN_LSPCI" = false ] && [ "$INTEL_GPU_IN_LSPCI" = false ] && [ "$AMD_GPU_IN_LSPCI" = false ] && command -v lspci >/dev/null 2>&1; then + GPU_INFO=$(lspci -nn | grep -i "VGA\|Display" | head -1) + if [ -n "$GPU_INFO" ]; then + echo "🔍 Detected GPU: $GPU_INFO" + # Extract model for cleaner display in summary + GPU_MODEL=$(echo "$GPU_INFO" | sed -E 's/.*: (.*) \[.*/\1/' | sed 's/Corporation //' | sed 's/Technologies //') + fi + else + # Use already detected GPU model info + if [ "$NVIDIA_GPU_IN_LSPCI" = true ]; then + GPU_MODEL=$NVIDIA_MODEL + elif [ "$INTEL_GPU_IN_LSPCI" = true ]; then + GPU_MODEL=$INTEL_MODEL + elif [ "$AMD_GPU_IN_LSPCI" = true ]; then + GPU_MODEL=$AMD_MODEL + fi + fi + + if [ -n "$GPU_MODEL" ]; then + echo "🔍 GPU model: $GPU_MODEL" + fi + # Check for LIBVA_DRIVER_NAME environment variable + if [ -n "$LIBVA_DRIVER_NAME" ]; then + echo "ℹ️ LIBVA_DRIVER_NAME is set to '$LIBVA_DRIVER_NAME'" + echo " Note: If you experience issues with hardware acceleration, try removing this" + echo " environment variable to let the system auto-detect the appropriate driver." + else + # Check if we can detect the GPU type + if command -v lspci >/dev/null 2>&1; then + echo "ℹ️ VAAPI driver auto-detection is usually reliable. Settings below only needed if you experience issues." + + # Create variables to store recommended driver and supported methods + INTEL_RECOMMENDED_DRIVER="" + INTEL_SUPPORTS_QSV=false + + # Use the Intel model information we already captured + if [ "$INTEL_GPU_IN_LSPCI" = true ] && [ -n "$INTEL_MODEL" ]; then + # Check for newer Intel generations that use iHD + if echo "$INTEL_MODEL" | grep -q -E "Arc|Xe|Alchemist|Tiger|Alder|Raptor|Meteor|Gen1[2-9]"; then + echo "💡 Detected Intel GPU that supports iHD (e.g. Gen12+/Arc/Xe)" + echo " Recommended: LIBVA_DRIVER_NAME=iHD" + echo " Note: Only set this environment variable if hardware acceleration doesn't work by default" + INTEL_RECOMMENDED_DRIVER="iHD" + INTEL_SUPPORTS_QSV=true + elif echo "$INTEL_MODEL" | grep -q -E "Coffee|Whiskey|Comet|Gen11"; then + echo "💡 Detected Intel GPU that supports both i965 and iHD (e.g. Gen9.5/Gen11)" + echo " Preferred: LIBVA_DRIVER_NAME=iHD" + echo " Recommended: Try i965 only if iHD has compatibility issues" + echo " Note: Only set this environment variable if hardware acceleration doesn't work by default" + INTEL_RECOMMENDED_DRIVER="iHD" + INTEL_SUPPORTS_QSV=true + elif echo "$INTEL_MODEL" | grep -q -E "Haswell|Broadwell|Skylake|Kaby"; then + echo "💡 Detected Intel GPU that supports i965 (e.g. Gen9 and below)" + echo " Recommended: Set LIBVA_DRIVER_NAME=i965" + echo " Note: Only set this environment variable if hardware acceleration doesn't work by default" + INTEL_RECOMMENDED_DRIVER="i965" + # Older Intel GPUs support QSV through i965 driver but with more limitations + INTEL_SUPPORTS_QSV=false + else + # Generic Intel case - we're not fully confident in our recommendation + echo "💡 Unable to definitively identify Intel GPU generation" + echo " Try auto-detection first (no environment variable)" + echo " If issues occur: Try LIBVA_DRIVER_NAME=iHD first (newer GPUs)" + echo " If that fails: Try LIBVA_DRIVER_NAME=i965 (older GPUs)" + INTEL_RECOMMENDED_DRIVER="unknown" # Mark as unknown rather than assuming + INTEL_SUPPORTS_QSV="maybe" # Mark as maybe instead of assuming true + fi + elif [ "$AMD_GPU_IN_LSPCI" = true ]; then + echo "💡 If auto-detection fails: Set LIBVA_DRIVER_NAME=radeonsi for AMD GPUs" + echo " Note: Only set this environment variable if hardware acceleration doesn't work by default" + else + echo "ℹ️ Common VAAPI driver options if auto-detection fails:" + echo " - For modern Intel GPUs (Gen12+/Arc/Xe): LIBVA_DRIVER_NAME=iHD" + echo " - For older Intel GPUs: LIBVA_DRIVER_NAME=i965" + echo " - For AMD GPUs: LIBVA_DRIVER_NAME=radeonsi" + echo " Note: Only set these environment variables if hardware acceleration doesn't work by default" + fi + else + echo "ℹ️ Intel/AMD GPU detected. Auto-detection should work in most cases." + echo " If VAAPI doesn't work, you may need to set LIBVA_DRIVER_NAME manually." + fi + fi +fi + +# Check FFmpeg hardware acceleration support +echo "🔍 Checking FFmpeg hardware acceleration capabilities..." +if command -v ffmpeg >/dev/null 2>&1; then + HWACCEL=$(ffmpeg -hide_banner -hwaccels 2>/dev/null | grep -v "Hardware acceleration methods:" || echo "None found") + + # Initialize variables to store compatible and missing methods + COMPATIBLE_METHODS="" + MISSING_METHODS="" + + # Format the list of hardware acceleration methods in a more readable way + echo "🔍 Available FFmpeg hardware acceleration methods:" + echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" + + # Process the list into a more readable format with relevance indicators + if [ -n "$HWACCEL" ] && [ "$HWACCEL" != "None found" ]; then + # First, show methods compatible with detected hardware + echo " 📌 Compatible with your hardware:" + COMPATIBLE_FOUND=false + + for method in $HWACCEL; do + # Skip if it's just the header line or empty + if [ "$method" = "Hardware" ] || [ -z "$method" ]; then + continue + fi + + # Check if this method is relevant to detected hardware + IS_COMPATIBLE=false + DESCRIPTION="" + + if [ "$NVIDIA_FOUND" = true ] && [[ "$method" =~ ^(cuda|cuvid|nvenc|nvdec)$ ]]; then + IS_COMPATIBLE=true + DESCRIPTION="NVIDIA GPU acceleration" + elif [ "$INTEL_GPU_IN_LSPCI" = true ] && [ "$method" = "qsv" ] && [ "$INTEL_SUPPORTS_QSV" = true ]; then + IS_COMPATIBLE=true + DESCRIPTION="Intel QuickSync acceleration" + elif [ "$method" = "vaapi" ] && (([ "$INTEL_GPU_IN_LSPCI" = true ] || [ "$AMD_GPU_IN_LSPCI" = true ]) && [ "$DRI_DEVICES_FOUND" = true ]); then + IS_COMPATIBLE=true + if [ "$INTEL_GPU_IN_LSPCI" = true ]; then + DESCRIPTION="Intel VAAPI acceleration" + else + DESCRIPTION="AMD VAAPI acceleration" + fi + fi + + # Display compatible methods and store for summary + if [ "$IS_COMPATIBLE" = true ]; then + COMPATIBLE_FOUND=true + COMPATIBLE_METHODS="$COMPATIBLE_METHODS $method" + echo " ✅ $method - $DESCRIPTION" + fi + done + + if [ "$COMPATIBLE_FOUND" = false ]; then + echo " ❌ No compatible acceleration methods found for your hardware" + fi + + # Then show all other available methods + echo " 📌 Other available methods (not compatible with detected hardware):" + OTHER_FOUND=false + + for method in $HWACCEL; do + # Skip if it's just the header line or empty + if [ "$method" = "Hardware" ] || [ -z "$method" ]; then + continue + fi + + # Check if this method is relevant to detected hardware + IS_COMPATIBLE=false + + if [ "$NVIDIA_FOUND" = true ] && [[ "$method" =~ ^(cuda|cuvid|nvenc|nvdec)$ ]]; then + IS_COMPATIBLE=true + elif [ "$INTEL_GPU_IN_LSPCI" = true ] && [ "$method" = "qsv" ] && [ "$INTEL_SUPPORTS_QSV" = true ]; then + IS_COMPATIBLE=true + elif [ "$method" = "vaapi" ] && (([ "$INTEL_GPU_IN_LSPCI" = true ] || [ "$AMD_GPU_IN_LSPCI" = true ]) && [ "$DRI_DEVICES_FOUND" = true ]); then + IS_COMPATIBLE=true + fi + + # Display other methods that aren't compatible + if [ "$IS_COMPATIBLE" = false ]; then + OTHER_FOUND=true + echo " ℹ️ $method" + fi + done + + if [ "$OTHER_FOUND" = false ]; then + echo " None" + fi + + # Show expected methods that are missing + echo " 📌 Missing methods that should be available for your hardware:" + MISSING_FOUND=false + + # Check for NVIDIA methods if NVIDIA GPU is detected + if [ "$NVIDIA_FOUND" = true ]; then + EXPECTED_NVIDIA="cuda" # cuvid nvenc nvdec" keeping these in case future support is added + for method in $EXPECTED_NVIDIA; do + if ! echo "$HWACCEL" | grep -q "$method"; then + MISSING_FOUND=true + MISSING_METHODS="$MISSING_METHODS $method" + echo " ⚠️ $method - NVIDIA acceleration (missing but should be available)" + fi + done + fi + + # Check for Intel methods if Intel GPU is detected + if [ "$INTEL_GPU_IN_LSPCI" = true ] && [ "$DRI_DEVICES_FOUND" = true ]; then + if [ "$INTEL_SUPPORTS_QSV" = true ] && ! echo "$HWACCEL" | grep -q "qsv"; then + MISSING_FOUND=true + MISSING_METHODS="$MISSING_METHODS qsv" + echo " ⚠️ qsv - Intel QuickSync acceleration (missing but should be available)" + fi + + if ! echo "$HWACCEL" | grep -q "vaapi"; then + MISSING_FOUND=true + MISSING_METHODS="$MISSING_METHODS vaapi" + echo " ⚠️ vaapi - Intel VAAPI acceleration (missing but should be available)" + fi + fi + + # Check for AMD methods if AMD GPU is detected + if [ "$AMD_GPU_IN_LSPCI" = true ] && [ "$DRI_DEVICES_FOUND" = true ]; then + if ! echo "$HWACCEL" | grep -q "vaapi"; then + MISSING_FOUND=true + MISSING_METHODS="$MISSING_METHODS vaapi" + echo " ⚠️ vaapi - AMD VAAPI acceleration (missing but should be available)" + fi + fi + + if [ "$MISSING_FOUND" = false ]; then + echo " None - All expected methods are available" + fi + else + echo " ❌ No hardware acceleration methods found" + fi + echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" + + # Show hardware-appropriate method summary using the already gathered information + if [ -n "$COMPATIBLE_METHODS" ]; then + echo "✅ Hardware-appropriate acceleration methods available:$COMPATIBLE_METHODS" + fi + + # Show missing expected methods + if [ -n "$MISSING_METHODS" ]; then + echo "⚠️ Expected acceleration methods not found:$MISSING_METHODS" + echo " This might indicate missing libraries or improper driver configuration." + fi + + # Display specific cases of interest (simplify using previously captured information) + if [ "$NVIDIA_FOUND" = true ] && ! echo "$COMPATIBLE_METHODS" | grep -q "cuda\|nvenc\|cuvid"; then + echo "⚠️ NVIDIA GPU detected but no NVIDIA acceleration methods available." + echo " Ensure ffmpeg is built with NVIDIA support and required libraries are installed." + fi + + if (([ "$INTEL_GPU_IN_LSPCI" = true ] || [ "$AMD_GPU_IN_LSPCI" = true ]) && + [ "$DRI_DEVICES_FOUND" = true ] && ! echo "$COMPATIBLE_METHODS" | grep -q "vaapi"); then + echo "⚠️ Intel/AMD GPU detected but VAAPI acceleration not available." + echo " Ensure ffmpeg is built with VAAPI support and proper drivers are installed." + fi +else + echo "⚠️ FFmpeg not found in PATH." +fi + +# Provide a final summary of the hardware acceleration setup +echo "📋 ===================== SUMMARY =====================" + +# Identify which GPU type is active and working +if [ "$NVIDIA_FOUND" = true ] && (nvidia-smi >/dev/null 2>&1 || [ -n "$NVIDIA_VISIBLE_DEVICES" ]); then + if [ -n "$NVIDIA_MODEL" ]; then + echo "🔰 NVIDIA GPU: $NVIDIA_MODEL" + else + echo "🔰 NVIDIA GPU: ACTIVE (model detection unavailable)" + echo "ℹ️ Note: GPU model information couldn't be retrieved, but devices are present." + echo " This may be due to missing nvidia-smi tool or container limitations." + fi + + if [ "$NVIDIA_CONTAINER_TOOLKIT_FOUND" = true ]; then + echo "✅ NVIDIA Container Toolkit: CONFIGURED CORRECTLY" + elif [ -n "$NVIDIA_VISIBLE_DEVICES" ] && [ -n "$NVIDIA_DRIVER_CAPABILITIES" ]; then + echo "✅ NVIDIA Docker configuration: USING MODERN DEPLOYMENT" + else + echo "⚠️ NVIDIA setup method: DIRECT DEVICE MAPPING (functional but not optimal)" + fi + + # Add device accessibility status + if [ $NVIDIA_DEVICE_COUNT -gt 0 ]; then + if [ $NVIDIA_ACCESS_COUNT -eq $NVIDIA_DEVICE_COUNT ]; then + echo "✅ Device access: ALL NVIDIA DEVICES ACCESSIBLE ($NVIDIA_ACCESS_COUNT/$NVIDIA_DEVICE_COUNT)" + else + echo "⚠️ Device access: LIMITED NVIDIA DEVICE ACCESS ($NVIDIA_ACCESS_COUNT/$NVIDIA_DEVICE_COUNT)" + echo " Some hardware acceleration features may not work properly." + fi + fi + + # Display FFmpeg NVIDIA acceleration methods in more detail + if echo "$COMPATIBLE_METHODS" | grep -q "cuda\|nvenc\|cuvid"; then + echo "✅ FFmpeg NVIDIA acceleration: AVAILABLE" + + # Show detailed breakdown of available NVIDIA methods + NVIDIA_METHODS=$(echo "$COMPATIBLE_METHODS" | grep -o '\(cuda\|cuvid\|nvenc\|nvdec\)') + echo " Available NVIDIA methods: $NVIDIA_METHODS" + echo " Recommended for: Video transcoding with NVIDIA GPUs" + else + echo "⚠️ FFmpeg NVIDIA acceleration: NOT DETECTED" + if [ -n "$MISSING_METHODS" ]; then + echo " Missing methods that should be available: $MISSING_METHODS" + fi + fi +elif [ "$NVIDIA_GPU_IN_LSPCI" = true ] && [ "$DRI_DEVICES_FOUND" = true ]; then + # NVIDIA through DRI only (suboptimal but possible) + if [ -n "$NVIDIA_MODEL" ]; then + echo "🔰 NVIDIA GPU: $NVIDIA_MODEL (SUBOPTIMALLY CONFIGURED)" + else + echo "🔰 NVIDIA GPU: DETECTED BUT SUBOPTIMALLY CONFIGURED" + fi + echo "⚠️ Your NVIDIA GPU is only accessible through DRI devices" + echo " - VAAPI acceleration may work for some tasks" + echo " - NVENC/CUDA acceleration is NOT available" + + # Add device accessibility status + if [ $DRI_DEVICE_COUNT -gt 0 ]; then + if [ $DRI_ACCESS_COUNT -eq $DRI_DEVICE_COUNT ]; then + echo "✅ Device access: ALL DRI DEVICES ACCESSIBLE ($DRI_ACCESS_COUNT/$DRI_DEVICE_COUNT)" + echo " VAAPI acceleration should work properly." + else + echo "⚠️ Device access: LIMITED DRI DEVICE ACCESS ($DRI_ACCESS_COUNT/$DRI_DEVICE_COUNT)" + echo " VAAPI acceleration may not work properly." + fi + fi + + echo "💡 RECOMMENDATION: Use the proper NVIDIA container configuration:" + echo " deploy:" + echo " resources:" + echo " reservations:" + echo " devices:" + echo " - driver: nvidia" + echo " count: all" + echo " capabilities: [gpu]" + + if echo "$COMPATIBLE_METHODS" | grep -q "vaapi"; then + echo "✅ FFmpeg VAAPI acceleration: AVAILABLE (limited without NVENC)" + echo " VAAPI can be used for transcoding, but NVENC/CUDA would be more efficient" + else + echo "⚠️ FFmpeg VAAPI acceleration: NOT DETECTED" + fi +elif [ "$DRI_DEVICES_FOUND" = true ]; then + # Intel/AMD detection with model if available + if [ -n "$GPU_MODEL" ]; then + echo "🔰 GPU: $GPU_MODEL" + elif [ -n "$LIBVA_DRIVER_NAME" ]; then + echo "🔰 ${LIBVA_DRIVER_NAME^^} GPU: ACTIVE" + else + echo "🔰 INTEL/AMD GPU: ACTIVE (model detection unavailable)" + echo "ℹ️ Note: Basic GPU drivers appear to be loaded (device nodes exist), but" + echo " couldn't identify specific model. This doesn't necessarily indicate a problem." + fi + + # Add device accessibility status + if [ $DRI_DEVICE_COUNT -gt 0 ]; then + if [ $DRI_ACCESS_COUNT -eq $DRI_DEVICE_COUNT ]; then + echo "✅ Device access: ALL DRI DEVICES ACCESSIBLE ($DRI_ACCESS_COUNT/$DRI_DEVICE_COUNT)" + echo " VAAPI hardware acceleration should work properly." + else + echo "⚠️ Device access: LIMITED DRI DEVICE ACCESS ($DRI_ACCESS_COUNT/$DRI_DEVICE_COUNT)" + echo " VAAPI hardware acceleration may not work properly." + fi + fi + + # Display FFmpeg VAAPI acceleration method with more details + if echo "$COMPATIBLE_METHODS" | grep -q "vaapi"; then + echo "✅ FFmpeg VAAPI acceleration: AVAILABLE" + + # Add recommended usage information + echo " Recommended for: General video transcoding with Intel/AMD GPUs" + + # Add recommended driver information for Intel GPUs + if [ "$INTEL_GPU_IN_LSPCI" = true ] && [ -n "$INTEL_RECOMMENDED_DRIVER" ]; then + if [ "$INTEL_RECOMMENDED_DRIVER" = "unknown" ]; then + echo "ℹ️ Uncertain about recommended VAAPI driver for this Intel GPU" + echo " Auto-detection should work, but if issues occur try iHD or i965" + else + echo "ℹ️ Recommended VAAPI driver for this Intel GPU: $INTEL_RECOMMENDED_DRIVER" + fi + + if [ "$INTEL_SUPPORTS_QSV" = true ] && echo "$COMPATIBLE_METHODS" | grep -q "qsv"; then + echo "✅ QSV acceleration: AVAILABLE" + echo " Recommended for: Intel-specific optimized transcoding" + echo " Works best with: $INTEL_RECOMMENDED_DRIVER driver" + elif [ "$INTEL_SUPPORTS_QSV" = true ]; then + echo "ℹ️ QSV acceleration: NOT DETECTED (may be available with proper configuration)" + echo " Your Intel GPU supports QSV but it's not available in FFmpeg" + echo " Check if FFmpeg is built with QSV support" + elif [ "$INTEL_SUPPORTS_QSV" = "maybe" ]; then + echo "ℹ️ QSV acceleration: MAY BE AVAILABLE (depends on exact GPU model)" + fi + elif [ "$AMD_GPU_IN_LSPCI" = true ]; then + echo "ℹ️ Recommended VAAPI driver for AMD GPUs: radeonsi" + fi + else + echo "⚠️ FFmpeg VAAPI acceleration: NOT DETECTED" + if [ -n "$MISSING_METHODS" ]; then + echo " Missing methods that should be available: $MISSING_METHODS" + fi + fi +else + echo "❌ NO GPU ACCELERATION DETECTED" + echo "⚠️ Hardware acceleration is unavailable or misconfigured" +fi + +echo "📋 ==================================================" +echo "✅ GPU detection script complete." diff --git a/docker/init/99-init-dev.sh b/docker/init/99-init-dev.sh new file mode 100644 index 0000000..d2e6dd0 --- /dev/null +++ b/docker/init/99-init-dev.sh @@ -0,0 +1,33 @@ +#!/bin/bash + +if [ ! -e "/tmp/init" ]; then + echo "🚀 Development Mode - Setting up Frontend..." + + # Install Node.js + if ! command -v node 2>&1 >/dev/null + then + echo "=== setting up nodejs ===" + curl -sL https://deb.nodesource.com/setup_23.x -o /tmp/nodesource_setup.sh + bash /tmp/nodesource_setup.sh + apt-get update + apt-get install -y --no-install-recommends \ + nodejs + fi + + # Install frontend dependencies + cd /app/frontend && npm install + # Install Python dependencies using UV + cd /app && uv sync --python $UV_PROJECT_ENVIRONMENT/bin/python --no-install-project --no-dev + + # Install debugpy for remote debugging + if [ "$DISPATCHARR_DEBUG" = "true" ]; then + echo "=== setting up debugpy ===" + uv pip install --python $UV_PROJECT_ENVIRONMENT/bin/python debugpy + fi + + if [[ "$DISPATCHARR_ENV" = "dev" ]]; then + touch /tmp/init + fi +else + echo "Development mode initialization already done. Skipping dev setup." +fi diff --git a/docker/nginx.conf b/docker/nginx.conf new file mode 100644 index 0000000..c5cb429 --- /dev/null +++ b/docker/nginx.conf @@ -0,0 +1,99 @@ +proxy_cache_path /app/logo_cache levels=1:2 keys_zone=logo_cache:10m + inactive=24h use_temp_path=off; + +server { + listen NGINX_PORT; + listen [::]:NGINX_PORT; + + proxy_connect_timeout 75; + proxy_send_timeout 300; + proxy_read_timeout 300; + client_max_body_size 0; + + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Host $host:$server_port; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header Host $host; + proxy_set_header X-Forwarded-Port $server_port; + + # Serve Django via uWSGI + location / { + include uwsgi_params; + uwsgi_pass unix:/app/uwsgi.sock; + } + + location /assets/ { + root /app/static; + } + + location /static/ { + root /app; + } + + location /logos/ { + root /data; + } + + # Internal location for X-Accel-Redirect backup downloads + # Django handles auth, nginx serves the file directly + location /protected-backups/ { + internal; + alias /data/backups/; + } + + location ~ ^/api/channels/logos/(?\d+)/cache/ { + proxy_pass http://127.0.0.1:5656; + proxy_cache logo_cache; + proxy_cache_key "$scheme$request_uri"; # Cache per logo URL + proxy_cache_valid 200 24h; # Cache for 24 hours + proxy_cache_use_stale error timeout updating; # Serve stale if Django is slow + } + + location ~ ^/api/vod/vodlogos/(?\d+)/cache/ { + proxy_pass http://127.0.0.1:5656; + proxy_cache logo_cache; + proxy_cache_key "$scheme$request_uri"; # Cache per logo URL + proxy_cache_valid 200 24h; # Cache for 24 hours + proxy_cache_use_stale error timeout updating; # Serve stale if Django is slow + } + + # admin disabled when not in dev mode + location ~ ^/admin/?$ { + return 301 /login; + } + + # Route HDHR request to Django + location /hdhr { + include uwsgi_params; + uwsgi_pass unix:/app/uwsgi.sock; + } + + # Serve FFmpeg streams efficiently + location /output/stream/ { + proxy_pass http://127.0.0.1:5656; + proxy_buffering off; + proxy_set_header Connection keep-alive; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header Host $host; + } + + # WebSockets for real-time communication + location /ws/ { + proxy_pass http://127.0.0.1:8001; + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "Upgrade"; + } + + # Route TS proxy requests to the dedicated instance + location /proxy/ { + include uwsgi_params; + uwsgi_pass unix:/app/uwsgi.sock; + uwsgi_buffering off; + uwsgi_read_timeout 300s; + uwsgi_send_timeout 300s; + client_max_body_size 0; + } +} diff --git a/docker/tests/test-puid-pgid.sh b/docker/tests/test-puid-pgid.sh new file mode 100644 index 0000000..4f46045 --- /dev/null +++ b/docker/tests/test-puid-pgid.sh @@ -0,0 +1,1517 @@ +#!/bin/bash +# +# Integration test suite for PUID/PGID Docker init changes. +# Validates runtime behavior across fresh installs, upgrades, restarts, +# storage configurations, and deployment modes. +# +# Prerequisites: +# - Docker Desktop (or Docker Engine) running +# - Internet access (pulls postgres:16, postgres:17, redis:latest, +# and the Dispatcharr release image for upgrade tests) +# - ~10-15 minutes for a full run (PG upgrade scenarios dominate) +# +# Usage: +# cd +# bash docker/tests/test-puid-pgid.sh [--skip-build] [--keep-on-fail] [scenario_name] +# +# Options: +# --skip-build Skip Docker image build (use existing dispatcharr:puid-test image) +# --keep-on-fail Don't clean up containers/volumes on failure (for debugging) +# scenario_name Run only the named scenario (e.g., "fresh_default") +# +# Examples: +# bash docker/tests/test-puid-pgid.sh # Full run (build + all 20 scenarios) +# bash docker/tests/test-puid-pgid.sh fresh_default # Run one scenario +# bash docker/tests/test-puid-pgid.sh --skip-build # Reuse existing image +# bash docker/tests/test-puid-pgid.sh --keep-on-fail # Keep resources for debugging +# +# Scenarios: +# fresh_default Fresh AIO install, default PUID/PGID (1000:1000) +# fresh_custom_puid Fresh AIO install, PUID=1500 PGID=1500 +# upgrade_explicit_puid Upgrade from old UID 102 data with explicit PUID=1000 +# upgrade_auto_adapt Upgrade from old UID 102 data, no PUID set (auto-detect) +# restart_idempotent Container restart on existing data (no unnecessary chown) +# puid_change Change PUID between restarts (1000 -> 2000) +# uid_collision_102 PUID=102 (collides with postgres system user) +# puid_zero PUID=0 rejected (PostgreSQL can't run as root) +# puid_non_numeric PUID=abc rejected (must be positive integer) +# bind_mount Fresh install on bind mount (local filesystem) +# bind_mount_upgrade Upgrade from UID 102 on bind mount +# bind_mount_auto_adapt Auto-adapt PUID on bind mount (no migration) +# modular_mode External PostgreSQL + Redis (skip internal PG setup) +# custom_postgres_user Custom POSTGRES_USER=myapp +# custom_port Custom POSTGRES_PORT=5433 +# tmpfs_volume Ephemeral tmpfs storage +# pg_major_upgrade PostgreSQL 16 -> 17 major version upgrade + ownership migration +# pg_upgrade_post_puid PG 16 -> 17 upgrade on post-PUID data (install user = dispatch) +# e2e_web_ui Full HTTP stack verification (nginx -> uwsgi -> Django) +# readonly_rootfs Read-only root filesystem (security hardened) +# +# Exit codes: +# 0 All tests passed +# 1 One or more tests failed (or build failed) + +set -uo pipefail + +# Prevent Git Bash (MINGW) from converting Unix paths like /data/db to +# C:/Program Files/Git/data/db when passing arguments to docker exec. +export MSYS_NO_PATHCONV=1 + +############################################################################### +# Configuration +############################################################################### +IMAGE_NAME="dispatcharr:puid-test" +RELEASE_IMAGE="ghcr.io/dispatcharr/dispatcharr:latest" +BASE_IMAGE="ghcr.io/dispatcharr/dispatcharr:base" +TEST_PREFIX="puid_test" +STARTUP_TIMEOUT=180 # seconds to wait for container startup +SKIP_BUILD=false +KEEP_ON_FAIL=false +SINGLE_SCENARIO="" +USE_RELEASE_IMAGE=false +PASS=0 +FAIL=0 +SKIP=0 +ERRORS=() + +# Colors (disabled if not a terminal) +if [ -t 1 ]; then + RED='\033[0;31m'; GREEN='\033[0;32m'; YELLOW='\033[1;33m' + CYAN='\033[0;36m'; BOLD='\033[1m'; NC='\033[0m' +else + RED=''; GREEN=''; YELLOW=''; CYAN=''; BOLD=''; NC='' +fi + +############################################################################### +# Parse arguments +############################################################################### +for arg in "$@"; do + case "$arg" in + --skip-build) SKIP_BUILD=true ;; + --keep-on-fail) KEEP_ON_FAIL=true ;; + -*) echo "Unknown option: $arg"; exit 1 ;; + *) SINGLE_SCENARIO="$arg" ;; + esac +done + +############################################################################### +# Helpers +############################################################################### +log_pass() { echo -e " ${GREEN}✅ $1${NC}"; ((PASS++)); } +log_fail() { echo -e " ${RED}❌ $1${NC}"; ((FAIL++)); ERRORS+=("[$CURRENT_SCENARIO] $1"); } +log_skip() { echo -e " ${YELLOW}⏭️ $1${NC}"; ((SKIP++)); } +log_info() { echo -e " ${CYAN}ℹ️ $1${NC}"; } +section() { echo -e "\n${BOLD}━━━ $1 ━━━${NC}"; SCENARIO_FAIL_BEFORE=$FAIL; } + +CURRENT_SCENARIO="" +CLEANUP_ITEMS=() + +# Track resources for cleanup +track_container() { CLEANUP_ITEMS+=("container:$1"); } +track_volume() { CLEANUP_ITEMS+=("volume:$1"); } +track_network() { CLEANUP_ITEMS+=("network:$1"); } + +# Create a fresh volume, removing any stale one from a previous run +fresh_volume() { + local vol="$1" + docker rm -f $(docker ps -aq --filter "volume=${vol}") 2>/dev/null || true + docker volume rm "$vol" 2>/dev/null || true + docker volume create "$vol" >/dev/null + track_volume "$vol" +} + +cleanup_scenario() { + if [ "$KEEP_ON_FAIL" = true ] && [ ${#ERRORS[@]} -gt 0 ]; then + log_info "Keeping resources for debugging (--keep-on-fail)" + CLEANUP_ITEMS=() + return + fi + for item in "${CLEANUP_ITEMS[@]}"; do + local type="${item%%:*}" + local name="${item#*:}" + case "$type" in + container) docker stop "$name" 2>/dev/null; docker rm -f "$name" 2>/dev/null ;; + volume) docker volume rm "$name" 2>/dev/null ;; + network) docker network rm "$name" 2>/dev/null ;; + esac + done + CLEANUP_ITEMS=() +} + +# Ensure cleanup on script exit +trap 'cleanup_scenario' EXIT + +# Wait for container startup (looks for uwsgi or a known failure) +wait_for_ready() { + local name="$1" + local timeout="${2:-$STARTUP_TIMEOUT}" + local elapsed=0 + + while [ $elapsed -lt $timeout ]; do + # Check if container is still running + if ! docker ps -q -f "name=^${name}$" 2>/dev/null | grep -q .; then + echo " Container $name exited unexpectedly" + return 1 + fi + # Success: uwsgi started + if docker logs "$name" 2>&1 | grep -q "uwsgi started with PID"; then + return 0 + fi + # Known fatal: our error handler + if docker logs "$name" 2>&1 | grep -q "ERROR: Cannot update ownership"; then + echo " Container hit ownership error (expected in some tests)" + return 1 + fi + sleep 3 + ((elapsed+=3)) + done + echo " Timeout (${timeout}s) waiting for $name" + return 1 +} + +# Verify file/directory ownership +check_ownership() { + local container="$1" path="$2" expected_uid="$3" expected_gid="$4" + local actual + actual=$(docker exec "$container" stat -c '%u:%g' "$path" 2>/dev/null) + if [ "$actual" = "${expected_uid}:${expected_gid}" ]; then + log_pass "Ownership $path = $actual" + else + log_fail "Ownership $path: expected ${expected_uid}:${expected_gid}, got ${actual:-}" + fi +} + +# Verify file permissions (octal) +check_permissions() { + local container="$1" path="$2" expected="$3" + local actual + actual=$(docker exec "$container" stat -c '%a' "$path" 2>/dev/null) + if [ "$actual" = "$expected" ]; then + log_pass "Permissions $path = $actual" + else + log_fail "Permissions $path: expected $expected, got ${actual:-}" + fi +} + +# Verify PostgreSQL is accessible +check_pg_accessible() { + local container="$1" os_user="$2" db="${3:-dispatcharr}" port="${4:-5432}" + if docker exec "$container" su - "$os_user" -c \ + "psql -d $db -p $port -tAc 'SELECT 1;'" 2>/dev/null | grep -q 1; then + log_pass "PostgreSQL accessible as OS user '$os_user' (db=$db)" + else + log_fail "PostgreSQL not accessible as OS user '$os_user' (db=$db)" + fi +} + +# Verify a PG role exists with superuser +check_role_superuser() { + local container="$1" os_user="$2" role="$3" + local result + result=$(docker exec "$container" su - "$os_user" -c \ + "psql -d postgres -p 5432 -tAc \"SELECT rolsuper FROM pg_roles WHERE rolname='$role';\"" 2>/dev/null | tr -d ' ') + if [ "$result" = "t" ]; then + log_pass "PG role '$role' is superuser" + else + log_fail "PG role '$role' not superuser (got: '${result:-}')" + fi +} + +# Capture docker logs to a temp file (avoids pipe issues on Windows/Docker Desktop) +_capture_logs() { + local container="$1" logfile="$2" + docker logs "$container" > "$logfile" 2>&1 +} + +# Verify no permission errors in logs +check_no_permission_errors() { + local container="$1" + local tmplog; tmplog=$(mktemp) + _capture_logs "$container" "$tmplog" + local errors + errors=$(grep -iE "permission denied|operation not permitted" "$tmplog" \ + | grep -v "GPU acceleration" | grep -v "Warning:" | head -5) + rm -f "$tmplog" + if [ -n "$errors" ]; then + log_fail "Permission errors in logs:" + echo "$errors" | while read -r line; do echo " $line"; done + else + log_pass "No permission errors in logs" + fi +} + +# Verify Django migrations completed (search full log, not just tail) +check_migrations_done() { + local container="$1" + local tmplog; tmplog=$(mktemp) + _capture_logs "$container" "$tmplog" + if grep -qE "Running migrations|No migrations to apply|Operations to perform|static files copied|Applying .+\.\.\. OK" "$tmplog"; then + log_pass "Django migrations completed" + elif grep -q "uwsgi started with PID" "$tmplog"; then + # uwsgi starts AFTER migrations — if it's running, migrations succeeded + log_pass "Django migrations completed (confirmed via uwsgi startup)" + else + log_fail "Django migrations did not complete" + fi + rm -f "$tmplog" +} + +# Check that a log message appears (or does not) +check_log_contains() { + local container="$1" pattern="$2" description="$3" + local tmplog; tmplog=$(mktemp) + _capture_logs "$container" "$tmplog" + if grep -q "$pattern" "$tmplog"; then + log_pass "$description" + else + log_fail "$description (pattern not found: $pattern)" + fi + rm -f "$tmplog" +} +check_log_absent() { + local container="$1" pattern="$2" description="$3" + local tmplog; tmplog=$(mktemp) + _capture_logs "$container" "$tmplog" + if grep -q "$pattern" "$tmplog"; then + log_fail "$description (unexpected pattern found: $pattern)" + else + log_pass "$description" + fi + rm -f "$tmplog" +} + +# Verify web UI responds (nginx -> uwsgi -> Django stack is functional). +# Retries briefly since uwsgi may still be connecting after startup log. +check_web_ui() { + local container="$1" port="${2:-9191}" + local retries=5 status="" + for ((i=1; i<=retries; i++)); do + status=$(docker exec "$container" python3 -c " +import urllib.request, urllib.error +try: + r = urllib.request.urlopen('http://localhost:$port/', timeout=10) + print(r.status) +except urllib.error.HTTPError as e: + print(e.code) +except: + print(0) +" 2>/dev/null | tr -d '[:space:]') + if [ -n "$status" ] && [ "$status" != "0" ] 2>/dev/null; then + break + fi + sleep 2 + done + if [ -n "$status" ] && [ "$status" != "0" ] && [ "$status" -lt 500 ] 2>/dev/null; then + log_pass "Web UI responds (HTTP $status)" + else + log_fail "Web UI not responding (got: ${status:-})" + fi +} + +# Dump logs on failure (uses per-scenario tracking) +SCENARIO_FAIL_BEFORE=0 +dump_logs_on_fail() { + local container="$1" + if [ $FAIL -gt $SCENARIO_FAIL_BEFORE ]; then + echo -e "\n ${RED}--- Last 60 lines of logs ---${NC}" + docker logs "$container" 2>&1 | tail -60 | sed 's/^/ | /' + echo -e " ${RED}--- End logs ---${NC}" + fi +} + +# Create old-style PostgreSQL data by running the actual release image. +# This is the most realistic simulation of an upgrade — the release image +# runs its own entrypoint, initializing PG as the postgres system user +# with the real init scripts that users currently have. +setup_old_pg_data() { + local volume="$1" + local name="${TEST_PREFIX}_old_setup" + log_info "Creating old-style data using release image ($RELEASE_IMAGE)..." + + docker rm -f "$name" 2>/dev/null + docker run -d --name "$name" \ + -e DISPATCHARR_ENV=aio \ + -v "${volume}:/data" \ + "$RELEASE_IMAGE" >/dev/null + + # Wait for the release image to fully initialize (PG running + migrations) + local elapsed=0 + while [ $elapsed -lt $STARTUP_TIMEOUT ]; do + if docker logs "$name" 2>&1 | grep -q "uwsgi started with PID"; then + log_info "Release image initialized successfully" + break + fi + if ! docker ps -q -f "name=^${name}$" 2>/dev/null | grep -q .; then + log_info "Release image exited during init (checking data...)" + break + fi + sleep 3; ((elapsed+=3)) + done + + docker stop "$name" >/dev/null 2>&1 + docker rm "$name" >/dev/null 2>&1 + + # Verify data was created (use --entrypoint to avoid running full app) + local owner + owner=$(docker run --rm --entrypoint stat -v "${volume}:/data" "$IMAGE_NAME" \ + -c '%u:%g' /data/db/PG_VERSION 2>/dev/null) + log_info "Old data owner: ${owner:-}" +} + +# Fallback: create old-style data manually (if release image unavailable) +setup_old_pg_data_manual() { + local volume="$1" + log_info "Initializing old-style PG data manually (UID 102, postgres superuser)..." + docker run --rm --entrypoint bash -v "${volume}:/data" "$IMAGE_NAME" -c ' + PG_VER=$(ls /usr/lib/postgresql/ | sort -V | tail -n 1) + PG_BIN=/usr/lib/postgresql/$PG_VER/bin + mkdir -p /data/db + chown -R postgres:postgres /data/db + chmod 700 /data/db + su - postgres -c "$PG_BIN/initdb -D /data/db" + su - postgres -c "$PG_BIN/pg_ctl -D /data/db start -w -o \"-c port=5432\"" + su - postgres -c "psql -p 5432 -c \"CREATE ROLE dispatch WITH LOGIN PASSWORD '\''secret'\'';\"" + su - postgres -c "createdb -p 5432 --encoding=UTF8 --owner=dispatch dispatcharr" + su - postgres -c "$PG_BIN/pg_ctl -D /data/db stop -w" + echo "Old PG data ready: $(stat -c "%u:%g" /data/db/PG_VERSION) on PG_VERSION" + ' +} + +############################################################################### +# Test Scenarios +############################################################################### + +# Verifies a clean install with no PUID/PGID set defaults to 1000:1000. +# Checks: ownership, permissions, PG access, role, sentinel, migrations. +test_fresh_default() { + CURRENT_SCENARIO="fresh_default" + section "Fresh install — default config (no PUID/PGID)" + + local name="${TEST_PREFIX}_fresh_def" + local vol="${name}_data" + cleanup_scenario + fresh_volume "$vol" + track_container "$name" + + docker run -d --name "$name" \ + -e DISPATCHARR_ENV=aio \ + -v "${vol}:/data" \ + "$IMAGE_NAME" >/dev/null + + if wait_for_ready "$name"; then + check_ownership "$name" "/data/db" "1000" "1000" + check_ownership "$name" "/data/db/PG_VERSION" "1000" "1000" + check_ownership "$name" "/data/db/pg_hba.conf" "1000" "1000" + check_permissions "$name" "/data/db" "700" + check_pg_accessible "$name" "dispatch" + check_role_superuser "$name" "dispatch" "dispatch" + check_no_permission_errors "$name" + check_migrations_done "$name" + + # Verify ownership sentinel was created + local sentinel_val + sentinel_val=$(docker exec "$name" cat /data/db/.owner_puid 2>/dev/null | tr -d '[:space:]') + if [ "$sentinel_val" = "1000:1000" ]; then + log_pass "Ownership sentinel created (1000:1000)" + else + log_fail "Ownership sentinel: expected 1000:1000, got ${sentinel_val:-}" + fi + else + log_fail "Container failed to start" + fi + dump_logs_on_fail "$name" + cleanup_scenario +} + +# Verifies fresh install with explicitly set PUID/PGID (non-default). +# Checks: ownership matches 1500:1500, PG accessible, role created. +test_fresh_custom_puid() { + CURRENT_SCENARIO="fresh_custom_puid" + section "Fresh install — PUID=1500 PGID=1500" + + local name="${TEST_PREFIX}_fresh_puid" + local vol="${name}_data" + cleanup_scenario + fresh_volume "$vol" + track_container "$name" + + docker run -d --name "$name" \ + -e DISPATCHARR_ENV=aio \ + -e PUID=1500 -e PGID=1500 \ + -v "${vol}:/data" \ + "$IMAGE_NAME" >/dev/null + + if wait_for_ready "$name"; then + check_ownership "$name" "/data/db" "1500" "1500" + check_ownership "$name" "/data/db/PG_VERSION" "1500" "1500" + check_ownership "$name" "/data/db/pg_hba.conf" "1500" "1500" + check_pg_accessible "$name" "dispatch" + check_role_superuser "$name" "dispatch" "dispatch" + check_no_permission_errors "$name" + check_migrations_done "$name" + else + log_fail "Container failed to start" + fi + dump_logs_on_fail "$name" + cleanup_scenario +} + +# Simulates upgrading from pre-PUID image (data owned by UID 102) with +# explicit PUID=1000. Verifies ownership migrates, roles are promoted, +# and the postgres role is preserved for rollback compatibility. +test_upgrade_explicit_puid() { + CURRENT_SCENARIO="upgrade_explicit_puid" + section "Upgrade — old UID 102 data, explicit PUID=1000" + + local name="${TEST_PREFIX}_upg_puid" + local vol="${name}_data" + cleanup_scenario + fresh_volume "$vol" + track_container "$name" + + if [ "$USE_RELEASE_IMAGE" = true ]; then + setup_old_pg_data "$vol" + else + setup_old_pg_data_manual "$vol" + fi + + docker run -d --name "$name" \ + -e DISPATCHARR_ENV=aio \ + -e PUID=1000 -e PGID=1000 \ + -v "${vol}:/data" \ + "$IMAGE_NAME" >/dev/null + + if wait_for_ready "$name"; then + # Ownership should have migrated to 1000:1000 + check_ownership "$name" "/data/db" "1000" "1000" + check_ownership "$name" "/data/db/PG_VERSION" "1000" "1000" + check_ownership "$name" "/data/db/pg_hba.conf" "1000" "1000" + check_permissions "$name" "/data/db" "700" + check_pg_accessible "$name" "dispatch" + check_role_superuser "$name" "dispatch" "dispatch" + # Rollback compatibility: postgres role still superuser + check_role_superuser "$name" "dispatch" "postgres" + check_no_permission_errors "$name" + check_migrations_done "$name" + check_log_contains "$name" "Migrating PostgreSQL data ownership" \ + "Ownership migration logged" + check_log_contains "$name" "Application role configured" \ + "Role setup executed" + else + log_fail "Container failed to start" + fi + dump_logs_on_fail "$name" + cleanup_scenario +} + +# Simulates upgrade without PUID set. Auto-adapt should detect the +# existing data owner (UID 102) and skip ownership migration entirely. +test_upgrade_auto_adapt() { + CURRENT_SCENARIO="upgrade_auto_adapt" + section "Upgrade — old UID 102 data, no PUID (auto-adapt)" + + local name="${TEST_PREFIX}_upg_auto" + local vol="${name}_data" + cleanup_scenario + fresh_volume "$vol" + track_container "$name" + + if [ "$USE_RELEASE_IMAGE" = true ]; then + setup_old_pg_data "$vol" + else + setup_old_pg_data_manual "$vol" + fi + + # No PUID/PGID — should auto-adapt to data owner (UID 102) + docker run -d --name "$name" \ + -e DISPATCHARR_ENV=aio \ + -v "${vol}:/data" \ + "$IMAGE_NAME" >/dev/null + + if wait_for_ready "$name"; then + # Data should stay at original UID — no migration. GID depends on + # the postgres group GID in the release image (typically 104). + local actual_owner + actual_owner=$(docker exec "$name" stat -c '%u:%g' /data/db/PG_VERSION 2>/dev/null) + local expected_uid="102" + local actual_uid="${actual_owner%%:*}" + if [ "$actual_uid" = "$expected_uid" ]; then + log_pass "Ownership /data/db/PG_VERSION UID = $actual_uid (auto-adapted)" + else + log_fail "Ownership /data/db/PG_VERSION UID: expected $expected_uid, got $actual_uid" + fi + check_pg_accessible "$name" "dispatch" + check_no_permission_errors "$name" + check_migrations_done "$name" + check_log_contains "$name" "PUID not set" \ + "Auto-adapt logged" + check_log_absent "$name" "Migrating PostgreSQL data ownership" \ + "No ownership migration (correctly skipped)" + else + log_fail "Container failed to start" + fi + dump_logs_on_fail "$name" + cleanup_scenario +} + +# Verifies that restarting a container on existing data is idempotent: +# no unnecessary chown, no migration logged, sentinel skip works. +test_restart_idempotent() { + CURRENT_SCENARIO="restart_idempotent" + section "Container restart — idempotent (same PUID)" + + local name="${TEST_PREFIX}_restart" + local vol="${name}_data" + cleanup_scenario + fresh_volume "$vol" + track_container "$name" + + # First run + docker run -d --name "$name" \ + -e DISPATCHARR_ENV=aio \ + -e PUID=1000 -e PGID=1000 \ + -v "${vol}:/data" \ + "$IMAGE_NAME" >/dev/null + + if ! wait_for_ready "$name"; then + log_fail "First run failed to start" + dump_logs_on_fail "$name" + cleanup_scenario + return + fi + log_pass "First run started successfully" + + # Stop and restart + docker stop "$name" >/dev/null + docker rm "$name" >/dev/null + + docker run -d --name "$name" \ + -e DISPATCHARR_ENV=aio \ + -e PUID=1000 -e PGID=1000 \ + -v "${vol}:/data" \ + "$IMAGE_NAME" >/dev/null + track_container "$name" + + if wait_for_ready "$name"; then + check_ownership "$name" "/data/db/PG_VERSION" "1000" "1000" + check_ownership "$name" "/data/db/pg_hba.conf" "1000" "1000" + check_pg_accessible "$name" "dispatch" + check_no_permission_errors "$name" + check_migrations_done "$name" + check_log_absent "$name" "Migrating PostgreSQL data ownership" \ + "No migration on restart" + else + log_fail "Restart failed" + fi + dump_logs_on_fail "$name" + cleanup_scenario +} + +# Verifies that changing PUID between restarts triggers ownership migration. +# First run: PUID=1000. Second run: PUID=2000 — should chown all PG data. +test_puid_change() { + CURRENT_SCENARIO="puid_change" + section "PUID change between restarts (1000 → 2000)" + + local name="${TEST_PREFIX}_puidchg" + local vol="${name}_data" + cleanup_scenario + fresh_volume "$vol" + track_container "$name" + + # First run with PUID=1000 + docker run -d --name "$name" \ + -e DISPATCHARR_ENV=aio \ + -e PUID=1000 -e PGID=1000 \ + -v "${vol}:/data" \ + "$IMAGE_NAME" >/dev/null + + if ! wait_for_ready "$name"; then + log_fail "First run (PUID=1000) failed" + dump_logs_on_fail "$name" + cleanup_scenario + return + fi + log_pass "First run (PUID=1000) started" + docker stop "$name" >/dev/null; docker rm "$name" >/dev/null + + # Second run with PUID=2000 + docker run -d --name "$name" \ + -e DISPATCHARR_ENV=aio \ + -e PUID=2000 -e PGID=2000 \ + -v "${vol}:/data" \ + "$IMAGE_NAME" >/dev/null + track_container "$name" + + if wait_for_ready "$name"; then + check_ownership "$name" "/data/db" "2000" "2000" + check_ownership "$name" "/data/db/PG_VERSION" "2000" "2000" + check_ownership "$name" "/data/db/pg_hba.conf" "2000" "2000" + check_pg_accessible "$name" "dispatch" + check_role_superuser "$name" "dispatch" "dispatch" + check_no_permission_errors "$name" + check_migrations_done "$name" + check_log_contains "$name" "Migrating PostgreSQL data ownership" \ + "Ownership migration logged for PUID change" + else + log_fail "Second run (PUID=2000) failed" + fi + dump_logs_on_fail "$name" + cleanup_scenario +} + +# Verifies PUID=102 (which collides with the postgres system user UID). +# 01-user-setup.sh renames the postgres user to $POSTGRES_USER — this +# should be harmless since all operations use $POSTGRES_USER, not 'postgres'. +test_uid_collision_102() { + CURRENT_SCENARIO="uid_collision_102" + section "PUID=102 — collision with postgres system user" + + local name="${TEST_PREFIX}_uid102" + local vol="${name}_data" + cleanup_scenario + fresh_volume "$vol" + track_container "$name" + + docker run -d --name "$name" \ + -e DISPATCHARR_ENV=aio \ + -e PUID=102 -e PGID=102 \ + -v "${vol}:/data" \ + "$IMAGE_NAME" >/dev/null + + if wait_for_ready "$name"; then + check_ownership "$name" "/data/db" "102" "102" + check_ownership "$name" "/data/db/PG_VERSION" "102" "102" + check_ownership "$name" "/data/db/pg_hba.conf" "102" "102" + check_pg_accessible "$name" "dispatch" + check_no_permission_errors "$name" + check_migrations_done "$name" + log_pass "PUID=102 collision handled correctly" + else + log_fail "Container failed to start with PUID=102" + fi + dump_logs_on_fail "$name" + cleanup_scenario +} + +# Verifies PUID=0 is rejected early with a clear error message. +# PostgreSQL refuses to run as root, and reassigning UID 0 would +# rename the root user inside the container. +test_puid_zero() { + CURRENT_SCENARIO="puid_zero" + section "PUID=0 — rejected (PostgreSQL cannot run as root)" + + local name="${TEST_PREFIX}_uid0" + local vol="${name}_data" + cleanup_scenario + fresh_volume "$vol" + track_container "$name" + + docker run -d --name "$name" \ + -e DISPATCHARR_ENV=aio \ + -e PUID=0 -e PGID=0 \ + -v "${vol}:/data" \ + "$IMAGE_NAME" >/dev/null + + # Container should exit quickly with an error + sleep 5 + + if docker ps -q -f "name=^${name}$" 2>/dev/null | grep -q .; then + log_fail "Container should have exited with PUID=0" + else + log_pass "Container exited (expected with PUID=0)" + fi + + check_log_contains "$name" "PUID=0 or PGID=0 is not supported" \ + "Clear error message for PUID=0" + check_log_absent "$name" "Initializing PostgreSQL" \ + "PostgreSQL init was not attempted" + + dump_logs_on_fail "$name" + cleanup_scenario +} + +# Verifies non-numeric PUID/PGID values are rejected early with +# a clear error message before any user/group manipulation. +test_puid_non_numeric() { + CURRENT_SCENARIO="puid_non_numeric" + section "PUID=abc — rejected (must be positive integer)" + + local name="${TEST_PREFIX}_nonnumeric" + local vol="${name}_data" + cleanup_scenario + fresh_volume "$vol" + track_container "$name" + + docker run -d --name "$name" \ + -e DISPATCHARR_ENV=aio \ + -e PUID=abc -e PGID=xyz \ + -v "${vol}:/data" \ + "$IMAGE_NAME" >/dev/null + + sleep 5 + + if docker ps -q -f "name=^${name}$" 2>/dev/null | grep -q .; then + log_fail "Container should have exited with non-numeric PUID" + else + log_pass "Container exited (expected with non-numeric PUID)" + fi + + check_log_contains "$name" "PUID and PGID must be positive integers" \ + "Clear error message for non-numeric PUID" + check_log_absent "$name" "Initializing PostgreSQL" \ + "PostgreSQL init was not attempted" + + dump_logs_on_fail "$name" + cleanup_scenario +} + +# Verifies fresh install works on a bind mount (host directory). +# Uses Docker-managed /tmp to avoid Windows path conversion issues. +test_bind_mount() { + CURRENT_SCENARIO="bind_mount" + section "Bind mount — local filesystem" + + local name="${TEST_PREFIX}_bind" + local hostdir + # Create a temp directory for the bind mount + hostdir=$(docker run --rm "$IMAGE_NAME" bash -c "mktemp -d /tmp/puid_test_bind.XXXXXX" 2>/dev/null) + if [ -z "$hostdir" ]; then + # Fallback: create on Windows host via Docker + hostdir="/tmp/puid_test_bind_$$" + fi + + cleanup_scenario + track_container "$name" + + # Use a Docker-managed temp dir to avoid Windows path issues + # Create the bind mount dir inside a helper container, then use it + docker run --rm -v /tmp:/hosttemp "$IMAGE_NAME" bash -c " + mkdir -p /hosttemp/puid_test_bind_$$ + chmod 777 /hosttemp/puid_test_bind_$$ + " 2>/dev/null + local bind_path="/tmp/puid_test_bind_$$" + + docker run -d --name "$name" \ + -e DISPATCHARR_ENV=aio \ + -e PUID=1000 -e PGID=1000 \ + -v "${bind_path}:/data" \ + "$IMAGE_NAME" >/dev/null + + if wait_for_ready "$name"; then + check_ownership "$name" "/data/db" "1000" "1000" + check_ownership "$name" "/data/db/PG_VERSION" "1000" "1000" + check_ownership "$name" "/data/db/pg_hba.conf" "1000" "1000" + check_pg_accessible "$name" "dispatch" + check_no_permission_errors "$name" + check_migrations_done "$name" + log_pass "Bind mount fresh install works" + else + log_fail "Bind mount fresh install failed" + fi + dump_logs_on_fail "$name" + + # Clean up bind mount + docker run --rm -v /tmp:/hosttemp "$IMAGE_NAME" bash -c \ + "rm -rf /hosttemp/puid_test_bind_$$" 2>/dev/null + cleanup_scenario +} + +# Verifies ownership migration works on bind mounts (UID 102 -> 1000). +# Creates old-style PG data manually, then starts with new image. +test_bind_mount_upgrade() { + CURRENT_SCENARIO="bind_mount_upgrade" + section "Bind mount upgrade — old UID 102 → PUID=1000" + + local name="${TEST_PREFIX}_bind_upg" + local bind_path="/tmp/puid_test_bind_upg_$$" + cleanup_scenario + track_container "$name" + + # Create bind mount dir with old-style PG data (UID 102) + docker run --rm -v /tmp:/hosttemp --entrypoint bash "$IMAGE_NAME" -c " + mkdir -p /hosttemp/puid_test_bind_upg_$$/db + PG_VER=\$(ls /usr/lib/postgresql/ | sort -V | tail -n 1) + PG_BIN=/usr/lib/postgresql/\$PG_VER/bin + chown -R postgres:postgres /hosttemp/puid_test_bind_upg_$$/db + chmod 700 /hosttemp/puid_test_bind_upg_$$/db + su - postgres -c \"\$PG_BIN/initdb -D /hosttemp/puid_test_bind_upg_$$/db\" + su - postgres -c \"\$PG_BIN/pg_ctl -D /hosttemp/puid_test_bind_upg_$$/db start -w -o '-c port=5432'\" + su - postgres -c \"psql -p 5432 -c \\\"CREATE ROLE dispatch WITH LOGIN PASSWORD 'secret';\\\"\" + su - postgres -c \"createdb -p 5432 --encoding=UTF8 --owner=dispatch dispatcharr\" + su - postgres -c \"\$PG_BIN/pg_ctl -D /hosttemp/puid_test_bind_upg_$$/db stop -w\" + " + + docker run -d --name "$name" \ + -e DISPATCHARR_ENV=aio \ + -e PUID=1000 -e PGID=1000 \ + -v "${bind_path}:/data" \ + "$IMAGE_NAME" >/dev/null + + if wait_for_ready "$name"; then + check_ownership "$name" "/data/db" "1000" "1000" + check_ownership "$name" "/data/db/PG_VERSION" "1000" "1000" + check_ownership "$name" "/data/db/pg_hba.conf" "1000" "1000" + check_pg_accessible "$name" "dispatch" + check_no_permission_errors "$name" + check_migrations_done "$name" + check_log_contains "$name" "Migrating PostgreSQL data ownership" \ + "Bind mount ownership migration logged" + else + log_fail "Bind mount upgrade failed" + fi + dump_logs_on_fail "$name" + + docker run --rm -v /tmp:/hosttemp "$IMAGE_NAME" bash -c \ + "rm -rf /hosttemp/puid_test_bind_upg_$$" 2>/dev/null + cleanup_scenario +} + +# Verifies auto-adapt works on bind mounts: no PUID set, data owned by +# UID 102 — should auto-detect and skip migration entirely. +test_bind_mount_auto_adapt() { + CURRENT_SCENARIO="bind_mount_auto_adapt" + section "Bind mount upgrade — no PUID (auto-adapt to UID 102)" + + local name="${TEST_PREFIX}_bind_auto" + local bind_path="/tmp/puid_test_bind_auto_$$" + cleanup_scenario + track_container "$name" + + # Create bind mount dir with old-style data + docker run --rm -v /tmp:/hosttemp --entrypoint bash "$IMAGE_NAME" -c " + mkdir -p /hosttemp/puid_test_bind_auto_$$/db + PG_VER=\$(ls /usr/lib/postgresql/ | sort -V | tail -n 1) + PG_BIN=/usr/lib/postgresql/\$PG_VER/bin + chown -R postgres:postgres /hosttemp/puid_test_bind_auto_$$/db + chmod 700 /hosttemp/puid_test_bind_auto_$$/db + su - postgres -c \"\$PG_BIN/initdb -D /hosttemp/puid_test_bind_auto_$$/db\" + su - postgres -c \"\$PG_BIN/pg_ctl -D /hosttemp/puid_test_bind_auto_$$/db start -w -o '-c port=5432'\" + su - postgres -c \"psql -p 5432 -c \\\"CREATE ROLE dispatch WITH LOGIN PASSWORD 'secret';\\\"\" + su - postgres -c \"createdb -p 5432 --encoding=UTF8 --owner=dispatch dispatcharr\" + su - postgres -c \"\$PG_BIN/pg_ctl -D /hosttemp/puid_test_bind_auto_$$/db stop -w\" + " + + # No PUID — auto-adapt should match data owner + docker run -d --name "$name" \ + -e DISPATCHARR_ENV=aio \ + -v "${bind_path}:/data" \ + "$IMAGE_NAME" >/dev/null + + if wait_for_ready "$name"; then + # Should stay at original UID — no migration. GID depends on the + # postgres group GID in the image (typically 104, not 102). + local actual_owner + actual_owner=$(docker exec "$name" stat -c '%u:%g' /data/db/PG_VERSION 2>/dev/null) + local expected_uid="102" + local actual_uid="${actual_owner%%:*}" + if [ "$actual_uid" = "$expected_uid" ]; then + log_pass "Ownership /data/db/PG_VERSION UID = $actual_uid (auto-adapted)" + else + log_fail "Ownership /data/db/PG_VERSION UID: expected $expected_uid, got $actual_uid" + fi + check_pg_accessible "$name" "dispatch" + check_no_permission_errors "$name" + check_migrations_done "$name" + check_log_contains "$name" "PUID not set" \ + "Auto-adapt logged on bind mount" + check_log_absent "$name" "Migrating PostgreSQL data ownership" \ + "No migration on auto-adapted bind mount" + else + log_fail "Bind mount auto-adapt failed" + fi + dump_logs_on_fail "$name" + + docker run --rm -v /tmp:/hosttemp "$IMAGE_NAME" bash -c \ + "rm -rf /hosttemp/puid_test_bind_auto_$$" 2>/dev/null + cleanup_scenario +} + +# Verifies modular mode (external PostgreSQL + Redis). Internal PG setup +# should be completely skipped. No /data/db directory should be created. +test_modular_mode() { + CURRENT_SCENARIO="modular_mode" + section "Modular mode — external PostgreSQL + Redis" + + local name="${TEST_PREFIX}_modular" + local net="${TEST_PREFIX}_modular_net" + local pg_name="${TEST_PREFIX}_modular_pg" + local redis_name="${TEST_PREFIX}_modular_redis" + local vol="${name}_data" + cleanup_scenario + + docker network create "$net" >/dev/null 2>&1 + fresh_volume "$vol" + track_network "$net" + track_container "$pg_name"; track_container "$redis_name"; track_container "$name" + + # Start external PostgreSQL + docker run -d --name "$pg_name" --network "$net" \ + -e POSTGRES_USER=dispatch \ + -e POSTGRES_PASSWORD=secret \ + -e POSTGRES_DB=dispatcharr \ + postgres:17 >/dev/null + + # Start external Redis + docker run -d --name "$redis_name" --network "$net" \ + redis:latest >/dev/null + + # Wait for PG to be ready + local elapsed=0 + while [ $elapsed -lt 30 ]; do + if docker exec "$pg_name" pg_isready -U dispatch 2>/dev/null | grep -q "accepting"; then + break + fi + sleep 2; ((elapsed+=2)) + done + + docker run -d --name "$name" --network "$net" \ + -e DISPATCHARR_ENV=modular \ + -e POSTGRES_HOST="$pg_name" \ + -e POSTGRES_PORT=5432 \ + -e POSTGRES_USER=dispatch \ + -e POSTGRES_PASSWORD=secret \ + -e POSTGRES_DB=dispatcharr \ + -e REDIS_HOST="$redis_name" \ + -v "${vol}:/data" \ + "$IMAGE_NAME" >/dev/null + + if wait_for_ready "$name"; then + # Verify NO internal PG data created + if docker exec "$name" test -f /data/db/PG_VERSION 2>/dev/null; then + log_fail "/data/db/PG_VERSION exists in modular mode" + else + log_pass "No internal PG data in modular mode" + fi + check_log_absent "$name" "Migrating PostgreSQL data ownership" \ + "No ownership migration in modular mode" + check_log_absent "$name" "Initializing PostgreSQL database" \ + "No PG init in modular mode" + check_log_contains "$name" "Modular mode" \ + "Modular mode detected" + check_no_permission_errors "$name" + check_migrations_done "$name" + else + log_fail "Modular mode failed to start" + fi + dump_logs_on_fail "$name" + cleanup_scenario +} + +# Verifies that a non-default POSTGRES_USER name works end-to-end. +# All init scripts use $POSTGRES_USER, so "myapp" should work identically. +test_custom_postgres_user() { + CURRENT_SCENARIO="custom_postgres_user" + section "Custom POSTGRES_USER=myapp" + + local name="${TEST_PREFIX}_custuser" + local vol="${name}_data" + cleanup_scenario + fresh_volume "$vol" + track_container "$name" + + docker run -d --name "$name" \ + -e DISPATCHARR_ENV=aio \ + -e PUID=1000 -e PGID=1000 \ + -e POSTGRES_USER=myapp \ + -e POSTGRES_DB=myappdb \ + -e POSTGRES_PASSWORD=mypassword \ + -v "${vol}:/data" \ + "$IMAGE_NAME" >/dev/null + + if wait_for_ready "$name"; then + check_ownership "$name" "/data/db" "1000" "1000" + check_pg_accessible "$name" "myapp" "myappdb" + check_role_superuser "$name" "myapp" "myapp" + check_no_permission_errors "$name" + check_migrations_done "$name" + log_pass "Custom POSTGRES_USER works" + else + log_fail "Custom POSTGRES_USER failed" + fi + dump_logs_on_fail "$name" + cleanup_scenario +} + +# Verifies that a non-default POSTGRES_PORT works. PostgreSQL should +# listen on port 5433, and all operations should use that port. +test_custom_port() { + CURRENT_SCENARIO="custom_port" + section "Custom POSTGRES_PORT=5433" + + local name="${TEST_PREFIX}_custport" + local vol="${name}_data" + cleanup_scenario + fresh_volume "$vol" + track_container "$name" + + docker run -d --name "$name" \ + -e DISPATCHARR_ENV=aio \ + -e PUID=1000 -e PGID=1000 \ + -e POSTGRES_PORT=5433 \ + -v "${vol}:/data" \ + "$IMAGE_NAME" >/dev/null + + if wait_for_ready "$name"; then + # Verify PG is on the custom port + if docker exec "$name" su - dispatch -c \ + "psql -d dispatcharr -p 5433 -tAc 'SELECT 1;'" 2>/dev/null | grep -q 1; then + log_pass "PostgreSQL running on custom port 5433" + else + log_fail "PostgreSQL not accessible on port 5433" + fi + check_no_permission_errors "$name" + check_migrations_done "$name" + else + log_fail "Custom port failed" + fi + dump_logs_on_fail "$name" + cleanup_scenario +} + +# Verifies fresh install works on ephemeral tmpfs storage. +# Data is lost on restart — this tests that init scripts handle +# an empty /data directory correctly every time. +test_tmpfs_volume() { + CURRENT_SCENARIO="tmpfs_volume" + section "tmpfs volume — ephemeral storage" + + local name="${TEST_PREFIX}_tmpfs" + cleanup_scenario + track_container "$name" + + docker run -d --name "$name" \ + --tmpfs /data:rw,size=512m \ + -e DISPATCHARR_ENV=aio \ + -e PUID=1000 -e PGID=1000 \ + "$IMAGE_NAME" >/dev/null + + if wait_for_ready "$name"; then + check_ownership "$name" "/data/db" "1000" "1000" + check_pg_accessible "$name" "dispatch" + check_no_permission_errors "$name" + check_migrations_done "$name" + log_pass "tmpfs volume works (ephemeral, data lost on restart)" + else + log_fail "tmpfs volume failed" + fi + dump_logs_on_fail "$name" + cleanup_scenario +} + +# Tests both ownership migration (UID 999 -> 1000) AND PostgreSQL major +# version upgrade (16 -> 17) simultaneously. Uses official postgres:16 +# image to create realistic PG 16 data with UID 999 (postgres in that image). +# Requires postgres:16 image to be available. +test_pg_major_upgrade() { + CURRENT_SCENARIO="pg_major_upgrade" + section "PostgreSQL major version upgrade (16 → 17)" + + if [ "$PG16_AVAILABLE" != true ]; then + log_skip "postgres:16 image not available — skipping" + return + fi + + local name="${TEST_PREFIX}_pgupg" + local vol="${name}_data" + cleanup_scenario + fresh_volume "$vol" + track_container "$name" + + # Create a PG 16 data cluster using the official postgres:16 image. + # This simulates an older Dispatcharr installation that used PG 16. + # The postgres user in the official image has UID 999. + log_info "Creating PG 16 data cluster..." + if ! docker run --rm -v "${vol}:/data" --entrypoint bash postgres:16 -c ' + mkdir -p /data/db + chown -R postgres:postgres /data/db + chmod 700 /data/db + gosu postgres /usr/lib/postgresql/16/bin/initdb -D /data/db + gosu postgres /usr/lib/postgresql/16/bin/pg_ctl -D /data/db start -w -o "-c port=5432" + gosu postgres psql -p 5432 -c "CREATE ROLE dispatch WITH LOGIN PASSWORD '\''secret'\'';" + gosu postgres createdb -p 5432 --encoding=UTF8 --owner=dispatch dispatcharr + gosu postgres /usr/lib/postgresql/16/bin/pg_ctl -D /data/db stop -w + echo "PG 16 data created: $(cat /data/db/PG_VERSION)" + '; then + log_fail "Failed to create PG 16 data cluster" + cleanup_scenario + return + fi + + # Verify PG 16 data exists + local pg_ver + pg_ver=$(docker run --rm -v "${vol}:/data" --entrypoint cat "$IMAGE_NAME" \ + /data/db/PG_VERSION 2>/dev/null | tr -d '[:space:]') + if [ "$pg_ver" = "16" ]; then + log_pass "PG 16 data cluster created (owned by UID 999)" + else + log_fail "PG_VERSION expected 16, got ${pg_ver:-}" + cleanup_scenario + return + fi + + # Run our image (PG 17) against the PG 16 data. + # This tests BOTH ownership migration (999->1000) AND pg_upgrade (16->17). + docker run -d --name "$name" \ + -e DISPATCHARR_ENV=aio \ + -e PUID=1000 -e PGID=1000 \ + -v "${vol}:/data" \ + "$IMAGE_NAME" >/dev/null + + # pg_upgrade + apt install of PG 16 binaries takes longer than normal startup + if wait_for_ready "$name" 300; then + # Verify data was upgraded to PG 17 + local new_ver + new_ver=$(docker exec "$name" cat /data/db/PG_VERSION 2>/dev/null | tr -d '[:space:]') + if [ "$new_ver" = "17" ]; then + log_pass "PG data upgraded to version 17" + else + log_fail "PG data version: expected 17, got ${new_ver:-}" + fi + + check_ownership "$name" "/data/db" "1000" "1000" + check_ownership "$name" "/data/db/PG_VERSION" "1000" "1000" + check_pg_accessible "$name" "dispatch" + check_role_superuser "$name" "dispatch" "dispatch" + check_no_permission_errors "$name" + check_migrations_done "$name" + + check_log_contains "$name" "Migrating PostgreSQL data ownership" \ + "Ownership migration logged (999→1000)" + check_log_contains "$name" "upgrading to" \ + "PG version upgrade logged" + check_log_contains "$name" "Upgrade complete" \ + "PG upgrade completion logged" + + # Verify old data was backed up + if docker exec "$name" bash -c 'ls -d /data/db_backup_16_* 2>/dev/null' | grep -q "backup"; then + log_pass "Old PG 16 data backed up" + else + log_fail "No backup of old PG 16 data found" + fi + else + log_fail "Container failed to start after pg_upgrade" + fi + + dump_logs_on_fail "$name" + cleanup_scenario +} + +# Tests PG major upgrade on data that was created by the post-PUID image +# (install user = "dispatch", not "postgres"). This validates the future +# upgrade path where pg_upgrade -U must use "dispatch" instead of "postgres". +# Requires postgres:16 image to be available. +test_pg_upgrade_post_puid() { + CURRENT_SCENARIO="pg_upgrade_post_puid" + section "PostgreSQL major upgrade — post-PUID data (install user = dispatch)" + + if [ "$PG16_AVAILABLE" != true ]; then + log_skip "postgres:16 image not available — skipping" + return + fi + + local name="${TEST_PREFIX}_pgupg2" + local vol="${name}_data" + cleanup_scenario + fresh_volume "$vol" + track_container "$name" + + # Create a PG 16 data cluster that simulates data created by the + # post-PUID image: owned by UID 1000, install user = "dispatch". + # This tests the upgrade path for future PG version bumps where + # the install user is $POSTGRES_USER, not "postgres". + log_info "Creating PG 16 data cluster with dispatch as install user..." + if ! docker run --rm -v "${vol}:/data" --entrypoint bash "$IMAGE_NAME" -c ' + # Install PG 16 binaries + apt-get update -qq && apt-get install -y -qq postgresql-16 >/dev/null 2>&1 + + # Create dispatch user (matches PUID=1000 scenario) + groupadd -g 1000 dispatch 2>/dev/null || true + useradd -u 1000 -g 1000 -m dispatch 2>/dev/null || true + + mkdir -p /data/db + chown -R 1000:1000 /data/db + chmod 700 /data/db + + # Initialize with dispatch as the install user (bootstrap superuser) + su - dispatch -c "/usr/lib/postgresql/16/bin/initdb -U dispatch -D /data/db" + + # Ensure socket directory is writable by dispatch + mkdir -p /var/run/postgresql + chown 1000:1000 /var/run/postgresql + + # Start, create app database, stop + su - dispatch -c "/usr/lib/postgresql/16/bin/pg_ctl -D /data/db start -w -o \"-c port=5432\"" + su - dispatch -c "psql -U dispatch -d template1 -p 5432 -c \"CREATE DATABASE dispatcharr OWNER dispatch ENCODING '\''UTF8'\'';\"" + su - dispatch -c "/usr/lib/postgresql/16/bin/pg_ctl -D /data/db stop -w" + echo "PG 16 (post-PUID) data created: $(cat /data/db/PG_VERSION)" + '; then + log_fail "Failed to create PG 16 post-PUID data cluster" + cleanup_scenario + return + fi + + # Verify PG 16 data exists with correct ownership + local pg_ver + pg_ver=$(docker run --rm -v "${vol}:/data" --entrypoint cat "$IMAGE_NAME" \ + /data/db/PG_VERSION 2>/dev/null | tr -d '[:space:]') + if [ "$pg_ver" = "16" ]; then + log_pass "PG 16 post-PUID data cluster created (owned by UID 1000)" + else + log_fail "PG_VERSION expected 16, got ${pg_ver:-}" + cleanup_scenario + return + fi + + # Run our image (PG 17) against the post-PUID PG 16 data. + # This tests pg_upgrade when install user = "dispatch" (not "postgres"). + # No ownership migration should occur (already UID 1000). + docker run -d --name "$name" \ + -e DISPATCHARR_ENV=aio \ + -e PUID=1000 -e PGID=1000 \ + -v "${vol}:/data" \ + "$IMAGE_NAME" >/dev/null + + if wait_for_ready "$name" 300; then + # Verify data was upgraded to PG 17 + local new_ver + new_ver=$(docker exec "$name" cat /data/db/PG_VERSION 2>/dev/null | tr -d '[:space:]') + if [ "$new_ver" = "17" ]; then + log_pass "PG data upgraded to version 17" + else + log_fail "PG data version: expected 17, got ${new_ver:-}" + fi + + check_ownership "$name" "/data/db" "1000" "1000" + check_pg_accessible "$name" "dispatch" + check_role_superuser "$name" "dispatch" "dispatch" + check_no_permission_errors "$name" + check_migrations_done "$name" + + # Key assertion: install user detected as "dispatch", not "postgres" + check_log_contains "$name" "Old cluster install user: dispatch" \ + "Install user detected as dispatch (post-PUID path)" + + # No ownership migration should have occurred + check_log_absent "$name" "Migrating PostgreSQL data ownership" \ + "No ownership migration (UID already matches)" + + check_log_contains "$name" "upgrading to" \ + "PG version upgrade logged" + check_log_contains "$name" "Upgrade complete" \ + "PG upgrade completion logged" + + # Verify old data was backed up + if docker exec "$name" bash -c 'ls -d /data/db_backup_16_* 2>/dev/null' | grep -q "backup"; then + log_pass "Old PG 16 data backed up" + else + log_fail "No backup of old PG 16 data found" + fi + else + log_fail "Container failed to start after post-PUID pg_upgrade" + fi + + dump_logs_on_fail "$name" + cleanup_scenario +} + +# Verifies the full HTTP stack after startup: nginx serves the frontend, +# uwsgi proxies to Django, and static files are collected. Uses an HTTP +# request from inside the container (no port mapping needed). +test_e2e_web_ui() { + CURRENT_SCENARIO="e2e_web_ui" + section "End-to-end — full HTTP stack (nginx → uwsgi → Django)" + + local name="${TEST_PREFIX}_e2e" + local vol="${name}_data" + cleanup_scenario + fresh_volume "$vol" + track_container "$name" + + docker run -d --name "$name" \ + -e DISPATCHARR_ENV=aio \ + -e PUID=1000 -e PGID=1000 \ + -v "${vol}:/data" \ + "$IMAGE_NAME" >/dev/null + + if wait_for_ready "$name"; then + check_pg_accessible "$name" "dispatch" + check_migrations_done "$name" + + # Verify the full HTTP stack responds + check_web_ui "$name" + + # Verify static files were collected (collectstatic runs before uwsgi) + if docker exec "$name" test -d /app/static 2>/dev/null; then + log_pass "Static files directory exists" + else + log_fail "Static files directory missing" + fi + + check_no_permission_errors "$name" + else + log_fail "Container failed to start" + fi + dump_logs_on_fail "$name" + cleanup_scenario +} + +# Tests startup with a read-only root filesystem (security hardening). +# This is an ambitious test — nginx/uwsgi need writable paths provided +# via tmpfs. The test verifies that PUID init scripts specifically don't +# fail; non-PUID failures (missing tmpfs mounts) are skipped, not failed. +test_readonly_rootfs() { + CURRENT_SCENARIO="readonly_rootfs" + section "Read-only root filesystem (security hardened)" + + local name="${TEST_PREFIX}_rofs" + local vol="${name}_data" + cleanup_scenario + fresh_volume "$vol" + track_container "$name" + + # Read-only rootfs requires tmpfs for writable paths + docker run -d --name "$name" \ + --read-only \ + --tmpfs /tmp:rw \ + --tmpfs /run:rw \ + --tmpfs /var/run:rw \ + --tmpfs /var/log:rw \ + --tmpfs /etc:rw \ + --tmpfs /root:rw \ + --tmpfs /app/static:rw \ + --tmpfs /app/media:rw \ + --tmpfs /app/logo_cache:rw \ + -e DISPATCHARR_ENV=aio \ + -e PUID=1000 -e PGID=1000 \ + -v "${vol}:/data" \ + "$IMAGE_NAME" >/dev/null + + if wait_for_ready "$name" 120; then + check_ownership "$name" "/data/db" "1000" "1000" + check_pg_accessible "$name" "dispatch" + check_no_permission_errors "$name" + log_pass "Read-only rootfs works" + else + # Check if it's our PUID code that broke or something else (e.g., can't + # write to /etc, nginx needs writable paths, etc.) + local ro_errors + ro_errors=$(docker logs "$name" 2>&1 | grep -iE "read-only file system|No such file or directory" | head -3) + if [ -n "$ro_errors" ]; then + log_skip "Read-only rootfs: non-PUID failure (expected — needs more tmpfs mounts)" + elif docker logs "$name" 2>&1 | grep -iE "Cannot update ownership|permission denied" | grep -q "/data/"; then + log_fail "Read-only rootfs: PUID-related failure" + else + log_skip "Read-only rootfs: unrelated startup failure" + fi + fi + dump_logs_on_fail "$name" + cleanup_scenario +} + +############################################################################### +# Main +############################################################################### + +echo -e "${BOLD}" +echo "╔══════════════════════════════════════════╗" +echo "║ PUID/PGID Docker Init Test Suite ║" +echo "╚══════════════════════════════════════════╝" +echo -e "${NC}" + +# Pull required images +section "Pulling required images" +if docker pull "$RELEASE_IMAGE" 2>/dev/null; then + log_pass "Release image pulled ($RELEASE_IMAGE)" + USE_RELEASE_IMAGE=true +else + log_info "Could not pull release image — upgrade tests will use manual setup" + USE_RELEASE_IMAGE=false +fi + +if docker pull postgres:17 2>/dev/null; then + log_pass "PostgreSQL 17 image pulled" +else + log_info "postgres:17 not available — modular test will be skipped" +fi + +docker pull redis:latest 2>/dev/null && log_pass "Redis image pulled" || true + +PG16_AVAILABLE=false +if docker pull postgres:16 2>/dev/null; then + log_pass "PostgreSQL 16 image pulled (for upgrade test)" + PG16_AVAILABLE=true +else + log_info "postgres:16 not available — pg_major_upgrade test will be skipped" +fi + +# Build test image from local changes +if [ "$SKIP_BUILD" = false ]; then + section "Building test image from local changes" + if docker build -t "$IMAGE_NAME" -f docker/Dockerfile . ; then + log_pass "Test image built ($IMAGE_NAME)" + else + echo -e "${RED}Image build failed. Aborting.${NC}" + exit 1 + fi +else + log_info "Skipping build (--skip-build)" +fi + +# Define scenario list +SCENARIOS=( + fresh_default + fresh_custom_puid + upgrade_explicit_puid + upgrade_auto_adapt + restart_idempotent + puid_change + uid_collision_102 + puid_zero + puid_non_numeric + bind_mount + bind_mount_upgrade + bind_mount_auto_adapt + modular_mode + custom_postgres_user + custom_port + tmpfs_volume + pg_major_upgrade + pg_upgrade_post_puid + e2e_web_ui + readonly_rootfs +) + +# Run scenarios +for scenario in "${SCENARIOS[@]}"; do + if [ -n "$SINGLE_SCENARIO" ] && [ "$scenario" != "$SINGLE_SCENARIO" ]; then + continue + fi + "test_${scenario}" +done + +# Summary +echo "" +echo -e "${BOLD}╔══════════════════════════════════════════╗" +echo -e "║ RESULTS ║" +echo -e "╚══════════════════════════════════════════╝${NC}" +echo -e " ${GREEN}Passed: $PASS${NC}" +echo -e " ${RED}Failed: $FAIL${NC}" +echo -e " ${YELLOW}Skipped: $SKIP${NC}" + +if [ ${#ERRORS[@]} -gt 0 ]; then + echo "" + echo -e "${RED}Failures:${NC}" + for err in "${ERRORS[@]}"; do + echo -e " ${RED}• $err${NC}" + done +fi + +echo "" +if [ $FAIL -eq 0 ]; then + echo -e "${GREEN}${BOLD}All tests passed!${NC}" + exit 0 +else + echo -e "${RED}${BOLD}$FAIL test(s) failed.${NC}" + exit 1 +fi diff --git a/docker/tests/test-tls-postgres.sh b/docker/tests/test-tls-postgres.sh new file mode 100644 index 0000000..8a7630d --- /dev/null +++ b/docker/tests/test-tls-postgres.sh @@ -0,0 +1,892 @@ +#!/bin/bash +# +# Integration test suite for TLS/mTLS in modular mode. +# Validates that Dispatcharr connects correctly to external PostgreSQL and +# Redis services using various TLS configurations. +# +# Prerequisites: +# - Docker Desktop (or Docker Engine) running +# - Internet access (pulls postgres:17, redis:latest) +# - ~10-15 minutes for a full run +# +# Usage: +# cd +# bash docker/tests/test-tls-postgres.sh [--skip-build] [--keep-on-fail] [scenario_name] +# +# Options: +# --skip-build Skip Docker image build (use existing dispatcharr:tls-test image) +# --keep-on-fail Don't clean up containers/volumes on failure (for debugging) +# scenario_name Run only the named scenario +# +# Scenarios: +# modular_mtls_no_password PG mTLS cert-only auth, no password +# modular_mtls_with_password PG mTLS + password auth combined +# modular_tls_server_only PG server-side TLS only (no client cert) +# modular_tls_key_permission PG mTLS with 0777 client key (Docker Desktop scenario) +# modular_no_tls_regression Non-TLS modular mode still works +# modular_pg_verify_full PG mTLS with verify-full (CN must match hostname) +# modular_redis_tls Redis with TLS (server-side verification) +# modular_full_tls_celery PG mTLS + Redis TLS with separate Celery container +# +# Exit codes: +# 0 All tests passed +# 1 One or more tests failed (or build failed) + +set -uo pipefail + +# Prevent Git Bash (MINGW) from converting Unix paths +export MSYS_NO_PATHCONV=1 + +############################################################################### +# Configuration +############################################################################### +IMAGE_NAME="dispatcharr:tls-test" +TEST_PREFIX="tls_test" +STARTUP_TIMEOUT=120 +SKIP_BUILD=false +KEEP_ON_FAIL=false +SINGLE_SCENARIO="" +PASS=0 +FAIL=0 +SKIP=0 +ERRORS=() +CERT_DIR="" + +# Colors (disabled if not a terminal) +if [ -t 1 ]; then + RED='\033[0;31m'; GREEN='\033[0;32m'; YELLOW='\033[1;33m' + CYAN='\033[0;36m'; BOLD='\033[1m'; NC='\033[0m' +else + RED=''; GREEN=''; YELLOW=''; CYAN=''; BOLD=''; NC='' +fi + +############################################################################### +# Parse arguments +############################################################################### +for arg in "$@"; do + case "$arg" in + --skip-build) SKIP_BUILD=true ;; + --keep-on-fail) KEEP_ON_FAIL=true ;; + -*) echo "Unknown option: $arg"; exit 1 ;; + *) SINGLE_SCENARIO="$arg" ;; + esac +done + +############################################################################### +# Helpers +############################################################################### +CURRENT_SCENARIO="" +CLEANUP_ITEMS=() + +log_pass() { echo -e " ${GREEN}✅ $1${NC}"; PASS=$((PASS + 1)); } +log_fail() { echo -e " ${RED}❌ $1${NC}"; FAIL=$((FAIL + 1)); ERRORS+=("[$CURRENT_SCENARIO] $1"); } +log_skip() { echo -e " ${YELLOW}⏭️ $1${NC}"; SKIP=$((SKIP + 1)); } +log_info() { echo -e " ${CYAN}ℹ️ $1${NC}"; } +section() { echo -e "\n${BOLD}━━━ $1 ━━━${NC}"; SCENARIO_FAIL_BEFORE=$FAIL; } + +track_container() { CLEANUP_ITEMS+=("container:$1"); } +track_volume() { CLEANUP_ITEMS+=("volume:$1"); } +track_network() { CLEANUP_ITEMS+=("network:$1"); } + +fresh_volume() { + local vol="$1" + docker rm -f $(docker ps -aq --filter "volume=${vol}") 2>/dev/null || true + docker volume rm "$vol" 2>/dev/null || true + docker volume create "$vol" >/dev/null + track_volume "$vol" +} + +cleanup_scenario() { + if [ "$KEEP_ON_FAIL" = true ] && [ "$FAIL" -gt "${SCENARIO_FAIL_BEFORE:-0}" ]; then + log_info "Keeping resources for debugging (--keep-on-fail)" + CLEANUP_ITEMS=() + return + fi + for item in "${CLEANUP_ITEMS[@]}"; do + local type="${item%%:*}" + local name="${item#*:}" + case "$type" in + container) docker stop "$name" 2>/dev/null; docker rm -f "$name" 2>/dev/null ;; + volume) docker volume rm "$name" 2>/dev/null ;; + network) docker network rm "$name" 2>/dev/null ;; + esac + done + CLEANUP_ITEMS=() +} + +trap 'cleanup_scenario; [ -n "$CERT_DIR" ] && rm -rf "$CERT_DIR"' EXIT + +wait_for_ready() { + local name="$1" + local timeout="${2:-$STARTUP_TIMEOUT}" + local elapsed=0 + + while [ $elapsed -lt $timeout ]; do + if ! docker ps -q -f "name=^${name}$" 2>/dev/null | grep -q .; then + echo " Container $name exited unexpectedly" + return 1 + fi + if docker logs "$name" 2>&1 | grep -q "uwsgi started with PID"; then + return 0 + fi + sleep 3 + ((elapsed+=3)) + done + echo " Timeout (${timeout}s) waiting for $name" + return 1 +} + +_capture_logs() { + local container="$1" logfile="$2" + docker logs "$container" > "$logfile" 2>&1 +} + +check_log_contains() { + local container="$1" pattern="$2" description="$3" + local tmplog; tmplog=$(mktemp) + _capture_logs "$container" "$tmplog" + if grep -q "$pattern" "$tmplog"; then + log_pass "$description" + else + log_fail "$description (pattern not found: $pattern)" + fi + rm -f "$tmplog" +} + +check_log_absent() { + local container="$1" pattern="$2" description="$3" + local tmplog; tmplog=$(mktemp) + _capture_logs "$container" "$tmplog" + if grep -q "$pattern" "$tmplog"; then + log_fail "$description (unexpected pattern found: $pattern)" + else + log_pass "$description" + fi + rm -f "$tmplog" +} + +check_migrations_done() { + local container="$1" + local tmplog; tmplog=$(mktemp) + _capture_logs "$container" "$tmplog" + if grep -qE "Running migrations|No migrations to apply|Operations to perform|Applying .+\.\.\. OK" "$tmplog"; then + log_pass "Django migrations completed" + elif grep -q "uwsgi started with PID" "$tmplog"; then + log_pass "Django migrations completed (confirmed via uwsgi startup)" + else + log_fail "Django migrations did not complete" + fi + rm -f "$tmplog" +} + +check_no_permission_errors() { + local container="$1" + local tmplog; tmplog=$(mktemp) + _capture_logs "$container" "$tmplog" + local errors + errors=$(grep -iE "permission denied|operation not permitted" "$tmplog" \ + | grep -v "GPU acceleration" | grep -v "Warning:" | head -5) + rm -f "$tmplog" + if [ -n "$errors" ]; then + log_fail "Permission errors in logs:" + echo "$errors" | while read -r line; do echo " $line"; done + else + log_pass "No permission errors in logs" + fi +} + +dump_logs_on_fail() { + local container="$1" + if [ $FAIL -gt ${SCENARIO_FAIL_BEFORE:-0} ]; then + echo -e " ${YELLOW}--- Container logs ($container) ---${NC}" + docker logs "$container" 2>&1 | tail -30 | sed 's/^/ /' + echo -e " ${YELLOW}--- End logs ---${NC}" + fi +} + +############################################################################### +# Certificate generation +############################################################################### +generate_test_certs() { + CERT_DIR=$(mktemp -d) + log_info "Generating test certificates in $CERT_DIR" + + # Generate certs inside a container for cross-platform compatibility. + # Shared CA for both PG and Redis. CN of server certs must match their + # Docker container hostnames for verify-full mode. + docker run --rm --entrypoint sh \ + -v "$(cygpath -w "$CERT_DIR" 2>/dev/null || echo "$CERT_DIR"):/certs" \ + -w /certs alpine/openssl -c ' + # Shared CA + openssl req -new -x509 -days 1 -nodes \ + -keyout ca.key -out ca.crt -subj "/CN=Test CA" 2>/dev/null && + + # PostgreSQL server cert (CN = PG container hostname) + openssl req -new -nodes \ + -keyout pg-server.key -out pg-server.csr -subj "/CN='"${TEST_PREFIX}"'_pg" 2>/dev/null && + openssl x509 -req -days 1 -in pg-server.csr \ + -CA ca.crt -CAkey ca.key -CAcreateserial -out pg-server.crt 2>/dev/null && + # PostgreSQL client cert (CN = POSTGRES_USER) + openssl req -new -nodes \ + -keyout pg-client.key -out pg-client.csr -subj "/CN=dispatch" 2>/dev/null && + openssl x509 -req -days 1 -in pg-client.csr \ + -CA ca.crt -CAkey ca.key -CAcreateserial -out pg-client.crt 2>/dev/null && + + # Redis server cert (CN = Redis container hostname) + openssl req -new -nodes \ + -keyout redis-server.key -out redis-server.csr -subj "/CN='"${TEST_PREFIX}"'_redis" 2>/dev/null && + openssl x509 -req -days 1 -in redis-server.csr \ + -CA ca.crt -CAkey ca.key -CAcreateserial -out redis-server.crt 2>/dev/null && + + # Backwards-compat aliases (existing PG-only scenarios use these names) + cp pg-server.crt server.crt && cp pg-server.key server.key && + cp pg-client.crt client.crt && cp pg-client.key client.key && + + chmod 600 pg-server.key pg-client.key redis-server.key server.key client.key + ' || { log_fail "Certificate generation failed"; return 1; } + + log_pass "Test certificates generated" +} + +############################################################################### +# Start a TLS-enabled Redis container +############################################################################### +start_tls_redis() { + local name="$1" net="$2" + + local cert_mount + cert_mount="$(cygpath -w "$CERT_DIR" 2>/dev/null || echo "$CERT_DIR")" + + # Redis needs certs owned by redis user (uid 999 in the official image). + # Mount certs, copy to a writable location, fix ownership, then start + # with TLS flags. + docker run -d --name "$name" --network "$net" \ + -v "${cert_mount}:/certs:ro" \ + redis:latest \ + sh -c ' + cp /certs/redis-server.crt /certs/redis-server.key /certs/ca.crt /tmp/ && + chmod 600 /tmp/redis-server.key && + chown redis:redis /tmp/redis-server.crt /tmp/redis-server.key /tmp/ca.crt && + exec redis-server \ + --tls-port 6379 --port 0 \ + --tls-cert-file /tmp/redis-server.crt \ + --tls-key-file /tmp/redis-server.key \ + --tls-ca-cert-file /tmp/ca.crt \ + --tls-auth-clients no + ' >/dev/null + + # Wait for Redis TLS to be ready + local elapsed=0 + while [ $elapsed -lt 20 ]; do + if docker exec "$name" redis-cli --tls \ + --cert /certs/redis-server.crt --key /certs/redis-server.key --cacert /certs/ca.crt \ + ping 2>/dev/null | grep -q "PONG"; then + break + fi + sleep 2; elapsed=$((elapsed + 2)) + done +} + +############################################################################### +# Start a TLS-enabled PostgreSQL container +############################################################################### +start_tls_postgres() { + local name="$1" net="$2" hba_auth="$3" + + local cert_mount + cert_mount="$(cygpath -w "$CERT_DIR" 2>/dev/null || echo "$CERT_DIR")" + + docker run -d --name "$name" --network "$net" \ + -e POSTGRES_USER=dispatch \ + -e POSTGRES_PASSWORD=tempsetup \ + -e POSTGRES_DB=dispatcharr \ + -v "${cert_mount}:/certs:ro" \ + postgres:17 >/dev/null + + # Wait for PG to initialize + local elapsed=0 + while [ $elapsed -lt 30 ]; do + if docker exec "$name" su postgres -c "/usr/lib/postgresql/17/bin/pg_isready" 2>/dev/null | grep -q "accepting"; then + break + fi + sleep 2; ((elapsed+=2)) + done + + # Configure SSL and pg_hba.conf + docker exec "$name" bash -c " + cp /certs/server.crt /certs/server.key /certs/ca.crt /var/lib/postgresql/ + chown postgres:postgres /var/lib/postgresql/server.crt /var/lib/postgresql/server.key /var/lib/postgresql/ca.crt + chmod 600 /var/lib/postgresql/server.key + echo \"ssl = on\" >> /var/lib/postgresql/data/postgresql.conf + echo \"ssl_cert_file = '/var/lib/postgresql/server.crt'\" >> /var/lib/postgresql/data/postgresql.conf + echo \"ssl_key_file = '/var/lib/postgresql/server.key'\" >> /var/lib/postgresql/data/postgresql.conf + echo \"ssl_ca_file = '/var/lib/postgresql/ca.crt'\" >> /var/lib/postgresql/data/postgresql.conf + cat > /var/lib/postgresql/data/pg_hba.conf << HBA +local all all trust +hostssl all all 0.0.0.0/0 ${hba_auth} +hostssl all all ::0/0 ${hba_auth} +HBA + su postgres -c '/usr/lib/postgresql/17/bin/pg_ctl reload -D /var/lib/postgresql/data' + " >/dev/null 2>&1 + sleep 1 +} + +############################################################################### +# Test scenarios +############################################################################### + +test_modular_mtls_no_password() { + CURRENT_SCENARIO="modular_mtls_no_password" + section "Modular mode — mTLS cert-only auth (no password)" + + local name="${TEST_PREFIX}_app" + local pg_name="${TEST_PREFIX}_pg" + local redis_name="${TEST_PREFIX}_redis" + local net="${TEST_PREFIX}_net" + local vol="${name}_data" + cleanup_scenario + + docker network create "$net" >/dev/null 2>&1 + fresh_volume "$vol" + track_network "$net" + track_container "$pg_name"; track_container "$redis_name"; track_container "$name" + + start_tls_postgres "$pg_name" "$net" "cert" + + docker run -d --name "$redis_name" --network "$net" redis:latest >/dev/null + + local cert_mount + cert_mount="$(cygpath -w "$CERT_DIR" 2>/dev/null || echo "$CERT_DIR")" + + # No POSTGRES_PASSWORD — cert-only auth + docker run -d --name "$name" --network "$net" \ + -e DISPATCHARR_ENV=modular \ + -e POSTGRES_HOST="$pg_name" \ + -e POSTGRES_PORT=5432 \ + -e POSTGRES_USER=dispatch \ + -e POSTGRES_DB=dispatcharr \ + -e REDIS_HOST="$redis_name" \ + -e POSTGRES_SSL=true \ + -e POSTGRES_SSL_MODE=verify-ca \ + -e POSTGRES_SSL_CA_CERT=/certs/ca.crt \ + -e POSTGRES_SSL_CERT=/certs/client.crt \ + -e POSTGRES_SSL_KEY=/certs/client.key \ + -v "${cert_mount}:/certs:ro" \ + -v "${vol}:/data" \ + "$IMAGE_NAME" >/dev/null + + if wait_for_ready "$name"; then + log_pass "Container started with mTLS cert-only auth" + check_log_contains "$name" "PostgreSQL version check passed" \ + "Version check passed with mTLS" + check_log_contains "$name" "PostgreSQL TLS: enabled" \ + "Django sees TLS enabled" + check_migrations_done "$name" + check_no_permission_errors "$name" + else + log_fail "Container failed to start with mTLS cert-only auth" + fi + dump_logs_on_fail "$name" + cleanup_scenario +} + +test_modular_mtls_with_password() { + CURRENT_SCENARIO="modular_mtls_with_password" + section "Modular mode — mTLS + password auth" + + local name="${TEST_PREFIX}_app" + local pg_name="${TEST_PREFIX}_pg" + local redis_name="${TEST_PREFIX}_redis" + local net="${TEST_PREFIX}_net" + local vol="${name}_data" + cleanup_scenario + + docker network create "$net" >/dev/null 2>&1 + fresh_volume "$vol" + track_network "$net" + track_container "$pg_name"; track_container "$redis_name"; track_container "$name" + + # cert + md5 password + start_tls_postgres "$pg_name" "$net" "cert" + + docker run -d --name "$redis_name" --network "$net" redis:latest >/dev/null + + local cert_mount + cert_mount="$(cygpath -w "$CERT_DIR" 2>/dev/null || echo "$CERT_DIR")" + + docker run -d --name "$name" --network "$net" \ + -e DISPATCHARR_ENV=modular \ + -e POSTGRES_HOST="$pg_name" \ + -e POSTGRES_PORT=5432 \ + -e POSTGRES_USER=dispatch \ + -e POSTGRES_PASSWORD=tempsetup \ + -e POSTGRES_DB=dispatcharr \ + -e REDIS_HOST="$redis_name" \ + -e POSTGRES_SSL=true \ + -e POSTGRES_SSL_MODE=verify-ca \ + -e POSTGRES_SSL_CA_CERT=/certs/ca.crt \ + -e POSTGRES_SSL_CERT=/certs/client.crt \ + -e POSTGRES_SSL_KEY=/certs/client.key \ + -v "${cert_mount}:/certs:ro" \ + -v "${vol}:/data" \ + "$IMAGE_NAME" >/dev/null + + if wait_for_ready "$name"; then + log_pass "Container started with mTLS + password" + check_log_contains "$name" "PostgreSQL version check passed" \ + "Version check passed with mTLS + password" + check_migrations_done "$name" + else + log_fail "Container failed to start with mTLS + password" + fi + dump_logs_on_fail "$name" + cleanup_scenario +} + +test_modular_tls_server_only() { + CURRENT_SCENARIO="modular_tls_server_only" + section "Modular mode — server-only TLS (no client cert)" + + local name="${TEST_PREFIX}_app" + local pg_name="${TEST_PREFIX}_pg" + local redis_name="${TEST_PREFIX}_redis" + local net="${TEST_PREFIX}_net" + local vol="${name}_data" + cleanup_scenario + + docker network create "$net" >/dev/null 2>&1 + fresh_volume "$vol" + track_network "$net" + track_container "$pg_name"; track_container "$redis_name"; track_container "$name" + + # md5 auth over TLS (no client cert required) + start_tls_postgres "$pg_name" "$net" "md5" + + docker run -d --name "$redis_name" --network "$net" redis:latest >/dev/null + + local cert_mount + cert_mount="$(cygpath -w "$CERT_DIR" 2>/dev/null || echo "$CERT_DIR")" + + docker run -d --name "$name" --network "$net" \ + -e DISPATCHARR_ENV=modular \ + -e POSTGRES_HOST="$pg_name" \ + -e POSTGRES_PORT=5432 \ + -e POSTGRES_USER=dispatch \ + -e POSTGRES_PASSWORD=tempsetup \ + -e POSTGRES_DB=dispatcharr \ + -e REDIS_HOST="$redis_name" \ + -e POSTGRES_SSL=true \ + -e POSTGRES_SSL_MODE=verify-ca \ + -e POSTGRES_SSL_CA_CERT=/certs/ca.crt \ + -v "${cert_mount}:/certs:ro" \ + -v "${vol}:/data" \ + "$IMAGE_NAME" >/dev/null + + if wait_for_ready "$name"; then + log_pass "Container started with server-only TLS" + check_log_contains "$name" "PostgreSQL version check passed" \ + "Version check passed with server-only TLS" + check_migrations_done "$name" + else + log_fail "Container failed to start with server-only TLS" + fi + dump_logs_on_fail "$name" + cleanup_scenario +} + +test_modular_tls_key_permission() { + CURRENT_SCENARIO="modular_tls_key_permission" + section "Modular mode — mTLS with 0777 client key (Docker Desktop scenario)" + + local name="${TEST_PREFIX}_app" + local pg_name="${TEST_PREFIX}_pg" + local redis_name="${TEST_PREFIX}_redis" + local net="${TEST_PREFIX}_net" + local vol="${name}_data" + cleanup_scenario + + docker network create "$net" >/dev/null 2>&1 + fresh_volume "$vol" + track_network "$net" + track_container "$pg_name"; track_container "$redis_name"; track_container "$name" + + start_tls_postgres "$pg_name" "$net" "cert" + + docker run -d --name "$redis_name" --network "$net" redis:latest >/dev/null + + # Create a copy of certs with 0777 key permissions + local bad_perms_dir + bad_perms_dir=$(mktemp -d) + cp "$CERT_DIR"/ca.crt "$CERT_DIR"/client.crt "$CERT_DIR"/client.key "$bad_perms_dir/" + chmod 777 "$bad_perms_dir/client.key" + + local cert_mount + cert_mount="$(cygpath -w "$bad_perms_dir" 2>/dev/null || echo "$bad_perms_dir")" + + docker run -d --name "$name" --network "$net" \ + -e DISPATCHARR_ENV=modular \ + -e POSTGRES_HOST="$pg_name" \ + -e POSTGRES_PORT=5432 \ + -e POSTGRES_USER=dispatch \ + -e POSTGRES_DB=dispatcharr \ + -e REDIS_HOST="$redis_name" \ + -e POSTGRES_SSL=true \ + -e POSTGRES_SSL_MODE=verify-ca \ + -e POSTGRES_SSL_CA_CERT=/certs/ca.crt \ + -e POSTGRES_SSL_CERT=/certs/client.crt \ + -e POSTGRES_SSL_KEY=/certs/client.key \ + -v "${cert_mount}:/certs:ro" \ + -v "${vol}:/data" \ + "$IMAGE_NAME" >/dev/null + + if wait_for_ready "$name"; then + log_pass "Container started with 0777 client key" + check_log_contains "$name" "Fixed PostgreSQL client key" \ + "Key permission fix triggered" + check_log_contains "$name" "PostgreSQL version check passed" \ + "Version check passed after key fix" + check_migrations_done "$name" + else + log_fail "Container failed to start with 0777 client key" + fi + dump_logs_on_fail "$name" + rm -rf "$bad_perms_dir" + cleanup_scenario +} + +test_modular_no_tls_regression() { + CURRENT_SCENARIO="modular_no_tls_regression" + section "Modular mode — no TLS (regression check)" + + local name="${TEST_PREFIX}_app" + local pg_name="${TEST_PREFIX}_pg" + local redis_name="${TEST_PREFIX}_redis" + local net="${TEST_PREFIX}_net" + local vol="${name}_data" + cleanup_scenario + + docker network create "$net" >/dev/null 2>&1 + fresh_volume "$vol" + track_network "$net" + track_container "$pg_name"; track_container "$redis_name"; track_container "$name" + + # Plain PostgreSQL — no TLS + docker run -d --name "$pg_name" --network "$net" \ + -e POSTGRES_USER=dispatch \ + -e POSTGRES_PASSWORD=secret \ + -e POSTGRES_DB=dispatcharr \ + postgres:17 >/dev/null + + local elapsed=0 + while [ $elapsed -lt 30 ]; do + if docker exec "$pg_name" su postgres -c "/usr/lib/postgresql/17/bin/pg_isready" 2>/dev/null | grep -q "accepting"; then + break + fi + sleep 2; ((elapsed+=2)) + done + + docker run -d --name "$redis_name" --network "$net" redis:latest >/dev/null + + docker run -d --name "$name" --network "$net" \ + -e DISPATCHARR_ENV=modular \ + -e POSTGRES_HOST="$pg_name" \ + -e POSTGRES_PORT=5432 \ + -e POSTGRES_USER=dispatch \ + -e POSTGRES_PASSWORD=secret \ + -e POSTGRES_DB=dispatcharr \ + -e REDIS_HOST="$redis_name" \ + -v "${vol}:/data" \ + "$IMAGE_NAME" >/dev/null + + if wait_for_ready "$name"; then + log_pass "Container started without TLS (regression check)" + check_log_contains "$name" "PostgreSQL version check passed" \ + "Version check passed without TLS" + check_log_absent "$name" "Fixed PostgreSQL client key" \ + "No key fix when TLS disabled" + check_migrations_done "$name" + else + log_fail "Container failed to start without TLS" + fi + dump_logs_on_fail "$name" + cleanup_scenario +} + +test_modular_pg_verify_full() { + CURRENT_SCENARIO="modular_pg_verify_full" + section "Modular mode — PG mTLS with verify-full (CN must match hostname)" + + local name="${TEST_PREFIX}_app" + local pg_name="${TEST_PREFIX}_pg" + local redis_name="${TEST_PREFIX}_redis" + local net="${TEST_PREFIX}_net" + local vol="${name}_data" + cleanup_scenario + + docker network create "$net" >/dev/null 2>&1 + fresh_volume "$vol" + track_network "$net" + track_container "$pg_name"; track_container "$redis_name"; track_container "$name" + + start_tls_postgres "$pg_name" "$net" "cert" + + docker run -d --name "$redis_name" --network "$net" redis:latest >/dev/null + + local cert_mount + cert_mount="$(cygpath -w "$CERT_DIR" 2>/dev/null || echo "$CERT_DIR")" + + # verify-full requires server cert CN to match the hostname used to connect. + # Our PG server cert CN is "${TEST_PREFIX}_pg", which matches the container name + # used in POSTGRES_HOST. + docker run -d --name "$name" --network "$net" \ + -e DISPATCHARR_ENV=modular \ + -e POSTGRES_HOST="$pg_name" \ + -e POSTGRES_PORT=5432 \ + -e POSTGRES_USER=dispatch \ + -e POSTGRES_DB=dispatcharr \ + -e REDIS_HOST="$redis_name" \ + -e POSTGRES_SSL=true \ + -e POSTGRES_SSL_MODE=verify-full \ + -e POSTGRES_SSL_CA_CERT=/certs/ca.crt \ + -e POSTGRES_SSL_CERT=/certs/client.crt \ + -e POSTGRES_SSL_KEY=/certs/client.key \ + -v "${cert_mount}:/certs:ro" \ + -v "${vol}:/data" \ + "$IMAGE_NAME" >/dev/null + + if wait_for_ready "$name"; then + log_pass "Container started with verify-full" + check_log_contains "$name" "PostgreSQL version check passed" \ + "Version check passed with verify-full" + check_log_contains "$name" "sslmode=verify-full" \ + "Django reports verify-full mode" + check_migrations_done "$name" + else + log_fail "Container failed to start with verify-full" + fi + dump_logs_on_fail "$name" + cleanup_scenario +} + +test_modular_redis_tls() { + CURRENT_SCENARIO="modular_redis_tls" + section "Modular mode — Redis with TLS" + + local name="${TEST_PREFIX}_app" + local pg_name="${TEST_PREFIX}_pg" + local redis_name="${TEST_PREFIX}_redis" + local net="${TEST_PREFIX}_net" + local vol="${name}_data" + cleanup_scenario + + docker network create "$net" >/dev/null 2>&1 + fresh_volume "$vol" + track_network "$net" + track_container "$pg_name"; track_container "$redis_name"; track_container "$name" + + # Plain PG (no TLS) — isolate Redis TLS testing + docker run -d --name "$pg_name" --network "$net" \ + -e POSTGRES_USER=dispatch \ + -e POSTGRES_PASSWORD=secret \ + -e POSTGRES_DB=dispatcharr \ + postgres:17 >/dev/null + + local elapsed=0 + while [ $elapsed -lt 30 ]; do + if docker exec "$pg_name" su postgres -c "/usr/lib/postgresql/17/bin/pg_isready" 2>/dev/null | grep -q "accepting"; then + break + fi + sleep 2; elapsed=$((elapsed + 2)) + done + + start_tls_redis "$redis_name" "$net" + + local cert_mount + cert_mount="$(cygpath -w "$CERT_DIR" 2>/dev/null || echo "$CERT_DIR")" + + docker run -d --name "$name" --network "$net" \ + -e DISPATCHARR_ENV=modular \ + -e POSTGRES_HOST="$pg_name" \ + -e POSTGRES_PORT=5432 \ + -e POSTGRES_USER=dispatch \ + -e POSTGRES_PASSWORD=secret \ + -e POSTGRES_DB=dispatcharr \ + -e REDIS_HOST="$redis_name" \ + -e REDIS_SSL=true \ + -e REDIS_SSL_VERIFY=false \ + -e REDIS_SSL_CA_CERT=/certs/ca.crt \ + -v "${cert_mount}:/certs:ro" \ + -v "${vol}:/data" \ + "$IMAGE_NAME" >/dev/null + + if wait_for_ready "$name"; then + log_pass "Container started with Redis TLS" + check_log_contains "$name" "Redis TLS: enabled" \ + "Django reports Redis TLS enabled" + check_log_contains "$name" "Redis at ${redis_name}" \ + "Redis connected via TLS" + check_migrations_done "$name" + else + log_fail "Container failed to start with Redis TLS" + fi + dump_logs_on_fail "$name" + cleanup_scenario +} + +test_modular_full_tls_celery() { + CURRENT_SCENARIO="modular_full_tls_celery" + section "Modular mode — PG mTLS + Redis TLS with Celery container" + + local name="${TEST_PREFIX}_app" + local celery_name="${TEST_PREFIX}_celery" + local pg_name="${TEST_PREFIX}_pg" + local redis_name="${TEST_PREFIX}_redis" + local net="${TEST_PREFIX}_net" + local vol="${name}_data" + cleanup_scenario + + docker network create "$net" >/dev/null 2>&1 + fresh_volume "$vol" + track_network "$net" + track_container "$pg_name"; track_container "$redis_name" + track_container "$name"; track_container "$celery_name" + + start_tls_postgres "$pg_name" "$net" "cert" + start_tls_redis "$redis_name" "$net" + + local cert_mount + cert_mount="$(cygpath -w "$CERT_DIR" 2>/dev/null || echo "$CERT_DIR")" + + # Shared env vars for both web and celery containers + local -a tls_env=( + -e DISPATCHARR_ENV=modular + -e POSTGRES_HOST="$pg_name" + -e POSTGRES_PORT=5432 + -e POSTGRES_USER=dispatch + -e POSTGRES_DB=dispatcharr + -e REDIS_HOST="$redis_name" + -e POSTGRES_SSL=true + -e POSTGRES_SSL_MODE=verify-ca + -e POSTGRES_SSL_CA_CERT=/certs/ca.crt + -e POSTGRES_SSL_CERT=/certs/client.crt + -e POSTGRES_SSL_KEY=/certs/client.key + -e REDIS_SSL=true + -e REDIS_SSL_VERIFY=false + -e REDIS_SSL_CA_CERT=/certs/ca.crt + ) + + # Start web container first (generates JWT, runs migrations) + docker run -d --name "$name" --network "$net" \ + "${tls_env[@]}" \ + -v "${cert_mount}:/certs:ro" \ + -v "${vol}:/data" \ + "$IMAGE_NAME" >/dev/null + + if ! wait_for_ready "$name"; then + log_fail "Web container failed to start with full TLS" + dump_logs_on_fail "$name" + cleanup_scenario + return + fi + log_pass "Web container started with PG mTLS + Redis TLS" + + # Start Celery container (shares /data volume for JWT, waits for migrations) + docker run -d --name "$celery_name" --network "$net" \ + "${tls_env[@]}" \ + -e DJANGO_SETTINGS_MODULE=dispatcharr.settings \ + -e PYTHONUNBUFFERED=1 \ + -v "${cert_mount}:/certs:ro" \ + -v "${vol}:/data" \ + --entrypoint /app/docker/entrypoint.celery.sh \ + "$IMAGE_NAME" >/dev/null + + # Wait for Celery to start (look for "starting Celery" message) + local elapsed=0 + local celery_ok=false + while [ $elapsed -lt 90 ]; do + if ! docker ps -q -f "name=^${celery_name}$" 2>/dev/null | grep -q .; then + echo " Celery container exited unexpectedly" + break + fi + if docker logs "$celery_name" 2>&1 | grep -q "starting Celery"; then + celery_ok=true + break + fi + sleep 3; elapsed=$((elapsed + 3)) + done + + if [ "$celery_ok" = true ]; then + log_pass "Celery container started with PG mTLS + Redis TLS" + check_log_contains "$celery_name" "Migrations complete" \ + "Celery confirmed migrations complete via TLS" + check_log_contains "$celery_name" "PostgreSQL TLS: enabled" \ + "Celery sees PostgreSQL TLS enabled" + check_log_contains "$celery_name" "Redis TLS: enabled" \ + "Celery sees Redis TLS enabled" + else + log_fail "Celery container failed to start with full TLS" + echo -e " ${YELLOW}--- Celery logs ---${NC}" + docker logs "$celery_name" 2>&1 | tail -20 | sed 's/^/ /' + echo -e " ${YELLOW}--- End logs ---${NC}" + fi + + dump_logs_on_fail "$name" + cleanup_scenario +} + +############################################################################### +# Main +############################################################################### +echo -e "${BOLD}╔═══════════════════════════════════════════════════════════╗${NC}" +echo -e "${BOLD}║ Dispatcharr — TLS Integration Tests ║${NC}" +echo -e "${BOLD}╚═══════════════════════════════════════════════════════════╝${NC}" + +# Build image +if [ "$SKIP_BUILD" = false ]; then + echo -e "\n${BOLD}Building test image...${NC}" + if ! docker build -t "$IMAGE_NAME" -f docker/Dockerfile . 2>&1 | tail -5; then + echo -e "${RED}Build failed${NC}" + exit 1 + fi + echo -e "${GREEN}Build complete${NC}" +else + echo -e "\n${YELLOW}Skipping build (--skip-build)${NC}" +fi + +# Generate certificates +generate_test_certs || exit 1 + +# Run scenarios +SCENARIOS=( + modular_mtls_no_password + modular_mtls_with_password + modular_tls_server_only + modular_tls_key_permission + modular_no_tls_regression + modular_pg_verify_full + modular_redis_tls + modular_full_tls_celery +) + +for scenario in "${SCENARIOS[@]}"; do + if [ -n "$SINGLE_SCENARIO" ] && [ "$scenario" != "$SINGLE_SCENARIO" ]; then + continue + fi + "test_${scenario}" +done + +# Clean up certs +rm -rf "$CERT_DIR" + +# Summary +echo -e "\n${BOLD}═══════════════════════════════════════════════════════════${NC}" +echo -e " ${GREEN}Passed: $PASS${NC} ${RED}Failed: $FAIL${NC} ${YELLOW}Skipped: $SKIP${NC}" +if [ ${#ERRORS[@]} -gt 0 ]; then + echo -e "\n ${RED}Failures:${NC}" + for err in "${ERRORS[@]}"; do + echo -e " ${RED}• $err${NC}" + done +fi +echo -e "${BOLD}═══════════════════════════════════════════════════════════${NC}" + +[ $FAIL -eq 0 ] diff --git a/docker/uwsgi.debug.ini b/docker/uwsgi.debug.ini new file mode 100644 index 0000000..d284733 --- /dev/null +++ b/docker/uwsgi.debug.ini @@ -0,0 +1,84 @@ +[uwsgi] +; exec-before = python manage.py collectstatic --noinput +; exec-before = python manage.py migrate --noinput + +; First run Redis availability check script once +exec-before = python /app/scripts/wait_for_redis.py + +; Start Redis first +attach-daemon = redis-server +; Then start other services with configurable nice level (default: 5 for low priority) +; Users can override via CELERY_NICE_LEVEL environment variable in docker-compose +attach-daemon = nice -n $(CELERY_NICE_LEVEL) celery -A dispatcharr worker --autoscale=6,1 +attach-daemon = nice -n $(CELERY_NICE_LEVEL) celery -A dispatcharr beat +attach-daemon = daphne -b 0.0.0.0 -p 8001 dispatcharr.asgi:application +attach-daemon = cd /app/frontend && npm run dev + +# Core settings +chdir = /app +module = scripts.debug_wrapper:application +virtualenv = /dispatcharrpy +master = true +env = DJANGO_SETTINGS_MODULE=dispatcharr.settings +socket = /app/uwsgi.sock +chmod-socket = 777 +vacuum = true +die-on-term = true +static-map = /static=/app/static + +# Worker configuration +workers = 1 +lazy-apps = true + +# HTTP server +http = 0.0.0.0:5656 +http-keepalive = 1 +buffer-size = 65536 +http-timeout = 600 + +# Async mode (use gevent for high concurrency) +gevent = 100 +async = 100 + +# Performance tuning +thunder-lock = true +log-4xx = true +log-5xx = true +disable-logging = false +log-buffering = 1024 # Add buffer size limit for logging + +; Longer timeouts for debugging sessions +harakiri = 3600 +socket-timeout = 3600 +http-timeout = 3600 + + +# Ignore unknown options +ignore-sigpipe = true +ignore-write-errors = true +disable-write-exception = true + +# Debugging settings +py-autoreload = 1 +honour-stdin = true + +# Environment variables +env = PYTHONPATH=/app +env = PYTHONUNBUFFERED=1 +env = PYDEVD_DISABLE_FILE_VALIDATION=1 +env = PYTHONUTF8=1 +env = PYTHONXOPT=-Xfrozen_modules=off +env = PYDEVD_DEBUG=1 +env = DEBUGPY_LOG_DIR=/app/debugpy_logs + +# Debugging control variables +env = WAIT_FOR_DEBUGGER=false +env = DEBUG_TIMEOUT=30 + +# Enable console logging (stdout) +log-master = true +# Enable strftime formatting for timestamps +logformat-strftime = true +log-date = %%Y-%%m-%%d %%H:%%M:%%S,000 +# Use the environment variable in log format - ensure consistent formatting with other files +log-format = %(ftime) $(DISPATCHARR_LOG_LEVEL) uwsgi.requests Worker ID: %(wid) %(method) %(status) %(uri) %(msecs)ms \ No newline at end of file diff --git a/docker/uwsgi.dev.ini b/docker/uwsgi.dev.ini new file mode 100644 index 0000000..51aae9a --- /dev/null +++ b/docker/uwsgi.dev.ini @@ -0,0 +1,60 @@ +[uwsgi] +; Remove file creation commands since we're not logging to files anymore +; exec-pre = mkdir -p /data/logs +; exec-pre = touch /data/logs/uwsgi-dev.log +; exec-pre = chmod 666 /data/logs/uwsgi-dev.log + +; First run Redis availability check script once +exec-pre = python /app/scripts/wait_for_redis.py + +; Start Redis first +attach-daemon = redis-server --protected-mode no +; Then start other services with configurable nice level (default: 5 for low priority) +; Users can override via CELERY_NICE_LEVEL environment variable in docker-compose +attach-daemon = nice -n $(CELERY_NICE_LEVEL) celery -A dispatcharr worker --autoscale=6,1 +attach-daemon = nice -n $(CELERY_NICE_LEVEL) celery -A dispatcharr beat +attach-daemon = daphne -b 0.0.0.0 -p 8001 dispatcharr.asgi:application +attach-daemon = cd /app/frontend && npm run dev + +# Core settings +chdir = /app +module = dispatcharr.wsgi:application +virtualenv = /dispatcharrpy +master = true +env = DJANGO_SETTINGS_MODULE=dispatcharr.settings +socket = /app/uwsgi.sock +chmod-socket = 777 +vacuum = true +die-on-term = true +static-map = /static=/app/static + +# Worker management +workers = 4 + +# Optimize for streaming +http = 0.0.0.0:5656 +http-keepalive = 1 +buffer-size = 65536 # Increase buffer for large payloads +post-buffering = 4096 # Reduce buffering for real-time streaming +http-timeout = 600 # Prevent disconnects from long streams +lazy-apps = true # Improve memory efficiency + +# Async mode (use gevent for high concurrency) +gevent = 100 +async = 100 + +# Performance tuning +thunder-lock = true +log-4xx = true +log-5xx = true +disable-logging = false + +# Logging configuration - development mode +# Enable console logging (stdout) +log-master = true +# Enable strftime formatting for timestamps +logformat-strftime = true +log-date = %%Y-%%m-%%d %%H:%%M:%%S,000 +# Use formatted time with environment variable for log level +log-format = %(ftime) $(DISPATCHARR_LOG_LEVEL) uwsgi.requests Worker ID: %(wid) %(method) %(status) %(uri) %(msecs)ms +log-buffering = 1024 # Add buffer size limit for logging diff --git a/docker/uwsgi.ini b/docker/uwsgi.ini new file mode 100644 index 0000000..920bac4 --- /dev/null +++ b/docker/uwsgi.ini @@ -0,0 +1,62 @@ +[uwsgi] +; Remove file creation commands since we're not logging to files anymore +; exec-pre = mkdir -p /data/logs +; exec-pre = touch /data/logs/uwsgi.log +; exec-pre = chmod 666 /data/logs/uwsgi.log + +; First run Redis availability check script once +exec-pre = python /app/scripts/wait_for_redis.py + +; Start Redis first +attach-daemon = redis-server +; Then start other services with configurable nice level (default: 5 for low priority) +; Users can override via CELERY_NICE_LEVEL environment variable in docker-compose +attach-daemon = nice -n $(CELERY_NICE_LEVEL) celery -A dispatcharr worker --autoscale=6,1 +attach-daemon = nice -n $(CELERY_NICE_LEVEL) celery -A dispatcharr beat +attach-daemon = daphne -b 0.0.0.0 -p 8001 dispatcharr.asgi:application + +# Core settings +chdir = /app +module = dispatcharr.wsgi:application +virtualenv = /dispatcharrpy +master = true +env = DJANGO_SETTINGS_MODULE=dispatcharr.settings +env = USE_NGINX_ACCEL=true +socket = /app/uwsgi.sock +chmod-socket = 777 +vacuum = true +die-on-term = true +static-map = /static=/app/static + +# Worker management +workers = 4 + +# Optimize for streaming +http = 0.0.0.0:5656 +http-keepalive = 1 +buffer-size = 65536 # Increase buffer for large payloads +post-buffering = 4096 # Reduce buffering for real-time streaming +http-timeout = 600 # Prevent disconnects from long streams +socket-timeout = 600 # Prevent write timeouts when client buffers +lazy-apps = true # Improve memory efficiency + +# Async mode (use gevent for high concurrency) +gevent = 400 # Each unused greenlet costs ~2-4KB of memory +# Higher values have minimal performance impact when idle, but provide capacity for traffic spikes +# If memory usage becomes an issue, reduce this value + +# Performance tuning +thunder-lock = true +log-4xx = true +log-5xx = true +disable-logging = false + +# Logging configuration +# Enable console logging (stdout) +log-master = true +# Enable strftime formatting for timestamps +logformat-strftime = true +log-date = %%Y-%%m-%%d %%H:%%M:%%S,000 +# Use formatted time with environment variable for log level +log-format = %(ftime) $(DISPATCHARR_LOG_LEVEL) uwsgi.requests Worker ID: %(wid) %(method) %(status) %(uri) %(msecs)ms +log-buffering = 1024 # Add buffer size limit for logging diff --git a/docker/uwsgi.modular.ini b/docker/uwsgi.modular.ini new file mode 100644 index 0000000..57ecaea --- /dev/null +++ b/docker/uwsgi.modular.ini @@ -0,0 +1,59 @@ +[uwsgi] +; Modular deployment mode - external PostgreSQL, Redis, and Celery +; Remove file creation commands since we're not logging to files anymore +; exec-pre = mkdir -p /data/logs +; exec-pre = touch /data/logs/uwsgi.log +; exec-pre = chmod 666 /data/logs/uwsgi.log + +; Redis wait + flush is handled by the entrypoint in modular mode +; (uWSGI exec-pre runs under 'su -' which strips Docker env vars) + +; Start Daphne for WebSocket support (required for real-time features) +; Redis and Celery run in separate containers in modular mode +attach-daemon = daphne -b 0.0.0.0 -p 8001 dispatcharr.asgi:application + +# Core settings +chdir = /app +module = dispatcharr.wsgi:application +virtualenv = /dispatcharrpy +master = true +env = DJANGO_SETTINGS_MODULE=dispatcharr.settings +env = USE_NGINX_ACCEL=true +socket = /app/uwsgi.sock +chmod-socket = 777 +vacuum = true +die-on-term = true +static-map = /static=/app/static + +# Worker management +workers = 4 + +# Optimize for streaming +http = 0.0.0.0:5656 +http-keepalive = 1 +buffer-size = 65536 # Increase buffer for large payloads +post-buffering = 4096 # Reduce buffering for real-time streaming +http-timeout = 600 # Prevent disconnects from long streams +socket-timeout = 600 # Prevent write timeouts when client buffers +lazy-apps = true # Improve memory efficiency + +# Async mode (use gevent for high concurrency) +gevent = 400 # Each unused greenlet costs ~2-4KB of memory +# Higher values have minimal performance impact when idle, but provide capacity for traffic spikes +# If memory usage becomes an issue, reduce this value + +# Performance tuning +thunder-lock = true +log-4xx = true +log-5xx = true +disable-logging = false + +# Logging configuration +# Enable console logging (stdout) +log-master = true +# Enable strftime formatting for timestamps +logformat-strftime = true +log-date = %%Y-%%m-%%d %%H:%%M:%%S,000 +# Use formatted time with environment variable for log level +log-format = %(ftime) $(DISPATCHARR_LOG_LEVEL) uwsgi.requests Worker ID: %(wid) %(method) %(status) %(uri) %(msecs)ms +log-buffering = 1024 # Add buffer size limit for logging diff --git a/docs/images/channels.png b/docs/images/channels.png new file mode 100644 index 0000000000000000000000000000000000000000..87236f1f5305a44029f692b915581bc4279195d2 GIT binary patch literal 130246 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≧!bJFLd}g289; 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

cUD~7xEZyd*2J*OmD>_M&&V;!7) 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 literal 0 HcmV?d00001 diff --git a/docs/images/m3u-epg-manager.png b/docs/images/m3u-epg-manager.png new file mode 100644 index 0000000000000000000000000000000000000000..34bea87d324872c712e541ce9c5d9287dacec86e GIT binary patch literal 62820 zcmbrlXIN8P7d49F5j;}F22xdyqS8b@g0ANS9QS2=Hvc-=C)#l=;ZdYtj-FV6d8 zPwv_Wad8Rs{Qf(XCMzJu#kF%_eCO7~Fqe&4o*W75S-ts&@0W{()Cv?2k3GNp#>My0 z2Z6s6PVbTu&tGx>Qp3An@pOfrkb9Xo^wZ(mi)IGge~VQ{IFTOlmf-&+8c@;jibkbG@4>bT}D;r(cv|F-l}8Xf9~b>+AMD?P*kVn zswy+6Y6~INqvcB@tO3WNHYR9Sy$(4dj7#LtSMA&zu&^{k-FlGb9q2i@Tkenx*idtv zGmSr=f9`RJ9kzHu*kQQ9cYe&kvGOOLJu`coET( zkQpA-*oUUs(CLL1_J04O`wX|>OkPdvT0&v_HP|UUxHH_{eymPJ3ccIchGW+3c_y;b zdUfd=vqah-!<^vizUQ6o*L0Px)wDa|m=T?*#b%Jon0wReX4z`;%w=_KapY!;46zZN zvbv2@be20Jc_mIV;_gp`H?#w=T6_w9IosDs;Lv{$5NyiDC!5(Xm6w&3jg<#|2>l0y zNjZmpC?LO+R-5CXO;Utrewf>eJ+>N60$i2|o1Y%Ll;M;fD=3w&OUblIvl6JPZkoDg zmbra%)POzqH|)=_Hb)M-*?pghqU0s?M^k4JVO1erhv;RQ%3fy<`Cz7eYMnpiMek{P z8fW8E*>>b+7HwTPm3#H&DP6~xgOHg3FV0{W;>4X&es2{nuA9Fn31zOk8oZ&$s<(H_ z**{cai<<$k38F|ba67PdZ)$zuo!WtJyRz4ioFva!_S{bC0y)d4=4$I_>YN1jUjZQz zTtI*EUgS|D=q{|!ez&u4boNPiNFwG*P065mV;Fnv#gIsu%M)>m9Q4m!VK=!yiO zhf}9iU@(~$-my$rTF#>c-8Mo!j8^z>R&t7Jc3@^O?R8mc;xOGl!`bu{dJMvO@M8vD zVuPAkwY;2cXeY-;sCP96U9{I0QSB@2UxLtw+5#W!Cdhb%T-N856I!h`Sq&-qyG2sW zyP~>LVYF^3mR+z%Uy`v*Uev6_AH-#*sf< zwL06v4lqUh^K5;x&Lh$DZwyv%>4TzhExU_b@buEK4mFCda!RJ@udV=#WxH6r^mfy1 z-+E&j4a+V=U+(w+mmWP_Gt_O-bI{B#ZoS5*GC8V8h;6O?SSZG@T&!S~|t+F4_kc{Vv(|M6iSpd{`|Lpvq*0=l$i@Vi$Q+u7{joDuo51hkuQkC@M-q=E4aJN`S>T zkkhHQF5#Z7>osi&tP>YPUUpmnVa-2YFIdsTCxK6=+RtD$CWhmMq!X@d-d-s86aV64 z&DY=Y&V%u8ym^pnIaJfON#iVE+e|LM+GPHQ>=6^wIR)ql?Txm|Pfn+IBnO^RL` zc}F+34+@*uuwNz8r5^4GewaESwX92e;%=vE?|Ex&AqC9KpmZN#oL`d#J7lE|rNnK* z*_pGnj@p3u6RMzb|Mr0C4hX4}zPV6mGh?N>xEdilXXTm` zGV!xF2(SEu%3|&zkg9HZe?rC%B4ok=P>r0E9Vobjpo9WSkW4(RFtIBn-!wW9H06UZ z;Kq?_r*1Hru8GShrS``IRyV&x%?ON@_MI+$!P2+VhG`y0(>dmO03W=GTs3PGe&h`q z)Waq?z!9^dj-yDlLttiqO+6YIZ5^l@($+P8RmqmzR^3;ws}sBCU(xCw2aN7q5+wYc*R z(v!qQ)kOoGUnvE^imM@Wxq@AQfJf7opGdQ@4$ZT#CpGkiPgj!l1+ zPy05QrDj1lza^bhPUyERGCZ5A8Aeo?PriweDFm*a7&M2B_tN9JRIo9F1)y=z<~?mi z(4pG$!m~k+@=IpuGTq(G1(&auvKm>%tW5~g+5AtWMP-y?vIaU|^{FMx(`lDEeo&eR z4e0GPpfZjRBh=C2w}?V<9Wu;c!>7=((|%d7n7!3PZTgInUan;10=ckdIkC3=pJaPK zQ_WHjs@Va=^7#Hd_nZx%ksqG-mCq8L27AKrLiafOUjrRhXT$EMfQ&ne34r4QqgUwkpLBqn7jVj{> zdwI_J1t|pyXYhwQZR5JcJS`!y`~gz=((| z|7&LShN1ivVDn?+ybeO%^~j>c&nQZh*r=_K4&-zc7|9IvbY;h1uEkTntt;K8HfKCh z0ZXjw-gf)X@^+HUm4&=wFpQpC7{17v`at>}PDc6?c%)|30P25Kiw8dXPtbmku&$MM zYH?+T%!Pn=jHzNP)8`K2WH>=Hr5}4&Jok_W6xQbQ3_qh7y)9 z2j>b~i5CLyR}`VINgFDx7?wOLvT?qzc7tD3TSHYv<#g@|k@Onl_m|HcTKOG8$76{# z2ZWD%-BSMafFBp{&H~FMgdQb-A%a60FY(}qQObf3%bsCH-4D}r^PIma>%BHbT{~~>tffnS{4^lfJ zHjyHt)RN?>#ughtKBMp%l!UAfP7rQg$y4>Qq1Ec+W@AXR;HU#=!iZ|t z<01bJykc)~#_;`wK-w;loU2Eh^xNzu%~rD{i{o8!7zVXBlxap>%iyn8_<|6fR|0y?luZIH`4;# zn*Y+PA}=ow{Py`5aFXM<^97Z~@i9SuZnv)URW--?s#J@&f>@e8jlZMn#YPE0sISlw zBMq_P)Gc-F(?NHdcZ$ZQNw&|3;fQ#uQq-?kdUBht(`md~(m#^0Ne3ZIv0^cf+MTOr z;A@gaz?I(^#9&Jl)jI9pc9vO|6F7CTfFd?`y?{575}9yaBdbq$vsn<1EaUy)p^@(K z-b@V$B`G=OD)g-PMW*PTC1(Lh?Ln>NJ~6al%#&N12Dm%l#4kX8D1+4E_O&Zel6c9; zjk*P$P}^)yamG2)OC#CL+D5uqs0nBP24&1<%WSxqPKybB)!iC-u{3hqN;2Ynhkrlz z?6W5`FQ}Zj2UB3NhR5Dp=-Hc(o?Uc_!ju^BFI2`vg-?MJ$r3}A(!ViEqW1i8SNa3{ z`f`XAW2jJbEfqSyuX2zFwJ3|i%Nm4_pG|pOfORZuY1Z3%#3T8R6^Jd~yIhg+P(O%r zl>1%%GM5I3ZD@uQfgOw`eu9^jh)7WnN!AZ+{gs+*pdntUxpELhDA8NP^6RSK?Ws#G;jfu07Izpwx?Y{9yFVvbob38DggC{+qPS_%;|Qg3Qjx`S z7qp}1KQs<$uBYici+$$OxDy75!dRp8za$^0Igb4hcD*L4U$X@Gck;8!zbl_r$^)Kc z>49BDAF2rHTp7$&1`NKwY=v44%I9ko;`%Q%aG~DU9Ck}L1s|S2)WP$_(FJe?ZfK!D z-2n}X&*q*DZ}1XUeL?tD+lY*iV+c>XgCSJ%Q(6)yiokfYTd z#ORq@Pd@34$V)n+XY7>|&WFwAXcDPBDgG;4LIx+a!|pmNkCEH81^!(5^cCmIlq1dX zeZ;)d*XgFQ(CXuViw>Mur{P&$4PK@e%B~St2lLeR`2FQbf-@s;Vv|MFVr#Wpz0I|5 z&x;@V^8{QYkKMTE|GKy%-+J-0e8SY=^g0|kw~kN%9a2C2D8LbW1M0%rCyB>fGZG1W zqE2&CVP8DPk4kE}_dGw>q${R8ZN)9gWwh*gr?HyfzzlcW ze=q!t2g*dTY$C+Qf+C0m3_6d%Rch_Wk1F&je$<62f%mFZ@V&z5oSzNo{9Arh9)BRv zxH3sR)8?E~y@<=Z^BRZIydgeqvbF8)Ifwr|-par=tGKe&b>u^VQCF9_t=`4~d^f6K zli!<5Ts5?40t~kSYZej%w&usLd*27;>d3%{a?%|7{XQ;BlG9bdZ1v*w(W0|+QqB9d zmj-h+i@Zg(vR+7PH`j%UYx#zO#9QwvkuDv3R%RN0z8WzXn;4w!-wG5uV(UYP1&;|Q z1Yn&Z(e^jCE}OU4@qYRPJKe%n*Oo0;d(B+#4kOooszA%Lx=cWGpTk#MZ1o(EbV&pkUPv5N3RG((Yw*gG-bWyzz(P^C@0mf_NtcH0k?d{Dx1c&Nn&B*3 zg}U(H;y(2Rv%Vdy_NpV_EY~Oj;Cy~pO;!_cHSm-aSO}UF#pr3N4y}Zr8sV5(P(t$> z|G68*yk1`IJMQ=7FMrLQO7R8^OixV6sls3~3D9X$&ItJwuo?l)6>;StM zzA!HFQSg<`Lr!~MqZ>g&CkSp37GwN+v65<%I82m92?H^Hg2&#-#^fE<`Due!ai8OT z=CPI@g1s9=oz#RYgz*G<4Q&JwahKle&v5*^C5#XL%=;xmvyop(_wbUw08lnj6oDc1 z=)~$DY0P~cAoNu-WL$|uwM=1aCcHURQd1-^!E8j$3yfo=-XabE>cz13oPvs(P^` zoPy35^EXK*xV}AV0eg@H7K{-#Jw?ez&Q^}Kd(6QcTo7iUTtMy0*qgp4tZ4q3%A&v_m&0FWyNm&5V?Oj`xnxRBv$d1GUJSh<@GM4fzuio-ScIP^ZOs`PHDV z(cL!hd(+q5)5fp=dl%G9$pd~9tht-4j!Qdg@VU*czVQ3X#6*(=QmiI>xszHAWH~}i zs=IBCl%nLg4bukHC1h=84j5 ze}@@;^*hNkVJL#1Mb?Os2j-3(9 zh;SF(VW2#NhJ_eSA@Y^U_kwU*7X~ATSyv6Lh7~(=L~YJx9qkQ*LL5Qu`!tm+n_^C< zx<1o>>~=>uz!k2E*m*+sNnn_m&QYyqr=#nO0fA0 zjzc?ciFsP$A-61i@cAmF5Dt(rlKS9)n7s(+tIRHAtiGpQJO(V(hJLH^-ave^1Oedo65OJU&l;v@RR%)U` zYqNO6$WO1l>CUK@*Q@0L;$NP`xCC1&H%7Sx5_T$sL(I+n(tE>GUk=&p8 zy=?Q`pRS+ys6u_CgJyK^nO9e;m!xNumc_o6e$buM0vxH8DeXI6bL}gx+)|vKPTl&S z2e<*nwnV!)sl%e%Fv;{c%$`6gmL5=+MLMBI4*qoI;0QhH$#tInAD?cv1^*aUq zh30SNIPKOTr0f>jtI4b_-|_8pSJkKI?silk(hl32-$}4lDSOl%TXI2P6vb&{{65l+ zIIRK`4wY)k0TE$YddLzv;@nf7E=v?7O-T9ye&i8wc_-rmDo1na^wq)0!RGP$^`@)U zx|iXd+Bzi{l0Q9K85w_q(+7?uzaN{Infh9NMt*tIJmVfK2A+Rq=6Zq|o z^#czN71SRn*0l|qYi|K=&in-KZxsLsKJcI!2NaajZ;;t+)6JL|*J_sU%SE!LK6!G2 z&HClqyVc5GW#eNHRH!+ny6=zTf{jEOsXU3R34ft(-S`~Oxfv&DZ$2lDzEN@SDX;$n zex9EB@*@+CZVzu58~@Qc-)vOa0GUZwfz z(c;#)R?=rqJ_=fpv*gX%by!QAQmWySxNuIum1pu_5FO{MhB zHl*C@RQ`6rP1PSh#xK~r_d(}$V({Nu5eH>cg z%E`-j_K*KJ)aSzqNnR@>FoL#8^|6&I&*akasdht*8cd;xRphQ{(U_| z4vsZUb%9UzRoXcE1!pOFJkA0@{Ox~Zwa@?N#Btea#R~YDy_dl`3WWH5;_14{KEVay zr1(y|jVKPB%HT8t82==TC>3%xUC(RiJ()a4Ng(f9ce$lBx>uN8+|0YcW+IEmPFcvc z%k&_S%-z3`V(77q&YiUXOp#CeeUpfYQ(TH^m-C-3p)H^hw`h2&S_^PkH;2xDRmjLC zX*Y^HkY}Lm4?DjJAJwPSS!#acdQGJK#4+fqDa${2JY*-@-lYfAggY2I*V`*NxKx(l;Cni(M!AlCc0e!ZpI@wf)y#WGa zS$l}cfw~bui~akGcAb6?-}QrQxb|qQKN~|bjB$a6Z`Ab*w>4j=`C3F;;1pex(^(QsDRsdduh%^(?+}2^0Qm zP3tvlDr*EjJWTnED?%|#Q~(Z5Eh>3a&bVLMKyey%ou$SzpRVm!A?POIQva}-!?mLr z*3A&t!cZqUs}S5JDX0z3HiR~`=aoLb9VK~j!f%c}Kx*4-_>pI_IpGpn9cY?8H7paW zUp4G)sibf?etS*Lipf{8R6xs|Nby(_nzm%YdX!Kce$jr%78Ap+B>ZCmASM#015Jge zJ-(r>tx*+}DyG6t@6C?XDq8#6_A<$DSN-!Ivj>7~2#`H>==y!9ToJu7vi|hjeWhwO z;3?Is6?)$S9w_!Sm-`6=Zd&3MVvCymHnwco`#q`jGqwy@`o6}6Nml?29?J(bl{Vy4 zq=|{~l)aTUI?Wsr4p0SeOfE{WP5{)&%Ux?iL zIUG|jV#H3wrR{5TIKHv^Vrt>B?8@5CCh;|?IvwBUUq^}4cbz`X{Uj+?nQrUe63@4$ z9$x@0Od{iPQ~N^?eXJKrmUHZHo@B>ds%k{wAtQ0|KJ9JB_OYI{pB(S)Aw@AdsuM(S zLTPAu4wd9gjO)8Wc+B*0ibu|p5&tNfXse^>ZKJ&ucNcpiIC?{DF?!i8kBf`un$uRH zrY{P`<4It@>Y19MAOq#%18p1K(v6Kh-RPgL19ZXBaiGN>&-M))YHhv95FQ`h7sw%e zy-s(Nr^0D9dtPhKRT*o#j!_eua)OxX?v=upt%{*NDyMB{()&b3UJ@UOhAWXY`i6&J zGrxemXE3Y_vZ?#It;{_@B1_}(C%=yKO-0Qks$f|%Gd1YlsMLabQ4y}>qe9ldLxDHN zh9%;IEV*}eZ!=)l$n3gy49P$Kc+~d(8)!epLlQ!rh04a-8Xk7bKLNM~zvX8${c`Yy zpzephB;d8UshW*MiM%)2#)HpHauyTG2YV9jTDgrlyr1h-1M?>jrRSCeZ2g` zeie)CRC5mdWs$J?S_UT9e-qb}Vipovt@A~EI%t=E5+Ez)*Z62u3UIlu;k`u+VYPxM zfxBQYQm8M*xxLDz$J{+QU|t*$p>s@3>tr5^(}S>K91XoBP(ksT*h~ujN1z?bq9mdU znsBJeV{vKDQMP!17&1HHVrd)6YL|7B)6wtfrNV0q)4fA*mr2;FA2!9q(&?&uij-$D zIVff-Gf`xm`G#|_29^J8;Y;i{u-Di3I-#fXAb>)maa0o6y6)2 zpdD>XSLXH%*Ax`u)Ni{Y>vC~5aC&S(EBkEg6t42jwDXTPT2AxG`P+;4BNfiOAX=f{ z1}-$^O`30q3Qt>)|1*qq43BcU`)+qLD06LE|GQth+yYwn(vCI5ju37j4EFLNvC`&2)KrXE6^FNERXV)ZC(vxSLvp z9MW0bs}s0n#Tat&4rebGinv$?hY0ph)>S5OZ>`)G;HF&!L8yz@zD)l5QENY2N4%%1 zl5*;=Itebn1&CUwML^dC2BLAhlcAG98h;m+y5M$W&(GaOHi&Xx!roFh)w}wFYk-T+ zsAXj(bEKK3ja~R|+ui?tbU!y0w{~S%Rpa)?@rQ&n+qj)D_H6EmBPXwL-6vhVKd3st zK#Hm71+5^#Ps@qQ?a5d4yCh`b{N4KM2rgUa2A=jw2 zdsG!Dc0s5ApuL@QuP{$(uA@`EsU905y+fc~9xc}|AGuRfYG515%@>u1Wg*OvOG8)7 za$stcZp^~(>9!pprzt2q8r`qQ$_$j-Nu7-KFp>!cwI1ii7YBxvT~cT)pJi1Z+P)c2 z>T<+I+!R&!M=!M1eK}ftrBhb+on4%S^Oci4lNP!M4>cRhy=m73O6jdd!nbaF$sA9A z=)X`obttjI<%^^2p7jD%4hpTXW$jrzbqP@tutZ~Ga@ycFD|``IQYS}76g0etbN0b< zy+-&Oi}}9xpzxfY#N%CsiSI9CaForSKx_HL=)+v)kd~uP42jx_`0C%@FK`zRhJe40 z)$C9iQ~nKd(90hEt3+Wq=kZW)VCys{vosAv_HYHM@Y%}t10 zB=XFdv#gyMitAkeJ3T7K#NwN7p+#=H?))Tas-DfUaa8Gb>y_<2bK$ug=F}c$?A|rRm9a=P1 zaeJ5RI;*c==V|>~bV~i&u>$WKihFw7q@bw&O1c&XTjQqy@@y=OksR%DVN}Pzua#K! z*K4G;20T!jvo&?h)iq!1ef@>gxn$>->u5UB9i=EWo&6~-ai3}7 z!3d;q1s!q_;)6)6YGdlJx%&E$GqEmJqSNNSK6S}UtTkr158@L@e$qjSG+86fRl_>E zM`$U379&NTtzvHWMO|bNzlXQ3tuJ;p*QV*)1pj9eX@|%qkGWrigO?vdZbr&3O94hP zmt_jqE$Z~c8RR^dEu#fAaBW;>;K7Ps-KkSNNo42pS@O4M9=_OsSN2Z)!sxh3r35m@ z4|*Py$)sVC#yB>skdjRL+ONBpxo~Y}747w*L^j1Pu1=v6DG~5QVlX815_YYkhM;k` zD(0w!L{11MYUSMkm3z(qoSWxK9C?cLIOpSc+^?`C?yf7TFS_<6dvE!{nru{fatOja z5lU>0`DEPEl|

yqij5V#Id4d!CHL3A@bd@;oEmLvJ%0Y#{Uk3{_k>dLvXN3!vC?& z=3Z??Wf3!z)z~kb!O)Jrg0Oc6QS>RAk zQ{=*7rgZ52h5Wi!=9nK zaP?qspDgWzxxjKC5Eu7N#idF}ZdbKMoK(!+fJ+y-b~2n~dpdu2VoW&d9Il8fpjbo7 zh0!C%5HrDsg@nfe;|oXKjPJ~6XG2?u0Ayr_R$q)F79Q+F`dpAN*fSA^-oCljrC!RCM<|#a?VJAuDg8xd{KKE6-XOOM^-&$PP=}G-H5;4rnEu|hfniSP zKUsdYD%IQq&;jz-!q*D{@gamVi0Q}obKz^{I|!cX!wVIvqGD)Lzu!AiQPF{Su-9$} zgVfnP7z`W0kG}wr%8c#Yj0V!wac{;xj{+xk^DL6HO|SGToTg+3(G|yLvWM-ro`ncq z5j@Wk!yWmb@bt60&$-TstU7$06{{%jQzz zjL^N%@QZ^mY?i1?R+b@SX&Sh-K}aMsiJxa5mngP-J&ss6E<%6!^a3?zV6Y!K)bC;r zNXpL84HzolnFhp_c0=;g%vn%nbfmdd+GabH%(XwFR9W&q8*9eHqCeZ|%F*-$V$~$I zRGi^I`&K{nmzA2pA`9f4$KPN(6|1*fsU?{iP;LHcDpI(r#}c?Xi!`N3wkX53$yE2O zFY6tpyszTgBSv@TU z@wJV}Z-wqmYwYfr4c$I&e+HySxU#-N`7v!3jKN_~baU!zor7pjm%mYWJM`yhdj;w~ zN9p$3Gcm+94T=LqBs*YMwWe4NVaZdw@0NDOYfn**wID0QYpyJCMn%`p3?1TfGr(Y! zguAxgCz=s^Ci`uhAbm7{-jr_i(!AMpH|_+}HM+lawbjDc))d{k)(YHP=1KRUp?bRjq0@6x~ZlKk!*|Hd`z)NcKLlmNQ~JUiS0&@v)9LZKzH9=_Q;(=OAR* zkT#{Qw>f3;DWoXb#Xeo`G=7e`34ssGwsK^}MA&6=1V>{ER1}=eJ^k8~qo5!T6sTG5 zeX@wY5xt@>b#?g^RE*Q47!TYibl1c)7qLm6>vE0$Actyk2d5AnFLI|lj3c>lu&;MV ztzU`JcW2gA86y3Ych(Z;M&{~#JD5Zsh5b3nxw$`7Tzbc7k&z+tS$Qyv7_U$&b+;}BK!@$bEfJXt^0U?0cH%@RlWgD9j*oO5j;NotK~%1!XZXpc z_*L{f1&E`i`k%`ox0S+zF^TDwOQRv7d=5I2Dfp$S<9UE=sP%{b-~$aEh&3hvgWXuu zEQq+JZ=rB9h5Dv!n82wB@c^2CaHD|MNmLraHF^+aTe+wF$^1Q5-_%NW4wAI~o$hM+ zL#oautIeOip$>yCR-#LNLSoo60qaS3y*j)S3!(m$l;bXugN0CFLL>`DCugAdvIkA1 zST2tH^eNk6V;udP$tqY-t<)k%xjrXw6)4hSPM&m_!68(u>#>9fcpC|}yid6IgRGF< zjIsHUrn~x(7_fb76eo-_*PKZa7Oj*afbH5T%s7%q^5cV-7I3YpgQO>)MNiCe91XaO z=}*_M`sPAy#g^!?!^sYxFF==trVJ;~-3qW4@3~_qhRn2onHk=eaGoM9rP;Y6s5!E+ zF)wp_oYxgFhH0KWD`S@RP@rUidFRAz8b{FkZ2{7`mj6OPZXob;PDaNbK!_)xPNCwE z-^*Tb1bUOhWCSP~5ZC{OueV5A>#-TS02*U`_FAp@+X+o~{7z~&SfTZ5UYl|?dC&HP zhrLbAZY54`->>Zolbotv!s*w0*tc$U>@Ott2I|mOJUoq~6Q9)SarZ@%n6`DS2x;?u zFk-f;ms05Hk&F>OKJSsWrgjRAt>nGSiB;^z+EibIzvDR)*3rGCJPq=4eWSeH45*U4 zG%_aqZ0zPwkJQ;jKjk@!^00QB(-ErdG+n}*47mWta=IVBY_ke~yu`V0!v&JPt%K_a z8OZhuIp?N}Tg)$l3UYB{&pqkk0pdo?gyU~12Kxb#+FL0~RT!vrpN=P9 z;(7mDgTPVv4K3&O)`33s*O483sxhprfSK$Ub049*7m3O0bHz*%L<8X^uRT8Jfh2C0 z{6x;NJx)7%S427Spg)qhl4Ibto^K-;x$C|YmdnT*5b^;m&Z{^;;k zX|I%fr`Qu`S+HmZY8A#Kh931wy4OHZU`__I#-YXOtHY(~UXt-*%=D66pYX*!6#}dh z{e~zgYm#okA#8BHLl2DLBN4Kp=iR-eA(&iPyYz$H%b~u3;la+%CBn|T15F>*T%FVN zgqh?VxjtLMIVK!smkXnvQB~Ft5&N4*D(XoqZLKDIBsnV|$z~=monV#}ksgU_^w3gX zu&xZ_$otU=FRHcuKJ+(+M+IX@5k!by55-*O6ct*xpH;1o|Z zSxYmjIssSy5p0D2+CgPC=mZN$yqdCBLh{D@Abx3FNeZ{tq0R&WXG7Y+ohzTpU=DJ@ zDL9H<>Oq@LWvGtr5^nj8-tW8eG`~ z+gO`AGQ~Zb_o90!9`s(A2!&!{!9m8EqkC?nK`bht)=uHRHp;$JaU+G@C{ zhG1MYTOn@hYd6R2<>H`IU!sBw=DH6!d}L|AWVVAq$zRaQ%`0>pz9PfqYlm}E?tBay zRK=$0?@GrY9}neVI{kFFx?TX8yX(~3MQKE4{Z5i(2dK_Z-ohOR_*$}7H!}XoD)H=_ zatm^1e}K1uA--8viZtQk<`}t=J1pI^chXth1KXs5!7v&$YzF&L7%by=w;*Ol>`}1l ziDZvyaZu=9PJ5KZt7cC6ord6;sDhMUgH^JNPe>Cd_&tj7Is}fV+|aS#{8$>HtEs?S z>lZC|$IV5|u6AX)dd@}~Vl2_gkr_{Hj1nG~=?~EywHP^(W8=bS{cao}U9h2@rAU`H znRM)y`VaQ>8DO6#*tkJKS7ESqvD-kM1glYeoCRe|jphF;vg-cGk9xP1$|bULpAj%5 zQxb%=JXI?39e@od6idS?DK*zZD=G$G8wx|`@x>79BmPPXHJyn|iL&JFodT`sn%Ba3 z_f8=$>kK7Oc5kzD=;{F`zb@%ucRb3a<<5v>N1#SiNy_0b_xV4%JFaeCGm9u|;P~E3 z&#k*3dYkus7nB4}Z-Z~KIueHc6Xjhnx_KJql^xrS6?;$R6JNZCiBEF0gx}*q@z~L{ zYI{GfNh?QiN66@+eS1bbzm2Q%gt0Fw+{wvo#@bC{T`dOe(`)=eBlE$TS}|KiTjs$q z;`Dl}=)*oR*qXuWe+IH$uPa;ET;%???}SI_!Nh8X+&lVk>5P{Cv?=%(kZnZU?9MXS zhEcjWd?^?*?^5f+peO^!)jZIxBv-}&gqYXG>Q@TC zuip2IXl(!%#oiFw>KJ$FR%)Hu@c6J)dcLSq?UUp5!+-hWhO&l<-cv&Zw_RZ6Z|wnQ z<>~$F!4nTXEz%okGaqeXu1Se3P(y2-lRckpf5=4p;~&x%r)slNeaX{RD~0VvArGRf z*R~D6Vc)h7RWNg_k-o_sy~h@veMj@O%tYRm+ap6g^yI{&S|@8WT`NO#m$%!a9vSOp zuQiX12Ho~TVcvSd!Y_9$$fB zr`Cco2g}$5GI=It(eK)D1EONuG^S%^7&VG%a){Q$Y_ANb&Q{m15z@+t1DqdSSVj5U zA1UgNQ=Id4mvan>!ThpIU#ZBm=Fq~Z3{)i!xpctni^7K#qNbyF;e{yothLD3tM2xo zvg4SLw*;}_>dvwg__isV+aWHw%$nUhA+v_I(X$K@lFUFQx|h;=3T=%w+f^Q*#P_Gn z`49>AvmA(2&_FD7fDzD^_TM~5cmL(w0hA$+LI%Mp4mJz!z3n@_i zN451UEBpaf)*T_X@py8LLae~-px&}re}+UZ1vo7^t?(q}c)9ehfeM{=1p@5xfh>i!@axoFHpCzv_mA^fEN^tehvpI))UEy1s=c zZR9=vsO}g!ibs1uqrfyYQ?d9pRVPn(j{;3A@!M`>?oR2D;YDS_gS!1XP-#Xo2dh}c zB1!D!3WI(60mRH=+V=(`%8)u&HxCH=61xtW>}`gLHg4{#?3hU@#zmAaeQFBPY7QqS zUP%|`qzQd;zh>TtA!jhl4N?|5H)`E$ydBm7nhana5EL~g?RExQqi$=5*0{?Qw4#Lo zM_NA3io+J&q?g42LbZ{zK8e3ZoCgO}?!3r0eM^O`S;n*JVXqv4;#3eWJ4?U!N7QNa*#Kjem&&cDt zj-ADU*`Xev9_zMnu__mihN$M~M{@R0`={s76D$R)jkfxPU(GWKXCfHvq$7@`=B^SW zQij#yddW00;yl(gIf##JWscyLk`qoW6^H( zbZ;6rXQ!=5!#V{Gy0_ZROI6orOII!xC#>|%xM_=I?S=)|hCKsm4rYbMy+iv;EDY!P8qjJwN)3FkSOdsK)- zNTXBk>)}gT6Uo#pSM|e9R}$v|%R**;adznr)2I(nfIA?e>w54WGimq4 zv~Rm%Jf~ZFbOBOr({8g1I7yOq?GfreP|}UFgK0YllQXGzAV`WO4Ck{;a&R(zk9AH; z^mwJMFu|FO0g>8SRIiO=IUvc78D+!MhvaG(ZqH143~2~#thuki;@>nC1tkWE3-@2pLrYF9KZozAjFpUoPhb7YJG+fR*|OI z+GZE!iMWR^48Y~b#?~u>xD}^uL|t0;+8e1Ulrgr;4Q=LTP16?jM{4_r%^0`4Lad>j z*`_K|3?*l$Ri1E$X@bsIRX5W|==@Xiwld?=tWWeorV63P#0u3I_rEj?spCE&)0FYg zKfI%I-k5hTpUwevHJ`*Q+6UF1Uz?)2=9zt5d=;#R8cxY$y$ z2e{eK*?*H5!hKm+V@JA}FwdnH4!j!R0sjW9xS@*XdbO-un`WpZ@C8Ie#9x8pZMDLw>ELA9cL2ah!mj1?OpqyVR81W9AR0#oRnCL_zIFzzOHtg~hO!WV0<;XmDKPoNLF* z@@5;`W>3DNa;ZvJ=5~YKgxs8$whTuvu5KY_#%=V?GR@*^TzgD~yFI=q(nUt|e$5#f zsx8@kLm+#n}gimeP zFH%ME9&`3aC`_98^B#t@)2gYQ$pnTV{>)sVwlm`mBG0ZCvnzyZU#lpZit~9f!~2Uu zCz}3f+uLomYGYL{H1In6RbH5|{}6IUayPgwo;qp&eu7!u>Sv8;+w6p*DD-NkS?cgr zMSVGNJ7Pl-+R_o0gln;jZIAA4IyZdWIa&`Nedqo5ue91I&ry{S+v$6db$FS(j!m8z zNqr9D1$DeAGKd=FI?Gae>Oe%6D(Gw|eB2#ddQbZB-NQQb+ExL2XH`VN1#<0A!U(d7I?+na{&#owIviEDD` zKq4c(Bi4Pq6#1Y?rFtC(F%fl^Q7W}NA}tZBR8zLB7vf6R@E#24^{^N`V5(c8#=kF( zY#VX$?X;D5OiKaG;bw6Pt0@sV+O@F)qb;DMOG3}UN4zbwZ#30*I_->DDz_9AUFHZX zTWKAyN03dxa*a1h!&Q~@^?=cwhr4cFlv?ug1i zOIh^>r0fcr=|W_1dK$~8aAnOEnn?2b2^lfmmDfEBx(7NXoEE1cAlx zO6a&=)?o;SKPGj%GL1&13ZD2M*1ElP#S!1tR9b91aXG&d{wiX9DMMK2s%51XX=UM` zz~E6SIDX+sNKVdXd6l|L#HaP8m1@@C&RPz77|}-@N@YPOy+F%3_@EdXvjMi$uM&Mc zuBPZHtBc@`xM*5&h9sM=sXGjMGKHpcdZkfn&a;U6hNU(0m;Wf?U`NkObPV%CrTI{v zLghO)PQ^mE9>_uAearRZKbu6?)t~o>q-1?`UiEETAwrZogM{im$>F=6I@k&YBQG5C z43XIfLA3JCV&X}!+Zk+5gD3I2GCQ3x-_qcBiEo_b5br29{=2grPOE4`zOW{XO{Wbt zC8l&zCDP<8ciS3+AHF`X;82a_dfk+`Lg^j4aJzd_AP_2G4QTz?CL_E9QD;y2$qt<{ zIDQN+9NbS15@&m1N=pEd=Ix>I-f~Xo663-(nip87K*BdR4;(9(GOvDu!&UjC&9HQOvZKVuSP5E3)8^-fu`NNT zw4UYUzx@xV;JvH#UQqivT%U$QzqS|dVPc6SYN4_Oi{T0y3)8|jb!%0mQ$n1f9h~h zi;+!V{F)J#JSnh^sW}*!7;1WE2V2_?y$qA$iN*kn&VAZXPA00y5Gtmi=-y8RQbKc}4Ia7&_i$@bpe1a|?m4uLt8{DgSIthvgL#NCHmLd| zBuWNwa$Uh(Wa=|p-B!7`#E zphJ@YK`8;LBE2L*5l}h_5PA@#gceFjAc3$~D9`h}d)~AA`|Wwpp0j(_bLJlg?%cWW z>$<+}Q~U&4#M0m!{injj#>!T1K0icrP$?n=77WB%t%|!cP2C2gg-LOf zn#$SJY3d#ZMtRDSAwNY7N%?p{zvp0tpqI@2lsrrhVBZPSu1Ebyq@WT^hYP19@0uzZ z9mx;=UHjU6=2?of^)t&z8%25Hvg2;`_0i4Me3G?!P`LmaQq!j0IbFV)A-)fLtmrk- z(4-p`85=J0{MadCev9PH%0{mElh(FpPunMn0!DhWy++43d4D`PbaD*dBV(*@%+$&g z4?nat)INehgees-q#w8Ec6C7Y(Bn)JFSvI3l@_|~?|lP1aI>qYmsL133W01anj&MP(V3$>~r**sN<60 zx;j3o^6aTzH&j5ar-F{%g-M%&CBaXjCVt7!{StSM`dMZ{Pk%t6ay28Y(VutrJhHvL zg+L^goh{mw@kjd)H&QOG_Hy=gocgg?UPCc7K`BiVx+tO3)1W?mZ;oy6y=sON0!SLM zT*v)YVGgKb(ec$WcoXUI?fDqL;p9kHMta70s><bWJ`U~i+q0DH`o!&0!Oq0*1n+F88`r(v|UF`P!yLU7^VtqMmPo==3zHmLLq0l9}D-SM%^2i?{$V)0c{ z)yE`}D-RT|3Ugf7b<~Vc9%}7~;oA@)yQ+>W`dD5BzYkN6A5*C^(#U@*6V+7oiSzLs-BItC$^FC0X3q2~r4jnf>OM$~R6R!Jj6rUzwqxkFG z^95`g@5Xf%8ynO^mQ5t_#nj~b7s{ow`#!5a&yLtGfBaIt@O~j|sw8lDCM2r&m*wgl z5doaUZeqohp6*x`8oY|qnzXQqY58M*$zYB|bPKeZ|X!wq|X7b z*Ip?S)9<4v#isbj`pT<4bS%n(#)ou*^aZ9)_*kUqMLA#!fi_KE)6l7CO$X}()gsU9 z)WtfkXC<&y>;{FFY?tNc{(kX5HDZ3%dGTd7Q~H@E<1-?YiE^&#tdgK>E0(-D^TmxD zRqX4j9Waq*dXu8=(BsQetS5|jaLec=%9d=cVHCEK4&pz+wbD2}Di_70a)apB@%J}p ze#A|77;;_J<##NQ)iTnMi4jC5D?3XwAM)QYHa12o6*wt!HO7$aKXEhD>9zacLWQY1 z##b~|PrO+C9qCvo+P6BnP90t@N5L0vYmYq%>Mni3H=<{p@>@sQt%nceAJb7!QZez5 zJ43y`E#LW-B&#_UAesQg^~P$$xk8VYQ^8}Ieu7Adh*s|p(S9mR>= z;Atg;bXCLGx!ScPif}H*5Spk#Y-?D_AVTX&!NG#Hv`PiK7%{7gKK#Z~6bE>dX|bG9 z))HKnx3SF&VpfX}%h3Hq)Zb6VHrf=J9ojd1VC$DvA3v;MG1^>~f1nhHr6EuG;~*#A zBB%;qXWgr#uiRyjz*GCF?zVRNQ>SiE%YpAvcOVI{?z|NEfTbux6Y^=5`{!cW72tcj;oX3f(~Nz`n0d*;I5XTU7?!`ZlyAJpL*mmPF4Q?Q)AheBL8UL%I*|Q64X)9@}M4L%N?oot)&d zY+;3!v?*C-cfc3q$!S{~W{fcboOPTB7>|+V)7JlVb$6F~s=l(^LLeNL4Ap|BGOk(d z^W8z{{AY-gx)=90SCa`pzR?rdL~p@5K>pHe5?;Z2pUnc;&5uQHnknwjp(%k5)yvH- zE$=1<0ZTI(Q0Cv2(nmt#Pw&8FAdy0^1na&7_QtJ)!XFTpFbrtuXUSe6SOx`&Lr_W{ zmTJ>)A4h(lusBdBi3+@;Hk0Fk_aoZaCD78A#+$NXq2oeiD-y$$(FO}Mj)Tv;+3A64 zwMW<1#!5%_RUS=+RG_fapvIhY(zc2#aevQobo+xpc$-fuh7$oOy|n*+GXG`~d|swu z9RG`Ah{T2_V6jBjDF?)!87@5TaWGn&wdUKUBOv`VD_TTdX}*kTt0V9orZV})0e@Y{ zBaSu*!`biYfnkr{fC!(OolK6oz=+XiNq}p5v+34gGJlnK4X{=L93werUj3M&v)Mh~ zNzcmfPsxvt$T*8dzf%4@5^WtaUq<$Tg;?#e!=wy3e}k~rJ7jM~h%(xJ0Ye~*Cv34^ zr{90jBN-sgoydUiyYP|uok#WtP!2L!O2Mj3zuiyxOMsOlJTn5A-B#Vl+igq^^!Ip` zjjzC;wy~XZD-QHJZ9qrQ$R~`w>D4j&Qp$9#gdUCZuUM$NH!H9idTwHu&p_NJ|5T>-~ z>k57|e2zCebe;u&`e|x)+O~>!#Pj2~Mlw*|f&!6;hv`y}1}dB8pB%0csOKF4N=P>I ziqAol2mN|wt$or9FhVOxRw|RlK$$1=oUy1g!nW0k09A$piloia0N)tWn+`y`wQQ~M z4{h~HwDGt2vHq^S(Z}!$91g3RNT3PU1+6XC^Teq)PmxWmBNpHV^I z)vCMG&_f7;@Z5lGLsih4-REXT|r&j)+J`0H!#ClOk7sEEz zs8O&`hfhvx1++RdNNwn}2V{sKw#$r6ki~op=FB`iO=8gk$bdwJ1X3$m)M_H8T*@Zc zHQ8)IznfN|cwgVRpLk~95a5MA`A`=8d(0Fn1UqN-XSZm33}8Nf5r?WZJ(v&uI9r5! zSJ~mEpXjzl%O(L_hohbK&5Kpe;%dmJyP|ywu>C(nz`*^83pf>^Kt#ut(G?T+M*?^4 z(1B$k(%RE8ubVn`YMw)t=`>H@>{S0?RrFQ^JaT3B&zk5j%$HtEDTlHgR6_)V10<0} zyB9Up2N$JOO(hfFMPMb`kct-yFtmaH(PDk0K&GXWf1HkY>orXd@bvVQVKU6RkJ&n< zmaldTb=~tl@W~7DohR(WyyzDU|3k`TGR&4c%XhWMEE`~o+H?pVcX6Av?JaL4ny!Hq z?cI>yIa#C-VCRG}$WC~u@^H#R_3T3Tz`!4IY-yaBVeP=pi%PAQP{&(HS=#go5_)>rl#f_qo1ea*`A3TtqN<+6y-UMZie4yZ49B5Jh=` z8M3)b%#MN2Igt6%)~*?e`U5E)YUkC+BCDOdxYIB63bF=Ten@WZjbX9~MJ3-Etuo}* zlsfNd^bdSxu4=h}RjtJNWo)YovHgdda)o&Xz-!n3?s61EFp+$ai zp%74=w*Q4s++=Hl6gX;Mc<bh~gjZ}#A*J2B#H9179q66>szd%=JLqh;4K-P1UEM6DBK3bV3K5Bqfd@a?sj%5+MDmrMN=oV$&o z?Jc3S|7S0ZOc$&Zu%VVv!RA&e_}F|NaM#$t@{ui820<1PbkpK*{7haE3Jh%Gs1JFB zjT3g33Z&C`({g_$NH7&#@CD=)A8eX|>O|?sQlD%TW5V~xYu!vDF6;^*3~b_?w8KK# z(u8uJ*{QaPAi@`?2U?mxgyjx%ghXdHEn#Ipz{1dzeM79KlKr-#o zk#;?>(SuZXPv5$a1Pb&Ly>V@6JhDm7p)nI=6;@;}SfApsFQHAx;_B6_OMOC;snoy5 zmR?=umJ!WMpR1Ze$u6i08)z&&5w8Q#Bc5OCEm|d-Gwyo_*bn4z)B^QC-xDWxw}uq7 zMFGOrjf-re*G@h2Z9GZRFS*}#{^?acJ-vRSy3ymnp-L|RhB7){1_uPkG^;0{R4*3t zPsnMZXAOXISdr@8&q6JKm!k_Zj(aTx|1P$>p--3T?;iyTNnYR(P<)GQ%Efj=xQTX% zdi%CZQy%7^o|z`m|dBOqdx6pmzI5oySyNDW=H}qgfyppLLymddMv;Xfi1scR{s# zK5BAua%^?Q?XQ9R=v^>0=jY$K!ohF!k1YZAsv#_>g+-oB-g?Cb8uB~pi6esxmG)y= zWNI?OO9bf#HPfF`>OyG778aDW4>;Oct#mmwD!4FC?%bRC3_}qTwmv~_a5(T*Q|^B2 zo3W)Iv0LgQ0KElte-dc--#K-lY&2*(3acsLF3%)61m)717?3>R(aAC+bG{}oW6-G~ z>A{Ha$AEMLUnhVO8uA`!(tNmg!c1(+maQi{RKt^B~sN zPIDu!Dy$1D`bdOgSPY?Nyb^CWAd-Lb@Q`l_!mCZOQ+mf4JLg2+6#A!$A-;)$2lSFS-?# z$1SV(5c`IXZ>5e&)6oKjH5amaZQ=Z&9|0N?^ZkIO0P@$@XAH3w!fZA@1mKeK$@$r4 z9E+ohy4il(m3Bxv{-weS$(S&djgoDGz~%1EqZRB+R`H{}d71B>>{2JSnck}CkbQTy z|DIhDeniLf zfmGW;^g6*QJ83JpREmSqLjA^*)!m6SP5t1w&GQGpharOB>q%|QI!OoIc#chvMr|=@ zhW}9h?yZN9%z8mIGF|Q=nHyB^L(?#yv=CJ;3kTo50)~Bo%DbI!yKmF>Zx;Er*D3_Y zzuVK3rrO5rzXLvn{VxHw`)z=VwA@>c#WyCp*-+c!%X=59yCm2NSz}73CKPTi{6x;A z!_RWrIhyZ%p}I>aIw65SK74N&=RH^}Z9~T#!3$Lk7oXToP2X;Dt!ajf!DpURha(Xw zy}~8I)uTtUvx^XOK01WXoqK>#i-5wGQ#&#q>(doO-~dd}VV^A={$b6+>%z^EzsLoA zBi07HEBX=wd}8IhckiY@$LWe0z_zFn`1jf>@?1jIx75bRB{Pq(n0YR#J;bw(^h9}W zW&@RS0qE2DJK|#y7QQA+wXPLS?M1RprB$p}F?1w$xd7>A{GX#UPaXqgev;q<#27F zHo4C?(b7`OL!tc@m0Mbq67S!mPtKfWrirB`DuoP+>I7^SC})>w)3X|`jGpFQVYDJd z2rMNDQ4iNRAp@FpswR-6(eTl_pI(;`ocs-yij955skVsx~WsJ$YhO`CxVx+MV0l zp>iO_2NAT9l--Oir}N)^&@FEJGu#5#^_#B~6sn7eYgjhPe(v`N|93yXP+h2zS@Nf7 z;>)0JC0!B6p7lTNBxUu&Uhb;MnI34#Gb`KEGkommVdO53LBTHpE zf&Hn@qN=XNrfxscBb;fflOpfNIABAVddtd9Y1b3F(D6kE(cO%yJ3oH9Yj9QQ?u;Fa zRY60eGfchnBP_e-{QZzy6{LrewT*o}$bqJS?kE1-ohu3?yT$beja+es_F1hCN%Z@6 z$y&sx2dKSj?ZAFPi0<1#lbn#zLYM(Db-h0Q3>hJcP!L%xcECdepy!)ps?--!Q%1@M zQ4_b5b2gF50fi<7HiC-gZj^$W%=84811Y?(t_zeQ@)XT0`kZ4G=Kq>%dDlJxQ4J_7 zyO$^4{qn&IQF++>Xpy4-D%JWVr&ABIu&ZZ8wo)Erf#c0IYw;GkMaqvk5+kTbu!n z91D09HC7g(3Ey;(%zStCX{K?zwmfE&Aa=`G6keHaJ8IHx>V@eEdQ$BEJ*q1)s5# zx^q7tFDJ40AAlk|)P7CnWXm5y1`BI;Fxk&dhfXqk-R$SZucb4sJ>Dud_Ky_E)W|i2Dm*T zVkv)cuNn-^DoI5_e*Zk0b2^0jFe2`>9y)urqTFQ?d!RGfW3gF$sE~7@XQ0)HP+=*d z5;-tr!TnMlxwk=l!BVU!FGn|idQ8ye(H7riSWRUWC=F}atTvI*812v>kD5sF#$5Tr z$FJY$H(6;eqhq2jm6YF{bS_tQ?YQpIo4YYIfPRx#xK_`lGi{EUf;|fY=Q^)`Z+pPo zAC^sV+LAVoL600#o% zKm8SKgtWsVw+VN9=WHv-*W%j6AB!Y;}O)n?(`3Y#r<@D*ihPT4{&BUoU?8# zaFLb%-p1VW%;s96qa8RMgrHZ_z&3L{ymOn~MI5+;7p0&b80wffofgI9Zh}7?7j517 zzqcs|>bm1GTGSEo#RC7GpMCf^?FAY7Js|8>)Uy4Fvs_V+1wuO}VNENKtY0w%#>H*%Lj>Byh25>*Kyzh-HYmJ{^M=Y z)53~i*8#s!OUHRMwfhleSA9!rug5Oxa$?|(>7Y+?>5L4>1tlWlvo6`ShTIJfUP?fq z1w1R&|3Bq|M2?FeLialO`NF}mtJK^*dkRQ`-M=!W|1nGSztEZEzwU!}*{YlOJKtX3 zuBJd$<#_WJA=}Up!5q{CafWRf5hbYjQTUe2NNo@~T77{nXy$mhGigti_BIg=^~9m5 zP}pvISob1R*}@Qaunq_w-DmU^72W_n&@hXSoi+i(soW}~b>b(mU<^ShAM`Yiwr~a< za6ITbXP=b6A9wna?p_sgAp$J@&5;9C@R7Hl;G9yH+NjY1!o4Fl-rpQ`Mr*pR7wHRm zRsx9-#~zn4Rd&NYcwC5dyM0%nH5@fVho~SC+(3tl;@cu!Q62!u|IS8Q_rAVT2z)T8 zU}Tzl2E}KOjKAFd2*vKh-)^FfgcMQx9C-L+c2+sWhD?V9S|C-H8pWmN^IPuDu=@d8 z4qn4i9l0xQHimnmwij>D0By~XgGE5fR3`wCK+x<;4X(m=5YU1meM1Hu6K$HEq-MH} zan>55aO%MqP0`oB3f-l+WT#@k);a_N`2E@JnWV?uYAR7;IKRD2`@=nkCIkg)%B4WI zD~Dm&^7#C)o892?YV5``Rl%XL6$qN=62AC+27Ug>riTwUSCun?XeXX6!<@!J)4*N695z$9Ro=?mDd zWq`WM3r4ce0I{XMOW*JKrkB`gu64aiK(T(Aw{dS(Uh1wI@CR@3mexQ^_h!x9G9z7S zPJP77JfwnxJ(vk($XTmjWz95WZ9-NKaiDPV&8LJp+h;VHNS~(6chlEx`k6B+yk_a zKLE~ww{_5jN$Lo3Zx|Cq4S{Hct}U{=bUGzpzzoDI1gr&|0d8X=YvRvyK*Yd|e|xc3 z4AZg|z&iBKeaQQr9CwbVRC&KhS%}1F)SXIoIS-SYVG*@w@fF6%)kz&Z#QwIeuyLrryT9-0EMQEd^MIX%9A0WZdv1`sdU@Yvjmmx^zhQT|12UxA12@JO2tlj`JIeIcRZ!8QaM{>O_ zya_rmo_kv-Ki_l)vc{N&oxRe3=uwF4=O@FFXWrz|N26q}^n;B=0Ix)Zy%R4f$7pXX z)>mT;AF#!CjEJw z(Ke;mCLs0Wy?5DZVQL;FP%HVbK;kd8@#RTls#zIG(4;Gd)G&*mkATydcX!xa*L*jL zv+gd?cXz3oFNf|N=RhJ7%GNsqN{%NR7QeohAmrI$8pH{urKOpm?iWxflxX1SCvT~LTYvA^v7ZzkP%(Vwmp08U zz*oGby|u10{1o^7U4*EC_9nRl51xUUMgXvtI=5RnvPT@Y``5zv(+ol2H0|1ImG8*Z z-neVerbrWTTX2M{hYdALd`?8!v!rysd=L$c7!(Vzj2WHvgIW))8Q^G=VT8;ApaB%D z=)iCd7yLXKL?h702%amBz$+51lecxbzoqAnOB&S5d4SpX#2Y1`1$evr&!>($Tz9r# zRd{^;?*$wwsX>z#WV@|u*?HU+mVCiZRnaDxtizt~+X5{G(2hTi85e;Wvb9&0N7f>i z`}NLz?qBU4eDK;6%*3Uy3}fgcd2i0_`)=dkpEX_N2Ve(aQt|!4lTSVC>~{a5r@Nae zL16!J$kse8mOZ7F_oyc4;Q_z@b+pP@4i&giolo^2AcN0ecV)CX8Ju-xh72*astj|_n}4dB~UkqHFB*{)$=a#^8CjhYAq zD~kZI4pz}NXCDn8dRBuU8nV{d9e>;j>Y{Kg0$-~aO_ka%FzIhTA!8u>k-ai_2v!ef zKh@^)on0F~`g|N-W^93R--&p$yA`w;5)iH9Jh0h~-x`ufzNr*1YrZDl&sLN+Q}8$OY`JD(ap(GWhof30>U?6$&d z4WQQG3$nGWjxZX5{8FAJxw$c=eN&s!^%EIv2TP-JQWz6WCS+${D6ITFHDn=ZqU7N9 zLb6psl{(n<^KF;JyuW_Z`4wm)n{A6vUw?3*aqrtjavTW;Y?{HV90#8yg$Pij|)pJ^6>m zWDwQlKnd^KAR{bRkp@PmNLeLURuvRnEmPT!9<7SiU`(5`nidmbD<6VX2&_?I`^y!K@hJx6rRV) zCHUtP6C;AMX#GluHtuc6yFhmrmvhbGsQ*4PAN=z`ZP!uiejbfCY(4VyNY+_C)@~n` z8k4oF+XI1IT->X!{_h$5{I7f?`%C*qp-TOgbGte29T#?;2b{;Ce3Bz?&gcg^?9E*2 z@SR`@=NM(YXab@r0(*6#l*q#Fg#16u&FrfVhX?fCWtM_i8-G=Ak{ynKE_bK+Fwjvo z*>y;SP>IOg;=-~0VRO4NjNl14#7`sSpY);6^LfhJA zkJp1CE6gaiytr6SBzRgK4>Tl`M1!Zlvx{*Q3Qp^{N1pBhfq*8>R;LdfJFJ87Aau&f zB+6TBk++yp>N8Fved{881`*_q==7EFZut2>- zQ?!SyIzT19_@Io8j9xNQa%|qOV~p<-TRF^jy;urf526nT*ct71IzLDOi-(OG((oHC zWFj2}H|Oz{ZWp%ti(0rUZY}=@h=}-rYA3Ef_k?=P%3r zgO_oAv<4W2CY+9JQH+39lPAaIzJ%<4LvvG34M_AQTDJCP5J=5{KsuqgDeNqqte5?Z zA|l%Wo-Rzlc^3=`AD~|=Q=JO5HS@k9To$glx%*nBc);H;+yJNq28-Td7^AIKCUsJU zcc1Rq-*r3ARhampgh~b@;huy_|=Fm`Dd@MsYV%a1Q)_I6SeO9mC-wLdu@j zUI;!iYe<>!VtbXEgdKTX4Nx0& zdlllGCcwW$Hc4F>m2y20!}c95dw#46)PCCN7GTN|+2ky^qt6DpWAV?E+RCHM1UP=$ zWzQCEd8h$wld+o{<^C2M*b0gcW6-L8g7GpOu{2Vzu&hx=x<=nt5e5zzI?h5+6(=Jh zmCqyW`eeIG`#4i82`orb;k2qqj44V$it9h5#{bDwo+rE>h?{F*zH=1;o;xLcKes+e zh66y>d+lFY?3)FePa8oqk8T zMJFK;s=GZM@GED)z*^1$53yk~0k$SZ{u=v7Ow+4lt zrQN%O%l*pn;7P4h>M90c&?rz!2rcGhr_v{v*v>qxl$)`hhM?1go9HWlyUt=# zU}6Y%L(F0Kql2A50EfY6U;2(dgxyTE_O{>+A^nN$m}d3lvud0uu(>O?{g@HMy41hh zpzKs^X_xPuxm2VGD|`M92xZ!PuQ;j18nK&a!WGh0lT!Wc@eUOT`ho}(@PYq zy6$-#D(_T4VHP{^3GaAyKstT4 zryj#O6=^AOS`xVcrupkIu)oI3hIx50dMnz`J{o<{?}*bEWA;f7P`GN-BhS3cFnw^~ z(|#|+o#|Wu6X*~C=jF5ocW4G=0W~pI5icO*NSo`|;p+r7xB=U?gRR8gd~Wut0zVsX zCuRQKD)ICO>K-(A8@k0PJ3P&H;{sg-ZOoJ=Mfx*oarE)l&59xK*?)+!8Zp5un!#7Q zS)d65$H$x5n(QG(rK~o?ct*d)N(z+0XWQ6qmZ1z~Qjsz;ObeV5gqxD^cPaaJS6lxK zJp^zgBlcinrra(6v|WcJi2P^lmH(8|^1t}Q`dfDvcAO~x+0c6NHE^;u$(Y;E5ZF9C zOteFxkVw-D5j*#5ZuC+FNFQlxo`yV)uLIR;Qm?s#J2=G@dk*ZJ{pK7BN=j&ez?6?V zDSt+D{fDum3;n<2ps^ueAxjcnL$wBia`u5;JlhwqTXtI*3UUD-4o-G7aO#;#%o^qc z`0Ct>y~nU>$hQYf`rn6{vwzQ7$HqAVU))r1d|C^t`8w}`tS%wUT{#v(T@lY(ios=p z5!;zf7F{_L$6Ad}-%TOIS;c?u8)awIHV-}pfod0*9TWP)K_`HM*^+9e4Xw0I{JFcU z|2Z|~@R>@D#RHMOSX#f+RyQ91M;w=+!dV-DV|QtNBgZb#*(Io(tLrTSTGMK`Y^eL= zP&hc-Q++|TkOh2D!qg_>2ms{>jM44YXnpi-5Q5}1SG$cJ&vo+cyR-eFZ=s?9!_5;wPA1vPU`FPRGEa`X0bK%R!Tsq__JM->$Hs#Y=r%D&}3g8!Q}!RJON8 z`hNvtT=f}*T%Kry$4<%#4(7IPc8<2eE*S!N>Z;u>2U!bwE>us+vA{YCheil(j>;r$ zof3Ia7v{39(TN6QH*gk{R;_j|fFM$yj32uKcUvpsV7){B3K`)ERR0NxxgiEeSD;GGTk2-(5dV zDTQE8PTZy^+=74^1y43en}N*=Kth+4tXK8E~`}0^R}N) z#YB?r8M$BtdAv4JI&z8KBL)-PzHOK~+ahc;=tJ$=C!Tfy+|?S$keOXjRVHc3Z6|@o zvYwzPY=B#9=)j8v=({4?_wx*;6;{u@_=%LrlVH4a&z*MQS=9Dgx@CdLb_zo)tC7O% z^n(_+g+ZC`#6Ydl1yyx620VuM2b&|tUI+~jQ~)0+lO1|#xsV z+z}l+)ze$b?n%QO23`~i?OMzb7ov@1+-VW!JPeVj1u}$ezzcaNMXt%IQui1r)p^t} zh4kePvep;kFnwYHEP(;g89c@`fII~9P9D57jS4k$AN2&a!)Ae53qYd%Am4d=&1YtN z*ZIl1t}IlLusVmBKoG+?!M~tps+KuSxy80IRtR1D!2SYyfKz8Ek8pq}?<1egx$ zYXiO6$Ym*4&NTml_wNCtfSO7PRZb0K5S!(8PQpupV|`6{PBV``xTQTYe#rz;zL6AQ zF@8?3T4{cL3J;TH$!IFAE`5CRkh=KL7_T;!Zw5@Kfb$<{YGngq+*~$^4-J~j8@_`c zh?_o>t>>D`PlAJM0L;qpOcKJE0U8nU+hbkU2CBkt`1Qc6irf+}FIkdvdk{WzFw!ga zuNo5@C|_Bmk55;ZuWz^LQ&zdgfx1&jpJ+i0JKa-bt;J-4BSi_+0a*$%kxFq4W_{E8u4QwS`Iwbt~UoM7c?0~-D(1PJam^Gbj+6d1BThYguba$

  • 9Fh-&HWCzWhoR@ZNSPb%2WXWm0}69o9T;TZvG~X8Ijqm!?wr>R z;~}JwYeo`l0RAwmu)!rK<4V$v+ROzLoH=p&(P-A8^zC&X{;-(15OVxGMf4ygR%ytS zAnGUQq2bgqOglUEdD$L3a4?lG53F5k)cz&L<{QBs_@Fn8|NZQKqC{;Jn2CDNldOf5 z7gmJI0!u0puxE=(Bua(9Xjy%keV80+TkcP4=uvRV`ed>|47-9_TAMM08EKeOiiVSv zYwune@aez+|CB{~hS=r}HmAgBR;Bt_*{cln&Qj^z>a~QojV+OQ%2Q`4&ow;4WLjcC zcY22{lA>A?ICl$mX5r+JSUPPU3g@p$S~9T@vR5&I9#zGSD##378c@oYOO| z-XP+nnnjZtfsD0-(T0O@RU;Du)+zoW!%yr7HfrD8ALp{Ocu6VPPY==q=Hpv-K%*V3 zZfZ7oabVs4C}l2a8h&;d7}Aix_y9l&64J>N?bRI!R;G9JUuyq|8V?)T-haTYTl$Yd z<$d4VH9(6lvQ=Wz$`6oJ1Y*Dmu=NX_wFwN|O%;ypKoz=Ua-cN}1G8uXZd3ccOBtHl zULj%{O-4*@OxMyD7QP{*!JWgJagiU5u}kp^?tLG`pG70<)ZI(qjL9mJf!JEN*L;Wi zT2B3oChRsBYr@A)UrfXMt`0$}+1^^8kOl@8C`C8_U_k5vHpPI_7*bd*>>xhu9VLBx6=mDYo}SDcn8;Sg#AW>bV-m!$Rhaqg_^F zR2=w%FJLCSXe$z95li``dwN z7Fc<5P=w$I$O&^*(EUtp0H-zJeHK@UABdhr(FZBO`FA4`r+>Z~7ztoRo;L5C)-NXf z)Uis#VRR84ga&r9$BKXR)V`6!MR12M>hfI*BEVwcj4QX8S6>KTjHMyxQ8|nzlbzCJ zntWwQP0H2LDI3o_vMoyP^Fy^_MC8k*JbcFal}oiNYC`w}CgzTG*!*^&@F`BN5O+BI zVc2TD7%Rt_Gk7XBz<@bd02iY>Qv>9;dN*9b+O^-mr-$)0@UE<03pb4)<{0vh-I=`22sId3&2 zpfb5rx=Lwo1QP*mGM3ZEi)$(VAm##Gk=u zwP5F(>uu;mCK_SmEnBhWCvko;NXO{)A2O)ehS?vZ%FNG1?4M7%wwNS9eKKnD0XYBE zEy9r(p1-n959I;F*{G#F&l8Z)FJv#&9CC|MU%0AMCnWx5b8CM1@Ec*rRPDupzrWr5 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�)*4pkTcV7W*4cI3M=4f;mG+Bxq7gJ#SC(WP)9Pc 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 MXn7#Vi_@% literal 0 HcmV?d00001 diff --git a/docs/images/stats.png b/docs/images/stats.png new file mode 100644 index 0000000000000000000000000000000000000000..a664bb7db24930f1f692fa0dcfa7feeb6006233a GIT binary patch literal 84755 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 F{{U9>2X6oX literal 0 HcmV?d00001 diff --git a/docs/images/vod-library.png b/docs/images/vod-library.png new file mode 100644 index 0000000000000000000000000000000000000000..6effdb7072fca34404408c3353c5a2c65a07dcc8 GIT binary patch literal 1121112 zcmeFYWmuG5+crFif-!^zgS059hHkGebysH-mzN64K46v@}S^Al)F{LpL*Y z%rNksT=#Rm_vQ6`-=FXI%eFDG=3IHKb;N$`E9j-NEZNmNS3w{UnY`Sy*B}t74+unj zbeRMw5l@0puW1FeQlSD)5YFGYDk!2t-60 z3e;K#foOWe(Q?xu5Ln(!TKc8Dv^3L8dmCdjOCu0SqcKn26subtQ^DLja{273IRN%>~SrAkUHDahuAoRJ%dGX0AX zGKuZ&Tr}l++gD#o^@n!;SA`zhC0vECl>~jBeR=``6^kOdJ3)_AX?JoxpO6fGW1qK= zxZHdrkic}%-ktow&sDHVu#Y5PUVW`(pl+c3=n0?I%gEkdNYf7u+#Qj}9I;DVnIFzv zI=f$ibM?up-7`4u$A%P&-}aE#dUmgA9Q3t9W7MResY~&>Tulc<>Gifs z9uuD{+Yj87ISP-=^t!>;swDm(*#k0U{O45bTH}5hH`v^Zx zp{E_TiG2N~Jp@u1{?H(*NaHcbP5BRmw>S;6Dvk#kXm5{vPbTLLjf^_19SZQ)M>iu9)LUSacoEPIdfrX=O(m$xXhKGCy%zPDZUh+!mh>9{p(Y0kMnDa+eq z5*~FkccGgpG;+C>ysNv{H&Hb`j9Py8``jDvxkb&Bz6a4#N7j_&6-=N)p9=^4JlEe5 z*;%N2QP;c&O(!K z(%K5BAKx1FXl&+6Qza94q2A&-GU8pI@5L<$?;OIlDL@xw*!Esm

    `X!iUU0X01U2 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�DCqp1H+WAF}$nX1mdzkeID-H_-4cjS>;l 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