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) => {
|
listing.entries = listing.entries.map((e) => {
|
||||||
if (e.type === 'file') {
|
if (e.type === 'file') {
|
||||||
if (e.mediaType !== 'image') return e
|
|
||||||
const relPath = subpath ? path.join(subpath, e.name) : e.name
|
const relPath = subpath ? path.join(subpath, e.name) : e.name
|
||||||
const itemKey = `${libraryId}:mixed_file:${encodeURIComponent(relPath)}`
|
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') {
|
if (e.type === 'directory') {
|
||||||
const dirRel = subpath ? `${subpath}/${e.name}` : e.name
|
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'
|
'use client'
|
||||||
|
|
||||||
import { useEffect, useState } from 'react'
|
import { useEffect, useState } from 'react'
|
||||||
import type { Tag, TagCategory } from '@/types'
|
import type { Tag, TagCategory, RatingOperator } from '@/types'
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
libraryId: string
|
libraryId: string
|
||||||
@@ -11,9 +11,24 @@ interface Props {
|
|||||||
selectedTagIds: Set<string>
|
selectedTagIds: Set<string>
|
||||||
onTagToggle: (tagId: string) => void
|
onTagToggle: (tagId: string) => void
|
||||||
refreshKey?: number
|
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 [categories, setCategories] = useState<TagCategory[]>([])
|
||||||
const [tags, setTags] = useState<Tag[]>([])
|
const [tags, setTags] = useState<Tag[]>([])
|
||||||
const [loading, setLoading] = useState(true)
|
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 */}
|
{/* Tag filters */}
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<div className="flex flex-col gap-3">
|
<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"
|
className="h-3 w-16 rounded animate-pulse"
|
||||||
style={{ backgroundColor: 'var(--border)' }}
|
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) => (
|
{[50, 65, 42].map((w) => (
|
||||||
<div
|
<div
|
||||||
key={w}
|
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)' }}>
|
<p className="text-xs mb-1.5" style={{ color: 'var(--text-secondary)' }}>
|
||||||
{cat.name}
|
{cat.name}
|
||||||
</p>
|
</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) => {
|
{catTags.map((tag) => {
|
||||||
const active = selectedTagIds.has(tag.id)
|
const active = selectedTagIds.has(tag.id)
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
import { useCallback, useEffect, useRef, useState, useMemo } from 'react'
|
||||||
import type { ComicIssue, ComicSeries } from '@/types'
|
import type { ComicIssue, ComicSeries, RatingOperator } from '@/types'
|
||||||
|
import { useDebounce } from '@/hooks/useDebounce'
|
||||||
import ComicIssueView from './ComicIssueView'
|
import ComicIssueView from './ComicIssueView'
|
||||||
import FilterPanel from '@/components/FilterPanel'
|
import FilterPanel from '@/components/FilterPanel'
|
||||||
import TagSelector from '@/components/tags/TagSelector'
|
import TagSelector from '@/components/tags/TagSelector'
|
||||||
@@ -29,6 +30,9 @@ export default function ComicsView({ libraryId, readOnly }: Props) {
|
|||||||
const [search, setSearch] = useState('')
|
const [search, setSearch] = useState('')
|
||||||
const [selectedTagIds, setSelectedTagIds] = useState<Set<string>>(new Set())
|
const [selectedTagIds, setSelectedTagIds] = useState<Set<string>>(new Set())
|
||||||
const [assignments, setAssignments] = useState<Record<string, string[]>>({})
|
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<
|
const [seriesIssueMeta, setSeriesIssueMeta] = useState<
|
||||||
Record<string, { tagIds: string[]; issueTitles: string[] }>
|
Record<string, { tagIds: string[]; issueTitles: string[] }>
|
||||||
>({})
|
>({})
|
||||||
@@ -132,43 +136,76 @@ export default function ComicsView({ libraryId, readOnly }: Props) {
|
|||||||
fetchSeriesIssueMeta()
|
fetchSeriesIssueMeta()
|
||||||
}, [fetchAssignments, 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 isSeries = 'issueCount' in item
|
||||||
|
const series = isSeries ? (item as ComicSeries) : null
|
||||||
|
const issue = isSeries ? null : (item as ComicIssue)
|
||||||
|
|
||||||
if (isSeries) {
|
if (series) {
|
||||||
const meta = seriesIssueMeta[item.item_key ?? ''] ?? { tagIds: [], issueTitles: [] }
|
const meta = seriesIssueMeta[series.item_key ?? ''] ?? { tagIds: [], issueTitles: [] }
|
||||||
|
|
||||||
if (search) {
|
if (debouncedSearch) {
|
||||||
const q = search.toLowerCase()
|
const q = debouncedSearch.toLowerCase()
|
||||||
const titleMatch = item.title.toLowerCase().includes(q)
|
const titleMatch = series.title.toLowerCase().includes(q)
|
||||||
const issueMatch = meta.issueTitles.some((t) => t.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) {
|
if (selectedTagIds.size > 0) {
|
||||||
const seriesTags = assignments[item.item_key ?? ''] ?? []
|
const seriesTags = assignments[series.item_key ?? ''] ?? []
|
||||||
const allTags = [...new Set([...seriesTags, ...meta.tagIds])]
|
const allTags = [...new Set([...seriesTags, ...meta.tagIds])]
|
||||||
if (![...selectedTagIds].every((id) => allTags.includes(id))) return false
|
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
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
// Standalone issue
|
// 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) {
|
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 (![...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
|
return true
|
||||||
})
|
}), [items, debouncedSearch, selectedTagIds, assignments, seriesIssueMeta, ratingValue, ratingOperator])
|
||||||
|
|
||||||
// Flat list of issues at the current navigation level for prev/next
|
// Flat list of issues at the current navigation level for prev/next
|
||||||
const filteredIssues: ComicIssue[] = selectedSeries
|
const filteredIssues: ComicIssue[] = selectedSeries
|
||||||
? seriesIssues
|
? seriesIssues
|
||||||
: filtered.filter((item): item is ComicIssue => !('issueCount' in item))
|
: filtered.filter((item): item is ComicIssue => !('issueCount' in item))
|
||||||
|
|
||||||
const filtersActive = search !== '' || selectedTagIds.size > 0
|
const filtersActive = search !== '' || selectedTagIds.size > 0 || ratingValue !== null
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@@ -198,6 +235,9 @@ export default function ComicsView({ libraryId, readOnly }: Props) {
|
|||||||
selectedTagIds={selectedTagIds}
|
selectedTagIds={selectedTagIds}
|
||||||
onTagToggle={toggleTag}
|
onTagToggle={toggleTag}
|
||||||
refreshKey={filterRefreshKey}
|
refreshKey={filterRefreshKey}
|
||||||
|
ratingValue={ratingValue}
|
||||||
|
ratingOperator={ratingOperator}
|
||||||
|
onRatingChange={handleRatingChange}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { useEffect, useState, useCallback, useRef } from 'react'
|
import { useEffect, useState, useCallback, useRef, useMemo } from 'react'
|
||||||
import type { Game, GamePlatform, GameSeries } from '@/types'
|
import type { Game, GamePlatform, GameSeries, RatingOperator } from '@/types'
|
||||||
|
import { useDebounce } from '@/hooks/useDebounce'
|
||||||
import GameDetailModal from './GameDetailModal'
|
import GameDetailModal from './GameDetailModal'
|
||||||
import FilterPanel from '@/components/FilterPanel'
|
import FilterPanel from '@/components/FilterPanel'
|
||||||
|
|
||||||
@@ -72,6 +73,9 @@ export default function GamesView({ libraryId, readOnly }: Props) {
|
|||||||
const [search, setSearch] = useState('')
|
const [search, setSearch] = useState('')
|
||||||
const [selectedTagIds, setSelectedTagIds] = useState<Set<string>>(new Set())
|
const [selectedTagIds, setSelectedTagIds] = useState<Set<string>>(new Set())
|
||||||
const [assignments, setAssignments] = useState<Record<string, string[]>>({})
|
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 [filterRefreshKey, setFilterRefreshKey] = useState(0)
|
||||||
const [showFilters, setShowFilters] = useState(
|
const [showFilters, setShowFilters] = useState(
|
||||||
() => typeof window !== 'undefined' && window.innerWidth >= 768
|
() => typeof window !== 'undefined' && window.innerWidth >= 768
|
||||||
@@ -128,29 +132,69 @@ export default function GamesView({ libraryId, readOnly }: Props) {
|
|||||||
? selectedSeries.games
|
? selectedSeries.games
|
||||||
: items
|
: 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) {
|
if ('games' in item) {
|
||||||
const searchMatch = !search ||
|
if (debouncedSearch) {
|
||||||
item.title.toLowerCase().includes(search.toLowerCase()) ||
|
const q = debouncedSearch.toLowerCase()
|
||||||
item.games.some((g) => g.title.toLowerCase().includes(search.toLowerCase()))
|
const searchMatch =
|
||||||
if (!searchMatch) return false
|
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) {
|
if (selectedTagIds.size > 0) {
|
||||||
return item.games.some((g) => {
|
if (!item.games.some((g) => {
|
||||||
const gameTags = assignments[g.item_key!] ?? []
|
const gameTags = assignments[g.item_key!] ?? []
|
||||||
return [...selectedTagIds].every((id) => gameTags.includes(id))
|
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
|
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) {
|
if (selectedTagIds.size > 0) {
|
||||||
const gameTags = assignments[item.item_key!] ?? []
|
const gameTags = assignments[item.item_key!] ?? []
|
||||||
if (![...selectedTagIds].every((id) => gameTags.includes(id))) return false
|
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
|
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) =>
|
const filteredGames: Game[] = filtered.flatMap((item) =>
|
||||||
'games' in item ? item.games : [item as Game]
|
'games' in item ? item.games : [item as Game]
|
||||||
)
|
)
|
||||||
@@ -182,6 +226,9 @@ export default function GamesView({ libraryId, readOnly }: Props) {
|
|||||||
selectedTagIds={selectedTagIds}
|
selectedTagIds={selectedTagIds}
|
||||||
onTagToggle={toggleTag}
|
onTagToggle={toggleTag}
|
||||||
refreshKey={filterRefreshKey}
|
refreshKey={filterRefreshKey}
|
||||||
|
ratingValue={ratingValue}
|
||||||
|
ratingOperator={ratingOperator}
|
||||||
|
onRatingChange={handleRatingChange}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { useEffect, useState, useCallback, useRef } from 'react'
|
import { useEffect, useState, useCallback, useRef, useMemo } from 'react'
|
||||||
import type { DirectoryListing, FileEntry } from '@/types'
|
import type { DirectoryListing, FileEntry, RatingOperator } from '@/types'
|
||||||
|
import { useDebounce } from '@/hooks/useDebounce'
|
||||||
import VideoPlayerModal from './VideoPlayerModal'
|
import VideoPlayerModal from './VideoPlayerModal'
|
||||||
import ImageLightbox from './ImageLightbox'
|
import ImageLightbox from './ImageLightbox'
|
||||||
import TagSelector from '@/components/tags/TagSelector'
|
import TagSelector from '@/components/tags/TagSelector'
|
||||||
@@ -34,6 +35,7 @@ export default function MixedView({ libraryId, libraryName, initialPath, readOnl
|
|||||||
const [search, setSearch] = useState('')
|
const [search, setSearch] = useState('')
|
||||||
const [selectedTagIds, setSelectedTagIds] = useState<Set<string>>(new Set())
|
const [selectedTagIds, setSelectedTagIds] = useState<Set<string>>(new Set())
|
||||||
const [assignments, setAssignments] = useState<Record<string, string[]>>({})
|
const [assignments, setAssignments] = useState<Record<string, string[]>>({})
|
||||||
|
const debouncedSearch = useDebounce(search, 200)
|
||||||
const [filterRefreshKey, setFilterRefreshKey] = useState(0)
|
const [filterRefreshKey, setFilterRefreshKey] = useState(0)
|
||||||
const [showFilters, setShowFilters] = useState(
|
const [showFilters, setShowFilters] = useState(
|
||||||
() => typeof window !== 'undefined' && window.innerWidth >= 768
|
() => typeof window !== 'undefined' && window.innerWidth >= 768
|
||||||
@@ -110,7 +112,19 @@ export default function MixedView({ libraryId, libraryName, initialPath, readOnl
|
|||||||
.catch(() => {})
|
.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(() => {
|
const fetchRecursive = useCallback(() => {
|
||||||
if (recursiveLoaded || recursiveLoading) return
|
if (recursiveLoaded || recursiveLoading) return
|
||||||
@@ -155,14 +169,22 @@ export default function MixedView({ libraryId, libraryName, initialPath, readOnl
|
|||||||
|
|
||||||
const sourceEntries = filtersActive ? recursiveEntries : (listing?.entries ?? [])
|
const sourceEntries = filtersActive ? recursiveEntries : (listing?.entries ?? [])
|
||||||
|
|
||||||
const filteredEntries = sourceEntries.filter((entry) => {
|
const filteredEntries = useMemo(() => sourceEntries.filter((entry) => {
|
||||||
if (search && !entry.name.toLowerCase().includes(search.toLowerCase())) return false
|
if (debouncedSearch && !entry.name.toLowerCase().includes(debouncedSearch.toLowerCase())) return false
|
||||||
if (selectedTagIds.size > 0 && entry.type !== 'directory') {
|
if (selectedTagIds.size > 0 && entry.type !== 'directory') {
|
||||||
const entryTags = assignments[itemKeyFor(entry)] ?? []
|
const entryTags = assignments[itemKeyFor(entry)] ?? []
|
||||||
if (![...selectedTagIds].every((id) => entryTags.includes(id))) return false
|
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
|
return true
|
||||||
})
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}), [sourceEntries, debouncedSearch, selectedTagIds, assignments, ratingValue, ratingOperator])
|
||||||
|
|
||||||
const mediaEntries = filteredEntries.filter(
|
const mediaEntries = filteredEntries.filter(
|
||||||
(e) => e.mediaType === 'video' || e.mediaType === 'image'
|
(e) => e.mediaType === 'video' || e.mediaType === 'image'
|
||||||
@@ -337,6 +359,9 @@ export default function MixedView({ libraryId, libraryName, initialPath, readOnl
|
|||||||
selectedTagIds={selectedTagIds}
|
selectedTagIds={selectedTagIds}
|
||||||
onTagToggle={toggleTag}
|
onTagToggle={toggleTag}
|
||||||
refreshKey={filterRefreshKey}
|
refreshKey={filterRefreshKey}
|
||||||
|
ratingValue={ratingValue}
|
||||||
|
ratingOperator={ratingOperator}
|
||||||
|
onRatingChange={handleRatingChange}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -1,11 +1,12 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { useEffect, useState, useCallback } from 'react'
|
import { useEffect, useState, useCallback, useMemo } from 'react'
|
||||||
import type { Movie } from '@/types'
|
import type { Movie, RatingOperator } from '@/types'
|
||||||
import MovieDetailModal from './MovieDetailModal'
|
import MovieDetailModal from './MovieDetailModal'
|
||||||
import FilterPanel from '@/components/FilterPanel'
|
import FilterPanel from '@/components/FilterPanel'
|
||||||
import DoomScrollView, { type DoomScrollItem } from '@/components/DoomScrollView'
|
import DoomScrollView, { type DoomScrollItem } from '@/components/DoomScrollView'
|
||||||
import { isBrowserPlayable } from '@/lib/browser-media'
|
import { isBrowserPlayable } from '@/lib/browser-media'
|
||||||
|
import { useDebounce } from '@/hooks/useDebounce'
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
libraryId: string
|
libraryId: string
|
||||||
@@ -20,6 +21,9 @@ export default function MoviesView({ libraryId, readOnly }: Props) {
|
|||||||
const [search, setSearch] = useState('')
|
const [search, setSearch] = useState('')
|
||||||
const [selectedTagIds, setSelectedTagIds] = useState<Set<string>>(new Set())
|
const [selectedTagIds, setSelectedTagIds] = useState<Set<string>>(new Set())
|
||||||
const [assignments, setAssignments] = useState<Record<string, string[]>>({})
|
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 [filterRefreshKey, setFilterRefreshKey] = useState(0)
|
||||||
const [showFilters, setShowFilters] = useState(
|
const [showFilters, setShowFilters] = useState(
|
||||||
() => typeof window !== 'undefined' && window.innerWidth >= 768
|
() => typeof window !== 'undefined' && window.innerWidth >= 768
|
||||||
@@ -58,14 +62,34 @@ export default function MoviesView({ libraryId, readOnly }: Props) {
|
|||||||
|
|
||||||
useEffect(() => { fetchAssignments() }, [fetchAssignments])
|
useEffect(() => { fetchAssignments() }, [fetchAssignments])
|
||||||
|
|
||||||
const filtered = movies.filter((movie) => {
|
const handleRatingChange = (value: number | null, operator: RatingOperator) => {
|
||||||
if (search && !movie.title.toLowerCase().includes(search.toLowerCase())) return false
|
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) {
|
if (selectedTagIds.size > 0) {
|
||||||
const movieTags = assignments[movie.item_key!] ?? []
|
const movieTags = assignments[movie.item_key!] ?? []
|
||||||
if (![...selectedTagIds].every((id) => movieTags.includes(id))) return false
|
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
|
return true
|
||||||
})
|
}), [movies, debouncedSearch, selectedTagIds, assignments, ratingValue, ratingOperator])
|
||||||
|
|
||||||
const selected = selectedIndex !== null ? filtered[selectedIndex] ?? null : null
|
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))
|
setMovies((prev) => prev.filter((m) => m.id !== movieId))
|
||||||
}
|
}
|
||||||
|
|
||||||
const filtersActive = search !== '' || selectedTagIds.size > 0
|
const filtersActive = search !== '' || selectedTagIds.size > 0 || ratingValue !== null
|
||||||
|
|
||||||
const handleDoomScroll = () => {
|
const handleDoomScroll = () => {
|
||||||
// Use filtered movies — respects any active search/tag filters automatically
|
// Use filtered movies — respects any active search/tag filters automatically
|
||||||
@@ -135,6 +159,9 @@ export default function MoviesView({ libraryId, readOnly }: Props) {
|
|||||||
selectedTagIds={selectedTagIds}
|
selectedTagIds={selectedTagIds}
|
||||||
onTagToggle={toggleTag}
|
onTagToggle={toggleTag}
|
||||||
refreshKey={filterRefreshKey}
|
refreshKey={filterRefreshKey}
|
||||||
|
ratingValue={ratingValue}
|
||||||
|
ratingOperator={ratingOperator}
|
||||||
|
onRatingChange={handleRatingChange}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { useState } from 'react'
|
import { useState, useEffect, useCallback } from 'react'
|
||||||
import TagSelector from './TagSelector'
|
import TagSelector from './TagSelector'
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
@@ -33,6 +33,35 @@ export default function MediaTagPanel({
|
|||||||
const [aiTagging, setAiTagging] = useState(false)
|
const [aiTagging, setAiTagging] = useState(false)
|
||||||
const [aiTagError, setAiTagError] = useState<string | null>(null)
|
const [aiTagError, setAiTagError] = useState<string | null>(null)
|
||||||
const [internalRefreshKey, setInternalRefreshKey] = useState(0)
|
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 () => {
|
const handleAiTag = async () => {
|
||||||
if (!onAiTag) return
|
if (!onAiTag) return
|
||||||
@@ -94,8 +123,44 @@ export default function MediaTagPanel({
|
|||||||
) : null
|
) : 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 */}
|
{/* 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)' }}>
|
<p className="text-xs font-semibold uppercase tracking-wider" style={{ color: 'var(--text-secondary)' }}>
|
||||||
Tags
|
Tags
|
||||||
</p>
|
</p>
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { useEffect, useRef, useState, useCallback } from 'react'
|
import { useEffect, useRef, useState, useCallback, useMemo } from 'react'
|
||||||
import type { TvSeries, TvSeason, TvEpisode } from '@/types'
|
import type { TvSeries, TvSeason, TvEpisode, RatingOperator } from '@/types'
|
||||||
|
import { useDebounce } from '@/hooks/useDebounce'
|
||||||
|
|
||||||
import FilterPanel from '@/components/FilterPanel'
|
import FilterPanel from '@/components/FilterPanel'
|
||||||
import VideoPlayerModal from '@/components/mixed/VideoPlayerModal'
|
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 [selectedTagIds, setSelectedTagIds] = useState<Set<string>>(new Set())
|
||||||
const [assignments, setAssignments] = useState<Record<string, string[]>>({})
|
const [assignments, setAssignments] = useState<Record<string, string[]>>({})
|
||||||
const [seriesEpisodeTags, setSeriesEpisodeTags] = 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 [filterRefreshKey, setFilterRefreshKey] = useState(0)
|
||||||
const [showFilters, setShowFilters] = useState(
|
const [showFilters, setShowFilters] = useState(
|
||||||
() => typeof window !== 'undefined' && window.innerWidth >= 768
|
() => typeof window !== 'undefined' && window.innerWidth >= 768
|
||||||
@@ -375,29 +379,58 @@ export default function TvView({ libraryId, readOnly }: Props) {
|
|||||||
}
|
}
|
||||||
}, [view, menuOpen, showTagPanel, selectedSeries])
|
}, [view, menuOpen, showTagPanel, selectedSeries])
|
||||||
|
|
||||||
const filtersActive = search !== '' || selectedTagIds.size > 0
|
const filtersActive = search !== '' || selectedTagIds.size > 0 || ratingValue !== null
|
||||||
|
|
||||||
const filteredSeries = series.filter((s) => {
|
const handleRatingChange = (value: number | null, operator: RatingOperator) => {
|
||||||
if (search && !s.title.toLowerCase().includes(search.toLowerCase())) return false
|
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) {
|
if (selectedTagIds.size > 0) {
|
||||||
const seriesTags = assignments[s.item_key!] ?? []
|
const seriesTags = assignments[s.item_key!] ?? []
|
||||||
const episodeTags = seriesEpisodeTags[s.id] ?? []
|
const episodeTags = seriesEpisodeTags[s.id] ?? []
|
||||||
const allTags = seriesTags.length === 0 ? episodeTags
|
const allTags = [...new Set([...seriesTags, ...episodeTags])]
|
||||||
: episodeTags.length === 0 ? seriesTags
|
|
||||||
: [...new Set([...seriesTags, ...episodeTags])]
|
|
||||||
if (![...selectedTagIds].every((id) => allTags.includes(id))) return false
|
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
|
return true
|
||||||
})
|
}), [series, debouncedSearch, selectedTagIds, assignments, seriesEpisodeTags, ratingValue, ratingOperator])
|
||||||
|
|
||||||
const filteredEpisodes = episodes.filter((ep) => {
|
const filteredEpisodes = useMemo(() => episodes.filter((ep) => {
|
||||||
if (search && !ep.title.toLowerCase().includes(search.toLowerCase())) return false
|
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) {
|
if (selectedTagIds.size > 0) {
|
||||||
const epTags = assignments[ep.item_key!] ?? []
|
const epTags = assignments[ep.item_key!] ?? []
|
||||||
if (![...selectedTagIds].every((id) => epTags.includes(id))) return false
|
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
|
return true
|
||||||
})
|
}), [episodes, debouncedSearch, selectedTagIds, assignments, ratingValue, ratingOperator])
|
||||||
|
|
||||||
// Arrow key navigation for series/season levels (mirrors the prev/next UI buttons)
|
// Arrow key navigation for series/season levels (mirrors the prev/next UI buttons)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -524,6 +557,9 @@ export default function TvView({ libraryId, readOnly }: Props) {
|
|||||||
selectedTagIds={selectedTagIds}
|
selectedTagIds={selectedTagIds}
|
||||||
onTagToggle={toggleTag}
|
onTagToggle={toggleTag}
|
||||||
refreshKey={filterRefreshKey}
|
refreshKey={filterRefreshKey}
|
||||||
|
ratingValue={ratingValue}
|
||||||
|
ratingOperator={ratingOperator}
|
||||||
|
onRatingChange={handleRatingChange}
|
||||||
/>
|
/>
|
||||||
</div>
|
</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,
|
coverUrl,
|
||||||
filePath: c.relPath,
|
filePath: c.relPath,
|
||||||
isStandalone: c.isStandalone,
|
isStandalone: c.isStandalone,
|
||||||
|
userRating: null,
|
||||||
|
aiDescription: null,
|
||||||
|
extractedText: null,
|
||||||
|
extractedTextTranslated: null,
|
||||||
}
|
}
|
||||||
|
|
||||||
if (c.isStandalone) {
|
if (c.isStandalone) {
|
||||||
@@ -166,6 +170,10 @@ export async function scanComicsLibrary(
|
|||||||
coverUrl, // first issue (sorted) becomes the series cover
|
coverUrl, // first issue (sorted) becomes the series cover
|
||||||
issueCount: 0,
|
issueCount: 0,
|
||||||
issues: [],
|
issues: [],
|
||||||
|
userRating: null,
|
||||||
|
aiDescription: null,
|
||||||
|
extractedText: null,
|
||||||
|
extractedTextTranslated: null,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
const series = seriesMap.get(key)!
|
const series = seriesMap.get(key)!
|
||||||
@@ -201,6 +209,10 @@ export function comicsFromDb(
|
|||||||
title: string | null
|
title: string | null
|
||||||
metadata: string | null
|
metadata: string | null
|
||||||
file_path: 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 = `
|
const baseWhere = `
|
||||||
@@ -216,17 +228,20 @@ export function comicsFromDb(
|
|||||||
.prepare(`SELECT COUNT(*) as cnt FROM media_items ${baseWhere}`)
|
.prepare(`SELECT COUNT(*) as cnt FROM media_items ${baseWhere}`)
|
||||||
.get(libraryId) as { cnt: number }).cnt
|
.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
|
const rows: DbRow[] = opts.search
|
||||||
? db
|
? db
|
||||||
.prepare(
|
.prepare(
|
||||||
`SELECT item_key, item_type, parent_key, title, metadata, file_path
|
`SELECT ${cols}
|
||||||
FROM media_items ${baseWhere} AND title LIKE ? ESCAPE '\\'
|
FROM media_items ${baseWhere} AND title LIKE ? ESCAPE '\\'
|
||||||
ORDER BY title LIMIT ? OFFSET ?`
|
ORDER BY title LIMIT ? OFFSET ?`
|
||||||
)
|
)
|
||||||
.all(libraryId, escapeLike(opts.search), opts.pageSize, offset) as DbRow[]
|
.all(libraryId, escapeLike(opts.search), opts.pageSize, offset) as DbRow[]
|
||||||
: db
|
: db
|
||||||
.prepare(
|
.prepare(
|
||||||
`SELECT item_key, item_type, parent_key, title, metadata, file_path
|
`SELECT ${cols}
|
||||||
FROM media_items ${baseWhere}
|
FROM media_items ${baseWhere}
|
||||||
ORDER BY title LIMIT ? OFFSET ?`
|
ORDER BY title LIMIT ? OFFSET ?`
|
||||||
)
|
)
|
||||||
@@ -243,6 +258,10 @@ export function comicsFromDb(
|
|||||||
title: row.title ?? decodeURIComponent(idPart),
|
title: row.title ?? decodeURIComponent(idPart),
|
||||||
coverUrl: meta.coverUrl ?? null,
|
coverUrl: meta.coverUrl ?? null,
|
||||||
issueCount: meta.issueCount ?? 0,
|
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)
|
} as ComicSeries)
|
||||||
} else {
|
} else {
|
||||||
const idPart = row.item_key.split(':comic_issue:')[1] ?? row.item_key
|
const idPart = row.item_key.split(':comic_issue:')[1] ?? row.item_key
|
||||||
@@ -255,6 +274,10 @@ export function comicsFromDb(
|
|||||||
coverUrl: meta.coverUrl ?? null,
|
coverUrl: meta.coverUrl ?? null,
|
||||||
filePath: row.file_path ?? '',
|
filePath: row.file_path ?? '',
|
||||||
isStandalone: meta.isStandalone ?? true,
|
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)
|
} as ComicIssue)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -271,11 +294,16 @@ export function comicIssuesFromDb(libraryId: string, seriesId: string): ComicIss
|
|||||||
title: string | null
|
title: string | null
|
||||||
metadata: string | null
|
metadata: string | null
|
||||||
file_path: 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
|
const rows = db
|
||||||
.prepare(
|
.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
|
FROM media_items
|
||||||
WHERE parent_key = ? AND item_type = 'comic_issue'`
|
WHERE parent_key = ? AND item_type = 'comic_issue'`
|
||||||
)
|
)
|
||||||
@@ -293,6 +321,10 @@ export function comicIssuesFromDb(libraryId: string, seriesId: string): ComicIss
|
|||||||
coverUrl: meta.coverUrl ?? null,
|
coverUrl: meta.coverUrl ?? null,
|
||||||
filePath: row.file_path ?? '',
|
filePath: row.file_path ?? '',
|
||||||
isStandalone: false,
|
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)
|
migrateImportedTags(db)
|
||||||
migrateComicsIndex(db)
|
migrateComicsIndex(db)
|
||||||
migrateTagMappingsIndexes(db)
|
migrateTagMappingsIndexes(db)
|
||||||
|
migrateUserRating(db)
|
||||||
seedAppSettings(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);
|
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,
|
: null,
|
||||||
gameFiles,
|
gameFiles,
|
||||||
platforms,
|
platforms,
|
||||||
|
userRating: null,
|
||||||
|
aiDescription: null,
|
||||||
|
extractedText: null,
|
||||||
|
extractedTextTranslated: null,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -175,10 +179,15 @@ export function gamesFromDb(libraryId: string): (Game | GameSeries)[] {
|
|||||||
parent_key: string | null
|
parent_key: string | null
|
||||||
title: string | null
|
title: string | null
|
||||||
metadata: 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
|
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
|
FROM media_items
|
||||||
WHERE library_id = ? AND item_type IN ('game', 'game_series')
|
WHERE library_id = ? AND item_type IN ('game', 'game_series')
|
||||||
ORDER BY title`)
|
ORDER BY title`)
|
||||||
@@ -233,6 +242,10 @@ export function gamesFromDb(libraryId: string): (Game | GameSeries)[] {
|
|||||||
wideCoverUrl: meta.wideCoverUrl ?? null,
|
wideCoverUrl: meta.wideCoverUrl ?? null,
|
||||||
gameFiles,
|
gameFiles,
|
||||||
platforms,
|
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)) {
|
if (row.parent_key && seriesMap.has(row.parent_key)) {
|
||||||
seriesMap.get(row.parent_key)!.games.push(game)
|
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))
|
? fileApiUrl(libraryId, path.join(dirName, backdropFile))
|
||||||
: null,
|
: null,
|
||||||
videoPath: videoRelPath,
|
videoPath: videoRelPath,
|
||||||
|
userRating: null,
|
||||||
|
aiDescription: null,
|
||||||
|
extractedText: null,
|
||||||
|
extractedTextTranslated: null,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -90,6 +94,10 @@ export function moviesFromDb(libraryId: string): Movie[] {
|
|||||||
genres: string | null
|
genres: string | null
|
||||||
metadata: string | null
|
metadata: string | null
|
||||||
file_path: 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) => {
|
return rows.map((row) => {
|
||||||
@@ -108,6 +116,10 @@ export function moviesFromDb(libraryId: string): Movie[] {
|
|||||||
backdropUrl: meta.backdropUrl ?? null,
|
backdropUrl: meta.backdropUrl ?? null,
|
||||||
videoPath: row.file_path ?? '',
|
videoPath: row.file_path ?? '',
|
||||||
manuallyEdited: meta.manuallyEdited === true,
|
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))
|
? fileApiUrl(libraryId, path.join(dirName, backdropFile))
|
||||||
: null,
|
: null,
|
||||||
seasonCount,
|
seasonCount,
|
||||||
|
userRating: null,
|
||||||
|
aiDescription: null,
|
||||||
|
extractedText: null,
|
||||||
|
extractedTextTranslated: null,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -181,6 +185,10 @@ export function scanTvEpisodes(
|
|||||||
rating: null,
|
rating: null,
|
||||||
thumbnailUrl: thumbnailApiUrl(libraryId, videoRelPath),
|
thumbnailUrl: thumbnailApiUrl(libraryId, videoRelPath),
|
||||||
videoPath: videoRelPath,
|
videoPath: videoRelPath,
|
||||||
|
userRating: null,
|
||||||
|
aiDescription: null,
|
||||||
|
extractedText: null,
|
||||||
|
extractedTextTranslated: null,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -204,6 +212,10 @@ type DbRow = {
|
|||||||
genres: string | null
|
genres: string | null
|
||||||
metadata: string | null
|
metadata: string | null
|
||||||
file_path: 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[] {
|
export function tvSeriesFromDb(libraryId: string): TvSeries[] {
|
||||||
@@ -227,6 +239,10 @@ export function tvSeriesFromDb(libraryId: string): TvSeries[] {
|
|||||||
backdropUrl: meta.backdropUrl ?? null,
|
backdropUrl: meta.backdropUrl ?? null,
|
||||||
seasonCount: meta.seasonCount ?? 0,
|
seasonCount: meta.seasonCount ?? 0,
|
||||||
manuallyEdited: meta.manuallyEdited === true,
|
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,
|
rating: meta.rating ?? null,
|
||||||
thumbnailUrl: meta.thumbnailUrl ?? null,
|
thumbnailUrl: meta.thumbnailUrl ?? null,
|
||||||
videoPath: row.file_path ?? '',
|
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) => {
|
.sort((a, b) => {
|
||||||
|
|||||||
@@ -1,11 +1,17 @@
|
|||||||
export type LibraryType = 'comics' | 'games' | 'mixed' | 'movies' | 'tv'
|
export type LibraryType = 'comics' | 'games' | 'mixed' | 'movies' | 'tv'
|
||||||
|
|
||||||
|
export type RatingOperator = 'gte' | 'eq' | 'lte'
|
||||||
|
|
||||||
export interface ComicSeries {
|
export interface ComicSeries {
|
||||||
id: string
|
id: string
|
||||||
item_key?: string
|
item_key?: string
|
||||||
title: string
|
title: string
|
||||||
coverUrl: string | null
|
coverUrl: string | null
|
||||||
issueCount: number
|
issueCount: number
|
||||||
|
userRating: number | null
|
||||||
|
aiDescription: string | null
|
||||||
|
extractedText: string | null
|
||||||
|
extractedTextTranslated: string | null
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ComicIssue {
|
export interface ComicIssue {
|
||||||
@@ -17,6 +23,10 @@ export interface ComicIssue {
|
|||||||
coverUrl: string | null
|
coverUrl: string | null
|
||||||
filePath: string
|
filePath: string
|
||||||
isStandalone: boolean
|
isStandalone: boolean
|
||||||
|
userRating: number | null
|
||||||
|
aiDescription: string | null
|
||||||
|
extractedText: string | null
|
||||||
|
extractedTextTranslated: string | null
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Library {
|
export interface Library {
|
||||||
@@ -45,6 +55,10 @@ export interface Game {
|
|||||||
wideCoverUrl: string | null
|
wideCoverUrl: string | null
|
||||||
gameFiles: GameFile[]
|
gameFiles: GameFile[]
|
||||||
platforms: GamePlatform[]
|
platforms: GamePlatform[]
|
||||||
|
userRating: number | null
|
||||||
|
aiDescription: string | null
|
||||||
|
extractedText: string | null
|
||||||
|
extractedTextTranslated: string | null
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface GameSeries {
|
export interface GameSeries {
|
||||||
@@ -65,6 +79,7 @@ export interface FileEntry {
|
|||||||
url: string | null
|
url: string | null
|
||||||
thumbnailUrl: string | null
|
thumbnailUrl: string | null
|
||||||
hasExtractedText?: boolean
|
hasExtractedText?: boolean
|
||||||
|
userRating?: number | null
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Movie {
|
export interface Movie {
|
||||||
@@ -80,6 +95,10 @@ export interface Movie {
|
|||||||
backdropUrl: string | null
|
backdropUrl: string | null
|
||||||
videoPath: string
|
videoPath: string
|
||||||
manuallyEdited?: boolean
|
manuallyEdited?: boolean
|
||||||
|
userRating: number | null
|
||||||
|
aiDescription: string | null
|
||||||
|
extractedText: string | null
|
||||||
|
extractedTextTranslated: string | null
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface TvSeries {
|
export interface TvSeries {
|
||||||
@@ -94,6 +113,10 @@ export interface TvSeries {
|
|||||||
backdropUrl: string | null
|
backdropUrl: string | null
|
||||||
seasonCount: number
|
seasonCount: number
|
||||||
manuallyEdited?: boolean
|
manuallyEdited?: boolean
|
||||||
|
userRating: number | null
|
||||||
|
aiDescription: string | null
|
||||||
|
extractedText: string | null
|
||||||
|
extractedTextTranslated: string | null
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface TvSeason {
|
export interface TvSeason {
|
||||||
@@ -117,6 +140,10 @@ export interface TvEpisode {
|
|||||||
rating: number | null
|
rating: number | null
|
||||||
thumbnailUrl: string | null
|
thumbnailUrl: string | null
|
||||||
videoPath: string
|
videoPath: string
|
||||||
|
userRating: number | null
|
||||||
|
aiDescription: string | null
|
||||||
|
extractedText: string | null
|
||||||
|
extractedTextTranslated: string | null
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface DirectoryListing {
|
export interface DirectoryListing {
|
||||||
|
|||||||
Reference in New Issue
Block a user