feat: add licensing and access controls

This commit is contained in:
ShukeBta
2026-05-29 12:47:54 +08:00
parent 2f7ec3ab17
commit 27df93fa3d
61 changed files with 2032 additions and 237 deletions
+4
View File
@@ -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=
+60 -12
View File
@@ -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 的刮削顺序尽量避免重复请求和错误覆盖:
+59 -11
View File
@@ -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:
+6
View File
@@ -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
+1 -1
View File
@@ -17,7 +17,7 @@
#
# 镜像版本:
# 默认拉取 latest;如需固定版本,创建 .env 并写入:
# MEDIASTATION_IMAGE_TAG=MediaStationGo-v0.0.11
# MEDIASTATION_IMAGE_TAG=MediaStationGo-v0.0.15
#
# 路径映射总览:
# /data 程序数据目录。保存 SQLite 数据库、JWT secret、系统配置等,必须持久化。
+10
View File
@@ -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 填充派生默认值并自愈空的关键字段。
+129
View File
@@ -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"`
+1
View File
@@ -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,
+48 -41
View File
@@ -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))
+394
View File
@@ -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
}
+26
View File
@@ -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)
+46 -18
View File
@@ -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 {
+4
View File
@@ -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
}
+6
View File
@@ -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})
}
}
+8
View File
@@ -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 签名密钥"},
},
},
},
})
}
+9 -1
View File
@@ -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 使用)。
+26
View File
@@ -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).
+17 -7
View File
@@ -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.
+100
View File
@@ -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")
}
}
+22 -12
View File
@@ -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,
})
}
+2 -1
View File
@@ -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 {
+62
View File
@@ -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
}
+189
View File
@@ -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
}
+20 -8
View File
@@ -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,
}
}
+10 -2
View File
@@ -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)
+4
View File
@@ -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
}
}
+7 -3
View File
@@ -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
}
+36 -29
View File
@@ -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
}
+40
View File
@@ -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)
}
}
+22
View File
@@ -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 {
+8 -1
View File
@@ -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 = () => <p className="px-6 py-8 text-sand-500">加载中…</p>
@@ -247,7 +250,11 @@ export default function App() {
/>
<Route
path="license"
element={<Navigate to="/admin" replace />}
element={
<RequireAdmin>
<LicensePage />
</RequireAdmin>
}
/>
<Route
path="storage-config"
+6
View File
@@ -4,6 +4,12 @@ import type { AccessLog, Setting, User } from '../types'
export const adminAPI = {
listUsers: () => api.get<User[]>('/admin/users').then((r) => r.data),
createUser: (payload: { username: string; password: string }) =>
api.post<User>('/admin/users', payload).then((r) => r.data),
updateUser: (id: string, payload: { username: string }) =>
api.patch<User>(`/admin/users/${id}`, payload).then((r) => r.data),
deleteUser: (id: string) => api.delete(`/admin/users/${id}`).then((r) => r.data),
listSettings: () => api.get<Setting[]>('/admin/settings').then((r) => r.data),
+5
View File
@@ -23,6 +23,11 @@ export interface ExternalMediaResult {
bangumi_id?: number
douban_id?: string
subscribe_keyword: string
total_episodes?: number
downloaded_episodes?: number
local_media_count?: number
missing_episodes?: number[]
in_library?: boolean
}
export const aiAPI = {
+1
View File
@@ -25,6 +25,7 @@ export interface LicenseStatus {
/** Whether a license is currently active */
active: boolean
activation?: LicenseActivation
max_users?: number
/** Error or status message */
message?: string
}
+14
View File
@@ -21,6 +21,12 @@ export interface PlaylistDetail {
items: Media[]
}
export interface ExternalPlayer {
name: string
scheme: string
url: string
}
export const playbackAPI = {
recordProgress: (mediaId: string, positionMs: number, durationMs: number) =>
api
@@ -61,4 +67,12 @@ export const playbackAPI = {
deletePlaylist: (id: string) =>
api.delete(`/playlists/${id}`).then((r) => r.data),
externalPlayers: (mediaId: string) =>
api
.get<{ players: ExternalPlayer[]; url?: string }>(`/playback/${mediaId}/external-players`)
.then((r) => r.data),
externalURL: (mediaId: string) =>
api.get<{ url: string; token: string }>(`/playback/${mediaId}/external-url`).then((r) => r.data),
}
+1
View File
@@ -25,6 +25,7 @@ export const subscriptionsAPI = {
exclude_words?: string
wash_enabled?: boolean
wash_priority?: string
total_episodes?: number
priority?: number
enabled?: boolean
}) =>
+2 -1
View File
@@ -3,6 +3,7 @@ import toast from 'react-hot-toast'
import { Eye, KeyRound, Save, Trash2, X } from 'lucide-react'
import { apiConfigsAPI, type APIConfig } from '../api/api_configs'
import { confirmAction } from './ConfirmDialog'
// Compact inline-editable provider table for use inside AdminPage's "外部API" tab.
export function APIConfigsPanel() {
@@ -112,7 +113,7 @@ export function APIConfigsPanel() {
{item.has_key && (
<button
onClick={async () => {
if (!confirm(`确定清除 ${item.provider} 的 API Key?`)) return
if (!(await confirmAction({ title: '清除 API Key', message: `确定清除 ${item.provider} 的 API Key?`, confirmText: '清除' }))) return
await apiConfigsAPI.remove(item.provider)
toast.success('已清除')
refresh()
+83
View File
@@ -0,0 +1,83 @@
import { createRoot } from 'react-dom/client'
import { AlertTriangle } from 'lucide-react'
type ConfirmOptions = {
title?: string
message: string
confirmText?: string
cancelText?: string
danger?: boolean
}
export function confirmAction(options: ConfirmOptions): Promise<boolean> {
return new Promise((resolve) => {
const host = document.createElement('div')
document.body.appendChild(host)
const root = createRoot(host)
const close = (value: boolean) => {
root.unmount()
host.remove()
resolve(value)
}
root.render(<ConfirmDialog options={options} onClose={close} />)
})
}
function ConfirmDialog({
options,
onClose,
}: {
options: ConfirmOptions
onClose: (value: boolean) => void
}) {
const danger = options.danger ?? true
return (
<div
className="fixed inset-0 z-[100] flex items-center justify-center bg-black/35 p-4 backdrop-blur-sm"
onClick={() => onClose(false)}
>
<div
role="dialog"
aria-modal="true"
className="w-full max-w-md overflow-hidden rounded-3xl border border-white/70 bg-white shadow-2xl"
onClick={(event) => event.stopPropagation()}
>
<div className="flex gap-4 p-5">
<div
className={
'flex h-11 w-11 shrink-0 items-center justify-center rounded-2xl ' +
(danger ? 'bg-red-50 text-red-500' : 'bg-primary-400/10 text-brand-500')
}
>
<AlertTriangle size={22} />
</div>
<div className="min-w-0 flex-1">
<h3 className="font-display text-lg font-bold text-ink-600">
{options.title || '确认操作'}
</h3>
<p className="mt-2 text-sm leading-6 text-ink-50">{options.message}</p>
</div>
</div>
<div className="flex justify-end gap-2 border-t border-gray-100 bg-gray-50/80 px-5 py-4">
<button
type="button"
onClick={() => onClose(false)}
className="rounded-xl border border-gray-200 bg-white px-4 py-2 text-sm font-semibold text-ink-100 hover:bg-gray-50"
>
{options.cancelText || '取消'}
</button>
<button
type="button"
onClick={() => onClose(true)}
className={
'rounded-xl px-4 py-2 text-sm font-semibold text-white shadow-sm transition ' +
(danger ? 'bg-red-500 hover:bg-red-600' : 'bg-brand-500 hover:bg-brand-600')
}
>
{options.confirmText || '确认'}
</button>
</div>
</div>
</div>
)
}
+124
View File
@@ -0,0 +1,124 @@
import { useState } from 'react'
import toast from 'react-hot-toast'
import { Copy, ExternalLink, PlaySquare, X } from 'lucide-react'
import { playbackAPI, type ExternalPlayer } from '../api/playback'
export function ExternalPlayerButton({
mediaId,
label = '外部播放器',
compact = false,
}: {
mediaId: string
label?: string
compact?: boolean
}) {
const [open, setOpen] = useState(false)
const [loading, setLoading] = useState(false)
const [players, setPlayers] = useState<ExternalPlayer[]>([])
const [streamURL, setStreamURL] = useState('')
const load = async () => {
setLoading(true)
try {
const [playerData, urlData] = await Promise.all([
playbackAPI.externalPlayers(mediaId),
playbackAPI.externalURL(mediaId),
])
setPlayers(playerData.players ?? [])
setStreamURL(urlData.url)
setOpen(true)
} catch (err: unknown) {
const msg =
(err as { response?: { data?: { error?: string } } })?.response?.data?.error ??
'生成外部播放链接失败'
toast.error(msg)
} finally {
setLoading(false)
}
}
return (
<>
<button
type="button"
disabled={loading}
onClick={(event) => {
event.preventDefault()
event.stopPropagation()
load()
}}
className={
compact
? 'rounded-lg border border-primary-400/35 bg-white px-2 py-1 text-xs font-semibold text-brand-500 hover:bg-primary-400/10 disabled:opacity-50'
: 'btn-outline border-brand-500/30 px-5 text-[#c9954a] hover:border-brand-500 hover:bg-brand-50'
}
>
<PlaySquare size={compact ? 13 : 14} className="mr-1 inline" />
{loading ? '生成中…' : label}
</button>
{open && (
<ExternalPlayerModal
players={players}
streamURL={streamURL}
onClose={() => setOpen(false)}
/>
)}
</>
)
}
function ExternalPlayerModal({
players,
streamURL,
onClose,
}: {
players: ExternalPlayer[]
streamURL: string
onClose: () => void
}) {
const copy = async (value: string) => {
await navigator.clipboard.writeText(value)
toast.success('播放链接已复制')
}
return (
<div className="fixed inset-0 z-[90] flex items-center justify-center bg-black/40 p-4 backdrop-blur-sm" onClick={onClose}>
<div className="w-full max-w-2xl overflow-hidden rounded-3xl border border-white/70 bg-white shadow-2xl" onClick={(event) => event.stopPropagation()}>
<div className="flex items-start justify-between gap-4 border-b border-gray-100 p-5">
<div>
<h3 className="font-display text-xl font-bold text-ink-600">外部播放器播放</h3>
<p className="mt-1 text-xs text-ink-50">链接已包含临时播放 Token,可复制到 VLC、Infuse、VidHub、SenPlayer 等客户端。</p>
</div>
<button onClick={onClose} className="rounded-xl p-2 text-ink-50 hover:bg-gray-100 hover:text-ink-600">
<X size={18} />
</button>
</div>
<div className="space-y-4 p-5">
<div className="rounded-2xl border border-gray-100 bg-gray-50 p-3">
<div className="mb-2 text-xs font-semibold text-sand-500">直链播放地址</div>
<div className="flex gap-2">
<input readOnly value={streamURL} className="input-base min-w-0 flex-1 font-mono text-xs" />
<button onClick={() => copy(streamURL)} className="btn-outline shrink-0 px-3">
<Copy size={14} />
复制
</button>
</div>
</div>
<div className="grid gap-2 sm:grid-cols-2">
{players.map((player) => (
<a
key={player.name}
href={player.url}
className="flex items-center justify-between rounded-2xl border border-gray-100 bg-white px-4 py-3 text-sm font-semibold text-ink-600 shadow-sm hover:border-brand-300 hover:bg-brand-50"
>
<span>{player.name}</span>
<ExternalLink size={15} className="text-brand-500" />
</a>
))}
</div>
</div>
</div>
</div>
)
}
+27 -12
View File
@@ -3,7 +3,7 @@ import { Link, NavLink, Outlet, useLocation, useNavigate } from 'react-router-do
import { AnimatePresence, motion } from 'framer-motion'
import {
Activity, Bell, Clock, CloudDownload, Compass, Film,
Cast, Globe, HardDrive, Heart, Home, Image,
Cast, Globe, HardDrive, Heart, Home, Image, KeySquare,
ListMusic, LogOut, Rss, Search,
Settings, Sliders, Sparkles, UserCog, Wrench,
Library as LibraryIcon, User as UserIcon, ChevronDown, Menu, X
@@ -11,12 +11,17 @@ import {
import clsx from 'clsx'
import { AppFooter } from './AppFooter'
import { useAuthStore } from '../stores/auth'
import { usePermissionStore } from '../stores/permissions'
export function Layout() {
const navigate = useNavigate()
const location = useLocation()
const user = useAuthStore((s) => s.user)
const logout = useAuthStore((s) => s.logout)
const permissions = usePermissionStore((s) => s.permissions)
const isSuper = usePermissionStore((s) => s.isSuper)
const isPermissionLoading = usePermissionStore((s) => s.isLoading)
const fetchPermissions = usePermissionStore((s) => s.fetchPermissions)
const [isSidebarOpen, setIsSidebarOpen] = useState(true)
const [isMobileDrawerOpen, setIsMobileDrawerOpen] = useState(false)
const [isProfileOpen, setIsProfileOpen] = useState(false)
@@ -41,6 +46,15 @@ export function Layout() {
setIsMobileDrawerOpen(false)
}, [location.pathname])
useEffect(() => {
if (user && !isPermissionLoading && Object.keys(permissions).length === 0) {
fetchPermissions().catch(() => undefined)
}
}, [fetchPermissions, isPermissionLoading, permissions, user])
const isAdmin = user?.role === 'admin'
const can = (key: string) => isAdmin || isSuper || permissions[key] === true
const handleSearchSubmit = (e: React.FormEvent) => {
e.preventDefault()
if (searchQuery.trim()) {
@@ -93,10 +107,10 @@ export function Layout() {
<SidebarLink to="/" icon={<Home size={18} />} label="系统首页" end collapsed={!isSidebarOpen && !isMobileDrawerOpen} />
<SidebarLink to="/libraries" icon={<LibraryIcon size={18} />} label="媒体库" collapsed={!isSidebarOpen && !isMobileDrawerOpen} />
<SidebarLink to="/poster-wall" icon={<Image size={18} />} label="海报墙" collapsed={!isSidebarOpen && !isMobileDrawerOpen} />
<SidebarLink to="/discover" icon={<Compass size={18} />} label="精彩发现" collapsed={!isSidebarOpen && !isMobileDrawerOpen} />
<SidebarLink to="/search" icon={<Search size={18} />} label="智能搜索" collapsed={!isSidebarOpen && !isMobileDrawerOpen} />
<SidebarLink to="/dlna" icon={<Cast size={18} />} label="DLNA 投屏" collapsed={!isSidebarOpen && !isMobileDrawerOpen} />
<SidebarLink to="/ai" icon={<Sparkles size={18} />} label="AI 助理" collapsed={!isSidebarOpen && !isMobileDrawerOpen} />
{can('can_view_discover') && <SidebarLink to="/discover" icon={<Compass size={18} />} label="精彩发现" collapsed={!isSidebarOpen && !isMobileDrawerOpen} />}
{can('can_use_ai') && <SidebarLink to="/search" icon={<Search size={18} />} label="智能搜索" collapsed={!isSidebarOpen && !isMobileDrawerOpen} />}
{can('can_cast') && <SidebarLink to="/dlna" icon={<Cast size={18} />} label="DLNA 投屏" collapsed={!isSidebarOpen && !isMobileDrawerOpen} />}
{can('can_use_ai_assistant') && <SidebarLink to="/ai" icon={<Sparkles size={18} />} label="AI 助理" collapsed={!isSidebarOpen && !isMobileDrawerOpen} />}
</div>
</div>
@@ -112,18 +126,18 @@ export function Layout() {
</div>
{/* Navigation Group: Automation */}
<div>
{(can('can_manage_downloads') || can('can_manage_subscriptions') || can('can_manage_sites')) && <div>
<SectionHeader label="下载订阅" visible={isSidebarOpen || isMobileDrawerOpen} />
<div className="space-y-1">
<SidebarLink to="/downloads" icon={<CloudDownload size={18} />} label="下载中心" collapsed={!isSidebarOpen && !isMobileDrawerOpen} />
<SidebarLink to="/download-clients" icon={<Sliders size={18} />} label="下载器管理" collapsed={!isSidebarOpen && !isMobileDrawerOpen} />
<SidebarLink to="/subscriptions" icon={<Rss size={18} />} label="订阅管理" collapsed={!isSidebarOpen && !isMobileDrawerOpen} />
<SidebarLink to="/site-search" icon={<Search size={18} />} label="站点检索" collapsed={!isSidebarOpen && !isMobileDrawerOpen} />
{can('can_manage_downloads') && <SidebarLink to="/downloads" icon={<CloudDownload size={18} />} label="下载中心" collapsed={!isSidebarOpen && !isMobileDrawerOpen} />}
{isAdmin && <SidebarLink to="/download-clients" icon={<Sliders size={18} />} label="下载器管理" collapsed={!isSidebarOpen && !isMobileDrawerOpen} />}
{can('can_manage_subscriptions') && <SidebarLink to="/subscriptions" icon={<Rss size={18} />} label="订阅管理" collapsed={!isSidebarOpen && !isMobileDrawerOpen} />}
{can('can_manage_sites') && <SidebarLink to="/site-search" icon={<Search size={18} />} label="站点检索" collapsed={!isSidebarOpen && !isMobileDrawerOpen} />}
</div>
</div>
</div>}
{/* Navigation Group: Admin Dashboard */}
{user?.role === 'admin' && (
{isAdmin && (
<div>
<SectionHeader label="统一管理" visible={isSidebarOpen || isMobileDrawerOpen} />
<div className="space-y-1">
@@ -132,6 +146,7 @@ export function Layout() {
<SidebarLink to="/tools" icon={<Wrench size={18} />} label="整理与维护" collapsed={!isSidebarOpen && !isMobileDrawerOpen} />
<SidebarLink to="/storage" icon={<HardDrive size={18} />} label="存储与文件" collapsed={!isSidebarOpen && !isMobileDrawerOpen} />
<SidebarLink to="/stats" icon={<Activity size={18} />} label="运行状态" collapsed={!isSidebarOpen && !isMobileDrawerOpen} />
<SidebarLink to="/license" icon={<KeySquare size={18} />} label="授权许可" collapsed={!isSidebarOpen && !isMobileDrawerOpen} />
<SidebarLink to="/settings" icon={<Sliders size={18} />} label="系统设置" collapsed={!isSidebarOpen && !isMobileDrawerOpen} />
</div>
</div>
+1
View File
@@ -248,6 +248,7 @@ export function AIAssistantPage() {
poster_url: item.poster_url,
backdrop_url: item.backdrop_url,
overview: item.overview,
total_episodes: item.total_episodes,
enabled: true,
})
const run = await subscriptionsAPI.runNow(sub.id)
+2 -1
View File
@@ -3,6 +3,7 @@ import toast from 'react-hot-toast'
import { Eye, KeyRound, Save, Trash2 } from 'lucide-react'
import { apiConfigsAPI, type APIConfig } from '../api/api_configs'
import { confirmAction } from '../components/ConfirmDialog'
// APIConfigsPage manages third-party API keys (TMDb / Bangumi / TheTVDB /
// Fanart / OpenAI / Douban). Plaintext keys are never returned by the
@@ -78,7 +79,7 @@ function ProviderCard({ item, onUpdated }: { item: APIConfig; onUpdated: () => v
}
const removeKey = async () => {
if (!confirm(`确定清除 ${item.provider} 的 API Key?`)) return
if (!(await confirmAction({ title: '清除 API Key', message: `确定清除 ${item.provider} 的 API Key?`, confirmText: '清除' }))) return
await apiConfigsAPI.remove(item.provider)
toast.success('已清除')
onUpdated()
+157 -36
View File
@@ -1,12 +1,13 @@
import { FormEvent, useEffect, useState } from 'react'
import toast from 'react-hot-toast'
import { Trash2 } from 'lucide-react'
import { Pencil, Plus, ShieldCheck, Trash2, X } from 'lucide-react'
import { adminAPI } from '../api/admin'
import { libraryAPI } from '../api/library'
import type { Library, User } from '../types'
import { APIConfigsPanel } from '../components/APIConfigsPanel'
import { ManagementShortcuts } from '../components/ManagementShortcuts'
import { confirmAction } from '../components/ConfirmDialog'
export function AdminPage() {
const [tab, setTab] = useState<'library' | 'users' | 'api'>('library')
@@ -141,7 +142,7 @@ function LibraryPanel() {
<button
className="rounded-lg border border-red-400/40 px-2 py-1 text-xs text-red-400 hover:bg-red-400/10"
onClick={async () => {
if (!confirm(`确定删除「${l.name}」?`)) return
if (!(await confirmAction({ title: '删除媒体库', message: `确定删除「${l.name}」?`, confirmText: '删除' }))) return
await libraryAPI.remove(l.id)
toast.success('已删除')
await refresh()
@@ -161,47 +162,167 @@ function LibraryPanel() {
function UsersPanel() {
const [users, setUsers] = useState<User[]>([])
const [username, setUsername] = useState('')
const [password, setPassword] = useState('')
const [editingID, setEditingID] = useState<string | null>(null)
const [editingUsername, setEditingUsername] = useState('')
const refresh = () => adminAPI.listUsers().then(setUsers)
useEffect(() => {
refresh().catch(() => undefined)
}, [])
const handleCreate = async (e: FormEvent) => {
e.preventDefault()
try {
await adminAPI.createUser({ username, password })
toast.success('用户已添加,默认仅允许浏览与播放媒体')
setUsername('')
setPassword('')
await refresh()
} catch (err: unknown) {
const msg =
(err as { response?: { data?: { error?: string } } })?.response?.data?.error ??
'添加用户失败'
toast.error(msg)
}
}
const startEdit = (u: User) => {
setEditingID(u.id)
setEditingUsername(u.username)
}
const saveEdit = async (id: string) => {
try {
await adminAPI.updateUser(id, { username: editingUsername })
toast.success('用户名已更新')
setEditingID(null)
await refresh()
} catch (err: unknown) {
const msg =
(err as { response?: { data?: { error?: string } } })?.response?.data?.error ??
'更新失败'
toast.error(msg)
}
}
return (
<div className="glass-panel">
<table className="w-full text-left text-sm">
<thead className="text-xs uppercase tracking-wider text-sand-500">
<tr>
<th className="py-2">用户名</th>
<th>角色</th>
<th>最近登录</th>
<th className="text-right">操作</th>
</tr>
</thead>
<tbody>
{users.map((u) => (
<tr key={u.id} className="border-t border-gray-200">
<td className="py-2 text-ink-600">{u.username}</td>
<td className="text-ink-100">{u.role}</td>
<td className="text-ink-50">
{u.last_login_at ? new Date(u.last_login_at).toLocaleString() : '从未登录'}
</td>
<td className="py-2 text-right">
<button
className="rounded-lg border border-red-400/40 px-2 py-1 text-xs text-red-400 hover:bg-red-400/10"
onClick={async () => {
if (!confirm(`确定删除「${u.username}」?`)) return
await adminAPI.deleteUser(u.id)
toast.success('已删除')
await refresh()
}}
>
<Trash2 size={12} />
</button>
</td>
<div className="space-y-6">
<form onSubmit={handleCreate} className="glass-panel grid gap-3 md:grid-cols-[1fr_1fr_auto]">
<div className="md:col-span-3 flex flex-wrap items-center justify-between gap-3">
<div>
<h2 className="font-display text-lg font-semibold text-ink-600">用户管理</h2>
<p className="text-xs text-sand-500">
已创建 {users.length}/20 个用户;新增用户默认只有媒体库浏览、播放、外部播放器与第三方客户端观看权限。
</p>
</div>
<span className="rounded-full border border-primary-400/30 px-3 py-1 text-xs text-brand-500">
默认管理员不可删除 · 最高权限
</span>
</div>
<input
required
className="input-base"
placeholder="用户名"
value={username}
onChange={(e) => setUsername(e.target.value)}
disabled={users.length >= 20}
/>
<input
required
minLength={6}
className="input-base"
placeholder="初始密码(至少 6 位)"
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
disabled={users.length >= 20}
/>
<button type="submit" className="neon-button inline-flex items-center justify-center gap-2" disabled={users.length >= 20}>
<Plus size={16} />
添加用户
</button>
</form>
<div className="glass-panel overflow-x-auto">
<table className="w-full text-left text-sm">
<thead className="text-xs uppercase tracking-wider text-sand-500">
<tr>
<th className="py-2">用户名</th>
<th>角色</th>
<th>权限说明</th>
<th>最近登录</th>
<th className="text-right">操作</th>
</tr>
))}
</tbody>
</table>
</thead>
<tbody>
{users.map((u) => (
<tr key={u.id} className="border-t border-gray-200">
<td className="py-2 text-ink-600">
{editingID === u.id ? (
<input
className="input-base h-9 max-w-48"
value={editingUsername}
onChange={(e) => setEditingUsername(e.target.value)}
/>
) : (
<span className="inline-flex items-center gap-2">
{u.username}
{u.is_default_admin && <ShieldCheck size={15} className="text-brand-500" />}
</span>
)}
</td>
<td className="text-ink-100">{u.role === 'admin' ? '管理员' : '观看用户'}</td>
<td className="text-ink-50">
{u.role === 'admin' ? '全部管理权限' : '仅浏览/播放/外部播放器,无下载与文件操作'}
</td>
<td className="text-ink-50">
{u.last_login_at ? new Date(u.last_login_at).toLocaleString() : '从未登录'}
</td>
<td className="space-x-2 py-2 text-right">
{editingID === u.id ? (
<>
<button
className="rounded-lg border border-primary-400/40 px-2 py-1 text-xs text-brand-500 hover:bg-primary-400/10"
onClick={() => saveEdit(u.id)}
>
保存
</button>
<button
className="rounded-lg border border-gray-300 px-2 py-1 text-xs text-ink-100 hover:bg-gray-100"
onClick={() => setEditingID(null)}
>
<X size={12} />
</button>
</>
) : (
<button
className="rounded-lg border border-primary-400/40 px-2 py-1 text-xs text-brand-500 hover:bg-primary-400/10"
onClick={() => startEdit(u)}
>
<Pencil size={12} />
</button>
)}
<button
className="rounded-lg border border-red-400/40 px-2 py-1 text-xs text-red-400 hover:bg-red-400/10 disabled:cursor-not-allowed disabled:opacity-40"
disabled={u.is_protected}
title={u.is_protected ? '默认管理员禁止删除' : '删除用户'}
onClick={async () => {
if (u.is_protected) return
if (!(await confirmAction({ title: '删除用户', message: `确定删除「${u.username}」?`, confirmText: '删除' }))) return
await adminAPI.deleteUser(u.id)
toast.success('已删除')
await refresh()
}}
>
<Trash2 size={12} />
</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
)
}
+2 -1
View File
@@ -8,6 +8,7 @@ import {
type AssistantSession,
type SessionView,
} from '../api/assistant'
import { confirmAction } from '../components/ConfirmDialog'
// AssistantChatPage is the multi-turn chat surface backed by the Go
// AssistantService. It complements the older AIAssistantPage which is
@@ -63,7 +64,7 @@ export function AssistantChatPage() {
}
const onDelete = async (id: string) => {
if (!confirm('删除此会话?')) return
if (!(await confirmAction({ title: '删除会话', message: '删除此会话?', confirmText: '删除' }))) return
try {
await assistantAPI.deleteSession(id)
if (active?.session.id === id) setActive(null)
+2 -1
View File
@@ -8,6 +8,7 @@ import {
type DownloadClientInput,
type DownloadClientType,
} from '../api/download_clients'
import { confirmAction } from '../components/ConfirmDialog'
// DownloadClientsPage manages multiple downloader integrations.
// Replaces the Vue UI's DownloadView "clients" tab with a typed CRUD
@@ -45,7 +46,7 @@ export function DownloadClientsPage() {
}
const onDelete = async (c: DownloadClient) => {
if (!confirm(`确定删除「${c.name}」?`)) return
if (!(await confirmAction({ title: '删除下载器', message: `确定删除「${c.name}」?`, confirmText: '删除' }))) return
try {
await downloadClientsAPI.remove(c.id)
toast.success('已删除')
+2 -1
View File
@@ -5,6 +5,7 @@ import { ArrowDown, ArrowUp, Download, Film, HardDrive, Rss, ShieldCheck, Trash2
import { imageURL } from '../api/client'
import { downloadsAPI } from '../api/downloads'
import { useAuthStore } from '../stores/auth'
import { confirmAction } from '../components/ConfirmDialog'
import type { DownloadTask, QBitTorrent } from '../types'
type DownloadCardItem = {
@@ -297,7 +298,7 @@ export function DownloadsPage() {
item={toLiveCard(torrent)}
removable={role === 'admin'}
onRemove={async () => {
if (!confirm(`删除「${torrent.title || torrent.name}」?`)) return
if (!(await confirmAction({ title: '删除下载任务', message: `删除「${torrent.title || torrent.name}」?`, confirmText: '删除' }))) return
await downloadsAPI.remove(torrent.hash, false)
toast.success('已删除任务')
await refresh()
+2 -1
View File
@@ -4,6 +4,7 @@ import { Copy, Trash2 } from 'lucide-react'
import { duplicatesAPI, type DuplicateReport } from '../api/duplicates'
import { libraryAPI } from '../api/library'
import { confirmAction } from '../components/ConfirmDialog'
import type { Library } from '../types'
function fmtBytes(n: number): string {
@@ -50,7 +51,7 @@ export function DuplicatesPage() {
}
const unmark = async () => {
if (!confirm('清除所有重复标记?(磁盘文件不会被删除)')) return
if (!(await confirmAction({ title: '清除重复标记', message: '清除所有重复标记?(磁盘文件不会被删除)', confirmText: '清除' }))) return
const r = await duplicatesAPI.unmark(libID)
toast.success(`已清除 ${r.unmarked} 项`)
setReport(null)
+27 -21
View File
@@ -7,6 +7,7 @@ import { ArrowLeft, Play, Film } from 'lucide-react'
import { libraryAPI } from '../api/library'
import type { Library, Media } from '../types'
import { MediaCard } from '../components/MediaCard'
import { ExternalPlayerButton } from '../components/ExternalPlayerButton'
import { imageURL } from '../api/client'
import { useAuthStore } from '../stores/auth'
import { getSeriesKey, groupSeries, isEpisodeLike, seriesTitle, type SeriesCard } from '../utils/groupSeries'
@@ -248,10 +249,13 @@ export function LibraryPage() {
)
const first = firstEps.length > 0 ? firstEps[0] : null
return first ? (
<Link to={`/play/${first.id}`} className="btn-primary inline-flex">
<Play size={16} fill="currentColor" />
从第一集开始播放
</Link>
<div className="flex flex-wrap gap-2">
<Link to={`/play/${first.id}`} className="btn-primary inline-flex">
<Play size={16} fill="currentColor" />
从第一集开始播放
</Link>
<ExternalPlayerButton mediaId={first.id} label="外部播放器播放" />
</div>
) : null
})()}
</div>
@@ -282,26 +286,28 @@ export function LibraryPage() {
</h3>
<div className="grid grid-cols-2 gap-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-6">
{visibleEpisodes.map((ep) => (
<Link
<div
key={ep.id}
to={`/play/${ep.id}`}
className="group flex items-center gap-3 rounded-xl border border-sand-200 bg-white p-3 shadow-card transition-all hover:border-brand-300 hover:shadow-card-hover"
>
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-xl bg-brand-50 text-brand-600 font-semibold text-sm">
{ep.episode_num || '—'}
</div>
<div className="min-w-0 flex-1">
<p className="truncate text-sm font-medium text-ink-600">
{ep.original_name || (ep.episode_num > 0 ? `第 ${ep.episode_num} 集` : ep.title)}
</p>
<p className="text-xs text-sand-500">
{ep.duration_sec > 0
? `${Math.floor(ep.duration_sec / 60)} 分钟`
: formatSize(ep.size_bytes)}
</p>
</div>
<Play size={14} className="shrink-0 text-gray-500 opacity-0 transition-opacity group-hover:opacity-100 group-hover:text-brand-500" />
</Link>
<Link to={`/play/${ep.id}`} className="flex min-w-0 flex-1 items-center gap-3">
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-xl bg-brand-50 text-brand-600 font-semibold text-sm">
{ep.episode_num || '—'}
</div>
<div className="min-w-0 flex-1">
<p className="truncate text-sm font-medium text-ink-600">
{ep.original_name || (ep.episode_num > 0 ? `第 ${ep.episode_num} 集` : ep.title)}
</p>
<p className="text-xs text-sand-500">
{ep.duration_sec > 0
? `${Math.floor(ep.duration_sec / 60)} 分钟`
: formatSize(ep.size_bytes)}
</p>
</div>
<Play size={14} className="shrink-0 text-gray-500 opacity-0 transition-opacity group-hover:opacity-100 group-hover:text-brand-500" />
</Link>
<ExternalPlayerButton mediaId={ep.id} label="外部" compact />
</div>
))}
</div>
</div>
+1
View File
@@ -198,6 +198,7 @@ export function LicensePage() {
/>
<StatusBadge label="最近心跳" value={fmtDateTime(activation.heartbeat_at)} />
<StatusBadge label="客户端 IP" value={activation.ip ?? '—'} />
<StatusBadge label="用户额度" value={`${status?.max_users ?? 20} 人`} />
</div>
)}
+6 -2
View File
@@ -11,6 +11,8 @@ import { imageURL } from '../api/client'
import { useAuthStore } from '../stores/auth'
import { api } from '../api/client'
import type { Media } from '../types'
import { confirmAction } from '../components/ConfirmDialog'
import { ExternalPlayerButton } from '../components/ExternalPlayerButton'
function fmtDuration(sec: number): string {
if (!sec || sec <= 0) return '—'
@@ -107,7 +109,7 @@ export function MediaDetailPage() {
const softDelete = async () => {
if (!media) return
if (!confirm(`将「${media.title}」移至回收站? (磁盘文件保留)`)) return
if (!(await confirmAction({ title: '移入回收站', message: `将「${media.title}」移至回收站? (磁盘文件保留)`, confirmText: '移入回收站' }))) return
await recycleAPI.softDelete(media.id)
toast.success('已移至回收站')
navigate(-1)
@@ -287,6 +289,8 @@ export function MediaDetailPage() {
<span>HLS 兼容转码播放</span>
</Link>
<ExternalPlayerButton mediaId={media.id} />
{/* Toggle Favourites */}
<button
onClick={toggleFav}
@@ -334,4 +338,4 @@ export function MediaDetailPage() {
</div>
</div>
)
}
}
+2 -1
View File
@@ -6,6 +6,7 @@ import {
notifyChannelsAPI,
type NotifyChannelInput,
} from '../api/notify_channels'
import { confirmAction } from '../components/ConfirmDialog'
import type { NotifyChannel } from '../types'
// NotifyChannelsPage replaces the Vue NotifyTab. Operators can register
@@ -42,7 +43,7 @@ export function NotifyChannelsPage() {
}
const onDelete = async (ch: NotifyChannel) => {
if (!confirm(`确定删除「${ch.name}」?`)) return
if (!(await confirmAction({ title: '删除通知渠道', message: `确定删除「${ch.name}」?`, confirmText: '删除' }))) return
try {
await notifyChannelsAPI.remove(ch.id)
toast.success('已删除')
+2 -1
View File
@@ -4,6 +4,7 @@ import { ListPlus, Trash2 } from 'lucide-react'
import toast from 'react-hot-toast'
import { playbackAPI } from '../api/playback'
import { confirmAction } from '../components/ConfirmDialog'
import type { Playlist } from '../types'
// Landing page for playlists. Lists every playlist owned by the current
@@ -74,7 +75,7 @@ export function PlaylistsPage() {
</Link>
<button
onClick={async () => {
if (!confirm(`删除「${p.name}」?`)) return
if (!(await confirmAction({ title: '删除播放列表', message: `删除「${p.name}」?`, confirmText: '删除' }))) return
await playbackAPI.deletePlaylist(p.id)
toast.success('已删除')
await refresh()
+2 -1
View File
@@ -5,6 +5,7 @@ import toast from 'react-hot-toast'
import { libraryAPI } from '../api/library'
import { playProfilesAPI, type PlayProfileInput } from '../api/play_profiles'
import { useAuthStore } from '../stores/auth'
import { confirmAction } from '../components/ConfirmDialog'
import type { Library, PlayProfile } from '../types'
// ProfileManagementPage replicates the Vue ProfileManagementView. It
@@ -42,7 +43,7 @@ export function ProfileManagementPage() {
}, [isAdmin])
const onDelete = async (p: PlayProfile) => {
if (!confirm(`确定删除 Profile「${p.name}」?`)) return
if (!(await confirmAction({ title: '删除播放档案', message: `确定删除 Profile「${p.name}」?`, confirmText: '删除' }))) return
try {
await playProfilesAPI.remove(p.id)
toast.success('已删除')
+2 -1
View File
@@ -3,6 +3,7 @@ import toast from 'react-hot-toast'
import { RotateCcw, Trash2 } from 'lucide-react'
import { recycleAPI } from '../api/recycle'
import { confirmAction } from '../components/ConfirmDialog'
import type { Media } from '../types'
export function RecycleBinPage() {
@@ -60,7 +61,7 @@ export function RecycleBinPage() {
<button
className="rounded-lg border border-red-400/40 px-2 py-1 text-xs text-red-400 hover:bg-red-400/10"
onClick={async () => {
if (!confirm(`彻底删除「${m.title}」? (磁盘文件保留)`)) return
if (!(await confirmAction({ title: '彻底删除记录', message: `彻底删除「${m.title}」? (磁盘文件保留)`, confirmText: '彻底删除' }))) return
await recycleAPI.purge(m.id)
toast.success('已彻底删除')
await refresh()
+129 -3
View File
@@ -1,6 +1,6 @@
import { ChangeEvent, FormEvent, useCallback, useEffect, useMemo, useState } from 'react'
import toast from 'react-hot-toast'
import { Rss, Sparkles } from 'lucide-react'
import { CheckCircle2, Info, Rss, Sparkles } from 'lucide-react'
import { aiAPI, type ExternalMediaResult, type SearchIntent } from '../api/ai'
import { imageURL } from '../api/client'
@@ -199,6 +199,7 @@ export function SearchPage() {
poster_url: item.poster_url,
backdrop_url: item.backdrop_url,
overview: item.overview,
total_episodes: item.total_episodes,
enabled: true,
})
const run = await subscriptionsAPI.runNow(sub.id)
@@ -231,6 +232,7 @@ function ExternalResults({
busyKey: string
onSubscribe: (item: ExternalMediaResult) => Promise<void>
}) {
const [detail, setDetail] = useState<ExternalMediaResult | null>(null)
return (
<section className="space-y-3">
<div>
@@ -244,7 +246,16 @@ function ExternalResults({
const keyword = item.subscribe_keyword || item.title
const key = `${item.source}:${keyword}`
return (
<article key={key} className="glass-panel flex gap-3 !p-3">
<article
key={key}
role="button"
tabIndex={0}
onClick={() => setDetail(item)}
onKeyDown={(event) => {
if (event.key === 'Enter' || event.key === ' ') setDetail(item)
}}
className="glass-panel flex cursor-pointer gap-3 !p-3 transition hover:-translate-y-0.5 hover:shadow-lg"
>
<div className="h-28 w-20 shrink-0 overflow-hidden rounded-xl bg-gray-100">
{item.poster_url ? (
<img
@@ -267,8 +278,21 @@ function ExternalResults({
<p className="mt-1 line-clamp-2 text-xs text-ink-50">
{item.overview || `订阅关键词:${keyword}`}
</p>
<div className="mt-2 flex flex-wrap gap-1.5 text-[10px]">
<span className={'rounded-full px-2 py-0.5 font-semibold ' + (item.in_library ? 'bg-emerald-50 text-emerald-600' : 'bg-amber-50 text-amber-600')}>
{item.in_library ? '本地已入库' : '本地未入库'}
</span>
{isSeriesItem(item) ? (
<span className="rounded-full bg-gray-100 px-2 py-0.5 text-ink-100">
已有 {item.downloaded_episodes || 0}/{item.total_episodes || '未知'} 集
</span>
) : null}
</div>
<button
onClick={() => onSubscribe(item)}
onClick={(event) => {
event.stopPropagation()
onSubscribe(item)
}}
disabled={busyKey === key}
className="mt-3 rounded-lg border border-primary-400/40 px-2 py-1 text-xs text-brand-500 hover:bg-primary-400/10 disabled:opacity-50"
>
@@ -280,6 +304,108 @@ function ExternalResults({
)
})}
</div>
{detail && (
<ExternalDetailModal
item={detail}
busy={busyKey === `${detail.source}:${detail.subscribe_keyword || detail.title}`}
onClose={() => setDetail(null)}
onSubscribe={onSubscribe}
/>
)}
</section>
)
}
function ExternalDetailModal({
item,
busy,
onClose,
onSubscribe,
}: {
item: ExternalMediaResult
busy: boolean
onClose: () => void
onSubscribe: (item: ExternalMediaResult) => Promise<void>
}) {
const missing = item.missing_episodes ?? []
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/35 p-4 backdrop-blur-sm" onClick={onClose}>
<div className="max-h-[88vh] w-full max-w-3xl overflow-hidden rounded-3xl bg-white shadow-2xl" onClick={(event) => event.stopPropagation()}>
<div className="grid gap-0 md:grid-cols-[220px,1fr]">
<div className="min-h-72 bg-gray-100">
{item.poster_url ? (
<img src={imageURL(item.poster_url)} alt={item.title} className="h-full w-full object-cover" />
) : (
<div className="flex h-full min-h-72 items-center justify-center text-brand-500">
<Info size={42} />
</div>
)}
</div>
<div className="space-y-4 p-5">
<div>
<div className="mb-2 flex flex-wrap gap-2 text-xs">
<span className="rounded-full bg-primary-400/10 px-2 py-0.5 font-semibold uppercase text-brand-500">{item.source}</span>
{item.media_type ? <span className="rounded-full bg-gray-100 px-2 py-0.5 text-ink-100">{item.media_type}</span> : null}
{item.year ? <span className="rounded-full bg-gray-100 px-2 py-0.5 text-ink-100">{item.year}</span> : null}
{item.rating ? <span className="rounded-full bg-amber-50 px-2 py-0.5 text-amber-600">★ {item.rating.toFixed(1)}</span> : null}
</div>
<h3 className="font-display text-2xl font-bold text-ink-600">{item.title}</h3>
<p className="mt-2 text-sm leading-6 text-ink-50">{item.overview || '暂无简介。'}</p>
</div>
<div className="grid gap-3 sm:grid-cols-3">
<StatusBox label="入库状态" value={item.in_library ? '已入库' : '未入库'} ok={item.in_library} />
<StatusBox label="本地条目" value={`${item.local_media_count || 0} 个`} />
<StatusBox label="剧集进度" value={isSeriesItem(item) ? `${item.downloaded_episodes || 0}/${item.total_episodes || '未知'} 集` : '单部影片'} />
</div>
{isSeriesItem(item) && (
<div className="rounded-2xl border border-gray-100 bg-gray-50 p-3 text-sm">
<div className="mb-2 font-semibold text-ink-600">缺失情况</div>
{missing.length > 0 ? (
<div className="flex flex-wrap gap-1.5">
{missing.slice(0, 80).map((episode) => (
<span key={episode} className="rounded-full bg-white px-2 py-0.5 text-xs text-ink-100 shadow-sm">第 {episode} 集</span>
))}
{missing.length > 80 ? <span className="text-xs text-sand-500">还有 {missing.length - 80} 集…</span> : null}
</div>
) : item.in_library && item.total_episodes ? (
<p className="flex items-center gap-1.5 text-emerald-600"><CheckCircle2 size={15} /> 已完整入库</p>
) : (
<p className="text-sand-500">总集数未知,订阅时会跳过本地已有单集,优先补新集。</p>
)}
</div>
)}
<div className="flex justify-end gap-2 pt-2">
<button onClick={onClose} className="rounded-xl border border-gray-200 px-4 py-2 text-sm text-ink-100 hover:bg-gray-50">关闭</button>
<button
disabled={busy}
onClick={async () => {
await onSubscribe(item)
onClose()
}}
className="neon-button"
>
<Rss size={14} /> {busy ? '订阅中…' : item.in_library ? '补全缺失集' : '订阅全集'}
</button>
</div>
</div>
</div>
</div>
</div>
)
}
function StatusBox({ label, value, ok }: { label: string; value: string; ok?: boolean }) {
return (
<div className="rounded-2xl border border-gray-100 bg-gray-50 p-3">
<div className="text-xs text-sand-500">{label}</div>
<div className={'mt-1 font-semibold ' + (ok ? 'text-emerald-600' : 'text-ink-600')}>{value}</div>
</div>
)
}
function isSeriesItem(item: ExternalMediaResult) {
return ['tv', 'anime', 'variety'].includes((item.media_type || '').toLowerCase())
}
+19
View File
@@ -85,6 +85,25 @@ const GROUPS: SettingGroup[] = [
},
],
},
{
key: 'license',
label: '授权服务',
description: '连接私有 MediaStationLicenseServer;开源版默认最多 20 个用户,激活后按授权策略提升额度。',
items: [
{
key: 'license.server_url',
label: 'License Server 地址',
type: 'text',
placeholder: 'http://127.0.0.1:8001',
},
{
key: 'license.hmac_secret',
label: 'HMAC 签名密钥',
type: 'text',
hint: '必须与 License Server 的 LICENSE_HMAC_SECRET 保持一致;留空则跳过响应签名校验。',
},
],
},
{
key: 'organize',
label: '整理 & 刮削',
+2 -1
View File
@@ -5,6 +5,7 @@ import { Globe, Plus, Trash2, Wifi, RefreshCw, X, Edit3, CheckCircle, XCircle, H
import { sitesAPI } from '../api/sites'
import type { Site } from '../types'
import { ManagementShortcuts } from '../components/ManagementShortcuts'
import { confirmAction } from '../components/ConfirmDialog'
// ── 站点类型映射 ──
const SITE_TYPE_LABELS: Record<string, string> = {
@@ -215,7 +216,7 @@ export function SitesPage() {
// ── 删除 ──
const handleDelete = async (site: Site) => {
if (!confirm(`确定要删除站点「${site.name}」吗?此操作不可撤销。`)) return
if (!(await confirmAction({ title: '删除站点', message: `确定要删除站点「${site.name}」吗?此操作不可撤销。`, confirmText: '删除' }))) return
try {
await sitesAPI.remove(site.id)
toast.success('站点已删除')
+2 -1
View File
@@ -4,6 +4,7 @@ import toast from 'react-hot-toast'
import { libraryAPI, mediaAPI } from '../api/library'
import { strmAPI } from '../api/strm'
import { confirmAction } from '../components/ConfirmDialog'
import type { Library, Media } from '../types'
// StrmPage exposes the URL-as-file admin tooling backed by the Go server:
@@ -97,7 +98,7 @@ export function StrmPage() {
}
const onDetach = async (m: Media) => {
if (!confirm(`清除「${m.title}」的 STRM URL?`)) return
if (!(await confirmAction({ title: '清除 STRM URL', message: `清除「${m.title}」的 STRM URL?`, confirmText: '清除' }))) return
try {
await strmAPI.clear(m.id)
toast.success('已清除')
+21 -2
View File
@@ -1,9 +1,10 @@
import { FormEvent, useEffect, useState } from 'react'
import toast from 'react-hot-toast'
import { CalendarClock, Film, Pencil, Play, Plus, Save, ShieldCheck, Trash2 } from 'lucide-react'
import { CalendarClock, CheckCircle2, Film, Pencil, Play, Plus, Save, ShieldCheck, Trash2 } from 'lucide-react'
import { subscriptionsAPI } from '../api/subscriptions'
import { imageURL } from '../api/client'
import { confirmAction } from '../components/ConfirmDialog'
import type { Subscription } from '../types'
export function SubscriptionsPage() {
@@ -258,6 +259,10 @@ export function SubscriptionsPage() {
<CalendarClock size={13} className="text-brand-500" />
<span>{subscription.last_run_at ? new Date(subscription.last_run_at).toLocaleString() : '尚未运行'}</span>
</div>
<div className="flex items-center gap-1.5">
<CheckCircle2 size={13} className="text-brand-500" />
<span>{subscriptionProgressLabel(subscription)}</span>
</div>
</div>
<div className="flex flex-wrap gap-1.5">
@@ -291,7 +296,7 @@ export function SubscriptionsPage() {
<button
className="rounded-xl border border-red-400/40 bg-white px-3 py-1.5 text-xs font-semibold text-red-400 hover:bg-red-400/10"
onClick={async () => {
if (!confirm(`删除订阅「${subscription.name}」?`)) return
if (!(await confirmAction({ title: '删除订阅', message: `删除订阅「${subscription.name}」?`, confirmText: '删除' }))) return
await subscriptionsAPI.remove(subscription.id)
toast.success('已删除')
await refresh()
@@ -321,6 +326,20 @@ function subscriptionRuleBadges(subscription: Subscription): string[] {
return labels.filter(Boolean)
}
function subscriptionProgressLabel(subscription: Subscription): string {
const isSeries = ['tv', 'anime', 'variety'].includes((subscription.media_type || '').toLowerCase())
if (!isSeries) {
return subscription.in_library ? '本地已入库' : '本地未入库'
}
const downloaded = subscription.downloaded_episodes || 0
const total = subscription.total_episodes || 0
if (total > 0) {
const missing = subscription.missing_episodes?.length || 0
return missing > 0 ? `已下载 ${downloaded}/${total} 集,缺 ${missing} 集` : `已下载 ${downloaded}/${total} 集`
}
return `已下载 ${downloaded}/未知 集`
}
function washPriorityLabel(priority?: string): string {
switch (priority) {
case 'resolution':
+3 -2
View File
@@ -5,6 +5,7 @@ import toast from 'react-hot-toast'
import { historyAPI } from '../api/history'
import { imageURL } from '../api/client'
import { confirmAction } from '../components/ConfirmDialog'
import type { HistoryItem } from '../types'
function fmtDuration(ms: number): string {
@@ -33,7 +34,7 @@ export function WatchHistoryPage() {
useEffect(() => { load() }, [])
const removeOne = async (id: string) => {
if (!confirm('确定移除此条观看历史?不会删除媒体文件。')) return
if (!(await confirmAction({ title: '移除观看历史', message: '确定移除此条观看历史?不会删除媒体文件。', confirmText: '移除' }))) return
setBusy(id)
try {
await historyAPI.remove(id)
@@ -46,7 +47,7 @@ export function WatchHistoryPage() {
const clearByStatus = async (status: 'completed' | 'incomplete') => {
const label = status === 'completed' ? '已看完' : '未看完'
if (!confirm(`确定清除所有${label}的观看历史?不会删除媒体文件。`)) return
if (!(await confirmAction({ title: '清除观看历史', message: `确定清除所有${label}的观看历史?不会删除媒体文件。`, confirmText: '清除' }))) return
setBusy(status)
try {
await historyAPI.clear(undefined, status)
+7
View File
@@ -10,6 +10,8 @@ export interface User {
avatar_url?: string
force_password_reset: boolean
is_active: boolean
is_default_admin?: boolean
is_protected?: boolean
last_login_at?: string
created_at: string
updated_at: string
@@ -189,6 +191,11 @@ export interface Subscription {
exclude_words?: string
wash_enabled?: boolean
wash_priority?: string
total_episodes?: number
downloaded_episodes?: number
local_media_count?: number
missing_episodes?: number[]
in_library?: boolean
priority?: number
enabled: boolean
last_run_at?: string