mirror of
https://github.com/truewhile/MeBox.git
synced 2026-10-02 12:26:36 +08:00
perf(strm): 优化 OpenList 元数据下载速度 (#15)
* Add favourite buttons to library list and series detail pages Co-authored-by: truewhile <truewhile@users.noreply.github.com> * Fix favourites list for remote Emby mounted media Co-authored-by: truewhile <truewhile@users.noreply.github.com> * Route favourites to library series detail when appropriate Co-authored-by: truewhile <truewhile@users.noreply.github.com> * Fix favourites link to library series for remote anime paths Co-authored-by: truewhile <truewhile@users.noreply.github.com> * Fix remote Emby playback history and continue watching - Preserve duration_ms when web player reports duration=0 - Add GET /playback/:id/resume for reliable per-item resume - Switch home continue watching to /watch-history/continue API - Set library_id on remote media for play-profile visibility - Keep continue-watching rows when remote metadata hydration fails Co-authored-by: truewhile <truewhile@users.noreply.github.com> * Fix mobile sort dropdown overflowing off-screen Align dropdown to button left edge on small screens and cap width to viewport so sort options stay fully visible on mobile. Co-authored-by: truewhile <truewhile@users.noreply.github.com> * Remove stale Windows binary backup files Co-authored-by: truewhile <truewhile@users.noreply.github.com> * Fix STRM account edit form not echoing saved config Return config_preview from account API for server/url/username and secret flags. Merge partial config updates so empty password/token fields do not wipe stored credentials. Co-authored-by: truewhile <truewhile@users.noreply.github.com> * perf(strm): speed up OpenList metadata downloads with provider-aware concurrency - Split download semaphore: 115 stays capped at 3 for WAF safety; OpenList/CloudDrive2 WebDAV metadata uses strm.download_threads (default raised to 6, max 16) - Scope 115 WAF cooldown to 115 tasks only so OpenList downloads are not paused - Requeue claimed tasks back to pending when slot acquisition fails or 115 is cooling - Add tests for separate semaphores and requeue behavior Co-authored-by: truewhile <truewhile@users.noreply.github.com> --------- Co-authored-by: Cursor Agent <cursoragent@cursor.com> Co-authored-by: truewhile <truewhile@users.noreply.github.com>
This commit is contained in:
@@ -33,6 +33,11 @@ function publicOriginHeader() {
|
||||
}
|
||||
|
||||
export const playbackAPI = {
|
||||
getResume: (mediaId: string) =>
|
||||
api
|
||||
.get<{ position_ms: number; duration_ms: number; completed: boolean }>(`/playback/${mediaId}/resume`)
|
||||
.then((r) => r.data),
|
||||
|
||||
recordProgress: (mediaId: string, positionMs: number, durationMs: number) =>
|
||||
api
|
||||
.post('/history', {
|
||||
|
||||
@@ -84,7 +84,7 @@ export function MediaSortDropdown({
|
||||
</button>
|
||||
|
||||
{isOpen && (
|
||||
<div className="absolute right-0 z-50 mt-1.5 w-52 origin-top-right rounded-2xl border border-sand-200/80 bg-white/95 p-1.5 shadow-xl backdrop-blur-md transition-all animate-in fade-in-0 zoom-in-95">
|
||||
<div className="absolute left-0 z-50 mt-1.5 w-52 max-w-[calc(100vw-2rem)] origin-top-left rounded-2xl border border-sand-200/80 bg-white/95 p-1.5 shadow-xl backdrop-blur-md transition-all animate-in fade-in-0 zoom-in-95 sm:left-auto sm:right-0 sm:origin-top-right">
|
||||
<div className="px-2.5 py-1.5 text-[11px] font-bold text-sand-500 border-b border-sand-100">
|
||||
排序方式
|
||||
</div>
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
|
||||
import { libraryAPI, type LibraryWithPreview } from '../api/library'
|
||||
import { playbackAPI, type HistoryItem } from '../api/playback'
|
||||
import { historyAPI } from '../api/history'
|
||||
import type { HistoryItem } from '../api/playback'
|
||||
import type { Library, Media } from '../types'
|
||||
import type { SeriesCard } from '../utils/groupSeries'
|
||||
import {
|
||||
@@ -33,15 +34,24 @@ export function HomePage() {
|
||||
.list({ withPreview: true, previewLimit: 20 })
|
||||
.then((rows) => asArray<LibraryWithPreview>(rows))
|
||||
.catch(() => [] as LibraryWithPreview[]),
|
||||
playbackAPI
|
||||
.recentHistory()
|
||||
.then((rows) => asArray<HistoryItem>(rows))
|
||||
historyAPI
|
||||
.continueWatching(12)
|
||||
.then((rows) =>
|
||||
rows.map(
|
||||
(row): HistoryItem => ({
|
||||
...row.history,
|
||||
created_at: '',
|
||||
updated_at: '',
|
||||
media: row.media,
|
||||
}),
|
||||
),
|
||||
)
|
||||
.catch(() => [] as HistoryItem[]),
|
||||
])
|
||||
|
||||
if (cancelled) return
|
||||
setLibraries(libs)
|
||||
setHistory(hist.filter((h) => h && !h.completed && !!h.media))
|
||||
setHistory(hist.filter((h) => h && !h.completed))
|
||||
|
||||
const mapData: Record<string, { cards: SeriesCard[]; items: Media[]; total: number }> = {}
|
||||
for (const lib of libs) {
|
||||
|
||||
@@ -23,6 +23,7 @@ import type { HistoryItem } from '../api/playback'
|
||||
import type { Library, Media } from '../types'
|
||||
import type { SeriesCard } from '../utils/groupSeries'
|
||||
import { seriesCardLink } from '../utils/groupSeries'
|
||||
import { isRemoteEmbyID } from '../utils/remoteEmby'
|
||||
import { getLibraryArtworks } from './librariesPageModel'
|
||||
|
||||
const TYPE_ICONS: Record<string, ReactNode> = {
|
||||
@@ -575,8 +576,12 @@ export function ContinueWatchingSection({ history }: { history: HistoryItem[] })
|
||||
style={{ scrollbarWidth: 'none', msOverflowStyle: 'none' }}
|
||||
>
|
||||
{history.slice(0, 12).map((h) => {
|
||||
const media = h.media
|
||||
if (!media) return null
|
||||
const media: Media = h.media ?? ({
|
||||
id: h.media_id,
|
||||
title: isRemoteEmbyID(h.media_id) ? '远程媒体' : '媒体',
|
||||
poster_url: '',
|
||||
updated_at: h.watched_at,
|
||||
} as Media)
|
||||
const progress = h.duration_ms > 0 ? h.position_ms / h.duration_ms : 0
|
||||
return (
|
||||
<div key={h.id} className="w-64 sm:w-72 shrink-0">
|
||||
@@ -592,7 +597,7 @@ export function ContinueWatchingSection({ history }: { history: HistoryItem[] })
|
||||
function ContinueCard({ media, progress }: { media: Media; progress: number }) {
|
||||
return (
|
||||
<Link
|
||||
to={`/media/${media.id}`}
|
||||
to={`/play/${media.id}`}
|
||||
className="group flex items-center gap-3.5 rounded-2xl border border-[var(--app-border)] bg-[var(--app-panel)] p-3 shadow-sm transition-all duration-300 hover:border-brand-500/30 hover:bg-[var(--app-panel-soft)] hover:shadow-md"
|
||||
>
|
||||
<div className="relative h-20 w-14 shrink-0 overflow-hidden rounded-xl bg-[var(--app-panel-soft)]">
|
||||
|
||||
@@ -262,11 +262,10 @@ export function PlayerPage() {
|
||||
setResumePosition(0)
|
||||
setInitialSeekDone(false)
|
||||
playbackAPI
|
||||
.recentHistory()
|
||||
.then((items) => {
|
||||
const found = items.find((h) => h.media_id === id)
|
||||
if (found && found.position_ms > 2000 && !found.completed) {
|
||||
setResumePosition(found.position_ms / 1000)
|
||||
.getResume(id)
|
||||
.then((progress) => {
|
||||
if (progress.position_ms > 2000 && !progress.completed) {
|
||||
setResumePosition(progress.position_ms / 1000)
|
||||
}
|
||||
})
|
||||
.catch(() => undefined)
|
||||
|
||||
@@ -95,6 +95,26 @@ function Field({ label, hint, children }: { label: string; hint?: string; childr
|
||||
|
||||
const inputCls = 'input-base w-full'
|
||||
|
||||
const SECRET_PLACEHOLDER = '已配置,留空则不修改'
|
||||
|
||||
function accountDialogDefaults(existing: StrmAccount | null) {
|
||||
const preview = existing?.config_preview
|
||||
return {
|
||||
provider: (existing?.provider ?? 'cloud115') as StrmProvider,
|
||||
name: existing?.name ?? '',
|
||||
enabled: existing?.enabled ?? true,
|
||||
url: preview?.url ?? '',
|
||||
server: preview?.server ?? '',
|
||||
username: preview?.username ?? '',
|
||||
password: '',
|
||||
token: '',
|
||||
proxyPlay: existing?.proxy_play ?? false,
|
||||
embyLines: defaultEmbyRemoteLines(existing?.provider === 'emby_remote' ? existing?.emby_lines : undefined),
|
||||
hasPassword: Boolean(preview?.has_password),
|
||||
hasToken: Boolean(preview?.has_token || preview?.has_api_key),
|
||||
}
|
||||
}
|
||||
|
||||
// ─── 添加/编辑网盘账号 ────────────────────────────────────────────────────────
|
||||
|
||||
const PROVIDER_OPTIONS: { provider: StrmProvider; label: string; desc: string }[] = [
|
||||
@@ -112,18 +132,19 @@ export function StrmAccountDialog({
|
||||
onClose: () => void
|
||||
onSaved: () => void
|
||||
}) {
|
||||
const [provider, setProvider] = useState<StrmProvider>(existing?.provider ?? 'cloud115')
|
||||
const [name, setName] = useState(existing?.name ?? '')
|
||||
const [enabled, setEnabled] = useState(existing?.enabled ?? true)
|
||||
const [url, setUrl] = useState('')
|
||||
const [embyLines, setEmbyLines] = useState<EmbyRemoteLine[]>(() =>
|
||||
defaultEmbyRemoteLines(existing?.provider === 'emby_remote' ? existing?.emby_lines : undefined),
|
||||
)
|
||||
const [server, setServer] = useState('')
|
||||
const [username, setUsername] = useState('')
|
||||
const [password, setPassword] = useState('')
|
||||
const [token, setToken] = useState('')
|
||||
const [proxyPlay, setProxyPlay] = useState(existing?.proxy_play ?? false)
|
||||
const defaults = accountDialogDefaults(existing)
|
||||
const [provider, setProvider] = useState<StrmProvider>(defaults.provider)
|
||||
const [name, setName] = useState(defaults.name)
|
||||
const [enabled, setEnabled] = useState(defaults.enabled)
|
||||
const [url, setUrl] = useState(defaults.url)
|
||||
const [embyLines, setEmbyLines] = useState<EmbyRemoteLine[]>(defaults.embyLines)
|
||||
const [server, setServer] = useState(defaults.server)
|
||||
const [username, setUsername] = useState(defaults.username)
|
||||
const [password, setPassword] = useState(defaults.password)
|
||||
const [token, setToken] = useState(defaults.token)
|
||||
const [proxyPlay, setProxyPlay] = useState(defaults.proxyPlay)
|
||||
const [hasPassword, setHasPassword] = useState(defaults.hasPassword)
|
||||
const [hasToken, setHasToken] = useState(defaults.hasToken)
|
||||
const [saving, setSaving] = useState(false)
|
||||
|
||||
const buildConfig = (): Record<string, string> => {
|
||||
@@ -178,7 +199,7 @@ export function StrmAccountDialog({
|
||||
name,
|
||||
provider: existing.provider,
|
||||
enabled,
|
||||
config: existing.has_credential && !Object.keys(buildConfig()).length ? {} : buildConfig(),
|
||||
config: buildConfig(),
|
||||
})
|
||||
toast.success('网盘账号已更新')
|
||||
} else {
|
||||
@@ -364,7 +385,16 @@ export function StrmAccountDialog({
|
||||
<input className={inputCls} value={username} onChange={(e) => setUsername(e.target.value)} />
|
||||
</Field>
|
||||
<Field label="密码">
|
||||
<input className={inputCls} type="password" value={password} onChange={(e) => setPassword(e.target.value)} />
|
||||
<input
|
||||
className={inputCls}
|
||||
type="password"
|
||||
value={password}
|
||||
placeholder={existing && hasPassword ? SECRET_PLACEHOLDER : undefined}
|
||||
onChange={(e) => {
|
||||
setPassword(e.target.value)
|
||||
if (e.target.value) setHasPassword(false)
|
||||
}}
|
||||
/>
|
||||
</Field>
|
||||
</div>
|
||||
)}
|
||||
@@ -375,20 +405,45 @@ export function StrmAccountDialog({
|
||||
<input className={inputCls} value={username} onChange={(e) => setUsername(e.target.value)} />
|
||||
</Field>
|
||||
<Field label="密码">
|
||||
<input className={inputCls} type="password" value={password} onChange={(e) => setPassword(e.target.value)} />
|
||||
<input
|
||||
className={inputCls}
|
||||
type="password"
|
||||
value={password}
|
||||
placeholder={existing && hasPassword ? SECRET_PLACEHOLDER : undefined}
|
||||
onChange={(e) => {
|
||||
setPassword(e.target.value)
|
||||
if (e.target.value) setHasPassword(false)
|
||||
}}
|
||||
/>
|
||||
</Field>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{provider === 'emby_remote' && (
|
||||
<Field label="API Key(可选)" hint="留空则用下方用户名/密码自动认证获取">
|
||||
<input className={inputCls} value={token} placeholder="留空自动认证" onChange={(e) => setToken(e.target.value)} />
|
||||
<input
|
||||
className={inputCls}
|
||||
value={token}
|
||||
placeholder={existing && hasToken ? SECRET_PLACEHOLDER : '留空自动认证'}
|
||||
onChange={(e) => {
|
||||
setToken(e.target.value)
|
||||
if (e.target.value) setHasToken(false)
|
||||
}}
|
||||
/>
|
||||
</Field>
|
||||
)}
|
||||
|
||||
{provider === 'openlist' && (
|
||||
<Field label="Token(可选,优先于密码)">
|
||||
<input className={inputCls} value={token} onChange={(e) => setToken(e.target.value)} />
|
||||
<input
|
||||
className={inputCls}
|
||||
value={token}
|
||||
placeholder={existing && hasToken ? SECRET_PLACEHOLDER : undefined}
|
||||
onChange={(e) => {
|
||||
setToken(e.target.value)
|
||||
if (e.target.value) setHasToken(false)
|
||||
}}
|
||||
/>
|
||||
</Field>
|
||||
)}
|
||||
|
||||
|
||||
@@ -220,6 +220,7 @@ export function StrmManagePage() {
|
||||
|
||||
{dialog === 'account' && (
|
||||
<StrmAccountDialog
|
||||
key={editingAccount?.id ?? 'new'}
|
||||
existing={editingAccount}
|
||||
onClose={() => setDialog(null)}
|
||||
onSaved={() => { setDialog(null); refresh().catch(() => undefined) }}
|
||||
|
||||
@@ -26,6 +26,7 @@ export interface StrmAccount {
|
||||
last_test_ok: boolean
|
||||
has_credential: boolean
|
||||
provider_label: string
|
||||
config_preview?: StrmAccountConfigPreview
|
||||
/** 仅远程 Emby 挂载账号返回:播放流量是否经过 MMTL 代理 */
|
||||
proxy_play?: boolean
|
||||
/** 仅远程 Emby 挂载账号返回:多线路配置 */
|
||||
@@ -34,6 +35,15 @@ export interface StrmAccount {
|
||||
emby_active_line?: number
|
||||
}
|
||||
|
||||
export interface StrmAccountConfigPreview {
|
||||
url?: string
|
||||
server?: string
|
||||
username?: string
|
||||
has_password?: boolean
|
||||
has_token?: boolean
|
||||
has_api_key?: boolean
|
||||
}
|
||||
|
||||
export interface StrmAccountInput {
|
||||
name?: string
|
||||
provider: StrmProvider
|
||||
|
||||
Reference in New Issue
Block a user