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' // 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 [strmEnabled, setStrmEnabled] = 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(settings['app.server_url'] || settings['strm.base_url'] || '') setOutputDir(settings['strm.output_dir'] || '') setStrmEnabled(settings['strm.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 { await Promise.all([ adminAPI.updateSetting('strm.enabled', String(strmEnabled)), adminAPI.updateSetting('strm.auto_generate_enabled', String(autoGenerate)), ]) toast.success(strmEnabled ? 'STRM 播放已启用' : 'STRM 播放已关闭') } 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) } } return (

STRM 管理

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

自动生成 STRM 文件

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

{strmEnabled ? 'STRM 播放已启用' : 'STRM 播放已关闭'}
setBaseURL(e.target.value)} /> setOutputDir(e.target.value)} />

生成内容为 域名 + /api/stream/媒体ID 或网盘 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 && (

未找到匹配的媒体。

)}
) }