mirror of
https://github.com/truewhile/MeBox.git
synced 2026-10-12 16:26:36 +08:00
Fix series selection playback and query cleaning
This commit is contained in:
@@ -108,7 +108,7 @@ func CleanQuery(raw string) (title string, year int) {
|
|||||||
for _, t := range noiseTokens {
|
for _, t := range noiseTokens {
|
||||||
lower = strings.ReplaceAll(lower, t, " ")
|
lower = strings.ReplaceAll(lower, t, " ")
|
||||||
}
|
}
|
||||||
for _, sep := range []string{".", "_", "-", "[", "]", "(", ")", "×", "x"} {
|
for _, sep := range []string{".", "_", "-", "[", "]", "(", ")", "×"} {
|
||||||
lower = strings.ReplaceAll(lower, sep, " ")
|
lower = strings.ReplaceAll(lower, sep, " ")
|
||||||
}
|
}
|
||||||
// 拆分后丢掉过短(≤1)且全为 ASCII 数字 / 字母的"碎片",避免
|
// 拆分后丢掉过短(≤1)且全为 ASCII 数字 / 字母的"碎片",避免
|
||||||
|
|||||||
@@ -6,15 +6,19 @@ import { imageURL } from '../api/client'
|
|||||||
import type { Media } from '../types'
|
import type { Media } from '../types'
|
||||||
|
|
||||||
export const MediaCard = ({
|
export const MediaCard = ({
|
||||||
media, progress, count, rating, linkTo,
|
media, progress, count, rating, linkTo, onClick,
|
||||||
}: {
|
}: {
|
||||||
media: Media; progress?: number; count?: number; rating?: number; linkTo?: string
|
media: Media
|
||||||
|
progress?: number
|
||||||
|
count?: number
|
||||||
|
rating?: number
|
||||||
|
linkTo?: string
|
||||||
|
onClick?: () => void
|
||||||
}) => {
|
}) => {
|
||||||
const ref = useRef<HTMLDivElement>(null)
|
const ref = useRef<HTMLDivElement>(null)
|
||||||
const href = linkTo ?? `/media/${media.id}`
|
const href = linkTo ?? `/media/${media.id}`
|
||||||
|
|
||||||
return (
|
const card = (
|
||||||
<Link to={href} className="group block">
|
|
||||||
<motion.div
|
<motion.div
|
||||||
ref={ref}
|
ref={ref}
|
||||||
whileHover={{ scale: 1.04, y: -6 }}
|
whileHover={{ scale: 1.04, y: -6 }}
|
||||||
@@ -98,6 +102,19 @@ export const MediaCard = ({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
|
)
|
||||||
|
|
||||||
|
if (onClick) {
|
||||||
|
return (
|
||||||
|
<button type="button" onClick={onClick} className="group block w-full text-left">
|
||||||
|
{card}
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Link to={href} className="group block">
|
||||||
|
{card}
|
||||||
</Link>
|
</Link>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -228,7 +228,7 @@ export function HomePage() {
|
|||||||
key={s.rep.id}
|
key={s.rep.id}
|
||||||
media={s.rep}
|
media={s.rep}
|
||||||
count={s.count}
|
count={s.count}
|
||||||
linkTo={s.count > 1 ? `/library/${row.library.id}` : undefined}
|
linkTo={s.count > 1 ? `/library/${row.library.id}?series=${encodeURIComponent(s.key)}` : undefined}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@@ -300,4 +300,4 @@ function ContinueCard({ media, progress }: { media: Media; progress: number }) {
|
|||||||
</div>
|
</div>
|
||||||
</Link>
|
</Link>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,19 +1,19 @@
|
|||||||
import { useEffect, useMemo, useState } from 'react'
|
import { useEffect, useMemo, useState } from 'react'
|
||||||
import { Link, useParams, useNavigate } from 'react-router-dom'
|
import { Link, useParams, useSearchParams } from 'react-router-dom'
|
||||||
import { motion, AnimatePresence } from 'framer-motion'
|
import { motion, AnimatePresence } from 'framer-motion'
|
||||||
import toast from 'react-hot-toast'
|
import toast from 'react-hot-toast'
|
||||||
import { ArrowLeft, Play, Film, Layers } from 'lucide-react'
|
import { ArrowLeft, Play, Film } from 'lucide-react'
|
||||||
|
|
||||||
import { libraryAPI } from '../api/library'
|
import { libraryAPI } from '../api/library'
|
||||||
import type { Library, Media } from '../types'
|
import type { Library, Media } from '../types'
|
||||||
import { MediaCard } from '../components/MediaCard'
|
import { MediaCard } from '../components/MediaCard'
|
||||||
import { imageURL } from '../api/client'
|
import { imageURL } from '../api/client'
|
||||||
import { useAuthStore } from '../stores/auth'
|
import { useAuthStore } from '../stores/auth'
|
||||||
import { groupSeries, type SeriesCard } from '../utils/groupSeries'
|
import { getSeriesKey, groupSeries, type SeriesCard } from '../utils/groupSeries'
|
||||||
|
|
||||||
export function LibraryPage() {
|
export function LibraryPage() {
|
||||||
const { id = '' } = useParams()
|
const { id = '' } = useParams()
|
||||||
const navigate = useNavigate()
|
const [searchParams, setSearchParams] = useSearchParams()
|
||||||
const role = useAuthStore((s) => s.user?.role)
|
const role = useAuthStore((s) => s.user?.role)
|
||||||
|
|
||||||
const [library, setLibrary] = useState<Library | null>(null)
|
const [library, setLibrary] = useState<Library | null>(null)
|
||||||
@@ -37,10 +37,7 @@ export function LibraryPage() {
|
|||||||
// 选中的剧集:所有集按季分组
|
// 选中的剧集:所有集按季分组
|
||||||
const selectedEpisodes = useMemo(() => {
|
const selectedEpisodes = useMemo(() => {
|
||||||
if (!selectedSeries || items.length === 0) return []
|
if (!selectedSeries || items.length === 0) return []
|
||||||
const title = selectedSeries.rep.title.toLowerCase()
|
const eps = items.filter((m) => getSeriesKey(m) === selectedSeries.key)
|
||||||
const eps = items.filter(
|
|
||||||
(m) => (m.title ?? '').toLowerCase() === title || m.tmdb_id === selectedSeries.rep.tmdb_id,
|
|
||||||
)
|
|
||||||
// 按季分组,按集排序
|
// 按季分组,按集排序
|
||||||
const seasons = new Map<number, Media[]>()
|
const seasons = new Map<number, Media[]>()
|
||||||
for (const ep of eps) {
|
for (const ep of eps) {
|
||||||
@@ -78,6 +75,22 @@ export function LibraryPage() {
|
|||||||
.finally(() => setLoading(false))
|
.finally(() => setLoading(false))
|
||||||
}, [id, library])
|
}, [id, library])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isSeries) {
|
||||||
|
setSelectedSeries(null)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const key = searchParams.get('series')
|
||||||
|
if (!key) {
|
||||||
|
setSelectedSeries(null)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const next = seriesCards.find((card) => card.key === key)
|
||||||
|
if (next) setSelectedSeries(next)
|
||||||
|
}, [isSeries, searchParams, seriesCards])
|
||||||
|
|
||||||
const handleScan = async () => {
|
const handleScan = async () => {
|
||||||
setScanning(true)
|
setScanning(true)
|
||||||
try {
|
try {
|
||||||
@@ -100,9 +113,19 @@ export function LibraryPage() {
|
|||||||
|
|
||||||
const handleSeriesClick = (card: SeriesCard) => {
|
const handleSeriesClick = (card: SeriesCard) => {
|
||||||
setSelectedSeries(card)
|
setSelectedSeries(card)
|
||||||
|
const next = new URLSearchParams(searchParams)
|
||||||
|
next.set('series', card.key)
|
||||||
|
setSearchParams(next)
|
||||||
window.scrollTo({ top: 0, behavior: 'smooth' })
|
window.scrollTo({ top: 0, behavior: 'smooth' })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const clearSelectedSeries = () => {
|
||||||
|
setSelectedSeries(null)
|
||||||
|
const next = new URLSearchParams(searchParams)
|
||||||
|
next.delete('series')
|
||||||
|
setSearchParams(next)
|
||||||
|
}
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center justify-center py-32">
|
<div className="flex items-center justify-center py-32">
|
||||||
@@ -153,9 +176,7 @@ export function LibraryPage() {
|
|||||||
{isSeries && seriesCards.length > 0 && !selectedSeries && (
|
{isSeries && seriesCards.length > 0 && !selectedSeries && (
|
||||||
<div className="grid grid-cols-3 gap-4 sm:grid-cols-4 md:grid-cols-5 lg:grid-cols-6 xl:grid-cols-7 2xl:grid-cols-8">
|
<div className="grid grid-cols-3 gap-4 sm:grid-cols-4 md:grid-cols-5 lg:grid-cols-6 xl:grid-cols-7 2xl:grid-cols-8">
|
||||||
{seriesCards.map((s) => (
|
{seriesCards.map((s) => (
|
||||||
<button key={s.rep.id} onClick={() => handleSeriesClick(s)} className="text-left">
|
<MediaCard key={s.key} media={s.rep} count={s.count} onClick={() => handleSeriesClick(s)} />
|
||||||
<MediaCard media={s.rep} count={s.count} />
|
|
||||||
</button>
|
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -172,7 +193,7 @@ export function LibraryPage() {
|
|||||||
{/* 返回按钮 + 标题 */}
|
{/* 返回按钮 + 标题 */}
|
||||||
<div className="flex items-center gap-4">
|
<div className="flex items-center gap-4">
|
||||||
<button
|
<button
|
||||||
onClick={() => setSelectedSeries(null)}
|
onClick={clearSelectedSeries}
|
||||||
className="btn-ghost gap-2"
|
className="btn-ghost gap-2"
|
||||||
>
|
>
|
||||||
<ArrowLeft size={16} />
|
<ArrowLeft size={16} />
|
||||||
@@ -200,7 +221,12 @@ export function LibraryPage() {
|
|||||||
|
|
||||||
{/* 从第一集开始 */}
|
{/* 从第一集开始 */}
|
||||||
{(() => {
|
{(() => {
|
||||||
const firstEps = selectedEpisodes.flatMap((s) => s.episodes).filter((e) => e.episode_num > 0).sort((a, b) => (a.episode_num || 0) - (b.episode_num || 0))
|
const firstEps = selectedEpisodes
|
||||||
|
.flatMap((s) => s.episodes)
|
||||||
|
.sort((a, b) =>
|
||||||
|
(a.season_num || 0) - (b.season_num || 0)
|
||||||
|
|| (a.episode_num || 0) - (b.episode_num || 0),
|
||||||
|
)
|
||||||
const first = firstEps.length > 0 ? firstEps[0] : null
|
const first = firstEps.length > 0 ? firstEps[0] : null
|
||||||
return first ? (
|
return first ? (
|
||||||
<Link to={`/play/${first.id}`} className="btn-primary inline-flex">
|
<Link to={`/play/${first.id}`} className="btn-primary inline-flex">
|
||||||
|
|||||||
@@ -16,20 +16,23 @@ import type { Media } from '../types'
|
|||||||
*
|
*
|
||||||
* 同一组内取最早 created_at 的那条作为代表卡片,并带 count 表示集数。
|
* 同一组内取最早 created_at 的那条作为代表卡片,并带 count 表示集数。
|
||||||
*/
|
*/
|
||||||
export type SeriesCard = { rep: Media; count: number }
|
export type SeriesCard = { key: string; rep: Media; count: number }
|
||||||
|
|
||||||
|
export function getSeriesKey(media: Media): string {
|
||||||
|
if (media.tmdb_id && media.tmdb_id > 0) return `tmdb:${media.tmdb_id}`
|
||||||
|
if (media.bangumi_id && media.bangumi_id > 0) return `bgm:${media.bangumi_id}`
|
||||||
|
if (media.series_id) return `series:${media.series_id}`
|
||||||
|
return `lib:${media.library_id}|${(media.title ?? '').toLowerCase().trim()}`
|
||||||
|
}
|
||||||
|
|
||||||
export function groupSeries(items: Media[]): SeriesCard[] {
|
export function groupSeries(items: Media[]): SeriesCard[] {
|
||||||
const groups = new Map<string, SeriesCard>()
|
const groups = new Map<string, SeriesCard>()
|
||||||
for (const m of items) {
|
for (const m of items) {
|
||||||
let key = ''
|
const key = getSeriesKey(m)
|
||||||
if (m.tmdb_id && m.tmdb_id > 0) key = `tmdb:${m.tmdb_id}`
|
|
||||||
else if (m.bangumi_id && m.bangumi_id > 0) key = `bgm:${m.bangumi_id}`
|
|
||||||
else if (m.series_id) key = `series:${m.series_id}`
|
|
||||||
else key = `lib:${m.library_id}|${(m.title ?? '').toLowerCase().trim()}`
|
|
||||||
|
|
||||||
const g = groups.get(key)
|
const g = groups.get(key)
|
||||||
if (!g) {
|
if (!g) {
|
||||||
groups.set(key, { rep: m, count: 1 })
|
groups.set(key, { key, rep: m, count: 1 })
|
||||||
} else {
|
} else {
|
||||||
g.count += 1
|
g.count += 1
|
||||||
const repHasPoster = !!g.rep.poster_url
|
const repHasPoster = !!g.rep.poster_url
|
||||||
|
|||||||
Reference in New Issue
Block a user