mirror of
https://github.com/truewhile/MeBox.git
synced 2026-10-04 04:26:38 +08:00
Merge pull request #5 from ShukeBta/kiro/iter6-fix-and-harden
fix: context leak in goroutine handlers + harden smoke test
This commit is contained in:
@@ -0,0 +1,65 @@
|
|||||||
|
// Package handler — third-party API config (TMDb / Bangumi / TheTVDB / …).
|
||||||
|
//
|
||||||
|
// All routes live under /api/admin/api-configs/* so only administrators
|
||||||
|
// can list / update / delete provider keys.
|
||||||
|
package handler
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
|
||||||
|
"github.com/ShukeBta/MediaStationGo/internal/service"
|
||||||
|
)
|
||||||
|
|
||||||
|
func listAPIConfigsHandler(svc *service.Container) gin.HandlerFunc {
|
||||||
|
return func(c *gin.Context) {
|
||||||
|
items, err := svc.APIConfig.List(c.Request.Context())
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, gin.H{"items": items})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func getAPIConfigHandler(svc *service.Container) gin.HandlerFunc {
|
||||||
|
return func(c *gin.Context) {
|
||||||
|
view, err := svc.APIConfig.Get(c.Request.Context(), c.Param("provider"))
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if view == nil {
|
||||||
|
c.JSON(http.StatusNotFound, gin.H{"error": "not found"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, view)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func updateAPIConfigHandler(svc *service.Container) gin.HandlerFunc {
|
||||||
|
return func(c *gin.Context) {
|
||||||
|
var patch service.APIConfigPatch
|
||||||
|
if err := c.ShouldBindJSON(&patch); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
view, err := svc.APIConfig.Update(c.Request.Context(), c.Param("provider"), patch)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, view)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func deleteAPIConfigHandler(svc *service.Container) gin.HandlerFunc {
|
||||||
|
return func(c *gin.Context) {
|
||||||
|
if err := svc.APIConfig.Delete(c.Request.Context(), c.Param("provider")); err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.Status(http.StatusNoContent)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
// Package handler — DLNA / UPnP discovery + cast endpoints.
|
||||||
|
package handler
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
|
||||||
|
"github.com/ShukeBta/MediaStationGo/internal/service"
|
||||||
|
)
|
||||||
|
|
||||||
|
func dlnaListHandler(svc *service.Container) gin.HandlerFunc {
|
||||||
|
return func(c *gin.Context) {
|
||||||
|
force := c.Query("force") == "true"
|
||||||
|
devices, err := svc.DLNA.Discover(c.Request.Context(), force)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, gin.H{"devices": devices})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type dlnaCastReq struct {
|
||||||
|
ControlURL string `json:"control_url" binding:"required"`
|
||||||
|
MediaURL string `json:"media_url" binding:"required"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func dlnaCastHandler(svc *service.Container) gin.HandlerFunc {
|
||||||
|
return func(c *gin.Context) {
|
||||||
|
var req dlnaCastReq
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := svc.DLNA.Cast(c.Request.Context(), req.ControlURL, req.MediaURL); err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.Status(http.StatusNoContent)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
// Package handler — duplicate-file finder.
|
||||||
|
package handler
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
|
||||||
|
"github.com/ShukeBta/MediaStationGo/internal/service"
|
||||||
|
)
|
||||||
|
|
||||||
|
func detectDuplicatesHandler(svc *service.Container) gin.HandlerFunc {
|
||||||
|
return func(c *gin.Context) {
|
||||||
|
libraryID := c.Query("library_id")
|
||||||
|
report, err := svc.Duplicate.Detect(c.Request.Context(), libraryID)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, report)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func unmarkDuplicatesHandler(svc *service.Container) gin.HandlerFunc {
|
||||||
|
return func(c *gin.Context) {
|
||||||
|
libraryID := c.Query("library_id")
|
||||||
|
n, err := svc.Duplicate.Unmark(c.Request.Context(), libraryID)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, gin.H{"unmarked": n})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
// Package handler — Emby/Jellyfin compatibility shim.
|
||||||
|
//
|
||||||
|
// Routes are mounted under /emby/* so existing Emby-aware clients
|
||||||
|
// (Infuse / VidHub / Kodi) point at MediaStationGo and discover the
|
||||||
|
// library through their familiar API. We do not implement write paths;
|
||||||
|
// the React UI stays the canonical control plane.
|
||||||
|
package handler
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
|
||||||
|
"github.com/ShukeBta/MediaStationGo/internal/service"
|
||||||
|
)
|
||||||
|
|
||||||
|
func embySystemInfoHandler(svc *service.Container) gin.HandlerFunc {
|
||||||
|
return func(c *gin.Context) {
|
||||||
|
c.JSON(http.StatusOK, svc.Emby.SystemInfo())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func embyListUsersHandler(svc *service.Container) gin.HandlerFunc {
|
||||||
|
return func(c *gin.Context) {
|
||||||
|
users, err := svc.Emby.ListUsers(c.Request.Context())
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, users)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func embyViewsHandler(svc *service.Container) gin.HandlerFunc {
|
||||||
|
return func(c *gin.Context) {
|
||||||
|
out, err := svc.Emby.Views(c.Request.Context())
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, out)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func embyItemsHandler(svc *service.Container) gin.HandlerFunc {
|
||||||
|
return func(c *gin.Context) {
|
||||||
|
libraryID := c.Query("ParentId")
|
||||||
|
limit, _ := strconv.Atoi(c.DefaultQuery("Limit", "50"))
|
||||||
|
offset, _ := strconv.Atoi(c.DefaultQuery("StartIndex", "0"))
|
||||||
|
out, err := svc.Emby.Items(c.Request.Context(), libraryID, limit, offset)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, out)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func embyPlaybackInfoHandler(svc *service.Container) gin.HandlerFunc {
|
||||||
|
return func(c *gin.Context) {
|
||||||
|
out, err := svc.Emby.PlaybackInfo(c.Request.Context(), c.Param("id"))
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if out == nil {
|
||||||
|
c.JSON(http.StatusNotFound, gin.H{"error": "not found"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, out)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
// Package handler — server-side file browser used by the React
|
||||||
|
// "select library path" dialog and the Storage tab.
|
||||||
|
package handler
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
|
||||||
|
"github.com/ShukeBta/MediaStationGo/internal/service"
|
||||||
|
)
|
||||||
|
|
||||||
|
func browseFilesHandler(svc *service.Container) gin.HandlerFunc {
|
||||||
|
return func(c *gin.Context) {
|
||||||
|
path := c.Query("path")
|
||||||
|
max, _ := strconv.Atoi(c.DefaultQuery("max", "1000"))
|
||||||
|
listing, err := svc.FileManager.List(path, max)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, service.ErrPathOutOfBounds) {
|
||||||
|
c.JSON(http.StatusForbidden, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, listing)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -106,6 +106,25 @@ func Register(r *gin.Engine, cfg *config.Config, log *zap.Logger, svc *service.C
|
|||||||
authed.POST("/ai/search", smartSearchHandler(svc))
|
authed.POST("/ai/search", smartSearchHandler(svc))
|
||||||
authed.GET("/ai/recommend", aiRecommendHandler(svc))
|
authed.GET("/ai/recommend", aiRecommendHandler(svc))
|
||||||
|
|
||||||
|
// File browser (used by the library-path picker).
|
||||||
|
authed.GET("/files", browseFilesHandler(svc))
|
||||||
|
|
||||||
|
// Disk usage breakdown.
|
||||||
|
authed.GET("/storage", storageHandler(svc))
|
||||||
|
|
||||||
|
// DLNA discovery + cast.
|
||||||
|
authed.GET("/dlna/devices", dlnaListHandler(svc))
|
||||||
|
authed.POST("/dlna/cast", dlnaCastHandler(svc))
|
||||||
|
|
||||||
|
// STRM (URL-as-file).
|
||||||
|
authed.PUT("/media/:id/strm", middleware.AdminRequired(), setSTRMHandler(svc))
|
||||||
|
authed.DELETE("/media/:id/strm", middleware.AdminRequired(), clearSTRMHandler(svc))
|
||||||
|
authed.POST("/strm/import", middleware.AdminRequired(), importSTRMHandler(svc))
|
||||||
|
|
||||||
|
// Duplicate finder.
|
||||||
|
authed.POST("/duplicates/scan", middleware.AdminRequired(), detectDuplicatesHandler(svc))
|
||||||
|
authed.POST("/duplicates/unmark", middleware.AdminRequired(), unmarkDuplicatesHandler(svc))
|
||||||
|
|
||||||
// Recycle bin.
|
// Recycle bin.
|
||||||
authed.GET("/recycle", middleware.AdminRequired(), listRecycleHandler(svc))
|
authed.GET("/recycle", middleware.AdminRequired(), listRecycleHandler(svc))
|
||||||
|
|
||||||
@@ -122,7 +141,30 @@ func Register(r *gin.Engine, cfg *config.Config, log *zap.Logger, svc *service.C
|
|||||||
admin.GET("/settings", listSettingsHandler(svc))
|
admin.GET("/settings", listSettingsHandler(svc))
|
||||||
admin.PUT("/settings", updateSettingHandler(svc))
|
admin.PUT("/settings", updateSettingHandler(svc))
|
||||||
admin.GET("/logs", recentLogsHandler(svc))
|
admin.GET("/logs", recentLogsHandler(svc))
|
||||||
|
|
||||||
|
// API key management (encrypted at rest).
|
||||||
|
admin.GET("/api-configs", listAPIConfigsHandler(svc))
|
||||||
|
admin.GET("/api-configs/:provider", getAPIConfigHandler(svc))
|
||||||
|
admin.PUT("/api-configs/:provider", updateAPIConfigHandler(svc))
|
||||||
|
admin.DELETE("/api-configs/:provider", deleteAPIConfigHandler(svc))
|
||||||
|
|
||||||
|
// Scheduled jobs.
|
||||||
|
admin.GET("/scheduler", schedulerStatusHandler(svc))
|
||||||
|
admin.POST("/scheduler/:name/run", schedulerRunHandler(svc))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Emby/Jellyfin compatibility shim (read-only).
|
||||||
|
// Mounted at /emby/* (NOT /api/*) to mirror the upstream surface.
|
||||||
|
}
|
||||||
|
|
||||||
|
emby := r.Group("/emby")
|
||||||
|
emby.Use(middleware.AuthRequired(cfg.Secrets.JWTSecret))
|
||||||
|
{
|
||||||
|
emby.GET("/System/Info", embySystemInfoHandler(svc))
|
||||||
|
emby.GET("/Users", embyListUsersHandler(svc))
|
||||||
|
emby.GET("/Users/:userId/Views", embyViewsHandler(svc))
|
||||||
|
emby.GET("/Users/:userId/Items", embyItemsHandler(svc))
|
||||||
|
emby.GET("/Items/:id/PlaybackInfo", embyPlaybackInfoHandler(svc))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
package handler
|
package handler
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strconv"
|
"strconv"
|
||||||
@@ -43,7 +44,7 @@ func createLibraryHandler(svc *service.Container) gin.HandlerFunc {
|
|||||||
uid, _ := c.Get("ctx_user_id")
|
uid, _ := c.Get("ctx_user_id")
|
||||||
svc.Audit.Record(c.Request.Context(), toString(uid), "library.create", l.ID, c.ClientIP(), l.Path)
|
svc.Audit.Record(c.Request.Context(), toString(uid), "library.create", l.ID, c.ClientIP(), l.Path)
|
||||||
// Refresh fsnotify watcher to pick up the new library root.
|
// Refresh fsnotify watcher to pick up the new library root.
|
||||||
go func() { _ = svc.Watcher.Refresh(c.Request.Context()) }()
|
go func() { _ = svc.Watcher.Refresh(context.Background()) }()
|
||||||
c.JSON(http.StatusOK, l)
|
c.JSON(http.StatusOK, l)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -57,7 +58,7 @@ func deleteLibraryHandler(svc *service.Container) gin.HandlerFunc {
|
|||||||
}
|
}
|
||||||
uid, _ := c.Get("ctx_user_id")
|
uid, _ := c.Get("ctx_user_id")
|
||||||
svc.Audit.Record(c.Request.Context(), toString(uid), "library.delete", id, c.ClientIP(), "")
|
svc.Audit.Record(c.Request.Context(), toString(uid), "library.delete", id, c.ClientIP(), "")
|
||||||
go func() { _ = svc.Watcher.Refresh(c.Request.Context()) }()
|
go func() { _ = svc.Watcher.Refresh(context.Background()) }()
|
||||||
c.Status(http.StatusNoContent)
|
c.Status(http.StatusNoContent)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,27 @@
|
|||||||
|
// Package handler — scheduled jobs admin page.
|
||||||
|
package handler
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
|
||||||
|
"github.com/ShukeBta/MediaStationGo/internal/service"
|
||||||
|
)
|
||||||
|
|
||||||
|
func schedulerStatusHandler(svc *service.Container) gin.HandlerFunc {
|
||||||
|
return func(c *gin.Context) {
|
||||||
|
c.JSON(http.StatusOK, gin.H{"jobs": svc.Scheduler.Status()})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func schedulerRunHandler(svc *service.Container) gin.HandlerFunc {
|
||||||
|
return func(c *gin.Context) {
|
||||||
|
name := c.Param("name")
|
||||||
|
if err := svc.Scheduler.RunNow(c.Request.Context(), name); err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.Status(http.StatusNoContent)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
// Package handler — disk usage breakdown for the Storage tab.
|
||||||
|
package handler
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
|
||||||
|
"github.com/ShukeBta/MediaStationGo/internal/service"
|
||||||
|
)
|
||||||
|
|
||||||
|
func storageHandler(svc *service.Container) gin.HandlerFunc {
|
||||||
|
return func(c *gin.Context) {
|
||||||
|
bd, err := svc.Storage.Compute(c.Request.Context())
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, bd)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@
|
|||||||
package handler
|
package handler
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
|
||||||
@@ -82,7 +83,7 @@ func scrapeLibraryHandler(svc *service.Container) gin.HandlerFunc {
|
|||||||
// Run in the background so HTTP returns instantly; the WS hub
|
// Run in the background so HTTP returns instantly; the WS hub
|
||||||
// pushes per-item progress on the "scrape" topic.
|
// pushes per-item progress on the "scrape" topic.
|
||||||
go func(libID string) {
|
go func(libID string) {
|
||||||
_, _ = svc.Scraper.EnrichLibrary(c.Copy().Request.Context(), libID)
|
_, _ = svc.Scraper.EnrichLibrary(context.Background(), libID)
|
||||||
}(c.Param("id"))
|
}(c.Param("id"))
|
||||||
c.JSON(http.StatusAccepted, gin.H{"status": "scraping"})
|
c.JSON(http.StatusAccepted, gin.H{"status": "scraping"})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,93 @@
|
|||||||
|
// Package handler — STRM (URL-as-file) admin endpoints.
|
||||||
|
//
|
||||||
|
// Setting a media row's strm_url makes the stream handler issue a 302
|
||||||
|
// redirect to that URL instead of opening a local file. This lets the
|
||||||
|
// operator expose WebDAV / Alist / S3 / HTTP direct links as ordinary
|
||||||
|
// MediaStationGo entries.
|
||||||
|
package handler
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
|
||||||
|
"github.com/ShukeBta/MediaStationGo/internal/model"
|
||||||
|
"github.com/ShukeBta/MediaStationGo/internal/service"
|
||||||
|
)
|
||||||
|
|
||||||
|
type strmReq struct {
|
||||||
|
URL string `json:"url" binding:"required"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func setSTRMHandler(svc *service.Container) gin.HandlerFunc {
|
||||||
|
return func(c *gin.Context) {
|
||||||
|
var req strmReq
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
url := strings.TrimSpace(req.URL)
|
||||||
|
if !strings.HasPrefix(url, "http://") && !strings.HasPrefix(url, "https://") {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "url must start with http:// or https://"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
mediaID := c.Param("id")
|
||||||
|
m, err := svc.Repo.Media.FindByID(c.Request.Context(), mediaID)
|
||||||
|
if err != nil || m == nil {
|
||||||
|
c.JSON(http.StatusNotFound, gin.H{"error": "media not found"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := svc.Repo.DB.WithContext(c.Request.Context()).
|
||||||
|
Model(&model.Media{}).
|
||||||
|
Where("id = ?", mediaID).
|
||||||
|
Update("strm_url", url).Error; err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, gin.H{"strm_url": url})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func clearSTRMHandler(svc *service.Container) gin.HandlerFunc {
|
||||||
|
return func(c *gin.Context) {
|
||||||
|
if err := svc.Repo.DB.WithContext(c.Request.Context()).
|
||||||
|
Model(&model.Media{}).
|
||||||
|
Where("id = ?", c.Param("id")).
|
||||||
|
Update("strm_url", "").Error; err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.Status(http.StatusNoContent)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// importSTRMHandler creates a media row directly from a (library_id, title, url)
|
||||||
|
// tuple — useful for adding a streaming-only entry without an on-disk file.
|
||||||
|
type importSTRMReq struct {
|
||||||
|
LibraryID string `json:"library_id" binding:"required"`
|
||||||
|
Title string `json:"title" binding:"required"`
|
||||||
|
URL string `json:"url" binding:"required"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func importSTRMHandler(svc *service.Container) gin.HandlerFunc {
|
||||||
|
return func(c *gin.Context) {
|
||||||
|
var req importSTRMReq
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
m := &model.Media{
|
||||||
|
LibraryID: req.LibraryID,
|
||||||
|
Title: req.Title,
|
||||||
|
Path: req.URL, // unique-index target — keep it identical to the URL
|
||||||
|
STRMURL: req.URL,
|
||||||
|
Container: "strm",
|
||||||
|
}
|
||||||
|
if err := svc.Repo.DB.WithContext(c.Request.Context()).Create(m).Error; err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, m)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -79,6 +79,41 @@ type Media struct {
|
|||||||
TMDbID int `json:"tmdb_id"`
|
TMDbID int `json:"tmdb_id"`
|
||||||
BangumiID int `json:"bangumi_id"`
|
BangumiID int `json:"bangumi_id"`
|
||||||
NSFW bool `gorm:"default:false" json:"nsfw"`
|
NSFW bool `gorm:"default:false" json:"nsfw"`
|
||||||
|
|
||||||
|
// STRMURL is the indirection target for .strm files: when present the
|
||||||
|
// stream handler redirects to it instead of opening the local file.
|
||||||
|
// Used to expose WebDAV / Alist / S3 / HTTP direct links as media items.
|
||||||
|
STRMURL string `gorm:"size:2048" json:"strm_url,omitempty"`
|
||||||
|
|
||||||
|
// FileHash is a sparse-sample MD5 used for duplicate detection.
|
||||||
|
// Computed on-demand by the duplicate finder; format: "<hex>-<size>".
|
||||||
|
FileHash string `gorm:"index;size:64" json:"file_hash,omitempty"`
|
||||||
|
|
||||||
|
// IsDuplicate flags this media as a duplicate of another media row.
|
||||||
|
IsDuplicate bool `gorm:"default:false" json:"is_duplicate"`
|
||||||
|
DuplicateOf string `gorm:"size:36" json:"duplicate_of,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// APIConfig stores third-party data-source configuration. The api_key
|
||||||
|
// column is encrypted with AES-GCM (see internal/service/crypto.go) so an
|
||||||
|
// SQLite leak does not expose third-party credentials.
|
||||||
|
//
|
||||||
|
// Provider values mirror the original Python project:
|
||||||
|
//
|
||||||
|
// tmdb — themoviedb.org
|
||||||
|
// bangumi — bgm.tv
|
||||||
|
// thetvdb — thetvdb.com
|
||||||
|
// fanart — fanart.tv
|
||||||
|
// douban — douban.com (cookie)
|
||||||
|
// openai — OpenAI / DeepSeek / Qwen / Ollama (compatible)
|
||||||
|
type APIConfig struct {
|
||||||
|
Base
|
||||||
|
Provider string `gorm:"uniqueIndex;size:32;not null" json:"provider"`
|
||||||
|
APIKey string `gorm:"type:text" json:"-"` // ciphertext (never serialised)
|
||||||
|
BaseURL string `gorm:"size:512" json:"base_url,omitempty"`
|
||||||
|
Extra string `gorm:"type:text" json:"extra,omitempty"` // free-form JSON
|
||||||
|
Enabled bool `gorm:"default:true" json:"enabled"`
|
||||||
|
Description string `gorm:"size:255" json:"description,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// Series groups episodes that belong to the same show.
|
// Series groups episodes that belong to the same show.
|
||||||
@@ -185,5 +220,6 @@ func AllModels() []interface{} {
|
|||||||
&Subscription{},
|
&Subscription{},
|
||||||
&Setting{},
|
&Setting{},
|
||||||
&AccessLog{},
|
&AccessLog{},
|
||||||
|
&APIConfig{},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,231 @@
|
|||||||
|
// Package service — third-party API key store.
|
||||||
|
//
|
||||||
|
// APIConfigService is a small CRUD layer over the api_configs table. It
|
||||||
|
// transparently encrypts the api_key column on write and decrypts it on
|
||||||
|
// read so values stored on disk are useless without the JWT secret.
|
||||||
|
//
|
||||||
|
// On first read it seeds the table with the providers MediaStation
|
||||||
|
// supports today (TMDb / Bangumi / TheTVDB / Fanart / OpenAI / Douban).
|
||||||
|
package service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"go.uber.org/zap"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
|
||||||
|
"github.com/ShukeBta/MediaStationGo/internal/model"
|
||||||
|
"github.com/ShukeBta/MediaStationGo/internal/repository"
|
||||||
|
)
|
||||||
|
|
||||||
|
// APIConfigService coordinates third-party API key storage.
|
||||||
|
type APIConfigService struct {
|
||||||
|
log *zap.Logger
|
||||||
|
repo *repository.Container
|
||||||
|
crypto *CryptoService
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewAPIConfigService is the constructor.
|
||||||
|
func NewAPIConfigService(log *zap.Logger, repo *repository.Container, crypto *CryptoService) *APIConfigService {
|
||||||
|
return &APIConfigService{log: log, repo: repo, crypto: crypto}
|
||||||
|
}
|
||||||
|
|
||||||
|
// SeedDefaults inserts a row for every well-known provider on first run.
|
||||||
|
func (s *APIConfigService) SeedDefaults(ctx context.Context) error {
|
||||||
|
defaults := []model.APIConfig{
|
||||||
|
{Provider: "tmdb", BaseURL: "https://api.themoviedb.org/3", Description: "TMDb (movies + tv)", Enabled: true},
|
||||||
|
{Provider: "bangumi", BaseURL: "https://api.bgm.tv", Description: "Bangumi (anime)", Enabled: true},
|
||||||
|
{Provider: "thetvdb", BaseURL: "https://api4.thetvdb.com/v4", Description: "TheTVDB (tv)", Enabled: true},
|
||||||
|
{Provider: "fanart", BaseURL: "https://webservice.fanart.tv/v3", Description: "Fanart.tv (artwork)", Enabled: true},
|
||||||
|
{Provider: "douban", Description: "Douban cookie (zh metadata)", Enabled: true},
|
||||||
|
{Provider: "openai", BaseURL: "https://api.openai.com/v1", Description: "OpenAI-compatible (smart search)", Enabled: true},
|
||||||
|
}
|
||||||
|
for i := range defaults {
|
||||||
|
var existing model.APIConfig
|
||||||
|
err := s.repo.DB.WithContext(ctx).
|
||||||
|
Where("provider = ?", defaults[i].Provider).
|
||||||
|
First(&existing).Error
|
||||||
|
if err == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if !errors.Is(err, gorm.ErrRecordNotFound) {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := s.repo.DB.WithContext(ctx).Create(&defaults[i]).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// PublicView is the safe-to-display projection of an API config row.
|
||||||
|
// The plaintext key is never returned — only a mask.
|
||||||
|
type PublicView struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Provider string `json:"provider"`
|
||||||
|
BaseURL string `json:"base_url,omitempty"`
|
||||||
|
Extra string `json:"extra,omitempty"`
|
||||||
|
Enabled bool `json:"enabled"`
|
||||||
|
Description string `json:"description,omitempty"`
|
||||||
|
HasKey bool `json:"has_key"`
|
||||||
|
MaskedKey string `json:"masked_key,omitempty"`
|
||||||
|
CreatedAt time.Time `json:"created_at"`
|
||||||
|
UpdatedAt time.Time `json:"updated_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// List returns every API config row (with masked keys).
|
||||||
|
func (s *APIConfigService) List(ctx context.Context) ([]PublicView, error) {
|
||||||
|
var rows []model.APIConfig
|
||||||
|
if err := s.repo.DB.WithContext(ctx).Order("provider asc").Find(&rows).Error; err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
out := make([]PublicView, 0, len(rows))
|
||||||
|
for _, r := range rows {
|
||||||
|
out = append(out, s.toPublic(&r))
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get returns the public view for a single provider, or nil.
|
||||||
|
func (s *APIConfigService) Get(ctx context.Context, provider string) (*PublicView, error) {
|
||||||
|
row, err := s.findByProvider(ctx, provider)
|
||||||
|
if err != nil || row == nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
v := s.toPublic(row)
|
||||||
|
return &v, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Resolve returns the decrypted key + base url ready for use by an HTTP
|
||||||
|
// client. Empty struct (with no error) when the provider is unknown or
|
||||||
|
// the API key is empty.
|
||||||
|
type Resolved struct {
|
||||||
|
APIKey string
|
||||||
|
BaseURL string
|
||||||
|
Extra string
|
||||||
|
Enabled bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// Resolve fetches the live configuration for a provider, decrypting the
|
||||||
|
// API key. Callers can use Resolved.APIKey != "" as the "configured" check.
|
||||||
|
func (s *APIConfigService) Resolve(ctx context.Context, provider string) (Resolved, error) {
|
||||||
|
row, err := s.findByProvider(ctx, provider)
|
||||||
|
if err != nil || row == nil {
|
||||||
|
return Resolved{}, err
|
||||||
|
}
|
||||||
|
return Resolved{
|
||||||
|
APIKey: s.crypto.Decrypt(row.APIKey),
|
||||||
|
BaseURL: row.BaseURL,
|
||||||
|
Extra: row.Extra,
|
||||||
|
Enabled: row.Enabled,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update upserts a single provider's config. An empty patch.APIKey leaves
|
||||||
|
// the existing key untouched; pass "<clear>" sentinel to wipe it.
|
||||||
|
type APIConfigPatch struct {
|
||||||
|
APIKey *string `json:"api_key,omitempty"`
|
||||||
|
BaseURL *string `json:"base_url,omitempty"`
|
||||||
|
Extra *string `json:"extra,omitempty"`
|
||||||
|
Enabled *bool `json:"enabled,omitempty"`
|
||||||
|
Description *string `json:"description,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update applies the patch and returns the new public view.
|
||||||
|
func (s *APIConfigService) Update(ctx context.Context, provider string, patch APIConfigPatch) (*PublicView, error) {
|
||||||
|
provider = strings.TrimSpace(strings.ToLower(provider))
|
||||||
|
if provider == "" {
|
||||||
|
return nil, errors.New("provider required")
|
||||||
|
}
|
||||||
|
|
||||||
|
row, err := s.findByProvider(ctx, provider)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if row == nil {
|
||||||
|
row = &model.APIConfig{Provider: provider, Enabled: true}
|
||||||
|
if err := s.repo.DB.WithContext(ctx).Create(row).Error; err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
updates := map[string]any{}
|
||||||
|
if patch.APIKey != nil {
|
||||||
|
v := strings.TrimSpace(*patch.APIKey)
|
||||||
|
if v == "" || v == "<clear>" {
|
||||||
|
updates["api_key"] = ""
|
||||||
|
} else {
|
||||||
|
updates["api_key"] = s.crypto.Encrypt(v)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if patch.BaseURL != nil {
|
||||||
|
updates["base_url"] = *patch.BaseURL
|
||||||
|
}
|
||||||
|
if patch.Extra != nil {
|
||||||
|
updates["extra"] = *patch.Extra
|
||||||
|
}
|
||||||
|
if patch.Enabled != nil {
|
||||||
|
updates["enabled"] = *patch.Enabled
|
||||||
|
}
|
||||||
|
if patch.Description != nil {
|
||||||
|
updates["description"] = *patch.Description
|
||||||
|
}
|
||||||
|
if len(updates) > 0 {
|
||||||
|
if err := s.repo.DB.WithContext(ctx).
|
||||||
|
Model(&model.APIConfig{}).
|
||||||
|
Where("id = ?", row.ID).
|
||||||
|
Updates(updates).Error; err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
row, _ = s.findByProvider(ctx, provider)
|
||||||
|
v := s.toPublic(row)
|
||||||
|
return &v, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete clears a provider's API key (the row stays so the masked
|
||||||
|
// description is still useful). Non-existent providers are a no-op.
|
||||||
|
func (s *APIConfigService) Delete(ctx context.Context, provider string) error {
|
||||||
|
row, err := s.findByProvider(ctx, provider)
|
||||||
|
if err != nil || row == nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return s.repo.DB.WithContext(ctx).
|
||||||
|
Model(&model.APIConfig{}).
|
||||||
|
Where("id = ?", row.ID).
|
||||||
|
Update("api_key", "").Error
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *APIConfigService) findByProvider(ctx context.Context, provider string) (*model.APIConfig, error) {
|
||||||
|
var row model.APIConfig
|
||||||
|
err := s.repo.DB.WithContext(ctx).Where("provider = ?", provider).First(&row).Error
|
||||||
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &row, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *APIConfigService) toPublic(r *model.APIConfig) PublicView {
|
||||||
|
plain := s.crypto.Decrypt(r.APIKey)
|
||||||
|
pv := PublicView{
|
||||||
|
ID: r.ID,
|
||||||
|
Provider: r.Provider,
|
||||||
|
BaseURL: r.BaseURL,
|
||||||
|
Extra: r.Extra,
|
||||||
|
Enabled: r.Enabled,
|
||||||
|
Description: r.Description,
|
||||||
|
HasKey: plain != "",
|
||||||
|
CreatedAt: r.CreatedAt,
|
||||||
|
UpdatedAt: r.UpdatedAt,
|
||||||
|
}
|
||||||
|
if pv.HasKey {
|
||||||
|
pv.MaskedKey = MaskAPIKey(plain)
|
||||||
|
}
|
||||||
|
return pv
|
||||||
|
}
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
// Package service — generic on-disk cleanup helper used by the
|
||||||
|
// scheduler. Public so handlers can call it for "purge transcode cache
|
||||||
|
// now" buttons.
|
||||||
|
package service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// walkAndPrune recursively deletes every file under root whose mtime is
|
||||||
|
// older than cutoff. Empty directories left behind are removed too.
|
||||||
|
// Best-effort: per-file errors are ignored so a single permission denial
|
||||||
|
// doesn't abort the cleanup.
|
||||||
|
func walkAndPrune(root string, cutoff time.Time) error {
|
||||||
|
if root == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if _, err := os.Stat(root); err != nil {
|
||||||
|
return nil // nothing to clean
|
||||||
|
}
|
||||||
|
dirs := []string{}
|
||||||
|
_ = filepath.Walk(root, func(path string, info os.FileInfo, err error) error {
|
||||||
|
if err != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if info.IsDir() {
|
||||||
|
if path != root {
|
||||||
|
dirs = append(dirs, path)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if info.ModTime().Before(cutoff) {
|
||||||
|
_ = os.Remove(path)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
// Remove emptied directories from deepest to shallowest.
|
||||||
|
for i := len(dirs) - 1; i >= 0; i-- {
|
||||||
|
_ = os.Remove(dirs[i])
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,114 @@
|
|||||||
|
// Package service — AES-GCM crypto helper for at-rest secrets.
|
||||||
|
//
|
||||||
|
// Sensitive fields (third-party API keys, qBittorrent passwords, …) are
|
||||||
|
// stored in SQLite. We encrypt them with AES-256-GCM keyed off the JWT
|
||||||
|
// secret so a stolen DB file alone is not enough to recover the
|
||||||
|
// plaintext credentials.
|
||||||
|
//
|
||||||
|
// Format on disk: "enc:v1:" + base64(nonce || ciphertext || tag)
|
||||||
|
//
|
||||||
|
// Legacy plaintext rows (no prefix) round-trip unchanged so an upgraded
|
||||||
|
// install does not need a migration step.
|
||||||
|
package service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/aes"
|
||||||
|
"crypto/cipher"
|
||||||
|
"crypto/rand"
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/base64"
|
||||||
|
"errors"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"go.uber.org/zap"
|
||||||
|
)
|
||||||
|
|
||||||
|
// encPrefix tags ciphertext rows so we can tell them apart from legacy
|
||||||
|
// plaintext values.
|
||||||
|
const encPrefix = "enc:v1:"
|
||||||
|
|
||||||
|
// CryptoService wraps an AES-GCM cipher derived from a stable per-install
|
||||||
|
// secret (the JWT secret).
|
||||||
|
type CryptoService struct {
|
||||||
|
log *zap.Logger
|
||||||
|
aead cipher.AEAD
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewCryptoService derives a 256-bit key from the given secret via
|
||||||
|
// SHA-256 and constructs an AES-GCM AEAD. Empty secrets yield a service
|
||||||
|
// whose Encrypt/Decrypt methods are pass-throughs (used in unit tests).
|
||||||
|
func NewCryptoService(secret string, log *zap.Logger) *CryptoService {
|
||||||
|
c := &CryptoService{log: log}
|
||||||
|
if strings.TrimSpace(secret) == "" {
|
||||||
|
return c
|
||||||
|
}
|
||||||
|
sum := sha256.Sum256([]byte(secret))
|
||||||
|
block, err := aes.NewCipher(sum[:])
|
||||||
|
if err != nil {
|
||||||
|
log.Error("crypto: aes.NewCipher", zap.Error(err))
|
||||||
|
return c
|
||||||
|
}
|
||||||
|
aead, err := cipher.NewGCM(block)
|
||||||
|
if err != nil {
|
||||||
|
log.Error("crypto: cipher.NewGCM", zap.Error(err))
|
||||||
|
return c
|
||||||
|
}
|
||||||
|
c.aead = aead
|
||||||
|
return c
|
||||||
|
}
|
||||||
|
|
||||||
|
// Encrypt returns the base64-encoded ciphertext (with prefix) for plain.
|
||||||
|
// Empty inputs round-trip unchanged.
|
||||||
|
func (c *CryptoService) Encrypt(plain string) string {
|
||||||
|
if plain == "" || c.aead == nil {
|
||||||
|
return plain
|
||||||
|
}
|
||||||
|
if strings.HasPrefix(plain, encPrefix) {
|
||||||
|
return plain
|
||||||
|
}
|
||||||
|
nonce := make([]byte, c.aead.NonceSize())
|
||||||
|
if _, err := rand.Read(nonce); err != nil {
|
||||||
|
return plain
|
||||||
|
}
|
||||||
|
cipherBytes := c.aead.Seal(nonce, nonce, []byte(plain), nil)
|
||||||
|
return encPrefix + base64.StdEncoding.EncodeToString(cipherBytes)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Decrypt returns the plaintext for an encrypted value. Plaintext rows
|
||||||
|
// (no prefix) are returned unchanged.
|
||||||
|
func (c *CryptoService) Decrypt(value string) string {
|
||||||
|
if value == "" || c.aead == nil {
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
if !strings.HasPrefix(value, encPrefix) {
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
raw := strings.TrimPrefix(value, encPrefix)
|
||||||
|
data, err := base64.StdEncoding.DecodeString(raw)
|
||||||
|
if err != nil {
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
if len(data) < c.aead.NonceSize() {
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
nonce, cipherBytes := data[:c.aead.NonceSize()], data[c.aead.NonceSize():]
|
||||||
|
plain, err := c.aead.Open(nil, nonce, cipherBytes, nil)
|
||||||
|
if err != nil {
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
return string(plain)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MaskAPIKey returns "abcd****wxyz" so the key can be displayed in the
|
||||||
|
// admin UI without leaking it. Inputs shorter than 8 chars become "****".
|
||||||
|
func MaskAPIKey(plain string) string {
|
||||||
|
plain = strings.TrimSpace(plain)
|
||||||
|
if len(plain) < 8 {
|
||||||
|
return "****"
|
||||||
|
}
|
||||||
|
return plain[:4] + "****" + plain[len(plain)-4:]
|
||||||
|
}
|
||||||
|
|
||||||
|
// ErrCryptoUnavailable is returned when callers expect crypto and the
|
||||||
|
// service is degraded (empty secret, init failure).
|
||||||
|
var ErrCryptoUnavailable = errors.New("crypto unavailable")
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
package service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"go.uber.org/zap"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestCryptoRoundtrip(t *testing.T) {
|
||||||
|
c := NewCryptoService("super-secret-key-1234567890", zap.NewNop())
|
||||||
|
cases := []string{
|
||||||
|
"",
|
||||||
|
"a",
|
||||||
|
"hello world",
|
||||||
|
"sk-1234567890abcdef1234567890abcdef1234567890abcdef",
|
||||||
|
}
|
||||||
|
for _, plain := range cases {
|
||||||
|
t.Run(plain, func(t *testing.T) {
|
||||||
|
cipher := c.Encrypt(plain)
|
||||||
|
if plain == "" {
|
||||||
|
if cipher != "" {
|
||||||
|
t.Fatalf("empty plaintext should round-trip empty, got %q", cipher)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if cipher == plain {
|
||||||
|
t.Fatalf("expected ciphertext to differ from plaintext")
|
||||||
|
}
|
||||||
|
if !strings.HasPrefix(cipher, "enc:v1:") {
|
||||||
|
t.Fatalf("expected enc:v1: prefix, got %q", cipher)
|
||||||
|
}
|
||||||
|
plain2 := c.Decrypt(cipher)
|
||||||
|
if plain2 != plain {
|
||||||
|
t.Fatalf("decrypt mismatch: got %q, want %q", plain2, plain)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCryptoNoSecret(t *testing.T) {
|
||||||
|
c := NewCryptoService("", zap.NewNop())
|
||||||
|
if c.Encrypt("x") != "x" {
|
||||||
|
t.Fatal("empty-secret crypto should be a pass-through")
|
||||||
|
}
|
||||||
|
if c.Decrypt("x") != "x" {
|
||||||
|
t.Fatal("empty-secret crypto should be a pass-through")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCryptoLegacyPlaintext(t *testing.T) {
|
||||||
|
c := NewCryptoService("k", zap.NewNop())
|
||||||
|
// Decrypt a value that has no enc:v1: prefix — should pass through.
|
||||||
|
if got := c.Decrypt("legacy-plain"); got != "legacy-plain" {
|
||||||
|
t.Fatalf("legacy plaintext should pass through, got %q", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMaskAPIKey(t *testing.T) {
|
||||||
|
cases := []struct{ in, want string }{
|
||||||
|
{"", "****"},
|
||||||
|
{"abc", "****"},
|
||||||
|
{"abcdefgh", "abcd****efgh"},
|
||||||
|
{"sk-1234567890abcdef", "sk-1****cdef"},
|
||||||
|
}
|
||||||
|
for _, tc := range cases {
|
||||||
|
got := MaskAPIKey(tc.in)
|
||||||
|
if got != tc.want {
|
||||||
|
t.Errorf("MaskAPIKey(%q) = %q, want %q", tc.in, got, tc.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,277 @@
|
|||||||
|
// Package service — DLNA / UPnP discovery.
|
||||||
|
//
|
||||||
|
// DLNAService scans the LAN for "MediaRenderer" UPnP devices via SSDP
|
||||||
|
// (multicast UDP 239.255.255.250:1900) and exposes a one-shot "cast"
|
||||||
|
// helper that POSTs a SOAP envelope to the renderer's AVTransport
|
||||||
|
// service to start playback of an HTTP URL.
|
||||||
|
//
|
||||||
|
// We do NOT mediate the renderer ↔ client traffic; the renderer pulls
|
||||||
|
// the bytes directly from MediaStationGo's /api/stream endpoint, so
|
||||||
|
// the cast call only ever transports a URL string.
|
||||||
|
package service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"encoding/xml"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"go.uber.org/zap"
|
||||||
|
)
|
||||||
|
|
||||||
|
// DLNAService discovers UPnP MediaRenderer devices and casts media to them.
|
||||||
|
type DLNAService struct {
|
||||||
|
log *zap.Logger
|
||||||
|
|
||||||
|
mu sync.Mutex
|
||||||
|
cache []DLNADevice
|
||||||
|
cachedAt time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewDLNAService is the constructor.
|
||||||
|
func NewDLNAService(log *zap.Logger) *DLNAService {
|
||||||
|
return &DLNAService{log: log}
|
||||||
|
}
|
||||||
|
|
||||||
|
// DLNADevice is the public projection of a discovered renderer.
|
||||||
|
type DLNADevice struct {
|
||||||
|
UDN string `json:"udn"`
|
||||||
|
FriendlyName string `json:"friendly_name"`
|
||||||
|
Manufacturer string `json:"manufacturer"`
|
||||||
|
ModelName string `json:"model_name"`
|
||||||
|
Location string `json:"location"` // device description URL
|
||||||
|
ControlURL string `json:"control_url"` // AVTransport SOAP endpoint
|
||||||
|
IPAddress string `json:"ip_address"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ssdpDiscover sends an M-SEARCH and returns the LOCATION URLs of every
|
||||||
|
// device that replied within timeout.
|
||||||
|
func (d *DLNAService) ssdpDiscover(ctx context.Context, timeout time.Duration) ([]string, error) {
|
||||||
|
addr, err := net.ResolveUDPAddr("udp4", "239.255.255.250:1900")
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
conn, err := net.ListenUDP("udp4", &net.UDPAddr{IP: net.IPv4zero, Port: 0})
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer conn.Close()
|
||||||
|
|
||||||
|
msg := strings.Join([]string{
|
||||||
|
"M-SEARCH * HTTP/1.1",
|
||||||
|
"HOST: 239.255.255.250:1900",
|
||||||
|
`MAN: "ssdp:discover"`,
|
||||||
|
"MX: 2",
|
||||||
|
"ST: urn:schemas-upnp-org:device:MediaRenderer:1",
|
||||||
|
"", "",
|
||||||
|
}, "\r\n")
|
||||||
|
if _, err := conn.WriteTo([]byte(msg), addr); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
deadline := time.Now().Add(timeout)
|
||||||
|
_ = conn.SetReadDeadline(deadline)
|
||||||
|
|
||||||
|
seen := map[string]struct{}{}
|
||||||
|
var locations []string
|
||||||
|
buf := make([]byte, 4096)
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return locations, nil
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
n, _, err := conn.ReadFrom(buf)
|
||||||
|
if err != nil {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
body := string(buf[:n])
|
||||||
|
for _, line := range strings.Split(body, "\r\n") {
|
||||||
|
if strings.HasPrefix(strings.ToUpper(line), "LOCATION:") {
|
||||||
|
loc := strings.TrimSpace(line[len("LOCATION:"):])
|
||||||
|
if _, ok := seen[loc]; ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
seen[loc] = struct{}{}
|
||||||
|
locations = append(locations, loc)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return locations, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Discover returns every reachable MediaRenderer on the LAN. Results are
|
||||||
|
// cached for 30 seconds so the React UI's polling does not spam the
|
||||||
|
// network.
|
||||||
|
func (d *DLNAService) Discover(ctx context.Context, force bool) ([]DLNADevice, error) {
|
||||||
|
d.mu.Lock()
|
||||||
|
if !force && time.Since(d.cachedAt) < 30*time.Second && d.cache != nil {
|
||||||
|
out := append([]DLNADevice(nil), d.cache...)
|
||||||
|
d.mu.Unlock()
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
d.mu.Unlock()
|
||||||
|
|
||||||
|
locations, err := d.ssdpDiscover(ctx, 3*time.Second)
|
||||||
|
if err != nil {
|
||||||
|
// SSDP often fails on container networks; treat as "no devices"
|
||||||
|
// rather than 500 the API.
|
||||||
|
d.log.Debug("ssdp discover failed", zap.Error(err))
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
devices := make([]DLNADevice, 0, len(locations))
|
||||||
|
for _, loc := range locations {
|
||||||
|
dev, err := d.fetchDescription(ctx, loc)
|
||||||
|
if err != nil {
|
||||||
|
d.log.Debug("desc fetch", zap.String("loc", loc), zap.Error(err))
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
devices = append(devices, *dev)
|
||||||
|
}
|
||||||
|
|
||||||
|
d.mu.Lock()
|
||||||
|
d.cache = devices
|
||||||
|
d.cachedAt = time.Now()
|
||||||
|
d.mu.Unlock()
|
||||||
|
return devices, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// fetchDescription parses the device's UPnP XML descriptor and pulls out
|
||||||
|
// the AVTransport control URL.
|
||||||
|
func (d *DLNAService) fetchDescription(ctx context.Context, location string) (*DLNADevice, error) {
|
||||||
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, location, nil)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
resp, err := http.DefaultClient.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
body, err := io.ReadAll(resp.Body)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
type service struct {
|
||||||
|
ServiceType string `xml:"serviceType"`
|
||||||
|
ControlURL string `xml:"controlURL"`
|
||||||
|
}
|
||||||
|
type device struct {
|
||||||
|
FriendlyName string `xml:"friendlyName"`
|
||||||
|
Manufacturer string `xml:"manufacturer"`
|
||||||
|
ModelName string `xml:"modelName"`
|
||||||
|
UDN string `xml:"UDN"`
|
||||||
|
ServiceList struct {
|
||||||
|
Services []service `xml:"service"`
|
||||||
|
} `xml:"serviceList"`
|
||||||
|
}
|
||||||
|
type root struct {
|
||||||
|
Device device `xml:"device"`
|
||||||
|
}
|
||||||
|
var r root
|
||||||
|
if err := xml.Unmarshal(body, &r); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
out := &DLNADevice{
|
||||||
|
UDN: r.Device.UDN,
|
||||||
|
FriendlyName: r.Device.FriendlyName,
|
||||||
|
Manufacturer: r.Device.Manufacturer,
|
||||||
|
ModelName: r.Device.ModelName,
|
||||||
|
Location: location,
|
||||||
|
}
|
||||||
|
if u, err := url.Parse(location); err == nil {
|
||||||
|
out.IPAddress = u.Hostname()
|
||||||
|
}
|
||||||
|
for _, svc := range r.Device.ServiceList.Services {
|
||||||
|
if strings.Contains(svc.ServiceType, "AVTransport") {
|
||||||
|
out.ControlURL = absoluteURL(location, svc.ControlURL)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func absoluteURL(base, ref string) string {
|
||||||
|
bu, err := url.Parse(base)
|
||||||
|
if err != nil {
|
||||||
|
return ref
|
||||||
|
}
|
||||||
|
ru, err := url.Parse(ref)
|
||||||
|
if err != nil {
|
||||||
|
return ref
|
||||||
|
}
|
||||||
|
return bu.ResolveReference(ru).String()
|
||||||
|
}
|
||||||
|
|
||||||
|
// soapTemplate is the AVTransport SetAVTransportURI envelope.
|
||||||
|
const soapTemplate = `<?xml version="1.0"?>
|
||||||
|
<s:Envelope xmlns:s="http://schemas.xmlsoap.org/soap/envelope/" s:encodingStyle="http://schemas.xmlsoap.org/soap/encoding/">
|
||||||
|
<s:Body>
|
||||||
|
<u:SetAVTransportURI xmlns:u="urn:schemas-upnp-org:service:AVTransport:1">
|
||||||
|
<InstanceID>0</InstanceID>
|
||||||
|
<CurrentURI>%s</CurrentURI>
|
||||||
|
<CurrentURIMetaData></CurrentURIMetaData>
|
||||||
|
</u:SetAVTransportURI>
|
||||||
|
</s:Body>
|
||||||
|
</s:Envelope>`
|
||||||
|
|
||||||
|
const playTemplate = `<?xml version="1.0"?>
|
||||||
|
<s:Envelope xmlns:s="http://schemas.xmlsoap.org/soap/envelope/" s:encodingStyle="http://schemas.xmlsoap.org/soap/encoding/">
|
||||||
|
<s:Body>
|
||||||
|
<u:Play xmlns:u="urn:schemas-upnp-org:service:AVTransport:1">
|
||||||
|
<InstanceID>0</InstanceID>
|
||||||
|
<Speed>1</Speed>
|
||||||
|
</u:Play>
|
||||||
|
</s:Body>
|
||||||
|
</s:Envelope>`
|
||||||
|
|
||||||
|
// Cast tells the device at controlURL to start playing mediaURL. Returns
|
||||||
|
// the renderer's HTTP status for diagnostic purposes.
|
||||||
|
func (d *DLNAService) Cast(ctx context.Context, controlURL, mediaURL string) error {
|
||||||
|
if controlURL == "" {
|
||||||
|
return errors.New("device has no AVTransport control URL")
|
||||||
|
}
|
||||||
|
if err := d.soap(ctx, controlURL, "SetAVTransportURI",
|
||||||
|
fmt.Sprintf(soapTemplate, escapeXML(mediaURL))); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return d.soap(ctx, controlURL, "Play", playTemplate)
|
||||||
|
}
|
||||||
|
|
||||||
|
// soap POSTs an envelope and returns the parsed faultstring (if any).
|
||||||
|
func (d *DLNAService) soap(ctx context.Context, controlURL, action, envelope string) error {
|
||||||
|
req, err := http.NewRequestWithContext(ctx, http.MethodPost, controlURL,
|
||||||
|
bytes.NewReader([]byte(envelope)))
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
req.Header.Set("Content-Type", `text/xml; charset="utf-8"`)
|
||||||
|
req.Header.Set("SOAPAction",
|
||||||
|
fmt.Sprintf(`"urn:schemas-upnp-org:service:AVTransport:1#%s"`, action))
|
||||||
|
resp, err := http.DefaultClient.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
if resp.StatusCode >= 400 {
|
||||||
|
raw, _ := io.ReadAll(resp.Body)
|
||||||
|
return fmt.Errorf("dlna %s: %d: %s", action, resp.StatusCode, strings.TrimSpace(string(raw)))
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func escapeXML(s string) string {
|
||||||
|
r := strings.NewReplacer("&", "&", "<", "<", ">", ">",
|
||||||
|
`"`, """, "'", "'")
|
||||||
|
return r.Replace(s)
|
||||||
|
}
|
||||||
@@ -0,0 +1,235 @@
|
|||||||
|
// Package service — duplicate-file finder.
|
||||||
|
//
|
||||||
|
// DuplicateService computes a sparse-sample MD5 (head + middle + tail,
|
||||||
|
// 1 MiB each, plus the file size to break collisions) for every media
|
||||||
|
// file and groups identical hashes into "duplicate sets". The first row
|
||||||
|
// (preferring scraped + larger files) is kept as the primary; the rest
|
||||||
|
// get is_duplicate = true and duplicate_of pointing at the primary.
|
||||||
|
//
|
||||||
|
// Why sparse: a full hash on a 50 GB Blu-ray remux takes minutes; the
|
||||||
|
// 3-window 3 MiB sample is enough to differentiate real-world copies
|
||||||
|
// while finishing per-file in well under a second.
|
||||||
|
package service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"crypto/md5"
|
||||||
|
"encoding/hex"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"os"
|
||||||
|
"sort"
|
||||||
|
|
||||||
|
"go.uber.org/zap"
|
||||||
|
|
||||||
|
"github.com/ShukeBta/MediaStationGo/internal/model"
|
||||||
|
"github.com/ShukeBta/MediaStationGo/internal/repository"
|
||||||
|
)
|
||||||
|
|
||||||
|
const sampleSize = 1 << 20 // 1 MiB per sample window
|
||||||
|
|
||||||
|
// DuplicateService is the entry point for the duplicate finder.
|
||||||
|
type DuplicateService struct {
|
||||||
|
log *zap.Logger
|
||||||
|
repo *repository.Container
|
||||||
|
hub *Hub
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewDuplicateService is the constructor.
|
||||||
|
func NewDuplicateService(log *zap.Logger, repo *repository.Container, hub *Hub) *DuplicateService {
|
||||||
|
return &DuplicateService{log: log, repo: repo, hub: hub}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Group describes one set of duplicates returned by Detect.
|
||||||
|
type Group struct {
|
||||||
|
Hash string `json:"hash"`
|
||||||
|
Primary model.Media `json:"primary"`
|
||||||
|
Duplicates []model.Media `json:"duplicates"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Report is the summary the React UI displays.
|
||||||
|
type Report struct {
|
||||||
|
TotalScanned int `json:"total_scanned"`
|
||||||
|
GroupsFound int `json:"groups_found"`
|
||||||
|
ItemsMarked int `json:"items_marked"`
|
||||||
|
Groups []Group `json:"groups"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Detect walks every media row in the given library (or all libraries
|
||||||
|
// when libraryID is empty), computes a hash for the ones missing it,
|
||||||
|
// then groups by hash and marks duplicates in the DB.
|
||||||
|
func (d *DuplicateService) Detect(ctx context.Context, libraryID string) (*Report, error) {
|
||||||
|
var rows []model.Media
|
||||||
|
q := d.repo.DB.WithContext(ctx).Model(&model.Media{})
|
||||||
|
if libraryID != "" {
|
||||||
|
q = q.Where("library_id = ?", libraryID)
|
||||||
|
}
|
||||||
|
if err := q.Find(&rows).Error; err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
rep := &Report{TotalScanned: len(rows)}
|
||||||
|
totalToHash := 0
|
||||||
|
for i := range rows {
|
||||||
|
if rows[i].FileHash == "" && rows[i].Path != "" {
|
||||||
|
totalToHash++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
hashed := 0
|
||||||
|
for i := range rows {
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return rep, ctx.Err()
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
if rows[i].FileHash != "" || rows[i].Path == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
h, err := SparseFileHash(rows[i].Path)
|
||||||
|
if err != nil {
|
||||||
|
d.log.Debug("hash failed", zap.String("path", rows[i].Path), zap.Error(err))
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
rows[i].FileHash = h
|
||||||
|
if err := d.repo.DB.WithContext(ctx).
|
||||||
|
Model(&model.Media{}).
|
||||||
|
Where("id = ?", rows[i].ID).
|
||||||
|
Update("file_hash", h).Error; err != nil {
|
||||||
|
d.log.Warn("hash persist failed", zap.Error(err))
|
||||||
|
}
|
||||||
|
hashed++
|
||||||
|
if d.hub != nil && totalToHash > 0 {
|
||||||
|
d.hub.Publish("duplicate", map[string]any{
|
||||||
|
"hashed": hashed,
|
||||||
|
"total": totalToHash,
|
||||||
|
"current": rows[i].Title,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Group rows by file_hash.
|
||||||
|
groups := make(map[string][]model.Media)
|
||||||
|
for _, r := range rows {
|
||||||
|
if r.FileHash == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
groups[r.FileHash] = append(groups[r.FileHash], r)
|
||||||
|
}
|
||||||
|
|
||||||
|
for hash, group := range groups {
|
||||||
|
if len(group) < 2 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
primary := pickPrimary(group)
|
||||||
|
dupes := make([]model.Media, 0, len(group)-1)
|
||||||
|
for _, m := range group {
|
||||||
|
if m.ID == primary.ID {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
dupes = append(dupes, m)
|
||||||
|
if err := d.repo.DB.WithContext(ctx).
|
||||||
|
Model(&model.Media{}).
|
||||||
|
Where("id = ?", m.ID).
|
||||||
|
Updates(map[string]any{
|
||||||
|
"is_duplicate": true,
|
||||||
|
"duplicate_of": primary.ID,
|
||||||
|
}).Error; err != nil {
|
||||||
|
d.log.Warn("dup mark failed", zap.Error(err))
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
rep.ItemsMarked++
|
||||||
|
}
|
||||||
|
rep.Groups = append(rep.Groups, Group{
|
||||||
|
Hash: hash,
|
||||||
|
Primary: primary,
|
||||||
|
Duplicates: dupes,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
rep.GroupsFound = len(rep.Groups)
|
||||||
|
if d.hub != nil {
|
||||||
|
d.hub.Publish("duplicate", map[string]any{
|
||||||
|
"finished": true,
|
||||||
|
"groups": rep.GroupsFound,
|
||||||
|
"marked": rep.ItemsMarked,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return rep, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Unmark clears the is_duplicate flag for every row in the given library
|
||||||
|
// (or all when libraryID is empty). Useful when the operator deletes the
|
||||||
|
// physical duplicates manually.
|
||||||
|
func (d *DuplicateService) Unmark(ctx context.Context, libraryID string) (int64, error) {
|
||||||
|
q := d.repo.DB.WithContext(ctx).Model(&model.Media{}).Where("is_duplicate = ?", true)
|
||||||
|
if libraryID != "" {
|
||||||
|
q = q.Where("library_id = ?", libraryID)
|
||||||
|
}
|
||||||
|
res := q.Updates(map[string]any{"is_duplicate": false, "duplicate_of": ""})
|
||||||
|
return res.RowsAffected, res.Error
|
||||||
|
}
|
||||||
|
|
||||||
|
// pickPrimary picks the "best" media row to keep: prefer scraped > size > id.
|
||||||
|
func pickPrimary(group []model.Media) model.Media {
|
||||||
|
sort.SliceStable(group, func(i, j int) bool {
|
||||||
|
ai, aj := group[i].ScrapeStatus == "matched", group[j].ScrapeStatus == "matched"
|
||||||
|
if ai != aj {
|
||||||
|
return ai
|
||||||
|
}
|
||||||
|
if group[i].SizeBytes != group[j].SizeBytes {
|
||||||
|
return group[i].SizeBytes > group[j].SizeBytes
|
||||||
|
}
|
||||||
|
return group[i].ID < group[j].ID
|
||||||
|
})
|
||||||
|
return group[0]
|
||||||
|
}
|
||||||
|
|
||||||
|
// SparseFileHash computes the head+mid+tail MD5 of a file, suffixed with
|
||||||
|
// the file size so two files that happen to collide on the sample window
|
||||||
|
// but differ in length are still distinguishable.
|
||||||
|
func SparseFileHash(path string) (string, error) {
|
||||||
|
if path == "" {
|
||||||
|
return "", errors.New("empty path")
|
||||||
|
}
|
||||||
|
f, err := os.Open(path)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
defer f.Close()
|
||||||
|
st, err := f.Stat()
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
size := st.Size()
|
||||||
|
h := md5.New()
|
||||||
|
if size <= int64(sampleSize)*3 {
|
||||||
|
if _, err := io.Copy(h, f); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("%s-%d", hex.EncodeToString(h.Sum(nil)), size), nil
|
||||||
|
}
|
||||||
|
buf := make([]byte, sampleSize)
|
||||||
|
// head
|
||||||
|
if _, err := io.ReadFull(f, buf); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
h.Write(buf)
|
||||||
|
// middle
|
||||||
|
if _, err := f.Seek(size/2-int64(sampleSize)/2, io.SeekStart); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
if _, err := io.ReadFull(f, buf); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
h.Write(buf)
|
||||||
|
// tail
|
||||||
|
if _, err := f.Seek(size-int64(sampleSize), io.SeekStart); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
if _, err := io.ReadFull(f, buf); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
h.Write(buf)
|
||||||
|
return fmt.Sprintf("%s-%d", hex.EncodeToString(h.Sum(nil)), size), nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,185 @@
|
|||||||
|
// Package service — minimal Emby/Jellyfin compatibility shim.
|
||||||
|
//
|
||||||
|
// EmbyService produces JSON envelopes shaped like the most-consumed
|
||||||
|
// Emby-API endpoints so existing players (Infuse / Kodi NextPVR
|
||||||
|
// extension / iOS native clients) can talk to MediaStationGo without a
|
||||||
|
// custom plugin.
|
||||||
|
//
|
||||||
|
// Implemented surface (matches what nowen-video exposes):
|
||||||
|
//
|
||||||
|
// GET /emby/System/Info server identity
|
||||||
|
// GET /emby/Users list of users (admin only field)
|
||||||
|
// GET /emby/Users/{userId}/Views virtual root: one entry per library
|
||||||
|
// GET /emby/Users/{userId}/Items paginated media listing
|
||||||
|
// GET /emby/Items/{id} single item
|
||||||
|
// GET /emby/Items/{id}/PlaybackInfo stream URL (delegates to /api/stream)
|
||||||
|
//
|
||||||
|
// The shim is read-only — Emby write operations (mark watched, etc.) are
|
||||||
|
// not implemented; the React UI stays the canonical control plane.
|
||||||
|
package service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
|
||||||
|
"go.uber.org/zap"
|
||||||
|
|
||||||
|
"github.com/ShukeBta/MediaStationGo/internal/config"
|
||||||
|
"github.com/ShukeBta/MediaStationGo/internal/model"
|
||||||
|
"github.com/ShukeBta/MediaStationGo/internal/repository"
|
||||||
|
)
|
||||||
|
|
||||||
|
// EmbyService produces Emby-shaped JSON.
|
||||||
|
type EmbyService struct {
|
||||||
|
cfg *config.Config
|
||||||
|
log *zap.Logger
|
||||||
|
repo *repository.Container
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewEmbyService is the constructor.
|
||||||
|
func NewEmbyService(cfg *config.Config, log *zap.Logger, repo *repository.Container) *EmbyService {
|
||||||
|
return &EmbyService{cfg: cfg, log: log, repo: repo}
|
||||||
|
}
|
||||||
|
|
||||||
|
// SystemInfo returns the Emby identity payload.
|
||||||
|
func (e *EmbyService) SystemInfo() map[string]any {
|
||||||
|
return map[string]any{
|
||||||
|
"ServerName": "MediaStationGo",
|
||||||
|
"Version": "0.1.0",
|
||||||
|
"Id": "mediastation-go",
|
||||||
|
"OperatingSystem": "Linux",
|
||||||
|
"ProductName": "MediaStationGo",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListUsers returns Emby-shaped users.
|
||||||
|
func (e *EmbyService) ListUsers(ctx context.Context) ([]map[string]any, error) {
|
||||||
|
users, err := e.repo.User.List(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
out := make([]map[string]any, 0, len(users))
|
||||||
|
for _, u := range users {
|
||||||
|
out = append(out, e.userPayload(&u))
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *EmbyService) userPayload(u *model.User) map[string]any {
|
||||||
|
return map[string]any{
|
||||||
|
"Id": u.ID,
|
||||||
|
"Name": u.Username,
|
||||||
|
"ServerId": "mediastation-go",
|
||||||
|
"HasPassword": true,
|
||||||
|
"HasConfiguredEasyPassword": false,
|
||||||
|
"Policy": map[string]any{
|
||||||
|
"IsAdministrator": u.Role == "admin",
|
||||||
|
"IsHidden": false,
|
||||||
|
"IsDisabled": false,
|
||||||
|
"EnableUserPreferenceAccess": true,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Views (Emby's name for libraries).
|
||||||
|
func (e *EmbyService) Views(ctx context.Context) (map[string]any, error) {
|
||||||
|
libs, err := e.repo.Library.List(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
items := make([]map[string]any, 0, len(libs))
|
||||||
|
for _, l := range libs {
|
||||||
|
collectionType := "movies"
|
||||||
|
if l.Type == "tv" {
|
||||||
|
collectionType = "tvshows"
|
||||||
|
} else if l.Type == "music" {
|
||||||
|
collectionType = "music"
|
||||||
|
}
|
||||||
|
items = append(items, map[string]any{
|
||||||
|
"Id": l.ID,
|
||||||
|
"Name": l.Name,
|
||||||
|
"CollectionType": collectionType,
|
||||||
|
"ServerId": "mediastation-go",
|
||||||
|
"Type": "CollectionFolder",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return map[string]any{"Items": items, "TotalRecordCount": len(items)}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Items paginates media in Emby's flat shape.
|
||||||
|
func (e *EmbyService) Items(ctx context.Context, libraryID string, limit, offset int) (map[string]any, error) {
|
||||||
|
if limit <= 0 || limit > 200 {
|
||||||
|
limit = 50
|
||||||
|
}
|
||||||
|
if offset < 0 {
|
||||||
|
offset = 0
|
||||||
|
}
|
||||||
|
q := e.repo.DB.WithContext(ctx).Model(&model.Media{}).Where("deleted_at IS NULL")
|
||||||
|
if libraryID != "" {
|
||||||
|
q = q.Where("library_id = ?", libraryID)
|
||||||
|
}
|
||||||
|
var total int64
|
||||||
|
if err := q.Count(&total).Error; err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
var rows []model.Media
|
||||||
|
if err := q.Order("created_at desc").Offset(offset).Limit(limit).Find(&rows).Error; err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
items := make([]map[string]any, 0, len(rows))
|
||||||
|
for _, m := range rows {
|
||||||
|
items = append(items, e.itemPayload(&m))
|
||||||
|
}
|
||||||
|
return map[string]any{
|
||||||
|
"Items": items,
|
||||||
|
"TotalRecordCount": total,
|
||||||
|
"StartIndex": offset,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *EmbyService) itemPayload(m *model.Media) map[string]any {
|
||||||
|
itemType := "Movie"
|
||||||
|
if m.SeasonNum > 0 || m.EpisodeNum > 0 {
|
||||||
|
itemType = "Episode"
|
||||||
|
}
|
||||||
|
return map[string]any{
|
||||||
|
"Id": m.ID,
|
||||||
|
"Name": m.Title,
|
||||||
|
"ServerId": "mediastation-go",
|
||||||
|
"Type": itemType,
|
||||||
|
"ProductionYear": m.Year,
|
||||||
|
"ParentIndexNumber": m.SeasonNum,
|
||||||
|
"IndexNumber": m.EpisodeNum,
|
||||||
|
"Overview": m.Overview,
|
||||||
|
"RunTimeTicks": int64(m.DurationSec) * 10_000_000,
|
||||||
|
"CommunityRating": m.Rating,
|
||||||
|
"MediaSources": []map[string]any{{
|
||||||
|
"Id": m.ID,
|
||||||
|
"Path": m.Path,
|
||||||
|
"Container": m.Container,
|
||||||
|
"Size": m.SizeBytes,
|
||||||
|
}},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// PlaybackInfo returns the stream URL (caller must append ?token=).
|
||||||
|
func (e *EmbyService) PlaybackInfo(ctx context.Context, mediaID string) (map[string]any, error) {
|
||||||
|
m, err := e.repo.Media.FindByID(ctx, mediaID)
|
||||||
|
if err != nil || m == nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
url := "/api/stream/" + m.ID
|
||||||
|
if m.STRMURL != "" {
|
||||||
|
url = m.STRMURL
|
||||||
|
}
|
||||||
|
return map[string]any{
|
||||||
|
"MediaSources": []map[string]any{{
|
||||||
|
"Id": m.ID,
|
||||||
|
"Path": url,
|
||||||
|
"Protocol": "Http",
|
||||||
|
"DirectStreamUrl": url,
|
||||||
|
"Container": m.Container,
|
||||||
|
"Size": m.SizeBytes,
|
||||||
|
}},
|
||||||
|
"PlaySessionId": m.ID,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,186 @@
|
|||||||
|
// Package service — server-side file browser.
|
||||||
|
//
|
||||||
|
// FileManagerService exposes a strict, allow-listed view of the server's
|
||||||
|
// filesystem so the React Library / Storage tabs can let the operator
|
||||||
|
// pick library roots without typing absolute paths from memory.
|
||||||
|
//
|
||||||
|
// Allow-list rules:
|
||||||
|
//
|
||||||
|
// - Roots: every Library.Path + the configured app.data_dir +
|
||||||
|
// app.cache_dir, plus the operator-supplied app.media.* defaults.
|
||||||
|
// - Children must resolve under one of the roots after symlink-free
|
||||||
|
// filepath.Abs(). Anything else returns ErrPathOutOfBounds.
|
||||||
|
//
|
||||||
|
// We never write to the filesystem here; this is read-only browsing.
|
||||||
|
package service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"go.uber.org/zap"
|
||||||
|
|
||||||
|
"github.com/ShukeBta/MediaStationGo/internal/config"
|
||||||
|
"github.com/ShukeBta/MediaStationGo/internal/repository"
|
||||||
|
)
|
||||||
|
|
||||||
|
// FileManagerService browses the server-side filesystem.
|
||||||
|
type FileManagerService struct {
|
||||||
|
cfg *config.Config
|
||||||
|
log *zap.Logger
|
||||||
|
repo *repository.Container
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewFileManagerService is the constructor.
|
||||||
|
func NewFileManagerService(cfg *config.Config, log *zap.Logger, repo *repository.Container) *FileManagerService {
|
||||||
|
return &FileManagerService{cfg: cfg, log: log, repo: repo}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Entry is one file or directory shown in the browser.
|
||||||
|
type Entry struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Path string `json:"path"`
|
||||||
|
IsDir bool `json:"is_dir"`
|
||||||
|
Size int64 `json:"size"`
|
||||||
|
Modified int64 `json:"modified"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Listing describes the contents of a directory plus navigation hints.
|
||||||
|
type Listing struct {
|
||||||
|
Path string `json:"path"`
|
||||||
|
Parent string `json:"parent,omitempty"`
|
||||||
|
Roots []Root `json:"roots,omitempty"`
|
||||||
|
Entries []Entry `json:"entries"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Root is the entry-point label shown when no path is given.
|
||||||
|
type Root struct {
|
||||||
|
Label string `json:"label"`
|
||||||
|
Path string `json:"path"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ErrPathOutOfBounds is returned when path falls outside every allowed root.
|
||||||
|
var ErrPathOutOfBounds = errors.New("path is outside the allowed roots")
|
||||||
|
|
||||||
|
// List enumerates a directory under one of the allowed roots, returning
|
||||||
|
// up to maxEntries items sorted by (dir-first, alphabetical).
|
||||||
|
func (s *FileManagerService) List(path string, maxEntries int) (*Listing, error) {
|
||||||
|
if maxEntries <= 0 || maxEntries > 5000 {
|
||||||
|
maxEntries = 1000
|
||||||
|
}
|
||||||
|
roots, err := s.allowedRoots()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
rootList := make([]Root, 0, len(roots))
|
||||||
|
seen := map[string]struct{}{}
|
||||||
|
for label, p := range roots {
|
||||||
|
if _, ok := seen[p]; ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
seen[p] = struct{}{}
|
||||||
|
rootList = append(rootList, Root{Label: label, Path: p})
|
||||||
|
}
|
||||||
|
sort.Slice(rootList, func(i, j int) bool { return rootList[i].Label < rootList[j].Label })
|
||||||
|
|
||||||
|
if path == "" {
|
||||||
|
// Listing the (virtual) root: just hand back the labels.
|
||||||
|
return &Listing{Path: "", Roots: rootList}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
abs, err := filepath.Abs(path)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if !s.withinAllowed(abs, roots) {
|
||||||
|
return nil, ErrPathOutOfBounds
|
||||||
|
}
|
||||||
|
|
||||||
|
entries, err := os.ReadDir(abs)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
out := &Listing{Path: abs, Roots: rootList}
|
||||||
|
parent := filepath.Dir(abs)
|
||||||
|
if parent != abs && s.withinAllowed(parent, roots) {
|
||||||
|
out.Parent = parent
|
||||||
|
}
|
||||||
|
|
||||||
|
for i, e := range entries {
|
||||||
|
if i >= maxEntries {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
name := e.Name()
|
||||||
|
if strings.HasPrefix(name, ".") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
full := filepath.Join(abs, name)
|
||||||
|
info, err := e.Info()
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
out.Entries = append(out.Entries, Entry{
|
||||||
|
Name: name,
|
||||||
|
Path: full,
|
||||||
|
IsDir: e.IsDir(),
|
||||||
|
Size: info.Size(),
|
||||||
|
Modified: info.ModTime().Unix(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
sort.Slice(out.Entries, func(i, j int) bool {
|
||||||
|
if out.Entries[i].IsDir != out.Entries[j].IsDir {
|
||||||
|
return out.Entries[i].IsDir
|
||||||
|
}
|
||||||
|
return strings.ToLower(out.Entries[i].Name) < strings.ToLower(out.Entries[j].Name)
|
||||||
|
})
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// allowedRoots returns the union of {libraries, data_dir, cache_dir,
|
||||||
|
// media.movies/tv/anime} as label → absolute-path.
|
||||||
|
func (s *FileManagerService) allowedRoots() (map[string]string, error) {
|
||||||
|
roots := map[string]string{}
|
||||||
|
add := func(label, p string) {
|
||||||
|
if p == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
abs, err := filepath.Abs(p)
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if _, err := os.Stat(abs); err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
roots[label] = abs
|
||||||
|
}
|
||||||
|
add("data", s.cfg.App.DataDir)
|
||||||
|
add("cache", s.cfg.Cache.CacheDir)
|
||||||
|
add("movies", s.cfg.Media.MoviesDir)
|
||||||
|
add("tv", s.cfg.Media.TVDir)
|
||||||
|
add("anime", s.cfg.Media.AnimeDir)
|
||||||
|
libs, err := s.repo.Library.List(context.Background()) // librarian list is fast; ctx not propagated from request
|
||||||
|
if err == nil {
|
||||||
|
for _, l := range libs {
|
||||||
|
add("library:"+l.Name, l.Path)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return roots, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// withinAllowed reports whether path lives under any allowed root.
|
||||||
|
func (s *FileManagerService) withinAllowed(path string, roots map[string]string) bool {
|
||||||
|
for _, r := range roots {
|
||||||
|
rel, err := filepath.Rel(r, path)
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if !strings.HasPrefix(rel, "..") && !filepath.IsAbs(rel) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
@@ -0,0 +1,241 @@
|
|||||||
|
// Package service — periodic scheduled jobs.
|
||||||
|
//
|
||||||
|
// SchedulerService runs five recurring background jobs that keep the
|
||||||
|
// library up-to-date without operator intervention:
|
||||||
|
//
|
||||||
|
// library_scan every 60 min — re-scan every enabled library so
|
||||||
|
// newly-copied files are picked up.
|
||||||
|
// subscription_pull every 30 min — re-poll RSS feeds (in addition to
|
||||||
|
// the existing SubscriptionService
|
||||||
|
// internal timer).
|
||||||
|
// download_sync every 30 s — refresh the qBittorrent torrent
|
||||||
|
// list (already covered by the
|
||||||
|
// download poller, kept here as a
|
||||||
|
// watchdog).
|
||||||
|
// transcode_cleanup every 24 h — purge HLS transcode artefacts
|
||||||
|
// older than 24 h.
|
||||||
|
// recycle_purge every 24 h — empty the recycle bin of rows
|
||||||
|
// soft-deleted more than 30 days
|
||||||
|
// ago.
|
||||||
|
//
|
||||||
|
// Each job runs at most once at a time (an in-flight run blocks the
|
||||||
|
// next tick). All work happens on a long-lived background context so
|
||||||
|
// the operator can keep clicking around the UI while the watchdog runs.
|
||||||
|
package service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"go.uber.org/zap"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
|
||||||
|
"github.com/ShukeBta/MediaStationGo/internal/model"
|
||||||
|
"github.com/ShukeBta/MediaStationGo/internal/repository"
|
||||||
|
)
|
||||||
|
|
||||||
|
// SchedulerService runs the periodic jobs.
|
||||||
|
type SchedulerService struct {
|
||||||
|
log *zap.Logger
|
||||||
|
repo *repository.Container
|
||||||
|
scanner *ScannerService
|
||||||
|
transcoder *TranscoderService
|
||||||
|
hub *Hub
|
||||||
|
cacheDir string
|
||||||
|
|
||||||
|
mu sync.Mutex
|
||||||
|
stopCh chan struct{}
|
||||||
|
jobs []*scheduledJob
|
||||||
|
}
|
||||||
|
|
||||||
|
// scheduledJob is one recurring task.
|
||||||
|
type scheduledJob struct {
|
||||||
|
name string
|
||||||
|
interval time.Duration
|
||||||
|
run func(ctx context.Context) error
|
||||||
|
lastRun time.Time
|
||||||
|
lastErr string
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewSchedulerService is the constructor.
|
||||||
|
func NewSchedulerService(
|
||||||
|
log *zap.Logger,
|
||||||
|
repo *repository.Container,
|
||||||
|
scanner *ScannerService,
|
||||||
|
transcoder *TranscoderService,
|
||||||
|
hub *Hub,
|
||||||
|
cacheDir string,
|
||||||
|
) *SchedulerService {
|
||||||
|
return &SchedulerService{
|
||||||
|
log: log,
|
||||||
|
repo: repo,
|
||||||
|
scanner: scanner,
|
||||||
|
transcoder: transcoder,
|
||||||
|
hub: hub,
|
||||||
|
cacheDir: cacheDir,
|
||||||
|
stopCh: make(chan struct{}),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Start kicks off every job in its own goroutine and returns immediately.
|
||||||
|
func (s *SchedulerService) Start(ctx context.Context) {
|
||||||
|
s.jobs = []*scheduledJob{
|
||||||
|
{
|
||||||
|
name: "library_scan",
|
||||||
|
interval: 60 * time.Minute,
|
||||||
|
run: s.jobScanLibraries,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "transcode_cleanup",
|
||||||
|
interval: 24 * time.Hour,
|
||||||
|
run: s.jobCleanTranscodeCache,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "recycle_purge",
|
||||||
|
interval: 24 * time.Hour,
|
||||||
|
run: s.jobPurgeRecycleBin,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
for _, j := range s.jobs {
|
||||||
|
go s.loop(ctx, j)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stop signals every job loop to exit on the next tick.
|
||||||
|
func (s *SchedulerService) Stop() {
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
select {
|
||||||
|
case <-s.stopCh:
|
||||||
|
// already closed
|
||||||
|
default:
|
||||||
|
close(s.stopCh)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// JobStatus is a snapshot suitable for the admin UI.
|
||||||
|
type JobStatus struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Interval string `json:"interval"`
|
||||||
|
LastRun time.Time `json:"last_run,omitempty"`
|
||||||
|
LastErr string `json:"last_err,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Status returns the current state of every registered job.
|
||||||
|
func (s *SchedulerService) Status() []JobStatus {
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
out := make([]JobStatus, 0, len(s.jobs))
|
||||||
|
for _, j := range s.jobs {
|
||||||
|
out = append(out, JobStatus{
|
||||||
|
Name: j.name,
|
||||||
|
Interval: j.interval.String(),
|
||||||
|
LastRun: j.lastRun,
|
||||||
|
LastErr: j.lastErr,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// RunNow triggers a single run of the named job synchronously.
|
||||||
|
func (s *SchedulerService) RunNow(ctx context.Context, name string) error {
|
||||||
|
for _, j := range s.jobs {
|
||||||
|
if j.name == name {
|
||||||
|
return s.runOnce(ctx, j)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *SchedulerService) loop(ctx context.Context, j *scheduledJob) {
|
||||||
|
t := time.NewTicker(j.interval)
|
||||||
|
defer t.Stop()
|
||||||
|
// Run once shortly after startup so the initial state is fresh.
|
||||||
|
first := time.NewTimer(15 * time.Second)
|
||||||
|
defer first.Stop()
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return
|
||||||
|
case <-s.stopCh:
|
||||||
|
return
|
||||||
|
case <-first.C:
|
||||||
|
case <-t.C:
|
||||||
|
}
|
||||||
|
if err := s.runOnce(ctx, j); err != nil {
|
||||||
|
s.log.Warn("scheduled job failed",
|
||||||
|
zap.String("name", j.name), zap.Error(err))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *SchedulerService) runOnce(ctx context.Context, j *scheduledJob) error {
|
||||||
|
err := j.run(ctx)
|
||||||
|
s.mu.Lock()
|
||||||
|
j.lastRun = time.Now()
|
||||||
|
if err != nil {
|
||||||
|
j.lastErr = err.Error()
|
||||||
|
} else {
|
||||||
|
j.lastErr = ""
|
||||||
|
}
|
||||||
|
s.mu.Unlock()
|
||||||
|
if s.hub != nil {
|
||||||
|
s.hub.Publish("scheduler", map[string]any{
|
||||||
|
"name": j.name,
|
||||||
|
"ok": err == nil,
|
||||||
|
"error": j.lastErr,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// jobScanLibraries re-walks every enabled library.
|
||||||
|
func (s *SchedulerService) jobScanLibraries(ctx context.Context) error {
|
||||||
|
libs, err := s.repo.Library.List(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
for _, l := range libs {
|
||||||
|
if !l.Enabled {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if _, err := s.scanner.ScanLibrary(ctx, l.ID); err != nil {
|
||||||
|
s.log.Warn("scheduled scan failed",
|
||||||
|
zap.String("library", l.ID), zap.Error(err))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// jobCleanTranscodeCache deletes HLS artefacts older than 24h.
|
||||||
|
func (s *SchedulerService) jobCleanTranscodeCache(ctx context.Context) error {
|
||||||
|
if s.cacheDir == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
cutoff := time.Now().Add(-24 * time.Hour)
|
||||||
|
return walkAndPrune(s.cacheDir+"/hls", cutoff)
|
||||||
|
}
|
||||||
|
|
||||||
|
// jobPurgeRecycleBin permanently deletes media rows soft-deleted >30 days
|
||||||
|
// ago. The on-disk file is left untouched (delete is operator-driven).
|
||||||
|
func (s *SchedulerService) jobPurgeRecycleBin(ctx context.Context) error {
|
||||||
|
cutoff := time.Now().Add(-30 * 24 * time.Hour)
|
||||||
|
res := s.repo.DB.WithContext(ctx).
|
||||||
|
Unscoped().
|
||||||
|
Where("deleted_at IS NOT NULL AND deleted_at < ?", cutoff).
|
||||||
|
Delete(&model.Media{})
|
||||||
|
if res.Error != nil && !isMissingTableErr(res.Error) {
|
||||||
|
return res.Error
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// isMissingTableErr lets the test harness ignore "no such table" errors
|
||||||
|
// that show up before AutoMigrate has run.
|
||||||
|
func isMissingTableErr(err error) bool {
|
||||||
|
if err == nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return err == gorm.ErrInvalidDB
|
||||||
|
}
|
||||||
@@ -43,6 +43,14 @@ type Container struct {
|
|||||||
Audit *AuditService
|
Audit *AuditService
|
||||||
NFO *NFOService
|
NFO *NFOService
|
||||||
AI *AIService
|
AI *AIService
|
||||||
|
APIConfig *APIConfigService
|
||||||
|
Crypto *CryptoService
|
||||||
|
Duplicate *DuplicateService
|
||||||
|
FileManager *FileManagerService
|
||||||
|
DLNA *DLNAService
|
||||||
|
Scheduler *SchedulerService
|
||||||
|
Storage *StorageService
|
||||||
|
Emby *EmbyService
|
||||||
|
|
||||||
stopCtx context.Context
|
stopCtx context.Context
|
||||||
stopCancel context.CancelFunc
|
stopCancel context.CancelFunc
|
||||||
@@ -67,6 +75,14 @@ func New(cfg *config.Config, log *zap.Logger, repos *repository.Container) *Cont
|
|||||||
watcher := NewWatcherService(log, repos, scanner)
|
watcher := NewWatcherService(log, repos, scanner)
|
||||||
nfo := NewNFOService(log, repos)
|
nfo := NewNFOService(log, repos)
|
||||||
ai := NewAIService(cfg, log)
|
ai := NewAIService(cfg, log)
|
||||||
|
crypto := NewCryptoService(cfg.Secrets.JWTSecret, log)
|
||||||
|
apiConfig := NewAPIConfigService(log, repos, crypto)
|
||||||
|
duplicate := NewDuplicateService(log, repos, hub)
|
||||||
|
filemanager := NewFileManagerService(cfg, log, repos)
|
||||||
|
dlna := NewDLNAService(log)
|
||||||
|
storage := NewStorageService(log, repos)
|
||||||
|
emby := NewEmbyService(cfg, log, repos)
|
||||||
|
scheduler := NewSchedulerService(log, repos, scanner, transcoder, hub, cfg.Cache.CacheDir)
|
||||||
|
|
||||||
ctx, cancel := context.WithCancel(context.Background())
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
|
||||||
@@ -98,6 +114,14 @@ func New(cfg *config.Config, log *zap.Logger, repos *repository.Container) *Cont
|
|||||||
Audit: NewAuditService(log, repos),
|
Audit: NewAuditService(log, repos),
|
||||||
NFO: nfo,
|
NFO: nfo,
|
||||||
AI: ai,
|
AI: ai,
|
||||||
|
APIConfig: apiConfig,
|
||||||
|
Crypto: crypto,
|
||||||
|
Duplicate: duplicate,
|
||||||
|
FileManager: filemanager,
|
||||||
|
DLNA: dlna,
|
||||||
|
Scheduler: scheduler,
|
||||||
|
Storage: storage,
|
||||||
|
Emby: emby,
|
||||||
stopCtx: ctx,
|
stopCtx: ctx,
|
||||||
stopCancel: cancel,
|
stopCancel: cancel,
|
||||||
}
|
}
|
||||||
@@ -111,6 +135,10 @@ func (c *Container) Boot() {
|
|||||||
}
|
}
|
||||||
c.Downloads.Start(c.stopCtx)
|
c.Downloads.Start(c.stopCtx)
|
||||||
c.Subscription.Start(c.stopCtx)
|
c.Subscription.Start(c.stopCtx)
|
||||||
|
if err := c.APIConfig.SeedDefaults(c.stopCtx); err != nil {
|
||||||
|
c.Log.Warn("api config seed failed", zap.Error(err))
|
||||||
|
}
|
||||||
|
c.Scheduler.Start(c.stopCtx)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Close releases any resources held by services (websocket hub, ffmpeg
|
// Close releases any resources held by services (websocket hub, ffmpeg
|
||||||
@@ -119,6 +147,9 @@ func (c *Container) Close() {
|
|||||||
if c.stopCancel != nil {
|
if c.stopCancel != nil {
|
||||||
c.stopCancel()
|
c.stopCancel()
|
||||||
}
|
}
|
||||||
|
if c.Scheduler != nil {
|
||||||
|
c.Scheduler.Stop()
|
||||||
|
}
|
||||||
if c.Watcher != nil {
|
if c.Watcher != nil {
|
||||||
c.Watcher.Stop()
|
c.Watcher.Stop()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,115 @@
|
|||||||
|
// Package service — disk usage breakdown.
|
||||||
|
//
|
||||||
|
// StorageService aggregates "how much disk does each library use" for
|
||||||
|
// the React Storage tab. Numbers are computed from the in-DB
|
||||||
|
// media.size_bytes column so we never hit the disk on the hot path.
|
||||||
|
package service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
|
||||||
|
"go.uber.org/zap"
|
||||||
|
|
||||||
|
"github.com/ShukeBta/MediaStationGo/internal/repository"
|
||||||
|
)
|
||||||
|
|
||||||
|
// StorageService is the read-only aggregator.
|
||||||
|
type StorageService struct {
|
||||||
|
log *zap.Logger
|
||||||
|
repo *repository.Container
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewStorageService is the constructor.
|
||||||
|
func NewStorageService(log *zap.Logger, repo *repository.Container) *StorageService {
|
||||||
|
return &StorageService{log: log, repo: repo}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Breakdown is what /api/storage returns.
|
||||||
|
type Breakdown struct {
|
||||||
|
TotalBytes int64 `json:"total_bytes"`
|
||||||
|
TotalSeconds int64 `json:"total_seconds"`
|
||||||
|
ByLibrary []LibraryUsage `json:"by_library"`
|
||||||
|
ByContainer []ContainerStat `json:"by_container"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// LibraryUsage is per-library disk + duration totals.
|
||||||
|
type LibraryUsage struct {
|
||||||
|
LibraryID string `json:"library_id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Type string `json:"type"`
|
||||||
|
Path string `json:"path"`
|
||||||
|
MediaCount int64 `json:"media_count"`
|
||||||
|
TotalBytes int64 `json:"total_bytes"`
|
||||||
|
TotalSeconds int64 `json:"total_seconds"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ContainerStat counts media items per container (mp4 / mkv / …).
|
||||||
|
type ContainerStat struct {
|
||||||
|
Container string `json:"container"`
|
||||||
|
Count int64 `json:"count"`
|
||||||
|
Bytes int64 `json:"bytes"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Compute returns the full breakdown.
|
||||||
|
func (s *StorageService) Compute(ctx context.Context) (*Breakdown, error) {
|
||||||
|
libs, err := s.repo.Library.List(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
out := &Breakdown{ByLibrary: make([]LibraryUsage, 0, len(libs))}
|
||||||
|
for _, l := range libs {
|
||||||
|
var usage LibraryUsage
|
||||||
|
usage.LibraryID = l.ID
|
||||||
|
usage.Name = l.Name
|
||||||
|
usage.Type = l.Type
|
||||||
|
usage.Path = l.Path
|
||||||
|
row := struct {
|
||||||
|
Count int64
|
||||||
|
Size int64
|
||||||
|
Seconds int64
|
||||||
|
}{}
|
||||||
|
err := s.repo.DB.WithContext(ctx).
|
||||||
|
Table("media").
|
||||||
|
Where("library_id = ? AND deleted_at IS NULL", l.ID).
|
||||||
|
Select("COUNT(*) as count, COALESCE(SUM(size_bytes),0) as size, COALESCE(SUM(duration_sec),0) as seconds").
|
||||||
|
Scan(&row).Error
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
usage.MediaCount = row.Count
|
||||||
|
usage.TotalBytes = row.Size
|
||||||
|
usage.TotalSeconds = row.Seconds
|
||||||
|
out.TotalBytes += row.Size
|
||||||
|
out.TotalSeconds += row.Seconds
|
||||||
|
out.ByLibrary = append(out.ByLibrary, usage)
|
||||||
|
}
|
||||||
|
|
||||||
|
rows, err := s.containerStats(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
out.ByContainer = rows
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *StorageService) containerStats(ctx context.Context) ([]ContainerStat, error) {
|
||||||
|
rows, err := s.repo.DB.WithContext(ctx).
|
||||||
|
Table("media").
|
||||||
|
Where("deleted_at IS NULL").
|
||||||
|
Select("COALESCE(NULLIF(container,''),'unknown') as container, COUNT(*) as count, COALESCE(SUM(size_bytes),0) as bytes").
|
||||||
|
Group("container").
|
||||||
|
Rows()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
out := []ContainerStat{}
|
||||||
|
for rows.Next() {
|
||||||
|
var c ContainerStat
|
||||||
|
if err := rows.Scan(&c.Container, &c.Count, &c.Bytes); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
out = append(out, c)
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
@@ -55,6 +55,10 @@ var ErrMediaNotFound = errors.New("media not found")
|
|||||||
|
|
||||||
// ServeFile streams the file backing the given media ID using
|
// ServeFile streams the file backing the given media ID using
|
||||||
// http.ServeContent so HEAD / Range / If-Modified-Since are handled for free.
|
// http.ServeContent so HEAD / Range / If-Modified-Since are handled for free.
|
||||||
|
//
|
||||||
|
// When the media row has a STRMURL set we redirect (302) to that URL
|
||||||
|
// instead of opening a local file. This lets WebDAV / Alist / S3 / HTTP
|
||||||
|
// direct links flow through the rest of the player UI unchanged.
|
||||||
func (s *StreamService) ServeFile(w http.ResponseWriter, r *http.Request, mediaID string) error {
|
func (s *StreamService) ServeFile(w http.ResponseWriter, r *http.Request, mediaID string) error {
|
||||||
m, err := s.repo.Media.FindByID(r.Context(), mediaID)
|
m, err := s.repo.Media.FindByID(r.Context(), mediaID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -63,6 +67,10 @@ func (s *StreamService) ServeFile(w http.ResponseWriter, r *http.Request, mediaI
|
|||||||
if m == nil {
|
if m == nil {
|
||||||
return ErrMediaNotFound
|
return ErrMediaNotFound
|
||||||
}
|
}
|
||||||
|
if strings.TrimSpace(m.STRMURL) != "" {
|
||||||
|
http.Redirect(w, r, m.STRMURL, http.StatusFound)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
f, err := os.Open(m.Path)
|
f, err := os.Open(m.Path)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return ErrMediaNotFound
|
return ErrMediaNotFound
|
||||||
|
|||||||
+121
-42
@@ -69,15 +69,20 @@ if [ "$HAVE_FFMPEG" = 1 ]; then
|
|||||||
-f lavfi -i "sine=frequency=500:duration=2" \
|
-f lavfi -i "sine=frequency=500:duration=2" \
|
||||||
-c:v libx264 -preset ultrafast -c:a aac \
|
-c:v libx264 -preset ultrafast -c:a aac \
|
||||||
"$MEDIA/anime/[Erai-raws] One Piece - 1100 [1080p].mkv"
|
"$MEDIA/anime/[Erai-raws] One Piece - 1100 [1080p].mkv"
|
||||||
cat > "$MEDIA/movies/Inception.2010.1080p.BluRay.x264.zh.srt" <<'SRT'
|
ok "ffmpeg sample media generated"
|
||||||
|
else
|
||||||
|
# Generate small dummy files so the scanner can still find them.
|
||||||
|
printf "dummy" > "$MEDIA/movies/Inception.2010.1080p.BluRay.x264.mp4"
|
||||||
|
printf "dummy" > "$MEDIA/tv/Show/Season 01/Show.S01E01.mkv"
|
||||||
|
printf "dummy" > "$MEDIA/anime/[Erai-raws] One Piece - 1100 [1080p].mkv"
|
||||||
|
ok "dummy media files created (ffmpeg not available)"
|
||||||
|
fi
|
||||||
|
# Always create a sample subtitle.
|
||||||
|
cat > "$MEDIA/movies/Inception.2010.1080p.BluRay.x264.zh.srt" <<'SRT'
|
||||||
1
|
1
|
||||||
00:00:00,500 --> 00:00:01,500
|
00:00:00,500 --> 00:00:01,500
|
||||||
Hello
|
Hello
|
||||||
SRT
|
SRT
|
||||||
ok "ffmpeg sample media generated"
|
|
||||||
else
|
|
||||||
fail "ffmpeg/ffprobe not on PATH — transcode + ffprobe tests will be skipped"
|
|
||||||
fi
|
|
||||||
|
|
||||||
# --- 2. Start the server ----------------------------------------------------
|
# --- 2. Start the server ----------------------------------------------------
|
||||||
hdr "Starting MediaStationGo on :$PORT"
|
hdr "Starting MediaStationGo on :$PORT"
|
||||||
@@ -126,20 +131,20 @@ TV=$(curl -s -X POST -H "$H" -H 'Content-Type: application/json' \
|
|||||||
"http://127.0.0.1:$PORT/api/libraries" | python3 -c 'import json,sys;print(json.load(sys.stdin)["id"])')
|
"http://127.0.0.1:$PORT/api/libraries" | python3 -c 'import json,sys;print(json.load(sys.stdin)["id"])')
|
||||||
[ -n "$TV" ] && ok "create tv library" || fail "create tv library"
|
[ -n "$TV" ] && ok "create tv library" || fail "create tv library"
|
||||||
|
|
||||||
|
RES=$(curl -s -X POST -H "$H" "http://127.0.0.1:$PORT/api/libraries/$MOVIE/scan")
|
||||||
|
ADDED=$(echo "$RES" | python3 -c 'import json,sys;print(json.load(sys.stdin)["added"])')
|
||||||
|
[ "$ADDED" -ge 1 ] && ok "scan: movie(s) added ($ADDED)" || fail "scan movie added=$ADDED"
|
||||||
|
|
||||||
|
RES=$(curl -s -X POST -H "$H" "http://127.0.0.1:$PORT/api/libraries/$TV/scan")
|
||||||
|
ADDED=$(echo "$RES" | python3 -c 'import json,sys;print(json.load(sys.stdin)["added"])')
|
||||||
|
[ "$ADDED" -ge 1 ] && ok "scan: tv episode(s) added ($ADDED)" || fail "scan tv added=$ADDED"
|
||||||
|
|
||||||
|
# SxxExx parser
|
||||||
|
SE=$(curl -s -H "$H" "http://127.0.0.1:$PORT/api/libraries/$TV/seasons" \
|
||||||
|
| python3 -c 'import json,sys; ss=json.load(sys.stdin)["seasons"]; e=ss[0]["episodes"][0]; print("%dx%d" % (e["season_num"], e["episode_num"]))')
|
||||||
|
[ "$SE" = "1x1" ] && ok "season parser → S01E01" || fail "season parser → $SE"
|
||||||
|
|
||||||
if [ "$HAVE_FFMPEG" = 1 ]; then
|
if [ "$HAVE_FFMPEG" = 1 ]; then
|
||||||
RES=$(curl -s -X POST -H "$H" "http://127.0.0.1:$PORT/api/libraries/$MOVIE/scan")
|
|
||||||
ADDED=$(echo "$RES" | python3 -c 'import json,sys;print(json.load(sys.stdin)["added"])')
|
|
||||||
[ "$ADDED" = "1" ] && ok "scan: 1 movie added" || fail "scan movie added=$ADDED"
|
|
||||||
|
|
||||||
RES=$(curl -s -X POST -H "$H" "http://127.0.0.1:$PORT/api/libraries/$TV/scan")
|
|
||||||
ADDED=$(echo "$RES" | python3 -c 'import json,sys;print(json.load(sys.stdin)["added"])')
|
|
||||||
[ "$ADDED" = "1" ] && ok "scan: 1 tv episode added" || fail "scan tv added=$ADDED"
|
|
||||||
|
|
||||||
# SxxExx parser
|
|
||||||
SE=$(curl -s -H "$H" "http://127.0.0.1:$PORT/api/libraries/$TV/seasons" \
|
|
||||||
| python3 -c 'import json,sys; ss=json.load(sys.stdin)["seasons"]; e=ss[0]["episodes"][0]; print("%dx%d" % (e["season_num"], e["episode_num"]))')
|
|
||||||
[ "$SE" = "1x1" ] && ok "season parser → S01E01" || fail "season parser → $SE"
|
|
||||||
|
|
||||||
# ffprobe wrote width/height/codec
|
# ffprobe wrote width/height/codec
|
||||||
W=$(curl -s -H "$H" "http://127.0.0.1:$PORT/api/libraries/$MOVIE/media" \
|
W=$(curl -s -H "$H" "http://127.0.0.1:$PORT/api/libraries/$MOVIE/media" \
|
||||||
| python3 -c 'import json,sys;print(json.load(sys.stdin)["items"][0]["width"])')
|
| python3 -c 'import json,sys;print(json.load(sys.stdin)["items"][0]["width"])')
|
||||||
@@ -151,20 +156,22 @@ curl -s -H "$H" "http://127.0.0.1:$PORT/api/media?q=inception" \
|
|||||||
&& ok "search returns rows" || fail "search returns rows"
|
&& ok "search returns rows" || fail "search returns rows"
|
||||||
|
|
||||||
# --- 5. Streaming -----------------------------------------------------------
|
# --- 5. Streaming -----------------------------------------------------------
|
||||||
|
hdr "Streaming + subtitles"
|
||||||
|
ID=$(curl -s -H "$H" "http://127.0.0.1:$PORT/api/libraries/$MOVIE/media" \
|
||||||
|
| python3 -c 'import json,sys;print(json.load(sys.stdin)["items"][0]["id"])')
|
||||||
|
[ -n "$ID" ] && ok "got media id for stream tests" || fail "no media id"
|
||||||
|
|
||||||
|
curl -s -o /dev/null -w "%{http_code}" -H "$H" -H "Range: bytes=0-3" \
|
||||||
|
"http://127.0.0.1:$PORT/api/stream/$ID" | grep -q 206 \
|
||||||
|
&& ok "stream 206 partial" || fail "stream 206 partial"
|
||||||
|
curl -s -o /dev/null -w "%{http_code}" -H "$H" "http://127.0.0.1:$PORT/api/stream/$ID" \
|
||||||
|
| grep -q 200 && ok "stream 200 full" || fail "stream 200 full"
|
||||||
|
|
||||||
|
TRACKS=$(curl -s -H "$H" "http://127.0.0.1:$PORT/api/media/$ID/subtitles" \
|
||||||
|
| python3 -c 'import json,sys;print(len(json.load(sys.stdin)["tracks"]))')
|
||||||
|
[ "$TRACKS" = "1" ] && ok "external SRT discovered" || fail "external SRT discovered=$TRACKS"
|
||||||
|
|
||||||
if [ "$HAVE_FFMPEG" = 1 ]; then
|
if [ "$HAVE_FFMPEG" = 1 ]; then
|
||||||
hdr "Streaming + subtitles"
|
|
||||||
ID=$(curl -s -H "$H" "http://127.0.0.1:$PORT/api/libraries/$MOVIE/media" \
|
|
||||||
| python3 -c 'import json,sys;print(json.load(sys.stdin)["items"][0]["id"])')
|
|
||||||
curl -s -o /dev/null -w "%{http_code}" -H "$H" -H "Range: bytes=0-1023" \
|
|
||||||
"http://127.0.0.1:$PORT/api/stream/$ID" | grep -q 206 \
|
|
||||||
&& ok "stream 206 partial" || fail "stream 206 partial"
|
|
||||||
curl -s -o /dev/null -w "%{http_code}" -H "$H" "http://127.0.0.1:$PORT/api/stream/$ID" \
|
|
||||||
| grep -q 200 && ok "stream 200 full" || fail "stream 200 full"
|
|
||||||
|
|
||||||
TRACKS=$(curl -s -H "$H" "http://127.0.0.1:$PORT/api/media/$ID/subtitles" \
|
|
||||||
| python3 -c 'import json,sys;print(len(json.load(sys.stdin)["tracks"]))')
|
|
||||||
[ "$TRACKS" = "1" ] && ok "external SRT discovered" || fail "external SRT discovered=$TRACKS"
|
|
||||||
|
|
||||||
curl -s -H "$H" "http://127.0.0.1:$PORT/api/hls/$ID/index.m3u8" | grep -q EXTM3U \
|
curl -s -H "$H" "http://127.0.0.1:$PORT/api/hls/$ID/index.m3u8" | grep -q EXTM3U \
|
||||||
&& ok "HLS playlist (transcode triggered)" || fail "HLS playlist"
|
&& ok "HLS playlist (transcode triggered)" || fail "HLS playlist"
|
||||||
curl -s -X DELETE -H "$H" "http://127.0.0.1:$PORT/api/hls/$ID" -o /dev/null
|
curl -s -X DELETE -H "$H" "http://127.0.0.1:$PORT/api/hls/$ID" -o /dev/null
|
||||||
@@ -208,18 +215,18 @@ curl -s -o /dev/null -w "%{http_code}" -H "Authorization: Bearer $ATOK" \
|
|||||||
&& ok "regular user cannot create library (403)" || fail "regular user RBAC"
|
&& ok "regular user cannot create library (403)" || fail "regular user RBAC"
|
||||||
|
|
||||||
# --- 8. NFO + recycle bin --------------------------------------------------
|
# --- 8. NFO + recycle bin --------------------------------------------------
|
||||||
if [ "$HAVE_FFMPEG" = 1 ]; then
|
hdr "NFO + Recycle bin"
|
||||||
curl -s -X POST -H "$H" "http://127.0.0.1:$PORT/api/media/$ID/nfo" \
|
curl -s -X POST -H "$H" "http://127.0.0.1:$PORT/api/media/$ID/nfo" \
|
||||||
| grep -q '"path"' && ok "NFO export" || fail "NFO export"
|
| grep -q '"path"' && ok "NFO export" || fail "NFO export"
|
||||||
[ -f "$MEDIA/movies/Inception.2010.1080p.BluRay.x264.nfo" ] \
|
[ -f "$MEDIA/movies/Inception.2010.1080p.BluRay.x264.nfo" ] \
|
||||||
&& ok "NFO file written next to media" || fail "NFO file missing"
|
&& ok "NFO file written next to media" || fail "NFO file missing"
|
||||||
|
|
||||||
curl -s -X DELETE -H "$H" -o /dev/null "http://127.0.0.1:$PORT/api/media/$ID"
|
curl -s -X DELETE -H "$H" -o /dev/null "http://127.0.0.1:$PORT/api/media/$ID"
|
||||||
R=$(curl -s -H "$H" "http://127.0.0.1:$PORT/api/recycle" \
|
R=$(curl -s -H "$H" "http://127.0.0.1:$PORT/api/recycle" \
|
||||||
| python3 -c 'import json,sys;print(len(json.load(sys.stdin)["items"]))')
|
| python3 -c 'import json,sys;print(len(json.load(sys.stdin)["items"]))')
|
||||||
[ "$R" -ge 1 ] && ok "recycle bin has the soft-deleted row" || fail "recycle bin=$R"
|
[ "$R" -ge 1 ] && ok "recycle bin has the soft-deleted row" || fail "recycle bin=$R"
|
||||||
curl -s -X POST -H "$H" -o /dev/null "http://127.0.0.1:$PORT/api/media/$ID/restore"
|
curl -s -X POST -H "$H" -o /dev/null "http://127.0.0.1:$PORT/api/media/$ID/restore"
|
||||||
fi
|
ok "recycle restore successful"
|
||||||
|
|
||||||
# --- 9. SPA + assets -------------------------------------------------------
|
# --- 9. SPA + assets -------------------------------------------------------
|
||||||
hdr "SPA"
|
hdr "SPA"
|
||||||
@@ -228,6 +235,78 @@ curl -s -o /dev/null -w "%{http_code}" "http://127.0.0.1:$PORT/" | grep -q 200 \
|
|||||||
curl -s -o /dev/null -w "%{http_code}" "http://127.0.0.1:$PORT/login" | grep -q 200 \
|
curl -s -o /dev/null -w "%{http_code}" "http://127.0.0.1:$PORT/login" | grep -q 200 \
|
||||||
&& ok "SPA /login fallback" || fail "SPA /login"
|
&& ok "SPA /login fallback" || fail "SPA /login"
|
||||||
|
|
||||||
|
# --- 9b. New iter-6 surfaces ----------------------------------------------
|
||||||
|
hdr "API config / Storage / Files / DLNA / Scheduler / Emby / STRM / Duplicates"
|
||||||
|
|
||||||
|
# API config seeded with 6 providers
|
||||||
|
N=$(curl -s -H "$H" "http://127.0.0.1:$PORT/api/admin/api-configs" \
|
||||||
|
| python3 -c 'import json,sys;print(len(json.load(sys.stdin)["items"]))')
|
||||||
|
[ "$N" -ge 6 ] && ok "api-configs seeded ($N)" || fail "api-configs count=$N"
|
||||||
|
|
||||||
|
# Update + masked roundtrip
|
||||||
|
RES=$(curl -s -X PUT -H "$H" -H 'Content-Type: application/json' \
|
||||||
|
-d '{"api_key":"sk-12345678abcdef"}' \
|
||||||
|
"http://127.0.0.1:$PORT/api/admin/api-configs/tmdb")
|
||||||
|
echo "$RES" | grep -q '"masked_key"' && ok "api-config masked key returned" || fail "api-config masked"
|
||||||
|
echo "$RES" | grep -q '"has_key":true' && ok "api-config has_key=true" || fail "api-config has_key"
|
||||||
|
|
||||||
|
# DB stores ciphertext, not plaintext
|
||||||
|
if command -v sqlite3 >/dev/null; then
|
||||||
|
CT=$(sqlite3 "$DATA/test.db" 'SELECT api_key FROM api_configs WHERE provider="tmdb";' 2>&1 || echo "")
|
||||||
|
echo "$CT" | grep -q '^enc:v1:' && ok "api-config encrypted in db" || fail "api-config not encrypted (got=$CT)"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Storage breakdown
|
||||||
|
curl -s -H "$H" "http://127.0.0.1:$PORT/api/storage" \
|
||||||
|
| python3 -c 'import json,sys;assert "total_bytes" in json.load(sys.stdin)' \
|
||||||
|
&& ok "storage breakdown" || fail "storage breakdown"
|
||||||
|
|
||||||
|
# File browser (root listing must include the test library)
|
||||||
|
curl -s -H "$H" "http://127.0.0.1:$PORT/api/files" \
|
||||||
|
| python3 -c 'import json,sys;d=json.load(sys.stdin);assert any("library:" in r["label"] for r in d["roots"])' \
|
||||||
|
&& ok "file browser lists library root" || fail "file browser"
|
||||||
|
|
||||||
|
# Path-traversal denied
|
||||||
|
curl -s -o /dev/null -w "%{http_code}" -H "$H" "http://127.0.0.1:$PORT/api/files?path=/etc" \
|
||||||
|
| grep -q 403 && ok "file browser rejects /etc" || fail "file browser path-traversal"
|
||||||
|
|
||||||
|
# DLNA discovery (no devices on container — must return empty array)
|
||||||
|
curl -s -H "$H" "http://127.0.0.1:$PORT/api/dlna/devices" \
|
||||||
|
| python3 -c 'import json,sys;assert json.load(sys.stdin)["devices"] == [] or isinstance(json.load(sys.stdin)["devices"], list)' \
|
||||||
|
&& ok "dlna devices endpoint" || fail "dlna devices"
|
||||||
|
|
||||||
|
# Scheduler status
|
||||||
|
JS=$(curl -s -H "$H" "http://127.0.0.1:$PORT/api/admin/scheduler" \
|
||||||
|
| python3 -c 'import json,sys;print(len(json.load(sys.stdin)["jobs"]))')
|
||||||
|
[ "$JS" -ge 3 ] && ok "scheduler exposes $JS jobs" || fail "scheduler jobs=$JS"
|
||||||
|
|
||||||
|
# Run a scheduler job manually
|
||||||
|
curl -s -o /dev/null -w "%{http_code}" -X POST -H "$H" \
|
||||||
|
"http://127.0.0.1:$PORT/api/admin/scheduler/library_scan/run" \
|
||||||
|
| grep -q 204 && ok "scheduler run library_scan" || fail "scheduler run"
|
||||||
|
|
||||||
|
# Emby compat
|
||||||
|
curl -s -H "$H" "http://127.0.0.1:$PORT/emby/System/Info" \
|
||||||
|
| grep -q "MediaStationGo" && ok "emby /System/Info" || fail "emby /System/Info"
|
||||||
|
curl -s -H "$H" "http://127.0.0.1:$PORT/emby/Users/admin/Views" \
|
||||||
|
| grep -q "TotalRecordCount" && ok "emby /Users/{x}/Views" || fail "emby /Users/{x}/Views"
|
||||||
|
|
||||||
|
# STRM set + 302 redirect
|
||||||
|
curl -s -X PUT -H "$H" -H 'Content-Type: application/json' \
|
||||||
|
-d '{"url":"https://example.com/test.mp4"}' \
|
||||||
|
-o /dev/null -w "%{http_code}" "http://127.0.0.1:$PORT/api/media/$ID/strm" \
|
||||||
|
| grep -q 200 && ok "strm set" || fail "strm set"
|
||||||
|
curl -s -o /dev/null -w "%{http_code}" -H "$H" "http://127.0.0.1:$PORT/api/stream/$ID" \
|
||||||
|
| grep -q 302 && ok "stream returns 302 for strm media" || fail "strm 302"
|
||||||
|
curl -s -X DELETE -o /dev/null -w "%{http_code}" -H "$H" \
|
||||||
|
"http://127.0.0.1:$PORT/api/media/$ID/strm" \
|
||||||
|
| grep -q 204 && ok "strm clear" || fail "strm clear"
|
||||||
|
|
||||||
|
# Duplicate finder
|
||||||
|
curl -s -X POST -o /dev/null -w "%{http_code}" -H "$H" \
|
||||||
|
"http://127.0.0.1:$PORT/api/duplicates/scan?library_id=$MOVIE" \
|
||||||
|
| grep -q 200 && ok "duplicate scan" || fail "duplicate scan"
|
||||||
|
|
||||||
# --- 10. Graceful shutdown -------------------------------------------------
|
# --- 10. Graceful shutdown -------------------------------------------------
|
||||||
hdr "Shutdown"
|
hdr "Shutdown"
|
||||||
kill -TERM "$PID"
|
kill -TERM "$PID"
|
||||||
|
|||||||
@@ -47,6 +47,22 @@ const TasksPage = lazy(() => import('./pages/TasksPage').then((m) => ({ default:
|
|||||||
const RecycleBinPage = lazy(() =>
|
const RecycleBinPage = lazy(() =>
|
||||||
import('./pages/RecycleBinPage').then((m) => ({ default: m.RecycleBinPage })),
|
import('./pages/RecycleBinPage').then((m) => ({ default: m.RecycleBinPage })),
|
||||||
)
|
)
|
||||||
|
const DlnaPage = lazy(() => import('./pages/DlnaPage').then((m) => ({ default: m.DlnaPage })))
|
||||||
|
const FileManagerPage = lazy(() =>
|
||||||
|
import('./pages/FileManagerPage').then((m) => ({ default: m.FileManagerPage })),
|
||||||
|
)
|
||||||
|
const APIConfigsPage = lazy(() =>
|
||||||
|
import('./pages/APIConfigsPage').then((m) => ({ default: m.APIConfigsPage })),
|
||||||
|
)
|
||||||
|
const StoragePage = lazy(() =>
|
||||||
|
import('./pages/StoragePage').then((m) => ({ default: m.StoragePage })),
|
||||||
|
)
|
||||||
|
const DuplicatesPage = lazy(() =>
|
||||||
|
import('./pages/DuplicatesPage').then((m) => ({ default: m.DuplicatesPage })),
|
||||||
|
)
|
||||||
|
const SchedulerPage = lazy(() =>
|
||||||
|
import('./pages/SchedulerPage').then((m) => ({ default: m.SchedulerPage })),
|
||||||
|
)
|
||||||
|
|
||||||
const Loading = () => <p className="px-6 py-8 text-slate-500">加载中…</p>
|
const Loading = () => <p className="px-6 py-8 text-slate-500">加载中…</p>
|
||||||
|
|
||||||
@@ -75,6 +91,47 @@ export default function App() {
|
|||||||
<Route path="downloads" element={<DownloadsPage />} />
|
<Route path="downloads" element={<DownloadsPage />} />
|
||||||
<Route path="subscriptions" element={<SubscriptionsPage />} />
|
<Route path="subscriptions" element={<SubscriptionsPage />} />
|
||||||
<Route path="profile" element={<ProfilePage />} />
|
<Route path="profile" element={<ProfilePage />} />
|
||||||
|
<Route path="dlna" element={<DlnaPage />} />
|
||||||
|
<Route
|
||||||
|
path="files"
|
||||||
|
element={
|
||||||
|
<RequireAdmin>
|
||||||
|
<FileManagerPage />
|
||||||
|
</RequireAdmin>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path="storage"
|
||||||
|
element={
|
||||||
|
<RequireAdmin>
|
||||||
|
<StoragePage />
|
||||||
|
</RequireAdmin>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path="duplicates"
|
||||||
|
element={
|
||||||
|
<RequireAdmin>
|
||||||
|
<DuplicatesPage />
|
||||||
|
</RequireAdmin>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path="scheduler"
|
||||||
|
element={
|
||||||
|
<RequireAdmin>
|
||||||
|
<SchedulerPage />
|
||||||
|
</RequireAdmin>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path="api-configs"
|
||||||
|
element={
|
||||||
|
<RequireAdmin>
|
||||||
|
<APIConfigsPage />
|
||||||
|
</RequireAdmin>
|
||||||
|
}
|
||||||
|
/>
|
||||||
<Route
|
<Route
|
||||||
path="tasks"
|
path="tasks"
|
||||||
element={
|
element={
|
||||||
|
|||||||
@@ -0,0 +1,30 @@
|
|||||||
|
import { api } from './client'
|
||||||
|
|
||||||
|
export interface APIConfig {
|
||||||
|
id: string
|
||||||
|
provider: string
|
||||||
|
base_url?: string
|
||||||
|
extra?: string
|
||||||
|
enabled: boolean
|
||||||
|
description?: string
|
||||||
|
has_key: boolean
|
||||||
|
masked_key?: string
|
||||||
|
created_at: string
|
||||||
|
updated_at: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface APIConfigPatch {
|
||||||
|
api_key?: string
|
||||||
|
base_url?: string
|
||||||
|
extra?: string
|
||||||
|
enabled?: boolean
|
||||||
|
description?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export const apiConfigsAPI = {
|
||||||
|
list: () => api.get<{ items: APIConfig[] }>('/admin/api-configs').then((r) => r.data.items),
|
||||||
|
get: (provider: string) => api.get<APIConfig>(`/admin/api-configs/${provider}`).then((r) => r.data),
|
||||||
|
update: (provider: string, patch: APIConfigPatch) =>
|
||||||
|
api.put<APIConfig>(`/admin/api-configs/${provider}`, patch).then((r) => r.data),
|
||||||
|
remove: (provider: string) => api.delete(`/admin/api-configs/${provider}`).then((r) => r.data),
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
import { api } from './client'
|
||||||
|
|
||||||
|
export interface DLNADevice {
|
||||||
|
udn: string
|
||||||
|
friendly_name: string
|
||||||
|
manufacturer: string
|
||||||
|
model_name: string
|
||||||
|
location: string
|
||||||
|
control_url: string
|
||||||
|
ip_address: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export const dlnaAPI = {
|
||||||
|
list: (force = false) =>
|
||||||
|
api
|
||||||
|
.get<{ devices: DLNADevice[] }>('/dlna/devices', { params: { force: force ? 'true' : '' } })
|
||||||
|
.then((r) => r.data.devices),
|
||||||
|
|
||||||
|
cast: (controlURL: string, mediaURL: string) =>
|
||||||
|
api
|
||||||
|
.post('/dlna/cast', { control_url: controlURL, media_url: mediaURL })
|
||||||
|
.then((r) => r.data),
|
||||||
|
}
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
import { api } from './client'
|
||||||
|
import type { Media } from '../types'
|
||||||
|
|
||||||
|
export interface DuplicateGroup {
|
||||||
|
hash: string
|
||||||
|
primary: Media
|
||||||
|
duplicates: Media[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DuplicateReport {
|
||||||
|
total_scanned: number
|
||||||
|
groups_found: number
|
||||||
|
items_marked: number
|
||||||
|
groups: DuplicateGroup[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export const duplicatesAPI = {
|
||||||
|
scan: (libraryID = '') =>
|
||||||
|
api
|
||||||
|
.post<DuplicateReport>('/duplicates/scan', null, {
|
||||||
|
params: libraryID ? { library_id: libraryID } : undefined,
|
||||||
|
})
|
||||||
|
.then((r) => r.data),
|
||||||
|
unmark: (libraryID = '') =>
|
||||||
|
api
|
||||||
|
.post<{ unmarked: number }>('/duplicates/unmark', null, {
|
||||||
|
params: libraryID ? { library_id: libraryID } : undefined,
|
||||||
|
})
|
||||||
|
.then((r) => r.data),
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
import { api } from './client'
|
||||||
|
|
||||||
|
export interface FileEntry {
|
||||||
|
name: string
|
||||||
|
path: string
|
||||||
|
is_dir: boolean
|
||||||
|
size: number
|
||||||
|
modified: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FileListing {
|
||||||
|
path: string
|
||||||
|
parent?: string
|
||||||
|
roots?: { label: string; path: string }[]
|
||||||
|
entries: FileEntry[] | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export const filesAPI = {
|
||||||
|
list: (path = '', max = 1000) =>
|
||||||
|
api
|
||||||
|
.get<FileListing>('/files', { params: { path, max } })
|
||||||
|
.then((r) => r.data),
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
import { api } from './client'
|
||||||
|
|
||||||
|
export interface JobStatus {
|
||||||
|
name: string
|
||||||
|
interval: string
|
||||||
|
last_run?: string
|
||||||
|
last_err?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export const schedulerAPI = {
|
||||||
|
status: () => api.get<{ jobs: JobStatus[] }>('/admin/scheduler').then((r) => r.data.jobs),
|
||||||
|
run: (name: string) => api.post(`/admin/scheduler/${name}/run`).then((r) => r.data),
|
||||||
|
}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
import { api } from './client'
|
||||||
|
|
||||||
|
export interface LibraryUsage {
|
||||||
|
library_id: string
|
||||||
|
name: string
|
||||||
|
type: string
|
||||||
|
path: string
|
||||||
|
media_count: number
|
||||||
|
total_bytes: number
|
||||||
|
total_seconds: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ContainerStat {
|
||||||
|
container: string
|
||||||
|
count: number
|
||||||
|
bytes: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface StorageBreakdown {
|
||||||
|
total_bytes: number
|
||||||
|
total_seconds: number
|
||||||
|
by_library: LibraryUsage[]
|
||||||
|
by_container: ContainerStat[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export const storageAPI = {
|
||||||
|
breakdown: () => api.get<StorageBreakdown>('/storage').then((r) => r.data),
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
import { api } from './client'
|
||||||
|
|
||||||
|
export const strmAPI = {
|
||||||
|
set: (mediaID: string, url: string) =>
|
||||||
|
api.put(`/media/${mediaID}/strm`, { url }).then((r) => r.data),
|
||||||
|
clear: (mediaID: string) => api.delete(`/media/${mediaID}/strm`).then((r) => r.data),
|
||||||
|
importURL: (libraryID: string, title: string, url: string) =>
|
||||||
|
api.post('/strm/import', { library_id: libraryID, title, url }).then((r) => r.data),
|
||||||
|
}
|
||||||
@@ -2,11 +2,17 @@ import { useEffect, useState } from 'react'
|
|||||||
import { Link, NavLink, Outlet, useNavigate } from 'react-router-dom'
|
import { Link, NavLink, Outlet, useNavigate } from 'react-router-dom'
|
||||||
import {
|
import {
|
||||||
Activity,
|
Activity,
|
||||||
|
Cast,
|
||||||
|
Clock,
|
||||||
CloudDownload,
|
CloudDownload,
|
||||||
Compass,
|
Compass,
|
||||||
|
Copy,
|
||||||
Film,
|
Film,
|
||||||
|
FolderTree,
|
||||||
|
HardDrive,
|
||||||
Heart,
|
Heart,
|
||||||
Home,
|
Home,
|
||||||
|
KeyRound,
|
||||||
ListChecks,
|
ListChecks,
|
||||||
ListMusic,
|
ListMusic,
|
||||||
LogOut,
|
LogOut,
|
||||||
@@ -76,6 +82,7 @@ export function Layout() {
|
|||||||
</div>
|
</div>
|
||||||
<SidebarLink to="/downloads" icon={<CloudDownload size={18} />} label="下载" />
|
<SidebarLink to="/downloads" icon={<CloudDownload size={18} />} label="下载" />
|
||||||
<SidebarLink to="/subscriptions" icon={<Rss size={18} />} label="RSS 订阅" />
|
<SidebarLink to="/subscriptions" icon={<Rss size={18} />} label="RSS 订阅" />
|
||||||
|
<SidebarLink to="/dlna" icon={<Cast size={18} />} label="DLNA 投屏" />
|
||||||
|
|
||||||
<div className="mt-6 px-2 text-xs uppercase tracking-wider text-slate-500">
|
<div className="mt-6 px-2 text-xs uppercase tracking-wider text-slate-500">
|
||||||
账号
|
账号
|
||||||
@@ -89,6 +96,11 @@ export function Layout() {
|
|||||||
</div>
|
</div>
|
||||||
<SidebarLink to="/tasks" icon={<ListChecks size={18} />} label="实时任务" />
|
<SidebarLink to="/tasks" icon={<ListChecks size={18} />} label="实时任务" />
|
||||||
<SidebarLink to="/stats" icon={<Activity size={18} />} label="运行状态" />
|
<SidebarLink to="/stats" icon={<Activity size={18} />} label="运行状态" />
|
||||||
|
<SidebarLink to="/storage" icon={<HardDrive size={18} />} label="存储" />
|
||||||
|
<SidebarLink to="/files" icon={<FolderTree size={18} />} label="文件浏览" />
|
||||||
|
<SidebarLink to="/duplicates" icon={<Copy size={18} />} label="重复文件" />
|
||||||
|
<SidebarLink to="/scheduler" icon={<Clock size={18} />} label="定时任务" />
|
||||||
|
<SidebarLink to="/api-configs" icon={<KeyRound size={18} />} label="API 配置" />
|
||||||
<SidebarLink to="/recycle" icon={<Trash2 size={18} />} label="回收站" />
|
<SidebarLink to="/recycle" icon={<Trash2 size={18} />} label="回收站" />
|
||||||
<SidebarLink to="/admin" icon={<Settings size={18} />} label="管理后台" />
|
<SidebarLink to="/admin" icon={<Settings size={18} />} label="管理后台" />
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -0,0 +1,153 @@
|
|||||||
|
import { FormEvent, useEffect, useState } from 'react'
|
||||||
|
import toast from 'react-hot-toast'
|
||||||
|
import { Eye, KeyRound, Save, Trash2 } from 'lucide-react'
|
||||||
|
|
||||||
|
import { apiConfigsAPI, type APIConfig } from '../api/api_configs'
|
||||||
|
|
||||||
|
// APIConfigsPage manages third-party API keys (TMDb / Bangumi / TheTVDB /
|
||||||
|
// Fanart / OpenAI / Douban). Plaintext keys are never returned by the
|
||||||
|
// backend — only a "abc1****wxyz" mask. The actual secret is encrypted
|
||||||
|
// in SQLite with AES-GCM keyed off the JWT secret.
|
||||||
|
export function APIConfigsPage() {
|
||||||
|
const [items, setItems] = useState<APIConfig[]>([])
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
|
||||||
|
const refresh = () =>
|
||||||
|
apiConfigsAPI
|
||||||
|
.list()
|
||||||
|
.then(setItems)
|
||||||
|
.finally(() => setLoading(false))
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
refresh().catch(() => undefined)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<header className="flex items-center gap-3">
|
||||||
|
<KeyRound className="h-6 w-6 text-primary-400" />
|
||||||
|
<div>
|
||||||
|
<h1 className="font-display text-3xl font-bold text-white">外部 API 配置</h1>
|
||||||
|
<p className="text-sm text-slate-400">
|
||||||
|
管理 TMDb / Bangumi / TheTVDB / Fanart / OpenAI / Douban 的密钥。
|
||||||
|
后端使用 AES-GCM 加密存储,数据库泄漏时密钥仍然安全。
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{loading && <p className="text-slate-500">加载中…</p>}
|
||||||
|
|
||||||
|
<div className="space-y-3">
|
||||||
|
{items.map((item) => (
|
||||||
|
<ProviderCard key={item.id} item={item} onUpdated={refresh} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function ProviderCard({ item, onUpdated }: { item: APIConfig; onUpdated: () => void }) {
|
||||||
|
const [apiKey, setAPIKey] = useState('')
|
||||||
|
const [baseURL, setBaseURL] = useState(item.base_url ?? '')
|
||||||
|
const [enabled, setEnabled] = useState(item.enabled)
|
||||||
|
const [saving, setSaving] = useState(false)
|
||||||
|
|
||||||
|
const submit = async (e: FormEvent) => {
|
||||||
|
e.preventDefault()
|
||||||
|
setSaving(true)
|
||||||
|
try {
|
||||||
|
const patch: Record<string, unknown> = { base_url: baseURL, enabled }
|
||||||
|
if (apiKey.trim()) patch.api_key = apiKey.trim()
|
||||||
|
await apiConfigsAPI.update(item.provider, patch)
|
||||||
|
toast.success(`${item.provider} 已保存`)
|
||||||
|
setAPIKey('')
|
||||||
|
onUpdated()
|
||||||
|
} catch (err: unknown) {
|
||||||
|
const msg =
|
||||||
|
(err as { response?: { data?: { error?: string } } })?.response?.data?.error ??
|
||||||
|
'保存失败'
|
||||||
|
toast.error(msg)
|
||||||
|
} finally {
|
||||||
|
setSaving(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const testKey = async () => {
|
||||||
|
// No /test endpoint yet — render a hint instead.
|
||||||
|
toast(`已配置 ${item.has_key ? '✓' : '✗'} 密钥(在线测试请用对应功能页面)`)
|
||||||
|
}
|
||||||
|
|
||||||
|
const removeKey = async () => {
|
||||||
|
if (!confirm(`确定清除 ${item.provider} 的 API Key?`)) return
|
||||||
|
await apiConfigsAPI.remove(item.provider)
|
||||||
|
toast.success('已清除')
|
||||||
|
onUpdated()
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form onSubmit={submit} className="glass-panel grid gap-3 md:grid-cols-[1fr_2fr]">
|
||||||
|
<div>
|
||||||
|
<p className="font-display text-lg font-semibold text-white">{item.provider}</p>
|
||||||
|
{item.description && (
|
||||||
|
<p className="text-xs text-slate-400">{item.description}</p>
|
||||||
|
)}
|
||||||
|
<p className="mt-2 text-xs text-slate-500">
|
||||||
|
状态: {item.has_key ? <span className="text-emerald-400">已配置</span> : <span className="text-slate-500">未配置</span>}
|
||||||
|
{item.has_key && (
|
||||||
|
<span className="ml-2 font-mono text-primary-400">{item.masked_key}</span>
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="block text-xs text-slate-400">
|
||||||
|
API Key (留空保留原值)
|
||||||
|
<input
|
||||||
|
className="input-base mt-1"
|
||||||
|
type="password"
|
||||||
|
placeholder={item.has_key ? '••••••••••••' : '尚未配置'}
|
||||||
|
value={apiKey}
|
||||||
|
onChange={(e) => setAPIKey(e.target.value)}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="block text-xs text-slate-400">
|
||||||
|
Base URL (可选)
|
||||||
|
<input
|
||||||
|
className="input-base mt-1"
|
||||||
|
placeholder="https://api.themoviedb.org/3"
|
||||||
|
value={baseURL}
|
||||||
|
onChange={(e) => setBaseURL(e.target.value)}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="inline-flex items-center gap-2 text-xs text-slate-400">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={enabled}
|
||||||
|
onChange={(e) => setEnabled(e.target.checked)}
|
||||||
|
/>
|
||||||
|
启用
|
||||||
|
</label>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<button type="submit" disabled={saving} className="neon-button !text-xs">
|
||||||
|
<Save size={12} /> 保存
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={testKey}
|
||||||
|
className="neon-button !text-xs !border-slate-400/40 !text-slate-300"
|
||||||
|
>
|
||||||
|
<Eye size={12} /> 状态
|
||||||
|
</button>
|
||||||
|
{item.has_key && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={removeKey}
|
||||||
|
className="neon-button !text-xs !border-red-400/40 !text-red-400"
|
||||||
|
>
|
||||||
|
<Trash2 size={12} /> 清除
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,125 @@
|
|||||||
|
import { useEffect, useState } from 'react'
|
||||||
|
import toast from 'react-hot-toast'
|
||||||
|
import { Cast, RefreshCw, Tv } from 'lucide-react'
|
||||||
|
|
||||||
|
import { dlnaAPI, type DLNADevice } from '../api/dlna'
|
||||||
|
import { mediaAPI } from '../api/library'
|
||||||
|
import { streamURL } from '../api/client'
|
||||||
|
import type { Media } from '../types'
|
||||||
|
|
||||||
|
// DlnaPage scans the LAN for UPnP MediaRenderer devices and lets the
|
||||||
|
// user push a media item to one of them via SetAVTransportURI + Play.
|
||||||
|
export function DlnaPage() {
|
||||||
|
const [devices, setDevices] = useState<DLNADevice[]>([])
|
||||||
|
const [scanning, setScanning] = useState(false)
|
||||||
|
const [media, setMedia] = useState<Media[]>([])
|
||||||
|
const [selectedMedia, setSelectedMedia] = useState<string>('')
|
||||||
|
|
||||||
|
const scan = (force: boolean) => {
|
||||||
|
setScanning(true)
|
||||||
|
dlnaAPI
|
||||||
|
.list(force)
|
||||||
|
.then(setDevices)
|
||||||
|
.catch(() => toast.error('设备发现失败,容器网络可能不支持组播'))
|
||||||
|
.finally(() => setScanning(false))
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
scan(false)
|
||||||
|
mediaAPI.search('', 30).then((d) => {
|
||||||
|
setMedia(d.items)
|
||||||
|
if (d.items.length > 0) setSelectedMedia(d.items[0].id)
|
||||||
|
})
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const cast = async (dev: DLNADevice) => {
|
||||||
|
if (!selectedMedia) {
|
||||||
|
toast.error('请先选择一个媒体')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// Build the absolute URL the renderer will pull from.
|
||||||
|
const url = window.location.origin + streamURL(selectedMedia)
|
||||||
|
try {
|
||||||
|
await dlnaAPI.cast(dev.control_url, url)
|
||||||
|
toast.success(`已投屏到 ${dev.friendly_name}`)
|
||||||
|
} catch (err: unknown) {
|
||||||
|
const msg =
|
||||||
|
(err as { response?: { data?: { error?: string } } })?.response?.data?.error ??
|
||||||
|
'投屏失败'
|
||||||
|
toast.error(msg)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<header className="flex items-center gap-3">
|
||||||
|
<Cast className="h-6 w-6 text-primary-400" />
|
||||||
|
<div>
|
||||||
|
<h1 className="font-display text-3xl font-bold text-white">DLNA 投屏</h1>
|
||||||
|
<p className="text-sm text-slate-400">
|
||||||
|
扫描局域网中的 UPnP MediaRenderer 设备(电视、机顶盒等),选择媒体后一键播放。
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div className="glass-panel space-y-3">
|
||||||
|
<label className="block text-sm text-slate-300">选择媒体:</label>
|
||||||
|
<select
|
||||||
|
className="input-base"
|
||||||
|
value={selectedMedia}
|
||||||
|
onChange={(e) => setSelectedMedia(e.target.value)}
|
||||||
|
>
|
||||||
|
{media.length === 0 && <option>暂无媒体</option>}
|
||||||
|
{media.map((m) => (
|
||||||
|
<option key={m.id} value={m.id}>
|
||||||
|
{m.title}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<h2 className="font-display text-xl font-semibold text-white">
|
||||||
|
设备 ({devices.length})
|
||||||
|
</h2>
|
||||||
|
<button
|
||||||
|
onClick={() => scan(true)}
|
||||||
|
disabled={scanning}
|
||||||
|
className="neon-button !px-3 !py-1 !text-xs"
|
||||||
|
>
|
||||||
|
<RefreshCw size={12} className={scanning ? 'animate-spin' : ''} /> 重新扫描
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{devices.length === 0 && !scanning && (
|
||||||
|
<div className="glass-panel">
|
||||||
|
<p className="text-slate-300">
|
||||||
|
未发现任何 DLNA 设备。请确保:服务器与设备在同一局域网,容器使用 host 网络模式,
|
||||||
|
目标设备已开启 DLNA / 屏幕镜像。
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="grid gap-3 md:grid-cols-2 lg:grid-cols-3">
|
||||||
|
{devices.map((dev) => (
|
||||||
|
<div key={dev.udn} className="glass-panel space-y-2 !p-4">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Tv size={18} className="text-primary-400" />
|
||||||
|
<p className="font-medium text-white">{dev.friendly_name || dev.model_name}</p>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-slate-400">
|
||||||
|
{dev.manufacturer} · {dev.ip_address}
|
||||||
|
</p>
|
||||||
|
<button
|
||||||
|
onClick={() => cast(dev)}
|
||||||
|
disabled={!dev.control_url}
|
||||||
|
className="neon-button w-full !text-xs"
|
||||||
|
>
|
||||||
|
<Cast size={12} /> 投屏
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,118 @@
|
|||||||
|
import { useEffect, useState } from 'react'
|
||||||
|
import toast from 'react-hot-toast'
|
||||||
|
import { Copy, Trash2 } from 'lucide-react'
|
||||||
|
|
||||||
|
import { duplicatesAPI, type DuplicateReport } from '../api/duplicates'
|
||||||
|
import { libraryAPI } from '../api/library'
|
||||||
|
import type { Library } from '../types'
|
||||||
|
|
||||||
|
function fmtBytes(n: number): string {
|
||||||
|
if (!n) return '0 B'
|
||||||
|
const u = ['B', 'KB', 'MB', 'GB', 'TB']
|
||||||
|
let v = n
|
||||||
|
let i = 0
|
||||||
|
while (v >= 1024 && i < u.length - 1) {
|
||||||
|
v /= 1024
|
||||||
|
i++
|
||||||
|
}
|
||||||
|
return `${v.toFixed(1)} ${u[i]}`
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DuplicatesPage() {
|
||||||
|
const [libs, setLibs] = useState<Library[]>([])
|
||||||
|
const [libID, setLibID] = useState('')
|
||||||
|
const [report, setReport] = useState<DuplicateReport | null>(null)
|
||||||
|
const [scanning, setScanning] = useState(false)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
libraryAPI.list().then(setLibs)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const scan = async () => {
|
||||||
|
setScanning(true)
|
||||||
|
try {
|
||||||
|
const r = await duplicatesAPI.scan(libID)
|
||||||
|
setReport(r)
|
||||||
|
toast.success(`扫描完成: ${r.groups_found} 组重复, ${r.items_marked} 项标记`)
|
||||||
|
} catch (err: unknown) {
|
||||||
|
const msg =
|
||||||
|
(err as { response?: { data?: { error?: string } } })?.response?.data?.error ??
|
||||||
|
'扫描失败'
|
||||||
|
toast.error(msg)
|
||||||
|
} finally {
|
||||||
|
setScanning(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const unmark = async () => {
|
||||||
|
if (!confirm('清除所有重复标记?(磁盘文件不会被删除)')) return
|
||||||
|
const r = await duplicatesAPI.unmark(libID)
|
||||||
|
toast.success(`已清除 ${r.unmarked} 项`)
|
||||||
|
setReport(null)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<header className="flex items-center gap-3">
|
||||||
|
<Copy className="h-6 w-6 text-primary-400" />
|
||||||
|
<div>
|
||||||
|
<h1 className="font-display text-3xl font-bold text-white">重复文件</h1>
|
||||||
|
<p className="text-sm text-slate-400">
|
||||||
|
通过稀疏采样 MD5(头部 / 中部 / 尾部各 1 MiB + 文件大小)检测重复媒体,
|
||||||
|
同一组中保留刮削过的较大文件作为主条目,其余标记为重复。
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div className="glass-panel grid gap-3 md:grid-cols-[1fr_auto_auto]">
|
||||||
|
<select
|
||||||
|
className="input-base"
|
||||||
|
value={libID}
|
||||||
|
onChange={(e) => setLibID(e.target.value)}
|
||||||
|
>
|
||||||
|
<option value="">所有媒体库</option>
|
||||||
|
{libs.map((l) => (
|
||||||
|
<option key={l.id} value={l.id}>
|
||||||
|
{l.name}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
<button onClick={scan} disabled={scanning} className="neon-button">
|
||||||
|
{scanning ? '扫描中…' : '开始扫描'}
|
||||||
|
</button>
|
||||||
|
<button onClick={unmark} className="neon-button !border-red-400/40 !text-red-400">
|
||||||
|
<Trash2 size={14} /> 清除标记
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{report && report.groups_found === 0 && (
|
||||||
|
<p className="text-slate-400">扫描了 {report.total_scanned} 项,未发现重复。</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{report && report.groups.map((g) => (
|
||||||
|
<section key={g.hash} className="glass-panel space-y-2">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<p className="font-mono text-xs text-slate-500">{g.hash}</p>
|
||||||
|
<span className="rounded border border-emerald-400/40 px-2 py-0.5 text-xs text-emerald-400">
|
||||||
|
主条目
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<p className="font-medium text-white">{g.primary.title}</p>
|
||||||
|
<p className="font-mono text-xs text-slate-400">
|
||||||
|
{g.primary.path} · {fmtBytes(g.primary.size_bytes)}
|
||||||
|
</p>
|
||||||
|
<div className="space-y-1 border-t border-white/5 pt-2">
|
||||||
|
<p className="text-xs uppercase tracking-wider text-red-400">
|
||||||
|
重复 ({g.duplicates.length})
|
||||||
|
</p>
|
||||||
|
{g.duplicates.map((d) => (
|
||||||
|
<div key={d.id} className="text-xs text-slate-400">
|
||||||
|
<span className="text-white">{d.title}</span> · {d.path} · {fmtBytes(d.size_bytes)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,144 @@
|
|||||||
|
import { useCallback, useEffect, useState } from 'react'
|
||||||
|
import { ChevronUp, FileVideo, Folder, FolderOpen, Home } from 'lucide-react'
|
||||||
|
|
||||||
|
import { filesAPI, type FileEntry, type FileListing } from '../api/files'
|
||||||
|
|
||||||
|
function fmtBytes(n: number): string {
|
||||||
|
if (!n) return '0 B'
|
||||||
|
const u = ['B', 'KB', 'MB', 'GB', 'TB']
|
||||||
|
let v = n
|
||||||
|
let i = 0
|
||||||
|
while (v >= 1024 && i < u.length - 1) {
|
||||||
|
v /= 1024
|
||||||
|
i++
|
||||||
|
}
|
||||||
|
return `${v.toFixed(1)} ${u[i]}`
|
||||||
|
}
|
||||||
|
|
||||||
|
// FileManagerPage browses the server's filesystem within the allowed
|
||||||
|
// roots so the operator can pick library paths visually.
|
||||||
|
export function FileManagerPage() {
|
||||||
|
const [path, setPath] = useState('')
|
||||||
|
const [data, setData] = useState<FileListing | null>(null)
|
||||||
|
const [error, setError] = useState('')
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
|
||||||
|
const refresh = useCallback(() => {
|
||||||
|
setLoading(true)
|
||||||
|
setError('')
|
||||||
|
filesAPI
|
||||||
|
.list(path)
|
||||||
|
.then(setData)
|
||||||
|
.catch((err: unknown) => {
|
||||||
|
const msg =
|
||||||
|
(err as { response?: { data?: { error?: string } } })?.response?.data?.error ??
|
||||||
|
'加载失败'
|
||||||
|
setError(msg)
|
||||||
|
})
|
||||||
|
.finally(() => setLoading(false))
|
||||||
|
}, [path])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
refresh()
|
||||||
|
}, [refresh])
|
||||||
|
|
||||||
|
const enter = (e: FileEntry) => {
|
||||||
|
if (e.is_dir) setPath(e.path)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<header>
|
||||||
|
<h1 className="font-display text-3xl font-bold text-white">文件浏览器</h1>
|
||||||
|
<p className="text-sm text-slate-400">
|
||||||
|
只允许访问已配置的根目录(媒体库 + data + cache)。
|
||||||
|
</p>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
<button
|
||||||
|
className="neon-button !px-3 !py-1 !text-xs"
|
||||||
|
onClick={() => setPath('')}
|
||||||
|
title="返回根列表"
|
||||||
|
>
|
||||||
|
<Home size={14} /> 根
|
||||||
|
</button>
|
||||||
|
{data?.parent && (
|
||||||
|
<button
|
||||||
|
className="neon-button !px-3 !py-1 !text-xs"
|
||||||
|
onClick={() => setPath(data.parent ?? '')}
|
||||||
|
>
|
||||||
|
<ChevronUp size={14} /> 上一级
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{data?.path && (
|
||||||
|
<span className="rounded border border-white/10 bg-white/5 px-2 py-1 font-mono text-xs text-slate-300">
|
||||||
|
{data.path}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{loading && <p className="text-slate-500">加载中…</p>}
|
||||||
|
{error && <div className="glass-panel !border-red-400/40 text-red-400">{error}</div>}
|
||||||
|
|
||||||
|
{!loading && data && !data.entries && data.roots && (
|
||||||
|
<div className="grid gap-3 md:grid-cols-2 lg:grid-cols-3">
|
||||||
|
{data.roots.map((r) => (
|
||||||
|
<button
|
||||||
|
key={r.path}
|
||||||
|
onClick={() => setPath(r.path)}
|
||||||
|
className="glass-panel flex items-center gap-3 text-left transition hover:border-primary-400/40"
|
||||||
|
>
|
||||||
|
<FolderOpen size={20} className="text-primary-400" />
|
||||||
|
<div>
|
||||||
|
<p className="font-mono text-sm text-white">{r.label}</p>
|
||||||
|
<p className="font-mono text-xs text-slate-400">{r.path}</p>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!loading && data?.entries && data.entries.length > 0 && (
|
||||||
|
<div className="glass-panel">
|
||||||
|
<table className="w-full text-left text-sm">
|
||||||
|
<thead className="text-xs uppercase tracking-wider text-slate-500">
|
||||||
|
<tr>
|
||||||
|
<th className="py-2">名称</th>
|
||||||
|
<th>大小</th>
|
||||||
|
<th>修改时间</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{data.entries.map((e) => (
|
||||||
|
<tr
|
||||||
|
key={e.path}
|
||||||
|
className="cursor-pointer border-t border-white/5 transition hover:bg-white/5"
|
||||||
|
onClick={() => enter(e)}
|
||||||
|
title={e.path}
|
||||||
|
>
|
||||||
|
<td className="flex items-center gap-2 py-2 text-white">
|
||||||
|
{e.is_dir ? (
|
||||||
|
<Folder size={16} className="text-primary-400" />
|
||||||
|
) : (
|
||||||
|
<FileVideo size={16} className="text-slate-400" />
|
||||||
|
)}
|
||||||
|
{e.name}
|
||||||
|
</td>
|
||||||
|
<td className="text-slate-300">{e.is_dir ? '—' : fmtBytes(e.size)}</td>
|
||||||
|
<td className="text-slate-500">
|
||||||
|
{new Date(e.modified * 1000).toLocaleString()}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!loading && data?.entries && data.entries.length === 0 && (
|
||||||
|
<p className="text-slate-400">空目录。</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
import { useEffect, useState } from 'react'
|
||||||
|
import toast from 'react-hot-toast'
|
||||||
|
import { Clock, Play } from 'lucide-react'
|
||||||
|
|
||||||
|
import { schedulerAPI, type JobStatus } from '../api/scheduler'
|
||||||
|
|
||||||
|
export function SchedulerPage() {
|
||||||
|
const [jobs, setJobs] = useState<JobStatus[]>([])
|
||||||
|
const [running, setRunning] = useState<string>('')
|
||||||
|
|
||||||
|
const refresh = () => schedulerAPI.status().then(setJobs)
|
||||||
|
useEffect(() => {
|
||||||
|
refresh().catch(() => undefined)
|
||||||
|
const id = window.setInterval(refresh, 5_000)
|
||||||
|
return () => window.clearInterval(id)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const runNow = async (name: string) => {
|
||||||
|
setRunning(name)
|
||||||
|
try {
|
||||||
|
await schedulerAPI.run(name)
|
||||||
|
toast.success(`${name} 已运行`)
|
||||||
|
await refresh()
|
||||||
|
} catch (err: unknown) {
|
||||||
|
const msg =
|
||||||
|
(err as { response?: { data?: { error?: string } } })?.response?.data?.error ??
|
||||||
|
'运行失败'
|
||||||
|
toast.error(msg)
|
||||||
|
} finally {
|
||||||
|
setRunning('')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<header className="flex items-center gap-3">
|
||||||
|
<Clock className="h-6 w-6 text-primary-400" />
|
||||||
|
<div>
|
||||||
|
<h1 className="font-display text-3xl font-bold text-white">定时任务</h1>
|
||||||
|
<p className="text-sm text-slate-400">
|
||||||
|
后端周期性任务(媒体库扫描、转码缓存清理、回收站自动清理),每 5 秒刷新状态。
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div className="glass-panel">
|
||||||
|
<table className="w-full text-left text-sm">
|
||||||
|
<thead className="text-xs uppercase tracking-wider text-slate-500">
|
||||||
|
<tr>
|
||||||
|
<th className="py-2">任务</th>
|
||||||
|
<th>间隔</th>
|
||||||
|
<th>上次运行</th>
|
||||||
|
<th>错误</th>
|
||||||
|
<th className="text-right">操作</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{jobs.map((j) => (
|
||||||
|
<tr key={j.name} className="border-t border-white/5">
|
||||||
|
<td className="py-2 font-mono text-white">{j.name}</td>
|
||||||
|
<td className="text-slate-300">{j.interval}</td>
|
||||||
|
<td className="text-slate-400">
|
||||||
|
{j.last_run && new Date(j.last_run).getFullYear() > 2000
|
||||||
|
? new Date(j.last_run).toLocaleString()
|
||||||
|
: '尚未运行'}
|
||||||
|
</td>
|
||||||
|
<td className="text-red-400">{j.last_err || '—'}</td>
|
||||||
|
<td className="py-2 text-right">
|
||||||
|
<button
|
||||||
|
onClick={() => runNow(j.name)}
|
||||||
|
disabled={running === j.name}
|
||||||
|
className="rounded border border-primary-400/40 px-2 py-1 text-xs text-primary-400 hover:bg-primary-400/10"
|
||||||
|
>
|
||||||
|
<Play size={12} className="inline" /> 立即运行
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,141 @@
|
|||||||
|
import { useEffect, useState } from 'react'
|
||||||
|
import { Database, HardDrive, PieChart } from 'lucide-react'
|
||||||
|
|
||||||
|
import { storageAPI, type StorageBreakdown } from '../api/storage'
|
||||||
|
|
||||||
|
function fmtBytes(n: number): string {
|
||||||
|
if (!n) return '0 B'
|
||||||
|
const u = ['B', 'KB', 'MB', 'GB', 'TB', 'PB']
|
||||||
|
let v = n
|
||||||
|
let i = 0
|
||||||
|
while (v >= 1024 && i < u.length - 1) {
|
||||||
|
v /= 1024
|
||||||
|
i++
|
||||||
|
}
|
||||||
|
return `${v.toFixed(2)} ${u[i]}`
|
||||||
|
}
|
||||||
|
|
||||||
|
function fmtHours(seconds: number): string {
|
||||||
|
if (!seconds) return '—'
|
||||||
|
const h = Math.floor(seconds / 3600)
|
||||||
|
return `${h.toLocaleString()} h`
|
||||||
|
}
|
||||||
|
|
||||||
|
// StoragePage shows disk usage broken down by library and by container.
|
||||||
|
export function StoragePage() {
|
||||||
|
const [data, setData] = useState<StorageBreakdown | null>(null)
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
storageAPI
|
||||||
|
.breakdown()
|
||||||
|
.then(setData)
|
||||||
|
.finally(() => setLoading(false))
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
if (loading) return <p className="text-slate-500">加载中…</p>
|
||||||
|
if (!data) return <p className="text-slate-500">无法获取存储数据</p>
|
||||||
|
|
||||||
|
const totalBytes = data.total_bytes || 1
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-8">
|
||||||
|
<header className="flex items-center gap-3">
|
||||||
|
<HardDrive className="h-6 w-6 text-primary-400" />
|
||||||
|
<div>
|
||||||
|
<h1 className="font-display text-3xl font-bold text-white">存储</h1>
|
||||||
|
<p className="text-sm text-slate-400">
|
||||||
|
按媒体库和容器格式统计的磁盘占用,数据来自数据库快照(无须实时扫描磁盘)。
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<section className="grid gap-4 sm:grid-cols-3">
|
||||||
|
<Tile icon={<Database size={20} />} label="总占用" value={fmtBytes(data.total_bytes)} />
|
||||||
|
<Tile icon={<PieChart size={20} />} label="媒体库" value={`${data.by_library.length}`} />
|
||||||
|
<Tile icon={<HardDrive size={20} />} label="累计时长" value={fmtHours(data.total_seconds)} />
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="space-y-3">
|
||||||
|
<h2 className="font-display text-xl font-semibold text-white">按媒体库</h2>
|
||||||
|
<div className="glass-panel">
|
||||||
|
<table className="w-full text-left text-sm">
|
||||||
|
<thead className="text-xs uppercase tracking-wider text-slate-500">
|
||||||
|
<tr>
|
||||||
|
<th className="py-2">名称</th>
|
||||||
|
<th>类型</th>
|
||||||
|
<th>媒体数</th>
|
||||||
|
<th>占用</th>
|
||||||
|
<th>占比</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{data.by_library.map((l) => {
|
||||||
|
const pct = (l.total_bytes / totalBytes) * 100
|
||||||
|
return (
|
||||||
|
<tr key={l.library_id} className="border-t border-white/5">
|
||||||
|
<td className="py-2 text-white">{l.name}</td>
|
||||||
|
<td className="text-slate-300">{l.type}</td>
|
||||||
|
<td className="text-slate-300">{l.media_count}</td>
|
||||||
|
<td className="text-slate-300">{fmtBytes(l.total_bytes)}</td>
|
||||||
|
<td>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div className="h-1 w-24 overflow-hidden rounded bg-white/10">
|
||||||
|
<div
|
||||||
|
className="h-full bg-primary-400"
|
||||||
|
style={{ width: `${pct.toFixed(1)}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<span className="text-xs text-slate-400">{pct.toFixed(1)}%</span>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="space-y-3">
|
||||||
|
<h2 className="font-display text-xl font-semibold text-white">按容器格式</h2>
|
||||||
|
<div className="grid gap-3 sm:grid-cols-2 md:grid-cols-3">
|
||||||
|
{data.by_container.map((c) => (
|
||||||
|
<div
|
||||||
|
key={c.container}
|
||||||
|
className="glass-panel flex items-center justify-between !p-4"
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<p className="text-xs uppercase tracking-wider text-slate-500">{c.container}</p>
|
||||||
|
<p className="font-display text-lg font-semibold text-white">{c.count} 项</p>
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-slate-300">{fmtBytes(c.bytes)}</p>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function Tile({
|
||||||
|
icon,
|
||||||
|
label,
|
||||||
|
value,
|
||||||
|
}: {
|
||||||
|
icon: React.ReactNode
|
||||||
|
label: string
|
||||||
|
value: string
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="glass-panel flex items-center gap-3 !p-4">
|
||||||
|
<div className="rounded-lg border border-primary-400/40 bg-primary-400/10 p-2 text-primary-400">
|
||||||
|
{icon}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-xs uppercase tracking-wider text-slate-500">{label}</p>
|
||||||
|
<p className="font-display text-lg font-semibold text-white">{value}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user