mirror of
https://github.com/Dispatcharr/Dispatcharr.git
synced 2026-07-18 17:16:26 +00:00
STRM output Test
This commit is contained in:
parent
a9effb5ab5
commit
fc4cd37a49
10 changed files with 3045 additions and 177 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
85
apps/media_servers/output_paths.py
Normal file
85
apps/media_servers/output_paths.py
Normal 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
|
||||
|
|
@ -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'}:
|
||||
|
|
|
|||
612
apps/media_servers/strm_export.py
Normal file
612
apps/media_servers/strm_export.py
Normal 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,
|
||||
}
|
||||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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
Loading…
Add table
Add a link
Reference in a new issue