diff --git a/apps/channels/tasks.py b/apps/channels/tasks.py index b70b8043..664dd723 100755 --- a/apps/channels/tasks.py +++ b/apps/channels/tasks.py @@ -500,18 +500,35 @@ def _build_output_paths(channel, program, start_time, end_time): } template = CoreSettings.get_dvr_movie_template() if is_movie else CoreSettings.get_dvr_tv_template() - # If TV and no season/episode info, use datetime fallback under TVShow//.mkv + # Build relative path from templates with smart fallbacks rel_path = None if not is_movie and (season == 0 or episode == 0): - # User-requested fallback when S/E missing - rel_path = f"TVShow/{show}/{values['start']}.mkv" + # TV fallback template when S/E are missing + try: + tv_fb = CoreSettings.get_dvr_tv_fallback_template() + rel_path = tv_fb.format(**values) + except Exception: + # Older setting support + try: + fallback_root = CoreSettings.get_dvr_tv_fallback_dir() + except Exception: + fallback_root = "TV_Shows" + rel_path = f"{fallback_root}/{show}/{values['start']}.mkv" if not rel_path: - # Allow templates that omit extension; ensure .mkv try: rel_path = template.format(**values) except Exception: - # Fallback minimal - rel_path = f"Recordings/{show}/S{season:02d}E{episode:02d}.mkv" + rel_path = None + # Movie-specific fallback if formatting failed or title missing + if is_movie and not rel_path: + try: + m_fb = CoreSettings.get_dvr_movie_fallback_template() + rel_path = m_fb.format(**values) + except Exception: + rel_path = f"Movies/{values['start']}.mkv" + # As a last resort for TV + if not is_movie and not rel_path: + rel_path = f"TV_Shows/{show}/S{season:02d}E{episode:02d}.mkv" # If template contains a leading "Recordings/" (legacy), drop it because we already root at recordings dir if rel_path.startswith(('Recordings/', 'recordings/')): rel_path = rel_path.split('/', 1)[1] diff --git a/core/migrations/0015_dvr_templates.py b/core/migrations/0015_dvr_templates.py index f764af09..7c2b475e 100644 --- a/core/migrations/0015_dvr_templates.py +++ b/core/migrations/0015_dvr_templates.py @@ -8,8 +8,12 @@ def add_dvr_templates(apps, schema_editor): CoreSettings = apps.get_model("core", "CoreSettings") defaults = [ - (slugify("DVR TV Template"), "DVR TV Template", "TV_Shows/{show}/S{season:02d}E{episode:02d}.mkv"), - (slugify("DVR Movie Template"), "DVR Movie Template", "Movies/{title} ({year}).mkv"), + (slugify("DVR TV Template"), "DVR TV Template", "Recordings/TV_Shows/{show}/S{season:02d}E{episode:02d}.mkv"), + (slugify("DVR Movie Template"), "DVR Movie Template", "Recordings/Movies/{title} ({year}).mkv"), + (slugify("DVR TV Fallback Template"), "DVR TV Fallback Template", "Recordings/TV_Shows/{show}/{start}.mkv"), + (slugify("DVR Movie Fallback Template"), "DVR Movie Fallback Template", "Recordings/Movies/{start}.mkv"), + # Legacy support (older builds looked up a fallback folder name) + (slugify("DVR TV Fallback Dir"), "DVR TV Fallback Dir", "TV_Shows"), ] for key, name, value in defaults: diff --git a/core/models.py b/core/models.py index e8620bc2..3d8043a0 100644 --- a/core/models.py +++ b/core/models.py @@ -154,6 +154,9 @@ PROXY_SETTINGS_KEY = slugify("Proxy Settings") DVR_TV_TEMPLATE_KEY = slugify("DVR TV Template") DVR_MOVIE_TEMPLATE_KEY = slugify("DVR Movie Template") DVR_SERIES_RULES_KEY = slugify("DVR Series Rules") +DVR_TV_FALLBACK_DIR_KEY = slugify("DVR TV Fallback Dir") +DVR_TV_FALLBACK_TEMPLATE_KEY = slugify("DVR TV Fallback Template") +DVR_MOVIE_FALLBACK_TEMPLATE_KEY = slugify("DVR Movie Fallback Template") class CoreSettings(models.Model): @@ -232,6 +235,33 @@ class CoreSettings(models.Model): except cls.DoesNotExist: return "Movies/{title} ({year}).mkv" + @classmethod + def get_dvr_tv_fallback_dir(cls): + """Folder name to use when a TV episode has no season/episode information. + Defaults to 'TV_Show' to match existing behavior but can be overridden in settings. + """ + try: + return cls.objects.get(key=DVR_TV_FALLBACK_DIR_KEY).value or "TV_Shows" + except cls.DoesNotExist: + return "TV_Shows" + + @classmethod + def get_dvr_tv_fallback_template(cls): + """Full path template used when season/episode are missing for a TV airing.""" + try: + return cls.objects.get(key=DVR_TV_FALLBACK_TEMPLATE_KEY).value + except cls.DoesNotExist: + # default requested by user + return "Recordings/TV_Shows/{show}/{start}.mkv" + + @classmethod + def get_dvr_movie_fallback_template(cls): + """Full path template used when movie metadata is incomplete.""" + try: + return cls.objects.get(key=DVR_MOVIE_FALLBACK_TEMPLATE_KEY).value + except cls.DoesNotExist: + return "Recordings/Movies/{start}.mkv" + @classmethod def get_dvr_series_rules(cls): """Return list of series recording rules. Each: {tvg_id, title, mode: 'all'|'new'}""" diff --git a/docker/docker-compose.aio.yml b/docker/docker-compose.aio.yml index 90cd8654..0cf387d5 100644 --- a/docker/docker-compose.aio.yml +++ b/docker/docker-compose.aio.yml @@ -9,6 +9,7 @@ services: - 9191:9191 volumes: - dispatcharr_data:/data + - ./data:/data environment: - DISPATCHARR_ENV=aio - REDIS_HOST=localhost diff --git a/docker/docker-compose.dev.yml b/docker/docker-compose.dev.yml index 4f42e1b0..00394d55 100644 --- a/docker/docker-compose.dev.yml +++ b/docker/docker-compose.dev.yml @@ -11,7 +11,7 @@ services: - 8001:8001 volumes: - ../:/app - # - ./data/db:/data + - ./data:/data environment: - DISPATCHARR_ENV=dev - REDIS_HOST=localhost diff --git a/docker/docker-compose.yml b/docker/docker-compose.yml index d195fbdc..88f9fc84 100644 --- a/docker/docker-compose.yml +++ b/docker/docker-compose.yml @@ -4,6 +4,8 @@ services: container_name: dispatcharr_web ports: - 9191:9191 + volumes: + - ./data:/data depends_on: - db - redis diff --git a/frontend/src/api.js b/frontend/src/api.js index b5b1ca7e..b099a155 100644 --- a/frontend/src/api.js +++ b/frontend/src/api.js @@ -1251,6 +1251,19 @@ export default class API { } } + static async createSetting(values) { + try { + const response = await request(`${host}/api/core/settings/`, { + method: 'POST', + body: values, + }); + useSettingsStore.getState().updateSetting(response); + return response; + } catch (e) { + errorNotification('Failed to create setting', e); + } + } + static async getChannelStats(uuid = null) { try { const response = await request(`${host}/proxy/ts/status`); diff --git a/frontend/src/pages/DVR.jsx b/frontend/src/pages/DVR.jsx index 84c4e616..ca72134c 100644 --- a/frontend/src/pages/DVR.jsx +++ b/frontend/src/pages/DVR.jsx @@ -66,6 +66,7 @@ const RecordingDetailsModal = ({ opened, onClose, recording, channel, posterUrl, const customProps = recording.custom_properties || {}; const program = customProps.program || {}; const recordingName = program.title || 'Custom Recording'; + const subTitle = program.sub_title || ''; const description = program.description || customProps.description || ''; const start = dayjs(recording.start_time); const end = dayjs(recording.end_time); @@ -181,7 +182,7 @@ const RecordingDetailsModal = ({ opened, onClose, recording, channel, posterUrl, @@ -285,6 +286,7 @@ const RecordingCard = ({ recording, category, onOpenDetails }) => { const channels = useChannelsStore((s) => s.channels); const env_mode = useSettingsStore((s) => s.environment.env_mode); const showVideo = useVideoStore((s) => s.showVideo); + const fetchRecordings = useChannelsStore((s) => s.fetchRecordings); const channel = channels?.[recording.channel]; @@ -295,6 +297,7 @@ const RecordingCard = ({ recording, category, onOpenDetails }) => { const customProps = recording.custom_properties || {}; const program = customProps.program || {}; const recordingName = program.title || 'Custom Recording'; + const subTitle = program.sub_title || ''; const description = program.description || customProps.description || ''; // Poster or channel logo @@ -341,6 +344,47 @@ const RecordingCard = ({ recording, category, onOpenDetails }) => { showVideo(fileUrl, 'vod', { name: recordingName, logo: { url: posterUrl } }); }; + // Cancel handling for series groups + const [cancelOpen, setCancelOpen] = React.useState(false); + const [busy, setBusy] = React.useState(false); + const handleCancelClick = (e) => { + e.stopPropagation(); + if (isSeriesGroup) setCancelOpen(true); + else deleteRecording(recording.id); + }; + + const seriesInfo = React.useMemo(() => { + const cp = customProps || {}; + const pr = cp.program || {}; + return { tvg_id: pr.tvg_id, title: pr.title }; + }, [customProps]); + + const removeUpcomingOnly = async () => { + try { + setBusy(true); + await API.deleteRecording(recording.id); + } finally { + setBusy(false); + setCancelOpen(false); + try { await fetchRecordings(); } catch {} + } + }; + + const removeSeriesAndRule = async () => { + try { + setBusy(true); + const { tvg_id, title } = seriesInfo; + if (tvg_id) { + try { await API.bulkRemoveSeriesRecordings({ tvg_id, title, scope: 'title' }); } catch {} + try { await API.deleteSeriesRule(tvg_id); } catch {} + } + } finally { + setBusy(false); + setCancelOpen(false); + try { await fetchRecordings(); } catch {} + } + }; + const MainCard = ( { onClick={() => onOpenDetails?.(recording)} > - + {isInterrupted ? 'Interrupted' : isInProgress ? 'Recording' : isUpcoming ? 'Scheduled' : 'Completed'} {isInterrupted && } - - {recordingName} - - {isSeriesGroup && ( - Series - )} - {seLabel && !isSeriesGroup && ( - {seLabel} - )} + + + + {recordingName} + + {isSeriesGroup && ( + Series + )} + {seLabel && !isSeriesGroup && ( + {seLabel} + )} + +
@@ -378,7 +426,7 @@ const RecordingCard = ({ recording, category, onOpenDetails }) => { { e.stopPropagation(); deleteRecording(recording.id); }} + onClick={handleCancelClick} > @@ -397,6 +445,12 @@ const RecordingCard = ({ recording, category, onOpenDetails }) => { fallbackSrc="/logo.png" /> + {!isSeriesGroup && subTitle && ( + + Episode + {subTitle} + + )} Channel @@ -408,7 +462,7 @@ const RecordingCard = ({ recording, category, onOpenDetails }) => { - Time + {isSeriesGroup ? 'Next recording' : 'Time'} {start.format('MMM D, YYYY h:mma')} – {end.format('h:mma')} @@ -456,6 +510,15 @@ const RecordingCard = ({ recording, category, onOpenDetails }) => { // Stacked look for series groups: render two shadow layers behind the main card return ( + setCancelOpen(false)} title="Cancel Series" centered size="md" zIndex={9999}> + + This is a series rule. What would you like to cancel? + + + + + + { 'preferred-region': '', 'auto-import-mapped-files': true, 'm3u-hash-key': [], + 'dvr-tv-template': '', + 'dvr-movie-template': '', + 'dvr-tv-fallback-template': '', + 'dvr-movie-fallback-template': '', }, validate: { @@ -171,8 +175,13 @@ const SettingsPage = () => { let m3uHashKeyChanged = false; for (const settingKey in values) { - // If the user changed the setting's value from what's in the DB: - if (String(values[settingKey]) !== String(settings[settingKey].value)) { + // Only compare against existing value if the setting exists + const existing = settings[settingKey]; + if (!existing) { + // Create new setting on save + changedSettings[settingKey] = `${values[settingKey]}`; + } else if (String(values[settingKey]) !== String(existing.value)) { + // If the user changed the setting's value from what's in the DB: changedSettings[settingKey] = `${values[settingKey]}`; // Check if M3U hash key was changed @@ -189,12 +198,21 @@ const SettingsPage = () => { return; } - // Update each changed setting in the backend + // Update each changed setting in the backend (create if missing) for (const updatedKey in changedSettings) { - await API.updateSetting({ - ...settings[updatedKey], - value: changedSettings[updatedKey], - }); + const existing = settings[updatedKey]; + if (existing && existing.id) { + await API.updateSetting({ + ...existing, + value: changedSettings[updatedKey], + }); + } else { + await API.createSetting({ + key: updatedKey, + name: updatedKey.replace(/-/g, ' '), + value: changedSettings[updatedKey], + }); + } } }; @@ -417,6 +435,15 @@ const SettingsPage = () => { id={settings['dvr-tv-template']?.id || 'dvr-tv-template'} name={settings['dvr-tv-template']?.key || 'dvr-tv-template'} /> + { id={settings['dvr-movie-template']?.id || 'dvr-movie-template'} name={settings['dvr-movie-template']?.key || 'dvr-movie-template'} /> +