135 lines
5.1 KiB
TypeScript
135 lines
5.1 KiB
TypeScript
import { useMemo } from 'react'
|
|
import { useNavigate } from 'react-router-dom'
|
|
import { motion } from 'framer-motion'
|
|
import { Film, Tv, AlertCircle } from '../lib/icons'
|
|
import { useLibraryItems } from '../hooks/use-jellyfin'
|
|
import { getBestImage, getStoredServerUrl } from '../api/jellyfin'
|
|
import PosterCard from '../components/ui/PosterCard'
|
|
|
|
export default function DuplicatesPage() {
|
|
const navigate = useNavigate()
|
|
const serverUrl = getStoredServerUrl()
|
|
const { data, isLoading } = useLibraryItems(undefined, {
|
|
includeItemTypes: ['Movie', 'Series'],
|
|
sortBy: ['SortName'],
|
|
sortOrder: ['Ascending'],
|
|
limit: 5000,
|
|
})
|
|
const items = data?.Items || []
|
|
|
|
const duplicates = useMemo(() => {
|
|
// Group by TMDB ID first (most reliable)
|
|
const byTmdb = new Map<string, typeof items>()
|
|
const byNameYear = new Map<string, typeof items>()
|
|
|
|
for (const item of items) {
|
|
const tmdb = item.ProviderIds?.Tmdb
|
|
if (tmdb) {
|
|
const list = byTmdb.get(String(tmdb)) || []
|
|
list.push(item)
|
|
byTmdb.set(String(tmdb), list)
|
|
continue
|
|
}
|
|
// Fallback: normalized name + year + type
|
|
const key = `${(item.Name || '').toLowerCase().trim()}|${item.ProductionYear || 'none'}|${item.Type}`
|
|
const list = byNameYear.get(key) || []
|
|
list.push(item)
|
|
byNameYear.set(key, list)
|
|
}
|
|
|
|
const groups: { key: string; label: string; items: typeof items }[] = []
|
|
for (const [tmdb, list] of byTmdb) {
|
|
if (list.length >= 2) {
|
|
const first = list[0]
|
|
groups.push({
|
|
key: `tmdb-${tmdb}`,
|
|
label: `${first.Name} (${first.ProductionYear || '?'})`,
|
|
items: list,
|
|
})
|
|
}
|
|
}
|
|
for (const [, list] of byNameYear) {
|
|
if (list.length >= 2) {
|
|
const first = list[0]
|
|
groups.push({
|
|
key: `name-${first.Id}`,
|
|
label: `${first.Name} (${first.ProductionYear || '?'}) - no TMDB match`,
|
|
items: list,
|
|
})
|
|
}
|
|
}
|
|
|
|
return groups.sort((a, b) => a.label.localeCompare(b.label))
|
|
}, [items])
|
|
|
|
return (
|
|
<div className="px-7 pt-4 pb-12">
|
|
<header className="mb-7">
|
|
<div className="flex items-center gap-2 mb-1.5">
|
|
<span className="w-1 h-3.5 rounded-full bg-accent" />
|
|
<span className="text-[11px] font-semibold text-text-2 uppercase tracking-[0.14em]">Tools</span>
|
|
</div>
|
|
<h1 className="text-3xl md:text-4xl font-bold font-display text-text-1 tracking-tight">
|
|
Duplicate finder
|
|
</h1>
|
|
<p className="text-[13px] text-text-3 mt-1.5 max-w-xl">
|
|
Scans your library for items that appear more than once - either by shared TMDB ID or by matching name + year.
|
|
</p>
|
|
</header>
|
|
|
|
{isLoading && items.length === 0 && (
|
|
<div className="rounded-xl bg-elevated/30 border border-border p-8 text-center">
|
|
<p className="text-[13px] text-text-2 font-medium">Scanning your library...</p>
|
|
</div>
|
|
)}
|
|
|
|
{duplicates.length === 0 && !isLoading && (
|
|
<div className="rounded-xl bg-elevated/30 border border-border p-10 text-center max-w-xl">
|
|
<div className="relative w-14 h-14 mx-auto mb-4">
|
|
<div className="absolute inset-0 rounded-full bg-success/10 blur-xl" />
|
|
<div className="relative w-full h-full rounded-full bg-gradient-to-br from-elevated to-surface ring-1 ring-success/30 grid place-items-center">
|
|
<AlertCircle size={20} className="text-success" />
|
|
</div>
|
|
</div>
|
|
<p className="text-[15px] text-text-1 font-semibold tracking-tight mb-1">No duplicates found</p>
|
|
<p className="text-[12.5px] text-text-3 max-w-sm mx-auto leading-relaxed">
|
|
Every movie and series in your library has a unique identity. Nice and tidy.
|
|
</p>
|
|
</div>
|
|
)}
|
|
|
|
<div className="space-y-8">
|
|
{duplicates.map((group, gi) => (
|
|
<section key={group.key}>
|
|
<div className="flex items-center gap-2 mb-3">
|
|
{group.items[0]?.Type === 'Series' ? (
|
|
<Tv size={13} className="text-accent" />
|
|
) : (
|
|
<Film size={13} className="text-accent" />
|
|
)}
|
|
<h2 className="text-[14px] font-semibold text-text-1 tracking-tight">{group.label}</h2>
|
|
<span className="text-[11px] text-text-4 tabular-nums">{group.items.length} copies</span>
|
|
</div>
|
|
<div className="grid grid-cols-3 sm:grid-cols-4 md:grid-cols-5 xl:grid-cols-6 2xl:grid-cols-7 gap-4">
|
|
{group.items.map((item, i) => (
|
|
<motion.div
|
|
key={item.Id}
|
|
initial={{ opacity: 0, y: 8 }}
|
|
animate={{ opacity: 1, y: 0 }}
|
|
transition={{ duration: 0.3, delay: Math.min(i * 0.03, 0.3) }}
|
|
>
|
|
<PosterCard
|
|
item={item}
|
|
priority={gi < 3 && i < 6}
|
|
onClick={() => item.Id && navigate(`/item/${item.Id}`)}
|
|
/>
|
|
</motion.div>
|
|
))}
|
|
</div>
|
|
</section>
|
|
))}
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|