ai-customization #22

Merged
gpatti merged 4 commits from ai-customization into main 2026-04-13 01:13:41 +00:00
8 changed files with 640 additions and 40 deletions

View File

@@ -0,0 +1,44 @@
import { NextRequest, NextResponse } from 'next/server'
import { requireAdmin } from '@/lib/auth'
import { getLibraryAiOverrides, setLibraryAiOverrides } from '@/lib/app-settings'
export async function GET(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
const auth = await requireAdmin(request)
if (auth instanceof NextResponse) return auth
const { id } = await params
return NextResponse.json(getLibraryAiOverrides(id))
}
export async function PUT(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
const auth = await requireAdmin(request)
if (auth instanceof NextResponse) return auth
const { id } = await params
let body: Record<string, unknown>
try {
body = await request.json()
} catch {
return NextResponse.json({ error: 'Invalid JSON body' }, { status: 400 })
}
setLibraryAiOverrides(id, {
modelTagging: typeof body.modelTagging === 'string' ? body.modelTagging : undefined,
modelDescribe: typeof body.modelDescribe === 'string' ? body.modelDescribe : undefined,
modelExtract: typeof body.modelExtract === 'string' ? body.modelExtract : undefined,
modelTranslate: typeof body.modelTranslate === 'string' ? body.modelTranslate : undefined,
promptDescribe: typeof body.promptDescribe === 'string' ? body.promptDescribe : undefined,
promptTagger: typeof body.promptTagger === 'string' ? body.promptTagger : undefined,
promptExtract: typeof body.promptExtract === 'string' ? body.promptExtract : undefined,
promptTranslate: typeof body.promptTranslate === 'string' ? body.promptTranslate : undefined,
})
return NextResponse.json(getLibraryAiOverrides(id))
}

View File

@@ -6,23 +6,40 @@ export async function GET(request: NextRequest) {
const auth = await requireAdmin(request) const auth = await requireAdmin(request)
if (auth instanceof NextResponse) return auth if (auth instanceof NextResponse) return auth
const { endpoint, model, modelTagging, modelDescribe, modelExtract, modelTranslate, enabled } = getAiConfig() const config = getAiConfig()
const preferredLanguage = getPreferredLanguage() const preferredLanguage = getPreferredLanguage()
return NextResponse.json({ endpoint, model, modelTagging, modelDescribe, modelExtract, modelTranslate, enabled, preferredLanguage }) return NextResponse.json({ ...config, preferredLanguage })
} }
export async function PUT(request: NextRequest) { export async function PUT(request: NextRequest) {
const auth = await requireAdmin(request) const auth = await requireAdmin(request)
if (auth instanceof NextResponse) return auth if (auth instanceof NextResponse) return auth
let body: { endpoint?: string; model?: string; modelTagging?: string; modelDescribe?: string; modelExtract?: string; modelTranslate?: string; enabled?: boolean; preferredLanguage?: string } let body: {
endpoint?: string
model?: string
modelTagging?: string
modelDescribe?: string
modelExtract?: string
modelTranslate?: string
enabled?: boolean
preferredLanguage?: string
promptDescribe?: string
promptTagger?: string
promptExtract?: string
promptTranslate?: string
}
try { try {
body = await request.json() body = await request.json()
} catch { } catch {
return NextResponse.json({ error: 'Invalid JSON body' }, { status: 400 }) return NextResponse.json({ error: 'Invalid JSON body' }, { status: 400 })
} }
const { endpoint, model, enabled, preferredLanguage, modelTagging, modelDescribe, modelExtract, modelTranslate } = body const {
endpoint, model, enabled, preferredLanguage,
modelTagging, modelDescribe, modelExtract, modelTranslate,
promptDescribe, promptTagger, promptExtract, promptTranslate,
} = body
if (typeof endpoint !== 'string') { if (typeof endpoint !== 'string') {
return NextResponse.json({ error: 'endpoint is required' }, { status: 400 }) return NextResponse.json({ error: 'endpoint is required' }, { status: 400 })
@@ -42,6 +59,10 @@ export async function PUT(request: NextRequest) {
typeof modelDescribe === 'string' ? modelDescribe : undefined, typeof modelDescribe === 'string' ? modelDescribe : undefined,
typeof modelExtract === 'string' ? modelExtract : undefined, typeof modelExtract === 'string' ? modelExtract : undefined,
typeof modelTranslate === 'string' ? modelTranslate : undefined, typeof modelTranslate === 'string' ? modelTranslate : undefined,
typeof promptDescribe === 'string' ? promptDescribe : undefined,
typeof promptTagger === 'string' ? promptTagger : undefined,
typeof promptExtract === 'string' ? promptExtract : undefined,
typeof promptTranslate === 'string' ? promptTranslate : undefined,
) )
if (typeof preferredLanguage === 'string' && preferredLanguage.trim()) { if (typeof preferredLanguage === 'string' && preferredLanguage.trim()) {

View File

@@ -11,10 +11,34 @@ interface AiSettings {
modelTranslate: string modelTranslate: string
enabled: boolean enabled: boolean
preferredLanguage: string preferredLanguage: string
promptDescribe: string
promptTagger: string
promptExtract: string
promptTranslate: string
}
interface Library {
id: string
name: string
}
interface LibraryOverride {
modelTagging: string
modelDescribe: string
modelExtract: string
modelTranslate: string
promptDescribe: string
promptTagger: string
promptExtract: string
promptTranslate: string
} }
export default function AiTaggingPage() { export default function AiTaggingPage() {
const [settings, setSettings] = useState<AiSettings>({ endpoint: '', model: '', modelTagging: '', modelDescribe: '', modelExtract: '', modelTranslate: '', enabled: false, preferredLanguage: 'English' }) const [settings, setSettings] = useState<AiSettings>({
endpoint: '', model: '', modelTagging: '', modelDescribe: '', modelExtract: '', modelTranslate: '',
enabled: false, preferredLanguage: 'English',
promptDescribe: '', promptTagger: '', promptExtract: '', promptTranslate: '',
})
const [loading, setLoading] = useState(true) const [loading, setLoading] = useState(true)
const [saving, setSaving] = useState(false) const [saving, setSaving] = useState(false)
const [saveError, setSaveError] = useState<string | null>(null) const [saveError, setSaveError] = useState<string | null>(null)
@@ -24,12 +48,26 @@ export default function AiTaggingPage() {
const [retagging, setRetagging] = useState(false) const [retagging, setRetagging] = useState(false)
const [retagResult, setRetagResult] = useState<string | null>(null) const [retagResult, setRetagResult] = useState<string | null>(null)
const [libraries, setLibraries] = useState<Library[]>([])
const [libraryOverrides, setLibraryOverrides] = useState<Record<string, LibraryOverride>>({})
const [expandedLibrary, setExpandedLibrary] = useState<string | null>(null)
const [librarySaving, setLibrarySaving] = useState<Record<string, boolean>>({})
const [librarySaveResult, setLibrarySaveResult] = useState<Record<string, { ok: boolean; message: string }>>({})
const fetchSettings = useCallback(async () => { const fetchSettings = useCallback(async () => {
try { try {
const res = await fetch('/api/ai-settings') const [settingsRes, librariesRes] = await Promise.all([
if (!res.ok) return fetch('/api/ai-settings'),
const data: AiSettings = await res.json() fetch('/api/libraries'),
])
if (settingsRes.ok) {
const data: AiSettings = await settingsRes.json()
setSettings(data) setSettings(data)
}
if (librariesRes.ok) {
const data: Library[] = await librariesRes.json()
setLibraries(data)
}
} catch { } catch {
// ignore // ignore
} finally { } finally {
@@ -41,6 +79,29 @@ export default function AiTaggingPage() {
fetchSettings() fetchSettings()
}, [fetchSettings]) }, [fetchSettings])
const fetchLibraryOverrides = useCallback(async (libraryId: string) => {
try {
const res = await fetch(`/api/ai-settings/library/${libraryId}`)
if (res.ok) {
const data: LibraryOverride = await res.json()
setLibraryOverrides((prev) => ({ ...prev, [libraryId]: data }))
}
} catch {
// ignore
}
}, [])
const handleToggleLibrary = (libraryId: string) => {
if (expandedLibrary === libraryId) {
setExpandedLibrary(null)
} else {
setExpandedLibrary(libraryId)
if (!libraryOverrides[libraryId]) {
fetchLibraryOverrides(libraryId)
}
}
}
const handleSave = async (e: React.FormEvent) => { const handleSave = async (e: React.FormEvent) => {
e.preventDefault() e.preventDefault()
setSaveError(null) setSaveError(null)
@@ -104,6 +165,39 @@ export default function AiTaggingPage() {
} }
} }
const handleSaveLibraryOverride = async (libraryId: string) => {
const overrides = libraryOverrides[libraryId]
if (!overrides) return
setLibrarySaving((prev) => ({ ...prev, [libraryId]: true }))
setLibrarySaveResult((prev) => ({ ...prev, [libraryId]: { ok: false, message: '' } }))
try {
const res = await fetch(`/api/ai-settings/library/${libraryId}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(overrides),
})
const data = await res.json()
if (res.ok) {
setLibraryOverrides((prev) => ({ ...prev, [libraryId]: data }))
setLibrarySaveResult((prev) => ({ ...prev, [libraryId]: { ok: true, message: 'Saved.' } }))
setTimeout(() => setLibrarySaveResult((prev) => ({ ...prev, [libraryId]: { ok: true, message: '' } })), 3000)
} else {
setLibrarySaveResult((prev) => ({ ...prev, [libraryId]: { ok: false, message: data.error ?? 'Failed to save.' } }))
}
} catch {
setLibrarySaveResult((prev) => ({ ...prev, [libraryId]: { ok: false, message: 'Network error.' } }))
} finally {
setLibrarySaving((prev) => ({ ...prev, [libraryId]: false }))
}
}
const updateLibraryOverride = (libraryId: string, field: keyof LibraryOverride, value: string) => {
setLibraryOverrides((prev) => ({
...prev,
[libraryId]: { ...(prev[libraryId] ?? emptyOverride()), [field]: value },
}))
}
return ( return (
<div className="max-w-2xl"> <div className="max-w-2xl">
<h1 className="text-2xl font-semibold mb-1" style={{ color: 'var(--text-primary)' }}> <h1 className="text-2xl font-semibold mb-1" style={{ color: 'var(--text-primary)' }}>
@@ -334,6 +428,255 @@ export default function AiTaggingPage() {
)} )}
</Section> </Section>
<Section title="Prompts">
{loading ? (
<LoadingRows />
) : (
<form onSubmit={handleSave} className="flex flex-col gap-5">
<Field label="Description Instructions">
<textarea
rows={4}
value={settings.promptDescribe}
onChange={(e) => setSettings((s) => ({ ...s, promptDescribe: e.target.value }))}
className="w-full rounded-lg px-3 py-2 text-sm outline-none focus:ring-2 resize-y"
style={{
backgroundColor: 'var(--background)',
border: '1px solid var(--border)',
color: 'var(--text-primary)',
}}
onFocus={(e) => ((e.currentTarget as HTMLElement).style.borderColor = 'var(--accent)')}
onBlur={(e) => ((e.currentTarget as HTMLElement).style.borderColor = 'var(--border)')}
/>
</Field>
<Field label="Tagger Instructions">
<textarea
rows={4}
value={settings.promptTagger}
onChange={(e) => setSettings((s) => ({ ...s, promptTagger: e.target.value }))}
placeholder="(blank by default)"
className="w-full rounded-lg px-3 py-2 text-sm outline-none focus:ring-2 resize-y"
style={{
backgroundColor: 'var(--background)',
border: '1px solid var(--border)',
color: 'var(--text-primary)',
}}
onFocus={(e) => ((e.currentTarget as HTMLElement).style.borderColor = 'var(--accent)')}
onBlur={(e) => ((e.currentTarget as HTMLElement).style.borderColor = 'var(--border)')}
/>
</Field>
<Field label="Text Extraction Instructions">
<textarea
rows={4}
value={settings.promptExtract}
onChange={(e) => setSettings((s) => ({ ...s, promptExtract: e.target.value }))}
className="w-full rounded-lg px-3 py-2 text-sm outline-none focus:ring-2 resize-y"
style={{
backgroundColor: 'var(--background)',
border: '1px solid var(--border)',
color: 'var(--text-primary)',
}}
onFocus={(e) => ((e.currentTarget as HTMLElement).style.borderColor = 'var(--accent)')}
onBlur={(e) => ((e.currentTarget as HTMLElement).style.borderColor = 'var(--border)')}
/>
</Field>
<Field label="Translation Instructions">
<textarea
rows={4}
value={settings.promptTranslate}
onChange={(e) => setSettings((s) => ({ ...s, promptTranslate: e.target.value }))}
className="w-full rounded-lg px-3 py-2 text-sm outline-none focus:ring-2 resize-y"
style={{
backgroundColor: 'var(--background)',
border: '1px solid var(--border)',
color: 'var(--text-primary)',
}}
onFocus={(e) => ((e.currentTarget as HTMLElement).style.borderColor = 'var(--accent)')}
onBlur={(e) => ((e.currentTarget as HTMLElement).style.borderColor = 'var(--border)')}
/>
</Field>
{saveError && (
<p
className="text-sm rounded-lg px-3 py-2"
style={{ backgroundColor: '#7f1d1d33', color: '#fca5a5' }}
>
{saveError}
</p>
)}
{saveSuccess && (
<p
className="text-sm rounded-lg px-3 py-2"
style={{ backgroundColor: '#14532d33', color: '#4ade80' }}
>
Settings saved.
</p>
)}
<div>
<button
type="submit"
disabled={saving}
className="px-4 py-2 rounded-lg text-sm font-medium transition-colors disabled:opacity-50"
style={{ backgroundColor: 'var(--accent)', color: '#fff' }}
onMouseEnter={(e) => {
if (!saving) (e.currentTarget as HTMLElement).style.backgroundColor = 'var(--accent-hover)'
}}
onMouseLeave={(e) => {
(e.currentTarget as HTMLElement).style.backgroundColor = 'var(--accent)'
}}
>
{saving ? 'Saving...' : 'Save Prompts'}
</button>
</div>
</form>
)}
</Section>
<Section title="Library Overrides">
{loading ? (
<LoadingRows />
) : libraries.length === 0 ? (
<p className="text-sm" style={{ color: 'var(--text-secondary)' }}>No libraries configured.</p>
) : (
<div className="flex flex-col divide-y" style={{ borderColor: 'var(--border)' }}>
{libraries.map((lib) => {
const isExpanded = expandedLibrary === lib.id
const overrides = libraryOverrides[lib.id] ?? null
const isSaving = librarySaving[lib.id] ?? false
const saveResult = librarySaveResult[lib.id]
return (
<div key={lib.id}>
<button
type="button"
onClick={() => handleToggleLibrary(lib.id)}
className="w-full flex items-center justify-between py-3 text-left"
style={{ color: 'var(--text-primary)' }}
>
<span className="text-sm font-medium">{lib.name}</span>
<span
className="text-xs transition-transform"
style={{
color: 'var(--text-secondary)',
transform: isExpanded ? 'rotate(180deg)' : 'rotate(0deg)',
display: 'inline-block',
}}
>
</span>
</button>
{isExpanded && (
<div className="pb-5 flex flex-col gap-5">
{overrides === null ? (
<p className="text-xs" style={{ color: 'var(--text-secondary)' }}>Loading</p>
) : (
<>
<p className="text-xs" style={{ color: 'var(--text-secondary)' }}>
Leave any field blank to use the global default.
</p>
<div className="flex flex-col gap-3">
<p className="text-xs font-medium uppercase tracking-wide" style={{ color: 'var(--text-secondary)' }}>Models</p>
{(
[
['modelTagging', 'Tagging Model'],
['modelDescribe', 'Description Model'],
['modelExtract', 'Text Extraction Model'],
['modelTranslate', 'Translation Model'],
] as [keyof LibraryOverride, string][]
).map(([field, label]) => (
<Field key={field} label={label}>
<input
type="text"
value={overrides[field]}
onChange={(e) => updateLibraryOverride(lib.id, field, e.target.value)}
placeholder={`Leave blank to use global default${settings[field as keyof AiSettings] ? ` (${settings[field as keyof AiSettings]})` : ''}`}
className="w-full rounded-lg px-3 py-2 text-sm font-mono outline-none focus:ring-2"
style={{
backgroundColor: 'var(--background)',
border: '1px solid var(--border)',
color: 'var(--text-primary)',
}}
onFocus={(e) => ((e.currentTarget as HTMLElement).style.borderColor = 'var(--accent)')}
onBlur={(e) => ((e.currentTarget as HTMLElement).style.borderColor = 'var(--border)')}
/>
</Field>
))}
</div>
<div className="flex flex-col gap-3">
<p className="text-xs font-medium uppercase tracking-wide" style={{ color: 'var(--text-secondary)' }}>Prompts</p>
{(
[
['promptDescribe', 'Description Instructions', settings.promptDescribe],
['promptTagger', 'Tagger Instructions', settings.promptTagger],
['promptExtract', 'Text Extraction Instructions', settings.promptExtract],
['promptTranslate', 'Translation Instructions', settings.promptTranslate],
] as [keyof LibraryOverride, string, string][]
).map(([field, label, globalValue]) => (
<Field key={field} label={label}>
<textarea
rows={3}
value={overrides[field]}
onChange={(e) => updateLibraryOverride(lib.id, field, e.target.value)}
placeholder={globalValue ? `Leave blank to use global default:\n${globalValue}` : 'Leave blank to use global default'}
className="w-full rounded-lg px-3 py-2 text-sm outline-none focus:ring-2 resize-y"
style={{
backgroundColor: 'var(--background)',
border: '1px solid var(--border)',
color: 'var(--text-primary)',
}}
onFocus={(e) => ((e.currentTarget as HTMLElement).style.borderColor = 'var(--accent)')}
onBlur={(e) => ((e.currentTarget as HTMLElement).style.borderColor = 'var(--border)')}
/>
</Field>
))}
</div>
{saveResult?.message && (
<p
className="text-sm rounded-lg px-3 py-2"
style={{
backgroundColor: saveResult.ok ? '#14532d33' : '#7f1d1d33',
color: saveResult.ok ? '#4ade80' : '#fca5a5',
}}
>
{saveResult.message}
</p>
)}
<div>
<button
type="button"
onClick={() => handleSaveLibraryOverride(lib.id)}
disabled={isSaving}
className="px-4 py-2 rounded-lg text-sm font-medium transition-colors disabled:opacity-50"
style={{ backgroundColor: 'var(--accent)', color: '#fff' }}
onMouseEnter={(e) => {
if (!isSaving) (e.currentTarget as HTMLElement).style.backgroundColor = 'var(--accent-hover)'
}}
onMouseLeave={(e) => {
(e.currentTarget as HTMLElement).style.backgroundColor = 'var(--accent)'
}}
>
{isSaving ? 'Saving...' : `Save ${lib.name} Overrides`}
</button>
</div>
</>
)}
</div>
)}
</div>
)
})}
</div>
)}
</Section>
<Section title="Re-tag"> <Section title="Re-tag">
<div className="flex flex-col gap-3"> <div className="flex flex-col gap-3">
<p className="text-sm" style={{ color: 'var(--text-secondary)' }}> <p className="text-sm" style={{ color: 'var(--text-secondary)' }}>
@@ -365,6 +708,13 @@ export default function AiTaggingPage() {
) )
} }
function emptyOverride(): LibraryOverride {
return {
modelTagging: '', modelDescribe: '', modelExtract: '', modelTranslate: '',
promptDescribe: '', promptTagger: '', promptExtract: '', promptTranslate: '',
}
}
function Section({ title, children }: { title: string; children: React.ReactNode }) { function Section({ title, children }: { title: string; children: React.ReactNode }) {
return ( return (
<div className="mb-10"> <div className="mb-10">

View File

@@ -38,6 +38,9 @@ export default function MixedView({ libraryId, initialPath }: Props) {
const [recursiveLoaded, setRecursiveLoaded] = useState(false) const [recursiveLoaded, setRecursiveLoaded] = useState(false)
const [doomScrollActive, setDoomScrollActive] = useState(false) const [doomScrollActive, setDoomScrollActive] = useState(false)
const [doomScrollLoading, setDoomScrollLoading] = useState(false) const [doomScrollLoading, setDoomScrollLoading] = useState(false)
const [doomScrollEntries, setDoomScrollEntries] = useState<FileEntry[]>([])
const [doomScrollEntriesLoading, setDoomScrollEntriesLoading] = useState(false)
const [doomScrollEntriesLoaded, setDoomScrollEntriesLoaded] = useState(false)
const toggleTag = (tagId: string) => const toggleTag = (tagId: string) =>
setSelectedTagIds((prev) => { setSelectedTagIds((prev) => {
@@ -71,6 +74,14 @@ export default function MixedView({ libraryId, initialPath }: Props) {
loadPath(initialPath) loadPath(initialPath)
}, [loadPath, initialPath]) }, [loadPath, initialPath])
// Invalidate doom scroll entry cache when the user navigates to a different directory
useEffect(() => {
setDoomScrollEntries([])
setDoomScrollEntriesLoaded(false)
setDoomScrollEntriesLoading(false)
setDoomScrollLoading(false)
}, [currentPath])
const fetchAssignments = useCallback(() => { const fetchAssignments = useCallback(() => {
fetch(`/api/tags/library-assignments?libraryId=${encodeURIComponent(libraryId)}`) fetch(`/api/tags/library-assignments?libraryId=${encodeURIComponent(libraryId)}`)
.then((r) => r.json()) .then((r) => r.json())
@@ -95,6 +106,21 @@ export default function MixedView({ libraryId, initialPath }: Props) {
.finally(() => setRecursiveLoading(false)) .finally(() => setRecursiveLoading(false))
}, [libraryId, recursiveLoaded, recursiveLoading]) }, [libraryId, recursiveLoaded, recursiveLoading])
const fetchDoomScrollEntries = useCallback(() => {
if (doomScrollEntriesLoaded || doomScrollEntriesLoading) return
setDoomScrollEntriesLoading(true)
fetch(
`/api/browse?libraryId=${encodeURIComponent(libraryId)}&path=${encodeURIComponent(currentPath)}&recursive=true`
)
.then((r) => r.json())
.then((data: DirectoryListing) => {
setDoomScrollEntries(data.entries)
setDoomScrollEntriesLoaded(true)
})
.catch(() => {})
.finally(() => setDoomScrollEntriesLoading(false))
}, [libraryId, currentPath, doomScrollEntriesLoaded, doomScrollEntriesLoading])
// Fetch the full recursive listing the first time any filter becomes active // Fetch the full recursive listing the first time any filter becomes active
useEffect(() => { useEffect(() => {
if (!filtersActive) return if (!filtersActive) return
@@ -182,25 +208,33 @@ export default function MixedView({ libraryId, initialPath }: Props) {
fetchRecursive() fetchRecursive()
return return
} }
if (recursiveLoaded) { // No filters: scope to current directory
if (doomScrollEntriesLoaded) {
setDoomScrollActive(true) setDoomScrollActive(true)
return return
} }
setDoomScrollLoading(true) setDoomScrollLoading(true)
fetchRecursive() fetchDoomScrollEntries()
} }
// Activate doom scroll once the recursive listing finishes loading (when triggered by button) // Activate doom scroll once the appropriate listing finishes loading (when triggered by button)
useEffect(() => { useEffect(() => {
if (doomScrollLoading && !recursiveLoading && recursiveLoaded) { if (!doomScrollLoading) return
const filtersDone = filtersActive && !recursiveLoading && recursiveLoaded
const noFiltersDone = !filtersActive && !doomScrollEntriesLoading && doomScrollEntriesLoaded
if (filtersDone || noFiltersDone) {
setDoomScrollLoading(false) setDoomScrollLoading(false)
setDoomScrollActive(true) setDoomScrollActive(true)
} }
}, [doomScrollLoading, recursiveLoading, recursiveLoaded]) }, [
doomScrollLoading, filtersActive,
recursiveLoading, recursiveLoaded,
doomScrollEntriesLoading, doomScrollEntriesLoaded,
])
// When filters are active, doom scroll uses filteredEntries (already filtered by search/tags). // When filters are active, doom scroll uses filteredEntries (already filtered by search/tags).
// When no filters, doom scroll uses the full recursiveEntries. // When no filters, doom scroll uses files recursively under the current directory.
const doomScrollItems: DoomScrollItem[] = (filtersActive ? filteredEntries : recursiveEntries) const doomScrollItems: DoomScrollItem[] = (filtersActive ? filteredEntries : doomScrollEntries)
.filter((e) => e.type === 'file' && (e.mediaType === 'video' || e.mediaType === 'image') && e.url && isBrowserPlayable(e.name)) .filter((e) => e.type === 'file' && (e.mediaType === 'video' || e.mediaType === 'image') && e.url && isBrowserPlayable(e.name))
.map((e) => ({ url: e.url!, name: e.name, mediaType: e.mediaType as 'video' | 'image' })) .map((e) => ({ url: e.url!, name: e.name, mediaType: e.mediaType as 'video' | 'image' }))

View File

@@ -298,9 +298,13 @@ export default function TagSelector({ itemKey, onTagsChanged, refreshKey }: Prop
{all.categories.map((category) => { {all.categories.map((category) => {
const categoryTags = all.tags.filter((t) => t.categoryId === category.id) const categoryTags = all.tags.filter((t) => t.categoryId === category.id)
const search = categorySearches[category.id] ?? '' const search = categorySearches[category.id] ?? ''
const visibleTags = categoryTags const filtered = categoryTags.filter(
.filter((t) => !search || t.name.toLowerCase().includes(search.toLowerCase())) (t) => !search || t.name.toLowerCase().includes(search.toLowerCase())
.slice(0, 25) )
const visibleTags = [
...filtered.filter((t) => isAssigned(t.id)),
...filtered.filter((t) => !isAssigned(t.id)),
].slice(0, 25)
return ( return (
<div key={category.id}> <div key={category.id}>

View File

@@ -2,7 +2,7 @@ import fs from 'fs'
import path from 'path' import path from 'path'
import type { Library, Tag, TagCategory } from '@/types' import type { Library, Tag, TagCategory } from '@/types'
import { getDb } from './db' import { getDb } from './db'
import { getAiConfig, getPreferredLanguage } from './app-settings' import { getAiConfig, getEffectiveAiConfig, getPreferredLanguage } from './app-settings'
import { getTags, getCategories, addTagToItem, getActiveCategoryIdsForLibrary, getResolvedTagsForItem } from './tags' import { getTags, getCategories, addTagToItem, getActiveCategoryIdsForLibrary, getResolvedTagsForItem } from './tags'
import { getThumbnailPath, getVideoFramePaths } from './thumbnails' import { getThumbnailPath, getVideoFramePaths } from './thumbnails'
import { findFile } from './media-utils' import { findFile } from './media-utils'
@@ -105,10 +105,11 @@ interface TagPromptContext {
mediaContext?: 'image' | 'video' mediaContext?: 'image' | 'video'
aiDescription?: string | null aiDescription?: string | null
extractedText?: string | null extractedText?: string | null
customInstruction?: string
} }
function buildTagPrompt(tags: Tag[], categories: TagCategory[], ctx: TagPromptContext = {}): string { function buildTagPrompt(tags: Tag[], categories: TagCategory[], ctx: TagPromptContext = {}): string {
const { currentTags, mediaContext = 'image', aiDescription, extractedText } = ctx const { currentTags, mediaContext = 'image', aiDescription, extractedText, customInstruction } = ctx
const categoryMap = new Map(categories.map((c) => [c.id, c.name])) const categoryMap = new Map(categories.map((c) => [c.id, c.name]))
const grouped: Record<string, { id: string; name: string }[]> = {} const grouped: Record<string, { id: string; name: string }[]> = {}
@@ -132,6 +133,11 @@ function buildTagPrompt(tags: Tag[], categories: TagCategory[], ctx: TagPromptCo
'If no tags match, return an empty array (e.i., [])', 'If no tags match, return an empty array (e.i., [])',
] ]
if (customInstruction) {
parts.push('')
parts.push(customInstruction)
}
if (aiDescription) { if (aiDescription) {
parts.push('') parts.push('')
parts.push(`AI-generated description of this content: ${aiDescription}`) parts.push(`AI-generated description of this content: ${aiDescription}`)
@@ -222,7 +228,7 @@ async function callVisionApi(
* Processes up to BATCH_LIMIT untagged items per invocation. * Processes up to BATCH_LIMIT untagged items per invocation.
*/ */
export async function runAiTagging(library: Library, libraryRoot: string): Promise<void> { export async function runAiTagging(library: Library, libraryRoot: string): Promise<void> {
const config = getAiConfig() const config = getEffectiveAiConfig(library.id)
const taggingModel = config.modelTagging || config.model const taggingModel = config.modelTagging || config.model
if (!config.enabled || !config.endpoint || !taggingModel) return if (!config.enabled || !config.endpoint || !taggingModel) return
@@ -284,6 +290,7 @@ export async function runAiTagging(library: Library, libraryRoot: string): Promi
mediaContext: resolvedMedia.mediaType, mediaContext: resolvedMedia.mediaType,
aiDescription: aiFields.aiDescription, aiDescription: aiFields.aiDescription,
extractedText: aiFields.extractedTextTranslated ?? aiFields.extractedText, extractedText: aiFields.extractedTextTranslated ?? aiFields.extractedText,
customInstruction: config.promptTagger || undefined,
}) })
const suggestedIds = await callVisionApi(config.endpoint, taggingModel, base64Images, systemPrompt) const suggestedIds = await callVisionApi(config.endpoint, taggingModel, base64Images, systemPrompt)
@@ -317,14 +324,13 @@ export async function runAiTagging(library: Library, libraryRoot: string): Promi
* Throws descriptive errors so the API route can return appropriate status codes. * Throws descriptive errors so the API route can return appropriate status codes.
*/ */
export async function tagSingleItem(itemKey: string): Promise<string[]> { export async function tagSingleItem(itemKey: string): Promise<string[]> {
const config = getAiConfig() const libraryId = itemKey.split(':')[0]
const config = getEffectiveAiConfig(libraryId)
const taggingModel = config.modelTagging || config.model const taggingModel = config.modelTagging || config.model
if (!config.endpoint || !taggingModel) { if (!config.endpoint || !taggingModel) {
throw Object.assign(new Error('AI tagging endpoint and model are not configured'), { code: 'NOT_CONFIGURED' }) throw Object.assign(new Error('AI tagging endpoint and model are not configured'), { code: 'NOT_CONFIGURED' })
} }
const libraryId = itemKey.split(':')[0]
const activeCategoryIds = new Set(getActiveCategoryIdsForLibrary(libraryId)) const activeCategoryIds = new Set(getActiveCategoryIdsForLibrary(libraryId))
const allTags = getTags() const allTags = getTags()
const allCategories = getCategories() const allCategories = getCategories()
@@ -372,6 +378,7 @@ export async function tagSingleItem(itemKey: string): Promise<string[]> {
mediaContext: imagePath.mediaType, mediaContext: imagePath.mediaType,
aiDescription: aiFields.aiDescription, aiDescription: aiFields.aiDescription,
extractedText: aiFields.extractedTextTranslated ?? aiFields.extractedText, extractedText: aiFields.extractedTextTranslated ?? aiFields.extractedText,
customInstruction: config.promptTagger || undefined,
}) })
const suggestedIds = await callVisionApi(config.endpoint, taggingModel, base64Images, systemPromptWithContext) const suggestedIds = await callVisionApi(config.endpoint, taggingModel, base64Images, systemPromptWithContext)
@@ -491,13 +498,13 @@ async function callChatApiText(
* Stores the result in the ai_description column and returns it. * Stores the result in the ai_description column and returns it.
*/ */
export async function generateItemDescription(itemKey: string): Promise<string> { export async function generateItemDescription(itemKey: string): Promise<string> {
const config = getAiConfig() const libraryId = itemKey.split(':')[0]
const config = getEffectiveAiConfig(libraryId)
const describeModel = config.modelDescribe || config.model const describeModel = config.modelDescribe || config.model
if (!config.endpoint || !describeModel) { if (!config.endpoint || !describeModel) {
throw Object.assign(new Error('AI endpoint and model are not configured'), { code: 'NOT_CONFIGURED' }) throw Object.assign(new Error('AI endpoint and model are not configured'), { code: 'NOT_CONFIGURED' })
} }
const libraryId = itemKey.split(':')[0]
const db = getDb() const db = getDb()
const item = db const item = db
.prepare('SELECT item_key, item_type, file_path, metadata FROM media_items WHERE item_key = ?') .prepare('SELECT item_key, item_type, file_path, metadata FROM media_items WHERE item_key = ?')
@@ -526,7 +533,11 @@ export async function generateItemDescription(itemKey: string): Promise<string>
base64Images = [fs.readFileSync(thumbnailPath, 'base64')] base64Images = [fs.readFileSync(thumbnailPath, 'base64')]
} }
const systemPrompt = 'You are a media cataloging assistant. Describe the given image briefly and objectively in 1-3 sentences. Focus on the visual content, subjects, setting, and mood. Do not speculate about context outside the image. Do not preface the description with any phrases like "This image shows" or "This image features". Return only the description text with no additional commentary.' const { tags: currentTags } = getResolvedTagsForItem(itemKey)
const tagContext = currentTags.length > 0
? ` This content has the following tags applied describing it: ${currentTags.map((t) => t.name).join(', ')}. Use these as additional context and treat them as a source of truth, overriding any conflicting assumptions made from the image.`
: ''
const systemPrompt = `You are a media cataloging assistant. Describe the given image briefly and objectively in 1-3 sentences.${config.promptDescribe ? ' ' + config.promptDescribe : ''}${tagContext}`
const description = await callVisionApiText(config.endpoint, describeModel, base64Images, systemPrompt) const description = await callVisionApiText(config.endpoint, describeModel, base64Images, systemPrompt)
@@ -544,13 +555,13 @@ export async function generateItemDescription(itemKey: string): Promise<string>
* Returns { extractedText, translatedText }. * Returns { extractedText, translatedText }.
*/ */
export async function extractItemText(itemKey: string): Promise<{ extractedText: string; translatedText: string | null }> { export async function extractItemText(itemKey: string): Promise<{ extractedText: string; translatedText: string | null }> {
const config = getAiConfig() const libraryId = itemKey.split(':')[0]
const config = getEffectiveAiConfig(libraryId)
const extractModel = config.modelExtract || config.model const extractModel = config.modelExtract || config.model
if (!config.endpoint || !extractModel) { if (!config.endpoint || !extractModel) {
throw Object.assign(new Error('AI endpoint and model are not configured'), { code: 'NOT_CONFIGURED' }) throw Object.assign(new Error('AI endpoint and model are not configured'), { code: 'NOT_CONFIGURED' })
} }
const libraryId = itemKey.split(':')[0]
const db = getDb() const db = getDb()
const item = db const item = db
.prepare('SELECT item_key, item_type, file_path, metadata FROM media_items WHERE item_key = ?') .prepare('SELECT item_key, item_type, file_path, metadata FROM media_items WHERE item_key = ?')
@@ -579,7 +590,7 @@ export async function extractItemText(itemKey: string): Promise<{ extractedText:
const thumbnailPath = await getThumbnailPath(resolvedMedia.path, libraryId, 'image') const thumbnailPath = await getThumbnailPath(resolvedMedia.path, libraryId, 'image')
const base64Images = [fs.readFileSync(thumbnailPath, 'base64')] const base64Images = [fs.readFileSync(thumbnailPath, 'base64')]
const systemPrompt = 'You are an OCR assistant. Extract ALL text visible in the image exactly as it appears. Preserve line breaks and formatting. Be mindful of different colors of text that may indicate different speakers or emphasis. If there is no text in the image, respond with exactly: [NO TEXT]' const systemPrompt = `You are an OCR assistant. Extract ALL text visible in the image exactly as it appears. Preserve line breaks and formatting.${config.promptExtract ? ' ' + config.promptExtract : ''} If there is no text in the image, respond with exactly: [NO TEXT]`
const extractedText = await callVisionApiText(config.endpoint, extractModel, base64Images, systemPrompt) const extractedText = await callVisionApiText(config.endpoint, extractModel, base64Images, systemPrompt)
@@ -596,7 +607,7 @@ export async function extractItemText(itemKey: string): Promise<{ extractedText:
if (preferredLanguage) { if (preferredLanguage) {
const translateModel = config.modelTranslate || config.model const translateModel = config.modelTranslate || config.model
try { try {
translatedText = await translateText(config.endpoint, translateModel, extractedText, preferredLanguage) translatedText = await translateText(config.endpoint, translateModel, extractedText, preferredLanguage, config.promptTranslate)
if (translatedText) { if (translatedText) {
db.prepare('UPDATE media_items SET extracted_text_translated = ? WHERE item_key = ?').run(translatedText, itemKey) db.prepare('UPDATE media_items SET extracted_text_translated = ? WHERE item_key = ?').run(translatedText, itemKey)
} }
@@ -613,7 +624,8 @@ export async function extractItemText(itemKey: string): Promise<{ extractedText:
* Returns the translated text or null if no text to translate. * Returns the translated text or null if no text to translate.
*/ */
export async function translateItemText(itemKey: string): Promise<string | null> { export async function translateItemText(itemKey: string): Promise<string | null> {
const config = getAiConfig() const libraryId = itemKey.split(':')[0]
const config = getEffectiveAiConfig(libraryId)
const translateModel = config.modelTranslate || config.model const translateModel = config.modelTranslate || config.model
if (!config.endpoint || !translateModel) { if (!config.endpoint || !translateModel) {
throw Object.assign(new Error('AI endpoint and model are not configured'), { code: 'NOT_CONFIGURED' }) throw Object.assign(new Error('AI endpoint and model are not configured'), { code: 'NOT_CONFIGURED' })
@@ -633,7 +645,7 @@ export async function translateItemText(itemKey: string): Promise<string | null>
const preferredLanguage = getPreferredLanguage() const preferredLanguage = getPreferredLanguage()
if (!preferredLanguage) return null if (!preferredLanguage) return null
const translatedText = await translateText(config.endpoint, translateModel, row.extracted_text, preferredLanguage) const translatedText = await translateText(config.endpoint, translateModel, row.extracted_text, preferredLanguage, config.promptTranslate)
if (translatedText) { if (translatedText) {
db.prepare('UPDATE media_items SET extracted_text_translated = ? WHERE item_key = ?').run(translatedText, itemKey) db.prepare('UPDATE media_items SET extracted_text_translated = ? WHERE item_key = ?').run(translatedText, itemKey)
} }
@@ -649,9 +661,10 @@ async function translateText(
endpoint: string, endpoint: string,
model: string, model: string,
text: string, text: string,
targetLanguage: string targetLanguage: string,
customInstruction = '',
): Promise<string | null> { ): Promise<string | null> {
const systemPrompt = `You are a translator. Determine if the following text is already in ${targetLanguage}. If it is, respond with exactly: [ALREADY_TARGET_LANGUAGE]. If it is not, translate it to ${targetLanguage}. Return ONLY the translated text with no additional commentary.` const systemPrompt = `You are a translator. Determine if the following text is already in ${targetLanguage}. If it is, respond with exactly: [ALREADY_TARGET_LANGUAGE]. If it is not, translate it to ${targetLanguage}.${customInstruction ? ' ' + customInstruction : ''}`
const result = await callChatApiText(endpoint, model, systemPrompt, text) const result = await callChatApiText(endpoint, model, systemPrompt, text)
@@ -667,7 +680,7 @@ async function translateText(
* Returns the number of items processed. * Returns the number of items processed.
*/ */
export async function extractDirectoryText(libraryId: string, dirPath: string): Promise<number> { export async function extractDirectoryText(libraryId: string, dirPath: string): Promise<number> {
const config = getAiConfig() const config = getEffectiveAiConfig(libraryId)
const extractModel = config.modelExtract || config.model const extractModel = config.modelExtract || config.model
if (!config.endpoint || !extractModel) { if (!config.endpoint || !extractModel) {
throw Object.assign(new Error('AI endpoint and model are not configured'), { code: 'NOT_CONFIGURED' }) throw Object.assign(new Error('AI endpoint and model are not configured'), { code: 'NOT_CONFIGURED' })
@@ -718,7 +731,7 @@ export async function extractDirectoryText(libraryId: string, dirPath: string):
* Returns the number of items processed. * Returns the number of items processed.
*/ */
export async function describeDirectoryItems(libraryId: string, dirPath: string): Promise<number> { export async function describeDirectoryItems(libraryId: string, dirPath: string): Promise<number> {
const config = getAiConfig() const config = getEffectiveAiConfig(libraryId)
const describeModel = config.modelDescribe || config.model const describeModel = config.modelDescribe || config.model
if (!config.endpoint || !describeModel) { if (!config.endpoint || !describeModel) {
throw Object.assign(new Error('AI endpoint and model are not configured'), { code: 'NOT_CONFIGURED' }) throw Object.assign(new Error('AI endpoint and model are not configured'), { code: 'NOT_CONFIGURED' })

View File

@@ -39,7 +39,14 @@ export function setScanLastRan(ts: number): void {
// ─── AI Settings ───────────────────────────────────────────────────────────── // ─── AI Settings ─────────────────────────────────────────────────────────────
interface AiConfig { const DEFAULT_PROMPT_DESCRIBE =
'Focus on the visual content, subjects, setting, and mood. Do not speculate about context outside the image. Do not preface the description with any phrases like "This image shows" or "This image features". Return only the description text with no additional commentary.'
const DEFAULT_PROMPT_TAGGER = ''
const DEFAULT_PROMPT_EXTRACT =
'Be mindful of different colors of text that may indicate different speakers or emphasis.'
const DEFAULT_PROMPT_TRANSLATE = 'Return ONLY the translated text with no additional commentary.'
export interface AiConfig {
endpoint: string endpoint: string
model: string model: string
modelTagging: string modelTagging: string
@@ -47,6 +54,10 @@ interface AiConfig {
modelExtract: string modelExtract: string
modelTranslate: string modelTranslate: string
enabled: boolean enabled: boolean
promptDescribe: string
promptTagger: string
promptExtract: string
promptTranslate: string
} }
export function getAiConfig(): AiConfig { export function getAiConfig(): AiConfig {
@@ -57,7 +68,18 @@ export function getAiConfig(): AiConfig {
const modelExtract = getSetting('ai_model_extract') ?? '' const modelExtract = getSetting('ai_model_extract') ?? ''
const modelTranslate = getSetting('ai_model_translate') ?? '' const modelTranslate = getSetting('ai_model_translate') ?? ''
const enabled = getSetting('ai_enabled') === 'true' const enabled = getSetting('ai_enabled') === 'true'
return { endpoint, model, modelTagging, modelDescribe, modelExtract, modelTranslate, enabled } const promptDescribeRaw = getSetting('ai_prompt_describe')
const promptDescribe = promptDescribeRaw !== null ? promptDescribeRaw : DEFAULT_PROMPT_DESCRIBE
const promptTaggerRaw = getSetting('ai_prompt_tagger')
const promptTagger = promptTaggerRaw !== null ? promptTaggerRaw : DEFAULT_PROMPT_TAGGER
const promptExtractRaw = getSetting('ai_prompt_extract')
const promptExtract = promptExtractRaw !== null ? promptExtractRaw : DEFAULT_PROMPT_EXTRACT
const promptTranslateRaw = getSetting('ai_prompt_translate')
const promptTranslate = promptTranslateRaw !== null ? promptTranslateRaw : DEFAULT_PROMPT_TRANSLATE
return {
endpoint, model, modelTagging, modelDescribe, modelExtract, modelTranslate, enabled,
promptDescribe, promptTagger, promptExtract, promptTranslate,
}
} }
export function updateAiConfig( export function updateAiConfig(
@@ -68,6 +90,10 @@ export function updateAiConfig(
modelDescribe?: string, modelDescribe?: string,
modelExtract?: string, modelExtract?: string,
modelTranslate?: string, modelTranslate?: string,
promptDescribe?: string,
promptTagger?: string,
promptExtract?: string,
promptTranslate?: string,
): void { ): void {
setSetting('ai_endpoint', endpoint) setSetting('ai_endpoint', endpoint)
setSetting('ai_model', model) setSetting('ai_model', model)
@@ -76,6 +102,10 @@ export function updateAiConfig(
if (modelDescribe !== undefined) setSetting('ai_model_describe', modelDescribe) if (modelDescribe !== undefined) setSetting('ai_model_describe', modelDescribe)
if (modelExtract !== undefined) setSetting('ai_model_extract', modelExtract) if (modelExtract !== undefined) setSetting('ai_model_extract', modelExtract)
if (modelTranslate !== undefined) setSetting('ai_model_translate', modelTranslate) if (modelTranslate !== undefined) setSetting('ai_model_translate', modelTranslate)
if (promptDescribe !== undefined) setSetting('ai_prompt_describe', promptDescribe)
if (promptTagger !== undefined) setSetting('ai_prompt_tagger', promptTagger)
if (promptExtract !== undefined) setSetting('ai_prompt_extract', promptExtract)
if (promptTranslate !== undefined) setSetting('ai_prompt_translate', promptTranslate)
} }
export function getPreferredLanguage(): string { export function getPreferredLanguage(): string {
@@ -85,3 +115,90 @@ export function getPreferredLanguage(): string {
export function setPreferredLanguage(language: string): void { export function setPreferredLanguage(language: string): void {
setSetting('preferred_language', language) setSetting('preferred_language', language)
} }
// ─── Per-library AI overrides ─────────────────────────────────────────────────
export interface LibraryAiOverrides {
modelTagging: string
modelDescribe: string
modelExtract: string
modelTranslate: string
promptDescribe: string
promptTagger: string
promptExtract: string
promptTranslate: string
}
interface LibraryAiSettingsRow {
model_tagging: string | null
model_describe: string | null
model_extract: string | null
model_translate: string | null
prompt_describe: string | null
prompt_tagger: string | null
prompt_extract: string | null
prompt_translate: string | null
}
export function getLibraryAiOverrides(libraryId: string): LibraryAiOverrides {
const db = getDb()
const row = db
.prepare('SELECT * FROM library_ai_settings WHERE library_id = ?')
.get(libraryId) as LibraryAiSettingsRow | undefined
return {
modelTagging: row?.model_tagging ?? '',
modelDescribe: row?.model_describe ?? '',
modelExtract: row?.model_extract ?? '',
modelTranslate: row?.model_translate ?? '',
promptDescribe: row?.prompt_describe ?? '',
promptTagger: row?.prompt_tagger ?? '',
promptExtract: row?.prompt_extract ?? '',
promptTranslate: row?.prompt_translate ?? '',
}
}
export function setLibraryAiOverrides(libraryId: string, overrides: Partial<LibraryAiOverrides>): void {
const db = getDb()
// Ensure a row exists
db.prepare(
'INSERT OR IGNORE INTO library_ai_settings (library_id) VALUES (?)'
).run(libraryId)
const fields: Record<string, string | undefined> = {
model_tagging: overrides.modelTagging,
model_describe: overrides.modelDescribe,
model_extract: overrides.modelExtract,
model_translate: overrides.modelTranslate,
prompt_describe: overrides.promptDescribe,
prompt_tagger: overrides.promptTagger,
prompt_extract: overrides.promptExtract,
prompt_translate: overrides.promptTranslate,
}
for (const [col, val] of Object.entries(fields)) {
if (val !== undefined) {
db.prepare(`UPDATE library_ai_settings SET ${col} = ? WHERE library_id = ?`).run(
val === '' ? null : val,
libraryId,
)
}
}
}
export function getEffectiveAiConfig(libraryId: string): AiConfig {
const global = getAiConfig()
const overrides = getLibraryAiOverrides(libraryId)
return {
endpoint: global.endpoint,
model: global.model,
enabled: global.enabled,
modelTagging: overrides.modelTagging || global.modelTagging,
modelDescribe: overrides.modelDescribe || global.modelDescribe,
modelExtract: overrides.modelExtract || global.modelExtract,
modelTranslate: overrides.modelTranslate || global.modelTranslate,
promptDescribe: overrides.promptDescribe || global.promptDescribe,
promptTagger: overrides.promptTagger || global.promptTagger,
promptExtract: overrides.promptExtract || global.promptExtract,
promptTranslate: overrides.promptTranslate || global.promptTranslate,
}
}

View File

@@ -104,6 +104,7 @@ function initDb(db: Database.Database): void {
migrateMediaTagsToItemKey(db) migrateMediaTagsToItemKey(db)
migrateMediaItemsAiTagged(db) migrateMediaItemsAiTagged(db)
migrateMediaItemsAiFields(db) migrateMediaItemsAiFields(db)
migrateLibraryAiSettings(db)
seedAppSettings(db) seedAppSettings(db)
} }
@@ -259,6 +260,22 @@ function migrateMediaItemsAiFields(db: Database.Database): void {
} }
} }
function migrateLibraryAiSettings(db: Database.Database): void {
db.exec(`
CREATE TABLE IF NOT EXISTS library_ai_settings (
library_id TEXT PRIMARY KEY REFERENCES libraries(id) ON DELETE CASCADE,
model_tagging TEXT,
model_describe TEXT,
model_extract TEXT,
model_translate TEXT,
prompt_describe TEXT,
prompt_tagger TEXT,
prompt_extract TEXT,
prompt_translate TEXT
);
`)
}
function migrateLibrariesType(db: Database.Database): void { function migrateLibrariesType(db: Database.Database): void {
const row = db const row = db
.prepare("SELECT sql FROM sqlite_master WHERE type='table' AND name='libraries'") .prepare("SELECT sql FROM sqlite_master WHERE type='table' AND name='libraries'")