diff --git a/.github/workflows/beta_media_release.yml b/.github/workflows/beta_media_release.yml new file mode 100644 index 000000000..103b5e219 --- /dev/null +++ b/.github/workflows/beta_media_release.yml @@ -0,0 +1,153 @@ +name: Beta Media Release builds + +on: + push: + branches: ["dev-media"] + workflow_dispatch: + +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + +permissions: + contents: write + +jobs: + changelog: + name: Beta Media Release Changelog + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Create or update ref + id: create-or-update-ref + uses: ovsds/create-or-update-ref-action@v1 + with: + ref: tags/beta-media + sha: ${{ github.sha }} + + - name: Delete beta-media tag + run: git tag -d beta-media + continue-on-error: true + + - name: changelog + id: changelog + run: | + git tag -l + npx changelogithub --output CHANGELOG.md + + - name: Upload assets to beta-media release + uses: softprops/action-gh-release@v2 + with: + body_path: CHANGELOG.md + files: CHANGELOG.md + prerelease: true + tag_name: beta-media + + - name: Upload assets to github artifact + uses: actions/upload-artifact@v4 + with: + name: beta-media changelog + path: ${{ github.workspace }}/CHANGELOG.md + compression-level: 0 + if-no-files-found: error + + release: + needs: + - changelog + strategy: + matrix: + include: + - target: "!(*musl*|*windows-arm64*|*windows7-*|*android*|*freebsd*)" # xgo and loongarch + hash: "md5" + - target: "linux-!(arm*)-musl*" #musl-not-arm + hash: "md5-linux-musl" + - target: "linux-arm*-musl*" #musl-arm + hash: "md5-linux-musl-arm" + - target: "windows-arm64" #win-arm64 + hash: "md5-windows-arm64" + - target: "windows7-*" #win7 + hash: "md5-windows7" + - target: "android-*" #android + hash: "md5-android" + - target: "freebsd-*" #freebsd + hash: "md5-freebsd" + + name: Beta Media Release + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Setup Go + uses: actions/setup-go@v5 + with: + go-version: "1.25.0" + + - name: Setup web + run: | + frontendRepo="${FRONTEND_REPO:-OpenListTeam/OpenList-Frontend}" + release_json=$(curl -fsSL --max-time 10 \ + -H "Authorization: Bearer $GITHUB_TOKEN" \ + -H "Accept: application/vnd.github.v3+json" \ + "https://api.github.com/repos/$frontendRepo/releases/tags/beta-media") + tar_url=$(echo "$release_json" | jq -r '.assets[].browser_download_url' | grep "openlist-frontend-dist" | grep -v "lite" | grep "\.tar\.gz$") + echo "Downloading frontend from: $tar_url" + curl -fsSL "$tar_url" -o dist.tar.gz + rm -rf public/dist && mkdir -p public/dist + tar -zxvf dist.tar.gz -C public/dist + rm -rf dist.tar.gz + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + FRONTEND_REPO: ${{ vars.FRONTEND_REPO }} + + - name: Build + uses: OpenListTeam/cgo-actions@v1.2.2 + with: + targets: ${{ matrix.target }} + musl-target-format: $os-$musl-$arch + github-token: ${{ secrets.GITHUB_TOKEN }} + out-dir: build + output: openlist-$target$ext + musl-base-url: "https://github.com/OpenListTeam/musl-compilers/releases/latest/download/" + x-flags: | + github.com/OpenListTeam/OpenList/v4/internal/conf.BuiltAt=$built_at + github.com/OpenListTeam/OpenList/v4/internal/conf.GitAuthor=The OpenList Projects Contributors + github.com/OpenListTeam/OpenList/v4/internal/conf.GitCommit=$git_commit + github.com/OpenListTeam/OpenList/v4/internal/conf.Version=$tag + github.com/OpenListTeam/OpenList/v4/internal/conf.WebVersion=rolling + + - name: Compress + run: | + bash build.sh zip ${{ matrix.hash }} + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + - name: Upload assets to beta-media release + uses: softprops/action-gh-release@v2 + with: + files: build/compress/* + prerelease: true + tag_name: beta-media + + - name: Clean illegal characters from matrix.target + id: clean_target_name + run: | + ILLEGAL_CHARS_REGEX='[":<>|*?\\/\r\n]' + CLEANED_TARGET=$(echo "${{ matrix.target }}" | sed -E "s/$ILLEGAL_CHARS_REGEX//g") + echo "Original target: ${{ matrix.target }}" + echo "Cleaned target: $CLEANED_TARGET" + echo "cleaned_target=$CLEANED_TARGET" >> $GITHUB_ENV + + - name: Upload assets to github artifact + uses: actions/upload-artifact@v4 + with: + name: beta-media builds for ${{ env.cleaned_target }} + path: ${{ github.workspace }}/build/compress/* + compression-level: 0 + if-no-files-found: error diff --git a/go.mod b/go.mod index 2fc141f2e..593696103 100644 --- a/go.mod +++ b/go.mod @@ -65,6 +65,7 @@ require ( github.com/pquerna/otp v1.5.0 github.com/quic-go/quic-go v0.54.1 github.com/rclone/rclone v1.70.3 + github.com/rwcarlsen/goexif v0.0.0-20190401172101-9e8deecbddbd github.com/shirou/gopsutil/v4 v4.25.5 github.com/sirupsen/logrus v1.9.3 github.com/spf13/afero v1.14.0 @@ -85,8 +86,8 @@ require ( gopkg.in/ldap.v3 v3.1.0 gorm.io/driver/mysql v1.5.7 gorm.io/driver/postgres v1.5.9 - gorm.io/driver/sqlite v1.5.6 - gorm.io/gorm v1.25.11 + gorm.io/driver/sqlite v1.6.0 + gorm.io/gorm v1.30.0 ) require ( @@ -120,6 +121,7 @@ require ( github.com/jcmturner/gokrb5/v8 v8.4.4 // indirect github.com/jcmturner/rpc/v2 v2.0.3 // indirect github.com/lanrat/extsort v1.0.2 // indirect + github.com/mattn/go-sqlite3 v1.14.22 // indirect github.com/mikelolasagasti/xz v1.0.1 // indirect github.com/minio/minlz v1.0.0 // indirect github.com/minio/xxml v0.0.3 // indirect @@ -251,7 +253,6 @@ require ( github.com/mattn/go-isatty v0.0.20 // indirect github.com/mattn/go-localereader v0.0.1 // indirect github.com/mattn/go-runewidth v0.0.16 // indirect - github.com/mattn/go-sqlite3 v1.14.22 // indirect github.com/minio/sha256-simd v1.0.1 // indirect github.com/mitchellh/go-homedir v1.1.0 // indirect github.com/mitchellh/mapstructure v1.5.0 // indirect diff --git a/go.sum b/go.sum index 0f69ce117..49fbbe1c4 100644 --- a/go.sum +++ b/go.sum @@ -593,6 +593,8 @@ github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUc github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= +github.com/rwcarlsen/goexif v0.0.0-20190401172101-9e8deecbddbd h1:CmH9+J6ZSsIjUK3dcGsnCnO41eRBOnY12zwkn5qVwgc= +github.com/rwcarlsen/goexif v0.0.0-20190401172101-9e8deecbddbd/go.mod h1:hPqNNc0+uJM6H+SuU8sEs5K5IQeKccPqeSjfgcKGgPk= github.com/ryszard/goskiplist v0.0.0-20150312221310-2dfbae5fcf46 h1:GHRpF1pTW19a8tTFrMLUcfWwyC0pnifVo2ClaLq+hP8= github.com/ryszard/goskiplist v0.0.0-20150312221310-2dfbae5fcf46/go.mod h1:uAQ5PCi+MFsC7HjREoAz1BU+Mq60+05gifQSsHSDG/8= github.com/secsy/goftp v0.0.0-20200609142545-aa2de14babf4 h1:PT+ElG/UUFMfqy5HrxJxNzj3QBOf7dZwupeVC+mG1Lo= @@ -853,11 +855,11 @@ gorm.io/driver/mysql v1.5.7 h1:MndhOPYOfEp2rHKgkZIhJ16eVUIRf2HmzgoPmh7FCWo= gorm.io/driver/mysql v1.5.7/go.mod h1:sEtPWMiqiN1N1cMXoXmBbd8C6/l+TESwriotuRRpkDM= gorm.io/driver/postgres v1.5.9 h1:DkegyItji119OlcaLjqN11kHoUgZ/j13E0jkJZgD6A8= gorm.io/driver/postgres v1.5.9/go.mod h1:DX3GReXH+3FPWGrrgffdvCk3DQ1dwDPdmbenSkweRGI= -gorm.io/driver/sqlite v1.5.6 h1:fO/X46qn5NUEEOZtnjJRWRzZMe8nqJiQ9E+0hi+hKQE= -gorm.io/driver/sqlite v1.5.6/go.mod h1:U+J8craQU6Fzkcvu8oLeAQmi50TkwPEhHDEjQZXDah4= +gorm.io/driver/sqlite v1.6.0 h1:WHRRrIiulaPiPFmDcod6prc4l2VGVWHz80KspNsxSfQ= +gorm.io/driver/sqlite v1.6.0/go.mod h1:AO9V1qIQddBESngQUKWL9yoH93HIeA1X6V633rBwyT8= gorm.io/gorm v1.25.7/go.mod h1:hbnx/Oo0ChWMn1BIhpy1oYozzpM15i4YPuHDmfYtwg8= -gorm.io/gorm v1.25.11 h1:/Wfyg1B/je1hnDx3sMkX+gAlxrlZpn6X0BXRlwXlvHg= -gorm.io/gorm v1.25.11/go.mod h1:xh7N7RHfYlNc5EmcI/El95gXusucDrQnHXe0+CgWcLQ= +gorm.io/gorm v1.30.0 h1:qbT5aPv1UH8gI99OsRlvDToLxW5zR7FzS9acZDOZcgs= +gorm.io/gorm v1.30.0/go.mod h1:8Z33v652h4//uMA76KjeDH8mJXPm1QNCYrMeatR0DOE= lukechampine.com/blake3 v1.1.7 h1:GgRMhmdsuK8+ii6UZFDL8Nb+VyMwadAgcJyfYHxG6n0= lukechampine.com/blake3 v1.1.7/go.mod h1:tkKEOtDkNtklkXtLNEOGNq5tcV90tJiA1vAA12R78LA= modernc.org/libc v1.22.5 h1:91BNch/e5B0uPbJFgqbxXuOnxBQjlS//icfQEGmvyjE= diff --git a/internal/bootstrap/data/setting.go b/internal/bootstrap/data/setting.go index 7bff851de..1c440ab48 100644 --- a/internal/bootstrap/data/setting.go +++ b/internal/bootstrap/data/setting.go @@ -242,6 +242,13 @@ func InitialSettings() []model.SettingItem { {Key: conf.StreamMaxClientUploadSpeed, Value: "-1", Type: conf.TypeNumber, Group: model.TRAFFIC, Flag: model.PRIVATE}, {Key: conf.StreamMaxServerDownloadSpeed, Value: "-1", Type: conf.TypeNumber, Group: model.TRAFFIC, Flag: model.PRIVATE}, {Key: conf.StreamMaxServerUploadSpeed, Value: "-1", Type: conf.TypeNumber, Group: model.TRAFFIC, Flag: model.PRIVATE}, + + // media settings + {Key: conf.MediaTMDBKey, Value: "", Type: conf.TypeString, Group: model.MEDIA, Flag: model.PRIVATE}, + {Key: conf.MediaDiscogsToken, Value: "", Type: conf.TypeString, Group: model.MEDIA, Flag: model.PRIVATE}, + {Key: conf.MediaStoreThumbnail, Value: "false", Type: conf.TypeBool, Group: model.MEDIA, Flag: model.PRIVATE}, + {Key: conf.MediaThumbnailMode, Value: "base64", Type: conf.TypeSelect, Options: "base64,local", Group: model.MEDIA, Flag: model.PRIVATE}, + {Key: conf.MediaThumbnailPath, Value: "/.thumbnail", Type: conf.TypeString, Group: model.MEDIA, Flag: model.PRIVATE}, } additionalSettingItems := tool.Tools.Items() // 固定顺序 diff --git a/internal/bootstrap/db.go b/internal/bootstrap/db.go index 7b91769f9..95b9f7079 100644 --- a/internal/bootstrap/db.go +++ b/internal/bootstrap/db.go @@ -20,12 +20,12 @@ import ( func InitDB() { logLevel := logger.Silent if flags.Debug || flags.Dev { - logLevel = logger.Info + logLevel = logger.Warn // Warn 级别:只输出慢查询和错误,不输出每条 SQL } newLogger := logger.New( stdlog.New(log.StandardLogger().Out, "\r\n", stdlog.LstdFlags), logger.Config{ - SlowThreshold: time.Second, + SlowThreshold: 200 * time.Millisecond, // 超过 200ms 才记录慢查询 LogLevel: logLevel, IgnoreRecordNotFoundError: true, Colorful: true, diff --git a/internal/conf/const.go b/internal/conf/const.go index b99d8849c..d1be011fe 100644 --- a/internal/conf/const.go +++ b/internal/conf/const.go @@ -161,6 +161,13 @@ const ( StreamMaxClientUploadSpeed = "max_client_upload_speed" StreamMaxServerDownloadSpeed = "max_server_download_speed" StreamMaxServerUploadSpeed = "max_server_upload_speed" + + // media + MediaTMDBKey = "media_tmdb_key" + MediaDiscogsToken = "media_discogs_token" + MediaThumbnailMode = "media_thumbnail_mode" + MediaThumbnailPath = "media_thumbnail_path" + MediaStoreThumbnail = "media_store_thumbnail" ) const ( diff --git a/internal/db/db.go b/internal/db/db.go index 96529c15d..571b95805 100644 --- a/internal/db/db.go +++ b/internal/db/db.go @@ -12,12 +12,41 @@ var db *gorm.DB func Init(d *gorm.DB) { db = d - 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)) + // 迁移前处理:删除旧的 folder_path 唯一索引(如果存在),避免迁移冲突 + // 同时清空旧的 media_items 数据(folder_path 语义已变更,旧数据不可复用) + migrateMediaItems() + 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), new(model.MediaScanPath)) if err != nil { log.Fatalf("failed migrate database: %s", err.Error()) } } +// migrateMediaItems 处理 media_items 表的迁移兼容性 +// 存储语义已变更:folder_path 恒定为扫描根路径,file_name 为文件/文件夹名 +// 唯一性由 folder_path + file_name + album_name 组合索引保证 +func migrateMediaItems() { + // 检查表是否存在 + if !db.Migrator().HasTable("x_media_items") { + return + } + // 已迁移到新组合索引,跳过 + if db.Migrator().HasIndex("x_media_items", "idx_media_folder_file_album") { + return + } + // 旧表存在但没有新组合索引,说明是旧版本数据,需要清空后重建 + // 旧数据的 folder_path 语义已变更(原来存完整路径,现在恒定为扫描根路径),无法复用 + log.Info("media_items: 检测到旧版本数据,清空后重新迁移(存储结构已变更)") + // 先尝试删除旧的单字段唯一索引(如果存在),避免 AutoMigrate 冲突 + if db.Migrator().HasIndex("x_media_items", "idx_x_media_items_folder_path") { + if err := db.Migrator().DropIndex("x_media_items", "idx_x_media_items_folder_path"); err != nil { + log.Warnf("media_items: 删除旧唯一索引失败: %v", err) + } + } + if err := db.Exec("DELETE FROM x_media_items").Error; err != nil { + log.Warnf("media_items: 清空旧数据失败: %v", err) + } +} + func AutoMigrate(dst ...interface{}) error { var err error if conf.Conf.Database.Type == "mysql" { diff --git a/internal/db/media.go b/internal/db/media.go new file mode 100644 index 000000000..c39fcdd1d --- /dev/null +++ b/internal/db/media.go @@ -0,0 +1,382 @@ +package db + +import ( + "encoding/json" + "strings" + + "github.com/OpenListTeam/OpenList/v4/internal/model" + "gorm.io/gorm" +) + +// ==================== MediaConfig ==================== + +// GetMediaConfig 获取指定类型的媒体库配置,不存在则返回默认值 +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, + }, nil + } + return &cfg, result.Error +} + +// GetAllMediaConfigs 获取所有媒体库配置 +func GetAllMediaConfigs() ([]model.MediaConfig, error) { + var cfgs []model.MediaConfig + err := db.Find(&cfgs).Error + return cfgs, err +} + +// SaveMediaConfig 保存媒体库配置(upsert) +func SaveMediaConfig(cfg *model.MediaConfig) error { + var existing model.MediaConfig + result := db.Where("media_type = ?", cfg.MediaType).First(&existing) + if result.Error == gorm.ErrRecordNotFound { + return db.Create(cfg).Error + } + cfg.ID = existing.ID + return db.Save(cfg).Error +} + +// ==================== MediaScanPath ==================== + +// ListMediaScanPaths 获取指定媒体类型的所有扫描路径 +func ListMediaScanPaths(mediaType model.MediaType) ([]model.MediaScanPath, error) { + var paths []model.MediaScanPath + tx := db.Model(&model.MediaScanPath{}) + if mediaType != "" { + tx = tx.Where("media_type = ?", mediaType) + } + err := tx.Order("id asc").Find(&paths).Error + return paths, err +} + +// GetMediaScanPath 按ID获取扫描路径 +func GetMediaScanPath(id uint) (*model.MediaScanPath, error) { + var p model.MediaScanPath + err := db.First(&p, id).Error + return &p, err +} + +// CreateMediaScanPath 创建扫描路径 +func CreateMediaScanPath(p *model.MediaScanPath) error { + return db.Create(p).Error +} + +// UpdateMediaScanPath 更新扫描路径 +func UpdateMediaScanPath(p *model.MediaScanPath) error { + return db.Save(p).Error +} + +// DeleteMediaScanPath 删除扫描路径(硬删除) +func DeleteMediaScanPath(id uint) error { + return db.Unscoped().Delete(&model.MediaScanPath{}, id).Error +} + +// ==================== MediaItem ==================== + +// MediaItemQuery 媒体条目查询参数 +type MediaItemQuery struct { + MediaType model.MediaType + ScanPathID uint // 按扫描路径ID筛选 + FolderPath string // 按文件夹路径筛选 + TypeTag string // 按类型标签筛选(电影、电视剧等) + ContentTag string // 按内容标签筛选(喜剧、惊悚等) + Hidden *bool + Keyword string + OrderBy string // "name", "date", "size" + OrderDir string // "asc", "desc" + Page int + PageSize int +} + +// ListMediaItems 分页查询媒体条目 +func ListMediaItems(q MediaItemQuery) ([]model.MediaItem, int64, error) { + tx := db.Model(&model.MediaItem{}) + if q.MediaType != "" { + tx = tx.Where("media_type = ?", q.MediaType) + } + if q.ScanPathID > 0 { + tx = tx.Where("scan_path_id = ?", q.ScanPathID) + } + if q.FolderPath != "" { + tx = tx.Where("folder_path = ?", q.FolderPath) + } + if q.Hidden != nil { + tx = tx.Where("hidden = ?", *q.Hidden) + } + if q.Keyword != "" { + like := "%" + q.Keyword + "%" + tx = tx.Where("file_name LIKE ? OR scraped_name LIKE ?", like, like) + } + // 按类型标签筛选(通过关联扫描路径的type_tag) + if q.TypeTag != "" { + tx = tx.Joins("JOIN media_scan_paths ON media_scan_paths.id = media_items.scan_path_id"). + Where("media_scan_paths.type_tag = ?", q.TypeTag) + } + // 按内容标签筛选(通过关联扫描路径的content_tags) + if q.ContentTag != "" { + tx = tx.Joins("JOIN media_scan_paths ON media_scan_paths.id = media_items.scan_path_id"). + Where("media_scan_paths.content_tags LIKE ?", "%"+q.ContentTag+"%") + } + + var total int64 + if err := tx.Count(&total).Error; err != nil { + return nil, 0, err + } + + // 排序 + orderCol := "created_at" + switch q.OrderBy { + case "name": + orderCol = "COALESCE(NULLIF(scraped_name,''), file_name)" + case "date": + orderCol = "release_date" + case "size": + orderCol = "file_size" + } + dir := "asc" + if q.OrderDir == "desc" { + dir = "desc" + } + tx = tx.Order(orderCol + " " + dir) + + // 分页 + if q.PageSize <= 0 { + q.PageSize = 20 + } + if q.Page <= 0 { + q.Page = 1 + } + tx = tx.Offset((q.Page - 1) * q.PageSize).Limit(q.PageSize) + + var items []model.MediaItem + err := tx.Find(&items).Error + return items, total, err +} + +// GetMediaItemByID 按ID获取媒体条目 +func GetMediaItemByID(id uint) (*model.MediaItem, error) { + var item model.MediaItem + err := db.First(&item, id).Error + return &item, err +} + +// GetMediaItemByFolderPath 按文件夹路径获取媒体条目(用于合并模式) +func GetMediaItemByFolderPath(folderPath string) (*model.MediaItem, error) { + var item model.MediaItem + result := db.Where("folder_path = ?", folderPath).First(&item) + return &item, result.Error +} + +// CreateOrUpdateMediaItem 创建或更新媒体条目(按 folder_path + file_name + album_name 组合唯一) +// 更新时保留已有的刮削数据,避免重新扫描时把已刮削的字段清空 +func CreateOrUpdateMediaItem(item *model.MediaItem) error { + var existing model.MediaItem + result := db.Where("folder_path = ? AND file_name = ? AND album_name = ?", item.FolderPath, item.FileName, item.AlbumName).First(&existing) + if result.Error == gorm.ErrRecordNotFound { + return db.Create(item).Error + } + if result.Error != nil { + return result.Error + } + 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 +} + +// UpdateMediaItem 更新媒体条目(仅更新可编辑字段) +func UpdateMediaItem(item *model.MediaItem) error { + return db.Save(item).Error +} + +// DeleteMediaItem 硬删除媒体条目(真正从数据库删除) +func DeleteMediaItem(id uint) error { + return db.Unscoped().Delete(&model.MediaItem{}, id).Error +} + +// ClearMediaItems 硬删除指定类型的所有媒体条目(真正从数据库删除) +func ClearMediaItems(mediaType model.MediaType) error { + return db.Unscoped().Where("media_type = ?", mediaType).Delete(&model.MediaItem{}).Error +} + +// ClearMediaItemsByScanPath 硬删除指定扫描路径的所有媒体条目 +func ClearMediaItemsByScanPath(scanPathID uint) error { + return db.Unscoped().Where("scan_path_id = ?", scanPathID).Delete(&model.MediaItem{}).Error +} + +// ListAlbums 列出所有专辑(音乐专用) +func ListAlbums(q MediaItemQuery) ([]AlbumInfo, int64, error) { + type albumRow struct { + AlbumName string + AlbumArtist string + Cover string + ReleaseDate string + TrackCount int + ScanPathID uint + } + + // 构建基础查询 + baseQuery := db.Model(&model.MediaItem{}). + Where("media_type = ?", model.MediaTypeMusic) + if q.Hidden != nil { + baseQuery = baseQuery.Where("hidden = ?", *q.Hidden) + } + if q.ScanPathID > 0 { + baseQuery = baseQuery.Where("scan_path_id = ?", q.ScanPathID) + } + if q.Keyword != "" { + like := "%" + q.Keyword + "%" + baseQuery = baseQuery.Where("album_name LIKE ? OR album_artist LIKE ? OR scraped_name LIKE ?", like, like, like) + } + + // 统计分组数(用子查询) + var total int64 + if err := db.Table("(?) as sub", baseQuery. + Select("album_name, album_artist"). + Group("album_name, album_artist")). + Count(&total).Error; err != nil { + return nil, 0, err + } + + if q.PageSize <= 0 { + q.PageSize = 20 + } + if q.Page <= 0 { + q.Page = 1 + } + + tx := baseQuery. + Select("album_name, album_artist, MAX(cover) as cover, MAX(release_date) as release_date, COUNT(*) as track_count, MAX(scan_path_id) as scan_path_id"). + Group("album_name, album_artist"). + Offset((q.Page - 1) * q.PageSize).Limit(q.PageSize) + + var rows []albumRow + if err := tx.Scan(&rows).Error; err != nil { + return nil, 0, err + } + + albums := make([]AlbumInfo, len(rows)) + for i, r := range rows { + albums[i] = AlbumInfo{ + AlbumName: r.AlbumName, + AlbumArtist: r.AlbumArtist, + Cover: r.Cover, + ReleaseDate: r.ReleaseDate, + TrackCount: r.TrackCount, + ScanPathID: r.ScanPathID, + } + } + return albums, total, nil +} + +// AlbumInfo 专辑信息 +type AlbumInfo struct { + AlbumName string `json:"album_name"` + AlbumArtist string `json:"album_artist"` + Cover string `json:"cover"` + ReleaseDate string `json:"release_date"` + TrackCount int `json:"track_count"` + ScanPathID uint `json:"scan_path_id"` +} + +// GetAlbumTracks 获取专辑曲目列表 +// 支持两种模式: +// 1. 普通模式(is_folder=false):直接返回独立文件记录 +// 2. 合并文件夹模式(is_folder=true):把 episodes 展开成虚拟 MediaItem 列表 +// 展开后每条记录的 folder_path = 原folder_path/file_name(文件夹实际路径),file_name = episode.FileName +func GetAlbumTracks(albumName, albumArtist string) ([]model.MediaItem, error) { + var items []model.MediaItem + tx := db.Where("media_type = ?", model.MediaTypeMusic) + if albumName != "" { + tx = tx.Where("album_name = ?", albumName) + } else { + tx = tx.Where("(album_name = '' OR album_name IS NULL)") + } + if albumArtist != "" { + tx = tx.Where("album_artist = ?", albumArtist) + } + err := tx.Order("track_number asc").Find(&items).Error + if err != nil { + return nil, err + } + + // 展开合并文件夹条目的 episodes + var result []model.MediaItem + for _, item := range items { + if !item.IsFolder || item.Episodes == "" { + result = append(result, item) + continue + } + // 解析 episodes + type EpisodeInfo struct { + FileName string `json:"file_name"` + Index int `json:"index"` + Title string `json:"title"` + } + var eps []EpisodeInfo + if err := json.Unmarshal([]byte(item.Episodes), &eps); err != nil || len(eps) == 0 { + // 解析失败则跳过该条目(不返回文件夹本身,避免播放路径错误) + continue + } + // 文件夹实际路径 = folder_path + "/" + file_name + actualDir := strings.TrimRight(item.FolderPath, "/") + "/" + item.FileName + for _, ep := range eps { + track := item // 复制基础信息(封面、专辑名、艺术家等) + track.ID = 0 + track.IsFolder = false + track.FolderPath = actualDir + track.FileName = ep.FileName + track.TrackNumber = ep.Index + track.ScrapedName = ep.Title + track.Episodes = "" + result = append(result, track) + } + } + return result, nil +} + +// ListFolderPaths 列出指定媒体类型下的所有文件夹路径(目录浏览模式) +func ListFolderPaths(mediaType model.MediaType) ([]string, error) { + var paths []string + err := db.Model(&model.MediaItem{}). + Where("media_type = ?", mediaType). + Distinct("folder_path"). + Pluck("folder_path", &paths).Error + return paths, err +} + +// 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 +} diff --git a/internal/media/id3.go b/internal/media/id3.go new file mode 100644 index 000000000..2a4490c75 --- /dev/null +++ b/internal/media/id3.go @@ -0,0 +1,500 @@ +package media + +import ( + "encoding/binary" + "io" + "strconv" + "strings" +) + +// MusicTag 音频文件标签信息 +type MusicTag struct { + Title string // TIT2 + Artist string // TPE1 + Album string // TALB + AlbumArtist string // TPE2 + TrackNumber int // TRCK + Year string // TYER / TDRC + Genre string // TCON + CoverData []byte // 封面图片原始字节(APIC / PICTURE) + CoverMIME string // 封面图片 MIME 类型(如 image/jpeg) +} + +// ParseID3v2 从 io.Reader 中解析 ID3v2 标签(只读取文件头部,不需要 Seek) +// 支持 ID3v2.3 和 ID3v2.4 +func ParseID3v2(r io.Reader) (*MusicTag, error) { + // 读取 ID3v2 头部(10 字节) + header := make([]byte, 10) + if _, err := io.ReadFull(r, header); err != nil { + return nil, err + } + + // 检查魔数 + if string(header[0:3]) != "ID3" { + return nil, nil // 不是 ID3v2 文件,不报错 + } + + version := header[3] // 主版本号:3 = ID3v2.3, 4 = ID3v2.4 + if version < 3 || version > 4 { + return nil, nil // 只支持 v2.3 和 v2.4 + } + + // 解析标签总大小(syncsafe integer,4 字节,每字节最高位为 0) + tagSize := syncsafeToInt(header[6:10]) + if tagSize <= 0 || tagSize > 10*1024*1024 { // 最大 10MB + return nil, nil + } + + // 读取所有帧数据 + data := make([]byte, tagSize) + if _, err := io.ReadFull(r, data); err != nil { + return nil, nil // 读取失败时静默跳过 + } + + tag := &MusicTag{} + pos := 0 + + // 跳过扩展头部(如果有) + flags := header[5] + if flags&0x40 != 0 { // 有扩展头部 + if pos+4 > len(data) { + return tag, nil + } + extSize := int(binary.BigEndian.Uint32(data[pos : pos+4])) + pos += extSize + } + + // 解析帧 + for pos+10 <= len(data) { + frameID := string(data[pos : pos+4]) + if frameID == "\x00\x00\x00\x00" { + break // 填充区域,结束 + } + + frameSize := int(binary.BigEndian.Uint32(data[pos+4 : pos+8])) + pos += 10 // 跳过帧头(4+4+2) + + if frameSize <= 0 || pos+frameSize > len(data) { + break + } + + frameData := data[pos : pos+frameSize] + pos += frameSize + + // 解析文本帧(T 开头的帧) + if len(frameID) == 4 && frameID[0] == 'T' && len(frameData) > 0 { + text := decodeID3Text(frameData) + switch frameID { + case "TIT2": + tag.Title = text + case "TPE1": + tag.Artist = text + case "TALB": + tag.Album = text + case "TPE2": + tag.AlbumArtist = text + case "TRCK": + // 格式可能是 "1" 或 "1/12" + parts := strings.SplitN(text, "/", 2) + if n, err := strconv.Atoi(strings.TrimSpace(parts[0])); err == nil { + tag.TrackNumber = n + } + case "TYER", "TDRC": + if tag.Year == "" { + tag.Year = text + } + case "TCON": + tag.Genre = parseID3Genre(text) + } + } + + // 解析 APIC 帧(内嵌封面图片),只取第一张 + if frameID == "APIC" && tag.CoverData == nil && len(frameData) > 1 { + tag.CoverData, tag.CoverMIME = parseAPICFrame(frameData) + } + } + + return tag, nil +} + +// parseAPICFrame 解析 ID3v2 APIC 帧,返回图片字节和 MIME 类型 +// APIC 格式:编码字节(1) + MIME字符串(null结尾) + 图片类型(1) + 描述字符串(null结尾) + 图片数据 +func parseAPICFrame(data []byte) ([]byte, string) { + if len(data) < 4 { + return nil, "" + } + // encoding := data[0] // 文本编码(只影响描述字段,MIME 始终是 ASCII) + pos := 1 + + // 读取 MIME 类型(null 结尾的 ASCII 字符串) + nullIdx := -1 + for i := pos; i < len(data); i++ { + if data[i] == 0 { + nullIdx = i + break + } + } + if nullIdx < 0 { + return nil, "" + } + mimeType := string(data[pos:nullIdx]) + pos = nullIdx + 1 + + if pos >= len(data) { + return nil, "" + } + // 图片类型(1字节,3 = Cover (front),但我们取任意第一张) + pos++ // 跳过图片类型字节 + + // 跳过描述字符串(null 结尾,编码由第一字节决定) + encoding := data[0] + if encoding == 0x01 || encoding == 0x02 { + // UTF-16:找 \x00\x00 结尾 + for pos+1 < len(data) { + if data[pos] == 0 && data[pos+1] == 0 { + pos += 2 + break + } + pos += 2 + } + } else { + // ISO-8859-1 / UTF-8:找单个 \x00 结尾 + for pos < len(data) { + if data[pos] == 0 { + pos++ + break + } + pos++ + } + } + + if pos >= len(data) { + return nil, "" + } + + // 标准化 MIME 类型 + if mimeType == "" || mimeType == "image/" { + mimeType = "image/jpeg" // 默认 + } + + imgData := make([]byte, len(data)-pos) + copy(imgData, data[pos:]) + return imgData, mimeType +} + +// syncsafeToInt 将 syncsafe integer 转换为普通整数 +func syncsafeToInt(b []byte) int { + result := 0 + for _, v := range b { + result = (result << 7) | int(v&0x7F) + } + return result +} + +// decodeID3Text 解码 ID3 文本帧(第一字节是编码标识) +func decodeID3Text(data []byte) string { + if len(data) == 0 { + return "" + } + encoding := data[0] + content := data[1:] + + // 去掉末尾的 null 字节 + switch encoding { + case 0x00: // ISO-8859-1 + // 去掉末尾 null + content = trimNull(content, false) + // 尝试 UTF-8 解码,如果失败则按 Latin-1 处理 + return latin1ToUTF8(content) + case 0x01: // UTF-16 with BOM + content = trimNull(content, true) + return utf16ToUTF8(content) + case 0x02: // UTF-16 BE without BOM + content = trimNull(content, true) + return utf16BEToUTF8(content) + case 0x03: // UTF-8 + content = trimNull(content, false) + return string(content) + default: + return string(content) + } +} + +// trimNull 去掉末尾的 null 字节 +func trimNull(b []byte, wide bool) []byte { + if wide { + // UTF-16: 去掉末尾的 \x00\x00 + for len(b) >= 2 && b[len(b)-2] == 0 && b[len(b)-1] == 0 { + b = b[:len(b)-2] + } + } else { + for len(b) > 0 && b[len(b)-1] == 0 { + b = b[:len(b)-1] + } + } + return b +} + +// latin1ToUTF8 将 Latin-1 编码转换为 UTF-8 +func latin1ToUTF8(b []byte) string { + runes := make([]rune, len(b)) + for i, v := range b { + runes[i] = rune(v) + } + return string(runes) +} + +// utf16ToUTF8 将 UTF-16(带 BOM)转换为 UTF-8 +func utf16ToUTF8(b []byte) string { + if len(b) < 2 { + return "" + } + var bigEndian bool + if b[0] == 0xFF && b[1] == 0xFE { + bigEndian = false + b = b[2:] + } else if b[0] == 0xFE && b[1] == 0xFF { + bigEndian = true + b = b[2:] + } + return decodeUTF16(b, bigEndian) +} + +// utf16BEToUTF8 将 UTF-16 BE(无 BOM)转换为 UTF-8 +func utf16BEToUTF8(b []byte) string { + return decodeUTF16(b, true) +} + +// decodeUTF16 解码 UTF-16 字节序列 +func decodeUTF16(b []byte, bigEndian bool) string { + if len(b)%2 != 0 { + b = b[:len(b)-1] + } + runes := make([]rune, 0, len(b)/2) + for i := 0; i+1 < len(b); i += 2 { + var code uint16 + if bigEndian { + code = uint16(b[i])<<8 | uint16(b[i+1]) + } else { + code = uint16(b[i+1])<<8 | uint16(b[i]) + } + runes = append(runes, rune(code)) + } + return string(runes) +} + +// parseID3Genre 解析 ID3 流派字段(可能是 "(17)" 格式的数字引用) +func parseID3Genre(s string) string { + s = strings.TrimSpace(s) + if len(s) > 2 && s[0] == '(' && s[len(s)-1] == ')' { + // 数字引用格式,直接返回原始字符串 + return s + } + return s +} + +// ─── FLAC Vorbis Comment 解析 ──────────────────────────────────────────────── + +// ParseFLACVorbisComment 从 io.Reader 中解析 FLAC 文件的 Vorbis Comment 元数据 +// FLAC 格式:4字节魔数 "fLaC" + 若干 METADATA_BLOCK +// METADATA_BLOCK:1字节(最高位=是否最后块, 低7位=块类型) + 3字节长度 + 数据 +// 块类型 4 = VORBIS_COMMENT +func ParseFLACVorbisComment(r io.Reader) (*MusicTag, error) { + // 读取魔数(4字节) + magic := make([]byte, 4) + if _, err := io.ReadFull(r, magic); err != nil { + return nil, err + } + if string(magic) != "fLaC" { + return nil, nil // 不是 FLAC 文件 + } + + // 遍历 METADATA_BLOCK,找到 VORBIS_COMMENT(类型4)和 PICTURE(类型6) + var tag *MusicTag + for { + // 读取块头(4字节:1字节标志+类型 + 3字节长度) + blockHeader := make([]byte, 4) + if _, err := io.ReadFull(r, blockHeader); err != nil { + return nil, nil // 读取失败,静默跳过 + } + + isLast := blockHeader[0]&0x80 != 0 + blockType := blockHeader[0] & 0x7F + blockLen := int(blockHeader[1])<<16 | int(blockHeader[2])<<8 | int(blockHeader[3]) + + if blockLen < 0 || blockLen > 16*1024*1024 { // 最大 16MB + return nil, nil + } + + if blockType == 4 { + // VORBIS_COMMENT 块 + data := make([]byte, blockLen) + if _, err := io.ReadFull(r, data); err != nil { + return nil, nil + } + tag = parseVorbisCommentData(data) + if isLast { + return tag, nil + } + // 继续读取后续块,寻找 PICTURE 块(类型6) + continue + } + + if blockType == 6 { + // PICTURE 块(FLAC 内嵌封面) + data := make([]byte, blockLen) + if _, err := io.ReadFull(r, data); err == nil && tag != nil && tag.CoverData == nil { + tag.CoverData, tag.CoverMIME = parseFLACPictureBlock(data) + } else if err != nil { + // 读取失败,跳过 + _ = err + } + if isLast { + return tag, nil + } + continue + } + + // 跳过此块 + if _, err := io.CopyN(io.Discard, r, int64(blockLen)); err != nil { + return nil, nil + } + + if isLast { + break + } + } + + return tag, nil +} + +// parseFLACPictureBlock 解析 FLAC PICTURE 元数据块,返回图片字节和 MIME 类型 +// 格式(大端序): +// 4字节 picture_type +// 4字节 mime_length + mime_string +// 4字节 description_length + description_string +// 4字节 width, 4字节 height, 4字节 color_depth, 4字节 color_count +// 4字节 data_length + data +func parseFLACPictureBlock(data []byte) ([]byte, string) { + if len(data) < 8 { + return nil, "" + } + pos := 4 // 跳过 picture_type + + // 读取 MIME 类型 + if pos+4 > len(data) { + return nil, "" + } + mimeLen := int(binary.BigEndian.Uint32(data[pos : pos+4])) + pos += 4 + if pos+mimeLen > len(data) { + return nil, "" + } + mimeType := string(data[pos : pos+mimeLen]) + pos += mimeLen + + // 跳过描述字符串 + if pos+4 > len(data) { + return nil, "" + } + descLen := int(binary.BigEndian.Uint32(data[pos : pos+4])) + pos += 4 + descLen + + // 跳过 width(4) + height(4) + color_depth(4) + color_count(4) + pos += 16 + + // 读取图片数据 + if pos+4 > len(data) { + return nil, "" + } + dataLen := int(binary.BigEndian.Uint32(data[pos : pos+4])) + pos += 4 + if pos+dataLen > len(data) { + return nil, "" + } + + if mimeType == "" { + mimeType = "image/jpeg" + } + + imgData := make([]byte, dataLen) + copy(imgData, data[pos:pos+dataLen]) + return imgData, mimeType +} + +// parseVorbisCommentData 解析 Vorbis Comment 数据块 +// 格式(小端序): +// 4字节 vendor_length + vendor_string +// 4字节 comment_count +// 每条注释:4字节长度 + UTF-8字符串(格式 "KEY=VALUE") +func parseVorbisCommentData(data []byte) *MusicTag { + tag := &MusicTag{} + pos := 0 + + // 跳过 vendor string + if pos+4 > len(data) { + return tag + } + vendorLen := int(binary.LittleEndian.Uint32(data[pos : pos+4])) + pos += 4 + vendorLen + + // 读取注释数量 + if pos+4 > len(data) { + return tag + } + commentCount := int(binary.LittleEndian.Uint32(data[pos : pos+4])) + pos += 4 + + for i := 0; i < commentCount; i++ { + if pos+4 > len(data) { + break + } + commentLen := int(binary.LittleEndian.Uint32(data[pos : pos+4])) + pos += 4 + + if commentLen <= 0 || pos+commentLen > len(data) { + break + } + + comment := string(data[pos : pos+commentLen]) + pos += commentLen + + // 解析 "KEY=VALUE" 格式(大小写不敏感) + eqIdx := strings.IndexByte(comment, '=') + if eqIdx < 0 { + continue + } + key := strings.ToUpper(comment[:eqIdx]) + value := strings.TrimSpace(comment[eqIdx+1:]) + if value == "" { + continue + } + + switch key { + case "TITLE": + tag.Title = value + case "ARTIST": + if tag.Artist == "" { + tag.Artist = value + } + case "ALBUM": + tag.Album = value + case "ALBUMARTIST", "ALBUM ARTIST": + tag.AlbumArtist = value + case "TRACKNUMBER", "TRACK": + // 格式可能是 "1" 或 "1/12" + parts := strings.SplitN(value, "/", 2) + if n, err := strconv.Atoi(strings.TrimSpace(parts[0])); err == nil { + tag.TrackNumber = n + } + case "DATE", "YEAR": + if tag.Year == "" && len(value) >= 4 { + tag.Year = value[:4] + } + case "GENRE": + tag.Genre = value + } + } + + return tag +} diff --git a/internal/media/scanner.go b/internal/media/scanner.go new file mode 100644 index 000000000..60f1f4236 --- /dev/null +++ b/internal/media/scanner.go @@ -0,0 +1,697 @@ +package media + +import ( + "context" + "encoding/base64" + "encoding/json" + "io" + "mime" + "net/http" + stdpath "path" + "regexp" + "strings" + "sync" + "time" + "unicode" + + log "github.com/sirupsen/logrus" + + "github.com/OpenListTeam/OpenList/v4/internal/db" + "github.com/OpenListTeam/OpenList/v4/internal/fs" + "github.com/OpenListTeam/OpenList/v4/internal/model" + "github.com/OpenListTeam/OpenList/v4/pkg/http_range" +) + +// 支持的文件扩展名 +var ( + videoExts = map[string]bool{ + ".mp4": true, ".mkv": true, ".avi": true, ".mov": true, + ".wmv": true, ".flv": true, ".webm": true, ".m4v": true, + ".ts": true, ".rmvb": true, ".rm": true, ".3gp": true, + } + musicExts = map[string]bool{ + ".mp3": true, ".flac": true, ".aac": true, ".ogg": true, + ".wav": true, ".wma": true, ".m4a": true, ".ape": true, + ".opus": true, ".aiff": true, + } + imageExts = map[string]bool{ + ".jpg": true, ".jpeg": true, ".png": true, ".gif": true, + ".webp": true, ".bmp": true, ".tiff": true, ".svg": true, + ".heic": true, ".avif": true, + } + bookExts = map[string]bool{ + ".epub": true, ".pdf": true, ".mobi": true, ".azw3": true, + ".txt": true, ".djvu": true, ".cbz": true, ".cbr": true, + } +) + +// ScanProgress 扫描进度(全局,按媒体类型维护) +type ScanProgress struct { + mu sync.RWMutex + Running bool + Total int + Done int + Message string + Error string +} + +var progressMap = map[model.MediaType]*ScanProgress{ + model.MediaTypeVideo: {}, + model.MediaTypeMusic: {}, + model.MediaTypeImage: {}, + model.MediaTypeBook: {}, +} + +// GetProgress 获取扫描进度 +func GetProgress(mediaType model.MediaType) model.MediaScanProgress { + p, ok := progressMap[mediaType] + if !ok { + return model.MediaScanProgress{MediaType: mediaType} + } + p.mu.RLock() + defer p.mu.RUnlock() + return model.MediaScanProgress{ + MediaType: mediaType, + Running: p.Running, + Total: p.Total, + Done: p.Done, + Message: p.Message, + Error: p.Error, + } +} + +// ScanMedia 扫描媒体文件(异步),扫描指定媒体类型下的所有启用扫描路径 +func ScanMedia(cfg *model.MediaConfig) { + p, ok := progressMap[cfg.MediaType] + if !ok { + return + } + p.mu.Lock() + if p.Running { + p.mu.Unlock() + return + } + p.Running = true + p.Total = 0 + p.Done = 0 + p.Error = "" + p.Message = "正在扫描..." + p.mu.Unlock() + + go func() { + defer func() { + p.mu.Lock() + p.Running = false + p.mu.Unlock() + }() + + // 获取该媒体类型下所有扫描路径 + scanPaths, err := db.ListMediaScanPaths(cfg.MediaType) + if err != nil { + p.mu.Lock() + p.Error = err.Error() + p.Message = "获取扫描路径失败" + p.mu.Unlock() + return + } + if len(scanPaths) == 0 { + p.mu.Lock() + p.Message = "没有配置扫描路径" + p.mu.Unlock() + return + } + + for i := range scanPaths { + sp := &scanPaths[i] + p.mu.Lock() + p.Message = "正在扫描路径: " + sp.Name + p.mu.Unlock() + + if err := doScanPath(sp, p); err != nil { + log.Errorf("media scan error [%s] path[%s]: %v", cfg.MediaType, sp.Path, err) + } else { + // 更新最后扫描时间 + now := time.Now() + sp.LastScanAt = &now + _ = db.UpdateMediaScanPath(sp) + } + } + + p.mu.Lock() + p.Message = "扫描完成" + p.mu.Unlock() + // 更新媒体库最后扫描时间 + now := time.Now() + cfg.LastScanAt = &now + _ = db.SaveMediaConfig(cfg) + }() +} + +// ScanMediaPath 扫描单个扫描路径(异步) +func ScanMediaPath(sp *model.MediaScanPath) { + p, ok := progressMap[sp.MediaType] + if !ok { + return + } + p.mu.Lock() + if p.Running { + p.mu.Unlock() + return + } + p.Running = true + p.Total = 0 + p.Done = 0 + p.Error = "" + p.Message = "正在扫描路径: " + sp.Name + p.mu.Unlock() + + go func() { + defer func() { + p.mu.Lock() + p.Running = false + p.mu.Unlock() + }() + + if err := doScanPath(sp, p); err != nil { + p.mu.Lock() + p.Error = err.Error() + p.Message = "扫描失败" + p.mu.Unlock() + log.Errorf("media scan error [%s] path[%s]: %v", sp.MediaType, sp.Path, err) + } else { + p.mu.Lock() + p.Message = "扫描完成" + p.mu.Unlock() + now := time.Now() + sp.LastScanAt = &now + _ = db.UpdateMediaScanPath(sp) + } + }() +} + +func doScanPath(sp *model.MediaScanPath, p *ScanProgress) error { + scanRoot := sp.Path + if scanRoot == "" { + scanRoot = "/" + } + + // 收集所有待处理路径(VFS 虚拟路径) + var targets []string + + ctx := context.Background() + + if sp.PathMerge { + // 路径合并模式: + // - 子文件夹 → 作为一个合并条目(带 Episodes 选集信息) + // - 直接放在根目录下的单个媒体文件 → 正常作为独立条目扫描 + entries, err := fs.List(ctx, scanRoot, &fs.ListArgs{NoLog: true, Refresh: true}) + if err != nil { + return err + } + for _, e := range entries { + childPath := stdpath.Join(scanRoot, e.GetName()) + if e.IsDir() { + targets = append(targets, childPath) + } else if isMediaFile(e.GetName(), sp.MediaType) { + targets = append(targets, childPath) + } + } + } else { + // 普通模式:递归扫描所有匹配文件(每个目录都刷新缓存) + if err := walkVFS(ctx, scanRoot, sp.MediaType, &targets); err != nil { + return err + } + } + + p.mu.Lock() + p.Total += len(targets) + p.mu.Unlock() + + // 音乐类型:按专辑合并处理 + if sp.MediaType == model.MediaTypeMusic { + return doScanMusicByAlbum(ctx, targets, sp, p) + } + + for _, target := range targets { + item, err := buildMediaItemFromVFS(ctx, target, sp) + if err != nil { + log.Warnf("build media item error [%s]: %v", target, err) + continue + } + + // 路径合并模式下,扫描文件夹内的文件,填充选集信息 + if sp.PathMerge && item.IsFolder { + if episodes, err := buildEpisodesFromFolder(ctx, target, sp.MediaType); err == nil { + item.Episodes = episodes + } else { + log.Warnf("build episodes error [%s]: %v", target, err) + } + } + + if err := db.CreateOrUpdateMediaItem(item); err != nil { + log.Warnf("save media item error [%s]: %v", target, err) + } + p.mu.Lock() + p.Done++ + p.Message = "已扫描: " + stdpath.Base(target) + p.mu.Unlock() + } + return nil +} + +// doScanMusicByAlbum 音乐扫描 +// 存储规则: +// - 普通文件(is_folder=false): +// folder_path=文件所在目录,file_name=音乐文件名,episodes=空 +// - 合并文件夹(is_folder=true): +// folder_path=扫描根路径(sp.Path),file_name=文件夹名, +// episodes=文件夹内所有音乐文件列表,封面/标签取第一首 +func doScanMusicByAlbum(ctx context.Context, targets []string, sp *model.MediaScanPath, p *ScanProgress) error { + for _, target := range targets { + obj, err := fs.Get(ctx, target, &fs.GetArgs{NoLog: true}) + if err != nil { + log.Warnf("get vfs object error [%s]: %v", target, err) + p.mu.Lock() + p.Done++ + p.mu.Unlock() + continue + } + + name := obj.GetName() + + if obj.IsDir() { + // ---- 合并文件夹模式:文件夹条目 ---- + // 列出文件夹内所有音乐文件 + entries, err := fs.List(ctx, target, &fs.ListArgs{NoLog: true}) + if err != nil { + log.Warnf("list music folder error [%s]: %v", target, err) + p.mu.Lock() + p.Done++ + p.mu.Unlock() + continue + } + + var musicFiles []string + var episodes []EpisodeInfo + for idx, e := range entries { + if e.IsDir() || !isMediaFile(e.GetName(), sp.MediaType) { + continue + } + musicFiles = append(musicFiles, stdpath.Join(target, e.GetName())) + ep := EpisodeInfo{ + FileName: e.GetName(), + Index: idx + 1, + Title: strings.TrimSuffix(e.GetName(), stdpath.Ext(e.GetName())), + } + episodes = append(episodes, ep) + } + + if len(musicFiles) == 0 { + p.mu.Lock() + p.Done++ + p.mu.Unlock() + continue + } + + // 取第一首音乐文件读取标签 + firstFile := musicFiles[0] + firstExt := strings.ToLower(stdpath.Ext(firstFile)) + var tag *MusicTag + readCtx, readCancel := context.WithTimeout(ctx, 15*time.Second) + if reader := FetchFileReader(readCtx, firstFile); reader != nil { + switch firstExt { + case ".flac": + tag, _ = ParseFLACVorbisComment(reader) + default: + tag, _ = ParseID3v2(reader) + } + _ = reader.Close() + } + readCancel() + + // 解析标签(用文件夹名作为默认专辑名) + albumName := name + albumArtist := "" + cover := "" + releaseDate := "" + genre := "" + authors := "" + if tag != nil { + if tag.Album != "" { + albumName = tag.Album + } + albumArtist = tag.AlbumArtist + if albumArtist == "" { + albumArtist = tag.Artist + } + if tag.Year != "" && len(tag.Year) >= 4 { + releaseDate = tag.Year[:4] + "-01-01" + } + if tag.Genre != "" { + genre = tag.Genre + } + if tag.Artist != "" { + if authorsJSON, err := json.Marshal([]string{tag.Artist}); err == nil { + authors = string(authorsJSON) + } + } + if len(tag.CoverData) > 0 { + mimeType := tag.CoverMIME + if mimeType == "" { + mimeType = "image/jpeg" + } + cover = "data:" + mimeType + ";base64," + base64.StdEncoding.EncodeToString(tag.CoverData) + } + } + + episodesJSON := "" + if b, err := json.Marshal(episodes); err == nil { + episodesJSON = string(b) + } + + item := &model.MediaItem{ + MediaType: sp.MediaType, + ScanPathID: sp.ID, + FileName: name, // 文件夹名 + FolderPath: sp.Path, // 扫描根路径 + IsFolder: true, + AlbumName: albumName, + AlbumArtist: albumArtist, + ScrapedName: albumName, + Cover: cover, + ReleaseDate: releaseDate, + Genre: genre, + Authors: authors, + Episodes: episodesJSON, + } + + if err := db.CreateOrUpdateMediaItem(item); err != nil { + log.Warnf("save music folder item error [%s]: %v", target, err) + } + } else { + // ---- 普通文件:每首歌独立一条记录 ---- + folderPath := stdpath.Dir(target) + ext := strings.ToLower(stdpath.Ext(name)) + + var tag *MusicTag + readCtx, readCancel := context.WithTimeout(ctx, 15*time.Second) + if reader := FetchFileReader(readCtx, target); reader != nil { + switch ext { + case ".flac": + tag, _ = ParseFLACVorbisComment(reader) + default: + tag, _ = ParseID3v2(reader) + } + _ = reader.Close() + } + readCancel() + + albumName := "" + albumArtist := "" + trackTitle := strings.TrimSuffix(name, stdpath.Ext(name)) + trackNumber := 0 + cover := "" + releaseDate := "" + genre := "" + authors := "" + + if tag != nil { + albumName = tag.Album + albumArtist = tag.AlbumArtist + if albumArtist == "" { + albumArtist = tag.Artist + } + if tag.Title != "" { + trackTitle = tag.Title + } + trackNumber = tag.TrackNumber + if tag.Year != "" && len(tag.Year) >= 4 { + releaseDate = tag.Year[:4] + "-01-01" + } + if tag.Genre != "" { + genre = tag.Genre + } + if tag.Artist != "" { + if authorsJSON, err := json.Marshal([]string{tag.Artist}); err == nil { + authors = string(authorsJSON) + } + } + if len(tag.CoverData) > 0 { + mimeType := tag.CoverMIME + if mimeType == "" { + mimeType = "image/jpeg" + } + cover = "data:" + mimeType + ";base64," + base64.StdEncoding.EncodeToString(tag.CoverData) + } + } + + item := &model.MediaItem{ + MediaType: sp.MediaType, + ScanPathID: sp.ID, + FileName: name, + FolderPath: folderPath, + IsFolder: false, + FileSize: obj.GetSize(), + MimeType: mime.TypeByExtension(ext), + AlbumName: albumName, + AlbumArtist: albumArtist, + ScrapedName: trackTitle, + Cover: cover, + ReleaseDate: releaseDate, + Genre: genre, + Authors: authors, + TrackNumber: trackNumber, + } + + if err := db.CreateOrUpdateMediaItem(item); err != nil { + log.Warnf("save music item error [%s]: %v", target, err) + } + } + + p.mu.Lock() + p.Done++ + p.Message = "已扫描: " + name + p.mu.Unlock() + } + return nil +} + +// FetchFileReader 通过 VFS 路径获取文件内容流(用于刮削器读取文件内容) +// 优先使用 RangeReader 直接读取(本地存储无需 HTTP),失败时回退到 HTTP URL +// 返回 nil 表示无法获取(不影响主流程) +func FetchFileReader(ctx context.Context, vfsPath string) io.ReadCloser { + link, _, err := fs.Link(ctx, vfsPath, model.LinkArgs{}) + if err != nil || link == nil { + return nil + } + // 优先使用 RangeReader(本地存储直接读取,无需 HTTP 请求) + if link.RangeReader != nil { + rc, err := link.RangeReader.RangeRead(ctx, http_range.Range{Start: 0, Length: -1}) + if err == nil && rc != nil { + return rc + } + } + // 回退:通过 HTTP URL 读取(远程存储) + if link.URL == "" { + return nil + } + req, err := http.NewRequestWithContext(ctx, http.MethodGet, link.URL, nil) + if err != nil { + return nil + } + resp, err := http.DefaultClient.Do(req) + if err != nil { + return nil + } + if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusPartialContent { + _ = resp.Body.Close() + return nil + } + return resp.Body +} + +// walkVFS 递归遍历 VFS 路径,收集匹配的媒体文件路径(每个目录都刷新缓存) +func walkVFS(ctx context.Context, dirPath string, mediaType model.MediaType, targets *[]string) error { + entries, err := fs.List(ctx, dirPath, &fs.ListArgs{NoLog: true, Refresh: true}) + if err != nil { + log.Warnf("media scan: list vfs path [%s] error: %v", dirPath, err) + return nil // 跳过无权限目录,不中断整体扫描 + } + for _, e := range entries { + childPath := stdpath.Join(dirPath, e.GetName()) + if e.IsDir() { + if err := walkVFS(ctx, childPath, mediaType, targets); err != nil { + return err + } + } else if isMediaFile(e.GetName(), mediaType) { + *targets = append(*targets, childPath) + } + } + return nil +} + +// buildMediaItemFromVFS 根据 VFS 路径构建 MediaItem(非音乐类型使用) +// 存储规则: +// - folder_path:恒定为扫描根路径 sp.Path +// - file_name:文件夹就是文件夹名,文件就是文件名 +// - episodes:文件夹时存里面每个文件的信息,文件时为空 +func buildMediaItemFromVFS(ctx context.Context, vfsPath string, sp *model.MediaScanPath) (*model.MediaItem, error) { + obj, err := fs.Get(ctx, vfsPath, &fs.GetArgs{NoLog: true}) + if err != nil { + return nil, err + } + + name := obj.GetName() + ext := strings.ToLower(stdpath.Ext(name)) + + item := &model.MediaItem{ + MediaType: sp.MediaType, + ScanPathID: sp.ID, + FileName: name, // 文件夹名 或 文件名 + FolderPath: sp.Path, // 恒定为扫描根路径 + IsFolder: obj.IsDir(), + ScrapedName: strings.TrimSuffix(name, stdpath.Ext(name)), // 去掉扩展名作为默认名称 + } + + if !obj.IsDir() { + // 普通文件:记录大小和 MIME 类型,episodes 为空 + item.FileSize = obj.GetSize() + item.MimeType = mime.TypeByExtension(ext) + } + + return item, nil +} + +// episodeNumRe 匹配文件名开头的数字序号,支持 "1、" "2." "3-" "4 " 等分隔符 +var episodeNumRe = regexp.MustCompile(`^(\d+)[、.\-\s_]+(.*)`) + +// EpisodeInfo 选集信息 +type EpisodeInfo struct { + FileName string `json:"file_name"` // 原始文件名(含扩展名) + Index int `json:"index"` // 序号,默认0,文件名开头有数字则取该数字 + Title string `json:"title"` // 选集标题(去掉序号后的文件名,不含扩展名) +} + +// buildEpisodesFromFolder 扫描文件夹内的媒体文件,构建选集信息 JSON 字符串 +func buildEpisodesFromFolder(ctx context.Context, folderPath string, mediaType model.MediaType) (string, error) { + entries, err := fs.List(ctx, folderPath, &fs.ListArgs{NoLog: true}) + if err != nil { + return "", err + } + + var episodes []EpisodeInfo + for _, e := range entries { + if e.IsDir() { + continue + } + name := e.GetName() + if !isMediaFile(name, mediaType) { + continue + } + + // 去掉扩展名得到裸文件名 + ext := stdpath.Ext(name) + baseName := strings.TrimSuffix(name, ext) + + ep := EpisodeInfo{ + FileName: name, + Index: 0, + Title: baseName, + } + + // 尝试从文件名开头提取数字序号 + if m := episodeNumRe.FindStringSubmatch(baseName); len(m) == 3 { + if idx := parseLeadingInt(m[1]); idx > 0 { + ep.Index = idx + ep.Title = strings.TrimSpace(m[2]) + } + } else { + // 文件名直接以纯数字开头(无分隔符),也尝试提取 + if idx, rest := splitLeadingNumber(baseName); idx > 0 { + ep.Index = idx + ep.Title = strings.TrimSpace(rest) + } + } + + episodes = append(episodes, ep) + } + + if len(episodes) == 0 { + return "", nil + } + + b, err := json.Marshal(episodes) + if err != nil { + return "", err + } + return string(b), nil +} + +// parseLeadingInt 将纯数字字符串解析为 int,失败返回 0 +func parseLeadingInt(s string) int { + v := 0 + for _, c := range s { + if c < '0' || c > '9' { + return 0 + } + v = v*10 + int(c-'0') + } + return v +} + +// splitLeadingNumber 从字符串开头提取连续数字,返回 (数字值, 剩余字符串) +// 仅当开头确实有数字时才返回非零值 +func splitLeadingNumber(s string) (int, string) { + i := 0 + for i < len(s) && s[i] >= '0' && s[i] <= '9' { + i++ + } + if i == 0 || i == len(s) { + // 没有数字,或者整个字符串都是数字(没有标题部分) + return 0, s + } + // 剩余部分必须以非字母数字字符开头,避免把 "1080p" 之类的误识别 + if unicode.IsLetter(rune(s[i])) || unicode.IsDigit(rune(s[i])) { + return 0, s + } + v := parseLeadingInt(s[:i]) + return v, s[i:] +} + +// isMediaFile 判断文件名是否为指定媒体类型(按扩展名判断) +func isMediaFile(name string, mediaType model.MediaType) bool { + ext := strings.ToLower(stdpath.Ext(name)) + switch mediaType { + case model.MediaTypeVideo: + return videoExts[ext] + case model.MediaTypeMusic: + return musicExts[ext] + case model.MediaTypeImage: + return imageExts[ext] + case model.MediaTypeBook: + return bookExts[ext] + } + return false +} + +// GetSupportedExts 获取指定媒体类型支持的扩展名列表 +func GetSupportedExts(mediaType model.MediaType) []string { + var extMap map[string]bool + switch mediaType { + case model.MediaTypeVideo: + extMap = videoExts + case model.MediaTypeMusic: + extMap = musicExts + case model.MediaTypeImage: + extMap = imageExts + case model.MediaTypeBook: + extMap = bookExts + default: + return nil + } + exts := make([]string, 0, len(extMap)) + for ext := range extMap { + exts = append(exts, ext) + } + return exts +} \ No newline at end of file diff --git a/internal/media/scraper/book_local.go b/internal/media/scraper/book_local.go new file mode 100644 index 000000000..9d7f79d2c --- /dev/null +++ b/internal/media/scraper/book_local.go @@ -0,0 +1,402 @@ +package scraper + +import ( + "archive/zip" + "bytes" + "encoding/base64" + "encoding/xml" + "image" + "image/jpeg" + "io" + "os" + "path/filepath" + "strings" + "time" + + "github.com/disintegration/imaging" + "github.com/OpenListTeam/OpenList/v4/internal/model" +) + +// BookLocalScraper 书籍本地刮削器 +// 尝试从书籍文件中提取封面图片并以 base64 存入 Cover 字段,或保存到本地文件。 +// - EPUB:解压 zip,提取 OPF 声明的封面或 cover.jpg/cover.png +// - PDF:尝试提取内嵌 JPEG 图片流作为封面 +// - 其他/失败:降级为文件路径 +type BookLocalScraper struct { + // ThumbnailMode 缩略图存储方式:"base64"(默认,存入数据库)或 "local"(存到本地文件) + ThumbnailMode string + // ThumbnailPath 本地存储路径,ThumbnailMode 为 "local" 时有效,默认 "/.thumbnail" + ThumbnailPath string +} + +// NewBookLocalScraper 创建书籍本地刮削器 +func NewBookLocalScraper() *BookLocalScraper { + return &BookLocalScraper{ + ThumbnailMode: "base64", + ThumbnailPath: "/.thumbnail", + } +} + +// NewBookLocalScraperWithConfig 创建带完整配置的书籍本地刮削器 +func NewBookLocalScraperWithConfig(thumbnailMode, thumbnailPath string) *BookLocalScraper { + if thumbnailMode == "" { + thumbnailMode = "base64" + } + if thumbnailPath == "" { + thumbnailPath = "/.thumbnail" + } + return &BookLocalScraper{ + ThumbnailMode: thumbnailMode, + ThumbnailPath: thumbnailPath, + } +} + +// ScrapeBookLocal 从书籍文件流中提取封面图片,填充基本信息。 +// reader 为书籍文件内容流,不能为 nil。 +// 返回提取到的封面 base64 字符串(data URI),提取失败返回空字符串。 +// 注意:本函数不会将文件路径作为 cover 降级,cover 为空表示提取失败。 +func (s *BookLocalScraper) ScrapeBookLocal(item *model.MediaItem, reader io.Reader) error { + ext := strings.ToLower(getFileExt(item.FileName)) + + // 根据文件类型补充 MimeType + if item.MimeType == "" { + item.MimeType = mimeTypeByBookExt(ext) + } + // 去掉扩展名作为展示名 + if item.ScrapedName == "" { + item.ScrapedName = trimExt(item.FileName) + } + + // 从文件流提取封面,提取失败则 cover 保持为空 + if reader != nil { + data, err := io.ReadAll(reader) + if err == nil && len(data) > 0 { + var coverData []byte + switch ext { + case ".epub": + coverData = extractEpubCoverData(data) + case ".pdf": + coverData = extractPDFCoverData(data) + } + if len(coverData) > 0 { + cover := s.storeCover(item.FolderPath, coverData) + if cover != "" { + item.Cover = cover + } + } + } + } + + now := time.Now() + item.ScrapedAt = &now + return nil +} + +// ExtractLocalCover 从书籍文件流中提取封面并根据配置存储(供外部调用)。 +// 提取失败返回空字符串,不降级为文件路径。 +func (s *BookLocalScraper) ExtractLocalCover(fileName string, filePath string, reader io.Reader) string { + if reader == nil { + return "" + } + ext := strings.ToLower(getFileExt(fileName)) + data, err := io.ReadAll(reader) + if err != nil || len(data) == 0 { + return "" + } + var coverData []byte + switch ext { + case ".epub": + coverData = extractEpubCoverData(data) + case ".pdf": + coverData = extractPDFCoverData(data) + } + if len(coverData) == 0 { + return "" + } + return s.storeCover(filePath, coverData) +} + +// storeCover 根据 ThumbnailMode 存储封面数据,返回 Cover 字段值 +func (s *BookLocalScraper) storeCover(filePath string, coverData []byte) string { + if s.ThumbnailMode == "local" { + return s.saveCoverLocal(filePath, coverData) + } + // 默认 base64 模式 + return imageDataToBase64Thumb(coverData) +} + +// saveCoverLocal 将封面图片保存到本地文件系统,返回可访问的路径 +func (s *BookLocalScraper) saveCoverLocal(filePath string, coverData []byte) string { + // 生成缩略图数据 + img, _, err := image.Decode(bytes.NewReader(coverData)) + if err != nil { + // 解码失败时直接保存原始数据 + return s.writeLocalFile(filePath, coverData) + } + thumb := imaging.Resize(img, 300, 0, imaging.Lanczos) + var buf bytes.Buffer + if err := jpeg.Encode(&buf, thumb, &jpeg.Options{Quality: 80}); err != nil { + return "" + } + return s.writeLocalFile(filePath, buf.Bytes()) +} + +// writeLocalFile 将数据写入本地文件,返回内部访问路径 +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) +} + +// ── EPUB 封面提取 ───────────────────────────────────────────────────────────── + +// epubContainer 解析 META-INF/container.xml +type epubContainer struct { + Rootfiles []struct { + FullPath string `xml:"full-path,attr"` + } `xml:"rootfiles>rootfile"` +} + +// epubOPF 解析 OPF 文件,找封面 item +type epubOPF struct { + Metadata struct { + Metas []struct { + Name string `xml:"name,attr"` + Content string `xml:"content,attr"` + } `xml:"meta"` + } `xml:"metadata"` + Manifest struct { + Items []struct { + ID string `xml:"id,attr"` + Href string `xml:"href,attr"` + MediaType string `xml:"media-type,attr"` + } `xml:"item"` + } `xml:"manifest"` +} + +// extractEpubCoverData 从 EPUB(zip)数据中提取封面图片原始字节 +func extractEpubCoverData(data []byte) []byte { + r, err := zip.NewReader(bytes.NewReader(data), int64(len(data))) + if err != nil { + return nil + } + + // 建立文件名→ZipFile 映射(忽略大小写) + fileMap := make(map[string]*zip.File, len(r.File)) + for _, f := range r.File { + fileMap[strings.ToLower(f.Name)] = f + } + + // 1. 尝试读取 META-INF/container.xml 找 OPF 路径 + opfPath := "" + if cf, ok := fileMap["meta-inf/container.xml"]; ok { + if rc, err := cf.Open(); err == nil { + var container epubContainer + if xml.NewDecoder(rc).Decode(&container) == nil && len(container.Rootfiles) > 0 { + opfPath = container.Rootfiles[0].FullPath + } + rc.Close() + } + } + + // OPF 所在目录(用于拼接相对路径) + opfDir := "" + if idx := strings.LastIndex(opfPath, "/"); idx >= 0 { + opfDir = opfPath[:idx+1] + } + + // 2. 从 OPF 中找封面 item ID + coverHref := "" + if opfPath != "" { + if of, ok := fileMap[strings.ToLower(opfPath)]; ok { + if rc, err := of.Open(); err == nil { + var opf epubOPF + if xml.NewDecoder(rc).Decode(&opf) == nil { + // 先找 指向的 item id + coverItemID := "" + for _, m := range opf.Metadata.Metas { + if strings.EqualFold(m.Name, "cover") && m.Content != "" { + coverItemID = m.Content + break + } + } + // 在 manifest 中找对应 item + for _, item := range opf.Manifest.Items { + if coverItemID != "" && item.ID == coverItemID { + coverHref = opfDir + item.Href + break + } + // 如果没有指定 cover id,找第一个图片类型的 item + if coverItemID == "" && strings.Contains(strings.ToLower(item.MediaType), "image") { + // 优先选择 id 或 href 中包含 "cover" 的 + if strings.Contains(strings.ToLower(item.ID), "cover") || + strings.Contains(strings.ToLower(item.Href), "cover") { + coverHref = opfDir + item.Href + break + } + } + } + // 如果还没找到,取第一个图片 + if coverHref == "" && coverItemID == "" { + for _, item := range opf.Manifest.Items { + if strings.Contains(strings.ToLower(item.MediaType), "image") { + coverHref = opfDir + item.Href + break + } + } + } + } + rc.Close() + } + } + } + + // 3. 常见封面文件名候选(按优先级) + candidates := []string{} + if coverHref != "" { + candidates = append(candidates, strings.ToLower(coverHref)) + } + candidates = append(candidates, + "cover.jpg", "cover.jpeg", "cover.png", + "images/cover.jpg", "images/cover.jpeg", "images/cover.png", + "oebps/cover.jpg", "oebps/images/cover.jpg", + "oebps/cover.jpeg", "oebps/images/cover.jpeg", + ) + + for _, name := range candidates { + if f, ok := fileMap[name]; ok { + if rc, err := f.Open(); err == nil { + imgData, readErr := io.ReadAll(rc) + rc.Close() + if readErr == nil && len(imgData) > 0 { + return imgData + } + } + } + } + return nil +} + +// extractEpubCoverBase64 从 EPUB(zip)数据中提取封面图片,返回 data URI base64(向后兼容) +func extractEpubCoverBase64(data []byte) string { + coverData := extractEpubCoverData(data) + if len(coverData) == 0 { + return "" + } + return imageDataToBase64Thumb(coverData) +} + +// ── PDF 封面提取 ────────────────────────────────────────────────────────────── + +// extractPDFCoverData 从 PDF 数据中提取第一个内嵌 JPEG 图片流,返回原始字节 +// PDF 内嵌图片通常以 JPEG 流存储(SOI marker: 0xFF 0xD8),直接扫描字节流提取 +func extractPDFCoverData(data []byte) []byte { + // 扫描 JPEG SOI (0xFF 0xD8 0xFF) 标记 + for i := 0; i < len(data)-2; i++ { + if data[i] == 0xFF && data[i+1] == 0xD8 && data[i+2] == 0xFF { + // 找对应的 EOI (0xFF 0xD9) + for j := i + 2; j < len(data)-1; j++ { + if data[j] == 0xFF && data[j+1] == 0xD9 { + jpegData := data[i : j+2] + // 验证是否为有效图片(至少 1KB,避免误匹配小缩略图) + if len(jpegData) > 1024 { + return jpegData + } + break + } + } + } + } + return nil +} + +// extractPDFCoverBase64 从 PDF 数据中提取第一个内嵌 JPEG 图片流,返回 data URI base64(向后兼容) +func extractPDFCoverBase64(data []byte) string { + coverData := extractPDFCoverData(data) + if len(coverData) == 0 { + return "" + } + return imageDataToBase64Thumb(coverData) +} + +// ── 公共辅助 ────────────────────────────────────────────────────────────────── + +// imageDataToBase64Thumb 将图片字节解码并缩放为 300px 宽缩略图,返回 data URI base64 +func imageDataToBase64Thumb(data []byte) string { + img, _, err := image.Decode(bytes.NewReader(data)) + if err != nil { + // 解码失败时直接 base64 原始数据(可能是 PNG 等) + mimeType := detectImageMime(data) + return "data:" + mimeType + ";base64," + base64.StdEncoding.EncodeToString(data) + } + thumb := imaging.Resize(img, 300, 0, imaging.Lanczos) + var buf bytes.Buffer + if err := jpeg.Encode(&buf, thumb, &jpeg.Options{Quality: 80}); err != nil { + return "" + } + return "data:image/jpeg;base64," + base64.StdEncoding.EncodeToString(buf.Bytes()) +} + +// detectImageMime 根据文件头检测图片 MIME 类型 +func detectImageMime(data []byte) string { + if len(data) < 4 { + return "image/jpeg" + } + switch { + case data[0] == 0xFF && data[1] == 0xD8: + return "image/jpeg" + case data[0] == 0x89 && data[1] == 0x50 && data[2] == 0x4E && data[3] == 0x47: + return "image/png" + case data[0] == 0x47 && data[1] == 0x49 && data[2] == 0x46: + return "image/gif" + case data[0] == 0x52 && data[1] == 0x49 && data[2] == 0x46 && data[3] == 0x46: + return "image/webp" + default: + return "image/jpeg" + } +} + +// getFileExt 获取文件扩展名(含点号,小写) +func getFileExt(fileName string) string { + if idx := strings.LastIndex(fileName, "."); idx >= 0 { + return fileName[idx:] + } + return "" +} + +// mimeTypeByBookExt 根据书籍文件扩展名返回 MIME 类型 +func mimeTypeByBookExt(ext string) string { + switch ext { + case ".pdf": + return "application/pdf" + case ".epub": + return "application/epub+zip" + case ".mobi": + return "application/x-mobipocket-ebook" + case ".azw3": + return "application/vnd.amazon.ebook" + case ".txt": + return "text/plain" + case ".djvu": + return "image/vnd.djvu" + case ".cbz": + return "application/vnd.comicbook+zip" + case ".cbr": + return "application/vnd.comicbook-rar" + default: + return "application/octet-stream" + } +} diff --git a/internal/media/scraper/discogs.go b/internal/media/scraper/discogs.go new file mode 100644 index 000000000..4b086624e --- /dev/null +++ b/internal/media/scraper/discogs.go @@ -0,0 +1,291 @@ +package scraper + +import ( + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "strings" + "time" + + "github.com/OpenListTeam/OpenList/v4/internal/model" +) + +const discogsBaseURL = "https://api.discogs.com" + +// DiscogsScraper Discogs音乐刮削器 +type DiscogsScraper struct { + Token string + client *http.Client +} + +// NewDiscogsScraper 创建Discogs刮削器 +func NewDiscogsScraper(token string) *DiscogsScraper { + return &DiscogsScraper{ + Token: token, + client: &http.Client{Timeout: 15 * time.Second}, + } +} + +type discogsSearchResult struct { + Results []struct { + ID int `json:"id"` + Title string `json:"title"` + Type string `json:"type"` + Year string `json:"year"` + Thumb string `json:"thumb"` + CoverImage string `json:"cover_image"` + Genre []string `json:"genre"` + Style []string `json:"style"` + ResourceURL string `json:"resource_url"` + } `json:"results"` +} + +type discogsReleaseDetail struct { + ID int `json:"id"` + Title string `json:"title"` + Year int `json:"year"` + CoverImage string `json:"cover_image"` + Notes string `json:"notes"` + Artists []struct { + Name string `json:"name"` + } `json:"artists"` + Genres []string `json:"genres"` + Styles []string `json:"styles"` + Tracklist []struct { + Position string `json:"position"` + Title string `json:"title"` + Duration string `json:"duration"` + } `json:"tracklist"` + Community struct { + Rating struct { + Average float32 `json:"average"` + } `json:"rating"` + } `json:"community"` +} + +// parseMusicFileName 从音乐文件名中提取搜索关键词列表 +// 规则:去掉扩展名后,按" - "或"-"分割,返回各部分作为候选搜索词 +// 例如: +// +// "周杰伦 - 七里香.mp3" -> ["周杰伦 - 七里香", "周杰伦", "七里香"] +// "Coldplay-Yellow.flac" -> ["Coldplay-Yellow", "Coldplay", "Yellow"] +// "七里香.mp3" -> ["七里香"] +func parseMusicFileName(fileName string) []string { + // 去掉扩展名 + if idx := strings.LastIndex(fileName, "."); idx > 0 { + ext := strings.ToLower(fileName[idx:]) + if len(ext) <= 5 { + fileName = fileName[:idx] + } + } + fileName = strings.TrimSpace(fileName) + if fileName == "" { + return nil + } + + var queries []string + // 完整文件名作为第一候选 + queries = append(queries, fileName) + + // 按" - "(带空格)分割 + parts := strings.SplitN(fileName, " - ", 2) + if len(parts) == 2 { + before := strings.TrimSpace(parts[0]) + after := strings.TrimSpace(parts[1]) + if before != "" { + queries = append(queries, before) + } + if after != "" { + queries = append(queries, after) + } + } else { + // 按"-"(不带空格)分割 + parts = strings.SplitN(fileName, "-", 2) + if len(parts) == 2 { + before := strings.TrimSpace(parts[0]) + after := strings.TrimSpace(parts[1]) + if before != "" { + queries = append(queries, before) + } + if after != "" { + queries = append(queries, after) + } + } + } + + return queries +} + +// doDiscogsSearch 执行单次Discogs搜索 +func (s *DiscogsScraper) doDiscogsSearch(query string) (*discogsSearchResult, error) { + searchURL := fmt.Sprintf("%s/database/search?q=%s&type=release&token=%s", + discogsBaseURL, url.QueryEscape(query), s.Token) + + req, _ := http.NewRequest("GET", searchURL, nil) + req.Header.Set("User-Agent", "OpenList/4.0 +https://github.com/OpenListTeam/OpenList") + + resp, err := s.client.Do(req) + if err != nil { + return nil, fmt.Errorf("Discogs搜索请求失败: %w", err) + } + defer resp.Body.Close() + + body, _ := io.ReadAll(resp.Body) + var result discogsSearchResult + if err := json.Unmarshal(body, &result); err != nil { + return nil, fmt.Errorf("Discogs搜索结果解析失败: %w", err) + } + return &result, nil +} + +// buildMusicQuery 根据 MediaItem 已有字段拼接 Discogs 搜索词 +// 优先级:专辑名 > ScrapedName > 文件名解析 +// 若有歌手或年份信息,则附加到搜索词中以提高精准度 +func buildMusicQuery(base string, item *model.MediaItem) string { + q := base + // 附加歌手 + if item.AlbumArtist != "" { + q = item.AlbumArtist + " " + q + } + // 附加年份(取 ReleaseDate 前4位) + if len(item.ReleaseDate) >= 4 { + q = q + " " + item.ReleaseDate[:4] + } + return strings.TrimSpace(q) +} + +// ScrapeMusic 刮削音乐/专辑信息 +func (s *DiscogsScraper) ScrapeMusic(item *model.MediaItem) error { + if s.Token == "" { + return fmt.Errorf("Discogs Token 未配置") + } + + // 构建候选搜索词列表 + var queries []string + if item.AlbumName != "" { + queries = append(queries, buildMusicQuery(item.AlbumName, item)) + // 同时保留不带附加信息的纯专辑名作为降级候选 + if item.AlbumArtist != "" || len(item.ReleaseDate) >= 4 { + queries = append(queries, item.AlbumName) + } + } else if item.ScrapedName != "" { + queries = append(queries, buildMusicQuery(item.ScrapedName, item)) + if item.AlbumArtist != "" || len(item.ReleaseDate) >= 4 { + queries = append(queries, item.ScrapedName) + } + } else { + // 从文件名解析,按"-"分割分别搜索 + baseQueries := parseMusicFileName(item.FileName) + for _, bq := range baseQueries { + enhanced := buildMusicQuery(bq, item) + if enhanced != bq { + // 先放带附加信息的精准词,再放原始词作为降级 + queries = append(queries, enhanced) + } + queries = append(queries, bq) + } + } + + if len(queries) == 0 { + return fmt.Errorf("无法从文件名中提取有效搜索词") + } + + // 依次尝试各候选词,找到结果即停止(模糊匹配降级) + var searchResult *discogsSearchResult + var lastQuery string + for _, q := range queries { + result, err := s.doDiscogsSearch(q) + if err != nil { + return err + } + lastQuery = q + if len(result.Results) > 0 { + searchResult = result + break + } + } + + if searchResult == nil || len(searchResult.Results) == 0 { + return fmt.Errorf("Discogs未找到匹配结果: %s", lastQuery) + } + + first := searchResult.Results[0] + + // 获取详情 + detailURL := fmt.Sprintf("%s/releases/%d?token=%s", discogsBaseURL, first.ID, s.Token) + detailReq, _ := http.NewRequest("GET", detailURL, nil) + detailReq.Header.Set("User-Agent", "OpenList/4.0 +https://github.com/OpenListTeam/OpenList") + + detailResp, err := s.client.Do(detailReq) + if err != nil { + return fmt.Errorf("Discogs详情请求失败: %w", err) + } + defer detailResp.Body.Close() + + detailBody, _ := io.ReadAll(detailResp.Body) + var detail discogsReleaseDetail + if err := json.Unmarshal(detailBody, &detail); err != nil { + return fmt.Errorf("Discogs详情解析失败: %w", err) + } + + // 填充字段:已有值的字段不覆盖,仅补充空缺 + // Discogs 的 title 就是专辑名(不是 "Artist - Album" 格式) + if detail.Title != "" { + if item.AlbumName == "" { + item.AlbumName = detail.Title + } + if item.ScrapedName == "" { + item.ScrapedName = detail.Title + } + } + + if item.ReleaseDate == "" { + if detail.Year > 0 { + item.ReleaseDate = fmt.Sprintf("%d-01-01", detail.Year) + } else if first.Year != "" { + item.ReleaseDate = first.Year + "-01-01" + } + } + + item.Rating = detail.Community.Rating.Average + item.Plot = detail.Notes + item.ExternalID = fmt.Sprintf("discogs:%d", detail.ID) + + // 封面(存储 URL,前端直接展示) + if item.Cover == "" { + if detail.CoverImage != "" { + item.Cover = detail.CoverImage + } else if first.CoverImage != "" { + item.Cover = first.CoverImage + } else if first.Thumb != "" { + item.Cover = first.Thumb + } + } + + // 类型 + if item.Genre == "" { + genres := append(detail.Genres, detail.Styles...) + item.Genre = strings.Join(genres, ",") + } + + // 艺术家:仅在 Authors 和 AlbumArtist 均为空时才填充(ID3已有值则保留) + artists := make([]string, 0, len(detail.Artists)) + for _, a := range detail.Artists { + artists = append(artists, a.Name) + } + if len(artists) > 0 { + if item.Authors == "" { + authorsJSON, _ := json.Marshal(artists) + item.Authors = string(authorsJSON) + } + if item.AlbumArtist == "" { + item.AlbumArtist = artists[0] + } + } + + now := time.Now() + item.ScrapedAt = &now + return nil +} diff --git a/internal/media/scraper/douban.go b/internal/media/scraper/douban.go new file mode 100644 index 000000000..bcafddc28 --- /dev/null +++ b/internal/media/scraper/douban.go @@ -0,0 +1,472 @@ +package scraper + +import ( + "bytes" + "encoding/base64" + "encoding/json" + "fmt" + "image" + "image/jpeg" + "io" + "net/http" + "net/url" + "regexp" + "strings" + "time" + + "github.com/disintegration/imaging" + "github.com/OpenListTeam/OpenList/v4/internal/model" + "golang.org/x/net/html" +) + +const ( + doubanSearchURL = "https://www.douban.com/search" + doubanBookCat = "1001" + doubanBase = "https://book.douban.com/" +) + +var ( + doubanBookURLPattern = regexp.MustCompile(`.*/subject/(\d+)/?`) + doubanDatePattern = regexp.MustCompile(`(\d{4})-(\d+)`) + doubanDefaultHeaders = map[string]string{ + "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36", + "Referer": doubanBase, + } +) + +// DoubanScraper 豆瓣书籍刮削器(移植自 NewDouban.py) +type DoubanScraper struct { + client *http.Client + // ThumbnailMode 缩略图存储方式:"base64"(默认,存入数据库)或 "local"(存到本地文件) + ThumbnailMode string + // ThumbnailPath 本地存储路径,ThumbnailMode 为 "local" 时有效,默认 "/.thumbnail" + ThumbnailPath string +} + +// NewDoubanScraper 创建豆瓣刮削器 +func NewDoubanScraper() *DoubanScraper { + return &DoubanScraper{ + client: &http.Client{Timeout: 20 * time.Second}, + ThumbnailMode: "base64", + ThumbnailPath: "/.thumbnail", + } +} + +// NewDoubanScraperWithConfig 创建带完整配置的豆瓣刮削器 +func NewDoubanScraperWithConfig(thumbnailMode, thumbnailPath string) *DoubanScraper { + if thumbnailMode == "" { + thumbnailMode = "base64" + } + if thumbnailPath == "" { + thumbnailPath = "/.thumbnail" + } + return &DoubanScraper{ + client: &http.Client{Timeout: 20 * time.Second}, + ThumbnailMode: thumbnailMode, + ThumbnailPath: thumbnailPath, + } +} + +// ScrapeBook 刮削书籍信息 +func (s *DoubanScraper) ScrapeBook(item *model.MediaItem) error { + query := item.ScrapedName + if query == "" { + query = strings.TrimSuffix(item.FileName, strings.ToLower(item.FileName[strings.LastIndex(item.FileName, "."):])) + } + + // 搜索书籍URL列表 + bookURLs, err := s.searchBookURLs(query) + if err != nil { + return fmt.Errorf("豆瓣搜索失败: %w", err) + } + if len(bookURLs) == 0 { + return fmt.Errorf("豆瓣未找到匹配书籍: %s", query) + } + + // 取第一个结果 + bookDetail, err := s.loadBookDetail(bookURLs[0]) + if err != nil { + return fmt.Errorf("豆瓣获取书籍详情失败: %w", err) + } + + // 填充字段 + if bookDetail.Title != "" { + item.ScrapedName = bookDetail.Title + } + item.Plot = bookDetail.Description + // 仅在豆瓣返回了有效封面图片URL时才覆盖本地封面 + // 下载封面图片并根据 ThumbnailMode 存储,避免豆瓣防盗链导致前端无法显示 + if bookDetail.Cover != "" { + if cover := s.downloadAndStoreCover(item.FolderPath, bookDetail.Cover); cover != "" { + item.Cover = cover + } else { + // 下载失败时保留原 URL(降级) + item.Cover = bookDetail.Cover + } + } + item.Rating = bookDetail.Rating + item.ReleaseDate = bookDetail.PublishedDate + item.Publisher = bookDetail.Publisher + item.ISBN = bookDetail.ISBN + item.ExternalID = "douban:" + bookDetail.ID + + if len(bookDetail.Authors) > 0 { + authorsJSON, _ := json.Marshal(bookDetail.Authors) + item.Authors = string(authorsJSON) + } + if len(bookDetail.Tags) > 0 { + item.Genre = strings.Join(bookDetail.Tags, ",") + } + + now := time.Now() + item.ScrapedAt = &now + return nil +} + +type doubanBookDetail struct { + ID string + Title string + Authors []string + Publisher string + PublishedDate string + Cover string + Rating float32 + Description string + Tags []string + ISBN string +} + +// searchBookURLs 搜索豆瓣书籍URL列表 +func (s *DoubanScraper) searchBookURLs(query string) ([]string, error) { + searchURL := fmt.Sprintf("%s?cat=%s&q=%s", doubanSearchURL, doubanBookCat, url.QueryEscape(query)) + body, err := s.doGet(searchURL) + if err != nil { + return nil, err + } + + doc, err := html.Parse(strings.NewReader(string(body))) + if err != nil { + return nil, err + } + + var bookURLs []string + var traverse func(*html.Node) + traverse = func(n *html.Node) { + if n.Type == html.ElementNode && n.Data == "a" { + for _, attr := range n.Attr { + if attr.Key == "class" && attr.Val == "nbg" { + for _, a := range n.Attr { + if a.Key == "href" { + parsed := s.calcURL(a.Val) + if parsed != "" && len(bookURLs) < 5 { + bookURLs = append(bookURLs, parsed) + } + } + } + } + } + } + for c := n.FirstChild; c != nil; c = c.NextSibling { + traverse(c) + } + } + traverse(doc) + return bookURLs, nil +} + +// calcURL 解析豆瓣搜索结果中的真实URL +func (s *DoubanScraper) calcURL(href string) string { + parsed, err := url.Parse(href) + if err != nil { + return "" + } + query := parsed.Query() + rawURL := query.Get("url") + if rawURL == "" { + return "" + } + decoded, err := url.QueryUnescape(rawURL) + if err != nil { + return "" + } + if doubanBookURLPattern.MatchString(decoded) { + return decoded + } + return "" +} + +// loadBookDetail 加载书籍详情页 +func (s *DoubanScraper) loadBookDetail(bookURL string) (*doubanBookDetail, error) { + body, err := s.doGet(bookURL) + if err != nil { + return nil, err + } + return s.parseBookHTML(bookURL, string(body)) +} + +// parseBookHTML 解析书籍HTML(移植自 DoubanBookHtmlParser.parse_book) +func (s *DoubanScraper) parseBookHTML(bookURL, content string) (*doubanBookDetail, error) { + doc, err := html.Parse(strings.NewReader(content)) + if err != nil { + return nil, err + } + + detail := &doubanBookDetail{} + + // 提取豆瓣ID + if m := doubanBookURLPattern.FindStringSubmatch(bookURL); len(m) > 1 { + detail.ID = m[1] + } + + var traverse func(*html.Node) + traverse = func(n *html.Node) { + if n.Type == html.ElementNode { + switch n.Data { + case "span": + // 标题 + if getAttr(n, "property") == "v:itemreviewed" { + detail.Title = getTextContent(n) + } + // 元数据字段 + if getAttr(n, "class") == "pl" { + text := getTextContent(n) + tail := getNextText(n) + switch { + case strings.HasPrefix(text, "作者") || strings.HasPrefix(text, "译者"): + // 作者通过链接提取 + collectAuthors(n, &detail.Authors) + case strings.HasPrefix(text, "出版社"): + detail.Publisher = strings.TrimSpace(tail) + case strings.HasPrefix(text, "出版年"): + detail.PublishedDate = parseDoubanDate(strings.TrimSpace(tail)) + case strings.HasPrefix(text, "ISBN"): + detail.ISBN = strings.TrimSpace(tail) + case strings.HasPrefix(text, "副标题"): + if detail.Title != "" { + detail.Title += ":" + strings.TrimSpace(tail) + } + } + } + // 评分 + if getAttr(n, "property") == "v:average" { + ratingStr := getTextContent(n) + var rating float32 + fmt.Sscanf(ratingStr, "%f", &rating) + detail.Rating = rating / 2 + } + case "img": + // 封面图片: + if getAttr(n, "class") == "nbg" { + src := getAttr(n, "src") + if src != "" && !strings.HasSuffix(src, "update_image") { + detail.Cover = src + } + } + // 备用:#mainpic 下的 img + if getAttr(n, "id") == "mainpic" || (n.Parent != nil && getAttr(n.Parent, "id") == "mainpic") { + src := getAttr(n, "src") + if src != "" && detail.Cover == "" { + detail.Cover = src + } + } + case "a": + // 标签(原封面逻辑移除,改为从img获取) + cls := getAttr(n, "class") + if strings.Contains(cls, "tag") { + tag := getTextContent(n) + if tag != "" { + detail.Tags = append(detail.Tags, tag) + } + } + case "div": + // 简介 + if getAttr(n, "id") == "link-report" { + detail.Description = extractIntroText(n) + } + } + } + for c := n.FirstChild; c != nil; c = c.NextSibling { + traverse(c) + } + } + traverse(doc) + return detail, nil +} + +// HTML辅助函数 + +func getAttr(n *html.Node, key string) string { + for _, attr := range n.Attr { + if attr.Key == key { + return attr.Val + } + } + return "" +} + +func getTextContent(n *html.Node) string { + var sb strings.Builder + var walk func(*html.Node) + walk = func(node *html.Node) { + if node.Type == html.TextNode { + sb.WriteString(node.Data) + } + for c := node.FirstChild; c != nil; c = c.NextSibling { + walk(c) + } + } + walk(n) + return strings.TrimSpace(sb.String()) +} + +func getNextText(n *html.Node) string { + if n.NextSibling != nil { + if n.NextSibling.Type == html.TextNode { + return strings.TrimSpace(n.NextSibling.Data) + } + return getTextContent(n.NextSibling) + } + return "" +} + +func collectAuthors(n *html.Node, authors *[]string) { + var walk func(*html.Node) + walk = func(node *html.Node) { + if node.Type == html.ElementNode && node.Data == "a" { + href := getAttr(node, "href") + if strings.Contains(href, "/author") || strings.Contains(href, "/search") { + name := getTextContent(node) + if name != "" { + *authors = append(*authors, name) + } + } + } + for c := node.FirstChild; c != nil; c = c.NextSibling { + walk(c) + } + } + // 从父节点开始找 + if n.Parent != nil { + walk(n.Parent) + } +} + +func extractIntroText(n *html.Node) string { + var sb strings.Builder + var walk func(*html.Node) + walk = func(node *html.Node) { + if node.Type == html.ElementNode && node.Data == "div" { + if getAttr(node, "class") == "intro" { + sb.WriteString(getTextContent(node)) + return + } + } + for c := node.FirstChild; c != nil; c = c.NextSibling { + walk(c) + } + } + walk(n) + return strings.TrimSpace(sb.String()) +} + +func parseDoubanDate(dateStr string) string { + if dateStr == "" { + return "" + } + if m := doubanDatePattern.FindStringSubmatch(dateStr); len(m) > 2 { + return fmt.Sprintf("%s-%s-01", m[1], m[2]) + } + return dateStr +} + +// downloadAndStoreCover 下载封面图片并根据 ThumbnailMode 存储 +// 失败时返回空字符串 +func (s *DoubanScraper) downloadAndStoreCover(filePath, imgURL string) string { + data, mimeType := s.downloadImage(imgURL) + if len(data) == 0 { + return "" + } + if s.ThumbnailMode == "local" { + // 本地存储模式 + localScraper := NewBookLocalScraperWithConfig(s.ThumbnailMode, s.ThumbnailPath) + return localScraper.saveCoverLocal(filePath, data) + } + // Base64 模式(默认) + img, _, err := image.Decode(bytes.NewReader(data)) + if err != nil { + return "data:" + mimeType + ";base64," + base64.StdEncoding.EncodeToString(data) + } + thumb := imaging.Resize(img, 300, 0, imaging.Lanczos) + var buf bytes.Buffer + if err := jpeg.Encode(&buf, thumb, &jpeg.Options{Quality: 80}); err != nil { + return "" + } + return "data:image/jpeg;base64," + base64.StdEncoding.EncodeToString(buf.Bytes()) +} + +// downloadCoverAsBase64 下载封面图片并转为 data URI Base64 字符串(向后兼容) +// 失败时返回空字符串 +func (s *DoubanScraper) downloadCoverAsBase64(imgURL string) string { + data, mimeType := s.downloadImage(imgURL) + if len(data) == 0 { + return "" + } + img, _, err := image.Decode(bytes.NewReader(data)) + if err != nil { + return "data:" + mimeType + ";base64," + base64.StdEncoding.EncodeToString(data) + } + thumb := imaging.Resize(img, 300, 0, imaging.Lanczos) + var buf bytes.Buffer + if err := jpeg.Encode(&buf, thumb, &jpeg.Options{Quality: 80}); err != nil { + return "" + } + return "data:image/jpeg;base64," + base64.StdEncoding.EncodeToString(buf.Bytes()) +} + +// downloadImage 下载图片,返回原始字节和 MIME 类型 +func (s *DoubanScraper) downloadImage(imgURL string) ([]byte, string) { + req, err := http.NewRequest("GET", imgURL, nil) + if err != nil { + return nil, "" + } + for k, v := range doubanDefaultHeaders { + req.Header.Set(k, v) + } + resp, err := s.client.Do(req) + if err != nil { + return nil, "" + } + defer resp.Body.Close() + if resp.StatusCode != 200 { + return nil, "" + } + data, err := io.ReadAll(resp.Body) + if err != nil || len(data) == 0 { + return nil, "" + } + mimeType := resp.Header.Get("Content-Type") + if mimeType == "" { + mimeType = "image/jpeg" + } + return data, mimeType +} + +// doGet 发送GET请求 +func (s *DoubanScraper) doGet(reqURL string) ([]byte, error) { + req, err := http.NewRequest("GET", reqURL, nil) + if err != nil { + return nil, err + } + for k, v := range doubanDefaultHeaders { + req.Header.Set(k, v) + } + resp, err := s.client.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + if resp.StatusCode != 200 && resp.StatusCode != 201 { + return nil, fmt.Errorf("HTTP %d: %s", resp.StatusCode, reqURL) + } + return io.ReadAll(resp.Body) +} diff --git a/internal/media/scraper/image.go b/internal/media/scraper/image.go new file mode 100644 index 000000000..ed968fd4c --- /dev/null +++ b/internal/media/scraper/image.go @@ -0,0 +1,259 @@ +package scraper + +import ( + "bytes" + "encoding/base64" + "fmt" + "image" + "image/jpeg" + "io" + "os" + "path/filepath" + "strings" + "time" + + "github.com/disintegration/imaging" + "github.com/rwcarlsen/goexif/exif" + + "github.com/OpenListTeam/OpenList/v4/internal/model" +) + +// ImageScraper 图片刮削器 +// 从图片 EXIF 中提取元数据,并生成缩略图写入 Cover 字段。 +type ImageScraper struct { + // StoreThumbnail 为 true 时生成缩略图写入 Cover; + // 为 false 时直接用文件路径作为 Cover,节省数据库空间。 + StoreThumbnail bool + // ThumbnailMode 缩略图存储方式:"base64"(默认,存入数据库)或 "local"(存到本地文件) + ThumbnailMode string + // ThumbnailPath 本地存储路径,ThumbnailMode 为 "local" 时有效,默认 "/.thumbnail" + ThumbnailPath string +} + +// NewImageScraper 创建图片刮削器 +// storeThumbnail 为 true 时生成缩略图写入 Cover。 +func NewImageScraper(storeThumbnail bool) *ImageScraper { + return &ImageScraper{ + StoreThumbnail: storeThumbnail, + ThumbnailMode: "base64", + ThumbnailPath: "/.thumbnail", + } +} + +// NewImageScraperWithConfig 创建带完整配置的图片刮削器 +func NewImageScraperWithConfig(storeThumbnail bool, thumbnailMode, thumbnailPath string) *ImageScraper { + if thumbnailMode == "" { + thumbnailMode = "base64" + } + if thumbnailPath == "" { + thumbnailPath = "/.thumbnail" + } + return &ImageScraper{ + StoreThumbnail: storeThumbnail, + ThumbnailMode: thumbnailMode, + ThumbnailPath: thumbnailPath, + } +} + +// ScrapeImage 刮削图片信息 +// reader 为图片文件内容流,必须传入有效流才能生成缩略图。 +// 当 reader 不为 nil 时: +// - 从 EXIF 读取拍摄时间、GPS 地点、相机型号+参数、评分 +// - 生成 300px 宽缩略图,根据 ThumbnailMode 存储为 Base64 或本地文件 +// +// 注意:绝不将文件路径作为 cover,reader 为 nil 或缩略图生成失败时 cover 保持为空。 +func (s *ImageScraper) ScrapeImage(item *model.MediaItem, reader io.Reader) error { + // 去掉扩展名作为展示名(如果尚未设置) + if item.ScrapedName == "" { + item.ScrapedName = trimExt(item.FileName) + } + + if reader == nil { + // 无文件流时无法生成缩略图,cover 保持为空,等待下次重试 + now := time.Now() + item.ScrapedAt = &now + return nil + } + + // 将流读入内存(EXIF 解析和图像解码都需要完整数据) + data, err := io.ReadAll(reader) + if err != nil || len(data) == 0 { + // 读取失败时 cover 保持为空 + now := time.Now() + item.ScrapedAt = &now + return nil + } + + // ── 1. 解析 EXIF ────────────────────────────────────────────── + s.parseEXIF(item, bytes.NewReader(data)) + + // ── 2. 生成缩略图并存储 ────────────────────────────────────── + if s.ThumbnailMode == "local" { + // 本地存储模式:将缩略图保存到指定目录,Cover 存储本地路径 + if localPath := s.saveThumbnailLocal(item.FolderPath, data); localPath != "" { + item.Cover = localPath + } + // 本地存储失败时 cover 保持为空,不降级为文件路径 + } else { + // Base64 模式(默认):生成缩略图 Base64 写入 Cover + if thumb := s.generateThumbnail(data); thumb != "" { + item.Cover = thumb + } + // 缩略图生成失败时 cover 保持为空,不降级为文件路径 + } + + now := time.Now() + item.ScrapedAt = &now + return nil +} + +// saveThumbnailLocal 将缩略图保存到本地文件系统,返回可访问的路径 +// 文件保存在 ThumbnailPath 目录下,文件名为原文件路径的 hash + .jpg +func (s *ImageScraper) saveThumbnailLocal(filePath string, data []byte) string { + // 生成缩略图数据 + img, _, err := image.Decode(bytes.NewReader(data)) + if err != nil { + return "" + } + thumb := imaging.Resize(img, 300, 0, imaging.Lanczos) + var buf bytes.Buffer + if err := jpeg.Encode(&buf, thumb, &jpeg.Options{Quality: 75}); err != nil { + return "" + } + + // 构建本地存储路径: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) +} + +// parseEXIF 从图片数据中解析 EXIF 元数据并写入 item +func (s *ImageScraper) parseEXIF(item *model.MediaItem, r io.Reader) { + x, err := exif.Decode(r) + if err != nil { + return + } + + // ── 拍摄时间 → ReleaseDate ──────────────────────────────────── + if item.ReleaseDate == "" { + if t, err := x.DateTime(); err == nil { + item.ReleaseDate = t.Format("2006-01-02") + } + } + + // ── GPS 地点 → Authors(借用作者字段存储地点信息)──────────── + if item.Authors == "" { + if lat, lon, err := x.LatLong(); err == nil { + item.Authors = fmt.Sprintf(`["GPS: %.6f, %.6f"]`, lat, lon) + } + } + + // ── 相机型号 + 参数 → Genre ─────────────────────────────────── + if item.Genre == "" { + var parts []string + if make, err := x.Get(exif.Make); err == nil { + if v, err := make.StringVal(); err == nil && v != "" { + parts = append(parts, strings.TrimSpace(v)) + } + } + if model_, err := x.Get(exif.Model); err == nil { + if v, err := model_.StringVal(); err == nil && v != "" { + parts = append(parts, strings.TrimSpace(v)) + } + } + if fnum, err := x.Get(exif.FNumber); err == nil { + if num, den, err := fnum.Rat2(0); err == nil && den != 0 { + parts = append(parts, fmt.Sprintf("f/%.1f", float64(num)/float64(den))) + } + } + if exp, err := x.Get(exif.ExposureTime); err == nil { + if num, den, err := exp.Rat2(0); err == nil && den != 0 { + if num == 1 { + parts = append(parts, fmt.Sprintf("1/%ds", den)) + } else { + parts = append(parts, fmt.Sprintf("%d/%ds", num, den)) + } + } + } + if iso, err := x.Get(exif.ISOSpeedRatings); err == nil { + if v, err := iso.Int(0); err == nil { + parts = append(parts, fmt.Sprintf("ISO%d", v)) + } + } + if focal, err := x.Get(exif.FocalLength); err == nil { + if num, den, err := focal.Rat2(0); err == nil && den != 0 { + parts = append(parts, fmt.Sprintf("%.0fmm", float64(num)/float64(den))) + } + } + if len(parts) > 0 { + item.Genre = strings.Join(parts, ",") + } + } + + // ── EXIF Rating(0-5 星)→ Rating(0-10 分)────────────────── + // Windows 资源管理器、Lightroom、Darktable 等软件会写入此标签 + if item.Rating == 0 { + if ratingTag, err := x.Get(exif.FieldName("Rating")); err == nil { + if v, err := ratingTag.Int(0); err == nil && v > 0 { + // 1-5 星映射到 2-10 分 + item.Rating = float32(v) * 2 + } + } + } +} + +// generateThumbnail 将图片数据缩放为 300px 宽缩略图,返回 data URI Base64 字符串 +func (s *ImageScraper) generateThumbnail(data []byte) string { + img, _, err := image.Decode(bytes.NewReader(data)) + if err != nil { + return "" + } + + // 缩放到宽度 300px,高度等比 + thumb := imaging.Resize(img, 300, 0, imaging.Lanczos) + + var buf bytes.Buffer + if err := jpeg.Encode(&buf, thumb, &jpeg.Options{Quality: 75}); err != nil { + return "" + } + + return "data:image/jpeg;base64," + base64.StdEncoding.EncodeToString(buf.Bytes()) +} + +// buildInternalDownloadPath 将 VFS 文件路径转为内部代理下载路径 +// 格式:/d/path/to/file(前端通过此路径访问文件内容) +func buildInternalDownloadPath(filePath string) string { + if !strings.HasPrefix(filePath, "/") { + filePath = "/" + filePath + } + return "/d" + filePath +} + +// trimExt 去掉文件名的扩展名 +func trimExt(fileName string) string { + if idx := strings.LastIndex(fileName, "."); idx > 0 { + return fileName[:idx] + } + return fileName +} diff --git a/internal/media/scraper/tmdb.go b/internal/media/scraper/tmdb.go new file mode 100644 index 000000000..ab54493fd --- /dev/null +++ b/internal/media/scraper/tmdb.go @@ -0,0 +1,296 @@ +package scraper + +import ( + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "regexp" + "strings" + "time" + + "github.com/OpenListTeam/OpenList/v4/internal/model" +) + +// 年份正则(匹配 1900-2099) +var yearRegexp = regexp.MustCompile(`\b((?:19|20)\d{2})\b`) + +// chineseRegexp 匹配包含中文字符的片段 +var chineseRegexp = regexp.MustCompile(`[\p{Han}]`) + +// parsedVideoTitle 解析后的视频标题信息 +type parsedVideoTitle struct { + EnglishTitle string // 英文标题(第一个中文片段之前、年份之前的部分) + ChineseTitle string // 中文标题(第一个含中文的片段) + Year string // 年份 +} + +// parseVideoFileName 从视频文件名中提取标题和年份 +// 规则:按"."分割,第一个中文之前的是英文,第一个中文是中文标题,后面都是参数,中文标题之前是年份 +// 例如: +// +// "Inception.2010.盗梦空间.双语字幕.HR-HDTV.AC3.1024X576.X264-" -> {English:"Inception", Chinese:"盗梦空间", Year:"2010"} +// "Iron.Man.3.2013.钢铁侠3.国英音轨.双语字幕.HR-HDTV.AC3.1024X576.x264-" -> {English:"Iron Man 3", Chinese:"钢铁侠3", Year:"2013"} +// "The.Dark.Knight.2008.1080p.BluRay" -> {English:"The Dark Knight", Chinese:"", Year:"2008"} +func parseVideoFileName(fileName string) parsedVideoTitle { + // 去掉扩展名(.mkv .mp4 .avi 等,扩展名长度 <= 5) + if idx := strings.LastIndex(fileName, "."); idx > 0 { + ext := strings.ToLower(fileName[idx:]) + if len(ext) <= 5 { + fileName = fileName[:idx] + } + } + + // 按"."分割各字段 + parts := strings.Split(fileName, ".") + + var result parsedVideoTitle + var englishParts []string + foundChinese := false + foundYear := false + + for _, p := range parts { + p = strings.TrimSpace(p) + if p == "" { + continue + } + + // 已找到中文标题后,后面都是参数,跳过 + if foundChinese { + continue + } + + // 检测是否含中文 + if chineseRegexp.MatchString(p) { + // 第一个中文片段即为中文标题 + result.ChineseTitle = p + foundChinese = true + continue + } + + // 检测是否为年份(1900-2099) + if yearRegexp.MatchString(p) && !foundYear { + result.Year = yearRegexp.FindString(p) + foundYear = true + // 年份本身不加入英文标题 + continue + } + + // 年份之前的非中文片段加入英文标题 + if !foundYear { + englishParts = append(englishParts, p) + } + // 年份之后、中文之前的片段(如果有)忽略,通常是噪音 + } + + result.EnglishTitle = strings.Join(englishParts, " ") + return result +} + +const tmdbBaseURL = "https://api.themoviedb.org/3" +const tmdbImageBase = "https://image.tmdb.org/t/p/w500" + +// TMDBScraper TMDB视频刮削器 +type TMDBScraper struct { + APIKey string + client *http.Client +} + +// NewTMDBScraper 创建TMDB刮削器 +func NewTMDBScraper(apiKey string) *TMDBScraper { + return &TMDBScraper{ + APIKey: apiKey, + client: &http.Client{Timeout: 15 * time.Second}, + } +} + +// tmdbSearchResult TMDB搜索结果 +type tmdbSearchResult struct { + Results []struct { + ID int `json:"id"` + Title string `json:"title"` + Name string `json:"name"` // 电视剧用name + Overview string `json:"overview"` + PosterPath string `json:"poster_path"` + ReleaseDate string `json:"release_date"` + FirstAirDate string `json:"first_air_date"` // 电视剧 + VoteAverage float32 `json:"vote_average"` + MediaType string `json:"media_type"` + GenreIDs []int `json:"genre_ids"` + } `json:"results"` +} + +// tmdbMovieDetail TMDB电影详情 +type tmdbMovieDetail struct { + ID int `json:"id"` + Title string `json:"title"` + Overview string `json:"overview"` + PosterPath string `json:"poster_path"` + ReleaseDate string `json:"release_date"` + VoteAverage float32 `json:"vote_average"` + Genres []struct { + ID int `json:"id"` + Name string `json:"name"` + } `json:"genres"` + Credits struct { + Cast []struct { + Name string `json:"name"` + } `json:"cast"` + } `json:"credits"` +} + +// doTMDBSearch 执行一次TMDB搜索请求 +func (s *TMDBScraper) doTMDBSearch(query, year string) (*tmdbSearchResult, error) { + searchURL := fmt.Sprintf("%s/search/multi?api_key=%s&query=%s&language=zh-CN&search_type=ngram", + tmdbBaseURL, s.APIKey, url.QueryEscape(query)) + if year != "" { + searchURL += "&year=" + year + } + + resp, err := s.client.Get(searchURL) + if err != nil { + return nil, fmt.Errorf("TMDB搜索请求失败: %w", err) + } + defer resp.Body.Close() + + body, _ := io.ReadAll(resp.Body) + var result tmdbSearchResult + if err := json.Unmarshal(body, &result); err != nil { + return nil, fmt.Errorf("TMDB搜索结果解析失败: %w", err) + } + return &result, nil +} + +// searchWithFallback 带降级重试的TMDB搜索 +// 策略: +// 1. 有中文标题时,先用中文标题 + 年份搜索,再用中文标题不带年份搜索 +// 2. 有英文标题时,用英文标题 + 年份搜索,再用英文标题不带年份搜索 +// 3. 全部搜索失败才返回错误 +func (s *TMDBScraper) searchWithFallback(parsed parsedVideoTitle) (*tmdbSearchResult, error) { + type searchAttempt struct { + query string + year string + } + + var attempts []searchAttempt + + // 中文标题优先 + if parsed.ChineseTitle != "" { + if parsed.Year != "" { + attempts = append(attempts, searchAttempt{parsed.ChineseTitle, parsed.Year}) + } + attempts = append(attempts, searchAttempt{parsed.ChineseTitle, ""}) + } + + // 英文标题兜底 + if parsed.EnglishTitle != "" { + if parsed.Year != "" { + attempts = append(attempts, searchAttempt{parsed.EnglishTitle, parsed.Year}) + } + attempts = append(attempts, searchAttempt{parsed.EnglishTitle, ""}) + } + + if len(attempts) == 0 { + return nil, fmt.Errorf("无法从文件名中提取有效标题") + } + + for _, attempt := range attempts { + result, err := s.doTMDBSearch(attempt.query, attempt.year) + if err != nil { + return nil, err + } + if len(result.Results) > 0 { + return result, nil + } + } + + // 构造友好的错误信息 + titleInfo := parsed.ChineseTitle + if titleInfo == "" { + titleInfo = parsed.EnglishTitle + } + return nil, fmt.Errorf("TMDB未找到匹配结果: %s", titleInfo) +} + +// ScrapeVideo 刮削视频信息 +func (s *TMDBScraper) ScrapeVideo(item *model.MediaItem) error { + if s.APIKey == "" { + return fmt.Errorf("TMDB API Key 未配置") + } + + // 始终从文件名中解析出标题和年份(ScrapedName 是刮削结果字段,不作为搜索输入) + parsed := parseVideoFileName(item.FileName) + + // 搜索策略:中文标题优先,英文标题兜底,都搜不到才失败 + searchResult, err := s.searchWithFallback(parsed) + if err != nil { + return err + } + + // 取第一个结果 + first := searchResult.Results[0] + mediaType := first.MediaType + if mediaType == "" { + mediaType = "movie" + } + + // 获取详情 + detailURL := fmt.Sprintf("%s/%s/%d?api_key=%s&language=zh-CN&append_to_response=credits", + tmdbBaseURL, mediaType, first.ID, s.APIKey) + + detailResp, err := s.client.Get(detailURL) + if err != nil { + return fmt.Errorf("TMDB详情请求失败: %w", err) + } + defer detailResp.Body.Close() + + detailBody, _ := io.ReadAll(detailResp.Body) + var detail tmdbMovieDetail + if err := json.Unmarshal(detailBody, &detail); err != nil { + return fmt.Errorf("TMDB详情解析失败: %w", err) + } + + // 填充字段 + title := detail.Title + if title == "" { + title = first.Name + } + item.ScrapedName = title + item.Plot = detail.Overview + item.Rating = detail.VoteAverage + item.ExternalID = fmt.Sprintf("tmdb:%d", detail.ID) + item.VideoType = mediaType + + releaseDate := detail.ReleaseDate + if releaseDate == "" { + releaseDate = first.FirstAirDate + } + item.ReleaseDate = releaseDate + + if detail.PosterPath != "" { + item.Cover = tmdbImageBase + detail.PosterPath + } + + // 类型 + genres := make([]string, 0, len(detail.Genres)) + for _, g := range detail.Genres { + genres = append(genres, g.Name) + } + item.Genre = strings.Join(genres, ",") + + // 演员(取前10个) + actors := make([]string, 0) + for i, cast := range detail.Credits.Cast { + if i >= 10 { + break + } + actors = append(actors, cast.Name) + } + authorsJSON, _ := json.Marshal(actors) + item.Authors = string(authorsJSON) + + now := time.Now() + item.ScrapedAt = &now + return nil +} \ No newline at end of file diff --git a/internal/model/media.go b/internal/model/media.go new file mode 100644 index 000000000..7a6b4e2a2 --- /dev/null +++ b/internal/model/media.go @@ -0,0 +1,113 @@ +package model + +import ( + "time" + + "gorm.io/gorm" +) + +// MediaType 媒体类型,使用字符串便于后期扩展 +type MediaType string + +const ( + MediaTypeVideo MediaType = "video" + MediaTypeMusic MediaType = "music" + MediaTypeImage MediaType = "image" + MediaTypeBook MediaType = "book" +) + +// MediaScanPath 媒体库扫描路径(每个媒体库可配置多个扫描路径) +type MediaScanPath 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:"index;not null" json:"media_type"` // 所属媒体类型 + Name string `json:"name"` // 路径名称(用于前端筛选显示) + Path string `gorm:"not null" json:"path"` // VFS 扫描路径 + PathMerge bool `gorm:"default:false" json:"path_merge"` // 路径合并模式(子文件夹作为一个条目) + TypeTag string `json:"type_tag"` // 类型标签:电影、电视剧等 + ContentTags string `json:"content_tags"` // 内容标签:喜剧、惊悚等(逗号分隔) + EnableScrape bool `gorm:"default:true" json:"enable_scrape"` // 是否启用刮削 + LastScanAt *time.Time `json:"last_scan_at"` // 最后扫描时间 +} + +// MediaItem 媒体条目(统一表,通过 media_type 区分类型,便于后期扩展新类型) +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:"-"` + // 基础信息 + MediaType MediaType `gorm:"index;not null" json:"media_type"` + ScanPathID uint `gorm:"index" json:"scan_path_id"` // 关联的扫描路径ID + FileName string `gorm:"uniqueIndex:idx_media_folder_file_album" json:"file_name"` + FileSize int64 `json:"file_size"` + MimeType string `json:"mime_type"` + Hidden bool `gorm:"default:false" json:"hidden"` + + // 刮削/编辑信息 + ScrapedName string `json:"scraped_name"` + Description string `gorm:"type:text" json:"description"` + Cover string `json:"cover"` // 封面URL或本地路径 + ReleaseDate string `json:"release_date"` // 发布时间,格式 YYYY-MM-DD + Rating float32 `json:"rating"` // 评分 0-10 + Genre string `json:"genre"` // 类型,逗号分隔,如 "动作,科幻" + Authors string `gorm:"type:text" json:"authors"` // 作者/演员,JSON数组字符串 + Plot string `gorm:"type:text" json:"plot"` // 剧情/内容介绍 + Reviews string `gorm:"type:text" json:"reviews"` // 用户评价,JSON数组字符串 + + // 外部ID(用于刮削关联) + ExternalID string `json:"external_id"` // TMDB ID / Discogs ID / 豆瓣ID + + // 音乐专属字段 + AlbumName string `gorm:"uniqueIndex:idx_media_folder_file_album" json:"album_name"` // 所属专辑名 + AlbumArtist string `json:"album_artist"` // 专辑艺术家 + TrackNumber int `json:"track_number"` // 曲目编号 + Duration int `json:"duration"` // 时长(秒) + Lyrics string `gorm:"type:text" json:"lyrics"` // LRC格式歌词 + + // 视频专属字段 + VideoType string `json:"video_type"` // "movie" 或 "tv" + Season int `json:"season"` // 季(电视剧) + Episode int `json:"episode"` // 集(电视剧) + + // 书籍专属字段 + Publisher string `json:"publisher"` // 出版社 + ISBN string `json:"isbn"` // ISBN + + // 目录合并模式 + IsFolder bool `gorm:"default:false" json:"is_folder"` // 是否为文件夹模式条目 + FolderPath string `gorm:"index;uniqueIndex:idx_media_folder_file_album" json:"folder_path"` // 扫描根路径(恒定为扫描路径,与file_name+album_name组合唯一) + Episodes string `gorm:"type:text" json:"episodes"` // 选集信息,JSON数组,格式:[{file_name,index,title},...] + + ScrapedAt *time.Time `json:"scraped_at"` +} + +// 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"` + Enabled bool `gorm:"default:false" json:"enabled"` + LastScanAt *time.Time `json:"last_scan_at"` + LastScrapeAt *time.Time `json:"last_scrape_at"` +} + +// MediaScanProgress 扫描进度(内存中维护,不持久化) +type MediaScanProgress struct { + MediaType MediaType `json:"media_type"` + ScanPathID uint `json:"scan_path_id,omitempty"` // 0 表示全部路径 + Running bool `json:"running"` + Total int `json:"total"` + Done int `json:"done"` + Message string `json:"message"` + Error string `json:"error,omitempty"` +} \ No newline at end of file diff --git a/internal/model/setting.go b/internal/model/setting.go index 1e0fda0bb..b967e8e3e 100644 --- a/internal/model/setting.go +++ b/internal/model/setting.go @@ -13,6 +13,7 @@ const ( S3 FTP TRAFFIC + MEDIA ) const ( diff --git a/public/dist/README.md b/public/dist/README.md deleted file mode 100644 index d8709fb57..000000000 --- a/public/dist/README.md +++ /dev/null @@ -1 +0,0 @@ -## Put dist of frontend here. \ No newline at end of file diff --git a/server/handles/down.go b/server/handles/down.go index d4d634cbe..6c6062515 100644 --- a/server/handles/down.go +++ b/server/handles/down.go @@ -56,7 +56,9 @@ func Proxy(c *gin.Context) { common.ErrorPage(c, err, 500) return } - if canProxy(storage, filename) { + // 支持 ?force 参数强制代理(用于媒体库等需要 JS 加载文件内容的场景,避免 CORS 问题) + _, forceProxy := c.GetQuery("force") + if forceProxy || canProxy(storage, filename) { if _, ok := c.GetQuery("d"); !ok { if url := common.GenerateDownProxyURL(storage.GetStorage(), rawPath); url != "" { c.Redirect(302, url) diff --git a/server/handles/media.go b/server/handles/media.go new file mode 100644 index 000000000..bfdd0af5f --- /dev/null +++ b/server/handles/media.go @@ -0,0 +1,606 @@ +package handles + +import ( + "context" + "strconv" + "time" + + "github.com/OpenListTeam/OpenList/v4/internal/conf" + "github.com/OpenListTeam/OpenList/v4/internal/db" + "github.com/OpenListTeam/OpenList/v4/internal/media" + "github.com/OpenListTeam/OpenList/v4/internal/media/scraper" + "github.com/OpenListTeam/OpenList/v4/internal/model" + "github.com/OpenListTeam/OpenList/v4/internal/setting" + "github.com/OpenListTeam/OpenList/v4/server/common" + "github.com/gin-gonic/gin" + log "github.com/sirupsen/logrus" +) + +// ==================== 配置管理 ==================== + +// ListMediaConfigs 获取所有媒体库配置 +func ListMediaConfigs(c *gin.Context) { + types := []model.MediaType{ + model.MediaTypeVideo, + model.MediaTypeMusic, + model.MediaTypeImage, + model.MediaTypeBook, + } + cfgs := make([]*model.MediaConfig, 0, len(types)) + for _, t := range types { + cfg, err := db.GetMediaConfig(t) + if err != nil { + common.ErrorResp(c, err, 500) + return + } + cfgs = append(cfgs, cfg) + } + common.SuccessResp(c, cfgs) +} + +// SaveMediaConfigReq 保存配置请求 +type SaveMediaConfigReq struct { + MediaType model.MediaType `json:"media_type" binding:"required"` + Enabled bool `json:"enabled"` +} + +// SaveMediaConfig 保存媒体库配置 +func SaveMediaConfig(c *gin.Context) { + var req SaveMediaConfigReq + if err := c.ShouldBindJSON(&req); err != nil { + common.ErrorResp(c, err, 400) + return + } + cfg := &model.MediaConfig{ + MediaType: req.MediaType, + Enabled: req.Enabled, + } + if err := db.SaveMediaConfig(cfg); err != nil { + common.ErrorResp(c, err, 500) + return + } + common.SuccessResp(c) +} + +// ==================== 扫描路径管理 ==================== + +// ListMediaScanPaths 获取指定媒体类型的扫描路径列表 +func ListMediaScanPaths(c *gin.Context) { + mediaType := model.MediaType(c.Query("media_type")) + paths, err := db.ListMediaScanPaths(mediaType) + if err != nil { + common.ErrorResp(c, err, 500) + return + } + common.SuccessResp(c, paths) +} + +// CreateMediaScanPathReq 创建扫描路径请求 +type CreateMediaScanPathReq struct { + MediaType model.MediaType `json:"media_type" binding:"required"` + Name string `json:"name"` + Path string `json:"path" binding:"required"` + PathMerge bool `json:"path_merge"` + TypeTag string `json:"type_tag"` + ContentTags string `json:"content_tags"` + EnableScrape bool `json:"enable_scrape"` +} + +// CreateMediaScanPath 创建扫描路径 +func CreateMediaScanPath(c *gin.Context) { + var req CreateMediaScanPathReq + if err := c.ShouldBindJSON(&req); err != nil { + common.ErrorResp(c, err, 400) + return + } + if req.Path == "" { + req.Path = "/" + } + name := req.Name + if name == "" { + name = req.Path + } + sp := &model.MediaScanPath{ + MediaType: req.MediaType, + Name: name, + Path: req.Path, + PathMerge: req.PathMerge, + TypeTag: req.TypeTag, + ContentTags: req.ContentTags, + EnableScrape: req.EnableScrape, + } + if err := db.CreateMediaScanPath(sp); err != nil { + common.ErrorResp(c, err, 500) + return + } + common.SuccessResp(c, sp) +} + +// UpdateMediaScanPathReq 更新扫描路径请求 +type UpdateMediaScanPathReq struct { + ID uint `json:"id" binding:"required"` + Name string `json:"name"` + Path string `json:"path"` + PathMerge bool `json:"path_merge"` + TypeTag string `json:"type_tag"` + ContentTags string `json:"content_tags"` + EnableScrape bool `json:"enable_scrape"` +} + +// UpdateMediaScanPath 更新扫描路径 +func UpdateMediaScanPath(c *gin.Context) { + var req UpdateMediaScanPathReq + if err := c.ShouldBindJSON(&req); err != nil { + common.ErrorResp(c, err, 400) + return + } + sp, err := db.GetMediaScanPath(req.ID) + if err != nil { + common.ErrorResp(c, err, 404) + return + } + sp.Name = req.Name + sp.Path = req.Path + sp.PathMerge = req.PathMerge + sp.TypeTag = req.TypeTag + sp.ContentTags = req.ContentTags + sp.EnableScrape = req.EnableScrape + if err := db.UpdateMediaScanPath(sp); err != nil { + common.ErrorResp(c, err, 500) + return + } + common.SuccessResp(c) +} + +// DeleteMediaScanPath 删除扫描路径 +func DeleteMediaScanPath(c *gin.Context) { + idStr := c.Query("id") + id, err := strconv.ParseUint(idStr, 10, 64) + if err != nil { + common.ErrorStrResp(c, "无效的ID", 400) + return + } + if err := db.DeleteMediaScanPath(uint(id)); err != nil { + common.ErrorResp(c, err, 500) + return + } + common.SuccessResp(c) +} + +// ClearMediaScanPathDB 清空指定扫描路径的媒体数据 +func ClearMediaScanPathDB(c *gin.Context) { + idStr := c.Query("id") + id, err := strconv.ParseUint(idStr, 10, 64) + if err != nil { + common.ErrorStrResp(c, "无效的ID", 400) + return + } + if err := db.ClearMediaItemsByScanPath(uint(id)); err != nil { + common.ErrorResp(c, err, 500) + return + } + common.SuccessResp(c) +} + +// ==================== 媒体条目管理(后台) ==================== + +// ListMediaItemsAdmin 后台分页查询媒体条目 +func ListMediaItemsAdmin(c *gin.Context) { + mediaType := model.MediaType(c.Query("media_type")) + page, _ := strconv.Atoi(c.DefaultQuery("page", "1")) + pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "20")) + keyword := c.Query("keyword") + orderBy := c.DefaultQuery("order_by", "name") + orderDir := c.DefaultQuery("order_dir", "asc") + scanPathIDStr := c.Query("scan_path_id") + var scanPathID uint + if scanPathIDStr != "" { + if id, err := strconv.ParseUint(scanPathIDStr, 10, 64); err == nil { + scanPathID = uint(id) + } + } + + q := db.MediaItemQuery{ + MediaType: mediaType, + ScanPathID: scanPathID, + 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}) +} + +// UpdateMediaItemReq 更新媒体条目请求 +type UpdateMediaItemReq struct { + ID uint `json:"id" binding:"required"` + ScrapedName string `json:"scraped_name"` + Description string `json:"description"` + Cover string `json:"cover"` + ReleaseDate string `json:"release_date"` + Rating float32 `json:"rating"` + Genre string `json:"genre"` + Authors string `json:"authors"` + Plot string `json:"plot"` + Reviews string `json:"reviews"` + AlbumName string `json:"album_name"` + AlbumArtist string `json:"album_artist"` + Publisher string `json:"publisher"` + ISBN string `json:"isbn"` + Hidden bool `json:"hidden"` +} + +// UpdateMediaItemAdmin 后台更新媒体条目 +func UpdateMediaItemAdmin(c *gin.Context) { + var req UpdateMediaItemReq + if err := c.ShouldBindJSON(&req); err != nil { + common.ErrorResp(c, err, 400) + return + } + item, err := db.GetMediaItemByID(req.ID) + if err != nil { + common.ErrorResp(c, err, 404) + return + } + item.ScrapedName = req.ScrapedName + item.Description = req.Description + item.Cover = req.Cover + item.ReleaseDate = req.ReleaseDate + item.Rating = req.Rating + item.Genre = req.Genre + item.Authors = req.Authors + item.Plot = req.Plot + item.Reviews = req.Reviews + item.AlbumName = req.AlbumName + item.AlbumArtist = req.AlbumArtist + item.Publisher = req.Publisher + item.ISBN = req.ISBN + item.Hidden = req.Hidden + + if err := db.UpdateMediaItem(item); err != nil { + common.ErrorResp(c, err, 500) + return + } + common.SuccessResp(c) +} + +// DeleteMediaItemAdmin 后台删除媒体条目 +func DeleteMediaItemAdmin(c *gin.Context) { + idStr := c.Query("id") + id, err := strconv.ParseUint(idStr, 10, 64) + if err != nil { + common.ErrorStrResp(c, "无效的ID", 400) + return + } + if err := db.DeleteMediaItem(uint(id)); err != nil { + common.ErrorResp(c, err, 500) + return + } + common.SuccessResp(c) +} + +// ClearMediaDB 清空指定类型媒体数据库 +func ClearMediaDB(c *gin.Context) { + mediaType := model.MediaType(c.Query("media_type")) + if mediaType == "" { + common.ErrorStrResp(c, "media_type 不能为空", 400) + return + } + if err := db.ClearMediaItems(mediaType); err != nil { + common.ErrorResp(c, err, 500) + return + } + common.SuccessResp(c) +} + +// ==================== 扫描与刮削 ==================== + +// ScanMediaReq 扫描请求 +type ScanMediaReq struct { + MediaType model.MediaType `json:"media_type" binding:"required"` + ScanPathID uint `json:"scan_path_id"` // 0 表示扫描全部路径 +} + +// StartMediaScan 开始扫描 +func StartMediaScan(c *gin.Context) { + var req ScanMediaReq + 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 + } + if !cfg.Enabled { + common.ErrorStrResp(c, "该媒体库未启用", 400) + return + } + + if req.ScanPathID > 0 { + // 扫描单个路径 + sp, err := db.GetMediaScanPath(req.ScanPathID) + if err != nil { + common.ErrorResp(c, err, 404) + return + } + media.ScanMediaPath(sp) + } else { + // 扫描全部路径 + media.ScanMedia(cfg) + } + common.SuccessResp(c) +} + +// GetMediaScanProgress 获取扫描进度 +func GetMediaScanProgress(c *gin.Context) { + mediaType := model.MediaType(c.Query("media_type")) + if mediaType == "" { + common.ErrorStrResp(c, "media_type 不能为空", 400) + return + } + progress := media.GetProgress(mediaType) + common.SuccessResp(c, progress) +} + +// ScrapeMediaReq 刮削请求 +type ScrapeMediaReq struct { + MediaType model.MediaType `json:"media_type" binding:"required"` + ItemID uint `json:"item_id"` // 0 表示刮削全部未刮削的 +} + +// 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: + doubanScraper := scraper.NewDoubanScraperWithConfig( + thumbnailMode, + thumbnailPath, + ) + doubanErr := doubanScraper.ScrapeBook(item) + if doubanErr != nil { + log.Debugf("豆瓣刮削失败 [%s/%s]: %v,将尝试本地提取封面", item.FolderPath, item.FileName, doubanErr) + } + + if item.Cover == "" { + bookCtx, bookCancel := context.WithTimeout(context.Background(), 60*time.Second) + // 书籍文件路径 = folder_path + "/" + file_name + bookFilePath := item.FolderPath + "/" + item.FileName + bookReader := media.FetchFileReader(bookCtx, bookFilePath) + if bookReader != nil { + localScraper := scraper.NewBookLocalScraperWithConfig( + thumbnailMode, + thumbnailPath, + ) + if localCover := localScraper.ExtractLocalCover(item.FileName, bookFilePath, bookReader); localCover != "" { + item.Cover = localCover + } + _ = bookReader.Close() + } + bookCancel() + } + + if doubanErr != nil && item.Cover == "" { + scrapeErr = doubanErr + } + case model.MediaTypeImage: + imgCtx, imgCancel := context.WithTimeout(context.Background(), 30*time.Second) + // 图片文件路径 = folder_path + "/" + file_name + imgFilePath := item.FolderPath + "/" + item.FileName + imgReader := media.FetchFileReader(imgCtx, imgFilePath) + s := scraper.NewImageScraperWithConfig( + storeThumbnail, + thumbnailMode, + thumbnailPath, + ) + scrapeErr = s.ScrapeImage(item, imgReader) + if imgReader != nil { + _ = imgReader.Close() + } + imgCancel() + } + + if scrapeErr != nil { + log.Warnf("刮削失败 [%s] %s: %v", req.MediaType, item.FolderPath, scrapeErr) + continue + } + now := time.Now() + item.ScrapedAt = &now + if err := db.UpdateMediaItem(item); err != nil { + log.Warnf("保存刮削结果失败 [%s]: %v", item.FolderPath, err) + } + } + log.Infof("刮削完成 [%s],共处理 %d 条", req.MediaType, len(items)) + }() + + _ = cfg + common.SuccessResp(c) +} + +// ==================== 公开API(前端媒体库浏览) ==================== + +// PublicListMedia 公开媒体列表(前端浏览用) +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") + typeTag := c.Query("type_tag") + contentTag := c.Query("content_tag") + scanPathIDStr := c.Query("scan_path_id") + var scanPathID uint + if scanPathIDStr != "" { + if id, err := strconv.ParseUint(scanPathIDStr, 10, 64); err == nil { + scanPathID = uint(id) + } + } + + hidden := false + q := db.MediaItemQuery{ + MediaType: mediaType, + ScanPathID: scanPathID, + FolderPath: folderPath, + TypeTag: typeTag, + ContentTag: contentTag, + 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) +} + +// PublicListAlbums 公开专辑列表(音乐) +func PublicListAlbums(c *gin.Context) { + page, _ := strconv.Atoi(c.DefaultQuery("page", "1")) + pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "40")) + keyword := c.Query("keyword") + scanPathIDStr := c.Query("scan_path_id") + var scanPathID uint + if scanPathIDStr != "" { + if id, err := strconv.ParseUint(scanPathIDStr, 10, 64); err == nil { + scanPathID = uint(id) + } + } + + hidden := false + q := db.MediaItemQuery{ + MediaType: model.MediaTypeMusic, + ScanPathID: scanPathID, + Hidden: &hidden, + Keyword: keyword, + Page: page, + PageSize: pageSize, + } + albums, total, err := db.ListAlbums(q) + if err != nil { + common.ErrorResp(c, err, 500) + return + } + common.SuccessResp(c, common.PageResp{Content: albums, Total: total}) +} + +// PublicGetAlbum 公开获取专辑详情及曲目 +func PublicGetAlbum(c *gin.Context) { + albumName := c.Query("album_name") + albumArtist := c.Query("album_artist") + if albumName == "" && albumArtist == "" { + common.ErrorStrResp(c, "album_name 和 album_artist 不能同时为空", 400) + return + } + tracks, err := db.GetAlbumTracks(albumName, albumArtist) + if err != nil { + common.ErrorResp(c, err, 500) + return + } + common.SuccessResp(c, tracks) +} + +// PublicListFolders 公开文件夹列表(目录浏览模式) +func PublicListFolders(c *gin.Context) { + mediaType := model.MediaType(c.Query("media_type")) + if mediaType == "" { + common.ErrorStrResp(c, "media_type 不能为空", 400) + return + } + paths, err := db.ListFolderPaths(mediaType) + if err != nil { + common.ErrorResp(c, err, 500) + return + } + common.SuccessResp(c, paths) +} + +// PublicListScanPaths 公开获取扫描路径列表(前端筛选用) +func PublicListScanPaths(c *gin.Context) { + mediaType := model.MediaType(c.Query("media_type")) + paths, err := db.ListMediaScanPaths(mediaType) + if err != nil { + common.ErrorResp(c, err, 500) + return + } + common.SuccessResp(c, paths) +} diff --git a/server/router.go b/server/router.go index 57d1166ae..23d38622c 100644 --- a/server/router.go +++ b/server/router.go @@ -185,6 +185,24 @@ func admin(g *gin.RouterGroup) { scan.POST("/start", handles.StartManualScan) scan.POST("/stop", handles.StopManualScan) scan.GET("/progress", handles.GetManualScanProgress) + + // 媒体库管理 + mediaAdmin := g.Group("/media") + mediaAdmin.GET("/config/list", handles.ListMediaConfigs) + mediaAdmin.POST("/config/save", handles.SaveMediaConfig) + mediaAdmin.GET("/items", handles.ListMediaItemsAdmin) + mediaAdmin.POST("/items/update", handles.UpdateMediaItemAdmin) + mediaAdmin.POST("/items/delete", handles.DeleteMediaItemAdmin) + mediaAdmin.POST("/scan/start", handles.StartMediaScan) + mediaAdmin.GET("/scan/progress", handles.GetMediaScanProgress) + mediaAdmin.POST("/scrape/start", handles.StartMediaScrape) + mediaAdmin.POST("/clear", handles.ClearMediaDB) + // 扫描路径管理 + mediaAdmin.GET("/scan_paths", handles.ListMediaScanPaths) + mediaAdmin.POST("/scan_paths/create", handles.CreateMediaScanPath) + mediaAdmin.POST("/scan_paths/update", handles.UpdateMediaScanPath) + mediaAdmin.POST("/scan_paths/delete", handles.DeleteMediaScanPath) + mediaAdmin.POST("/scan_paths/clear", handles.ClearMediaScanPathDB) } func fsAndShare(g *gin.RouterGroup) { @@ -193,6 +211,15 @@ func fsAndShare(g *gin.RouterGroup) { a := g.Group("/archive") a.Any("/meta", handles.FsArchiveMetaSplit) a.Any("/list", handles.FsArchiveListSplit) + + // 媒体库公开API + mediaPublic := g.Group("/media") + mediaPublic.GET("/list", handles.PublicListMedia) + mediaPublic.GET("/item/:id", handles.PublicGetMedia) + mediaPublic.GET("/albums", handles.PublicListAlbums) + mediaPublic.GET("/album", handles.PublicGetAlbum) + mediaPublic.GET("/folders", handles.PublicListFolders) + mediaPublic.GET("/scan_paths", handles.PublicListScanPaths) } func _fs(g *gin.RouterGroup) {