Added ability to edit

This commit is contained in:
Dispatcharr 2025-10-08 12:01:58 -05:00
parent cd6e31acfa
commit b12b5c6b22
3 changed files with 371 additions and 9 deletions

View file

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

View file

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

View 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;