fix media playback and library workflows

This commit is contained in:
ShukeBta
2026-06-21 12:53:52 +08:00
parent ada41e7078
commit efca3cbe69
26 changed files with 921 additions and 81 deletions
+76 -23
View File
@@ -167,6 +167,7 @@ export function FileManagerPage() {
const [loading, setLoading] = useState(true)
const [recursive, setRecursive] = useState(false)
const [selected, setSelected] = useState<FileEntry | null>(null)
const [selectedPaths, setSelectedPaths] = useState<string[]>([])
const [folderName, setFolderName] = useState('')
const [renameTo, setRenameTo] = useState('')
const [destPath, setDestPath] = useState('')
@@ -246,6 +247,7 @@ export function FileManagerPage() {
useEffect(() => {
setSelected(null)
setSelectedPaths([])
setRenameTo('')
}, [path])
@@ -309,6 +311,15 @@ export function FileManagerPage() {
if (e.is_dir) setDestPath(e.path)
}
const toggleSelectedPath = (entry: FileEntry, checked: boolean) => {
setSelectedPaths((current) => {
if (checked) {
return current.includes(entry.path) ? current : [...current, entry.path]
}
return current.filter((item) => item !== entry.path)
})
}
const createFolder = async () => {
if (!currentDir || !folderName.trim()) return
setBusy('mkdir')
@@ -376,8 +387,9 @@ export function FileManagerPage() {
}
}
const organizeSource = selected?.path || currentDir
const organizeReady = Boolean(organizeSource && organizeDestPath.trim())
const organizeSources = selectedPaths.length > 0 ? selectedPaths : [selected?.path || currentDir].filter(Boolean)
const organizeSource = organizeSources.length === 1 ? organizeSources[0] : `${organizeSources.length} 个已选项目`
const organizeReady = organizeSources.length > 0 && Boolean(organizeDestPath.trim())
const runManualOrganize = async (dryRun: boolean) => {
if (!organizeReady) {
@@ -387,40 +399,50 @@ export function FileManagerPage() {
if (!dryRun) {
const ok = await confirmAction({
title: '确认整理入库',
message: `来源:${organizeSource}\n目标:${organizeDestPath}\n方式:${organizeTransferMode}${scanAfter ? '\n整理完成后会扫描入库。' : ''}${scanAfter && scrapeAfter ? '\n扫描后会自动刮削。' : ''}`,
message: `来源:${organizeSources.join('\n')}\n目标:${organizeDestPath}\n方式:${organizeTransferMode}${scanAfter ? '\n整理完成后会扫描入库。' : ''}${scanAfter && scrapeAfter ? '\n扫描后会自动刮削。' : ''}`,
confirmText: '开始整理',
})
if (!ok) return
}
setOrganizeBusy(dryRun ? 'preview' : 'run')
try {
const result = await toolsAPI.organizeDirectory({
source_path: organizeSource,
dest_path: organizeDestPath.trim(),
transfer_mode: organizeTransferMode,
media_type: organizeMediaType === 'auto' ? undefined : organizeMediaType,
scan_after: !dryRun && scanAfter,
scrape_after: !dryRun && scanAfter && scrapeAfter,
library_id: !dryRun && scanAfter && organizeLibraryID ? organizeLibraryID : undefined,
dry_run: dryRun,
})
setPreviewItems(result.items ?? [])
const replaced = result.replaced ?? 0
const total = (result.organized ?? 0) + replaced + (result.skipped ?? 0) + (result.errors?.length ?? 0)
const results = []
for (const sourcePath of organizeSources) {
results.push(await toolsAPI.organizeDirectory({
source_path: sourcePath,
dest_path: organizeDestPath.trim(),
transfer_mode: organizeTransferMode,
media_type: organizeMediaType === 'auto' ? undefined : organizeMediaType,
scan_after: !dryRun && scanAfter,
scrape_after: !dryRun && scanAfter && scrapeAfter,
library_id: !dryRun && scanAfter && organizeLibraryID ? organizeLibraryID : undefined,
dry_run: dryRun,
}))
}
const preview = results.flatMap((result) => result.items ?? [])
setPreviewItems(preview)
const organized = results.reduce((sum, result) => sum + (result.organized ?? 0), 0)
const replaced = results.reduce((sum, result) => sum + (result.replaced ?? 0), 0)
const skipped = results.reduce((sum, result) => sum + (result.skipped ?? 0), 0)
const errors = results.flatMap((result) => result.errors ?? [])
const scans = results.flatMap((result) => result.scans ?? [])
const scrapes = results.flatMap((result) => result.scrapes ?? [])
const total = organized + replaced + skipped + errors.length
if (total === 0) {
toast(`未发现可整理视频:${result.source_path || organizeSource}`, {
icon: '⚠️',
toast(`未发现可整理视频:${organizeSource}`, {
icon: '!',
duration: 6000,
})
return
}
if (dryRun) {
toast.success(`预览完成:新增 ${result.organized} · 替换 ${replaced} · 跳过 ${result.skipped}`)
toast.success(`预览完成:新增 ${organized} · 替换 ${replaced} · 跳过 ${skipped}`)
return
}
const scanText = scanAfter ? formatScanSummary(result.scans ?? []) : ''
const scrapeText = scanAfter && scrapeAfter ? formatScrapeSummary(result.scrapes ?? []) : ''
toast.success(`整理完成:新增 ${result.organized} · 替换 ${replaced} · 跳过 ${result.skipped}${scanText}${scrapeText}`)
const scanText = scanAfter ? formatScanSummary(scans) : ''
const scrapeText = scanAfter && scrapeAfter ? formatScrapeSummary(scrapes) : ''
toast.success(`整理完成:新增 ${organized} · 替换 ${replaced} · 跳过 ${skipped}${scanText}${scrapeText}`)
setSelectedPaths([])
refresh()
} catch (err: unknown) {
toast.error((err as { response?: { data?: { error?: string } } })?.response?.data?.error ?? '整理失败')
@@ -745,6 +767,14 @@ export function FileManagerPage() {
来源:{organizeSource || '未选择'}
</div>
</div>
{selectedPaths.length > 0 && (
<div className="flex flex-wrap items-center gap-2 rounded-xl border border-primary-400/20 bg-primary-400/5 px-3 py-2 text-xs text-ink-100">
<span>已选择 {selectedPaths.length} 个项目用于整理。</span>
<button type="button" className="font-semibold text-brand-500 hover:text-brand-700" onClick={() => setSelectedPaths([])}>
清空选择
</button>
</div>
)}
<div className="grid gap-3 lg:grid-cols-[1.2fr_1fr_150px_150px]">
<label className="space-y-1">
@@ -940,6 +970,21 @@ export function FileManagerPage() {
<table className="w-full text-left text-sm">
<thead className="text-xs uppercase tracking-wider text-sand-500">
<tr>
<th className="w-10 py-2">
<input
type="checkbox"
aria-label="选择当前目录全部项目"
checked={data.entries.length > 0 && data.entries.every((entry) => selectedPaths.includes(entry.path))}
onChange={(event) => {
const entries = data.entries ?? []
if (event.target.checked) {
setSelectedPaths(entries.map((entry) => entry.path))
} else {
setSelectedPaths([])
}
}}
/>
</th>
<th className="py-2">名称</th>
<th>大小</th>
<th>修改时间</th>
@@ -948,7 +993,15 @@ export function FileManagerPage() {
</thead>
<tbody>
{data.entries.map((entry) => (
<tr key={entry.path} className={'border-t border-gray-200 transition hover:bg-gray-50 ' + (selected?.path === entry.path ? 'bg-primary-400/5' : '')}>
<tr key={entry.path} className={'border-t border-gray-200 transition hover:bg-gray-50 ' + (selected?.path === entry.path || selectedPaths.includes(entry.path) ? 'bg-primary-400/5' : '')}>
<td className="py-2">
<input
type="checkbox"
aria-label={`选择 ${entry.name}`}
checked={selectedPaths.includes(entry.path)}
onChange={(event) => toggleSelectedPath(entry, event.target.checked)}
/>
</td>
<td className="py-2 text-ink-600">
<button className="flex max-w-xl items-center gap-2 text-left" onClick={() => (entry.is_dir ? enter(entry) : choose(entry))} title={entry.path}>
{entry.is_dir ? <Folder size={16} className="text-brand-500" /> : <FileVideo size={16} className="text-ink-50" />}
+63 -4
View File
@@ -1,8 +1,8 @@
import { useCallback, useEffect, useMemo, useState } from 'react'
import { Link, useParams, useSearchParams } from 'react-router-dom'
import { Link, useLocation, useParams, useSearchParams } from 'react-router-dom'
import { motion, AnimatePresence } from 'framer-motion'
import toast from 'react-hot-toast'
import { ArrowLeft, Play, Film, Database, FileText, Search, Sparkles, Trash2, Pencil } from 'lucide-react'
import { ArrowLeft, Play, Film, Database, FileText, Search, Sparkles, Trash2, Pencil, FolderInput } from 'lucide-react'
import { libraryAPI } from '../api/library'
import { toolsAPI } from '../api/tools'
@@ -23,6 +23,7 @@ import { MetadataEditDialog } from '../components/MetadataEditDialog'
export function LibraryPage() {
const { id = '' } = useParams()
const [searchParams, setSearchParams] = useSearchParams()
const location = useLocation()
const role = useAuthStore((s) => s.user?.role)
const [library, setLibrary] = useState<Library | null>(null)
@@ -320,6 +321,39 @@ export function LibraryPage() {
runSeriesTool('nfo', '整剧 NFO 写出', (media) => recycleAPI.exportNFO(media.id))
}
const handleSeriesOrganize = async () => {
if (!selectedSeries || selectedSeriesEpisodes.length === 0 || !library) return
const source = seriesSourceRoot(selectedSeriesEpisodes)
if (!source || source.toLowerCase().startsWith('cloud://')) {
toast.error('当前合集不是本地文件夹,无法使用本地整理入库')
return
}
if (!(await confirmAction({
title: '整理当前合集',
message: `来源:${source}\n目标:${library.path}\n将按当前媒体库类型整理整个文件夹。`,
confirmText: '开始整理',
}))) return
setSeriesToolBusy('organize')
try {
const result = await toolsAPI.organizeDirectory({
source_path: source,
dest_path: library.path,
media_type: library.type || 'auto',
library_id: library.id,
scan_after: true,
scrape_after: true,
})
const replaced = result.replaced ?? 0
toast.success(`合集整理完成:新增 ${result.organized ?? 0} · 替换 ${replaced} · 跳过 ${result.skipped ?? 0}`)
reloadCurrentLibrary()
} catch (err: unknown) {
const msg = (err as { response?: { data?: { error?: string } } })?.response?.data?.error || '合集整理失败'
toast.error(msg)
} finally {
setSeriesToolBusy('')
}
}
const handleSeriesSoftDelete = async () => {
if (!selectedSeries || selectedSeriesEpisodes.length === 0) return
if (!(await confirmAction({
@@ -517,7 +551,7 @@ export function LibraryPage() {
const first = firstEps.length > 0 ? firstEps[0] : null
return first ? (
<div className="flex flex-wrap gap-2">
<Link to={`/play/${first.id}`} className="btn-primary inline-flex">
<Link to={`/play/${first.id}`} state={{ from: `${location.pathname}${location.search}` }} className="btn-primary inline-flex">
<Play size={16} fill="currentColor" />
从第一集开始播放
</Link>
@@ -550,6 +584,10 @@ export function LibraryPage() {
<FileText size={13} />
<span>{seriesToolBusy === 'nfo' ? '写出中…' : '写出本地 NFO'}</span>
</button>
<button onClick={handleSeriesOrganize} disabled={!!seriesToolBusy} className="btn-outline py-2 px-3.5 text-xs gap-1.5">
<FolderInput size={13} />
<span>{seriesToolBusy === 'organize' ? '整理中…' : '整理当前合集'}</span>
</button>
<button onClick={handleSeriesSoftDelete} disabled={!!seriesToolBusy} className="btn-outline py-2 px-3.5 text-xs gap-1.5 !border-red-100 !text-red-500 hover:!bg-red-50 hover:!border-red-200">
<Trash2 size={13} />
<span>{seriesToolBusy === 'delete' ? '处理中…' : '移入回收站'}</span>
@@ -591,7 +629,7 @@ export function LibraryPage() {
key={ep.id}
className="group flex items-center gap-3 rounded-xl border border-sand-200 bg-white p-3 shadow-card transition-all hover:border-brand-300 hover:shadow-card-hover"
>
<Link to={`/play/${ep.id}`} className="flex min-w-0 flex-1 items-center gap-3">
<Link to={`/play/${ep.id}`} state={{ from: `${location.pathname}${location.search}` }} className="flex min-w-0 flex-1 items-center gap-3">
<div className="flex h-10 w-10 shrink-0 items-center justify-center overflow-hidden rounded-xl bg-brand-50 text-brand-600 font-semibold text-sm">
{ep.backdrop_url || ep.poster_url ? (
<img
@@ -690,6 +728,27 @@ function formatCloudScanStatus(status: CloudScanStatus): string {
return `${stage}:目录 ${status.dirs ?? 0} · 已发现 ${status.discovered ?? 0} · 已入库 ${status.visited ?? 0} · 新增 ${status.added ?? 0} · 更新 ${status.updated ?? 0}${speedText}`
}
function seriesSourceRoot(episodes: Media[]): string {
const firstPath = episodes.find((item) => item.path)?.path ?? ''
if (!firstPath) return ''
const dir = dirname(firstPath)
const base = basename(dir)
if (/^(?:s\d{1,2}|season[\s._-]*\d{1,2}|第\s*\d{1,2}\s*季|specials?|sp|ova|oad|特别篇|特別篇)$/i.test(base)) {
return dirname(dir)
}
return dir
}
function dirname(value: string): string {
const index = Math.max(value.lastIndexOf('/'), value.lastIndexOf('\\'))
return index > 0 ? value.slice(0, index) : ''
}
function basename(value: string): string {
const index = Math.max(value.lastIndexOf('/'), value.lastIndexOf('\\'))
return index >= 0 ? value.slice(index + 1) : value
}
function formatSize(bytes: number): string {
if (!bytes || bytes <= 0) return '—'
const units = ['B', 'KB', 'MB', 'GB', 'TB']
+23 -15
View File
@@ -1,5 +1,5 @@
import { useEffect, useRef, useState } from 'react'
import { useNavigate, useParams, useSearchParams } from 'react-router-dom'
import { useLocation, useNavigate, useParams, useSearchParams } from 'react-router-dom'
import type Hls from 'hls.js'
import { ArrowLeft, RefreshCw, Sparkles } from 'lucide-react'
import toast from 'react-hot-toast'
@@ -10,6 +10,7 @@ import { playbackAPI } from '../api/playback'
import { subtitlesAPI, type SubtitleTrack } from '../api/subtitles'
import { systemAPI } from '../api/system'
import type { Media } from '../types'
import { getSeriesKey, isEpisodeLike } from '../utils/groupSeries'
type Mode = 'direct' | 'hls'
@@ -28,6 +29,7 @@ export function PlayerPage() {
const { id = '' } = useParams()
const [params, setParams] = useSearchParams()
const navigate = useNavigate()
const location = useLocation()
const ref = useRef<HTMLVideoElement>(null)
const hlsRef = useRef<Hls | null>(null)
@@ -136,18 +138,6 @@ export function PlayerPage() {
}
}, [media])
// ESC = back.
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
if (e.key === 'Escape') {
const target = media?.id || id
navigate(target ? `/media/${target}` : '/', { replace: true })
}
}
window.addEventListener('keydown', onKey)
return () => window.removeEventListener('keydown', onKey)
}, [id, media?.id, navigate])
const teardownHls = (mediaId?: string, stopServer = false) => {
if (hlsRef.current) {
hlsRef.current.destroy()
@@ -158,11 +148,29 @@ export function PlayerPage() {
}
}
const goBack = () => {
const backTarget = () => {
const state = location.state as { from?: string } | null
if (state?.from) return state.from
if (media && isEpisodeLike(media) && media.library_id) {
return `/library/${encodeURIComponent(media.display_library_id || media.library_id)}?series=${encodeURIComponent(getSeriesKey(media))}`
}
const target = media?.id || id
navigate(target ? `/media/${target}` : '/', { replace: true })
return target ? `/media/${target}` : '/'
}
const goBack = () => {
navigate(backTarget(), { replace: true })
}
// ESC = back.
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
if (e.key === 'Escape') goBack()
}
window.addEventListener('keydown', onKey)
return () => window.removeEventListener('keydown', onKey)
}, [location.state, media, id])
return (
<div className="relative -m-6 flex min-h-screen flex-col overflow-hidden bg-black md:-m-8">
<div className="pointer-events-none absolute inset-x-0 top-0 z-20 flex items-center justify-between p-4 sm:p-6">