mirror of
https://github.com/truewhile/MeBox.git
synced 2026-09-29 03:26:37 +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.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.
|
||||
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.PUT("/settings", updateSettingHandler(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
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net/http"
|
||||
"strconv"
|
||||
@@ -43,7 +44,7 @@ func createLibraryHandler(svc *service.Container) gin.HandlerFunc {
|
||||
uid, _ := c.Get("ctx_user_id")
|
||||
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.
|
||||
go func() { _ = svc.Watcher.Refresh(c.Request.Context()) }()
|
||||
go func() { _ = svc.Watcher.Refresh(context.Background()) }()
|
||||
c.JSON(http.StatusOK, l)
|
||||
}
|
||||
}
|
||||
@@ -57,7 +58,7 @@ func deleteLibraryHandler(svc *service.Container) gin.HandlerFunc {
|
||||
}
|
||||
uid, _ := c.Get("ctx_user_id")
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net/http"
|
||||
|
||||
@@ -82,7 +83,7 @@ func scrapeLibraryHandler(svc *service.Container) gin.HandlerFunc {
|
||||
// Run in the background so HTTP returns instantly; the WS hub
|
||||
// pushes per-item progress on the "scrape" topic.
|
||||
go func(libID string) {
|
||||
_, _ = svc.Scraper.EnrichLibrary(c.Copy().Request.Context(), libID)
|
||||
_, _ = svc.Scraper.EnrichLibrary(context.Background(), libID)
|
||||
}(c.Param("id"))
|
||||
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"`
|
||||
BangumiID int `json:"bangumi_id"`
|
||||
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.
|
||||
@@ -185,5 +220,6 @@ func AllModels() []interface{} {
|
||||
&Subscription{},
|
||||
&Setting{},
|
||||
&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
|
||||
NFO *NFOService
|
||||
AI *AIService
|
||||
APIConfig *APIConfigService
|
||||
Crypto *CryptoService
|
||||
Duplicate *DuplicateService
|
||||
FileManager *FileManagerService
|
||||
DLNA *DLNAService
|
||||
Scheduler *SchedulerService
|
||||
Storage *StorageService
|
||||
Emby *EmbyService
|
||||
|
||||
stopCtx context.Context
|
||||
stopCancel context.CancelFunc
|
||||
@@ -67,6 +75,14 @@ func New(cfg *config.Config, log *zap.Logger, repos *repository.Container) *Cont
|
||||
watcher := NewWatcherService(log, repos, scanner)
|
||||
nfo := NewNFOService(log, repos)
|
||||
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())
|
||||
|
||||
@@ -98,6 +114,14 @@ func New(cfg *config.Config, log *zap.Logger, repos *repository.Container) *Cont
|
||||
Audit: NewAuditService(log, repos),
|
||||
NFO: nfo,
|
||||
AI: ai,
|
||||
APIConfig: apiConfig,
|
||||
Crypto: crypto,
|
||||
Duplicate: duplicate,
|
||||
FileManager: filemanager,
|
||||
DLNA: dlna,
|
||||
Scheduler: scheduler,
|
||||
Storage: storage,
|
||||
Emby: emby,
|
||||
stopCtx: ctx,
|
||||
stopCancel: cancel,
|
||||
}
|
||||
@@ -111,6 +135,10 @@ func (c *Container) Boot() {
|
||||
}
|
||||
c.Downloads.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
|
||||
@@ -119,6 +147,9 @@ func (c *Container) Close() {
|
||||
if c.stopCancel != nil {
|
||||
c.stopCancel()
|
||||
}
|
||||
if c.Scheduler != nil {
|
||||
c.Scheduler.Stop()
|
||||
}
|
||||
if c.Watcher != nil {
|
||||
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
|
||||
// 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 {
|
||||
m, err := s.repo.Media.FindByID(r.Context(), mediaID)
|
||||
if err != nil {
|
||||
@@ -63,6 +67,10 @@ func (s *StreamService) ServeFile(w http.ResponseWriter, r *http.Request, mediaI
|
||||
if m == nil {
|
||||
return ErrMediaNotFound
|
||||
}
|
||||
if strings.TrimSpace(m.STRMURL) != "" {
|
||||
http.Redirect(w, r, m.STRMURL, http.StatusFound)
|
||||
return nil
|
||||
}
|
||||
f, err := os.Open(m.Path)
|
||||
if err != nil {
|
||||
return ErrMediaNotFound
|
||||
|
||||
+121
-42
@@ -69,15 +69,20 @@ if [ "$HAVE_FFMPEG" = 1 ]; then
|
||||
-f lavfi -i "sine=frequency=500:duration=2" \
|
||||
-c:v libx264 -preset ultrafast -c:a aac \
|
||||
"$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
|
||||
00:00:00,500 --> 00:00:01,500
|
||||
Hello
|
||||
SRT
|
||||
ok "ffmpeg sample media generated"
|
||||
else
|
||||
fail "ffmpeg/ffprobe not on PATH — transcode + ffprobe tests will be skipped"
|
||||
fi
|
||||
|
||||
# --- 2. Start the server ----------------------------------------------------
|
||||
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"])')
|
||||
[ -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
|
||||
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
|
||||
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"])')
|
||||
@@ -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"
|
||||
|
||||
# --- 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
|
||||
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 \
|
||||
&& 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
|
||||
@@ -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"
|
||||
|
||||
# --- 8. NFO + recycle bin --------------------------------------------------
|
||||
if [ "$HAVE_FFMPEG" = 1 ]; then
|
||||
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"
|
||||
[ -f "$MEDIA/movies/Inception.2010.1080p.BluRay.x264.nfo" ] \
|
||||
&& ok "NFO file written next to media" || fail "NFO file missing"
|
||||
hdr "NFO + Recycle bin"
|
||||
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"
|
||||
[ -f "$MEDIA/movies/Inception.2010.1080p.BluRay.x264.nfo" ] \
|
||||
&& 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"
|
||||
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"]))')
|
||||
[ "$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"
|
||||
fi
|
||||
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" \
|
||||
| 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"
|
||||
curl -s -X POST -H "$H" -o /dev/null "http://127.0.0.1:$PORT/api/media/$ID/restore"
|
||||
ok "recycle restore successful"
|
||||
|
||||
# --- 9. SPA + assets -------------------------------------------------------
|
||||
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 \
|
||||
&& 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 -------------------------------------------------
|
||||
hdr "Shutdown"
|
||||
kill -TERM "$PID"
|
||||
|
||||
@@ -47,6 +47,22 @@ const TasksPage = lazy(() => import('./pages/TasksPage').then((m) => ({ default:
|
||||
const RecycleBinPage = lazy(() =>
|
||||
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>
|
||||
|
||||
@@ -75,6 +91,47 @@ export default function App() {
|
||||
<Route path="downloads" element={<DownloadsPage />} />
|
||||
<Route path="subscriptions" element={<SubscriptionsPage />} />
|
||||
<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
|
||||
path="tasks"
|
||||
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 {
|
||||
Activity,
|
||||
Cast,
|
||||
Clock,
|
||||
CloudDownload,
|
||||
Compass,
|
||||
Copy,
|
||||
Film,
|
||||
FolderTree,
|
||||
HardDrive,
|
||||
Heart,
|
||||
Home,
|
||||
KeyRound,
|
||||
ListChecks,
|
||||
ListMusic,
|
||||
LogOut,
|
||||
@@ -76,6 +82,7 @@ export function Layout() {
|
||||
</div>
|
||||
<SidebarLink to="/downloads" icon={<CloudDownload size={18} />} label="下载" />
|
||||
<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">
|
||||
账号
|
||||
@@ -89,6 +96,11 @@ export function Layout() {
|
||||
</div>
|
||||
<SidebarLink to="/tasks" icon={<ListChecks 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="/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