Skip to content

feat(func): media library#2210

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

feat(func): media library#2210
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).
    我已相应更新了相关仓库(若适用)。

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 introduces Virtual Host support and a new Media Library subsystem in the OpenList backend, along with supporting settings, DB models/migrations, and API routes.

Changes:

  • Added Virtual Host model, DB operations, admin CRUD APIs, and request-time host/path handling (including optional “web hosting” mode).
  • Added Media Library models/config, scanning pipeline, multi-source scraping (TMDB/Discogs/Douban/local), and admin/public APIs.
  • Updated database migrations and settings to include new Media-related configuration keys; updated sqlite driver usage and Go module dependencies.

Reviewed changes

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

Show a summary per file
File Description
server/static/static.go Adds vhost-aware catch-all handler and web-hosting file serving for unmatched routes.
server/router.go Registers new admin routes for vhosts + media management, and public media browsing APIs.
server/middlewares/virtual_host.go Adds a VirtualHost middleware (currently not wired into router).
server/middlewares/down.go Adds vhost path remapping for /d and /p download/proxy routes.
server/handles/virtual_host.go Implements admin CRUD handlers for virtual hosts.
server/handles/media.go Implements media config CRUD, scanning/scraping triggers, and public media browsing endpoints.
server/handles/fsread.go Adds vhost path remapping for /api/fs/list and /api/fs/get and adjusts /p link generation.
public/dist/README.md Removes placeholder README for frontend dist folder.
internal/op/virtual_host.go Adds cached vhost lookup and CRUD wrappers.
internal/model/virtual_host.go Adds VirtualHost model definition.
internal/model/setting.go Adds MEDIA setting group.
internal/model/media.go Adds MediaItem/MediaConfig models and progress struct.
internal/media/scanner.go Adds media scanner and file-reader helper used by scrapers.
internal/media/id3.go Adds ID3v2 + FLAC Vorbis Comment parsing for music metadata extraction.
internal/media/scraper/tmdb.go Adds TMDB scraper and filename parsing for video metadata.
internal/media/scraper/discogs.go Adds Discogs scraper for music/album metadata.
internal/media/scraper/douban.go Adds Douban scraper for book metadata and cover handling.
internal/media/scraper/book_local.go Adds local cover extraction for EPUB/PDF with optional local/base64 thumbnail storage.
internal/media/scraper/image.go Adds image EXIF parsing and thumbnail generation with optional local/base64 storage.
internal/db/virtual_host.go Adds VirtualHost DB queries and pagination.
internal/db/media.go Adds MediaConfig + MediaItem persistence/query helpers and album/folder queries.
internal/db/db.go Extends AutoMigrate to include new vhost + media models.
internal/conf/const.go Adds media-related setting keys and new context keys for vhost support.
internal/bootstrap/db.go Switches sqlite driver to github.com/glebarez/sqlite.
internal/bootstrap/data/setting.go Seeds default media settings (TMDB, Discogs, thumbnails).
go.mod Adds/updates dependencies for sqlite driver, EXIF, and bumps gorm versions.
go.sum Updates module checksums accordingly.

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

Comment on lines +456 to +473
func applyVhostPathMappingWithPrefix(c *gin.Context, reqPath string) (string, string) {
rawHost := c.Request.Host
domain := stripHostPortForVhost(rawHost)
if domain == "" {
return reqPath, ""
}
vhost, err := op.GetVirtualHostByDomain(domain)
if err != nil || vhost == nil {
return reqPath, ""
}
if !vhost.Enabled || vhost.WebHosting {
// 未启用,或者是 Web 托管模式(Web 托管不做路径重映射)
return reqPath, ""
}
// 路径重映射:将 reqPath 拼接到 vhost.Path 后面
mapped := stdpath.Join(vhost.Path, reqPath)
utils.Log.Debugf("[VirtualHost] API path remapping: domain=%q reqPath=%q -> mappedPath=%q", domain, reqPath, mapped)
return mapped, vhost.Path
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.

applyVhostPathMappingWithPrefix builds the mapped path via path.Join, which cleans .. segments. That can turn a request like path=/../secret into a mapped path outside vhost.Path, and it also defeats the existing relative-path protection in utils.JoinBasePath (because the .. disappears before user.JoinPath runs). Please join/validate using utils.JoinBasePath(vhost.Path, reqPath) (and handle errs.RelativePath), or explicitly reject any request path containing .. before joining.

Copilot uses AI. Check for mistakes.
Comment on lines +20 to +27
type MediaItem struct {
gorm.Model
// 覆盖 gorm.Model 的 ID 字段,使 JSON 序列化为小写 "id",与前端保持一致
ID uint `gorm:"primarykey" json:"id"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
DeletedAt gorm.DeletedAt `gorm:"index" json:"-"`
// 基础信息
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.

MediaItem embeds gorm.Model and then re-declares ID/CreatedAt/UpdatedAt/DeletedAt. This is likely to confuse GORM schema parsing/migrations (duplicate fields/columns) and makes the model harder to reason about. Prefer either (a) define the fields explicitly without embedding gorm.Model, or (b) embed gorm.Model and customize JSON via a separate response DTO / MarshalJSON rather than re-declaring the same columns.

Copilot uses AI. Check for mistakes.
Comment on lines +124 to +149
// 构建本地存储路径:ThumbnailPath/原文件路径.jpg
// 将文件路径中的 / 替换为 _ 避免目录嵌套问题
safeFileName := strings.ReplaceAll(strings.TrimPrefix(filePath, "/"), "/", "_") + ".jpg"
localDir := s.ThumbnailPath
if !filepath.IsAbs(localDir) {
// 相对路径转为绝对路径(相对于工作目录)
if wd, err := os.Getwd(); err == nil {
localDir = filepath.Join(wd, localDir)
}
}

// 确保目录存在
if err := os.MkdirAll(localDir, 0755); err != nil {
return ""
}

localFilePath := filepath.Join(localDir, safeFileName)
if err := os.WriteFile(localFilePath, buf.Bytes(), 0644); err != nil {
return ""
}

// 返回内部访问路径(通过 /d/ 前缀访问)
// 缩略图路径格式:ThumbnailPath/safeFileName
thumbVFSPath := strings.TrimSuffix(s.ThumbnailPath, "/") + "/" + safeFileName
return buildInternalDownloadPath(thumbVFSPath)
}
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.

In ThumbnailMode == "local", the default ThumbnailPath is "/.thumbnail" (see settings), and this code treats it as an OS path. That will try to create/write under the filesystem root (/.thumbnail), which will typically fail due to permissions, and it’s also unclear how this maps back to a VFS path for /d/... access. Consider storing under a writable app/data directory by default (e.g. conf.Conf.DataDir), and clearly separate the on-disk path from the VFS-access path.

Copilot uses AI. Check for mistakes.
Comment on lines +12 to +32
// VirtualHost 虚拟主机中间件,根据请求的 Host 头匹配虚拟主机配置
func VirtualHost(c *gin.Context) {
host := c.Request.Host
// 去掉端口号
domain := stripPort(host)
if domain == "" {
c.Next()
return
}

vhost, err := op.GetVirtualHostByDomain(domain)
if err != nil || !vhost.Enabled {
// 未找到匹配的虚拟主机或未启用,继续正常处理
c.Next()
return
}

// 将虚拟主机信息存入请求上下文
common.GinWithValue(c, conf.VirtualHostKey, vhost)
c.Next()
}
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 file defines a VirtualHost middleware and stores the matched vhost under conf.VirtualHostKey, but there are currently no references to middlewares.VirtualHost in the codebase (so the middleware never runs and the context value is never set). Either register it in the router (e.g. g.Use(middlewares.VirtualHost)) and update the path-mapping code to consume the context value, or remove this unused middleware to avoid dead code.

Copilot uses AI. Check for mistakes.
Comment on lines +225 to +228
fmt.Printf("[VirtualHost] handler triggered: method=%s path=%s host=%q domain=%q\n",
c.Request.Method, c.Request.URL.Path, rawHost, domain)
utils.Log.Infof("[VirtualHost] handler triggered: method=%s path=%s host=%q domain=%q",
c.Request.Method, c.Request.URL.Path, rawHost, domain)
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.

fmt.Printf writes directly to stdout (bypassing the project logger) and will be noisy in production; it also duplicates the subsequent utils.Log.Infof. Please remove the fmt.Printf and keep a single structured log line (preferably at Debug level).

Copilot uses AI. Check for mistakes.
Comment on lines +296 to +299
reqPath := c.Request.URL.Path
// 将请求路径映射到虚拟主机的根目录
filePath := stdpath.Join(vhost.Path, reqPath)
utils.Log.Infof("[VirtualHost] handleWebHosting: reqPath=%q -> filePath=%q", reqPath, filePath)
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.

reqPath comes from the URL and may contain relative segments (e.g. ..). Using path.Join(vhost.Path, reqPath) will clean those segments and can escape the configured vhost root (path traversal), potentially serving arbitrary VFS paths. Use the existing utils.JoinBasePath(vhost.Path, reqPath) (or equivalent validation) and return 400/404 on errs.RelativePath, and/or verify the final path stays under vhost.Path.

Copilot uses AI. Check for mistakes.
Comment on lines +72 to +75
query := item.ScrapedName
if query == "" {
query = strings.TrimSuffix(item.FileName, strings.ToLower(item.FileName[strings.LastIndex(item.FileName, "."):]))
}
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.

DoubanScraper.ScrapeBook can panic when item.FileName has no extension: strings.LastIndex(item.FileName, ".") returns -1, and slicing with [-1:] will crash. Please guard for LastIndex == -1 (or reuse the existing trimExt helper) when deriving the default query string.

Copilot uses AI. Check for mistakes.
Comment on lines +145 to +162
func (s *BookLocalScraper) writeLocalFile(filePath string, data []byte) string {
safeFileName := strings.ReplaceAll(strings.TrimPrefix(filePath, "/"), "/", "_") + ".jpg"
localDir := s.ThumbnailPath
if !filepath.IsAbs(localDir) {
if wd, err := os.Getwd(); err == nil {
localDir = filepath.Join(wd, localDir)
}
}
if err := os.MkdirAll(localDir, 0755); err != nil {
return ""
}
localFilePath := filepath.Join(localDir, safeFileName)
if err := os.WriteFile(localFilePath, data, 0644); err != nil {
return ""
}
thumbVFSPath := strings.TrimSuffix(s.ThumbnailPath, "/") + "/" + safeFileName
return buildInternalDownloadPath(thumbVFSPath)
}
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.

Same as ImageScraper: local thumbnail mode writes to ThumbnailPath as an OS directory. With the default "/.thumbnail", this will typically be unwritable, and the returned /d/... path may not correspond to where the file was written. Please ensure local thumbnail storage uses a known writable base directory by default and that the generated download path actually resolves to the on-disk file.

Copilot uses AI. Check for mistakes.
Comment on lines +11 to +21
func GetMediaConfig(mediaType model.MediaType) (*model.MediaConfig, error) {
var cfg model.MediaConfig
result := db.Where("media_type = ?", mediaType).First(&cfg)
if result.Error == gorm.ErrRecordNotFound {
// 返回默认配置
return &model.MediaConfig{
MediaType: mediaType,
Enabled: false,
ScanPath: "/",
PathMerge: false,
}, nil
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.

These queries check result.Error == gorm.ErrRecordNotFound. Elsewhere in the repo the pattern is errors.Is(err, gorm.ErrRecordNotFound) (important when errors may be wrapped). Consider switching to errors.Is here for consistency and robustness.

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 16 out of 18 changed files in this pull request and generated 12 comments.

Comments suppressed due to low confidence (1)

internal/db/db.go:21

  • Init currently contains unresolved Git merge-conflict markers (<<<<<<<, =======, >>>>>>>) and multiple duplicated AutoMigrate assignments. This will not compile and also makes the migration list ambiguous; resolve the conflict and keep a single err := AutoMigrate(...) call with the intended models.
	err := AutoMigrate(new(model.Storage), new(model.User), new(model.Meta), new(model.SettingItem), new(model.SearchNode), new(model.TaskItem), new(model.SSHPublicKey), new(model.SharingDB), new(model.MediaItem), new(model.MediaConfig))
	if err != nil {
		log.Fatalf("failed migrate database: %s", err.Error())
	}
}

func AutoMigrate(dst ...interface{}) error {

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

Comment on lines +72 to +79
// MediaConfig 媒体库配置(每种类型一条记录)
type MediaConfig struct {
gorm.Model
ID uint `gorm:"primarykey" json:"id"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
DeletedAt gorm.DeletedAt `gorm:"index" json:"-"`
MediaType MediaType `gorm:"uniqueIndex;not null" json:"media_type"`
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 has the same issue as MediaItem: it embeds gorm.Model and re-declares ID/CreatedAt/UpdatedAt/DeletedAt, which can lead to duplicate fields/columns and broken migrations. Remove the gorm.Model embed or the explicit fields so they are defined only once.

Copilot uses AI. Check for mistakes.
Comment on lines +23 to +30
type ImageScraper struct {
// StoreThumbnail 为 true 时生成缩略图写入 Cover;
// 为 false 时直接用文件路径作为 Cover,节省数据库空间。
StoreThumbnail bool
// ThumbnailMode 缩略图存储方式:"base64"(默认,存入数据库)或 "local"(存到本地文件)
ThumbnailMode string
// ThumbnailPath 本地存储路径,ThumbnailMode 为 "local" 时有效,默认 "/.thumbnail"
ThumbnailPath string
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.

The StoreThumbnail field comment says it can fall back to using the file path as Cover when false, but later comments/state say the scraper must never use file paths as covers. The code currently never uses StoreThumbnail at all. Please reconcile the documentation with the actual behavior (and either implement StoreThumbnail or remove it).

Copilot uses AI. Check for mistakes.
Comment on lines +244 to +251
// buildInternalDownloadPath 将 VFS 文件路径转为内部代理下载路径
// 格式:/d/path/to/file(前端通过此路径访问文件内容)
func buildInternalDownloadPath(filePath string) string {
if !strings.HasPrefix(filePath, "/") {
filePath = "/" + filePath
}
return "/d" + filePath
}
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.

buildInternalDownloadPath returns a bare /d/... path without adding ?sign= when signing is required (meta password/sign-all/storage sign). This can cause thumbnails/covers to fail to load under signed paths. Prefer generating download/proxy URLs using the existing signing helpers (e.g., sign.Sign(reqPath) and common.GetApiUrl(ctx) patterns) or otherwise ensure signed paths are handled.

Copilot uses AI. Check for mistakes.
Comment on lines +270 to +281
// GetUnscrappedItems 获取未刮削或刮削不完整的媒体条目
// 只要 scraped_at 为空,或 cover/scraped_name/description 任一为空,就需要重新刮削
func GetUnscrappedItems(mediaType model.MediaType, limit int) ([]model.MediaItem, error) {
var items []model.MediaItem
err := db.Where(
"media_type = ? AND (scraped_at IS NULL OR cover = '' OR cover IS NULL OR scraped_name = '' OR scraped_name IS NULL OR description = '' OR description IS NULL)",
mediaType,
).
Limit(limit).
Find(&items).Error
return items, err
}
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.

The function name GetUnscrappedItems uses a misspelling (“scrapped” vs “scraped”). Since this is a new API, renaming to GetUnscrapedItems would avoid propagating the typo into other packages and public-facing logs/messages.

Copilot uses AI. Check for mistakes.
Comment on lines +226 to +323
// StartMediaScrape 开始刮削
func StartMediaScrape(c *gin.Context) {
var req ScrapeMediaReq
if err := c.ShouldBindJSON(&req); err != nil {
common.ErrorResp(c, err, 400)
return
}

cfg, err := db.GetMediaConfig(req.MediaType)
if err != nil {
common.ErrorResp(c, err, 500)
return
}

// 从系统设置中读取刮削配置
tmdbKey := setting.GetStr(conf.MediaTMDBKey)
discogsToken := setting.GetStr(conf.MediaDiscogsToken)
thumbnailMode := setting.GetStr(conf.MediaThumbnailMode, "base64")
thumbnailPath := setting.GetStr(conf.MediaThumbnailPath, "/.thumbnail")
storeThumbnail := setting.GetBool(conf.MediaStoreThumbnail)

go func() {
var items []model.MediaItem
var err error

if req.ItemID > 0 {
item, e := db.GetMediaItemByID(req.ItemID)
if e == nil {
items = []model.MediaItem{*item}
}
} else {
items, err = db.GetUnscrappedItems(req.MediaType, 100)
if err != nil {
log.Errorf("获取未刮削条目失败: %v", err)
return
}
}

for i := range items {
item := &items[i]
var scrapeErr error

switch req.MediaType {
case model.MediaTypeVideo:
s := scraper.NewTMDBScraper(tmdbKey)
scrapeErr = s.ScrapeVideo(item)
case model.MediaTypeMusic:
s := scraper.NewDiscogsScraper(discogsToken)
scrapeErr = s.ScrapeMusic(item)
case model.MediaTypeBook:
// 步骤1:优先通过豆瓣刮削获取书名、评分、简介、封面
doubanScraper := scraper.NewDoubanScraperWithConfig(
thumbnailMode,
thumbnailPath,
)
doubanErr := doubanScraper.ScrapeBook(item)
if doubanErr != nil {
log.Debugf("豆瓣刮削失败 [%s]: %v,将尝试本地提取封面", item.FilePath, doubanErr)
}

// 步骤2:若豆瓣未能获取到封面(cover 为空),则本地读取文件提取封面
// 绝不将文件路径作为 cover
if item.Cover == "" {
bookCtx, bookCancel := context.WithTimeout(context.Background(), 60*time.Second)
bookReader := media.FetchFileReader(bookCtx, item.FilePath)
if bookReader != nil {
localScraper := scraper.NewBookLocalScraperWithConfig(
thumbnailMode,
thumbnailPath,
)
if localCover := localScraper.ExtractLocalCover(item.FileName, item.FilePath, bookReader); localCover != "" {
item.Cover = localCover
}
_ = bookReader.Close()
}
bookCancel()
}

// 豆瓣刮削失败且本地也无封面时,整体视为刮削失败
if doubanErr != nil && item.Cover == "" {
scrapeErr = doubanErr
}
case model.MediaTypeImage:
// 读取图片文件流,用于 EXIF 解析和缩略图生成
imgCtx, imgCancel := context.WithTimeout(context.Background(), 30*time.Second)
imgReader := media.FetchFileReader(imgCtx, item.FilePath)
s := scraper.NewImageScraperWithConfig(
storeThumbnail,
thumbnailMode,
thumbnailPath,
)
scrapeErr = s.ScrapeImage(item, imgReader)
if imgReader != nil {
_ = imgReader.Close()
}
imgCancel()
}

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.

StartMediaScrape does not validate media_type (empty/unknown values) and does not check whether the corresponding media library is enabled. With an empty/invalid type, the switch has no matching case, scrapeErr stays nil, and the item can be marked as scraped without actually scraping. Validate media_type against supported values and reject disabled libraries (similar to StartMediaScan).

Copilot uses AI. Check for mistakes.
Comment on lines +74 to +75
frameSize := int(binary.BigEndian.Uint32(data[pos+4 : pos+8]))
pos += 10 // 跳过帧头(4+4+2)
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.

ParseID3v2 claims to support ID3v2.4, but frame sizes in v2.4 are syncsafe integers (not plain big-endian uint32). Parsing v2.4 tags with binary.BigEndian.Uint32 will miscompute frameSize and can break tag extraction; handle v2.4 frame-size parsing (and extended-header sizing) according to the spec.

Copilot uses AI. Check for mistakes.
Comment on lines +345 to +357
func PublicListMedia(c *gin.Context) {
mediaType := model.MediaType(c.Query("media_type"))
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "40"))
orderBy := c.DefaultQuery("order_by", "name")
orderDir := c.DefaultQuery("order_dir", "asc")
folderPath := c.Query("folder_path")
keyword := c.Query("keyword")

hidden := false
q := db.MediaItemQuery{
MediaType: mediaType,
FolderPath: folderPath,
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.

PublicListMedia allows media_type to be empty, which results in an unfiltered DB query (MediaType omitted) and can return items of all types. This is both surprising behavior and a potential data-leak surface; require media_type (and validate it against the supported enum values) before querying.

Copilot uses AI. Check for mistakes.
Comment on lines +354 to +390
hidden := false
q := db.MediaItemQuery{
MediaType: mediaType,
FolderPath: folderPath,
Hidden: &hidden,
Keyword: keyword,
OrderBy: orderBy,
OrderDir: orderDir,
Page: page,
PageSize: pageSize,
}
items, total, err := db.ListMediaItems(q)
if err != nil {
common.ErrorResp(c, err, 500)
return
}
common.SuccessResp(c, common.PageResp{Content: items, Total: total})
}

// PublicGetMedia 公开获取媒体详情
func PublicGetMedia(c *gin.Context) {
idStr := c.Param("id")
id, err := strconv.ParseUint(idStr, 10, 64)
if err != nil {
common.ErrorStrResp(c, "无效的ID", 400)
return
}
item, err := db.GetMediaItemByID(uint(id))
if err != nil {
common.ErrorResp(c, err, 404)
return
}
if item.Hidden {
common.ErrorStrResp(c, "资源不存在", 404)
return
}
common.SuccessResp(c, item)
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.

The public media endpoints return items purely from the media DB and only filter by the hidden flag. This bypasses the existing per-user hide/permission logic used by the filesystem APIs (e.g., meta hide rules in internal/fs/list.go), so guests may be able to discover media entries for paths they cannot normally browse. Consider filtering results based on the current request user/meta rules (or enforcing equivalent access checks) before returning items/paths.

Copilot uses AI. Check for mistakes.
Comment on lines +135 to +156
item.ID = existing.ID
item.CreatedAt = existing.CreatedAt
// 如果已有刮削数据,保留刮削字段,防止重新扫描时覆盖刮削结果
if existing.ScrapedAt != nil {
item.ScrapedAt = existing.ScrapedAt
item.ScrapedName = existing.ScrapedName
item.Cover = existing.Cover
item.AlbumName = existing.AlbumName
item.AlbumArtist = existing.AlbumArtist
item.TrackNumber = existing.TrackNumber
item.Duration = existing.Duration
item.Genre = existing.Genre
item.ReleaseDate = existing.ReleaseDate
item.Rating = existing.Rating
item.Plot = existing.Plot
item.Authors = existing.Authors
item.Description = existing.Description
item.Publisher = existing.Publisher
item.ISBN = existing.ISBN
item.ExternalID = existing.ExternalID
}
return db.Save(item).Error
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.

CreateOrUpdateMediaItem does not preserve the existing Hidden flag on rescan. Since Hidden is user-managed (via the admin update endpoint), rescans will unintentionally unhide items. Preserve existing.Hidden (and any other user-editable flags) when updating scanned metadata.

Copilot uses AI. Check for mistakes.
Comment on lines +251 to +256
if getAttr(n, "property") == "v:average" {
ratingStr := getTextContent(n)
var rating float32
fmt.Sscanf(ratingStr, "%f", &rating)
detail.Rating = rating / 2
}
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.

Douban ratings are typically 0–10, but detail.Rating = rating / 2 scales them down to 0–5, which conflicts with MediaItem.Rating being documented as 0–10. Remove the / 2 (or adjust the model/other scrapers to use a consistent scale).

Copilot uses AI. Check for mistakes.
@KirCute
Copy link
Member

KirCute commented Mar 10, 2026

步子迈得是不是有点太大了,需要刮削的话我觉得写个这样的中间件驱动就可以,媒体管理面板我觉得应该交给社区

@xrgzs
Copy link
Member

xrgzs commented Mar 10, 2026

步子迈得是不是有点太大了,需要刮削的话我觉得写个这样的中间件驱动就可以,媒体管理面板我觉得应该交给社区

+1

@PIKACHUIM
Copy link
Member Author

步子迈得是不是有点太大了,需要刮削的话我觉得写个这样的中间件驱动就可以,媒体管理面板我觉得应该交给社区

我是觉得步子迈得确实很大hh,也许应该放到v5.0计划中
中间件驱动可以,但现阶段可以先不实现,刮削接口实际上没有很多
但不应该交给社区,框架性的内容总要有个人来写

有大功能更新————这是好事,不过要慎重评估兼容性

@PIKACHUIM
Copy link
Member Author

此功能更新,参考了一些面向NAS和网盘影音APP的功能和交互逻辑

因此,这涉及到一个问题,OpenList应该作为:

  • 一个单纯的List,只提供基本文件管理、WebDAV转换、简单预览
  • 一个功能完善的文件管理器,支持媒体库以及已有的STRM、加解密等高级功能

这反而是值得思考的问题

@KirCute
Copy link
Member

KirCute commented Mar 10, 2026

这反而是值得思考的问题

我反正是觉得专业的问题应该交给专业的东西做,我对openlist的理解是跟zfile,cd2类似的将来自各种不同协议的文件系统组织到一起的挂载工具,strm我觉得已经有点超出我理解的openlist的需求边界了

可以是一个功能完善的文件管理器,但我觉得文件管理器和媒体库还是有点区别,解决的问题不同,ui风格也不同

@KirCute
Copy link
Member

KirCute commented Mar 10, 2026

但不应该交给社区,框架性的内容总要有个人来写

可以新开一个媒体库版前端仓库,cicd发同一个内核,不同webui的两个版本

@jyxjjj
Copy link
Member

jyxjjj commented Mar 11, 2026

之前也有提过这个问题,我也觉得不应该专门做媒体功能,但是Pika之前群里发的截图我觉得还挺有意思的,虽然代码复杂度和复用性、覆盖率我也还没时间看,但是听了Kir的说法我觉得分开两套WebUI的方案非常合适,但还是认为不应该加专门的媒体功能。而且我觉得可能会稍微再激进一丁点,原WebUI的媒体预览部分应该具有此类新样式。
我可能语言组织没说清楚,我的意思是后端不应该提供专门用于刮削之类的功能,前端仅修改样式。

@xrgzs
Copy link
Member

xrgzs commented Mar 11, 2026

原WebUI的媒体预览部分应该具有此类新样式。

可以重构一下这两个视图,之前的确实丑,而且没懒加载

image

@PIKACHUIM
Copy link
Member Author

我反正是觉得专业的问题应该交给专业的东西做,我对openlist的理解是跟zfile,cd2类似的将来自各种不同协议的文件系统组织到一起的挂载工具,strm我觉得已经有点超出我理解的openlist的需求边界了

但zfile和cd2都已经有类似的功能了,甚至比此pr提供的功能更为丰富
zfile有画廊,音视频列表;cd2有功能极为丰富的视频、音频、文档、照片管理功能

可以是一个功能完善的文件管理器,但我觉得文件管理器和媒体库还是有点区别,解决的问题不同,ui风格也不同

类似于文件管理器支持媒体的,还有ES文件管理器等,我认为媒体管理作为和文件管理的并列项,并没有问题————比如百度网盘、阿里网盘等,管理视频音频,是文件管理的并列项

之前也有提过这个问题,我也觉得不应该专门做媒体功能,但是Pika之前群里发的截图我觉得还挺有意思的,虽然代码复杂度和复用性、覆盖率我也还没时间看,但是听了Kir的说法我觉得分开两套WebUI的方案非常合适,但还是认为不应该加专门的媒体功能。而且我觉得可能会稍微再激进一丁点,原WebUI的媒体预览部分应该具有此类新样式。我可能语言组织没说清楚,我的意思是后端不应该提供专门用于刮削之类的功能,前端仅修改样式。

提供刮削功能没有问题,已有很多APP都实现了,本身也不存在法律风险
鉴于本项目非常复杂,现阶段不应该分俩WebUI,徒增维护工作量

其实最好的方案,应该是全部做成插件,最小镜像只提供最基本的文件浏览功能,
包括STRM、媒体预览等功能全部丢进插件进行维护

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

Status: No status

Development

Successfully merging this pull request may close these issues.

5 participants