Skip to content

feat(func): media library#396

Open
PIKACHUIM wants to merge 3 commits intomainfrom
dev-media
Open

feat(func): media library#396
PIKACHUIM wants to merge 3 commits intomainfrom
dev-media

Conversation

@PIKACHUIM
Copy link
Member

@PIKACHUIM PIKACHUIM commented Mar 9, 2026

Description / 描述

本次 PR 为 OpenList 引入了主要功能:媒体库(Media Library),同时对前端整体布局进行了重构,新增了全局侧边栏导航。

1. 媒体库(Media Library)

  • 后端
    • 新增统一媒体数据模型 MediaItem,通过 media_type 字段区分影视、音乐、图片、书籍四种类型,支持刮削元数据(封面、评分、简介、演员/作者等)。
    • 新增 MediaConfig 配置模型,支持按类型配置扫描路径、路径合并模式等。
    • 新增媒体扫描器(internal/media/scanner.go)和多源刮削器(TMDB、Discogs、豆瓣、本地书籍、图片)。
    • 新增 ID3 标签解析(internal/media/id3.go)用于音乐文件元数据提取。
    • 新增管理 API(/admin/media/...):配置管理、条目 CRUD、扫描/刮削触发、数据库清空。
    • 新增公开 API(/fs/media/...):媒体列表、详情、专辑列表、专辑曲目、文件夹列表。
  • 前端
    • 新增四个媒体浏览页面:VideoLibrary(影视)、MusicLibrary(音乐,含全局悬浮播放器)、ImageLibrary(图片)、BookLibrary(书籍)。
    • 新增 MediaBrowserMediaLayoutMediaSettings 等通用媒体组件。
    • 新增媒体库管理后台页面 MediaManage.tsx,支持扫描进度展示、条目编辑、刮削触发。
    • 新增 src/utils/media_api.ts 封装所有媒体相关 API 调用。
    • 新增 src/types/media.ts 定义媒体相关 TypeScript 类型。

2. 全局侧边栏布局重构

  • 新增 GlobalSidebar.tsx:全局固定侧边栏,支持折叠/展开、透明模式、亮暗主题切换、移动端汉堡菜单,导航项包含文件、影视、音乐、图片、书籍。
  • 新增 RootLayout.tsx:根布局组件,将侧边栏与顶栏(含面包屑、搜索、布局切换)统一管理,替代原有的 Header 组件。
  • 重构 App.tsx:所有主要路由(文件浏览、媒体库各页面)均包裹在 RootLayout 中。
  • 重构 Body.tsx / Layout.tsx:移除原有 HeaderContainer 组件,适配新布局结构。

Motivation and Context / 背景

OpenList 此前仅提供文件浏览功能,缺乏对媒体内容的专项管理与展示能力。本次改动旨在:

  1. 媒体库:为用户提供影视、音乐、图片、书籍的统一管理入口,支持自动扫描、元数据刮削(TMDB/Discogs/豆瓣等),提升媒体内容的浏览体验。
  2. 侧边栏布局:提供更现代、更直观的导航体验,方便用户在文件浏览与各媒体库之间快速切换。

How Has This Been Tested? / 测试

  • 本地启动前端,验证虚拟主机管理页面的增删改查流程。
  • 配置媒体库扫描路径,触发扫描并验证媒体条目入库、刮削元数据填充。
  • 验证影视、音乐、图片、书籍四个媒体浏览页面的列表展示、搜索、详情查看功能。
  • 验证音乐播放器(全局悬浮)的播放、暂停、切曲功能。
  • 验证侧边栏折叠/展开、透明模式、亮暗主题切换、移动端响应式布局。

Checklist / 检查清单

  • I have read the CONTRIBUTING document.
    我已阅读 CONTRIBUTING 文档。
  • I have formatted my code with go fmt or prettier.
    我已使用 go fmtprettier 格式化提交的代码。
  • I have added appropriate labels to this PR (or mentioned needed labels in the description if lacking permissions).
    我已为此 PR 添加了适当的标签(如无权限或需要的标签不存在,请在描述中说明,管理员将后续处理)。
  • I have requested review from relevant code authors using the "Request review" feature when applicable.
    我已在适当情况下使用"Request review"功能请求相关代码作者进行审查。
  • I have updated the repository accordingly (If it's needed).
    我已相应更新了相关仓库(若适用)。

@PIKACHUIM PIKACHUIM requested review from KirCute, Copilot and jyxjjj March 9, 2026 12:27
@PIKACHUIM PIKACHUIM requested review from cxw620 and removed request for cxw620 March 9, 2026 12:27
Copy link
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR adds the frontend surface area for OpenList’s new Media Library (video/music/image/book browsing + admin management) and Virtual Host management, and introduces a new RootLayout + GlobalSidebar navigation shell that wraps the main app routes.

Changes:

  • Add media API wrappers + TS types, plus four new media browsing pages and shared browser/layout components.
  • Add admin UI for Virtual Hosts (list + add/edit) and wire it into the manage side menu + routes + i18n.
  • Refactor the main app layout by introducing RootLayout and a global sidebar/topbar, and adjusting home toolbar/body usage accordingly.

Reviewed changes

Copilot reviewed 27 out of 27 changed files in this pull request and generated 12 comments.

Show a summary per file
File Description
src/utils/media_api.ts Adds media-related public/admin API wrappers used by new media UI.
src/types/virtual_host.ts Adds VirtualHost type for manage pages.
src/types/setting.ts Adds Group.MEDIA for media-related settings grouping.
src/types/media.ts Adds media domain types + helper utilities.
src/types/index.ts Re-exports new media + vhost types.
src/pages/media/video/VideoLibrary.tsx Implements video library browsing + detail + inline player.
src/pages/media/music/MusicLibrary.tsx Implements music library browsing + album detail + global audio player + lyrics UI.
src/pages/media/image/ImageLibrary.tsx Implements image browsing + fullscreen viewer.
src/pages/media/book/BookLibrary.tsx Implements book browsing + PDF/EPUB reader UI.
src/pages/media/MediaSettings.tsx Adds media settings page (thumbnail/cover storage behavior).
src/pages/media/MediaLayout.tsx Shared layout wrapper for media pages (padding for music player).
src/pages/media/MediaBrowser.tsx Shared media browser (waterfall/list, search/sort, folder browsing).
src/pages/manage/virtual_hosts/VirtualHosts.tsx Virtual host list UI + delete operation.
src/pages/manage/virtual_hosts/AddOrEdit.tsx Virtual host create/edit form.
src/pages/manage/sidemenu_items.tsx Wires Virtual Hosts + Media Library manage section into sidebar menu.
src/pages/manage/routes.tsx Adds hidden routes for virtual host add/edit pages.
src/pages/manage/media/MediaManage.tsx Adds admin media management UI (config, scan/scrape, CRUD).
src/pages/home/toolbar/Right.tsx Moves file actions into a topbar-friendly component; stubs old floating toolbar.
src/pages/home/Layout.tsx Removes old header usage and adapts to new layout container.
src/pages/home/Body.tsx Removes embedded Nav/Container and adapts body spacing to new layout.
src/lang/en/virtual_hosts.json Adds i18n strings for virtual host UI.
src/lang/en/settings.json Adds i18n strings for media-related settings keys.
src/lang/en/manage.json Adds i18n strings for new manage menu entries.
src/lang/en/entry.ts Registers virtual host i18n dictionary.
src/components/GlobalSidebar.tsx Adds global sidebar navigation + theme controls + transparency mode.
src/app/RootLayout.tsx Adds root layout with global sidebar and new topbar (breadcrumbs/search/actions/layout toggle).
src/app/App.tsx Wraps key routes in RootLayout and adds media routes (with global music player).

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines +22 to +32
import { MusicPlayer } from "~/pages/media/music/MusicLibrary"
import { RootLayout } from "./RootLayout"

const Home = lazy(() => import("~/pages/home/Layout"))
const Manage = lazy(() => import("~/pages/manage"))
const Login = lazy(() => import("~/pages/login"))
const Test = lazy(() => import("~/pages/test"))
const VideoLibrary = lazy(() => import("~/pages/media/video/VideoLibrary"))
const MusicLibrary = lazy(() => import("~/pages/media/music/MusicLibrary"))
const ImageLibrary = lazy(() => import("~/pages/media/image/ImageLibrary"))
const BookLibrary = lazy(() => import("~/pages/media/book/BookLibrary"))
Copy link

Copilot AI Mar 9, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

MusicPlayer is imported from ~/pages/media/music/MusicLibrary, which likely defeats the lazy(() => import(...MusicLibrary)) route-level code-splitting (the music module becomes part of the initial bundle). Extract MusicPlayer/playerState into a separate lightweight module and import that here, keeping the full MusicLibrary page lazily loaded.

Copilot uses AI. Check for mistakes.
Comment on lines +67 to +74
export const playTrack = (item: MediaItem) => {
const audio = initAudio()
// 使用文件路径构建播放URL
const url = `${base_path}/d${item.file_path}`
audio.src = url
audio.play()
setPlayerState((s) => ({ ...s, playing: true }))
}
Copy link

Copilot AI Mar 9, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

HTMLMediaElement.play() returns a Promise and can reject (e.g. autoplay restrictions). Calling it without handling the promise can trigger unhandled promise rejections in the console and leave playerState.playing out of sync with actual playback. Consider awaiting/catching the promise and only setting playing: true on success (or reverting on failure).

Copilot uses AI. Check for mistakes.
Comment on lines 1 to 16
@@ -14,146 +14,104 @@ import { Motion } from "solid-motionone"
import { isTocVisible, setTocDisabled } from "~/components"
import { BiSolidBookContent } from "solid-icons/bi"

Copy link

Copilot AI Mar 9, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Several imports are now unused (Box, createDisclosure, VStack, CgMoreO, Motion). Keeping them makes the file harder to maintain and can confuse readers about the intended behavior. Remove unused imports now that the floating toolbar has been replaced by TopBarActions.

Copilot uses AI. Check for mistakes.
Comment on lines +53 to +61
export interface MediaConfig {
id?: number
media_type: MediaType
enabled: boolean
scan_path: string
path_merge: boolean
last_scan_at: string | null
last_scrape_at: string | null
}
Copy link

Copilot AI Mar 9, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

MediaConfig is missing fields that are used elsewhere in this PR (e.g. scraper_config in MediaSettings.tsx). With strict TS this causes property access errors and also breaks adminSaveMediaConfig({ ...imageConfig, scraper_config: ... }) due to excess-property checks. Add the missing fields to MediaConfig (and/or define a dedicated type for scraper config) so the settings and API wrappers type-check.

Copilot uses AI. Check for mistakes.
Comment on lines +68 to +112
if (imageConfig) {
try {
const sc = JSON.parse(imageConfig.scraper_config || "{}")
setImageStoreThumbnail(sc.store_thumbnail === "true")
setImageThumbnailMode(
(sc.thumbnail_mode as "base64" | "local") || "base64",
)
setImageThumbnailPath(sc.thumbnail_path || "/.thumbnail")
} catch {}
}
if (bookConfig) {
try {
const sc = JSON.parse(bookConfig.scraper_config || "{}")
setBookThumbnailMode(
(sc.thumbnail_mode as "base64" | "local") || "base64",
)
setBookThumbnailPath(sc.thumbnail_path || "/.thumbnail")
} catch {}
}
})

const handleSave = async () => {
setSaving(true)
setSaveMsg("")
const configs = configData()
if (!configs) {
setSaving(false)
return
}

try {
// 保存图片配置
const imageConfig = configs.find((c) => c.media_type === "image")
if (imageConfig) {
let sc: Record<string, string> = {}
try {
sc = JSON.parse(imageConfig.scraper_config || "{}")
} catch {}
sc.store_thumbnail = imageStoreThumbnail() ? "true" : "false"
sc.thumbnail_mode = imageThumbnailMode()
sc.thumbnail_path = imageThumbnailPath()
await adminSaveMediaConfig({
...imageConfig,
scraper_config: JSON.stringify(sc),
})
Copy link

Copilot AI Mar 9, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This component reads/writes imageConfig.scraper_config / bookConfig.scraper_config, but MediaConfig (as added in this PR) doesn't define that property. This will fail TypeScript compilation under strict (property doesn't exist + excess-property checks when calling adminSaveMediaConfig). Update the MediaConfig type (or cast appropriately) so these accesses are valid and type-safe.

Copilot uses AI. Check for mistakes.
Comment on lines +211 to +215
// 与 GlobalSidebar 中的 sidebarWidth 保持一致:180px / 56px
const marginLeft = createMemo(() => {
if (isMobile()) return "0px"
return sidebarCollapsed() ? "48px" : "120px"
})
Copy link

Copilot AI Mar 9, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

margin-left is inconsistent with the actual sidebar width. GlobalSidebar uses 48px (collapsed) / 130px (expanded), but here the expanded margin is 120px, which will cause the content to overlap the sidebar by ~10px. Consider deriving this from a shared constant or exporting the computed width from GlobalSidebar to avoid drift.

Copilot uses AI. Check for mistakes.
Comment on lines +48 to +67
onMount(async () => {
// 动态加载 pdf.js CDN
const pdfjsLib = (window as any).pdfjsLib
if (!pdfjsLib) {
const script = document.createElement("script")
script.src =
"https://cdnjs.cloudflare.com/ajax/libs/pdf.js/3.11.174/pdf.min.js"
script.onload = () => initPDF()
document.head.appendChild(script)
} else {
initPDF()
}
})

const initPDF = async () => {
try {
const pdfjsLib = (window as any).pdfjsLib
pdfjsLib.GlobalWorkerOptions.workerSrc =
"https://cdnjs.cloudflare.com/ajax/libs/pdf.js/3.11.174/pdf.worker.min.js"
pdfDoc = await pdfjsLib.getDocument(props.url).promise
Copy link

Copilot AI Mar 9, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This introduces runtime loading of third-party scripts from cdnjs.cloudflare.com (pdf.js / epub.js). This is a security and operational risk (supply-chain compromise, CSP breakage, offline/self-hosted deployments, version drift). Prefer bundling these libs as dependencies or serving them from your own origin with integrity checks (SRI) and explicit CSP allowances.

Copilot uses AI. Check for mistakes.
Comment on lines +6 to +21
interface MediaLayoutProps {
children: JSX.Element
title: string
headerRight?: JSX.Element
}

export const MediaLayout = (props: MediaLayoutProps) => {
const { colorMode } = useColorMode()
const isDark = createMemo(() => colorMode() === "dark")

// 跟随主题的颜色 token
const bg = createMemo(() =>
isDark() ? "rgba(15,20,35,1)" : "rgba(248,250,252,1)",
)
const titleColor = createMemo(() => (isDark() ? "#f1f5f9" : "#0f172a"))

Copy link

Copilot AI Mar 9, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

MediaLayout declares title/headerRight props (and even computes titleColor) but never renders them. This makes call sites misleading (they pass a title that is ignored) and leaves dead code. Either render a header using these props or remove them from the interface to match actual behavior.

Copilot uses AI. Check for mistakes.
Comment on lines +1 to +5
import { JSX } from "solid-js"
import { useColorMode } from "@hope-ui/solid"
import { createMemo } from "solid-js"
import { playerState } from "./music/MusicLibrary"

Copy link

Copilot AI Mar 9, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

MediaLayout imports playerState from the huge MusicLibrary module just to compute padding. This creates a tight coupling and can force the entire music page code into any media page bundle (hurting code-splitting and initial load). Consider moving playerState (and MusicPlayer) into a small shared store/module that both MediaLayout and MusicLibrary import.

Copilot uses AI. Check for mistakes.
Comment on lines +139 to +143
// 数据变化时通知父组件
createEffect(() => {
const list = items()
if (list.length > 0) props.onItemsChange?.(list)
})
Copy link

Copilot AI Mar 9, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

onItemsChange is only called when items().length > 0. When a query/page/filter change returns an empty list, parents will keep stale items from the previous fetch (e.g. the image viewer in ImageLibrary can keep an old currentPageItems). Call onItemsChange for empty lists as well so parent state stays in sync.

Copilot uses AI. Check for mistakes.
Copy link
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 21 out of 21 changed files in this pull request and generated 6 comments.


💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines +109 to +113
await adminSaveMediaConfig({
...imageConfig,
scraper_config: JSON.stringify(sc),
})
}
Copy link

Copilot AI Mar 9, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

handleSave 里调用 adminSaveMediaConfig 后没有检查返回的 resp.code,即使后端返回业务错误(HTTP 200 但 code != 200)也会显示“保存成功”。建议捕获并判断响应码/消息(或复用现有 handleResp* 工具)后再设置成功提示。

Copilot uses AI. Check for mistakes.
Comment on lines +498 to +503
{item.authors
? JSON.parse(item.authors || "[]")
.slice(0, 2)
.join(", ")
: "-"}
</div>
Copy link

Copilot AI Mar 9, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

此处直接 JSON.parse(item.authors) 没有 try/catch;只要后端返回的 authors 不是合法 JSON(或是普通字符串)就会导致整个管理表格渲染崩溃。建议复用已存在的 parseAuthors 容错解析(或至少包一层 try/catch 并回退到 -)。

Copilot uses AI. Check for mistakes.
Comment on lines +70 to +75
const sc = JSON.parse(imageConfig.scraper_config || "{}")
setImageStoreThumbnail(sc.store_thumbnail === "true")
setImageThumbnailMode(
(sc.thumbnail_mode as "base64" | "local") || "base64",
)
setImageThumbnailPath(sc.thumbnail_path || "/.thumbnail")
Copy link

Copilot AI Mar 9, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

MediaConfig 类型中未定义 scraper_config,但这里多处读写 imageConfig.scraper_config / bookConfig.scraper_config,在 TS strict 下会直接编译报错。建议在 src/types/media.tsMediaConfig 中补充 scraper_config?: string(或按后端实际字段命名/类型调整),并同步更新相关 API 类型。

Copilot uses AI. Check for mistakes.
Comment on lines +101 to +122
const handleScan = async () => {
setScanning(true)
setProgress({ status: "扫描中...", current: 0, total: 0 })
await scanMedia(props.mediaType)
// 轮询进度
const timer = setInterval(async () => {
const resp = await getMediaScanProgress(props.mediaType)
if (resp.code === 200 && resp.data) {
const d = resp.data
setProgress({
status: d.message || (d.running ? "扫描中..." : "完成"),
current: d.done,
total: d.total,
})
if (!d.running) {
clearInterval(timer)
setScanning(false)
refetchItems()
}
}
}, 1000)
}
Copy link

Copilot AI Mar 9, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

这里使用 setInterval 轮询扫描进度,但没有在组件卸载时清理定时器;如果用户切换路由/页面,会留下后台轮询导致内存泄漏和无效请求。建议把 timer 提升到外层并在 onCleanupclearInterval,同时在轮询失败/超时场景也清理。

Copilot uses AI. Check for mistakes.
Comment on lines +151 to +154
const handleNav = (path: string) => {
window.location.href = joinBase(path)
if (isMobile()) setSidebarVisible(false)
}
Copy link

Copilot AI Mar 9, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

侧边栏跳转使用 window.location.href 会触发整页刷新,导致 SPA 状态丢失(例如全局音乐播放器播放会被中断、已加载资源需要重新请求)。建议改用路由导航(如 useNavigate / useRouter().to)在应用内跳转,并仅在需要强制刷新时才使用 location.href

Copilot uses AI. Check for mistakes.
Comment on lines +36 to +52
const navItems: NavItem[] = [
{ icon: TbFolder, label: "文件", path: "/", desc: "文件管理" },
{
icon: BsPlayCircleFill,
label: "影视",
path: "/@media/video",
desc: "电影剧集",
},
{ icon: TbMusic, label: "音乐", path: "/@media/music", desc: "专辑歌曲" },
{ icon: BsCardImage, label: "图片", path: "/@media/image", desc: "相册图库" },
{
icon: BiSolidBookContent,
label: "书籍",
path: "/@media/books",
desc: "图书文档",
},
]
Copy link

Copilot AI Mar 9, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

GlobalSidebar 的 navItemslabel/desc 都是硬编码中文字符串,会导致切换到英文等语言时侧边栏仍显示中文。仓库内类似导航/标题通常走 i18n(例如 src/pages/home/Nav.tsx:24-71 使用 useT() + manage.sidemenu.*)。建议把这些文案改为翻译 key,并补充 src/lang/*/*.json 对应条目。

Copilot uses AI. Check for mistakes.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants