add more management capabilities

This commit is contained in:
Garret Patti
2026-04-11 18:33:03 -04:00
parent 1ca90184f5
commit 768c49ef00
16 changed files with 1420 additions and 55 deletions

View File

@@ -37,6 +37,14 @@ export default function TvView({ libraryId }: Props) {
const [confirming, setConfirming] = useState(false)
const [deleting, setDeleting] = useState(false)
const [refreshingMeta, setRefreshingMeta] = useState(false)
const [editingMeta, setEditingMeta] = useState(false)
const [savingMeta, setSavingMeta] = useState(false)
const [editForm, setEditForm] = useState({ title: '', year: '', plot: '', genres: '' })
const [warnRefresh, setWarnRefresh] = useState(false)
const [renaming, setRenaming] = useState(false)
const [renameName, setRenameName] = useState('')
const [renameError, setRenameError] = useState<string | null>(null)
const [renameSaving, setRenameSaving] = useState(false)
const [doomScrollActive, setDoomScrollActive] = useState(false)
const [doomScrollItems, setDoomScrollItems] = useState<DoomScrollItem[]>([])
const [doomScrollLoading, setDoomScrollLoading] = useState(false)
@@ -151,10 +159,10 @@ export default function TvView({ libraryId }: Props) {
.catch(() => setDeleting(false))
}
const handleRefreshSeriesMetadata = () => {
const doRefreshSeriesMetadata = () => {
if (!selectedSeries) return
setRefreshingMeta(true)
setMenuOpen(false)
setWarnRefresh(false)
const itemKey = `${libraryId}:tv_series:${selectedSeries.id}`
fetch(
`/api/nfo-refresh?libraryId=${encodeURIComponent(libraryId)}&itemType=tv_series&itemKey=${encodeURIComponent(itemKey)}`,
@@ -164,6 +172,85 @@ export default function TvView({ libraryId }: Props) {
.finally(() => setRefreshingMeta(false))
}
const handleRefreshSeriesMetadata = () => {
setMenuOpen(false)
if (selectedSeries?.manuallyEdited) {
setWarnRefresh(true)
} else {
doRefreshSeriesMetadata()
}
}
const handleStartEditingMeta = () => {
if (!selectedSeries) return
setMenuOpen(false)
setEditForm({
title: selectedSeries.title,
year: selectedSeries.year?.toString() ?? '',
plot: selectedSeries.plot ?? '',
genres: selectedSeries.genres.join(', '),
})
setEditingMeta(true)
}
const handleSaveSeriesMetadata = () => {
if (!selectedSeries) return
setSavingMeta(true)
const genres = editForm.genres.split(',').map((g) => g.trim()).filter(Boolean)
const yearNum = editForm.year ? parseInt(editForm.year, 10) : null
fetch('/api/metadata', {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
itemKey: selectedSeries.item_key,
title: editForm.title,
year: isNaN(yearNum as number) ? null : yearNum,
plot: editForm.plot || null,
genres,
}),
})
.then(() => { setEditingMeta(false); fetchSeries() })
.finally(() => setSavingMeta(false))
}
const handleStartRename = () => {
if (!selectedSeries) return
setMenuOpen(false)
setRenameName(decodeURIComponent(selectedSeries.id))
setRenameError(null)
setRenaming(true)
}
const handleRename = () => {
if (!selectedSeries) return
const trimmed = renameName.trim()
if (!trimmed) return
setRenameSaving(true)
setRenameError(null)
fetch('/api/rename', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
libraryId,
oldPath: decodeURIComponent(selectedSeries.id),
newName: trimmed,
itemType: 'tv_series',
}),
})
.then(async (res) => {
if (res.status === 409) {
const data = await res.json()
setRenameError(data.error)
return
}
if (!res.ok) throw new Error()
setRenaming(false)
fetchSeries()
})
.catch(() => setRenameError('Rename failed'))
.finally(() => setRenameSaving(false))
}
const handleDoomScroll = async () => {
setDoomScrollLoading(true)
try {
@@ -457,6 +544,24 @@ export default function TvView({ libraryId }: Props) {
>
{refreshingMeta ? 'Refreshing…' : 'Refresh metadata'}
</button>
<button
onClick={handleStartEditingMeta}
className="flex items-center gap-2 w-full px-4 py-2 text-sm text-left transition-colors"
style={{ color: 'var(--text-primary)' }}
onMouseEnter={(e) => ((e.currentTarget as HTMLElement).style.backgroundColor = 'var(--border)')}
onMouseLeave={(e) => ((e.currentTarget as HTMLElement).style.backgroundColor = 'transparent')}
>
Edit metadata
</button>
<button
onClick={handleStartRename}
className="flex items-center gap-2 w-full px-4 py-2 text-sm text-left transition-colors"
style={{ color: 'var(--text-primary)' }}
onMouseEnter={(e) => ((e.currentTarget as HTMLElement).style.backgroundColor = 'var(--border)')}
onMouseLeave={(e) => ((e.currentTarget as HTMLElement).style.backgroundColor = 'transparent')}
>
Rename folder
</button>
<button
onClick={() => { setMenuOpen(false); setConfirming(true) }}
className="flex items-center gap-2 w-full px-4 py-2 text-sm text-left transition-colors"
@@ -470,19 +575,142 @@ export default function TvView({ libraryId }: Props) {
)}
</div>
</div>
{(selectedSeries.year || selectedSeries.genres.length > 0) && (
<div className="flex flex-wrap items-center gap-2 mt-1">
{selectedSeries.year && <span className="text-xs" style={{ color: 'var(--text-secondary)' }}>{selectedSeries.year}</span>}
{selectedSeries.genres.map((g) => (
<span key={g} className="text-xs px-1.5 py-0.5 rounded" style={{ backgroundColor: 'var(--border)', color: 'var(--text-secondary)' }}>{g}</span>
))}
{/* Rename inline input */}
{renaming && (
<div className="flex flex-col gap-2 mt-2">
<div className="flex gap-2">
<input
type="text"
value={renameName}
onChange={(e) => setRenameName(e.target.value)}
onKeyDown={(e) => { if (e.key === 'Enter') handleRename(); if (e.key === 'Escape') setRenaming(false) }}
className="flex-1 px-3 py-1.5 rounded-lg text-sm min-w-0"
style={{ backgroundColor: 'var(--border)', color: 'var(--text-primary)', border: '1px solid var(--border)' }}
autoFocus
/>
<button
onClick={() => setRenaming(false)}
className="px-2 py-1.5 rounded-lg text-sm transition-colors"
style={{ color: 'var(--text-secondary)', backgroundColor: 'var(--border)' }}
>
Cancel
</button>
<button
onClick={handleRename}
disabled={renameSaving}
className="px-3 py-1.5 rounded-lg text-sm font-medium transition-colors disabled:opacity-50"
style={{ backgroundColor: 'var(--accent)', color: '#fff' }}
>
{renameSaving ? '…' : 'Rename'}
</button>
</div>
{renameError && <p className="text-xs" style={{ color: '#fca5a5' }}>{renameError}</p>}
</div>
)}
{selectedSeries.plot && (
<p className="text-sm mt-2 line-clamp-3" style={{ color: 'var(--text-secondary)' }}>{selectedSeries.plot}</p>
{editingMeta ? (
<div className="flex flex-col gap-3 mt-2">
<div>
<label className="text-xs font-medium mb-1 block" style={{ color: 'var(--text-secondary)' }}>Title</label>
<input
type="text"
value={editForm.title}
onChange={(e) => setEditForm((f) => ({ ...f, title: e.target.value }))}
className="w-full px-3 py-1.5 rounded-lg text-sm"
style={{ backgroundColor: 'var(--border)', color: 'var(--text-primary)', border: '1px solid var(--border)' }}
autoFocus
/>
</div>
<div>
<label className="text-xs font-medium mb-1 block" style={{ color: 'var(--text-secondary)' }}>Year</label>
<input
type="number"
value={editForm.year}
onChange={(e) => setEditForm((f) => ({ ...f, year: e.target.value }))}
className="w-full px-3 py-1.5 rounded-lg text-sm"
style={{ backgroundColor: 'var(--border)', color: 'var(--text-primary)', border: '1px solid var(--border)' }}
/>
</div>
<div>
<label className="text-xs font-medium mb-1 block" style={{ color: 'var(--text-secondary)' }}>Plot</label>
<textarea
rows={3}
value={editForm.plot}
onChange={(e) => setEditForm((f) => ({ ...f, plot: e.target.value }))}
className="w-full px-3 py-1.5 rounded-lg text-sm resize-none"
style={{ backgroundColor: 'var(--border)', color: 'var(--text-primary)', border: '1px solid var(--border)' }}
/>
</div>
<div>
<label className="text-xs font-medium mb-1 block" style={{ color: 'var(--text-secondary)' }}>Genres (comma-separated)</label>
<input
type="text"
value={editForm.genres}
onChange={(e) => setEditForm((f) => ({ ...f, genres: e.target.value }))}
className="w-full px-3 py-1.5 rounded-lg text-sm"
style={{ backgroundColor: 'var(--border)', color: 'var(--text-primary)', border: '1px solid var(--border)' }}
/>
</div>
<div className="flex gap-2 justify-end">
<button
onClick={() => setEditingMeta(false)}
className="px-3 py-1.5 rounded-lg text-sm transition-colors"
style={{ color: 'var(--text-secondary)', backgroundColor: 'var(--border)' }}
>
Cancel
</button>
<button
onClick={handleSaveSeriesMetadata}
disabled={savingMeta}
className="px-3 py-1.5 rounded-lg text-sm font-medium transition-colors disabled:opacity-50"
style={{ backgroundColor: 'var(--accent)', color: '#fff' }}
>
{savingMeta ? 'Saving…' : 'Save'}
</button>
</div>
</div>
) : (
<>
{(selectedSeries.year || selectedSeries.genres.length > 0) && (
<div className="flex flex-wrap items-center gap-2 mt-1">
{selectedSeries.year && <span className="text-xs" style={{ color: 'var(--text-secondary)' }}>{selectedSeries.year}</span>}
{selectedSeries.genres.map((g) => (
<span key={g} className="text-xs px-1.5 py-0.5 rounded" style={{ backgroundColor: 'var(--border)', color: 'var(--text-secondary)' }}>{g}</span>
))}
</div>
)}
{selectedSeries.plot && (
<p className="text-sm mt-2 line-clamp-3" style={{ color: 'var(--text-secondary)' }}>{selectedSeries.plot}</p>
)}
</>
)}
</div>
</div>
{/* NFO refresh warning */}
{warnRefresh && (
<div
className="flex items-center gap-3 mt-3 px-3 py-2.5 rounded-lg"
style={{ backgroundColor: '#78350f33', border: '1px solid #78350f' }}
>
<p className="flex-1 text-xs" style={{ color: '#fbbf24' }}>
Refreshing from NFO will overwrite your manual edits.
</p>
<button
onClick={() => setWarnRefresh(false)}
className="text-xs px-2 py-1 rounded flex-shrink-0 transition-colors"
style={{ color: 'var(--text-secondary)', backgroundColor: 'var(--border)' }}
>
Cancel
</button>
<button
onClick={doRefreshSeriesMetadata}
className="text-xs px-2 py-1 rounded flex-shrink-0 transition-colors"
style={{ backgroundColor: '#78350f', color: '#fbbf24' }}
>
Overwrite
</button>
</div>
)}
{/* Confirmation banner */}
{confirming && (
<div
@@ -581,6 +809,29 @@ export default function TvView({ libraryId }: Props) {
episode={ep}
onClick={() => setPlayingEpisodeIndex(episodes.indexOf(ep))}
onTag={() => setTagPanel({ itemKey: ep.item_key!, title: ep.title })}
onDelete={() => {
fetch(
`/api/tv?libraryId=${encodeURIComponent(libraryId)}&seriesId=${encodeURIComponent(selectedSeries!.id)}&episodeKey=${encodeURIComponent(ep.item_key!)}`,
{ method: 'DELETE' }
).then(() => {
setEpisodes((prev) => prev.filter((e) => e.id !== ep.id))
})
}}
onRename={async (newName) => {
const res = await fetch('/api/rename', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ libraryId, oldPath: ep.videoPath, newName, itemType: 'tv_episode' }),
})
if (!res.ok) return false
// Refetch episodes to get updated data
const seasonId = selectedSeason!.id
const data = await fetch(
`/api/tv?libraryId=${encodeURIComponent(libraryId)}&seriesId=${encodeURIComponent(selectedSeries!.id)}&seasonId=${encodeURIComponent(seasonId)}`
).then((r) => r.json())
setEpisodes(data)
return true
}}
/>
))}
</div>