Conversation
There was a problem hiding this comment.
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.
server/handles/fsread.go
Outdated
| 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 |
There was a problem hiding this comment.
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.
| 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:"-"` | ||
| // 基础信息 |
There was a problem hiding this comment.
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.
| // 构建本地存储路径: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) | ||
| } |
There was a problem hiding this comment.
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.
server/middlewares/virtual_host.go
Outdated
| // 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() | ||
| } |
There was a problem hiding this comment.
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.
server/static/static.go
Outdated
| 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) |
There was a problem hiding this comment.
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).
server/static/static.go
Outdated
| reqPath := c.Request.URL.Path | ||
| // 将请求路径映射到虚拟主机的根目录 | ||
| filePath := stdpath.Join(vhost.Path, reqPath) | ||
| utils.Log.Infof("[VirtualHost] handleWebHosting: reqPath=%q -> filePath=%q", reqPath, filePath) |
There was a problem hiding this comment.
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.
| query := item.ScrapedName | ||
| if query == "" { | ||
| query = strings.TrimSuffix(item.FileName, strings.ToLower(item.FileName[strings.LastIndex(item.FileName, "."):])) | ||
| } |
There was a problem hiding this comment.
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.
| 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) | ||
| } |
There was a problem hiding this comment.
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.
| 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 |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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
Initcurrently contains unresolved Git merge-conflict markers (<<<<<<<,=======,>>>>>>>) and multiple duplicatedAutoMigrateassignments. This will not compile and also makes the migration list ambiguous; resolve the conflict and keep a singleerr := 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.
| // 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"` |
There was a problem hiding this comment.
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.
| type ImageScraper struct { | ||
| // StoreThumbnail 为 true 时生成缩略图写入 Cover; | ||
| // 为 false 时直接用文件路径作为 Cover,节省数据库空间。 | ||
| StoreThumbnail bool | ||
| // ThumbnailMode 缩略图存储方式:"base64"(默认,存入数据库)或 "local"(存到本地文件) | ||
| ThumbnailMode string | ||
| // ThumbnailPath 本地存储路径,ThumbnailMode 为 "local" 时有效,默认 "/.thumbnail" | ||
| ThumbnailPath string |
There was a problem hiding this comment.
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).
| // buildInternalDownloadPath 将 VFS 文件路径转为内部代理下载路径 | ||
| // 格式:/d/path/to/file(前端通过此路径访问文件内容) | ||
| func buildInternalDownloadPath(filePath string) string { | ||
| if !strings.HasPrefix(filePath, "/") { | ||
| filePath = "/" + filePath | ||
| } | ||
| return "/d" + filePath | ||
| } |
There was a problem hiding this comment.
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.
| // 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 | ||
| } |
There was a problem hiding this comment.
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.
| // 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() | ||
| } | ||
|
|
There was a problem hiding this comment.
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).
| frameSize := int(binary.BigEndian.Uint32(data[pos+4 : pos+8])) | ||
| pos += 10 // 跳过帧头(4+4+2) |
There was a problem hiding this comment.
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.
| 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, |
There was a problem hiding this comment.
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.
| 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) |
There was a problem hiding this comment.
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.
| 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 |
There was a problem hiding this comment.
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.
| if getAttr(n, "property") == "v:average" { | ||
| ratingStr := getTextContent(n) | ||
| var rating float32 | ||
| fmt.Sscanf(ratingStr, "%f", &rating) | ||
| detail.Rating = rating / 2 | ||
| } |
There was a problem hiding this comment.
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).
|
步子迈得是不是有点太大了,需要刮削的话我觉得写个这样的中间件驱动就可以,媒体管理面板我觉得应该交给社区 |
+1 |
我是觉得步子迈得确实很大hh,也许应该放到v5.0计划中 有大功能更新————这是好事,不过要慎重评估兼容性 |
|
此功能更新,参考了一些面向NAS和网盘影音APP的功能和交互逻辑 因此,这涉及到一个问题,OpenList应该作为:
这反而是值得思考的问题 |
我反正是觉得专业的问题应该交给专业的东西做,我对openlist的理解是跟zfile,cd2类似的将来自各种不同协议的文件系统组织到一起的挂载工具,strm我觉得已经有点超出我理解的openlist的需求边界了 可以是一个功能完善的文件管理器,但我觉得文件管理器和媒体库还是有点区别,解决的问题不同,ui风格也不同 |
可以新开一个媒体库版前端仓库,cicd发同一个内核,不同webui的两个版本 |
|
之前也有提过这个问题,我也觉得不应该专门做媒体功能,但是Pika之前群里发的截图我觉得还挺有意思的,虽然代码复杂度和复用性、覆盖率我也还没时间看,但是听了Kir的说法我觉得分开两套WebUI的方案非常合适,但还是认为不应该加专门的媒体功能。而且我觉得可能会稍微再激进一丁点,原WebUI的媒体预览部分应该具有此类新样式。 |
但zfile和cd2都已经有类似的功能了,甚至比此pr提供的功能更为丰富
类似于文件管理器支持媒体的,还有ES文件管理器等,我认为媒体管理作为和文件管理的并列项,并没有问题————比如百度网盘、阿里网盘等,管理视频音频,是文件管理的并列项
提供刮削功能没有问题,已有很多APP都实现了,本身也不存在法律风险 其实最好的方案,应该是全部做成插件,最小镜像只提供最基本的文件浏览功能, |

Description / 描述
本次 PR 为 OpenList 引入了主要功能:媒体库(Media Library),同时对前端整体布局进行了重构,新增了全局侧边栏导航。
1. 媒体库(Media Library)
MediaItem,通过media_type字段区分影视、音乐、图片、书籍四种类型,支持刮削元数据(封面、评分、简介、演员/作者等)。MediaConfig配置模型,支持按类型配置扫描路径、路径合并模式等。internal/media/scanner.go)和多源刮削器(TMDB、Discogs、豆瓣、本地书籍、图片)。internal/media/id3.go)用于音乐文件元数据提取。/admin/media/...):配置管理、条目 CRUD、扫描/刮削触发、数据库清空。/fs/media/...):媒体列表、详情、专辑列表、专辑曲目、文件夹列表。VideoLibrary(影视)、MusicLibrary(音乐,含全局悬浮播放器)、ImageLibrary(图片)、BookLibrary(书籍)。MediaBrowser、MediaLayout、MediaSettings等通用媒体组件。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:移除原有Header和Container组件,适配新布局结构。Motivation and Context / 背景
OpenList 此前仅提供文件浏览功能,缺乏对媒体内容的专项管理与展示能力。本次改动旨在:
How Has This Been Tested? / 测试
Checklist / 检查清单
我已阅读 CONTRIBUTING 文档。
go fmtor prettier.我已使用
go fmt或 prettier 格式化提交的代码。我已为此 PR 添加了适当的标签(如无权限或需要的标签不存在,请在描述中说明,管理员将后续处理)。
我已在适当情况下使用"Request review"功能请求相关代码作者进行审查。
我已相应更新了相关仓库(若适用)。