diff --git a/.github/workflows/frontend-tests.yml b/.github/workflows/frontend-tests.yml new file mode 100644 index 00000000..4e9e2505 --- /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@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: '24' + cache: 'npm' + cache-dependency-path: './frontend/package-lock.json' + + - name: Install dependencies + run: npm ci + + # - name: Run linter + # run: npm run lint + + - name: Run tests + run: npm test diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index a1cb27bb..9186541d 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -184,13 +184,13 @@ jobs: echo "Creating multi-arch manifest for ${OWNER}/${REPO}" # GitHub Container Registry manifests - # latest tag + # 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=latest" \ + --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" \ @@ -200,9 +200,11 @@ jobs: --annotation "index:maintainer=${{ github.actor }}" \ --annotation "index:build_version=Dispatcharr version: ${VERSION} Build date: ${TIMESTAMP}" \ --tag ghcr.io/${OWNER}/${REPO}:latest \ - ghcr.io/${OWNER}/${REPO}:latest-amd64 ghcr.io/${OWNER}/${REPO}:latest-arm64 + --tag ghcr.io/${OWNER}/${REPO}:${VERSION} \ + ghcr.io/${OWNER}/${REPO}:${VERSION}-amd64 ghcr.io/${OWNER}/${REPO}:${VERSION}-arm64 - # version tag + # 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." \ @@ -217,43 +219,7 @@ jobs: --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}:${VERSION} \ - ghcr.io/${OWNER}/${REPO}:${VERSION}-amd64 ghcr.io/${OWNER}/${REPO}:${VERSION}-arm64 - - # Docker Hub manifests - # latest 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=latest" \ - --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 \ - docker.io/${{ secrets.DOCKERHUB_ORGANIZATION }}/${REPO}:latest-amd64 docker.io/${{ secrets.DOCKERHUB_ORGANIZATION }}/${REPO}:latest-arm64 - - # version tag - docker buildx imagetools create \ - --annotation "index:org.opencontainers.image.title=${{ needs.prepare.outputs.repo_name }}" \ - --annotation "index:org.opencontainers.image.description=Your ultimate IPTV & stream Management companion." \ - --annotation "index:org.opencontainers.image.url=https://github.com/${{ github.repository }}" \ - --annotation "index:org.opencontainers.image.source=https://github.com/${{ github.repository }}" \ - --annotation "index:org.opencontainers.image.version=${VERSION}" \ - --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}:${VERSION} \ docker.io/${{ secrets.DOCKERHUB_ORGANIZATION }}/${REPO}:${VERSION}-amd64 docker.io/${{ secrets.DOCKERHUB_ORGANIZATION }}/${REPO}:${VERSION}-arm64 diff --git a/.gitignore b/.gitignore index a9d76412..20968f46 100755 --- a/.gitignore +++ b/.gitignore @@ -18,4 +18,5 @@ dump.rdb debugpy* uwsgi.sock package-lock.json -models \ No newline at end of file +models +.idea \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md index 4155bb68..610c2ee5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,119 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Changed + +- 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. + +### Fixed + +- 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 + +- 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 diff --git a/apps/backups/scheduler.py b/apps/backups/scheduler.py index b5f99fe5..aa7e9bcd 100644 --- a/apps/backups/scheduler.py +++ b/apps/backups/scheduler.py @@ -9,60 +9,47 @@ logger = logging.getLogger(__name__) BACKUP_SCHEDULE_TASK_NAME = "backup-scheduled-task" -SETTING_KEYS = { - "enabled": "backup_schedule_enabled", - "frequency": "backup_schedule_frequency", - "time": "backup_schedule_time", - "day_of_week": "backup_schedule_day_of_week", - "retention_count": "backup_retention_count", - "cron_expression": "backup_schedule_cron_expression", -} - DEFAULTS = { - "enabled": True, - "frequency": "daily", - "time": "03:00", - "day_of_week": 0, # Sunday + "schedule_enabled": True, + "schedule_frequency": "daily", + "schedule_time": "03:00", + "schedule_day_of_week": 0, # Sunday "retention_count": 3, - "cron_expression": "", + "schedule_cron_expression": "", } -def _get_setting(key: str, default=None): - """Get a backup setting from CoreSettings.""" +def _get_backup_settings(): + """Get all backup settings from CoreSettings grouped JSON.""" try: - setting = CoreSettings.objects.get(key=SETTING_KEYS[key]) - value = setting.value - if key == "enabled": - return value.lower() == "true" - elif key in ("day_of_week", "retention_count"): - return int(value) - return value + 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 default if default is not None else DEFAULTS.get(key) + return DEFAULTS.copy() -def _set_setting(key: str, value) -> None: - """Set a backup setting in CoreSettings.""" - str_value = str(value).lower() if isinstance(value, bool) else str(value) - CoreSettings.objects.update_or_create( - key=SETTING_KEYS[key], - defaults={ - "name": f"Backup {key.replace('_', ' ').title()}", - "value": str_value, - }, +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": _get_setting("enabled"), - "frequency": _get_setting("frequency"), - "time": _get_setting("time"), - "day_of_week": _get_setting("day_of_week"), - "retention_count": _get_setting("retention_count"), - "cron_expression": _get_setting("cron_expression"), + "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"])), } @@ -90,10 +77,22 @@ def update_schedule_settings(data: dict) -> dict: if count < 0: raise ValueError("retention_count must be >= 0") - # Update settings - for key in ("enabled", "frequency", "time", "day_of_week", "retention_count", "cron_expression"): - if key in data: - _set_setting(key, data[key]) + # 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() diff --git a/apps/backups/services.py b/apps/backups/services.py index b99fab6d..b638e701 100644 --- a/apps/backups/services.py +++ b/apps/backups/services.py @@ -72,17 +72,47 @@ def _dump_postgresql(output_file: Path) -> None: 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", - "--clean", # Clean (drop) database objects before recreating + "--no-owner", # Skip ownership commands (we already created schema) *pg_args, "-v", # Verbose str(dump_file), diff --git a/apps/channels/api_views.py b/apps/channels/api_views.py index 1f98358e..c2ba7a06 100644 --- a/apps/channels/api_views.py +++ b/apps/channels/api_views.py @@ -8,7 +8,9 @@ from drf_yasg.utils import swagger_auto_schema from drf_yasg import openapi from django.shortcuts import get_object_or_404, get_list_or_404 from django.db import transaction -import os, json, requests, logging +from django.db.models import Q +import os, json, requests, logging, mimetypes +from django.utils.http import http_date from urllib.parse import unquote from apps.accounts.permissions import ( Authenticated, @@ -129,6 +131,8 @@ class StreamViewSet(viewsets.ModelViewSet): 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: @@ -235,12 +239,8 @@ class ChannelGroupViewSet(viewsets.ModelViewSet): return [Authenticated()] def get_queryset(self): - """Add annotation for association counts""" - from django.db.models import Count - return ChannelGroup.objects.annotate( - channel_count=Count('channels', distinct=True), - m3u_account_count=Count('m3u_accounts', distinct=True) - ) + """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""" @@ -276,15 +276,20 @@ class ChannelGroupViewSet(viewsets.ModelViewSet): @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 Count + 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')) - # Find groups with no channels and no M3U account associations unused_groups = ChannelGroup.objects.annotate( - channel_count=Count('channels', distinct=True), - m3u_account_count=Count('m3u_accounts', distinct=True) + has_channels=Exists(has_channels), + has_accounts=Exists(has_accounts) ).filter( - channel_count=0, - m3u_account_count=0 + has_channels=False, + has_accounts=False ) deleted_count = unused_groups.count() @@ -385,6 +390,72 @@ class ChannelViewSet(viewsets.ModelViewSet): ordering_fields = ["channel_number", "name", "channel_group__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", @@ -420,10 +491,41 @@ class ChannelViewSet(viewsets.ModelViewSet): group_names = channel_group.split(",") qs = qs.filter(channel_group__name__in=group_names) - if self.request.user.user_level < 10: - qs = qs.filter(user_level__lte=self.request.user.user_level) + filters = {} + q_filters = Q() - return qs + 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) + + 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 self.request.user.user_level < 10: + filters["user_level__lte"] = self.request.user.user_level + + 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() @@ -519,11 +621,18 @@ class ChannelViewSet(viewsets.ModelViewSet): # Single bulk_update query instead of individual saves channels_to_update = [channel for channel, _ in validated_updates] if channels_to_update: - Channel.objects.bulk_update( - channels_to_update, - fields=list(validated_updates[0][1].keys()), - batch_size=100 - ) + # Collect all unique field names from all updates + all_fields = set() + for _, validated_data in validated_updates: + all_fields.update(validated_data.keys()) + + # Only call bulk_update if there are fields to update + if all_fields: + Channel.objects.bulk_update( + channels_to_update, + fields=list(all_fields), + batch_size=100 + ) # Return the updated objects (already in memory) serialized_channels = ChannelSerializer( @@ -708,7 +817,7 @@ class ChannelViewSet(viewsets.ModelViewSet): "channel_profile_ids": openapi.Schema( type=openapi.TYPE_ARRAY, items=openapi.Items(type=openapi.TYPE_INTEGER), - description="(Optional) Channel profile ID(s) to add the channel to. Can be a single ID or array of IDs. If not provided, channel is added to all profiles." + description="(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." ), }, ), @@ -801,14 +910,37 @@ class ChannelViewSet(viewsets.ModelViewSet): 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] - if channel_profile_ids: - # Add channel only to the specified profiles + # 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): @@ -831,13 +963,6 @@ class ChannelViewSet(viewsets.ModelViewSet): {"error": f"Error creating profile memberships: {str(e)}"}, status=status.HTTP_400_BAD_REQUEST, ) - else: - # Default behavior: add to all profiles - profiles = ChannelProfile.objects.all() - ChannelProfileMembership.objects.bulk_create([ - ChannelProfileMembership(channel_profile=profile, channel=channel, enabled=True) - for profile in profiles - ]) # Send WebSocket notification for single channel creation from core.utils import send_websocket_update @@ -870,7 +995,7 @@ class ChannelViewSet(viewsets.ModelViewSet): "channel_profile_ids": openapi.Schema( type=openapi.TYPE_ARRAY, items=openapi.Items(type=openapi.TYPE_INTEGER), - description="(Optional) Channel profile ID(s) to add the channels to. If not provided, channels are added to all profiles." + description="(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." ), "starting_channel_number": openapi.Schema( type=openapi.TYPE_INTEGER, @@ -1529,11 +1654,10 @@ class LogoViewSet(viewsets.ModelViewSet): """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: @@ -1543,6 +1667,8 @@ class LogoViewSet(viewsets.ModelViewSet): 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) ) @@ -1582,6 +1708,10 @@ class LogoViewSet(viewsets.ModelViewSet): remote_response.iter_content(chunk_size=8192), content_type=content_type, ) + 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) ) @@ -1613,11 +1743,58 @@ class ChannelProfileViewSet(viewsets.ModelViewSet): 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): @@ -1674,6 +1851,30 @@ class BulkUpdateChannelMembershipAPIView(APIView): except KeyError: return [Authenticated()] + @swagger_auto_schema( + operation_description="Bulk enable or disable channels for a specific profile. Creates membership records if they don't exist.", + request_body=BulkChannelProfileMembershipSerializer, + responses={ + 200: openapi.Response( + description="Channels updated successfully", + schema=openapi.Schema( + type=openapi.TYPE_OBJECT, + properties={ + "status": openapi.Schema(type=openapi.TYPE_STRING, example="success"), + "updated": openapi.Schema(type=openapi.TYPE_INTEGER, description="Number of channels updated"), + "created": openapi.Schema(type=openapi.TYPE_INTEGER, description="Number of new memberships created"), + "invalid_channels": openapi.Schema( + type=openapi.TYPE_ARRAY, + items=openapi.Schema(type=openapi.TYPE_INTEGER), + description="List of channel IDs that don't exist" + ), + }, + ), + ), + 400: "Invalid request data", + 404: "Profile not found", + }, + ) def patch(self, request, profile_id): """Bulk enable or disable channels for a specific profile""" # Get the channel profile @@ -1686,21 +1887,67 @@ class BulkUpdateChannelMembershipAPIView(APIView): updates = serializer.validated_data["channels"] channel_ids = [entry["channel_id"] for entry in updates] - memberships = ChannelProfileMembership.objects.filter( + # 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} - membership_dict = {m.channel.id: m for m in 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, + ) + ) - ChannelProfileMembership.objects.bulk_update(memberships, ["enabled"]) + # 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"}, status=status.HTTP_200_OK) + 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) @@ -1746,7 +1993,7 @@ class RecordingViewSet(viewsets.ModelViewSet): def get_permissions(self): # Allow unauthenticated playback of recording files (like other streaming endpoints) - if getattr(self, 'action', None) == 'file': + if self.action == 'file': return [AllowAny()] try: return [perm() for perm in permission_classes_by_action[self.action]] 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 00000000..2428a97b --- /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 datetime +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('dispatcharr_channels', '0030_alter_stream_url'), + ] + + operations = [ + migrations.AddField( + model_name='channelgroupm3uaccount', + name='is_stale', + field=models.BooleanField(db_index=True, default=False, help_text='Whether this group relationship is stale (not seen in recent refresh, pending deletion)'), + ), + migrations.AddField( + model_name='channelgroupm3uaccount', + name='last_seen', + field=models.DateTimeField(db_index=True, default=datetime.datetime.now, help_text='Last time this group was seen in the M3U source during a refresh'), + ), + migrations.AddField( + model_name='stream', + name='is_stale', + field=models.BooleanField(db_index=True, default=False, help_text='Whether this stream is stale (not seen in recent refresh, pending deletion)'), + ), + ] diff --git a/apps/channels/models.py b/apps/channels/models.py index 88df3661..6d199520 100644 --- a/apps/channels/models.py +++ b/apps/channels/models.py @@ -94,6 +94,11 @@ class Stream(models.Model): db_index=True, ) last_seen = models.DateTimeField(db_index=True, default=datetime.now) + is_stale = models.BooleanField( + default=False, + db_index=True, + help_text="Whether this stream is stale (not seen in recent refresh, pending deletion)" + ) custom_properties = models.JSONField(default=dict, blank=True, null=True) # Stream statistics fields @@ -589,6 +594,16 @@ class ChannelGroupM3UAccount(models.Model): blank=True, help_text='Starting channel number for auto-created channels in this group' ) + last_seen = models.DateTimeField( + default=datetime.now, + db_index=True, + help_text='Last time this group was seen in the M3U source during a refresh' + ) + is_stale = models.BooleanField( + default=False, + db_index=True, + help_text='Whether this group relationship is stale (not seen in recent refresh, pending deletion)' + ) class Meta: unique_together = ("channel_group", "m3u_account") diff --git a/apps/channels/serializers.py b/apps/channels/serializers.py index 635281d5..c1919e24 100644 --- a/apps/channels/serializers.py +++ b/apps/channels/serializers.py @@ -119,6 +119,7 @@ class StreamSerializer(serializers.ModelSerializer): "current_viewers", "updated_at", "last_seen", + "is_stale", "stream_profile_id", "is_custom", "channel_group", @@ -155,7 +156,7 @@ class ChannelGroupM3UAccountSerializer(serializers.ModelSerializer): class Meta: model = ChannelGroupM3UAccount - fields = ["m3u_accounts", "channel_group", "enabled", "auto_channel_sync", "auto_sync_channel_start", "custom_properties"] + 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) @@ -179,8 +180,8 @@ class ChannelGroupM3UAccountSerializer(serializers.ModelSerializer): # Channel Group # class ChannelGroupSerializer(serializers.ModelSerializer): - channel_count = serializers.IntegerField(read_only=True) - m3u_account_count = serializers.IntegerField(read_only=True) + channel_count = serializers.SerializerMethodField() + m3u_account_count = serializers.SerializerMethodField() m3u_accounts = ChannelGroupM3UAccountSerializer( many=True, read_only=True @@ -190,6 +191,14 @@ class ChannelGroupSerializer(serializers.ModelSerializer): 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() diff --git a/apps/channels/tasks.py b/apps/channels/tasks.py index 7ca73ac2..b3e11251 100755 --- a/apps/channels/tasks.py +++ b/apps/channels/tasks.py @@ -2679,7 +2679,38 @@ def bulk_create_channels_from_streams(self, stream_ids, channel_profile_ids=None ) # Handle channel profile membership - if profile_ids: + # 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([ @@ -2695,17 +2726,6 @@ def bulk_create_channels_from_streams(self, stream_ids, channel_profile_ids=None 'channel_id': channel.id, 'error': f'Failed to add to profiles: {str(e)}' }) - else: - # Add to all profiles by default - all_profiles = ChannelProfile.objects.all() - channel_profile_memberships.extend([ - ChannelProfileMembership( - channel_profile=profile, - channel=channel, - enabled=True - ) - for profile in all_profiles - ]) # Bulk update channels with logos if update: diff --git a/apps/channels/tests/test_channel_api.py b/apps/channels/tests/test_channel_api.py new file mode 100644 index 00000000..bb245da1 --- /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/epg/tasks.py b/apps/epg/tasks.py index bd78c6a3..97552171 100644 --- a/apps/epg/tasks.py +++ b/apps/epg/tasks.py @@ -286,11 +286,12 @@ def fetch_xmltv(source): logger.info(f"Fetching XMLTV data from source: {source.name}") try: # Get default user agent from settings - default_user_agent_setting = CoreSettings.objects.filter(key='default-user-agent').first() + stream_settings = CoreSettings.get_stream_settings() user_agent = "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:138.0) Gecko/20100101 Firefox/138.0" # Fallback default - if default_user_agent_setting and default_user_agent_setting.value: + default_user_agent_id = stream_settings.get('default_user_agent') + if default_user_agent_id: try: - user_agent_obj = UserAgent.objects.filter(id=int(default_user_agent_setting.value)).first() + user_agent_obj = UserAgent.objects.filter(id=int(default_user_agent_id)).first() if user_agent_obj and user_agent_obj.user_agent: user_agent = user_agent_obj.user_agent logger.debug(f"Using default user agent: {user_agent}") @@ -1714,12 +1715,13 @@ def fetch_schedules_direct(source): logger.info(f"Fetching Schedules Direct data from source: {source.name}") try: # Get default user agent from settings - default_user_agent_setting = CoreSettings.objects.filter(key='default-user-agent').first() + stream_settings = CoreSettings.get_stream_settings() user_agent = "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:138.0) Gecko/20100101 Firefox/138.0" # Fallback default + default_user_agent_id = stream_settings.get('default_user_agent') - if default_user_agent_setting and default_user_agent_setting.value: + if default_user_agent_id: try: - user_agent_obj = UserAgent.objects.filter(id=int(default_user_agent_setting.value)).first() + user_agent_obj = UserAgent.objects.filter(id=int(default_user_agent_id)).first() if user_agent_obj and user_agent_obj.user_agent: user_agent = user_agent_obj.user_agent logger.debug(f"Using default user agent: {user_agent}") diff --git a/apps/m3u/tasks.py b/apps/m3u/tasks.py index 87759ab9..ed9eb465 100644 --- a/apps/m3u/tasks.py +++ b/apps/m3u/tasks.py @@ -513,7 +513,19 @@ def check_field_lengths(streams_to_create): @shared_task -def process_groups(account, groups): +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()) @@ -553,24 +565,8 @@ def process_groups(account, groups): ).select_related('channel_group') } - # Get ALL existing relationships for this account to identify orphaned ones - all_existing_relationships = { - rel.channel_group.name: rel - for rel in ChannelGroupM3UAccount.objects.filter( - m3u_account=account - ).select_related('channel_group') - } - relations_to_create = [] relations_to_update = [] - relations_to_delete = [] - - # Find orphaned relationships (groups that no longer exist in the source) - current_group_names = set(groups.keys()) - for group_name, rel in all_existing_relationships.items(): - if group_name not in current_group_names: - relations_to_delete.append(rel) - logger.debug(f"Marking relationship for deletion: group '{group_name}' no longer exists in source for account {account.id}") for group in all_group_objs: custom_props = groups.get(group.name, {}) @@ -597,9 +593,15 @@ def process_groups(account, groups): 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 @@ -613,6 +615,8 @@ def process_groups(account, groups): m3u_account=account, custom_properties=custom_props, enabled=auto_enable_new_groups_live, + last_seen=scan_start_time, + is_stale=False, ) ) @@ -623,15 +627,38 @@ def process_groups(account, groups): # Bulk update existing relationships if relations_to_update: - ChannelGroupM3UAccount.objects.bulk_update(relations_to_update, ['custom_properties']) - logger.info(f"Updated {len(relations_to_update)} existing group relationships with new xc_id values for account {account.id}") + 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}") - # Delete orphaned relationships - if relations_to_delete: - ChannelGroupM3UAccount.objects.filter( - id__in=[rel.id for rel in relations_to_delete] - ).delete() - logger.info(f"Deleted {len(relations_to_delete)} orphaned group relationships for account {account.id}: {[rel.channel_group.name for rel in relations_to_delete]}") + +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 = [] @@ -656,6 +683,10 @@ def process_groups(account, groups): 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): @@ -803,6 +834,7 @@ def process_xc_category_direct(account_id, batch, groups, hash_keys): "channel_group_id": int(group_id), "stream_hash": stream_hash, "custom_properties": stream, + "is_stale": False, } if stream_hash not in stream_hashes: @@ -838,10 +870,12 @@ def process_xc_category_direct(account_id, batch, groups, hash_keys): 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) @@ -852,6 +886,7 @@ def process_xc_category_direct(account_id, batch, groups, hash_keys): 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: @@ -863,7 +898,7 @@ def process_xc_category_direct(account_id, batch, groups, hash_keys): # Simplified bulk update for better performance Stream.objects.bulk_update( streams_to_update, - ['name', 'url', 'logo_url', 'tvg_id', 'custom_properties', 'last_seen', 'updated_at'], + ['name', 'url', 'logo_url', 'tvg_id', 'custom_properties', 'last_seen', 'updated_at', 'is_stale'], batch_size=150 # Smaller batch size for XC processing ) @@ -976,6 +1011,7 @@ def process_m3u_batch_direct(account_id, batch, groups, hash_keys): "channel_group_id": int(groups.get(group_title)), "stream_hash": stream_hash, "custom_properties": stream_info["attributes"], + "is_stale": False, } if stream_hash not in stream_hashes: @@ -1015,11 +1051,15 @@ def process_m3u_batch_direct(account_id, batch, groups, hash_keys): obj.custom_properties = stream_props["custom_properties"] 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: @@ -1031,7 +1071,7 @@ def process_m3u_batch_direct(account_id, batch, groups, hash_keys): # Update all streams in a single bulk operation Stream.objects.bulk_update( streams_to_update, - ['name', 'url', 'logo_url', 'tvg_id', 'custom_properties', 'last_seen', 'updated_at'], + ['name', 'url', 'logo_url', 'tvg_id', 'custom_properties', 'last_seen', 'updated_at', 'is_stale'], batch_size=200 ) except Exception as e: @@ -1092,7 +1132,15 @@ def cleanup_streams(account_id, scan_start_time=timezone.now): @shared_task -def refresh_m3u_groups(account_id, use_cache=False, full_refresh=False): +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 @@ -1419,7 +1467,7 @@ def refresh_m3u_groups(account_id, use_cache=False, full_refresh=False): send_m3u_update(account_id, "processing_groups", 0) - process_groups(account, groups) + process_groups(account, groups, scan_start_time) release_task_lock("refresh_m3u_account_groups", account_id) @@ -2526,7 +2574,7 @@ def refresh_single_m3u_account(account_id): 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) + 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 @@ -2806,9 +2854,26 @@ def refresh_single_m3u_account(account_id): 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: diff --git a/apps/output/views.py b/apps/output/views.py index c0d72bfb..47798ee2 100644 --- a/apps/output/views.py +++ b/apps/output/views.py @@ -7,7 +7,6 @@ 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 core.models import CoreSettings, NETWORK_ACCESS from dispatcharr.utils import network_access_allowed from django.utils import timezone as django_timezone from django.shortcuts import get_object_or_404 @@ -174,16 +173,26 @@ def generate_m3u(request, profile_name=None, user=None): tvg_id_source = request.GET.get('tvg_id_source', 'channel_number').lower() # Build EPG URL with query parameters if needed - epg_base_url = build_absolute_uri_with_port(request, reverse('output:epg_endpoint', args=[profile_name]) if profile_name else reverse('output:epg_endpoint')) + # 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') - # Optionally preserve certain query parameters - preserved_params = ['tvg_id_source', 'cachedlogos', '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)}" + if user is not None and xc_username and xc_password: + # 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: - epg_url = epg_base_url + # 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'] + 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' @@ -247,12 +256,10 @@ def generate_m3u(request, profile_name=None, user=None): stream_url = first_stream.url else: # Fall back to proxy URL if no direct URL available - base_url = request.build_absolute_uri('/')[:-1] - stream_url = f"{base_url}/proxy/ts/stream/{channel.uuid}" + stream_url = build_absolute_uri_with_port(request, f"/proxy/ts/stream/{channel.uuid}") else: # Standard behavior - use proxy URL - base_url = request.build_absolute_uri('/')[:-1] - stream_url = f"{base_url}/proxy/ts/stream/{channel.uuid}" + stream_url = build_absolute_uri_with_port(request, f"/proxy/ts/stream/{channel.uuid}") m3u_content += extinf_line + stream_url + "\n" @@ -2258,7 +2265,7 @@ def xc_get_epg(request, user, short=False): # Get the mapped integer for this specific channel channel_num_int = channel_num_map.get(channel.id, int(channel.channel_number)) - limit = request.GET.get('limit', 4) + limit = int(request.GET.get('limit', 4)) 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': @@ -2326,7 +2333,7 @@ def xc_get_epg(request, user, short=False): if short == False: program_output["now_playing"] = 1 if start <= django_timezone.now() <= end else 0 - program_output["has_archive"] = "0" + program_output["has_archive"] = 0 output['epg_listings'].append(program_output) @@ -2932,19 +2939,16 @@ def get_host_and_port(request): if xfh: if ":" in xfh: host, port = xfh.split(":", 1) - # Omit standard ports from URLs, or omit if port doesn't match standard for scheme - # (e.g., HTTPS but port is 9191 = behind external reverse proxy) + # Omit standard ports from URLs if port == standard_port: return host, None - # If port doesn't match standard and X-Forwarded-Proto is set, likely behind external RP - if request.META.get("HTTP_X_FORWARDED_PROTO"): - host = xfh.split(":")[0] # Strip port, will check for proper port below - else: - return host, port + # 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 already find a valid port) + # 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 @@ -2962,22 +2966,28 @@ def get_host_and_port(request): else: host = raw_host - # 3. Check if we're behind a reverse proxy (X-Forwarded-Proto or X-Forwarded-For present) + # 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 - # 4. Try SERVER_PORT from META (only if NOT behind reverse proxy) + # 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 - # 5. Dev fallback: guess port 5656 + # 6. Dev fallback: guess port 5656 if os.environ.get("DISPATCHARR_ENV") == "dev" or host in ("localhost", "127.0.0.1"): return host, "5656" - # 6. Final fallback: assume standard port for scheme (omit from URL) + # 7. Final fallback: assume standard port for scheme (omit from URL) return host, None def build_absolute_uri_with_port(request, path): diff --git a/apps/proxy/ts_proxy/services/channel_service.py b/apps/proxy/ts_proxy/services/channel_service.py index 6484cd3f..4c4a73ac 100644 --- a/apps/proxy/ts_proxy/services/channel_service.py +++ b/apps/proxy/ts_proxy/services/channel_service.py @@ -15,6 +15,7 @@ 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") @@ -419,124 +420,51 @@ class ChannelService: @staticmethod def parse_and_store_stream_info(channel_id, stream_info_line, stream_type="video", stream_id=None): - """Parse FFmpeg stream info line and store in Redis metadata and database""" + """ + Parse stream info from FFmpeg/VLC/Streamlink logs and store in Redis/DB. + Uses specialized parsers for each streaming tool. + """ try: - if stream_type == "input": - # Example lines: - # Input #0, mpegts, from 'http://example.com/stream.ts': - # Input #0, hls, from 'http://example.com/stream.m3u8': + # Use factory to parse the line based on stream type + parsed_data = LogParserFactory.parse(stream_type, stream_info_line) + + if not parsed_data: + return - # Extract input format (e.g., "mpegts", "hls", "flv", etc.) - input_match = re.search(r'Input #\d+,\s*([^,]+)', stream_info_line) - input_format = input_match.group(1).strip() if input_match else None + # Update Redis and database with parsed data + ChannelService._update_stream_info_in_redis( + channel_id, + parsed_data.get('video_codec'), + parsed_data.get('resolution'), + parsed_data.get('width'), + parsed_data.get('height'), + parsed_data.get('source_fps'), + parsed_data.get('pixel_format'), + parsed_data.get('video_bitrate'), + parsed_data.get('audio_codec'), + parsed_data.get('sample_rate'), + parsed_data.get('audio_channels'), + parsed_data.get('audio_bitrate'), + parsed_data.get('stream_type') + ) - # Store in Redis if we have valid data - if input_format: - ChannelService._update_stream_info_in_redis(channel_id, None, None, None, None, None, None, None, None, None, None, None, input_format) - # Save to database if stream_id is provided - if stream_id: - ChannelService._update_stream_stats_in_db(stream_id, stream_type=input_format) - - logger.debug(f"Input format info - Format: {input_format} for channel {channel_id}") - - elif stream_type == "video": - # Example line: - # Stream #0:0: Video: h264 (Main), yuv420p(tv, progressive), 1280x720 [SAR 1:1 DAR 16:9], q=2-31, 2000 kb/s, 29.97 fps, 90k tbn - - # Extract video codec (e.g., "h264", "mpeg2video", etc.) - codec_match = re.search(r'Video:\s*([a-zA-Z0-9_]+)', stream_info_line) - video_codec = codec_match.group(1) if codec_match else None - - # Extract resolution (e.g., "1280x720") - be more specific to avoid hex values - # Look for resolution patterns that are realistic video dimensions - resolution_match = re.search(r'\b(\d{3,5})x(\d{3,5})\b', stream_info_line) - if resolution_match: - width = int(resolution_match.group(1)) - height = int(resolution_match.group(2)) - # Validate that these look like reasonable video dimensions - if 100 <= width <= 10000 and 100 <= height <= 10000: - resolution = f"{width}x{height}" - else: - width = height = resolution = None - else: - width = height = resolution = None - - # Extract source FPS (e.g., "29.97 fps") - fps_match = re.search(r'(\d+(?:\.\d+)?)\s*fps', stream_info_line) - source_fps = float(fps_match.group(1)) if fps_match else None - - # Extract pixel format (e.g., "yuv420p") - pixel_format_match = re.search(r'Video:\s*[^,]+,\s*([^,(]+)', stream_info_line) - pixel_format = None - if pixel_format_match: - pf = pixel_format_match.group(1).strip() - # Clean up pixel format (remove extra info in parentheses) - if '(' in pf: - pf = pf.split('(')[0].strip() - pixel_format = pf - - # Extract bitrate if present (e.g., "2000 kb/s") - video_bitrate = None - bitrate_match = re.search(r'(\d+(?:\.\d+)?)\s*kb/s', stream_info_line) - if bitrate_match: - video_bitrate = float(bitrate_match.group(1)) - - # Store in Redis if we have valid data - if any(x is not None for x in [video_codec, resolution, source_fps, pixel_format, video_bitrate]): - ChannelService._update_stream_info_in_redis(channel_id, video_codec, resolution, width, height, source_fps, pixel_format, video_bitrate, None, None, None, None, None) - # Save to database if stream_id is provided - if stream_id: - ChannelService._update_stream_stats_in_db( - stream_id, - video_codec=video_codec, - resolution=resolution, - source_fps=source_fps, - pixel_format=pixel_format, - video_bitrate=video_bitrate - ) - - logger.info(f"Video stream info - Codec: {video_codec}, Resolution: {resolution}, " - f"Source FPS: {source_fps}, Pixel Format: {pixel_format}, " - f"Video Bitrate: {video_bitrate} kb/s") - - elif stream_type == "audio": - # Example line: - # Stream #0:1[0x101]: Audio: aac (LC) ([15][0][0][0] / 0x000F), 48000 Hz, stereo, fltp, 64 kb/s - - # Extract audio codec (e.g., "aac", "mp3", etc.) - codec_match = re.search(r'Audio:\s*([a-zA-Z0-9_]+)', stream_info_line) - audio_codec = codec_match.group(1) if codec_match else None - - # Extract sample rate (e.g., "48000 Hz") - sample_rate_match = re.search(r'(\d+)\s*Hz', stream_info_line) - sample_rate = int(sample_rate_match.group(1)) if sample_rate_match else None - - # Extract channel layout (e.g., "stereo", "5.1", "mono") - # Look for common channel layouts - channel_match = re.search(r'\b(mono|stereo|5\.1|7\.1|quad|2\.1)\b', stream_info_line, re.IGNORECASE) - channels = channel_match.group(1) if channel_match else None - - # Extract audio bitrate if present (e.g., "64 kb/s") - audio_bitrate = None - bitrate_match = re.search(r'(\d+(?:\.\d+)?)\s*kb/s', stream_info_line) - if bitrate_match: - audio_bitrate = float(bitrate_match.group(1)) - - # Store in Redis if we have valid data - if any(x is not None for x in [audio_codec, sample_rate, channels, audio_bitrate]): - ChannelService._update_stream_info_in_redis(channel_id, None, None, None, None, None, None, None, audio_codec, sample_rate, channels, audio_bitrate, None) - # Save to database if stream_id is provided - if stream_id: - ChannelService._update_stream_stats_in_db( - stream_id, - audio_codec=audio_codec, - sample_rate=sample_rate, - audio_channels=channels, - audio_bitrate=audio_bitrate - ) + if stream_id: + ChannelService._update_stream_stats_in_db( + stream_id, + video_codec=parsed_data.get('video_codec'), + resolution=parsed_data.get('resolution'), + source_fps=parsed_data.get('source_fps'), + pixel_format=parsed_data.get('pixel_format'), + video_bitrate=parsed_data.get('video_bitrate'), + audio_codec=parsed_data.get('audio_codec'), + sample_rate=parsed_data.get('sample_rate'), + audio_channels=parsed_data.get('audio_channels'), + audio_bitrate=parsed_data.get('audio_bitrate'), + stream_type=parsed_data.get('stream_type') + ) except Exception as e: - logger.debug(f"Error parsing FFmpeg {stream_type} stream info: {e}") + logger.debug(f"Error parsing {stream_type} stream info: {e}") @staticmethod def _update_stream_info_in_redis(channel_id, codec, resolution, width, height, fps, pixel_format, video_bitrate, audio_codec=None, sample_rate=None, channels=None, audio_bitrate=None, input_format=None): 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 00000000..95ee7a06 --- /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_manager.py b/apps/proxy/ts_proxy/stream_manager.py index bbeb4bb7..e7f752d8 100644 --- a/apps/proxy/ts_proxy/stream_manager.py +++ b/apps/proxy/ts_proxy/stream_manager.py @@ -107,6 +107,10 @@ class StreamManager: # 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() @@ -476,6 +480,21 @@ class StreamManager: # Build and start transcode command self.transcode_cmd = stream_profile.build_command(self.url, self.user_agent) + # Store stream command for efficient log parser routing + self.stream_command = stream_profile.command + # Map actual commands to parser types for direct routing + command_to_parser = { + 'ffmpeg': 'ffmpeg', + 'cvlc': 'vlc', + 'vlc': 'vlc', + 'streamlink': 'streamlink' + } + self.parser_type = command_to_parser.get(self.stream_command.lower()) + if self.parser_type: + logger.debug(f"Using {self.parser_type} parser for log parsing (command: {self.stream_command})") + else: + logger.debug(f"Unknown stream command '{self.stream_command}', will use auto-detection for log parsing") + # For UDP streams, remove any user_agent parameters from the command if hasattr(self, 'stream_type') and self.stream_type == StreamType.UDP: # Filter out any arguments that contain the user_agent value or related headers @@ -645,35 +664,51 @@ class StreamManager: if content_lower.startswith('output #') or 'encoder' in content_lower: self.ffmpeg_input_phase = False - # Only parse stream info if we're still in the input phase - if ("stream #" in content_lower and - ("video:" in content_lower or "audio:" in content_lower) and - self.ffmpeg_input_phase): + # Route to appropriate parser based on known command type + from .services.log_parsers import LogParserFactory + from .services.channel_service import ChannelService - from .services.channel_service import ChannelService - if "video:" in content_lower: - ChannelService.parse_and_store_stream_info(self.channel_id, content, "video", self.current_stream_id) - elif "audio:" in content_lower: - ChannelService.parse_and_store_stream_info(self.channel_id, content, "audio", self.current_stream_id) + parse_result = None + + # If we know the parser type, use direct routing for efficiency + if self.parser_type: + # Get the appropriate parser and check what it can parse + parser = LogParserFactory._parsers.get(self.parser_type) + if parser: + stream_type = parser.can_parse(content) + if stream_type: + # Parser can handle this line, parse it directly + parsed_data = LogParserFactory.parse(stream_type, content) + if parsed_data: + parse_result = (stream_type, parsed_data) + else: + # Unknown command type - use auto-detection as fallback + parse_result = LogParserFactory.auto_parse(content) + + if parse_result: + stream_type, parsed_data = parse_result + # For FFmpeg, only parse during input phase + if stream_type in ['video', 'audio', 'input']: + if self.ffmpeg_input_phase: + ChannelService.parse_and_store_stream_info(self.channel_id, content, stream_type, self.current_stream_id) + else: + # VLC and Streamlink can be parsed anytime + ChannelService.parse_and_store_stream_info(self.channel_id, content, stream_type, self.current_stream_id) # Determine log level based on content if any(keyword in content_lower for keyword in ['error', 'failed', 'cannot', 'invalid', 'corrupt']): - logger.error(f"FFmpeg stderr for channel {self.channel_id}: {content}") + logger.error(f"Stream process error for channel {self.channel_id}: {content}") elif any(keyword in content_lower for keyword in ['warning', 'deprecated', 'ignoring']): - logger.warning(f"FFmpeg stderr for channel {self.channel_id}: {content}") + logger.warning(f"Stream process warning for channel {self.channel_id}: {content}") elif content.startswith('frame=') or 'fps=' in content or 'speed=' in content: # Stats lines - log at trace level to avoid spam - logger.trace(f"FFmpeg stats for channel {self.channel_id}: {content}") + logger.trace(f"Stream stats for channel {self.channel_id}: {content}") elif any(keyword in content_lower for keyword in ['input', 'output', 'stream', 'video', 'audio']): # Stream info - log at info level - logger.info(f"FFmpeg info for channel {self.channel_id}: {content}") - if content.startswith('Input #0'): - # If it's input 0, parse stream info - from .services.channel_service import ChannelService - ChannelService.parse_and_store_stream_info(self.channel_id, content, "input", self.current_stream_id) + logger.info(f"Stream info for channel {self.channel_id}: {content}") else: # Everything else at debug level - logger.debug(f"FFmpeg stderr for channel {self.channel_id}: {content}") + logger.debug(f"Stream process output for channel {self.channel_id}: {content}") except Exception as e: logger.error(f"Error logging stderr content for channel {self.channel_id}: {e}") diff --git a/apps/proxy/ts_proxy/url_utils.py b/apps/proxy/ts_proxy/url_utils.py index 3b05c9f2..8b467b7f 100644 --- a/apps/proxy/ts_proxy/url_utils.py +++ b/apps/proxy/ts_proxy/url_utils.py @@ -462,16 +462,21 @@ def validate_stream_url(url, user_agent=None, timeout=(5, 5)): session.headers.update(headers) # Make HEAD request first as it's faster and doesn't download content - head_response = session.head( - url, - timeout=timeout, - allow_redirects=True - ) + head_request_success = True + try: + head_response = session.head( + url, + timeout=timeout, + allow_redirects=True + ) + except requests.exceptions.RequestException as e: + head_request_success = False + logger.warning(f"Request error (HEAD), assuming HEAD not supported: {str(e)}") # If HEAD not supported, server will return 405 or other error - if 200 <= head_response.status_code < 300: + if head_request_success and (200 <= head_response.status_code < 300): # HEAD request successful - return True, head_response.url, head_response.status_code, "Valid (HEAD request)" + return True, url, head_response.status_code, "Valid (HEAD request)" # Try a GET request with stream=True to avoid downloading all content get_response = session.get( @@ -484,7 +489,7 @@ def validate_stream_url(url, user_agent=None, timeout=(5, 5)): # IMPORTANT: Check status code first before checking content if not (200 <= get_response.status_code < 300): logger.warning(f"Stream validation failed with HTTP status {get_response.status_code}") - return False, get_response.url, get_response.status_code, f"Invalid HTTP status: {get_response.status_code}" + return False, url, get_response.status_code, f"Invalid HTTP status: {get_response.status_code}" # Only check content if status code is valid try: @@ -538,7 +543,7 @@ def validate_stream_url(url, user_agent=None, timeout=(5, 5)): get_response.close() # If we have content, consider it valid even with unrecognized content type - return is_valid, get_response.url, get_response.status_code, message + return is_valid, url, get_response.status_code, message except requests.exceptions.Timeout: return False, url, 0, "Timeout connecting to stream" diff --git a/apps/proxy/vod_proxy/multi_worker_connection_manager.py b/apps/proxy/vod_proxy/multi_worker_connection_manager.py index 251721c5..1534f761 100644 --- a/apps/proxy/vod_proxy/multi_worker_connection_manager.py +++ b/apps/proxy/vod_proxy/multi_worker_connection_manager.py @@ -357,12 +357,12 @@ class RedisBackedVODConnection: logger.info(f"[{self.session_id}] Making request #{state.request_count} to {'final' if state.final_url else 'original'} URL") - # Make request + # 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, 30), + timeout=(10, 10), allow_redirects=allow_redirects ) response.raise_for_status() @@ -712,6 +712,10 @@ class MultiWorkerVODConnectionManager: 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: @@ -802,6 +806,7 @@ class MultiWorkerVODConnectionManager: # Increment profile connections after successful connection creation self._increment_profile_connections(m3u_profile) + profile_connections_incremented = True logger.info(f"[{client_id}] Worker {self.worker_id} - Created consolidated connection with session metadata") else: @@ -1024,6 +1029,19 @@ class MultiWorkerVODConnectionManager: 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 + if redis_connection: + try: + redis_connection.cleanup(connection_manager=self, 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): diff --git a/apps/vod/api_views.py b/apps/vod/api_views.py index 8cc55a11..3bd984e6 100644 --- a/apps/vod/api_views.py +++ b/apps/vod/api_views.py @@ -62,7 +62,7 @@ class MovieFilter(django_filters.FilterSet): # Handle the format 'category_name|category_type' if '|' in value: - category_name, category_type = value.split('|', 1) + category_name, category_type = value.rsplit('|', 1) return queryset.filter( m3u_relations__category__name=category_name, m3u_relations__category__category_type=category_type @@ -219,7 +219,7 @@ class SeriesFilter(django_filters.FilterSet): # Handle the format 'category_name|category_type' if '|' in value: - category_name, category_type = value.split('|', 1) + category_name, category_type = value.rsplit('|', 1) return queryset.filter( m3u_relations__category__name=category_name, m3u_relations__category__category_type=category_type @@ -588,7 +588,7 @@ class UnifiedContentViewSet(viewsets.ReadOnlyModelViewSet): if category: if '|' in category: - cat_name, cat_type = category.split('|', 1) + 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 diff --git a/apps/vod/models.py b/apps/vod/models.py index 69aed808..7067856e 100644 --- a/apps/vod/models.py +++ b/apps/vod/models.py @@ -245,10 +245,13 @@ class M3UMovieRelation(models.Model): """Get the full stream URL for this movie from this provider""" # Build URL dynamically for XtreamCodes accounts if self.m3u_account.account_type == 'XC': - server_url = self.m3u_account.server_url.rstrip('/') + 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"{server_url}/movie/{username}/{password}/{self.stream_id}.{self.container_extension or 'mp4'}" + 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 @@ -285,10 +288,12 @@ class M3UEpisodeRelation(models.Model): if self.m3u_account.account_type == 'XC': # For XtreamCodes accounts, build the URL dynamically - server_url = self.m3u_account.server_url.rstrip('/') + # 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"{server_url}/series/{username}/{password}/{self.stream_id}.{self.container_extension or 'mp4'}" + 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 diff --git a/apps/vod/tasks.py b/apps/vod/tasks.py index d42be946..0dcd9cfd 100644 --- a/apps/vod/tasks.py +++ b/apps/vod/tasks.py @@ -410,10 +410,10 @@ def process_movie_batch(account, batch, categories, relations, scan_start_time=N 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 - if tmdb_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 == '': + 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) @@ -614,26 +614,41 @@ def process_movie_batch(account, batch, categories, relations, scan_start_time=N # First, create new movies and get their IDs created_movies = {} if movies_to_create: - Movie.objects.bulk_create(movies_to_create, ignore_conflicts=True) + # 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] - # Get the newly created movies with their IDs - # We need to re-fetch them to get the primary keys + 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: - # Find the movie by its unique identifiers - if movie.tmdb_id: - db_movie = Movie.objects.filter(tmdb_id=movie.tmdb_id).first() - elif movie.imdb_id: - db_movie = Movie.objects.filter(imdb_id=movie.imdb_id).first() - else: - db_movie = Movie.objects.filter( - name=movie.name, - year=movie.year, - tmdb_id__isnull=True, - imdb_id__isnull=True - ).first() + 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 db_movie: - created_movies[id(movie)] = db_movie + 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: @@ -649,12 +664,16 @@ def process_movie_batch(account, batch, categories, relations, scan_start_time=N movie.logo = movie._logo_to_update movie.save(update_fields=['logo']) - # Update relations to reference the correct movie objects + # 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)] - # Handle relations + 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) @@ -724,10 +743,10 @@ def process_series_batch(account, batch, categories, relations, scan_start_time= 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 - if tmdb_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 == '': + 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) @@ -945,26 +964,41 @@ def process_series_batch(account, batch, categories, relations, scan_start_time= # First, create new series and get their IDs created_series = {} if series_to_create: - Series.objects.bulk_create(series_to_create, ignore_conflicts=True) + # 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] - # Get the newly created series with their IDs - # We need to re-fetch them to get the primary keys + 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: - # Find the series by its unique identifiers - if series.tmdb_id: - db_series = Series.objects.filter(tmdb_id=series.tmdb_id).first() - elif series.imdb_id: - db_series = Series.objects.filter(imdb_id=series.imdb_id).first() - else: - db_series = Series.objects.filter( - name=series.name, - year=series.year, - tmdb_id__isnull=True, - imdb_id__isnull=True - ).first() + 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 db_series: - created_series[id(series)] = db_series + 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: @@ -980,12 +1014,16 @@ def process_series_batch(account, batch, categories, relations, scan_start_time= series.logo = series._logo_to_update series.save(update_fields=['logo']) - # Update relations to reference the correct series objects + # 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)] - # Handle relations + 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) @@ -1292,8 +1330,17 @@ def batch_process_episodes(account, series, episodes_data, scan_start_time=None) try: episode_id = str(episode_data.get('id')) episode_name = episode_data.get('title', 'Unknown Episode') - season_number = episode_data['_season_number'] - episode_number = episode_data.get('episode_num', 0) + # 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 @@ -1324,7 +1371,7 @@ def batch_process_episodes(account, series, episodes_data, scan_start_time=None) # 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:02d}E{episode_number:02d} (stream_id: {episode_id})") + logger.debug(f"Reusing pending episode for S{season_number}E{episode_number} (stream_id: {episode_id})") if episode: # Update existing episode @@ -1432,6 +1479,21 @@ def batch_process_episodes(account, series, episodes_data, scan_start_time=None) 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, [ diff --git a/core/api_views.py b/core/api_views.py index c50d7fa6..30829174 100644 --- a/core/api_views.py +++ b/core/api_views.py @@ -15,8 +15,9 @@ from .models import ( UserAgent, StreamProfile, CoreSettings, - STREAM_HASH_KEY, - NETWORK_ACCESS, + STREAM_SETTINGS_KEY, + DVR_SETTINGS_KEY, + NETWORK_ACCESS_KEY, PROXY_SETTINGS_KEY, ) from .serializers import ( @@ -68,16 +69,28 @@ class CoreSettingsViewSet(viewsets.ModelViewSet): def update(self, request, *args, **kwargs): instance = self.get_object() + old_value = instance.value response = super().update(request, *args, **kwargs) - if instance.key == STREAM_HASH_KEY: - if instance.value != request.data["value"]: - rehash_streams.delay(request.data["value"].split(",")) - # If DVR pre/post offsets changed, reschedule upcoming recordings - try: - from core.models import DVR_PRE_OFFSET_MINUTES_KEY, DVR_POST_OFFSET_MINUTES_KEY - if instance.key in (DVR_PRE_OFFSET_MINUTES_KEY, DVR_POST_OFFSET_MINUTES_KEY): - if instance.value != request.data.get("value"): + # 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 @@ -86,24 +99,23 @@ class CoreSettingsViewSet(viewsets.ModelViewSet): # Fallback to synchronous implementation from apps.channels.tasks import reschedule_upcoming_recordings_for_offset_change_impl reschedule_upcoming_recordings_for_offset_change_impl() - except Exception: - pass return response def create(self, request, *args, **kwargs): response = super().create(request, *args, **kwargs) - # If creating DVR pre/post offset settings, also reschedule upcoming recordings + # If creating DVR settings with offset values, reschedule upcoming recordings try: key = request.data.get("key") - from core.models import DVR_PRE_OFFSET_MINUTES_KEY, DVR_POST_OFFSET_MINUTES_KEY - if key in (DVR_PRE_OFFSET_MINUTES_KEY, DVR_POST_OFFSET_MINUTES_KEY): - 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() + 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 @@ -111,13 +123,13 @@ class CoreSettingsViewSet(viewsets.ModelViewSet): def check(self, request, *args, **kwargs): data = request.data - if data.get("key") == NETWORK_ACCESS: + if data.get("key") == NETWORK_ACCESS_KEY: client_ip = ipaddress.ip_address(get_client_ip(request)) in_network = {} invalid = [] - value = json.loads(data.get("value", "{}")) + value = data.get("value", {}) for key, val in value.items(): in_network[key] = [] cidrs = val.split(",") @@ -143,7 +155,11 @@ class CoreSettingsViewSet(viewsets.ModelViewSet): status=status.HTTP_200_OK, ) - return Response(in_network, 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) @@ -157,8 +173,8 @@ class ProxySettingsViewSet(viewsets.ViewSet): """Get or create the proxy settings CoreSettings entry""" try: settings_obj = CoreSettings.objects.get(key=PROXY_SETTINGS_KEY) - settings_data = json.loads(settings_obj.value) - except (CoreSettings.DoesNotExist, json.JSONDecodeError): + settings_data = settings_obj.value + except CoreSettings.DoesNotExist: # Create default settings settings_data = { "buffering_timeout": 15, @@ -171,7 +187,7 @@ class ProxySettingsViewSet(viewsets.ViewSet): key=PROXY_SETTINGS_KEY, defaults={ "name": "Proxy Settings", - "value": json.dumps(settings_data) + "value": settings_data } ) return settings_obj, settings_data @@ -193,8 +209,8 @@ class ProxySettingsViewSet(viewsets.ViewSet): serializer = ProxySettingsSerializer(data=request.data) serializer.is_valid(raise_exception=True) - # Update the JSON data - settings_obj.value = json.dumps(serializer.validated_data) + # Update the JSON data - store as dict directly + settings_obj.value = serializer.validated_data settings_obj.save() return Response(serializer.validated_data) @@ -209,8 +225,8 @@ class ProxySettingsViewSet(viewsets.ViewSet): serializer = ProxySettingsSerializer(data=updated_data) serializer.is_valid(raise_exception=True) - # Update the JSON data - settings_obj.value = json.dumps(serializer.validated_data) + # Update the JSON data - store as dict directly + settings_obj.value = serializer.validated_data settings_obj.save() return Response(serializer.validated_data) @@ -328,8 +344,8 @@ def rehash_streams_endpoint(request): """Trigger the rehash streams task""" try: # Get the current hash keys from settings - hash_key_setting = CoreSettings.objects.get(key=STREAM_HASH_KEY) - hash_keys = hash_key_setting.value.split(",") + 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) @@ -340,10 +356,10 @@ def rehash_streams_endpoint(request): "task_id": task.id }, status=status.HTTP_200_OK) - except CoreSettings.DoesNotExist: + except Exception as e: return Response({ "success": False, - "message": "Hash key settings not found" + "message": f"Error triggering rehash: {str(e)}" }, status=status.HTTP_400_BAD_REQUEST) except Exception as e: diff --git a/core/fixtures/initial_data.json b/core/fixtures/initial_data.json index c037fa78..889f0d24 100644 --- a/core/fixtures/initial_data.json +++ b/core/fixtures/initial_data.json @@ -23,7 +23,7 @@ "model": "core.streamprofile", "pk": 1, "fields": { - "name": "ffmpeg", + "name": "FFmpeg", "command": "ffmpeg", "parameters": "-i {streamUrl} -c:v copy -c:a copy -f mpegts pipe:1", "is_active": true, @@ -34,11 +34,22 @@ "model": "core.streamprofile", "pk": 2, "fields": { - "name": "streamlink", + "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/reset_network_access.py b/core/management/commands/reset_network_access.py index 3b0e5a55..a31d247c 100644 --- a/core/management/commands/reset_network_access.py +++ b/core/management/commands/reset_network_access.py @@ -1,13 +1,13 @@ # your_app/management/commands/update_column.py from django.core.management.base import BaseCommand -from core.models import CoreSettings, NETWORK_ACCESS +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) - setting.value = "{}" + setting = CoreSettings.objects.get(key=NETWORK_ACCESS_KEY) + setting.value = {} setting.save() 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 00000000..c3f72592 --- /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 00000000..ac6ad089 --- /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/models.py b/core/models.py index b9166f66..683acb0d 100644 --- a/core/models.py +++ b/core/models.py @@ -1,4 +1,7 @@ # 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 @@ -133,7 +136,7 @@ class StreamProfile(models.Model): # Split the command and iterate through each part to apply replacements cmd = [self.command] + [ self._replace_in_part(part, replacements) - for part in self.parameters.split() + for part in shlex_split(self.parameters) # use shlex to handle quoted strings ] return cmd @@ -145,24 +148,13 @@ class StreamProfile(models.Model): return part -DEFAULT_USER_AGENT_KEY = slugify("Default User-Agent") -DEFAULT_STREAM_PROFILE_KEY = slugify("Default Stream Profile") -STREAM_HASH_KEY = slugify("M3U Hash Key") -PREFERRED_REGION_KEY = slugify("Preferred Region") -AUTO_IMPORT_MAPPED_FILES = slugify("Auto-Import Mapped Files") -NETWORK_ACCESS = slugify("Network Access") -PROXY_SETTINGS_KEY = slugify("Proxy Settings") -DVR_TV_TEMPLATE_KEY = slugify("DVR TV Template") -DVR_MOVIE_TEMPLATE_KEY = slugify("DVR Movie Template") -DVR_SERIES_RULES_KEY = slugify("DVR Series Rules") -DVR_TV_FALLBACK_DIR_KEY = slugify("DVR TV Fallback Dir") -DVR_TV_FALLBACK_TEMPLATE_KEY = slugify("DVR TV Fallback Template") -DVR_MOVIE_FALLBACK_TEMPLATE_KEY = slugify("DVR Movie Fallback Template") -DVR_COMSKIP_ENABLED_KEY = slugify("DVR Comskip Enabled") -DVR_COMSKIP_CUSTOM_PATH_KEY = slugify("DVR Comskip Custom Path") -DVR_PRE_OFFSET_MINUTES_KEY = slugify("DVR Pre-Offset Minutes") -DVR_POST_OFFSET_MINUTES_KEY = slugify("DVR Post-Offset Minutes") -SYSTEM_TIME_ZONE_KEY = slugify("System Time Zone") +# 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" class CoreSettings(models.Model): @@ -173,208 +165,166 @@ class CoreSettings(models.Model): name = models.CharField( max_length=255, ) - value = 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): - """Retrieve a system profile by name (or return None if not found).""" - return cls.objects.get(key=DEFAULT_USER_AGENT_KEY).value + return cls.get_stream_settings().get("default_user_agent") @classmethod def get_default_stream_profile_id(cls): - return cls.objects.get(key=DEFAULT_STREAM_PROFILE_KEY).value + return cls.get_stream_settings().get("default_stream_profile") @classmethod def get_m3u_hash_key(cls): - return cls.objects.get(key=STREAM_HASH_KEY).value + return cls.get_stream_settings().get("m3u_hash_key", "") @classmethod def get_preferred_region(cls): - """Retrieve the preferred region setting (or return None if not found).""" - try: - return cls.objects.get(key=PREFERRED_REGION_KEY).value - except cls.DoesNotExist: - return None + return cls.get_stream_settings().get("preferred_region") @classmethod def get_auto_import_mapped_files(cls): - """Retrieve the preferred region setting (or return None if not found).""" - try: - return cls.objects.get(key=AUTO_IMPORT_MAPPED_FILES).value - except cls.DoesNotExist: - return None + return cls.get_stream_settings().get("auto_import_mapped_files") + # DVR Settings @classmethod - def get_proxy_settings(cls): - """Retrieve proxy settings as dict (or return defaults if not found).""" - try: - import json - settings_json = cls.objects.get(key=PROXY_SETTINGS_KEY).value - return json.loads(settings_json) - except (cls.DoesNotExist, json.JSONDecodeError): - # Return defaults if not found or invalid JSON - return { - "buffering_timeout": 15, - "buffering_speed": 1.0, - "redis_chunk_ttl": 60, - "channel_shutdown_delay": 0, - "channel_init_grace_period": 5, - } + 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): - try: - return cls.objects.get(key=DVR_TV_TEMPLATE_KEY).value - except cls.DoesNotExist: - # Default: relative to recordings root (/data/recordings) - return "TV_Shows/{show}/S{season:02d}E{episode:02d}.mkv" + 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): - try: - return cls.objects.get(key=DVR_MOVIE_TEMPLATE_KEY).value - except cls.DoesNotExist: - return "Movies/{title} ({year}).mkv" + return cls.get_dvr_settings().get("movie_template", "Movies/{title} ({year}).mkv") @classmethod def get_dvr_tv_fallback_dir(cls): - """Folder name to use when a TV episode has no season/episode information. - Defaults to 'TV_Show' to match existing behavior but can be overridden in settings. - """ - try: - return cls.objects.get(key=DVR_TV_FALLBACK_DIR_KEY).value or "TV_Shows" - except cls.DoesNotExist: - return "TV_Shows" + return cls.get_dvr_settings().get("tv_fallback_dir", "TV_Shows") @classmethod def get_dvr_tv_fallback_template(cls): - """Full path template used when season/episode are missing for a TV airing.""" - try: - return cls.objects.get(key=DVR_TV_FALLBACK_TEMPLATE_KEY).value - except cls.DoesNotExist: - # default requested by user - return "TV_Shows/{show}/{start}.mkv" + return cls.get_dvr_settings().get("tv_fallback_template", "TV_Shows/{show}/{start}.mkv") @classmethod def get_dvr_movie_fallback_template(cls): - """Full path template used when movie metadata is incomplete.""" - try: - return cls.objects.get(key=DVR_MOVIE_FALLBACK_TEMPLATE_KEY).value - except cls.DoesNotExist: - return "Movies/{start}.mkv" + return cls.get_dvr_settings().get("movie_fallback_template", "Movies/{start}.mkv") @classmethod def get_dvr_comskip_enabled(cls): - """Return boolean-like string value ('true'/'false') for comskip enablement.""" - try: - val = cls.objects.get(key=DVR_COMSKIP_ENABLED_KEY).value - return str(val).lower() in ("1", "true", "yes", "on") - except cls.DoesNotExist: - return False + return bool(cls.get_dvr_settings().get("comskip_enabled", False)) @classmethod def get_dvr_comskip_custom_path(cls): - """Return configured comskip.ini path or empty string if unset.""" - try: - return cls.objects.get(key=DVR_COMSKIP_CUSTOM_PATH_KEY).value - except cls.DoesNotExist: - return "" + return cls.get_dvr_settings().get("comskip_custom_path", "") @classmethod def set_dvr_comskip_custom_path(cls, path: str | None): - """Persist the comskip.ini path setting, normalizing nulls to empty string.""" value = (path or "").strip() - obj, _ = cls.objects.get_or_create( - key=DVR_COMSKIP_CUSTOM_PATH_KEY, - defaults={"name": "DVR Comskip Custom Path", "value": value}, - ) - if obj.value != value: - obj.value = value - obj.save(update_fields=["value"]) + cls._update_group(DVR_SETTINGS_KEY, "DVR Settings", {"comskip_custom_path": value}) return value @classmethod def get_dvr_pre_offset_minutes(cls): - """Minutes to start recording before scheduled start (default 0).""" - try: - val = cls.objects.get(key=DVR_PRE_OFFSET_MINUTES_KEY).value - return int(val) - except cls.DoesNotExist: - return 0 - except Exception: - try: - return int(float(val)) - except Exception: - return 0 + return int(cls.get_dvr_settings().get("pre_offset_minutes", 0) or 0) @classmethod def get_dvr_post_offset_minutes(cls): - """Minutes to stop recording after scheduled end (default 0).""" - try: - val = cls.objects.get(key=DVR_POST_OFFSET_MINUTES_KEY).value - return int(val) - except cls.DoesNotExist: - return 0 - except Exception: - try: - return int(float(val)) - except Exception: - return 0 - - @classmethod - def get_system_time_zone(cls): - """Return configured system time zone or fall back to Django settings.""" - try: - value = cls.objects.get(key=SYSTEM_TIME_ZONE_KEY).value - if value: - return value - except cls.DoesNotExist: - pass - return getattr(settings, "TIME_ZONE", "UTC") or "UTC" - - @classmethod - def set_system_time_zone(cls, tz_name: str | None): - """Persist the desired system time zone identifier.""" - value = (tz_name or "").strip() or getattr(settings, "TIME_ZONE", "UTC") or "UTC" - obj, _ = cls.objects.get_or_create( - key=SYSTEM_TIME_ZONE_KEY, - defaults={"name": "System Time Zone", "value": value}, - ) - if obj.value != value: - obj.value = value - obj.save(update_fields=["value"]) - return value + return int(cls.get_dvr_settings().get("post_offset_minutes", 0) or 0) @classmethod def get_dvr_series_rules(cls): - """Return list of series recording rules. Each: {tvg_id, title, mode: 'all'|'new'}""" - import json - try: - raw = cls.objects.get(key=DVR_SERIES_RULES_KEY).value - rules = json.loads(raw) if raw else [] - if isinstance(rules, list): - return rules - return [] - except cls.DoesNotExist: - # Initialize empty if missing - cls.objects.create(key=DVR_SERIES_RULES_KEY, name="DVR Series Rules", value="[]") - return [] + return cls.get_dvr_settings().get("series_rules", []) @classmethod def set_dvr_series_rules(cls, rules): - import json - try: - obj, _ = cls.objects.get_or_create(key=DVR_SERIES_RULES_KEY, defaults={"name": "DVR Series Rules", "value": "[]"}) - obj.value = json.dumps(rules) - obj.save(update_fields=["value"]) - return rules - except Exception: - return rules + cls._update_group(DVR_SETTINGS_KEY, "DVR Settings", {"series_rules": rules}) + return rules + + # 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, + }) + + # 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 class SystemEvent(models.Model): diff --git a/core/serializers.py b/core/serializers.py index c6029bc4..b2bd8ecc 100644 --- a/core/serializers.py +++ b/core/serializers.py @@ -3,7 +3,7 @@ import json import ipaddress from rest_framework import serializers -from .models import CoreSettings, UserAgent, StreamProfile, NETWORK_ACCESS +from .models import CoreSettings, UserAgent, StreamProfile, NETWORK_ACCESS_KEY class UserAgentSerializer(serializers.ModelSerializer): @@ -40,10 +40,10 @@ class CoreSettingsSerializer(serializers.ModelSerializer): fields = "__all__" def update(self, instance, validated_data): - if instance.key == NETWORK_ACCESS: + if instance.key == NETWORK_ACCESS_KEY: errors = False invalid = {} - value = json.loads(validated_data.get("value")) + value = validated_data.get("value") for key, val in value.items(): cidrs = val.split(",") for cidr in cidrs: diff --git a/core/utils.py b/core/utils.py index 7b6dd9b0..e3d6c389 100644 --- a/core/utils.py +++ b/core/utils.py @@ -417,8 +417,12 @@ def log_system_event(event_type, channel_id=None, channel_name=None, **details): # Get max events from settings (default 100) try: - max_events_setting = CoreSettings.objects.filter(key='max-system-events').first() - max_events = int(max_events_setting.value) if max_events_setting else 100 + 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 diff --git a/core/views.py b/core/views.py index 312d8836..5806d63c 100644 --- a/core/views.py +++ b/core/views.py @@ -1,5 +1,6 @@ # core/views.py import os +from shlex import split as shlex_split import sys import subprocess import logging @@ -131,7 +132,7 @@ def stream_view(request, channel_uuid): 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.objects.get(key="default-stream-profile").value) + stream_profile = StreamProfile.objects.get(id=CoreSettings.get_default_stream_profile_id()) logger.debug("Stream profile used: %s", stream_profile.name) @@ -144,7 +145,7 @@ def stream_view(request, channel_uuid): logger.debug("Formatted parameters: %s", parameters) # Build the final command. - cmd = [stream_profile.command] + parameters.split() + cmd = [stream_profile.command] + shlex_split(parameters) logger.debug("Executing command: %s", cmd) try: diff --git a/dispatcharr/utils.py b/dispatcharr/utils.py index 56243b7a..e588bcaa 100644 --- a/dispatcharr/utils.py +++ b/dispatcharr/utils.py @@ -3,7 +3,7 @@ import json import ipaddress from django.http import JsonResponse from django.core.exceptions import ValidationError -from core.models import CoreSettings, NETWORK_ACCESS +from core.models import CoreSettings, NETWORK_ACCESS_KEY def json_error_response(message, status=400): @@ -39,7 +39,10 @@ def get_client_ip(request): def network_access_allowed(request, settings_key): - network_access = json.loads(CoreSettings.objects.get(key=NETWORK_ACCESS).value) + try: + network_access = CoreSettings.objects.get(key=NETWORK_ACCESS_KEY).value + except CoreSettings.DoesNotExist: + network_access = {} cidrs = ( network_access[settings_key].split(",") diff --git a/docker/DispatcharrBase b/docker/DispatcharrBase index d37d8958..149bfffb 100644 --- a/docker/DispatcharrBase +++ b/docker/DispatcharrBase @@ -4,26 +4,44 @@ ENV DEBIAN_FRONTEND=noninteractive ENV VIRTUAL_ENV=/dispatcharrpy ENV PATH="$VIRTUAL_ENV/bin:$PATH" -# --- Install Python 3.13 and system dependencies --- +# --- 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 \ + python3.13 python3.13-dev python3.13-venv libpython3.13 \ python-is-python3 python3-pip \ - libpcre3 libpcre3-dev libpq-dev procps \ - build-essential gcc pciutils \ - nginx streamlink comskip\ - && apt-get clean && rm -rf /var/lib/apt/lists/* + libpcre3 libpcre3-dev libpq-dev procps pciutils \ + nginx streamlink comskip \ + vlc-bin vlc-plugin-base \ + build-essential gcc g++ gfortran libopenblas-dev libopenblas0 ninja-build # --- Create Python virtual environment --- RUN python3.13 -m venv $VIRTUAL_ENV && $VIRTUAL_ENV/bin/pip install --upgrade pip # --- Install Python dependencies --- COPY requirements.txt /tmp/requirements.txt -RUN $VIRTUAL_ENV/bin/pip install --no-cache-dir -r /tmp/requirements.txt && rm /tmp/requirements.txt +RUN $VIRTUAL_ENV/bin/pip install --no-cache-dir -r /tmp/requirements.txt && \ + rm /tmp/requirements.txt + +# --- Build legacy NumPy wheel for old hardware (store for runtime switching) --- +RUN $VIRTUAL_ENV/bin/pip install --no-cache-dir build && \ + cd /tmp && \ + $VIRTUAL_ENV/bin/pip download --no-binary numpy --no-deps numpy && \ + tar -xzf numpy-*.tar.gz && \ + cd numpy-*/ && \ + $VIRTUAL_ENV/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 && \ + $VIRTUAL_ENV/bin/pip uninstall -y build + +# --- 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 && \ diff --git a/docker/Dockerfile b/docker/Dockerfile index dc437227..bfb35c11 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -35,9 +35,6 @@ RUN rm -rf /app/frontend # Copy built frontend assets COPY --from=frontend-builder /app/frontend/dist /app/frontend/dist -# Run Django collectstatic -RUN python manage.py collectstatic --noinput - # Add timestamp argument ARG TIMESTAMP diff --git a/docker/docker-compose.aio.yml b/docker/docker-compose.aio.yml index fe5e1507..2b1fd2ae 100644 --- a/docker/docker-compose.aio.yml +++ b/docker/docker-compose.aio.yml @@ -14,6 +14,10 @@ services: - 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) diff --git a/docker/docker-compose.debug.yml b/docker/docker-compose.debug.yml index d9dbef0e..c576cfd1 100644 --- a/docker/docker-compose.debug.yml +++ b/docker/docker-compose.debug.yml @@ -18,6 +18,10 @@ services: - 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) diff --git a/docker/docker-compose.dev.yml b/docker/docker-compose.dev.yml index d1bb3680..b20c3296 100644 --- a/docker/docker-compose.dev.yml +++ b/docker/docker-compose.dev.yml @@ -17,6 +17,10 @@ services: - 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) diff --git a/docker/docker-compose.yml b/docker/docker-compose.yml index aaa63990..e4093e4b 100644 --- a/docker/docker-compose.yml +++ b/docker/docker-compose.yml @@ -17,6 +17,10 @@ services: - REDIS_HOST=redis - CELERY_BROKER_URL=redis://redis: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) diff --git a/docker/entrypoint.sh b/docker/entrypoint.sh index 72eb5928..a50f2f49 100755 --- a/docker/entrypoint.sh +++ b/docker/entrypoint.sh @@ -27,6 +27,18 @@ echo_with_timestamp() { echo "$(date '+%Y-%m-%d %H:%M:%S') - $1" } +# --- NumPy version switching for legacy hardware --- +if [ "$USE_LEGACY_NUMPY" = "true" ]; then + # Check if NumPy was compiled with baseline support + if /dispatcharrpy/bin/python -c "import numpy; numpy.show_config()" 2>&1 | grep -qi "baseline"; then + echo_with_timestamp "🔧 Switching to legacy NumPy (no CPU baseline)..." + /dispatcharrpy/bin/pip install --no-cache-dir --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 + # Set PostgreSQL environment variables export POSTGRES_DB=${POSTGRES_DB:-dispatcharr} export POSTGRES_USER=${POSTGRES_USER:-dispatch} @@ -100,7 +112,7 @@ export POSTGRES_DIR=/data/db if [[ ! -f /etc/profile.d/dispatcharr.sh ]]; then # Define all variables to process variables=( - PATH VIRTUAL_ENV DJANGO_SETTINGS_MODULE PYTHONUNBUFFERED + 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_DB POSTGRES_DIR DISPATCHARR_PORT @@ -174,9 +186,9 @@ else pids+=("$nginx_pid") fi -cd /app -python manage.py migrate --noinput -python manage.py collectstatic --noinput +# 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 diff --git a/docker/init/03-init-dispatcharr.sh b/docker/init/03-init-dispatcharr.sh index c9eaf18b..0c317017 100644 --- a/docker/init/03-init-dispatcharr.sh +++ b/docker/init/03-init-dispatcharr.sh @@ -15,6 +15,7 @@ DATA_DIRS=( APP_DIRS=( "/app/logo_cache" "/app/media" + "/app/static" ) # Create all directories @@ -36,6 +37,14 @@ if ! [[ "$DISPATCHARR_PORT" =~ ^[0-9]+$ ]]; then 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 diff --git a/docker/uwsgi.ini b/docker/uwsgi.ini index d831adfc..920bac48 100644 --- a/docker/uwsgi.ini +++ b/docker/uwsgi.ini @@ -37,6 +37,7 @@ 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) @@ -58,4 +59,4 @@ 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 \ No newline at end of file +log-buffering = 1024 # Add buffer size limit for logging diff --git a/fixtures.json b/fixtures.json index 2d42f84e..3c31f926 100644 --- a/fixtures.json +++ b/fixtures.json @@ -36,7 +36,7 @@ "model": "core.streamprofile", "pk": 1, "fields": { - "profile_name": "ffmpeg", + "profile_name": "FFmpeg", "command": "ffmpeg", "parameters": "-i {streamUrl} -c:a copy -c:v copy -f mpegts pipe:1", "is_active": true, @@ -46,13 +46,23 @@ { "model": "core.streamprofile", "fields": { - "profile_name": "streamlink", + "profile_name": "Streamlink", "command": "streamlink", "parameters": "{streamUrl} best --stdout", "is_active": true, "user_agent": "1" } }, + { + "model": "core.streamprofile", + "fields": { + "profile_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" + } + }, { "model": "core.coresettings", "fields": { diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 84d18989..ed9e6010 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -12,6 +12,7 @@ "@dnd-kit/modifiers": "^9.0.0", "@dnd-kit/sortable": "^10.0.0", "@dnd-kit/utilities": "^3.2.2", + "@hookform/resolvers": "^5.2.2", "@mantine/charts": "~8.0.1", "@mantine/core": "~8.0.1", "@mantine/dates": "~8.0.1", @@ -22,13 +23,13 @@ "@tanstack/react-table": "^8.21.2", "allotment": "^1.20.4", "dayjs": "^1.11.13", - "formik": "^2.4.6", "hls.js": "^1.5.20", "lucide-react": "^0.511.0", "mpegts.js": "^1.8.0", "react": "^19.1.0", "react-dom": "^19.1.0", "react-draggable": "^4.4.6", + "react-hook-form": "^7.70.0", "react-pro-sidebar": "^1.1.0", "react-router-dom": "^7.3.0", "react-virtualized": "^9.22.6", @@ -1248,6 +1249,18 @@ "integrity": "sha512-aGTxbpbg8/b5JfU1HXSrbH3wXZuLPJcNEcZQFMxLs3oSzgtVu6nFPkbbGGUvBcUjKV2YyB9Wxxabo+HEH9tcRQ==", "license": "MIT" }, + "node_modules/@hookform/resolvers": { + "version": "5.2.2", + "resolved": "https://registry.npmjs.org/@hookform/resolvers/-/resolvers-5.2.2.tgz", + "integrity": "sha512-A/IxlMLShx3KjV/HeTcTfaMxdwy690+L/ZADoeaTltLx+CVuzkeVIPuybK3jrRfw7YZnmdKsVVHAlEPIAEUNlA==", + "license": "MIT", + "dependencies": { + "@standard-schema/utils": "^0.3.0" + }, + "peerDependencies": { + "react-hook-form": "^7.55.0" + } + }, "node_modules/@humanfs/core": { "version": "0.19.1", "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz", @@ -1776,6 +1789,12 @@ "win32" ] }, + "node_modules/@standard-schema/utils": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/@standard-schema/utils/-/utils-0.3.0.tgz", + "integrity": "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==", + "license": "MIT" + }, "node_modules/@swc/core": { "name": "@swc/wasm", "version": "1.13.20", @@ -2008,18 +2027,6 @@ "dev": true, "license": "MIT" }, - "node_modules/@types/hoist-non-react-statics": { - "version": "3.3.7", - "resolved": "https://registry.npmjs.org/@types/hoist-non-react-statics/-/hoist-non-react-statics-3.3.7.tgz", - "integrity": "sha512-PQTyIulDkIDro8P+IHbKCsw7U2xxBYflVzW/FgWdCAePD9xGSidgA76/GeJ6lBKoblyhf9pBY763gbrN+1dI8g==", - "license": "MIT", - "dependencies": { - "hoist-non-react-statics": "^3.3.0" - }, - "peerDependencies": { - "@types/react": "*" - } - }, "node_modules/@types/json-schema": { "version": "7.0.15", "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", @@ -2037,6 +2044,7 @@ "version": "19.2.7", "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.7.tgz", "integrity": "sha512-MWtvHrGZLFttgeEj28VXHxpmwYbor/ATPYbBfSFZEIRK0ecCFLl2Qo55z52Hss+UV9CRN7trSeq1zbgx7YDWWg==", + "devOptional": true, "license": "MIT", "dependencies": { "csstype": "^3.2.2" @@ -2833,15 +2841,6 @@ "dev": true, "license": "MIT" }, - "node_modules/deepmerge": { - "version": "2.2.1", - "resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-2.2.1.tgz", - "integrity": "sha512-R9hc1Xa/NOBi9WRVUWg19rl1UB7Tt4kuPd+thNJgFZoxXsTz7ncaPaeIm+40oSGuP33DfMb4sZt1QIGiJzC4EA==", - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/dequal": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", @@ -3288,31 +3287,6 @@ "dev": true, "license": "ISC" }, - "node_modules/formik": { - "version": "2.4.9", - "resolved": "https://registry.npmjs.org/formik/-/formik-2.4.9.tgz", - "integrity": "sha512-5nI94BMnlFDdQRBY4Sz39WkhxajZJ57Fzs8wVbtsQlm5ScKIR1QLYqv/ultBnobObtlUyxpxoLodpixrsf36Og==", - "funding": [ - { - "type": "individual", - "url": "https://opencollective.com/formik" - } - ], - "license": "Apache-2.0", - "dependencies": { - "@types/hoist-non-react-statics": "^3.3.1", - "deepmerge": "^2.1.1", - "hoist-non-react-statics": "^3.3.0", - "lodash": "^4.17.21", - "lodash-es": "^4.17.21", - "react-fast-compare": "^2.0.1", - "tiny-warning": "^1.0.2", - "tslib": "^2.0.0" - }, - "peerDependencies": { - "react": ">=16.8.0" - } - }, "node_modules/fsevents": { "version": "2.3.3", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", @@ -3751,12 +3725,6 @@ "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==", "license": "MIT" }, - "node_modules/lodash-es": { - "version": "4.17.22", - "resolved": "https://registry.npmjs.org/lodash-es/-/lodash-es-4.17.22.tgz", - "integrity": "sha512-XEawp1t0gxSi9x01glktRZ5HDy0HXqrM0x5pXQM98EaI0NxO6jVM7omDOxsuEo5UIASAnm2bRp1Jt/e0a2XU8Q==", - "license": "MIT" - }, "node_modules/lodash.clamp": { "version": "4.0.3", "resolved": "https://registry.npmjs.org/lodash.clamp/-/lodash.clamp-4.0.3.tgz", @@ -4334,11 +4302,21 @@ "react": ">= 16.8 || 18.0.0" } }, - "node_modules/react-fast-compare": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/react-fast-compare/-/react-fast-compare-2.0.4.tgz", - "integrity": "sha512-suNP+J1VU1MWFKcyt7RtjiSWUjvidmQSlqu+eHslq+342xCbGTYmC0mEhPCOHxlW0CywylOC1u2DFAT+bv4dBw==", - "license": "MIT" + "node_modules/react-hook-form": { + "version": "7.70.0", + "resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.70.0.tgz", + "integrity": "sha512-COOMajS4FI3Wuwrs3GPpi/Jeef/5W1DRR84Yl5/ShlT3dKVFUfoGiEZ/QE6Uw8P4T2/CLJdcTVYKvWBMQTEpvw==", + "license": "MIT", + "engines": { + "node": ">=18.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/react-hook-form" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17 || ^18 || ^19" + } }, "node_modules/react-is": { "version": "16.13.1", @@ -4923,12 +4901,6 @@ "integrity": "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==", "license": "MIT" }, - "node_modules/tiny-warning": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/tiny-warning/-/tiny-warning-1.0.3.tgz", - "integrity": "sha512-lBN9zLN/oAf68o3zNXYrdCt1kP8WsiGW8Oo2ka41b2IM5JL/S1CTyX1rW0mb/zSuJun0ZUrDxx4sqvYS2FWzPA==", - "license": "MIT" - }, "node_modules/tinybench": { "version": "2.9.0", "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", diff --git a/frontend/package.json b/frontend/package.json index ff5be72d..7b2d5927 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -23,11 +23,12 @@ "@mantine/form": "~8.0.1", "@mantine/hooks": "~8.0.1", "@mantine/notifications": "~8.0.1", + "@hookform/resolvers": "^5.2.2", "@tanstack/react-table": "^8.21.2", "allotment": "^1.20.4", "dayjs": "^1.11.13", - "formik": "^2.4.6", "hls.js": "^1.5.20", + "react-hook-form": "^7.70.0", "lucide-react": "^0.511.0", "mpegts.js": "^1.8.0", "react": "^19.1.0", diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index 3c7c3877..f22d408f 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -19,7 +19,6 @@ import Users from './pages/Users'; import LogosPage from './pages/Logos'; import VODsPage from './pages/VODs'; import useAuthStore from './store/auth'; -import useLogosStore from './store/logos'; import FloatingVideo from './components/FloatingVideo'; import { WebsocketProvider } from './WebSocket'; import { Box, AppShell, MantineProvider } from '@mantine/core'; @@ -40,8 +39,6 @@ const defaultRoute = '/channels'; const App = () => { const [open, setOpen] = useState(true); - const [backgroundLoadingStarted, setBackgroundLoadingStarted] = - useState(false); const isAuthenticated = useAuthStore((s) => s.isAuthenticated); const setIsAuthenticated = useAuthStore((s) => s.setIsAuthenticated); const logout = useAuthStore((s) => s.logout); @@ -81,11 +78,7 @@ const App = () => { const loggedIn = await initializeAuth(); if (loggedIn) { await initData(); - // Start background logo loading after app is fully initialized (only once) - if (!backgroundLoadingStarted) { - setBackgroundLoadingStarted(true); - useLogosStore.getState().startBackgroundLoading(); - } + // Logos are now loaded at the end of initData, no need for background loading } else { await logout(); } @@ -96,7 +89,7 @@ const App = () => { }; checkAuth(); - }, [initializeAuth, initData, logout, backgroundLoadingStarted]); + }, [initializeAuth, initData, logout]); return ( { try { await API.requeryChannels(); await useChannelsStore.getState().fetchChannels(); + await fetchChannelProfiles(); console.log('Channels refreshed after bulk creation'); } catch (error) { console.error( diff --git a/frontend/src/api.js b/frontend/src/api.js index 64ce4d77..c33ff1ee 100644 --- a/frontend/src/api.js +++ b/frontend/src/api.js @@ -336,6 +336,15 @@ export default class API { delete channelData.channel_number; } + // Add channel profile IDs based on current selection + const selectedProfileId = useChannelsStore.getState().selectedProfileId; + if (selectedProfileId && selectedProfileId !== '0') { + // Specific profile selected - add only to that profile + channelData.channel_profile_ids = [parseInt(selectedProfileId)]; + } + // If selectedProfileId is '0' or not set, don't include channel_profile_ids + // which will trigger the backend's default behavior of adding to all profiles + if (channel.logo_file) { // Must send FormData for file upload body = new FormData(); @@ -2112,6 +2121,24 @@ export default class API { } } + static async duplicateChannelProfile(id, name) { + try { + const response = await request( + `${host}/api/channels/profiles/${id}/duplicate/`, + { + method: 'POST', + body: { name }, + } + ); + + useChannelsStore.getState().addProfile(response); + + return response; + } catch (e) { + errorNotification(`Failed to duplicate channel profile ${id}`, e); + } + } + static async deleteChannelProfile(id) { try { await request(`${host}/api/channels/profiles/${id}/`, { diff --git a/frontend/src/components/ConfirmationDialog.jsx b/frontend/src/components/ConfirmationDialog.jsx index 73805513..94fb169c 100644 --- a/frontend/src/components/ConfirmationDialog.jsx +++ b/frontend/src/components/ConfirmationDialog.jsx @@ -16,6 +16,7 @@ import useWarningsStore from '../store/warnings'; * @param {string} props.actionKey - Unique key for this type of action (used for suppression) * @param {Function} props.onSuppressChange - Called when "don't show again" option changes * @param {string} [props.size='md'] - Size of the modal + * @param {boolean} [props.loading=false] - Whether the confirm button should show loading state */ const ConfirmationDialog = ({ opened, @@ -31,6 +32,7 @@ const ConfirmationDialog = ({ zIndex = 1000, showDeleteFileOption = false, deleteFileLabel = 'Also delete files from disk', + loading = false, }) => { const suppressWarning = useWarningsStore((s) => s.suppressWarning); const isWarningSuppressed = useWarningsStore((s) => s.isWarningSuppressed); @@ -93,10 +95,16 @@ const ConfirmationDialog = ({ )} - - diff --git a/frontend/src/components/ErrorBoundary.jsx b/frontend/src/components/ErrorBoundary.jsx new file mode 100644 index 00000000..60c4ba38 --- /dev/null +++ b/frontend/src/components/ErrorBoundary.jsx @@ -0,0 +1,18 @@ +import React from 'react'; + +class ErrorBoundary extends React.Component { + state = { hasError: false }; + + static getDerivedStateFromError(error) { + return { hasError: true }; + } + + render() { + if (this.state.hasError) { + return
Something went wrong
; + } + return this.props.children; + } +} + +export default ErrorBoundary; \ No newline at end of file diff --git a/frontend/src/components/Field.jsx b/frontend/src/components/Field.jsx new file mode 100644 index 00000000..1293bf7b --- /dev/null +++ b/frontend/src/components/Field.jsx @@ -0,0 +1,47 @@ +import { NumberInput, Select, Switch, TextInput } from '@mantine/core'; +import React from 'react'; + +export const Field = ({ field, value, onChange }) => { + const common = { label: field.label, description: field.help_text }; + const effective = value ?? field.default; + switch (field.type) { + case 'boolean': + return ( + onChange(field.id, e.currentTarget.checked)} + label={field.label} + description={field.help_text} + /> + ); + case 'number': + return ( + onChange(field.id, v)} + {...common} + /> + ); + case 'select': + return ( + handleScheduleChange('frequency', value)} + onChange={(value) => + handleScheduleChange('frequency', value) + } data={[ { value: 'daily', label: 'Daily' }, { value: 'weekly', label: 'Weekly' }, @@ -634,7 +687,9 @@ export default function BackupManager() { { - const minute = schedule.time ? schedule.time.split(':')[1] : '00'; + const minute = schedule.time + ? schedule.time.split(':')[1] + : '00'; handleTimeChange24h(`${value}:${minute}`); }} data={Array.from({ length: 24 }, (_, i) => ({ @@ -698,9 +761,13 @@ export default function BackupManager() { /> + + )} + + {/* Add stream information badges */} + + {channel.resolution && ( + + + {channel.resolution} + + + )} + {channel.source_fps && ( + + + {channel.source_fps} FPS + + + )} + {channel.video_codec && ( + + + {channel.video_codec.toUpperCase()} + + + )} + {channel.audio_codec && ( + + + {channel.audio_codec.toUpperCase()} + + + )} + {channel.audio_channels && ( + + + {channel.audio_channels} + + + )} + {channel.stream_type && ( + + + {channel.stream_type.toUpperCase()} + + + )} + {channel.ffmpeg_speed && ( + + = + getBufferingSpeedThreshold(settings['proxy_settings']) + ? 'green' + : 'red' + } + > + {parseFloat(channel.ffmpeg_speed).toFixed(2)}x + + + )} + + + + + + + + {formatSpeed(bitrates.at(-1) || 0)} + + + + + + + Avg: {avgBitrate} + + + + + + + + {formatBytes(totalBytes)} + + + + + + + + + {clientCount} + + + + + + + + + ); +}; + +export default StreamConnectionCard; diff --git a/frontend/src/components/cards/VODCard.jsx b/frontend/src/components/cards/VODCard.jsx new file mode 100644 index 00000000..42468dae --- /dev/null +++ b/frontend/src/components/cards/VODCard.jsx @@ -0,0 +1,143 @@ +import { + ActionIcon, + Badge, + Box, + Card, + CardSection, + Group, + Image, + Stack, + Text, +} from '@mantine/core'; +import { Calendar, Clock, Play, Star } from 'lucide-react'; +import React from 'react'; +import { + formatDuration, + getSeasonLabel, +} from '../../utils/cards/VODCardUtils.js'; + +const VODCard = ({ vod, onClick }) => { + const isEpisode = vod.type === 'episode'; + + const getDisplayTitle = () => { + if (isEpisode && vod.series) { + return ( + + + {vod.series.name} + + + {getSeasonLabel(vod)} - {vod.name} + + + ); + } + return {vod.name}; + }; + + const handleCardClick = async () => { + // Just pass the basic vod info to the parent handler + onClick(vod); + }; + + return ( + + + + {vod.logo?.url ? ( + {vod.name} + ) : ( + + + + )} + + { + e.stopPropagation(); + onClick(vod); + }} + > + + + + + {isEpisode ? 'Episode' : 'Movie'} + + + + + + {getDisplayTitle()} + + + {vod.year && ( + + + + {vod.year} + + + )} + + {vod.duration && ( + + + + {formatDuration(vod.duration_secs)} + + + )} + + {vod.rating && ( + + + + {vod.rating} + + + )} + + + {vod.genre && ( + + {vod.genre} + + )} + + + ); +}; + +export default VODCard; \ No newline at end of file diff --git a/frontend/src/components/cards/VodConnectionCard.jsx b/frontend/src/components/cards/VodConnectionCard.jsx new file mode 100644 index 00000000..57564dce --- /dev/null +++ b/frontend/src/components/cards/VodConnectionCard.jsx @@ -0,0 +1,422 @@ +// Format duration for content length +import useLocalStorage from '../../hooks/useLocalStorage.jsx'; +import React, { useCallback, useEffect, useState } from 'react'; +import logo from '../../images/logo.png'; +import { ActionIcon, Badge, Box, Card, Center, Flex, Group, Progress, Stack, Text, Tooltip } from '@mantine/core'; +import { convertToSec, fromNow, toFriendlyDuration } from '../../utils/dateTimeUtils.js'; +import { ChevronDown, HardDriveUpload, SquareX, Timer, Video } from 'lucide-react'; +import { + calculateConnectionDuration, + calculateConnectionStartTime, + calculateProgress, + formatDuration, + formatTime, + getEpisodeDisplayTitle, + getEpisodeSubtitle, + getMovieDisplayTitle, + getMovieSubtitle, +} from '../../utils/cards/VodConnectionCardUtils.js'; + +const ClientDetails = ({ connection, connectionStartTime }) => { + return ( + + {connection.user_agent && + connection.user_agent !== 'Unknown' && ( + + + User Agent: + + + {connection.user_agent.length > 100 + ? `${connection.user_agent.substring(0, 100)}...` + : connection.user_agent} + + + )} + + + + Client ID: + + + {connection.client_id || 'Unknown'} + + + + {connection.connected_at && ( + + + Connected: + + {connectionStartTime} + + )} + + {connection.duration && connection.duration > 0 && ( + + + Watch Duration: + + + {toFriendlyDuration(connection.duration, 'seconds')} + + + )} + + {/* Seek/Position Information */} + {(connection.last_seek_percentage > 0 || + connection.last_seek_byte > 0) && ( + <> + + + Last Seek: + + + {connection.last_seek_percentage?.toFixed(1)}% + {connection.total_content_size > 0 && ( + + {' '} + ({Math.round(connection.last_seek_byte / (1024 * 1024))} + MB /{' '} + {Math.round( + connection.total_content_size / (1024 * 1024) + )} + MB) + + )} + + + + {Number(connection.last_seek_timestamp) > 0 && ( + + + Seek Time: + + + {fromNow(convertToSec(Number(connection.last_seek_timestamp)))} + + + )} + + )} + + {connection.bytes_sent > 0 && ( + + + Data Sent: + + + {(connection.bytes_sent / (1024 * 1024)).toFixed(1)} MB + + + )} + + ); +} + +// Create a VOD Card component similar to ChannelCard +const VodConnectionCard = ({ vodContent, stopVODClient }) => { + const [dateFormatSetting] = useLocalStorage('date-format', 'mdy'); + const dateFormat = dateFormatSetting === 'mdy' ? 'MM/DD' : 'DD/MM'; + const [isClientExpanded, setIsClientExpanded] = useState(false); + const [, setUpdateTrigger] = useState(0); // Force re-renders for progress updates + + // Get metadata from the VOD content + const metadata = vodContent.content_metadata || {}; + const contentType = vodContent.content_type; + const isMovie = contentType === 'movie'; + const isEpisode = contentType === 'episode'; + + // Set up timer to update progress every second + useEffect(() => { + const interval = setInterval(() => { + setUpdateTrigger((prev) => prev + 1); + }, 1000); + + return () => clearInterval(interval); + }, []); + + // Get the individual connection (since we now separate cards per connection) + const connection = + vodContent.individual_connection || + (vodContent.connections && vodContent.connections[0]); + + // Get poster/logo URL + const posterUrl = metadata.logo_url || logo; + + // Get display title + const getDisplayTitle = () => { + if (isMovie) { + return getMovieDisplayTitle(vodContent); + } else if (isEpisode) { + return getEpisodeDisplayTitle(metadata); + } + return vodContent.content_name; + }; + + // Get subtitle info + const getSubtitle = () => { + if (isMovie) { + return getMovieSubtitle(metadata); + } else if (isEpisode) { + return getEpisodeSubtitle(metadata); + } + return []; + }; + + // Render subtitle + const renderSubtitle = () => { + const subtitleParts = getSubtitle(); + if (subtitleParts.length === 0) return null; + + return ( + + {subtitleParts.join(' • ')} + + ); + }; + + // Calculate progress percentage and time + const getProgressInfo = useCallback(() => { + return calculateProgress(connection, metadata.duration_secs); + }, [connection, metadata.duration_secs]); + + // Calculate duration for connection + const getConnectionDuration = useCallback((connection) => { + return calculateConnectionDuration(connection); + }, []); + + // Get connection start time for tooltip + const getConnectionStartTime = useCallback( + (connection) => { + return calculateConnectionStartTime(connection, dateFormat); + }, + [dateFormat] + ); + + return ( + + + {/* Header with poster and basic info */} + + + content poster + + + + {connection && ( + +
+ + {getConnectionDuration(connection)} +
+
+ )} + {connection && stopVODClient && ( +
+ + stopVODClient(connection.client_id)} + > + + + +
+ )} +
+
+ + {/* Title and type */} + + + {getDisplayTitle()} + + + + + + + + + {/* Display M3U profile information - matching channel card style */} + {connection && + connection.m3u_profile && + (connection.m3u_profile.profile_name || + connection.m3u_profile.account_name) && ( + + + + + + + {connection.m3u_profile.account_name || 'Unknown Account'} + + + + + {connection.m3u_profile.profile_name || 'Default Profile'} + + + + + + )} + + {/* Subtitle/episode info */} + {getSubtitle().length > 0 && ( + + {renderSubtitle()} + + )} + + {/* Content information badges - streamlined to avoid duplication */} + + {metadata.year && ( + + + {metadata.year} + + + )} + + {metadata.duration_secs && ( + + + {formatDuration(metadata.duration_secs)} + + + )} + + {metadata.rating && ( + + + {parseFloat(metadata.rating).toFixed(1)}/10 + + + )} + + + {/* Progress bar - show current position in content */} + {connection && + metadata.duration_secs && + (() => { + const { totalTime, currentTime, percentage} = getProgressInfo(); + return totalTime > 0 ? ( + + + + Progress + + + {formatTime(currentTime)} /{' '} + {formatTime(totalTime)} + + + + + {percentage.toFixed(1)}% watched + + + ) : null; + })()} + + {/* Client information section - collapsible like channel cards */} + {connection && ( + + {/* Client summary header - always visible */} + setIsClientExpanded(!isClientExpanded)} + > + + + Client: + + + {connection.client_ip || 'Unknown IP'} + + + + + + {isClientExpanded ? 'Hide Details' : 'Show Details'} + + + + + + {/* Expanded client details */} + {isClientExpanded && ( + + )} + + )} +
+
+ ); +}; + +export default VodConnectionCard; \ No newline at end of file diff --git a/frontend/src/components/forms/Channel.jsx b/frontend/src/components/forms/Channel.jsx index cc6c5f47..4f12dd2c 100644 --- a/frontend/src/components/forms/Channel.jsx +++ b/frontend/src/components/forms/Channel.jsx @@ -1,5 +1,6 @@ import React, { useState, useEffect, useRef, useMemo } from 'react'; -import { useFormik } from 'formik'; +import { useForm } from 'react-hook-form'; +import { yupResolver } from '@hookform/resolvers/yup'; import * as Yup from 'yup'; import useChannelsStore from '../../store/channels'; import API from '../../api'; @@ -42,6 +43,11 @@ import useEPGsStore from '../../store/epgs'; import { FixedSizeList as List } from 'react-window'; import { USER_LEVELS, USER_LEVEL_LABELS } from '../../constants'; +const validationSchema = Yup.object({ + name: Yup.string().required('Name is required'), + channel_group_id: Yup.string().required('Channel group is required'), +}); + const ChannelForm = ({ channel = null, isOpen, onClose }) => { const theme = useMantineTheme(); @@ -100,7 +106,7 @@ const ChannelForm = ({ channel = null, isOpen, onClose }) => { const handleLogoSuccess = ({ logo }) => { if (logo && logo.id) { - formik.setFieldValue('logo_id', logo.id); + setValue('logo_id', logo.id); ensureLogosLoaded(); // Refresh logos } setLogoModalOpen(false); @@ -124,7 +130,7 @@ const ChannelForm = ({ channel = null, isOpen, onClose }) => { if (response.matched) { // Update the form with the new EPG data if (response.channel && response.channel.epg_data_id) { - formik.setFieldValue('epg_data_id', response.channel.epg_data_id); + setValue('epg_data_id', response.channel.epg_data_id); } notifications.show({ @@ -152,7 +158,7 @@ const ChannelForm = ({ channel = null, isOpen, onClose }) => { }; const handleSetNameFromEpg = () => { - const epgDataId = formik.values.epg_data_id; + const epgDataId = watch('epg_data_id'); if (!epgDataId) { notifications.show({ title: 'No EPG Selected', @@ -164,7 +170,7 @@ const ChannelForm = ({ channel = null, isOpen, onClose }) => { const tvg = tvgsById[epgDataId]; if (tvg && tvg.name) { - formik.setFieldValue('name', tvg.name); + setValue('name', tvg.name); notifications.show({ title: 'Success', message: `Channel name set to "${tvg.name}"`, @@ -180,7 +186,7 @@ const ChannelForm = ({ channel = null, isOpen, onClose }) => { }; const handleSetLogoFromEpg = async () => { - const epgDataId = formik.values.epg_data_id; + const epgDataId = watch('epg_data_id'); if (!epgDataId) { notifications.show({ title: 'No EPG Selected', @@ -207,7 +213,7 @@ const ChannelForm = ({ channel = null, isOpen, onClose }) => { ); if (matchingLogo) { - formik.setFieldValue('logo_id', matchingLogo.id); + setValue('logo_id', matchingLogo.id); notifications.show({ title: 'Success', message: `Logo set to "${matchingLogo.name}"`, @@ -231,7 +237,7 @@ const ChannelForm = ({ channel = null, isOpen, onClose }) => { // Create logo by calling the Logo API directly const newLogo = await API.createLogo(newLogoData); - formik.setFieldValue('logo_id', newLogo.id); + setValue('logo_id', newLogo.id); notifications.update({ id: 'creating-logo', @@ -264,7 +270,7 @@ const ChannelForm = ({ channel = null, isOpen, onClose }) => { }; const handleSetTvgIdFromEpg = () => { - const epgDataId = formik.values.epg_data_id; + const epgDataId = watch('epg_data_id'); if (!epgDataId) { notifications.show({ title: 'No EPG Selected', @@ -276,7 +282,7 @@ const ChannelForm = ({ channel = null, isOpen, onClose }) => { const tvg = tvgsById[epgDataId]; if (tvg && tvg.tvg_id) { - formik.setFieldValue('tvg_id', tvg.tvg_id); + setValue('tvg_id', tvg.tvg_id); notifications.show({ title: 'Success', message: `TVG-ID set to "${tvg.tvg_id}"`, @@ -291,130 +297,130 @@ const ChannelForm = ({ channel = null, isOpen, onClose }) => { } }; - const formik = useFormik({ - initialValues: { - name: '', - channel_number: '', // Change from 0 to empty string for consistency - channel_group_id: - Object.keys(channelGroups).length > 0 + const defaultValues = useMemo( + () => ({ + name: channel?.name || '', + channel_number: + channel?.channel_number !== null && + channel?.channel_number !== undefined + ? channel.channel_number + : '', + channel_group_id: channel?.channel_group_id + ? `${channel.channel_group_id}` + : Object.keys(channelGroups).length > 0 ? Object.keys(channelGroups)[0] : '', - stream_profile_id: '0', - tvg_id: '', - tvc_guide_stationid: '', - epg_data_id: '', - logo_id: '', - user_level: '0', - }, - validationSchema: Yup.object({ - name: Yup.string().required('Name is required'), - channel_group_id: Yup.string().required('Channel group is required'), + stream_profile_id: channel?.stream_profile_id + ? `${channel.stream_profile_id}` + : '0', + tvg_id: channel?.tvg_id || '', + tvc_guide_stationid: channel?.tvc_guide_stationid || '', + epg_data_id: channel?.epg_data_id ?? '', + logo_id: channel?.logo_id ? `${channel.logo_id}` : '', + user_level: `${channel?.user_level ?? '0'}`, }), - onSubmit: async (values, { setSubmitting }) => { - let response; + [channel, channelGroups] + ); - try { - const formattedValues = { ...values }; + const { + register, + handleSubmit, + setValue, + watch, + reset, + formState: { errors, isSubmitting }, + } = useForm({ + defaultValues, + resolver: yupResolver(validationSchema), + }); - // Convert empty or "0" stream_profile_id to null for the API - if ( - !formattedValues.stream_profile_id || - formattedValues.stream_profile_id === '0' - ) { - formattedValues.stream_profile_id = null; - } + const onSubmit = async (values) => { + let response; - // Ensure tvg_id is properly included (no empty strings) - formattedValues.tvg_id = formattedValues.tvg_id || null; + try { + const formattedValues = { ...values }; - // Ensure tvc_guide_stationid is properly included (no empty strings) - formattedValues.tvc_guide_stationid = - formattedValues.tvc_guide_stationid || null; + // Convert empty or "0" stream_profile_id to null for the API + if ( + !formattedValues.stream_profile_id || + formattedValues.stream_profile_id === '0' + ) { + formattedValues.stream_profile_id = null; + } - if (channel) { - // If there's an EPG to set, use our enhanced endpoint - if (values.epg_data_id !== (channel.epg_data_id ?? '')) { - // Use the special endpoint to set EPG and trigger refresh - const epgResponse = await API.setChannelEPG( - channel.id, - values.epg_data_id - ); + // Ensure tvg_id is properly included (no empty strings) + formattedValues.tvg_id = formattedValues.tvg_id || null; - // Remove epg_data_id from values since we've handled it separately - const { epg_data_id, ...otherValues } = formattedValues; + // Ensure tvc_guide_stationid is properly included (no empty strings) + formattedValues.tvc_guide_stationid = + formattedValues.tvc_guide_stationid || null; - // Update other channel fields if needed - if (Object.keys(otherValues).length > 0) { - response = await API.updateChannel({ - id: channel.id, - ...otherValues, - streams: channelStreams.map((stream) => stream.id), - }); - } - } else { - // No EPG change, regular update + if (channel) { + // If there's an EPG to set, use our enhanced endpoint + if (values.epg_data_id !== (channel.epg_data_id ?? '')) { + // Use the special endpoint to set EPG and trigger refresh + const epgResponse = await API.setChannelEPG( + channel.id, + values.epg_data_id + ); + + // Remove epg_data_id from values since we've handled it separately + const { epg_data_id, ...otherValues } = formattedValues; + + // Update other channel fields if needed + if (Object.keys(otherValues).length > 0) { response = await API.updateChannel({ id: channel.id, - ...formattedValues, + ...otherValues, streams: channelStreams.map((stream) => stream.id), }); } } else { - // New channel creation - use the standard method - response = await API.addChannel({ + // No EPG change, regular update + response = await API.updateChannel({ + id: channel.id, ...formattedValues, streams: channelStreams.map((stream) => stream.id), }); } - } catch (error) { - console.error('Error saving channel:', error); + } else { + // New channel creation - use the standard method + response = await API.addChannel({ + ...formattedValues, + streams: channelStreams.map((stream) => stream.id), + }); } + } catch (error) { + console.error('Error saving channel:', error); + } - formik.resetForm(); - API.requeryChannels(); + reset(); + API.requeryChannels(); - // Refresh channel profiles to update the membership information - useChannelsStore.getState().fetchChannelProfiles(); + // Refresh channel profiles to update the membership information + useChannelsStore.getState().fetchChannelProfiles(); - setSubmitting(false); - setTvgFilter(''); - setLogoFilter(''); - onClose(); - }, - }); + setTvgFilter(''); + setLogoFilter(''); + onClose(); + }; useEffect(() => { - if (channel) { - if (channel.epg_data_id) { - const epgSource = epgs[tvgsById[channel.epg_data_id]?.epg_source]; - setSelectedEPG(epgSource ? `${epgSource.id}` : ''); - } + reset(defaultValues); + setChannelStreams(channel?.streams || []); - formik.setValues({ - name: channel.name || '', - channel_number: - channel.channel_number !== null ? channel.channel_number : '', - channel_group_id: channel.channel_group_id - ? `${channel.channel_group_id}` - : '', - stream_profile_id: channel.stream_profile_id - ? `${channel.stream_profile_id}` - : '0', - tvg_id: channel.tvg_id || '', - tvc_guide_stationid: channel.tvc_guide_stationid || '', - epg_data_id: channel.epg_data_id ?? '', - logo_id: channel.logo_id ? `${channel.logo_id}` : '', - user_level: `${channel.user_level}`, - }); - - setChannelStreams(channel.streams || []); + if (channel?.epg_data_id) { + const epgSource = epgs[tvgsById[channel.epg_data_id]?.epg_source]; + setSelectedEPG(epgSource ? `${epgSource.id}` : ''); } else { - formik.resetForm(); + setSelectedEPG(''); + } + + if (!channel) { setTvgFilter(''); setLogoFilter(''); - setChannelStreams([]); // Ensure streams are cleared when adding a new channel } - }, [channel, tvgsById, channelGroups]); + }, [defaultValues, channel, reset, epgs, tvgsById]); // Memoize logo options to prevent infinite re-renders during background loading const logoOptions = useMemo(() => { @@ -431,10 +437,7 @@ const ChannelForm = ({ channel = null, isOpen, onClose }) => { // If a new group was created and returned, update the form with it if (newGroup && newGroup.id) { // Preserve all current form values while updating just the channel_group_id - formik.setValues({ - ...formik.values, - channel_group_id: `${newGroup.id}`, - }); + setValue('channel_group_id', `${newGroup.id}`); } }; @@ -472,7 +475,7 @@ const ChannelForm = ({ channel = null, isOpen, onClose }) => { } styles={{ content: { '--mantine-color-body': '#27272A' } }} > -
+ { label={ Channel Name - {formik.values.epg_data_id && ( + {watch('epg_data_id') && ( @@ -933,7 +905,7 @@ const ChannelForm = ({ channel = null, isOpen, onClose }) => { } readOnly value={(() => { - const tvg = tvgsById[formik.values.epg_data_id]; + const tvg = tvgsById[watch('epg_data_id')]; const epgSource = tvg && epgs[tvg.epg_source]; const tvgLabel = tvg ? tvg.name || tvg.id : ''; if (epgSource && tvgLabel) { @@ -953,7 +925,7 @@ const ChannelForm = ({ channel = null, isOpen, onClose }) => { color="white" onClick={(e) => { e.stopPropagation(); - formik.setFieldValue('epg_data_id', null); + setValue('epg_data_id', null); }} title="Create new group" size="small" @@ -1012,12 +984,9 @@ const ChannelForm = ({ channel = null, isOpen, onClose }) => { size="xs" onClick={() => { if (filteredTvgs[index].id == '0') { - formik.setFieldValue('epg_data_id', null); + setValue('epg_data_id', null); } else { - formik.setFieldValue( - 'epg_data_id', - filteredTvgs[index].id - ); + setValue('epg_data_id', filteredTvgs[index].id); // Also update selectedEPG to match the EPG source of the selected tvg if (filteredTvgs[index].epg_source) { setSelectedEPG( @@ -1047,11 +1016,11 @@ const ChannelForm = ({ channel = null, isOpen, onClose }) => { diff --git a/frontend/src/components/forms/ChannelBatch.jsx b/frontend/src/components/forms/ChannelBatch.jsx index a1cebe54..14dd22f1 100644 --- a/frontend/src/components/forms/ChannelBatch.jsx +++ b/frontend/src/components/forms/ChannelBatch.jsx @@ -77,6 +77,9 @@ const ChannelBatchForm = ({ channelIds, isOpen, onClose }) => { const [confirmSetLogosOpen, setConfirmSetLogosOpen] = useState(false); const [confirmSetTvgIdsOpen, setConfirmSetTvgIdsOpen] = useState(false); const [confirmBatchUpdateOpen, setConfirmBatchUpdateOpen] = useState(false); + const [settingNames, setSettingNames] = useState(false); + const [settingLogos, setSettingLogos] = useState(false); + const [settingTvgIds, setSettingTvgIds] = useState(false); const isWarningSuppressed = useWarningsStore((s) => s.isWarningSuppressed); const suppressWarning = useWarningsStore((s) => s.suppressWarning); @@ -328,6 +331,7 @@ const ChannelBatchForm = ({ channelIds, isOpen, onClose }) => { }; const executeSetNamesFromEpg = async () => { + setSettingNames(true); try { // Start the backend task await API.setChannelNamesFromEpg(channelIds); @@ -341,7 +345,6 @@ const ChannelBatchForm = ({ channelIds, isOpen, onClose }) => { }); // Close the modal since the task is now running in background - setConfirmSetNamesOpen(false); onClose(); } catch (error) { console.error('Failed to start EPG name setting task:', error); @@ -350,6 +353,8 @@ const ChannelBatchForm = ({ channelIds, isOpen, onClose }) => { message: 'Failed to start EPG name setting task.', color: 'red', }); + } finally { + setSettingNames(false); setConfirmSetNamesOpen(false); } }; @@ -373,6 +378,7 @@ const ChannelBatchForm = ({ channelIds, isOpen, onClose }) => { }; const executeSetLogosFromEpg = async () => { + setSettingLogos(true); try { // Start the backend task await API.setChannelLogosFromEpg(channelIds); @@ -386,7 +392,6 @@ const ChannelBatchForm = ({ channelIds, isOpen, onClose }) => { }); // Close the modal since the task is now running in background - setConfirmSetLogosOpen(false); onClose(); } catch (error) { console.error('Failed to start EPG logo setting task:', error); @@ -395,6 +400,8 @@ const ChannelBatchForm = ({ channelIds, isOpen, onClose }) => { message: 'Failed to start EPG logo setting task.', color: 'red', }); + } finally { + setSettingLogos(false); setConfirmSetLogosOpen(false); } }; @@ -418,6 +425,7 @@ const ChannelBatchForm = ({ channelIds, isOpen, onClose }) => { }; const executeSetTvgIdsFromEpg = async () => { + setSettingTvgIds(true); try { // Start the backend task await API.setChannelTvgIdsFromEpg(channelIds); @@ -431,7 +439,6 @@ const ChannelBatchForm = ({ channelIds, isOpen, onClose }) => { }); // Close the modal since the task is now running in background - setConfirmSetTvgIdsOpen(false); onClose(); } catch (error) { console.error('Failed to start EPG TVG-ID setting task:', error); @@ -440,6 +447,8 @@ const ChannelBatchForm = ({ channelIds, isOpen, onClose }) => { message: 'Failed to start EPG TVG-ID setting task.', color: 'red', }); + } finally { + setSettingTvgIds(false); setConfirmSetTvgIdsOpen(false); } }; @@ -947,6 +956,7 @@ const ChannelBatchForm = ({ channelIds, isOpen, onClose }) => { opened={confirmSetNamesOpen} onClose={() => setConfirmSetNamesOpen(false)} onConfirm={executeSetNamesFromEpg} + loading={settingNames} title="Confirm Set Names from EPG" message={
@@ -968,6 +978,7 @@ This action cannot be undone.`} opened={confirmSetLogosOpen} onClose={() => setConfirmSetLogosOpen(false)} onConfirm={executeSetLogosFromEpg} + loading={settingLogos} title="Confirm Set Logos from EPG" message={
@@ -989,6 +1000,7 @@ This action cannot be undone.`} opened={confirmSetTvgIdsOpen} onClose={() => setConfirmSetTvgIdsOpen(false)} onConfirm={executeSetTvgIdsFromEpg} + loading={settingTvgIds} title="Confirm Set TVG-IDs from EPG" message={
@@ -1010,6 +1022,7 @@ This action cannot be undone.`} opened={confirmBatchUpdateOpen} onClose={() => setConfirmBatchUpdateOpen(false)} onConfirm={onSubmit} + loading={isSubmitting} title="Confirm Batch Update" message={
diff --git a/frontend/src/components/forms/GroupManager.jsx b/frontend/src/components/forms/GroupManager.jsx index adcd55d3..453d9494 100644 --- a/frontend/src/components/forms/GroupManager.jsx +++ b/frontend/src/components/forms/GroupManager.jsx @@ -183,6 +183,7 @@ const GroupManager = React.memo(({ isOpen, onClose }) => { const [confirmDeleteOpen, setConfirmDeleteOpen] = useState(false); const [groupToDelete, setGroupToDelete] = useState(null); const [confirmCleanupOpen, setConfirmCleanupOpen] = useState(false); + const [deletingGroup, setDeletingGroup] = useState(false); // Memoize the channel groups array to prevent unnecessary re-renders const channelGroupsArray = useMemo( @@ -382,6 +383,7 @@ const GroupManager = React.memo(({ isOpen, onClose }) => { const executeDeleteGroup = useCallback( async (group) => { + setDeletingGroup(true); try { await API.deleteChannelGroup(group.id); @@ -392,13 +394,14 @@ const GroupManager = React.memo(({ isOpen, onClose }) => { }); await fetchGroupUsage(); // Refresh usage data - setConfirmDeleteOpen(false); } catch (error) { notifications.show({ title: 'Error', message: 'Failed to delete group', color: 'red', }); + } finally { + setDeletingGroup(false); setConfirmDeleteOpen(false); } }, @@ -680,6 +683,7 @@ const GroupManager = React.memo(({ isOpen, onClose }) => { opened={confirmDeleteOpen} onClose={() => setConfirmDeleteOpen(false)} onConfirm={() => groupToDelete && executeDeleteGroup(groupToDelete)} + loading={deletingGroup} title="Confirm Group Deletion" message={ groupToDelete ? ( @@ -706,6 +710,7 @@ This action cannot be undone.`} opened={confirmCleanupOpen} onClose={() => setConfirmCleanupOpen(false)} onConfirm={executeCleanup} + loading={isCleaningUp} title="Confirm Group Cleanup" message={
diff --git a/frontend/src/components/forms/LiveGroupFilter.jsx b/frontend/src/components/forms/LiveGroupFilter.jsx index 3497957b..68f4db8c 100644 --- a/frontend/src/components/forms/LiveGroupFilter.jsx +++ b/frontend/src/components/forms/LiveGroupFilter.jsx @@ -96,28 +96,30 @@ const LiveGroupFilter = ({ } setGroupStates( - playlist.channel_groups.map((group) => { - // Parse custom_properties if present - let customProps = {}; - if (group.custom_properties) { - try { - customProps = - typeof group.custom_properties === 'string' - ? JSON.parse(group.custom_properties) - : group.custom_properties; - } catch { - customProps = {}; + playlist.channel_groups + .filter((group) => channelGroups[group.channel_group]) // Filter out groups that don't exist + .map((group) => { + // Parse custom_properties if present + let customProps = {}; + if (group.custom_properties) { + try { + customProps = + typeof group.custom_properties === 'string' + ? JSON.parse(group.custom_properties) + : group.custom_properties; + } catch { + customProps = {}; + } } - } - return { - ...group, - name: channelGroups[group.channel_group].name, - auto_channel_sync: group.auto_channel_sync || false, - auto_sync_channel_start: group.auto_sync_channel_start || 1.0, - custom_properties: customProps, - original_enabled: group.enabled, - }; - }) + return { + ...group, + name: channelGroups[group.channel_group].name, + auto_channel_sync: group.auto_channel_sync || false, + auto_sync_channel_start: group.auto_sync_channel_start || 1.0, + custom_properties: customProps, + original_enabled: group.enabled, + }; + }) ); }, [playlist, channelGroups]); @@ -261,25 +263,42 @@ const LiveGroupFilter = ({ }} > {/* Group Enable/Disable Button */} - + + {/* Auto Sync Controls */} @@ -367,7 +386,8 @@ const LiveGroupFilter = ({ if ( group.custom_properties?.custom_epg_id !== undefined || - group.custom_properties?.force_dummy_epg + group.custom_properties?.force_dummy_epg || + group.custom_properties?.force_epg_selected ) { selectedValues.push('force_epg'); } @@ -430,23 +450,20 @@ const LiveGroupFilter = ({ // Handle force_epg if (selectedOptions.includes('force_epg')) { - // Migrate from old force_dummy_epg if present + // Set default to force_dummy_epg if no EPG settings exist yet if ( - newCustomProps.force_dummy_epg && - newCustomProps.custom_epg_id === undefined + newCustomProps.custom_epg_id === + undefined && + !newCustomProps.force_dummy_epg ) { - // Migrate: force_dummy_epg=true becomes custom_epg_id=null - newCustomProps.custom_epg_id = null; - delete newCustomProps.force_dummy_epg; - } else if ( - newCustomProps.custom_epg_id === undefined - ) { - // New configuration: initialize with null (no EPG/default dummy) - newCustomProps.custom_epg_id = null; + // Default to "No EPG (Disabled)" + newCustomProps.force_dummy_epg = true; } } else { - // Only remove custom_epg_id when deselected + // Remove all EPG settings when deselected delete newCustomProps.custom_epg_id; + delete newCustomProps.force_dummy_epg; + delete newCustomProps.force_epg_selected; } // Handle group_override @@ -1122,7 +1139,8 @@ const LiveGroupFilter = ({ {/* Show EPG selector when force_epg is selected */} {(group.custom_properties?.custom_epg_id !== undefined || - group.custom_properties?.force_dummy_epg) && ( + group.custom_properties?.force_dummy_epg || + group.custom_properties?.force_epg_selected) && ( { - // Handle migration from force_dummy_epg + // Show custom EPG if set if ( group.custom_properties?.custom_epg_id !== - undefined + undefined && + group.custom_properties?.custom_epg_id !== null ) { - // Convert to string, use '0' for null/no EPG - return group.custom_properties.custom_epg_id === - null - ? '0' - : group.custom_properties.custom_epg_id.toString(); - } else if ( - group.custom_properties?.force_dummy_epg - ) { - // Show "No EPG" for old force_dummy_epg configs + return group.custom_properties.custom_epg_id.toString(); + } + // Show "No EPG" if force_dummy_epg is set + if (group.custom_properties?.force_dummy_epg) { return '0'; } - return '0'; + // Otherwise show empty/placeholder + return null; })()} onChange={(value) => { - // Convert back: '0' means no EPG (null) - const newValue = - value === '0' ? null : parseInt(value); - setGroupStates( - groupStates.map((state) => { - if ( - state.channel_group === group.channel_group - ) { - return { - ...state, - custom_properties: { + if (value === '0') { + // "No EPG (Disabled)" selected - use force_dummy_epg + setGroupStates( + groupStates.map((state) => { + if ( + state.channel_group === + group.channel_group + ) { + const newProps = { ...state.custom_properties, - custom_epg_id: newValue, - }, - }; - } - return state; - }) - ); + }; + delete newProps.custom_epg_id; + delete newProps.force_epg_selected; + newProps.force_dummy_epg = true; + return { + ...state, + custom_properties: newProps, + }; + } + return state; + }) + ); + } else if (value) { + // Specific EPG source selected + const epgId = parseInt(value); + setGroupStates( + groupStates.map((state) => { + if ( + state.channel_group === + group.channel_group + ) { + const newProps = { + ...state.custom_properties, + }; + newProps.custom_epg_id = epgId; + delete newProps.force_dummy_epg; + delete newProps.force_epg_selected; + return { + ...state, + custom_properties: newProps, + }; + } + return state; + }) + ); + } else { + // Cleared - remove all EPG settings + setGroupStates( + groupStates.map((state) => { + if ( + state.channel_group === + group.channel_group + ) { + const newProps = { + ...state.custom_properties, + }; + delete newProps.custom_epg_id; + delete newProps.force_dummy_epg; + delete newProps.force_epg_selected; + return { + ...state, + custom_properties: newProps, + }; + } + return state; + }) + ); + } }} data={[ { value: '0', label: 'No EPG (Disabled)' }, diff --git a/frontend/src/components/forms/Logo.jsx b/frontend/src/components/forms/Logo.jsx index 8362b891..c6e63ba6 100644 --- a/frontend/src/components/forms/Logo.jsx +++ b/frontend/src/components/forms/Logo.jsx @@ -1,5 +1,6 @@ -import React, { useState, useEffect } from 'react'; -import { useFormik } from 'formik'; +import React, { useState, useEffect, useMemo } from 'react'; +import { useForm } from 'react-hook-form'; +import { yupResolver } from '@hookform/resolvers/yup'; import * as Yup from 'yup'; import { Modal, @@ -18,143 +19,148 @@ import { Upload, FileImage, X } from 'lucide-react'; import { notifications } from '@mantine/notifications'; import API from '../../api'; +const schema = Yup.object({ + name: Yup.string().required('Name is required'), + url: Yup.string() + .required('URL is required') + .test( + 'valid-url-or-path', + 'Must be a valid URL or local file path', + (value) => { + if (!value) return false; + // Allow local file paths starting with /data/logos/ + if (value.startsWith('/data/logos/')) return true; + // Allow valid URLs + try { + new URL(value); + return true; + } catch { + return false; + } + } + ), +}); + const LogoForm = ({ logo = null, isOpen, onClose, onSuccess }) => { const [logoPreview, setLogoPreview] = useState(null); const [uploading, setUploading] = useState(false); const [selectedFile, setSelectedFile] = useState(null); // Store selected file - const formik = useFormik({ - initialValues: { - name: '', - url: '', - }, - validationSchema: Yup.object({ - name: Yup.string().required('Name is required'), - url: Yup.string() - .required('URL is required') - .test( - 'valid-url-or-path', - 'Must be a valid URL or local file path', - (value) => { - if (!value) return false; - // Allow local file paths starting with /data/logos/ - if (value.startsWith('/data/logos/')) return true; - // Allow valid URLs - try { - new URL(value); - return true; - } catch { - return false; - } - } - ), + const defaultValues = useMemo( + () => ({ + name: logo?.name || '', + url: logo?.url || '', }), - onSubmit: async (values, { setSubmitting }) => { - try { - setUploading(true); - let uploadResponse = null; // Store upload response for later use + [logo] + ); - // If we have a selected file, upload it first - if (selectedFile) { - try { - uploadResponse = await API.uploadLogo(selectedFile, values.name); - // Use the uploaded file data instead of form values - values.name = uploadResponse.name; - values.url = uploadResponse.url; - } catch (uploadError) { - let errorMessage = 'Failed to upload logo file'; - - if ( - uploadError.code === 'NETWORK_ERROR' || - uploadError.message?.includes('timeout') - ) { - errorMessage = 'Upload timed out. Please try again.'; - } else if (uploadError.status === 413) { - errorMessage = 'File too large. Please choose a smaller file.'; - } else if (uploadError.body?.error) { - errorMessage = uploadError.body.error; - } - - notifications.show({ - title: 'Upload Error', - message: errorMessage, - color: 'red', - }); - return; // Don't proceed with creation if upload fails - } - } - - // Now create or update the logo with the final values - // Only proceed if we don't already have a logo from file upload - if (logo) { - const updatedLogo = await API.updateLogo(logo.id, values); - notifications.show({ - title: 'Success', - message: 'Logo updated successfully', - color: 'green', - }); - onSuccess?.({ type: 'update', logo: updatedLogo }); // Call onSuccess for updates - } else if (!selectedFile) { - // Only create a new logo entry if we're not uploading a file - // (file upload already created the logo entry) - const newLogo = await API.createLogo(values); - notifications.show({ - title: 'Success', - message: 'Logo created successfully', - color: 'green', - }); - onSuccess?.({ type: 'create', logo: newLogo }); // Call onSuccess for creates - } else { - // File was uploaded and logo was already created - notifications.show({ - title: 'Success', - message: 'Logo uploaded successfully', - color: 'green', - }); - onSuccess?.({ type: 'create', logo: uploadResponse }); - } - onClose(); - } catch (error) { - let errorMessage = logo - ? 'Failed to update logo' - : 'Failed to create logo'; - - // Handle specific timeout errors - if ( - error.code === 'NETWORK_ERROR' || - error.message?.includes('timeout') - ) { - errorMessage = 'Request timed out. Please try again.'; - } else if (error.response?.data?.error) { - errorMessage = error.response.data.error; - } - - notifications.show({ - title: 'Error', - message: errorMessage, - color: 'red', - }); - } finally { - setSubmitting(false); - setUploading(false); - } - }, + const { + register, + handleSubmit, + formState: { errors, isSubmitting }, + reset, + setValue, + watch, + } = useForm({ + defaultValues, + resolver: yupResolver(schema), }); - useEffect(() => { - if (logo) { - formik.setValues({ - name: logo.name || '', - url: logo.url || '', + const onSubmit = async (values) => { + try { + setUploading(true); + let uploadResponse = null; // Store upload response for later use + + // If we have a selected file, upload it first + if (selectedFile) { + try { + uploadResponse = await API.uploadLogo(selectedFile, values.name); + // Use the uploaded file data instead of form values + values.name = uploadResponse.name; + values.url = uploadResponse.url; + } catch (uploadError) { + let errorMessage = 'Failed to upload logo file'; + + if ( + uploadError.code === 'NETWORK_ERROR' || + uploadError.message?.includes('timeout') + ) { + errorMessage = 'Upload timed out. Please try again.'; + } else if (uploadError.status === 413) { + errorMessage = 'File too large. Please choose a smaller file.'; + } else if (uploadError.body?.error) { + errorMessage = uploadError.body.error; + } + + notifications.show({ + title: 'Upload Error', + message: errorMessage, + color: 'red', + }); + return; // Don't proceed with creation if upload fails + } + } + + // Now create or update the logo with the final values + // Only proceed if we don't already have a logo from file upload + if (logo) { + const updatedLogo = await API.updateLogo(logo.id, values); + notifications.show({ + title: 'Success', + message: 'Logo updated successfully', + color: 'green', + }); + onSuccess?.({ type: 'update', logo: updatedLogo }); // Call onSuccess for updates + } else if (!selectedFile) { + // Only create a new logo entry if we're not uploading a file + // (file upload already created the logo entry) + const newLogo = await API.createLogo(values); + notifications.show({ + title: 'Success', + message: 'Logo created successfully', + color: 'green', + }); + onSuccess?.({ type: 'create', logo: newLogo }); // Call onSuccess for creates + } else { + // File was uploaded and logo was already created + notifications.show({ + title: 'Success', + message: 'Logo uploaded successfully', + color: 'green', + }); + onSuccess?.({ type: 'create', logo: uploadResponse }); + } + onClose(); + } catch (error) { + let errorMessage = logo + ? 'Failed to update logo' + : 'Failed to create logo'; + + // Handle specific timeout errors + if ( + error.code === 'NETWORK_ERROR' || + error.message?.includes('timeout') + ) { + errorMessage = 'Request timed out. Please try again.'; + } else if (error.response?.data?.error) { + errorMessage = error.response.data.error; + } + + notifications.show({ + title: 'Error', + message: errorMessage, + color: 'red', }); - setLogoPreview(logo.cache_url); - } else { - formik.resetForm(); - setLogoPreview(null); + } finally { + setUploading(false); } - // Clear any selected file when logo changes + }; + + useEffect(() => { + reset(defaultValues); + setLogoPreview(logo?.cache_url || null); setSelectedFile(null); - }, [logo, isOpen]); + }, [defaultValues, logo, reset]); const handleFileSelect = (files) => { if (files.length === 0) return; @@ -180,18 +186,19 @@ const LogoForm = ({ logo = null, isOpen, onClose, onSuccess }) => { setLogoPreview(previewUrl); // Auto-fill the name field if empty - if (!formik.values.name) { + const currentName = watch('name'); + if (!currentName) { const nameWithoutExtension = file.name.replace(/\.[^/.]+$/, ''); - formik.setFieldValue('name', nameWithoutExtension); + setValue('name', nameWithoutExtension); } // Set a placeholder URL (will be replaced after upload) - formik.setFieldValue('url', 'file://pending-upload'); + setValue('url', 'file://pending-upload'); }; const handleUrlChange = (event) => { const url = event.target.value; - formik.setFieldValue('url', url); + setValue('url', url); // Clear any selected file when manually entering URL if (selectedFile) { @@ -219,7 +226,7 @@ const LogoForm = ({ logo = null, isOpen, onClose, onSuccess }) => { const filename = pathname.substring(pathname.lastIndexOf('/') + 1); const nameWithoutExtension = filename.replace(/\.[^/.]+$/, ''); if (nameWithoutExtension) { - formik.setFieldValue('name', nameWithoutExtension); + setValue('name', nameWithoutExtension); } } catch (error) { // If the URL is invalid, do nothing. @@ -244,7 +251,7 @@ const LogoForm = ({ logo = null, isOpen, onClose, onSuccess }) => { title={logo ? 'Edit Logo' : 'Add Logo'} size="md" > -
+ {/* Logo Preview */} {logoPreview && ( @@ -338,18 +345,18 @@ const LogoForm = ({ logo = null, isOpen, onClose, onSuccess }) => { {selectedFile && ( @@ -363,7 +370,7 @@ const LogoForm = ({ logo = null, isOpen, onClose, onSuccess }) => { - diff --git a/frontend/src/components/forms/M3UFilters.jsx b/frontend/src/components/forms/M3UFilters.jsx index f8bf9975..acd0c446 100644 --- a/frontend/src/components/forms/M3UFilters.jsx +++ b/frontend/src/components/forms/M3UFilters.jsx @@ -151,6 +151,7 @@ const M3UFilters = ({ playlist, isOpen, onClose }) => { const [deleteTarget, setDeleteTarget] = useState(null); const [filterToDelete, setFilterToDelete] = useState(null); const [filters, setFilters] = useState([]); + const [deleting, setDeleting] = useState(false); const isWarningSuppressed = useWarningsStore((s) => s.isWarningSuppressed); const suppressWarning = useWarningsStore((s) => s.suppressWarning); @@ -192,16 +193,17 @@ const M3UFilters = ({ playlist, isOpen, onClose }) => { const deleteFilter = async (id) => { if (!playlist || !playlist.id) return; + setDeleting(true); try { await API.deleteM3UFilter(playlist.id, id); - setConfirmDeleteOpen(false); + fetchPlaylist(playlist.id); + setFilters(filters.filter((f) => f.id !== id)); } catch (error) { console.error('Error deleting profile:', error); + } finally { + setDeleting(false); setConfirmDeleteOpen(false); } - - fetchPlaylist(playlist.id); - setFilters(filters.filter((f) => f.id !== id)); }; const closeEditor = (updatedPlaylist = null) => { @@ -321,6 +323,7 @@ const M3UFilters = ({ playlist, isOpen, onClose }) => { opened={confirmDeleteOpen} onClose={() => setConfirmDeleteOpen(false)} onConfirm={() => deleteFilter(deleteTarget)} + loading={deleting} title="Confirm Filter Deletion" message={ filterToDelete ? ( diff --git a/frontend/src/components/forms/M3UGroupFilter.jsx b/frontend/src/components/forms/M3UGroupFilter.jsx index 542fc88a..0a7dc224 100644 --- a/frontend/src/components/forms/M3UGroupFilter.jsx +++ b/frontend/src/components/forms/M3UGroupFilter.jsx @@ -1,6 +1,5 @@ // Modal.js import React, { useState, useEffect, forwardRef } from 'react'; -import { useFormik } from 'formik'; import * as Yup from 'yup'; import API from '../../api'; import M3UProfiles from './M3UProfiles'; diff --git a/frontend/src/components/forms/M3UProfile.jsx b/frontend/src/components/forms/M3UProfile.jsx index b225ec38..025d3cae 100644 --- a/frontend/src/components/forms/M3UProfile.jsx +++ b/frontend/src/components/forms/M3UProfile.jsx @@ -1,5 +1,6 @@ -import React, { useState, useEffect } from 'react'; -import { useFormik } from 'formik'; +import React, { useState, useEffect, useMemo } from 'react'; +import { useForm } from 'react-hook-form'; +import { yupResolver } from '@hookform/resolvers/yup'; import * as Yup from 'yup'; import API from '../../api'; import { @@ -31,6 +32,89 @@ const RegexFormAndView = ({ profile = null, m3u, isOpen, onClose }) => { const [sampleInput, setSampleInput] = useState(''); const isDefaultProfile = profile?.is_default; + const defaultValues = useMemo( + () => ({ + name: profile?.name || '', + max_streams: profile?.max_streams || 0, + search_pattern: profile?.search_pattern || '', + replace_pattern: profile?.replace_pattern || '', + notes: profile?.custom_properties?.notes || '', + }), + [profile] + ); + + const schema = Yup.object({ + name: Yup.string().required('Name is required'), + search_pattern: Yup.string().when([], { + is: () => !isDefaultProfile, + then: (schema) => schema.required('Search pattern is required'), + otherwise: (schema) => schema.notRequired(), + }), + replace_pattern: Yup.string().when([], { + is: () => !isDefaultProfile, + then: (schema) => schema.required('Replace pattern is required'), + otherwise: (schema) => schema.notRequired(), + }), + notes: Yup.string(), // Optional field + }); + + const { + register, + handleSubmit, + formState: { errors, isSubmitting }, + reset, + setValue, + watch, + } = useForm({ + defaultValues, + resolver: yupResolver(schema), + }); + + const onSubmit = async (values) => { + console.log('submiting'); + + // For default profiles, only send name and custom_properties (notes) + let submitValues; + if (isDefaultProfile) { + submitValues = { + name: values.name, + custom_properties: { + // Preserve existing custom_properties and add/update notes + ...(profile?.custom_properties || {}), + notes: values.notes || '', + }, + }; + } else { + // For regular profiles, send all fields + submitValues = { + name: values.name, + max_streams: values.max_streams, + search_pattern: values.search_pattern, + replace_pattern: values.replace_pattern, + custom_properties: { + // Preserve existing custom_properties and add/update notes + ...(profile?.custom_properties || {}), + notes: values.notes || '', + }, + }; + } + + if (profile?.id) { + await API.updateM3UProfile(m3u.id, { + id: profile.id, + ...submitValues, + }); + } else { + await API.addM3UProfile(m3u.id, submitValues); + } + + reset(); + // Reset local state to sync with form reset + setSearchPattern(''); + setReplacePattern(''); + onClose(); + }; + useEffect(() => { async function fetchStreamUrl() { try { @@ -79,99 +163,22 @@ const RegexFormAndView = ({ profile = null, m3u, isOpen, onClose }) => { }, [searchPattern, replacePattern]); const onSearchPatternUpdate = (e) => { - formik.handleChange(e); - setSearchPattern(e.target.value); + const value = e.target.value; + setSearchPattern(value); + setValue('search_pattern', value); }; const onReplacePatternUpdate = (e) => { - formik.handleChange(e); - setReplacePattern(e.target.value); + const value = e.target.value; + setReplacePattern(value); + setValue('replace_pattern', value); }; - const formik = useFormik({ - initialValues: { - name: '', - max_streams: 0, - search_pattern: '', - replace_pattern: '', - notes: '', - }, - validationSchema: Yup.object({ - name: Yup.string().required('Name is required'), - search_pattern: Yup.string().when([], { - is: () => !isDefaultProfile, - then: (schema) => schema.required('Search pattern is required'), - otherwise: (schema) => schema.notRequired(), - }), - replace_pattern: Yup.string().when([], { - is: () => !isDefaultProfile, - then: (schema) => schema.required('Replace pattern is required'), - otherwise: (schema) => schema.notRequired(), - }), - notes: Yup.string(), // Optional field - }), - onSubmit: async (values, { setSubmitting, resetForm }) => { - console.log('submiting'); - - // For default profiles, only send name and custom_properties (notes) - let submitValues; - if (isDefaultProfile) { - submitValues = { - name: values.name, - custom_properties: { - // Preserve existing custom_properties and add/update notes - ...(profile?.custom_properties || {}), - notes: values.notes || '', - }, - }; - } else { - // For regular profiles, send all fields - submitValues = { - name: values.name, - max_streams: values.max_streams, - search_pattern: values.search_pattern, - replace_pattern: values.replace_pattern, - custom_properties: { - // Preserve existing custom_properties and add/update notes - ...(profile?.custom_properties || {}), - notes: values.notes || '', - }, - }; - } - - if (profile?.id) { - await API.updateM3UProfile(m3u.id, { - id: profile.id, - ...submitValues, - }); - } else { - await API.addM3UProfile(m3u.id, submitValues); - } - - resetForm(); - // Reset local state to sync with formik reset - setSearchPattern(''); - setReplacePattern(''); - setSubmitting(false); - onClose(); - }, - }); - useEffect(() => { - if (profile) { - setSearchPattern(profile.search_pattern); - setReplacePattern(profile.replace_pattern); - formik.setValues({ - name: profile.name, - max_streams: profile.max_streams, - search_pattern: profile.search_pattern, - replace_pattern: profile.replace_pattern, - notes: profile.custom_properties?.notes || '', - }); - } else { - formik.resetForm(); - } - }, [profile]); // eslint-disable-line react-hooks/exhaustive-deps + reset(defaultValues); + setSearchPattern(profile?.search_pattern || ''); + setReplacePattern(profile?.replace_pattern || ''); + }, [defaultValues, profile, reset]); const handleSampleInputChange = (e) => { setSampleInput(e.target.value); @@ -212,27 +219,21 @@ const RegexFormAndView = ({ profile = null, m3u, isOpen, onClose }) => { } size="lg" > - + {/* Only show max streams field for non-default profiles */} {!isDefaultProfile && ( - formik.setFieldValue('max_streams', value || 0) - } - error={formik.errors.max_streams ? formik.touched.max_streams : ''} + {...register('max_streams')} + value={watch('max_streams')} + onChange={(value) => setValue('max_streams', value || 0)} + error={errors.max_streams?.message} min={0} placeholder="0 = unlimited" /> @@ -242,40 +243,25 @@ const RegexFormAndView = ({ profile = null, m3u, isOpen, onClose }) => { {!isDefaultProfile && ( <> )}