Files
OpenList/server/router.go
25e4eb663f feat(func): media library (#2210)
* feat(func): media library

* feat(func): media library

* feat(func): media library

* feat(func): media library build test

* feat(func): media library build test

* feat(func): media library fix remote fetch error

* feat(func): media library fix remote fetch error

* feat(func): media library fix remote fetch error

* feat(func): media library support select

* feat(func): media library support select

* feat(func): media library support select

* feat(func): media library support select

* feat(func): media library support change api url

* feat(func): media library support change api url

* feat(func): media library support change api url

* feat(media): 并发刮削(默认 5)+ 单条落库
增强 TMDB 搜索:模糊匹配 + 多候选关键词
管理页面新增导入/导出媒体库(全部 / 按扫描路径)
缩略图路径默认值改为 imgs

* feat(media): 清空刮削、删除已失效

* fix(media): fix api proxy path

* fix(media): fix lrc file display & music id3

* fix(media): fix media scan sub path error

* feat(media): support cloud codec via ffmpeg

* feat(media): support cloud codec via ffmpeg

* feat(media): support cloud codec via ffmpeg

* feat(media): support cloud codec via ffmpeg

* feat(media): 提升刮削精准度,支持模糊刮削

* fix(media): clean up imports and improve FetchFileReader logic

* feat(media): 提升刮削精准度,支持模糊刮削

* feat(media): 刮削支持自定义刮削数量,避免太占性能

* feat(media): 刮削支持自定义刮削数量,避免太占性能

* feat(media): 刮削修复写入作品名问题

* fix(media): return 404 on missing item_id and fix unscraped item query

- Validate GetMediaItemByID result before spawning goroutine in
  StartMediaScrape; return 404 when item_id > 0 but item is not found
- Replace `description` check with `plot` check in GetUnscrappedItems
  since scrapers populate Plot, not Description, to avoid re-selecting
  already-scraped items on every run

* Apply remaining changes

* feat(media): add master switch, app keys, STRM, Emby and OpenSubsonic APIs

* feat(media): allow any logged-in user to manage their own media app keys

* feat(media): allow signing arbitrary media path for local player

* fix(media): always return an array for media status types

* fix(media): persist per-type scraper config and apply it to thumbnails

* feat(media): add local thumbnail endpoint and cover proxy with fallback gateway

* fix(media): allow guest media access, per-file local thumbnails and optional cover proxy

* feat(media): thumbnail storage modes and STRM same-directory output

* feat(media): Google Books/OpenLibrary sources and remote cover download

* chore(media): rename transcode cache dir to ctmp and log thumbnail storage dir

* feat(media): progress and favorites API shared with Emby/Subsonic

* chore: ignore runtime thumbnail directory

* feat(media): add preferred scrape language and namespace protocol endpoints under @

* fix(media): harden anonymous emby images, pagination and cover proxy SSRF

* fix(emby): allow anonymous user discovery and login before auth

* chore: drop redundant media build workflow and protocol doc

* fix(media): ID3v2.4 syncsafe frame size and preserve Hidden flag on rescan

* fix(media): address security review (path traversal, guest bypass, cover XSS, auth rate limit, SSRF, quotas, migration)

* fix(media): 清理失效条目时只删除确认不存在的记录

此前 fs.Get 返回任何错误都视为失效并硬删除,驱动故障/超时/鉴权失败时会误删有效媒体记录。现在仅 errs.IsObjectNotFound 才删除,其余错误跳过并在响应中返回 skipped 计数。

* fix(transcode): 播放凭据与 Worker 回写凭据分离,并收紧 profile 与鉴权校验

- 新增 Job.PlayToken(json 标签为 -),master/playlist/切片只认播放凭据,播放器不再拿到可上传、可结束任务的 callback_token

- Worker 上传切片时校验 profile 必须属于任务声明的档位

- TCMaster 与 WorkerJobFinish 改用恒定时间比较,避免时序侧信道

* fix(transcode): 含冒号的本地文件名可转码,补基址缺失与重新激活配额

- ValidSourceInput 不再按冒号位置判协议:本地输入只要求以 / 开头或 Windows 盘符路径,concat:/subfile: 等前缀仍被拒且由 -protocol_whitelist 兜底(修复 /mnt/x/Star Wars: IV.mkv 被误判)

- trustedInternalBaseURL 返回空串时直接 503 并给出明确原因,不再拼出 /d/xxx 让 ffmpeg 报文件不存在

- 远程输入白名单收窄为 http,https,tcp,tls(去掉 file 与 crypto)

- Reactivate 复用同一套单用户配额,避免「先让任务闲置取消、再批量激活」绕过 SubmitLimited;配额不足返回 429

- OwnerID 改为配额键:普通用户 user:<id>,访客 guest:<clientIP>,避免单个匿名访客占满全部匿名配额;补充对应单测

* fix(media): 收紧缩略图解码内存、跳过超长条目可见化,并补 CI 单测

- MaxImagePixels 从 64MP 降到 32MP,宽高改 int64 相乘防 32 位溢出,解码阶段加 12 路全局信号量

- 封面解码失败退回原始字节时限制 512KB,避免十几 MB 的 base64 写进 cover 列(豆瓣/本地书籍三处)

- 扫描进度新增 Skipped 计数,入库被跳过的超长路径会在完成消息与日志中体现

- 数据库迁移失败日志补充 OPENLIST_MEDIA_MIGRATE_WIPE=1 的处置提示

- Subsonic 认证只对「确实带了凭据却校验失败」计数,裸探测不再刷新 IP 锁定

- goexif 从 indirect 调整为直接依赖(internal/media/scraper/image.go 直接 import)

- CI 新增 go test ./internal/db/... ./internal/transcode/... ./internal/media/...

* fix(transcode): 配额/队列满时仍放行已缓存切片,并同步 worker 协议白名单

- TCSegment/TCPlaylist: Reactivate 返回 ErrUserQuota/ErrQueueFull 时不再直接 429,改为标记 quotaBlocked,缓存命中的切片与 playlist 仍可读,只有缓存缺失才返回 429
- Reactivate: 补充全局排队上限 (MaxPendingJobs) 检查,避免批量激活顶爆队列,并补单测
- cmd/worker: 远程输入协议白名单与 master 同步为 http,https,tcp,tls(去掉 file/crypto)
- scraper: decodeSem 容量 12 -> 3,避免信号量大于默认刮削并发而形同失效

* feat(emby): 完善客户端兼容层并修复认证锁定,Subsonic 复用统一密钥校验

- Emby: 用户 ID 改用 GUID 并兼容数字/旧 ID;补齐 /Users/{id}/Authenticate、Sessions 与 WebSocket、首页分区、合集/播放列表、字幕流与歌词、Series/Season 结构
- Emby: 版本号返回客户端可接受的兼容版本,/Users/Public 返回可登录用户以渲染用户选择
- Subsonic: 支持 enc: 十六进制密码编码;app key 校验与 Emby 共用 LookUpAppKeyUser
- 限流: 认证被拒时不再刷新锁定过期时间,避免客户端持续轮询把 IP 永久锁死

* fix(media): 认证失败计数增加用户名维度,防止伪造 X-Forwarded-For 绕过锁定

- media_ratelimit: 新增 mediaAuthLockedFor / mediaAuthFailedFor / mediaAuthSucceededFor,IP 与用户名双维度计数;达到上限同样锁定 5 分钟且被拒时不续期
- emby: 按用户名登录与按 id 登录都检查用户名维度;失败计入两个维度,成功清除两个维度
- subsonic: subAuth 与 429 分支改用双维度判定;成功时用真实用户名清理计数(apiKey 认证时 u 参数为空)
- 仍保留主干 5 次/5 分钟的策略,代理信任范围(SetTrustedProxies)留给项目层面单独处理

* fix(media): 匿名封面按访客身份过滤,认证日志脱敏并收紧限流计数

- embyImage: 匿名封面不再走 loadEmbyLibraryAll(全库可见),改为 loadEmbyLibrary(guest):
  hidden / base_path 之外 / meta 受保护的条目都不可见,访客被禁用时直接 401
- embyUnauthorized: 只记录 Path、UA 与是否带 token,避免 query 里的 Pw/Password/api_key
  和 X-Emby-Authorization 里的设备令牌写进日志
- media_ratelimit: 失败计数 Set 时带 5min TTL,避免 IP/用户名 key 无限增长(用户名来自请求参数)
- auth.loginHash: 锁定期间不再续期,失败计数同样带 TTL,与媒体侧语义一致
- /System/Endpoint: 按 RemoteAddr 判断 IsLocal/IsInNetwork;/System/Info 不再返回真实监听端口

* feat(media): PDF 批注持久化

- 新增 MediaAnnotation 模型:按 (user_id, item_id, remote_id) 唯一,anchor 原样保存查看器批注对象,不重复建模页码/坐标
- GET/POST /fs/media/annotations:读取当前用户批注、按 remote_id 增量 upsert/delete(单条 512KB、单次 8MB、最多 500 条)
- 批注为用户私有数据:未登录返回空列表,访客写入 403,并校验条目可见性与访问权限
- 条目删除 / 清空类型 / 清空扫描路径 / 批量删除时,在同一事务内清理对应批注

* fix(media): 选集签名前校验最终文件权限;转码在 ffprobe 前预检配额

- GetMediaStreamURL / GetMediaStrm: 解析选集后对最终 rawPath 再执行 CanUserAccessPath,避免「目录可见但 meta 隐藏规则禁止其中某个文件」时仍能拿到有效签名(签名之后下载链路不会重跑隐藏规则)
- TranscodePlay: ProbeSource 之前调用 Scheduler.Capacity 预检配额,已满直接 429,不再先起最长 30s 的 ffprobe
- 新增探测并发闸门(全局 8 / 每用户 3),覆盖预检与 SubmitLimited 之间的并发窗口;用 defer 释放,探测 panic 也不会占死槽位
- 补 Capacity 单测

---------

Co-authored-by: j2rong4cn <j2rong@qq.com>
Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: pikachuren <pikachuren@example.com>
2026-10-24 17:34:29 +08:00

368 lines
16 KiB
Go

package server
import (
"github.com/OpenListTeam/OpenList/v4/cmd/flags"
"github.com/OpenListTeam/OpenList/v4/internal/conf"
"github.com/OpenListTeam/OpenList/v4/internal/message"
multipartPkg "github.com/OpenListTeam/OpenList/v4/internal/multipart"
"github.com/OpenListTeam/OpenList/v4/internal/sign"
"github.com/OpenListTeam/OpenList/v4/internal/stream"
"github.com/OpenListTeam/OpenList/v4/pkg/utils"
"github.com/OpenListTeam/OpenList/v4/server/common"
"github.com/OpenListTeam/OpenList/v4/server/handles"
"github.com/OpenListTeam/OpenList/v4/server/middlewares"
"github.com/OpenListTeam/OpenList/v4/server/static"
"github.com/gin-contrib/cors"
"github.com/gin-gonic/gin"
)
func Init(e *gin.Engine) {
e.ContextWithFallback = true
if !utils.SliceContains([]string{"", "/"}, conf.URL.Path) {
e.GET("/", func(c *gin.Context) {
c.Redirect(302, conf.URL.Path)
})
}
Cors(e)
g := e.Group(conf.URL.Path)
if conf.Conf.Scheme.HttpPort != -1 && conf.Conf.Scheme.HttpsPort != -1 && conf.Conf.Scheme.ForceHttps {
e.Use(middlewares.ForceHttps)
}
g.Any("/ping", func(c *gin.Context) {
c.String(200, "pong")
})
g.GET("/favicon.ico", handles.Favicon)
g.GET("/robots.txt", handles.Robots)
g.GET("/manifest.json", static.ManifestJSON)
g.GET("/i/:link_name", handles.Plist)
common.SecretKey = []byte(conf.Conf.JwtSecret)
g.Use(middlewares.StoragesLoaded)
if conf.Conf.MaxConnections > 0 {
g.Use(middlewares.MaxAllowed(conf.Conf.MaxConnections))
}
WebDav(g.Group("/dav"))
S3(g.Group("/s3"))
MCP(g)
downloadLimiter := middlewares.DownloadRateLimiter(stream.ClientDownloadLimit)
signCheck := middlewares.Down(sign.Verify)
g.GET("/d/*path", middlewares.PathParse, signCheck, downloadLimiter, handles.Down)
g.GET("/p/*path", middlewares.PathParse, signCheck, downloadLimiter, handles.Proxy)
g.HEAD("/d/*path", middlewares.PathParse, signCheck, handles.Down)
g.HEAD("/p/*path", middlewares.PathParse, signCheck, handles.Proxy)
archiveSignCheck := middlewares.Down(sign.VerifyArchive)
g.GET("/ad/*path", middlewares.PathParse, archiveSignCheck, downloadLimiter, handles.ArchiveDown)
g.GET("/ap/*path", middlewares.PathParse, archiveSignCheck, downloadLimiter, handles.ArchiveProxy)
g.GET("/ae/*path", middlewares.PathParse, archiveSignCheck, downloadLimiter, handles.ArchiveInternalExtract)
g.HEAD("/ad/*path", middlewares.PathParse, archiveSignCheck, handles.ArchiveDown)
g.HEAD("/ap/*path", middlewares.PathParse, archiveSignCheck, handles.ArchiveProxy)
g.HEAD("/ae/*path", middlewares.PathParse, archiveSignCheck, handles.ArchiveInternalExtract)
g.GET("/sd/:sid", middlewares.EmptyPathParse, middlewares.SharingIdParse, downloadLimiter, handles.SharingDown)
g.GET("/sd/:sid/*path", middlewares.PathParse, middlewares.SharingIdParse, downloadLimiter, handles.SharingDown)
g.HEAD("/sd/:sid", middlewares.EmptyPathParse, middlewares.SharingIdParse, handles.SharingDown)
g.HEAD("/sd/:sid/*path", middlewares.PathParse, middlewares.SharingIdParse, handles.SharingDown)
g.GET("/sad/:sid", middlewares.EmptyPathParse, middlewares.SharingIdParse, downloadLimiter, handles.SharingArchiveExtract)
g.GET("/sad/:sid/*path", middlewares.PathParse, middlewares.SharingIdParse, downloadLimiter, handles.SharingArchiveExtract)
g.HEAD("/sad/:sid", middlewares.EmptyPathParse, middlewares.SharingIdParse, handles.SharingArchiveExtract)
g.HEAD("/sad/:sid/*path", middlewares.PathParse, middlewares.SharingIdParse, handles.SharingArchiveExtract)
// 转码播放:master/variant playlist 与切片下发;token 已包含在路径中作为鉴权
g.GET("/tc/:job/:token/master.m3u8", handles.TCMaster)
g.GET("/tc/:job/:token/:profile/playlist.m3u8", handles.TCPlaylist)
g.GET("/tc/:job/:token/:profile/:seg", handles.TCSegment)
api := g.Group("/api")
auth := api.Group("", middlewares.Auth(false))
webauthn := api.Group("/authn", middlewares.Authn)
api.POST("/auth/login", handles.Login)
api.POST("/auth/login/hash", handles.LoginHash)
api.POST("/auth/login/ldap", handles.LoginLdap)
auth.GET("/me", handles.CurrentUser)
auth.POST("/me/update", handles.UpdateCurrent)
// 媒体应用密钥(每个登录用户都可以为自己的客户端签发密钥)
auth.GET("/me/appkey/list", handles.ListMediaAppKeys)
auth.POST("/me/appkey/create", handles.CreateMediaAppKey)
auth.POST("/me/appkey/delete", handles.DeleteMediaAppKey)
auth.GET("/me/sshkey/list", handles.ListMyPublicKey)
auth.POST("/me/sshkey/add", handles.AddMyPublicKey)
auth.POST("/me/sshkey/delete", handles.DeleteMyPublicKey)
auth.POST("/auth/2fa/generate", handles.Generate2FA)
auth.POST("/auth/2fa/verify", handles.Verify2FA)
auth.GET("/auth/logout", handles.LogOut)
// auth
api.GET("/auth/sso", handles.SSOLoginRedirect)
api.GET("/auth/sso_callback", handles.SSOLoginCallback)
api.GET("/auth/get_sso_id", handles.SSOLoginCallback)
api.GET("/auth/sso_get_token", handles.SSOLoginCallback)
// webauthn
api.GET("/authn/webauthn_begin_login", handles.BeginAuthnLogin)
api.POST("/authn/webauthn_finish_login", handles.FinishAuthnLogin)
webauthn.GET("/webauthn_begin_registration", handles.BeginAuthnRegistration)
webauthn.POST("/webauthn_finish_registration", handles.FinishAuthnRegistration)
webauthn.POST("/delete_authn", handles.DeleteAuthnLogin)
webauthn.GET("/getcredentials", handles.GetAuthnCredentials)
// no need auth
public := api.Group("/public")
public.Any("/settings", handles.PublicSettings)
public.GET("/media_status", handles.PublicMediaStatus)
// 封面代理与本地缩略图(媒体库页面使用,免鉴权)
public.GET("/media/proxy", handles.PublicMediaProxy)
public.GET("/media/thumb", handles.PublicMediaThumb)
public.Any("/offline_download_tools", handles.OfflineDownloadTools)
public.Any("/archive_extensions", handles.ArchiveExtensions)
public.Any("/init_status", handles.InitStatus)
public.POST("/init/setup", handles.InitSetup)
// 转码 Worker 协议端点(远程 Worker 节点对接,由共享密钥/token 鉴权,无需登录)
transcodeWorker := api.Group("/transcode/worker")
transcodeWorker.POST("/register", handles.WorkerRegister)
transcodeWorker.POST("/heartbeat", handles.WorkerHeartbeat)
transcodeWorker.POST("/claim", handles.WorkerClaim)
transcodeWorker.PUT("/segment", handles.WorkerSegmentUpload)
transcodeWorker.POST("/job/finish", handles.WorkerJobFinish)
// Emby 兼容 API(自带鉴权与开关判断,路径大小写由 handler 归一化)
// 使用 @ 前缀,避免与用户自己创建的 /emby、/rest 目录冲突
g.Any("/@emby/*path", handles.EmbyAPI)
g.Any("/@mediabrowser/*path", handles.EmbyAPI)
// OpenSubsonic API(自带鉴权与开关判断)
g.Any("/@rest/*path", handles.SubsonicAPI)
_fs(auth.Group("/fs"))
// 媒体库公开 API:与文件浏览一致,允许访客访问(访客仍受自身 base_path/权限限制)
mediaPublic := auth.Group("/fs/media", handles.RequireMediaEnabled)
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)
mediaPublic.GET("/strm", handles.GetMediaStrm)
mediaPublic.GET("/stream_url", handles.GetMediaStreamURL)
// 播放进度与收藏(与 Emby / Subsonic 共用同一份用户数据)
mediaPublic.GET("/userdata", handles.GetMediaUserDataBatch)
mediaPublic.GET("/progress", handles.GetMediaProgress)
mediaPublic.POST("/progress", handles.SaveMediaProgress)
mediaPublic.POST("/favorite", handles.SetMediaFavorite)
mediaPublic.GET("/favorites", handles.ListMediaFavoritesAPI)
mediaPublic.GET("/resume", handles.ListMediaResumeAPI)
// 阅读器批注(登录用户私有数据;访客只读返回空列表,写入直接 403)
mediaPublic.GET("/annotations", handles.ListMediaAnnotationsAPI)
mediaPublic.POST("/annotations", handles.SyncMediaAnnotationsAPI)
fsAndShare(api.Group("/fs", middlewares.Auth(true)))
_task(auth.Group("/task", middlewares.AuthNotGuest))
_sharing(auth.Group("/share", middlewares.AuthNotGuest))
admin(auth.Group("/admin", middlewares.AuthAdmin))
if flags.Debug || flags.Dev {
debug(g.Group("/debug"))
}
static.Static(g, func(handlers ...gin.HandlerFunc) {
e.NoRoute(handlers...)
})
}
func admin(g *gin.RouterGroup) {
meta := g.Group("/meta")
meta.GET("/list", handles.ListMetas)
meta.GET("/get", handles.GetMeta)
meta.POST("/create", handles.CreateMeta)
meta.POST("/update", handles.UpdateMeta)
meta.POST("/delete", handles.DeleteMeta)
user := g.Group("/user")
user.GET("/list", handles.ListUsers)
user.GET("/get", handles.GetUser)
user.POST("/create", handles.CreateUser)
user.POST("/update", handles.UpdateUser)
user.POST("/cancel_2fa", handles.Cancel2FAById)
user.POST("/delete", handles.DeleteUser)
user.POST("/del_cache", handles.DelUserCache)
user.GET("/sshkey/list", handles.ListPublicKeys)
user.POST("/sshkey/delete", handles.DeletePublicKey)
storage := g.Group("/storage")
storage.GET("/list", handles.ListStorages)
storage.GET("/get", handles.GetStorage)
storage.POST("/create", handles.CreateStorage)
storage.POST("/update", handles.UpdateStorage)
storage.POST("/delete", handles.DeleteStorage)
storage.POST("/enable", handles.EnableStorage)
storage.POST("/disable", handles.DisableStorage)
storage.POST("/load_all", handles.LoadAllStorages)
driver := g.Group("/driver")
driver.GET("/list", handles.ListDriverInfo)
driver.GET("/names", handles.ListDriverNames)
driver.GET("/info", handles.GetDriverInfo)
setting := g.Group("/setting")
setting.GET("/get", handles.GetSetting)
setting.GET("/list", handles.ListSettings)
setting.POST("/save", handles.SaveSettings)
setting.POST("/delete", handles.DeleteSetting)
setting.POST("/default", handles.DefaultSettings)
setting.POST("/reset_token", handles.ResetToken)
setting.POST("/set_aria2", handles.SetAria2)
setting.POST("/set_qbit", handles.SetQbittorrent)
setting.POST("/set_transmission", handles.SetTransmission)
setting.POST("/set_115", handles.Set115)
setting.POST("/set_115_open", handles.Set115Open)
setting.POST("/set_123_pan", handles.Set123Pan)
setting.POST("/set_123_open", handles.Set123Open)
setting.POST("/set_pikpak", handles.SetPikPak)
setting.POST("/set_thunder", handles.SetThunder)
setting.POST("/set_thunderx", handles.SetThunderX)
setting.POST("/set_thunder_browser", handles.SetThunderBrowser)
setting.POST("/set_guangyapan", handles.SetGuangYaPan)
// retain /admin/task API to ensure compatibility with legacy automation scripts
_task(g.Group("/task"))
ms := g.Group("/message")
ms.POST("/get", message.HttpInstance.GetHandle)
ms.POST("/send", message.HttpInstance.SendHandle)
index := g.Group("/index")
index.POST("/build", middlewares.SearchIndex, handles.BuildIndex)
index.POST("/update", middlewares.SearchIndex, handles.UpdateIndex)
index.POST("/stop", middlewares.SearchIndex, handles.StopIndex)
index.POST("/clear", middlewares.SearchIndex, handles.ClearIndex)
index.GET("/progress", middlewares.SearchIndex, handles.GetProgress)
scan := g.Group("/scan")
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.GET("/scrape/stats", handles.GetMediaScrapeStats)
mediaAdmin.POST("/clear", handles.ClearMediaDB)
mediaAdmin.POST("/clear_scrape", handles.ClearMediaScrape)
mediaAdmin.POST("/delete_invalid", handles.DeleteInvalidMedia)
// 扫描路径管理
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)
// 导入 / 导出
mediaAdmin.GET("/export", handles.ExportMediaDB)
mediaAdmin.POST("/import", handles.ImportMediaDB)
// 媒体应用密钥(Subsonic/Emby/MoviePilot 等无交互客户端)
mediaAdmin.GET("/appkey/list", handles.ListMediaAppKeys)
mediaAdmin.POST("/appkey/create", handles.CreateMediaAppKey)
mediaAdmin.POST("/appkey/delete", handles.DeleteMediaAppKey)
// STRM 批量生成
mediaAdmin.POST("/strm/generate", handles.GenerateMediaStrm)
// 转码管理
transcodeAdmin := g.Group("/transcode")
transcodeAdmin.GET("/workers", handles.ListTranscodeWorkers)
transcodeAdmin.GET("/jobs", handles.ListTranscodeJobs)
transcodeAdmin.POST("/cancel", handles.CancelTranscodeJob)
}
func fsAndShare(g *gin.RouterGroup) {
g.Any("/list", handles.FsListSplit)
g.Any("/get", handles.FsGetSplit)
a := g.Group("/archive")
a.Any("/meta", handles.FsArchiveMetaSplit)
a.Any("/list", handles.FsArchiveListSplit)
// 转码 - 用户态:申请播放
g.POST("/transcode/play", handles.RequireMediaEnabled, handles.TranscodePlay)
}
func _fs(g *gin.RouterGroup) {
g.Any("/search", middlewares.SearchIndex, handles.Search)
g.Any("/other", handles.FsOther)
g.Any("/dirs", handles.FsDirs)
g.POST("/mkdir", handles.FsMkdir)
g.POST("/rename", handles.FsRename)
g.POST("/batch_rename", handles.FsBatchRename)
g.POST("/regex_rename", handles.FsRegexRename)
g.POST("/move", handles.FsMove)
g.POST("/recursive_move", handles.FsRecursiveMove)
g.POST("/copy", handles.FsCopy)
g.POST("/remove", handles.FsRemove)
g.POST("/remove_empty_directory", handles.FsRemoveEmptyDirectory)
uploadLimiter := middlewares.UploadRateLimiter(stream.ClientUploadLimit)
g.PUT("/put", middlewares.FsUp, uploadLimiter, handles.FsStream)
g.PUT("/form", middlewares.FsUp, uploadLimiter, handles.FsForm)
multipartPkg.DefaultManager.StartGC() // reclaim ring files orphaned by a previous run
multipart := g.Group("/multipart")
multipart.POST("/init", middlewares.FsUp, handles.MultipartInit)
multipart.PUT("/chunk", uploadLimiter, handles.MultipartChunk)
multipart.POST("/complete", handles.MultipartComplete)
multipart.GET("/status", handles.MultipartStatus)
multipart.POST("/abort", handles.MultipartAbort)
g.POST("/link", middlewares.AuthAdmin, handles.Link)
// g.POST("/add_aria2", handles.AddOfflineDownload)
// g.POST("/add_qbit", handles.AddQbittorrent)
// g.POST("/add_transmission", handles.SetTransmission)
g.POST("/add_offline_download", handles.AddOfflineDownload)
g.POST("/archive/decompress", handles.FsArchiveDecompress)
// Torrent 相关接口
g.POST("/torrent/parse", handles.ParseTorrent)
g.POST("/torrent/upload_parse", handles.UploadTorrentAndParse)
g.POST("/torrent/rapid_upload", handles.TorrentRapidUpload)
g.POST("/torrent/generate", handles.GenerateTorrentForPath)
// Unified transfer seed APIs. Legacy torrent routes above remain supported.
seed := g.Group("/seed")
seed.POST("/parse", handles.ParseSeed)
seed.POST("/upload_parse", handles.UploadSeedAndParse)
seed.POST("/generate", handles.GenerateSeedForPaths)
seed.POST("/convert", handles.ConvertSeed)
seed.POST("/diagnose", handles.DiagnoseSeed)
seed.POST("/capabilities", handles.SeedCapabilities)
seed.POST("/rapid_upload", handles.QuickSaveSeed)
seed.POST("/offline_download", handles.QuickSaveSeed)
seed.POST("/update", handles.UpdateSeed)
seed.POST("/quick_save", handles.QuickSaveSeed)
seed.POST("/update_channels", handles.UpdateSeedChannels)
// Direct upload (client-side upload to storage)
g.POST("/get_direct_upload_info", handles.FsGetDirectUploadInfo)
}
func _task(g *gin.RouterGroup) {
handles.SetupTaskRoute(g)
}
func _sharing(g *gin.RouterGroup) {
g.Any("/list", handles.ListSharings)
g.GET("/get", handles.GetSharing)
g.POST("/create", handles.CreateSharing)
g.POST("/update", handles.UpdateSharing)
g.POST("/delete", handles.DeleteSharing)
g.POST("/enable", handles.SetEnableSharing(false))
g.POST("/disable", handles.SetEnableSharing(true))
}
func Cors(r *gin.Engine) {
config := cors.DefaultConfig()
// config.AllowAllOrigins = true
config.AllowOrigins = conf.Conf.Cors.AllowOrigins
config.AllowHeaders = conf.Conf.Cors.AllowHeaders
config.AllowMethods = conf.Conf.Cors.AllowMethods
r.Use(cors.New(config))
}
func InitS3(e *gin.Engine) {
Cors(e)
S3Server(e.Group("/"))
}