fix media workflows and download organization

This commit is contained in:
ShukeBta
2026-06-26 08:42:16 +08:00
parent 0da96f7e4e
commit 65adca04df
102 changed files with 4063 additions and 437 deletions
+28 -4
View File
@@ -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')
+8
View File
@@ -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">
+51 -81
View File
@@ -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('')
}
}
+2 -2
View File
@@ -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">
+33 -2
View File
@@ -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
+24 -4
View File
@@ -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}
/>
)}
+9
View File
@@ -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}
/>
)
+42 -25
View File
@@ -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>
-2
View File
@@ -47,8 +47,6 @@ export function SearchPage() {
{search.externalItems.length > 0 && (
<ExternalResults
items={search.externalItems}
busyKey={search.subscribing}
onSubscribe={search.onSubscribe}
/>
)}
</div>
+17 -3
View File
@@ -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>
)
}
+1 -1
View File
@@ -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',
},
],
}
+2
View File
@@ -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,
]
+8 -2
View File
@@ -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 })
+1 -1
View File
@@ -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
+1 -38
View File
@@ -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,
}
}
+6
View File
@@ -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
}