Compare commits

..

3 Commits

Author SHA1 Message Date
da3ad97d51 Merge pull request 'ratings' (#36) from ratings into main
All checks were successful
Build and Push Docker Image / build (push) Successful in 57s
Reviewed-on: http://gitea.lan/gpatti/MediaLore/pulls/36
2026-04-21 15:18:00 +00:00
Garret Patti
b5d144c8cc add ratings to doom scroll 2026-04-21 11:17:43 -04:00
Garret Patti
d854bbe99b add rating system 2026-04-21 10:57:08 -04:00
17 changed files with 932 additions and 93 deletions

View File

@@ -52,12 +52,20 @@ export async function GET(request: NextRequest) {
}
}
const ratingRows = db
.prepare('SELECT item_key, user_rating FROM media_items WHERE library_id = ? AND user_rating IS NOT NULL')
.all(libraryId) as { item_key: string; user_rating: number }[]
const ratingMap = new Map(ratingRows.map((r) => [r.item_key, r.user_rating]))
listing.entries = listing.entries.map((e) => {
if (e.type === 'file') {
if (e.mediaType !== 'image') return e
const relPath = subpath ? path.join(subpath, e.name) : e.name
const itemKey = `${libraryId}:mixed_file:${encodeURIComponent(relPath)}`
return { ...e, hasExtractedText: withText.has(itemKey) }
return {
...e,
...(e.mediaType === 'image' ? { hasExtractedText: withText.has(itemKey) } : {}),
userRating: ratingMap.get(itemKey) ?? null,
}
}
if (e.type === 'directory') {
const dirRel = subpath ? `${subpath}/${e.name}` : e.name

View File

@@ -0,0 +1,64 @@
import { NextRequest, NextResponse } from 'next/server'
import { requireLibraryAccess, requireLibraryWriteAccess } from '@/lib/auth'
import { getDb } from '@/lib/db'
function extractLibraryId(itemKey: string): string | null {
const colonIdx = itemKey.indexOf(':')
if (colonIdx === -1) return null
return itemKey.slice(0, colonIdx)
}
export async function GET(request: NextRequest) {
const { searchParams } = new URL(request.url)
const itemKey = searchParams.get('itemKey')
if (!itemKey) {
return NextResponse.json({ error: 'itemKey is required' }, { status: 400 })
}
const libraryId = extractLibraryId(itemKey)
if (!libraryId) {
return NextResponse.json({ error: 'Invalid itemKey' }, { status: 400 })
}
const auth = await requireLibraryAccess(request, libraryId)
if (auth instanceof NextResponse) return auth
const db = getDb()
const row = db
.prepare('SELECT user_rating FROM media_items WHERE item_key = ?')
.get(itemKey) as { user_rating: number | null } | undefined
if (!row) {
return NextResponse.json({ error: 'Item not found' }, { status: 404 })
}
return NextResponse.json({ userRating: row.user_rating ?? null })
}
export async function PATCH(request: NextRequest) {
const body = await request.json()
const { itemKey, userRating } = body as { itemKey: string; userRating: number | null }
if (!itemKey) {
return NextResponse.json({ error: 'itemKey is required' }, { status: 400 })
}
if (userRating !== null && (typeof userRating !== 'number' || !Number.isInteger(userRating) || userRating < 1 || userRating > 5)) {
return NextResponse.json({ error: 'userRating must be null or an integer 15' }, { status: 400 })
}
const libraryId = extractLibraryId(itemKey)
if (!libraryId) {
return NextResponse.json({ error: 'Invalid itemKey' }, { status: 400 })
}
const auth = await requireLibraryWriteAccess(request, libraryId)
if (auth instanceof NextResponse) return auth
const db = getDb()
const result = db
.prepare('UPDATE media_items SET user_rating = ? WHERE item_key = ?')
.run(userRating, itemKey)
if (result.changes === 0) {
return NextResponse.json({ error: 'Item not found' }, { status: 404 })
}
return NextResponse.json({ success: true })
}

View File

@@ -41,14 +41,29 @@ export default function DoomScrollView({ items, videoContext = 'mixed', onClose,
const [autoPlayEnabled, setAutoPlayEnabled] = useState(false)
const [autoPlaySeconds, setAutoPlaySeconds] = useState(5)
// Tools overlay visibility
const [showToolsOverlay, setShowToolsOverlay] = useState(false)
// Rating state
const [userRating, setUserRatingState] = useState<number | null>(null)
const [ratingHover, setRatingHover] = useState<number | null>(null)
const [savingRating, setSavingRating] = useState(false)
// Text overlay state
const [extractedText, setExtractedText] = useState<string | null>(null)
const [editedExtractedText, setEditedExtractedText] = useState<string>('')
const [savingText, setSavingText] = useState(false)
const [translatedText, setTranslatedText] = useState<string | null>(null)
const [showTextOverlay, setShowTextOverlay] = useState(false)
const [showOriginal, setShowOriginal] = useState(false)
const [extracting, setExtracting] = useState(false)
const [extractError, setExtractError] = useState<string | null>(null)
const [extractPending, setExtractPending] = useState(false)
const [retranslating, setRetranslating] = useState(false)
const [translatePending, setTranslatePending] = useState(false)
const [ocrLanguageInput, setOcrLanguageInput] = useState('')
const [defaultOcrLanguages, setDefaultOcrLanguages] = useState('eng')
const [sourceLanguage, setSourceLanguage] = useState('')
const videoRef = useRef<HTMLVideoElement>(null)
const extractPollRef = useRef<ReturnType<typeof setInterval> | null>(null)
@@ -128,27 +143,50 @@ export default function DoomScrollView({ items, videoContext = 'mixed', onClose,
return () => clearTimeout(id)
}, [autoPlayEnabled, isPaused, autoPlaySeconds, current?.url, goNext])
// Fetch extracted text for current item; clear any in-flight poll on item change
// Fetch OCR settings once on mount
useEffect(() => {
fetch('/api/ai-settings/ocr')
.then((r) => r.json())
.then((d: { ocrMode: string; ocrLanguages: string }) => {
setDefaultOcrLanguages(d.ocrLanguages)
})
.catch(() => {})
}, [])
// Fetch extracted text + rating for current item; clear any in-flight poll on item change
useEffect(() => {
if (extractPollRef.current) {
clearInterval(extractPollRef.current)
extractPollRef.current = null
}
setExtractedText(null)
setEditedExtractedText('')
setTranslatedText(null)
setShowTextOverlay(false)
setShowOriginal(false)
setExtracting(false)
setExtractError(null)
setExtractPending(false)
setRetranslating(false)
setTranslatePending(false)
setUserRatingState(null)
setRatingHover(null)
if (!current?.itemKey) return
fetch(`/api/ai-tagging/fields?itemKey=${encodeURIComponent(current.itemKey)}`)
const key = current.itemKey
fetch(`/api/ai-tagging/fields?itemKey=${encodeURIComponent(key)}`)
.then((r) => r.json())
.then((data: { extractedText: string | null; extractedTextTranslated: string | null }) => {
setExtractedText(data.extractedText)
setEditedExtractedText(data.extractedText ?? '')
setTranslatedText(data.extractedTextTranslated)
})
.catch(() => {})
fetch(`/api/ratings?itemKey=${encodeURIComponent(key)}`)
.then((r) => r.json())
.then((data: { userRating: number | null }) => {
setUserRatingState(data.userRating)
})
.catch(() => {})
}, [current?.itemKey])
// Clean up poll on unmount
@@ -196,7 +234,58 @@ export default function DoomScrollView({ items, videoContext = 'mixed', onClose,
}
}, [navigate, onClose, extractedText])
const handleExtractText = async () => {
// ── Polling helper ──────────────────────────────────────────────────────────
const startPolling = useCallback((snapshotText: string | null, snapshotTranslated: string | null) => {
if (!current?.itemKey) return
const itemKey = current.itemKey
if (extractPollRef.current) clearInterval(extractPollRef.current)
const deadline = Date.now() + 5 * 60 * 1000
extractPollRef.current = setInterval(async () => {
if (Date.now() > deadline) {
clearInterval(extractPollRef.current!)
extractPollRef.current = null
setExtractPending(false)
setTranslatePending(false)
return
}
try {
const r = await fetch(`/api/ai-tagging/fields?itemKey=${encodeURIComponent(itemKey)}`)
const data: { extractedText: string | null; extractedTextTranslated: string | null } = await r.json()
const textChanged = data.extractedText !== snapshotText
const translationChanged = data.extractedTextTranslated !== snapshotTranslated
if (textChanged || translationChanged) {
clearInterval(extractPollRef.current!)
extractPollRef.current = null
setExtractedText(data.extractedText)
setEditedExtractedText(data.extractedText ?? '')
setTranslatedText(data.extractedTextTranslated)
setExtractPending(false)
setTranslatePending(false)
if (data.extractedText) setShowTextOverlay(true)
}
} catch { /* ignore */ }
}, 2000)
}, [current?.itemKey])
// ── Rating actions ───────────────────────────────────────────────────────────
const handleSetRating = useCallback(async (star: number) => {
if (!current?.itemKey) return
const next = userRating === star ? null : star
setSavingRating(true)
try {
const res = await fetch('/api/ratings', {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ itemKey: current.itemKey, userRating: next }),
})
if (res.ok) setUserRatingState(next)
} finally {
setSavingRating(false)
}
}, [current?.itemKey, userRating])
// ── Text extraction ──────────────────────────────────────────────────────────
const callExtract = useCallback(async (modeOverride: string) => {
if (!current?.itemKey) return
const itemKey = current.itemKey
setExtracting(true)
@@ -206,30 +295,15 @@ export default function DoomScrollView({ items, videoContext = 'mixed', onClose,
const res = await fetch('/api/ai-tagging/extract-text', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ itemKey }),
body: JSON.stringify({
itemKey,
ocrMode: modeOverride,
...(modeOverride !== 'llm' && ocrLanguageInput.trim() && { ocrLanguages: ocrLanguageInput.trim() }),
}),
})
if (res.status === 202) {
// Job queued — poll until it completes (up to 5 min)
setExtractPending(true)
const deadline = Date.now() + 5 * 60 * 1000
extractPollRef.current = setInterval(async () => {
if (Date.now() > deadline) {
if (extractPollRef.current) clearInterval(extractPollRef.current)
setExtractPending(false)
return
}
try {
const r = await fetch(`/api/ai-tagging/fields?itemKey=${encodeURIComponent(itemKey)}`)
const data: { extractedText: string | null; extractedTextTranslated: string | null } = await r.json()
if (data.extractedText) {
if (extractPollRef.current) clearInterval(extractPollRef.current)
setExtractPending(false)
setExtractedText(data.extractedText)
setTranslatedText(data.extractedTextTranslated)
setShowTextOverlay(true)
}
} catch { /* ignore */ }
}, 2000)
startPolling(extractedText, translatedText)
return
}
if (!res.ok) {
@@ -237,16 +311,67 @@ export default function DoomScrollView({ items, videoContext = 'mixed', onClose,
throw new Error((data as { error?: string }).error ?? 'Extraction failed')
}
const result = await res.json()
setExtractedText(result.extractedText || null)
setTranslatedText(result.translatedText || null)
if (result.extractedText) setShowTextOverlay(true)
const newText: string | null = result.extractedText || null
const newTranslated: string | null = result.translatedText || null
setExtractedText(newText)
setEditedExtractedText(newText ?? '')
setTranslatedText(newTranslated)
if (newText) setShowTextOverlay(true)
} catch (err) {
setExtractError(err instanceof Error ? err.message : 'Extraction failed')
setTimeout(() => setExtractError(null), 4000)
} finally {
setExtracting(false)
}
}, [current?.itemKey, ocrLanguageInput, extractedText, translatedText, startPolling])
// ── Save edited extracted text ───────────────────────────────────────────────
const handleSaveExtractedText = useCallback(async () => {
if (!current?.itemKey) return
setSavingText(true)
try {
await fetch('/api/ai-tagging/fields', {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ itemKey: current.itemKey, extractedText: editedExtractedText }),
})
setExtractedText(editedExtractedText)
} finally {
setSavingText(false)
}
}, [current?.itemKey, editedExtractedText])
// ── Translation ──────────────────────────────────────────────────────────────
const handleTranslate = useCallback(async () => {
if (!current?.itemKey) return
setRetranslating(true)
setTranslatePending(false)
try {
const res = await fetch('/api/ai-tagging/translate', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
itemKey: current.itemKey,
...(sourceLanguage.trim() && { sourceLanguage: sourceLanguage.trim() }),
}),
})
if (res.status === 202) {
setTranslatePending(true)
startPolling(extractedText, translatedText)
return
}
if (!res.ok) {
const data = await res.json().catch(() => ({}))
throw new Error((data as { error?: string }).error ?? 'Translation failed')
}
const result = await res.json()
setTranslatedText(result.translatedText || null)
} catch {
// ignore
} finally {
setRetranslating(false)
}
}, [current?.itemKey, sourceLanguage, extractedText, translatedText, startPolling])
return (
<div className="fixed inset-0 z-50 flex flex-col" style={{ backgroundColor: '#000' }}>
@@ -333,6 +458,193 @@ export default function DoomScrollView({ items, videoContext = 'mixed', onClose,
) : null}
</div>
{/* Tools overlay — anchored lower-left, above the bottom bar */}
{showToolsOverlay && current?.itemKey && (
<div
className="absolute bottom-16 left-4 z-20 rounded-xl p-4 flex flex-col gap-3 overflow-y-auto"
style={{
backgroundColor: 'rgba(10,10,10,0.92)',
border: '1px solid rgba(255,255,255,0.12)',
width: 'min(320px, calc(100vw - 2rem))',
maxHeight: 'calc(100vh - 8rem)',
}}
onClick={(e) => e.stopPropagation()}
>
{/* ── Rating ──────────────────────────────────────────── */}
<div>
<p className="text-xs font-semibold uppercase tracking-wider mb-2" style={{ color: 'rgba(255,255,255,0.45)' }}>
Rating
</p>
<div className="flex items-center gap-1" onMouseLeave={() => setRatingHover(null)}>
{[1, 2, 3, 4, 5].map((star) => {
const filled = (ratingHover ?? userRating ?? 0) >= star
return (
<button
key={star}
onClick={() => handleSetRating(star)}
onMouseEnter={() => setRatingHover(star)}
disabled={savingRating}
aria-label={`Rate ${star} star${star > 1 ? 's' : ''}`}
style={{
fontSize: '1.4rem',
color: filled ? '#f59e0b' : 'rgba(255,255,255,0.2)',
background: 'none',
border: 'none',
padding: '0 2px',
cursor: savingRating ? 'wait' : 'pointer',
transition: 'color 0.1s',
lineHeight: 1,
}}
>
</button>
)
})}
</div>
</div>
{/* ── Text Extraction (images only) ───────────────────── */}
{current.mediaType === 'image' && (
<div style={{ borderTop: '1px solid rgba(255,255,255,0.1)', paddingTop: '0.75rem' }}>
<div className="flex items-center justify-between mb-2">
<p className="text-xs font-semibold uppercase tracking-wider" style={{ color: 'rgba(255,255,255,0.45)' }}>
Text Extraction
</p>
<button
onClick={() => callExtract('llm')}
disabled={extracting || extractPending}
className="w-7 h-7 rounded-full flex items-center justify-center transition-opacity disabled:opacity-40"
style={{
backgroundColor: extractPending ? 'var(--accent)' : 'rgba(255,255,255,0.12)',
color: extractPending ? '#fff' : 'rgba(255,255,255,0.7)',
fontSize: '0.95rem',
}}
aria-label="Extract with AI"
title="Extract with AI (skips OCR)"
>
{extracting || extractPending ? <span className="animate-spin" style={{ display: 'inline-block' }}></span> : '✨'}
</button>
</div>
<div className="flex items-center gap-2 flex-wrap">
<button
onClick={() => callExtract('tesseract')}
disabled={extracting || extractPending}
className="text-xs px-2 py-1 rounded-lg transition-colors disabled:opacity-40 flex-shrink-0"
style={{ backgroundColor: 'rgba(255,255,255,0.1)', color: 'rgba(255,255,255,0.7)' }}
>
{extracting ? '⟳ Scanning…' : extractedText ? '🔍 Re-scan with OCR' : '🔍 Scan with OCR'}
</button>
<input
type="text"
value={ocrLanguageInput}
onChange={(e) => setOcrLanguageInput(e.target.value)}
placeholder={defaultOcrLanguages}
className="text-xs px-2 py-0.5 rounded-full outline-none"
style={{
backgroundColor: 'rgba(255,255,255,0.07)',
border: '1px solid rgba(255,255,255,0.15)',
color: 'rgba(255,255,255,0.85)',
width: 120,
}}
title="Tesseract language(s) for this extraction (e.g. jpn+jpn_vert). Leave blank to use the configured default."
/>
</div>
{extractError && (
<p className="text-xs mt-1" style={{ color: '#f87171' }}>{extractError}</p>
)}
{/* Extracted text editor */}
{extractedText !== null && (
<div className="flex flex-col gap-1 mt-2">
<p className="text-xs font-medium" style={{ color: 'rgba(255,255,255,0.45)' }}>Extracted Text</p>
<textarea
value={editedExtractedText}
onChange={(e) => setEditedExtractedText(e.target.value)}
className="text-xs rounded-lg p-2 w-full resize-y outline-none"
style={{
backgroundColor: 'rgba(255,255,255,0.07)',
border: '1px solid rgba(255,255,255,0.15)',
color: 'rgba(255,255,255,0.9)',
minHeight: '3.5rem',
maxHeight: '8rem',
fontFamily: 'inherit',
}}
/>
{editedExtractedText !== extractedText && (
<button
onClick={handleSaveExtractedText}
disabled={savingText}
className="self-start text-xs px-2 py-0.5 rounded-full transition-opacity disabled:opacity-50"
style={{ backgroundColor: 'var(--accent)', color: '#fff' }}
>
{savingText ? '⟳ Saving…' : 'Save'}
</button>
)}
{/* Translation display */}
{translatedText && (
<div className="mt-1">
<p className="text-xs font-medium mb-1" style={{ color: 'rgba(255,255,255,0.45)' }}>Translation</p>
<pre
className="text-xs whitespace-pre-wrap rounded-lg p-2 max-h-32 overflow-y-auto"
style={{
backgroundColor: 'rgba(255,255,255,0.07)',
border: '1px solid rgba(255,255,255,0.15)',
color: 'rgba(255,255,255,0.9)',
}}
>
{translatedText}
</pre>
</div>
)}
{/* Original / translation toggle */}
{extractedText && translatedText && (
<button
onClick={() => setShowOriginal((v) => !v)}
className="self-start text-xs px-2 py-0.5 rounded-full mt-1"
style={{ backgroundColor: 'rgba(255,255,255,0.12)', color: 'rgba(255,255,255,0.7)' }}
>
{showOriginal ? 'Show Translation in popover' : 'Show Original in popover'}
</button>
)}
{/* Translate / re-translate */}
<div className="flex items-center gap-1.5 flex-wrap mt-1">
<input
type="text"
value={sourceLanguage}
onChange={(e) => setSourceLanguage(e.target.value)}
placeholder="Source lang…"
className="text-xs px-2 py-0.5 rounded-full outline-none"
style={{
backgroundColor: 'rgba(255,255,255,0.07)',
border: '1px solid rgba(255,255,255,0.15)',
color: 'rgba(255,255,255,0.85)',
width: 100,
}}
/>
<button
onClick={handleTranslate}
disabled={retranslating || translatePending}
className="text-xs px-2 py-0.5 rounded-full transition-colors disabled:opacity-40"
style={{
backgroundColor: translatePending ? 'var(--accent)' : 'rgba(255,255,255,0.12)',
color: translatePending ? '#fff' : 'rgba(255,255,255,0.7)',
}}
>
{retranslating ? '⟳ Translating…' : translatePending ? '⟳ Queued…' : translatedText ? '🌐 Re-translate' : '🌐 Translate'}
</button>
</div>
</div>
)}
</div>
)}
</div>
)}
{/* Text overlay */}
{showTextOverlay && displayText && (
<div
@@ -357,9 +669,9 @@ export default function DoomScrollView({ items, videoContext = 'mixed', onClose,
</div>
)}
{/* Bottom bar: mute | filename | action buttons */}
{/* Bottom bar: [mute + tools] | filename | action buttons */}
<div className="absolute bottom-0 left-0 right-0 flex items-center gap-3 px-4 pb-3 pt-2 z-10">
<div className="w-9 flex-shrink-0">
<div className="flex items-center gap-1 flex-shrink-0">
{isVideo && (
<button
onClick={() => setLocalMuted((v) => !v)}
@@ -382,6 +694,27 @@ export default function DoomScrollView({ items, videoContext = 'mixed', onClose,
)}
</button>
)}
{current?.itemKey && (
<button
onClick={() => setShowToolsOverlay((v) => !v)}
className="w-9 h-9 rounded-full flex items-center justify-center transition-opacity hover:opacity-100 opacity-70"
style={{
backgroundColor: showToolsOverlay ? 'rgba(255,255,255,0.2)' : 'rgba(0,0,0,0.5)',
color: '#fff',
}}
aria-label={showToolsOverlay ? 'Close tools' : 'Open tools'}
title="Rating &amp; text tools"
>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<circle cx="12" cy="12" r="3"/>
<path d="M19.07 4.93a10 10 0 0 1 0 14.14M4.93 4.93a10 10 0 0 0 0 14.14"/>
<line x1="12" y1="2" x2="12" y2="5"/>
<line x1="12" y1="19" x2="12" y2="22"/>
<line x1="2" y1="12" x2="5" y2="12"/>
<line x1="19" y1="12" x2="22" y2="12"/>
</svg>
</button>
)}
</div>
<span className="flex-1 text-xs truncate text-center" style={{ color: 'rgba(255,255,255,0.4)' }}>
{current?.name}
@@ -405,7 +738,7 @@ export default function DoomScrollView({ items, videoContext = 'mixed', onClose,
</button>
) : current?.itemKey && current?.mediaType === 'image' ? (
<button
onClick={handleExtractText}
onClick={() => callExtract('tesseract')}
disabled={extracting || extractPending}
className="w-9 h-9 rounded-full flex items-center justify-center transition-opacity hover:opacity-100 opacity-70 disabled:opacity-40"
style={{

View File

@@ -1,7 +1,7 @@
'use client'
import { useEffect, useState } from 'react'
import type { Tag, TagCategory } from '@/types'
import type { Tag, TagCategory, RatingOperator } from '@/types'
interface Props {
libraryId: string
@@ -11,9 +11,24 @@ interface Props {
selectedTagIds: Set<string>
onTagToggle: (tagId: string) => void
refreshKey?: number
ratingValue: number | null
ratingOperator: RatingOperator
onRatingChange: (value: number | null, operator: RatingOperator) => void
showRatingFilter?: boolean
}
export default function FilterPanel({ assignments, search, onSearchChange, selectedTagIds, onTagToggle, refreshKey }: Props) {
export default function FilterPanel({
assignments,
search,
onSearchChange,
selectedTagIds,
onTagToggle,
refreshKey,
ratingValue,
ratingOperator,
onRatingChange,
showRatingFilter = true,
}: Props) {
const [categories, setCategories] = useState<TagCategory[]>([])
const [tags, setTags] = useState<Tag[]>([])
const [loading, setLoading] = useState(true)
@@ -53,6 +68,59 @@ export default function FilterPanel({ assignments, search, onSearchChange, selec
}}
/>
{/* Rating filter */}
{showRatingFilter && (
<div className="flex flex-col gap-1.5">
<p className="text-xs" style={{ color: 'var(--text-secondary)' }}>Rating</p>
{/* Operator toggle */}
<div className="flex gap-1">
{(['gte', 'eq', 'lte'] as RatingOperator[]).map((op) => {
const label = op === 'gte' ? '≥' : op === 'eq' ? '=' : '≤'
const active = ratingValue !== null && ratingOperator === op
return (
<button
key={op}
onClick={() => onRatingChange(active ? null : (ratingValue ?? 3), op)}
className="flex-1 py-0.5 rounded text-xs font-medium transition-colors"
style={{
backgroundColor: active ? 'var(--accent)' : 'var(--border)',
color: active ? '#fff' : 'var(--text-secondary)',
cursor: 'pointer',
}}
>
{label}
</button>
)
})}
</div>
{/* Star picker */}
<div className="flex gap-0.5">
{[1, 2, 3, 4, 5].map((star) => {
const lit =
ratingValue !== null &&
((ratingOperator === 'gte' && star <= ratingValue) ||
(ratingOperator === 'eq' && star === ratingValue) ||
(ratingOperator === 'lte' && star >= ratingValue))
return (
<button
key={star}
onClick={() => onRatingChange(ratingValue === star ? null : star, ratingOperator)}
className="flex-1 text-base py-0.5 rounded transition-colors"
style={{
color: lit ? '#f59e0b' : 'var(--border)',
background: 'none',
cursor: 'pointer',
}}
aria-label={`${star} star${star !== 1 ? 's' : ''}`}
>
</button>
)
})}
</div>
</div>
)}
{/* Tag filters */}
{loading ? (
<div className="flex flex-col gap-3">
@@ -62,7 +130,7 @@ export default function FilterPanel({ assignments, search, onSearchChange, selec
className="h-3 w-16 rounded animate-pulse"
style={{ backgroundColor: 'var(--border)' }}
/>
<div className="flex flex-wrap gap-1.5">
<div className="flex flex-wrap gap-1.5 max-h-24 overflow-y-auto">
{[50, 65, 42].map((w) => (
<div
key={w}
@@ -84,7 +152,7 @@ export default function FilterPanel({ assignments, search, onSearchChange, selec
<p className="text-xs mb-1.5" style={{ color: 'var(--text-secondary)' }}>
{cat.name}
</p>
<div className="flex flex-wrap gap-1.5">
<div className="flex flex-wrap gap-1.5 max-h-24 overflow-y-auto">
{catTags.map((tag) => {
const active = selectedTagIds.has(tag.id)
return (

View File

@@ -1,7 +1,8 @@
'use client'
import { useCallback, useEffect, useRef, useState } from 'react'
import type { ComicIssue, ComicSeries } from '@/types'
import { useCallback, useEffect, useRef, useState, useMemo } from 'react'
import type { ComicIssue, ComicSeries, RatingOperator } from '@/types'
import { useDebounce } from '@/hooks/useDebounce'
import ComicIssueView from './ComicIssueView'
import FilterPanel from '@/components/FilterPanel'
import TagSelector from '@/components/tags/TagSelector'
@@ -29,6 +30,9 @@ export default function ComicsView({ libraryId, readOnly }: Props) {
const [search, setSearch] = useState('')
const [selectedTagIds, setSelectedTagIds] = useState<Set<string>>(new Set())
const [assignments, setAssignments] = useState<Record<string, string[]>>({})
const [ratingValue, setRatingValue] = useState<number | null>(null)
const [ratingOperator, setRatingOperator] = useState<RatingOperator>('gte')
const debouncedSearch = useDebounce(search, 200)
const [seriesIssueMeta, setSeriesIssueMeta] = useState<
Record<string, { tagIds: string[]; issueTitles: string[] }>
>({})
@@ -132,43 +136,76 @@ export default function ComicsView({ libraryId, readOnly }: Props) {
fetchSeriesIssueMeta()
}, [fetchAssignments, fetchSeriesIssueMeta])
const filtered = items.filter((item) => {
const handleRatingChange = (value: number | null, operator: RatingOperator) => {
if (value === ratingValue && operator === ratingOperator) {
setRatingValue(null)
} else {
setRatingValue(value)
setRatingOperator(operator)
}
}
const filtered = useMemo(() => items.filter((item) => {
const isSeries = 'issueCount' in item
const series = isSeries ? (item as ComicSeries) : null
const issue = isSeries ? null : (item as ComicIssue)
if (isSeries) {
const meta = seriesIssueMeta[item.item_key ?? ''] ?? { tagIds: [], issueTitles: [] }
if (series) {
const meta = seriesIssueMeta[series.item_key ?? ''] ?? { tagIds: [], issueTitles: [] }
if (search) {
const q = search.toLowerCase()
const titleMatch = item.title.toLowerCase().includes(q)
if (debouncedSearch) {
const q = debouncedSearch.toLowerCase()
const titleMatch = series.title.toLowerCase().includes(q)
const issueMatch = meta.issueTitles.some((t) => t.toLowerCase().includes(q))
if (!titleMatch && !issueMatch) return false
const aiMatch = series.aiDescription?.toLowerCase().includes(q) ?? false
const textMatch = series.extractedText?.toLowerCase().includes(q) ?? false
const translatedMatch = series.extractedTextTranslated?.toLowerCase().includes(q) ?? false
if (!titleMatch && !issueMatch && !aiMatch && !textMatch && !translatedMatch) return false
}
if (selectedTagIds.size > 0) {
const seriesTags = assignments[item.item_key ?? ''] ?? []
const seriesTags = assignments[series.item_key ?? ''] ?? []
const allTags = [...new Set([...seriesTags, ...meta.tagIds])]
if (![...selectedTagIds].every((id) => allTags.includes(id))) return false
}
if (ratingValue !== null) {
const r = series.userRating
if (r === null) return false
if (ratingOperator === 'gte' && r < ratingValue) return false
if (ratingOperator === 'eq' && r !== ratingValue) return false
if (ratingOperator === 'lte' && r > ratingValue) return false
}
return true
}
// Standalone issue
if (search && !item.title.toLowerCase().includes(search.toLowerCase())) return false
if (debouncedSearch) {
const q = debouncedSearch.toLowerCase()
if (![issue!.title, issue!.aiDescription, issue!.extractedText, issue!.extractedTextTranslated]
.some((f) => f?.toLowerCase().includes(q))) return false
}
if (selectedTagIds.size > 0) {
const tags = assignments[item.item_key ?? ''] ?? []
const tags = assignments[issue!.item_key ?? ''] ?? []
if (![...selectedTagIds].every((id) => tags.includes(id))) return false
}
if (ratingValue !== null) {
const r = issue!.userRating
if (r === null) return false
if (ratingOperator === 'gte' && r < ratingValue) return false
if (ratingOperator === 'eq' && r !== ratingValue) return false
if (ratingOperator === 'lte' && r > ratingValue) return false
}
return true
})
}), [items, debouncedSearch, selectedTagIds, assignments, seriesIssueMeta, ratingValue, ratingOperator])
// Flat list of issues at the current navigation level for prev/next
const filteredIssues: ComicIssue[] = selectedSeries
? seriesIssues
: filtered.filter((item): item is ComicIssue => !('issueCount' in item))
const filtersActive = search !== '' || selectedTagIds.size > 0
const filtersActive = search !== '' || selectedTagIds.size > 0 || ratingValue !== null
return (
<>
@@ -198,6 +235,9 @@ export default function ComicsView({ libraryId, readOnly }: Props) {
selectedTagIds={selectedTagIds}
onTagToggle={toggleTag}
refreshKey={filterRefreshKey}
ratingValue={ratingValue}
ratingOperator={ratingOperator}
onRatingChange={handleRatingChange}
/>
</div>
)}

View File

@@ -1,7 +1,8 @@
'use client'
import { useEffect, useState, useCallback, useRef } from 'react'
import type { Game, GamePlatform, GameSeries } from '@/types'
import { useEffect, useState, useCallback, useRef, useMemo } from 'react'
import type { Game, GamePlatform, GameSeries, RatingOperator } from '@/types'
import { useDebounce } from '@/hooks/useDebounce'
import GameDetailModal from './GameDetailModal'
import FilterPanel from '@/components/FilterPanel'
@@ -72,6 +73,9 @@ export default function GamesView({ libraryId, readOnly }: Props) {
const [search, setSearch] = useState('')
const [selectedTagIds, setSelectedTagIds] = useState<Set<string>>(new Set())
const [assignments, setAssignments] = useState<Record<string, string[]>>({})
const [ratingValue, setRatingValue] = useState<number | null>(null)
const [ratingOperator, setRatingOperator] = useState<RatingOperator>('gte')
const debouncedSearch = useDebounce(search, 200)
const [filterRefreshKey, setFilterRefreshKey] = useState(0)
const [showFilters, setShowFilters] = useState(
() => typeof window !== 'undefined' && window.innerWidth >= 768
@@ -128,29 +132,69 @@ export default function GamesView({ libraryId, readOnly }: Props) {
? selectedSeries.games
: items
const filtered = visibleItems.filter((item) => {
const handleRatingChange = (value: number | null, operator: RatingOperator) => {
if (value === ratingValue && operator === ratingOperator) {
setRatingValue(null)
} else {
setRatingValue(value)
setRatingOperator(operator)
}
}
const filtered = useMemo(() => visibleItems.filter((item) => {
if ('games' in item) {
const searchMatch = !search ||
item.title.toLowerCase().includes(search.toLowerCase()) ||
item.games.some((g) => g.title.toLowerCase().includes(search.toLowerCase()))
if (debouncedSearch) {
const q = debouncedSearch.toLowerCase()
const searchMatch =
item.title.toLowerCase().includes(q) ||
item.games.some((g) =>
g.title.toLowerCase().includes(q) ||
(g.aiDescription?.toLowerCase().includes(q) ?? false) ||
(g.extractedText?.toLowerCase().includes(q) ?? false) ||
(g.extractedTextTranslated?.toLowerCase().includes(q) ?? false)
)
if (!searchMatch) return false
}
if (selectedTagIds.size > 0) {
return item.games.some((g) => {
if (!item.games.some((g) => {
const gameTags = assignments[g.item_key!] ?? []
return [...selectedTagIds].every((id) => gameTags.includes(id))
})
})) return false
}
if (ratingValue !== null) {
if (!item.games.some((g) => {
const r = g.userRating
if (r === null) return false
if (ratingOperator === 'gte') return r >= ratingValue
if (ratingOperator === 'eq') return r === ratingValue
if (ratingOperator === 'lte') return r <= ratingValue
return false
})) return false
}
return true
}
if (search && !item.title.toLowerCase().includes(search.toLowerCase())) return false
// Standalone Game
if (debouncedSearch) {
const q = debouncedSearch.toLowerCase()
const g = item as Game
if (![g.title, g.aiDescription, g.extractedText, g.extractedTextTranslated]
.some((f) => f?.toLowerCase().includes(q))) return false
}
if (selectedTagIds.size > 0) {
const gameTags = assignments[item.item_key!] ?? []
if (![...selectedTagIds].every((id) => gameTags.includes(id))) return false
}
if (ratingValue !== null) {
const r = (item as Game).userRating
if (r === null) return false
if (ratingOperator === 'gte' && r < ratingValue) return false
if (ratingOperator === 'eq' && r !== ratingValue) return false
if (ratingOperator === 'lte' && r > ratingValue) return false
}
return true
})
}), [visibleItems, debouncedSearch, selectedTagIds, assignments, ratingValue, ratingOperator])
const filtersActive = search !== '' || selectedTagIds.size > 0
const filtersActive = search !== '' || selectedTagIds.size > 0 || ratingValue !== null
const filteredGames: Game[] = filtered.flatMap((item) =>
'games' in item ? item.games : [item as Game]
)
@@ -182,6 +226,9 @@ export default function GamesView({ libraryId, readOnly }: Props) {
selectedTagIds={selectedTagIds}
onTagToggle={toggleTag}
refreshKey={filterRefreshKey}
ratingValue={ratingValue}
ratingOperator={ratingOperator}
onRatingChange={handleRatingChange}
/>
</div>
)}

View File

@@ -1,7 +1,8 @@
'use client'
import { useEffect, useState, useCallback, useRef } from 'react'
import type { DirectoryListing, FileEntry } from '@/types'
import { useEffect, useState, useCallback, useRef, useMemo } from 'react'
import type { DirectoryListing, FileEntry, RatingOperator } from '@/types'
import { useDebounce } from '@/hooks/useDebounce'
import VideoPlayerModal from './VideoPlayerModal'
import ImageLightbox from './ImageLightbox'
import TagSelector from '@/components/tags/TagSelector'
@@ -34,6 +35,7 @@ export default function MixedView({ libraryId, libraryName, initialPath, readOnl
const [search, setSearch] = useState('')
const [selectedTagIds, setSelectedTagIds] = useState<Set<string>>(new Set())
const [assignments, setAssignments] = useState<Record<string, string[]>>({})
const debouncedSearch = useDebounce(search, 200)
const [filterRefreshKey, setFilterRefreshKey] = useState(0)
const [showFilters, setShowFilters] = useState(
() => typeof window !== 'undefined' && window.innerWidth >= 768
@@ -110,7 +112,19 @@ export default function MixedView({ libraryId, libraryName, initialPath, readOnl
.catch(() => {})
}, [])
const filtersActive = search !== '' || selectedTagIds.size > 0
const [ratingValue, setRatingValue] = useState<number | null>(null)
const [ratingOperator, setRatingOperator] = useState<RatingOperator>('gte')
const handleRatingChange = (value: number | null, operator: RatingOperator) => {
if (value === ratingValue && operator === ratingOperator) {
setRatingValue(null)
} else {
setRatingValue(value)
setRatingOperator(operator)
}
}
const filtersActive = search !== '' || selectedTagIds.size > 0 || ratingValue !== null
const fetchRecursive = useCallback(() => {
if (recursiveLoaded || recursiveLoading) return
@@ -155,14 +169,22 @@ export default function MixedView({ libraryId, libraryName, initialPath, readOnl
const sourceEntries = filtersActive ? recursiveEntries : (listing?.entries ?? [])
const filteredEntries = sourceEntries.filter((entry) => {
if (search && !entry.name.toLowerCase().includes(search.toLowerCase())) return false
const filteredEntries = useMemo(() => sourceEntries.filter((entry) => {
if (debouncedSearch && !entry.name.toLowerCase().includes(debouncedSearch.toLowerCase())) return false
if (selectedTagIds.size > 0 && entry.type !== 'directory') {
const entryTags = assignments[itemKeyFor(entry)] ?? []
if (![...selectedTagIds].every((id) => entryTags.includes(id))) return false
}
if (ratingValue !== null && entry.type !== 'directory') {
const r = entry.userRating ?? null
if (r === null) return false
if (ratingOperator === 'gte' && r < ratingValue) return false
if (ratingOperator === 'eq' && r !== ratingValue) return false
if (ratingOperator === 'lte' && r > ratingValue) return false
}
return true
})
// eslint-disable-next-line react-hooks/exhaustive-deps
}), [sourceEntries, debouncedSearch, selectedTagIds, assignments, ratingValue, ratingOperator])
const mediaEntries = filteredEntries.filter(
(e) => e.mediaType === 'video' || e.mediaType === 'image'
@@ -337,6 +359,9 @@ export default function MixedView({ libraryId, libraryName, initialPath, readOnl
selectedTagIds={selectedTagIds}
onTagToggle={toggleTag}
refreshKey={filterRefreshKey}
ratingValue={ratingValue}
ratingOperator={ratingOperator}
onRatingChange={handleRatingChange}
/>
</div>
)}

View File

@@ -1,11 +1,12 @@
'use client'
import { useEffect, useState, useCallback } from 'react'
import type { Movie } from '@/types'
import { useEffect, useState, useCallback, useMemo } from 'react'
import type { Movie, RatingOperator } from '@/types'
import MovieDetailModal from './MovieDetailModal'
import FilterPanel from '@/components/FilterPanel'
import DoomScrollView, { type DoomScrollItem } from '@/components/DoomScrollView'
import { isBrowserPlayable } from '@/lib/browser-media'
import { useDebounce } from '@/hooks/useDebounce'
interface Props {
libraryId: string
@@ -20,6 +21,9 @@ export default function MoviesView({ libraryId, readOnly }: Props) {
const [search, setSearch] = useState('')
const [selectedTagIds, setSelectedTagIds] = useState<Set<string>>(new Set())
const [assignments, setAssignments] = useState<Record<string, string[]>>({})
const [ratingValue, setRatingValue] = useState<number | null>(null)
const [ratingOperator, setRatingOperator] = useState<RatingOperator>('gte')
const debouncedSearch = useDebounce(search, 200)
const [filterRefreshKey, setFilterRefreshKey] = useState(0)
const [showFilters, setShowFilters] = useState(
() => typeof window !== 'undefined' && window.innerWidth >= 768
@@ -58,14 +62,34 @@ export default function MoviesView({ libraryId, readOnly }: Props) {
useEffect(() => { fetchAssignments() }, [fetchAssignments])
const filtered = movies.filter((movie) => {
if (search && !movie.title.toLowerCase().includes(search.toLowerCase())) return false
const handleRatingChange = (value: number | null, operator: RatingOperator) => {
if (value === ratingValue && operator === ratingOperator) {
setRatingValue(null)
} else {
setRatingValue(value)
setRatingOperator(operator)
}
}
const filtered = useMemo(() => movies.filter((movie) => {
if (debouncedSearch) {
const q = debouncedSearch.toLowerCase()
if (![movie.title, movie.plot, movie.aiDescription, movie.extractedText, movie.extractedTextTranslated]
.some((f) => f?.toLowerCase().includes(q))) return false
}
if (selectedTagIds.size > 0) {
const movieTags = assignments[movie.item_key!] ?? []
if (![...selectedTagIds].every((id) => movieTags.includes(id))) return false
}
if (ratingValue !== null) {
const r = movie.userRating
if (r === null) return false
if (ratingOperator === 'gte' && r < ratingValue) return false
if (ratingOperator === 'eq' && r !== ratingValue) return false
if (ratingOperator === 'lte' && r > ratingValue) return false
}
return true
})
}), [movies, debouncedSearch, selectedTagIds, assignments, ratingValue, ratingOperator])
const selected = selectedIndex !== null ? filtered[selectedIndex] ?? null : null
@@ -74,7 +98,7 @@ export default function MoviesView({ libraryId, readOnly }: Props) {
setMovies((prev) => prev.filter((m) => m.id !== movieId))
}
const filtersActive = search !== '' || selectedTagIds.size > 0
const filtersActive = search !== '' || selectedTagIds.size > 0 || ratingValue !== null
const handleDoomScroll = () => {
// Use filtered movies — respects any active search/tag filters automatically
@@ -135,6 +159,9 @@ export default function MoviesView({ libraryId, readOnly }: Props) {
selectedTagIds={selectedTagIds}
onTagToggle={toggleTag}
refreshKey={filterRefreshKey}
ratingValue={ratingValue}
ratingOperator={ratingOperator}
onRatingChange={handleRatingChange}
/>
</div>
)}

View File

@@ -1,6 +1,6 @@
'use client'
import { useState } from 'react'
import { useState, useEffect, useCallback } from 'react'
import TagSelector from './TagSelector'
interface Props {
@@ -33,6 +33,35 @@ export default function MediaTagPanel({
const [aiTagging, setAiTagging] = useState(false)
const [aiTagError, setAiTagError] = useState<string | null>(null)
const [internalRefreshKey, setInternalRefreshKey] = useState(0)
const [userRating, setUserRatingState] = useState<number | null>(null)
const [ratingHover, setRatingHover] = useState<number | null>(null)
const [savingRating, setSavingRating] = useState(false)
const fetchRating = useCallback(async () => {
if (!itemKey) return
const res = await fetch(`/api/ratings?itemKey=${encodeURIComponent(itemKey)}`)
if (res.ok) {
const { userRating: r } = await res.json()
setUserRatingState(r)
}
}, [itemKey])
useEffect(() => { fetchRating() }, [fetchRating])
const setRating = async (star: number) => {
const next = userRating === star ? null : star
setSavingRating(true)
try {
const res = await fetch('/api/ratings', {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ itemKey, userRating: next }),
})
if (res.ok) setUserRatingState(next)
} finally {
setSavingRating(false)
}
}
const handleAiTag = async () => {
if (!onAiTag) return
@@ -94,8 +123,44 @@ export default function MediaTagPanel({
) : null
) : (
<>
{/* Rating section */}
<div className="mt-4 mb-3">
<p className="text-xs font-semibold uppercase tracking-wider mb-2" style={{ color: 'var(--text-secondary)' }}>
Rating
</p>
<div className="flex items-center gap-1" onMouseLeave={() => setRatingHover(null)}>
{[1, 2, 3, 4, 5].map((star) => {
const filled = (ratingHover ?? userRating ?? 0) >= star
return readOnly ? (
<span
key={star}
style={{ fontSize: '1.1rem', color: (userRating ?? 0) >= star ? '#f59e0b' : 'var(--border)' }}
aria-label={`${star} star`}
></span>
) : (
<button
key={star}
onClick={() => setRating(star)}
onMouseEnter={() => setRatingHover(star)}
disabled={savingRating}
aria-label={`Rate ${star} star${star > 1 ? 's' : ''}`}
style={{
fontSize: '1.1rem',
color: filled ? '#f59e0b' : 'var(--border)',
background: 'none',
border: 'none',
padding: '0 1px',
cursor: savingRating ? 'wait' : 'pointer',
transition: 'color 0.1s',
lineHeight: 1,
}}
></button>
)
})}
</div>
</div>
{/* Tags section heading + optional AI button */}
<div className="flex items-center justify-between mt-4 mb-3">
<div className="flex items-center justify-between mb-3">
<p className="text-xs font-semibold uppercase tracking-wider" style={{ color: 'var(--text-secondary)' }}>
Tags
</p>

View File

@@ -1,7 +1,8 @@
'use client'
import { useEffect, useRef, useState, useCallback } from 'react'
import type { TvSeries, TvSeason, TvEpisode } from '@/types'
import { useEffect, useRef, useState, useCallback, useMemo } from 'react'
import type { TvSeries, TvSeason, TvEpisode, RatingOperator } from '@/types'
import { useDebounce } from '@/hooks/useDebounce'
import FilterPanel from '@/components/FilterPanel'
import VideoPlayerModal from '@/components/mixed/VideoPlayerModal'
@@ -33,6 +34,9 @@ export default function TvView({ libraryId, readOnly }: Props) {
const [selectedTagIds, setSelectedTagIds] = useState<Set<string>>(new Set())
const [assignments, setAssignments] = useState<Record<string, string[]>>({})
const [seriesEpisodeTags, setSeriesEpisodeTags] = useState<Record<string, string[]>>({})
const [ratingValue, setRatingValue] = useState<number | null>(null)
const [ratingOperator, setRatingOperator] = useState<RatingOperator>('gte')
const debouncedSearch = useDebounce(search, 200)
const [filterRefreshKey, setFilterRefreshKey] = useState(0)
const [showFilters, setShowFilters] = useState(
() => typeof window !== 'undefined' && window.innerWidth >= 768
@@ -375,29 +379,58 @@ export default function TvView({ libraryId, readOnly }: Props) {
}
}, [view, menuOpen, showTagPanel, selectedSeries])
const filtersActive = search !== '' || selectedTagIds.size > 0
const filtersActive = search !== '' || selectedTagIds.size > 0 || ratingValue !== null
const filteredSeries = series.filter((s) => {
if (search && !s.title.toLowerCase().includes(search.toLowerCase())) return false
const handleRatingChange = (value: number | null, operator: RatingOperator) => {
if (value === ratingValue && operator === ratingOperator) {
setRatingValue(null)
} else {
setRatingValue(value)
setRatingOperator(operator)
}
}
const filteredSeries = useMemo(() => series.filter((s) => {
if (debouncedSearch) {
const q = debouncedSearch.toLowerCase()
if (![s.title, s.plot, s.aiDescription, s.extractedText, s.extractedTextTranslated]
.some((f) => f?.toLowerCase().includes(q))) return false
}
if (selectedTagIds.size > 0) {
const seriesTags = assignments[s.item_key!] ?? []
const episodeTags = seriesEpisodeTags[s.id] ?? []
const allTags = seriesTags.length === 0 ? episodeTags
: episodeTags.length === 0 ? seriesTags
: [...new Set([...seriesTags, ...episodeTags])]
const allTags = [...new Set([...seriesTags, ...episodeTags])]
if (![...selectedTagIds].every((id) => allTags.includes(id))) return false
}
if (ratingValue !== null) {
const r = s.userRating
if (r === null) return false
if (ratingOperator === 'gte' && r < ratingValue) return false
if (ratingOperator === 'eq' && r !== ratingValue) return false
if (ratingOperator === 'lte' && r > ratingValue) return false
}
return true
})
}), [series, debouncedSearch, selectedTagIds, assignments, seriesEpisodeTags, ratingValue, ratingOperator])
const filteredEpisodes = episodes.filter((ep) => {
if (search && !ep.title.toLowerCase().includes(search.toLowerCase())) return false
const filteredEpisodes = useMemo(() => episodes.filter((ep) => {
if (debouncedSearch) {
const q = debouncedSearch.toLowerCase()
if (![ep.title, ep.plot, ep.aiDescription, ep.extractedText, ep.extractedTextTranslated]
.some((f) => f?.toLowerCase().includes(q))) return false
}
if (selectedTagIds.size > 0) {
const epTags = assignments[ep.item_key!] ?? []
if (![...selectedTagIds].every((id) => epTags.includes(id))) return false
}
if (ratingValue !== null) {
const r = ep.userRating
if (r === null) return false
if (ratingOperator === 'gte' && r < ratingValue) return false
if (ratingOperator === 'eq' && r !== ratingValue) return false
if (ratingOperator === 'lte' && r > ratingValue) return false
}
return true
})
}), [episodes, debouncedSearch, selectedTagIds, assignments, ratingValue, ratingOperator])
// Arrow key navigation for series/season levels (mirrors the prev/next UI buttons)
useEffect(() => {
@@ -524,6 +557,9 @@ export default function TvView({ libraryId, readOnly }: Props) {
selectedTagIds={selectedTagIds}
onTagToggle={toggleTag}
refreshKey={filterRefreshKey}
ratingValue={ratingValue}
ratingOperator={ratingOperator}
onRatingChange={handleRatingChange}
/>
</div>
)}

14
src/hooks/useDebounce.ts Normal file
View File

@@ -0,0 +1,14 @@
'use client'
import { useEffect, useState } from 'react'
export function useDebounce<T>(value: T, delayMs: number): T {
const [debounced, setDebounced] = useState<T>(value)
useEffect(() => {
const id = setTimeout(() => setDebounced(value), delayMs)
return () => clearTimeout(id)
}, [value, delayMs])
return debounced
}

View File

@@ -153,6 +153,10 @@ export async function scanComicsLibrary(
coverUrl,
filePath: c.relPath,
isStandalone: c.isStandalone,
userRating: null,
aiDescription: null,
extractedText: null,
extractedTextTranslated: null,
}
if (c.isStandalone) {
@@ -166,6 +170,10 @@ export async function scanComicsLibrary(
coverUrl, // first issue (sorted) becomes the series cover
issueCount: 0,
issues: [],
userRating: null,
aiDescription: null,
extractedText: null,
extractedTextTranslated: null,
})
}
const series = seriesMap.get(key)!
@@ -201,6 +209,10 @@ export function comicsFromDb(
title: string | null
metadata: string | null
file_path: string | null
user_rating: number | null
ai_description: string | null
extracted_text: string | null
extracted_text_translated: string | null
}
const baseWhere = `
@@ -216,17 +228,20 @@ export function comicsFromDb(
.prepare(`SELECT COUNT(*) as cnt FROM media_items ${baseWhere}`)
.get(libraryId) as { cnt: number }).cnt
const cols = `item_key, item_type, parent_key, title, metadata, file_path,
user_rating, ai_description, extracted_text, extracted_text_translated`
const rows: DbRow[] = opts.search
? db
.prepare(
`SELECT item_key, item_type, parent_key, title, metadata, file_path
`SELECT ${cols}
FROM media_items ${baseWhere} AND title LIKE ? ESCAPE '\\'
ORDER BY title LIMIT ? OFFSET ?`
)
.all(libraryId, escapeLike(opts.search), opts.pageSize, offset) as DbRow[]
: db
.prepare(
`SELECT item_key, item_type, parent_key, title, metadata, file_path
`SELECT ${cols}
FROM media_items ${baseWhere}
ORDER BY title LIMIT ? OFFSET ?`
)
@@ -243,6 +258,10 @@ export function comicsFromDb(
title: row.title ?? decodeURIComponent(idPart),
coverUrl: meta.coverUrl ?? null,
issueCount: meta.issueCount ?? 0,
userRating: row.user_rating ?? null,
aiDescription: row.ai_description ?? null,
extractedText: row.extracted_text ?? null,
extractedTextTranslated: row.extracted_text_translated ?? null,
} as ComicSeries)
} else {
const idPart = row.item_key.split(':comic_issue:')[1] ?? row.item_key
@@ -255,6 +274,10 @@ export function comicsFromDb(
coverUrl: meta.coverUrl ?? null,
filePath: row.file_path ?? '',
isStandalone: meta.isStandalone ?? true,
userRating: row.user_rating ?? null,
aiDescription: row.ai_description ?? null,
extractedText: row.extracted_text ?? null,
extractedTextTranslated: row.extracted_text_translated ?? null,
} as ComicIssue)
}
}
@@ -271,11 +294,16 @@ export function comicIssuesFromDb(libraryId: string, seriesId: string): ComicIss
title: string | null
metadata: string | null
file_path: string | null
user_rating: number | null
ai_description: string | null
extracted_text: string | null
extracted_text_translated: string | null
}
const rows = db
.prepare(
`SELECT item_key, title, metadata, file_path
`SELECT item_key, title, metadata, file_path,
user_rating, ai_description, extracted_text, extracted_text_translated
FROM media_items
WHERE parent_key = ? AND item_type = 'comic_issue'`
)
@@ -293,6 +321,10 @@ export function comicIssuesFromDb(libraryId: string, seriesId: string): ComicIss
coverUrl: meta.coverUrl ?? null,
filePath: row.file_path ?? '',
isStandalone: false,
userRating: row.user_rating ?? null,
aiDescription: row.ai_description ?? null,
extractedText: row.extracted_text ?? null,
extractedTextTranslated: row.extracted_text_translated ?? null,
}
})

View File

@@ -112,6 +112,7 @@ function initDb(db: Database.Database): void {
migrateImportedTags(db)
migrateComicsIndex(db)
migrateTagMappingsIndexes(db)
migrateUserRating(db)
seedAppSettings(db)
}
@@ -465,3 +466,10 @@ function migrateTagMappingsIndexes(db: Database.Database): void {
CREATE INDEX IF NOT EXISTS item_imported_tags_imported_tag_id ON item_imported_tags(imported_tag_id);
`)
}
function migrateUserRating(db: Database.Database): void {
const cols = db.pragma('table_info(media_items)') as { name: string }[]
if (!cols.some((c) => c.name === 'user_rating')) {
db.exec('ALTER TABLE media_items ADD COLUMN user_rating INTEGER')
}
}

View File

@@ -93,6 +93,10 @@ function buildGame(
: null,
gameFiles,
platforms,
userRating: null,
aiDescription: null,
extractedText: null,
extractedTextTranslated: null,
}
}
@@ -175,10 +179,15 @@ export function gamesFromDb(libraryId: string): (Game | GameSeries)[] {
parent_key: string | null
title: string | null
metadata: string | null
user_rating: number | null
ai_description: string | null
extracted_text: string | null
extracted_text_translated: string | null
}
const allRows = db
.prepare(`SELECT item_key, item_type, parent_key, title, metadata
.prepare(`SELECT item_key, item_type, parent_key, title, metadata,
user_rating, ai_description, extracted_text, extracted_text_translated
FROM media_items
WHERE library_id = ? AND item_type IN ('game', 'game_series')
ORDER BY title`)
@@ -233,6 +242,10 @@ export function gamesFromDb(libraryId: string): (Game | GameSeries)[] {
wideCoverUrl: meta.wideCoverUrl ?? null,
gameFiles,
platforms,
userRating: row.user_rating ?? null,
aiDescription: row.ai_description ?? null,
extractedText: row.extracted_text ?? null,
extractedTextTranslated: row.extracted_text_translated ?? null,
}
if (row.parent_key && seriesMap.has(row.parent_key)) {
seriesMap.get(row.parent_key)!.games.push(game)

View File

@@ -72,6 +72,10 @@ export function scanMoviesLibrary(libraryRoot: string, libraryId: string): Movie
? fileApiUrl(libraryId, path.join(dirName, backdropFile))
: null,
videoPath: videoRelPath,
userRating: null,
aiDescription: null,
extractedText: null,
extractedTextTranslated: null,
})
}
@@ -90,6 +94,10 @@ export function moviesFromDb(libraryId: string): Movie[] {
genres: string | null
metadata: string | null
file_path: string | null
user_rating: number | null
ai_description: string | null
extracted_text: string | null
extracted_text_translated: string | null
}>
return rows.map((row) => {
@@ -108,6 +116,10 @@ export function moviesFromDb(libraryId: string): Movie[] {
backdropUrl: meta.backdropUrl ?? null,
videoPath: row.file_path ?? '',
manuallyEdited: meta.manuallyEdited === true,
userRating: row.user_rating ?? null,
aiDescription: row.ai_description ?? null,
extractedText: row.extracted_text ?? null,
extractedTextTranslated: row.extracted_text_translated ?? null,
}
})
}

View File

@@ -81,6 +81,10 @@ export function scanTvLibrary(libraryRoot: string, libraryId: string): TvSeries[
? fileApiUrl(libraryId, path.join(dirName, backdropFile))
: null,
seasonCount,
userRating: null,
aiDescription: null,
extractedText: null,
extractedTextTranslated: null,
})
}
@@ -181,6 +185,10 @@ export function scanTvEpisodes(
rating: null,
thumbnailUrl: thumbnailApiUrl(libraryId, videoRelPath),
videoPath: videoRelPath,
userRating: null,
aiDescription: null,
extractedText: null,
extractedTextTranslated: null,
})
}
@@ -204,6 +212,10 @@ type DbRow = {
genres: string | null
metadata: string | null
file_path: string | null
user_rating: number | null
ai_description: string | null
extracted_text: string | null
extracted_text_translated: string | null
}
export function tvSeriesFromDb(libraryId: string): TvSeries[] {
@@ -227,6 +239,10 @@ export function tvSeriesFromDb(libraryId: string): TvSeries[] {
backdropUrl: meta.backdropUrl ?? null,
seasonCount: meta.seasonCount ?? 0,
manuallyEdited: meta.manuallyEdited === true,
userRating: row.user_rating ?? null,
aiDescription: row.ai_description ?? null,
extractedText: row.extracted_text ?? null,
extractedTextTranslated: row.extracted_text_translated ?? null,
}
})
}
@@ -290,6 +306,10 @@ export function tvEpisodesFromDb(
rating: meta.rating ?? null,
thumbnailUrl: meta.thumbnailUrl ?? null,
videoPath: row.file_path ?? '',
userRating: row.user_rating ?? null,
aiDescription: row.ai_description ?? null,
extractedText: row.extracted_text ?? null,
extractedTextTranslated: row.extracted_text_translated ?? null,
}
})
.sort((a, b) => {

View File

@@ -1,11 +1,17 @@
export type LibraryType = 'comics' | 'games' | 'mixed' | 'movies' | 'tv'
export type RatingOperator = 'gte' | 'eq' | 'lte'
export interface ComicSeries {
id: string
item_key?: string
title: string
coverUrl: string | null
issueCount: number
userRating: number | null
aiDescription: string | null
extractedText: string | null
extractedTextTranslated: string | null
}
export interface ComicIssue {
@@ -17,6 +23,10 @@ export interface ComicIssue {
coverUrl: string | null
filePath: string
isStandalone: boolean
userRating: number | null
aiDescription: string | null
extractedText: string | null
extractedTextTranslated: string | null
}
export interface Library {
@@ -45,6 +55,10 @@ export interface Game {
wideCoverUrl: string | null
gameFiles: GameFile[]
platforms: GamePlatform[]
userRating: number | null
aiDescription: string | null
extractedText: string | null
extractedTextTranslated: string | null
}
export interface GameSeries {
@@ -65,6 +79,7 @@ export interface FileEntry {
url: string | null
thumbnailUrl: string | null
hasExtractedText?: boolean
userRating?: number | null
}
export interface Movie {
@@ -80,6 +95,10 @@ export interface Movie {
backdropUrl: string | null
videoPath: string
manuallyEdited?: boolean
userRating: number | null
aiDescription: string | null
extractedText: string | null
extractedTextTranslated: string | null
}
export interface TvSeries {
@@ -94,6 +113,10 @@ export interface TvSeries {
backdropUrl: string | null
seasonCount: number
manuallyEdited?: boolean
userRating: number | null
aiDescription: string | null
extractedText: string | null
extractedTextTranslated: string | null
}
export interface TvSeason {
@@ -117,6 +140,10 @@ export interface TvEpisode {
rating: number | null
thumbnailUrl: string | null
videoPath: string
userRating: number | null
aiDescription: string | null
extractedText: string | null
extractedTextTranslated: string | null
}
export interface DirectoryListing {