add tagging system for media items

Introduces user-defined tag categories and tags with a many-to-many
relationship to media items. Tags are stored in a SQLite database
(medialore.db via better-sqlite3) with ON DELETE CASCADE for automatic
cleanup. Users can manage categories and tags at /manage/tags, assign
tags to games in the detail modal, and tag mixed media files via a
hover button on each tile.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-25 17:29:24 -04:00
parent bf54b45fa1
commit f788b1a441
17 changed files with 1680 additions and 5 deletions

229
src/lib/tags.ts Normal file
View File

@@ -0,0 +1,229 @@
import path from 'path'
import Database from 'better-sqlite3'
import type { Tag, TagCategory } from '@/types'
const DB_PATH = path.resolve(process.cwd(), 'medialore.db')
let _db: Database.Database | null = null
function getDb(): Database.Database {
if (_db) return _db
_db = new Database(DB_PATH)
_db.pragma('journal_mode = WAL')
_db.pragma('foreign_keys = ON')
initDb(_db)
return _db
}
function initDb(db: Database.Database): void {
db.exec(`
CREATE TABLE IF NOT EXISTS tag_categories (
id TEXT PRIMARY KEY,
name TEXT NOT NULL UNIQUE
);
CREATE TABLE IF NOT EXISTS tags (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
category_id TEXT NOT NULL REFERENCES tag_categories(id) ON DELETE CASCADE
);
CREATE UNIQUE INDEX IF NOT EXISTS tags_name_category ON tags(name, category_id);
CREATE TABLE IF NOT EXISTS media_tags (
media_key TEXT NOT NULL,
tag_id TEXT NOT NULL REFERENCES tags(id) ON DELETE CASCADE,
PRIMARY KEY (media_key, tag_id)
);
`)
}
function slugify(name: string): string {
return name
.toLowerCase()
.trim()
.replace(/[^a-z0-9\s-]/g, '')
.replace(/\s+/g, '-')
.replace(/-+/g, '-')
.replace(/^-|-$/g, '')
}
// ─── Categories ───────────────────────────────────────────────────────────────
export function getCategories(): TagCategory[] {
const db = getDb()
return db.prepare('SELECT id, name FROM tag_categories ORDER BY name').all() as TagCategory[]
}
export function addCategory(name: string): TagCategory {
const trimmed = name.trim()
if (!trimmed) throw new Error('Category name is required.')
const db = getDb()
const baseId = slugify(trimmed) || 'category'
let id = baseId
let suffix = 2
while (db.prepare('SELECT 1 FROM tag_categories WHERE id = ?').get(id)) {
id = `${baseId}-${suffix++}`
}
try {
db.prepare('INSERT INTO tag_categories (id, name) VALUES (?, ?)').run(id, trimmed)
} catch {
throw new Error(`A category named "${trimmed}" already exists.`)
}
return { id, name: trimmed }
}
export function updateCategory(id: string, name: string): TagCategory {
const trimmed = name.trim()
if (!trimmed) throw new Error('Category name is required.')
const db = getDb()
try {
const result = db.prepare('UPDATE tag_categories SET name = ? WHERE id = ?').run(trimmed, id)
if (result.changes === 0) throw new Error(`Category not found: ${id}`)
} catch (err) {
const msg = (err as Error).message
if (msg.includes('UNIQUE')) throw new Error(`A category named "${trimmed}" already exists.`)
throw err
}
return { id, name: trimmed }
}
export function deleteCategory(id: string): void {
const db = getDb()
const tagCount = (
db.prepare('SELECT COUNT(*) as count FROM tags WHERE category_id = ?').get(id) as { count: number }
).count
if (tagCount > 0) {
throw new Error(
`Category has ${tagCount} tag${tagCount === 1 ? '' : 's'}. Delete all tags first or use force delete.`
)
}
const result = db.prepare('DELETE FROM tag_categories WHERE id = ?').run(id)
if (result.changes === 0) throw new Error(`Category not found: ${id}`)
}
export function deleteCategoryForce(id: string): void {
const db = getDb()
// CASCADE on tags will also cascade to media_tags
const result = db.prepare('DELETE FROM tag_categories WHERE id = ?').run(id)
if (result.changes === 0) throw new Error(`Category not found: ${id}`)
}
// ─── Tags ─────────────────────────────────────────────────────────────────────
export function getTags(categoryId?: string): Tag[] {
const db = getDb()
if (categoryId) {
return db
.prepare('SELECT id, name, category_id as categoryId FROM tags WHERE category_id = ? ORDER BY name')
.all(categoryId) as Tag[]
}
return db
.prepare('SELECT id, name, category_id as categoryId FROM tags ORDER BY name')
.all() as Tag[]
}
export function addTag(name: string, categoryId: string): Tag {
const trimmed = name.trim()
if (!trimmed) throw new Error('Tag name is required.')
const db = getDb()
const category = db.prepare('SELECT 1 FROM tag_categories WHERE id = ?').get(categoryId)
if (!category) throw new Error(`Category not found: ${categoryId}`)
const baseId = `tag-${slugify(trimmed) || 'tag'}`
let id = baseId
let suffix = 2
while (db.prepare('SELECT 1 FROM tags WHERE id = ?').get(id)) {
id = `${baseId}-${suffix++}`
}
try {
db.prepare('INSERT INTO tags (id, name, category_id) VALUES (?, ?, ?)').run(id, trimmed, categoryId)
} catch {
throw new Error(`A tag named "${trimmed}" already exists in this category.`)
}
return { id, name: trimmed, categoryId }
}
export function updateTag(id: string, name: string): Tag {
const trimmed = name.trim()
if (!trimmed) throw new Error('Tag name is required.')
const db = getDb()
const existing = db
.prepare('SELECT id, name, category_id as categoryId FROM tags WHERE id = ?')
.get(id) as Tag | undefined
if (!existing) throw new Error(`Tag not found: ${id}`)
try {
db.prepare('UPDATE tags SET name = ? WHERE id = ?').run(trimmed, id)
} catch {
throw new Error(`A tag named "${trimmed}" already exists in this category.`)
}
return { id, name: trimmed, categoryId: existing.categoryId }
}
export function deleteTag(id: string): void {
const db = getDb()
// CASCADE removes media_tags rows automatically
const result = db.prepare('DELETE FROM tags WHERE id = ?').run(id)
if (result.changes === 0) throw new Error(`Tag not found: ${id}`)
}
// ─── Assignments ──────────────────────────────────────────────────────────────
export function addTagToItem(mediaKey: string, tagId: string): void {
const db = getDb()
const tag = db.prepare('SELECT 1 FROM tags WHERE id = ?').get(tagId)
if (!tag) throw new Error(`Tag not found: ${tagId}`)
// INSERT OR IGNORE handles duplicate gracefully
db.prepare('INSERT OR IGNORE INTO media_tags (media_key, tag_id) VALUES (?, ?)').run(mediaKey, tagId)
}
export function removeTagFromItem(mediaKey: string, tagId: string): void {
const db = getDb()
db.prepare('DELETE FROM media_tags WHERE media_key = ? AND tag_id = ?').run(mediaKey, tagId)
}
export function getResolvedTagsForItem(mediaKey: string): { tags: Tag[]; categories: TagCategory[] } {
const db = getDb()
const tags = db
.prepare(
`SELECT t.id, t.name, t.category_id as categoryId
FROM tags t
JOIN media_tags mt ON mt.tag_id = t.id
WHERE mt.media_key = ?
ORDER BY t.name`
)
.all(mediaKey) as Tag[]
const categoryIds = [...new Set(tags.map((t) => t.categoryId))]
const categories: TagCategory[] =
categoryIds.length > 0
? (db
.prepare(
`SELECT id, name FROM tag_categories WHERE id IN (${categoryIds.map(() => '?').join(',')}) ORDER BY name`
)
.all(...categoryIds) as TagCategory[])
: []
return { tags, categories }
}
export function removeAllAssignmentsForLibrary(libraryId: string): void {
const db = getDb()
db.prepare("DELETE FROM media_tags WHERE media_key LIKE ?").run(`${libraryId}:%`)
}