diff --git a/web/src/components/MediaVersionSwitcher.tsx b/web/src/components/MediaVersionSwitcher.tsx
index d6ae415..7cbdeee 100644
--- a/web/src/components/MediaVersionSwitcher.tsx
+++ b/web/src/components/MediaVersionSwitcher.tsx
@@ -1,8 +1,8 @@
-import { Layers } from 'lucide-react'
+import { Cloud, Layers } from 'lucide-react'
import { Link } from 'react-router-dom'
import type { Media } from '../types'
-import { mediaVersionLabel, mediaVersionsOf } from '../utils/mediaVersion'
+import { isStrmMedia, mediaVersionLabel, mediaVersionsOf } from '../utils/mediaVersion'
type MediaVersionSwitcherProps = {
media: Media
@@ -31,14 +31,30 @@ export function MediaVersionSwitcher({
{versions.map((version) => {
const active = version.id === media.id
+ const isStrm = isStrmMedia(version)
const label = mediaVersionLabel(version)
- const className = dark
+ const btnClass = dark
? active
- ? 'border-white/40 bg-white/20 text-white'
- : 'border-white/15 bg-white/5 text-white/80 hover:bg-white/15'
+ ? 'border-white/50 bg-white/20 text-white shadow-sm ring-1 ring-white/20'
+ : 'border-white/15 bg-white/5 text-white/80 hover:bg-white/15 hover:text-white'
: active
- ? 'border-brand-500/40 bg-brand-50 text-[#b07d35]'
+ ? 'border-brand-500/40 bg-brand-50 text-[#b07d35] font-bold shadow-sm'
: 'border-gray-200 bg-white text-ink-100 hover:border-brand-500/30 hover:bg-brand-50/40'
+
+ const content = (
+
+ {isStrm && (
+
+ )}
+ {label}
+ {!dark && active ? · 当前 : null}
+
+ )
+
if (mode === 'player') {
return (
)
}
@@ -58,11 +74,10 @@ export function MediaVersionSwitcher({
key={version.id}
to={`/play/${version.id}`}
state={{ from: `/media/${media.id}` }}
- className={`rounded-xl border px-3 py-1.5 text-xs font-semibold transition ${className}`}
- title={version.path}
+ className={`rounded-xl border px-3 py-1.5 text-xs font-semibold transition ${btnClass}`}
+ title={version.path || version.strm_url}
>
- {label}
- {active ? ' · 当前' : ''}
+ {content}
)
})}
diff --git a/web/src/utils/mediaVersion.ts b/web/src/utils/mediaVersion.ts
index dba99d1..26fbbea 100644
--- a/web/src/utils/mediaVersion.ts
+++ b/web/src/utils/mediaVersion.ts
@@ -1,33 +1,62 @@
import type { Media } from '../types'
-/** 从路径 / strm URL 推断容器扩展名(mkv/mp4…)。 */
+const VIDEO_EXTENSIONS = new Set([
+ 'mkv', 'mp4', 'm4v', 'avi', 'mov', 'webm', 'flv', 'wmv', 'ts', 'm2ts', 'mts', 'vob', 'rmvb', 'rm', 'iso',
+])
+
+export function isStrmMedia(media?: Media | null): boolean {
+ if (!media) return false
+ const path = (media.path || '').toLowerCase()
+ const strmUrl = (media.strm_url || '').toLowerCase()
+ return path.endsWith('.strm') || path.startsWith('cloud://') || Boolean(strmUrl.trim())
+}
+
+/** 从路径 / strm URL 推断真实视频容器扩展名(mkv/mp4…)。 */
export function mediaVersionContainer(media: Media): string {
+ const explicit = (media.container || '').trim().toLowerCase().replace(/^\./, '')
+ if (explicit && explicit !== 'strm' && VIDEO_EXTENSIONS.has(explicit)) {
+ return explicit
+ }
+
const path = (media.path || '').replace(/\\/g, '/')
const base = path.split('/').pop() || ''
const ext = base.includes('.') ? base.slice(base.lastIndexOf('.')).toLowerCase() : ''
- let name = ext ? base.slice(0, -ext.length) : base
+ const name = ext ? base.slice(0, -ext.length) : base
+
if (ext === '.strm') {
- const second = name.includes('.') ? name.slice(name.lastIndexOf('.')).toLowerCase() : ''
- if (second && second !== '.strm') {
- return second.replace(/^\./, '')
+ const second = name.includes('.') ? name.slice(name.lastIndexOf('.')).toLowerCase().replace(/^\./, '') : ''
+ if (second && VIDEO_EXTENSIONS.has(second)) {
+ return second
}
+
const strm = (media.strm_url || '').toLowerCase()
- const marker = '/video.'
- const idx = strm.lastIndexOf(marker)
- if (idx >= 0) {
- let rest = strm.slice(idx + marker.length)
- const end = rest.search(/[?#&/]/)
- if (end >= 0) rest = rest.slice(0, end)
- rest = rest.replace(/^\./, '').trim()
- if (rest) return rest
+ if (strm) {
+ // 1. /video.mkv 或 /video.mp4 模式
+ const marker = '/video.'
+ const idx = strm.lastIndexOf(marker)
+ if (idx >= 0) {
+ let rest = strm.slice(idx + marker.length)
+ const end = rest.search(/[?#&/]/)
+ if (end >= 0) rest = rest.slice(0, end)
+ rest = rest.replace(/^\./, '').trim()
+ if (rest && VIDEO_EXTENSIONS.has(rest)) return rest
+ }
+
+ // 2. 从 query 参数(如 ref=/movies/abc.mkv 或 path=...)或 pathname 里匹配扩展名
+ const match = strm.match(/\.(mkv|mp4|m4v|avi|mov|webm|flv|wmv|ts|m2ts|iso)(?=[?#&/]|$)/i)
+ if (match?.[1]) {
+ return match[1].toLowerCase()
+ }
}
return 'strm'
}
- return ext.replace(/^\./, '')
+
+ const cleanExt = ext.replace(/^\./, '')
+ return VIDEO_EXTENSIONS.has(cleanExt) ? cleanExt : cleanExt
}
function formatSize(bytes: number): string {
- if (!bytes || bytes < 1024) return bytes ? `${bytes} B` : ''
+ if (!bytes || bytes < 1024) return ''
const units = ['KB', 'MB', 'GB', 'TB']
let value = bytes
let unit = -1
@@ -38,19 +67,99 @@ function formatSize(bytes: number): string {
return `${value.toFixed(1)} ${units[unit]}`
}
-/** 版本切换展示名:分辨率 · 容器 · 编码 · 体积,缺省回退文件名。 */
+/** 从文件名/路径中提取质量或规格标签(如 4K, 2160p, REMUX, HDR, 1080p, 60fps 等) */
+function extractQualityKeywords(text: string): string[] {
+ const result: string[] = []
+ const upper = text.toUpperCase()
+
+ // 分辨率
+ if (/\b(4K|2160P|UHD)\b/i.test(upper)) result.push('4K')
+ else if (/\b(1080P|FHD)\b/i.test(upper)) result.push('1080p')
+ else if (/\b(720P|HD)\b/i.test(upper)) result.push('720p')
+
+ // 格式/版本规格
+ if (/\bREMUX\b/i.test(upper)) result.push('REMUX')
+ else if (/\b(BLURAY|BLU-RAY|BD)\b/i.test(upper)) result.push('BluRay')
+ else if (/\b(WEB-?DL|WEBRIP)\b/i.test(upper)) result.push('WEB-DL')
+
+ // 动态范围/画质技术
+ if (/\b(DV|DOVI|DOLBY[\s._-]?VISION)\b/i.test(upper)) result.push('DV')
+ if (/\b(HDR10\+|HDR10PLUS)\b/i.test(upper)) result.push('HDR10+')
+ else if (/\bHDR\b/i.test(upper) && !result.includes('DV')) result.push('HDR')
+
+ // 帧率
+ if (/\b(60FPS|120FPS)\b/i.test(upper)) {
+ const fpsMatch = upper.match(/\b(60FPS|120FPS)\b/i)
+ if (fpsMatch) result.push(fpsMatch[1])
+ }
+
+ return result
+}
+
+/** 清理文件名以用作干净的兜底展示 */
+function cleanFallbackFilename(raw: string): string {
+ let name = raw.replace(/\\/g, '/').split('/').pop() || ''
+ // 去除 .strm 及前缀扩展名
+ name = name.replace(/\.strm$/i, '')
+ name = name.replace(/\.(mkv|mp4|m4v|avi|mov|webm|flv|wmv|ts|iso)$/i, '')
+ // 替换连续的点或下划线为空格
+ name = name.replace(/[._]+/g, ' ').trim()
+ return name
+}
+
+/** 版本切换展示名:分辨率 · 容器 · 编码 · 体积 / 规格 / 来源标签。 */
export function mediaVersionLabel(media: Media): string {
+ const isStrm = isStrmMedia(media)
const parts: string[] = []
- if (media.height > 0) parts.push(`${media.height}p`)
- else if (media.width > 0) parts.push(`${media.width}w`)
+
+ // 1. 分辨率(优先从媒体元数据获取,其次从路径中提取)
+ if (media.height > 0) {
+ if (media.height >= 2100) parts.push('4K')
+ else parts.push(`${media.height}p`)
+ } else if (media.width > 0) {
+ if (media.width >= 3800) parts.push('4K')
+ else parts.push(`${media.width}w`)
+ }
+
+ // 2. 容器
const container = mediaVersionContainer(media)
- if (container && container !== 'strm') parts.push(container.toUpperCase())
- if (media.video_codec?.trim()) parts.push(media.video_codec.trim().toUpperCase())
- const size = formatSize(media.size_bytes)
- if (size) parts.push(size)
- if (parts.length > 0) return parts.join(' · ')
- const base = (media.path || '').replace(/\\/g, '/').split('/').pop()
- return base || media.title || media.id
+ if (container && container !== 'strm') {
+ parts.push(container.toUpperCase())
+ }
+
+ // 3. 视频编码
+ if (media.video_codec?.trim()) {
+ parts.push(media.video_codec.trim().toUpperCase())
+ }
+
+ // 4. 从文件名/路径补充规格特征(针对 STRM 缺乏探测数据时的关键画质提示)
+ const pathKeywords = extractQualityKeywords(`${media.path || ''} ${media.strm_url || ''}`)
+ for (const kw of pathKeywords) {
+ // 避免重复(如已经有了 4K / 1080p)
+ if (!parts.some((p) => p.toUpperCase() === kw.toUpperCase())) {
+ parts.push(kw)
+ }
+ }
+
+ // 5. 文件大小:过滤掉本地 STRM 占位小文件(< 1MB 的 STRM 索引通常不是真实视频大小)
+ const isPlaceholderSize = isStrm && media.size_bytes > 0 && media.size_bytes < 1024 * 1024
+ if (media.size_bytes > 0 && !isPlaceholderSize) {
+ const size = formatSize(media.size_bytes)
+ if (size) parts.push(size)
+ }
+
+ // 6. 如果仍然没有解析出足够特征,标记来源(云盘 / STRM)
+ if (isStrm && !parts.some((p) => ['4K', '1080P', '720P', 'REMUX', 'WEB-DL', 'MKV', 'MP4'].includes(p.toUpperCase()))) {
+ parts.push('云端直链')
+ }
+
+ if (parts.length > 0) {
+ return parts.join(' · ')
+ }
+
+ // 兜底:使用清理后的文件名
+ const cleaned = cleanFallbackFilename(media.path || '')
+ return cleaned || media.title || media.id
}
export function mediaVersionsOf(media: Media | null | undefined): Media[] {