import { FormEvent, useEffect, useState } from 'react' import { Link as LinkIcon, Loader2, Plus, Save, Search, Trash2, Wand2 } from 'lucide-react' import toast from 'react-hot-toast' import { adminAPI } from '../api/admin' import { libraryAPI, mediaAPI } from '../api/library' import { strmAPI, type GenerateSTRMResult } from '../api/strm' import { confirmAction } from '../components/ConfirmDialog' import type { Library, Media } from '../types' type CloudPlaybackMode = 'strm' | 'redirect_proxy' function currentOrigin() { if (typeof window === 'undefined') return '' return window.location.origin.replace(/\/+$/, '') } function isLocalPlaybackBase(raw: string) { try { const u = new URL(raw) const host = u.hostname.toLowerCase() return host === 'localhost' || host === '127.0.0.1' || host === '::1' } catch { return false } } function preferredSTRMBaseURL(saved: string) { const current = currentOrigin() const trimmed = saved.trim().replace(/\/+$/, '') if (!trimmed) return current if (current && isLocalPlaybackBase(trimmed) && trimmed !== current) return current return trimmed } // 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. // - search existing media and attach / detach a STRM URL so the player // issues a 302 redirect to the remote source instead of opening a // local file. export function StrmPage() { const [libraries, setLibraries] = useState([]) // Auto-generate state const [generateLibraryID, setGenerateLibraryID] = useState('') const [baseURL, setBaseURL] = useState('') const [outputDir, setOutputDir] = useState('') const [cloudPlaybackMode, setCloudPlaybackMode] = useState('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) const [generating, setGenerating] = useState(false) const [generateResult, setGenerateResult] = useState(null) // Import form state const [libraryID, setLibraryID] = useState('') const [title, setTitle] = useState('') const [url, setURL] = useState('') const [importing, setImporting] = useState(false) // Search + attach state const [query, setQuery] = useState('') const [searching, setSearching] = useState(false) const [results, setResults] = useState([]) const [drafts, setDrafts] = useState>({}) useEffect(() => { libraryAPI.list().then(setLibraries).catch(() => undefined) adminAPI .listSettings() .then((rows) => { const settings = Object.fromEntries(rows.map((row) => [row.key, row.value])) setBaseURL(preferredSTRMBaseURL(settings['strm.base_url'] || settings['app.server_url'] || '')) setOutputDir(settings['strm.output_dir'] || '') 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) }, []) // Default the import library to the first available one once loaded. useEffect(() => { if (!libraryID && libraries[0]) setLibraryID(libraries[0].id) if (!generateLibraryID && libraries[0]) setGenerateLibraryID(libraries[0].id) }, [libraries, libraryID, generateLibraryID]) const onGenerate = async (e: FormEvent) => { e.preventDefault() if (!generateLibraryID || !baseURL.trim()) return if (!/^https?:\/\//i.test(baseURL.trim())) { toast.error('域名必须以 http:// 或 https:// 开头') return } setGenerating(true) try { const result = await strmAPI.generate({ library_id: generateLibraryID, base_url: baseURL.trim().replace(/\/+$/, ''), output_dir: outputDir.trim(), overwrite, enabled: autoGenerate, include_local: true, }) setGenerateResult(result) setOutputDir(result.output_dir || outputDir) toast.success(`生成完成:新增 ${result.generated} · 更新 ${result.updated} · 跳过 ${result.skipped}`) } catch (err: unknown) { const msg = (err as { response?: { data?: { error?: string } } })?.response?.data?.error ?? '生成失败' toast.error(msg) } finally { setGenerating(false) } } const saveSTRMSettings = async () => { setSavingSettings(true) try { const effectiveMode: CloudPlaybackMode = strmPlaybackEnabled && !redirectProxyEnabled ? 'strm' : !strmPlaybackEnabled && redirectProxyEnabled ? 'redirect_proxy' : cloudPlaybackMode await Promise.all([ 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)), ]) setCloudPlaybackMode(effectiveMode) toast.success( strmPlaybackEnabled || redirectProxyEnabled ? `播放设置已保存:优先 ${effectiveMode === 'strm' ? 'STRMURL' : '302/反代'}` : '播放设置已保存:云盘第三方播放已关闭', ) } catch (err: unknown) { const msg = (err as { response?: { data?: { error?: string } } })?.response?.data?.error ?? '保存 STRM 开关失败' toast.error(msg) } finally { setSavingSettings(false) } } const onImport = async (e: FormEvent) => { e.preventDefault() if (!libraryID || !title.trim() || !url.trim()) return if (!/^https?:\/\//i.test(url.trim())) { toast.error('URL 必须以 http:// 或 https:// 开头') return } setImporting(true) try { await strmAPI.importURL(libraryID, title.trim(), url.trim()) toast.success(`已导入「${title.trim()}」`) setTitle('') setURL('') } catch (err: unknown) { const msg = (err as { response?: { data?: { error?: string } } })?.response?.data?.error ?? '导入失败' toast.error(msg) } finally { setImporting(false) } } const doSearch = async (e?: FormEvent) => { e?.preventDefault() if (!query.trim()) return setSearching(true) try { const r = await mediaAPI.search(query.trim(), 30) setResults(r.items ?? []) } catch { toast.error('搜索失败') } finally { setSearching(false) } } const onAttach = async (m: Media) => { const next = (drafts[m.id] ?? '').trim() if (!next) return if (!/^https?:\/\//i.test(next)) { toast.error('URL 必须以 http:// 或 https:// 开头') return } try { await strmAPI.set(m.id, next) toast.success('已设置 STRM URL') // Optimistic update so the user sees the new state without a re-search. setResults((rs) => rs.map((x) => (x.id === m.id ? ({ ...x, container: 'strm' } as Media) : x)), ) setDrafts((d) => ({ ...d, [m.id]: '' })) } catch (err: unknown) { const msg = (err as { response?: { data?: { error?: string } } })?.response?.data?.error ?? '设置失败' toast.error(msg) } } const onDetach = async (m: Media) => { if (!(await confirmAction({ title: '清除 STRM URL', message: `清除「${m.title}」的 STRM URL?`, confirmText: '清除' }))) return try { await strmAPI.clear(m.id) toast.success('已清除') setResults((rs) => rs.map((x) => x.id === m.id ? ({ ...x, container: x.container === 'strm' ? '' : x.container } as Media) : x, ), ) } catch (err: unknown) { const msg = (err as { response?: { data?: { error?: string } } })?.response?.data?.error ?? '清除失败' toast.error(msg) } } const playbackStatus = strmPlaybackEnabled && redirectProxyEnabled ? `两者开启 · 优先${cloudPlaybackMode === 'strm' ? 'STRMURL' : '302/反代'}` : strmPlaybackEnabled ? '仅 STRMURL' : redirectProxyEnabled ? '仅 302/反代' : '云盘第三方播放关闭' return (

STRM 管理

将外部 HTTP / WebDAV / Alist 直链以"虚拟文件"形式纳入媒体库

自动生成 STRM 文件

只需要填写自己的访问域名,系统会按媒体库内每个媒体批量生成可播放的 .strm 文件。

{playbackStatus}
setBaseURL(e.target.value)} /> setOutputDir(e.target.value)} />

生成内容为 域名 + /api/stream/媒体ID?token=...;第三方客户端播放优先方式由上方「STRMURL / 302反代」模式决定。域名会同步保存到系统设置中的「公开访问域名 / STRM 域名」。

{generateResult && (
输出目录:{generateResult.output_dir}
新增 {generateResult.generated} · 更新 {generateResult.updated} · 跳过 {generateResult.skipped}
{generateResult.errors && generateResult.errors.length > 0 && (
失败 {generateResult.errors.length} 条:{generateResult.errors.slice(0, 3).join(';')}
)}
)}
{/* Import a new STRM-only entry. */}

导入 STRM 条目

setTitle(e.target.value)} /> setURL(e.target.value)} />

导入后会创建一条 container=strm 的媒体记录,播放时会 302 重定向到该 URL。

{/* Attach / detach STRM URL on existing media. */}

附加 STRM URL 到已有媒体

setQuery(e.target.value)} />
{results.length > 0 && (
{results.map((m) => { const isStrm = m.container === 'strm' return (
{m.title}
{m.year > 0 && `${m.year} · `} {m.container || '本地文件'}
{isStrm && (
已设置 STRM
)}
{isStrm && ( )}
setDrafts((d) => ({ ...d, [m.id]: e.target.value })) } />
) })}
)} {!searching && query && results.length === 0 && (

未找到匹配的媒体。

)}
) }