STRM output Test

This commit is contained in:
Dispatcharr 2026-02-24 19:06:14 -06:00
parent a9effb5ab5
commit fc4cd37a49
10 changed files with 3045 additions and 177 deletions

View file

@ -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

View file

@ -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)

View file

@ -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

View file

@ -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'}:

View file

@ -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 "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\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,
}

View file

@ -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(

View file

@ -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)

View file

@ -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"

View file

@ -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/`, {

File diff suppressed because it is too large Load diff