ratings #36

Merged
gpatti merged 2 commits from ratings into main 2026-04-21 15:18:00 +00:00
16 changed files with 568 additions and 62 deletions
Showing only changes of commit d854bbe99b - Show all commits

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

@@ -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 {