fix emby clients and cloud playback controls

This commit is contained in:
ShukeBta
2026-06-13 12:08:57 +08:00
parent ba43117a57
commit a177dc61dd
28 changed files with 2031 additions and 138 deletions
+5
View File
@@ -80,6 +80,11 @@ export const storageAPI = {
.put<StorageConfig>(`/admin/storage/${type}`, { type, config, enabled })
.then((r) => r.data),
logout: (type: StorageType) =>
api
.post<StorageConfig>(`/admin/storage/${type}/logout`)
.then((r) => r.data),
test: (type: StorageType, config: Record<string, string>) =>
api
.post<{ ok: boolean; error?: string }>(`/admin/storage/${type}/test`, {
+2 -2
View File
@@ -107,7 +107,7 @@ function settingOn(value: string): boolean {
return ['1', 'true', 'yes', 'on', 'enabled', '启用', '开启'].includes(value.trim().toLowerCase())
}
// FileManagerPage mirrors MoviePilot's local Storage view at a smaller scope:
// FileManagerPage provides a focused local storage view:
// browse allowed roots, optionally recurse, and perform safe local operations.
export function FileManagerPage() {
const [libraries, setLibraries] = useState<Library[]>([])
@@ -372,7 +372,7 @@ export function FileManagerPage() {
<header>
<h1 className="font-display text-3xl font-bold text-ink-600">文件管理</h1>
<p className="text-sm text-ink-50">
像 MoviePilot 一样:在下载目录中选择文件夹或视频,直接设置目标并整理入库。
在下载目录中选择文件夹或视频,直接设置目标并整理入库。
</p>
</header>
+36 -6
View File
@@ -1,5 +1,5 @@
import { FormEvent, useEffect, useMemo, useState } from 'react'
import { Cloud, FileVideo, Folder, Loader2, PauseCircle, QrCode, RefreshCw, Save, Send, Trash2, Upload } from 'lucide-react'
import { Cloud, FileVideo, Folder, Loader2, LogOut, PauseCircle, QrCode, RefreshCw, Save, Send, Trash2, Upload } from 'lucide-react'
import toast from 'react-hot-toast'
import { libraryAPI } from '../api/library'
@@ -119,7 +119,6 @@ const FIELD_DEFS: Record<StorageType, { key: string; label: string; secret?: boo
{ key: 'username', label: 'WebDAV 用户名' },
{ key: 'password', label: 'WebDAV 密码', secret: true },
{ key: 'timeout_seconds', label: '请求超时秒数', placeholder: '120' },
{ key: 'force_302', label: '强制 302 直链(true/false,默认自动优先直链)' },
],
webdav: [
{ key: 'url', label: 'URL', placeholder: 'https://example.com/dav/' },
@@ -137,11 +136,9 @@ const FIELD_DEFS: Record<StorageType, { key: string; label: string; secret?: boo
],
cloud115: [
{ key: 'cookie', label: 'Cookie(UID / CID / SEID,或扫码登录自动填充)', secret: true, placeholder: 'UID=...; CID=...; SEID=...' },
{ key: 'force_proxy', label: '强制反代(true/false,默认 302 直链)' },
],
quark: [
{ key: 'cookie', label: 'Cookie(从 pan.quark.cn 复制整段)', secret: true, placeholder: '__pus=...; __kp=...; kps=...' },
{ key: 'force_302', label: '强制 302 直链(true/false,默认反代)' },
],
clouddrive2: [
{ key: 'url', label: 'CloudDrive2 WebDAV URL', placeholder: 'http://host.docker.internal:19798/dav 或 http://NAS-IP:19798/dav' },
@@ -149,7 +146,6 @@ const FIELD_DEFS: Record<StorageType, { key: string; label: string; secret?: boo
{ key: 'password', label: '密码 / Token', secret: true },
{ key: 'token', label: 'Authorization Token(可选)', secret: true, placeholder: 'Bearer ... 或 Basic ...' },
{ key: 'timeout_seconds', label: '请求超时秒数', placeholder: '120' },
{ key: 'force_302', label: '强制 302 直链(true/false,默认反代)' },
],
}
@@ -160,6 +156,7 @@ function StorageForm({ type }: { type: StorageType }) {
const [loading, setLoading] = useState(true)
const [saving, setSaving] = useState(false)
const [testing, setTesting] = useState(false)
const [loggingOut, setLoggingOut] = useState(false)
const refresh = async () => {
setLoading(true)
@@ -221,6 +218,28 @@ function StorageForm({ type }: { type: StorageType }) {
}
}
const onLogout = async () => {
const ok = await confirmAction({
title: '退出云盘登录',
message: `将清空「${TYPE_LABEL[type] ?? type}」保存的 Cookie / Token / 密码并停用该外部存储;不会删除网盘文件,也不会删除已挂载媒体库。`,
confirmText: '退出登录',
})
if (!ok) return
setLoggingOut(true)
try {
await storageAPI.logout(type)
toast.success('已退出云盘登录并停用该存储')
await refresh()
} catch (err: unknown) {
const msg =
(err as { response?: { data?: { error?: string } } })?.response?.data?.error ??
'退出登录失败'
toast.error(msg)
} finally {
setLoggingOut(false)
}
}
if (loading) {
return (
<div className="flex justify-center py-12 text-ink-50">
@@ -262,6 +281,17 @@ function StorageForm({ type }: { type: StorageType }) {
启用
</label>
<div className="flex justify-end gap-2 pt-2">
{isCloud(type) && (
<button
type="button"
onClick={onLogout}
disabled={loggingOut}
className="rounded-lg border border-red-200 px-4 py-2 text-sm text-red-500 hover:bg-red-50 disabled:opacity-50"
>
{loggingOut ? <Loader2 size={14} className="inline animate-spin" /> : <LogOut size={14} className="inline" />}
{' '}退出登录
</button>
)}
<button
type="button"
onClick={onTest}
@@ -647,7 +677,7 @@ function CloudBrowser({ type }: { type: StorageType }) {
}
return (
<div className="mt-2 rounded-lg border border-gray-200 p-3" onClick={(e) => e.preventDefault()}>
<div className="mt-2 rounded-lg border border-gray-200 p-3">
<div className="mb-3 rounded border border-emerald-100 bg-emerald-50/60 p-2">
<div className="mb-2 flex flex-wrap items-center justify-between gap-2">
<div>
+79 -12
View File
@@ -8,6 +8,8 @@ import { strmAPI, type GenerateSTRMResult } from '../api/strm'
import { confirmAction } from '../components/ConfirmDialog'
import type { Library, Media } from '../types'
type CloudPlaybackMode = 'strm' | 'redirect_proxy'
// StrmPage exposes the URL-as-file admin tooling backed by the Go server:
// - import a brand-new media row directly from a (library, title, url)
// tuple — useful for streaming-only entries with no on-disk file.
@@ -21,7 +23,9 @@ export function StrmPage() {
const [generateLibraryID, setGenerateLibraryID] = useState('')
const [baseURL, setBaseURL] = useState('')
const [outputDir, setOutputDir] = useState('')
const [strmEnabled, setStrmEnabled] = useState(true)
const [cloudPlaybackMode, setCloudPlaybackMode] = useState<CloudPlaybackMode>('redirect_proxy')
const [strmPlaybackEnabled, setStrmPlaybackEnabled] = useState(false)
const [redirectProxyEnabled, setRedirectProxyEnabled] = useState(true)
const [autoGenerate, setAutoGenerate] = useState(false)
const [savingSettings, setSavingSettings] = useState(false)
const [overwrite, setOverwrite] = useState(false)
@@ -48,7 +52,20 @@ export function StrmPage() {
const settings = Object.fromEntries(rows.map((row) => [row.key, row.value]))
setBaseURL(settings['app.server_url'] || settings['strm.base_url'] || '')
setOutputDir(settings['strm.output_dir'] || '')
setStrmEnabled(settings['strm.enabled'] !== 'false')
const mode = settings['cloud.playback_mode']
const nextMode =
mode === 'strm' || mode === 'redirect_proxy'
? mode
: settings['strm.enabled'] === 'true'
? 'strm'
: 'redirect_proxy'
setCloudPlaybackMode(nextMode)
setStrmPlaybackEnabled(
settings['cloud.playback_strm_enabled'] != null
? settings['cloud.playback_strm_enabled'] === 'true'
: settings['strm.enabled'] === 'true' || nextMode === 'strm',
)
setRedirectProxyEnabled(settings['cloud.playback_redirect_proxy_enabled'] !== 'false')
setAutoGenerate(settings['strm.auto_generate_enabled'] === 'true')
})
.catch(() => undefined)
@@ -93,11 +110,25 @@ export function StrmPage() {
const saveSTRMSettings = async () => {
setSavingSettings(true)
try {
const effectiveMode: CloudPlaybackMode =
strmPlaybackEnabled && !redirectProxyEnabled
? 'strm'
: !strmPlaybackEnabled && redirectProxyEnabled
? 'redirect_proxy'
: cloudPlaybackMode
await Promise.all([
adminAPI.updateSetting('strm.enabled', String(strmEnabled)),
adminAPI.updateSetting('cloud.playback_mode', effectiveMode),
adminAPI.updateSetting('cloud.playback_strm_enabled', String(strmPlaybackEnabled)),
adminAPI.updateSetting('cloud.playback_redirect_proxy_enabled', String(redirectProxyEnabled)),
adminAPI.updateSetting('strm.enabled', String(strmPlaybackEnabled)),
adminAPI.updateSetting('strm.auto_generate_enabled', String(autoGenerate)),
])
toast.success(strmEnabled ? 'STRM 播放已启用' : 'STRM 播放已关闭')
setCloudPlaybackMode(effectiveMode)
toast.success(
strmPlaybackEnabled || redirectProxyEnabled
? `播放设置已保存:优先 ${effectiveMode === 'strm' ? 'STRMURL' : '302/反代'}`
: '播放设置已保存:云盘第三方播放已关闭',
)
} catch (err: unknown) {
const msg =
(err as { response?: { data?: { error?: string } } })?.response?.data?.error ??
@@ -186,6 +217,15 @@ export function StrmPage() {
}
}
const playbackStatus =
strmPlaybackEnabled && redirectProxyEnabled
? `两者开启 · 优先${cloudPlaybackMode === 'strm' ? 'STRMURL' : '302/反代'}`
: strmPlaybackEnabled
? '仅 STRMURL'
: redirectProxyEnabled
? '仅 302/反代'
: '云盘第三方播放关闭'
return (
<div className="space-y-6">
<div className="flex items-center gap-3">
@@ -209,26 +249,53 @@ export function StrmPage() {
</p>
</div>
<span className={`rounded-full border px-3 py-1 text-xs font-semibold ${
strmEnabled
strmPlaybackEnabled || redirectProxyEnabled
? 'border-emerald-300/40 bg-emerald-400/10 text-emerald-500'
: 'border-red-300/40 bg-red-400/10 text-red-500'
}`}>
{strmEnabled ? 'STRM 播放已启用' : 'STRM 播放已关闭'}
{playbackStatus}
</span>
</div>
<div className="grid gap-3 rounded-2xl border border-gray-200 bg-white/70 p-4 md:grid-cols-[1fr_1fr_auto]">
<div className="grid gap-3 rounded-2xl border border-gray-200 bg-white/70 p-4 md:grid-cols-2">
<label className="flex items-start gap-3 text-sm text-ink-100">
<input
type="checkbox"
className="mt-1 h-4 w-4 accent-primary-400"
checked={strmEnabled}
onChange={(e) => setStrmEnabled(e.target.checked)}
checked={strmPlaybackEnabled}
onChange={(e) => setStrmPlaybackEnabled(e.target.checked)}
/>
<span>
<span className="block font-medium text-ink-600">启用 STRM 播放</span>
<span className="text-xs text-ink-50">开启后第三方播放优先走 STRM 入口;关闭后使用 302/反代播放入口。</span>
<span className="block font-medium text-ink-600">启用 STRMURL 播放</span>
<span className="text-xs text-ink-50">第三方客户端可拿到 /api/stream/媒体ID 入口,适合 STRM 管理和自动生成方案。</span>
</span>
</label>
<label className="flex items-start gap-3 text-sm text-ink-100">
<input
type="checkbox"
className="mt-1 h-4 w-4 accent-primary-400"
checked={redirectProxyEnabled}
onChange={(e) => setRedirectProxyEnabled(e.target.checked)}
/>
<span>
<span className="block font-medium text-ink-600">启用 302/反代播放</span>
<span className="text-xs text-ink-50">第三方客户端可拿到 /Videos/媒体ID/stream 入口,由服务端解析后 302 或必要时反代。</span>
</span>
</label>
</div>
<div className="grid gap-3 rounded-2xl border border-gray-200 bg-white/70 p-4 md:grid-cols-[1fr_1fr_auto]">
<label className="text-sm text-ink-100">
<span className="mb-1 block font-medium text-ink-600">两者都开启时优先</span>
<select
className="input-base"
value={cloudPlaybackMode}
onChange={(e) => setCloudPlaybackMode(e.target.value as CloudPlaybackMode)}
disabled={!strmPlaybackEnabled || !redirectProxyEnabled}
>
<option value="strm">优先 STRMURL</option>
<option value="redirect_proxy">优先 302/反代</option>
</select>
<span className="mt-1 block text-xs text-ink-50">只开启一个时自动使用已开启的播放方式;两个都关闭时云盘媒体不向第三方提供播放。</span>
</label>
<label className="flex items-start gap-3 text-sm text-ink-100">
<input
type="checkbox"
@@ -289,7 +356,7 @@ export function StrmPage() {
</button>
</form>
<p className="text-xs text-sand-500">
生成内容为 <code>域名 + /api/stream/媒体ID?token=...</code>;开启 STRM 播放时第三方客户端优先使用该入口,关闭后回到 302/反代播放。域名会同步保存到系统设置中的「公开访问域名 / STRM 域名」。
生成内容为 <code>域名 + /api/stream/媒体ID?token=...</code>;第三方客户端播放优先方式由上方「STRMURL / 302反代」模式决定。域名会同步保存到系统设置中的「公开访问域名 / STRM 域名」。
</p>
{generateResult && (
<div className="rounded-2xl border border-gray-200 bg-gray-50 p-4 text-sm text-ink-50">