DVR Updates

Added fallback settings.
Added subtitles to cards.
Add data volume mount to Docker container.
This commit is contained in:
Dispatcharr 2025-09-04 08:22:13 -05:00
parent 00cc83882a
commit 41e32bc08a
9 changed files with 196 additions and 30 deletions

View file

@ -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/<show>/<start>.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]

View file

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

View file

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

View file

@ -9,6 +9,7 @@ services:
- 9191:9191
volumes:
- dispatcharr_data:/data
- ./data:/data
environment:
- DISPATCHARR_ENV=aio
- REDIS_HOST=localhost

View file

@ -11,7 +11,7 @@ services:
- 8001:8001
volumes:
- ../:/app
# - ./data/db:/data
- ./data:/data
environment:
- DISPATCHARR_ENV=dev
- REDIS_HOST=localhost

View file

@ -4,6 +4,8 @@ services:
container_name: dispatcharr_web
ports:
- 9191:9191
volumes:
- ./data:/data
depends_on:
- db
- redis

View file

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

View file

@ -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,
<Modal
opened={opened}
onClose={onClose}
title={isSeriesGroup ? `Series: ${recordingName}` : recordingName}
title={isSeriesGroup ? `Series: ${recordingName}` : `${recordingName}${program.sub_title ? ` - ${program.sub_title}` : ''}`}
size="lg"
centered
radius="md"
@ -189,7 +190,7 @@ const RecordingDetailsModal = ({ opened, onClose, recording, channel, posterUrl,
overlayProps={{ color: '#000', backgroundOpacity: 0.55, blur: 0 }}
styles={{
content: { backgroundColor: '#18181B', color: 'white' },
header: { backgroundColor: '#18181B', color: 'white', borderBottom: '1px solid #27272A' },
header: { backgroundColor: '#18181B', color: 'white' },
title: { color: 'white' },
}}
>
@ -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 = (
<Card
shadow="sm"
@ -357,20 +401,24 @@ const RecordingCard = ({ recording, category, onOpenDetails }) => {
onClick={() => onOpenDetails?.(recording)}
>
<Flex justify="space-between" align="center" style={{ paddingBottom: 8 }}>
<Group gap={8}>
<Group gap={8} style={{ flex: 1, minWidth: 0 }}>
<Badge color={isInterrupted ? 'red.7' : isInProgress ? 'red.6' : isUpcoming ? 'yellow.6' : 'gray.6'}>
{isInterrupted ? 'Interrupted' : isInProgress ? 'Recording' : isUpcoming ? 'Scheduled' : 'Completed'}
</Badge>
{isInterrupted && <AlertTriangle size={16} color="#ffa94d" />}
<Text fw={600} lineClamp={1} title={recordingName}>
{recordingName}
</Text>
{isSeriesGroup && (
<Badge color="teal" variant="filled">Series</Badge>
)}
{seLabel && !isSeriesGroup && (
<Badge color="gray" variant="light">{seLabel}</Badge>
)}
<Stack gap={2} style={{ flex: 1, minWidth: 0 }}>
<Group gap={8} wrap="nowrap">
<Text fw={600} lineClamp={1} title={recordingName}>
{recordingName}
</Text>
{isSeriesGroup && (
<Badge color="teal" variant="filled">Series</Badge>
)}
{seLabel && !isSeriesGroup && (
<Badge color="gray" variant="light">{seLabel}</Badge>
)}
</Group>
</Stack>
</Group>
<Center>
@ -378,7 +426,7 @@ const RecordingCard = ({ recording, category, onOpenDetails }) => {
<ActionIcon
variant="transparent"
color="red.9"
onClick={(e) => { e.stopPropagation(); deleteRecording(recording.id); }}
onClick={handleCancelClick}
>
<SquareX size="20" />
</ActionIcon>
@ -397,6 +445,12 @@ const RecordingCard = ({ recording, category, onOpenDetails }) => {
fallbackSrc="/logo.png"
/>
<Stack gap={6} style={{ flex: 1 }}>
{!isSeriesGroup && subTitle && (
<Group justify="space-between">
<Text size="sm" c="dimmed">Episode</Text>
<Text size="sm" fw={700} title={subTitle}>{subTitle}</Text>
</Group>
)}
<Group justify="space-between">
<Text size="sm" c="dimmed">
Channel
@ -408,7 +462,7 @@ const RecordingCard = ({ recording, category, onOpenDetails }) => {
<Group justify="space-between">
<Text size="sm" c="dimmed">
Time
{isSeriesGroup ? 'Next recording' : 'Time'}
</Text>
<Text size="sm">{start.format('MMM D, YYYY h:mma')} {end.format('h:mma')}</Text>
</Group>
@ -456,6 +510,15 @@ const RecordingCard = ({ recording, category, onOpenDetails }) => {
// Stacked look for series groups: render two shadow layers behind the main card
return (
<Box style={{ position: 'relative' }}>
<Modal opened={cancelOpen} onClose={() => setCancelOpen(false)} title="Cancel Series" centered size="md" zIndex={9999}>
<Stack gap="sm">
<Text>This is a series rule. What would you like to cancel?</Text>
<Group justify="flex-end">
<Button variant="default" loading={busy} onClick={removeUpcomingOnly}>Only this upcoming</Button>
<Button color="red" loading={busy} onClick={removeSeriesAndRule}>Entire series + rule</Button>
</Group>
</Stack>
</Modal>
<Box
style={{
position: 'absolute',

View file

@ -72,6 +72,10 @@ const SettingsPage = () => {
'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'}
/>
<TextInput
label="TV Fallback Template"
description="Template used when an episode has no season/episode. Supports {show}, {start}, {end}, {channel}, {year}."
placeholder="Recordings/TV_Shows/{show}/{start}.mkv"
{...form.getInputProps('dvr-tv-fallback-template')}
key={form.key('dvr-tv-fallback-template')}
id={settings['dvr-tv-fallback-template']?.id || 'dvr-tv-fallback-template'}
name={settings['dvr-tv-fallback-template']?.key || 'dvr-tv-fallback-template'}
/>
<TextInput
label="Movie Path Template"
description="Supports {title}, {year}, {channel}, {start}, {end}. Relative paths are under your library dir."
@ -426,6 +453,15 @@ const SettingsPage = () => {
id={settings['dvr-movie-template']?.id || 'dvr-movie-template'}
name={settings['dvr-movie-template']?.key || 'dvr-movie-template'}
/>
<TextInput
label="Movie Fallback Template"
description="Template used when movie metadata is incomplete. Supports {start}, {end}, {channel}."
placeholder="Recordings/Movies/{start}.mkv"
{...form.getInputProps('dvr-movie-fallback-template')}
key={form.key('dvr-movie-fallback-template')}
id={settings['dvr-movie-fallback-template']?.id || 'dvr-movie-fallback-template'}
name={settings['dvr-movie-fallback-template']?.key || 'dvr-movie-fallback-template'}
/>
<Flex mih={50} gap="xs" justify="flex-end" align="flex-end">
<Button type="submit" variant="default">Save</Button>
</Flex>