mirror of
https://github.com/Dispatcharr/Dispatcharr.git
synced 2026-01-23 02:35:14 +00:00
DVR Updates
Added fallback settings. Added subtitles to cards. Add data volume mount to Docker container.
This commit is contained in:
parent
00cc83882a
commit
41e32bc08a
9 changed files with 196 additions and 30 deletions
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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'}"""
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ services:
|
|||
- 9191:9191
|
||||
volumes:
|
||||
- dispatcharr_data:/data
|
||||
- ./data:/data
|
||||
environment:
|
||||
- DISPATCHARR_ENV=aio
|
||||
- REDIS_HOST=localhost
|
||||
|
|
|
|||
|
|
@ -11,7 +11,7 @@ services:
|
|||
- 8001:8001
|
||||
volumes:
|
||||
- ../:/app
|
||||
# - ./data/db:/data
|
||||
- ./data:/data
|
||||
environment:
|
||||
- DISPATCHARR_ENV=dev
|
||||
- REDIS_HOST=localhost
|
||||
|
|
|
|||
|
|
@ -4,6 +4,8 @@ services:
|
|||
container_name: dispatcharr_web
|
||||
ports:
|
||||
- 9191:9191
|
||||
volumes:
|
||||
- ./data:/data
|
||||
depends_on:
|
||||
- db
|
||||
- redis
|
||||
|
|
|
|||
|
|
@ -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`);
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue