diff --git a/internal/handler/api_config.go b/internal/handler/api_config.go new file mode 100644 index 0000000..9ebdb46 --- /dev/null +++ b/internal/handler/api_config.go @@ -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) + } +} diff --git a/internal/handler/dlna.go b/internal/handler/dlna.go new file mode 100644 index 0000000..3d3277e --- /dev/null +++ b/internal/handler/dlna.go @@ -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) + } +} diff --git a/internal/handler/duplicate.go b/internal/handler/duplicate.go new file mode 100644 index 0000000..407f672 --- /dev/null +++ b/internal/handler/duplicate.go @@ -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}) + } +} diff --git a/internal/handler/emby.go b/internal/handler/emby.go new file mode 100644 index 0000000..c36f297 --- /dev/null +++ b/internal/handler/emby.go @@ -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) + } +} diff --git a/internal/handler/filemanager.go b/internal/handler/filemanager.go new file mode 100644 index 0000000..334ba49 --- /dev/null +++ b/internal/handler/filemanager.go @@ -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) + } +} diff --git a/internal/handler/handler.go b/internal/handler/handler.go index db9033c..68fad04 100644 --- a/internal/handler/handler.go +++ b/internal/handler/handler.go @@ -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)) } } diff --git a/internal/handler/media.go b/internal/handler/media.go index f0659b7..38c5cd5 100644 --- a/internal/handler/media.go +++ b/internal/handler/media.go @@ -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) } } diff --git a/internal/handler/scheduler.go b/internal/handler/scheduler.go new file mode 100644 index 0000000..6e0e3de --- /dev/null +++ b/internal/handler/scheduler.go @@ -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) + } +} diff --git a/internal/handler/storage.go b/internal/handler/storage.go new file mode 100644 index 0000000..b9a6e12 --- /dev/null +++ b/internal/handler/storage.go @@ -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) + } +} diff --git a/internal/handler/streaming.go b/internal/handler/streaming.go index 9572bc0..c452987 100644 --- a/internal/handler/streaming.go +++ b/internal/handler/streaming.go @@ -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"}) } diff --git a/internal/handler/strm.go b/internal/handler/strm.go new file mode 100644 index 0000000..7e804a9 --- /dev/null +++ b/internal/handler/strm.go @@ -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) + } +} diff --git a/internal/model/model.go b/internal/model/model.go index 835f243..c82588a 100644 --- a/internal/model/model.go +++ b/internal/model/model.go @@ -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: "-". + 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{}, } } diff --git a/internal/service/api_config.go b/internal/service/api_config.go new file mode 100644 index 0000000..fa254f4 --- /dev/null +++ b/internal/service/api_config.go @@ -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 "" 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 == "" { + 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 +} diff --git a/internal/service/cache_cleanup.go b/internal/service/cache_cleanup.go new file mode 100644 index 0000000..bb74895 --- /dev/null +++ b/internal/service/cache_cleanup.go @@ -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 +} diff --git a/internal/service/crypto.go b/internal/service/crypto.go new file mode 100644 index 0000000..513ea0e --- /dev/null +++ b/internal/service/crypto.go @@ -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") diff --git a/internal/service/crypto_test.go b/internal/service/crypto_test.go new file mode 100644 index 0000000..321d560 --- /dev/null +++ b/internal/service/crypto_test.go @@ -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) + } + } +} diff --git a/internal/service/dlna.go b/internal/service/dlna.go new file mode 100644 index 0000000..517bc9c --- /dev/null +++ b/internal/service/dlna.go @@ -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 = ` + + + + 0 + %s + + + +` + +const playTemplate = ` + + + + 0 + 1 + + +` + +// Cast tells the device at controlURL to start playing mediaURL. Returns +// the renderer's HTTP status for diagnostic purposes. +func (d *DLNAService) Cast(ctx context.Context, controlURL, mediaURL string) error { + if controlURL == "" { + return errors.New("device has no AVTransport control URL") + } + if err := d.soap(ctx, controlURL, "SetAVTransportURI", + fmt.Sprintf(soapTemplate, escapeXML(mediaURL))); err != nil { + return err + } + return d.soap(ctx, controlURL, "Play", playTemplate) +} + +// soap POSTs an envelope and returns the parsed faultstring (if any). +func (d *DLNAService) soap(ctx context.Context, controlURL, action, envelope string) error { + req, err := http.NewRequestWithContext(ctx, http.MethodPost, controlURL, + bytes.NewReader([]byte(envelope))) + if err != nil { + return err + } + req.Header.Set("Content-Type", `text/xml; charset="utf-8"`) + req.Header.Set("SOAPAction", + fmt.Sprintf(`"urn:schemas-upnp-org:service:AVTransport:1#%s"`, action)) + resp, err := http.DefaultClient.Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + if resp.StatusCode >= 400 { + raw, _ := io.ReadAll(resp.Body) + return fmt.Errorf("dlna %s: %d: %s", action, resp.StatusCode, strings.TrimSpace(string(raw))) + } + return nil +} + +func escapeXML(s string) string { + r := strings.NewReplacer("&", "&", "<", "<", ">", ">", + `"`, """, "'", "'") + return r.Replace(s) +} diff --git a/internal/service/duplicate.go b/internal/service/duplicate.go new file mode 100644 index 0000000..0efdb38 --- /dev/null +++ b/internal/service/duplicate.go @@ -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 +} diff --git a/internal/service/emby_compat.go b/internal/service/emby_compat.go new file mode 100644 index 0000000..dc0e586 --- /dev/null +++ b/internal/service/emby_compat.go @@ -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 +} diff --git a/internal/service/filemanager.go b/internal/service/filemanager.go new file mode 100644 index 0000000..00847d1 --- /dev/null +++ b/internal/service/filemanager.go @@ -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 +} diff --git a/internal/service/scheduler.go b/internal/service/scheduler.go new file mode 100644 index 0000000..2a2ed14 --- /dev/null +++ b/internal/service/scheduler.go @@ -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 +} diff --git a/internal/service/service.go b/internal/service/service.go index f5b7c1b..c0b5b3d 100644 --- a/internal/service/service.go +++ b/internal/service/service.go @@ -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() } diff --git a/internal/service/storage.go b/internal/service/storage.go new file mode 100644 index 0000000..799c069 --- /dev/null +++ b/internal/service/storage.go @@ -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 +} diff --git a/internal/service/stream.go b/internal/service/stream.go index 8e256e0..ed6ad17 100644 --- a/internal/service/stream.go +++ b/internal/service/stream.go @@ -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 diff --git a/scripts/smoke-test.sh b/scripts/smoke-test.sh index 12507e0..7c04098 100755 --- a/scripts/smoke-test.sh +++ b/scripts/smoke-test.sh @@ -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" diff --git a/web/src/App.tsx b/web/src/App.tsx index cb4cf94..0cd0256 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -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 = () =>

加载中…

@@ -75,6 +91,47 @@ export default function App() { } /> } /> } /> + } /> + + + + } + /> + + + + } + /> + + + + } + /> + + + + } + /> + + + + } + /> api.get<{ items: APIConfig[] }>('/admin/api-configs').then((r) => r.data.items), + get: (provider: string) => api.get(`/admin/api-configs/${provider}`).then((r) => r.data), + update: (provider: string, patch: APIConfigPatch) => + api.put(`/admin/api-configs/${provider}`, patch).then((r) => r.data), + remove: (provider: string) => api.delete(`/admin/api-configs/${provider}`).then((r) => r.data), +} diff --git a/web/src/api/dlna.ts b/web/src/api/dlna.ts new file mode 100644 index 0000000..72ba023 --- /dev/null +++ b/web/src/api/dlna.ts @@ -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), +} diff --git a/web/src/api/duplicates.ts b/web/src/api/duplicates.ts new file mode 100644 index 0000000..3c0f239 --- /dev/null +++ b/web/src/api/duplicates.ts @@ -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('/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), +} diff --git a/web/src/api/files.ts b/web/src/api/files.ts new file mode 100644 index 0000000..45349d5 --- /dev/null +++ b/web/src/api/files.ts @@ -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('/files', { params: { path, max } }) + .then((r) => r.data), +} diff --git a/web/src/api/scheduler.ts b/web/src/api/scheduler.ts new file mode 100644 index 0000000..14ff7c3 --- /dev/null +++ b/web/src/api/scheduler.ts @@ -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), +} diff --git a/web/src/api/storage.ts b/web/src/api/storage.ts new file mode 100644 index 0000000..426c8e6 --- /dev/null +++ b/web/src/api/storage.ts @@ -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('/storage').then((r) => r.data), +} diff --git a/web/src/api/strm.ts b/web/src/api/strm.ts new file mode 100644 index 0000000..313586e --- /dev/null +++ b/web/src/api/strm.ts @@ -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), +} diff --git a/web/src/components/Layout.tsx b/web/src/components/Layout.tsx index da2a1b7..74fff0d 100644 --- a/web/src/components/Layout.tsx +++ b/web/src/components/Layout.tsx @@ -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() { } label="下载" /> } label="RSS 订阅" /> + } label="DLNA 投屏" />
账号 @@ -89,6 +96,11 @@ export function Layout() {
} label="实时任务" /> } label="运行状态" /> + } label="存储" /> + } label="文件浏览" /> + } label="重复文件" /> + } label="定时任务" /> + } label="API 配置" /> } label="回收站" /> } label="管理后台" /> diff --git a/web/src/pages/APIConfigsPage.tsx b/web/src/pages/APIConfigsPage.tsx new file mode 100644 index 0000000..c98aaa6 --- /dev/null +++ b/web/src/pages/APIConfigsPage.tsx @@ -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([]) + const [loading, setLoading] = useState(true) + + const refresh = () => + apiConfigsAPI + .list() + .then(setItems) + .finally(() => setLoading(false)) + + useEffect(() => { + refresh().catch(() => undefined) + }, []) + + return ( +
+
+ +
+

外部 API 配置

+

+ 管理 TMDb / Bangumi / TheTVDB / Fanart / OpenAI / Douban 的密钥。 + 后端使用 AES-GCM 加密存储,数据库泄漏时密钥仍然安全。 +

+
+
+ + {loading &&

加载中…

} + +
+ {items.map((item) => ( + + ))} +
+
+ ) +} + +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 = { 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 ( +
+
+

{item.provider}

+ {item.description && ( +

{item.description}

+ )} +

+ 状态: {item.has_key ? 已配置 : 未配置} + {item.has_key && ( + {item.masked_key} + )} +

+
+
+ + + +
+ + + {item.has_key && ( + + )} +
+
+
+ ) +} diff --git a/web/src/pages/DlnaPage.tsx b/web/src/pages/DlnaPage.tsx new file mode 100644 index 0000000..1829e38 --- /dev/null +++ b/web/src/pages/DlnaPage.tsx @@ -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([]) + const [scanning, setScanning] = useState(false) + const [media, setMedia] = useState([]) + const [selectedMedia, setSelectedMedia] = useState('') + + 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 ( +
+
+ +
+

DLNA 投屏

+

+ 扫描局域网中的 UPnP MediaRenderer 设备(电视、机顶盒等),选择媒体后一键播放。 +

+
+
+ +
+ + +
+ +
+

+ 设备 ({devices.length}) +

+ +
+ + {devices.length === 0 && !scanning && ( +
+

+ 未发现任何 DLNA 设备。请确保:服务器与设备在同一局域网,容器使用 host 网络模式, + 目标设备已开启 DLNA / 屏幕镜像。 +

+
+ )} + +
+ {devices.map((dev) => ( +
+
+ +

{dev.friendly_name || dev.model_name}

+
+

+ {dev.manufacturer} · {dev.ip_address} +

+ +
+ ))} +
+
+ ) +} diff --git a/web/src/pages/DuplicatesPage.tsx b/web/src/pages/DuplicatesPage.tsx new file mode 100644 index 0000000..e424f02 --- /dev/null +++ b/web/src/pages/DuplicatesPage.tsx @@ -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([]) + const [libID, setLibID] = useState('') + const [report, setReport] = useState(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 ( +
+
+ +
+

重复文件

+

+ 通过稀疏采样 MD5(头部 / 中部 / 尾部各 1 MiB + 文件大小)检测重复媒体, + 同一组中保留刮削过的较大文件作为主条目,其余标记为重复。 +

+
+
+ +
+ + + +
+ + {report && report.groups_found === 0 && ( +

扫描了 {report.total_scanned} 项,未发现重复。

+ )} + + {report && report.groups.map((g) => ( +
+
+

{g.hash}

+ + 主条目 + +
+

{g.primary.title}

+

+ {g.primary.path} · {fmtBytes(g.primary.size_bytes)} +

+
+

+ 重复 ({g.duplicates.length}) +

+ {g.duplicates.map((d) => ( +
+ {d.title} · {d.path} · {fmtBytes(d.size_bytes)} +
+ ))} +
+
+ ))} +
+ ) +} diff --git a/web/src/pages/FileManagerPage.tsx b/web/src/pages/FileManagerPage.tsx new file mode 100644 index 0000000..9f2d6dc --- /dev/null +++ b/web/src/pages/FileManagerPage.tsx @@ -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(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 ( +
+
+

文件浏览器

+

+ 只允许访问已配置的根目录(媒体库 + data + cache)。 +

+
+ +
+ + {data?.parent && ( + + )} + {data?.path && ( + + {data.path} + + )} +
+ + {loading &&

加载中…

} + {error &&
{error}
} + + {!loading && data && !data.entries && data.roots && ( +
+ {data.roots.map((r) => ( + + ))} +
+ )} + + {!loading && data?.entries && data.entries.length > 0 && ( +
+ + + + + + + + + + {data.entries.map((e) => ( + enter(e)} + title={e.path} + > + + + + + ))} + +
名称大小修改时间
+ {e.is_dir ? ( + + ) : ( + + )} + {e.name} + {e.is_dir ? '—' : fmtBytes(e.size)} + {new Date(e.modified * 1000).toLocaleString()} +
+
+ )} + + {!loading && data?.entries && data.entries.length === 0 && ( +

空目录。

+ )} +
+ ) +} diff --git a/web/src/pages/SchedulerPage.tsx b/web/src/pages/SchedulerPage.tsx new file mode 100644 index 0000000..ecef77c --- /dev/null +++ b/web/src/pages/SchedulerPage.tsx @@ -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([]) + const [running, setRunning] = useState('') + + 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 ( +
+
+ +
+

定时任务

+

+ 后端周期性任务(媒体库扫描、转码缓存清理、回收站自动清理),每 5 秒刷新状态。 +

+
+
+ +
+ + + + + + + + + + + + {jobs.map((j) => ( + + + + + + + + ))} + +
任务间隔上次运行错误操作
{j.name}{j.interval} + {j.last_run && new Date(j.last_run).getFullYear() > 2000 + ? new Date(j.last_run).toLocaleString() + : '尚未运行'} + {j.last_err || '—'} + +
+
+
+ ) +} diff --git a/web/src/pages/StoragePage.tsx b/web/src/pages/StoragePage.tsx new file mode 100644 index 0000000..c513fae --- /dev/null +++ b/web/src/pages/StoragePage.tsx @@ -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(null) + const [loading, setLoading] = useState(true) + + useEffect(() => { + storageAPI + .breakdown() + .then(setData) + .finally(() => setLoading(false)) + }, []) + + if (loading) return

加载中…

+ if (!data) return

无法获取存储数据

+ + const totalBytes = data.total_bytes || 1 + + return ( +
+
+ +
+

存储

+

+ 按媒体库和容器格式统计的磁盘占用,数据来自数据库快照(无须实时扫描磁盘)。 +

+
+
+ +
+ } label="总占用" value={fmtBytes(data.total_bytes)} /> + } label="媒体库" value={`${data.by_library.length}`} /> + } label="累计时长" value={fmtHours(data.total_seconds)} /> +
+ +
+

按媒体库

+
+ + + + + + + + + + + + {data.by_library.map((l) => { + const pct = (l.total_bytes / totalBytes) * 100 + return ( + + + + + + + + ) + })} + +
名称类型媒体数占用占比
{l.name}{l.type}{l.media_count}{fmtBytes(l.total_bytes)} +
+
+
+
+ {pct.toFixed(1)}% +
+
+
+
+ +
+

按容器格式

+
+ {data.by_container.map((c) => ( +
+
+

{c.container}

+

{c.count} 项

+
+

{fmtBytes(c.bytes)}

+
+ ))} +
+
+
+ ) +} + +function Tile({ + icon, + label, + value, +}: { + icon: React.ReactNode + label: string + value: string +}) { + return ( +
+
+ {icon} +
+
+

{label}

+

{value}

+
+
+ ) +}