diff --git a/.gitignore b/.gitignore index a380343..51686e5 100644 --- a/.gitignore +++ b/.gitignore @@ -27,6 +27,8 @@ data/ cache/ logs/ .tmp-deploy-data/ +.tmp-deploy-smoke-data/ +.tmp-deploy-smoke-cache/ .tmp-deploy-cache/ .tmp-deploy-server.* .mediastation.pid diff --git a/internal/handler/cloud.go b/internal/handler/cloud.go index dc9bcf6..296e983 100644 --- a/internal/handler/cloud.go +++ b/internal/handler/cloud.go @@ -3,14 +3,11 @@ package handler import ( - "context" "io" "net/http" "strings" - "time" "github.com/gin-gonic/gin" - "go.uber.org/zap" "github.com/ShukeBta/MediaStationGo/internal/model" "github.com/ShukeBta/MediaStationGo/internal/service" @@ -146,34 +143,7 @@ func cloudMountHandler(svc *service.Container) gin.HandlerFunc { "estimate_message": "小目录通常几十秒;几万文件的大目录可能需要数分钟到数小时,取决于网盘接口速度", }) } - go func() { - ctx, cancel := context.WithTimeout(context.Background(), 6*time.Hour) - defer cancel() - res, err := svc.Scan.ScanLibraryWithoutAutoScrape(ctx, libID) - if err != nil { - if svc.Log != nil { - svc.Log.Warn("cloud mount background scan failed", zap.String("library_id", libID), zap.Error(err)) - } - if svc.WSHub != nil { - svc.WSHub.Publish("scan", gin.H{ - "library_id": libID, - "cloud": true, - "finished": true, - "error": err.Error(), - }) - } - return - } - if svc.Log != nil { - svc.Log.Info("cloud mount background scan finished", - zap.String("library_id", libID), - zap.Int("visited", res.Visited), - zap.Int("added", res.Added), - zap.Int("updated", res.Updated), - zap.Int("skipped", res.Skipped), - zap.Int64("removed", res.Removed)) - } - }() + _, _, _ = svc.Scan.StartCloudLibraryScan(libID, false) } c.JSON(http.StatusAccepted, gin.H{ "library": lib, @@ -185,6 +155,62 @@ func cloudMountHandler(svc *service.Container) gin.HandlerFunc { } } +func cloudScanAllHandler(svc *service.Container) gin.HandlerFunc { + return func(c *gin.Context) { + if svc.Scan == nil { + c.JSON(http.StatusServiceUnavailable, gin.H{"error": "scanner unavailable"}) + return + } + statuses, err := svc.Scan.StartAllCloudLibraryScans() + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + c.JSON(http.StatusAccepted, gin.H{ + "items": statuses, + "scan_queued": true, + "message": "已开始扫描所有启用的网盘媒体库", + "resume_message": "中断后再次点击扫描会重新遍历,但已入库媒体会去重更新,只补齐缺失项。", + "estimate_message": "小目录通常几十秒;几万文件的大目录可能需要数分钟到数小时,取决于网盘接口速度", + }) + } +} + +func cloudScanCancelHandler(svc *service.Container) gin.HandlerFunc { + return func(c *gin.Context) { + if svc.Scan == nil { + c.JSON(http.StatusServiceUnavailable, gin.H{"error": "scanner unavailable"}) + return + } + libraryID := strings.TrimSpace(c.Query("library_id")) + provider := strings.TrimSpace(c.Query("provider")) + cancelled := 0 + if libraryID != "" { + if svc.Scan.CancelCloudScan(libraryID) { + cancelled = 1 + } + } else if provider != "" { + cancelled = svc.Scan.CancelCloudScansForProvider(provider) + } else { + cancelled = svc.Scan.CancelAllCloudScans() + } + c.JSON(http.StatusOK, gin.H{ + "cancelled": cancelled, + "message": "已发送中断信号;正在等待当前网盘请求返回后停止", + }) + } +} + +func cloudScanStatusHandler(svc *service.Container) gin.HandlerFunc { + return func(c *gin.Context) { + if svc.Scan == nil { + c.JSON(http.StatusOK, gin.H{"items": []service.CloudScanStatus{}}) + return + } + c.JSON(http.StatusOK, gin.H{"items": svc.Scan.CloudScanStatuses()}) + } +} + func cloudMountLibraryName(typ, dir string) string { base := typ switch typ { @@ -253,47 +279,74 @@ func cloudPlayHandler(svc *service.Container) gin.HandlerFunc { c.JSON(http.StatusBadRequest, gin.H{"error": "ref required"}) return } - link, err := svc.StorageCfg.CloudResolve(c.Request.Context(), typ, ref, c.Request.UserAgent()) - if err != nil { - c.JSON(http.StatusBadGateway, gin.H{"error": err.Error()}) - return - } - if !link.Proxy { - // Pure offload: send the client straight to the cloud CDN. - c.Redirect(http.StatusFound, link.URL) - return - } - // Proxy mode: the direct link needs auth headers the browser cannot - // carry. Stream through with Range forwarding. - method := c.Request.Method - if method == "" { - method = http.MethodGet - } - req, err := http.NewRequestWithContext(c.Request.Context(), method, link.URL, nil) - if err != nil { - c.JSON(http.StatusBadGateway, gin.H{"error": err.Error()}) - return - } - for k, v := range link.Headers { - req.Header.Set(k, v) - } - if rng := c.GetHeader("Range"); rng != "" { - req.Header.Set("Range", rng) - } - resp, err := http.DefaultClient.Do(req) - if err != nil { - c.JSON(http.StatusBadGateway, gin.H{"error": err.Error()}) - return - } - defer resp.Body.Close() - for _, h := range []string{"Content-Type", "Content-Length", "Content-Range", "Accept-Ranges"} { - if v := resp.Header.Get(h); v != "" { - c.Header(h, v) - } - } - c.Status(resp.StatusCode) - if c.Request.Method != http.MethodHead { - _, _ = io.Copy(c.Writer, resp.Body) - } + serveCloudResolvedLink(svc, c, typ, ref) } } + +func serveCloudResolvedLink(svc *service.Container, c *gin.Context, typ, ref string) { + if svc == nil || svc.StorageCfg == nil { + c.JSON(http.StatusServiceUnavailable, gin.H{"error": "cloud storage service unavailable"}) + return + } + link, err := svc.StorageCfg.CloudResolve(c.Request.Context(), typ, ref, c.Request.UserAgent()) + if err != nil { + c.JSON(http.StatusBadGateway, gin.H{"error": err.Error()}) + return + } + if isCloudImageRef(ref) && svc.ImageProxy != nil { + if err := svc.ImageProxy.ServeCloudResolved(c.Request.Context(), c.Writer, c.Request, typ+":"+ref, link); err != nil { + c.JSON(http.StatusBadGateway, gin.H{"error": err.Error()}) + } + return + } + if isCloudImageRef(ref) { + c.Header("Cache-Control", "public, max-age=2592000, immutable") + } + if !link.Proxy { + // Pure offload: send the client straight to the cloud CDN. + c.Redirect(http.StatusFound, link.URL) + return + } + // Proxy mode: the direct link needs auth headers the browser cannot + // carry. Stream through with Range forwarding. + method := c.Request.Method + if method == "" { + method = http.MethodGet + } + req, err := http.NewRequestWithContext(c.Request.Context(), method, link.URL, nil) + if err != nil { + c.JSON(http.StatusBadGateway, gin.H{"error": err.Error()}) + return + } + for k, v := range link.Headers { + req.Header.Set(k, v) + } + if rng := c.GetHeader("Range"); rng != "" { + req.Header.Set("Range", rng) + } + resp, err := http.DefaultClient.Do(req) + if err != nil { + c.JSON(http.StatusBadGateway, gin.H{"error": err.Error()}) + return + } + defer resp.Body.Close() + for _, h := range []string{"Content-Type", "Content-Length", "Content-Range", "Accept-Ranges"} { + if v := resp.Header.Get(h); v != "" { + c.Header(h, v) + } + } + c.Status(resp.StatusCode) + if c.Request.Method != http.MethodHead { + _, _ = io.Copy(c.Writer, resp.Body) + } +} + +func isCloudImageRef(ref string) bool { + ref = strings.ToLower(strings.TrimSpace(ref)) + for _, suffix := range []string{".jpg", ".jpeg", ".png", ".webp", ".gif", ".bmp"} { + if strings.HasSuffix(ref, suffix) { + return true + } + } + return false +} diff --git a/internal/handler/emby.go b/internal/handler/emby.go index 0862898..c2ac7e2 100644 --- a/internal/handler/emby.go +++ b/internal/handler/emby.go @@ -610,6 +610,10 @@ func embyItemImageHandler(svc *service.Container) gin.HandlerFunc { c.Status(http.StatusNotFound) return } + if typ, ref, ok := parseCloudPlayImageURL(raw); ok { + serveCloudResolvedLink(svc, c, typ, ref) + return + } if svc.ImageProxy == nil { c.Status(http.StatusNotFound) return @@ -620,6 +624,28 @@ func embyItemImageHandler(svc *service.Container) gin.HandlerFunc { } } +func parseCloudPlayImageURL(raw string) (string, string, bool) { + raw = strings.TrimSpace(raw) + if raw == "" { + return "", "", false + } + u, err := url.Parse(raw) + if err != nil { + return "", "", false + } + path := strings.Trim(u.Path, "/") + const prefix = "api/cloud/play/" + if !strings.HasPrefix(path, prefix) { + return "", "", false + } + typ := strings.TrimSpace(strings.TrimPrefix(path, prefix)) + ref := strings.TrimSpace(u.Query().Get("ref")) + if typ == "" || ref == "" { + return "", "", false + } + return typ, ref, true +} + func embyShowSeasonsHandler(svc *service.Container) gin.HandlerFunc { return func(c *gin.Context) { params := service.ItemsParams{ diff --git a/internal/handler/media.go b/internal/handler/media.go index 9a84b6c..c22e504 100644 --- a/internal/handler/media.go +++ b/internal/handler/media.go @@ -6,10 +6,8 @@ import ( "errors" "net/http" "strconv" - "time" "github.com/gin-gonic/gin" - "go.uber.org/zap" "github.com/ShukeBta/MediaStationGo/internal/middleware" "github.com/ShukeBta/MediaStationGo/internal/service" @@ -68,6 +66,11 @@ func createLibraryHandler(svc *service.Container) gin.HandlerFunc { func deleteLibraryHandler(svc *service.Container) gin.HandlerFunc { return func(c *gin.Context) { id := c.Param("id") + if lib, err := svc.Repo.Library.FindByID(c.Request.Context(), id); err == nil && lib != nil { + if _, ok := service.ParseCloudLibraryMount(lib.Path); ok && svc.Scan != nil { + _ = svc.Scan.CancelCloudScan(id) + } + } if err := svc.Media.DeleteLibrary(c.Request.Context(), id); err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return @@ -102,23 +105,7 @@ func scanLibraryHandler(svc *service.Container) gin.HandlerFunc { "estimate_message": "小目录通常几十秒;几万文件的大目录可能需要数分钟到数小时,取决于网盘接口速度", }) } - go func(libraryID string) { - ctx, cancel := context.WithTimeout(context.Background(), 6*time.Hour) - defer cancel() - if _, err := svc.Scan.ScanLibraryWithoutAutoScrape(ctx, libraryID); err != nil { - if svc.Log != nil { - svc.Log.Warn("cloud library async scan failed", zap.String("library_id", libraryID), zap.Error(err)) - } - if svc.WSHub != nil { - svc.WSHub.Publish("scan", gin.H{ - "library_id": libraryID, - "cloud": true, - "finished": true, - "error": err.Error(), - }) - } - } - }(id) + _, _, _ = svc.Scan.StartCloudLibraryScan(id, false) c.JSON(http.StatusAccepted, gin.H{ "library_id": id, "visited": 0, diff --git a/internal/handler/routes_admin.go b/internal/handler/routes_admin.go index 12156d8..d0c5fa0 100644 --- a/internal/handler/routes_admin.go +++ b/internal/handler/routes_admin.go @@ -38,6 +38,9 @@ func registerAdminRoutes(api *gin.RouterGroup, cfg *config.Config, svc *service. admin.POST("/storage/:type/upload-local", storageUploadLocalHandler(svc)) // Cloud disk (115 / 夸克) browsing, QR login and 302 import. + admin.POST("/cloud/scan-all", cloudScanAllHandler(svc)) + admin.POST("/cloud/scan/cancel", cloudScanCancelHandler(svc)) + admin.GET("/cloud/scan/status", cloudScanStatusHandler(svc)) admin.GET("/cloud/:type/list", cloudListHandler(svc)) admin.POST("/cloud/:type/import", cloudImportHandler(svc)) admin.POST("/cloud/:type/mount", cloudMountHandler(svc)) diff --git a/internal/handler/stats.go b/internal/handler/stats.go index ecc275a..fb5659a 100644 --- a/internal/handler/stats.go +++ b/internal/handler/stats.go @@ -32,8 +32,14 @@ func applyStatsVisibility(c *gin.Context, svc *service.Container, snap *service. if err != nil { return err } + libs = service.FilterShadowedCloudLibraries(libs) var visibleLibraries int64 + activeLibraryIDs := make([]string, 0, len(libs)) for _, lib := range libs { + if !lib.Enabled { + continue + } + activeLibraryIDs = append(activeLibraryIDs, lib.ID) if service.LibraryVisibleForUser(c.Request.Context(), svc.Repo, lib, visibility) { visibleLibraries++ } @@ -41,6 +47,7 @@ func applyStatsVisibility(c *gin.Context, svc *service.Container, snap *service. snap.Libraries = visibleLibraries q := applyMediaVisibilityQuery(svc.Repo.DB.WithContext(c.Request.Context()).Model(&model.Media{}), visibility) + q = applyActiveLibraryQuery(q, activeLibraryIDs) if err := q.Count(&snap.MediaCount).Error; err != nil { return err } @@ -49,7 +56,7 @@ func applyStatsVisibility(c *gin.Context, svc *service.Container, snap *service. Seconds int64 } var sum sumRow - if err := applyMediaVisibilityQuery(svc.Repo.DB.WithContext(c.Request.Context()).Model(&model.Media{}), visibility). + if err := applyActiveLibraryQuery(applyMediaVisibilityQuery(svc.Repo.DB.WithContext(c.Request.Context()).Model(&model.Media{}), visibility), activeLibraryIDs). Select("COALESCE(SUM(size_bytes),0) as size, COALESCE(SUM(duration_sec),0) as seconds"). Scan(&sum).Error; err != nil { return err @@ -58,7 +65,7 @@ func applyStatsVisibility(c *gin.Context, svc *service.Container, snap *service. snap.TotalSeconds = sum.Seconds var recent []model.Media - if err := applyMediaVisibilityQuery(svc.Repo.DB.WithContext(c.Request.Context()).Model(&model.Media{}), visibility). + if err := applyActiveLibraryQuery(applyMediaVisibilityQuery(svc.Repo.DB.WithContext(c.Request.Context()).Model(&model.Media{}), visibility), activeLibraryIDs). Order("created_at desc"). Limit(12). Find(&recent).Error; err != nil { @@ -80,3 +87,10 @@ func applyMediaVisibilityQuery(q *gorm.DB, visibility service.MediaVisibility) * } return q } + +func applyActiveLibraryQuery(q *gorm.DB, libraryIDs []string) *gorm.DB { + if len(libraryIDs) == 0 { + return q.Where("1 = 0") + } + return q.Where("library_id IN ?", libraryIDs) +} diff --git a/internal/handler/stats_extra.go b/internal/handler/stats_extra.go index 87046b1..fb30df8 100644 --- a/internal/handler/stats_extra.go +++ b/internal/handler/stats_extra.go @@ -129,14 +129,18 @@ func statsTopContentHandler(svc *service.Container) gin.HandlerFunc { // statsLibrariesHandler returns per-library counts + size. func statsLibrariesHandler(svc *service.Container) gin.HandlerFunc { return func(c *gin.Context) { - var libs []model.Library - if err := svc.Repo.DB.Find(&libs).Error; err != nil { + libs, err := svc.Repo.Library.List(c.Request.Context()) + if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } + libs = service.FilterShadowedCloudLibraries(libs) out := make([]gin.H, 0, len(libs)) visibility := mediaVisibilityForRequest(c, svc) for _, l := range libs { + if !l.Enabled { + continue + } if !service.LibraryVisibleForUser(c.Request.Context(), svc.Repo, l, visibility) { continue } diff --git a/internal/handler/storage_config.go b/internal/handler/storage_config.go index 2e2cd73..b595632 100644 --- a/internal/handler/storage_config.go +++ b/internal/handler/storage_config.go @@ -53,6 +53,9 @@ func saveStorageConfigHandler(svc *service.Container) gin.HandlerFunc { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } + if in.Enabled != nil && !*in.Enabled && svc.Scan != nil { + _ = svc.Scan.CancelCloudScansForProvider(in.Type) + } c.JSON(http.StatusOK, row) } } diff --git a/internal/handler/system_extra.go b/internal/handler/system_extra.go index 7fa1b17..cacdc09 100644 --- a/internal/handler/system_extra.go +++ b/internal/handler/system_extra.go @@ -63,6 +63,7 @@ func schemaHandler(_ *service.Container) gin.HandlerFunc { "label": "常规", "items": []gin.H{ {"key": "tmdb.language", "type": "select", "label": "TMDb 元数据语言"}, + {"key": "app.server_url", "type": "text", "label": "公开访问域名 / STRM 域名"}, {"key": "transcode.enabled", "type": "toggle", "label": "启用转码"}, {"key": "transcode.hw_accel", "type": "select", "label": "硬件编码器"}, {"key": "transcode.hw_enabled", "type": "toggle", "label": "启用硬件加速"}, diff --git a/internal/repository/repository.go b/internal/repository/repository.go index 271c4dc..b5add9d 100644 --- a/internal/repository/repository.go +++ b/internal/repository/repository.go @@ -455,6 +455,12 @@ func (r *MediaRepository) DeleteByLibrary(ctx context.Context, libraryID string) return r.db.WithContext(ctx).Where("library_id = ?", libraryID).Delete(&model.Media{}).Error } +// PurgeByLibrary permanently removes media tied to a library. Used for virtual +// cloud mounts where "remove mount" must not populate the recycle bin. +func (r *MediaRepository) PurgeByLibrary(ctx context.Context, libraryID string) error { + return r.db.WithContext(ctx).Unscoped().Where("library_id = ?", libraryID).Delete(&model.Media{}).Error +} + // ─── Series ────────────────────────────────────────────────────────────────── // SeriesRepository persists model.Series records. diff --git a/internal/repository/storage_config_repo.go b/internal/repository/storage_config_repo.go index 0630911..fd2d1af 100644 --- a/internal/repository/storage_config_repo.go +++ b/internal/repository/storage_config_repo.go @@ -3,7 +3,9 @@ package repository import ( "context" "errors" + "time" + "github.com/google/uuid" "gorm.io/gorm" "github.com/ShukeBta/MediaStationGo/internal/model" @@ -34,6 +36,32 @@ func (r *StorageConfigRepository) List(ctx context.Context) ([]model.StorageConf // Upsert creates or replaces a storage config keyed by Type. func (r *StorageConfigRepository) Upsert(ctx context.Context, c *model.StorageConfig) error { - return r.db.WithContext(ctx).Where("type = ?", c.Type). - Assign(*c).FirstOrCreate(c).Error + db := r.db.WithContext(ctx) + var existing model.StorageConfig + err := db.Where("type = ?", c.Type).First(&existing).Error + if errors.Is(err, gorm.ErrRecordNotFound) { + now := time.Now() + c.ID = uuid.NewString() + c.CreatedAt = now + c.UpdatedAt = now + return db.Model(&model.StorageConfig{}).Create(map[string]any{ + "id": c.ID, + "type": c.Type, + "config": c.Config, + "enabled": c.Enabled, + "last_error": c.LastError, + "created_at": c.CreatedAt, + "updated_at": c.UpdatedAt, + }).Error + } + if err != nil { + return err + } + c.ID = existing.ID + return db.Model(&model.StorageConfig{}).Where("id = ?", existing.ID).Updates(map[string]any{ + "config": c.Config, + "enabled": c.Enabled, + "last_error": c.LastError, + "updated_at": time.Now(), + }).Error } diff --git a/internal/service/emby_compat.go b/internal/service/emby_compat.go index b150743..502b593 100644 --- a/internal/service/emby_compat.go +++ b/internal/service/emby_compat.go @@ -251,7 +251,7 @@ const ( ) var ( - embySeasonDirRE = regexp.MustCompile(`(?i)^(season[\s._-]*\d+|s\d+|第\s*\d+\s*季)$`) + embySeasonDirRE = regexp.MustCompile(`(?i)^(season[\s._-]*\d+|s\d+|第\s*[0-9一二三四五六七八九十百零两]+\s*季)$`) embyYearSuffixRE = regexp.MustCompile(`\s*[\((\[]\d{4}[\))\]]\s*$`) embyEpisodeTitleRE = regexp.MustCompile(`(?i)\s*[-_ ]*s\d{1,2}e\d{1,3}.*$`) ) diff --git a/internal/service/emby_compat_test.go b/internal/service/emby_compat_test.go index c1c7c41..f1a0f07 100644 --- a/internal/service/emby_compat_test.go +++ b/internal/service/emby_compat_test.go @@ -100,6 +100,45 @@ func TestEmbyItemsExposeSeriesSeasonEpisodeHierarchy(t *testing.T) { } } +func TestEmbyCloudAnimeUsesSeriesNameFromChineseSeasonFolder(t *testing.T) { + svc := newTestEmbyService(t) + lib := model.Library{Name: "OpenList · 国漫", Path: `cloud://openlist/国漫`, Type: "anime", Enabled: true} + if err := svc.repo.Library.Create(t.Context(), &lib); err != nil { + t.Fatalf("create library: %v", err) + } + for _, media := range []model.Media{ + { + Base: model.Base{ID: "cloud-ep-1"}, + LibraryID: lib.ID, + Title: "04", + Path: `cloud://openlist/国漫/剑来/第二季/04.mkv`, + SeasonNum: 2, + EpisodeNum: 4, + }, + { + Base: model.Base{ID: "cloud-ep-2"}, + LibraryID: lib.ID, + Title: "05", + Path: `cloud://openlist/国漫/剑来/第二季/05.mkv`, + SeasonNum: 2, + EpisodeNum: 5, + }, + } { + if err := svc.repo.DB.Create(&media).Error; err != nil { + t.Fatalf("create media: %v", err) + } + } + + root, err := svc.Items(t.Context(), ItemsParams{ParentID: lib.ID, Limit: 50}) + if err != nil { + t.Fatalf("library items: %v", err) + } + items := root["Items"].([]map[string]any) + if len(items) != 1 || items[0]["Type"] != "Series" || items[0]["Name"] != "剑来" { + t.Fatalf("cloud anime should be grouped as one series named 剑来, got %#v", items) + } +} + func TestEmbyRootItemsExposeLibraries(t *testing.T) { svc := newTestEmbyService(t) for _, lib := range []model.Library{ diff --git a/internal/service/episode_parser.go b/internal/service/episode_parser.go index 2f4e662..ff077a4 100644 --- a/internal/service/episode_parser.go +++ b/internal/service/episode_parser.go @@ -18,15 +18,17 @@ import ( "regexp" "strconv" "strings" + "unicode/utf8" ) var ( patSEnE = regexp.MustCompile(`(?i)s(\d{1,2})e(\d{1,3})`) patNxE = regexp.MustCompile(`(\d{1,2})x(\d{1,3})`) patEP = regexp.MustCompile(`(?i)(?:^|[^a-z])(?:e|ep)\.?\s*(\d{1,3})(?:[^0-9]|$)`) - patCN = regexp.MustCompile(`第\s*(\d{1,3})\s*[集话話期]`) + patCN = regexp.MustCompile(`第\s*([0-9一二三四五六七八九十百零两]+)\s*[集话話期]`) patDashEpisode = regexp.MustCompile(`[\s._-][-–—]\s*(\d{1,3})(?:\s*(?:v\d+)?)?(?:\s*[\[\(._-]|$)`) - patSeasonFolder = regexp.MustCompile(`(?i)(?:^|[^a-z])(?:s|season)\.?\s*(\d{1,2})(?:[^0-9]|$)|第\s*(\d{1,2})\s*季`) + patSeasonFolder = regexp.MustCompile(`(?i)(?:^|[^a-z])(?:s|season)\.?\s*(\d{1,2})(?:[^0-9]|$)|第\s*([0-9一二三四五六七八九十百零两]+)\s*季`) + patBareEpisode = regexp.MustCompile(`^(?:第\s*)?0?(\d{1,3})(?:\s*(?:v\d+)?)?$`) // patCNSeason 匹配中文季/部标记,支持阿拉伯数字与中文数字(如「第二季」「第2部」)。 patCNSeason = regexp.MustCompile(`第\s*[0-9一二三四五六七八九十百零两]+\s*[季部]`) ) @@ -70,6 +72,13 @@ func ParseEpisode(path string) (season, episode int) { episode = mustAtoi(m[1]) return } + if parentSeason := seasonFromParents(path); parentSeason > 0 { + if m := patBareEpisode.FindStringSubmatch(strings.TrimSpace(name)); len(m) >= 2 { + season = parentSeason + episode = mustAtoi(m[1]) + return + } + } return 0, 0 } @@ -97,6 +106,76 @@ func seasonFromParents(path string) int { } func mustAtoi(s string) int { - v, _ := strconv.Atoi(s) + s = strings.TrimSpace(s) + if v, err := strconv.Atoi(s); err == nil { + return v + } + v := parseChineseNumber(s) + if v > 0 { + return v + } return v } + +func parseChineseNumber(s string) int { + s = strings.TrimSpace(s) + if s == "" { + return 0 + } + digit := func(r rune) (int, bool) { + switch r { + case '零', '〇': + return 0, true + case '一': + return 1, true + case '二', '两': + return 2, true + case '三': + return 3, true + case '四': + return 4, true + case '五': + return 5, true + case '六': + return 6, true + case '七': + return 7, true + case '八': + return 8, true + case '九': + return 9, true + default: + return 0, false + } + } + if utf8.RuneCountInString(s) == 1 { + if v, ok := digit([]rune(s)[0]); ok { + return v + } + } + total := 0 + current := 0 + for _, r := range s { + switch r { + case '百': + if current == 0 { + current = 1 + } + total += current * 100 + current = 0 + case '十': + if current == 0 { + current = 1 + } + total += current * 10 + current = 0 + default: + v, ok := digit(r) + if !ok { + return 0 + } + current = v + } + } + return total + current +} diff --git a/internal/service/episode_parser_test.go b/internal/service/episode_parser_test.go index d61de4d..847aff7 100644 --- a/internal/service/episode_parser_test.go +++ b/internal/service/episode_parser_test.go @@ -18,9 +18,13 @@ func TestParseEpisode(t *testing.T) { {`Some Show/S02/Some Show - E04.mkv`, 2, 4}, {`剧集/第2季/剧集 第05集.mkv`, 2, 5}, {"日剧 第03集.mkv", 1, 3}, + {"日剧 第十集.mkv", 1, 10}, + {"日剧 第二十五话.mkv", 1, 25}, {"日剧 第12话.mkv", 1, 12}, {"综艺 第4期下.mkv", 1, 4}, {`综艺/Season 06/综艺 第17期.mkv`, 6, 17}, + {`动漫/第二季/04.mkv`, 2, 4}, + {`动漫/第十季/第十一集.mkv`, 10, 11}, {"Movie.2020.1080p.mkv", 0, 0}, } for _, tc := range cases { diff --git a/internal/service/image_proxy.go b/internal/service/image_proxy.go index 4f68115..a909310 100644 --- a/internal/service/image_proxy.go +++ b/internal/service/image_proxy.go @@ -31,6 +31,7 @@ import ( "go.uber.org/zap" "github.com/ShukeBta/MediaStationGo/internal/config" + "github.com/ShukeBta/MediaStationGo/internal/service/cloud" ) // transparent1x1PNG is a baseline 67-byte PNG used as a fallback when the @@ -377,6 +378,116 @@ func (p *ImageProxy) Serve(ctx context.Context, w http.ResponseWriter, r *http.R return nil } +// ServeCloudResolved stores a cloud sidecar image in the same disk cache used +// by remote posters, then serves it with long browser-cache headers. Cloud +// direct links are often short-lived, so caching by the stable provider/ref +// avoids re-resolving and re-downloading artwork every time the web UI or an +// Emby-compatible client opens a library. +func (p *ImageProxy) ServeCloudResolved(ctx context.Context, w http.ResponseWriter, r *http.Request, stableKey string, link *cloud.DirectLink) error { + if p == nil || link == nil || strings.TrimSpace(link.URL) == "" { + servePlaceholder(w) + return nil + } + stableKey = strings.TrimSpace(stableKey) + if stableKey == "" { + stableKey = link.URL + } + sum := sha1.Sum([]byte("cloud-image:" + stableKey)) + key := "cloud-" + hex.EncodeToString(sum[:]) + cachePath := filepath.Join(p.cacheDir, key) + failPath := cachePath + ".fail" + + if data, err := os.ReadFile(cachePath); err == nil && len(data) > 0 { + w.Header().Set("Content-Type", detectContentType(data)) + w.Header().Set("Cache-Control", imageBrowserCacheControl) + stat, _ := os.Stat(cachePath) + modTime := time.Now() + if stat != nil { + modTime = stat.ModTime() + } + http.ServeContent(w, r, key, modTime, bytes.NewReader(data)) + return nil + } + if stat, err := os.Stat(failPath); err == nil && time.Since(stat.ModTime()) < imageNegativeCacheTTL { + serveCachedPlaceholder(w) + return nil + } else if err == nil { + _ = os.Remove(failPath) + } + if err := os.MkdirAll(p.cacheDir, 0o755); err != nil { + p.log.Warn("imageproxy: mkdir failed", zap.String("dir", p.cacheDir), zap.Error(err)) + servePlaceholder(w) + return nil + } + + req, err := http.NewRequestWithContext(ctx, http.MethodGet, link.URL, nil) + if err != nil { + p.log.Warn("imageproxy: build cloud image request failed", zap.Error(err)) + servePlaceholder(w) + return nil + } + for k, v := range link.Headers { + req.Header.Set(k, v) + } + if req.Header.Get("User-Agent") == "" { + if ua := r.UserAgent(); ua != "" { + req.Header.Set("User-Agent", ua) + } else { + req.Header.Set("User-Agent", "MediaStationGo/0.1") + } + } + req.Header.Set("Accept", "image/avif,image/webp,image/apng,image/svg+xml,image/*,*/*;q=0.8") + + resp, err := p.client.Do(req) + if err != nil { + p.log.Warn("imageproxy: cloud image fetch failed", zap.String("url", link.URL), zap.Error(err)) + p.markImageFetchFailed(failPath) + serveCachedPlaceholder(w) + return nil + } + defer resp.Body.Close() + if resp.StatusCode >= 400 { + p.log.Warn("imageproxy: cloud image returned non-OK", + zap.String("url", link.URL), zap.String("status", resp.Status)) + p.markImageFetchFailed(failPath) + serveCachedPlaceholder(w) + return nil + } + data, err := io.ReadAll(io.LimitReader(resp.Body, 32<<20)) + if err != nil || len(data) == 0 { + p.log.Warn("imageproxy: read cloud image body failed", zap.String("url", link.URL), zap.Error(err)) + p.markImageFetchFailed(failPath) + serveCachedPlaceholder(w) + return nil + } + + p.mu.Lock() + tmp, tmpErr := os.CreateTemp(p.cacheDir, "img-cloud-*.tmp") + if tmpErr == nil { + if _, werr := tmp.Write(data); werr == nil { + tmp.Close() + if rerr := os.Rename(tmp.Name(), cachePath); rerr != nil { + _ = os.Remove(tmp.Name()) + } else { + _ = os.Remove(failPath) + } + } else { + tmp.Close() + _ = os.Remove(tmp.Name()) + } + } + p.mu.Unlock() + + ctype := resp.Header.Get("Content-Type") + if ctype == "" { + ctype = detectContentType(data) + } + w.Header().Set("Content-Type", ctype) + w.Header().Set("Cache-Control", imageBrowserCacheControl) + http.ServeContent(w, r, key, time.Now(), bytes.NewReader(data)) + return nil +} + func (p *ImageProxy) markImageFetchFailed(failPath string) { if err := os.MkdirAll(filepath.Dir(failPath), 0o755); err != nil { return diff --git a/internal/service/image_proxy_test.go b/internal/service/image_proxy_test.go index 91f8e51..97ca8ee 100644 --- a/internal/service/image_proxy_test.go +++ b/internal/service/image_proxy_test.go @@ -13,6 +13,7 @@ import ( "go.uber.org/zap" "github.com/ShukeBta/MediaStationGo/internal/config" + "github.com/ShukeBta/MediaStationGo/internal/service/cloud" ) func TestImageProxyServesLocalImagePath(t *testing.T) { @@ -115,6 +116,38 @@ func TestImageProxyCachesFailedRemoteImageFetch(t *testing.T) { } } +func TestImageProxyCachesCloudResolvedImage(t *testing.T) { + var calls int32 + proxy := NewImageProxy(&config.Config{Cache: config.CacheConfig{CacheDir: filepath.Join(t.TempDir(), "cache")}}, zap.NewNop()) + proxy.client = &http.Client{Transport: imageRoundTripFunc(func(req *http.Request) (*http.Response, error) { + atomic.AddInt32(&calls, 1) + return &http.Response{ + StatusCode: http.StatusOK, + Status: "200 OK", + Header: http.Header{"Content-Type": []string{"image/png"}}, + Body: io.NopCloser(strings.NewReader(string(transparent1x1PNG))), + Request: req, + }, nil + })} + + link := &cloud.DirectLink{URL: "http://cloud-provider.invalid/poster.png"} + for i := 0; i < 2; i++ { + rec := httptest.NewRecorder() + if err := proxy.ServeCloudResolved(t.Context(), rec, httptest.NewRequest(http.MethodGet, "/api/cloud/play/openlist?ref=poster.png", nil), "openlist:poster.png", link); err != nil { + t.Fatal(err) + } + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want 200", rec.Code) + } + if got := rec.Header().Get("Cache-Control"); got != imageBrowserCacheControl { + t.Fatalf("Cache-Control = %q, want %q", got, imageBrowserCacheControl) + } + } + if got := atomic.LoadInt32(&calls); got != 1 { + t.Fatalf("upstream calls = %d, want 1 due to cloud image cache", got) + } +} + type imageRoundTripFunc func(*http.Request) (*http.Response, error) func (f imageRoundTripFunc) RoundTrip(req *http.Request) (*http.Response, error) { diff --git a/internal/service/media.go b/internal/service/media.go index 1bd5b5b..dc064a6 100644 --- a/internal/service/media.go +++ b/internal/service/media.go @@ -228,6 +228,18 @@ func (s *MediaService) ListLibraries(ctx context.Context) ([]model.Library, erro // DeleteLibrary removes a library and its media rows. The on-disk files are // left untouched. func (s *MediaService) DeleteLibrary(ctx context.Context, id string) error { + lib, err := s.repo.Library.FindByID(ctx, id) + if err != nil { + return err + } + if lib != nil { + if _, ok := ParseCloudLibraryMount(lib.Path); ok { + if err := s.repo.Media.PurgeByLibrary(ctx, id); err != nil { + return err + } + return s.repo.DB.WithContext(ctx).Unscoped().Where("id = ?", id).Delete(&model.Library{}).Error + } + } if err := s.repo.Media.DeleteByLibrary(ctx, id); err != nil { return err } @@ -282,6 +294,13 @@ func (s *MediaService) GetMedia(ctx context.Context, id string) (*model.Media, e // SoftDelete moves a media row to the recycle bin (gorm soft delete). // The on-disk file is kept; admins can purge it later. func (s *MediaService) SoftDelete(ctx context.Context, id string) error { + media, err := s.repo.Media.FindByID(ctx, id) + if err != nil { + return err + } + if media != nil && isCloudMediaPath(media.Path) { + return s.repo.DB.WithContext(ctx).Unscoped().Where("id = ?", id).Delete(&model.Media{}).Error + } return s.repo.DB.Where("id = ?", id).Delete(&model.Media{}).Error } diff --git a/internal/service/media_test.go b/internal/service/media_test.go index 1371fa7..8fbbacd 100644 --- a/internal/service/media_test.go +++ b/internal/service/media_test.go @@ -4,6 +4,14 @@ import ( "os" "path/filepath" "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 TestResolveAccessibleLibraryPathMapsConfiguredHostMediaDir(t *testing.T) { @@ -40,3 +48,92 @@ func TestResolveAccessibleLibraryPathKeepsAccessibleContainerPath(t *testing.T) t.Fatalf("resolveAccessibleLibraryPath() = %q, want %q", got, filepath.Clean(containerLibrary)) } } + +func TestDeleteCloudLibraryPurgesMountWithoutRecycleBin(t *testing.T) { + db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{}) + if err != nil { + t.Fatal(err) + } + if err := db.AutoMigrate(&model.Library{}, &model.Media{}); err != nil { + t.Fatal(err) + } + repos := repository.New(db) + lib := model.Library{Name: "OpenList · 剑来", Path: "cloud://openlist/Anime/JianLai", Type: "anime", Enabled: true} + if err := repos.Library.Create(t.Context(), &lib); err != nil { + t.Fatal(err) + } + if err := repos.Media.Upsert(t.Context(), &model.Media{ + LibraryID: lib.ID, + Title: "剑来", + Path: "cloud://openlist/Anime/JianLai/Season 1/01.mkv", + STRMURL: "/api/cloud/play/openlist?ref=/Anime/JianLai/Season%201/01.mkv", + }); err != nil { + t.Fatal(err) + } + + svc := NewMediaService(&config.Config{}, zap.NewNop(), repos) + if err := svc.DeleteLibrary(t.Context(), lib.ID); err != nil { + t.Fatal(err) + } + + var mediaCount int64 + if err := db.Unscoped().Model(&model.Media{}).Where("library_id = ?", lib.ID).Count(&mediaCount).Error; err != nil { + t.Fatal(err) + } + if mediaCount != 0 { + t.Fatalf("cloud mount media should be purged, count=%d", mediaCount) + } + recycle, err := svc.ListRecycleBin(t.Context(), 100) + if err != nil { + t.Fatal(err) + } + if len(recycle) != 0 { + t.Fatalf("cloud mount removal must not populate recycle bin: %#v", recycle) + } + var libCount int64 + if err := db.Unscoped().Model(&model.Library{}).Where("id = ?", lib.ID).Count(&libCount).Error; err != nil { + t.Fatal(err) + } + if libCount != 0 { + t.Fatalf("cloud mount library should be purged, count=%d", libCount) + } +} + +func TestSoftDeleteCloudMediaPurgesRecordWithoutRecycleBin(t *testing.T) { + db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{}) + if err != nil { + t.Fatal(err) + } + if err := db.AutoMigrate(&model.Media{}); err != nil { + t.Fatal(err) + } + repos := repository.New(db) + media := model.Media{ + Base: model.Base{ID: "cloud-media"}, + Title: "网盘电影", + Path: "cloud://openlist/电影/Movie.mkv", + STRMURL: "/api/cloud/play/openlist?ref=/电影/Movie.mkv", + } + if err := repos.DB.Create(&media).Error; err != nil { + t.Fatal(err) + } + + svc := NewMediaService(&config.Config{}, zap.NewNop(), repos) + if err := svc.SoftDelete(t.Context(), media.ID); err != nil { + t.Fatal(err) + } + var count int64 + if err := db.Unscoped().Model(&model.Media{}).Where("id = ?", media.ID).Count(&count).Error; err != nil { + t.Fatal(err) + } + if count != 0 { + t.Fatalf("cloud media should be purged, count=%d", count) + } + recycle, err := svc.ListRecycleBin(t.Context(), 100) + if err != nil { + t.Fatal(err) + } + if len(recycle) != 0 { + t.Fatalf("cloud media removal must not populate recycle bin: %#v", recycle) + } +} diff --git a/internal/service/notify_channels.go b/internal/service/notify_channels.go index d1b42f5..50e50ec 100644 --- a/internal/service/notify_channels.go +++ b/internal/service/notify_channels.go @@ -108,6 +108,11 @@ func (s *NotifyChannelService) Create(ctx context.Context, in ChannelInput) (*ch if err := s.repo.NotifyChannel.Create(ctx, n); err != nil { return nil, err } + if n.Type == "telegram" && n.Enabled { + if err := registerTelegramBotCommands(ctx, telegramStringConfigFromAny(in.Config)); err != nil && s.log != nil { + s.log.Warn("telegram setMyCommands failed", zap.Error(sanitizeTelegramError(err))) + } + } v := toView(*n) return &v, nil } @@ -147,6 +152,11 @@ func (s *NotifyChannelService) Update(ctx context.Context, id string, in Channel if err := s.repo.NotifyChannel.Update(ctx, existing); err != nil { return nil, err } + if existing.Type == "telegram" && existing.Enabled { + if err := registerTelegramBotCommands(ctx, telegramStringConfigFromAny(in.Config)); err != nil && s.log != nil { + s.log.Warn("telegram setMyCommands failed", zap.Error(sanitizeTelegramError(err))) + } + } row, err := s.repo.NotifyChannel.FindByID(ctx, id) if err != nil || row == nil { return nil, err diff --git a/internal/service/public_url.go b/internal/service/public_url.go new file mode 100644 index 0000000..b1ce2db --- /dev/null +++ b/internal/service/public_url.go @@ -0,0 +1,49 @@ +package service + +import ( + "context" + "net/url" + "strings" + + "github.com/ShukeBta/MediaStationGo/internal/config" + "github.com/ShukeBta/MediaStationGo/internal/repository" +) + +// PublicServerURL returns the operator-configured public MediaStationGo base +// URL. It is intentionally read from Setting first so Docker users only need to +// fill one domain in the admin settings page after deployment. +func PublicServerURL(ctx context.Context, repo *repository.Container, cfg *config.Config) string { + for _, key := range []string{"app.server_url", "server.url", "public.server_url", "strm.base_url"} { + if repo != nil && repo.Setting != nil { + if value, err := repo.Setting.Get(ctx, key); err == nil && strings.TrimSpace(value) != "" { + return strings.TrimRight(strings.TrimSpace(value), "/") + } + } + } + if cfg != nil && strings.TrimSpace(cfg.App.ServerURL) != "" { + return strings.TrimRight(strings.TrimSpace(cfg.App.ServerURL), "/") + } + return "" +} + +// BuildPublicAPIURL builds an API URL. Without a public base it returns a +// relative same-origin URL; with a configured domain it returns an absolute URL. +func BuildPublicAPIURL(ctx context.Context, repo *repository.Container, cfg *config.Config, apiPath string, query url.Values) string { + apiPath = "/" + strings.TrimLeft(strings.TrimSpace(apiPath), "/") + if query != nil && len(query) > 0 { + apiPath += "?" + query.Encode() + } + base := PublicServerURL(ctx, repo, cfg) + if base == "" { + return apiPath + } + u, err := url.Parse(base) + if err != nil || u.Scheme == "" || u.Host == "" { + return apiPath + } + u.Path = strings.TrimRight(u.Path, "/") + "/" + strings.TrimLeft(strings.Split(apiPath, "?")[0], "/") + if query != nil && len(query) > 0 { + u.RawQuery = query.Encode() + } + return u.String() +} diff --git a/internal/service/runtime_settings.go b/internal/service/runtime_settings.go index 9818f6b..5746a24 100644 --- a/internal/service/runtime_settings.go +++ b/internal/service/runtime_settings.go @@ -35,6 +35,8 @@ func ApplyRuntimeSetting(cfg *config.Config, key, value string) { switch key { case "ffmpeg.path", "app.ffmpeg_path": cfg.App.FFmpegPath = value + case "app.server_url", "server.url", "public.server_url", "strm.base_url": + cfg.App.ServerURL = strings.TrimRight(value, "/") case "ffprobe.path", "app.ffprobe_path": cfg.App.FFprobePath = value case "ffprobe.max_concurrent", "app.ffprobe_max_concurrent": diff --git a/internal/service/scanner.go b/internal/service/scanner.go index e8a0ecc..5039f69 100644 --- a/internal/service/scanner.go +++ b/internal/service/scanner.go @@ -12,11 +12,13 @@ package service import ( "context" + "errors" "fmt" "net/url" "os" "path/filepath" "strings" + "sync" "time" "go.uber.org/zap" @@ -53,6 +55,9 @@ type ScannerService struct { probe *FFprobeService scraper *ScraperService storage *StorageConfigService + + cloudScanMu sync.Mutex + cloudScans map[string]*cloudScanEntry } // NewScannerService is the constructor. @@ -67,6 +72,7 @@ func NewScannerService( return &ScannerService{ cfg: cfg, log: log, repo: repo, hub: hub, probe: probe, scraper: scraper, + cloudScans: make(map[string]*cloudScanEntry), } } @@ -89,6 +95,292 @@ type ScanResult struct { Removed int64 `json:"removed"` } +var ErrCloudScanAlreadyRunning = errors.New("cloud scan already running") + +// CloudScanStatus is the operator-facing state for long-running cloud scans. +type CloudScanStatus struct { + LibraryID string `json:"library_id"` + Provider string `json:"provider"` + Stage string `json:"stage"` + State string `json:"state"` + StartedAt time.Time `json:"started_at,omitempty"` + UpdatedAt time.Time `json:"updated_at,omitempty"` + FinishedAt time.Time `json:"finished_at,omitempty"` + Dirs int `json:"dirs"` + Discovered int `json:"discovered"` + Visited int `json:"visited"` + Added int `json:"added"` + Updated int `json:"updated"` + Skipped int `json:"skipped"` + Removed int64 `json:"removed"` + Error string `json:"error,omitempty"` + ResumeHint string `json:"resume_hint,omitempty"` + Estimate string `json:"estimate_message,omitempty"` + FilesPerSecond float64 `json:"files_per_second,omitempty"` +} + +type cloudScanEntry struct { + status CloudScanStatus + cancel context.CancelFunc +} + +func (s *ScannerService) beginCloudScan(ctx context.Context, lib *model.Library, mount CloudMountInfo) (context.Context, func(*ScanResult, error), error) { + if s == nil || lib == nil { + return ctx, func(*ScanResult, error) {}, nil + } + s.cloudScanMu.Lock() + if s.cloudScans == nil { + s.cloudScans = make(map[string]*cloudScanEntry) + } + if entry := s.cloudScans[lib.ID]; entry != nil && (entry.status.State == "running" || entry.status.State == "canceling") { + s.cloudScanMu.Unlock() + return ctx, nil, ErrCloudScanAlreadyRunning + } + runCtx, cancel := context.WithCancel(ctx) + now := time.Now() + entry := &cloudScanEntry{ + status: CloudScanStatus{ + LibraryID: lib.ID, + Provider: mount.Provider, + Stage: "listing", + State: "running", + StartedAt: now, + UpdatedAt: now, + ResumeHint: "中断后再次点击扫描会从头遍历,但已入库媒体会去重更新,只补齐缺失项。", + Estimate: "小目录通常几十秒;几万文件的大目录可能需要数分钟到数小时,取决于网盘接口速度。", + }, + cancel: cancel, + } + s.cloudScans[lib.ID] = entry + s.cloudScanMu.Unlock() + + finish := func(res *ScanResult, err error) { + s.cloudScanMu.Lock() + defer s.cloudScanMu.Unlock() + current := s.cloudScans[lib.ID] + if current == nil { + return + } + now := time.Now() + if res != nil { + current.status.Visited = res.Visited + current.status.Added = res.Added + current.status.Updated = res.Updated + current.status.Skipped = res.Skipped + current.status.Removed = res.Removed + } + current.status.UpdatedAt = now + current.status.FinishedAt = now + current.cancel = nil + switch { + case errors.Is(err, context.Canceled): + current.status.State = "canceled" + current.status.Stage = "canceled" + current.status.Error = "" + case errors.Is(err, context.DeadlineExceeded): + current.status.State = "error" + current.status.Stage = "error" + current.status.Error = "扫描超时:" + err.Error() + case err != nil: + current.status.State = "error" + current.status.Stage = "error" + current.status.Error = err.Error() + default: + current.status.State = "finished" + current.status.Stage = "finished" + current.status.Error = "" + } + if s.hub != nil { + s.hub.Publish("scan", map[string]any{ + "library_id": lib.ID, + "provider": mount.Provider, + "cloud": true, + "finished": true, + "state": current.status.State, + "stage": current.status.Stage, + "error": current.status.Error, + "visited": current.status.Visited, + "added": current.status.Added, + "updated": current.status.Updated, + "skipped": current.status.Skipped, + "removed": current.status.Removed, + }) + } + } + return runCtx, finish, nil +} + +func (s *ScannerService) updateCloudScanProgress(libraryID, stage string, dirs, discovered, visited, added, updated, skipped int, removed int64, filesPerSecond float64) { + if s == nil { + return + } + s.cloudScanMu.Lock() + defer s.cloudScanMu.Unlock() + entry := s.cloudScans[libraryID] + if entry == nil { + return + } + entry.status.Stage = stage + entry.status.UpdatedAt = time.Now() + entry.status.Dirs = dirs + entry.status.Discovered = discovered + entry.status.Visited = visited + entry.status.Added = added + entry.status.Updated = updated + entry.status.Skipped = skipped + entry.status.Removed = removed + entry.status.FilesPerSecond = filesPerSecond +} + +// CloudScanStatuses returns the current or most recent status per cloud library. +func (s *ScannerService) CloudScanStatuses() []CloudScanStatus { + if s == nil { + return nil + } + s.cloudScanMu.Lock() + defer s.cloudScanMu.Unlock() + out := make([]CloudScanStatus, 0, len(s.cloudScans)) + for _, entry := range s.cloudScans { + out = append(out, entry.status) + } + return out +} + +func (s *ScannerService) CancelCloudScan(libraryID string) bool { + if s == nil || strings.TrimSpace(libraryID) == "" { + return false + } + s.cloudScanMu.Lock() + defer s.cloudScanMu.Unlock() + entry := s.cloudScans[libraryID] + if entry == nil || entry.cancel == nil || (entry.status.State != "running" && entry.status.State != "canceling") { + return false + } + entry.status.State = "canceling" + entry.status.Stage = "canceling" + entry.status.UpdatedAt = time.Now() + entry.cancel() + return true +} + +func (s *ScannerService) CancelAllCloudScans() int { + if s == nil { + return 0 + } + s.cloudScanMu.Lock() + defer s.cloudScanMu.Unlock() + cancelled := 0 + for _, entry := range s.cloudScans { + if entry == nil || entry.cancel == nil || (entry.status.State != "running" && entry.status.State != "canceling") { + continue + } + entry.status.State = "canceling" + entry.status.Stage = "canceling" + entry.status.UpdatedAt = time.Now() + entry.cancel() + cancelled++ + } + return cancelled +} + +func (s *ScannerService) CancelCloudScansForProvider(provider string) int { + if s == nil { + return 0 + } + provider = strings.TrimSpace(provider) + if provider == "" { + return 0 + } + s.cloudScanMu.Lock() + defer s.cloudScanMu.Unlock() + cancelled := 0 + for _, entry := range s.cloudScans { + if entry == nil || entry.status.Provider != provider || entry.cancel == nil || (entry.status.State != "running" && entry.status.State != "canceling") { + continue + } + entry.status.State = "canceling" + entry.status.Stage = "canceling" + entry.status.UpdatedAt = time.Now() + entry.cancel() + cancelled++ + } + return cancelled +} + +func (s *ScannerService) StartCloudLibraryScan(libraryID string, autoScrape bool) (CloudScanStatus, bool, error) { + if s == nil { + return CloudScanStatus{}, false, errors.New("scanner unavailable") + } + lib, err := s.repo.Library.FindByID(context.Background(), libraryID) + if err != nil { + return CloudScanStatus{}, false, err + } + if lib == nil { + return CloudScanStatus{}, false, errors.New("library not found") + } + mount, ok := ParseCloudLibraryMount(lib.Path) + if !ok { + return CloudScanStatus{}, false, errors.New("library is not a cloud mount") + } + s.cloudScanMu.Lock() + if entry := s.cloudScans[libraryID]; entry != nil && (entry.status.State == "running" || entry.status.State == "canceling") { + status := entry.status + s.cloudScanMu.Unlock() + return status, false, nil + } + s.cloudScanMu.Unlock() + + go func() { + ctx, cancel := context.WithTimeout(context.Background(), 6*time.Hour) + defer cancel() + if autoScrape { + _, err = s.ScanLibrary(ctx, libraryID) + } else { + _, err = s.ScanLibraryWithoutAutoScrape(ctx, libraryID) + } + if err != nil && !errors.Is(err, ErrCloudScanAlreadyRunning) && s.log != nil { + s.log.Warn("cloud library background scan failed", zap.String("library_id", libraryID), zap.Error(err)) + } + }() + status := CloudScanStatus{ + LibraryID: libraryID, + Provider: mount.Provider, + Stage: "queued", + State: "queued", + StartedAt: time.Now(), + UpdatedAt: time.Now(), + ResumeHint: "中断后再次点击扫描会从头遍历,但已入库媒体会去重更新,只补齐缺失项。", + Estimate: "小目录通常几十秒;几万文件的大目录可能需要数分钟到数小时,取决于网盘接口速度。", + } + return status, true, nil +} + +func (s *ScannerService) StartAllCloudLibraryScans() ([]CloudScanStatus, error) { + if s == nil { + return nil, errors.New("scanner unavailable") + } + libs, err := s.repo.Library.List(context.Background()) + if err != nil { + return nil, err + } + libs = FilterShadowedCloudLibraries(libs) + statuses := make([]CloudScanStatus, 0, len(libs)) + for _, lib := range libs { + if !lib.Enabled { + continue + } + if _, ok := ParseCloudLibraryMount(lib.Path); !ok { + continue + } + status, _, err := s.StartCloudLibraryScan(lib.ID, false) + if err != nil { + status = CloudScanStatus{LibraryID: lib.ID, State: "error", Error: err.Error(), UpdatedAt: time.Now()} + } + statuses = append(statuses, status) + } + return statuses, nil +} + // ScanLibrary walks the library root and persists discovered media files. func (s *ScannerService) ScanLibrary(ctx context.Context, libraryID string) (*ScanResult, error) { return s.scanLibrary(ctx, libraryID, true) @@ -122,7 +414,18 @@ func (s *ScannerService) scanLibrary(ctx context.Context, libraryID string, auto }) return res, nil } - return s.scanCloudLibrary(ctx, lib, mount, autoScrape) + scanCtx, finish, err := s.beginCloudScan(ctx, lib, mount) + if err != nil { + if errors.Is(err, ErrCloudScanAlreadyRunning) { + return &ScanResult{LibraryID: lib.ID, Skipped: 1}, nil + } + return nil, err + } + res, err := s.scanCloudLibrary(scanCtx, lib, mount, autoScrape) + if finish != nil { + finish(res, err) + } + return res, err } res := &ScanResult{LibraryID: lib.ID} seen := make(map[string]struct{}) @@ -238,6 +541,7 @@ func (s *ScannerService) scanCloudLibrary(ctx context.Context, lib *model.Librar if elapsed.Seconds() > 0 { filesPerSecond = float64(processed) / elapsed.Seconds() } + s.updateCloudScanProgress(lib.ID, stage, dirsVisited, filesDiscovered, res.Visited, res.Added, res.Updated, res.Skipped, res.Removed, filesPerSecond) s.hub.Publish("scan", map[string]any{ "library_id": lib.ID, "cloud": true, @@ -335,6 +639,11 @@ func (s *ScannerService) scanCloudLibrary(ctx context.Context, lib *model.Librar return res, err } for _, candidate := range candidates { + select { + case <-ctx.Done(): + return res, ctx.Err() + default: + } seen[candidate.path] = struct{}{} s.ingestCloudFile(ctx, lib, typ, candidate.ref, candidate.path, candidate.name, candidate.size, candidate.localMeta, res) publishProgress("importing", res.Visited == 1 || res.Visited%100 == 0) @@ -403,7 +712,7 @@ func (s *ScannerService) ingestCloudFile(ctx context.Context, lib *model.Library Path: path, SizeBytes: size, Container: strings.TrimPrefix(ext, "."), - STRMURL: "/api/cloud/play/" + typ + "?ref=" + url.QueryEscape(ref), + STRMURL: BuildPublicAPIURL(ctx, s.repo, s.cfg, "/api/cloud/play/"+typ, url.Values{"ref": []string{ref}}), ScrapeStatus: "pending", } if ext == ".strm" { @@ -658,6 +967,7 @@ func (s *ScannerService) pruneMissingCloudMedia(ctx context.Context, libraryID s continue } res := s.repo.DB.WithContext(ctx). + Unscoped(). Where("id = ?", row.ID). Delete(&model.Media{}) if res.Error != nil { diff --git a/internal/service/scanner_cloud_test.go b/internal/service/scanner_cloud_test.go index c39bc02..97ceff1 100644 --- a/internal/service/scanner_cloud_test.go +++ b/internal/service/scanner_cloud_test.go @@ -103,6 +103,13 @@ func TestScanCloudLibraryImportsRecursivePlayableMedia(t *testing.T) { if got := countMedia(t, repos); got != 0 { t.Fatalf("media count after prune = %d, want 0", got) } + var allRows int64 + if err := repos.DB.Unscoped().Model(&model.Media{}).Count(&allRows).Error; err != nil { + t.Fatal(err) + } + if allRows != 0 { + t.Fatalf("unscoped media count after cloud prune = %d, want 0", allRows) + } } func TestCloudLibraryPathParsing(t *testing.T) { @@ -158,6 +165,27 @@ func TestCloudMountConflictDetectsNestedMounts(t *testing.T) { } } +func TestCancelCloudScansForProviderSignalsRunningScan(t *testing.T) { + scanner := NewScannerService(&config.Config{}, zap.NewNop(), repository.New(nil), NewHub(zap.NewNop()), nil, nil) + cancelled := false + scanner.cloudScans["lib-1"] = &cloudScanEntry{ + status: CloudScanStatus{LibraryID: "lib-1", Provider: "openlist", State: "running"}, + cancel: func() { + cancelled = true + }, + } + + if got := scanner.CancelCloudScansForProvider("openlist"); got != 1 { + t.Fatalf("cancelled = %d, want 1", got) + } + if !cancelled { + t.Fatal("cancel func was not called") + } + if state := scanner.cloudScans["lib-1"].status.State; state != "canceling" { + t.Fatalf("state = %q, want canceling", state) + } +} + func TestInferCloudMountMediaType(t *testing.T) { cases := map[string]string{ "/日漫": "anime", diff --git a/internal/service/stats.go b/internal/service/stats.go index 23840db..2fa56fd 100644 --- a/internal/service/stats.go +++ b/internal/service/stats.go @@ -34,14 +34,14 @@ func NewStatsService(log *zap.Logger, repo *repository.Container) *StatsService // Snapshot is the JSON returned by /api/stats. type Snapshot struct { - Libraries int64 `json:"libraries"` - MediaCount int64 `json:"media_count"` - UsersCount int64 `json:"users_count"` - TotalSizeBytes int64 `json:"total_size_bytes"` - TotalSeconds int64 `json:"total_seconds"` + Libraries int64 `json:"libraries"` + MediaCount int64 `json:"media_count"` + UsersCount int64 `json:"users_count"` + TotalSizeBytes int64 `json:"total_size_bytes"` + TotalSeconds int64 `json:"total_seconds"` RecentlyAdded []model.Media `json:"recently_added"` - Hardware Hardware `json:"hardware"` - GeneratedAt time.Time `json:"generated_at"` + Hardware Hardware `json:"hardware"` + GeneratedAt time.Time `json:"generated_at"` } // Hardware is the live CPU / memory / disk readings. @@ -58,10 +58,26 @@ type Hardware struct { // Compute builds a fresh snapshot. func (s *StatsService) Compute(ctx context.Context, dataDir string) (*Snapshot, error) { snap := &Snapshot{GeneratedAt: time.Now()} - if err := s.repo.DB.Model(&model.Library{}).Count(&snap.Libraries).Error; err != nil { + libs, err := s.repo.Library.List(ctx) + if err != nil { return nil, err } - if err := s.repo.DB.Model(&model.Media{}).Count(&snap.MediaCount).Error; err != nil { + libs = FilterShadowedCloudLibraries(libs) + activeLibraryIDs := make([]string, 0, len(libs)) + for _, lib := range libs { + if !lib.Enabled { + continue + } + activeLibraryIDs = append(activeLibraryIDs, lib.ID) + } + snap.Libraries = int64(len(activeLibraryIDs)) + mediaQuery := s.repo.DB.Model(&model.Media{}) + if len(activeLibraryIDs) == 0 { + mediaQuery = mediaQuery.Where("1 = 0") + } else { + mediaQuery = mediaQuery.Where("library_id IN ?", activeLibraryIDs) + } + if err := mediaQuery.Count(&snap.MediaCount).Error; err != nil { return nil, err } if err := s.repo.DB.Model(&model.User{}).Count(&snap.UsersCount).Error; err != nil { @@ -72,7 +88,13 @@ func (s *StatsService) Compute(ctx context.Context, dataDir string) (*Snapshot, Seconds int64 } var sum sumRow - if err := s.repo.DB.Model(&model.Media{}). + sumQuery := s.repo.DB.Model(&model.Media{}) + if len(activeLibraryIDs) == 0 { + sumQuery = sumQuery.Where("1 = 0") + } else { + sumQuery = sumQuery.Where("library_id IN ?", activeLibraryIDs) + } + if err := sumQuery. Select("COALESCE(SUM(size_bytes),0) as size, COALESCE(SUM(duration_sec),0) as seconds"). Scan(&sum).Error; err != nil { return nil, err @@ -80,7 +102,13 @@ func (s *StatsService) Compute(ctx context.Context, dataDir string) (*Snapshot, snap.TotalSizeBytes = sum.Size snap.TotalSeconds = sum.Seconds - if err := s.repo.DB.Model(&model.Media{}). + recentQuery := s.repo.DB.Model(&model.Media{}) + if len(activeLibraryIDs) == 0 { + recentQuery = recentQuery.Where("1 = 0") + } else { + recentQuery = recentQuery.Where("library_id IN ?", activeLibraryIDs) + } + if err := recentQuery. Order("created_at desc").Limit(12). Find(&snap.RecentlyAdded).Error; err != nil { return nil, err diff --git a/internal/service/stats_test.go b/internal/service/stats_test.go new file mode 100644 index 0000000..309b590 --- /dev/null +++ b/internal/service/stats_test.go @@ -0,0 +1,50 @@ +package service + +import ( + "testing" + + "github.com/glebarez/sqlite" + "go.uber.org/zap" + "gorm.io/gorm" + + "github.com/ShukeBta/MediaStationGo/internal/model" + "github.com/ShukeBta/MediaStationGo/internal/repository" +) + +func TestStatsComputeFiltersDisabledLibraries(t *testing.T) { + db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{}) + if err != nil { + t.Fatal(err) + } + if err := db.AutoMigrate(&model.Library{}, &model.Media{}, &model.User{}); err != nil { + t.Fatal(err) + } + repos := repository.New(db) + enabled := &model.Library{Name: "电影", Path: "/media/movies", Type: "movie", Enabled: true} + disabled := &model.Library{Name: "停用库", Path: "/media/disabled", Type: "movie", Enabled: false} + if err := repos.Library.Create(t.Context(), enabled); err != nil { + t.Fatal(err) + } + if err := repos.Library.Create(t.Context(), disabled); err != nil { + t.Fatal(err) + } + if err := db.Model(&model.Library{}).Where("id = ?", disabled.ID).Update("enabled", false).Error; err != nil { + t.Fatal(err) + } + for _, media := range []*model.Media{ + {LibraryID: enabled.ID, Title: "Visible", Path: "/media/movies/a.mkv", SizeBytes: 100}, + {LibraryID: disabled.ID, Title: "Hidden", Path: "/media/disabled/b.mkv", SizeBytes: 900}, + } { + if err := repos.Media.Upsert(t.Context(), media); err != nil { + t.Fatal(err) + } + } + + snap, err := NewStatsService(zap.NewNop(), repos).Compute(t.Context(), t.TempDir()) + if err != nil { + t.Fatal(err) + } + if snap.Libraries != 1 || snap.MediaCount != 1 || snap.TotalSizeBytes != 100 { + t.Fatalf("stats = libraries=%d media=%d size=%d, want 1/1/100", snap.Libraries, snap.MediaCount, snap.TotalSizeBytes) + } +} diff --git a/internal/service/storage_config.go b/internal/service/storage_config.go index b43e154..2e9f230 100644 --- a/internal/service/storage_config.go +++ b/internal/service/storage_config.go @@ -13,6 +13,7 @@ import ( "io" "net/http" "net/url" + "strconv" "strings" "time" @@ -37,7 +38,7 @@ func NewStorageConfigService(log *zap.Logger, repo *repository.Container, crypto log: log, repo: repo, crypto: crypto, - client: &http.Client{Timeout: 15 * time.Second}, + client: &http.Client{Timeout: 120 * time.Second}, } } @@ -129,6 +130,7 @@ func (s *StorageConfigService) Test(ctx context.Context, in StorageInput) error if cfg == nil { return errors.New("config required") } + client := s.clientForConfig(cfg) switch in.Type { case "alist": server := strings.TrimRight(strr(cfg["server"]), "/") @@ -139,7 +141,7 @@ func (s *StorageConfigService) Test(ctx context.Context, in StorageInput) error if tok := strr(cfg["token"]); tok != "" { req.Header.Set("Authorization", tok) } - resp, err := s.client.Do(req) + resp, err := client.Do(req) if err != nil { return err } @@ -150,7 +152,7 @@ func (s *StorageConfigService) Test(ctx context.Context, in StorageInput) error return nil case cloud.TypeOpenList: if hasWebDAVProbeConfig(cfg) { - p, err := cloud.New(in.Type, cfg, s.client) + p, err := cloud.New(in.Type, cfg, client) if err != nil { return err } @@ -162,7 +164,7 @@ func (s *StorageConfigService) Test(ctx context.Context, in StorageInput) error if tok := strr(cfg["token"]); tok != "" { req.Header.Set("Authorization", tok) } - resp, err := s.client.Do(req) + resp, err := client.Do(req) if err != nil { return decorateStorageTransportError("openlist", server, err) } @@ -172,7 +174,7 @@ func (s *StorageConfigService) Test(ctx context.Context, in StorageInput) error } return nil } - p, err := cloud.New(in.Type, cfg, s.client) + p, err := cloud.New(in.Type, cfg, client) if err != nil { return err } @@ -187,7 +189,7 @@ func (s *StorageConfigService) Test(ctx context.Context, in StorageInput) error req.SetBasicAuth(user, strr(cfg["password"])) } req.Header.Set("Depth", "0") - resp, err := s.client.Do(req) + resp, err := client.Do(req) if err != nil { return decorateStorageTransportError("webdav", u, err) } @@ -208,14 +210,14 @@ func (s *StorageConfigService) Test(ctx context.Context, in StorageInput) error // We only verify endpoint reachability — full SigV4 is a large // dependency; the upstream Vue project also stops at this level. req, _ := http.NewRequestWithContext(ctx, http.MethodGet, ep, nil) - resp, err := s.client.Do(req) + resp, err := client.Do(req) if err != nil { return err } defer resp.Body.Close() return nil case cloud.TypeQuark, cloud.Type115, cloud.TypeCloudDrive2: - p, err := cloud.New(in.Type, cfg, s.client) + p, err := cloud.New(in.Type, cfg, client) if err != nil { return err } @@ -238,7 +240,10 @@ func (s *StorageConfigService) CloudProvider(ctx context.Context, typ string) (c if view == nil { return nil, fmt.Errorf("%s storage not configured", typ) } - return cloud.New(typ, view.Config, s.client) + if !view.Enabled { + return nil, fmt.Errorf("%s storage disabled", typ) + } + return cloud.New(typ, view.Config, s.clientForConfig(view.Config)) } // CloudList lists entries under dirID for the configured cloud provider. @@ -314,6 +319,9 @@ func (s *StorageConfigService) cloudProviderWithUA(ctx context.Context, typ, cli if view == nil { return nil, fmt.Errorf("%s storage not configured", typ) } + if !view.Enabled { + return nil, fmt.Errorf("%s storage disabled", typ) + } cfg := view.Config if strings.TrimSpace(clientUA) != "" { // Copy so we never mutate the cached view config. @@ -324,7 +332,52 @@ func (s *StorageConfigService) cloudProviderWithUA(ctx context.Context, typ, cli cp["ua"] = clientUA cfg = cp } - return cloud.New(typ, cfg, s.client) + return cloud.New(typ, cfg, s.clientForConfig(cfg)) +} + +func (s *StorageConfigService) clientForConfig(cfg map[string]any) *http.Client { + if s == nil || s.client == nil { + return &http.Client{Timeout: 120 * time.Second} + } + timeout := storageTimeoutFromConfig(cfg, s.client.Timeout) + if timeout == s.client.Timeout { + return s.client + } + cp := *s.client + cp.Timeout = timeout + return &cp +} + +func storageTimeoutFromConfig(cfg map[string]any, fallback time.Duration) time.Duration { + if fallback <= 0 { + fallback = 120 * time.Second + } + raw := "" + for _, key := range []string{"timeout_seconds", "webdav_timeout_seconds", "request_timeout_seconds"} { + if value := strr(cfg[key]); value != "" { + raw = value + break + } + } + if raw == "" { + return fallback + } + seconds, err := strconv.Atoi(raw) + if err != nil { + if f, ferr := strconv.ParseFloat(raw, 64); ferr == nil { + seconds = int(f) + } + } + if seconds <= 0 { + return fallback + } + if seconds < 5 { + seconds = 5 + } + if seconds > 600 { + seconds = 600 + } + return time.Duration(seconds) * time.Second } // cloudLibraryName maps a provider type to a friendly Chinese library name. @@ -392,7 +445,7 @@ func (s *StorageConfigService) CloudImport(ctx context.Context, typ, fileRef, na Path: cloudMediaPath(typ, fileRef), SizeBytes: size, Container: container, - STRMURL: "/api/cloud/play/" + typ + "?ref=" + url.QueryEscape(fileRef), + STRMURL: BuildPublicAPIURL(ctx, s.repo, nil, "/api/cloud/play/"+typ, url.Values{"ref": []string{fileRef}}), ScrapeStatus: "pending", } if err := s.repo.Media.Upsert(ctx, m); err != nil { diff --git a/internal/service/storage_upload_test.go b/internal/service/storage_upload_test.go index b632fb7..d211f45 100644 --- a/internal/service/storage_upload_test.go +++ b/internal/service/storage_upload_test.go @@ -168,6 +168,24 @@ func TestStorageConfigOpenListHTTPSAgainstHTTPHint(t *testing.T) { } } +func TestStorageConfigCloudProviderRejectsDisabledConfig(t *testing.T) { + _, storage := newStorageUploadTestService(t) + enabled := false + if _, err := storage.Save(t.Context(), StorageInput{ + Type: "openlist", + Config: map[string]any{ + "url": "http://127.0.0.1:5244/dav", + }, + Enabled: &enabled, + }); err != nil { + t.Fatal(err) + } + _, err := storage.CloudProvider(t.Context(), "openlist") + if err == nil || !strings.Contains(err.Error(), "disabled") { + t.Fatalf("disabled provider error = %v, want disabled", err) + } +} + func TestSchedulerCloudUploadUsesConfiguredLocalSource(t *testing.T) { var uploaded []string alist := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { diff --git a/internal/service/telegram_api_test.go b/internal/service/telegram_api_test.go index 1dcd3c8..2ed8a26 100644 --- a/internal/service/telegram_api_test.go +++ b/internal/service/telegram_api_test.go @@ -94,6 +94,35 @@ func TestTelegramTargetChatIDsUsesLegacyPrivateChatID(t *testing.T) { } } +func TestRegisterTelegramBotCommands(t *testing.T) { + var gotPath string + var payload struct { + Commands []telegramBotCommand `json:"commands"` + } + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotPath = r.URL.Path + if err := json.NewDecoder(r.Body).Decode(&payload); err != nil { + t.Fatalf("decode payload: %v", err) + } + _, _ = w.Write([]byte(`{"ok":true}`)) + })) + defer server.Close() + + err := registerTelegramBotCommands(t.Context(), map[string]string{ + "bot_token": "123456:ABC", + "api_base_url": server.URL, + }) + if err != nil { + t.Fatal(err) + } + if gotPath != "/bot123456:ABC/setMyCommands" { + t.Fatalf("path = %q", gotPath) + } + if len(payload.Commands) == 0 || payload.Commands[0].Command != "start" { + t.Fatalf("commands not registered: %#v", payload.Commands) + } +} + func TestTelegramProxyCandidatesDefaultLocalFallbacks(t *testing.T) { got := telegramProxyCandidates(map[string]string{}) joined := strings.Join(got, ",") diff --git a/internal/service/telegram_bot.go b/internal/service/telegram_bot.go index 33c0ffb..9145659 100644 --- a/internal/service/telegram_bot.go +++ b/internal/service/telegram_bot.go @@ -510,11 +510,15 @@ func (s *TelegramBotService) cmdHideAdult(ctx context.Context, msg *TelegramMess // cmdStatus 处理 /status 命令。 func (s *TelegramBotService) cmdStatus(ctx context.Context) (telegramCommandReply, error) { + libraryIDs, err := s.activeTelegramStatsLibraryIDs(ctx) + if err != nil { + return telegramCommandReply{}, err + } var mediaCount int64 - s.repo.DB.Model(&model.Media{}).Count(&mediaCount) + s.mediaStatsQuery(libraryIDs).Count(&mediaCount) var totalSize int64 - s.repo.DB.Raw("SELECT COALESCE(SUM(size_bytes), 0) FROM media").Scan(&totalSize) + s.mediaStatsQuery(libraryIDs).Select("COALESCE(SUM(size_bytes), 0)").Row().Scan(&totalSize) totalSizeGB := float64(totalSize) / 1024 / 1024 / 1024 return telegramCommandReply{Text: fmt.Sprintf( @@ -605,30 +609,42 @@ func (s *TelegramBotService) cmdDownloads(ctx context.Context) (telegramCommandR // cmdStats 处理 /stats 命令。 func (s *TelegramBotService) cmdStats(ctx context.Context) (telegramCommandReply, error) { + libs, err := s.activeTelegramStatsLibraries(ctx) + if err != nil { + return telegramCommandReply{}, err + } + libraryIDs := make([]string, 0, len(libs)) + for _, lib := range libs { + libraryIDs = append(libraryIDs, lib.ID) + } var totalMedia int64 - s.repo.DB.Model(&model.Media{}).Count(&totalMedia) + s.mediaStatsQuery(libraryIDs).Count(&totalMedia) var totalSize int64 - s.repo.DB.Raw("SELECT COALESCE(SUM(size_bytes), 0) FROM media").Scan(&totalSize) + s.mediaStatsQuery(libraryIDs).Select("COALESCE(SUM(size_bytes), 0)").Row().Scan(&totalSize) type LibStat struct { Name string Type string Count int64 } - var libs []LibStat - s.repo.DB.Raw( - "SELECT l.name, l.type, COUNT(m.id) as count FROM libraries l LEFT JOIN media m ON m.library_id = l.id GROUP BY l.id ORDER BY count DESC", - ).Scan(&libs) + stats := make([]LibStat, 0, len(libs)) + for _, lib := range libs { + var count int64 + if err := s.repo.DB.WithContext(ctx).Model(&model.Media{}).Where("library_id = ?", lib.ID).Count(&count).Error; err != nil { + return telegramCommandReply{}, err + } + stats = append(stats, LibStat{Name: lib.Name, Type: lib.Type, Count: count}) + } var sb strings.Builder sb.WriteString("媒体库统计\n\n") sb.WriteString(fmt.Sprintf("📚 总数: %d\n", totalMedia)) sb.WriteString(fmt.Sprintf("💾 大小: %s\n", formatSize(totalSize))) - if len(libs) > 0 { + if len(stats) > 0 { sb.WriteString("\n各库分布:\n") - for _, l := range libs { + for _, l := range stats { icon := "🎬" switch l.Type { case "tv": @@ -645,6 +661,44 @@ func (s *TelegramBotService) cmdStats(ctx context.Context) (telegramCommandReply return telegramCommandReply{Text: sb.String()}, nil } +func (s *TelegramBotService) activeTelegramStatsLibraries(ctx context.Context) ([]model.Library, error) { + if s == nil || s.repo == nil || s.repo.Library == nil { + return nil, nil + } + libs, err := s.repo.Library.List(ctx) + if err != nil { + return nil, err + } + libs = FilterShadowedCloudLibraries(libs) + out := libs[:0] + for _, lib := range libs { + if lib.Enabled { + out = append(out, lib) + } + } + return out, nil +} + +func (s *TelegramBotService) activeTelegramStatsLibraryIDs(ctx context.Context) ([]string, error) { + libs, err := s.activeTelegramStatsLibraries(ctx) + if err != nil { + return nil, err + } + ids := make([]string, 0, len(libs)) + for _, lib := range libs { + ids = append(ids, lib.ID) + } + return ids, nil +} + +func (s *TelegramBotService) mediaStatsQuery(libraryIDs []string) *gorm.DB { + q := s.repo.DB.Model(&model.Media{}) + if len(libraryIDs) == 0 { + return q.Where("1 = 0") + } + return q.Where("library_id IN ?", libraryIDs) +} + // ── Polling ── // StartPolling 为所有已启用的 Telegram 通知渠道启动长轮询。 @@ -671,6 +725,9 @@ func (s *TelegramBotService) StartPolling(ctx context.Context) { if botToken == "" { continue } + if err := registerTelegramBotCommands(ctx, cfg); err != nil && s.log != nil { + s.log.Warn("telegram setMyCommands failed", zap.Error(sanitizeTelegramError(err))) + } s.pollingMu.Lock() if _, running := s.pollingCancel[botToken]; running { @@ -1294,11 +1351,14 @@ func userNameOrFallback(user *model.User) string { // SetWebhook 注册 Telegram Bot Webhook URL。 func (s *TelegramBotService) SetWebhook(ctx context.Context, botToken, webhookURL string) error { + cfg := map[string]string{"bot_token": botToken} + if err := registerTelegramBotCommands(ctx, cfg); err != nil && s.log != nil { + s.log.Warn("telegram setMyCommands failed", zap.Error(sanitizeTelegramError(err))) + } payload := map[string]interface{}{ "url": webhookURL, "allowed_updates": []string{"message", "callback_query"}, } - cfg := map[string]string{"bot_token": botToken} return telegramPostJSON(ctx, cfg, "setWebhook", payload, 15*time.Second) } diff --git a/internal/service/telegram_commands.go b/internal/service/telegram_commands.go index 9f2fc16..a69be43 100644 --- a/internal/service/telegram_commands.go +++ b/internal/service/telegram_commands.go @@ -5,6 +5,7 @@ import ( "context" "fmt" "strings" + "time" "github.com/ShukeBta/MediaStationGo/internal/model" ) @@ -125,3 +126,40 @@ var telegramSupportedCommandSet = map[string]struct{}{ "/devicepolicy": {}, "/policy": {}, "/antishare": {}, "/cleanup": {}, "/cleanup_mode": {}, "/cleanup_rule": {}, "/ban": {}, "/unban": {}, "/status": {}, "/search": {}, "/downloads": {}, "/stats": {}, } + +type telegramBotCommand struct { + Command string `json:"command"` + Description string `json:"description"` +} + +func telegramBotCommandMenu() []telegramBotCommand { + return []telegramBotCommand{ + {Command: "start", Description: "绑定账号或打开主菜单"}, + {Command: "menu", Description: "打开功能菜单"}, + {Command: "help", Description: "查看命令帮助"}, + {Command: "account", Description: "查看账号状态"}, + {Command: "signin", Description: "签到"}, + {Command: "devices", Description: "查看登录设备"}, + {Command: "kick", Description: "踢下线设备"}, + {Command: "hideadult", Description: "隐藏/显示成人媒体库"}, + {Command: "redeem", Description: "兑换注册码或续期码"}, + {Command: "register", Description: "注册新账号"}, + {Command: "status", Description: "系统运行状态(管理员)"}, + {Command: "search", Description: "搜索媒体库(管理员)"}, + {Command: "downloads", Description: "下载列表(管理员)"}, + {Command: "stats", Description: "媒体库统计(管理员)"}, + {Command: "users", Description: "用户管理(管理员)"}, + {Command: "cleanup", Description: "删号规则巡检(管理员)"}, + {Command: "cleanup_rule", Description: "保号规则管理(管理员)"}, + } +} + +func registerTelegramBotCommands(ctx context.Context, cfg map[string]string) error { + if strings.TrimSpace(cfg["bot_token"]) == "" { + return nil + } + payload := map[string]interface{}{ + "commands": telegramBotCommandMenu(), + } + return telegramPostJSON(ctx, cfg, "setMyCommands", payload, 15*time.Second) +} diff --git a/web/src/api/client.ts b/web/src/api/client.ts index 4ab14ca..c020ccc 100644 --- a/web/src/api/client.ts +++ b/web/src/api/client.ts @@ -151,9 +151,15 @@ export function hlsURL(mediaId: string): string { export function imageURL(remote?: string): string { if (!remote) return '' if (remote.startsWith('/api/img')) return remote + if (remote.startsWith('/api/')) return withQuery(remote, tokenQuery()) return `/api/img?url=${encodeURIComponent(remote)}&${tokenQuery()}` } +function withQuery(url: string, query: string): string { + if (!query) return url + return `${url}${url.includes('?') ? '&' : '?'}${query}` +} + // getToken returns the current auth token export function getToken(): string | null { return useAuthStore.getState().token diff --git a/web/src/api/storage_config.ts b/web/src/api/storage_config.ts index 97e7b92..075096c 100644 --- a/web/src/api/storage_config.ts +++ b/web/src/api/storage_config.ts @@ -48,6 +48,24 @@ export interface CloudUploadResult { }> } +export interface CloudScanStatus { + library_id: string + provider: string + stage: string + state: string + dirs: number + discovered: number + visited: number + added: number + updated: number + skipped: number + removed: number + error?: string + resume_hint?: string + estimate_message?: string + files_per_second?: number +} + export const storageAPI = { status: () => api @@ -83,6 +101,23 @@ export const storageAPI = { api .post<{ result: CloudUploadResult; error?: string }>(`/admin/storage/${type}/upload-local`, input) .then((r) => r.data), + + scanAllCloud: () => + api + .post<{ items: CloudScanStatus[]; message?: string; estimate_message?: string }>('/admin/cloud/scan-all') + .then((r) => r.data), + + cancelCloudScan: (libraryID = '', provider = '') => + api + .post<{ cancelled: number; message?: string }>('/admin/cloud/scan/cancel', null, { + params: libraryID ? { library_id: libraryID } : provider ? { provider } : undefined, + }) + .then((r) => r.data), + + cloudScanStatus: () => + api + .get<{ items: CloudScanStatus[] }>('/admin/cloud/scan/status') + .then((r) => r.data), } // cloudAPI drives 网盘 browsing, QR login and 302 import. diff --git a/web/src/pages/SettingsPage.tsx b/web/src/pages/SettingsPage.tsx index 1beaf9c..ba69699 100644 --- a/web/src/pages/SettingsPage.tsx +++ b/web/src/pages/SettingsPage.tsx @@ -47,6 +47,13 @@ const GROUPS: SettingGroup[] = [ { value: 'ja-JP', label: '日本語' }, ], }, + { + key: 'app.server_url', + label: '公开访问域名 / STRM 域名', + type: 'text', + hint: '例如 http://NAS-IP:18080 或 https://media.example.com。填写后网盘媒体扫描会自动生成完整 STRM/302 播放入口;不填则使用同源相对路径。', + placeholder: 'http://192.168.1.125:18080', + }, { key: 'playback.direct_only', label: '客户端直连解码(释放宿主机资源)', diff --git a/web/src/pages/StorageConfigPage.tsx b/web/src/pages/StorageConfigPage.tsx index aa99018..fb6b373 100644 --- a/web/src/pages/StorageConfigPage.tsx +++ b/web/src/pages/StorageConfigPage.tsx @@ -1,11 +1,12 @@ import { FormEvent, useEffect, useMemo, useState } from 'react' -import { Cloud, FileVideo, Folder, Loader2, QrCode, Save, Send, Trash2, Upload } from 'lucide-react' +import { Cloud, FileVideo, Folder, Loader2, PauseCircle, QrCode, RefreshCw, Save, Send, Trash2, Upload } from 'lucide-react' import toast from 'react-hot-toast' import { libraryAPI } from '../api/library' import { cloudAPI, storageAPI, + type CloudScanStatus, type CloudEntry, type QRSession, type StorageType, @@ -117,12 +118,14 @@ const FIELD_DEFS: Record = {} for (const f of fields) next[f.key] = '' @@ -480,6 +484,9 @@ function CloudBrowser({ type }: { type: StorageType }) { const [loading, setLoading] = useState(false) const [mounting, setMounting] = useState(false) const [batchMounting, setBatchMounting] = useState(false) + const [scanBusy, setScanBusy] = useState(false) + const [cancelBusy, setCancelBusy] = useState(false) + const [scanStatuses, setScanStatuses] = useState([]) const [mountMediaType, setMountMediaType] = useState('auto') const [error, setError] = useState('') @@ -504,6 +511,11 @@ function CloudBrowser({ type }: { type: StorageType }) { setMounts(libs.filter((lib) => cloudLibraryProvider(lib.path) === type)) } + const loadScanStatus = async () => { + const r = await storageAPI.cloudScanStatus() + setScanStatuses((r.items ?? []).filter((item) => !type || item.provider === type)) + } + useEffect(() => { load(cur.id).catch(() => undefined) // eslint-disable-next-line react-hooks/exhaustive-deps @@ -511,6 +523,15 @@ function CloudBrowser({ type }: { type: StorageType }) { useEffect(() => { loadMounts().catch(() => undefined) + loadScanStatus().catch(() => undefined) + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [type]) + + useEffect(() => { + const timer = window.setInterval(() => { + loadScanStatus().catch(() => undefined) + }, 3000) + return () => window.clearInterval(timer) // eslint-disable-next-line react-hooks/exhaustive-deps }, [type]) @@ -600,8 +621,82 @@ function CloudBrowser({ type }: { type: StorageType }) { await loadMounts() } + const scanAllCloudLibraries = async () => { + setScanBusy(true) + try { + const r = await storageAPI.scanAllCloud() + setScanStatuses(r.items ?? []) + toast.success(r.message ?? '已开始扫描所有启用的网盘媒体库') + } catch (err: unknown) { + toast.error((err as { response?: { data?: { error?: string } } })?.response?.data?.error ?? '启动扫描失败') + } finally { + setScanBusy(false) + } + } + + const cancelCloudScans = async () => { + setCancelBusy(true) + try { + const r = await storageAPI.cancelCloudScan('', type) + toast.success(r.message ?? `已中断 ${r.cancelled} 个扫描任务`) + await loadScanStatus() + } catch (err: unknown) { + toast.error((err as { response?: { data?: { error?: string } } })?.response?.data?.error ?? '中断扫描失败') + } finally { + setCancelBusy(false) + } + } + return ( e.preventDefault()}> + + + + 网盘媒体库扫描 + + 只需在系统设置填写公开域名,扫描会自动为网盘媒体生成 STRM/302 播放入口;中断后再次扫描会去重补齐。 + + + + + {scanBusy ? : } + {' '}一键扫描全部网盘库 + + + {cancelBusy ? : } + {' '}中断当前网盘扫描 + + + + {scanStatuses.length > 0 && ( + + {scanStatuses.slice(0, 6).map((item) => ( + + {item.state} + {' · '} + {item.provider} + {' · 目录 '} + {item.dirs} + {' · 发现 '} + {item.discovered} + {' · 入库 '} + {item.added + item.updated} + {item.error ? · {item.error} : null} + + ))} + + )} + {mounts.length > 0 && ( 已挂载目录 diff --git a/web/src/utils/groupSeries.ts b/web/src/utils/groupSeries.ts index 92cbad7..5ed095a 100644 --- a/web/src/utils/groupSeries.ts +++ b/web/src/utils/groupSeries.ts @@ -20,11 +20,13 @@ export type SeriesCard = { key: string; rep: Media; count: number } export function getSeriesKey(media: Media): string { if (media.series_id) return `series:${media.series_id}` + const fromPath = seriesTitleFromPath(media.path) if (isEpisodeLike(media)) { - return `lib:${media.library_id}|show:${normalizeTitle(seriesTitle(media))}` + return `lib:${media.library_id}|show:${normalizeTitle(fromPath || seriesTitle(media))}` } if (media.tmdb_id && media.tmdb_id > 0) return `tmdb:${media.tmdb_id}` if (media.bangumi_id && media.bangumi_id > 0) return `bgm:${media.bangumi_id}` + if (fromPath) return `lib:${media.library_id}|show:${normalizeTitle(fromPath)}` return `lib:${media.library_id}|${normalizeTitle(media.title)}` }
+ 只需在系统设置填写公开域名,扫描会自动为网盘媒体生成 STRM/302 播放入口;中断后再次扫描会去重补齐。 +