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:
Shuke
2026-05-15 22:27:36 +08:00
committed by GitHub
40 changed files with 3318 additions and 45 deletions
+65
View File
@@ -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)
}
}
+42
View File
@@ -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)
}
}
+34
View File
@@ -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})
}
}
+73
View File
@@ -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)
}
}
+30
View File
@@ -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)
}
}
+42
View File
@@ -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))
}
}
+3 -2
View File
@@ -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)
}
}
+27
View File
@@ -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)
}
}
+21
View File
@@ -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 -1
View File
@@ -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"})
}
+93
View File
@@ -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)
}
}
+36
View File
@@ -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{},
}
}
+231
View File
@@ -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
}
+44
View File
@@ -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
}
+114
View File
@@ -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")
+72
View File
@@ -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)
}
}
}
+277
View File
@@ -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("&", "&amp;", "<", "&lt;", ">", "&gt;",
`"`, "&quot;", "'", "&apos;")
return r.Replace(s)
}
+235
View File
@@ -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
}
+185
View File
@@ -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
}
+186
View File
@@ -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
}
+241
View File
@@ -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
}
+31
View File
@@ -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()
}
+115
View File
@@ -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
}
+8
View File
@@ -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
View File
@@ -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"
+57
View File
@@ -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={
+30
View File
@@ -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),
}
+23
View File
@@ -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),
}
+30
View File
@@ -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),
}
+23
View File
@@ -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),
}
+13
View File
@@ -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),
}
+28
View File
@@ -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),
}
+9
View File
@@ -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),
}
+12
View File
@@ -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="管理后台" />
</>
+153
View File
@@ -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>
)
}
+125
View File
@@ -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>
)
}
+118
View File
@@ -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>
)
}
+144
View File
@@ -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>
)
}
+84
View File
@@ -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>
)
}
+141
View File
@@ -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>
)
}