add more management capabilities
This commit is contained in:
@@ -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 })
|
||||
}
|
||||
|
||||
@@ -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 })
|
||||
}
|
||||
|
||||
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 { 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
|
||||
|
||||
Reference in New Issue
Block a user