diff --git a/apps/media_servers/api_urls.py b/apps/media_servers/api_urls.py index fc6f1076..b6f5042b 100644 --- a/apps/media_servers/api_urls.py +++ b/apps/media_servers/api_urls.py @@ -4,6 +4,8 @@ from rest_framework.routers import DefaultRouter from apps.media_servers.api_views import ( MediaServerIntegrationViewSet, MediaServerSyncRunViewSet, + OutputIntegrationView, + OutputSTRMExportBuildView, ) app_name = 'media_servers' @@ -36,5 +38,15 @@ urlpatterns = [ MediaServerIntegrationViewSet.as_view({'get': 'plex_auth_servers'}), name='media-server-integration-plex-auth-servers', ), + path( + 'output/integration/', + OutputIntegrationView.as_view(), + name='media-server-output-integration', + ), + path( + 'output/strm-export/build/', + OutputSTRMExportBuildView.as_view(), + name='media-server-output-strm-export-build', + ), ] urlpatterns += router.urls diff --git a/apps/media_servers/api_views.py b/apps/media_servers/api_views.py index 849af30d..4bb1704e 100644 --- a/apps/media_servers/api_views.py +++ b/apps/media_servers/api_views.py @@ -1,5 +1,7 @@ import logging import os +import re +import shutil from urllib.parse import urlencode import uuid @@ -608,3 +610,684 @@ class MediaServerSyncRunViewSet(viewsets.ModelViewSet): ) deleted, _ = queryset.delete() return Response({'deleted': deleted}) + + +# ---- Output Integration Views ---- + +from rest_framework.views import APIView + +from core.models import CoreSettings, LEGACY_OUTPUT_SETTINGS_KEY, OUTPUT_SETTINGS_KEY +from .output_paths import ( + DEFAULT_STRM_OUTPUT_PATH, + normalize_local_path, + normalize_strm_output_path, + paths_overlap, + sanitize_output_settings_paths, +) +from .strm_export import build_strm_nfo_snapshot + +LEGACY_OUTPUT_PROFILE_ID = '__legacy-output__' + + +def _as_bool_output(value, default=False) -> bool: + if isinstance(value, bool): + return value + if isinstance(value, (int, float)): + return bool(value) + if isinstance(value, str): + normalized = value.strip().lower() + if normalized in {'1', 'true', 'yes', 'on'}: + return True + if normalized in {'0', 'false', 'no', 'off', ''}: + return False + return default + + +def _normalize_settings_value(raw_value): + if isinstance(raw_value, dict): + return dict(raw_value) + if isinstance(raw_value, str): + try: + parsed = json.loads(raw_value) + except json.JSONDecodeError: + return {} + return dict(parsed) if isinstance(parsed, dict) else {} + return {} + + +def _get_output_settings_value() -> tuple[CoreSettings | None, dict]: + obj = CoreSettings.objects.filter(key=OUTPUT_SETTINGS_KEY).first() + if obj is None: + obj = CoreSettings.objects.filter(key=LEGACY_OUTPUT_SETTINGS_KEY).first() + if obj is None: + return None, {} + + settings_value = _normalize_settings_value(obj.value) + sanitized_value, changed = sanitize_output_settings_paths(settings_value) + if changed: + obj.value = sanitized_value + obj.save(update_fields=['value']) + if obj.key == LEGACY_OUTPUT_SETTINGS_KEY: + obj = _save_output_settings_value(sanitized_value, obj=obj) + return obj, sanitized_value + + +def _save_output_settings_value(raw_value: dict, obj: CoreSettings | None = None) -> CoreSettings: + safe_value = raw_value if isinstance(raw_value, dict) else {} + output_settings, _ = CoreSettings.objects.update_or_create( + key=OUTPUT_SETTINGS_KEY, + defaults={'name': 'Output Settings', 'value': safe_value}, + ) + CoreSettings.objects.filter(key=LEGACY_OUTPUT_SETTINGS_KEY).exclude( + pk=output_settings.pk + ).delete() + return output_settings + + +def _resolve_backend_base_url(request, settings_value: dict) -> str: + configured = str((settings_value or {}).get('backend_base_url') or '').strip() + if configured.startswith(('http://', 'https://')): + return configured.rstrip('/') + return request.build_absolute_uri('/').rstrip('/') + + +def _resolve_strm_output_path(request, settings_value: dict, payload_path: str = '') -> str: + raw_path = str(payload_path or '').strip() + if not raw_path: + raw_path = str((settings_value or {}).get('strm_output_path') or '').strip() + return normalize_strm_output_path(raw_path, fallback=DEFAULT_STRM_OUTPUT_PATH) + + +def _normalize_output_target_provider(raw_value) -> str: + provider = str(raw_value or '').strip().lower() + if provider in {'emby', 'jellyfin', 'jellyfin_emby'}: + return 'jellyfin_emby' + return 'jellyfin_emby' + + +def _normalize_output_profile(raw_profile: dict) -> dict: + profile = raw_profile if isinstance(raw_profile, dict) else {} + profile_id = str(profile.get('id') or profile.get('integration_id') or '').strip() + if not profile_id: + profile_id = f'output-{uuid.uuid4()}' + + return { + 'id': profile_id, + 'integration_name': str(profile.get('integration_name') or '').strip() + or 'Dispatcharr Output', + 'target_provider': _normalize_output_target_provider(profile.get('target_provider')), + 'export_mode': 'strm_nfo', + 'export_enabled': _as_bool_output(profile.get('export_enabled'), default=True), + 'target_base_url': '', + 'target_api_token': '', + 'target_verify_ssl': True, + 'strm_output_path': normalize_strm_output_path( + profile.get('strm_output_path'), + fallback=DEFAULT_STRM_OUTPUT_PATH, + ), + 'strm_include_nfo': _as_bool_output(profile.get('strm_include_nfo'), default=True), + 'strm_last_built_at': str(profile.get('strm_last_built_at') or '').strip(), + 'strm_last_build_summary': profile.get('strm_last_build_summary') + if isinstance(profile.get('strm_last_build_summary'), dict) + else None, + 'updated_at': str(profile.get('updated_at') or '').strip(), + } + + +def _legacy_output_configured(settings_value: dict) -> bool: + raw = settings_value if isinstance(settings_value, dict) else {} + return bool( + _as_bool_output(raw.get('export_enabled'), default=False) + or str(raw.get('updated_at') or '').strip() + or str(raw.get('strm_last_built_at') or '').strip() + or str(raw.get('strm_output_path') or '').strip() + or str(raw.get('integration_name') or '').strip() + ) + + +def _build_legacy_output_profile(settings_value: dict) -> dict: + raw = settings_value if isinstance(settings_value, dict) else {} + return _normalize_output_profile( + { + 'id': LEGACY_OUTPUT_PROFILE_ID, + 'integration_name': str(raw.get('integration_name') or '').strip() + or 'Dispatcharr Output', + 'target_provider': raw.get('target_provider'), + 'export_mode': raw.get('export_mode'), + 'export_enabled': _as_bool_output(raw.get('export_enabled'), default=True), + 'strm_output_path': normalize_strm_output_path( + raw.get('strm_output_path'), + fallback=DEFAULT_STRM_OUTPUT_PATH, + ), + 'strm_include_nfo': _as_bool_output(raw.get('strm_include_nfo'), default=True), + 'strm_last_built_at': str(raw.get('strm_last_built_at') or '').strip(), + 'strm_last_build_summary': raw.get('strm_last_build_summary'), + 'updated_at': str(raw.get('updated_at') or '').strip(), + } + ) + + +def _extract_output_profiles(settings_value: dict) -> list[dict]: + raw = settings_value if isinstance(settings_value, dict) else {} + raw_profiles = raw.get('output_integrations') + if isinstance(raw_profiles, list): + profiles = [] + used_ids = set() + for raw_profile in raw_profiles: + if not isinstance(raw_profile, dict): + continue + normalized = _normalize_output_profile(raw_profile) + profile_id = str(normalized.get('id') or '').strip() + if not profile_id or profile_id in used_ids: + continue + used_ids.add(profile_id) + profiles.append(normalized) + if profiles: + return profiles + + if _legacy_output_configured(raw): + return [_build_legacy_output_profile(raw)] + return [] + + +def _legacy_fields_from_output_profile(profile: dict | None) -> dict: + if not profile: + return _clear_output_integration_settings({}) + + return { + 'integration_name': str(profile.get('integration_name') or '').strip() + or 'Dispatcharr Output', + 'target_provider': _normalize_output_target_provider(profile.get('target_provider')), + 'export_mode': 'strm_nfo', + 'export_enabled': _as_bool_output(profile.get('export_enabled'), default=True), + 'target_base_url': '', + 'target_api_token': '', + 'target_verify_ssl': True, + 'strm_output_path': normalize_strm_output_path( + profile.get('strm_output_path'), + fallback=DEFAULT_STRM_OUTPUT_PATH, + ), + 'strm_include_nfo': _as_bool_output(profile.get('strm_include_nfo'), default=True), + 'strm_last_built_at': str(profile.get('strm_last_built_at') or '').strip(), + 'strm_last_build_summary': ( + profile.get('strm_last_build_summary') + if isinstance(profile.get('strm_last_build_summary'), dict) + else None + ), + 'updated_at': str(profile.get('updated_at') or '').strip(), + } + + +def _persist_output_profiles(settings_value: dict, profiles: list[dict]) -> dict: + base = dict(settings_value or {}) + if profiles: + normalized_profiles = [ + _normalize_output_profile(profile) + for profile in profiles + if isinstance(profile, dict) + ] + if not normalized_profiles: + cleared = _clear_output_integration_settings(base) + cleared['output_integrations'] = [] + return cleared + + primary = normalized_profiles[0] + legacy_fields = _legacy_fields_from_output_profile(primary) + base.update(legacy_fields) + base['output_integrations'] = normalized_profiles + base.pop('scan_target_server_url', None) + base.pop('scan_target_server_token', None) + base.pop('scan_target_server_verify_ssl', None) + base.pop('scan_control_enabled', None) + base.pop('scan_control_integration_id', None) + base.pop('scan_control_schedule_time', None) + base.pop('scan_control_library_ids', None) + base.pop('scan_control_timeout_seconds', None) + base.pop('scan_control_wait_for_idle_seconds', None) + base.pop('scan_last_run_at', None) + base.pop('scan_last_status', None) + base.pop('scan_last_message', None) + base.pop('scan_last_summary', None) + base.pop('strm_client_output_path', None) + return base + + cleared = _clear_output_integration_settings(base) + cleared['output_integrations'] = [] + return cleared + + +def _find_output_profile(profiles: list[dict], integration_id: str) -> tuple[int, dict | None]: + target_id = str(integration_id or '').strip() + if not target_id: + return -1, None + for idx, profile in enumerate(profiles): + if str(profile.get('id') or '').strip() == target_id: + return idx, profile + return -1, None + + +def _find_integrations_referencing_strm_output(output_root: str) -> list[dict]: + target = normalize_local_path(output_root) + if not target: + return [] + + matches = [] + queryset = MediaServerIntegration.objects.filter( + enabled=True, + provider_type=MediaServerIntegration.ProviderTypes.LOCAL, + ).only('id', 'name', 'provider_config') + + for integration in queryset: + provider_config = ( + integration.provider_config + if isinstance(integration.provider_config, dict) + else {} + ) + locations = provider_config.get('locations', []) + if not isinstance(locations, list): + continue + + for entry in locations: + if not isinstance(entry, dict): + continue + location_path = str(entry.get('path') or '').strip() + if not location_path: + continue + if not paths_overlap(location_path, target): + continue + matches.append( + { + 'integration_id': integration.id, + 'integration_name': integration.name, + 'path': location_path, + } + ) + return matches + + +def _find_output_profiles_referencing_strm_output( + settings_value: dict, + output_root: str, + *, + exclude_profile_id: str = '', +) -> list[dict]: + target = normalize_local_path(output_root) + if not target: + return [] + + excluded = str(exclude_profile_id or '').strip() + matches = [] + profiles = _extract_output_profiles(settings_value) + + for profile in profiles: + profile_id = str(profile.get('id') or '').strip() + if excluded and profile_id == excluded: + continue + + profile_output_path = str(profile.get('strm_output_path') or '').strip() + if not profile_output_path: + profile_output_path = DEFAULT_STRM_OUTPUT_PATH + if not paths_overlap(profile_output_path, target): + continue + + matches.append( + { + 'integration_id': profile_id, + 'integration_name': str( + profile.get('integration_name') or 'Dispatcharr Output' + ).strip() + or 'Dispatcharr Output', + 'path': profile_output_path, + } + ) + + return matches + + +def _delete_strm_output_files(output_root: str) -> dict: + root = normalize_local_path(output_root) + if not root: + return {'deleted_paths': [], 'removed_root': False} + + deleted_paths: list[str] = [] + for child in ('Movies', 'TV Shows'): + target = os.path.join(root, child) + if os.path.isdir(target): + shutil.rmtree(target) + deleted_paths.append(target) + + removed_root = False + if os.path.isdir(root): + try: + if not os.listdir(root): + os.rmdir(root) + removed_root = True + except OSError: + removed_root = False + + return { + 'deleted_paths': deleted_paths, + 'removed_root': removed_root, + } + + +def _clear_output_integration_settings(raw_settings: dict) -> dict: + cleaned = dict(raw_settings or {}) + cleaned.update( + { + 'integration_name': '', + 'target_provider': '', + 'export_mode': '', + 'export_enabled': False, + 'target_base_url': '', + 'target_api_token': '', + 'target_verify_ssl': True, + 'strm_output_path': DEFAULT_STRM_OUTPUT_PATH, + 'strm_include_nfo': True, + 'strm_last_built_at': '', + 'strm_last_build_summary': None, + 'updated_at': '', + } + ) + cleaned.pop('scan_control_enabled', None) + cleaned.pop('scan_control_integration_id', None) + cleaned.pop('scan_control_schedule_time', None) + cleaned.pop('scan_control_library_ids', None) + cleaned.pop('scan_control_timeout_seconds', None) + cleaned.pop('scan_control_wait_for_idle_seconds', None) + cleaned.pop('scan_last_run_at', None) + cleaned.pop('scan_last_status', None) + cleaned.pop('scan_last_message', None) + cleaned.pop('scan_last_summary', None) + cleaned.pop('scan_target_server_url', None) + cleaned.pop('scan_target_server_token', None) + cleaned.pop('scan_target_server_verify_ssl', None) + cleaned.pop('strm_client_output_path', None) + cleaned['output_integrations'] = [] + return cleaned + + +class OutputSTRMExportBuildView(APIView): + """ + Build STRM/NFO files to a server-side folder for Docker bind-mount usage. + """ + + permission_classes = [IsAdmin] + + def post(self, request): + settings_obj, raw_settings = _get_output_settings_value() + settings_value = dict(raw_settings or {}) + integration_id = str(request.data.get('integration_id') or '').strip() + profiles = _extract_output_profiles(settings_value) + has_output_path_override = bool(str(request.data.get('output_path') or '').strip()) + has_include_nfo_override = 'include_nfo' in request.data + has_explicit_overrides = has_output_path_override or has_include_nfo_override + + selected_profile = None + selected_profile_index = -1 + if profiles: + if integration_id: + selected_profile_index, selected_profile = _find_output_profile( + profiles, + integration_id, + ) + if selected_profile is None: + return Response( + {'detail': 'Output integration not found.'}, + status=status.HTTP_404_NOT_FOUND, + ) + elif not has_explicit_overrides: + selected_profile_index = 0 + selected_profile = profiles[0] + elif integration_id: + return Response( + {'detail': 'Output integration not found.'}, + status=status.HTTP_404_NOT_FOUND, + ) + + profile_value = selected_profile if isinstance(selected_profile, dict) else {} + include_nfo = _as_bool_output( + request.data.get('include_nfo'), + default=_as_bool_output( + profile_value.get('strm_include_nfo'), + default=_as_bool_output(settings_value.get('strm_include_nfo'), default=True), + ), + ) + profile_output_path = normalize_strm_output_path( + profile_value.get('strm_output_path'), + fallback=DEFAULT_STRM_OUTPUT_PATH, + ) + output_path = _resolve_strm_output_path( + request, + settings_value, + payload_path=( + str(request.data.get('output_path', '')).strip() or profile_output_path + ), + ) + base_url = _resolve_backend_base_url(request, settings_value) + + try: + os.makedirs(output_path, exist_ok=True) + except PermissionError: + return Response( + { + 'detail': ( + f'Output path is not writable: {output_path}. ' + 'Use a writable path inside the container (recommended: /data/media/strm), ' + 'or mount and grant write permission to your custom path.' + ) + }, + status=status.HTTP_400_BAD_REQUEST, + ) + except OSError as exc: + return Response( + {'detail': f'Unable to prepare output path {output_path}: {exc}'}, + status=status.HTTP_400_BAD_REQUEST, + ) + + try: + summary = build_strm_nfo_snapshot( + output_path, + base_url=base_url, + include_nfo=include_nfo, + ) + except PermissionError: + return Response( + { + 'detail': ( + f'Output path is not writable: {output_path}. ' + 'Use a writable path inside the container (recommended: /data/media/strm), ' + 'or mount and grant write permission to your custom path.' + ) + }, + status=status.HTTP_400_BAD_REQUEST, + ) + except Exception as exc: + logger.exception('STRM/NFO export build failed') + return Response( + {'detail': f'Failed to build STRM/NFO export: {exc}'}, + status=status.HTTP_500_INTERNAL_SERVER_ERROR, + ) + + now_iso = timezone.now().isoformat() + if selected_profile: + updated_profile = dict(selected_profile) + updated_profile.update( + { + 'strm_output_path': output_path, + 'strm_include_nfo': include_nfo, + 'strm_last_built_at': now_iso, + 'strm_last_build_summary': summary, + 'updated_at': now_iso, + } + ) + profiles[selected_profile_index] = updated_profile + merged_settings = _persist_output_profiles(settings_value, profiles) + elif not profiles: + merged_settings = dict(settings_value) + merged_settings.update( + { + 'strm_output_path': output_path, + 'strm_include_nfo': include_nfo, + 'strm_last_built_at': now_iso, + 'strm_last_build_summary': summary, + } + ) + else: + merged_settings = dict(settings_value) + + if merged_settings != settings_value or settings_obj is None: + _save_output_settings_value(merged_settings, obj=settings_obj) + + response_payload = { + 'success': True, + 'message': 'STRM/NFO export snapshot generated.', + **summary, + } + if selected_profile: + response_payload['integration_id'] = str(selected_profile.get('id') or '') + return Response(response_payload) + + +class OutputIntegrationView(APIView): + """ + Delete the saved output integration profile and clean STRM export files. + """ + + permission_classes = [IsAdmin] + + def get(self, request): + settings_obj, settings_value = _get_output_settings_value() + profiles = _extract_output_profiles(settings_value) + setting_payload = None + if settings_obj is not None: + setting_payload = { + 'id': settings_obj.id, + 'key': settings_obj.key, + 'name': settings_obj.name, + } + return Response( + { + 'setting': setting_payload, + 'value': settings_value, + 'profiles': profiles, + } + ) + + def delete(self, request): + settings_obj, raw_settings = _get_output_settings_value() + settings_value = dict(raw_settings or {}) + payload = request.data if hasattr(request, 'data') else {} + delete_strm_files = _as_bool_output( + payload.get('delete_strm_files'), + default=True, + ) + integration_id = str(payload.get('integration_id') or '').strip() + + profiles = _extract_output_profiles(settings_value) + selected_profile = None + selected_profile_index = -1 + if profiles: + if integration_id: + selected_profile_index, selected_profile = _find_output_profile( + profiles, + integration_id, + ) + if selected_profile is None: + return Response( + {'detail': 'Output integration not found.'}, + status=status.HTTP_404_NOT_FOUND, + ) + else: + selected_profile_index = 0 + selected_profile = profiles[0] + elif integration_id: + return Response( + {'detail': 'Output integration not found.'}, + status=status.HTTP_404_NOT_FOUND, + ) + + selected_profile_id = ( + str(selected_profile.get('id') or '').strip() + if selected_profile + else '' + ) + + cleanup = { + 'attempted': False, + 'deleted': False, + 'deleted_paths': [], + 'removed_root': False, + 'skipped': False, + 'skip_reason': '', + 'in_use_by': [], + 'output_path': '', + } + + if delete_strm_files and selected_profile: + output_path = _resolve_strm_output_path( + request, + settings_value, + payload_path=( + str(selected_profile.get('strm_output_path') or '').strip() + if selected_profile + else '' + ), + ) + cleanup['attempted'] = True + cleanup['output_path'] = output_path + + in_use_by_local = _find_integrations_referencing_strm_output(output_path) + in_use_by_outputs = _find_output_profiles_referencing_strm_output( + settings_value, + output_path, + exclude_profile_id=selected_profile_id, + ) + + if in_use_by_local or in_use_by_outputs: + cleanup['skipped'] = True + if in_use_by_outputs: + cleanup['skip_reason'] = ( + 'STRM cleanup skipped because another output integration ' + 'references this path.' + ) + else: + cleanup['skip_reason'] = ( + 'STRM cleanup skipped because another enabled local integration ' + 'references this path.' + ) + cleanup['in_use_by'] = [*in_use_by_local, *in_use_by_outputs] + else: + try: + deleted_result = _delete_strm_output_files(output_path) + except Exception as exc: + logger.exception('Failed to delete STRM output files', exc_info=exc) + return Response( + {'detail': f'Failed to delete STRM output files: {exc}'}, + status=status.HTTP_500_INTERNAL_SERVER_ERROR, + ) + + cleanup['deleted_paths'] = deleted_result.get('deleted_paths', []) + cleanup['removed_root'] = bool(deleted_result.get('removed_root')) + cleanup['deleted'] = bool(cleanup['deleted_paths']) + + if profiles and selected_profile: + remaining_profiles = [ + profile + for idx, profile in enumerate(profiles) + if idx != selected_profile_index + ] + next_settings = _persist_output_profiles(settings_value, remaining_profiles) + else: + next_settings = _clear_output_integration_settings(settings_value) + + _save_output_settings_value(next_settings, obj=settings_obj) + + response_payload = { + 'success': True, + 'message': 'Output integration deleted.', + 'strm_cleanup': cleanup, + } + if selected_profile_id: + response_payload['deleted_integration_id'] = selected_profile_id + return Response(response_payload) diff --git a/apps/media_servers/output_paths.py b/apps/media_servers/output_paths.py new file mode 100644 index 00000000..848d0c34 --- /dev/null +++ b/apps/media_servers/output_paths.py @@ -0,0 +1,85 @@ +from __future__ import annotations + +import os + +DEFAULT_STRM_OUTPUT_PATH = "/data/media/strm" + +_LEGACY_MEDIA_ROOT = "/data/media" +_LEGACY_MOVIES_PATH = "/data/media/Movies" +_LEGACY_TV_PATH = "/data/media/TV Shows" + + +def normalize_local_path(path_value: str) -> str: + raw = str(path_value or "").strip() + if not raw: + return "" + return os.path.realpath(os.path.abspath(os.path.expanduser(raw))) + + +def normalize_strm_output_path( + path_value: str, + *, + fallback: str = DEFAULT_STRM_OUTPUT_PATH, +) -> str: + normalized = normalize_local_path(path_value) + if not normalized: + normalized = normalize_local_path(fallback) + + if normalized in { + normalize_local_path(_LEGACY_MEDIA_ROOT), + normalize_local_path(_LEGACY_MOVIES_PATH), + normalize_local_path(_LEGACY_TV_PATH), + }: + return normalize_local_path(DEFAULT_STRM_OUTPUT_PATH) + return normalized + + +def paths_overlap(path_a: str, path_b: str) -> bool: + left = normalize_local_path(path_a) + right = normalize_local_path(path_b) + if not left or not right: + return False + try: + common = os.path.commonpath([left, right]) + except ValueError: + return False + return common == left or common == right + + +def sanitize_output_settings_paths(settings_value: dict) -> tuple[dict, bool]: + if not isinstance(settings_value, dict): + return {}, False + + next_value = dict(settings_value) + changed = False + + strm_output_path = normalize_strm_output_path(next_value.get("strm_output_path")) + if str(next_value.get("strm_output_path") or "").strip() != strm_output_path: + next_value["strm_output_path"] = strm_output_path + changed = True + + raw_profiles = next_value.get("output_integrations") + if isinstance(raw_profiles, list): + normalized_profiles = [] + profiles_changed = False + for raw_profile in raw_profiles: + if not isinstance(raw_profile, dict): + normalized_profiles.append(raw_profile) + continue + profile = dict(raw_profile) + profile_strm_output_path = normalize_strm_output_path( + profile.get("strm_output_path"), + fallback=strm_output_path, + ) + if ( + str(profile.get("strm_output_path") or "").strip() + != profile_strm_output_path + ): + profile["strm_output_path"] = profile_strm_output_path + profiles_changed = True + normalized_profiles.append(profile) + if profiles_changed: + next_value["output_integrations"] = normalized_profiles + changed = True + + return next_value, changed diff --git a/apps/media_servers/providers.py b/apps/media_servers/providers.py index ad56a7ff..6b884d7e 100644 --- a/apps/media_servers/providers.py +++ b/apps/media_servers/providers.py @@ -1,5 +1,6 @@ import hashlib import os +import xml.etree.ElementTree as ET from dataclasses import dataclass from typing import Iterable, Optional from urllib.parse import urlencode, urljoin, urlparse @@ -258,18 +259,17 @@ class PlexClient(BaseMediaServerClient): return payload def ping(self) -> None: - self._get_json( + self._get_sections_payload() + + def _get_sections_payload(self) -> dict: + return self._get_json( '/library/sections', params=self._with_token(), headers={'Accept': 'application/json'}, ) def list_libraries(self) -> list[ProviderLibrary]: - payload = self._get_json( - '/library/sections', - params=self._with_token(), - headers={'Accept': 'application/json'}, - ) + payload = self._get_sections_payload() directories = (payload.get('MediaContainer') or {}).get('Directory') or [] libraries = [] for entry in directories: @@ -288,6 +288,135 @@ class PlexClient(BaseMediaServerClient): ) return libraries + @staticmethod + def _parse_refreshing_flag(value) -> bool: + if isinstance(value, bool): + return value + if isinstance(value, (int, float)): + return int(value) != 0 + normalized = str(value or '').strip().lower() + return normalized in {'1', 'true', 'yes', 'on'} + + def _get_sections_xml(self) -> Optional[ET.Element]: + response = self.session.get( + self._build_url('/library/sections'), + params=self._with_token(), + headers={'Accept': 'application/xml,text/xml;q=0.9,*/*;q=0.8'}, + timeout=self.timeout_seconds, + verify=self.verify_ssl, + ) + response.raise_for_status() + try: + return ET.fromstring(response.content) + except ET.ParseError: + return None + + def trigger_library_scan(self, library_ids: Optional[list[str]] = None) -> dict: + requested_ids = [ + str(value).strip() + for value in (library_ids or []) + if str(value).strip() + ] + if not requested_ids: + requested_ids = [library.id for library in self.list_libraries()] + + triggered_ids = [] + for library_id in requested_ids: + last_error = None + for method in ('get', 'put'): + try: + response = self.session.request( + method.upper(), + self._build_url(f'/library/sections/{library_id}/refresh'), + params=self._with_token(), + headers={'Accept': 'application/json'}, + timeout=self.timeout_seconds, + verify=self.verify_ssl, + ) + if response.status_code < 400: + triggered_ids.append(library_id) + last_error = None + break + response.raise_for_status() + except requests.RequestException as exc: + last_error = exc + if last_error is not None: + raise last_error + + return { + 'requested_library_ids': requested_ids, + 'triggered_library_ids': triggered_ids, + 'triggered_count': len(triggered_ids), + } + + def get_library_refresh_state(self, library_ids: Optional[list[str]] = None) -> dict: + requested_ids = { + str(value).strip() + for value in (library_ids or []) + if str(value).strip() + } + payload = self._get_sections_payload() + directories = (payload.get('MediaContainer') or {}).get('Directory') or [] + libraries = [] + unknown_state = True + + for entry in directories: + library_id = str(entry.get('key') or '').strip() + if not library_id: + continue + if requested_ids and library_id not in requested_ids: + continue + raw_refreshing = ( + entry.get('refreshing') + if isinstance(entry, dict) + else None + ) + if raw_refreshing is None and isinstance(entry, dict): + raw_refreshing = entry.get('Refreshing') + if raw_refreshing is None and isinstance(entry, dict): + raw_refreshing = entry.get('scanning') + refreshing = self._parse_refreshing_flag(raw_refreshing) + if raw_refreshing is not None: + unknown_state = False + libraries.append( + { + 'id': library_id, + 'name': str(entry.get('title') or '').strip(), + 'refreshing': refreshing, + } + ) + + if unknown_state: + xml_root = self._get_sections_xml() + if xml_root is not None: + libraries = [] + for element in xml_root.findall('.//Directory'): + library_id = str(element.attrib.get('key') or '').strip() + if not library_id: + continue + if requested_ids and library_id not in requested_ids: + continue + refreshing = self._parse_refreshing_flag( + element.attrib.get('refreshing') + or element.attrib.get('scanning') + ) + libraries.append( + { + 'id': library_id, + 'name': str(element.attrib.get('title') or '').strip(), + 'refreshing': refreshing, + } + ) + + refreshing_library_ids = [ + library['id'] for library in libraries if library.get('refreshing') + ] + return { + 'libraries': libraries, + 'refreshing_library_ids': refreshing_library_ids, + 'any_refreshing': bool(refreshing_library_ids), + } + def iter_movies(self, libraries: list[ProviderLibrary]) -> Iterable[ProviderMovie]: for library in libraries: if library.content_type not in {'movie', 'mixed'}: diff --git a/apps/media_servers/strm_export.py b/apps/media_servers/strm_export.py new file mode 100644 index 00000000..44c437bb --- /dev/null +++ b/apps/media_servers/strm_export.py @@ -0,0 +1,612 @@ +from __future__ import annotations + +import json +import re +import shutil +import xml.etree.ElementTree as ET +from pathlib import Path +from typing import Any, Iterable, Optional + +from django.db.models import Prefetch +from django.urls import reverse + +from apps.vod.models import ( + Episode, + M3UEpisodeRelation, + M3UMovieRelation, + M3USeriesRelation, + Movie, + Series, +) + +INVALID_FILENAME_CHARS = re.compile(r'[<>:"/\\|?*\x00-\x1F]') +MAX_FILENAME_LEN = 240 +MAX_METADATA_FIELDS = 300 +MAX_METADATA_KEY_LEN = 128 +MAX_METADATA_VALUE_LEN = 4096 + + +def _sanitize_filename(value: Any, fallback: str = "item") -> str: + text = str(value or "").strip() + text = INVALID_FILENAME_CHARS.sub("_", text) + text = " ".join(text.split()) + text = text.rstrip(".") + if not text: + text = fallback + if len(text) > MAX_FILENAME_LEN: + text = text[:MAX_FILENAME_LEN].rstrip() + if not text: + text = fallback + return text + + +def _safe_int(value: Any) -> Optional[int]: + try: + if value is None or value == "": + return None + return int(value) + except (TypeError, ValueError): + return None + + +def _best_relation(relations: Iterable[Any]): + candidates = [] + for relation in relations or []: + account = getattr(relation, "m3u_account", None) + if not account or not bool(getattr(account, "is_active", False)): + continue + candidates.append(relation) + if not candidates: + return None + return sorted( + candidates, + key=lambda rel: (-(getattr(rel.m3u_account, "priority", 0) or 0), rel.id), + )[0] + + +def _relation_list(relations: Any) -> list[Any]: + if relations is None: + return [] + if hasattr(relations, "all"): + return list(relations.all()) + if isinstance(relations, (list, tuple, set)): + return list(relations) + return [] + + +def _read_dict(value: Any) -> dict: + return value if isinstance(value, dict) else {} + + +def _first_non_empty(*values: Any) -> Any: + for value in values: + if value is None: + continue + if isinstance(value, str) and not value.strip(): + continue + if isinstance(value, (list, tuple, dict)) and not value: + continue + return value + return None + + +def _stringify(value: Any) -> str: + if value is None: + return "" + if isinstance(value, str): + return value.strip() + return str(value).strip() + + +def _as_text_list(value: Any) -> list[str]: + if value is None: + return [] + if isinstance(value, (list, tuple, set)): + output = [] + for item in value: + output.extend(_as_text_list(item)) + return [item for item in output if item] + if isinstance(value, dict): + parts = [] + for key in ("name", "title", "value"): + text = _stringify(value.get(key)) + if text: + parts.append(text) + break + return parts + raw = _stringify(value) + if not raw: + return [] + if "," in raw: + return [part.strip() for part in raw.split(",") if part.strip()] + return [raw] + + +def _duration_minutes(seconds_value: Any) -> Optional[int]: + seconds = _safe_int(seconds_value) + if seconds is None or seconds <= 0: + return None + return max(1, int(round(seconds / 60.0))) + + +def _add_text(parent: ET.Element, tag: str, value: Any) -> None: + text = _stringify(value) + if text: + ET.SubElement(parent, tag).text = text + + +def _add_genres(parent: ET.Element, raw_genre: Any) -> None: + for genre in _as_text_list(raw_genre): + _add_text(parent, "genre", genre) + + +def _add_actors(parent: ET.Element, actors_value: Any) -> None: + actors = _as_text_list(actors_value) + for actor_name in actors: + actor_node = ET.SubElement(parent, "actor") + _add_text(actor_node, "name", actor_name) + + +def _add_unique_ids(parent: ET.Element, imdb_id: Any, tmdb_id: Any) -> None: + imdb_text = _stringify(imdb_id) + tmdb_text = _stringify(tmdb_id) + if imdb_text: + node = ET.SubElement(parent, "uniqueid", {"type": "imdb", "default": "true"}) + node.text = imdb_text + _add_text(parent, "imdbid", imdb_text) + if tmdb_text: + node = ET.SubElement(parent, "uniqueid", {"type": "tmdb", "default": "false"}) + node.text = tmdb_text + _add_text(parent, "tmdbid", tmdb_text) + + +def _compact_json(value: Any) -> str: + try: + return json.dumps(value, ensure_ascii=False, separators=(",", ":")) + except Exception: + return _stringify(value) + + +def _flatten_metadata( + key_prefix: str, + value: Any, + destination: dict[str, str], +) -> None: + if len(destination) >= MAX_METADATA_FIELDS: + return + + key = _stringify(key_prefix) + if not key: + return + + if value is None: + return + + if isinstance(value, dict): + for child_key, child_value in value.items(): + if len(destination) >= MAX_METADATA_FIELDS: + break + child_name = _stringify(child_key) + if not child_name: + continue + _flatten_metadata(f"{key}.{child_name}", child_value, destination) + return + + if isinstance(value, (list, tuple, set)): + values = _as_text_list(value) + if values: + destination[key[:MAX_METADATA_KEY_LEN]] = ", ".join(values)[ + :MAX_METADATA_VALUE_LEN + ] + else: + compact = _compact_json(value) + if compact: + destination[key[:MAX_METADATA_KEY_LEN]] = compact[ + :MAX_METADATA_VALUE_LEN + ] + return + + text = _stringify(value) + if not text and isinstance(value, (int, float, bool)): + text = str(value) + if text: + destination[key[:MAX_METADATA_KEY_LEN]] = text[:MAX_METADATA_VALUE_LEN] + + +def _add_dispatcharr_metadata(parent: ET.Element, **sources: Any) -> None: + flattened: dict[str, str] = {} + for source_name, source_value in sources.items(): + if not source_value: + continue + _flatten_metadata(source_name, source_value, flattened) + if len(flattened) >= MAX_METADATA_FIELDS: + break + + if not flattened: + return + + metadata_node = ET.SubElement(parent, "dispatcharr_metadata") + for key in sorted(flattened.keys()): + value = _stringify(flattened.get(key)) + if not value: + continue + field_node = ET.SubElement(metadata_node, "field", {"name": key}) + field_node.text = value + + +def _to_xml_text(root: ET.Element) -> str: + try: + ET.indent(root, space=" ") # Python 3.9+ + except Exception: + pass + xml_bytes = ET.tostring(root, encoding="utf-8") + return "\n" + xml_bytes.decode("utf-8") + + +def _build_movie_nfo(movie: Movie, relation: Optional[M3UMovieRelation]) -> str: + movie_props = _read_dict(movie.custom_properties) + relation_props = _read_dict(getattr(relation, "custom_properties", None)) + detailed = _read_dict(relation_props.get("detailed_info")) + movie_data = _read_dict(relation_props.get("movie_data")) + + title = _first_non_empty(detailed.get("name"), movie.name) + plot = _first_non_empty(detailed.get("plot"), detailed.get("description"), movie.description) + year = _first_non_empty(movie.year, detailed.get("year")) + release_date = _first_non_empty( + movie_props.get("release_date"), + detailed.get("release_date"), + detailed.get("releasedate"), + ) + rating = _first_non_empty(movie.rating, detailed.get("rating")) + genre = _first_non_empty(movie.genre, detailed.get("genre")) + director = _first_non_empty(movie_props.get("director"), detailed.get("director")) + writer = _first_non_empty( + movie_props.get("writer"), + movie_props.get("credits"), + detailed.get("writer"), + detailed.get("credits"), + ) + actors = _first_non_empty( + movie_props.get("actors"), + movie_props.get("cast"), + detailed.get("actors"), + detailed.get("cast"), + ) + country = _first_non_empty(movie_props.get("country"), detailed.get("country")) + trailer = _first_non_empty( + movie_props.get("youtube_trailer"), + detailed.get("youtube_trailer"), + detailed.get("trailer"), + ) + studio = _first_non_empty(movie_props.get("studio"), detailed.get("studio")) + mpaa = _first_non_empty(movie_props.get("age"), detailed.get("age")) + tagline = _first_non_empty(movie_props.get("tagline"), detailed.get("tagline")) + set_name = _first_non_empty(movie_props.get("set"), detailed.get("set")) + runtime = _duration_minutes(_first_non_empty(movie.duration_secs, detailed.get("duration_secs"))) + poster = _first_non_empty( + detailed.get("cover_big"), + detailed.get("movie_image"), + getattr(movie.logo, "url", None), + ) + backdrop = _first_non_empty(movie_props.get("backdrop_path"), detailed.get("backdrop_path")) + + root = ET.Element("movie") + _add_text(root, "title", title) + _add_text(root, "originaltitle", title) + _add_text(root, "plot", plot) + _add_text(root, "outline", plot) + _add_text(root, "tagline", tagline) + _add_text(root, "year", year) + _add_text(root, "premiered", release_date) + _add_text(root, "releasedate", release_date) + _add_text(root, "rating", rating) + _add_genres(root, genre) + _add_text(root, "director", director) + _add_text(root, "credits", writer) + _add_text(root, "country", country) + _add_text(root, "studio", studio) + _add_text(root, "trailer", trailer) + _add_text(root, "mpaa", mpaa) + _add_text(root, "set", set_name) + _add_text(root, "runtime", runtime) + _add_text(root, "thumb", poster) + _add_actors(root, actors) + backdrop_values = _as_text_list(backdrop) + if backdrop_values: + fanart = ET.SubElement(root, "fanart") + _add_text(fanart, "thumb", backdrop_values[0]) + _add_unique_ids(root, movie.imdb_id, movie.tmdb_id) + _add_dispatcharr_metadata( + root, + movie_custom_properties=movie_props, + relation_custom_properties=relation_props, + relation_movie_data=movie_data, + relation_detailed_info=detailed, + ) + return _to_xml_text(root) + + +def _build_tvshow_nfo(series: Series, relation: Optional[M3USeriesRelation]) -> str: + series_props = _read_dict(series.custom_properties) + relation_props = _read_dict(getattr(relation, "custom_properties", None)) + detailed = _read_dict(relation_props.get("detailed_info")) + + title = _first_non_empty(detailed.get("name"), series.name) + plot = _first_non_empty(detailed.get("plot"), detailed.get("description"), series.description) + year = _first_non_empty(series.year, detailed.get("year")) + release_date = _first_non_empty( + series_props.get("release_date"), + detailed.get("release_date"), + detailed.get("releasedate"), + ) + rating = _first_non_empty(series.rating, detailed.get("rating")) + genre = _first_non_empty(series.genre, detailed.get("genre")) + director = _first_non_empty(series_props.get("director"), detailed.get("director")) + writer = _first_non_empty( + series_props.get("writer"), + series_props.get("credits"), + detailed.get("writer"), + detailed.get("credits"), + ) + cast = _first_non_empty(series_props.get("cast"), detailed.get("cast")) + trailer = _first_non_empty( + series_props.get("youtube_trailer"), + detailed.get("youtube_trailer"), + detailed.get("trailer"), + ) + status = _first_non_empty(series_props.get("status"), detailed.get("status")) + studio = _first_non_empty(series_props.get("studio"), detailed.get("studio")) + country = _first_non_empty(series_props.get("country"), detailed.get("country")) + episode_runtime = _first_non_empty( + series_props.get("episode_run_time"), + detailed.get("episode_run_time"), + ) + poster = _first_non_empty( + detailed.get("cover_big"), + detailed.get("movie_image"), + getattr(series.logo, "url", None), + ) + backdrop = _first_non_empty(series_props.get("backdrop_path"), detailed.get("backdrop_path")) + + root = ET.Element("tvshow") + _add_text(root, "title", title) + _add_text(root, "showtitle", title) + _add_text(root, "plot", plot) + _add_text(root, "outline", plot) + _add_text(root, "year", year) + _add_text(root, "premiered", release_date) + _add_text(root, "rating", rating) + _add_genres(root, genre) + _add_text(root, "director", director) + _add_text(root, "credits", writer) + _add_text(root, "trailer", trailer) + _add_text(root, "studio", studio) + _add_text(root, "country", country) + _add_text(root, "status", status) + _add_text(root, "runtime", episode_runtime) + _add_text(root, "thumb", poster) + _add_actors(root, cast) + backdrop_values = _as_text_list(backdrop) + if backdrop_values: + fanart = ET.SubElement(root, "fanart") + _add_text(fanart, "thumb", backdrop_values[0]) + _add_unique_ids(root, series.imdb_id, series.tmdb_id) + _add_dispatcharr_metadata( + root, + series_custom_properties=series_props, + relation_custom_properties=relation_props, + relation_detailed_info=detailed, + ) + return _to_xml_text(root) + + +def _build_episode_nfo( + series: Series, + episode: Episode, + relation: Optional[M3UEpisodeRelation], +) -> str: + episode_props = _read_dict(episode.custom_properties) + relation_props = _read_dict(getattr(relation, "custom_properties", None)) + detailed = _read_dict(relation_props.get("detailed_info")) + + title = _first_non_empty(detailed.get("title"), detailed.get("name"), episode.name) + plot = _first_non_empty(detailed.get("plot"), detailed.get("description"), episode.description) + rating = _first_non_empty(episode.rating, detailed.get("rating")) + runtime = _duration_minutes(_first_non_empty(episode.duration_secs, detailed.get("duration_secs"))) + air_date = _first_non_empty( + episode.air_date.isoformat() if episode.air_date else None, + detailed.get("release_date"), + detailed.get("aired"), + ) + director = _first_non_empty(episode_props.get("director"), detailed.get("director")) + writer = _first_non_empty( + episode_props.get("writer"), + episode_props.get("credits"), + detailed.get("writer"), + detailed.get("credits"), + ) + cast = _first_non_empty(episode_props.get("cast"), episode_props.get("actors"), detailed.get("cast")) + genre = _first_non_empty(episode_props.get("genre"), detailed.get("genre"), series.genre) + poster = _first_non_empty( + episode_props.get("movie_image"), + detailed.get("movie_image"), + detailed.get("thumb"), + ) + + season_num = _safe_int(episode.season_number) or 0 + episode_num = _safe_int(episode.episode_number) or 0 + + root = ET.Element("episodedetails") + _add_text(root, "title", title) + _add_text(root, "showtitle", series.name) + _add_text(root, "plot", plot) + _add_text(root, "season", season_num) + _add_text(root, "episode", episode_num) + _add_text(root, "aired", air_date) + _add_text(root, "premiered", air_date) + _add_text(root, "rating", rating) + _add_genres(root, genre) + _add_text(root, "runtime", runtime) + _add_text(root, "director", director) + _add_text(root, "credits", writer) + _add_text(root, "thumb", poster) + _add_actors(root, cast) + _add_unique_ids(root, episode.imdb_id, episode.tmdb_id) + _add_dispatcharr_metadata( + root, + episode_custom_properties=episode_props, + relation_custom_properties=relation_props, + relation_detailed_info=detailed, + ) + return _to_xml_text(root) + + +def _stream_url(base_url: str, content_type: str, content_uuid) -> str: + path = reverse( + "proxy:vod_proxy:vod_stream", + kwargs={"content_type": content_type, "content_id": content_uuid}, + ) + return f"{base_url.rstrip('/')}{path}" + + +def _write_text(path: Path, contents: str) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(contents.rstrip() + "\n", encoding="utf-8") + + +def _movie_display_name(movie: Movie) -> str: + base_name = _sanitize_filename(movie.name, fallback=f"Movie-{movie.id}") + year = _safe_int(movie.year) + if year: + return _sanitize_filename(f"{base_name} ({year})", fallback=base_name) + return base_name + + +def _episode_base_name(series_name: str, season_num: int, episode_num: int) -> str: + return _sanitize_filename( + f"{series_name} - S{season_num:02d}E{episode_num:02d}", + fallback=f"Episode-S{season_num:02d}E{episode_num:02d}", + ) + + +def build_strm_nfo_snapshot( + output_root: Path | str, + *, + base_url: str, + include_nfo: bool = True, +) -> dict: + root = Path(output_root).expanduser().resolve() + movies_root = root / "Movies" + shows_root = root / "TV Shows" + + shutil.rmtree(movies_root, ignore_errors=True) + shutil.rmtree(shows_root, ignore_errors=True) + movies_root.mkdir(parents=True, exist_ok=True) + shows_root.mkdir(parents=True, exist_ok=True) + + movie_relations_qs = M3UMovieRelation.objects.select_related("m3u_account").filter( + m3u_account__is_active=True + ) + movies_qs = ( + Movie.objects.filter(m3u_relations__m3u_account__is_active=True) + .distinct() + .prefetch_related(Prefetch("m3u_relations", queryset=movie_relations_qs)) + .order_by("name", "year", "id") + ) + + series_relations_qs = M3USeriesRelation.objects.select_related("m3u_account").filter( + m3u_account__is_active=True + ) + episode_relations_qs = M3UEpisodeRelation.objects.select_related("m3u_account").filter( + m3u_account__is_active=True + ) + episodes_qs = Episode.objects.prefetch_related( + Prefetch("m3u_relations", queryset=episode_relations_qs) + ).order_by("season_number", "episode_number", "id") + series_qs = ( + Series.objects.filter(m3u_relations__m3u_account__is_active=True) + .distinct() + .prefetch_related( + Prefetch("m3u_relations", queryset=series_relations_qs), + Prefetch("episodes", queryset=episodes_qs), + ) + .order_by("name", "year", "id") + ) + + movies_written = 0 + series_written = 0 + episodes_written = 0 + nfo_written = 0 + strm_written = 0 + + for movie in movies_qs: + relation = _best_relation(_relation_list(getattr(movie, "m3u_relations", None))) + if not relation: + continue + + movie_name = _movie_display_name(movie) + movie_dir = movies_root / movie_name + strm_path = movie_dir / f"{movie_name}.strm" + _write_text( + strm_path, + _stream_url(base_url, "movie", movie.uuid), + ) + strm_written += 1 + + if include_nfo: + nfo_path = movie_dir / f"{movie_name}.nfo" + _write_text(nfo_path, _build_movie_nfo(movie, relation)) + nfo_written += 1 + movies_written += 1 + + for series in series_qs: + series_relation = _best_relation(_relation_list(getattr(series, "m3u_relations", None))) + series_name = _sanitize_filename(series.name, fallback=f"Series-{series.id}") + show_dir = shows_root / series_name + + series_has_episode = False + for episode in _relation_list(getattr(series, "episodes", None)): + episode_relation = _best_relation( + _relation_list(getattr(episode, "m3u_relations", None)) + ) + if not episode_relation: + continue + + season_num = _safe_int(episode.season_number) or 0 + episode_num = _safe_int(episode.episode_number) or 0 + season_dir = show_dir / f"Season {season_num:02d}" + episode_base = _episode_base_name(series_name, season_num, episode_num) + + strm_path = season_dir / f"{episode_base}.strm" + _write_text( + strm_path, + _stream_url(base_url, "episode", episode.uuid), + ) + strm_written += 1 + series_has_episode = True + episodes_written += 1 + + if include_nfo: + nfo_path = season_dir / f"{episode_base}.nfo" + _write_text(nfo_path, _build_episode_nfo(series, episode, episode_relation)) + nfo_written += 1 + + if series_has_episode and include_nfo: + tvshow_nfo = show_dir / "tvshow.nfo" + _write_text(tvshow_nfo, _build_tvshow_nfo(series, series_relation)) + nfo_written += 1 + + if series_has_episode: + series_written += 1 + + return { + "output_root": str(root), + "base_url": base_url.rstrip("/"), + "include_nfo": bool(include_nfo), + "movies_written": movies_written, + "series_written": series_written, + "episodes_written": episodes_written, + "strm_files_written": strm_written, + "nfo_files_written": nfo_written, + "total_files_written": strm_written + nfo_written, + } diff --git a/apps/media_servers/tasks.py b/apps/media_servers/tasks.py index 969df0f4..a5adc3ce 100644 --- a/apps/media_servers/tasks.py +++ b/apps/media_servers/tasks.py @@ -1,7 +1,10 @@ +import json import logging +import os +import uuid as uuid_lib from datetime import date from time import monotonic -from typing import Optional +from typing import Any, Optional from celery import shared_task from django.db import IntegrityError @@ -15,6 +18,12 @@ from apps.media_servers.providers import ( ProviderSeries, get_provider_client, ) +from apps.media_servers.output_paths import ( + DEFAULT_STRM_OUTPUT_PATH, + normalize_strm_output_path, + sanitize_output_settings_paths, +) +from apps.media_servers.strm_export import build_strm_nfo_snapshot from apps.vod.models import ( Episode, M3UEpisodeRelation, @@ -26,7 +35,13 @@ from apps.vod.models import ( VODCategory, VODLogo, ) -from core.utils import send_websocket_update +from core.models import CoreSettings, LEGACY_OUTPUT_SETTINGS_KEY, OUTPUT_SETTINGS_KEY +from core.utils import ( + RedisClient, + acquire_task_lock, + release_task_lock, + send_websocket_update, +) logger = logging.getLogger(__name__) @@ -37,6 +52,11 @@ STAGE_DISCOVERY = 'discovery' STAGE_IMPORT = 'import' STAGE_CLEANUP = 'cleanup' SYNC_WS_UPDATE_INTERVAL_SECONDS = 1.0 +OUTPUT_EXPORT_SYNC_TASK_LOCK_NAME = 'media_servers_output_export_sync' +OUTPUT_EXPORT_SYNC_TASK_LOCK_ID = 'global' +OUTPUT_EXPORT_SYNC_DEBOUNCE_KEY = 'media_servers:output_export_sync:queued' +OUTPUT_EXPORT_SYNC_DEBOUNCE_SECONDS = 30 +OUTPUT_EXPORT_SYNC_QUEUE_DELAY_SECONDS = 8 class SyncCancelled(Exception): @@ -175,6 +195,440 @@ def _set_sync_state( integration.save(update_fields=update_fields) +def _as_bool_output(value: Any, *, default: bool = False) -> bool: + if isinstance(value, bool): + return value + if isinstance(value, (int, float)): + return bool(value) + if isinstance(value, str): + normalized = value.strip().lower() + if normalized in {'1', 'true', 'yes', 'on'}: + return True + if normalized in {'0', 'false', 'no', 'off', ''}: + return False + return default + + +def _normalize_settings_payload(raw_value: Any) -> dict: + if isinstance(raw_value, dict): + return dict(raw_value) + if isinstance(raw_value, str): + try: + parsed = json.loads(raw_value) + except json.JSONDecodeError: + return {} + return dict(parsed) if isinstance(parsed, dict) else {} + return {} + + +def _derive_output_export_mode(target_provider: Any, export_mode: Any = '') -> str: + return 'strm_nfo' + + +def _legacy_output_configured(settings_value: dict) -> bool: + raw = settings_value if isinstance(settings_value, dict) else {} + return bool( + _as_bool_output(raw.get('export_enabled'), default=False) + or str(raw.get('updated_at') or '').strip() + or str(raw.get('strm_last_built_at') or '').strip() + or str(raw.get('strm_output_path') or '').strip() + or str(raw.get('integration_name') or '').strip() + ) + + +def _build_legacy_output_profile(settings_value: dict) -> dict: + raw = settings_value if isinstance(settings_value, dict) else {} + target_provider = str(raw.get('target_provider') or '').strip().lower() or 'jellyfin_emby' + return { + 'id': '__legacy-output__', + 'integration_name': str(raw.get('integration_name') or '').strip() or 'Dispatcharr Output', + 'target_provider': target_provider, + 'export_mode': 'strm_nfo', + 'export_enabled': _as_bool_output(raw.get('export_enabled'), default=True), + 'target_base_url': '', + 'target_api_token': '', + 'target_verify_ssl': True, + 'strm_output_path': normalize_strm_output_path( + raw.get('strm_output_path'), + fallback=DEFAULT_STRM_OUTPUT_PATH, + ), + 'strm_include_nfo': _as_bool_output(raw.get('strm_include_nfo'), default=True), + 'strm_last_built_at': str(raw.get('strm_last_built_at') or '').strip(), + 'strm_last_build_summary': ( + raw.get('strm_last_build_summary') + if isinstance(raw.get('strm_last_build_summary'), dict) + else None + ), + 'scan_last_run_at': str(raw.get('scan_last_run_at') or '').strip(), + 'scan_last_status': str(raw.get('scan_last_status') or '').strip(), + 'scan_last_message': str(raw.get('scan_last_message') or '').strip(), + 'scan_last_summary': ( + raw.get('scan_last_summary') + if isinstance(raw.get('scan_last_summary'), dict) + else None + ), + 'updated_at': str(raw.get('updated_at') or '').strip(), + } + + +def _extract_output_profiles_from_settings(settings_value: dict) -> list[dict]: + raw = settings_value if isinstance(settings_value, dict) else {} + raw_profiles = raw.get('output_integrations') + if isinstance(raw_profiles, list): + profiles = [] + used_ids: set[str] = set() + for raw_profile in raw_profiles: + if not isinstance(raw_profile, dict): + continue + profile = dict(raw_profile) + profile_id = str(profile.get('id') or profile.get('integration_id') or '').strip() + if not profile_id: + profile_id = f'output-{uuid_lib.uuid4()}' + if profile_id in used_ids: + continue + used_ids.add(profile_id) + profile['id'] = profile_id + profile.setdefault('integration_name', 'Dispatcharr Output') + profile.setdefault( + 'target_provider', + str(profile.get('target_provider') or '').strip().lower() + or 'jellyfin_emby', + ) + profile['export_mode'] = 'strm_nfo' + profile['export_enabled'] = _as_bool_output( + profile.get('export_enabled'), + default=True, + ) + profile['target_base_url'] = '' + profile['target_api_token'] = '' + profile['target_verify_ssl'] = True + profile['strm_output_path'] = normalize_strm_output_path( + profile.get('strm_output_path'), + fallback=DEFAULT_STRM_OUTPUT_PATH, + ) + profile['strm_include_nfo'] = _as_bool_output( + profile.get('strm_include_nfo'), + default=True, + ) + profile['scan_last_run_at'] = str(profile.get('scan_last_run_at') or '').strip() + profile['scan_last_status'] = str(profile.get('scan_last_status') or '').strip() + profile['scan_last_message'] = str(profile.get('scan_last_message') or '').strip() + if not isinstance(profile.get('scan_last_summary'), dict): + profile['scan_last_summary'] = None + profiles.append(profile) + if profiles: + return profiles + + if _legacy_output_configured(raw): + return [_build_legacy_output_profile(raw)] + return [] + + +def _output_profile_legacy_fields(profile: dict | None) -> dict: + if not profile: + return { + 'integration_name': '', + 'target_provider': '', + 'export_mode': '', + 'export_enabled': False, + 'target_base_url': '', + 'target_api_token': '', + 'target_verify_ssl': True, + 'strm_output_path': DEFAULT_STRM_OUTPUT_PATH, + 'strm_include_nfo': True, + 'strm_last_built_at': '', + 'strm_last_build_summary': None, + 'scan_last_run_at': '', + 'scan_last_status': '', + 'scan_last_message': '', + 'scan_last_summary': None, + 'updated_at': '', + } + + return { + 'integration_name': str(profile.get('integration_name') or '').strip() + or 'Dispatcharr Output', + 'target_provider': str(profile.get('target_provider') or '').strip().lower(), + 'export_mode': 'strm_nfo', + 'export_enabled': _as_bool_output(profile.get('export_enabled'), default=True), + 'target_base_url': '', + 'target_api_token': '', + 'target_verify_ssl': True, + 'strm_output_path': normalize_strm_output_path( + profile.get('strm_output_path'), + fallback=DEFAULT_STRM_OUTPUT_PATH, + ), + 'strm_include_nfo': _as_bool_output(profile.get('strm_include_nfo'), default=True), + 'strm_last_built_at': str(profile.get('strm_last_built_at') or '').strip(), + 'strm_last_build_summary': ( + profile.get('strm_last_build_summary') + if isinstance(profile.get('strm_last_build_summary'), dict) + else None + ), + 'scan_last_run_at': str(profile.get('scan_last_run_at') or '').strip(), + 'scan_last_status': str(profile.get('scan_last_status') or '').strip(), + 'scan_last_message': str(profile.get('scan_last_message') or '').strip(), + 'scan_last_summary': ( + profile.get('scan_last_summary') + if isinstance(profile.get('scan_last_summary'), dict) + else None + ), + 'updated_at': str(profile.get('updated_at') or '').strip(), + } + + +def _persist_output_profiles(settings_obj: CoreSettings, settings_value: dict, profiles: list[dict]) -> dict: + next_value = dict(settings_value or {}) + normalized_profiles: list[dict] = [] + used_ids: set[str] = set() + + for raw_profile in profiles: + if not isinstance(raw_profile, dict): + continue + profile = dict(raw_profile) + profile_id = str(profile.get('id') or profile.get('integration_id') or '').strip() + if not profile_id: + profile_id = f'output-{uuid_lib.uuid4()}' + if profile_id in used_ids: + continue + used_ids.add(profile_id) + profile['id'] = profile_id + normalized_profiles.append(profile) + + if normalized_profiles: + primary_profile = normalized_profiles[0] + next_value.update(_output_profile_legacy_fields(primary_profile)) + next_value['output_integrations'] = normalized_profiles + else: + next_value.update(_output_profile_legacy_fields(None)) + next_value['output_integrations'] = [] + + next_value.pop('scan_target_server_url', None) + next_value.pop('scan_target_server_token', None) + next_value.pop('scan_target_server_verify_ssl', None) + next_value.pop('strm_client_output_path', None) + + original_key = settings_obj.key + if original_key != OUTPUT_SETTINGS_KEY: + existing_output_settings = CoreSettings.objects.filter( + key=OUTPUT_SETTINGS_KEY + ).exclude(pk=settings_obj.pk).first() + if existing_output_settings is not None: + existing_output_settings.name = 'Output Settings' + existing_output_settings.value = next_value + existing_output_settings.save(update_fields=['name', 'value']) + CoreSettings.objects.filter(key=LEGACY_OUTPUT_SETTINGS_KEY).exclude( + pk=existing_output_settings.pk + ).delete() + return next_value + settings_obj.key = OUTPUT_SETTINGS_KEY + + settings_obj.name = 'Output Settings' + settings_obj.value = next_value + update_fields = ['name', 'value'] + if settings_obj.key != original_key: + update_fields.insert(0, 'key') + settings_obj.save(update_fields=update_fields) + CoreSettings.objects.filter(key=LEGACY_OUTPUT_SETTINGS_KEY).exclude( + pk=settings_obj.pk + ).delete() + return next_value + + +def _resolve_output_backend_base_url(settings_value: dict) -> str: + configured = str((settings_value or {}).get('backend_base_url') or '').strip() + if configured.startswith(('http://', 'https://')): + return configured.rstrip('/') + return 'http://127.0.0.1:9191' + +def _queue_output_export_sync(*, reason: str = '') -> bool: + safe_reason = str(reason or '').strip()[:255] + redis_client = None + should_enqueue = True + + try: + redis_client = RedisClient.get_client() + should_enqueue = bool( + redis_client.set( + OUTPUT_EXPORT_SYNC_DEBOUNCE_KEY, + '1', + ex=OUTPUT_EXPORT_SYNC_DEBOUNCE_SECONDS, + nx=True, + ) + ) + except Exception: + logger.debug( + 'Unable to set output export debounce key; enqueueing task without debounce.', + exc_info=True, + ) + should_enqueue = True + + if not should_enqueue: + logger.debug('Skipping output export sync enqueue; debounce key is active.') + return False + + try: + sync_output_exports.apply_async( + kwargs={'reason': safe_reason}, + countdown=OUTPUT_EXPORT_SYNC_QUEUE_DELAY_SECONDS, + ) + return True + except Exception: + logger.exception('Failed to enqueue output export sync task') + if redis_client is not None: + try: + redis_client.delete(OUTPUT_EXPORT_SYNC_DEBOUNCE_KEY) + except Exception: + logger.debug( + 'Failed clearing output export debounce key after enqueue error.', + exc_info=True, + ) + return False + + +@shared_task(bind=True) +def sync_output_exports(self, reason: str = ''): + try: + lock_acquired = acquire_task_lock( + OUTPUT_EXPORT_SYNC_TASK_LOCK_NAME, + OUTPUT_EXPORT_SYNC_TASK_LOCK_ID, + ) + except Exception: + logger.debug( + 'Output export sync lock unavailable; continuing without distributed lock.', + exc_info=True, + ) + lock_acquired = True + if not lock_acquired: + logger.info('Output export sync skipped because another run is in progress.') + return { + 'success': False, + 'skipped': True, + 'reason': 'already_running', + } + + redis_client = None + safe_reason = str(reason or '').strip()[:255] + + try: + try: + redis_client = RedisClient.get_client() + except Exception: + redis_client = None + + settings_obj = CoreSettings.objects.filter(key=OUTPUT_SETTINGS_KEY).first() + if settings_obj is None: + settings_obj = CoreSettings.objects.filter( + key=LEGACY_OUTPUT_SETTINGS_KEY + ).first() + if not settings_obj: + logger.info('Output export sync skipped: no output settings configured.') + return {'success': True, 'profiles_considered': 0, 'profiles_built': 0} + + settings_value = _normalize_settings_payload(settings_obj.value) + settings_value, settings_changed = sanitize_output_settings_paths(settings_value) + if settings_changed: + settings_obj.value = settings_value + settings_obj.save(update_fields=['value']) + profiles = _extract_output_profiles_from_settings(settings_value) + if not profiles: + logger.info('Output export sync skipped: no output integration profiles found.') + return {'success': True, 'profiles_considered': 0, 'profiles_built': 0} + + base_url = _resolve_output_backend_base_url(settings_value) + now_iso = timezone.now().isoformat() + profiles_considered = 0 + profiles_built = 0 + profiles_failed = 0 + + for profile in profiles: + export_mode = _derive_output_export_mode( + profile.get('target_provider'), + profile.get('export_mode'), + ) + if export_mode != 'strm_nfo': + continue + if not _as_bool_output(profile.get('export_enabled'), default=True): + continue + + profiles_considered += 1 + include_nfo = _as_bool_output(profile.get('strm_include_nfo'), default=True) + configured_output_path = normalize_strm_output_path( + profile.get('strm_output_path'), + fallback=DEFAULT_STRM_OUTPUT_PATH, + ) + output_path = configured_output_path + profile['strm_output_path'] = output_path + profile['strm_include_nfo'] = include_nfo + + try: + os.makedirs(output_path, exist_ok=True) + summary = build_strm_nfo_snapshot( + output_path, + base_url=base_url, + include_nfo=include_nfo, + ) + profiles_built += 1 + profile['strm_last_built_at'] = now_iso + profile['updated_at'] = now_iso + profile['strm_last_build_summary'] = { + **summary, + 'success': True, + 'build_mode': 'auto', + 'build_reason': safe_reason, + } + except Exception as exc: + profiles_failed += 1 + logger.exception( + 'Automatic STRM/NFO export failed for profile=%s path=%s', + profile.get('id'), + output_path, + ) + profile['updated_at'] = now_iso + profile['strm_last_build_summary'] = { + 'success': False, + 'message': 'Automatic STRM/NFO export failed.', + 'error': str(exc), + 'output_root': output_path, + 'build_mode': 'auto', + 'build_reason': safe_reason, + } + + _persist_output_profiles(settings_obj, settings_value, profiles) + logger.info( + 'Output export sync completed (considered=%s built=%s failed=%s reason=%s)', + profiles_considered, + profiles_built, + profiles_failed, + safe_reason or 'unspecified', + ) + return { + 'success': True, + 'profiles_considered': profiles_considered, + 'profiles_built': profiles_built, + 'profiles_failed': profiles_failed, + 'reason': safe_reason, + } + finally: + try: + release_task_lock( + OUTPUT_EXPORT_SYNC_TASK_LOCK_NAME, + OUTPUT_EXPORT_SYNC_TASK_LOCK_ID, + ) + except Exception: + logger.debug( + 'Output export sync lock release failed.', + exc_info=True, + ) + if redis_client is not None: + try: + redis_client.delete(OUTPUT_EXPORT_SYNC_DEBOUNCE_KEY) + except Exception: + logger.debug( + 'Failed clearing output export debounce key after task completion.', + exc_info=True, + ) + def _account_name(integration: MediaServerIntegration) -> str: return f'{MEDIA_SERVER_ACCOUNT_PREFIX} {integration.id}: {integration.name}' @@ -705,6 +1159,7 @@ def cleanup_integration_vod(integration: MediaServerIntegration) -> None: ).delete() _delete_orphan_series(series_ids) + _queue_output_export_sync(reason=f'integration_cleanup:{integration.id}') @shared_task(bind=True) @@ -1254,6 +1709,7 @@ def sync_media_server_integration(self, integration_id: int, sync_run_id: Option message=summary, update_synced_at=True, ) + _queue_output_export_sync(reason=f'integration_sync:{integration.id}') return summary except SyncCancelled as exc: logger.info( diff --git a/apps/proxy/vod_proxy/views.py b/apps/proxy/vod_proxy/views.py index 7524da99..eee8668c 100644 --- a/apps/proxy/vod_proxy/views.py +++ b/apps/proxy/vod_proxy/views.py @@ -247,7 +247,7 @@ class VODStreamView(View): def head(self, request, content_type, content_id, session_id=None, profile_id=None): """ - Handle HEAD requests for FUSE filesystem integration + Handle HEAD requests for session-aware VOD playback clients. Returns content length and session URL header for subsequent GET requests """ @@ -393,15 +393,14 @@ class VODStreamView(View): except Exception as e: logger.error(f"[VOD-HEAD] Failed to store content length in Redis: {e}") - # Now create a persistent connection for the session (if one doesn't exist) - # This ensures the FUSE GET requests will reuse the same connection + # Now create a persistent connection for the session (if one doesn't exist). + # This helps subsequent GET requests reuse the same session state. connection_manager = MultiWorkerVODConnectionManager.get_instance() logger.info(f"[VOD-HEAD] Pre-creating persistent connection for session: {session_id}") - # We don't actually stream content here, just ensure connection is ready - # The actual GET requests from FUSE will use the persistent connection + # We don't stream content here; we only prepare session metadata. # Use the total_size we extracted from the range response provider_content_type = response.headers.get('Content-Type') @@ -427,7 +426,7 @@ class VODStreamView(View): head_response['Content-Type'] = content_type_header head_response['Accept-Ranges'] = 'bytes' - # Custom header with session URL for FUSE + # Custom header with the session URL for the follow-up GET request. head_response['X-Session-URL'] = session_url head_response['X-Dispatcharr-Session'] = session_id @@ -1152,4 +1151,3 @@ def stop_vod_client(request): except Exception as e: logger.error(f"Error stopping VOD client: {e}", exc_info=True) return JsonResponse({'error': str(e)}, status=500) - diff --git a/core/models.py b/core/models.py index 25962a92..d1b9b817 100644 --- a/core/models.py +++ b/core/models.py @@ -153,6 +153,8 @@ STREAM_SETTINGS_KEY = "stream_settings" DVR_SETTINGS_KEY = "dvr_settings" BACKUP_SETTINGS_KEY = "backup_settings" PROXY_SETTINGS_KEY = "proxy_settings" +OUTPUT_SETTINGS_KEY = "output_settings" +LEGACY_OUTPUT_SETTINGS_KEY = "fuse_settings" NETWORK_ACCESS_KEY = "network_access" SYSTEM_SETTINGS_KEY = "system_settings" EPG_SETTINGS_KEY = "epg_settings" diff --git a/frontend/src/api.js b/frontend/src/api.js index 491273f9..e330a91d 100644 --- a/frontend/src/api.js +++ b/frontend/src/api.js @@ -2057,6 +2057,64 @@ export default class API { } } + static async buildStrmExportSnapshot({ + integrationId = '', + outputPath = '', + includeNfo = true, + } = {}) { + try { + const body = { + include_nfo: !!includeNfo, + }; + if (integrationId && String(integrationId).trim()) { + body.integration_id = String(integrationId).trim(); + } + if (outputPath && String(outputPath).trim()) { + body.output_path = String(outputPath).trim(); + } + + return await request(`${host}/api/media-servers/output/strm-export/build/`, { + method: 'POST', + body, + }); + } catch (e) { + errorNotification('Failed to build STRM/NFO export snapshot', e); + throw e; + } + } + + static async getOutputIntegrationState() { + try { + return await request(`${host}/api/media-servers/output/integration/`, { + method: 'GET', + }); + } catch (e) { + errorNotification('Failed to retrieve output integration', e); + throw e; + } + } + + static async deleteOutputIntegration({ + integrationId = '', + deleteStrmFiles = true, + } = {}) { + try { + const body = { + delete_strm_files: !!deleteStrmFiles, + }; + if (integrationId && String(integrationId).trim()) { + body.integration_id = String(integrationId).trim(); + } + return await request(`${host}/api/media-servers/output/integration/`, { + method: 'DELETE', + body, + }); + } catch (e) { + errorNotification('Failed to delete output integration', e); + throw e; + } + } + static async stopVODClient(clientId) { try { const response = await request(`${host}/proxy/vod/stop_client/`, { diff --git a/frontend/src/pages/MediaServers.jsx b/frontend/src/pages/MediaServers.jsx index ae468750..f1d56e60 100644 --- a/frontend/src/pages/MediaServers.jsx +++ b/frontend/src/pages/MediaServers.jsx @@ -1,28 +1,38 @@ -import React, { useCallback, useEffect, useState } from 'react'; +import React, { useCallback, useEffect, useMemo, useState } from 'react'; import { + Alert, Badge, Box, Button, Card, + Divider, Flex, Group, Loader, + Modal, + Select, Stack, Switch, Text, + TextInput, Title, } from '@mantine/core'; import { notifications } from '@mantine/notifications'; import { + ArrowDownToLine, + ArrowUpFromLine, CircleCheckBig, CircleDashed, CirclePlay, CircleX, FolderKanban, + HardDrive, + Pencil, RefreshCw, ScanSearch, Server, SquarePlus, + Trash2, } from 'lucide-react'; import API from '../api'; @@ -31,6 +41,44 @@ import { USER_LEVELS } from '../constants'; import MediaServerIntegrationForm from '../components/forms/MediaServerIntegrationForm'; import MediaServerSyncDrawer from '../components/MediaServerSyncDrawer'; +const EXPORT_TARGET_OPTIONS = [{ value: 'jellyfin_emby', label: 'Jellyfin / Emby' }]; + +const DEFAULT_EXPORT_PROFILE = { + id: '', + integration_name: 'Dispatcharr Output', + target_provider: 'jellyfin_emby', + export_mode: 'strm_nfo', + export_enabled: true, + strm_output_path: '/data/media/strm', + strm_include_nfo: true, + strm_last_built_at: '', + strm_last_build_summary: null, + updated_at: '', +}; + +const LEGACY_OUTPUT_ID = '__legacy-output__'; + +const OUTPUT_SETTINGS_LEGACY_KEYS_TO_CLEAR = [ + 'scan_control_enabled', + 'scan_control_integration_id', + 'scan_control_schedule_time', + 'scan_control_library_ids', + 'scan_control_timeout_seconds', + 'scan_control_wait_for_idle_seconds', + 'scan_last_run_at', + 'scan_last_status', + 'scan_last_message', + 'scan_last_summary', + 'scan_schedule_time', + 'scan_target_server_url', + 'scan_target_server_token', + 'scan_target_server_verify_ssl', + 'target_base_url', + 'target_api_token', + 'target_verify_ssl', + 'strm_client_output_path', +]; + function statusColor(status) { switch (status) { case 'success': @@ -57,21 +105,20 @@ function statusIcon(status) { } } -function ProviderBadge({ provider }) { +function providerLabel(provider) { const normalized = String(provider || '').toLowerCase(); - const label = - normalized === 'plex' - ? 'Plex' - : normalized === 'emby' - ? 'Emby' - : normalized === 'jellyfin' - ? 'Jellyfin' - : normalized === 'local' - ? 'Local' - : provider || 'Unknown'; + if (normalized === 'plex') return 'Plex'; + if (normalized === 'jellyfin_emby') return 'Jellyfin/Emby'; + if (normalized === 'emby') return 'Emby'; + if (normalized === 'jellyfin') return 'Jellyfin'; + if (normalized === 'local') return 'Local'; + return provider || 'Unknown'; +} + +function ProviderBadge({ provider }) { return ( - {label} + {providerLabel(provider)} ); } @@ -90,6 +137,190 @@ function formatSyncInterval(syncIntervalHours) { return `${hours} hour${hours === 1 ? '' : 's'}`; } +function parseBoolean(value, fallback = false) { + if (typeof value === 'boolean') return value; + if (typeof value === 'number') return value !== 0; + if (typeof value === 'string') { + const normalized = value.trim().toLowerCase(); + if (['1', 'true', 'yes', 'on'].includes(normalized)) return true; + if (['0', 'false', 'no', 'off', ''].includes(normalized)) return false; + } + return fallback; +} + +function normalizeSettingsValue(rawValue) { + if (rawValue && typeof rawValue === 'object' && !Array.isArray(rawValue)) { + return rawValue; + } + if (typeof rawValue === 'string') { + try { + const parsed = JSON.parse(rawValue); + if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) { + return parsed; + } + } catch (error) { + void error; + return {}; + } + } + return {}; +} + +function normalizeExportTarget(value) { + const normalized = String(value || '').trim().toLowerCase(); + if (normalized === 'emby' || normalized === 'jellyfin' || normalized === 'jellyfin_emby') { + return 'jellyfin_emby'; + } + return DEFAULT_EXPORT_PROFILE.target_provider; +} + +function deriveExportMode() { + return 'strm_nfo'; +} + +function createExportProfileId() { + if ( + typeof crypto !== 'undefined' && + crypto && + typeof crypto.randomUUID === 'function' + ) { + return crypto.randomUUID(); + } + return `output-${Date.now().toString(36)}-${Math.random() + .toString(36) + .slice(2, 10)}`; +} + +function buildExportProfile(rawSettings = {}) { + const normalized = normalizeSettingsValue(rawSettings); + const targetProvider = normalizeExportTarget(normalized.target_provider); + const profileId = + String(normalized.id || normalized.integration_id || '').trim() || + createExportProfileId(); + + return { + id: profileId, + integration_name: + String(normalized.integration_name || '').trim() || + DEFAULT_EXPORT_PROFILE.integration_name, + target_provider: targetProvider, + export_mode: deriveExportMode(), + export_enabled: parseBoolean(normalized.export_enabled, true), + strm_output_path: + String(normalized.strm_output_path || '').trim() || + DEFAULT_EXPORT_PROFILE.strm_output_path, + strm_include_nfo: parseBoolean( + normalized.strm_include_nfo, + DEFAULT_EXPORT_PROFILE.strm_include_nfo + ), + strm_last_built_at: String(normalized.strm_last_built_at || '').trim(), + strm_last_build_summary: + normalized.strm_last_build_summary && + typeof normalized.strm_last_build_summary === 'object' + ? normalized.strm_last_build_summary + : null, + updated_at: String(normalized.updated_at || '').trim(), + }; +} + +function hasLegacyOutputConfig(rawSettings) { + const normalized = normalizeSettingsValue(rawSettings); + return Boolean( + parseBoolean(normalized.export_enabled, false) || + String(normalized.updated_at || '').trim() || + String(normalized.strm_last_built_at || '').trim() || + String(normalized.strm_output_path || '').trim() || + String(normalized.integration_name || '').trim() + ); +} + +function buildExportProfiles(rawSettings) { + const normalized = normalizeSettingsValue(rawSettings); + const rawProfiles = Array.isArray(normalized.output_integrations) + ? normalized.output_integrations + : []; + + if (rawProfiles.length > 0) { + const usedIds = new Set(); + return rawProfiles + .filter((profile) => profile && typeof profile === 'object') + .map((profile) => buildExportProfile(profile)) + .filter((profile) => { + const profileId = String(profile.id || '').trim(); + if (!profileId || usedIds.has(profileId)) { + return false; + } + usedIds.add(profileId); + return true; + }); + } + + if (!hasLegacyOutputConfig(normalized)) { + return []; + } + + return [ + buildExportProfile({ + ...normalized, + id: + String(normalized.id || normalized.integration_id || '').trim() || + LEGACY_OUTPUT_ID, + }), + ]; +} + +function buildLegacyOutputFields(profile) { + if (!profile || typeof profile !== 'object') { + return { + integration_name: '', + target_provider: '', + export_mode: '', + export_enabled: false, + target_base_url: '', + target_api_token: '', + target_verify_ssl: true, + strm_output_path: DEFAULT_EXPORT_PROFILE.strm_output_path, + strm_include_nfo: DEFAULT_EXPORT_PROFILE.strm_include_nfo, + strm_last_built_at: '', + strm_last_build_summary: null, + updated_at: '', + }; + } + + return { + integration_name: + String(profile.integration_name || '').trim() || + DEFAULT_EXPORT_PROFILE.integration_name, + target_provider: normalizeExportTarget(profile.target_provider), + export_mode: deriveExportMode(), + export_enabled: parseBoolean(profile.export_enabled, true), + target_base_url: '', + target_api_token: '', + target_verify_ssl: true, + strm_output_path: + String(profile.strm_output_path || '').trim() || + DEFAULT_EXPORT_PROFILE.strm_output_path, + strm_include_nfo: parseBoolean(profile.strm_include_nfo, true), + strm_last_built_at: String(profile.strm_last_built_at || '').trim(), + strm_last_build_summary: + profile.strm_last_build_summary && + typeof profile.strm_last_build_summary === 'object' + ? profile.strm_last_build_summary + : null, + updated_at: String(profile.updated_at || '').trim() || new Date().toISOString(), + }; +} + +function sanitizeOutputSettings(value) { + const output = { ...value }; + OUTPUT_SETTINGS_LEGACY_KEYS_TO_CLEAR.forEach((key) => { + if (Object.prototype.hasOwnProperty.call(output, key)) { + delete output[key]; + } + }); + return output; +} + export default function MediaServersPage() { const authUser = useAuthStore((s) => s.user); const isAdmin = authUser?.user_level === USER_LEVELS.ADMIN; @@ -102,6 +333,24 @@ export default function MediaServersPage() { const [scanDrawerOpen, setScanDrawerOpen] = useState(false); const [scanIntegrationId, setScanIntegrationId] = useState(null); + const [integrationTypeOpen, setIntegrationTypeOpen] = useState(false); + const [exportSetupOpen, setExportSetupOpen] = useState(false); + + const [exportStateLoading, setExportStateLoading] = useState(false); + const [savingExportProfile, setSavingExportProfile] = useState(false); + const [deletingExportProfileId, setDeletingExportProfileId] = useState(''); + const [syncingExportId, setSyncingExportId] = useState(''); + const [outputSetting, setOutputSetting] = useState(null); + const [exportProfiles, setExportProfiles] = useState([]); + const [activeExportId, setActiveExportId] = useState(''); + const [exportProfile, setExportProfile] = useState( + buildExportProfile(DEFAULT_EXPORT_PROFILE) + ); + + const [strmBuildLoading, setStrmBuildLoading] = useState(false); + const [strmBuildError, setStrmBuildError] = useState(''); + const [strmBuildResult, setStrmBuildResult] = useState(null); + const setBusy = (id, value) => { setBusyById((prev) => ({ ...prev, [id]: value })); }; @@ -116,15 +365,93 @@ export default function MediaServersPage() { } }, []); + const fetchExportIntegrationState = useCallback(async () => { + setExportStateLoading(true); + try { + const outputState = await API.getOutputIntegrationState(); + const existingSetting = + outputState && typeof outputState.setting === 'object' + ? outputState.setting + : null; + setOutputSetting(existingSetting); + + const rawValue = normalizeSettingsValue(outputState?.value); + const nextProfiles = buildExportProfiles(rawValue); + setExportProfiles(nextProfiles); + + if (!exportSetupOpen) { + if (nextProfiles.length > 0) { + setActiveExportId(nextProfiles[0].id); + setExportProfile(nextProfiles[0]); + } else { + const blankProfile = buildExportProfile({ + ...DEFAULT_EXPORT_PROFILE, + id: createExportProfileId(), + export_enabled: true, + }); + setActiveExportId(blankProfile.id); + setExportProfile(blankProfile); + } + } + } catch (error) { + console.error('Failed to load output integration state:', error); + } finally { + setExportStateLoading(false); + } + }, [exportSetupOpen]); + useEffect(() => { fetchIntegrations(); - }, [fetchIntegrations]); + fetchExportIntegrationState(); + }, [fetchIntegrations, fetchExportIntegrationState]); + + const dockerMountSnippet = useMemo(() => { + const outputRoot = + String(exportProfile.strm_output_path || '').trim() || + DEFAULT_EXPORT_PROFILE.strm_output_path; + return `- ${outputRoot}/Movies:/media/Movies:ro\n- ${outputRoot}/TV Shows:/media/TV Shows:ro`; + }, [exportProfile.strm_output_path]); const openCreate = () => { + setIntegrationTypeOpen(true); + }; + + const closeIntegrationType = () => { + setIntegrationTypeOpen(false); + }; + + const startImportIntegration = () => { + setIntegrationTypeOpen(false); setActiveIntegration(null); setFormOpen(true); }; + const openExportSetup = useCallback((profile = null) => { + setStrmBuildError(''); + setStrmBuildResult(null); + const nextProfile = buildExportProfile( + profile && typeof profile === 'object' + ? { + ...profile, + id: String(profile.id || '').trim() || createExportProfileId(), + export_enabled: true, + } + : { + ...DEFAULT_EXPORT_PROFILE, + id: createExportProfileId(), + export_enabled: true, + } + ); + setActiveExportId(nextProfile.id); + setExportProfile(nextProfile); + setIntegrationTypeOpen(false); + setExportSetupOpen(true); + }, []); + + const closeExportSetup = () => { + setExportSetupOpen(false); + }; + const openEdit = (integration) => { setActiveIntegration(integration); setFormOpen(true); @@ -186,10 +513,9 @@ export default function MediaServersPage() { }; const deleteIntegration = async (integration) => { - const confirmed = window.confirm( - `Delete integration "${integration.name}"?` - ); + const confirmed = window.confirm(`Delete integration "${integration.name}"?`); if (!confirmed) return; + setBusy(integration.id, true); try { await API.deleteMediaServerIntegration(integration.id); @@ -212,9 +538,450 @@ export default function MediaServersPage() { setScanDrawerOpen(false); }; + const buildStrmSnapshot = async () => { + const selectedIntegrationId = String(activeExportId || exportProfile.id || '').trim(); + const persistedIntegrationId = exportProfiles.some( + (profile) => String(profile.id || '').trim() === selectedIntegrationId + ) + ? selectedIntegrationId + : ''; + + setStrmBuildLoading(true); + setStrmBuildError(''); + try { + const response = await API.buildStrmExportSnapshot({ + integrationId: persistedIntegrationId, + outputPath: exportProfile.strm_output_path, + includeNfo: exportProfile.strm_include_nfo, + }); + setStrmBuildResult(response); + notifications.show({ + title: 'STRM/NFO snapshot generated', + message: `Wrote ${response?.total_files_written || 0} files.`, + color: 'green', + }); + await fetchExportIntegrationState(); + } catch (error) { + console.error('Failed to build STRM/NFO snapshot', error); + setStrmBuildError( + error?.body?.detail || error?.message || 'Failed to build STRM/NFO snapshot.' + ); + } finally { + setStrmBuildLoading(false); + } + }; + + const syncExportProfile = async (profile) => { + const profileId = String(profile?.id || '').trim(); + if (!profileId) return; + + setSyncingExportId(profileId); + try { + const response = await API.buildStrmExportSnapshot({ + integrationId: profileId, + outputPath: profile?.strm_output_path, + includeNfo: parseBoolean(profile?.strm_include_nfo, true), + }); + notifications.show({ + title: 'STRM/NFO snapshot updated', + message: `Wrote ${response?.total_files_written || 0} files.`, + color: 'green', + }); + await fetchExportIntegrationState(); + } catch (error) { + console.error('Failed to sync output integration', error); + } finally { + setSyncingExportId(''); + } + }; + + const saveExportProfile = async () => { + setSavingExportProfile(true); + try { + const outputState = await API.getOutputIntegrationState(); + const existingSetting = + (outputState && typeof outputState.setting === 'object' + ? outputState.setting + : null) || outputSetting; + + const existingValue = normalizeSettingsValue(outputState?.value); + const existingValueWithoutDeprecatedPath = sanitizeOutputSettings(existingValue); + const existingProfiles = buildExportProfiles(existingValueWithoutDeprecatedPath); + const storedProfileList = Array.isArray( + existingValueWithoutDeprecatedPath.output_integrations + ) + ? existingValueWithoutDeprecatedPath.output_integrations + : []; + const replacingLegacyOnlyProfile = + storedProfileList.length === 0 && + existingProfiles.length === 1 && + String(existingProfiles[0]?.id || '').trim() === LEGACY_OUTPUT_ID; + + const integrationName = + String(exportProfile.integration_name || '').trim() || + DEFAULT_EXPORT_PROFILE.integration_name; + const draftProfileId = + String(exportProfile.id || activeExportId || '').trim() || createExportProfileId(); + const persistedProfileId = + draftProfileId === LEGACY_OUTPUT_ID ? createExportProfileId() : draftProfileId; + + const normalizedDraftProfile = buildExportProfile({ + ...exportProfile, + id: persistedProfileId, + integration_name: integrationName, + target_provider: normalizeExportTarget(exportProfile.target_provider), + export_mode: deriveExportMode(), + export_enabled: true, + strm_output_path: + String(exportProfile.strm_output_path || '').trim() || + DEFAULT_EXPORT_PROFILE.strm_output_path, + strm_include_nfo: parseBoolean(exportProfile.strm_include_nfo, true), + updated_at: new Date().toISOString(), + }); + + const nextProfiles = replacingLegacyOnlyProfile ? [] : [...existingProfiles]; + const existingProfileIndex = nextProfiles.findIndex( + (profile) => + String(profile.id || '').trim() === persistedProfileId || + (draftProfileId === LEGACY_OUTPUT_ID && + String(profile.id || '').trim() === LEGACY_OUTPUT_ID) + ); + + if (existingProfileIndex >= 0) { + nextProfiles[existingProfileIndex] = normalizedDraftProfile; + } else { + nextProfiles.push(normalizedDraftProfile); + } + + const primaryProfile = nextProfiles[0] || null; + const legacyOutputFields = buildLegacyOutputFields(primaryProfile); + const nextValue = sanitizeOutputSettings({ + ...existingValueWithoutDeprecatedPath, + ...legacyOutputFields, + output_integrations: nextProfiles, + }); + + if (existingSetting?.id) { + await API.updateSetting({ + id: existingSetting.id, + key: existingSetting.key, + name: existingSetting.name || 'Output Settings', + value: nextValue, + }); + } else { + await API.createSetting({ + key: 'output_settings', + name: 'Output Settings', + value: nextValue, + }); + } + + notifications.show({ + title: 'Output integration saved', + message: `${integrationName} is now configured.`, + color: 'green', + }); + + setActiveExportId(normalizedDraftProfile.id); + setExportProfile(normalizedDraftProfile); + await fetchExportIntegrationState(); + setExportSetupOpen(false); + } catch (error) { + console.error('Error saving output profile:', error); + } finally { + setSavingExportProfile(false); + } + }; + + const deleteExportProfile = async (profile) => { + const profileId = String(profile?.id || '').trim(); + if (!profileId) { + return; + } + const profileName = + String(profile?.integration_name || '').trim() || 'this output integration'; + const confirmed = window.confirm( + `Delete "${profileName}"? This will remove the saved output profile.` + ); + if (!confirmed) return; + + setDeletingExportProfileId(profileId); + try { + const response = await API.deleteOutputIntegration({ + integrationId: profileId, + deleteStrmFiles: true, + }); + const cleanup = response?.strm_cleanup || {}; + + let message = response?.message || 'Output integration deleted.'; + let color = 'green'; + if (cleanup?.attempted) { + if (cleanup?.skipped) { + color = 'yellow'; + const linkedIntegrations = Array.isArray(cleanup?.in_use_by) + ? cleanup.in_use_by + .map((entry) => String(entry?.integration_name || '').trim()) + .filter(Boolean) + .join(', ') + : ''; + message = cleanup?.skip_reason || 'STRM cleanup was skipped.'; + if (linkedIntegrations) { + message = `${message} In use by: ${linkedIntegrations}.`; + } + } else if (cleanup?.deleted) { + const outputPath = String(cleanup?.output_path || '').trim(); + message = outputPath + ? `Output integration deleted. Removed STRM files from ${outputPath}.` + : 'Output integration deleted. STRM files were removed.'; + } else { + message = 'Output integration deleted. No STRM files were found to remove.'; + } + } + + notifications.show({ + title: 'Output integration deleted', + message, + color, + }); + + if (profileId === activeExportId) { + setExportSetupOpen(false); + } + await fetchExportIntegrationState(); + } catch (error) { + console.error('Failed to delete output integration:', error); + } finally { + setDeletingExportProfileId(''); + } + }; + const activeScanIntegration = integrations.find((integration) => integration.id === scanIntegrationId) || null; + const editingExistingExport = useMemo( + () => + exportProfiles.some( + (profile) => + String(profile.id || '').trim() === String(activeExportId || '').trim() + ), + [activeExportId, exportProfiles] + ); + + const exportIntegrated = exportProfiles.length > 0; + + const integrationCards = integrations.map((integration) => { + const busy = !!busyById[integration.id]; + return ( + + + + + + + {integration.name} + + + {integration.provider_type === 'local' + ? 'Local filesystem import' + : integration.base_url} + + + toggleEnabled(integration)} + disabled={busy} + /> + + + + + + {integration.last_sync_status || 'idle'} + + + {integration.add_to_vod ? 'VOD Enabled' : 'VOD Disabled'} + + + + + + + {Array.isArray(integration.include_libraries) && + integration.include_libraries.length > 0 + ? `${integration.include_libraries.length} selected librar${integration.include_libraries.length > 1 ? 'ies' : 'y'}` + : 'All media libraries'} + + + + + Last synced:{' '} + {integration.last_synced_at + ? new Date(integration.last_synced_at).toLocaleString() + : 'Never'} + + + Auto sync: {formatSyncInterval(integration.sync_interval)} + + {integration.last_sync_message ? ( + + {integration.last_sync_message} + + ) : null} + + + + + + + + + + + ); + }); + + const exportCards = exportProfiles.map((profile) => { + const deletingThisProfile = + String(deletingExportProfileId || '').trim() === + String(profile.id || '').trim(); + const syncingThisProfile = + String(syncingExportId || '').trim() === String(profile.id || '').trim(); + + return ( + + + + + + + {profile.integration_name || 'Dispatcharr Output'} + + + Jellyfin/Emby output via STRM + NFO files. + + + + Export + + + + + + + {parseBoolean(profile.export_enabled, false) ? 'Enabled' : 'Disabled'} + + + STRM/NFO + + + + + Output path:{' '} + {profile.strm_output_path || DEFAULT_EXPORT_PROFILE.strm_output_path} + + + {profile.strm_last_build_summary?.message ? ( + + {profile.strm_last_build_summary.message} + + ) : null} + + + + + + + + + ); + }); + let content = null; if (loading) { content = ( @@ -222,23 +989,6 @@ export default function MediaServersPage() { ); - } else if (!integrations.length) { - content = ( - - - - No media server integrations configured - - Add Plex, Emby, Jellyfin, or Local and sync movie and series libraries into VOD. - - - - ); } else { content = ( - {integrations.map((integration) => { - const busy = !!busyById[integration.id]; - return ( - - - - - - - {integration.name} - - - {integration.provider_type === 'local' - ? 'Local filesystem import' - : integration.base_url} - - - toggleEnabled(integration)} - disabled={busy} - /> - - - - - - {integration.last_sync_status || 'idle'} - - - {integration.add_to_vod ? 'VOD Enabled' : 'VOD Disabled'} - - - - - - - {Array.isArray(integration.include_libraries) && - integration.include_libraries.length > 0 - ? `${integration.include_libraries.length} selected librar${integration.include_libraries.length > 1 ? 'ies' : 'y'}` - : 'All media libraries'} - - - - - Last synced:{' '} - {integration.last_synced_at - ? new Date(integration.last_synced_at).toLocaleString() - : 'Never'} - - - Auto sync: {formatSyncInterval(integration.sync_interval)} - - {integration.last_sync_message ? ( - - {integration.last_sync_message} - - ) : null} - - - - - - - - - - - ); - })} + {exportCards} + {integrationCards} + {!integrations.length && !exportIntegrated ? ( + + + + No integrations configured + + Add an import integration to ingest content, or add an output + integration to publish VODs. + + + + ) : null} ); } @@ -390,23 +1036,69 @@ export default function MediaServersPage() { Media Servers - {content} + + + + Choose whether Dispatcharr should ingest media from a source, or + publish Dispatcharr VODs to an external media server. + + + + + + + Ingest Media Into Dispatcharr + + + Connect Plex, Emby, Jellyfin, or Local folders so Dispatcharr can + import titles into VOD. + + + + + + + + + + + + Publish Dispatcharr VODs + + + Export VOD files as STRM/NFO output for Jellyfin or Emby. + + + + + + + + + + + + + + + Configure how Dispatcharr publishes VODs to your target media server. + + + {exportStateLoading ? ( + + + + Loading output integration state... + + + ) : null} + + { + const value = event.currentTarget.value; + setExportProfile((prev) => ({ + ...prev, + integration_name: value, + })); + }} + placeholder="Dispatcharr Output" + /> + +