diff --git a/.env.example b/.env.example index 0e934e6..a489f10 100644 --- a/.env.example +++ b/.env.example @@ -16,6 +16,10 @@ ADMIN_INITIAL_PASSWORD=admin123 # Strong random JWT secret (auto-generated when empty). # MEDIASTATION_SECRETS_JWT_SECRET=please-change-me +# Optional private license server. +# MEDIASTATION_LICENSE_SERVER_URL=http://127.0.0.1:8001 +# MEDIASTATION_LICENSE_HMAC_SECRET=must-match-license-server + # 3rd-party scrape providers. # MEDIASTATION_SECRETS_TMDB_API_KEY= # MEDIASTATION_SECRETS_BANGUMI_ACCESS_TOKEN= diff --git a/README.md b/README.md index 65a1ccc..090cc66 100644 --- a/README.md +++ b/README.md @@ -53,6 +53,13 @@ MediaStationGo 采用完全开源路线,核心媒体库、刮削、播放、 > 说明:GPL-3.0 是自由软件许可证,其正式授权范围以仓库 [LICENSE](LICENSE) 文件为准;上方「非商用承诺」表达项目维护者的使用边界与商业合作要求。如需商业合作、企业部署或二次发行,请先联系作者获得额外授权。 +### 源码开放与 Docker 部署边界 + +- 当前公开仓库继续以 `GPL-3.0` 作为基础许可证;如果代码包含 GPL 派生实现,不能通过“只发布 Docker 镜像”规避对应源码提供义务。 +- 可以把官方部署策略收敛为 **Docker-first / Docker-only support**:即项目只承诺维护 Docker Compose、GHCR 镜像和容器部署文档,裸机运行与二进制包可作为社区自助能力。 +- 若未来需要部分闭源,建议将闭源能力拆成独立插件、独立服务或私有模块,并确保该部分为作者自有或兼容许可证的干净实现;GPL 覆盖代码仍应按 GPL 公开。 +- README 中的非商用声明是维护者的使用边界与商业授权要求;正式代码授权仍以 [LICENSE](LICENSE) 为准。 + --- ## 🚀 在线演示 @@ -231,7 +238,7 @@ mkdir -p data cache media downloads ```bash cat > .env <<'EOF' # 固定版本;需要升级时改成新的 MediaStationGo-vX.Y.Z 后执行 docker compose pull && docker compose up -d -MEDIASTATION_IMAGE_TAG=MediaStationGo-v0.0.11 +MEDIASTATION_IMAGE_TAG=MediaStationGo-v0.0.15 MEDIASTATION_HTTP_PORT=18080 # 程序数据和缓存建议放在 MediaStationGo 部署目录下,便于备份和迁移。 @@ -300,7 +307,7 @@ vim docker-compose.yml # # 镜像版本: # 默认拉取 latest;如需固定版本,创建 .env 并写入: -# MEDIASTATION_IMAGE_TAG=MediaStationGo-v0.0.11 +# MEDIASTATION_IMAGE_TAG=MediaStationGo-v0.0.15 # # 路径映射总览: # /data 程序数据目录。保存 SQLite 数据库、JWT secret、系统配置等,必须持久化。 @@ -501,7 +508,7 @@ docker compose up -d ```bash cat > .env <<'EOF' -MEDIASTATION_IMAGE_TAG=MediaStationGo-v0.0.11 +MEDIASTATION_IMAGE_TAG=MediaStationGo-v0.0.15 MEDIASTATION_HTTP_PORT=18080 MEDIASTATION_DATA_DIR=./data MEDIASTATION_CACHE_DIR=./cache @@ -764,26 +771,26 @@ cd MediaStationGo | 平台 | 包名示例 | | --- | --- | -| Linux x86_64 | `MediaStationGo-v0.0.11-linux-amd64.tar.gz` | -| Linux ARM64 | `MediaStationGo-v0.0.11-linux-arm64.tar.gz` | -| Windows x86_64 | `MediaStationGo-v0.0.11-windows-amd64.zip` | -| macOS Intel | `MediaStationGo-v0.0.11-darwin-amd64.tar.gz` | -| macOS Apple Silicon | `MediaStationGo-v0.0.11-darwin-arm64.tar.gz` | +| Linux x86_64 | `MediaStationGo-v0.0.15-linux-amd64.tar.gz` | +| Linux ARM64 | `MediaStationGo-v0.0.15-linux-arm64.tar.gz` | +| Windows x86_64 | `MediaStationGo-v0.0.15-windows-amd64.zip` | +| macOS Intel | `MediaStationGo-v0.0.15-darwin-amd64.tar.gz` | +| macOS Apple Silicon | `MediaStationGo-v0.0.15-darwin-arm64.tar.gz` | 部署步骤: ```bash # Linux 示例 -tar -xzf MediaStationGo-v0.0.11-linux-amd64.tar.gz -cd MediaStationGo-v0.0.11-linux-amd64 +tar -xzf MediaStationGo-v0.0.15-linux-amd64.tar.gz +cd MediaStationGo-v0.0.15-linux-amd64 MEDIASTATION_APP_PORT=18080 ./mediastation-go ``` Windows: ```powershell -Expand-Archive .\MediaStationGo-v0.0.11-windows-amd64.zip -cd .\MediaStationGo-v0.0.11-windows-amd64 +Expand-Archive .\MediaStationGo-v0.0.15-windows-amd64.zip +cd .\MediaStationGo-v0.0.15-windows-amd64 $env:MEDIASTATION_APP_PORT = "18080" .\mediastation-go.exe ``` @@ -901,6 +908,47 @@ MediaStationGo/ --- +## 👥 用户与权限策略 + +- 默认管理员由系统首次启动创建,默认账号为 `admin / admin123`;该默认管理员可以改用户名,但不能删除,也不能降级,始终拥有最高权限。 +- 开源版默认最多允许 20 个用户,避免家庭 NAS 或公开测试环境被滥用;绑定私有授权服务后可按授权策略提升用户额度。 +- 管理后台新增用户默认为“观看用户”:允许登录 Web 与 Emby 兼容客户端、浏览媒体库、播放媒体、使用外部播放器、收藏与记录观看历史。 +- 普通观看用户默认不能扫描媒体库、重新刮削、删除媒体、探测媒体轨、写出 NFO、管理文件、管理 STRM、管理下载器、创建下载任务或订阅下载。 +- 由于视频流播放本身需要向客户端传输媒体数据,系统可以禁止“下载任务”和管理型下载入口,但无法从协议层完全阻止外部播放器或浏览器保存已授权播放的数据流。 + +--- + +## 🔐 私有授权服务 + +MediaStationGo 已预留并接入私有独立的 `MediaStationLicenseServer`: + +- 授权服务器:`ShukeBta/MediaStationLicenseServer`,本地备份路径示例为 `C:\Users\Administrator\WorkBuddy\license_server_backup`。 +- 主项目后端提供 `/api/license/activate`、`/api/license/status`、`/api/license/heartbeat`,由服务端代理调用 License Server,不在浏览器暴露 HMAC 密钥。 +- License Server 公共接口使用 `/api/v1/activate`、`/api/v1/status/:fingerprint`、`/api/v1/heartbeat`。 +- 在「系统设置 → 授权服务」填写 `license.server_url` 与 `license.hmac_secret`,然后在「授权许可」页面绑定授权码。 +- 未绑定或授权失效时保持开源版能力;授权有效时当前实现将用户额度提升到授权版额度。 + +环境变量示例: + +```bash +MEDIASTATION_LICENSE_SERVER_URL=http://127.0.0.1:8001 +MEDIASTATION_LICENSE_HMAC_SECRET=与 License Server 的 LICENSE_HMAC_SECRET 一致 +``` + +--- + +## 🎞️ FFmpeg / ffprobe 按需运行 + +MediaStationGo 不会把 `ffmpeg` 或 `ffprobe` 作为常驻守护进程启动。它们只在以下场景被临时调用: + +- 扫描或手动探测媒体轨时调用 `ffprobe`。 +- 浏览器无法直放、需要 HLS 转码时调用 `ffmpeg`。 +- 管理后台手动检测工具状态或手动安装工具时短暂调用版本检测/安装逻辑。 + +播放停止、转码任务取消或服务退出时,后台会结束对应转码任务。空闲状态下如果没有扫描、探测或转码,`ffmpeg/ffprobe` 不应持续占用 CPU。 + +--- + ## 🔍 刮削与元数据策略 MediaStationGo 的刮削顺序尽量避免重复请求和错误覆盖: diff --git a/README_EN.md b/README_EN.md index e72442a..f5ee6e9 100644 --- a/README_EN.md +++ b/README_EN.md @@ -53,6 +53,13 @@ The current base license is `GPL-3.0`, and contributions are welcome under that > Note: GPL-3.0 is a free software license, and the formal grant is defined by the repository [LICENSE](LICENSE) file. The non-commercial commitment above expresses the maintainer's intended usage boundary and commercial cooperation requirements. For commercial cooperation, enterprise deployment, or redistribution, contact the author for additional authorization first. +### Source Availability and Docker Support Boundary + +- The public repository currently uses `GPL-3.0` as its base license. If a component is GPL-derived, distributing it only as a Docker image does not remove the corresponding source-distribution obligations. +- The project can still define its official support scope as **Docker-first / Docker-only support**: Docker Compose, GHCR images, and container deployment docs are maintained as the supported path, while bare-metal binaries can be community/best-effort. +- If some future functionality needs to be closed-source, keep it as a separate plugin, private service, or independently implemented module whose license boundary is clean. GPL-covered code should remain available under GPL terms. +- The README non-commercial statement describes the maintainer's intended usage boundary and commercial authorization requirement; the formal code license remains governed by [LICENSE](LICENSE). + --- ## 🚀 Live Demo @@ -228,7 +235,7 @@ mkdir -p data cache media downloads ```bash cat > .env <<'EOF' -MEDIASTATION_IMAGE_TAG=MediaStationGo-v0.0.11 +MEDIASTATION_IMAGE_TAG=MediaStationGo-v0.0.15 MEDIASTATION_HTTP_PORT=18080 MEDIASTATION_DATA_DIR=./data MEDIASTATION_CACHE_DIR=./cache @@ -337,7 +344,7 @@ For production, pin a specific release tag instead of using `latest`. Recommende ```bash cat > .env <<'EOF' -MEDIASTATION_IMAGE_TAG=MediaStationGo-v0.0.11 +MEDIASTATION_IMAGE_TAG=MediaStationGo-v0.0.15 MEDIASTATION_HTTP_PORT=18080 MEDIASTATION_DATA_DIR=./data MEDIASTATION_CACHE_DIR=./cache @@ -586,25 +593,25 @@ Each release provides multi-platform archives: | Platform | Package example | | --- | --- | -| Linux x86_64 | `MediaStationGo-v0.0.11-linux-amd64.tar.gz` | -| Linux ARM64 | `MediaStationGo-v0.0.11-linux-arm64.tar.gz` | -| Windows x86_64 | `MediaStationGo-v0.0.11-windows-amd64.zip` | -| macOS Intel | `MediaStationGo-v0.0.11-darwin-amd64.tar.gz` | -| macOS Apple Silicon | `MediaStationGo-v0.0.11-darwin-arm64.tar.gz` | +| Linux x86_64 | `MediaStationGo-v0.0.15-linux-amd64.tar.gz` | +| Linux ARM64 | `MediaStationGo-v0.0.15-linux-arm64.tar.gz` | +| Windows x86_64 | `MediaStationGo-v0.0.15-windows-amd64.zip` | +| macOS Intel | `MediaStationGo-v0.0.15-darwin-amd64.tar.gz` | +| macOS Apple Silicon | `MediaStationGo-v0.0.15-darwin-arm64.tar.gz` | Linux example: ```bash -tar -xzf MediaStationGo-v0.0.11-linux-amd64.tar.gz -cd MediaStationGo-v0.0.11-linux-amd64 +tar -xzf MediaStationGo-v0.0.15-linux-amd64.tar.gz +cd MediaStationGo-v0.0.15-linux-amd64 MEDIASTATION_APP_PORT=18080 ./mediastation-go ``` Windows example: ```powershell -Expand-Archive .\MediaStationGo-v0.0.11-windows-amd64.zip -cd .\MediaStationGo-v0.0.11-windows-amd64 +Expand-Archive .\MediaStationGo-v0.0.15-windows-amd64.zip +cd .\MediaStationGo-v0.0.15-windows-amd64 $env:MEDIASTATION_APP_PORT = "18080" .\mediastation-go.exe ``` @@ -722,6 +729,47 @@ Runtime settings from the admin UI: --- +## 👥 Users and Permissions + +- The default administrator is created on first startup as `admin / admin123`. This account can be renamed, but it cannot be deleted or demoted and always keeps the highest privileges. +- The open-source edition allows up to 20 users by default to reduce abuse on home NAS or public test instances. Binding a private license server can raise the quota according to the activated license policy. +- Users created from the admin panel are “viewer users” by default: they can log in through the Web UI and Emby-compatible clients, browse libraries, play media, use external players, favorite items, and keep watch history. +- Viewer users cannot scan libraries, rescrape metadata, delete media, probe media tracks, export NFO files, manage files, manage STRM links, manage download clients, create download tasks, or create/run subscriptions. +- Because playback necessarily streams media data to the client, MediaStationGo can block download-management features and torrent/download tasks, but it cannot fully prevent an authorized browser or external player from saving an already authorized stream at the protocol level. + +--- + +## 🔐 Private License Server + +MediaStationGo includes a server-side bridge for the private standalone `MediaStationLicenseServer`: + +- License server: `ShukeBta/MediaStationLicenseServer`; a local backup may live at `C:\Users\Administrator\WorkBuddy\license_server_backup`. +- MediaStationGo exposes `/api/license/activate`, `/api/license/status`, and `/api/license/heartbeat`; these backend routes proxy the License Server and do not expose the HMAC secret to browsers. +- License Server public endpoints are `/api/v1/activate`, `/api/v1/status/:fingerprint`, and `/api/v1/heartbeat`. +- Configure `license.server_url` and `license.hmac_secret` under Settings → License Server, then bind a key on the License page. +- Without a valid license, MediaStationGo stays in open-source mode. With a valid license, the current implementation raises the user quota to the licensed tier. + +Example environment variables: + +```bash +MEDIASTATION_LICENSE_SERVER_URL=http://127.0.0.1:8001 +MEDIASTATION_LICENSE_HMAC_SECRET=must-match-LICENSE_HMAC_SECRET +``` + +--- + +## 🎞️ On-Demand FFmpeg / ffprobe + +MediaStationGo does not keep `ffmpeg` or `ffprobe` running as resident daemons. They are launched only when needed: + +- `ffprobe` runs during library scanning or manual media-track probing. +- `ffmpeg` runs when browser direct play is not suitable and HLS transcoding is required. +- Admin tool-status checks or manual tool installation may briefly execute version checks/install logic. + +When playback stops, a transcode job is cancelled, or the service shuts down, the corresponding transcoding process is stopped. If there is no scanning, probing, or transcoding, `ffmpeg/ffprobe` should not continuously consume CPU. + +--- + ## 🔍 Metadata Strategy MediaStationGo avoids unnecessary repeated scraping and tries not to overwrite good local metadata: diff --git a/config.example.yaml b/config.example.yaml index 5f989d3..46dad8f 100644 --- a/config.example.yaml +++ b/config.example.yaml @@ -87,6 +87,12 @@ ai: timeout: 30 max_concurrent: 3 +license: + # Optional private MediaStationLicenseServer bridge. + # Open-source mode works without this and is limited to 20 users. + server_url: "" # e.g. http://127.0.0.1:8001 + hmac_secret: "" # must match LICENSE_HMAC_SECRET on the license server + # FlareSolverr 配置(用于绕过 Cloudflare/WAF 保护) flaresolverr: enabled: false diff --git a/docker-compose.yml b/docker-compose.yml index b90cf37..8fae300 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -17,7 +17,7 @@ # # 镜像版本: # 默认拉取 latest;如需固定版本,创建 .env 并写入: -# MEDIASTATION_IMAGE_TAG=MediaStationGo-v0.0.11 +# MEDIASTATION_IMAGE_TAG=MediaStationGo-v0.0.15 # # 路径映射总览: # /data 程序数据目录。保存 SQLite 数据库、JWT secret、系统配置等,必须持久化。 diff --git a/internal/config/config.go b/internal/config/config.go index d8d611c..1339473 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -34,6 +34,7 @@ type Config struct { FlareSolverr FlareSolverrConfig `mapstructure:"flaresolverr"` ApiConfig ApiConfigConfig `mapstructure:"api_config"` Organizer OrganizerConfig `mapstructure:"organizer"` + License LicenseConfig `mapstructure:"license"` } // ApiConfigConfig API 配置相关设置。 @@ -131,6 +132,12 @@ type AIConfig struct { MaxConcurrent int `mapstructure:"max_concurrent"` } +// LicenseConfig configures the optional MediaStationLicenseServer bridge. +type LicenseConfig struct { + ServerURL string `mapstructure:"server_url"` + HMACSecret string `mapstructure:"hmac_secret"` +} + // OrganizerConfig 配置媒体文件智能分类整理。 type OrganizerConfig struct { SmartClassify bool `mapstructure:"smart_classify"` @@ -262,6 +269,9 @@ func setDefaults(v *viper.Viper) { // API Config 默认设置 v.SetDefault("api_config.auto_encrypt", true) v.SetDefault("api_config.default_timeout", 30) + + v.SetDefault("license.server_url", "") + v.SetDefault("license.hmac_secret", "") } // normalize 填充派生默认值并自愈空的关键字段。 diff --git a/internal/handler/admin.go b/internal/handler/admin.go index 653da90..fdbd0ae 100644 --- a/internal/handler/admin.go +++ b/internal/handler/admin.go @@ -2,10 +2,14 @@ package handler import ( + "context" + "errors" "net/http" + "strings" "github.com/gin-gonic/gin" + "github.com/ShukeBta/MediaStationGo/internal/model" "github.com/ShukeBta/MediaStationGo/internal/service" ) @@ -16,12 +20,110 @@ func listUsersHandler(svc *service.Container) gin.HandlerFunc { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } + if err := annotateProtectedUsers(c.Request.Context(), svc, users); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } c.JSON(http.StatusOK, users) } } +type adminCreateUserReq struct { + Username string `json:"username" binding:"required"` + Password string `json:"password" binding:"required"` +} + +func createUserHandler(svc *service.Container) gin.HandlerFunc { + return func(c *gin.Context) { + var req adminCreateUserReq + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + u, _, err := svc.Auth.Register(c.Request.Context(), req.Username, req.Password) + if err != nil { + writeUserMutationError(c, err) + return + } + // Admin-created users are intentionally normal viewers by default. + // They can log in from Web/Emby-compatible clients and play media, but + // cannot scrape, scan, download, delete, export NFO, or manage files. + if u.Role != "user" { + u, err = svc.Profile.AdminUpdateRole(c.Request.Context(), u.ID, "user") + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + } + c.JSON(http.StatusCreated, u) + } +} + +type adminUpdateUserReq struct { + Username string `json:"username" binding:"required"` +} + +func updateUserHandler(svc *service.Container) gin.HandlerFunc { + return func(c *gin.Context) { + var req adminUpdateUserReq + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + nextUsername := strings.TrimSpace(req.Username) + if nextUsername == "" { + c.JSON(http.StatusBadRequest, gin.H{"error": "username required"}) + return + } + userID := c.Param("id") + user, err := svc.Repo.User.FindByID(c.Request.Context(), userID) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + if user == nil { + c.JSON(http.StatusNotFound, gin.H{"error": "user not found"}) + return + } + if existing, err := svc.Repo.User.FindByUsername(c.Request.Context(), nextUsername); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } else if existing != nil && existing.ID != userID { + writeUserMutationError(c, service.ErrUsernameTaken) + return + } + updates := map[string]any{"username": nextUsername} + if firstAdmin, err := svc.Repo.User.FirstAdmin(c.Request.Context()); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } else if firstAdmin != nil && firstAdmin.ID == userID { + updates["role"] = "admin" + updates["tier"] = "plus" + } + if err := svc.Repo.User.UpdateFields(c.Request.Context(), userID, updates); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + updated, err := svc.Repo.User.FindByID(c.Request.Context(), userID) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + c.JSON(http.StatusOK, updated) + } +} + func deleteUserHandler(svc *service.Container) gin.HandlerFunc { return func(c *gin.Context) { + firstAdmin, err := svc.Repo.User.FirstAdmin(c.Request.Context()) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + if firstAdmin != nil && firstAdmin.ID == c.Param("id") { + c.JSON(http.StatusForbidden, gin.H{"error": "default admin cannot be deleted"}) + return + } if err := svc.Repo.User.Delete(c.Request.Context(), c.Param("id")); err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return @@ -30,6 +132,33 @@ func deleteUserHandler(svc *service.Container) gin.HandlerFunc { } } +func annotateProtectedUsers(ctx context.Context, svc *service.Container, users []model.User) error { + firstAdmin, err := svc.Repo.User.FirstAdmin(ctx) + if err != nil || firstAdmin == nil { + return err + } + for i := range users { + if users[i].ID == firstAdmin.ID { + users[i].IsDefaultAdmin = true + users[i].IsProtected = true + users[i].Role = "admin" + users[i].Tier = "plus" + } + } + return nil +} + +func writeUserMutationError(c *gin.Context, err error) { + switch { + case errors.Is(err, service.ErrUsernameTaken): + c.JSON(http.StatusConflict, gin.H{"error": "username already taken"}) + case errors.Is(err, service.ErrUserLimitReached): + c.JSON(http.StatusBadRequest, gin.H{"error": "user limit reached: maximum 20 users"}) + default: + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + } +} + type settingReq struct { Key string `json:"key" binding:"required"` Value string `json:"value"` diff --git a/internal/handler/ai.go b/internal/handler/ai.go index 6db7659..1f1bbb7 100644 --- a/internal/handler/ai.go +++ b/internal/handler/ai.go @@ -39,6 +39,7 @@ func smartSearchHandler(svc *service.Container) gin.HandlerFunc { svc.Douban, svc.Bangumi, ) + service.EnrichExternalMediaAvailability(c.Request.Context(), svc.Repo, external) c.JSON(http.StatusOK, gin.H{ "intent": intent, "items": items, diff --git a/internal/handler/handler.go b/internal/handler/handler.go index b21b073..7c6aece 100644 --- a/internal/handler/handler.go +++ b/internal/handler/handler.go @@ -47,6 +47,11 @@ func Register(r *gin.Engine, cfg *config.Config, log *zap.Logger, svc *service.C // Permissions. authed.GET("/auth/permissions", getMyPermissionsHandler(svc)) + // License activation bridge (admin only; talks to MediaStationLicenseServer). + authed.GET("/license/status", middleware.AdminRequired(), licenseStatusHandler(svc)) + authed.POST("/license/activate", middleware.AdminRequired(), licenseActivateHandler(svc)) + authed.POST("/license/heartbeat", middleware.AdminRequired(), licenseHeartbeatHandler(svc)) + // Libraries. authed.GET("/libraries", listLibrariesHandler(svc)) authed.POST("/libraries", middleware.AdminRequired(), createLibraryHandler(svc)) @@ -95,35 +100,35 @@ func Register(r *gin.Engine, cfg *config.Config, log *zap.Logger, svc *service.C authed.DELETE("/playlists/:id", deletePlaylistHandler(svc)) // Downloads. - authed.GET("/downloads", listDownloadsHandler(svc)) - authed.POST("/downloads", addDownloadHandler(svc)) - authed.DELETE("/downloads/:hash", middleware.AdminRequired(), deleteDownloadHandler(svc)) - authed.POST("/downloads/reload", middleware.AdminRequired(), reloadDownloadConfigHandler(svc)) + authed.GET("/downloads", requirePermission(svc, "can_manage_downloads"), listDownloadsHandler(svc)) + authed.POST("/downloads", requirePermission(svc, "can_manage_downloads"), addDownloadHandler(svc)) + authed.DELETE("/downloads/:hash", requirePermission(svc, "can_manage_downloads"), deleteDownloadHandler(svc)) + authed.POST("/downloads/reload", requirePermission(svc, "can_manage_downloads"), reloadDownloadConfigHandler(svc)) // Subscriptions. - authed.GET("/subscriptions", listSubscriptionsHandler(svc)) - authed.POST("/subscriptions", createSubscriptionHandler(svc)) - authed.DELETE("/subscriptions/:id", deleteSubscriptionHandler(svc)) - authed.POST("/subscriptions/:id/run", runSubscriptionHandler(svc)) + authed.GET("/subscriptions", requirePermission(svc, "can_manage_subscriptions"), listSubscriptionsHandler(svc)) + authed.POST("/subscriptions", requirePermission(svc, "can_manage_subscriptions"), createSubscriptionHandler(svc)) + authed.DELETE("/subscriptions/:id", requirePermission(svc, "can_manage_subscriptions"), deleteSubscriptionHandler(svc)) + authed.POST("/subscriptions/:id/run", requirePermission(svc, "can_manage_subscriptions"), runSubscriptionHandler(svc)) // Stats / dashboard. authed.GET("/stats", statsHandler(svc)) - authed.GET("/tasks", tasksHandler(svc)) + authed.GET("/tasks", middleware.AdminRequired(), tasksHandler(svc)) // Discover (TMDb trending / popular). - authed.GET("/discover/trending", trendingHandler(svc)) - authed.GET("/discover/popular", popularHandler(svc)) + authed.GET("/discover/trending", requirePermission(svc, "can_view_discover"), trendingHandler(svc)) + authed.GET("/discover/popular", requirePermission(svc, "can_view_discover"), popularHandler(svc)) // AI. - authed.GET("/ai/status", aiStatusHandler(svc)) - authed.POST("/ai/search", smartSearchHandler(svc)) - authed.GET("/ai/recommend", aiRecommendHandler(svc)) + authed.GET("/ai/status", requirePermission(svc, "can_use_ai"), aiStatusHandler(svc)) + authed.POST("/ai/search", requirePermission(svc, "can_use_ai"), smartSearchHandler(svc)) + authed.GET("/ai/recommend", requirePermission(svc, "can_use_ai"), aiRecommendHandler(svc)) // File browser (used by the library-path picker). - authed.GET("/files", browseFilesHandler(svc)) + authed.GET("/files", middleware.AdminRequired(), browseFilesHandler(svc)) // Disk usage breakdown. - authed.GET("/storage", storageHandler(svc)) + authed.GET("/storage", middleware.AdminRequired(), storageHandler(svc)) // DLNA discovery + cast. authed.GET("/dlna/devices", dlnaListHandler(svc)) @@ -141,15 +146,15 @@ func Register(r *gin.Engine, cfg *config.Config, log *zap.Logger, svc *service.C // Site management + cross-site torrent search (via SiteHandler). siteHandler := NewSiteHandler(svc) - authed.GET("/sites", siteHandler.ListSites) - authed.GET("/sites/types", siteHandler.GetSiteTypes) - authed.GET("/sites/auth-types", siteHandler.GetAuthTypes) - authed.POST("/sites", middleware.AdminRequired(), siteHandler.CreateSite) - authed.GET("/sites/:id", siteHandler.GetSite) - authed.PUT("/sites/:id", middleware.AdminRequired(), siteHandler.UpdateSite) - authed.DELETE("/sites/:id", middleware.AdminRequired(), siteHandler.DeleteSite) - authed.POST("/sites/:id/test", middleware.AdminRequired(), siteHandler.TestSite) - authed.GET("/sites/search", siteSearchHandler(svc)) + authed.GET("/sites", requirePermission(svc, "can_manage_sites"), siteHandler.ListSites) + authed.GET("/sites/types", requirePermission(svc, "can_manage_sites"), siteHandler.GetSiteTypes) + authed.GET("/sites/auth-types", requirePermission(svc, "can_manage_sites"), siteHandler.GetAuthTypes) + authed.POST("/sites", requirePermission(svc, "can_manage_sites"), siteHandler.CreateSite) + authed.GET("/sites/:id", requirePermission(svc, "can_manage_sites"), siteHandler.GetSite) + authed.PUT("/sites/:id", requirePermission(svc, "can_manage_sites"), siteHandler.UpdateSite) + authed.DELETE("/sites/:id", requirePermission(svc, "can_manage_sites"), siteHandler.DeleteSite) + authed.POST("/sites/:id/test", requirePermission(svc, "can_manage_sites"), siteHandler.TestSite) + authed.GET("/sites/search", requirePermission(svc, "can_manage_sites"), siteSearchHandler(svc)) // Recycle bin. authed.GET("/recycle", middleware.AdminRequired(), listRecycleHandler(svc)) @@ -176,8 +181,8 @@ func Register(r *gin.Engine, cfg *config.Config, log *zap.Logger, svc *service.C authed.DELETE("/watch-history/:id", historyDeleteOneHandler(svc)) // Multi-section TMDb feed used by DiscoverPage. - authed.GET("/discover/sections", discoverSectionsHandler(svc)) - authed.GET("/discover/feed", discoverFeedHandler(svc)) + authed.GET("/discover/sections", requirePermission(svc, "can_view_discover"), discoverSectionsHandler(svc)) + authed.GET("/discover/feed", requirePermission(svc, "can_view_discover"), discoverFeedHandler(svc)) // System metadata + read-only scheduler view. authed.GET("/system/info", systemInfoHandler(svc)) @@ -214,12 +219,12 @@ func Register(r *gin.Engine, cfg *config.Config, log *zap.Logger, svc *service.C authed.POST("/stats/play", statsPlayHandler(svc)) // ── Sites extras ── - authed.GET("/sites/:id/resource", siteResourceHandler(svc)) - authed.GET("/sites/:id/userdata", siteUserdataHandler(svc)) + authed.GET("/sites/:id/resource", requirePermission(svc, "can_manage_sites"), siteResourceHandler(svc)) + authed.GET("/sites/:id/userdata", requirePermission(svc, "can_manage_sites"), siteUserdataHandler(svc)) // ── Subscription extras ── - authed.PUT("/subscriptions/:id", updateSubscriptionHandler(svc)) - authed.POST("/subscriptions/:id/search", searchSubscriptionHandler(svc)) + authed.PUT("/subscriptions/:id", requirePermission(svc, "can_manage_subscriptions"), updateSubscriptionHandler(svc)) + authed.POST("/subscriptions/:id/search", requirePermission(svc, "can_manage_subscriptions"), searchSubscriptionHandler(svc)) // ── Playlist extras ── authed.POST("/playlists/:id/reorder", reorderPlaylistHandler(svc)) @@ -236,9 +241,9 @@ func Register(r *gin.Engine, cfg *config.Config, log *zap.Logger, svc *service.C authed.POST("/media/:id/favorite", addMediaFavoriteHandler(svc)) authed.DELETE("/media/:id/favorite", removeMediaFavoriteHandler(svc)) authed.GET("/media/:id/favorite/status", getMediaFavoriteStatusHandler(svc)) - authed.POST("/media/:id/ai-scrape", aiScrapeMediaHandler(svc)) - authed.POST("/media/scrape/test", scrapeTestHandler(svc)) - authed.POST("/media/organize", middleware.AdminRequired(), organizeBulkHandler(svc)) + authed.POST("/media/:id/ai-scrape", requirePermission(svc, "can_rescrape"), aiScrapeMediaHandler(svc)) + authed.POST("/media/scrape/test", requirePermission(svc, "can_rescrape"), scrapeTestHandler(svc)) + authed.POST("/media/organize", requirePermission(svc, "can_manage_files"), organizeBulkHandler(svc)) // ── Playback metadata + external player handoff ── authed.GET("/playback/:id/info", playbackInfoHandler(svc)) @@ -248,13 +253,13 @@ func Register(r *gin.Engine, cfg *config.Config, log *zap.Logger, svc *service.C authed.GET("/playback/transcode/:job_id/status", transcodeStatusHandler(svc)) // ── Download task ops + sync triggers ── - authed.POST("/download/:id/pause", downloadPauseHandler(svc)) - authed.POST("/download/:id/resume", downloadResumeHandler(svc)) - authed.POST("/download/:id/organize", middleware.AdminRequired(), downloadOrganizeOneHandler(svc)) - authed.POST("/download/organize", middleware.AdminRequired(), downloadOrganizeAllHandler(svc)) - authed.POST("/download/sync", middleware.AdminRequired(), downloadSyncHandler(svc)) - authed.POST("/download/start-auto-sync", middleware.AdminRequired(), downloadAutoSyncHandler(svc)) - authed.GET("/download/tasks", downloadTasksAliasHandler(svc)) + authed.POST("/download/:id/pause", requirePermission(svc, "can_manage_downloads"), downloadPauseHandler(svc)) + authed.POST("/download/:id/resume", requirePermission(svc, "can_manage_downloads"), downloadResumeHandler(svc)) + authed.POST("/download/:id/organize", requirePermission(svc, "can_manage_files"), downloadOrganizeOneHandler(svc)) + authed.POST("/download/organize", requirePermission(svc, "can_manage_files"), downloadOrganizeAllHandler(svc)) + authed.POST("/download/sync", requirePermission(svc, "can_manage_downloads"), downloadSyncHandler(svc)) + authed.POST("/download/start-auto-sync", requirePermission(svc, "can_manage_downloads"), downloadAutoSyncHandler(svc)) + authed.GET("/download/tasks", requirePermission(svc, "can_manage_downloads"), downloadTasksAliasHandler(svc)) // ── Assistant (multi-turn AI chat) ── authed.GET("/admin/assistant/sessions", listAssistantSessionsHandler(svc)) @@ -272,6 +277,8 @@ func Register(r *gin.Engine, cfg *config.Config, log *zap.Logger, svc *service.C admin.Use(middleware.AuthRequired(cfg.Secrets.JWTSecret), middleware.AdminRequired()) { admin.GET("/users", listUsersHandler(svc)) + admin.POST("/users", createUserHandler(svc)) + admin.PATCH("/users/:id", updateUserHandler(svc)) admin.PATCH("/users/:id/role", adminUpdateRoleHandler(svc)) admin.DELETE("/users/:id", deleteUserHandler(svc)) admin.GET("/settings", listSettingsHandler(svc)) diff --git a/internal/handler/license.go b/internal/handler/license.go new file mode 100644 index 0000000..e78eb56 --- /dev/null +++ b/internal/handler/license.go @@ -0,0 +1,394 @@ +package handler + +import ( + "bytes" + "context" + "crypto/hmac" + "crypto/rand" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "net/url" + "os" + "strings" + "time" + + "github.com/gin-gonic/gin" + + "github.com/ShukeBta/MediaStationGo/internal/service" +) + +const ( + licenseServerURLSetting = "license.server_url" + licenseHMACSecretSetting = "license.hmac_secret" + licenseDeviceIDSetting = "license.device_id" + licenseDeviceNameSetting = "license.device_name" +) + +type licenseActivateReq struct { + Key string `json:"key" binding:"required"` + DeviceID string `json:"device_id"` + DeviceName string `json:"device_name"` +} + +type licenseServerSignedResp struct { + Valid bool `json:"valid"` + LicenseType string `json:"license_type"` + ExpiryDate *string `json:"expiry_date"` + MaxDevices int `json:"max_devices"` + DaysRemaining *int `json:"days_remaining"` + NextHeartbeat string `json:"next_heartbeat"` + Signature string `json:"signature"` +} + +type licenseServerStatusResp struct { + Valid bool `json:"valid"` + LicenseType *string `json:"license_type"` + ExpiryDate *string `json:"expiry_date"` + DaysRemaining *int `json:"days_remaining"` + DeviceName string `json:"device_name"` + IsActive bool `json:"is_active"` +} + +func licenseActivateHandler(svc *service.Container) gin.HandlerFunc { + return func(c *gin.Context) { + var req licenseActivateReq + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + client, err := newLicenseClient(c.Request.Context(), svc) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + deviceID, err := ensureLicenseDeviceID(c.Request.Context(), svc, req.DeviceID) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + deviceName := strings.TrimSpace(req.DeviceName) + if deviceName == "" { + deviceName = defaultLicenseDeviceName() + } + _ = svc.Repo.Setting.Set(c.Request.Context(), licenseDeviceNameSetting, deviceName) + + payload := map[string]any{ + "key": strings.TrimSpace(req.Key), + "fingerprint": deviceID, + "device_name": deviceName, + "instance_id": deviceID, + } + var upstream licenseServerSignedResp + if err := client.post(c.Request.Context(), "/api/v1/activate", payload, &upstream); err != nil { + c.JSON(http.StatusBadGateway, gin.H{"error": err.Error()}) + return + } + if err := client.verifySigned(upstream); err != nil { + c.JSON(http.StatusBadGateway, gin.H{"error": err.Error()}) + return + } + state := licenseStateFromSigned(upstream, deviceID, deviceName) + if err := persistLicenseState(c.Request.Context(), svc, state); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + c.JSON(http.StatusOK, licenseActivationView(state)) + } +} + +func licenseStatusHandler(svc *service.Container) gin.HandlerFunc { + return func(c *gin.Context) { + state, _ := loadLicenseState(c.Request.Context(), svc) + client, err := newLicenseClient(c.Request.Context(), svc) + if err == nil { + deviceID, idErr := ensureLicenseDeviceID(c.Request.Context(), svc, "") + if idErr == nil { + var upstream licenseServerStatusResp + if getErr := client.get(c.Request.Context(), "/api/v1/status/"+url.PathEscape(deviceID), &upstream); getErr == nil && upstream.Valid { + state.Valid = upstream.Valid + if upstream.LicenseType != nil { + state.LicenseType = *upstream.LicenseType + } + if upstream.ExpiryDate != nil { + state.ExpiryDate = *upstream.ExpiryDate + } + state.DaysRemaining = upstream.DaysRemaining + if upstream.DeviceName != "" { + state.DeviceName = upstream.DeviceName + } + state.DeviceID = deviceID + state.UpdatedAt = time.Now().Format(time.RFC3339) + _ = persistLicenseState(c.Request.Context(), svc, state) + } else if getErr == nil && !upstream.Valid { + state.Valid = false + _ = persistLicenseState(c.Request.Context(), svc, state) + } + } + } + active := state.Valid && !licenseStateExpired(state.ExpiryDate) + c.JSON(http.StatusOK, gin.H{ + "active": active, + "message": licenseStatusMessage(active, err), + "max_users": service.LicensedMaxUsers(c.Request.Context(), svc.Repo), + "activation": licenseActivationView(state), + }) + } +} + +func licenseHeartbeatHandler(svc *service.Container) gin.HandlerFunc { + return func(c *gin.Context) { + client, err := newLicenseClient(c.Request.Context(), svc) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + deviceID, err := ensureLicenseDeviceID(c.Request.Context(), svc, "") + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + var upstream licenseServerSignedResp + if err := client.post(c.Request.Context(), "/api/v1/heartbeat", map[string]any{ + "fingerprint": deviceID, + "instance_id": deviceID, + }, &upstream); err != nil { + c.JSON(http.StatusBadGateway, gin.H{"error": err.Error()}) + return + } + if err := client.verifySigned(upstream); err != nil { + c.JSON(http.StatusBadGateway, gin.H{"error": err.Error()}) + return + } + deviceName, _ := svc.Repo.Setting.Get(c.Request.Context(), licenseDeviceNameSetting) + state := licenseStateFromSigned(upstream, deviceID, deviceName) + if err := persistLicenseState(c.Request.Context(), svc, state); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + c.JSON(http.StatusOK, licenseActivationView(state)) + } +} + +type licenseClient struct { + baseURL string + hmacSecret string + httpClient *http.Client +} + +func newLicenseClient(ctx context.Context, svc *service.Container) (*licenseClient, error) { + baseURL, _ := svc.Repo.Setting.Get(ctx, licenseServerURLSetting) + if strings.TrimSpace(baseURL) == "" { + baseURL = svc.Cfg.License.ServerURL + } + secret, _ := svc.Repo.Setting.Get(ctx, licenseHMACSecretSetting) + if strings.TrimSpace(secret) == "" { + secret = svc.Cfg.License.HMACSecret + } + baseURL = strings.TrimRight(strings.TrimSpace(baseURL), "/") + if baseURL == "" { + return nil, errors.New("license server url not configured") + } + return &licenseClient{ + baseURL: baseURL, + hmacSecret: strings.TrimSpace(secret), + httpClient: &http.Client{Timeout: 15 * time.Second}, + }, nil +} + +func (c *licenseClient) post(ctx context.Context, path string, payload any, out any) error { + body, err := json.Marshal(payload) + if err != nil { + return err + } + req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.baseURL+path, bytes.NewReader(body)) + if err != nil { + return err + } + req.Header.Set("Content-Type", "application/json") + return c.do(req, out) +} + +func (c *licenseClient) get(ctx context.Context, path string, out any) error { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.baseURL+path, nil) + if err != nil { + return err + } + return c.do(req, out) +} + +func (c *licenseClient) do(req *http.Request, out any) error { + resp, err := c.httpClient.Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + data, err := io.ReadAll(resp.Body) + if err != nil { + return err + } + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + var er struct { + Error string `json:"error"` + Message string `json:"message"` + } + _ = json.Unmarshal(data, &er) + if er.Message != "" { + return fmt.Errorf("license server: %s", er.Message) + } + if er.Error != "" { + return fmt.Errorf("license server: %s", er.Error) + } + return fmt.Errorf("license server http %d", resp.StatusCode) + } + return json.Unmarshal(data, out) +} + +func (c *licenseClient) verifySigned(resp licenseServerSignedResp) error { + if c.hmacSecret == "" { + return nil + } + unsigned := struct { + Valid bool `json:"valid"` + LicenseType string `json:"license_type"` + ExpiryDate *string `json:"expiry_date"` + MaxDevices int `json:"max_devices"` + DaysRemaining *int `json:"days_remaining"` + NextHeartbeat string `json:"next_heartbeat"` + }{ + Valid: resp.Valid, + LicenseType: resp.LicenseType, + ExpiryDate: resp.ExpiryDate, + MaxDevices: resp.MaxDevices, + DaysRemaining: resp.DaysRemaining, + NextHeartbeat: resp.NextHeartbeat, + } + payload, err := json.Marshal(unsigned) + if err != nil { + return err + } + mac := hmac.New(sha256.New, []byte(c.hmacSecret)) + _, _ = mac.Write(payload) + expected := hex.EncodeToString(mac.Sum(nil)) + if !hmac.Equal([]byte(expected), []byte(resp.Signature)) { + return errors.New("license server signature verification failed") + } + return nil +} + +func ensureLicenseDeviceID(ctx context.Context, svc *service.Container, candidate string) (string, error) { + if strings.TrimSpace(candidate) != "" { + return strings.TrimSpace(candidate), svc.Repo.Setting.Set(ctx, licenseDeviceIDSetting, strings.TrimSpace(candidate)) + } + existing, err := svc.Repo.Setting.Get(ctx, licenseDeviceIDSetting) + if err != nil { + return "", err + } + if strings.TrimSpace(existing) != "" { + return strings.TrimSpace(existing), nil + } + var buf [16]byte + if _, err := rand.Read(buf[:]); err != nil { + return "", err + } + id := "msgo-" + hex.EncodeToString(buf[:]) + return id, svc.Repo.Setting.Set(ctx, licenseDeviceIDSetting, id) +} + +func defaultLicenseDeviceName() string { + host, _ := os.Hostname() + if strings.TrimSpace(host) == "" { + return "MediaStationGo Server" + } + return "MediaStationGo - " + host +} + +func licenseStateFromSigned(resp licenseServerSignedResp, deviceID, deviceName string) service.LicenseActivationState { + expiry := "" + if resp.ExpiryDate != nil { + expiry = *resp.ExpiryDate + } + return service.LicenseActivationState{ + Valid: resp.Valid, + LicenseType: resp.LicenseType, + ExpiryDate: expiry, + MaxDevices: resp.MaxDevices, + DaysRemaining: resp.DaysRemaining, + NextHeartbeat: resp.NextHeartbeat, + DeviceID: deviceID, + DeviceName: deviceName, + UpdatedAt: time.Now().Format(time.RFC3339), + } +} + +func persistLicenseState(ctx context.Context, svc *service.Container, state service.LicenseActivationState) error { + data, err := json.Marshal(state) + if err != nil { + return err + } + return svc.Repo.Setting.Set(ctx, service.LicenseSettingActivation, string(data)) +} + +func loadLicenseState(ctx context.Context, svc *service.Container) (service.LicenseActivationState, error) { + raw, err := svc.Repo.Setting.Get(ctx, service.LicenseSettingActivation) + if err != nil || raw == "" { + return service.LicenseActivationState{}, err + } + var state service.LicenseActivationState + if err := json.Unmarshal([]byte(raw), &state); err != nil { + return service.LicenseActivationState{}, err + } + return state, nil +} + +func licenseActivationView(state service.LicenseActivationState) gin.H { + updatedAt := state.UpdatedAt + if strings.TrimSpace(updatedAt) == "" { + updatedAt = time.Now().Format(time.RFC3339) + } + return gin.H{ + "id": state.DeviceID, + "key_id": state.LicenseType, + "device_id": state.DeviceID, + "device_name": state.DeviceName, + "plan": state.LicenseType, + "max_activations": state.MaxDevices, + "expires_at": emptyAsNil(state.ExpiryDate), + "valid": state.Valid && !licenseStateExpired(state.ExpiryDate), + "heartbeat_at": updatedAt, + "created_at": updatedAt, + } +} + +func licenseStatusMessage(active bool, clientErr error) string { + if active { + return "已激活" + } + if clientErr != nil && !strings.Contains(clientErr.Error(), "not configured") { + return clientErr.Error() + } + return "开源版:最多 20 个用户" +} + +func licenseStateExpired(expiry string) bool { + if expiry == "" { + return false + } + for _, layout := range []string{time.RFC3339, "2006-01-02 15:04:05", "2006-01-02"} { + if t, err := time.Parse(layout, expiry); err == nil { + return time.Now().After(t) + } + } + return false +} + +func emptyAsNil(v string) any { + if strings.TrimSpace(v) == "" { + return nil + } + return v +} diff --git a/internal/handler/permissions.go b/internal/handler/permissions.go index 389509e..a2e1e55 100644 --- a/internal/handler/permissions.go +++ b/internal/handler/permissions.go @@ -16,6 +16,32 @@ import ( "github.com/ShukeBta/MediaStationGo/internal/service" ) +func requirePermission(svc *service.Container, key string) gin.HandlerFunc { + return func(c *gin.Context) { + role, _ := c.Get(middleware.CtxUserRole) + if role == "admin" { + c.Next() + return + } + uid, _ := c.Get(middleware.CtxUserID) + userID, _ := uid.(string) + if userID == "" { + c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "authentication required"}) + return + } + row, err := svc.Permissions.Effective(c.Request.Context(), userID) + if err != nil { + c.AbortWithStatusJSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + if row == nil || !row.PermissionMap()[key] { + c.AbortWithStatusJSON(http.StatusForbidden, gin.H{"error": "permission denied"}) + return + } + c.Next() + } +} + func myPermissionsHandler(svc *service.Container) gin.HandlerFunc { return func(c *gin.Context) { uid, _ := c.Get(middleware.CtxUserID) diff --git a/internal/handler/playback_extra.go b/internal/handler/playback_extra.go index ca7d8c3..604da4c 100644 --- a/internal/handler/playback_extra.go +++ b/internal/handler/playback_extra.go @@ -9,6 +9,8 @@ package handler import ( "net/http" + "net/url" + "strings" "github.com/gin-gonic/gin" @@ -27,9 +29,9 @@ func playbackInfoHandler(svc *service.Container) gin.HandlerFunc { return } c.JSON(http.StatusOK, gin.H{ - "media": m, - "stream_url": "/api/stream/" + m.ID, - "hls_url": "/api/hls/" + m.ID + "/index.m3u8", + "media": m, + "stream_url": "/api/stream/" + m.ID, + "hls_url": "/api/hls/" + m.ID + "/index.m3u8", }) } } @@ -69,13 +71,16 @@ func externalPlayersHandler(svc *service.Container) gin.HandlerFunc { c.JSON(http.StatusNotFound, gin.H{"error": "media not found"}) return } - streamURL := "/api/stream/" + m.ID + token := externalPlaybackToken(c, svc) + streamURL := absoluteRequestURL(c, "/api/stream/"+m.ID+"?token="+url.QueryEscape(token)) + escapedStream := url.QueryEscape(streamURL) c.JSON(http.StatusOK, gin.H{ + "url": streamURL, "players": []gin.H{ {"name": "VLC", "scheme": "vlc://", "url": "vlc://" + streamURL}, {"name": "PotPlayer", "scheme": "potplayer://", "url": "potplayer://" + streamURL}, {"name": "MX Player", "scheme": "intent://", "url": "intent://" + streamURL + "#Intent;package=com.mxtech.videoplayer.ad;end"}, - {"name": "IINA", "scheme": "iina://", "url": "iina://weblink?url=" + streamURL}, + {"name": "IINA", "scheme": "iina://", "url": "iina://weblink?url=" + escapedStream}, {"name": "nPlayer", "scheme": "nplayer-", "url": "nplayer-" + streamURL}, }, }) @@ -91,25 +96,48 @@ func externalURLHandler(svc *service.Container) gin.HandlerFunc { c.JSON(http.StatusNotFound, gin.H{"error": "media not found"}) return } - // Re-issue a short-lived token for this stream. - uid, _ := c.Get(middleware.CtxUserID) - u, err := svc.Repo.User.FindByID(c.Request.Context(), toString(uid)) - if err != nil || u == nil { - c.JSON(http.StatusUnauthorized, gin.H{"error": "user not found"}) - return - } - token, err := svc.Auth.IssueToken(u) - if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) - return - } + token := externalPlaybackToken(c, svc) c.JSON(http.StatusOK, gin.H{ - "url": "/api/stream/" + m.ID + "?token=" + token, + "url": absoluteRequestURL(c, "/api/stream/"+m.ID+"?token="+url.QueryEscape(token)), "token": token, }) } } +func externalPlaybackToken(c *gin.Context, svc *service.Container) string { + uid, _ := c.Get(middleware.CtxUserID) + u, err := svc.Repo.User.FindByID(c.Request.Context(), toString(uid)) + if err != nil || u == nil { + return "" + } + token, err := svc.Auth.IssueToken(u) + if err != nil { + return "" + } + return token +} + +func absoluteRequestURL(c *gin.Context, path string) string { + if strings.HasPrefix(path, "http://") || strings.HasPrefix(path, "https://") { + return path + } + scheme := strings.TrimSpace(c.GetHeader("X-Forwarded-Proto")) + if scheme == "" { + scheme = "http" + if c.Request.TLS != nil { + scheme = "https" + } + } + host := strings.TrimSpace(c.GetHeader("X-Forwarded-Host")) + if host == "" { + host = c.Request.Host + } + if !strings.HasPrefix(path, "/") { + path = "/" + path + } + return scheme + "://" + host + path +} + // transcodeStatusHandler reports the live status of one transcode job. // We surface the active jobs the transcoder knows about. func transcodeStatusHandler(svc *service.Container) gin.HandlerFunc { diff --git a/internal/handler/subscription_extra.go b/internal/handler/subscription_extra.go index d19afb7..18902f0 100644 --- a/internal/handler/subscription_extra.go +++ b/internal/handler/subscription_extra.go @@ -30,6 +30,7 @@ type subscriptionPatchReq struct { ExcludeWords *string `json:"exclude_words"` WashEnabled *bool `json:"wash_enabled"` WashPriority *string `json:"wash_priority"` + TotalEpisodes *int `json:"total_episodes"` Priority *int `json:"priority"` Enabled *bool `json:"enabled"` } @@ -117,6 +118,9 @@ func subscriptionPatchUpdates(patch subscriptionPatchReq) map[string]any { if patch.WashPriority != nil { updates["wash_priority"] = *patch.WashPriority } + if patch.TotalEpisodes != nil { + updates["total_episodes"] = *patch.TotalEpisodes + } if patch.Priority != nil { updates["priority"] = *patch.Priority } diff --git a/internal/handler/subscriptions.go b/internal/handler/subscriptions.go index e1fd539..7356877 100644 --- a/internal/handler/subscriptions.go +++ b/internal/handler/subscriptions.go @@ -31,6 +31,7 @@ type subscriptionReq struct { ExcludeWords string `json:"exclude_words"` WashEnabled bool `json:"wash_enabled"` WashPriority string `json:"wash_priority"` + TotalEpisodes int `json:"total_episodes"` Priority int `json:"priority"` Enabled *bool `json:"enabled"` } @@ -68,6 +69,7 @@ func createSubscriptionHandler(svc *service.Container) gin.HandlerFunc { ExcludeWords: req.ExcludeWords, WashEnabled: req.WashEnabled, WashPriority: req.WashPriority, + TotalEpisodes: req.TotalEpisodes, Priority: req.Priority, Enabled: enabled, } @@ -76,6 +78,9 @@ func createSubscriptionHandler(svc *service.Container) gin.HandlerFunc { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } + enriched := []model.Subscription{*s} + service.EnrichSubscriptionProgress(c.Request.Context(), svc.Repo, enriched) + *s = enriched[0] c.JSON(http.StatusOK, s) } } @@ -88,6 +93,7 @@ func listSubscriptionsHandler(svc *service.Container) gin.HandlerFunc { return } enrichAndPersistSubscriptions(c.Request.Context(), svc, items) + service.EnrichSubscriptionProgress(c.Request.Context(), svc.Repo, items) c.JSON(http.StatusOK, gin.H{"items": items}) } } diff --git a/internal/handler/system_extra.go b/internal/handler/system_extra.go index 07ac743..7ebe497 100644 --- a/internal/handler/system_extra.go +++ b/internal/handler/system_extra.go @@ -103,6 +103,14 @@ func schemaHandler(_ *service.Container) gin.HandlerFunc { {"key": "qbittorrent.savepath", "type": "text"}, }, }, + { + "key": "license", + "label": "授权服务", + "items": []gin.H{ + {"key": "license.server_url", "type": "text", "label": "License Server 地址"}, + {"key": "license.hmac_secret", "type": "text", "label": "HMAC 签名密钥"}, + }, + }, }, }) } diff --git a/internal/model/model.go b/internal/model/model.go index 773549a..512da4c 100644 --- a/internal/model/model.go +++ b/internal/model/model.go @@ -43,6 +43,8 @@ type User struct { ForcePasswordReset bool `gorm:"default:false" json:"force_password_reset"` IsActive bool `gorm:"default:true" json:"is_active"` LastLoginAt *time.Time `json:"last_login_at,omitempty"` + IsDefaultAdmin bool `gorm:"-" json:"is_default_admin,omitempty"` + IsProtected bool `gorm:"-" json:"is_protected,omitempty"` } // Library 表示用户定义的媒体根目录。 @@ -206,9 +208,15 @@ type Subscription struct { ExcludeWords string `gorm:"size:255" json:"exclude_words,omitempty"` // comma separated WashEnabled bool `gorm:"default:false" json:"wash_enabled"` WashPriority string `gorm:"size:32" json:"wash_priority,omitempty"` // balanced / resolution / quality / effects / seeders - Priority int `gorm:"default:50" json:"priority,omitempty"` // lower is earlier when schedulers sort later + TotalEpisodes int `gorm:"default:0" json:"total_episodes,omitempty"` + Priority int `gorm:"default:50" json:"priority,omitempty"` // lower is earlier when schedulers sort later Enabled bool `gorm:"default:true" json:"enabled"` LastRunAt *time.Time `json:"last_run_at,omitempty"` + + DownloadedEpisodes int `gorm:"-" json:"downloaded_episodes,omitempty"` + LocalMediaCount int `gorm:"-" json:"local_media_count,omitempty"` + MissingEpisodes []int `gorm:"-" json:"missing_episodes,omitempty"` + InLibrary bool `gorm:"-" json:"in_library"` } // Setting 是单个键/值系统级偏好(供管理 UI 使用)。 diff --git a/internal/repository/repository.go b/internal/repository/repository.go index 1856b3e..425a038 100644 --- a/internal/repository/repository.go +++ b/internal/repository/repository.go @@ -107,6 +107,13 @@ func (r *UserRepository) FindByID(ctx context.Context, id string) (*model.User, return &u, nil } +// Count returns the total number of non-deleted users. +func (r *UserRepository) Count(ctx context.Context) (int64, error) { + var n int64 + err := r.db.WithContext(ctx).Model(&model.User{}).Count(&n).Error + return n, err +} + // CountAdmins returns the number of users that hold the admin role. func (r *UserRepository) CountAdmins(ctx context.Context) (int64, error) { var n int64 @@ -115,6 +122,20 @@ func (r *UserRepository) CountAdmins(ctx context.Context) (int64, error) { return n, err } +// FirstAdmin returns the earliest admin user. This row represents the protected +// built-in/default administrator even if its username is later changed. +func (r *UserRepository) FirstAdmin(ctx context.Context) (*model.User, error) { + var u model.User + err := r.db.WithContext(ctx).Where("role = ?", "admin").Order("created_at asc").First(&u).Error + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, nil + } + if err != nil { + return nil, err + } + return &u, nil +} + // List returns all users ordered by creation time desc. func (r *UserRepository) List(ctx context.Context) ([]model.User, error) { var users []model.User @@ -122,6 +143,11 @@ func (r *UserRepository) List(ctx context.Context) ([]model.User, error) { return users, err } +// UpdateFields applies a narrow set of user field updates. +func (r *UserRepository) UpdateFields(ctx context.Context, id string, updates map[string]any) error { + return r.db.WithContext(ctx).Model(&model.User{}).Where("id = ?", id).Updates(updates).Error +} + // UpdatePassword sets a new password hash and clears ForcePasswordReset. func (r *UserRepository) UpdatePassword(ctx context.Context, id, hash string) error { return r.db.WithContext(ctx).Model(&model.User{}).Where("id = ?", id). diff --git a/internal/service/auth.go b/internal/service/auth.go index 73b2163..48c5ad7 100644 --- a/internal/service/auth.go +++ b/internal/service/auth.go @@ -20,10 +20,10 @@ import ( // AuthService handles registration, login, and JWT issuance. type AuthService struct { - cfg *config.Config - log *zap.Logger - repo *repository.Container - tokenSvc *TokenService + cfg *config.Config + log *zap.Logger + repo *repository.Container + tokenSvc *TokenService permissionSvc *PermissionService } @@ -36,9 +36,14 @@ func NewAuthService(cfg *config.Config, log *zap.Logger, repo *repository.Contai var ( ErrInvalidCredentials = errors.New("invalid username or password") ErrUsernameTaken = errors.New("username already taken") - ErrUserInactive = errors.New("user account is inactive") + ErrUserInactive = errors.New("user account is inactive") + ErrUserLimitReached = errors.New("user limit reached") ) +// MaxUsers is kept for compatibility with tests and callers; dynamic runtime +// checks use LicensedMaxUsers so official licensed builds can raise the quota. +const MaxUsers = OpenSourceUserLimit + // SeedAdmin makes sure at least one admin user exists. It mirrors the // MediaStation behaviour: if no admin row is found we create // `admin / admin123` (overridable through ADMIN_INITIAL_PASSWORD) and warn. @@ -89,6 +94,11 @@ func (s *AuthService) Register(ctx context.Context, username, password string) ( } else if existing != nil { return nil, nil, ErrUsernameTaken } + if n, err := s.repo.User.Count(ctx); err != nil { + return nil, nil, err + } else if n >= LicensedMaxUsers(ctx, s.repo) { + return nil, nil, ErrUserLimitReached + } hash, err := hashPassword(password) if err != nil { return nil, nil, err @@ -118,8 +128,8 @@ func (s *AuthService) Register(ctx context.Context, username, password string) ( // LoginResponse 登录响应结构。 type LoginResponse struct { - User *model.User `json:"user"` - Tokens *TokenPair `json:"tokens"` + User *model.User `json:"user"` + Tokens *TokenPair `json:"tokens"` } // Login validates credentials and returns the user + a fresh JWT token pair. diff --git a/internal/service/auth_user_limits_test.go b/internal/service/auth_user_limits_test.go new file mode 100644 index 0000000..fce94d9 --- /dev/null +++ b/internal/service/auth_user_limits_test.go @@ -0,0 +1,100 @@ +package service + +import ( + "context" + "errors" + "fmt" + "testing" + + "github.com/glebarez/sqlite" + "go.uber.org/zap" + "gorm.io/gorm" + + "github.com/ShukeBta/MediaStationGo/internal/config" + "github.com/ShukeBta/MediaStationGo/internal/model" + "github.com/ShukeBta/MediaStationGo/internal/repository" +) + +func newAuthTestServices(t *testing.T) (*repository.Container, *AuthService, *ProfileService, *PermissionService) { + t.Helper() + db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{}) + if err != nil { + t.Fatal(err) + } + if err := db.AutoMigrate(&model.User{}, &model.UserPermission{}, &model.RefreshToken{}); err != nil { + t.Fatal(err) + } + repos := repository.New(db) + cfg := &config.Config{} + cfg.Secrets.JWTSecret = "test-secret" + log := zap.NewNop() + permissions := NewPermissionService(log, repos) + tokenSvc := NewTokenService(cfg, log, repos) + auth := NewAuthService(cfg, log, repos, tokenSvc, permissions) + profile := NewProfileService(log, repos) + return repos, auth, profile, permissions +} + +func TestRegisterRejectsMoreThanTwentyUsers(t *testing.T) { + ctx := context.Background() + repos, auth, _, _ := newAuthTestServices(t) + for i := 0; i < MaxUsers; i++ { + if err := repos.User.Create(ctx, &model.User{ + Username: fmt.Sprintf("user-%02d", i), + PasswordHash: "hash", + Role: "user", + Tier: "free", + }); err != nil { + t.Fatal(err) + } + } + + _, _, err := auth.Register(ctx, "overflow", "password") + if !errors.Is(err, ErrUserLimitReached) { + t.Fatalf("expected ErrUserLimitReached, got %v", err) + } +} + +func TestDefaultPermissionsAreViewerOnly(t *testing.T) { + perms := DefaultPermissions("user-1") + if !perms.CanViewDashboard || !perms.CanPlayMedia || !perms.CanExternalPlayer { + t.Fatal("viewer defaults must allow library viewing, playback, and external players") + } + if perms.CanManageDownloads || perms.CanManageSubscriptions || perms.CanManageFiles || + perms.CanEditMedia || perms.CanRescrape || perms.CanCaptureFrames || + perms.CanManageSites || perms.CanManageUsers || perms.CanManageStrm { + t.Fatal("viewer defaults must not allow downloads, scraping, media edits, or file management") + } +} + +func TestAdminEffectivePermissionsAreAllGranted(t *testing.T) { + ctx := context.Background() + repos, _, _, permissions := newAuthTestServices(t) + admin := &model.User{Username: "admin", PasswordHash: "hash", Role: "admin", Tier: "plus"} + if err := repos.User.Create(ctx, admin); err != nil { + t.Fatal(err) + } + + perms, err := permissions.Effective(ctx, admin.ID) + if err != nil { + t.Fatal(err) + } + if !perms.CanEditMedia || !perms.CanRescrape || !perms.CanUseAI || + !perms.CanCaptureFrames || !perms.CanManageUsers || !perms.CanAccessSettings { + t.Fatal("admin effective permissions must grant every advanced capability") + } +} + +func TestDefaultAdminCannotBeDemoted(t *testing.T) { + ctx := context.Background() + repos, _, profile, _ := newAuthTestServices(t) + admin := &model.User{Username: "admin", PasswordHash: "hash", Role: "admin", Tier: "plus"} + if err := repos.User.Create(ctx, admin); err != nil { + t.Fatal(err) + } + + _, err := profile.AdminUpdateRole(ctx, admin.ID, "user") + if err == nil { + t.Fatal("expected default admin demotion to be rejected") + } +} diff --git a/internal/service/external_search.go b/internal/service/external_search.go index d9e8e91..3be6d62 100644 --- a/internal/service/external_search.go +++ b/internal/service/external_search.go @@ -11,18 +11,23 @@ import ( // intentionally separate from model.Media because the item may not exist in // the local library yet. type ExternalMediaResult struct { - Source string `json:"source"` - MediaType string `json:"media_type,omitempty"` - Title string `json:"title"` - Overview string `json:"overview,omitempty"` - PosterURL string `json:"poster_url,omitempty"` - BackdropURL string `json:"backdrop_url,omitempty"` - Year int `json:"year,omitempty"` - Rating float32 `json:"rating,omitempty"` - TMDbID int `json:"tmdb_id,omitempty"` - BangumiID int `json:"bangumi_id,omitempty"` - DoubanID string `json:"douban_id,omitempty"` - SubscribeKeyword string `json:"subscribe_keyword"` + Source string `json:"source"` + MediaType string `json:"media_type,omitempty"` + Title string `json:"title"` + Overview string `json:"overview,omitempty"` + PosterURL string `json:"poster_url,omitempty"` + BackdropURL string `json:"backdrop_url,omitempty"` + Year int `json:"year,omitempty"` + Rating float32 `json:"rating,omitempty"` + TMDbID int `json:"tmdb_id,omitempty"` + BangumiID int `json:"bangumi_id,omitempty"` + DoubanID string `json:"douban_id,omitempty"` + SubscribeKeyword string `json:"subscribe_keyword"` + TotalEpisodes int `json:"total_episodes,omitempty"` + DownloadedEpisodes int `json:"downloaded_episodes,omitempty"` + LocalMediaCount int `json:"local_media_count,omitempty"` + MissingEpisodes []int `json:"missing_episodes,omitempty"` + InLibrary bool `json:"in_library"` } // SearchExternalMedia fans out one normalized search intent to TMDb, Douban @@ -39,6 +44,10 @@ func SearchExternalMedia(ctx context.Context, query string, year int, mediaType if m == nil || strings.TrimSpace(m.Title) == "" { return } + totalEpisodes := 0 + if source == "tmdb" && typ == "tv" && m.TMDbID > 0 && tmdb != nil { + totalEpisodes, _ = tmdb.GetTVEpisodeCount(ctx, m.TMDbID) + } results = append(results, ExternalMediaResult{ Source: source, MediaType: typ, @@ -51,6 +60,7 @@ func SearchExternalMedia(ctx context.Context, query string, year int, mediaType TMDbID: m.TMDbID, BangumiID: m.BangumiID, SubscribeKeyword: buildSubscribeKeyword(m.Title, m.Year), + TotalEpisodes: totalEpisodes, }) } diff --git a/internal/service/ffmpeg_auto_install.go b/internal/service/ffmpeg_auto_install.go index 1f5b2c7..926c207 100644 --- a/internal/service/ffmpeg_auto_install.go +++ b/internal/service/ffmpeg_auto_install.go @@ -18,7 +18,8 @@ import ( "github.com/ShukeBta/MediaStationGo/internal/config" ) -// AutoInstallFFmpeg 在启动时检测并自动安装 ffmpeg/ffprobe +// AutoInstallFFmpeg is only called by the admin tool-install endpoint. The +// server must not auto-download or keep ffmpeg/ffprobe running during startup. func AutoInstallFFmpeg(log *zap.Logger, cfg *config.Config) (ffprobePath, ffmpegPath string) { // 1. 优先使用配置 / PATH / 本机常见软件目录中的现有工具。 if path, err := resolveLocalExecutable(cfg.App.FFprobePath, "ffprobe"); err == nil { diff --git a/internal/service/license.go b/internal/service/license.go new file mode 100644 index 0000000..6a56902 --- /dev/null +++ b/internal/service/license.go @@ -0,0 +1,62 @@ +package service + +import ( + "context" + "encoding/json" + "time" + + "github.com/ShukeBta/MediaStationGo/internal/repository" +) + +const ( + OpenSourceUserLimit = 20 + LicensedUserLimit = 100 + + LicenseSettingActivation = "license.activation" +) + +type LicenseActivationState struct { + Valid bool `json:"valid"` + LicenseType string `json:"license_type,omitempty"` + ExpiryDate string `json:"expiry_date,omitempty"` + MaxDevices int `json:"max_devices,omitempty"` + DaysRemaining *int `json:"days_remaining,omitempty"` + NextHeartbeat string `json:"next_heartbeat,omitempty"` + DeviceID string `json:"device_id,omitempty"` + DeviceName string `json:"device_name,omitempty"` + UpdatedAt string `json:"updated_at,omitempty"` +} + +func LicensedMaxUsers(ctx context.Context, repos *repository.Container) int64 { + if LicenseActive(ctx, repos) { + return LicensedUserLimit + } + return OpenSourceUserLimit +} + +func LicenseActive(ctx context.Context, repos *repository.Container) bool { + if repos == nil || repos.Setting == nil { + return false + } + raw, err := repos.Setting.Get(ctx, LicenseSettingActivation) + if err != nil || raw == "" { + return false + } + var state LicenseActivationState + if err := json.Unmarshal([]byte(raw), &state); err != nil { + return false + } + return state.Valid && !licenseExpired(state.ExpiryDate) +} + +func licenseExpired(expiry string) bool { + if expiry == "" { + return false + } + for _, layout := range []string{time.RFC3339, "2006-01-02 15:04:05", "2006-01-02"} { + if t, err := time.Parse(layout, expiry); err == nil { + return time.Now().After(t) + } + } + return false +} diff --git a/internal/service/local_availability.go b/internal/service/local_availability.go new file mode 100644 index 0000000..fb718a6 --- /dev/null +++ b/internal/service/local_availability.go @@ -0,0 +1,189 @@ +package service + +import ( + "context" + "fmt" + "regexp" + "sort" + "strings" + + "github.com/ShukeBta/MediaStationGo/internal/model" + "github.com/ShukeBta/MediaStationGo/internal/repository" +) + +var availabilityNoiseRE = regexp.MustCompile(`(?i)(自动订阅|订阅|全集|合集|complete|batch|season\s*\d+|s\d{1,2}|s\d{1,2}e\d{1,3}|第\s*\d+\s*季|第\s*\d+\s*[集话話期]|\(\d{4}\)|\b\d{4}\b|2160p|1080p|720p|4k|uhd|bluray|blu-ray|web-?dl|hdtv|remux|x26[45]|h\.?26[45]|hevc|avc|hdr10?\+?|dovi|dv|atmos|aac|ddp?5\.1|truehd|flac)`) + +type LocalAvailability struct { + DownloadedEpisodes int + TotalEpisodes int + LocalMediaCount int + MissingEpisodes []int + InLibrary bool + ExistingEpisodeKeys map[string]struct{} + MissingEpisodeKeys map[string]struct{} +} + +func EnrichExternalMediaAvailability(ctx context.Context, repo *repository.Container, items []ExternalMediaResult) { + for i := range items { + availability := LookupLocalAvailability(ctx, repo, items[i].Title, items[i].SubscribeKeyword, items[i].MediaType, items[i].TotalEpisodes) + items[i].DownloadedEpisodes = availability.DownloadedEpisodes + items[i].LocalMediaCount = availability.LocalMediaCount + items[i].MissingEpisodes = availability.MissingEpisodes + items[i].InLibrary = availability.InLibrary + if items[i].TotalEpisodes == 0 { + items[i].TotalEpisodes = availability.TotalEpisodes + } + } +} + +func EnrichSubscriptionProgress(ctx context.Context, repo *repository.Container, items []model.Subscription) { + for i := range items { + availability := SubscriptionLocalAvailability(ctx, repo, &items[i]) + items[i].DownloadedEpisodes = availability.DownloadedEpisodes + items[i].LocalMediaCount = availability.LocalMediaCount + items[i].MissingEpisodes = availability.MissingEpisodes + items[i].InLibrary = availability.InLibrary + if items[i].TotalEpisodes == 0 { + items[i].TotalEpisodes = availability.TotalEpisodes + } + } +} + +func SubscriptionLocalAvailability(ctx context.Context, repo *repository.Container, sub *model.Subscription) LocalAvailability { + if sub == nil { + return LocalAvailability{} + } + expected := sub.TotalEpisodes + return LookupLocalAvailability(ctx, repo, sub.Name, sub.Filter, sub.MediaType, expected) +} + +func LookupLocalAvailability(ctx context.Context, repo *repository.Container, title, keyword, mediaType string, expectedTotal int) LocalAvailability { + out := LocalAvailability{ + TotalEpisodes: expectedTotal, + ExistingEpisodeKeys: map[string]struct{}{}, + MissingEpisodeKeys: map[string]struct{}{}, + } + if repo == nil || repo.DB == nil { + return out + } + query := availabilityQuery(title, keyword) + if query == "" { + return out + } + like := "%" + query + "%" + var rows []model.Media + if err := repo.DB.WithContext(ctx). + Where("title LIKE ? OR original_name LIKE ?", like, like). + Order("season_num asc, episode_num asc, created_at desc"). + Limit(2000). + Find(&rows).Error; err != nil { + return out + } + out.LocalMediaCount = len(rows) + out.InLibrary = len(rows) > 0 + if len(rows) == 0 { + return out + } + + seriesLike := isSubscriptionSeriesType(mediaType) + for _, row := range rows { + if row.EpisodeNum <= 0 { + continue + } + season := row.SeasonNum + if season <= 0 { + season = 1 + } + key := episodeKey(season, row.EpisodeNum) + out.ExistingEpisodeKeys[key] = struct{}{} + } + if seriesLike || len(out.ExistingEpisodeKeys) > 0 { + out.DownloadedEpisodes = len(out.ExistingEpisodeKeys) + out.MissingEpisodes = missingEpisodes(out.ExistingEpisodeKeys, out.TotalEpisodes) + for _, episode := range out.MissingEpisodes { + out.MissingEpisodeKeys[episodeKey(1, episode)] = struct{}{} + } + return out + } + out.DownloadedEpisodes = 1 + if out.TotalEpisodes == 0 { + out.TotalEpisodes = 1 + } + return out +} + +func missingEpisodes(existing map[string]struct{}, total int) []int { + if total <= 0 { + return nil + } + missing := make([]int, 0) + for episode := 1; episode <= total; episode++ { + if _, ok := existing[episodeKey(1, episode)]; ok { + continue + } + missing = append(missing, episode) + } + return missing +} + +func availabilityQuery(title, keyword string) string { + for _, candidate := range []string{keyword, title} { + cleaned := cleanAvailabilityTitle(candidate) + if cleaned != "" { + return cleaned + } + } + return "" +} + +func cleanAvailabilityTitle(value string) string { + value = availabilityNoiseRE.ReplaceAllString(value, " ") + value = strings.TrimSpace(strings.Join(strings.Fields(value), " ")) + value = strings.TrimSuffix(value, "-") + value = strings.TrimSpace(value) + return value +} + +func episodeKey(season, episode int) string { + if season <= 0 { + season = 1 + } + return fmt.Sprintf("%02dE%03d", season, episode) +} + +func missingEpisodeSet(availability LocalAvailability) map[int]struct{} { + out := make(map[int]struct{}, len(availability.MissingEpisodes)) + for _, episode := range availability.MissingEpisodes { + out[episode] = struct{}{} + } + return out +} + +func sortedEpisodeCandidates(candidates []siteSearchCandidate) []siteSearchCandidate { + byEpisode := make(map[string]siteSearchCandidate) + order := make([]string, 0, len(candidates)) + for _, candidate := range candidates { + if candidate.Episode <= 0 { + continue + } + season := candidate.Season + if season <= 0 { + season = 1 + } + key := episodeKey(season, candidate.Episode) + if current, ok := byEpisode[key]; ok { + if current.Score < candidate.Score { + byEpisode[key] = candidate + } + continue + } + byEpisode[key] = candidate + order = append(order, key) + } + sort.Strings(order) + selected := make([]siteSearchCandidate, 0, len(order)) + for _, key := range order { + selected = append(selected, byEpisode[key]) + } + return selected +} diff --git a/internal/service/permission.go b/internal/service/permission.go index 6b83306..b913668 100644 --- a/internal/service/permission.go +++ b/internal/service/permission.go @@ -30,18 +30,24 @@ func NewPermissionService(log *zap.Logger, repo *repository.Container) *Permissi func DefaultPermissions(userID string) *model.UserPermission { return &model.UserPermission{ UserID: userID, + CanViewDashboard: true, CanPlayMedia: true, + CanCast: true, + CanExternalPlayer: true, CanFavorite: true, CanViewHistory: true, - CanViewDashboard: true, - CanViewDiscover: true, - CanCast: true, + CanEditMedia: false, + CanRescrape: false, + CanUseAI: false, + CanCaptureFrames: false, CanManageDownloads: false, + CanViewDiscover: false, CanManageSubscriptions: false, CanManageSites: false, + CanUseAIAssistant: false, + CanManageUsers: false, CanManageFiles: false, CanManageStrm: false, - CanUseAIAssistant: false, CanAccessSettings: false, } } @@ -50,18 +56,24 @@ func DefaultPermissions(userID string) *model.UserPermission { func adminGrant(userID string) *model.UserPermission { return &model.UserPermission{ UserID: userID, + CanViewDashboard: true, CanPlayMedia: true, + CanCast: true, + CanExternalPlayer: true, CanFavorite: true, CanViewHistory: true, - CanViewDashboard: true, - CanViewDiscover: true, + CanEditMedia: true, + CanRescrape: true, + CanUseAI: true, + CanCaptureFrames: true, CanManageDownloads: true, + CanViewDiscover: true, CanManageSubscriptions: true, CanManageSites: true, + CanUseAIAssistant: true, + CanManageUsers: true, CanManageFiles: true, CanManageStrm: true, - CanCast: true, - CanUseAIAssistant: true, CanAccessSettings: true, } } diff --git a/internal/service/profile.go b/internal/service/profile.go index a7ffc37..12ccfe0 100644 --- a/internal/service/profile.go +++ b/internal/service/profile.go @@ -59,8 +59,16 @@ func (p *ProfileService) AdminUpdateRole(ctx context.Context, userID, role strin if role != "admin" && role != "user" { return nil, errors.New("role must be admin or user") } - if err := p.repo.DB.Model(&model.User{}).Where("id = ?", userID). - Update("role", role).Error; err != nil { + if firstAdmin, err := p.repo.User.FirstAdmin(ctx); err != nil { + return nil, err + } else if firstAdmin != nil && firstAdmin.ID == userID && role != "admin" { + return nil, errors.New("default admin must keep admin role") + } + updates := map[string]any{"role": role} + if role == "admin" { + updates["tier"] = "plus" + } + if err := p.repo.User.UpdateFields(ctx, userID, updates); err != nil { return nil, err } return p.repo.User.FindByID(ctx, userID) diff --git a/internal/service/runtime_settings.go b/internal/service/runtime_settings.go index 438ddbe..c9f11ce 100644 --- a/internal/service/runtime_settings.go +++ b/internal/service/runtime_settings.go @@ -50,5 +50,9 @@ func ApplyRuntimeSetting(cfg *config.Config, key, value string) { } case "transcode.video_bitrate", "transcoder.video_bitrate": cfg.Transcoder.VideoBitrate = value + case "license.server_url": + cfg.License.ServerURL = value + case "license.hmac_secret": + cfg.License.HMACSecret = value } } diff --git a/internal/service/stream.go b/internal/service/stream.go index a5f8576..3ef63c3 100644 --- a/internal/service/stream.go +++ b/internal/service/stream.go @@ -11,9 +11,9 @@ // // The HTTP layer decides which mode to use based on the request path: // -// GET /api/stream/:id → direct play -// GET /api/hls/:id/index.m3u8 → HLS playlist -// GET /api/hls/:id/seg_NNNNN.ts → HLS segment +// GET /api/stream/:id → direct play +// GET /api/hls/:id/index.m3u8 → HLS playlist +// GET /api/hls/:id/seg_NNNNN.ts → HLS segment package service import ( @@ -82,6 +82,8 @@ func (s *StreamService) ServeFile(w http.ResponseWriter, r *http.Request, mediaI return err } w.Header().Set("Accept-Ranges", "bytes") + w.Header().Set("Content-Disposition", "inline") + w.Header().Set("X-Content-Type-Options", "nosniff") http.ServeContent(w, r, stat.Name(), stat.ModTime(), f) return nil } @@ -104,6 +106,7 @@ func (s *StreamService) ServeHLSPlaylist(w http.ResponseWriter, r *http.Request, stat, _ := f.Stat() w.Header().Set("Content-Type", "application/vnd.apple.mpegurl") w.Header().Set("Cache-Control", "no-cache") + w.Header().Set("Content-Disposition", "inline") if r.URL.RawQuery != "" { data, err := io.ReadAll(f) if err != nil { @@ -164,6 +167,7 @@ func (s *StreamService) ServeHLSSegment(w http.ResponseWriter, r *http.Request, stat, _ := f.Stat() w.Header().Set("Content-Type", "video/mp2t") w.Header().Set("Cache-Control", "public, max-age=3600") + w.Header().Set("Content-Disposition", "inline") http.ServeContent(w, r, stat.Name(), stat.ModTime(), f) return nil } diff --git a/internal/service/subscription.go b/internal/service/subscription.go index adb3a76..d89f25c 100644 --- a/internal/service/subscription.go +++ b/internal/service/subscription.go @@ -285,7 +285,8 @@ func (s *SubscriptionService) runSiteSearch(ctx context.Context, sub *model.Subs seenSet[g] = struct{}{} } - candidates := selectSiteSearchCandidates(results, sub, seenSet) + availability := SubscriptionLocalAvailability(ctx, s.repo, sub) + candidates := selectSiteSearchCandidates(results, sub, seenSet, availability) var lastEnqueueErr error queued := 0 var resources []string @@ -343,7 +344,7 @@ func (s *SubscriptionService) runSiteSearch(ctx context.Context, sub *model.Subs return 0, nil } -func selectSiteSearchCandidates(results []SearchResult, sub *model.Subscription, seenSet map[string]struct{}) []siteSearchCandidate { +func selectSiteSearchCandidates(results []SearchResult, sub *model.Subscription, seenSet map[string]struct{}, availability ...LocalAvailability) []siteSearchCandidate { candidates := make([]siteSearchCandidate, 0, len(results)) for _, item := range results { if !matchesSubscriptionRules(sub, item.Title) { @@ -390,41 +391,47 @@ func selectSiteSearchCandidates(results []SearchResult, sub *model.Subscription, return candidates[:1] } + var local LocalAvailability + if len(availability) > 0 { + local = availability[0] + } + if local.LocalMediaCount > 0 { + if local.TotalEpisodes > 0 && len(local.MissingEpisodes) == 0 { + return nil + } + missingSet := missingEpisodeSet(local) + onlyMissing := make([]siteSearchCandidate, 0, len(candidates)) + for _, candidate := range candidates { + if candidate.Episode <= 0 { + continue + } + season := candidate.Season + if season <= 0 { + season = 1 + } + if _, exists := local.ExistingEpisodeKeys[episodeKey(season, candidate.Episode)]; exists { + continue + } + if local.TotalEpisodes > 0 { + if _, missing := missingSet[candidate.Episode]; !missing { + continue + } + } + onlyMissing = append(onlyMissing, candidate) + } + return sortedEpisodeCandidates(onlyMissing) + } + for _, candidate := range candidates { if candidate.Pack { return []siteSearchCandidate{candidate} } } - byEpisode := make(map[string]siteSearchCandidate) - order := make([]string, 0, len(candidates)) - for _, candidate := range candidates { - if candidate.Episode <= 0 { - continue - } - season := candidate.Season - if season <= 0 { - season = 1 - } - key := fmt.Sprintf("%02dE%03d", season, candidate.Episode) - if current, ok := byEpisode[key]; ok { - if current.Score < candidate.Score { - byEpisode[key] = candidate - } - continue - } - byEpisode[key] = candidate - order = append(order, key) - } - if len(order) == 0 { + selected := sortedEpisodeCandidates(candidates) + if len(selected) == 0 { return candidates[:1] } - - selected := make([]siteSearchCandidate, 0, len(order)) - sort.Strings(order) - for _, key := range order { - selected = append(selected, byEpisode[key]) - } return selected } diff --git a/internal/service/subscription_test.go b/internal/service/subscription_test.go index 8e5105d..257288b 100644 --- a/internal/service/subscription_test.go +++ b/internal/service/subscription_test.go @@ -98,3 +98,43 @@ func TestSiteSearchKeywordCanUseIMDB(t *testing.T) { t.Fatalf("keyword = %q, want imdb id", got) } } + +func TestSelectSiteSearchCandidatesOnlyQueuesMissingLocalEpisodes(t *testing.T) { + sub := &model.Subscription{Name: "间谍过家家 自动订阅", Filter: "间谍过家家", MediaType: "tv", TotalEpisodes: 3} + results := []SearchResult{ + {Title: "间谍过家家 S01 Complete 1080p", DownloadURL: "https://pt/download/pack", Seeders: 100}, + {Title: "间谍过家家 S01E01 1080p", DownloadURL: "https://pt/download/1", Seeders: 90}, + {Title: "间谍过家家 S01E02 1080p", DownloadURL: "https://pt/download/2", Seeders: 80}, + {Title: "间谍过家家 S01E03 1080p", DownloadURL: "https://pt/download/3", Seeders: 70}, + } + availability := LocalAvailability{ + TotalEpisodes: 3, + LocalMediaCount: 2, + MissingEpisodes: []int{3}, + ExistingEpisodeKeys: map[string]struct{}{episodeKey(1, 1): {}, episodeKey(1, 2): {}}, + } + + got := selectSiteSearchCandidates(results, sub, map[string]struct{}{}, availability) + if len(got) != 1 || got[0].Episode != 3 { + t.Fatalf("selected %#v, want only missing episode 3", got) + } +} + +func TestSelectSiteSearchCandidatesWithUnknownTotalSkipsExistingEpisodes(t *testing.T) { + sub := &model.Subscription{Name: "葬送的芙莉莲 自动订阅", Filter: "葬送的芙莉莲", MediaType: "anime"} + results := []SearchResult{ + {Title: "葬送的芙莉莲 S01 Complete 1080p", DownloadURL: "https://pt/download/pack", Seeders: 100}, + {Title: "葬送的芙莉莲 S01E01 1080p", DownloadURL: "https://pt/download/1", Seeders: 90}, + {Title: "葬送的芙莉莲 S01E02 1080p", DownloadURL: "https://pt/download/2", Seeders: 80}, + {Title: "葬送的芙莉莲 S01E03 1080p", DownloadURL: "https://pt/download/3", Seeders: 70}, + } + availability := LocalAvailability{ + LocalMediaCount: 2, + ExistingEpisodeKeys: map[string]struct{}{episodeKey(1, 1): {}, episodeKey(1, 2): {}}, + } + + got := selectSiteSearchCandidates(results, sub, map[string]struct{}{}, availability) + if len(got) != 1 || got[0].Episode != 3 { + t.Fatalf("selected %#v, want only not-yet-local episode 3", got) + } +} diff --git a/internal/service/tmdb.go b/internal/service/tmdb.go index b08941b..9bc8b54 100644 --- a/internal/service/tmdb.go +++ b/internal/service/tmdb.go @@ -399,6 +399,28 @@ func (t *TMDbProvider) GetDetails(ctx context.Context, tmdbID int, mediaType str }, nil } +func (t *TMDbProvider) GetTVEpisodeCount(ctx context.Context, tmdbID int) (int, error) { + if tmdbID <= 0 { + return 0, nil + } + apiKey := t.resolveAPIKey(ctx) + if apiKey == "" { + return 0, nil + } + base := t.resolveBaseURL(ctx) + q := url.Values{} + q.Set("api_key", apiKey) + q.Set("language", "zh-CN") + u := base + "/tv/" + fmt.Sprint(tmdbID) + "?" + q.Encode() + var r struct { + NumberOfEpisodes int `json:"number_of_episodes"` + } + if err := t.getJSON(ctx, u, &r); err != nil { + return 0, err + } + return r.NumberOfEpisodes, nil +} + // deduplicate removes duplicates from a string slice. func deduplicate(s []string) []string { if len(s) == 0 { diff --git a/web/src/App.tsx b/web/src/App.tsx index 2cc371f..63ea7ed 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -102,6 +102,9 @@ const DownloadClientsPage = lazy(() => const StorageConfigPage = lazy(() => import('./pages/StorageConfigPage').then((m) => ({ default: m.StorageConfigPage })), ) +const LicensePage = lazy(() => + import('./pages/LicensePage').then((m) => ({ default: m.LicensePage })), +) const Loading = () =>
加载中…
@@ -247,7 +250,11 @@ export default function App() { />