mirror of
https://github.com/Dispatcharr/Dispatcharr.git
synced 2026-01-23 02:35:14 +00:00
Added ability to edit
This commit is contained in:
parent
cd6e31acfa
commit
b12b5c6b22
3 changed files with 371 additions and 9 deletions
|
|
@ -143,7 +143,7 @@ def _download_poster(media_item: MediaItem, source_url: str) -> tuple[str, str]
|
|||
return stored_path, _build_media_url(stored_path)
|
||||
|
||||
|
||||
def _ensure_logo_for_media_item(media_item: MediaItem) -> tuple[Logo | None, dict[str, Any] | None]:
|
||||
def _ensure_logo_for_media_item(media_item: MediaItem) -> tuple[Logo | None, dict[str, Any] | None, str | None]:
|
||||
metadata = dict(media_item.metadata or {})
|
||||
|
||||
poster_source = media_item.poster_url or None
|
||||
|
|
@ -153,7 +153,7 @@ def _ensure_logo_for_media_item(media_item: MediaItem) -> tuple[Logo | None, dic
|
|||
poster_source = poster_asset.local_path or poster_asset.external_url
|
||||
|
||||
if not poster_source:
|
||||
return None, None
|
||||
return None, None, None
|
||||
|
||||
stored_path = metadata.get("vod_poster_path")
|
||||
stored_url = metadata.get("vod_poster_url")
|
||||
|
|
@ -173,7 +173,7 @@ def _ensure_logo_for_media_item(media_item: MediaItem) -> tuple[Logo | None, dic
|
|||
stored_source = poster_source
|
||||
|
||||
if not stored_path or not stored_url:
|
||||
return None, None
|
||||
return None, None, None
|
||||
|
||||
metadata_updates = metadata.copy()
|
||||
metadata_updates.update(
|
||||
|
|
@ -186,7 +186,7 @@ def _ensure_logo_for_media_item(media_item: MediaItem) -> tuple[Logo | None, dic
|
|||
|
||||
logo_name = (media_item.title or "Library Asset")[:255]
|
||||
logo, _ = Logo.objects.get_or_create(url=stored_url, defaults={"name": logo_name})
|
||||
return logo, metadata_updates
|
||||
return logo, metadata_updates, poster_source
|
||||
|
||||
|
||||
def _clean_local_poster(media_item: MediaItem) -> None:
|
||||
|
|
@ -235,7 +235,7 @@ def _update_movie_from_media_item(movie: Movie, media_item: MediaItem) -> Movie:
|
|||
fields_to_update.append("imdb_id")
|
||||
|
||||
quality_info = _collect_quality_info(media_item) or {}
|
||||
logo, metadata_updates = _ensure_logo_for_media_item(media_item)
|
||||
logo, metadata_updates, poster_source = _ensure_logo_for_media_item(media_item)
|
||||
if metadata_updates is not None:
|
||||
current_metadata = media_item.metadata or {}
|
||||
if metadata_updates != current_metadata:
|
||||
|
|
@ -244,11 +244,14 @@ def _update_movie_from_media_item(movie: Movie, media_item: MediaItem) -> Movie:
|
|||
poster_media_url = (
|
||||
metadata_updates.get("vod_poster_url") if metadata_updates else None
|
||||
)
|
||||
poster_source_url = (
|
||||
metadata_updates.get("vod_poster_source_url") if metadata_updates else poster_source
|
||||
)
|
||||
custom_updates = {
|
||||
"source": "library",
|
||||
"library_id": media_item.library_id,
|
||||
"library_item_id": media_item.id,
|
||||
"poster_url": poster_media_url or media_item.poster_url,
|
||||
"poster_url": poster_media_url or poster_source_url or media_item.poster_url,
|
||||
"backdrop_url": media_item.backdrop_url,
|
||||
"quality": quality_info,
|
||||
}
|
||||
|
|
@ -289,7 +292,7 @@ def _update_series_from_media_item(series: Series, media_item: MediaItem) -> Ser
|
|||
series.imdb_id = media_item.imdb_id
|
||||
fields_to_update.append("imdb_id")
|
||||
|
||||
logo, metadata_updates = _ensure_logo_for_media_item(media_item)
|
||||
logo, metadata_updates, poster_source = _ensure_logo_for_media_item(media_item)
|
||||
if metadata_updates is not None:
|
||||
current_metadata = media_item.metadata or {}
|
||||
if metadata_updates != current_metadata:
|
||||
|
|
@ -298,12 +301,15 @@ def _update_series_from_media_item(series: Series, media_item: MediaItem) -> Ser
|
|||
poster_media_url = (
|
||||
metadata_updates.get("vod_poster_url") if metadata_updates else None
|
||||
)
|
||||
poster_source_url = (
|
||||
metadata_updates.get("vod_poster_source_url") if metadata_updates else poster_source
|
||||
)
|
||||
|
||||
custom_updates = {
|
||||
"source": "library",
|
||||
"library_id": media_item.library_id,
|
||||
"library_item_id": media_item.id,
|
||||
"poster_url": poster_media_url or media_item.poster_url,
|
||||
"poster_url": poster_media_url or poster_source_url or media_item.poster_url,
|
||||
"backdrop_url": media_item.backdrop_url,
|
||||
}
|
||||
merged_custom = _merge_custom_properties(series.custom_properties, custom_updates)
|
||||
|
|
|
|||
|
|
@ -26,11 +26,15 @@ import {
|
|||
RefreshCcw,
|
||||
Undo2,
|
||||
Trash2,
|
||||
Pencil,
|
||||
} from 'lucide-react';
|
||||
|
||||
import API from '../../api';
|
||||
import useMediaLibraryStore from '../../store/mediaLibrary';
|
||||
import useVideoStore from '../../store/useVideoStore';
|
||||
import useAuthStore from '../../store/auth';
|
||||
import { USER_LEVELS } from '../../constants';
|
||||
import MediaEditModal from './MediaEditModal';
|
||||
|
||||
// ---- quick tuning knobs ----
|
||||
const CAST_TILE_WIDTH = 96; // was 116
|
||||
|
|
@ -59,10 +63,13 @@ const MediaDetailModal = ({ opened, onClose }) => {
|
|||
const clearResumePrompt = useMediaLibraryStore((s) => s.clearResumePrompt);
|
||||
const setActiveProgress = useMediaLibraryStore((s) => s.setActiveProgress);
|
||||
const showVideo = useVideoStore((s) => s.showVideo);
|
||||
const userLevel = useAuthStore((s) => s.user?.user_level ?? 0);
|
||||
const canEditMetadata = userLevel >= USER_LEVELS.ADMIN;
|
||||
|
||||
const [startingPlayback, setStartingPlayback] = useState(false);
|
||||
const [resumeModalOpen, setResumeModalOpen] = useState(false);
|
||||
const [resumeMode, setResumeMode] = useState('start');
|
||||
const [editModalOpen, setEditModalOpen] = useState(false);
|
||||
|
||||
const [episodes, setEpisodes] = useState([]);
|
||||
const [episodesLoading, setEpisodesLoading] = useState(false);
|
||||
|
|
@ -109,6 +116,10 @@ const MediaDetailModal = ({ opened, onClose }) => {
|
|||
return useMediaLibraryStore.getState().openItem(activeItem.id);
|
||||
}, [activeItem]);
|
||||
|
||||
const handleEditSaved = useCallback(async () => {
|
||||
await refreshActiveItem();
|
||||
}, [refreshActiveItem]);
|
||||
|
||||
const orderedEpisodes = useMemo(() => {
|
||||
if (!episodes || episodes.length === 0) return [];
|
||||
return [...episodes].sort((a, b) => {
|
||||
|
|
@ -171,6 +182,7 @@ const MediaDetailModal = ({ opened, onClose }) => {
|
|||
setEpisodes([]);
|
||||
setEpisodesLoading(false);
|
||||
setEpisodePlayLoadingId(null);
|
||||
setEditModalOpen(false);
|
||||
return;
|
||||
}
|
||||
if (activeItem?.item_type === 'show') {
|
||||
|
|
@ -491,7 +503,23 @@ const MediaDetailModal = ({ opened, onClose }) => {
|
|||
size="xl"
|
||||
overlayProps={{ backgroundOpacity: 0.55, blur: 4 }}
|
||||
padding="md"
|
||||
title={activeItem ? activeItem.title : 'Media details'}
|
||||
title={
|
||||
<Group justify="space-between" align="center" gap="xs">
|
||||
<Text fw={600} truncate>
|
||||
{activeItem ? activeItem.title : 'Media details'}
|
||||
</Text>
|
||||
{canEditMetadata && activeItem && (
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
color="blue"
|
||||
title="Edit metadata"
|
||||
onClick={() => setEditModalOpen(true)}
|
||||
>
|
||||
<Pencil size={16} />
|
||||
</ActionIcon>
|
||||
)}
|
||||
</Group>
|
||||
}
|
||||
>
|
||||
{activeItemLoading ? (
|
||||
<Group justify="center" py="xl">
|
||||
|
|
@ -893,6 +921,14 @@ const MediaDetailModal = ({ opened, onClose }) => {
|
|||
</ScrollArea>
|
||||
)}
|
||||
</Modal>
|
||||
{canEditMetadata && activeItem && (
|
||||
<MediaEditModal
|
||||
opened={editModalOpen}
|
||||
onClose={() => setEditModalOpen(false)}
|
||||
mediaItemId={activeItem.id}
|
||||
onSaved={handleEditSaved}
|
||||
/>
|
||||
)}
|
||||
|
||||
<Modal
|
||||
opened={resumeModalOpen}
|
||||
|
|
|
|||
320
frontend/src/components/library/MediaEditModal.jsx
Normal file
320
frontend/src/components/library/MediaEditModal.jsx
Normal file
|
|
@ -0,0 +1,320 @@
|
|||
import React, { useEffect, useMemo, useState } from 'react';
|
||||
import {
|
||||
Button,
|
||||
Group,
|
||||
Loader,
|
||||
Modal,
|
||||
NumberInput,
|
||||
Stack,
|
||||
Text,
|
||||
TextInput,
|
||||
Textarea,
|
||||
} from '@mantine/core';
|
||||
import { useForm } from '@mantine/form';
|
||||
import { RefreshCcw } from 'lucide-react';
|
||||
import { notifications } from '@mantine/notifications';
|
||||
|
||||
import API from '../../api';
|
||||
import useMediaLibraryStore from '../../store/mediaLibrary';
|
||||
|
||||
const emptyValues = {
|
||||
title: '',
|
||||
synopsis: '',
|
||||
release_year: null,
|
||||
rating: '',
|
||||
genres: '',
|
||||
tags: '',
|
||||
studios: '',
|
||||
tmdb_id: '',
|
||||
imdb_id: '',
|
||||
poster_url: '',
|
||||
backdrop_url: '',
|
||||
};
|
||||
|
||||
const listToString = (value) =>
|
||||
Array.isArray(value) && value.length > 0 ? value.join(', ') : '';
|
||||
|
||||
const toList = (value) =>
|
||||
typeof value === 'string'
|
||||
? value
|
||||
.split(',')
|
||||
.map((entry) => entry.trim())
|
||||
.filter(Boolean)
|
||||
: Array.isArray(value)
|
||||
? value
|
||||
: [];
|
||||
|
||||
const MediaEditModal = ({ opened, onClose, mediaItemId, onSaved }) => {
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [applyingTmdb, setApplyingTmdb] = useState(false);
|
||||
const [mediaItem, setMediaItem] = useState(null);
|
||||
|
||||
const form = useForm({
|
||||
initialValues: emptyValues,
|
||||
});
|
||||
|
||||
const populateForm = (item) => {
|
||||
form.setValues({
|
||||
title: item.title || '',
|
||||
synopsis: item.synopsis || '',
|
||||
release_year: item.release_year || null,
|
||||
rating: item.rating || '',
|
||||
genres: listToString(item.genres),
|
||||
tags: listToString(item.tags),
|
||||
studios: listToString(item.studios),
|
||||
tmdb_id: item.tmdb_id || '',
|
||||
imdb_id: item.imdb_id || '',
|
||||
poster_url: item.poster_url || '',
|
||||
backdrop_url: item.backdrop_url || '',
|
||||
});
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!opened || !mediaItemId) {
|
||||
setMediaItem(null);
|
||||
form.setValues(emptyValues);
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
API.getMediaItem(mediaItemId)
|
||||
.then((item) => {
|
||||
setMediaItem(item);
|
||||
populateForm(item);
|
||||
})
|
||||
.catch((error) => {
|
||||
notifications.show({
|
||||
color: 'red',
|
||||
title: 'Failed to load media item',
|
||||
message: error.message || 'Unable to load media item details.',
|
||||
});
|
||||
})
|
||||
.finally(() => setLoading(false));
|
||||
}, [opened, mediaItemId]);
|
||||
|
||||
const handleApplyTmdb = async () => {
|
||||
if (!form.values.tmdb_id) {
|
||||
notifications.show({
|
||||
color: 'yellow',
|
||||
title: 'TMDB ID required',
|
||||
message: 'Enter a TMDB ID before applying metadata.',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
setApplyingTmdb(true);
|
||||
try {
|
||||
const updated = await API.setMediaItemTMDB(
|
||||
mediaItemId,
|
||||
form.values.tmdb_id
|
||||
);
|
||||
setMediaItem(updated);
|
||||
populateForm(updated);
|
||||
useMediaLibraryStore.getState().openItem(mediaItemId);
|
||||
notifications.show({
|
||||
color: 'green',
|
||||
title: 'Metadata updated',
|
||||
message: 'TMDB details applied successfully.',
|
||||
});
|
||||
} catch (error) {
|
||||
// errorNotification already handled in API helper
|
||||
} finally {
|
||||
setApplyingTmdb(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSubmit = async (values) => {
|
||||
if (!mediaItem) return;
|
||||
|
||||
const payload = {};
|
||||
|
||||
const assignIfChanged = (field, value) => {
|
||||
const current = mediaItem[field];
|
||||
const normalizedValue = value ?? '';
|
||||
const normalizedCurrent = current ?? '';
|
||||
|
||||
if (normalizedValue === '' && normalizedCurrent === '') {
|
||||
return;
|
||||
}
|
||||
|
||||
if (normalizedValue === '' && normalizedCurrent !== '') {
|
||||
payload[field] = '';
|
||||
return;
|
||||
}
|
||||
|
||||
if (normalizedValue !== normalizedCurrent) {
|
||||
payload[field] = value;
|
||||
}
|
||||
};
|
||||
|
||||
assignIfChanged('title', values.title);
|
||||
assignIfChanged('synopsis', values.synopsis);
|
||||
if (values.release_year !== mediaItem.release_year) {
|
||||
payload.release_year = values.release_year || null;
|
||||
}
|
||||
assignIfChanged('rating', values.rating);
|
||||
const genresList = toList(values.genres);
|
||||
if (JSON.stringify(genresList) !== JSON.stringify(mediaItem.genres || [])) {
|
||||
payload.genres = genresList;
|
||||
}
|
||||
const tagsList = toList(values.tags);
|
||||
if (JSON.stringify(tagsList) !== JSON.stringify(mediaItem.tags || [])) {
|
||||
payload.tags = tagsList;
|
||||
}
|
||||
const studiosList = toList(values.studios);
|
||||
if (JSON.stringify(studiosList) !== JSON.stringify(mediaItem.studios || [])) {
|
||||
payload.studios = studiosList;
|
||||
}
|
||||
assignIfChanged('tmdb_id', values.tmdb_id);
|
||||
assignIfChanged('imdb_id', values.imdb_id);
|
||||
assignIfChanged('poster_url', values.poster_url);
|
||||
assignIfChanged('backdrop_url', values.backdrop_url);
|
||||
|
||||
// Remove unchanged keys
|
||||
Object.keys(payload).forEach((key) => {
|
||||
const value = payload[key];
|
||||
if (
|
||||
value === undefined ||
|
||||
(Array.isArray(value) && value.length === 0 && !['genres', 'tags', 'studios'].includes(key))
|
||||
) {
|
||||
delete payload[key];
|
||||
}
|
||||
});
|
||||
|
||||
if (Object.keys(payload).length === 0) {
|
||||
notifications.show({
|
||||
color: 'blue',
|
||||
title: 'No changes detected',
|
||||
message: 'Update the fields before saving.',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
setSaving(true);
|
||||
try {
|
||||
const updated = await API.updateMediaItem(mediaItemId, payload);
|
||||
setMediaItem(updated);
|
||||
populateForm(updated);
|
||||
useMediaLibraryStore.getState().openItem(mediaItemId);
|
||||
|
||||
if (typeof onSaved === 'function') {
|
||||
await onSaved(updated);
|
||||
}
|
||||
|
||||
notifications.show({
|
||||
color: 'green',
|
||||
title: 'Media item saved',
|
||||
message: 'Changes were applied successfully.',
|
||||
});
|
||||
onClose();
|
||||
} catch (error) {
|
||||
// errorNotification already displayed
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const modalTitle = useMemo(() => {
|
||||
if (!mediaItem) return 'Edit Media';
|
||||
return `Edit ${mediaItem.title || 'Media'}`;
|
||||
}, [mediaItem]);
|
||||
|
||||
return (
|
||||
<Modal opened={opened} onClose={onClose} title={modalTitle} size="lg" centered>
|
||||
{loading ? (
|
||||
<Group justify="center" py="xl">
|
||||
<Loader />
|
||||
</Group>
|
||||
) : !mediaItem ? (
|
||||
<Text size="sm" c="dimmed">
|
||||
Unable to load media item details.
|
||||
</Text>
|
||||
) : (
|
||||
<form onSubmit={form.onSubmit(handleSubmit)}>
|
||||
<Stack gap="md">
|
||||
<TextInput
|
||||
label="Title"
|
||||
placeholder="Movie title"
|
||||
{...form.getInputProps('title')}
|
||||
/>
|
||||
<Textarea
|
||||
label="Synopsis"
|
||||
placeholder="Plot summary"
|
||||
minRows={3}
|
||||
{...form.getInputProps('synopsis')}
|
||||
/>
|
||||
<Group grow>
|
||||
<NumberInput
|
||||
label="Release Year"
|
||||
min={1895}
|
||||
max={3000}
|
||||
{...form.getInputProps('release_year')}
|
||||
/>
|
||||
<TextInput
|
||||
label="Rating"
|
||||
placeholder="PG-13"
|
||||
{...form.getInputProps('rating')}
|
||||
/>
|
||||
</Group>
|
||||
<TextInput
|
||||
label="Genres"
|
||||
placeholder="Comma separated"
|
||||
{...form.getInputProps('genres')}
|
||||
/>
|
||||
<TextInput
|
||||
label="Tags"
|
||||
placeholder="Comma separated"
|
||||
{...form.getInputProps('tags')}
|
||||
/>
|
||||
<TextInput
|
||||
label="Studios"
|
||||
placeholder="Comma separated"
|
||||
{...form.getInputProps('studios')}
|
||||
/>
|
||||
<Group grow align="end">
|
||||
<TextInput
|
||||
label="TMDB ID"
|
||||
placeholder="Enter TMDB ID"
|
||||
{...form.getInputProps('tmdb_id')}
|
||||
/>
|
||||
<Button
|
||||
variant="light"
|
||||
leftSection={<RefreshCcw size={14} />}
|
||||
onClick={handleApplyTmdb}
|
||||
loading={applyingTmdb}
|
||||
>
|
||||
Apply TMDB Metadata
|
||||
</Button>
|
||||
</Group>
|
||||
<TextInput
|
||||
label="IMDB ID"
|
||||
placeholder="tt1234567"
|
||||
{...form.getInputProps('imdb_id')}
|
||||
/>
|
||||
<TextInput
|
||||
label="Poster URL"
|
||||
placeholder="https://..."
|
||||
{...form.getInputProps('poster_url')}
|
||||
/>
|
||||
<TextInput
|
||||
label="Backdrop URL"
|
||||
placeholder="https://..."
|
||||
{...form.getInputProps('backdrop_url')}
|
||||
/>
|
||||
<Group justify="flex-end" mt="md">
|
||||
<Button variant="default" onClick={onClose} type="button">
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="submit" loading={saving}>
|
||||
Save Changes
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</form>
|
||||
)}
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
export default MediaEditModal;
|
||||
Loading…
Add table
Add a link
Reference in a new issue