add rating system
This commit is contained in:
@@ -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
|
||||
|
||||
64
src/app/api/ratings/route.ts
Normal file
64
src/app/api/ratings/route.ts
Normal 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 1–5' }, { 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 })
|
||||
}
|
||||
@@ -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 (
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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 (!searchMatch) return false
|
||||
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>
|
||||
)}
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
14
src/hooks/useDebounce.ts
Normal 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
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
@@ -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')
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user