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}
+
+
+ }
+ onClick={() => testConnection(integration)}
+ loading={busy}
+ >
+ Test
+
+ }
+ onClick={() => runSync(integration)}
+ loading={busy}
+ >
+ Sync
+
+ }
+ onClick={() => openScanDrawer(integration)}
+ disabled={busy}
+ >
+ View Scan
+
+
+
+
+
+
+ );
+ });
+
+ 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}
+
+
+ }
+ loading={syncingThisProfile}
+ onClick={() => syncExportProfile(profile)}
+ >
+ Sync
+
+ }
+ onClick={() => openExportSetup(profile)}
+ >
+ Edit
+
+ }
+ loading={deletingThisProfile}
+ onClick={() => deleteExportProfile(profile)}
+ >
+ Delete
+
+
+
+
+ );
+ });
+
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}
-
-
- }
- onClick={() => testConnection(integration)}
- loading={busy}
- >
- Test
-
- }
- onClick={() => runSync(integration)}
- loading={busy}
- >
- Sync
-
- }
- onClick={() => openScanDrawer(integration)}
- disabled={busy}
- >
- View Scan
-
-
-
-
-
-
- );
- })}
+ {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
- }
- variant="light"
- onClick={openCreate}
- >
+ } variant="light" onClick={openCreate}>
New Integration
{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"
+ />
+
+
+
);
}