feat: 网页端完整浏览/播放远程 Emby 挂载库

- 后端新增远程库→本地结构映射(emby_remote_web.go):Library/Media/SeriesCard
  与本地完全同构,前端无感知
- /api/libraries(含 with_preview)、/api/libraries/:id、/api/libraries/:id/media、
  /api/libraries/:id/series、/api/libraries/:id/series/episodes、/api/media/:id、
  /api/media/:id/episodes 全部支持远程伪装 ID 透传映射
- /api/stream/:id 对远程条目 302 直连远程 Emby 原地址(播放不经过本机)
- 库内容按 CollectionType 过滤(电影库取 Movie、剧集库取 Series),
  RecursiveItemCount 作为系列集数;修复伪装 ID 双重编码导致的图片/详情 500
- SPA fallback 放行 /library/embyremote~ 前缀(不再被当作 Emby API 路由 404)
- 前端:远程库/条目标记 is_remote_emby,隐藏扫描/刮削/编辑/NFO/回收站/
  HLS 转码等不适用操作;后台管理面板过滤远程库
This commit is contained in:
truewhile
2026-09-01 11:32:54 +08:00
parent 6af0e5fdcf
commit 4dc9cbe2e7
12 changed files with 774 additions and 25 deletions
+6
View File
@@ -1,6 +1,7 @@
import { Database, FileText, Search, Sparkles, Trash2 } from 'lucide-react'
import type { Media } from '../types'
import { isRemoteEmbyID } from '../utils/remoteEmby'
type LibraryMovieActionsProps = {
media: Media
@@ -23,6 +24,11 @@ export function LibraryMovieActions({
}: LibraryMovieActionsProps) {
const buttonClass = 'flex h-8 w-8 items-center justify-center rounded-lg border border-white/70 bg-white/90 text-gray-700 shadow-sm backdrop-blur transition hover:bg-brand-50 hover:text-brand-600 disabled:opacity-50'
// 远程 Emby 条目为只读视图:刮削/探测/NFO/回收站不适用。
if (isRemoteEmbyID(media.id)) {
return null
}
return (
<>
<button title="智能刮削" disabled={busy} onClick={() => onSmartScrape(media)} className={buttonClass}>
+1 -1
View File
@@ -63,7 +63,7 @@ export function LibraryPageHeader({
onChange={onSortChange}
onReshuffle={onReshuffle}
/>
{isAdmin && (
{isAdmin && !library?.is_remote_emby && (
<>
<EpisodeArtworkToggle
checked={scrapeEpisodeArtwork}
+2 -1
View File
@@ -1,3 +1,4 @@
import { isRemoteEmbyID } from '../utils/remoteEmby'
import { Link } from 'react-router-dom'
import { ArrowLeft, Database, FileText, Film, FolderInput, Pencil, Play, Search, Sparkles, Trash2 } from 'lucide-react'
@@ -84,7 +85,7 @@ export function LibrarySeriesDetailHeader({
</div>
)}
{isAdmin && allEpisodes.length > 0 && (
{isAdmin && allEpisodes.length > 0 && !isRemoteEmbyID(series.rep.id) && (
<div className="rounded-2xl border border-sand-200 bg-white/80 p-4 shadow-sm">
<p className="mb-3 text-[10px] font-bold uppercase tracking-[0.2em] text-[#c9954a]">系统后台高级控制面板</p>
<div className="flex flex-wrap gap-2">
+5
View File
@@ -2,6 +2,7 @@ import { Database, FileText, FolderInput, Pencil, Search, Sparkles, Trash2 } fro
import { EpisodeArtworkToggle } from '../components/EpisodeArtworkToggle'
import type { Media } from '../types'
import { isRemoteEmbyID } from '../utils/remoteEmby'
type MediaDetailAdminPanelProps = {
media: Media
@@ -28,6 +29,10 @@ export function MediaDetailAdminPanel({
onExportNFO,
onSoftDelete,
}: MediaDetailAdminPanelProps) {
if (isRemoteEmbyID(media.id)) {
return null
}
return (
<div className="rounded-2xl border border-gray-200 bg-gray-50/50 p-5 space-y-3">
<p className="text-[10px] font-bold uppercase tracking-[0.2em] text-[#c9954a]">系统后台高级控制面板</p>
+7 -4
View File
@@ -1,3 +1,4 @@
import { isRemoteEmbyID } from '../utils/remoteEmby'
import { ArrowLeft, Heart, Play, RefreshCw } from 'lucide-react'
import { Link } from 'react-router-dom'
@@ -95,14 +96,16 @@ export function MediaDetailPlaybackActions({
<span>立即播放</span>
</Link>
<Link
{!isRemoteEmbyID(media.id) && (
<Link
to={`/play/${media.id}?mode=hls`}
state={{ from: `/media/${media.id}` }}
className="btn-outline border-brand-500/30 hover:border-brand-500 text-[#c9954a] hover:bg-brand-50 px-5"
>
<RefreshCw size={14} className="animate-spin-slow" />
<span>HLS 兼容转码播放</span>
</Link>
<RefreshCw size={14} className="animate-spin-slow" />
<span>HLS 兼容转码播放</span>
</Link>
)}
<ExternalPlayerButton mediaId={media.id} />
+4 -1
View File
@@ -18,7 +18,10 @@ export function useAdminLibraryPanel() {
function useAdminLibraryList() {
const [libs, setLibs] = useState<Library[]>([])
const refresh = () => libraryAPI.list({ includeHidden: true }).then(setLibs)
const refresh = () =>
libraryAPI
.list({ includeHidden: true })
.then((libs) => setLibs(libs.filter((l) => !l.is_remote_emby))) // 远程挂载库只读,不在后台管理列表内
useEffect(() => {
refresh().catch(() => undefined)
+3
View File
@@ -21,6 +21,9 @@ export interface Library {
roots?: LibraryRoot[]
created_at: string
updated_at: string
/** 远程 Emby 挂载库(只读,不支持扫描/刮削/编辑) */
is_remote_emby?: boolean
remote_source?: string
}
export interface ScanResult {
+6
View File
@@ -0,0 +1,6 @@
// 远程 Emby 挂载条目 / 媒体库的识别与只读标记。
// 与后端 internal/service/emby_remote_ids.go 的 ID 伪装协议保持一致:
// embyremote~{accountID}~{remoteID}。
export function isRemoteEmbyID(id?: string | null): boolean {
return Boolean(id && id.startsWith('embyremote~'))
}