mirror of
https://github.com/truewhile/MeBox.git
synced 2026-10-01 20:16:36 +08:00
fix media workflows and download organization
This commit is contained in:
+28
-4
@@ -17,6 +17,11 @@ export interface DiscoverItem extends Partial<Media> {
|
||||
rating?: number
|
||||
subscribe_keyword?: string
|
||||
subscribe_aliases?: string[]
|
||||
total_episodes?: number
|
||||
downloaded_episodes?: number
|
||||
local_media_count?: number
|
||||
missing_episodes?: number[]
|
||||
in_library?: boolean
|
||||
}
|
||||
|
||||
export interface DiscoverSection {
|
||||
@@ -25,6 +30,16 @@ export interface DiscoverSection {
|
||||
provider?: string
|
||||
}
|
||||
|
||||
export interface DiscoverFeedMeta {
|
||||
page: number
|
||||
has_next: boolean
|
||||
}
|
||||
|
||||
export interface DiscoverFeedResult {
|
||||
items: Record<string, DiscoverItem[]>
|
||||
meta: Record<string, DiscoverFeedMeta>
|
||||
}
|
||||
|
||||
// 后端在 TMDb 不可达 / API key 缺失时统一返回 { items: [], error: "..." }
|
||||
// 200 状态码——前端必须能区分这两种情况,不能简单用 items.length === 0
|
||||
// 推断"未配置 API key"。
|
||||
@@ -46,10 +61,19 @@ export const discoverAPI = {
|
||||
})),
|
||||
sections: () =>
|
||||
api.get<{ sections: DiscoverSection[] }>('/discover/sections').then((r) => r.data.sections),
|
||||
feed: (sectionKeys: string[]) =>
|
||||
feed: (sectionKeys: string[], page = 1): Promise<DiscoverFeedResult> =>
|
||||
api
|
||||
.get<Record<string, DiscoverItem[] | null>>('/discover/feed', {
|
||||
params: { sections: sectionKeys.join(',') },
|
||||
.get<Record<string, DiscoverItem[] | DiscoverFeedMeta | Record<string, DiscoverFeedMeta> | null>>('/discover/feed', {
|
||||
params: { sections: sectionKeys.join(','), page },
|
||||
})
|
||||
.then((r) => r.data),
|
||||
.then((r) => {
|
||||
const raw = r.data
|
||||
const meta = ((raw._meta as Record<string, DiscoverFeedMeta> | undefined) ?? {})
|
||||
const items: Record<string, DiscoverItem[]> = {}
|
||||
for (const key of sectionKeys) {
|
||||
const row = raw[key]
|
||||
items[key] = Array.isArray(row) ? row : []
|
||||
}
|
||||
return { items, meta }
|
||||
}),
|
||||
}
|
||||
|
||||
@@ -58,21 +58,25 @@ export function ManualScrapeSearchControls({
|
||||
onEpisodeArtworkChange,
|
||||
}: ManualScrapeSearchControlsProps) {
|
||||
return (
|
||||
<div className="flex flex-col gap-3 border-b border-sand-200 p-5 lg:flex-row">
|
||||
<ProviderSelector selectedProviders={selectedProviders} onProviderChange={onProviderChange} />
|
||||
<div className="grid gap-4 border-b border-sand-200 bg-sand-50/40 p-5">
|
||||
<ManualScrapeQueryBar
|
||||
query={query}
|
||||
searching={searching}
|
||||
onQueryChange={onQueryChange}
|
||||
onSearch={onSearch}
|
||||
/>
|
||||
{showEpisodeArtworkToggle && (
|
||||
<EpisodeArtworkToggle
|
||||
checked={includeEpisodeArtwork}
|
||||
onChange={onEpisodeArtworkChange}
|
||||
title="关闭后仍写入每集简介、评分和时长,只跳过每集图片"
|
||||
/>
|
||||
)}
|
||||
<div className="grid gap-3 xl:grid-cols-[minmax(0,1fr)_auto] xl:items-end">
|
||||
<ProviderSelector selectedProviders={selectedProviders} onProviderChange={onProviderChange} />
|
||||
{showEpisodeArtworkToggle && (
|
||||
<div className="flex justify-start xl:justify-end">
|
||||
<EpisodeArtworkToggle
|
||||
checked={includeEpisodeArtwork}
|
||||
onChange={onEpisodeArtworkChange}
|
||||
title="关闭后仍写入每集简介、评分和时长,只跳过每集图片"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -85,11 +89,12 @@ function ProviderSelector({
|
||||
onProviderChange: (value: string[] | ((current: string[]) => string[])) => void
|
||||
}) {
|
||||
return (
|
||||
<div className="flex min-w-0 flex-col gap-2 lg:max-w-md">
|
||||
<div className="flex min-w-0 flex-col gap-2">
|
||||
<span className="text-xs font-bold text-sand-500">刮削源</span>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<div className="grid grid-cols-2 gap-2 sm:grid-cols-3 lg:flex lg:flex-wrap">
|
||||
<button
|
||||
type="button"
|
||||
aria-pressed={selectedProviders.length === 0}
|
||||
onClick={() => onProviderChange([])}
|
||||
className={providerButtonClass(selectedProviders.length === 0)}
|
||||
>
|
||||
@@ -102,6 +107,7 @@ function ProviderSelector({
|
||||
<button
|
||||
key={item.value}
|
||||
type="button"
|
||||
aria-pressed={active}
|
||||
onClick={() => onProviderChange((current) => toggleProvider(current, item.value))}
|
||||
className={providerButtonClass(active)}
|
||||
>
|
||||
@@ -127,22 +133,22 @@ function ManualScrapeQueryBar({
|
||||
onSearch: () => void
|
||||
}) {
|
||||
return (
|
||||
<>
|
||||
<div className="relative flex-1">
|
||||
<div className="grid gap-2 sm:grid-cols-[minmax(0,1fr)_7.5rem]">
|
||||
<div className="relative min-w-0">
|
||||
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-sand-500" />
|
||||
<input
|
||||
value={query}
|
||||
onChange={(event) => onQueryChange(event.target.value)}
|
||||
onKeyDown={(event) => { if (event.key === 'Enter') onSearch() }}
|
||||
className="h-11 w-full rounded-xl border border-sand-200 bg-white pl-9 pr-3 text-sm font-semibold text-ink-600 outline-none focus:border-brand-300"
|
||||
className="h-11 w-full rounded-xl border border-sand-200 bg-white pl-9 pr-3 text-sm font-semibold text-ink-600 outline-none transition focus:border-brand-300 focus:ring-2 focus:ring-brand-100"
|
||||
placeholder="输入标题或 TMDb / 豆瓣 / Bangumi / TheTVDB ID"
|
||||
/>
|
||||
</div>
|
||||
<button onClick={onSearch} disabled={searching} className="btn-primary h-11 px-5">
|
||||
<button type="button" onClick={onSearch} disabled={searching} className="btn-primary h-11 justify-center px-5">
|
||||
{searching ? <LoaderCircle size={16} className="animate-spin" /> : <Sparkles size={16} />}
|
||||
搜索
|
||||
</button>
|
||||
</>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -193,7 +199,7 @@ function ManualScrapeCandidateRow({
|
||||
onApply: (item: ManualScrapeCandidate) => void
|
||||
}) {
|
||||
return (
|
||||
<div className="flex gap-4 rounded-xl border border-sand-200 bg-white p-3 shadow-sm">
|
||||
<div className="flex flex-col gap-4 rounded-xl border border-sand-200 bg-white p-3 shadow-sm sm:flex-row">
|
||||
<div className="h-28 w-20 shrink-0 overflow-hidden rounded-lg bg-sand-100">
|
||||
{item.poster_url ? (
|
||||
<img src={imageURL(item.poster_url)} alt={item.title} className="h-full w-full object-cover" referrerPolicy="no-referrer" />
|
||||
@@ -211,7 +217,7 @@ function ManualScrapeCandidateRow({
|
||||
<p className="mt-1 line-clamp-2 text-xs leading-relaxed text-ink-50">{item.overview || '暂无简介'}</p>
|
||||
<p className="mt-2 text-[11px] font-semibold text-sand-500">{candidateIDText(item)}</p>
|
||||
</div>
|
||||
<button onClick={() => onApply(item)} disabled={disabled} className="btn-outline h-10 shrink-0 self-center px-3 text-xs">
|
||||
<button onClick={() => onApply(item)} disabled={disabled} className="btn-outline h-10 w-full shrink-0 justify-center px-3 text-xs sm:w-auto sm:self-center">
|
||||
{applying ? <LoaderCircle size={14} className="animate-spin" /> : <Check size={14} />}
|
||||
应用匹配
|
||||
</button>
|
||||
@@ -221,7 +227,7 @@ function ManualScrapeCandidateRow({
|
||||
|
||||
function providerButtonClass(active: boolean): string {
|
||||
return (
|
||||
'inline-flex h-11 items-center gap-1.5 rounded-xl border px-3 text-xs font-bold transition ' +
|
||||
'inline-flex h-9 min-w-0 shrink-0 items-center justify-center gap-1.5 rounded-lg border px-3 text-xs font-bold transition ' +
|
||||
(active
|
||||
? 'border-brand-300 bg-brand-50 text-brand-700'
|
||||
: 'border-sand-200 bg-white text-sand-600 hover:border-brand-200 hover:text-brand-600')
|
||||
|
||||
@@ -21,6 +21,7 @@ export const MediaCard = ({
|
||||
const [posterFit, setPosterFit] = useState<'cover' | 'contain'>('cover')
|
||||
const posterSrc = imageURL(media.poster_url, media.updated_at)
|
||||
const displayRating = rating ?? media.rating
|
||||
const versionCount = media.versions?.length ?? 0
|
||||
|
||||
useEffect(() => {
|
||||
setPosterFit('cover')
|
||||
@@ -78,6 +79,13 @@ export const MediaCard = ({
|
||||
</span>
|
||||
)}
|
||||
|
||||
{count === undefined && versionCount > 1 && (
|
||||
<span className="absolute right-3 top-3 inline-flex items-center gap-1 rounded-xl border border-white/15 bg-[#111827]/90 px-2 py-1 text-[10px] font-bold text-white shadow-sm">
|
||||
<Layers size={10} className="text-[#c9954a]" />
|
||||
<span>{versionCount} 版本</span>
|
||||
</span>
|
||||
)}
|
||||
|
||||
{/* Rating Badge */}
|
||||
{displayRating > 0 && (
|
||||
<span className="absolute left-3 top-3 inline-flex items-center gap-0.5 rounded-xl border border-white/15 bg-[#111827]/90 px-2 py-1 text-[10px] font-bold text-[#c9954a] shadow-sm">
|
||||
|
||||
@@ -1,99 +1,69 @@
|
||||
import { useState } from 'react'
|
||||
import { Rss } from 'lucide-react'
|
||||
import toast from 'react-hot-toast'
|
||||
|
||||
import type { ExternalMediaResult } from '../api/ai'
|
||||
import type { DiscoverItem } from '../api/discover'
|
||||
import { imageURL } from '../api/client'
|
||||
import { buildSiteSearchFeedURL, buildSubscriptionAliases, subscriptionsAPI } from '../api/subscriptions'
|
||||
import { DiscoverDetailModal } from './DiscoverDetailModal'
|
||||
|
||||
type AIAssistantExternalResultsProps = {
|
||||
items: ExternalMediaResult[]
|
||||
}
|
||||
|
||||
export function AIAssistantExternalResults({ items }: AIAssistantExternalResultsProps) {
|
||||
const [subscribing, setSubscribing] = useState('')
|
||||
const [activeItem, setActiveItem] = useState<ExternalMediaResult | null>(null)
|
||||
|
||||
if (items.length === 0) return null
|
||||
|
||||
return (
|
||||
<div className="grid gap-3 md:grid-cols-2 xl:grid-cols-3">
|
||||
{items.map((item) => {
|
||||
const keyword = item.subscribe_keyword || item.title
|
||||
const key = `${item.source}:${keyword}`
|
||||
return (
|
||||
<article key={key} className="rounded-2xl border border-gray-200 bg-gray-50 p-3">
|
||||
<div className="flex gap-3">
|
||||
<div className="h-24 w-16 shrink-0 overflow-hidden rounded-xl bg-white">
|
||||
{item.poster_url ? (
|
||||
<img
|
||||
src={imageURL(item.poster_url)}
|
||||
alt={item.title}
|
||||
className="h-full w-full object-cover"
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="mb-1 flex flex-wrap gap-2 text-[10px] uppercase text-brand-500">
|
||||
<span>{item.source}</span>
|
||||
{item.media_type && <span>{item.media_type}</span>}
|
||||
{item.year ? <span>{item.year}</span> : null}
|
||||
<>
|
||||
<div className="grid gap-3 md:grid-cols-2 xl:grid-cols-3">
|
||||
{items.map((item) => {
|
||||
const keyword = item.subscribe_keyword || item.title
|
||||
const key = `${item.source}:${keyword}`
|
||||
return (
|
||||
<article
|
||||
key={key}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onClick={() => setActiveItem(item)}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === 'Enter' || event.key === ' ') setActiveItem(item)
|
||||
}}
|
||||
className="cursor-pointer rounded-2xl border border-gray-200 bg-gray-50 p-3 transition hover:-translate-y-0.5 hover:border-primary-300"
|
||||
>
|
||||
<div className="flex gap-3">
|
||||
<div className="h-24 w-16 shrink-0 overflow-hidden rounded-xl bg-white">
|
||||
{item.poster_url ? (
|
||||
<img
|
||||
src={imageURL(item.poster_url)}
|
||||
alt={item.title}
|
||||
className="h-full w-full object-cover"
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="mb-1 flex flex-wrap gap-2 text-[10px] uppercase text-brand-500">
|
||||
<span>{item.source}</span>
|
||||
{item.media_type && <span>{item.media_type}</span>}
|
||||
{item.year ? <span>{item.year}</span> : null}
|
||||
</div>
|
||||
<h3 className="truncate font-semibold text-ink-600">{item.title}</h3>
|
||||
<p className="mt-1 line-clamp-2 text-xs text-ink-50">
|
||||
{item.overview || `订阅关键词:${keyword}`}
|
||||
</p>
|
||||
<p className="mt-2 text-xs font-semibold text-brand-500">详情 / 订阅设置</p>
|
||||
</div>
|
||||
<h3 className="truncate font-semibold text-ink-600">{item.title}</h3>
|
||||
<p className="mt-1 line-clamp-2 text-xs text-ink-50">
|
||||
{item.overview || `订阅关键词:${keyword}`}
|
||||
</p>
|
||||
<button
|
||||
disabled={subscribing === key}
|
||||
onClick={() => subscribeExternalItem(item, key, keyword, setSubscribing)}
|
||||
className="mt-2 rounded-lg border border-primary-400/40 px-2 py-1 text-xs text-brand-500 hover:bg-primary-400/10 disabled:opacity-50"
|
||||
>
|
||||
<Rss size={12} className="mr-1 inline" />
|
||||
{subscribing === key ? '订阅中…' : '订阅并搜索 PT'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</article>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
{activeItem && (
|
||||
<DiscoverDetailModal
|
||||
item={activeItem as unknown as DiscoverItem}
|
||||
onClose={() => setActiveItem(null)}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
async function subscribeExternalItem(
|
||||
item: ExternalMediaResult,
|
||||
key: string,
|
||||
keyword: string,
|
||||
setSubscribing: (key: string) => void,
|
||||
) {
|
||||
setSubscribing(key)
|
||||
try {
|
||||
const feed = buildSiteSearchFeedURL(keyword, item.source, buildSubscriptionAliases(item))
|
||||
const sub = await subscriptionsAPI.create({
|
||||
name: `${item.title} 自动订阅`,
|
||||
feed_url: feed,
|
||||
filter: keyword,
|
||||
media_type: item.media_type,
|
||||
source: item.source,
|
||||
poster_url: item.poster_url,
|
||||
backdrop_url: item.backdrop_url,
|
||||
overview: item.overview,
|
||||
original_name: item.original_name,
|
||||
year: item.year,
|
||||
total_episodes: item.total_episodes,
|
||||
enabled: true,
|
||||
})
|
||||
const run = await subscriptionsAPI.runNow(sub.id)
|
||||
toast.success(
|
||||
run.queued > 0
|
||||
? `已订阅并加入 ${run.queued} 个下载`
|
||||
: '已订阅,暂未在 PT 站点找到可下载资源',
|
||||
)
|
||||
} catch (err: unknown) {
|
||||
const msg =
|
||||
(err as { response?: { data?: { error?: string } } })?.response?.data?.error ??
|
||||
'订阅失败'
|
||||
toast.error(msg)
|
||||
} finally {
|
||||
setSubscribing('')
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,8 +6,8 @@ import { CloudScanPanel } from './CloudScanPanel'
|
||||
import { useCloudBrowser } from './useCloudBrowser'
|
||||
|
||||
// Lists cloud directories and imports a file as a 302-backed media.
|
||||
export function CloudBrowser({ type }: { type: StorageType }) {
|
||||
const browser = useCloudBrowser(type)
|
||||
export function CloudBrowser({ type, enabled = true }: { type: StorageType; enabled?: boolean }) {
|
||||
const browser = useCloudBrowser(type, enabled)
|
||||
|
||||
return (
|
||||
<div className="mt-2 rounded-lg border border-[var(--app-border)] bg-[var(--app-panel)] p-3">
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { Info } from 'lucide-react'
|
||||
import { ChevronLeft, ChevronRight, Info } from 'lucide-react'
|
||||
|
||||
import type { DiscoverItem } from '../api/discover'
|
||||
import { imageURL } from '../api/client'
|
||||
@@ -8,19 +8,50 @@ import { discoverItemSource } from './discoverPageModel'
|
||||
export function ContentRow({
|
||||
title,
|
||||
items,
|
||||
page = 1,
|
||||
canNext = false,
|
||||
imageVersion,
|
||||
refreshImageVersion,
|
||||
onPageChange,
|
||||
onSelect,
|
||||
}: {
|
||||
title: string
|
||||
items: DiscoverItem[]
|
||||
page?: number
|
||||
canNext?: boolean
|
||||
imageVersion?: string
|
||||
refreshImageVersion?: string
|
||||
onPageChange?: (delta: number) => void
|
||||
onSelect: (item: DiscoverItem) => void
|
||||
}) {
|
||||
return (
|
||||
<section className="space-y-4">
|
||||
<h2 className="pl-1 font-display text-2xl font-semibold text-ink-600">{title}</h2>
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<h2 className="pl-1 font-display text-2xl font-semibold text-ink-600">{title}</h2>
|
||||
{onPageChange && (
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
aria-label={`${title} 上一页`}
|
||||
disabled={page <= 1}
|
||||
onClick={() => onPageChange(-1)}
|
||||
className="inline-flex h-8 w-8 items-center justify-center rounded-lg border border-gray-200 bg-white text-ink-100 transition hover:border-primary-300 hover:text-brand-500 disabled:cursor-not-allowed disabled:opacity-40"
|
||||
>
|
||||
<ChevronLeft size={16} />
|
||||
</button>
|
||||
<span className="min-w-10 text-center text-xs font-semibold text-sand-500">第 {page} 页</span>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={`${title} 下一页`}
|
||||
disabled={!canNext}
|
||||
onClick={() => onPageChange(1)}
|
||||
className="inline-flex h-8 w-8 items-center justify-center rounded-lg border border-gray-200 bg-white text-ink-100 transition hover:border-primary-300 hover:text-brand-500 disabled:cursor-not-allowed disabled:opacity-40"
|
||||
>
|
||||
<ChevronRight size={16} />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="grid grid-cols-3 gap-4 sm:grid-cols-4 md:grid-cols-5 lg:grid-cols-7 xl:grid-cols-8">
|
||||
{items.map((item, index) => (
|
||||
<DiscoverCard
|
||||
|
||||
@@ -15,6 +15,8 @@ export function DiscoverPage() {
|
||||
const [sections, setSections] = useState<DiscoverSection[]>([])
|
||||
const [selected, setSelected] = useState<string[]>([])
|
||||
const [rows, setRows] = useState<Record<string, DiscoverItem[]>>({})
|
||||
const [rowPages, setRowPages] = useState<Record<string, number>>({})
|
||||
const [rowCanNext, setRowCanNext] = useState<Record<string, boolean>>({})
|
||||
const [rowLoading, setRowLoading] = useState<Record<string, boolean>>({})
|
||||
const [rowErrors, setRowErrors] = useState<Record<string, string>>({})
|
||||
const [sectionsReady, setSectionsReady] = useState(false)
|
||||
@@ -35,7 +37,9 @@ export function DiscoverPage() {
|
||||
const saved = readSavedSections(items)
|
||||
const available = new Set(items.map((item) => item.key))
|
||||
const fallback = defaultSections.filter((key) => available.has(key))
|
||||
setSelected(saved.length > 0 ? saved : fallback)
|
||||
const nextSelected = saved.length > 0 ? saved : fallback
|
||||
setSelected(nextSelected)
|
||||
setRowPages(Object.fromEntries(nextSelected.map((key) => [key, 1])))
|
||||
setSectionsReady(true)
|
||||
})
|
||||
.catch(() => {
|
||||
@@ -60,6 +64,7 @@ export function DiscoverPage() {
|
||||
if (selected.length === 0) {
|
||||
setRows({})
|
||||
setRowLoading({})
|
||||
setRowCanNext({})
|
||||
setRowErrors({})
|
||||
setLoading(false)
|
||||
return
|
||||
@@ -83,16 +88,19 @@ export function DiscoverPage() {
|
||||
if (!cancelled && pending <= 0) setLoading(false)
|
||||
}
|
||||
for (const key of selected) {
|
||||
const page = rowPages[key] ?? 1
|
||||
discoverAPI
|
||||
.feed([key])
|
||||
.feed([key], page)
|
||||
.then((feed) => {
|
||||
if (cancelled) return
|
||||
setRows((current) => ({ ...current, [key]: feed[key] ?? [] }))
|
||||
setRows((current) => ({ ...current, [key]: feed.items[key] ?? [] }))
|
||||
setRowCanNext((current) => ({ ...current, [key]: Boolean(feed.meta[key]?.has_next) }))
|
||||
})
|
||||
.catch((err) => {
|
||||
if (cancelled) return
|
||||
const message = err instanceof Error ? err.message : String(err)
|
||||
setRows((current) => ({ ...current, [key]: [] }))
|
||||
setRowCanNext((current) => ({ ...current, [key]: false }))
|
||||
setRowErrors((current) => ({ ...current, [key]: message }))
|
||||
})
|
||||
.finally(() => {
|
||||
@@ -105,7 +113,7 @@ export function DiscoverPage() {
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [sections, sectionsReady, selected, reloadSeq])
|
||||
}, [sections, sectionsReady, selected, rowPages, reloadSeq])
|
||||
|
||||
const sectionMap = useMemo(
|
||||
() => new Map(sections.map((section) => [section.key, section])),
|
||||
@@ -121,6 +129,15 @@ export function DiscoverPage() {
|
||||
}
|
||||
return [...current, key]
|
||||
})
|
||||
setRowPages((current) => ({ ...current, [key]: current[key] ?? 1 }))
|
||||
}
|
||||
|
||||
const changeDiscoverPage = (key: string, delta: number) => {
|
||||
setRowPages((current) => {
|
||||
const nextPage = Math.max(1, (current[key] ?? 1) + delta)
|
||||
if (nextPage === (current[key] ?? 1)) return current
|
||||
return { ...current, [key]: nextPage }
|
||||
})
|
||||
}
|
||||
|
||||
const refreshDiscover = () => {
|
||||
@@ -153,11 +170,14 @@ export function DiscoverPage() {
|
||||
rows={rows}
|
||||
rowLoading={rowLoading}
|
||||
rowErrors={rowErrors}
|
||||
rowPages={rowPages}
|
||||
rowCanNext={rowCanNext}
|
||||
loading={loading}
|
||||
hasContent={hasContent}
|
||||
imageVersion={imageVersion}
|
||||
refreshImageVersion={refreshImageVersion}
|
||||
sectionLabel={sectionLabel}
|
||||
onPageChange={changeDiscoverPage}
|
||||
onSelect={setActiveItem}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -84,22 +84,28 @@ export function DiscoverResults({
|
||||
rows,
|
||||
rowLoading,
|
||||
rowErrors,
|
||||
rowPages,
|
||||
rowCanNext,
|
||||
loading,
|
||||
hasContent,
|
||||
imageVersion,
|
||||
refreshImageVersion,
|
||||
sectionLabel,
|
||||
onPageChange,
|
||||
onSelect,
|
||||
}: {
|
||||
selected: string[]
|
||||
rows: Record<string, DiscoverItem[]>
|
||||
rowLoading: Record<string, boolean>
|
||||
rowErrors: Record<string, string>
|
||||
rowPages: Record<string, number>
|
||||
rowCanNext: Record<string, boolean>
|
||||
loading: boolean
|
||||
hasContent: boolean
|
||||
imageVersion: string
|
||||
refreshImageVersion?: string
|
||||
sectionLabel: SectionLabel
|
||||
onPageChange: (key: string, delta: number) => void
|
||||
onSelect: (item: DiscoverItem) => void
|
||||
}) {
|
||||
const hasRowErrors = Object.keys(rowErrors).length > 0
|
||||
@@ -119,8 +125,11 @@ export function DiscoverResults({
|
||||
key={key}
|
||||
title={sectionLabel(key)}
|
||||
items={items}
|
||||
page={rowPages[key] ?? 1}
|
||||
canNext={Boolean(rowCanNext[key])}
|
||||
imageVersion={imageVersion}
|
||||
refreshImageVersion={refreshImageVersion}
|
||||
onPageChange={(delta) => onPageChange(key, delta)}
|
||||
onSelect={onSelect}
|
||||
/>
|
||||
)
|
||||
|
||||
@@ -1,17 +1,22 @@
|
||||
import { useState } from 'react'
|
||||
import { CheckCircle2, Info, Rss } from 'lucide-react'
|
||||
import toast from 'react-hot-toast'
|
||||
|
||||
import type { DiscoverItem } from '../api/discover'
|
||||
import type { ExternalMediaResult } from '../api/ai'
|
||||
import { imageURL } from '../api/client'
|
||||
import { subscriptionsAPI } from '../api/subscriptions'
|
||||
import { DiscoverSubscriptionRules } from './DiscoverDetailModalSections'
|
||||
import {
|
||||
apiErrorMessage,
|
||||
buildDiscoverSubscriptionInput,
|
||||
initialDiscoverSubscriptionForm,
|
||||
} from './discoverDetailModalModel'
|
||||
|
||||
export function ExternalResults({
|
||||
items,
|
||||
busyKey,
|
||||
onSubscribe,
|
||||
}: {
|
||||
items: ExternalMediaResult[]
|
||||
busyKey: string
|
||||
onSubscribe: (item: ExternalMediaResult) => Promise<void>
|
||||
}) {
|
||||
const [detail, setDetail] = useState<ExternalMediaResult | null>(null)
|
||||
return (
|
||||
@@ -72,13 +77,12 @@ export function ExternalResults({
|
||||
<button
|
||||
onClick={(event) => {
|
||||
event.stopPropagation()
|
||||
onSubscribe(item)
|
||||
setDetail(item)
|
||||
}}
|
||||
disabled={busyKey === key}
|
||||
className="mt-3 rounded-lg border border-primary-400/40 px-2 py-1 text-xs text-brand-500 hover:bg-primary-400/10 disabled:opacity-50"
|
||||
>
|
||||
<Rss size={12} className="mr-1 inline" />
|
||||
{busyKey === key ? '订阅中…' : '订阅并搜索 PT'}
|
||||
订阅规则
|
||||
</button>
|
||||
</div>
|
||||
</article>
|
||||
@@ -88,9 +92,7 @@ export function ExternalResults({
|
||||
{detail && (
|
||||
<ExternalDetailModal
|
||||
item={detail}
|
||||
busy={busyKey === `${detail.source}:${detail.subscribe_keyword || detail.title}`}
|
||||
onClose={() => setDetail(null)}
|
||||
onSubscribe={onSubscribe}
|
||||
/>
|
||||
)}
|
||||
</section>
|
||||
@@ -99,19 +101,37 @@ export function ExternalResults({
|
||||
|
||||
function ExternalDetailModal({
|
||||
item,
|
||||
busy,
|
||||
onClose,
|
||||
onSubscribe,
|
||||
}: {
|
||||
item: ExternalMediaResult
|
||||
busy: boolean
|
||||
onClose: () => void
|
||||
onSubscribe: (item: ExternalMediaResult) => Promise<void>
|
||||
}) {
|
||||
const missing = item.missing_episodes ?? []
|
||||
const discoverItem = item as unknown as DiscoverItem
|
||||
const [form, setForm] = useState(() => initialDiscoverSubscriptionForm(discoverItem))
|
||||
const [formBusy, setFormBusy] = useState(false)
|
||||
const submit = async () => {
|
||||
setFormBusy(true)
|
||||
try {
|
||||
const sub = await subscriptionsAPI.create(
|
||||
buildDiscoverSubscriptionInput(discoverItem, form, item.source || 'tmdb'),
|
||||
)
|
||||
if (form.run_now) {
|
||||
const run = await subscriptionsAPI.runNow(sub.id)
|
||||
toast.success(run.queued > 0 ? `已订阅并加入 ${run.queued} 个下载` : '已订阅,暂未命中可下载资源')
|
||||
} else {
|
||||
toast.success('已创建订阅')
|
||||
}
|
||||
onClose()
|
||||
} catch (err) {
|
||||
toast.error(apiErrorMessage(err, '订阅失败'))
|
||||
} finally {
|
||||
setFormBusy(false)
|
||||
}
|
||||
}
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/35 p-4 backdrop-blur-sm" onClick={onClose}>
|
||||
<div className="max-h-[88vh] w-full max-w-3xl overflow-hidden rounded-3xl bg-white shadow-2xl" onClick={(event) => event.stopPropagation()}>
|
||||
<div className="max-h-[88vh] w-full max-w-3xl overflow-y-auto rounded-3xl bg-white shadow-2xl" onClick={(event) => event.stopPropagation()}>
|
||||
<div className="grid gap-0 md:grid-cols-[220px,1fr]">
|
||||
<div className="min-h-72 bg-gray-100">
|
||||
{item.poster_url ? (
|
||||
@@ -158,18 +178,15 @@ function ExternalDetailModal({
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex justify-end gap-2 pt-2">
|
||||
<DiscoverSubscriptionRules
|
||||
form={form}
|
||||
busy={formBusy}
|
||||
onChange={(patch) => setForm((current) => ({ ...current, ...patch }))}
|
||||
onSubmit={submit}
|
||||
/>
|
||||
|
||||
<div className="flex justify-end pt-2">
|
||||
<button onClick={onClose} className="rounded-xl border border-gray-200 px-4 py-2 text-sm text-ink-100 hover:bg-gray-50">关闭</button>
|
||||
<button
|
||||
disabled={busy}
|
||||
onClick={async () => {
|
||||
await onSubscribe(item)
|
||||
onClose()
|
||||
}}
|
||||
className="neon-button"
|
||||
>
|
||||
<Rss size={14} /> {busy ? '订阅中…' : item.in_library ? '补全缺失集' : '订阅全集'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -47,8 +47,6 @@ export function SearchPage() {
|
||||
{search.externalItems.length > 0 && (
|
||||
<ExternalResults
|
||||
items={search.externalItems}
|
||||
busyKey={search.subscribing}
|
||||
onSubscribe={search.onSubscribe}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -28,6 +28,7 @@ export function StorageForm({ type }: { type: StorageType }) {
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [testing, setTesting] = useState(false)
|
||||
const [loggingOut, setLoggingOut] = useState(false)
|
||||
const [browserVersion, setBrowserVersion] = useState(0)
|
||||
|
||||
const refresh = async () => {
|
||||
setLoading(true)
|
||||
@@ -66,6 +67,7 @@ export function StorageForm({ type }: { type: StorageType }) {
|
||||
await storageAPI.save(type, config, enabled)
|
||||
toast.success('已保存')
|
||||
await refresh()
|
||||
setBrowserVersion((version) => version + 1)
|
||||
} catch (err: unknown) {
|
||||
const msg =
|
||||
(err as { response?: { data?: { error?: string } } })?.response?.data?.error ??
|
||||
@@ -80,8 +82,19 @@ export function StorageForm({ type }: { type: StorageType }) {
|
||||
setTesting(true)
|
||||
try {
|
||||
const r = await storageAPI.test(type, config)
|
||||
if (r.ok) toast.success('连接成功')
|
||||
else toast.error(r.error ?? '连接失败')
|
||||
if (!r.ok) {
|
||||
toast.error(r.error ?? '连接失败')
|
||||
return
|
||||
}
|
||||
if (isCloud(type)) {
|
||||
await storageAPI.save(type, config, true)
|
||||
setEnabled(true)
|
||||
toast.success('连接成功,已保存并启用')
|
||||
await refresh()
|
||||
setBrowserVersion((version) => version + 1)
|
||||
return
|
||||
}
|
||||
toast.success('连接成功')
|
||||
} catch (err: unknown) {
|
||||
const msg =
|
||||
(err as { response?: { data?: { error?: string } } })?.response?.data?.error ??
|
||||
@@ -104,6 +117,7 @@ export function StorageForm({ type }: { type: StorageType }) {
|
||||
await storageAPI.logout(type)
|
||||
toast.success('已退出云盘登录、停用该存储并清理本项目挂载')
|
||||
await refresh()
|
||||
setBrowserVersion((version) => version + 1)
|
||||
} catch (err: unknown) {
|
||||
const msg =
|
||||
(err as { response?: { data?: { error?: string } } })?.response?.data?.error ??
|
||||
@@ -182,7 +196,7 @@ export function StorageForm({ type }: { type: StorageType }) {
|
||||
transferEnabled={transferEnabled}
|
||||
transferMode={transferMode}
|
||||
/>
|
||||
{isCloud(type) && <CloudBrowser type={type} />}
|
||||
{isCloud(type) && <CloudBrowser key={`${type}-${browserVersion}-${enabled ? 'on' : 'off'}`} type={type} enabled={enabled} />}
|
||||
</form>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -129,7 +129,7 @@ export function SubscriptionsPage() {
|
||||
<div className="space-y-6">
|
||||
<h1 className="font-display text-3xl font-bold text-ink-600">RSS 订阅</h1>
|
||||
<p className="text-sm text-ink-50">
|
||||
定期轮询 RSS 源(每 10 分钟一次),将匹配过滤器的项目自动加入下载队列;启用智能分类后会按二级分类写入下载目录。
|
||||
后台统一每 3 小时轮询 RSS / 站点搜索订阅;手动立即执行不受影响。匹配过滤器的项目会自动加入下载队列,启用智能分类后按二级分类写入下载目录。
|
||||
</p>
|
||||
|
||||
<SubscriptionForm
|
||||
|
||||
@@ -75,6 +75,7 @@ export function buildDiscoverSubscriptionInput(
|
||||
overview: item.overview || undefined,
|
||||
original_name: item.original_name || undefined,
|
||||
year: item.year || undefined,
|
||||
total_episodes: item.total_episodes || undefined,
|
||||
resolution: form.resolution === 'best' ? 'best' : form.resolution,
|
||||
quality: form.quality || undefined,
|
||||
effects: form.effects || undefined,
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
import type { SettingGroup } from './settingsGroupTypes'
|
||||
|
||||
export const subscriptionSettingsGroup: SettingGroup = {
|
||||
key: 'subscriptions',
|
||||
label: '订阅任务',
|
||||
description: '控制 RSS / 站点搜索订阅的唯一后台轮询频率。手动“立即执行”不受这里限制。',
|
||||
items: [
|
||||
{
|
||||
key: 'subscription.interval_seconds',
|
||||
label: '订阅自动同步间隔秒数',
|
||||
type: 'number',
|
||||
hint: '默认 10800 秒,最小 10800 秒。后台自动刷新固定保守节奏,避免馒头等站点 API 超限。',
|
||||
defaultValue: '10800',
|
||||
},
|
||||
],
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import { adultSettingsGroup } from './settingsGroupAccess'
|
||||
import { cloudUploadSettingsGroup } from './settingsGroupCloud'
|
||||
import { generalSettingsGroup, licenseSettingsGroup } from './settingsGroupGeneral'
|
||||
import { subscriptionSettingsGroup } from './settingsGroupSubscriptions'
|
||||
import type { SettingGroup } from './settingsGroupTypes'
|
||||
|
||||
export type { SettingGroup } from './settingsGroupTypes'
|
||||
@@ -8,6 +9,7 @@ export type { SettingGroup } from './settingsGroupTypes'
|
||||
export const GROUPS: SettingGroup[] = [
|
||||
generalSettingsGroup,
|
||||
licenseSettingsGroup,
|
||||
subscriptionSettingsGroup,
|
||||
cloudUploadSettingsGroup,
|
||||
adultSettingsGroup,
|
||||
]
|
||||
|
||||
@@ -13,7 +13,7 @@ import { useCloudBrowserFileActions } from './useCloudBrowserFileActions'
|
||||
import { useCloudBrowserMounts } from './useCloudBrowserMounts'
|
||||
import { useCloudBrowserScan } from './useCloudBrowserScan'
|
||||
|
||||
export function useCloudBrowser(type: StorageType) {
|
||||
export function useCloudBrowser(type: StorageType, enabled = true) {
|
||||
const [stack, setStack] = useState<{ id: string; name: string }[]>([{ id: '', name: '根目录' }])
|
||||
const [items, setItems] = useState<CloudEntry[]>([])
|
||||
const [mounts, setMounts] = useState<Library[]>([])
|
||||
@@ -23,6 +23,12 @@ export function useCloudBrowser(type: StorageType) {
|
||||
|
||||
const cur = stack[stack.length - 1]
|
||||
const load = useCallback(async (dir: string) => {
|
||||
if (!enabled) {
|
||||
setItems([])
|
||||
setError('该外部存储未启用,勾选启用并保存后再刷新资源')
|
||||
setLoading(false)
|
||||
return
|
||||
}
|
||||
setLoading(true)
|
||||
setError('')
|
||||
try {
|
||||
@@ -35,7 +41,7 @@ export function useCloudBrowser(type: StorageType) {
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [type])
|
||||
}, [enabled, type])
|
||||
|
||||
const loadMounts = useCallback(async () => {
|
||||
const libs = await libraryAPI.list({ includeHidden: true })
|
||||
|
||||
@@ -156,7 +156,7 @@ async function loadAllMedia(
|
||||
let page = 1
|
||||
let collected: Media[] = []
|
||||
for (;;) {
|
||||
const data = await libraryAPI.listMedia(libraryID, page, pageSize, { groupVersions: false })
|
||||
const data = await libraryAPI.listMedia(libraryID, page, pageSize)
|
||||
collected = collected.concat(data.items)
|
||||
onPage({ items: collected, total: data.total, firstPage: page === 1 })
|
||||
if (collected.length >= data.total || data.items.length < pageSize) break
|
||||
|
||||
@@ -4,7 +4,6 @@ import toast from 'react-hot-toast'
|
||||
|
||||
import { aiAPI, type ExternalMediaResult, type SearchIntent } from '../api/ai'
|
||||
import { mediaAPI } from '../api/library'
|
||||
import { buildSiteSearchFeedURL, buildSubscriptionAliases, subscriptionsAPI } from '../api/subscriptions'
|
||||
import type { Media } from '../types'
|
||||
import { groupSeries } from '../utils/groupSeries'
|
||||
|
||||
@@ -26,7 +25,6 @@ export function useSearchPage() {
|
||||
const [intent, setIntent] = useState<SearchIntent | null>(null)
|
||||
const [hasSearched, setHasSearched] = useState(false)
|
||||
const [externalItems, setExternalItems] = useState<ExternalMediaResult[]>([])
|
||||
const [subscribing, setSubscribing] = useState('')
|
||||
const [searchTotal, setSearchTotal] = useState(0)
|
||||
const searchSeq = useRef(0)
|
||||
const localCards = useMemo(() => groupSeries(items), [items])
|
||||
@@ -58,7 +56,7 @@ export function useSearchPage() {
|
||||
let page = 1
|
||||
let collected: Media[] = []
|
||||
for (;;) {
|
||||
const data = await mediaAPI.searchPage(query, page, LOCAL_SEARCH_PAGE_SIZE, { groupVersions: false })
|
||||
const data = await mediaAPI.searchPage(query, page, LOCAL_SEARCH_PAGE_SIZE)
|
||||
if (seq !== searchSeq.current) return
|
||||
const pageItems = data.items ?? []
|
||||
collected = collected.concat(pageItems)
|
||||
@@ -115,39 +113,6 @@ export function useSearchPage() {
|
||||
}
|
||||
}
|
||||
|
||||
const onSubscribe = async (item: ExternalMediaResult) => {
|
||||
const keyword = item.subscribe_keyword || item.title
|
||||
const key = `${item.source}:${keyword}`
|
||||
setSubscribing(key)
|
||||
try {
|
||||
const feed = buildSiteSearchFeedURL(keyword, item.source, buildSubscriptionAliases(item))
|
||||
const subscription = await subscriptionsAPI.create({
|
||||
name: `${item.title} 自动订阅`,
|
||||
feed_url: feed,
|
||||
filter: keyword,
|
||||
media_type: item.media_type,
|
||||
source: item.source,
|
||||
poster_url: item.poster_url,
|
||||
backdrop_url: item.backdrop_url,
|
||||
overview: item.overview,
|
||||
original_name: item.original_name,
|
||||
year: item.year,
|
||||
total_episodes: item.total_episodes,
|
||||
enabled: true,
|
||||
})
|
||||
const run = await subscriptionsAPI.runNow(subscription.id)
|
||||
toast.success(
|
||||
run.queued > 0
|
||||
? `已订阅并加入 ${run.queued} 个下载`
|
||||
: '已订阅,暂未在 PT 站点找到可下载资源',
|
||||
)
|
||||
} catch (err) {
|
||||
toast.error(apiErrorMessage(err, '订阅失败'))
|
||||
} finally {
|
||||
setSubscribing('')
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
aiAvailable,
|
||||
aiOn,
|
||||
@@ -158,13 +123,11 @@ export function useSearchPage() {
|
||||
loading,
|
||||
localCards,
|
||||
onAISubmit,
|
||||
onSubscribe,
|
||||
q,
|
||||
searchTotal,
|
||||
setAiOn,
|
||||
setQ,
|
||||
showEmpty: !loading && !error && hasSearched && localCards.length === 0,
|
||||
showIdle: !loading && !error && !hasSearched,
|
||||
subscribing,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -141,6 +141,12 @@ export interface Media {
|
||||
countries?: string
|
||||
genres?: string
|
||||
nsfw: boolean
|
||||
strm_url?: string
|
||||
file_hash?: string
|
||||
file_id?: string
|
||||
is_duplicate?: boolean
|
||||
duplicate_of?: string
|
||||
versions?: Media[]
|
||||
created_at: string
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user