Files
jellybloom/src/pages/DuplicatesPage.tsx
T
2026-03-30 13:40:42 +03:00

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>
)
}