fix cloud library mounts and artwork caching

This commit is contained in:
ShukeBta
2026-06-10 20:43:42 +08:00
parent 0bf983cec8
commit fd0428ee7d
36 changed files with 1469 additions and 140 deletions
+2
View File
@@ -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
+126 -73
View File
@@ -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
}
+26
View File
@@ -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{
+6 -19
View File
@@ -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,
+3
View File
@@ -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))
+16 -2
View File
@@ -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)
}
+6 -2
View File
@@ -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
}
+3
View File
@@ -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)
}
}
+1
View File
@@ -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": "启用硬件加速"},
+6
View File
@@ -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.
+30 -2
View File
@@ -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
}
+1 -1
View File
@@ -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}.*$`)
)
+39
View File
@@ -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{
+82 -3
View File
@@ -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
}
+4
View File
@@ -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 {
+111
View File
@@ -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
+33
View File
@@ -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) {
+19
View File
@@ -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
}
+97
View File
@@ -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)
}
}
+10
View File
@@ -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
+49
View File
@@ -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()
}
+2
View File
@@ -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":
+312 -2
View File
@@ -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 {
+28
View File
@@ -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",
+39 -11
View File
@@ -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
+50
View File
@@ -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)
}
}
+64 -11
View File
@@ -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 {
+18
View File
@@ -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) {
+29
View File
@@ -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, ",")
+71 -11
View File
@@ -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("<b>媒体库统计</b>\n\n")
sb.WriteString(fmt.Sprintf("📚 总数: <b>%d</b>\n", totalMedia))
sb.WriteString(fmt.Sprintf("💾 大小: <b>%s</b>\n", formatSize(totalSize)))
if len(libs) > 0 {
if len(stats) > 0 {
sb.WriteString("\n<b>各库分布:</b>\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)
}
+38
View File
@@ -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)
}
+6
View File
@@ -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
+35
View File
@@ -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.
+7
View File
@@ -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: '客户端直连解码(释放宿主机资源)',
+97 -2
View File
@@ -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<StorageType, { key: string; label: string; secret?: boo
{ key: 'url', label: 'WebDAV URL(浏览/挂载)', placeholder: 'http://NAS-IP:5244/dav/' },
{ key: 'username', label: 'WebDAV 用户名' },
{ key: 'password', label: 'WebDAV 密码', secret: true },
{ key: 'timeout_seconds', label: '请求超时秒数', placeholder: '120' },
{ key: 'force_302', label: '强制 302 直链(true/false,默认反代)' },
],
webdav: [
{ key: 'url', label: 'URL', placeholder: 'https://example.com/dav/' },
{ key: 'username', label: '用户名' },
{ key: 'password', label: '密码', secret: true },
{ key: 'timeout_seconds', label: '请求超时秒数', placeholder: '120' },
],
s3: [
{ key: 'endpoint', label: 'Endpoint', placeholder: 'https://s3.amazonaws.com' },
@@ -145,6 +148,7 @@ const FIELD_DEFS: Record<StorageType, { key: string; label: string; secret?: boo
{ key: 'username', label: '用户名' },
{ key: 'password', label: '密码 / Token', secret: true },
{ key: 'token', label: 'Authorization Token(可选)', secret: true, placeholder: 'Bearer ... 或 Basic ...' },
{ key: 'timeout_seconds', label: '请求超时秒数', placeholder: '120' },
{ key: 'force_302', label: '强制 302 直链(true/false,默认反代)' },
],
}
@@ -169,7 +173,7 @@ function StorageForm({ type }: { type: StorageType }) {
next[f.key] = v === '********' ? '' : v ?? ''
}
setConfig(next)
setEnabled(r.enabled)
setEnabled(r.enabled ?? true)
} catch {
const next: Record<string, string> = {}
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<CloudScanStatus[]>([])
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 (
<div className="mt-2 rounded-lg border border-gray-200 p-3" onClick={(e) => e.preventDefault()}>
<div className="mb-3 rounded border border-emerald-100 bg-emerald-50/60 p-2">
<div className="mb-2 flex flex-wrap items-center justify-between gap-2">
<div>
<div className="text-xs font-semibold text-ink-100">网盘媒体库扫描</div>
<p className="text-xs text-ink-50">
只需在系统设置填写公开域名,扫描会自动为网盘媒体生成 STRM/302 播放入口;中断后再次扫描会去重补齐。
</p>
</div>
<div className="flex flex-wrap gap-2">
<button
type="button"
className="rounded border border-emerald-300 px-2 py-1 text-xs text-emerald-700 hover:bg-emerald-50"
disabled={scanBusy}
onClick={scanAllCloudLibraries}
>
{scanBusy ? <Loader2 size={13} className="inline animate-spin" /> : <RefreshCw size={13} className="inline" />}
{' '}一键扫描全部网盘库
</button>
<button
type="button"
className="rounded border border-red-200 px-2 py-1 text-xs text-red-500 hover:bg-red-50"
disabled={cancelBusy}
onClick={cancelCloudScans}
>
{cancelBusy ? <Loader2 size={13} className="inline animate-spin" /> : <PauseCircle size={13} className="inline" />}
{' '}中断当前网盘扫描
</button>
</div>
</div>
{scanStatuses.length > 0 && (
<div className="grid gap-1 text-xs text-ink-50 md:grid-cols-2">
{scanStatuses.slice(0, 6).map((item) => (
<div key={item.library_id} className="rounded bg-white/80 px-2 py-1">
<span className="font-mono text-ink-100">{item.state}</span>
{' · '}
{item.provider}
{' · 目录 '}
{item.dirs}
{' · 发现 '}
{item.discovered}
{' · 入库 '}
{item.added + item.updated}
{item.error ? <span className="text-red-500"> · {item.error}</span> : null}
</div>
))}
</div>
)}
</div>
{mounts.length > 0 && (
<div className="mb-3 rounded border border-blue-100 bg-blue-50/60 p-2">
<div className="mb-1 text-xs font-semibold text-ink-100">已挂载目录</div>
+3 -1
View File
@@ -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)}`
}