mirror of
https://github.com/truewhile/MeBox.git
synced 2026-09-28 11:16:37 +08:00
fix cloud library mounts and artwork caching
This commit is contained in:
@@ -27,6 +27,8 @@ data/
|
|||||||
cache/
|
cache/
|
||||||
logs/
|
logs/
|
||||||
.tmp-deploy-data/
|
.tmp-deploy-data/
|
||||||
|
.tmp-deploy-smoke-data/
|
||||||
|
.tmp-deploy-smoke-cache/
|
||||||
.tmp-deploy-cache/
|
.tmp-deploy-cache/
|
||||||
.tmp-deploy-server.*
|
.tmp-deploy-server.*
|
||||||
.mediastation.pid
|
.mediastation.pid
|
||||||
|
|||||||
+126
-73
@@ -3,14 +3,11 @@
|
|||||||
package handler
|
package handler
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
|
||||||
"io"
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
"go.uber.org/zap"
|
|
||||||
|
|
||||||
"github.com/ShukeBta/MediaStationGo/internal/model"
|
"github.com/ShukeBta/MediaStationGo/internal/model"
|
||||||
"github.com/ShukeBta/MediaStationGo/internal/service"
|
"github.com/ShukeBta/MediaStationGo/internal/service"
|
||||||
@@ -146,34 +143,7 @@ func cloudMountHandler(svc *service.Container) gin.HandlerFunc {
|
|||||||
"estimate_message": "小目录通常几十秒;几万文件的大目录可能需要数分钟到数小时,取决于网盘接口速度",
|
"estimate_message": "小目录通常几十秒;几万文件的大目录可能需要数分钟到数小时,取决于网盘接口速度",
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
go func() {
|
_, _, _ = svc.Scan.StartCloudLibraryScan(libID, false)
|
||||||
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))
|
|
||||||
}
|
|
||||||
}()
|
|
||||||
}
|
}
|
||||||
c.JSON(http.StatusAccepted, gin.H{
|
c.JSON(http.StatusAccepted, gin.H{
|
||||||
"library": lib,
|
"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 {
|
func cloudMountLibraryName(typ, dir string) string {
|
||||||
base := typ
|
base := typ
|
||||||
switch typ {
|
switch typ {
|
||||||
@@ -253,47 +279,74 @@ func cloudPlayHandler(svc *service.Container) gin.HandlerFunc {
|
|||||||
c.JSON(http.StatusBadRequest, gin.H{"error": "ref required"})
|
c.JSON(http.StatusBadRequest, gin.H{"error": "ref required"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
link, err := svc.StorageCfg.CloudResolve(c.Request.Context(), typ, ref, c.Request.UserAgent())
|
serveCloudResolvedLink(svc, c, typ, ref)
|
||||||
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)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|||||||
@@ -610,6 +610,10 @@ func embyItemImageHandler(svc *service.Container) gin.HandlerFunc {
|
|||||||
c.Status(http.StatusNotFound)
|
c.Status(http.StatusNotFound)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if typ, ref, ok := parseCloudPlayImageURL(raw); ok {
|
||||||
|
serveCloudResolvedLink(svc, c, typ, ref)
|
||||||
|
return
|
||||||
|
}
|
||||||
if svc.ImageProxy == nil {
|
if svc.ImageProxy == nil {
|
||||||
c.Status(http.StatusNotFound)
|
c.Status(http.StatusNotFound)
|
||||||
return
|
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 {
|
func embyShowSeasonsHandler(svc *service.Container) gin.HandlerFunc {
|
||||||
return func(c *gin.Context) {
|
return func(c *gin.Context) {
|
||||||
params := service.ItemsParams{
|
params := service.ItemsParams{
|
||||||
|
|||||||
@@ -6,10 +6,8 @@ import (
|
|||||||
"errors"
|
"errors"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strconv"
|
"strconv"
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
"go.uber.org/zap"
|
|
||||||
|
|
||||||
"github.com/ShukeBta/MediaStationGo/internal/middleware"
|
"github.com/ShukeBta/MediaStationGo/internal/middleware"
|
||||||
"github.com/ShukeBta/MediaStationGo/internal/service"
|
"github.com/ShukeBta/MediaStationGo/internal/service"
|
||||||
@@ -68,6 +66,11 @@ func createLibraryHandler(svc *service.Container) gin.HandlerFunc {
|
|||||||
func deleteLibraryHandler(svc *service.Container) gin.HandlerFunc {
|
func deleteLibraryHandler(svc *service.Container) gin.HandlerFunc {
|
||||||
return func(c *gin.Context) {
|
return func(c *gin.Context) {
|
||||||
id := c.Param("id")
|
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 {
|
if err := svc.Media.DeleteLibrary(c.Request.Context(), id); err != nil {
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
return
|
return
|
||||||
@@ -102,23 +105,7 @@ func scanLibraryHandler(svc *service.Container) gin.HandlerFunc {
|
|||||||
"estimate_message": "小目录通常几十秒;几万文件的大目录可能需要数分钟到数小时,取决于网盘接口速度",
|
"estimate_message": "小目录通常几十秒;几万文件的大目录可能需要数分钟到数小时,取决于网盘接口速度",
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
go func(libraryID string) {
|
_, _, _ = svc.Scan.StartCloudLibraryScan(id, false)
|
||||||
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)
|
|
||||||
c.JSON(http.StatusAccepted, gin.H{
|
c.JSON(http.StatusAccepted, gin.H{
|
||||||
"library_id": id,
|
"library_id": id,
|
||||||
"visited": 0,
|
"visited": 0,
|
||||||
|
|||||||
@@ -38,6 +38,9 @@ func registerAdminRoutes(api *gin.RouterGroup, cfg *config.Config, svc *service.
|
|||||||
admin.POST("/storage/:type/upload-local", storageUploadLocalHandler(svc))
|
admin.POST("/storage/:type/upload-local", storageUploadLocalHandler(svc))
|
||||||
|
|
||||||
// Cloud disk (115 / 夸克) browsing, QR login and 302 import.
|
// 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.GET("/cloud/:type/list", cloudListHandler(svc))
|
||||||
admin.POST("/cloud/:type/import", cloudImportHandler(svc))
|
admin.POST("/cloud/:type/import", cloudImportHandler(svc))
|
||||||
admin.POST("/cloud/:type/mount", cloudMountHandler(svc))
|
admin.POST("/cloud/:type/mount", cloudMountHandler(svc))
|
||||||
|
|||||||
@@ -32,8 +32,14 @@ func applyStatsVisibility(c *gin.Context, svc *service.Container, snap *service.
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
libs = service.FilterShadowedCloudLibraries(libs)
|
||||||
var visibleLibraries int64
|
var visibleLibraries int64
|
||||||
|
activeLibraryIDs := make([]string, 0, len(libs))
|
||||||
for _, lib := range libs {
|
for _, lib := range libs {
|
||||||
|
if !lib.Enabled {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
activeLibraryIDs = append(activeLibraryIDs, lib.ID)
|
||||||
if service.LibraryVisibleForUser(c.Request.Context(), svc.Repo, lib, visibility) {
|
if service.LibraryVisibleForUser(c.Request.Context(), svc.Repo, lib, visibility) {
|
||||||
visibleLibraries++
|
visibleLibraries++
|
||||||
}
|
}
|
||||||
@@ -41,6 +47,7 @@ func applyStatsVisibility(c *gin.Context, svc *service.Container, snap *service.
|
|||||||
snap.Libraries = visibleLibraries
|
snap.Libraries = visibleLibraries
|
||||||
|
|
||||||
q := applyMediaVisibilityQuery(svc.Repo.DB.WithContext(c.Request.Context()).Model(&model.Media{}), visibility)
|
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 {
|
if err := q.Count(&snap.MediaCount).Error; err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -49,7 +56,7 @@ func applyStatsVisibility(c *gin.Context, svc *service.Container, snap *service.
|
|||||||
Seconds int64
|
Seconds int64
|
||||||
}
|
}
|
||||||
var sum sumRow
|
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").
|
Select("COALESCE(SUM(size_bytes),0) as size, COALESCE(SUM(duration_sec),0) as seconds").
|
||||||
Scan(&sum).Error; err != nil {
|
Scan(&sum).Error; err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -58,7 +65,7 @@ func applyStatsVisibility(c *gin.Context, svc *service.Container, snap *service.
|
|||||||
snap.TotalSeconds = sum.Seconds
|
snap.TotalSeconds = sum.Seconds
|
||||||
|
|
||||||
var recent []model.Media
|
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").
|
Order("created_at desc").
|
||||||
Limit(12).
|
Limit(12).
|
||||||
Find(&recent).Error; err != nil {
|
Find(&recent).Error; err != nil {
|
||||||
@@ -80,3 +87,10 @@ func applyMediaVisibilityQuery(q *gorm.DB, visibility service.MediaVisibility) *
|
|||||||
}
|
}
|
||||||
return q
|
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)
|
||||||
|
}
|
||||||
|
|||||||
@@ -129,14 +129,18 @@ func statsTopContentHandler(svc *service.Container) gin.HandlerFunc {
|
|||||||
// statsLibrariesHandler returns per-library counts + size.
|
// statsLibrariesHandler returns per-library counts + size.
|
||||||
func statsLibrariesHandler(svc *service.Container) gin.HandlerFunc {
|
func statsLibrariesHandler(svc *service.Container) gin.HandlerFunc {
|
||||||
return func(c *gin.Context) {
|
return func(c *gin.Context) {
|
||||||
var libs []model.Library
|
libs, err := svc.Repo.Library.List(c.Request.Context())
|
||||||
if err := svc.Repo.DB.Find(&libs).Error; err != nil {
|
if err != nil {
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
libs = service.FilterShadowedCloudLibraries(libs)
|
||||||
out := make([]gin.H, 0, len(libs))
|
out := make([]gin.H, 0, len(libs))
|
||||||
visibility := mediaVisibilityForRequest(c, svc)
|
visibility := mediaVisibilityForRequest(c, svc)
|
||||||
for _, l := range libs {
|
for _, l := range libs {
|
||||||
|
if !l.Enabled {
|
||||||
|
continue
|
||||||
|
}
|
||||||
if !service.LibraryVisibleForUser(c.Request.Context(), svc.Repo, l, visibility) {
|
if !service.LibraryVisibleForUser(c.Request.Context(), svc.Repo, l, visibility) {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -53,6 +53,9 @@ func saveStorageConfigHandler(svc *service.Container) gin.HandlerFunc {
|
|||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if in.Enabled != nil && !*in.Enabled && svc.Scan != nil {
|
||||||
|
_ = svc.Scan.CancelCloudScansForProvider(in.Type)
|
||||||
|
}
|
||||||
c.JSON(http.StatusOK, row)
|
c.JSON(http.StatusOK, row)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -63,6 +63,7 @@ func schemaHandler(_ *service.Container) gin.HandlerFunc {
|
|||||||
"label": "常规",
|
"label": "常规",
|
||||||
"items": []gin.H{
|
"items": []gin.H{
|
||||||
{"key": "tmdb.language", "type": "select", "label": "TMDb 元数据语言"},
|
{"key": "tmdb.language", "type": "select", "label": "TMDb 元数据语言"},
|
||||||
|
{"key": "app.server_url", "type": "text", "label": "公开访问域名 / STRM 域名"},
|
||||||
{"key": "transcode.enabled", "type": "toggle", "label": "启用转码"},
|
{"key": "transcode.enabled", "type": "toggle", "label": "启用转码"},
|
||||||
{"key": "transcode.hw_accel", "type": "select", "label": "硬件编码器"},
|
{"key": "transcode.hw_accel", "type": "select", "label": "硬件编码器"},
|
||||||
{"key": "transcode.hw_enabled", "type": "toggle", "label": "启用硬件加速"},
|
{"key": "transcode.hw_enabled", "type": "toggle", "label": "启用硬件加速"},
|
||||||
|
|||||||
@@ -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
|
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 ──────────────────────────────────────────────────────────────────
|
// ─── Series ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
// SeriesRepository persists model.Series records.
|
// SeriesRepository persists model.Series records.
|
||||||
|
|||||||
@@ -3,7 +3,9 @@ package repository
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
"gorm.io/gorm"
|
"gorm.io/gorm"
|
||||||
|
|
||||||
"github.com/ShukeBta/MediaStationGo/internal/model"
|
"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.
|
// Upsert creates or replaces a storage config keyed by Type.
|
||||||
func (r *StorageConfigRepository) Upsert(ctx context.Context, c *model.StorageConfig) error {
|
func (r *StorageConfigRepository) Upsert(ctx context.Context, c *model.StorageConfig) error {
|
||||||
return r.db.WithContext(ctx).Where("type = ?", c.Type).
|
db := r.db.WithContext(ctx)
|
||||||
Assign(*c).FirstOrCreate(c).Error
|
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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -251,7 +251,7 @@ const (
|
|||||||
)
|
)
|
||||||
|
|
||||||
var (
|
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*$`)
|
embyYearSuffixRE = regexp.MustCompile(`\s*[\((\[]\d{4}[\))\]]\s*$`)
|
||||||
embyEpisodeTitleRE = regexp.MustCompile(`(?i)\s*[-_ ]*s\d{1,2}e\d{1,3}.*$`)
|
embyEpisodeTitleRE = regexp.MustCompile(`(?i)\s*[-_ ]*s\d{1,2}e\d{1,3}.*$`)
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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) {
|
func TestEmbyRootItemsExposeLibraries(t *testing.T) {
|
||||||
svc := newTestEmbyService(t)
|
svc := newTestEmbyService(t)
|
||||||
for _, lib := range []model.Library{
|
for _, lib := range []model.Library{
|
||||||
|
|||||||
@@ -18,15 +18,17 @@ import (
|
|||||||
"regexp"
|
"regexp"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
"unicode/utf8"
|
||||||
)
|
)
|
||||||
|
|
||||||
var (
|
var (
|
||||||
patSEnE = regexp.MustCompile(`(?i)s(\d{1,2})e(\d{1,3})`)
|
patSEnE = regexp.MustCompile(`(?i)s(\d{1,2})e(\d{1,3})`)
|
||||||
patNxE = regexp.MustCompile(`(\d{1,2})x(\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]|$)`)
|
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*[\[\(._-]|$)`)
|
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 匹配中文季/部标记,支持阿拉伯数字与中文数字(如「第二季」「第2部」)。
|
||||||
patCNSeason = regexp.MustCompile(`第\s*[0-9一二三四五六七八九十百零两]+\s*[季部]`)
|
patCNSeason = regexp.MustCompile(`第\s*[0-9一二三四五六七八九十百零两]+\s*[季部]`)
|
||||||
)
|
)
|
||||||
@@ -70,6 +72,13 @@ func ParseEpisode(path string) (season, episode int) {
|
|||||||
episode = mustAtoi(m[1])
|
episode = mustAtoi(m[1])
|
||||||
return
|
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
|
return 0, 0
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -97,6 +106,76 @@ func seasonFromParents(path string) int {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func mustAtoi(s 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
|
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
|
||||||
|
}
|
||||||
|
|||||||
@@ -18,9 +18,13 @@ func TestParseEpisode(t *testing.T) {
|
|||||||
{`Some Show/S02/Some Show - E04.mkv`, 2, 4},
|
{`Some Show/S02/Some Show - E04.mkv`, 2, 4},
|
||||||
{`剧集/第2季/剧集 第05集.mkv`, 2, 5},
|
{`剧集/第2季/剧集 第05集.mkv`, 2, 5},
|
||||||
{"日剧 第03集.mkv", 1, 3},
|
{"日剧 第03集.mkv", 1, 3},
|
||||||
|
{"日剧 第十集.mkv", 1, 10},
|
||||||
|
{"日剧 第二十五话.mkv", 1, 25},
|
||||||
{"日剧 第12话.mkv", 1, 12},
|
{"日剧 第12话.mkv", 1, 12},
|
||||||
{"综艺 第4期下.mkv", 1, 4},
|
{"综艺 第4期下.mkv", 1, 4},
|
||||||
{`综艺/Season 06/综艺 第17期.mkv`, 6, 17},
|
{`综艺/Season 06/综艺 第17期.mkv`, 6, 17},
|
||||||
|
{`动漫/第二季/04.mkv`, 2, 4},
|
||||||
|
{`动漫/第十季/第十一集.mkv`, 10, 11},
|
||||||
{"Movie.2020.1080p.mkv", 0, 0},
|
{"Movie.2020.1080p.mkv", 0, 0},
|
||||||
}
|
}
|
||||||
for _, tc := range cases {
|
for _, tc := range cases {
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ import (
|
|||||||
"go.uber.org/zap"
|
"go.uber.org/zap"
|
||||||
|
|
||||||
"github.com/ShukeBta/MediaStationGo/internal/config"
|
"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
|
// 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
|
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) {
|
func (p *ImageProxy) markImageFetchFailed(failPath string) {
|
||||||
if err := os.MkdirAll(filepath.Dir(failPath), 0o755); err != nil {
|
if err := os.MkdirAll(filepath.Dir(failPath), 0o755); err != nil {
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import (
|
|||||||
"go.uber.org/zap"
|
"go.uber.org/zap"
|
||||||
|
|
||||||
"github.com/ShukeBta/MediaStationGo/internal/config"
|
"github.com/ShukeBta/MediaStationGo/internal/config"
|
||||||
|
"github.com/ShukeBta/MediaStationGo/internal/service/cloud"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestImageProxyServesLocalImagePath(t *testing.T) {
|
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)
|
type imageRoundTripFunc func(*http.Request) (*http.Response, error)
|
||||||
|
|
||||||
func (f imageRoundTripFunc) RoundTrip(req *http.Request) (*http.Response, error) {
|
func (f imageRoundTripFunc) RoundTrip(req *http.Request) (*http.Response, error) {
|
||||||
|
|||||||
@@ -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
|
// DeleteLibrary removes a library and its media rows. The on-disk files are
|
||||||
// left untouched.
|
// left untouched.
|
||||||
func (s *MediaService) DeleteLibrary(ctx context.Context, id string) error {
|
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 {
|
if err := s.repo.Media.DeleteByLibrary(ctx, id); err != nil {
|
||||||
return err
|
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).
|
// SoftDelete moves a media row to the recycle bin (gorm soft delete).
|
||||||
// The on-disk file is kept; admins can purge it later.
|
// The on-disk file is kept; admins can purge it later.
|
||||||
func (s *MediaService) SoftDelete(ctx context.Context, id string) error {
|
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
|
return s.repo.DB.Where("id = ?", id).Delete(&model.Media{}).Error
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -4,6 +4,14 @@ import (
|
|||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"testing"
|
"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) {
|
func TestResolveAccessibleLibraryPathMapsConfiguredHostMediaDir(t *testing.T) {
|
||||||
@@ -40,3 +48,92 @@ func TestResolveAccessibleLibraryPathKeepsAccessibleContainerPath(t *testing.T)
|
|||||||
t.Fatalf("resolveAccessibleLibraryPath() = %q, want %q", got, filepath.Clean(containerLibrary))
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -108,6 +108,11 @@ func (s *NotifyChannelService) Create(ctx context.Context, in ChannelInput) (*ch
|
|||||||
if err := s.repo.NotifyChannel.Create(ctx, n); err != nil {
|
if err := s.repo.NotifyChannel.Create(ctx, n); err != nil {
|
||||||
return nil, err
|
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)
|
v := toView(*n)
|
||||||
return &v, nil
|
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 {
|
if err := s.repo.NotifyChannel.Update(ctx, existing); err != nil {
|
||||||
return nil, err
|
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)
|
row, err := s.repo.NotifyChannel.FindByID(ctx, id)
|
||||||
if err != nil || row == nil {
|
if err != nil || row == nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
|
|||||||
@@ -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()
|
||||||
|
}
|
||||||
@@ -35,6 +35,8 @@ func ApplyRuntimeSetting(cfg *config.Config, key, value string) {
|
|||||||
switch key {
|
switch key {
|
||||||
case "ffmpeg.path", "app.ffmpeg_path":
|
case "ffmpeg.path", "app.ffmpeg_path":
|
||||||
cfg.App.FFmpegPath = value
|
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":
|
case "ffprobe.path", "app.ffprobe_path":
|
||||||
cfg.App.FFprobePath = value
|
cfg.App.FFprobePath = value
|
||||||
case "ffprobe.max_concurrent", "app.ffprobe_max_concurrent":
|
case "ffprobe.max_concurrent", "app.ffprobe_max_concurrent":
|
||||||
|
|||||||
+312
-2
@@ -12,11 +12,13 @@ package service
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"net/url"
|
"net/url"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"go.uber.org/zap"
|
"go.uber.org/zap"
|
||||||
@@ -53,6 +55,9 @@ type ScannerService struct {
|
|||||||
probe *FFprobeService
|
probe *FFprobeService
|
||||||
scraper *ScraperService
|
scraper *ScraperService
|
||||||
storage *StorageConfigService
|
storage *StorageConfigService
|
||||||
|
|
||||||
|
cloudScanMu sync.Mutex
|
||||||
|
cloudScans map[string]*cloudScanEntry
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewScannerService is the constructor.
|
// NewScannerService is the constructor.
|
||||||
@@ -67,6 +72,7 @@ func NewScannerService(
|
|||||||
return &ScannerService{
|
return &ScannerService{
|
||||||
cfg: cfg, log: log, repo: repo, hub: hub,
|
cfg: cfg, log: log, repo: repo, hub: hub,
|
||||||
probe: probe, scraper: scraper,
|
probe: probe, scraper: scraper,
|
||||||
|
cloudScans: make(map[string]*cloudScanEntry),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -89,6 +95,292 @@ type ScanResult struct {
|
|||||||
Removed int64 `json:"removed"`
|
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.
|
// ScanLibrary walks the library root and persists discovered media files.
|
||||||
func (s *ScannerService) ScanLibrary(ctx context.Context, libraryID string) (*ScanResult, error) {
|
func (s *ScannerService) ScanLibrary(ctx context.Context, libraryID string) (*ScanResult, error) {
|
||||||
return s.scanLibrary(ctx, libraryID, true)
|
return s.scanLibrary(ctx, libraryID, true)
|
||||||
@@ -122,7 +414,18 @@ func (s *ScannerService) scanLibrary(ctx context.Context, libraryID string, auto
|
|||||||
})
|
})
|
||||||
return res, nil
|
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}
|
res := &ScanResult{LibraryID: lib.ID}
|
||||||
seen := make(map[string]struct{})
|
seen := make(map[string]struct{})
|
||||||
@@ -238,6 +541,7 @@ func (s *ScannerService) scanCloudLibrary(ctx context.Context, lib *model.Librar
|
|||||||
if elapsed.Seconds() > 0 {
|
if elapsed.Seconds() > 0 {
|
||||||
filesPerSecond = float64(processed) / elapsed.Seconds()
|
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{
|
s.hub.Publish("scan", map[string]any{
|
||||||
"library_id": lib.ID,
|
"library_id": lib.ID,
|
||||||
"cloud": true,
|
"cloud": true,
|
||||||
@@ -335,6 +639,11 @@ func (s *ScannerService) scanCloudLibrary(ctx context.Context, lib *model.Librar
|
|||||||
return res, err
|
return res, err
|
||||||
}
|
}
|
||||||
for _, candidate := range candidates {
|
for _, candidate := range candidates {
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return res, ctx.Err()
|
||||||
|
default:
|
||||||
|
}
|
||||||
seen[candidate.path] = struct{}{}
|
seen[candidate.path] = struct{}{}
|
||||||
s.ingestCloudFile(ctx, lib, typ, candidate.ref, candidate.path, candidate.name, candidate.size, candidate.localMeta, res)
|
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)
|
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,
|
Path: path,
|
||||||
SizeBytes: size,
|
SizeBytes: size,
|
||||||
Container: strings.TrimPrefix(ext, "."),
|
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",
|
ScrapeStatus: "pending",
|
||||||
}
|
}
|
||||||
if ext == ".strm" {
|
if ext == ".strm" {
|
||||||
@@ -658,6 +967,7 @@ func (s *ScannerService) pruneMissingCloudMedia(ctx context.Context, libraryID s
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
res := s.repo.DB.WithContext(ctx).
|
res := s.repo.DB.WithContext(ctx).
|
||||||
|
Unscoped().
|
||||||
Where("id = ?", row.ID).
|
Where("id = ?", row.ID).
|
||||||
Delete(&model.Media{})
|
Delete(&model.Media{})
|
||||||
if res.Error != nil {
|
if res.Error != nil {
|
||||||
|
|||||||
@@ -103,6 +103,13 @@ func TestScanCloudLibraryImportsRecursivePlayableMedia(t *testing.T) {
|
|||||||
if got := countMedia(t, repos); got != 0 {
|
if got := countMedia(t, repos); got != 0 {
|
||||||
t.Fatalf("media count after prune = %d, want 0", got)
|
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) {
|
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) {
|
func TestInferCloudMountMediaType(t *testing.T) {
|
||||||
cases := map[string]string{
|
cases := map[string]string{
|
||||||
"/日漫": "anime",
|
"/日漫": "anime",
|
||||||
|
|||||||
+39
-11
@@ -34,14 +34,14 @@ func NewStatsService(log *zap.Logger, repo *repository.Container) *StatsService
|
|||||||
|
|
||||||
// Snapshot is the JSON returned by /api/stats.
|
// Snapshot is the JSON returned by /api/stats.
|
||||||
type Snapshot struct {
|
type Snapshot struct {
|
||||||
Libraries int64 `json:"libraries"`
|
Libraries int64 `json:"libraries"`
|
||||||
MediaCount int64 `json:"media_count"`
|
MediaCount int64 `json:"media_count"`
|
||||||
UsersCount int64 `json:"users_count"`
|
UsersCount int64 `json:"users_count"`
|
||||||
TotalSizeBytes int64 `json:"total_size_bytes"`
|
TotalSizeBytes int64 `json:"total_size_bytes"`
|
||||||
TotalSeconds int64 `json:"total_seconds"`
|
TotalSeconds int64 `json:"total_seconds"`
|
||||||
RecentlyAdded []model.Media `json:"recently_added"`
|
RecentlyAdded []model.Media `json:"recently_added"`
|
||||||
Hardware Hardware `json:"hardware"`
|
Hardware Hardware `json:"hardware"`
|
||||||
GeneratedAt time.Time `json:"generated_at"`
|
GeneratedAt time.Time `json:"generated_at"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// Hardware is the live CPU / memory / disk readings.
|
// Hardware is the live CPU / memory / disk readings.
|
||||||
@@ -58,10 +58,26 @@ type Hardware struct {
|
|||||||
// Compute builds a fresh snapshot.
|
// Compute builds a fresh snapshot.
|
||||||
func (s *StatsService) Compute(ctx context.Context, dataDir string) (*Snapshot, error) {
|
func (s *StatsService) Compute(ctx context.Context, dataDir string) (*Snapshot, error) {
|
||||||
snap := &Snapshot{GeneratedAt: time.Now()}
|
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
|
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
|
return nil, err
|
||||||
}
|
}
|
||||||
if err := s.repo.DB.Model(&model.User{}).Count(&snap.UsersCount).Error; err != nil {
|
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
|
Seconds int64
|
||||||
}
|
}
|
||||||
var sum sumRow
|
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").
|
Select("COALESCE(SUM(size_bytes),0) as size, COALESCE(SUM(duration_sec),0) as seconds").
|
||||||
Scan(&sum).Error; err != nil {
|
Scan(&sum).Error; err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -80,7 +102,13 @@ func (s *StatsService) Compute(ctx context.Context, dataDir string) (*Snapshot,
|
|||||||
snap.TotalSizeBytes = sum.Size
|
snap.TotalSizeBytes = sum.Size
|
||||||
snap.TotalSeconds = sum.Seconds
|
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).
|
Order("created_at desc").Limit(12).
|
||||||
Find(&snap.RecentlyAdded).Error; err != nil {
|
Find(&snap.RecentlyAdded).Error; err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -13,6 +13,7 @@ import (
|
|||||||
"io"
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/url"
|
"net/url"
|
||||||
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -37,7 +38,7 @@ func NewStorageConfigService(log *zap.Logger, repo *repository.Container, crypto
|
|||||||
log: log,
|
log: log,
|
||||||
repo: repo,
|
repo: repo,
|
||||||
crypto: crypto,
|
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 {
|
if cfg == nil {
|
||||||
return errors.New("config required")
|
return errors.New("config required")
|
||||||
}
|
}
|
||||||
|
client := s.clientForConfig(cfg)
|
||||||
switch in.Type {
|
switch in.Type {
|
||||||
case "alist":
|
case "alist":
|
||||||
server := strings.TrimRight(strr(cfg["server"]), "/")
|
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 != "" {
|
if tok := strr(cfg["token"]); tok != "" {
|
||||||
req.Header.Set("Authorization", tok)
|
req.Header.Set("Authorization", tok)
|
||||||
}
|
}
|
||||||
resp, err := s.client.Do(req)
|
resp, err := client.Do(req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -150,7 +152,7 @@ func (s *StorageConfigService) Test(ctx context.Context, in StorageInput) error
|
|||||||
return nil
|
return nil
|
||||||
case cloud.TypeOpenList:
|
case cloud.TypeOpenList:
|
||||||
if hasWebDAVProbeConfig(cfg) {
|
if hasWebDAVProbeConfig(cfg) {
|
||||||
p, err := cloud.New(in.Type, cfg, s.client)
|
p, err := cloud.New(in.Type, cfg, client)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -162,7 +164,7 @@ func (s *StorageConfigService) Test(ctx context.Context, in StorageInput) error
|
|||||||
if tok := strr(cfg["token"]); tok != "" {
|
if tok := strr(cfg["token"]); tok != "" {
|
||||||
req.Header.Set("Authorization", tok)
|
req.Header.Set("Authorization", tok)
|
||||||
}
|
}
|
||||||
resp, err := s.client.Do(req)
|
resp, err := client.Do(req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return decorateStorageTransportError("openlist", server, err)
|
return decorateStorageTransportError("openlist", server, err)
|
||||||
}
|
}
|
||||||
@@ -172,7 +174,7 @@ func (s *StorageConfigService) Test(ctx context.Context, in StorageInput) error
|
|||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
p, err := cloud.New(in.Type, cfg, s.client)
|
p, err := cloud.New(in.Type, cfg, client)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -187,7 +189,7 @@ func (s *StorageConfigService) Test(ctx context.Context, in StorageInput) error
|
|||||||
req.SetBasicAuth(user, strr(cfg["password"]))
|
req.SetBasicAuth(user, strr(cfg["password"]))
|
||||||
}
|
}
|
||||||
req.Header.Set("Depth", "0")
|
req.Header.Set("Depth", "0")
|
||||||
resp, err := s.client.Do(req)
|
resp, err := client.Do(req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return decorateStorageTransportError("webdav", u, err)
|
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
|
// We only verify endpoint reachability — full SigV4 is a large
|
||||||
// dependency; the upstream Vue project also stops at this level.
|
// dependency; the upstream Vue project also stops at this level.
|
||||||
req, _ := http.NewRequestWithContext(ctx, http.MethodGet, ep, nil)
|
req, _ := http.NewRequestWithContext(ctx, http.MethodGet, ep, nil)
|
||||||
resp, err := s.client.Do(req)
|
resp, err := client.Do(req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
defer resp.Body.Close()
|
defer resp.Body.Close()
|
||||||
return nil
|
return nil
|
||||||
case cloud.TypeQuark, cloud.Type115, cloud.TypeCloudDrive2:
|
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 {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -238,7 +240,10 @@ func (s *StorageConfigService) CloudProvider(ctx context.Context, typ string) (c
|
|||||||
if view == nil {
|
if view == nil {
|
||||||
return nil, fmt.Errorf("%s storage not configured", typ)
|
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.
|
// 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 {
|
if view == nil {
|
||||||
return nil, fmt.Errorf("%s storage not configured", typ)
|
return nil, fmt.Errorf("%s storage not configured", typ)
|
||||||
}
|
}
|
||||||
|
if !view.Enabled {
|
||||||
|
return nil, fmt.Errorf("%s storage disabled", typ)
|
||||||
|
}
|
||||||
cfg := view.Config
|
cfg := view.Config
|
||||||
if strings.TrimSpace(clientUA) != "" {
|
if strings.TrimSpace(clientUA) != "" {
|
||||||
// Copy so we never mutate the cached view config.
|
// 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
|
cp["ua"] = clientUA
|
||||||
cfg = cp
|
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.
|
// 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),
|
Path: cloudMediaPath(typ, fileRef),
|
||||||
SizeBytes: size,
|
SizeBytes: size,
|
||||||
Container: container,
|
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",
|
ScrapeStatus: "pending",
|
||||||
}
|
}
|
||||||
if err := s.repo.Media.Upsert(ctx, m); err != nil {
|
if err := s.repo.Media.Upsert(ctx, m); err != nil {
|
||||||
|
|||||||
@@ -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) {
|
func TestSchedulerCloudUploadUsesConfiguredLocalSource(t *testing.T) {
|
||||||
var uploaded []string
|
var uploaded []string
|
||||||
alist := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
alist := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
|||||||
@@ -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) {
|
func TestTelegramProxyCandidatesDefaultLocalFallbacks(t *testing.T) {
|
||||||
got := telegramProxyCandidates(map[string]string{})
|
got := telegramProxyCandidates(map[string]string{})
|
||||||
joined := strings.Join(got, ",")
|
joined := strings.Join(got, ",")
|
||||||
|
|||||||
@@ -510,11 +510,15 @@ func (s *TelegramBotService) cmdHideAdult(ctx context.Context, msg *TelegramMess
|
|||||||
|
|
||||||
// cmdStatus 处理 /status 命令。
|
// cmdStatus 处理 /status 命令。
|
||||||
func (s *TelegramBotService) cmdStatus(ctx context.Context) (telegramCommandReply, error) {
|
func (s *TelegramBotService) cmdStatus(ctx context.Context) (telegramCommandReply, error) {
|
||||||
|
libraryIDs, err := s.activeTelegramStatsLibraryIDs(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return telegramCommandReply{}, err
|
||||||
|
}
|
||||||
var mediaCount int64
|
var mediaCount int64
|
||||||
s.repo.DB.Model(&model.Media{}).Count(&mediaCount)
|
s.mediaStatsQuery(libraryIDs).Count(&mediaCount)
|
||||||
|
|
||||||
var totalSize int64
|
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
|
totalSizeGB := float64(totalSize) / 1024 / 1024 / 1024
|
||||||
|
|
||||||
return telegramCommandReply{Text: fmt.Sprintf(
|
return telegramCommandReply{Text: fmt.Sprintf(
|
||||||
@@ -605,30 +609,42 @@ func (s *TelegramBotService) cmdDownloads(ctx context.Context) (telegramCommandR
|
|||||||
|
|
||||||
// cmdStats 处理 /stats 命令。
|
// cmdStats 处理 /stats 命令。
|
||||||
func (s *TelegramBotService) cmdStats(ctx context.Context) (telegramCommandReply, error) {
|
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
|
var totalMedia int64
|
||||||
s.repo.DB.Model(&model.Media{}).Count(&totalMedia)
|
s.mediaStatsQuery(libraryIDs).Count(&totalMedia)
|
||||||
|
|
||||||
var totalSize int64
|
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 {
|
type LibStat struct {
|
||||||
Name string
|
Name string
|
||||||
Type string
|
Type string
|
||||||
Count int64
|
Count int64
|
||||||
}
|
}
|
||||||
var libs []LibStat
|
stats := make([]LibStat, 0, len(libs))
|
||||||
s.repo.DB.Raw(
|
for _, lib := range libs {
|
||||||
"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",
|
var count int64
|
||||||
).Scan(&libs)
|
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
|
var sb strings.Builder
|
||||||
sb.WriteString("<b>媒体库统计</b>\n\n")
|
sb.WriteString("<b>媒体库统计</b>\n\n")
|
||||||
sb.WriteString(fmt.Sprintf("📚 总数: <b>%d</b>\n", totalMedia))
|
sb.WriteString(fmt.Sprintf("📚 总数: <b>%d</b>\n", totalMedia))
|
||||||
sb.WriteString(fmt.Sprintf("💾 大小: <b>%s</b>\n", formatSize(totalSize)))
|
sb.WriteString(fmt.Sprintf("💾 大小: <b>%s</b>\n", formatSize(totalSize)))
|
||||||
|
|
||||||
if len(libs) > 0 {
|
if len(stats) > 0 {
|
||||||
sb.WriteString("\n<b>各库分布:</b>\n")
|
sb.WriteString("\n<b>各库分布:</b>\n")
|
||||||
for _, l := range libs {
|
for _, l := range stats {
|
||||||
icon := "🎬"
|
icon := "🎬"
|
||||||
switch l.Type {
|
switch l.Type {
|
||||||
case "tv":
|
case "tv":
|
||||||
@@ -645,6 +661,44 @@ func (s *TelegramBotService) cmdStats(ctx context.Context) (telegramCommandReply
|
|||||||
return telegramCommandReply{Text: sb.String()}, nil
|
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 ──
|
// ── Polling ──
|
||||||
|
|
||||||
// StartPolling 为所有已启用的 Telegram 通知渠道启动长轮询。
|
// StartPolling 为所有已启用的 Telegram 通知渠道启动长轮询。
|
||||||
@@ -671,6 +725,9 @@ func (s *TelegramBotService) StartPolling(ctx context.Context) {
|
|||||||
if botToken == "" {
|
if botToken == "" {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
if err := registerTelegramBotCommands(ctx, cfg); err != nil && s.log != nil {
|
||||||
|
s.log.Warn("telegram setMyCommands failed", zap.Error(sanitizeTelegramError(err)))
|
||||||
|
}
|
||||||
|
|
||||||
s.pollingMu.Lock()
|
s.pollingMu.Lock()
|
||||||
if _, running := s.pollingCancel[botToken]; running {
|
if _, running := s.pollingCancel[botToken]; running {
|
||||||
@@ -1294,11 +1351,14 @@ func userNameOrFallback(user *model.User) string {
|
|||||||
|
|
||||||
// SetWebhook 注册 Telegram Bot Webhook URL。
|
// SetWebhook 注册 Telegram Bot Webhook URL。
|
||||||
func (s *TelegramBotService) SetWebhook(ctx context.Context, botToken, webhookURL string) error {
|
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{}{
|
payload := map[string]interface{}{
|
||||||
"url": webhookURL,
|
"url": webhookURL,
|
||||||
"allowed_updates": []string{"message", "callback_query"},
|
"allowed_updates": []string{"message", "callback_query"},
|
||||||
}
|
}
|
||||||
cfg := map[string]string{"bot_token": botToken}
|
|
||||||
return telegramPostJSON(ctx, cfg, "setWebhook", payload, 15*time.Second)
|
return telegramPostJSON(ctx, cfg, "setWebhook", payload, 15*time.Second)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
"strings"
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/ShukeBta/MediaStationGo/internal/model"
|
"github.com/ShukeBta/MediaStationGo/internal/model"
|
||||||
)
|
)
|
||||||
@@ -125,3 +126,40 @@ var telegramSupportedCommandSet = map[string]struct{}{
|
|||||||
"/devicepolicy": {}, "/policy": {}, "/antishare": {}, "/cleanup": {}, "/cleanup_mode": {}, "/cleanup_rule": {},
|
"/devicepolicy": {}, "/policy": {}, "/antishare": {}, "/cleanup": {}, "/cleanup_mode": {}, "/cleanup_rule": {},
|
||||||
"/ban": {}, "/unban": {}, "/status": {}, "/search": {}, "/downloads": {}, "/stats": {},
|
"/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)
|
||||||
|
}
|
||||||
|
|||||||
@@ -151,9 +151,15 @@ export function hlsURL(mediaId: string): string {
|
|||||||
export function imageURL(remote?: string): string {
|
export function imageURL(remote?: string): string {
|
||||||
if (!remote) return ''
|
if (!remote) return ''
|
||||||
if (remote.startsWith('/api/img')) return remote
|
if (remote.startsWith('/api/img')) return remote
|
||||||
|
if (remote.startsWith('/api/')) return withQuery(remote, tokenQuery())
|
||||||
return `/api/img?url=${encodeURIComponent(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
|
// getToken returns the current auth token
|
||||||
export function getToken(): string | null {
|
export function getToken(): string | null {
|
||||||
return useAuthStore.getState().token
|
return useAuthStore.getState().token
|
||||||
|
|||||||
@@ -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 = {
|
export const storageAPI = {
|
||||||
status: () =>
|
status: () =>
|
||||||
api
|
api
|
||||||
@@ -83,6 +101,23 @@ export const storageAPI = {
|
|||||||
api
|
api
|
||||||
.post<{ result: CloudUploadResult; error?: string }>(`/admin/storage/${type}/upload-local`, input)
|
.post<{ result: CloudUploadResult; error?: string }>(`/admin/storage/${type}/upload-local`, input)
|
||||||
.then((r) => r.data),
|
.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.
|
// cloudAPI drives 网盘 browsing, QR login and 302 import.
|
||||||
|
|||||||
@@ -47,6 +47,13 @@ const GROUPS: SettingGroup[] = [
|
|||||||
{ value: 'ja-JP', label: '日本語' },
|
{ 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',
|
key: 'playback.direct_only',
|
||||||
label: '客户端直连解码(释放宿主机资源)',
|
label: '客户端直连解码(释放宿主机资源)',
|
||||||
|
|||||||
@@ -1,11 +1,12 @@
|
|||||||
import { FormEvent, useEffect, useMemo, useState } from 'react'
|
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 toast from 'react-hot-toast'
|
||||||
|
|
||||||
import { libraryAPI } from '../api/library'
|
import { libraryAPI } from '../api/library'
|
||||||
import {
|
import {
|
||||||
cloudAPI,
|
cloudAPI,
|
||||||
storageAPI,
|
storageAPI,
|
||||||
|
type CloudScanStatus,
|
||||||
type CloudEntry,
|
type CloudEntry,
|
||||||
type QRSession,
|
type QRSession,
|
||||||
type StorageType,
|
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: 'url', label: 'WebDAV URL(浏览/挂载)', placeholder: 'http://NAS-IP:5244/dav/' },
|
||||||
{ key: 'username', label: 'WebDAV 用户名' },
|
{ key: 'username', label: 'WebDAV 用户名' },
|
||||||
{ key: 'password', label: 'WebDAV 密码', secret: true },
|
{ key: 'password', label: 'WebDAV 密码', secret: true },
|
||||||
|
{ key: 'timeout_seconds', label: '请求超时秒数', placeholder: '120' },
|
||||||
{ key: 'force_302', label: '强制 302 直链(true/false,默认反代)' },
|
{ key: 'force_302', label: '强制 302 直链(true/false,默认反代)' },
|
||||||
],
|
],
|
||||||
webdav: [
|
webdav: [
|
||||||
{ key: 'url', label: 'URL', placeholder: 'https://example.com/dav/' },
|
{ key: 'url', label: 'URL', placeholder: 'https://example.com/dav/' },
|
||||||
{ key: 'username', label: '用户名' },
|
{ key: 'username', label: '用户名' },
|
||||||
{ key: 'password', label: '密码', secret: true },
|
{ key: 'password', label: '密码', secret: true },
|
||||||
|
{ key: 'timeout_seconds', label: '请求超时秒数', placeholder: '120' },
|
||||||
],
|
],
|
||||||
s3: [
|
s3: [
|
||||||
{ key: 'endpoint', label: 'Endpoint', placeholder: 'https://s3.amazonaws.com' },
|
{ 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: 'username', label: '用户名' },
|
||||||
{ key: 'password', label: '密码 / Token', secret: true },
|
{ key: 'password', label: '密码 / Token', secret: true },
|
||||||
{ key: 'token', label: 'Authorization Token(可选)', secret: true, placeholder: 'Bearer ... 或 Basic ...' },
|
{ key: 'token', label: 'Authorization Token(可选)', secret: true, placeholder: 'Bearer ... 或 Basic ...' },
|
||||||
|
{ key: 'timeout_seconds', label: '请求超时秒数', placeholder: '120' },
|
||||||
{ key: 'force_302', label: '强制 302 直链(true/false,默认反代)' },
|
{ key: 'force_302', label: '强制 302 直链(true/false,默认反代)' },
|
||||||
],
|
],
|
||||||
}
|
}
|
||||||
@@ -169,7 +173,7 @@ function StorageForm({ type }: { type: StorageType }) {
|
|||||||
next[f.key] = v === '********' ? '' : v ?? ''
|
next[f.key] = v === '********' ? '' : v ?? ''
|
||||||
}
|
}
|
||||||
setConfig(next)
|
setConfig(next)
|
||||||
setEnabled(r.enabled)
|
setEnabled(r.enabled ?? true)
|
||||||
} catch {
|
} catch {
|
||||||
const next: Record<string, string> = {}
|
const next: Record<string, string> = {}
|
||||||
for (const f of fields) next[f.key] = ''
|
for (const f of fields) next[f.key] = ''
|
||||||
@@ -480,6 +484,9 @@ function CloudBrowser({ type }: { type: StorageType }) {
|
|||||||
const [loading, setLoading] = useState(false)
|
const [loading, setLoading] = useState(false)
|
||||||
const [mounting, setMounting] = useState(false)
|
const [mounting, setMounting] = useState(false)
|
||||||
const [batchMounting, setBatchMounting] = 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 [mountMediaType, setMountMediaType] = useState('auto')
|
||||||
const [error, setError] = useState('')
|
const [error, setError] = useState('')
|
||||||
|
|
||||||
@@ -504,6 +511,11 @@ function CloudBrowser({ type }: { type: StorageType }) {
|
|||||||
setMounts(libs.filter((lib) => cloudLibraryProvider(lib.path) === type))
|
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(() => {
|
useEffect(() => {
|
||||||
load(cur.id).catch(() => undefined)
|
load(cur.id).catch(() => undefined)
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
@@ -511,6 +523,15 @@ function CloudBrowser({ type }: { type: StorageType }) {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
loadMounts().catch(() => undefined)
|
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
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [type])
|
}, [type])
|
||||||
|
|
||||||
@@ -600,8 +621,82 @@ function CloudBrowser({ type }: { type: StorageType }) {
|
|||||||
await loadMounts()
|
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 (
|
return (
|
||||||
<div className="mt-2 rounded-lg border border-gray-200 p-3" onClick={(e) => e.preventDefault()}>
|
<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 && (
|
{mounts.length > 0 && (
|
||||||
<div className="mb-3 rounded border border-blue-100 bg-blue-50/60 p-2">
|
<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>
|
<div className="mb-1 text-xs font-semibold text-ink-100">已挂载目录</div>
|
||||||
|
|||||||
@@ -20,11 +20,13 @@ export type SeriesCard = { key: string; rep: Media; count: number }
|
|||||||
|
|
||||||
export function getSeriesKey(media: Media): string {
|
export function getSeriesKey(media: Media): string {
|
||||||
if (media.series_id) return `series:${media.series_id}`
|
if (media.series_id) return `series:${media.series_id}`
|
||||||
|
const fromPath = seriesTitleFromPath(media.path)
|
||||||
if (isEpisodeLike(media)) {
|
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.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 (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)}`
|
return `lib:${media.library_id}|${normalizeTitle(media.title)}`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user