mirror of
https://github.com/truewhile/MeBox.git
synced 2026-09-30 03:36:37 +08:00
fix cloud library mounts and artwork caching
This commit is contained in:
+126
-73
@@ -3,14 +3,11 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"go.uber.org/zap"
|
||||
|
||||
"github.com/ShukeBta/MediaStationGo/internal/model"
|
||||
"github.com/ShukeBta/MediaStationGo/internal/service"
|
||||
@@ -146,34 +143,7 @@ func cloudMountHandler(svc *service.Container) gin.HandlerFunc {
|
||||
"estimate_message": "小目录通常几十秒;几万文件的大目录可能需要数分钟到数小时,取决于网盘接口速度",
|
||||
})
|
||||
}
|
||||
go func() {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 6*time.Hour)
|
||||
defer cancel()
|
||||
res, err := svc.Scan.ScanLibraryWithoutAutoScrape(ctx, libID)
|
||||
if err != nil {
|
||||
if svc.Log != nil {
|
||||
svc.Log.Warn("cloud mount background scan failed", zap.String("library_id", libID), zap.Error(err))
|
||||
}
|
||||
if svc.WSHub != nil {
|
||||
svc.WSHub.Publish("scan", gin.H{
|
||||
"library_id": libID,
|
||||
"cloud": true,
|
||||
"finished": true,
|
||||
"error": err.Error(),
|
||||
})
|
||||
}
|
||||
return
|
||||
}
|
||||
if svc.Log != nil {
|
||||
svc.Log.Info("cloud mount background scan finished",
|
||||
zap.String("library_id", libID),
|
||||
zap.Int("visited", res.Visited),
|
||||
zap.Int("added", res.Added),
|
||||
zap.Int("updated", res.Updated),
|
||||
zap.Int("skipped", res.Skipped),
|
||||
zap.Int64("removed", res.Removed))
|
||||
}
|
||||
}()
|
||||
_, _, _ = svc.Scan.StartCloudLibraryScan(libID, false)
|
||||
}
|
||||
c.JSON(http.StatusAccepted, gin.H{
|
||||
"library": lib,
|
||||
@@ -185,6 +155,62 @@ func cloudMountHandler(svc *service.Container) gin.HandlerFunc {
|
||||
}
|
||||
}
|
||||
|
||||
func cloudScanAllHandler(svc *service.Container) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
if svc.Scan == nil {
|
||||
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "scanner unavailable"})
|
||||
return
|
||||
}
|
||||
statuses, err := svc.Scan.StartAllCloudLibraryScans()
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusAccepted, gin.H{
|
||||
"items": statuses,
|
||||
"scan_queued": true,
|
||||
"message": "已开始扫描所有启用的网盘媒体库",
|
||||
"resume_message": "中断后再次点击扫描会重新遍历,但已入库媒体会去重更新,只补齐缺失项。",
|
||||
"estimate_message": "小目录通常几十秒;几万文件的大目录可能需要数分钟到数小时,取决于网盘接口速度",
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func cloudScanCancelHandler(svc *service.Container) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
if svc.Scan == nil {
|
||||
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "scanner unavailable"})
|
||||
return
|
||||
}
|
||||
libraryID := strings.TrimSpace(c.Query("library_id"))
|
||||
provider := strings.TrimSpace(c.Query("provider"))
|
||||
cancelled := 0
|
||||
if libraryID != "" {
|
||||
if svc.Scan.CancelCloudScan(libraryID) {
|
||||
cancelled = 1
|
||||
}
|
||||
} else if provider != "" {
|
||||
cancelled = svc.Scan.CancelCloudScansForProvider(provider)
|
||||
} else {
|
||||
cancelled = svc.Scan.CancelAllCloudScans()
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"cancelled": cancelled,
|
||||
"message": "已发送中断信号;正在等待当前网盘请求返回后停止",
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func cloudScanStatusHandler(svc *service.Container) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
if svc.Scan == nil {
|
||||
c.JSON(http.StatusOK, gin.H{"items": []service.CloudScanStatus{}})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"items": svc.Scan.CloudScanStatuses()})
|
||||
}
|
||||
}
|
||||
|
||||
func cloudMountLibraryName(typ, dir string) string {
|
||||
base := typ
|
||||
switch typ {
|
||||
@@ -253,47 +279,74 @@ func cloudPlayHandler(svc *service.Container) gin.HandlerFunc {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "ref required"})
|
||||
return
|
||||
}
|
||||
link, err := svc.StorageCfg.CloudResolve(c.Request.Context(), typ, ref, c.Request.UserAgent())
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadGateway, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
if !link.Proxy {
|
||||
// Pure offload: send the client straight to the cloud CDN.
|
||||
c.Redirect(http.StatusFound, link.URL)
|
||||
return
|
||||
}
|
||||
// Proxy mode: the direct link needs auth headers the browser cannot
|
||||
// carry. Stream through with Range forwarding.
|
||||
method := c.Request.Method
|
||||
if method == "" {
|
||||
method = http.MethodGet
|
||||
}
|
||||
req, err := http.NewRequestWithContext(c.Request.Context(), method, link.URL, nil)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadGateway, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
for k, v := range link.Headers {
|
||||
req.Header.Set(k, v)
|
||||
}
|
||||
if rng := c.GetHeader("Range"); rng != "" {
|
||||
req.Header.Set("Range", rng)
|
||||
}
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadGateway, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
for _, h := range []string{"Content-Type", "Content-Length", "Content-Range", "Accept-Ranges"} {
|
||||
if v := resp.Header.Get(h); v != "" {
|
||||
c.Header(h, v)
|
||||
}
|
||||
}
|
||||
c.Status(resp.StatusCode)
|
||||
if c.Request.Method != http.MethodHead {
|
||||
_, _ = io.Copy(c.Writer, resp.Body)
|
||||
}
|
||||
serveCloudResolvedLink(svc, c, typ, ref)
|
||||
}
|
||||
}
|
||||
|
||||
func serveCloudResolvedLink(svc *service.Container, c *gin.Context, typ, ref string) {
|
||||
if svc == nil || svc.StorageCfg == nil {
|
||||
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "cloud storage service unavailable"})
|
||||
return
|
||||
}
|
||||
link, err := svc.StorageCfg.CloudResolve(c.Request.Context(), typ, ref, c.Request.UserAgent())
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadGateway, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
if isCloudImageRef(ref) && svc.ImageProxy != nil {
|
||||
if err := svc.ImageProxy.ServeCloudResolved(c.Request.Context(), c.Writer, c.Request, typ+":"+ref, link); err != nil {
|
||||
c.JSON(http.StatusBadGateway, gin.H{"error": err.Error()})
|
||||
}
|
||||
return
|
||||
}
|
||||
if isCloudImageRef(ref) {
|
||||
c.Header("Cache-Control", "public, max-age=2592000, immutable")
|
||||
}
|
||||
if !link.Proxy {
|
||||
// Pure offload: send the client straight to the cloud CDN.
|
||||
c.Redirect(http.StatusFound, link.URL)
|
||||
return
|
||||
}
|
||||
// Proxy mode: the direct link needs auth headers the browser cannot
|
||||
// carry. Stream through with Range forwarding.
|
||||
method := c.Request.Method
|
||||
if method == "" {
|
||||
method = http.MethodGet
|
||||
}
|
||||
req, err := http.NewRequestWithContext(c.Request.Context(), method, link.URL, nil)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadGateway, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
for k, v := range link.Headers {
|
||||
req.Header.Set(k, v)
|
||||
}
|
||||
if rng := c.GetHeader("Range"); rng != "" {
|
||||
req.Header.Set("Range", rng)
|
||||
}
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadGateway, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
for _, h := range []string{"Content-Type", "Content-Length", "Content-Range", "Accept-Ranges"} {
|
||||
if v := resp.Header.Get(h); v != "" {
|
||||
c.Header(h, v)
|
||||
}
|
||||
}
|
||||
c.Status(resp.StatusCode)
|
||||
if c.Request.Method != http.MethodHead {
|
||||
_, _ = io.Copy(c.Writer, resp.Body)
|
||||
}
|
||||
}
|
||||
|
||||
func isCloudImageRef(ref string) bool {
|
||||
ref = strings.ToLower(strings.TrimSpace(ref))
|
||||
for _, suffix := range []string{".jpg", ".jpeg", ".png", ".webp", ".gif", ".bmp"} {
|
||||
if strings.HasSuffix(ref, suffix) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
@@ -610,6 +610,10 @@ func embyItemImageHandler(svc *service.Container) gin.HandlerFunc {
|
||||
c.Status(http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
if typ, ref, ok := parseCloudPlayImageURL(raw); ok {
|
||||
serveCloudResolvedLink(svc, c, typ, ref)
|
||||
return
|
||||
}
|
||||
if svc.ImageProxy == nil {
|
||||
c.Status(http.StatusNotFound)
|
||||
return
|
||||
@@ -620,6 +624,28 @@ func embyItemImageHandler(svc *service.Container) gin.HandlerFunc {
|
||||
}
|
||||
}
|
||||
|
||||
func parseCloudPlayImageURL(raw string) (string, string, bool) {
|
||||
raw = strings.TrimSpace(raw)
|
||||
if raw == "" {
|
||||
return "", "", false
|
||||
}
|
||||
u, err := url.Parse(raw)
|
||||
if err != nil {
|
||||
return "", "", false
|
||||
}
|
||||
path := strings.Trim(u.Path, "/")
|
||||
const prefix = "api/cloud/play/"
|
||||
if !strings.HasPrefix(path, prefix) {
|
||||
return "", "", false
|
||||
}
|
||||
typ := strings.TrimSpace(strings.TrimPrefix(path, prefix))
|
||||
ref := strings.TrimSpace(u.Query().Get("ref"))
|
||||
if typ == "" || ref == "" {
|
||||
return "", "", false
|
||||
}
|
||||
return typ, ref, true
|
||||
}
|
||||
|
||||
func embyShowSeasonsHandler(svc *service.Container) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
params := service.ItemsParams{
|
||||
|
||||
@@ -6,10 +6,8 @@ import (
|
||||
"errors"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"go.uber.org/zap"
|
||||
|
||||
"github.com/ShukeBta/MediaStationGo/internal/middleware"
|
||||
"github.com/ShukeBta/MediaStationGo/internal/service"
|
||||
@@ -68,6 +66,11 @@ func createLibraryHandler(svc *service.Container) gin.HandlerFunc {
|
||||
func deleteLibraryHandler(svc *service.Container) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
id := c.Param("id")
|
||||
if lib, err := svc.Repo.Library.FindByID(c.Request.Context(), id); err == nil && lib != nil {
|
||||
if _, ok := service.ParseCloudLibraryMount(lib.Path); ok && svc.Scan != nil {
|
||||
_ = svc.Scan.CancelCloudScan(id)
|
||||
}
|
||||
}
|
||||
if err := svc.Media.DeleteLibrary(c.Request.Context(), id); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
@@ -102,23 +105,7 @@ func scanLibraryHandler(svc *service.Container) gin.HandlerFunc {
|
||||
"estimate_message": "小目录通常几十秒;几万文件的大目录可能需要数分钟到数小时,取决于网盘接口速度",
|
||||
})
|
||||
}
|
||||
go func(libraryID string) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 6*time.Hour)
|
||||
defer cancel()
|
||||
if _, err := svc.Scan.ScanLibraryWithoutAutoScrape(ctx, libraryID); err != nil {
|
||||
if svc.Log != nil {
|
||||
svc.Log.Warn("cloud library async scan failed", zap.String("library_id", libraryID), zap.Error(err))
|
||||
}
|
||||
if svc.WSHub != nil {
|
||||
svc.WSHub.Publish("scan", gin.H{
|
||||
"library_id": libraryID,
|
||||
"cloud": true,
|
||||
"finished": true,
|
||||
"error": err.Error(),
|
||||
})
|
||||
}
|
||||
}
|
||||
}(id)
|
||||
_, _, _ = svc.Scan.StartCloudLibraryScan(id, false)
|
||||
c.JSON(http.StatusAccepted, gin.H{
|
||||
"library_id": id,
|
||||
"visited": 0,
|
||||
|
||||
@@ -38,6 +38,9 @@ func registerAdminRoutes(api *gin.RouterGroup, cfg *config.Config, svc *service.
|
||||
admin.POST("/storage/:type/upload-local", storageUploadLocalHandler(svc))
|
||||
|
||||
// Cloud disk (115 / 夸克) browsing, QR login and 302 import.
|
||||
admin.POST("/cloud/scan-all", cloudScanAllHandler(svc))
|
||||
admin.POST("/cloud/scan/cancel", cloudScanCancelHandler(svc))
|
||||
admin.GET("/cloud/scan/status", cloudScanStatusHandler(svc))
|
||||
admin.GET("/cloud/:type/list", cloudListHandler(svc))
|
||||
admin.POST("/cloud/:type/import", cloudImportHandler(svc))
|
||||
admin.POST("/cloud/:type/mount", cloudMountHandler(svc))
|
||||
|
||||
@@ -32,8 +32,14 @@ func applyStatsVisibility(c *gin.Context, svc *service.Container, snap *service.
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
libs = service.FilterShadowedCloudLibraries(libs)
|
||||
var visibleLibraries int64
|
||||
activeLibraryIDs := make([]string, 0, len(libs))
|
||||
for _, lib := range libs {
|
||||
if !lib.Enabled {
|
||||
continue
|
||||
}
|
||||
activeLibraryIDs = append(activeLibraryIDs, lib.ID)
|
||||
if service.LibraryVisibleForUser(c.Request.Context(), svc.Repo, lib, visibility) {
|
||||
visibleLibraries++
|
||||
}
|
||||
@@ -41,6 +47,7 @@ func applyStatsVisibility(c *gin.Context, svc *service.Container, snap *service.
|
||||
snap.Libraries = visibleLibraries
|
||||
|
||||
q := applyMediaVisibilityQuery(svc.Repo.DB.WithContext(c.Request.Context()).Model(&model.Media{}), visibility)
|
||||
q = applyActiveLibraryQuery(q, activeLibraryIDs)
|
||||
if err := q.Count(&snap.MediaCount).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -49,7 +56,7 @@ func applyStatsVisibility(c *gin.Context, svc *service.Container, snap *service.
|
||||
Seconds int64
|
||||
}
|
||||
var sum sumRow
|
||||
if err := applyMediaVisibilityQuery(svc.Repo.DB.WithContext(c.Request.Context()).Model(&model.Media{}), visibility).
|
||||
if err := applyActiveLibraryQuery(applyMediaVisibilityQuery(svc.Repo.DB.WithContext(c.Request.Context()).Model(&model.Media{}), visibility), activeLibraryIDs).
|
||||
Select("COALESCE(SUM(size_bytes),0) as size, COALESCE(SUM(duration_sec),0) as seconds").
|
||||
Scan(&sum).Error; err != nil {
|
||||
return err
|
||||
@@ -58,7 +65,7 @@ func applyStatsVisibility(c *gin.Context, svc *service.Container, snap *service.
|
||||
snap.TotalSeconds = sum.Seconds
|
||||
|
||||
var recent []model.Media
|
||||
if err := applyMediaVisibilityQuery(svc.Repo.DB.WithContext(c.Request.Context()).Model(&model.Media{}), visibility).
|
||||
if err := applyActiveLibraryQuery(applyMediaVisibilityQuery(svc.Repo.DB.WithContext(c.Request.Context()).Model(&model.Media{}), visibility), activeLibraryIDs).
|
||||
Order("created_at desc").
|
||||
Limit(12).
|
||||
Find(&recent).Error; err != nil {
|
||||
@@ -80,3 +87,10 @@ func applyMediaVisibilityQuery(q *gorm.DB, visibility service.MediaVisibility) *
|
||||
}
|
||||
return q
|
||||
}
|
||||
|
||||
func applyActiveLibraryQuery(q *gorm.DB, libraryIDs []string) *gorm.DB {
|
||||
if len(libraryIDs) == 0 {
|
||||
return q.Where("1 = 0")
|
||||
}
|
||||
return q.Where("library_id IN ?", libraryIDs)
|
||||
}
|
||||
|
||||
@@ -129,14 +129,18 @@ func statsTopContentHandler(svc *service.Container) gin.HandlerFunc {
|
||||
// statsLibrariesHandler returns per-library counts + size.
|
||||
func statsLibrariesHandler(svc *service.Container) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
var libs []model.Library
|
||||
if err := svc.Repo.DB.Find(&libs).Error; err != nil {
|
||||
libs, err := svc.Repo.Library.List(c.Request.Context())
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
libs = service.FilterShadowedCloudLibraries(libs)
|
||||
out := make([]gin.H, 0, len(libs))
|
||||
visibility := mediaVisibilityForRequest(c, svc)
|
||||
for _, l := range libs {
|
||||
if !l.Enabled {
|
||||
continue
|
||||
}
|
||||
if !service.LibraryVisibleForUser(c.Request.Context(), svc.Repo, l, visibility) {
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -53,6 +53,9 @@ func saveStorageConfigHandler(svc *service.Container) gin.HandlerFunc {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
if in.Enabled != nil && !*in.Enabled && svc.Scan != nil {
|
||||
_ = svc.Scan.CancelCloudScansForProvider(in.Type)
|
||||
}
|
||||
c.JSON(http.StatusOK, row)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -63,6 +63,7 @@ func schemaHandler(_ *service.Container) gin.HandlerFunc {
|
||||
"label": "常规",
|
||||
"items": []gin.H{
|
||||
{"key": "tmdb.language", "type": "select", "label": "TMDb 元数据语言"},
|
||||
{"key": "app.server_url", "type": "text", "label": "公开访问域名 / STRM 域名"},
|
||||
{"key": "transcode.enabled", "type": "toggle", "label": "启用转码"},
|
||||
{"key": "transcode.hw_accel", "type": "select", "label": "硬件编码器"},
|
||||
{"key": "transcode.hw_enabled", "type": "toggle", "label": "启用硬件加速"},
|
||||
|
||||
Reference in New Issue
Block a user