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

@@ -1,7 +1,10 @@
import fs from 'fs'
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 { 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) {
const { searchParams } = request.nextUrl
@@ -30,3 +33,58 @@ export async function GET(request: NextRequest) {
: scanDirectory(root, libraryId, subpath)
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 })
}

View File

@@ -1,7 +1,10 @@
import fs from 'fs'
import { NextRequest, NextResponse } from 'next/server'
import { getLibrary } from '@/lib/libraries'
import { getLibrary, resolveLibraryRoot, resolveAndJail } from '@/lib/libraries'
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) {
const { searchParams } = request.nextUrl
@@ -24,3 +27,46 @@ export async function GET(request: NextRequest) {
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 })
}

View 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
View 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 })
}

View File

@@ -5,6 +5,7 @@ import { getLibrary, resolveLibraryRoot, resolveAndJail } from '@/lib/libraries'
import { tvSeriesFromDb, tvSeasonsFromDb, tvEpisodesFromDb } from '@/lib/tv'
import { removeAllAssignmentsForItem } from '@/lib/tags'
import { requireLibraryAccess, requireAdmin } from '@/lib/auth'
import { getDb } from '@/lib/db'
export async function GET(request: NextRequest) {
const { searchParams } = request.nextUrl
@@ -45,6 +46,7 @@ export async function DELETE(request: NextRequest) {
const { searchParams } = request.nextUrl
const libraryId = searchParams.get('libraryId')
const seriesId = searchParams.get('seriesId')
const episodeKey = searchParams.get('episodeKey')
if (!libraryId || !seriesId) {
return NextResponse.json({ error: 'Missing libraryId or seriesId' }, { status: 400 })
@@ -59,6 +61,38 @@ export async function DELETE(request: NextRequest) {
}
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)
let seriesDir: string