Merge pull request 'add more management capabilities' (#16) from management into main
All checks were successful
Build and Push Docker Image / build (push) Successful in 52s
All checks were successful
Build and Push Docker Image / build (push) Successful in 52s
Reviewed-on: http://gitea.lan/gpatti/MediaLore/pulls/16
This commit is contained in:
1
.gitignore
vendored
1
.gitignore
vendored
@@ -9,3 +9,4 @@ medialore.db-shm
|
|||||||
medialore.db-wal
|
medialore.db-wal
|
||||||
tsconfig.tsbuildinfo
|
tsconfig.tsbuildinfo
|
||||||
.session_secret
|
.session_secret
|
||||||
|
.vscode/
|
||||||
@@ -1,7 +1,10 @@
|
|||||||
|
import fs from 'fs'
|
||||||
import { NextRequest, NextResponse } from 'next/server'
|
import { NextRequest, NextResponse } from 'next/server'
|
||||||
import { getLibrary, resolveLibraryRoot } from '@/lib/libraries'
|
import { getLibrary, resolveLibraryRoot, resolveAndJail } from '@/lib/libraries'
|
||||||
import { scanDirectory, scanDirectoryRecursive } from '@/lib/files'
|
import { scanDirectory, scanDirectoryRecursive } from '@/lib/files'
|
||||||
import { requireLibraryAccess } from '@/lib/auth'
|
import { requireLibraryAccess, requireAdmin } from '@/lib/auth'
|
||||||
|
import { removeAllAssignmentsForItem } from '@/lib/tags'
|
||||||
|
import { getDb } from '@/lib/db'
|
||||||
|
|
||||||
export async function GET(request: NextRequest) {
|
export async function GET(request: NextRequest) {
|
||||||
const { searchParams } = request.nextUrl
|
const { searchParams } = request.nextUrl
|
||||||
@@ -30,3 +33,58 @@ export async function GET(request: NextRequest) {
|
|||||||
: scanDirectory(root, libraryId, subpath)
|
: scanDirectory(root, libraryId, subpath)
|
||||||
return NextResponse.json(listing)
|
return NextResponse.json(listing)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function DELETE(request: NextRequest) {
|
||||||
|
const auth = await requireAdmin(request)
|
||||||
|
if (auth instanceof NextResponse) return auth
|
||||||
|
|
||||||
|
const { searchParams } = request.nextUrl
|
||||||
|
const libraryId = searchParams.get('libraryId')
|
||||||
|
const itemPath = searchParams.get('path')
|
||||||
|
|
||||||
|
if (!libraryId || !itemPath) {
|
||||||
|
return NextResponse.json({ error: 'Missing libraryId or path' }, { status: 400 })
|
||||||
|
}
|
||||||
|
|
||||||
|
const library = getLibrary(libraryId)
|
||||||
|
if (!library) {
|
||||||
|
return NextResponse.json({ error: 'Library not found' }, { status: 404 })
|
||||||
|
}
|
||||||
|
if (library.type !== 'mixed') {
|
||||||
|
return NextResponse.json({ error: 'Library is not a mixed library' }, { status: 400 })
|
||||||
|
}
|
||||||
|
|
||||||
|
const root = resolveLibraryRoot(library)
|
||||||
|
let absPath: string
|
||||||
|
try {
|
||||||
|
absPath = resolveAndJail(root, itemPath)
|
||||||
|
} catch {
|
||||||
|
return NextResponse.json({ error: 'Invalid path' }, { status: 400 })
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const stat = fs.statSync(absPath)
|
||||||
|
if (stat.isDirectory()) {
|
||||||
|
fs.rmSync(absPath, { recursive: true, force: true })
|
||||||
|
} else {
|
||||||
|
fs.unlinkSync(absPath)
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
return NextResponse.json({ error: 'Failed to delete' }, { status: 500 })
|
||||||
|
}
|
||||||
|
|
||||||
|
const db = getDb()
|
||||||
|
const itemKey = `${libraryId}:mixed_file:${encodeURIComponent(itemPath)}`
|
||||||
|
removeAllAssignmentsForItem(itemKey)
|
||||||
|
db.prepare('DELETE FROM media_items WHERE item_key = ?').run(itemKey)
|
||||||
|
|
||||||
|
// For directories, also clean up children
|
||||||
|
const prefix = `${libraryId}:mixed_file:${encodeURIComponent(itemPath + '/')}`
|
||||||
|
const children = db.prepare('SELECT item_key FROM media_items WHERE item_key LIKE ?').all(`${prefix}%`) as { item_key: string }[]
|
||||||
|
for (const child of children) {
|
||||||
|
removeAllAssignmentsForItem(child.item_key)
|
||||||
|
}
|
||||||
|
db.prepare('DELETE FROM media_items WHERE item_key LIKE ?').run(`${prefix}%`)
|
||||||
|
|
||||||
|
return new NextResponse(null, { status: 204 })
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,7 +1,10 @@
|
|||||||
|
import fs from 'fs'
|
||||||
import { NextRequest, NextResponse } from 'next/server'
|
import { NextRequest, NextResponse } from 'next/server'
|
||||||
import { getLibrary } from '@/lib/libraries'
|
import { getLibrary, resolveLibraryRoot, resolveAndJail } from '@/lib/libraries'
|
||||||
import { gamesFromDb } from '@/lib/games'
|
import { gamesFromDb } from '@/lib/games'
|
||||||
import { requireLibraryAccess } from '@/lib/auth'
|
import { requireLibraryAccess, requireAdmin } from '@/lib/auth'
|
||||||
|
import { removeAllAssignmentsForItem } from '@/lib/tags'
|
||||||
|
import { getDb } from '@/lib/db'
|
||||||
|
|
||||||
export async function GET(request: NextRequest) {
|
export async function GET(request: NextRequest) {
|
||||||
const { searchParams } = request.nextUrl
|
const { searchParams } = request.nextUrl
|
||||||
@@ -24,3 +27,46 @@ export async function GET(request: NextRequest) {
|
|||||||
|
|
||||||
return NextResponse.json(gamesFromDb(libraryId))
|
return NextResponse.json(gamesFromDb(libraryId))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function DELETE(request: NextRequest) {
|
||||||
|
const auth = await requireAdmin(request)
|
||||||
|
if (auth instanceof NextResponse) return auth
|
||||||
|
|
||||||
|
const { searchParams } = request.nextUrl
|
||||||
|
const libraryId = searchParams.get('libraryId')
|
||||||
|
const gameId = searchParams.get('gameId')
|
||||||
|
|
||||||
|
if (!libraryId || !gameId) {
|
||||||
|
return NextResponse.json({ error: 'Missing libraryId or gameId' }, { status: 400 })
|
||||||
|
}
|
||||||
|
|
||||||
|
const library = getLibrary(libraryId)
|
||||||
|
if (!library) {
|
||||||
|
return NextResponse.json({ error: 'Library not found' }, { status: 404 })
|
||||||
|
}
|
||||||
|
if (library.type !== 'games') {
|
||||||
|
return NextResponse.json({ error: 'Library is not a games library' }, { status: 400 })
|
||||||
|
}
|
||||||
|
|
||||||
|
const root = resolveLibraryRoot(library)
|
||||||
|
const dirName = decodeURIComponent(gameId)
|
||||||
|
|
||||||
|
let gameDir: string
|
||||||
|
try {
|
||||||
|
gameDir = resolveAndJail(root, dirName)
|
||||||
|
} catch {
|
||||||
|
return NextResponse.json({ error: 'Invalid game path' }, { status: 400 })
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
fs.rmSync(gameDir, { recursive: true, force: true })
|
||||||
|
} catch {
|
||||||
|
return NextResponse.json({ error: 'Failed to delete game directory' }, { status: 500 })
|
||||||
|
}
|
||||||
|
|
||||||
|
const itemKey = `${libraryId}:game:${gameId}`
|
||||||
|
removeAllAssignmentsForItem(itemKey)
|
||||||
|
getDb().prepare('DELETE FROM media_items WHERE item_key = ?').run(itemKey)
|
||||||
|
|
||||||
|
return new NextResponse(null, { status: 204 })
|
||||||
|
}
|
||||||
|
|||||||
61
src/app/api/metadata/route.ts
Normal file
61
src/app/api/metadata/route.ts
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server'
|
||||||
|
import { requireAdmin } from '@/lib/auth'
|
||||||
|
import { getDb } from '@/lib/db'
|
||||||
|
|
||||||
|
export async function PATCH(request: NextRequest) {
|
||||||
|
const auth = await requireAdmin(request)
|
||||||
|
if (auth instanceof NextResponse) return auth
|
||||||
|
|
||||||
|
const body = await request.json()
|
||||||
|
const { itemKey, title, year, plot, genres } = body as {
|
||||||
|
itemKey: string
|
||||||
|
title?: string
|
||||||
|
year?: number | null
|
||||||
|
plot?: string | null
|
||||||
|
genres?: string[]
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!itemKey) {
|
||||||
|
return NextResponse.json({ error: 'Missing itemKey' }, { status: 400 })
|
||||||
|
}
|
||||||
|
|
||||||
|
const db = getDb()
|
||||||
|
const row = db.prepare('SELECT metadata FROM media_items WHERE item_key = ?').get(itemKey) as { metadata: string | null } | undefined
|
||||||
|
if (!row) {
|
||||||
|
return NextResponse.json({ error: 'Item not found' }, { status: 404 })
|
||||||
|
}
|
||||||
|
|
||||||
|
const sets: string[] = []
|
||||||
|
const params: Record<string, unknown> = { item_key: itemKey }
|
||||||
|
|
||||||
|
if (title !== undefined) {
|
||||||
|
sets.push('title = @title')
|
||||||
|
params.title = title
|
||||||
|
}
|
||||||
|
if (year !== undefined) {
|
||||||
|
sets.push('year = @year')
|
||||||
|
params.year = year
|
||||||
|
}
|
||||||
|
if (plot !== undefined) {
|
||||||
|
sets.push('plot = @plot')
|
||||||
|
params.plot = plot
|
||||||
|
}
|
||||||
|
if (genres !== undefined) {
|
||||||
|
sets.push('genres = @genres')
|
||||||
|
params.genres = JSON.stringify(genres)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Always mark as manually edited in the metadata blob
|
||||||
|
const existingMeta = row.metadata ? JSON.parse(row.metadata) : {}
|
||||||
|
existingMeta.manuallyEdited = true
|
||||||
|
sets.push('metadata = @metadata')
|
||||||
|
params.metadata = JSON.stringify(existingMeta)
|
||||||
|
|
||||||
|
if (sets.length === 0) {
|
||||||
|
return NextResponse.json({ error: 'No fields to update' }, { status: 400 })
|
||||||
|
}
|
||||||
|
|
||||||
|
db.prepare(`UPDATE media_items SET ${sets.join(', ')} WHERE item_key = @item_key`).run(params)
|
||||||
|
|
||||||
|
return NextResponse.json({ success: true })
|
||||||
|
}
|
||||||
200
src/app/api/rename/route.ts
Normal file
200
src/app/api/rename/route.ts
Normal file
@@ -0,0 +1,200 @@
|
|||||||
|
import fs from 'fs'
|
||||||
|
import path from 'path'
|
||||||
|
import { NextRequest, NextResponse } from 'next/server'
|
||||||
|
import { getLibrary, resolveLibraryRoot, resolveAndJail } from '@/lib/libraries'
|
||||||
|
import { requireAdmin } from '@/lib/auth'
|
||||||
|
import { getDb } from '@/lib/db'
|
||||||
|
import { reKeyMediaItem } from '@/lib/tags'
|
||||||
|
|
||||||
|
export async function POST(request: NextRequest) {
|
||||||
|
const auth = await requireAdmin(request)
|
||||||
|
if (auth instanceof NextResponse) return auth
|
||||||
|
|
||||||
|
const body = await request.json()
|
||||||
|
const { libraryId, oldPath, newName, itemType } = body as {
|
||||||
|
libraryId: string
|
||||||
|
oldPath: string
|
||||||
|
newName: string
|
||||||
|
itemType: string
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!libraryId || !oldPath || !newName || !itemType) {
|
||||||
|
return NextResponse.json({ error: 'Missing required fields' }, { status: 400 })
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate newName
|
||||||
|
if (/[/\\]/.test(newName) || newName === '.' || newName === '..' || newName.startsWith('.')) {
|
||||||
|
return NextResponse.json({ error: 'Invalid name' }, { status: 400 })
|
||||||
|
}
|
||||||
|
|
||||||
|
const library = getLibrary(libraryId)
|
||||||
|
if (!library) {
|
||||||
|
return NextResponse.json({ error: 'Library not found' }, { status: 404 })
|
||||||
|
}
|
||||||
|
|
||||||
|
const root = resolveLibraryRoot(library)
|
||||||
|
|
||||||
|
let oldAbsPath: string
|
||||||
|
try {
|
||||||
|
oldAbsPath = resolveAndJail(root, oldPath)
|
||||||
|
} catch {
|
||||||
|
return NextResponse.json({ error: 'Invalid path' }, { status: 400 })
|
||||||
|
}
|
||||||
|
|
||||||
|
// Compute new path by replacing the last segment
|
||||||
|
const parentDir = path.dirname(oldPath)
|
||||||
|
const newPath = parentDir === '.' ? newName : `${parentDir}/${newName}`
|
||||||
|
|
||||||
|
let newAbsPath: string
|
||||||
|
try {
|
||||||
|
newAbsPath = resolveAndJail(root, newPath)
|
||||||
|
} catch {
|
||||||
|
return NextResponse.json({ error: 'Invalid new path' }, { status: 400 })
|
||||||
|
}
|
||||||
|
|
||||||
|
// Collision check
|
||||||
|
if (fs.existsSync(newAbsPath)) {
|
||||||
|
return NextResponse.json({ error: 'A file or folder with that name already exists' }, { status: 409 })
|
||||||
|
}
|
||||||
|
|
||||||
|
// Perform filesystem rename
|
||||||
|
try {
|
||||||
|
fs.renameSync(oldAbsPath, newAbsPath)
|
||||||
|
} catch {
|
||||||
|
return NextResponse.json({ error: 'Failed to rename' }, { status: 500 })
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update database records
|
||||||
|
const db = getDb()
|
||||||
|
const enc = encodeURIComponent
|
||||||
|
const oldEnc = enc(oldPath)
|
||||||
|
const newEnc = enc(newPath)
|
||||||
|
|
||||||
|
try {
|
||||||
|
db.transaction(() => {
|
||||||
|
switch (itemType) {
|
||||||
|
case 'movie': {
|
||||||
|
const oldKey = `${libraryId}:movie:${oldEnc}`
|
||||||
|
const newKey = `${libraryId}:movie:${newEnc}`
|
||||||
|
db.prepare('UPDATE media_items SET item_key = ?, file_path = replace(file_path, ?, ?) WHERE item_key = ?')
|
||||||
|
.run(newKey, oldPath, newPath, oldKey)
|
||||||
|
reKeyMediaItem(oldKey, newKey)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'tv_series': {
|
||||||
|
const oldKey = `${libraryId}:tv_series:${oldEnc}`
|
||||||
|
const newKey = `${libraryId}:tv_series:${newEnc}`
|
||||||
|
// Update series
|
||||||
|
db.prepare('UPDATE media_items SET item_key = ? WHERE item_key = ?').run(newKey, oldKey)
|
||||||
|
reKeyMediaItem(oldKey, newKey)
|
||||||
|
|
||||||
|
// Update seasons: item_key contains series id
|
||||||
|
const seasonRows = db.prepare(
|
||||||
|
"SELECT item_key FROM media_items WHERE item_key LIKE ? AND item_type = 'tv_season'"
|
||||||
|
).all(`${libraryId}:tv_season:${oldEnc}:%`) as { item_key: string }[]
|
||||||
|
|
||||||
|
for (const row of seasonRows) {
|
||||||
|
const newSeasonKey = row.item_key.replace(`:tv_season:${oldEnc}:`, `:tv_season:${newEnc}:`)
|
||||||
|
db.prepare('UPDATE media_items SET item_key = ?, parent_key = ? WHERE item_key = ?')
|
||||||
|
.run(newSeasonKey, newKey, row.item_key)
|
||||||
|
reKeyMediaItem(row.item_key, newSeasonKey)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update episodes: item_key and file_path contain series path
|
||||||
|
const epRows = db.prepare(
|
||||||
|
"SELECT item_key, file_path FROM media_items WHERE item_key LIKE ? AND item_type = 'tv_episode'"
|
||||||
|
).all(`${libraryId}:tv_episode:${oldEnc}:%`) as { item_key: string; file_path: string | null }[]
|
||||||
|
|
||||||
|
for (const row of epRows) {
|
||||||
|
const newEpKey = row.item_key.replace(`:tv_episode:${oldEnc}:`, `:tv_episode:${newEnc}:`)
|
||||||
|
// Find new parent_key from the episode's season portion
|
||||||
|
const newParentKey = row.item_key
|
||||||
|
.replace(`:tv_episode:${oldEnc}:`, `:tv_season:${newEnc}:`)
|
||||||
|
.split(':')
|
||||||
|
.slice(0, -1) // Remove episode id portion — parent is season
|
||||||
|
// Actually, parent_key is the season key. We need to reconstruct it.
|
||||||
|
// Episode key format: libraryId:tv_episode:seriesId:seasonId:episodeId
|
||||||
|
// Season key format: libraryId:tv_season:seriesId:seasonId
|
||||||
|
const parts = newEpKey.split(':')
|
||||||
|
// parts: [libraryId, 'tv_episode', seriesEnc, seasonEnc, episodeEnc]
|
||||||
|
const seasonKey = `${parts[0]}:tv_season:${parts[2]}:${parts[3]}`
|
||||||
|
const newFilePath = row.file_path ? row.file_path.replace(oldPath, newPath) : null
|
||||||
|
db.prepare('UPDATE media_items SET item_key = ?, parent_key = ?, file_path = ? WHERE item_key = ?')
|
||||||
|
.run(newEpKey, seasonKey, newFilePath, row.item_key)
|
||||||
|
reKeyMediaItem(row.item_key, newEpKey)
|
||||||
|
}
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'tv_episode': {
|
||||||
|
const oldKey = `${libraryId}:tv_episode:${oldEnc}`
|
||||||
|
const newKey = `${libraryId}:tv_episode:${newEnc}`
|
||||||
|
db.prepare('UPDATE media_items SET item_key = ?, file_path = ? WHERE item_key = ?')
|
||||||
|
.run(newKey, newPath, oldKey)
|
||||||
|
reKeyMediaItem(oldKey, newKey)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'game': {
|
||||||
|
const oldKey = `${libraryId}:game:${oldEnc}`
|
||||||
|
const newKey = `${libraryId}:game:${newEnc}`
|
||||||
|
db.prepare('UPDATE media_items SET item_key = ? WHERE item_key = ?').run(newKey, oldKey)
|
||||||
|
reKeyMediaItem(oldKey, newKey)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'game_series': {
|
||||||
|
const oldKey = `${libraryId}:game_series:${oldEnc}`
|
||||||
|
const newKey = `${libraryId}:game_series:${newEnc}`
|
||||||
|
db.prepare('UPDATE media_items SET item_key = ? WHERE item_key = ?').run(newKey, oldKey)
|
||||||
|
reKeyMediaItem(oldKey, newKey)
|
||||||
|
|
||||||
|
// Update child games
|
||||||
|
const gameRows = db.prepare(
|
||||||
|
"SELECT item_key FROM media_items WHERE parent_key = ? AND item_type = 'game'"
|
||||||
|
).all(oldKey) as { item_key: string }[]
|
||||||
|
|
||||||
|
for (const row of gameRows) {
|
||||||
|
const newGameKey = row.item_key.replace(`:game:${oldEnc}`, `:game:${newEnc}`)
|
||||||
|
db.prepare('UPDATE media_items SET item_key = ?, parent_key = ? WHERE item_key = ?')
|
||||||
|
.run(newGameKey, newKey, row.item_key)
|
||||||
|
reKeyMediaItem(row.item_key, newGameKey)
|
||||||
|
}
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'mixed_file': {
|
||||||
|
const oldKey = `${libraryId}:mixed_file:${oldEnc}`
|
||||||
|
const newKey = `${libraryId}:mixed_file:${newEnc}`
|
||||||
|
db.prepare('UPDATE media_items SET item_key = ?, file_path = ? WHERE item_key = ?')
|
||||||
|
.run(newKey, newPath, oldKey)
|
||||||
|
reKeyMediaItem(oldKey, newKey)
|
||||||
|
|
||||||
|
// If directory, update all children
|
||||||
|
const childRows = db.prepare(
|
||||||
|
"SELECT item_key, file_path FROM media_items WHERE item_key LIKE ?"
|
||||||
|
).all(`${libraryId}:mixed_file:${enc(oldPath + '/')}%`) as { item_key: string; file_path: string | null }[]
|
||||||
|
|
||||||
|
for (const row of childRows) {
|
||||||
|
const newChildKey = row.item_key.replace(
|
||||||
|
`mixed_file:${enc(oldPath + '/')}`,
|
||||||
|
`mixed_file:${enc(newPath + '/')}`
|
||||||
|
)
|
||||||
|
const newChildPath = row.file_path ? row.file_path.replace(oldPath + '/', newPath + '/') : null
|
||||||
|
db.prepare('UPDATE media_items SET item_key = ?, file_path = ? WHERE item_key = ?')
|
||||||
|
.run(newChildKey, newChildPath, row.item_key)
|
||||||
|
reKeyMediaItem(row.item_key, newChildKey)
|
||||||
|
}
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})()
|
||||||
|
} catch (err) {
|
||||||
|
// Attempt to rollback filesystem rename on DB failure
|
||||||
|
try { fs.renameSync(newAbsPath, oldAbsPath) } catch { /* best effort */ }
|
||||||
|
return NextResponse.json({ error: 'Database update failed' }, { status: 500 })
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json({ newName, newPath })
|
||||||
|
}
|
||||||
@@ -5,6 +5,7 @@ import { getLibrary, resolveLibraryRoot, resolveAndJail } from '@/lib/libraries'
|
|||||||
import { tvSeriesFromDb, tvSeasonsFromDb, tvEpisodesFromDb } from '@/lib/tv'
|
import { tvSeriesFromDb, tvSeasonsFromDb, tvEpisodesFromDb } from '@/lib/tv'
|
||||||
import { removeAllAssignmentsForItem } from '@/lib/tags'
|
import { removeAllAssignmentsForItem } from '@/lib/tags'
|
||||||
import { requireLibraryAccess, requireAdmin } from '@/lib/auth'
|
import { requireLibraryAccess, requireAdmin } from '@/lib/auth'
|
||||||
|
import { getDb } from '@/lib/db'
|
||||||
|
|
||||||
export async function GET(request: NextRequest) {
|
export async function GET(request: NextRequest) {
|
||||||
const { searchParams } = request.nextUrl
|
const { searchParams } = request.nextUrl
|
||||||
@@ -45,6 +46,7 @@ export async function DELETE(request: NextRequest) {
|
|||||||
const { searchParams } = request.nextUrl
|
const { searchParams } = request.nextUrl
|
||||||
const libraryId = searchParams.get('libraryId')
|
const libraryId = searchParams.get('libraryId')
|
||||||
const seriesId = searchParams.get('seriesId')
|
const seriesId = searchParams.get('seriesId')
|
||||||
|
const episodeKey = searchParams.get('episodeKey')
|
||||||
|
|
||||||
if (!libraryId || !seriesId) {
|
if (!libraryId || !seriesId) {
|
||||||
return NextResponse.json({ error: 'Missing libraryId or seriesId' }, { status: 400 })
|
return NextResponse.json({ error: 'Missing libraryId or seriesId' }, { status: 400 })
|
||||||
@@ -59,6 +61,38 @@ export async function DELETE(request: NextRequest) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const root = resolveLibraryRoot(library)
|
const root = resolveLibraryRoot(library)
|
||||||
|
|
||||||
|
// Episode-level delete
|
||||||
|
if (episodeKey) {
|
||||||
|
const db = getDb()
|
||||||
|
const row = db.prepare('SELECT file_path FROM media_items WHERE item_key = ?').get(episodeKey) as { file_path: string | null } | undefined
|
||||||
|
if (!row?.file_path) {
|
||||||
|
return NextResponse.json({ error: 'Episode not found' }, { status: 404 })
|
||||||
|
}
|
||||||
|
|
||||||
|
let episodePath: string
|
||||||
|
try {
|
||||||
|
episodePath = resolveAndJail(root, row.file_path)
|
||||||
|
} catch {
|
||||||
|
return NextResponse.json({ error: 'Invalid episode path' }, { status: 400 })
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
fs.unlinkSync(episodePath)
|
||||||
|
// Also remove sidecar NFO if it exists
|
||||||
|
const nfoPath = episodePath.replace(path.extname(episodePath), '.nfo')
|
||||||
|
if (fs.existsSync(nfoPath)) fs.unlinkSync(nfoPath)
|
||||||
|
} catch {
|
||||||
|
return NextResponse.json({ error: 'Failed to delete episode file' }, { status: 500 })
|
||||||
|
}
|
||||||
|
|
||||||
|
removeAllAssignmentsForItem(episodeKey)
|
||||||
|
db.prepare('DELETE FROM media_items WHERE item_key = ?').run(episodeKey)
|
||||||
|
|
||||||
|
return new NextResponse(null, { status: 204 })
|
||||||
|
}
|
||||||
|
|
||||||
|
// Series-level delete
|
||||||
const dirName = decodeURIComponent(seriesId)
|
const dirName = decodeURIComponent(seriesId)
|
||||||
|
|
||||||
let seriesDir: string
|
let seriesDir: string
|
||||||
|
|||||||
@@ -10,18 +10,27 @@ interface Props {
|
|||||||
onClose: () => void
|
onClose: () => void
|
||||||
onTagsChanged?: () => void
|
onTagsChanged?: () => void
|
||||||
onCoverUploaded?: () => void
|
onCoverUploaded?: () => void
|
||||||
|
onDeleted?: (gameId: string) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function GameDetailModal({ game, libraryId, onClose, onTagsChanged, onCoverUploaded }: Props) {
|
export default function GameDetailModal({ game, libraryId, onClose, onTagsChanged, onCoverUploaded, onDeleted }: Props) {
|
||||||
const overlayRef = useRef<HTMLDivElement>(null)
|
const overlayRef = useRef<HTMLDivElement>(null)
|
||||||
const menuRef = useRef<HTMLDivElement>(null)
|
const menuRef = useRef<HTMLDivElement>(null)
|
||||||
const [menuOpen, setMenuOpen] = useState(false)
|
const [menuOpen, setMenuOpen] = useState(false)
|
||||||
const [editingImages, setEditingImages] = useState(false)
|
const [editingImages, setEditingImages] = useState(false)
|
||||||
|
const [confirming, setConfirming] = useState(false)
|
||||||
|
const [deleting, setDeleting] = useState(false)
|
||||||
|
const [renaming, setRenaming] = useState(false)
|
||||||
|
const [renameName, setRenameName] = useState('')
|
||||||
|
const [renameError, setRenameError] = useState<string | null>(null)
|
||||||
|
const [renameSaving, setRenameSaving] = useState(false)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handleKey = (e: KeyboardEvent) => {
|
const handleKey = (e: KeyboardEvent) => {
|
||||||
if (e.key === 'Escape') {
|
if (e.key === 'Escape') {
|
||||||
if (menuOpen) { setMenuOpen(false); return }
|
if (menuOpen) { setMenuOpen(false); return }
|
||||||
|
if (confirming) { setConfirming(false); return }
|
||||||
|
if (renaming) { setRenaming(false); return }
|
||||||
if (editingImages) { setEditingImages(false); return }
|
if (editingImages) { setEditingImages(false); return }
|
||||||
onClose()
|
onClose()
|
||||||
}
|
}
|
||||||
@@ -32,7 +41,7 @@ export default function GameDetailModal({ game, libraryId, onClose, onTagsChange
|
|||||||
document.removeEventListener('keydown', handleKey)
|
document.removeEventListener('keydown', handleKey)
|
||||||
document.body.style.overflow = ''
|
document.body.style.overflow = ''
|
||||||
}
|
}
|
||||||
}, [onClose, menuOpen, editingImages])
|
}, [onClose, menuOpen, editingImages, confirming, renaming])
|
||||||
|
|
||||||
// Close menu on outside click
|
// Close menu on outside click
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -130,11 +139,144 @@ export default function GameDetailModal({ game, libraryId, onClose, onTagsChange
|
|||||||
>
|
>
|
||||||
Edit images
|
Edit images
|
||||||
</button>
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
setMenuOpen(false)
|
||||||
|
setRenameName(decodeURIComponent(game.id))
|
||||||
|
setRenameError(null)
|
||||||
|
setRenaming(true)
|
||||||
|
}}
|
||||||
|
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>
|
||||||
|
{onDeleted && (
|
||||||
|
<button
|
||||||
|
onClick={() => { setMenuOpen(false); setConfirming(true) }}
|
||||||
|
className="flex items-center gap-2 w-full px-4 py-2 text-sm text-left transition-colors"
|
||||||
|
style={{ color: '#fca5a5' }}
|
||||||
|
onMouseEnter={(e) => ((e.currentTarget as HTMLElement).style.backgroundColor = 'var(--border)')}
|
||||||
|
onMouseLeave={(e) => ((e.currentTarget as HTMLElement).style.backgroundColor = 'transparent')}
|
||||||
|
>
|
||||||
|
Delete game
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Rename inline input */}
|
||||||
|
{renaming && (
|
||||||
|
<div className="flex flex-col gap-2 mb-4">
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={renameName}
|
||||||
|
onChange={(e) => setRenameName(e.target.value)}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === 'Enter') {
|
||||||
|
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(game.id), newName: trimmed, itemType: 'game' }),
|
||||||
|
})
|
||||||
|
.then(async (res) => {
|
||||||
|
if (res.status === 409) { setRenameError((await res.json()).error); return }
|
||||||
|
if (!res.ok) throw new Error()
|
||||||
|
setRenaming(false)
|
||||||
|
onCoverUploaded?.() // triggers refetch
|
||||||
|
})
|
||||||
|
.catch(() => setRenameError('Rename failed'))
|
||||||
|
.finally(() => setRenameSaving(false))
|
||||||
|
}
|
||||||
|
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={() => {
|
||||||
|
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(game.id), newName: trimmed, itemType: 'game' }),
|
||||||
|
})
|
||||||
|
.then(async (res) => {
|
||||||
|
if (res.status === 409) { setRenameError((await res.json()).error); return }
|
||||||
|
if (!res.ok) throw new Error()
|
||||||
|
setRenaming(false)
|
||||||
|
onCoverUploaded?.()
|
||||||
|
})
|
||||||
|
.catch(() => setRenameError('Rename failed'))
|
||||||
|
.finally(() => setRenameSaving(false))
|
||||||
|
}}
|
||||||
|
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>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Delete confirmation banner */}
|
||||||
|
{confirming && (
|
||||||
|
<div
|
||||||
|
className="flex items-center gap-3 mb-4 px-3 py-2.5 rounded-lg text-sm"
|
||||||
|
style={{ backgroundColor: '#7f1d1d33', border: '1px solid #7f1d1d' }}
|
||||||
|
>
|
||||||
|
<p className="flex-1 text-xs" style={{ color: '#fca5a5' }}>
|
||||||
|
Permanently delete this game and all its files?
|
||||||
|
</p>
|
||||||
|
<button
|
||||||
|
onClick={() => setConfirming(false)}
|
||||||
|
className="text-xs px-2 py-1 rounded flex-shrink-0 transition-colors"
|
||||||
|
style={{ color: 'var(--text-secondary)', backgroundColor: 'var(--border)' }}
|
||||||
|
onMouseEnter={(e) => ((e.currentTarget as HTMLElement).style.color = 'var(--text-primary)')}
|
||||||
|
onMouseLeave={(e) => ((e.currentTarget as HTMLElement).style.color = 'var(--text-secondary)')}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
setDeleting(true)
|
||||||
|
fetch(`/api/games?libraryId=${encodeURIComponent(libraryId)}&gameId=${encodeURIComponent(game.id)}`, { method: 'DELETE' })
|
||||||
|
.then(() => onDeleted!(game.id))
|
||||||
|
.catch(() => setDeleting(false))
|
||||||
|
}}
|
||||||
|
disabled={deleting}
|
||||||
|
className="text-xs px-2 py-1 rounded flex-shrink-0 transition-colors disabled:opacity-50"
|
||||||
|
style={{ backgroundColor: '#7f1d1d', color: '#fca5a5' }}
|
||||||
|
onMouseEnter={(e) => ((e.currentTarget as HTMLElement).style.backgroundColor = '#991b1b')}
|
||||||
|
onMouseLeave={(e) => ((e.currentTarget as HTMLElement).style.backgroundColor = '#7f1d1d')}
|
||||||
|
>
|
||||||
|
{deleting ? 'Deleting…' : 'Yes, delete'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<DownloadButton zipFiles={game.zipFiles} downloadUrl={zipDownloadUrl} />
|
<DownloadButton zipFiles={game.zipFiles} downloadUrl={zipDownloadUrl} />
|
||||||
|
|
||||||
{/* Tags */}
|
{/* Tags */}
|
||||||
|
|||||||
@@ -183,6 +183,11 @@ export default function GamesView({ libraryId }: Props) {
|
|||||||
onClose={() => setSelected(null)}
|
onClose={() => setSelected(null)}
|
||||||
onTagsChanged={() => { setFilterRefreshKey((k) => k + 1); fetchAssignments() }}
|
onTagsChanged={() => { setFilterRefreshKey((k) => k + 1); fetchAssignments() }}
|
||||||
onCoverUploaded={() => fetchGames(true)}
|
onCoverUploaded={() => fetchGames(true)}
|
||||||
|
onDeleted={() => {
|
||||||
|
setSelected(null)
|
||||||
|
fetchGames()
|
||||||
|
fetchAssignments()
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -316,7 +316,39 @@ export default function MixedView({ libraryId, initialPath }: Props) {
|
|||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
{filteredEntries.map((entry) => (
|
{filteredEntries.map((entry) => (
|
||||||
<EntryTile key={entry.name} entry={entry} onOpen={handleEntry} onTag={handleTagEntry} />
|
<EntryTile
|
||||||
|
key={entry.name}
|
||||||
|
entry={entry}
|
||||||
|
onOpen={handleEntry}
|
||||||
|
onTag={handleTagEntry}
|
||||||
|
onDelete={(e) => {
|
||||||
|
const rel = filtersActive ? e.name : (currentPath ? `${currentPath}/${e.name}` : e.name)
|
||||||
|
fetch(`/api/browse?libraryId=${encodeURIComponent(libraryId)}&path=${encodeURIComponent(rel)}`, { method: 'DELETE' })
|
||||||
|
.then(() => {
|
||||||
|
if (filtersActive) {
|
||||||
|
setRecursiveEntries((prev) => prev.filter((r) => r.name !== e.name))
|
||||||
|
} else {
|
||||||
|
setListing((prev) => prev ? { ...prev, entries: prev.entries.filter((r) => r.name !== e.name) } : prev)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}}
|
||||||
|
onRename={async (e, newName) => {
|
||||||
|
const rel = filtersActive ? e.name : (currentPath ? `${currentPath}/${e.name}` : e.name)
|
||||||
|
const res = await fetch('/api/rename', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ libraryId, oldPath: rel, newName, itemType: 'mixed_file' }),
|
||||||
|
})
|
||||||
|
if (!res.ok) return false
|
||||||
|
// Refresh the listing
|
||||||
|
if (filtersActive) {
|
||||||
|
fetchRecursive()
|
||||||
|
} else {
|
||||||
|
loadPath(currentPath)
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}}
|
||||||
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -392,11 +424,28 @@ export default function MixedView({ libraryId, initialPath }: Props) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function EntryTile({ entry, onOpen, onTag }: { entry: FileEntry; onOpen: (e: FileEntry) => void; onTag: (e: FileEntry) => void }) {
|
function EntryTile({ entry, onOpen, onTag, onDelete, onRename }: { entry: FileEntry; onOpen: (e: FileEntry) => void; onTag: (e: FileEntry) => void; onDelete?: (e: FileEntry) => void; onRename?: (e: FileEntry, newName: string) => Promise<boolean> }) {
|
||||||
type ImgState = 'loading' | 'loaded' | 'error'
|
type ImgState = 'loading' | 'loaded' | 'error'
|
||||||
const [imgState, setImgState] = useState<ImgState>(
|
const [imgState, setImgState] = useState<ImgState>(
|
||||||
entry.thumbnailUrl ? 'loading' : 'error'
|
entry.thumbnailUrl ? 'loading' : 'error'
|
||||||
)
|
)
|
||||||
|
const menuRef = useRef<HTMLDivElement>(null)
|
||||||
|
const [menuOpen, setMenuOpen] = useState(false)
|
||||||
|
const [confirming, setConfirming] = useState(false)
|
||||||
|
const [deleting, setDeleting] = useState(false)
|
||||||
|
const [entryRenaming, setEntryRenaming] = useState(false)
|
||||||
|
const [entryRenameName, setEntryRenameName] = useState('')
|
||||||
|
const [entryRenameError, setEntryRenameError] = useState<string | null>(null)
|
||||||
|
const [entryRenameSaving, setEntryRenameSaving] = useState(false)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!menuOpen) return
|
||||||
|
const handler = (e: MouseEvent) => {
|
||||||
|
if (menuRef.current && !menuRef.current.contains(e.target as Node)) setMenuOpen(false)
|
||||||
|
}
|
||||||
|
document.addEventListener('mousedown', handler)
|
||||||
|
return () => document.removeEventListener('mousedown', handler)
|
||||||
|
}, [menuOpen])
|
||||||
// Reset image state when the entry changes (e.g. navigating to a new folder)
|
// Reset image state when the entry changes (e.g. navigating to a new folder)
|
||||||
const prevUrl = useRef(entry.thumbnailUrl)
|
const prevUrl = useRef(entry.thumbnailUrl)
|
||||||
if (prevUrl.current !== entry.thumbnailUrl) {
|
if (prevUrl.current !== entry.thumbnailUrl) {
|
||||||
@@ -497,6 +546,134 @@ function EntryTile({ entry, onOpen, onTag }: { entry: FileEntry; onOpen: (e: Fil
|
|||||||
>
|
>
|
||||||
🏷
|
🏷
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
|
{/* Kebab menu — top-right, shown on hover */}
|
||||||
|
{(onDelete || onRename) && (
|
||||||
|
<div className="absolute top-2 right-2 opacity-0 group-hover:opacity-100 transition-opacity hidden group-hover:block" ref={menuRef}>
|
||||||
|
<button
|
||||||
|
onClick={(e) => { e.stopPropagation(); setMenuOpen((o) => !o); setConfirming(false) }}
|
||||||
|
className="w-6 h-6 rounded-full flex items-center justify-center text-xs"
|
||||||
|
style={{ backgroundColor: 'rgba(0,0,0,0.55)', color: '#fff' }}
|
||||||
|
aria-label="More options"
|
||||||
|
>
|
||||||
|
⋮
|
||||||
|
</button>
|
||||||
|
{menuOpen && (
|
||||||
|
<div
|
||||||
|
className="absolute right-0 top-full mt-1 rounded-lg shadow-lg overflow-hidden z-20 min-w-max"
|
||||||
|
style={{ backgroundColor: 'var(--surface)', border: '1px solid var(--border)' }}
|
||||||
|
>
|
||||||
|
{onRename && (
|
||||||
|
<button
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation()
|
||||||
|
setMenuOpen(false)
|
||||||
|
setEntryRenameName(entry.name)
|
||||||
|
setEntryRenameError(null)
|
||||||
|
setEntryRenaming(true)
|
||||||
|
}}
|
||||||
|
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
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{onDelete && (
|
||||||
|
<button
|
||||||
|
onClick={(e) => { e.stopPropagation(); setMenuOpen(false); setConfirming(true) }}
|
||||||
|
className="flex items-center gap-2 w-full px-4 py-2 text-sm text-left transition-colors"
|
||||||
|
style={{ color: '#fca5a5' }}
|
||||||
|
onMouseEnter={(e) => ((e.currentTarget as HTMLElement).style.backgroundColor = 'var(--border)')}
|
||||||
|
onMouseLeave={(e) => ((e.currentTarget as HTMLElement).style.backgroundColor = 'transparent')}
|
||||||
|
>
|
||||||
|
Delete
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Delete confirmation overlay */}
|
||||||
|
{confirming && (
|
||||||
|
<div
|
||||||
|
className="absolute inset-x-0 bottom-0 z-10 flex items-center gap-2 px-2 py-2 text-xs"
|
||||||
|
style={{ backgroundColor: 'rgba(127,29,29,0.9)' }}
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
<p className="flex-1" style={{ color: '#fca5a5' }}>Delete?</p>
|
||||||
|
<button
|
||||||
|
onClick={() => setConfirming(false)}
|
||||||
|
className="px-2 py-0.5 rounded transition-colors"
|
||||||
|
style={{ color: 'var(--text-secondary)', backgroundColor: 'var(--border)' }}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => { setDeleting(true); onDelete!(entry) }}
|
||||||
|
disabled={deleting}
|
||||||
|
className="px-2 py-0.5 rounded transition-colors disabled:opacity-50"
|
||||||
|
style={{ backgroundColor: '#7f1d1d', color: '#fca5a5' }}
|
||||||
|
>
|
||||||
|
{deleting ? '…' : 'Yes'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Rename overlay */}
|
||||||
|
{entryRenaming && (
|
||||||
|
<div
|
||||||
|
className="absolute inset-x-0 bottom-0 z-10 flex flex-col gap-1 px-2 py-2 text-xs"
|
||||||
|
style={{ backgroundColor: 'rgba(0,0,0,0.85)' }}
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={entryRenameName}
|
||||||
|
onChange={(e) => setEntryRenameName(e.target.value)}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === 'Enter' && onRename) {
|
||||||
|
const trimmed = entryRenameName.trim()
|
||||||
|
if (!trimmed) return
|
||||||
|
setEntryRenameSaving(true)
|
||||||
|
setEntryRenameError(null)
|
||||||
|
onRename(entry, trimmed).then((ok) => {
|
||||||
|
if (ok) setEntryRenaming(false)
|
||||||
|
else setEntryRenameError('Name already exists')
|
||||||
|
}).finally(() => setEntryRenameSaving(false))
|
||||||
|
}
|
||||||
|
if (e.key === 'Escape') setEntryRenaming(false)
|
||||||
|
}}
|
||||||
|
className="w-full px-2 py-1 rounded text-xs"
|
||||||
|
style={{ backgroundColor: 'var(--border)', color: 'var(--text-primary)', border: '1px solid var(--border)' }}
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
<div className="flex gap-1 justify-end">
|
||||||
|
<button onClick={() => setEntryRenaming(false)} className="px-2 py-0.5 rounded" style={{ color: 'var(--text-secondary)', backgroundColor: 'var(--border)' }}>Cancel</button>
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
if (!onRename) return
|
||||||
|
const trimmed = entryRenameName.trim()
|
||||||
|
if (!trimmed) return
|
||||||
|
setEntryRenameSaving(true)
|
||||||
|
setEntryRenameError(null)
|
||||||
|
onRename(entry, trimmed).then((ok) => {
|
||||||
|
if (ok) setEntryRenaming(false)
|
||||||
|
else setEntryRenameError('Name already exists')
|
||||||
|
}).finally(() => setEntryRenameSaving(false))
|
||||||
|
}}
|
||||||
|
disabled={entryRenameSaving}
|
||||||
|
className="px-2 py-0.5 rounded disabled:opacity-50"
|
||||||
|
style={{ backgroundColor: 'var(--accent)', color: '#fff' }}
|
||||||
|
>
|
||||||
|
{entryRenameSaving ? '…' : 'Rename'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{entryRenameError && <p style={{ color: '#fca5a5' }}>{entryRenameError}</p>}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -24,12 +24,23 @@ export default function MovieDetailModal({ movie, libraryId, onClose, onPrev, on
|
|||||||
const [confirming, setConfirming] = useState(false)
|
const [confirming, setConfirming] = useState(false)
|
||||||
const [deleting, setDeleting] = useState(false)
|
const [deleting, setDeleting] = useState(false)
|
||||||
const [refreshing, setRefreshing] = useState(false)
|
const [refreshing, setRefreshing] = useState(false)
|
||||||
|
const [editing, setEditing] = useState(false)
|
||||||
|
const [saving, setSaving] = 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)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handleKey = (e: KeyboardEvent) => {
|
const handleKey = (e: KeyboardEvent) => {
|
||||||
if (e.key === 'Escape') {
|
if (e.key === 'Escape') {
|
||||||
if (menuOpen) { setMenuOpen(false); return }
|
if (menuOpen) { setMenuOpen(false); return }
|
||||||
if (confirming) { setConfirming(false); return }
|
if (confirming) { setConfirming(false); return }
|
||||||
|
if (warnRefresh) { setWarnRefresh(false); return }
|
||||||
|
if (editing) { setEditing(false); return }
|
||||||
|
if (renaming) { setRenaming(false); return }
|
||||||
onClose()
|
onClose()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -39,7 +50,7 @@ export default function MovieDetailModal({ movie, libraryId, onClose, onPrev, on
|
|||||||
document.removeEventListener('keydown', handleKey)
|
document.removeEventListener('keydown', handleKey)
|
||||||
document.body.style.overflow = ''
|
document.body.style.overflow = ''
|
||||||
}
|
}
|
||||||
}, [onClose, menuOpen, confirming])
|
}, [onClose, menuOpen, confirming, editing, warnRefresh, renaming])
|
||||||
|
|
||||||
// Close menu on outside click
|
// Close menu on outside click
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -68,9 +79,9 @@ export default function MovieDetailModal({ movie, libraryId, onClose, onPrev, on
|
|||||||
.catch(() => setDeleting(false))
|
.catch(() => setDeleting(false))
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleRefreshMetadata = () => {
|
const doRefreshMetadata = () => {
|
||||||
setRefreshing(true)
|
setRefreshing(true)
|
||||||
setMenuOpen(false)
|
setWarnRefresh(false)
|
||||||
const itemKey = `${libraryId}:movie:${movie.id}`
|
const itemKey = `${libraryId}:movie:${movie.id}`
|
||||||
fetch(
|
fetch(
|
||||||
`/api/nfo-refresh?libraryId=${encodeURIComponent(libraryId)}&itemType=movie&itemKey=${encodeURIComponent(itemKey)}`,
|
`/api/nfo-refresh?libraryId=${encodeURIComponent(libraryId)}&itemType=movie&itemKey=${encodeURIComponent(itemKey)}`,
|
||||||
@@ -80,6 +91,82 @@ export default function MovieDetailModal({ movie, libraryId, onClose, onPrev, on
|
|||||||
.finally(() => setRefreshing(false))
|
.finally(() => setRefreshing(false))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const handleRefreshMetadata = () => {
|
||||||
|
setMenuOpen(false)
|
||||||
|
if (movie.manuallyEdited) {
|
||||||
|
setWarnRefresh(true)
|
||||||
|
} else {
|
||||||
|
doRefreshMetadata()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleStartEditing = () => {
|
||||||
|
setMenuOpen(false)
|
||||||
|
setEditForm({
|
||||||
|
title: movie.title,
|
||||||
|
year: movie.year?.toString() ?? '',
|
||||||
|
plot: movie.plot ?? '',
|
||||||
|
genres: movie.genres.join(', '),
|
||||||
|
})
|
||||||
|
setEditing(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSaveMetadata = () => {
|
||||||
|
setSaving(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: movie.item_key,
|
||||||
|
title: editForm.title,
|
||||||
|
year: isNaN(yearNum as number) ? null : yearNum,
|
||||||
|
plot: editForm.plot || null,
|
||||||
|
genres,
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
.then(() => { setEditing(false); onMetadataRefreshed?.() })
|
||||||
|
.finally(() => setSaving(false))
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleStartRename = () => {
|
||||||
|
setMenuOpen(false)
|
||||||
|
// movie.id is the encoded folder name
|
||||||
|
setRenameName(decodeURIComponent(movie.id))
|
||||||
|
setRenameError(null)
|
||||||
|
setRenaming(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleRename = () => {
|
||||||
|
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(movie.id),
|
||||||
|
newName: trimmed,
|
||||||
|
itemType: 'movie',
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
.then(async (res) => {
|
||||||
|
if (res.status === 409) {
|
||||||
|
const data = await res.json()
|
||||||
|
setRenameError(data.error)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (!res.ok) throw new Error()
|
||||||
|
setRenaming(false)
|
||||||
|
onMetadataRefreshed?.()
|
||||||
|
})
|
||||||
|
.catch(() => setRenameError('Rename failed'))
|
||||||
|
.finally(() => setRenameSaving(false))
|
||||||
|
}
|
||||||
|
|
||||||
if (playing) {
|
if (playing) {
|
||||||
return (
|
return (
|
||||||
<VideoPlayerModal
|
<VideoPlayerModal
|
||||||
@@ -199,6 +286,24 @@ export default function MovieDetailModal({ movie, libraryId, onClose, onPrev, on
|
|||||||
>
|
>
|
||||||
{refreshing ? 'Refreshing…' : 'Refresh metadata'}
|
{refreshing ? 'Refreshing…' : 'Refresh metadata'}
|
||||||
</button>
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={handleStartEditing}
|
||||||
|
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
|
<button
|
||||||
onClick={() => { setMenuOpen(false); setConfirming(true) }}
|
onClick={() => { setMenuOpen(false); setConfirming(true) }}
|
||||||
className="flex items-center gap-2 w-full px-4 py-2 text-sm text-left transition-colors"
|
className="flex items-center gap-2 w-full px-4 py-2 text-sm text-left transition-colors"
|
||||||
@@ -213,6 +318,102 @@ export default function MovieDetailModal({ movie, libraryId, onClose, onPrev, on
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Rename inline input */}
|
||||||
|
{renaming && (
|
||||||
|
<div className="flex flex-col gap-2 mb-3">
|
||||||
|
<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>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{editing ? (
|
||||||
|
<div className="flex flex-col gap-3 mb-4">
|
||||||
|
<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={() => setEditing(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={handleSaveMetadata}
|
||||||
|
disabled={saving}
|
||||||
|
className="px-3 py-1.5 rounded-lg text-sm font-medium transition-colors disabled:opacity-50"
|
||||||
|
style={{ backgroundColor: 'var(--accent)', color: '#fff' }}
|
||||||
|
>
|
||||||
|
{saving ? 'Saving…' : 'Save'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
{/* Meta row */}
|
{/* Meta row */}
|
||||||
{(movie.rating !== null || movie.runtime !== null || movie.genres.length > 0) && (
|
{(movie.rating !== null || movie.runtime !== null || movie.genres.length > 0) && (
|
||||||
<div className="flex flex-wrap items-center gap-2 mb-3">
|
<div className="flex flex-wrap items-center gap-2 mb-3">
|
||||||
@@ -239,6 +440,34 @@ export default function MovieDetailModal({ movie, libraryId, onClose, onPrev, on
|
|||||||
{movie.plot}
|
{movie.plot}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* NFO refresh warning */}
|
||||||
|
{warnRefresh && (
|
||||||
|
<div
|
||||||
|
className="flex items-center gap-3 mb-4 px-3 py-2.5 rounded-lg text-sm"
|
||||||
|
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={doRefreshMetadata}
|
||||||
|
className="text-xs px-2 py-1 rounded flex-shrink-0 transition-colors"
|
||||||
|
style={{ backgroundColor: '#78350f', color: '#fbbf24' }}
|
||||||
|
>
|
||||||
|
Overwrite
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Confirmation banner */}
|
{/* Confirmation banner */}
|
||||||
{confirming && (
|
{confirming && (
|
||||||
|
|||||||
@@ -1,15 +1,35 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
|
import { useEffect, useRef, useState } from 'react'
|
||||||
import type { TvEpisode } from '@/types'
|
import type { TvEpisode } from '@/types'
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
episode: TvEpisode
|
episode: TvEpisode
|
||||||
onClick: () => void
|
onClick: () => void
|
||||||
onTag?: () => void
|
onTag?: () => void
|
||||||
|
onDelete?: () => void
|
||||||
|
onRename?: (newName: string) => Promise<boolean>
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function EpisodeCard({ episode, onClick, onTag }: Props) {
|
export default function EpisodeCard({ episode, onClick, onTag, onDelete, onRename }: Props) {
|
||||||
const epLabel = episode.episodeNumber !== null ? `E${String(episode.episodeNumber).padStart(2, '0')}` : null
|
const epLabel = episode.episodeNumber !== null ? `E${String(episode.episodeNumber).padStart(2, '0')}` : null
|
||||||
|
const menuRef = useRef<HTMLDivElement>(null)
|
||||||
|
const [menuOpen, setMenuOpen] = useState(false)
|
||||||
|
const [confirming, setConfirming] = useState(false)
|
||||||
|
const [deleting, setDeleting] = useState(false)
|
||||||
|
const [renaming, setRenaming] = useState(false)
|
||||||
|
const [renameName, setRenameName] = useState('')
|
||||||
|
const [renameError, setRenameError] = useState<string | null>(null)
|
||||||
|
const [renameSaving, setRenameSaving] = useState(false)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!menuOpen) return
|
||||||
|
const handler = (e: MouseEvent) => {
|
||||||
|
if (menuRef.current && !menuRef.current.contains(e.target as Node)) setMenuOpen(false)
|
||||||
|
}
|
||||||
|
document.addEventListener('mousedown', handler)
|
||||||
|
return () => document.removeEventListener('mousedown', handler)
|
||||||
|
}, [menuOpen])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
@@ -58,7 +78,134 @@ export default function EpisodeCard({ episode, onClick, onTag }: Props) {
|
|||||||
🏷
|
🏷
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
{/* Kebab menu */}
|
||||||
|
{onDelete && (
|
||||||
|
<div className="absolute top-2 right-2 opacity-0 group-hover:opacity-100 transition-opacity hidden group-hover:block" ref={menuRef}>
|
||||||
|
<button
|
||||||
|
onClick={(e) => { e.stopPropagation(); setMenuOpen((o) => !o); setConfirming(false) }}
|
||||||
|
className="w-6 h-6 rounded-full flex items-center justify-center text-xs"
|
||||||
|
style={{ backgroundColor: 'rgba(0,0,0,0.55)', color: '#fff' }}
|
||||||
|
aria-label="More options"
|
||||||
|
>
|
||||||
|
⋮
|
||||||
|
</button>
|
||||||
|
{menuOpen && (
|
||||||
|
<div
|
||||||
|
className="absolute right-0 top-full mt-1 rounded-lg shadow-lg overflow-hidden z-20 min-w-max"
|
||||||
|
style={{ backgroundColor: 'var(--surface)', border: '1px solid var(--border)' }}
|
||||||
|
>
|
||||||
|
{onRename && (
|
||||||
|
<button
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation()
|
||||||
|
setMenuOpen(false)
|
||||||
|
// Extract filename from videoPath (last segment, without extension for user friendliness)
|
||||||
|
const fileName = episode.videoPath.split('/').pop() ?? ''
|
||||||
|
setRenameName(fileName)
|
||||||
|
setRenameError(null)
|
||||||
|
setRenaming(true)
|
||||||
|
}}
|
||||||
|
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 file
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
onClick={(e) => { e.stopPropagation(); setMenuOpen(false); setConfirming(true) }}
|
||||||
|
className="flex items-center gap-2 w-full px-4 py-2 text-sm text-left transition-colors"
|
||||||
|
style={{ color: '#fca5a5' }}
|
||||||
|
onMouseEnter={(e) => ((e.currentTarget as HTMLElement).style.backgroundColor = 'var(--border)')}
|
||||||
|
onMouseLeave={(e) => ((e.currentTarget as HTMLElement).style.backgroundColor = 'transparent')}
|
||||||
|
>
|
||||||
|
Delete episode
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Delete confirmation overlay */}
|
||||||
|
{confirming && (
|
||||||
|
<div
|
||||||
|
className="absolute inset-x-0 bottom-0 z-10 flex items-center gap-2 px-2 py-2 text-xs"
|
||||||
|
style={{ backgroundColor: 'rgba(127,29,29,0.9)' }}
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
<p className="flex-1" style={{ color: '#fca5a5' }}>Delete?</p>
|
||||||
|
<button
|
||||||
|
onClick={() => setConfirming(false)}
|
||||||
|
className="px-2 py-0.5 rounded transition-colors"
|
||||||
|
style={{ color: 'var(--text-secondary)', backgroundColor: 'var(--border)' }}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => { setDeleting(true); onDelete!() }}
|
||||||
|
disabled={deleting}
|
||||||
|
className="px-2 py-0.5 rounded transition-colors disabled:opacity-50"
|
||||||
|
style={{ backgroundColor: '#7f1d1d', color: '#fca5a5' }}
|
||||||
|
>
|
||||||
|
{deleting ? 'Deleting…' : 'Yes'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Rename overlay */}
|
||||||
|
{renaming && (
|
||||||
|
<div
|
||||||
|
className="absolute inset-x-0 bottom-0 z-10 flex flex-col gap-1 px-2 py-2 text-xs"
|
||||||
|
style={{ backgroundColor: 'rgba(0,0,0,0.85)' }}
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={renameName}
|
||||||
|
onChange={(e) => setRenameName(e.target.value)}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === 'Enter') {
|
||||||
|
const trimmed = renameName.trim()
|
||||||
|
if (!trimmed || !onRename) return
|
||||||
|
setRenameSaving(true)
|
||||||
|
setRenameError(null)
|
||||||
|
onRename(trimmed).then((ok) => {
|
||||||
|
if (ok) setRenaming(false)
|
||||||
|
else setRenameError('Rename failed or name already exists')
|
||||||
|
}).finally(() => setRenameSaving(false))
|
||||||
|
}
|
||||||
|
if (e.key === 'Escape') setRenaming(false)
|
||||||
|
}}
|
||||||
|
className="w-full px-2 py-1 rounded text-xs"
|
||||||
|
style={{ backgroundColor: 'var(--border)', color: 'var(--text-primary)', border: '1px solid var(--border)' }}
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
<div className="flex gap-1 justify-end">
|
||||||
|
<button onClick={() => setRenaming(false)} className="px-2 py-0.5 rounded" style={{ color: 'var(--text-secondary)', backgroundColor: 'var(--border)' }}>Cancel</button>
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
const trimmed = renameName.trim()
|
||||||
|
if (!trimmed || !onRename) return
|
||||||
|
setRenameSaving(true)
|
||||||
|
setRenameError(null)
|
||||||
|
onRename(trimmed).then((ok) => {
|
||||||
|
if (ok) setRenaming(false)
|
||||||
|
else setRenameError('Rename failed or name already exists')
|
||||||
|
}).finally(() => setRenameSaving(false))
|
||||||
|
}}
|
||||||
|
disabled={renameSaving}
|
||||||
|
className="px-2 py-0.5 rounded disabled:opacity-50"
|
||||||
|
style={{ backgroundColor: 'var(--accent)', color: '#fff' }}
|
||||||
|
>
|
||||||
|
{renameSaving ? '…' : 'Rename'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{renameError && <p style={{ color: '#fca5a5' }}>{renameError}</p>}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<div className="p-2">
|
<div className="p-2">
|
||||||
{epLabel && (
|
{epLabel && (
|
||||||
<p className="text-xs font-semibold mb-0.5" style={{ color: 'var(--accent)' }}>
|
<p className="text-xs font-semibold mb-0.5" style={{ color: 'var(--accent)' }}>
|
||||||
|
|||||||
@@ -37,6 +37,14 @@ export default function TvView({ libraryId }: Props) {
|
|||||||
const [confirming, setConfirming] = useState(false)
|
const [confirming, setConfirming] = useState(false)
|
||||||
const [deleting, setDeleting] = useState(false)
|
const [deleting, setDeleting] = useState(false)
|
||||||
const [refreshingMeta, setRefreshingMeta] = 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 [doomScrollActive, setDoomScrollActive] = useState(false)
|
||||||
const [doomScrollItems, setDoomScrollItems] = useState<DoomScrollItem[]>([])
|
const [doomScrollItems, setDoomScrollItems] = useState<DoomScrollItem[]>([])
|
||||||
const [doomScrollLoading, setDoomScrollLoading] = useState(false)
|
const [doomScrollLoading, setDoomScrollLoading] = useState(false)
|
||||||
@@ -151,10 +159,10 @@ export default function TvView({ libraryId }: Props) {
|
|||||||
.catch(() => setDeleting(false))
|
.catch(() => setDeleting(false))
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleRefreshSeriesMetadata = () => {
|
const doRefreshSeriesMetadata = () => {
|
||||||
if (!selectedSeries) return
|
if (!selectedSeries) return
|
||||||
setRefreshingMeta(true)
|
setRefreshingMeta(true)
|
||||||
setMenuOpen(false)
|
setWarnRefresh(false)
|
||||||
const itemKey = `${libraryId}:tv_series:${selectedSeries.id}`
|
const itemKey = `${libraryId}:tv_series:${selectedSeries.id}`
|
||||||
fetch(
|
fetch(
|
||||||
`/api/nfo-refresh?libraryId=${encodeURIComponent(libraryId)}&itemType=tv_series&itemKey=${encodeURIComponent(itemKey)}`,
|
`/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))
|
.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 () => {
|
const handleDoomScroll = async () => {
|
||||||
setDoomScrollLoading(true)
|
setDoomScrollLoading(true)
|
||||||
try {
|
try {
|
||||||
@@ -457,6 +544,24 @@ export default function TvView({ libraryId }: Props) {
|
|||||||
>
|
>
|
||||||
{refreshingMeta ? 'Refreshing…' : 'Refresh metadata'}
|
{refreshingMeta ? 'Refreshing…' : 'Refresh metadata'}
|
||||||
</button>
|
</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
|
<button
|
||||||
onClick={() => { setMenuOpen(false); setConfirming(true) }}
|
onClick={() => { setMenuOpen(false); setConfirming(true) }}
|
||||||
className="flex items-center gap-2 w-full px-4 py-2 text-sm text-left transition-colors"
|
className="flex items-center gap-2 w-full px-4 py-2 text-sm text-left transition-colors"
|
||||||
@@ -470,6 +575,102 @@ export default function TvView({ libraryId }: Props) {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
{/* 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>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{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) && (
|
{(selectedSeries.year || selectedSeries.genres.length > 0) && (
|
||||||
<div className="flex flex-wrap items-center gap-2 mt-1">
|
<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.year && <span className="text-xs" style={{ color: 'var(--text-secondary)' }}>{selectedSeries.year}</span>}
|
||||||
@@ -481,8 +682,35 @@ export default function TvView({ libraryId }: Props) {
|
|||||||
{selectedSeries.plot && (
|
{selectedSeries.plot && (
|
||||||
<p className="text-sm mt-2 line-clamp-3" style={{ color: 'var(--text-secondary)' }}>{selectedSeries.plot}</p>
|
<p className="text-sm mt-2 line-clamp-3" style={{ color: 'var(--text-secondary)' }}>{selectedSeries.plot}</p>
|
||||||
)}
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</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 */}
|
{/* Confirmation banner */}
|
||||||
{confirming && (
|
{confirming && (
|
||||||
<div
|
<div
|
||||||
@@ -581,6 +809,29 @@ export default function TvView({ libraryId }: Props) {
|
|||||||
episode={ep}
|
episode={ep}
|
||||||
onClick={() => setPlayingEpisodeIndex(episodes.indexOf(ep))}
|
onClick={() => setPlayingEpisodeIndex(episodes.indexOf(ep))}
|
||||||
onTag={() => setTagPanel({ itemKey: ep.item_key!, title: ep.title })}
|
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>
|
</div>
|
||||||
|
|||||||
@@ -107,6 +107,7 @@ export function moviesFromDb(libraryId: string): Movie[] {
|
|||||||
posterUrl: meta.posterUrl ?? null,
|
posterUrl: meta.posterUrl ?? null,
|
||||||
backdropUrl: meta.backdropUrl ?? null,
|
backdropUrl: meta.backdropUrl ?? null,
|
||||||
videoPath: row.file_path ?? '',
|
videoPath: row.file_path ?? '',
|
||||||
|
manuallyEdited: meta.manuallyEdited === true,
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -94,11 +94,16 @@ async function scanMovies(library: Library, libraryRoot: string): Promise<void>
|
|||||||
INSERT INTO media_items (library_id, item_key, item_type, title, year, plot, genres, metadata, file_path, fingerprint, scanned_at)
|
INSERT INTO media_items (library_id, item_key, item_type, title, year, plot, genres, metadata, file_path, fingerprint, scanned_at)
|
||||||
VALUES (@library_id, @item_key, @item_type, @title, @year, @plot, @genres, @metadata, @file_path, @fingerprint, @scanned_at)
|
VALUES (@library_id, @item_key, @item_type, @title, @year, @plot, @genres, @metadata, @file_path, @fingerprint, @scanned_at)
|
||||||
ON CONFLICT(item_key) DO UPDATE SET
|
ON CONFLICT(item_key) DO UPDATE SET
|
||||||
title = excluded.title,
|
title = CASE WHEN json_extract(media_items.metadata, '$.manuallyEdited') = 1 THEN media_items.title ELSE excluded.title END,
|
||||||
year = excluded.year,
|
year = CASE WHEN json_extract(media_items.metadata, '$.manuallyEdited') = 1 THEN media_items.year ELSE excluded.year END,
|
||||||
plot = excluded.plot,
|
plot = CASE WHEN json_extract(media_items.metadata, '$.manuallyEdited') = 1 THEN media_items.plot ELSE excluded.plot END,
|
||||||
genres = excluded.genres,
|
genres = CASE WHEN json_extract(media_items.metadata, '$.manuallyEdited') = 1 THEN media_items.genres ELSE excluded.genres END,
|
||||||
metadata = excluded.metadata,
|
metadata = CASE WHEN json_extract(media_items.metadata, '$.manuallyEdited') = 1
|
||||||
|
THEN json_set(media_items.metadata, '$.rating', json_extract(excluded.metadata, '$.rating'),
|
||||||
|
'$.runtime', json_extract(excluded.metadata, '$.runtime'),
|
||||||
|
'$.posterUrl', json_extract(excluded.metadata, '$.posterUrl'),
|
||||||
|
'$.backdropUrl', json_extract(excluded.metadata, '$.backdropUrl'))
|
||||||
|
ELSE excluded.metadata END,
|
||||||
file_path = excluded.file_path,
|
file_path = excluded.file_path,
|
||||||
fingerprint = excluded.fingerprint,
|
fingerprint = excluded.fingerprint,
|
||||||
scanned_at = excluded.scanned_at
|
scanned_at = excluded.scanned_at
|
||||||
@@ -194,11 +199,16 @@ async function scanTv(library: Library, libraryRoot: string): Promise<void> {
|
|||||||
INSERT INTO media_items (library_id, item_key, item_type, title, year, plot, genres, metadata, file_path, fingerprint, scanned_at)
|
INSERT INTO media_items (library_id, item_key, item_type, title, year, plot, genres, metadata, file_path, fingerprint, scanned_at)
|
||||||
VALUES (@library_id, @item_key, @item_type, @title, @year, @plot, @genres, @metadata, @file_path, @fingerprint, @scanned_at)
|
VALUES (@library_id, @item_key, @item_type, @title, @year, @plot, @genres, @metadata, @file_path, @fingerprint, @scanned_at)
|
||||||
ON CONFLICT(item_key) DO UPDATE SET
|
ON CONFLICT(item_key) DO UPDATE SET
|
||||||
title = excluded.title,
|
title = CASE WHEN json_extract(media_items.metadata, '$.manuallyEdited') = 1 THEN media_items.title ELSE excluded.title END,
|
||||||
year = excluded.year,
|
year = CASE WHEN json_extract(media_items.metadata, '$.manuallyEdited') = 1 THEN media_items.year ELSE excluded.year END,
|
||||||
plot = excluded.plot,
|
plot = CASE WHEN json_extract(media_items.metadata, '$.manuallyEdited') = 1 THEN media_items.plot ELSE excluded.plot END,
|
||||||
genres = excluded.genres,
|
genres = CASE WHEN json_extract(media_items.metadata, '$.manuallyEdited') = 1 THEN media_items.genres ELSE excluded.genres END,
|
||||||
metadata = excluded.metadata,
|
metadata = CASE WHEN json_extract(media_items.metadata, '$.manuallyEdited') = 1
|
||||||
|
THEN json_set(media_items.metadata, '$.status', json_extract(excluded.metadata, '$.status'),
|
||||||
|
'$.seasonCount', json_extract(excluded.metadata, '$.seasonCount'),
|
||||||
|
'$.posterUrl', json_extract(excluded.metadata, '$.posterUrl'),
|
||||||
|
'$.backdropUrl', json_extract(excluded.metadata, '$.backdropUrl'))
|
||||||
|
ELSE excluded.metadata END,
|
||||||
file_path = excluded.file_path,
|
file_path = excluded.file_path,
|
||||||
fingerprint = excluded.fingerprint,
|
fingerprint = excluded.fingerprint,
|
||||||
scanned_at = excluded.scanned_at
|
scanned_at = excluded.scanned_at
|
||||||
|
|||||||
@@ -224,6 +224,7 @@ export function tvSeriesFromDb(libraryId: string): TvSeries[] {
|
|||||||
posterUrl: meta.posterUrl ?? null,
|
posterUrl: meta.posterUrl ?? null,
|
||||||
backdropUrl: meta.backdropUrl ?? null,
|
backdropUrl: meta.backdropUrl ?? null,
|
||||||
seasonCount: meta.seasonCount ?? 0,
|
seasonCount: meta.seasonCount ?? 0,
|
||||||
|
manuallyEdited: meta.manuallyEdited === true,
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -48,6 +48,7 @@ export interface Movie {
|
|||||||
posterUrl: string | null
|
posterUrl: string | null
|
||||||
backdropUrl: string | null
|
backdropUrl: string | null
|
||||||
videoPath: string
|
videoPath: string
|
||||||
|
manuallyEdited?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface TvSeries {
|
export interface TvSeries {
|
||||||
@@ -61,6 +62,7 @@ export interface TvSeries {
|
|||||||
posterUrl: string | null
|
posterUrl: string | null
|
||||||
backdropUrl: string | null
|
backdropUrl: string | null
|
||||||
seasonCount: number
|
seasonCount: number
|
||||||
|
manuallyEdited?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface TvSeason {
|
export interface TvSeason {
|
||||||
|
|||||||
Reference in New Issue
Block a user