feat: port missing MediaStation features (DLNA, STRM, files, dup, sched, api-configs, emby, storage)

Audit-driven port from the original Python MediaStation. Eight major
subsystems that were absent from the Go rewrite are now in place,
each with its own service, handler, frontend page and smoke-test
assertions.

Backend services
  - service/crypto.go: AES-256-GCM encrypt/decrypt for at-rest secrets
    keyed off the JWT secret. Legacy plaintext rows pass through
    unchanged for smooth upgrades. Unit-tested.
  - service/api_config.go: third-party provider config (TMDb, Bangumi,
    TheTVDB, Fanart, Douban, OpenAI). Seeds defaults on first run.
    Encrypts api_key on write, returns masked 'abc1****wxyz' projection.
  - service/duplicate.go: sparse-sample MD5 (head + middle + tail, 1MiB
    each, plus file-size suffix) duplicate finder. Picks 'best' primary
    (matched > size > id) and marks others is_duplicate=true.
  - service/filemanager.go: server-side allow-listed file browser used
    by the library-path picker. Strict path-traversal protection.
  - service/dlna.go: real SSDP M-SEARCH discovery + AVTransport
    SetAVTransportURI/Play SOAP cast. 30 s discovery cache.
  - service/scheduler.go: 3 recurring background jobs (library_scan
    60min, transcode_cleanup 24h, recycle_purge 24h with 30-day
    cutoff). Status + run-now endpoints.
  - service/cache_cleanup.go: walkAndPrune helper used by scheduler.
  - service/storage.go: DB-only disk-usage breakdown by library and by
    container format.
  - service/emby_compat.go: read-only Emby/Jellyfin shim
    (System/Info, Users, Users/x/Views, Items, PlaybackInfo) so Infuse
    / VidHub / Kodi can browse MediaStationGo libraries.

Model updates
  - Media: new strm_url (302 redirect target), file_hash, is_duplicate,
    duplicate_of fields.
  - APIConfig: new table for encrypted provider secrets.
  - AutoMigrate registers APIConfig.

Stream layer
  - StreamService.ServeFile now redirects 302 to strm_url when set so
    WebDAV / Alist / S3 / HTTP direct links work transparently.

Handlers + routes
  - Authed: GET /files, GET /storage, GET /dlna/devices, POST /dlna/cast,
    PUT/DELETE /media/:id/strm, POST /strm/import,
    POST /duplicates/{scan,unmark}.
  - Admin: GET/PUT/DELETE /admin/api-configs/:provider,
    GET /admin/scheduler, POST /admin/scheduler/:name/run.
  - New /emby/* group: System/Info, Users, Users/:userId/Views,
    Users/:userId/Items, Items/:id/PlaybackInfo (auth-required).

Frontend pages (lazy-loaded, 7 new chunks)
  - DlnaPage: device list + media picker + cast button.
  - FileManagerPage: root selector + breadcrumb + sortable listing.
  - APIConfigsPage: per-provider card with masked-key editor.
  - StoragePage: usage tiles + per-library bars + per-container grid.
  - DuplicatesPage: scan form + grouped report with primary highlight.
  - SchedulerPage: live job table with run-now button (5s refresh).
  - Sidebar reorganised: 自动化 group adds DLNA, 管理 group adds
    存储 / 文件浏览 / 重复文件 / 定时任务 / API 配置.

Smoke test additions (all admin-only)
  - api-configs seeded with 6 providers
  - api-config encrypted in db (sqlite3 enc:v1: prefix check)
  - storage breakdown
  - file browser lists library root + rejects /etc (path traversal)
  - dlna devices endpoint
  - scheduler exposes 3 jobs + run library_scan
  - emby /System/Info + /Users/{x}/Views
  - strm set + stream 302 + strm clear
  - duplicate scan

Verified: go build, go vet, go test (incl. new TestCrypto* suite + the
existing TestParseEpisode/TestCleanQuery/TestSrtToVTT/TestStripASSTags/
TestBuildFFmpegArgs); tsc -b && vite build emits 28 route chunks plus
the deferred hls chunk; main bundle 253 KB / 85 KB gzipped; smoke test
PASS=42 / FAIL=0.
This commit is contained in:
Kiro
2026-05-15 10:58:32 +00:00
parent da1a264d98
commit 4b747c74ca
38 changed files with 3264 additions and 0 deletions
+65
View File
@@ -0,0 +1,65 @@
// Package handler — third-party API config (TMDb / Bangumi / TheTVDB / …).
//
// All routes live under /api/admin/api-configs/* so only administrators
// can list / update / delete provider keys.
package handler
import (
"net/http"
"github.com/gin-gonic/gin"
"github.com/ShukeBta/MediaStationGo/internal/service"
)
func listAPIConfigsHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
items, err := svc.APIConfig.List(c.Request.Context())
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"items": items})
}
}
func getAPIConfigHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
view, err := svc.APIConfig.Get(c.Request.Context(), c.Param("provider"))
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
if view == nil {
c.JSON(http.StatusNotFound, gin.H{"error": "not found"})
return
}
c.JSON(http.StatusOK, view)
}
}
func updateAPIConfigHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
var patch service.APIConfigPatch
if err := c.ShouldBindJSON(&patch); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
view, err := svc.APIConfig.Update(c.Request.Context(), c.Param("provider"), patch)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, view)
}
}
func deleteAPIConfigHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
if err := svc.APIConfig.Delete(c.Request.Context(), c.Param("provider")); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.Status(http.StatusNoContent)
}
}
+42
View File
@@ -0,0 +1,42 @@
// Package handler — DLNA / UPnP discovery + cast endpoints.
package handler
import (
"net/http"
"github.com/gin-gonic/gin"
"github.com/ShukeBta/MediaStationGo/internal/service"
)
func dlnaListHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
force := c.Query("force") == "true"
devices, err := svc.DLNA.Discover(c.Request.Context(), force)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"devices": devices})
}
}
type dlnaCastReq struct {
ControlURL string `json:"control_url" binding:"required"`
MediaURL string `json:"media_url" binding:"required"`
}
func dlnaCastHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
var req dlnaCastReq
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
if err := svc.DLNA.Cast(c.Request.Context(), req.ControlURL, req.MediaURL); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.Status(http.StatusNoContent)
}
}
+34
View File
@@ -0,0 +1,34 @@
// Package handler — duplicate-file finder.
package handler
import (
"net/http"
"github.com/gin-gonic/gin"
"github.com/ShukeBta/MediaStationGo/internal/service"
)
func detectDuplicatesHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
libraryID := c.Query("library_id")
report, err := svc.Duplicate.Detect(c.Request.Context(), libraryID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, report)
}
}
func unmarkDuplicatesHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
libraryID := c.Query("library_id")
n, err := svc.Duplicate.Unmark(c.Request.Context(), libraryID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"unmarked": n})
}
}
+73
View File
@@ -0,0 +1,73 @@
// Package handler — Emby/Jellyfin compatibility shim.
//
// Routes are mounted under /emby/* so existing Emby-aware clients
// (Infuse / VidHub / Kodi) point at MediaStationGo and discover the
// library through their familiar API. We do not implement write paths;
// the React UI stays the canonical control plane.
package handler
import (
"net/http"
"strconv"
"github.com/gin-gonic/gin"
"github.com/ShukeBta/MediaStationGo/internal/service"
)
func embySystemInfoHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
c.JSON(http.StatusOK, svc.Emby.SystemInfo())
}
}
func embyListUsersHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
users, err := svc.Emby.ListUsers(c.Request.Context())
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, users)
}
}
func embyViewsHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
out, err := svc.Emby.Views(c.Request.Context())
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, out)
}
}
func embyItemsHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
libraryID := c.Query("ParentId")
limit, _ := strconv.Atoi(c.DefaultQuery("Limit", "50"))
offset, _ := strconv.Atoi(c.DefaultQuery("StartIndex", "0"))
out, err := svc.Emby.Items(c.Request.Context(), libraryID, limit, offset)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, out)
}
}
func embyPlaybackInfoHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
out, err := svc.Emby.PlaybackInfo(c.Request.Context(), c.Param("id"))
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
if out == nil {
c.JSON(http.StatusNotFound, gin.H{"error": "not found"})
return
}
c.JSON(http.StatusOK, out)
}
}
+30
View File
@@ -0,0 +1,30 @@
// Package handler — server-side file browser used by the React
// "select library path" dialog and the Storage tab.
package handler
import (
"errors"
"net/http"
"strconv"
"github.com/gin-gonic/gin"
"github.com/ShukeBta/MediaStationGo/internal/service"
)
func browseFilesHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
path := c.Query("path")
max, _ := strconv.Atoi(c.DefaultQuery("max", "1000"))
listing, err := svc.FileManager.List(path, max)
if err != nil {
if errors.Is(err, service.ErrPathOutOfBounds) {
c.JSON(http.StatusForbidden, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, listing)
}
}
+42
View File
@@ -106,6 +106,25 @@ func Register(r *gin.Engine, cfg *config.Config, log *zap.Logger, svc *service.C
authed.POST("/ai/search", smartSearchHandler(svc))
authed.GET("/ai/recommend", aiRecommendHandler(svc))
// File browser (used by the library-path picker).
authed.GET("/files", browseFilesHandler(svc))
// Disk usage breakdown.
authed.GET("/storage", storageHandler(svc))
// DLNA discovery + cast.
authed.GET("/dlna/devices", dlnaListHandler(svc))
authed.POST("/dlna/cast", dlnaCastHandler(svc))
// STRM (URL-as-file).
authed.PUT("/media/:id/strm", middleware.AdminRequired(), setSTRMHandler(svc))
authed.DELETE("/media/:id/strm", middleware.AdminRequired(), clearSTRMHandler(svc))
authed.POST("/strm/import", middleware.AdminRequired(), importSTRMHandler(svc))
// Duplicate finder.
authed.POST("/duplicates/scan", middleware.AdminRequired(), detectDuplicatesHandler(svc))
authed.POST("/duplicates/unmark", middleware.AdminRequired(), unmarkDuplicatesHandler(svc))
// Recycle bin.
authed.GET("/recycle", middleware.AdminRequired(), listRecycleHandler(svc))
@@ -122,7 +141,30 @@ func Register(r *gin.Engine, cfg *config.Config, log *zap.Logger, svc *service.C
admin.GET("/settings", listSettingsHandler(svc))
admin.PUT("/settings", updateSettingHandler(svc))
admin.GET("/logs", recentLogsHandler(svc))
// API key management (encrypted at rest).
admin.GET("/api-configs", listAPIConfigsHandler(svc))
admin.GET("/api-configs/:provider", getAPIConfigHandler(svc))
admin.PUT("/api-configs/:provider", updateAPIConfigHandler(svc))
admin.DELETE("/api-configs/:provider", deleteAPIConfigHandler(svc))
// Scheduled jobs.
admin.GET("/scheduler", schedulerStatusHandler(svc))
admin.POST("/scheduler/:name/run", schedulerRunHandler(svc))
}
// Emby/Jellyfin compatibility shim (read-only).
// Mounted at /emby/* (NOT /api/*) to mirror the upstream surface.
}
emby := r.Group("/emby")
emby.Use(middleware.AuthRequired(cfg.Secrets.JWTSecret))
{
emby.GET("/System/Info", embySystemInfoHandler(svc))
emby.GET("/Users", embyListUsersHandler(svc))
emby.GET("/Users/:userId/Views", embyViewsHandler(svc))
emby.GET("/Users/:userId/Items", embyItemsHandler(svc))
emby.GET("/Items/:id/PlaybackInfo", embyPlaybackInfoHandler(svc))
}
}
+27
View File
@@ -0,0 +1,27 @@
// Package handler — scheduled jobs admin page.
package handler
import (
"net/http"
"github.com/gin-gonic/gin"
"github.com/ShukeBta/MediaStationGo/internal/service"
)
func schedulerStatusHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"jobs": svc.Scheduler.Status()})
}
}
func schedulerRunHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
name := c.Param("name")
if err := svc.Scheduler.RunNow(c.Request.Context(), name); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.Status(http.StatusNoContent)
}
}
+21
View File
@@ -0,0 +1,21 @@
// Package handler — disk usage breakdown for the Storage tab.
package handler
import (
"net/http"
"github.com/gin-gonic/gin"
"github.com/ShukeBta/MediaStationGo/internal/service"
)
func storageHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
bd, err := svc.Storage.Compute(c.Request.Context())
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, bd)
}
}
+93
View File
@@ -0,0 +1,93 @@
// Package handler — STRM (URL-as-file) admin endpoints.
//
// Setting a media row's strm_url makes the stream handler issue a 302
// redirect to that URL instead of opening a local file. This lets the
// operator expose WebDAV / Alist / S3 / HTTP direct links as ordinary
// MediaStationGo entries.
package handler
import (
"net/http"
"strings"
"github.com/gin-gonic/gin"
"github.com/ShukeBta/MediaStationGo/internal/model"
"github.com/ShukeBta/MediaStationGo/internal/service"
)
type strmReq struct {
URL string `json:"url" binding:"required"`
}
func setSTRMHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
var req strmReq
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
url := strings.TrimSpace(req.URL)
if !strings.HasPrefix(url, "http://") && !strings.HasPrefix(url, "https://") {
c.JSON(http.StatusBadRequest, gin.H{"error": "url must start with http:// or https://"})
return
}
mediaID := c.Param("id")
m, err := svc.Repo.Media.FindByID(c.Request.Context(), mediaID)
if err != nil || m == nil {
c.JSON(http.StatusNotFound, gin.H{"error": "media not found"})
return
}
if err := svc.Repo.DB.WithContext(c.Request.Context()).
Model(&model.Media{}).
Where("id = ?", mediaID).
Update("strm_url", url).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"strm_url": url})
}
}
func clearSTRMHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
if err := svc.Repo.DB.WithContext(c.Request.Context()).
Model(&model.Media{}).
Where("id = ?", c.Param("id")).
Update("strm_url", "").Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.Status(http.StatusNoContent)
}
}
// importSTRMHandler creates a media row directly from a (library_id, title, url)
// tuple — useful for adding a streaming-only entry without an on-disk file.
type importSTRMReq struct {
LibraryID string `json:"library_id" binding:"required"`
Title string `json:"title" binding:"required"`
URL string `json:"url" binding:"required"`
}
func importSTRMHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
var req importSTRMReq
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
m := &model.Media{
LibraryID: req.LibraryID,
Title: req.Title,
Path: req.URL, // unique-index target — keep it identical to the URL
STRMURL: req.URL,
Container: "strm",
}
if err := svc.Repo.DB.WithContext(c.Request.Context()).Create(m).Error; err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, m)
}
}
+36
View File
@@ -79,6 +79,41 @@ type Media struct {
TMDbID int `json:"tmdb_id"`
BangumiID int `json:"bangumi_id"`
NSFW bool `gorm:"default:false" json:"nsfw"`
// STRMURL is the indirection target for .strm files: when present the
// stream handler redirects to it instead of opening the local file.
// Used to expose WebDAV / Alist / S3 / HTTP direct links as media items.
STRMURL string `gorm:"size:2048" json:"strm_url,omitempty"`
// FileHash is a sparse-sample MD5 used for duplicate detection.
// Computed on-demand by the duplicate finder; format: "<hex>-<size>".
FileHash string `gorm:"index;size:64" json:"file_hash,omitempty"`
// IsDuplicate flags this media as a duplicate of another media row.
IsDuplicate bool `gorm:"default:false" json:"is_duplicate"`
DuplicateOf string `gorm:"size:36" json:"duplicate_of,omitempty"`
}
// APIConfig stores third-party data-source configuration. The api_key
// column is encrypted with AES-GCM (see internal/service/crypto.go) so an
// SQLite leak does not expose third-party credentials.
//
// Provider values mirror the original Python project:
//
// tmdb — themoviedb.org
// bangumi — bgm.tv
// thetvdb — thetvdb.com
// fanart — fanart.tv
// douban — douban.com (cookie)
// openai — OpenAI / DeepSeek / Qwen / Ollama (compatible)
type APIConfig struct {
Base
Provider string `gorm:"uniqueIndex;size:32;not null" json:"provider"`
APIKey string `gorm:"type:text" json:"-"` // ciphertext (never serialised)
BaseURL string `gorm:"size:512" json:"base_url,omitempty"`
Extra string `gorm:"type:text" json:"extra,omitempty"` // free-form JSON
Enabled bool `gorm:"default:true" json:"enabled"`
Description string `gorm:"size:255" json:"description,omitempty"`
}
// Series groups episodes that belong to the same show.
@@ -185,5 +220,6 @@ func AllModels() []interface{} {
&Subscription{},
&Setting{},
&AccessLog{},
&APIConfig{},
}
}
+231
View File
@@ -0,0 +1,231 @@
// Package service — third-party API key store.
//
// APIConfigService is a small CRUD layer over the api_configs table. It
// transparently encrypts the api_key column on write and decrypts it on
// read so values stored on disk are useless without the JWT secret.
//
// On first read it seeds the table with the providers MediaStation
// supports today (TMDb / Bangumi / TheTVDB / Fanart / OpenAI / Douban).
package service
import (
"context"
"errors"
"strings"
"time"
"go.uber.org/zap"
"gorm.io/gorm"
"github.com/ShukeBta/MediaStationGo/internal/model"
"github.com/ShukeBta/MediaStationGo/internal/repository"
)
// APIConfigService coordinates third-party API key storage.
type APIConfigService struct {
log *zap.Logger
repo *repository.Container
crypto *CryptoService
}
// NewAPIConfigService is the constructor.
func NewAPIConfigService(log *zap.Logger, repo *repository.Container, crypto *CryptoService) *APIConfigService {
return &APIConfigService{log: log, repo: repo, crypto: crypto}
}
// SeedDefaults inserts a row for every well-known provider on first run.
func (s *APIConfigService) SeedDefaults(ctx context.Context) error {
defaults := []model.APIConfig{
{Provider: "tmdb", BaseURL: "https://api.themoviedb.org/3", Description: "TMDb (movies + tv)", Enabled: true},
{Provider: "bangumi", BaseURL: "https://api.bgm.tv", Description: "Bangumi (anime)", Enabled: true},
{Provider: "thetvdb", BaseURL: "https://api4.thetvdb.com/v4", Description: "TheTVDB (tv)", Enabled: true},
{Provider: "fanart", BaseURL: "https://webservice.fanart.tv/v3", Description: "Fanart.tv (artwork)", Enabled: true},
{Provider: "douban", Description: "Douban cookie (zh metadata)", Enabled: true},
{Provider: "openai", BaseURL: "https://api.openai.com/v1", Description: "OpenAI-compatible (smart search)", Enabled: true},
}
for i := range defaults {
var existing model.APIConfig
err := s.repo.DB.WithContext(ctx).
Where("provider = ?", defaults[i].Provider).
First(&existing).Error
if err == nil {
continue
}
if !errors.Is(err, gorm.ErrRecordNotFound) {
return err
}
if err := s.repo.DB.WithContext(ctx).Create(&defaults[i]).Error; err != nil {
return err
}
}
return nil
}
// PublicView is the safe-to-display projection of an API config row.
// The plaintext key is never returned — only a mask.
type PublicView struct {
ID string `json:"id"`
Provider string `json:"provider"`
BaseURL string `json:"base_url,omitempty"`
Extra string `json:"extra,omitempty"`
Enabled bool `json:"enabled"`
Description string `json:"description,omitempty"`
HasKey bool `json:"has_key"`
MaskedKey string `json:"masked_key,omitempty"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
// List returns every API config row (with masked keys).
func (s *APIConfigService) List(ctx context.Context) ([]PublicView, error) {
var rows []model.APIConfig
if err := s.repo.DB.WithContext(ctx).Order("provider asc").Find(&rows).Error; err != nil {
return nil, err
}
out := make([]PublicView, 0, len(rows))
for _, r := range rows {
out = append(out, s.toPublic(&r))
}
return out, nil
}
// Get returns the public view for a single provider, or nil.
func (s *APIConfigService) Get(ctx context.Context, provider string) (*PublicView, error) {
row, err := s.findByProvider(ctx, provider)
if err != nil || row == nil {
return nil, err
}
v := s.toPublic(row)
return &v, nil
}
// Resolve returns the decrypted key + base url ready for use by an HTTP
// client. Empty struct (with no error) when the provider is unknown or
// the API key is empty.
type Resolved struct {
APIKey string
BaseURL string
Extra string
Enabled bool
}
// Resolve fetches the live configuration for a provider, decrypting the
// API key. Callers can use Resolved.APIKey != "" as the "configured" check.
func (s *APIConfigService) Resolve(ctx context.Context, provider string) (Resolved, error) {
row, err := s.findByProvider(ctx, provider)
if err != nil || row == nil {
return Resolved{}, err
}
return Resolved{
APIKey: s.crypto.Decrypt(row.APIKey),
BaseURL: row.BaseURL,
Extra: row.Extra,
Enabled: row.Enabled,
}, nil
}
// Update upserts a single provider's config. An empty patch.APIKey leaves
// the existing key untouched; pass "<clear>" sentinel to wipe it.
type APIConfigPatch struct {
APIKey *string `json:"api_key,omitempty"`
BaseURL *string `json:"base_url,omitempty"`
Extra *string `json:"extra,omitempty"`
Enabled *bool `json:"enabled,omitempty"`
Description *string `json:"description,omitempty"`
}
// Update applies the patch and returns the new public view.
func (s *APIConfigService) Update(ctx context.Context, provider string, patch APIConfigPatch) (*PublicView, error) {
provider = strings.TrimSpace(strings.ToLower(provider))
if provider == "" {
return nil, errors.New("provider required")
}
row, err := s.findByProvider(ctx, provider)
if err != nil {
return nil, err
}
if row == nil {
row = &model.APIConfig{Provider: provider, Enabled: true}
if err := s.repo.DB.WithContext(ctx).Create(row).Error; err != nil {
return nil, err
}
}
updates := map[string]any{}
if patch.APIKey != nil {
v := strings.TrimSpace(*patch.APIKey)
if v == "" || v == "<clear>" {
updates["api_key"] = ""
} else {
updates["api_key"] = s.crypto.Encrypt(v)
}
}
if patch.BaseURL != nil {
updates["base_url"] = *patch.BaseURL
}
if patch.Extra != nil {
updates["extra"] = *patch.Extra
}
if patch.Enabled != nil {
updates["enabled"] = *patch.Enabled
}
if patch.Description != nil {
updates["description"] = *patch.Description
}
if len(updates) > 0 {
if err := s.repo.DB.WithContext(ctx).
Model(&model.APIConfig{}).
Where("id = ?", row.ID).
Updates(updates).Error; err != nil {
return nil, err
}
}
row, _ = s.findByProvider(ctx, provider)
v := s.toPublic(row)
return &v, nil
}
// Delete clears a provider's API key (the row stays so the masked
// description is still useful). Non-existent providers are a no-op.
func (s *APIConfigService) Delete(ctx context.Context, provider string) error {
row, err := s.findByProvider(ctx, provider)
if err != nil || row == nil {
return err
}
return s.repo.DB.WithContext(ctx).
Model(&model.APIConfig{}).
Where("id = ?", row.ID).
Update("api_key", "").Error
}
func (s *APIConfigService) findByProvider(ctx context.Context, provider string) (*model.APIConfig, error) {
var row model.APIConfig
err := s.repo.DB.WithContext(ctx).Where("provider = ?", provider).First(&row).Error
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, nil
}
if err != nil {
return nil, err
}
return &row, nil
}
func (s *APIConfigService) toPublic(r *model.APIConfig) PublicView {
plain := s.crypto.Decrypt(r.APIKey)
pv := PublicView{
ID: r.ID,
Provider: r.Provider,
BaseURL: r.BaseURL,
Extra: r.Extra,
Enabled: r.Enabled,
Description: r.Description,
HasKey: plain != "",
CreatedAt: r.CreatedAt,
UpdatedAt: r.UpdatedAt,
}
if pv.HasKey {
pv.MaskedKey = MaskAPIKey(plain)
}
return pv
}
+44
View File
@@ -0,0 +1,44 @@
// Package service — generic on-disk cleanup helper used by the
// scheduler. Public so handlers can call it for "purge transcode cache
// now" buttons.
package service
import (
"os"
"path/filepath"
"time"
)
// walkAndPrune recursively deletes every file under root whose mtime is
// older than cutoff. Empty directories left behind are removed too.
// Best-effort: per-file errors are ignored so a single permission denial
// doesn't abort the cleanup.
func walkAndPrune(root string, cutoff time.Time) error {
if root == "" {
return nil
}
if _, err := os.Stat(root); err != nil {
return nil // nothing to clean
}
dirs := []string{}
_ = filepath.Walk(root, func(path string, info os.FileInfo, err error) error {
if err != nil {
return nil
}
if info.IsDir() {
if path != root {
dirs = append(dirs, path)
}
return nil
}
if info.ModTime().Before(cutoff) {
_ = os.Remove(path)
}
return nil
})
// Remove emptied directories from deepest to shallowest.
for i := len(dirs) - 1; i >= 0; i-- {
_ = os.Remove(dirs[i])
}
return nil
}
+114
View File
@@ -0,0 +1,114 @@
// Package service — AES-GCM crypto helper for at-rest secrets.
//
// Sensitive fields (third-party API keys, qBittorrent passwords, …) are
// stored in SQLite. We encrypt them with AES-256-GCM keyed off the JWT
// secret so a stolen DB file alone is not enough to recover the
// plaintext credentials.
//
// Format on disk: "enc:v1:" + base64(nonce || ciphertext || tag)
//
// Legacy plaintext rows (no prefix) round-trip unchanged so an upgraded
// install does not need a migration step.
package service
import (
"crypto/aes"
"crypto/cipher"
"crypto/rand"
"crypto/sha256"
"encoding/base64"
"errors"
"strings"
"go.uber.org/zap"
)
// encPrefix tags ciphertext rows so we can tell them apart from legacy
// plaintext values.
const encPrefix = "enc:v1:"
// CryptoService wraps an AES-GCM cipher derived from a stable per-install
// secret (the JWT secret).
type CryptoService struct {
log *zap.Logger
aead cipher.AEAD
}
// NewCryptoService derives a 256-bit key from the given secret via
// SHA-256 and constructs an AES-GCM AEAD. Empty secrets yield a service
// whose Encrypt/Decrypt methods are pass-throughs (used in unit tests).
func NewCryptoService(secret string, log *zap.Logger) *CryptoService {
c := &CryptoService{log: log}
if strings.TrimSpace(secret) == "" {
return c
}
sum := sha256.Sum256([]byte(secret))
block, err := aes.NewCipher(sum[:])
if err != nil {
log.Error("crypto: aes.NewCipher", zap.Error(err))
return c
}
aead, err := cipher.NewGCM(block)
if err != nil {
log.Error("crypto: cipher.NewGCM", zap.Error(err))
return c
}
c.aead = aead
return c
}
// Encrypt returns the base64-encoded ciphertext (with prefix) for plain.
// Empty inputs round-trip unchanged.
func (c *CryptoService) Encrypt(plain string) string {
if plain == "" || c.aead == nil {
return plain
}
if strings.HasPrefix(plain, encPrefix) {
return plain
}
nonce := make([]byte, c.aead.NonceSize())
if _, err := rand.Read(nonce); err != nil {
return plain
}
cipherBytes := c.aead.Seal(nonce, nonce, []byte(plain), nil)
return encPrefix + base64.StdEncoding.EncodeToString(cipherBytes)
}
// Decrypt returns the plaintext for an encrypted value. Plaintext rows
// (no prefix) are returned unchanged.
func (c *CryptoService) Decrypt(value string) string {
if value == "" || c.aead == nil {
return value
}
if !strings.HasPrefix(value, encPrefix) {
return value
}
raw := strings.TrimPrefix(value, encPrefix)
data, err := base64.StdEncoding.DecodeString(raw)
if err != nil {
return value
}
if len(data) < c.aead.NonceSize() {
return value
}
nonce, cipherBytes := data[:c.aead.NonceSize()], data[c.aead.NonceSize():]
plain, err := c.aead.Open(nil, nonce, cipherBytes, nil)
if err != nil {
return value
}
return string(plain)
}
// MaskAPIKey returns "abcd****wxyz" so the key can be displayed in the
// admin UI without leaking it. Inputs shorter than 8 chars become "****".
func MaskAPIKey(plain string) string {
plain = strings.TrimSpace(plain)
if len(plain) < 8 {
return "****"
}
return plain[:4] + "****" + plain[len(plain)-4:]
}
// ErrCryptoUnavailable is returned when callers expect crypto and the
// service is degraded (empty secret, init failure).
var ErrCryptoUnavailable = errors.New("crypto unavailable")
+72
View File
@@ -0,0 +1,72 @@
package service
import (
"strings"
"testing"
"go.uber.org/zap"
)
func TestCryptoRoundtrip(t *testing.T) {
c := NewCryptoService("super-secret-key-1234567890", zap.NewNop())
cases := []string{
"",
"a",
"hello world",
"sk-1234567890abcdef1234567890abcdef1234567890abcdef",
}
for _, plain := range cases {
t.Run(plain, func(t *testing.T) {
cipher := c.Encrypt(plain)
if plain == "" {
if cipher != "" {
t.Fatalf("empty plaintext should round-trip empty, got %q", cipher)
}
return
}
if cipher == plain {
t.Fatalf("expected ciphertext to differ from plaintext")
}
if !strings.HasPrefix(cipher, "enc:v1:") {
t.Fatalf("expected enc:v1: prefix, got %q", cipher)
}
plain2 := c.Decrypt(cipher)
if plain2 != plain {
t.Fatalf("decrypt mismatch: got %q, want %q", plain2, plain)
}
})
}
}
func TestCryptoNoSecret(t *testing.T) {
c := NewCryptoService("", zap.NewNop())
if c.Encrypt("x") != "x" {
t.Fatal("empty-secret crypto should be a pass-through")
}
if c.Decrypt("x") != "x" {
t.Fatal("empty-secret crypto should be a pass-through")
}
}
func TestCryptoLegacyPlaintext(t *testing.T) {
c := NewCryptoService("k", zap.NewNop())
// Decrypt a value that has no enc:v1: prefix — should pass through.
if got := c.Decrypt("legacy-plain"); got != "legacy-plain" {
t.Fatalf("legacy plaintext should pass through, got %q", got)
}
}
func TestMaskAPIKey(t *testing.T) {
cases := []struct{ in, want string }{
{"", "****"},
{"abc", "****"},
{"abcdefgh", "abcd****efgh"},
{"sk-1234567890abcdef", "sk-1****cdef"},
}
for _, tc := range cases {
got := MaskAPIKey(tc.in)
if got != tc.want {
t.Errorf("MaskAPIKey(%q) = %q, want %q", tc.in, got, tc.want)
}
}
}
+277
View File
@@ -0,0 +1,277 @@
// Package service — DLNA / UPnP discovery.
//
// DLNAService scans the LAN for "MediaRenderer" UPnP devices via SSDP
// (multicast UDP 239.255.255.250:1900) and exposes a one-shot "cast"
// helper that POSTs a SOAP envelope to the renderer's AVTransport
// service to start playback of an HTTP URL.
//
// We do NOT mediate the renderer ↔ client traffic; the renderer pulls
// the bytes directly from MediaStationGo's /api/stream endpoint, so
// the cast call only ever transports a URL string.
package service
import (
"bytes"
"context"
"encoding/xml"
"errors"
"fmt"
"io"
"net"
"net/http"
"net/url"
"strings"
"sync"
"time"
"go.uber.org/zap"
)
// DLNAService discovers UPnP MediaRenderer devices and casts media to them.
type DLNAService struct {
log *zap.Logger
mu sync.Mutex
cache []DLNADevice
cachedAt time.Time
}
// NewDLNAService is the constructor.
func NewDLNAService(log *zap.Logger) *DLNAService {
return &DLNAService{log: log}
}
// DLNADevice is the public projection of a discovered renderer.
type DLNADevice struct {
UDN string `json:"udn"`
FriendlyName string `json:"friendly_name"`
Manufacturer string `json:"manufacturer"`
ModelName string `json:"model_name"`
Location string `json:"location"` // device description URL
ControlURL string `json:"control_url"` // AVTransport SOAP endpoint
IPAddress string `json:"ip_address"`
}
// ssdpDiscover sends an M-SEARCH and returns the LOCATION URLs of every
// device that replied within timeout.
func (d *DLNAService) ssdpDiscover(ctx context.Context, timeout time.Duration) ([]string, error) {
addr, err := net.ResolveUDPAddr("udp4", "239.255.255.250:1900")
if err != nil {
return nil, err
}
conn, err := net.ListenUDP("udp4", &net.UDPAddr{IP: net.IPv4zero, Port: 0})
if err != nil {
return nil, err
}
defer conn.Close()
msg := strings.Join([]string{
"M-SEARCH * HTTP/1.1",
"HOST: 239.255.255.250:1900",
`MAN: "ssdp:discover"`,
"MX: 2",
"ST: urn:schemas-upnp-org:device:MediaRenderer:1",
"", "",
}, "\r\n")
if _, err := conn.WriteTo([]byte(msg), addr); err != nil {
return nil, err
}
deadline := time.Now().Add(timeout)
_ = conn.SetReadDeadline(deadline)
seen := map[string]struct{}{}
var locations []string
buf := make([]byte, 4096)
for {
select {
case <-ctx.Done():
return locations, nil
default:
}
n, _, err := conn.ReadFrom(buf)
if err != nil {
break
}
body := string(buf[:n])
for _, line := range strings.Split(body, "\r\n") {
if strings.HasPrefix(strings.ToUpper(line), "LOCATION:") {
loc := strings.TrimSpace(line[len("LOCATION:"):])
if _, ok := seen[loc]; ok {
continue
}
seen[loc] = struct{}{}
locations = append(locations, loc)
}
}
}
return locations, nil
}
// Discover returns every reachable MediaRenderer on the LAN. Results are
// cached for 30 seconds so the React UI's polling does not spam the
// network.
func (d *DLNAService) Discover(ctx context.Context, force bool) ([]DLNADevice, error) {
d.mu.Lock()
if !force && time.Since(d.cachedAt) < 30*time.Second && d.cache != nil {
out := append([]DLNADevice(nil), d.cache...)
d.mu.Unlock()
return out, nil
}
d.mu.Unlock()
locations, err := d.ssdpDiscover(ctx, 3*time.Second)
if err != nil {
// SSDP often fails on container networks; treat as "no devices"
// rather than 500 the API.
d.log.Debug("ssdp discover failed", zap.Error(err))
return nil, nil
}
devices := make([]DLNADevice, 0, len(locations))
for _, loc := range locations {
dev, err := d.fetchDescription(ctx, loc)
if err != nil {
d.log.Debug("desc fetch", zap.String("loc", loc), zap.Error(err))
continue
}
devices = append(devices, *dev)
}
d.mu.Lock()
d.cache = devices
d.cachedAt = time.Now()
d.mu.Unlock()
return devices, nil
}
// fetchDescription parses the device's UPnP XML descriptor and pulls out
// the AVTransport control URL.
func (d *DLNAService) fetchDescription(ctx context.Context, location string) (*DLNADevice, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, location, nil)
if err != nil {
return nil, err
}
resp, err := http.DefaultClient.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, err
}
type service struct {
ServiceType string `xml:"serviceType"`
ControlURL string `xml:"controlURL"`
}
type device struct {
FriendlyName string `xml:"friendlyName"`
Manufacturer string `xml:"manufacturer"`
ModelName string `xml:"modelName"`
UDN string `xml:"UDN"`
ServiceList struct {
Services []service `xml:"service"`
} `xml:"serviceList"`
}
type root struct {
Device device `xml:"device"`
}
var r root
if err := xml.Unmarshal(body, &r); err != nil {
return nil, err
}
out := &DLNADevice{
UDN: r.Device.UDN,
FriendlyName: r.Device.FriendlyName,
Manufacturer: r.Device.Manufacturer,
ModelName: r.Device.ModelName,
Location: location,
}
if u, err := url.Parse(location); err == nil {
out.IPAddress = u.Hostname()
}
for _, svc := range r.Device.ServiceList.Services {
if strings.Contains(svc.ServiceType, "AVTransport") {
out.ControlURL = absoluteURL(location, svc.ControlURL)
break
}
}
return out, nil
}
func absoluteURL(base, ref string) string {
bu, err := url.Parse(base)
if err != nil {
return ref
}
ru, err := url.Parse(ref)
if err != nil {
return ref
}
return bu.ResolveReference(ru).String()
}
// soapTemplate is the AVTransport SetAVTransportURI envelope.
const soapTemplate = `<?xml version="1.0"?>
<s:Envelope xmlns:s="http://schemas.xmlsoap.org/soap/envelope/" s:encodingStyle="http://schemas.xmlsoap.org/soap/encoding/">
<s:Body>
<u:SetAVTransportURI xmlns:u="urn:schemas-upnp-org:service:AVTransport:1">
<InstanceID>0</InstanceID>
<CurrentURI>%s</CurrentURI>
<CurrentURIMetaData></CurrentURIMetaData>
</u:SetAVTransportURI>
</s:Body>
</s:Envelope>`
const playTemplate = `<?xml version="1.0"?>
<s:Envelope xmlns:s="http://schemas.xmlsoap.org/soap/envelope/" s:encodingStyle="http://schemas.xmlsoap.org/soap/encoding/">
<s:Body>
<u:Play xmlns:u="urn:schemas-upnp-org:service:AVTransport:1">
<InstanceID>0</InstanceID>
<Speed>1</Speed>
</u:Play>
</s:Body>
</s:Envelope>`
// Cast tells the device at controlURL to start playing mediaURL. Returns
// the renderer's HTTP status for diagnostic purposes.
func (d *DLNAService) Cast(ctx context.Context, controlURL, mediaURL string) error {
if controlURL == "" {
return errors.New("device has no AVTransport control URL")
}
if err := d.soap(ctx, controlURL, "SetAVTransportURI",
fmt.Sprintf(soapTemplate, escapeXML(mediaURL))); err != nil {
return err
}
return d.soap(ctx, controlURL, "Play", playTemplate)
}
// soap POSTs an envelope and returns the parsed faultstring (if any).
func (d *DLNAService) soap(ctx context.Context, controlURL, action, envelope string) error {
req, err := http.NewRequestWithContext(ctx, http.MethodPost, controlURL,
bytes.NewReader([]byte(envelope)))
if err != nil {
return err
}
req.Header.Set("Content-Type", `text/xml; charset="utf-8"`)
req.Header.Set("SOAPAction",
fmt.Sprintf(`"urn:schemas-upnp-org:service:AVTransport:1#%s"`, action))
resp, err := http.DefaultClient.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode >= 400 {
raw, _ := io.ReadAll(resp.Body)
return fmt.Errorf("dlna %s: %d: %s", action, resp.StatusCode, strings.TrimSpace(string(raw)))
}
return nil
}
func escapeXML(s string) string {
r := strings.NewReplacer("&", "&amp;", "<", "&lt;", ">", "&gt;",
`"`, "&quot;", "'", "&apos;")
return r.Replace(s)
}
+235
View File
@@ -0,0 +1,235 @@
// Package service — duplicate-file finder.
//
// DuplicateService computes a sparse-sample MD5 (head + middle + tail,
// 1 MiB each, plus the file size to break collisions) for every media
// file and groups identical hashes into "duplicate sets". The first row
// (preferring scraped + larger files) is kept as the primary; the rest
// get is_duplicate = true and duplicate_of pointing at the primary.
//
// Why sparse: a full hash on a 50 GB Blu-ray remux takes minutes; the
// 3-window 3 MiB sample is enough to differentiate real-world copies
// while finishing per-file in well under a second.
package service
import (
"context"
"crypto/md5"
"encoding/hex"
"errors"
"fmt"
"io"
"os"
"sort"
"go.uber.org/zap"
"github.com/ShukeBta/MediaStationGo/internal/model"
"github.com/ShukeBta/MediaStationGo/internal/repository"
)
const sampleSize = 1 << 20 // 1 MiB per sample window
// DuplicateService is the entry point for the duplicate finder.
type DuplicateService struct {
log *zap.Logger
repo *repository.Container
hub *Hub
}
// NewDuplicateService is the constructor.
func NewDuplicateService(log *zap.Logger, repo *repository.Container, hub *Hub) *DuplicateService {
return &DuplicateService{log: log, repo: repo, hub: hub}
}
// Group describes one set of duplicates returned by Detect.
type Group struct {
Hash string `json:"hash"`
Primary model.Media `json:"primary"`
Duplicates []model.Media `json:"duplicates"`
}
// Report is the summary the React UI displays.
type Report struct {
TotalScanned int `json:"total_scanned"`
GroupsFound int `json:"groups_found"`
ItemsMarked int `json:"items_marked"`
Groups []Group `json:"groups"`
}
// Detect walks every media row in the given library (or all libraries
// when libraryID is empty), computes a hash for the ones missing it,
// then groups by hash and marks duplicates in the DB.
func (d *DuplicateService) Detect(ctx context.Context, libraryID string) (*Report, error) {
var rows []model.Media
q := d.repo.DB.WithContext(ctx).Model(&model.Media{})
if libraryID != "" {
q = q.Where("library_id = ?", libraryID)
}
if err := q.Find(&rows).Error; err != nil {
return nil, err
}
rep := &Report{TotalScanned: len(rows)}
totalToHash := 0
for i := range rows {
if rows[i].FileHash == "" && rows[i].Path != "" {
totalToHash++
}
}
hashed := 0
for i := range rows {
select {
case <-ctx.Done():
return rep, ctx.Err()
default:
}
if rows[i].FileHash != "" || rows[i].Path == "" {
continue
}
h, err := SparseFileHash(rows[i].Path)
if err != nil {
d.log.Debug("hash failed", zap.String("path", rows[i].Path), zap.Error(err))
continue
}
rows[i].FileHash = h
if err := d.repo.DB.WithContext(ctx).
Model(&model.Media{}).
Where("id = ?", rows[i].ID).
Update("file_hash", h).Error; err != nil {
d.log.Warn("hash persist failed", zap.Error(err))
}
hashed++
if d.hub != nil && totalToHash > 0 {
d.hub.Publish("duplicate", map[string]any{
"hashed": hashed,
"total": totalToHash,
"current": rows[i].Title,
})
}
}
// Group rows by file_hash.
groups := make(map[string][]model.Media)
for _, r := range rows {
if r.FileHash == "" {
continue
}
groups[r.FileHash] = append(groups[r.FileHash], r)
}
for hash, group := range groups {
if len(group) < 2 {
continue
}
primary := pickPrimary(group)
dupes := make([]model.Media, 0, len(group)-1)
for _, m := range group {
if m.ID == primary.ID {
continue
}
dupes = append(dupes, m)
if err := d.repo.DB.WithContext(ctx).
Model(&model.Media{}).
Where("id = ?", m.ID).
Updates(map[string]any{
"is_duplicate": true,
"duplicate_of": primary.ID,
}).Error; err != nil {
d.log.Warn("dup mark failed", zap.Error(err))
continue
}
rep.ItemsMarked++
}
rep.Groups = append(rep.Groups, Group{
Hash: hash,
Primary: primary,
Duplicates: dupes,
})
}
rep.GroupsFound = len(rep.Groups)
if d.hub != nil {
d.hub.Publish("duplicate", map[string]any{
"finished": true,
"groups": rep.GroupsFound,
"marked": rep.ItemsMarked,
})
}
return rep, nil
}
// Unmark clears the is_duplicate flag for every row in the given library
// (or all when libraryID is empty). Useful when the operator deletes the
// physical duplicates manually.
func (d *DuplicateService) Unmark(ctx context.Context, libraryID string) (int64, error) {
q := d.repo.DB.WithContext(ctx).Model(&model.Media{}).Where("is_duplicate = ?", true)
if libraryID != "" {
q = q.Where("library_id = ?", libraryID)
}
res := q.Updates(map[string]any{"is_duplicate": false, "duplicate_of": ""})
return res.RowsAffected, res.Error
}
// pickPrimary picks the "best" media row to keep: prefer scraped > size > id.
func pickPrimary(group []model.Media) model.Media {
sort.SliceStable(group, func(i, j int) bool {
ai, aj := group[i].ScrapeStatus == "matched", group[j].ScrapeStatus == "matched"
if ai != aj {
return ai
}
if group[i].SizeBytes != group[j].SizeBytes {
return group[i].SizeBytes > group[j].SizeBytes
}
return group[i].ID < group[j].ID
})
return group[0]
}
// SparseFileHash computes the head+mid+tail MD5 of a file, suffixed with
// the file size so two files that happen to collide on the sample window
// but differ in length are still distinguishable.
func SparseFileHash(path string) (string, error) {
if path == "" {
return "", errors.New("empty path")
}
f, err := os.Open(path)
if err != nil {
return "", err
}
defer f.Close()
st, err := f.Stat()
if err != nil {
return "", err
}
size := st.Size()
h := md5.New()
if size <= int64(sampleSize)*3 {
if _, err := io.Copy(h, f); err != nil {
return "", err
}
return fmt.Sprintf("%s-%d", hex.EncodeToString(h.Sum(nil)), size), nil
}
buf := make([]byte, sampleSize)
// head
if _, err := io.ReadFull(f, buf); err != nil {
return "", err
}
h.Write(buf)
// middle
if _, err := f.Seek(size/2-int64(sampleSize)/2, io.SeekStart); err != nil {
return "", err
}
if _, err := io.ReadFull(f, buf); err != nil {
return "", err
}
h.Write(buf)
// tail
if _, err := f.Seek(size-int64(sampleSize), io.SeekStart); err != nil {
return "", err
}
if _, err := io.ReadFull(f, buf); err != nil {
return "", err
}
h.Write(buf)
return fmt.Sprintf("%s-%d", hex.EncodeToString(h.Sum(nil)), size), nil
}
+185
View File
@@ -0,0 +1,185 @@
// Package service — minimal Emby/Jellyfin compatibility shim.
//
// EmbyService produces JSON envelopes shaped like the most-consumed
// Emby-API endpoints so existing players (Infuse / Kodi NextPVR
// extension / iOS native clients) can talk to MediaStationGo without a
// custom plugin.
//
// Implemented surface (matches what nowen-video exposes):
//
// GET /emby/System/Info server identity
// GET /emby/Users list of users (admin only field)
// GET /emby/Users/{userId}/Views virtual root: one entry per library
// GET /emby/Users/{userId}/Items paginated media listing
// GET /emby/Items/{id} single item
// GET /emby/Items/{id}/PlaybackInfo stream URL (delegates to /api/stream)
//
// The shim is read-only — Emby write operations (mark watched, etc.) are
// not implemented; the React UI stays the canonical control plane.
package service
import (
"context"
"go.uber.org/zap"
"github.com/ShukeBta/MediaStationGo/internal/config"
"github.com/ShukeBta/MediaStationGo/internal/model"
"github.com/ShukeBta/MediaStationGo/internal/repository"
)
// EmbyService produces Emby-shaped JSON.
type EmbyService struct {
cfg *config.Config
log *zap.Logger
repo *repository.Container
}
// NewEmbyService is the constructor.
func NewEmbyService(cfg *config.Config, log *zap.Logger, repo *repository.Container) *EmbyService {
return &EmbyService{cfg: cfg, log: log, repo: repo}
}
// SystemInfo returns the Emby identity payload.
func (e *EmbyService) SystemInfo() map[string]any {
return map[string]any{
"ServerName": "MediaStationGo",
"Version": "0.1.0",
"Id": "mediastation-go",
"OperatingSystem": "Linux",
"ProductName": "MediaStationGo",
}
}
// ListUsers returns Emby-shaped users.
func (e *EmbyService) ListUsers(ctx context.Context) ([]map[string]any, error) {
users, err := e.repo.User.List(ctx)
if err != nil {
return nil, err
}
out := make([]map[string]any, 0, len(users))
for _, u := range users {
out = append(out, e.userPayload(&u))
}
return out, nil
}
func (e *EmbyService) userPayload(u *model.User) map[string]any {
return map[string]any{
"Id": u.ID,
"Name": u.Username,
"ServerId": "mediastation-go",
"HasPassword": true,
"HasConfiguredEasyPassword": false,
"Policy": map[string]any{
"IsAdministrator": u.Role == "admin",
"IsHidden": false,
"IsDisabled": false,
"EnableUserPreferenceAccess": true,
},
}
}
// Views (Emby's name for libraries).
func (e *EmbyService) Views(ctx context.Context) (map[string]any, error) {
libs, err := e.repo.Library.List(ctx)
if err != nil {
return nil, err
}
items := make([]map[string]any, 0, len(libs))
for _, l := range libs {
collectionType := "movies"
if l.Type == "tv" {
collectionType = "tvshows"
} else if l.Type == "music" {
collectionType = "music"
}
items = append(items, map[string]any{
"Id": l.ID,
"Name": l.Name,
"CollectionType": collectionType,
"ServerId": "mediastation-go",
"Type": "CollectionFolder",
})
}
return map[string]any{"Items": items, "TotalRecordCount": len(items)}, nil
}
// Items paginates media in Emby's flat shape.
func (e *EmbyService) Items(ctx context.Context, libraryID string, limit, offset int) (map[string]any, error) {
if limit <= 0 || limit > 200 {
limit = 50
}
if offset < 0 {
offset = 0
}
q := e.repo.DB.WithContext(ctx).Model(&model.Media{}).Where("deleted_at IS NULL")
if libraryID != "" {
q = q.Where("library_id = ?", libraryID)
}
var total int64
if err := q.Count(&total).Error; err != nil {
return nil, err
}
var rows []model.Media
if err := q.Order("created_at desc").Offset(offset).Limit(limit).Find(&rows).Error; err != nil {
return nil, err
}
items := make([]map[string]any, 0, len(rows))
for _, m := range rows {
items = append(items, e.itemPayload(&m))
}
return map[string]any{
"Items": items,
"TotalRecordCount": total,
"StartIndex": offset,
}, nil
}
func (e *EmbyService) itemPayload(m *model.Media) map[string]any {
itemType := "Movie"
if m.SeasonNum > 0 || m.EpisodeNum > 0 {
itemType = "Episode"
}
return map[string]any{
"Id": m.ID,
"Name": m.Title,
"ServerId": "mediastation-go",
"Type": itemType,
"ProductionYear": m.Year,
"ParentIndexNumber": m.SeasonNum,
"IndexNumber": m.EpisodeNum,
"Overview": m.Overview,
"RunTimeTicks": int64(m.DurationSec) * 10_000_000,
"CommunityRating": m.Rating,
"MediaSources": []map[string]any{{
"Id": m.ID,
"Path": m.Path,
"Container": m.Container,
"Size": m.SizeBytes,
}},
}
}
// PlaybackInfo returns the stream URL (caller must append ?token=).
func (e *EmbyService) PlaybackInfo(ctx context.Context, mediaID string) (map[string]any, error) {
m, err := e.repo.Media.FindByID(ctx, mediaID)
if err != nil || m == nil {
return nil, err
}
url := "/api/stream/" + m.ID
if m.STRMURL != "" {
url = m.STRMURL
}
return map[string]any{
"MediaSources": []map[string]any{{
"Id": m.ID,
"Path": url,
"Protocol": "Http",
"DirectStreamUrl": url,
"Container": m.Container,
"Size": m.SizeBytes,
}},
"PlaySessionId": m.ID,
}, nil
}
+186
View File
@@ -0,0 +1,186 @@
// Package service — server-side file browser.
//
// FileManagerService exposes a strict, allow-listed view of the server's
// filesystem so the React Library / Storage tabs can let the operator
// pick library roots without typing absolute paths from memory.
//
// Allow-list rules:
//
// - Roots: every Library.Path + the configured app.data_dir +
// app.cache_dir, plus the operator-supplied app.media.* defaults.
// - Children must resolve under one of the roots after symlink-free
// filepath.Abs(). Anything else returns ErrPathOutOfBounds.
//
// We never write to the filesystem here; this is read-only browsing.
package service
import (
"context"
"errors"
"os"
"path/filepath"
"sort"
"strings"
"go.uber.org/zap"
"github.com/ShukeBta/MediaStationGo/internal/config"
"github.com/ShukeBta/MediaStationGo/internal/repository"
)
// FileManagerService browses the server-side filesystem.
type FileManagerService struct {
cfg *config.Config
log *zap.Logger
repo *repository.Container
}
// NewFileManagerService is the constructor.
func NewFileManagerService(cfg *config.Config, log *zap.Logger, repo *repository.Container) *FileManagerService {
return &FileManagerService{cfg: cfg, log: log, repo: repo}
}
// Entry is one file or directory shown in the browser.
type Entry struct {
Name string `json:"name"`
Path string `json:"path"`
IsDir bool `json:"is_dir"`
Size int64 `json:"size"`
Modified int64 `json:"modified"`
}
// Listing describes the contents of a directory plus navigation hints.
type Listing struct {
Path string `json:"path"`
Parent string `json:"parent,omitempty"`
Roots []Root `json:"roots,omitempty"`
Entries []Entry `json:"entries"`
}
// Root is the entry-point label shown when no path is given.
type Root struct {
Label string `json:"label"`
Path string `json:"path"`
}
// ErrPathOutOfBounds is returned when path falls outside every allowed root.
var ErrPathOutOfBounds = errors.New("path is outside the allowed roots")
// List enumerates a directory under one of the allowed roots, returning
// up to maxEntries items sorted by (dir-first, alphabetical).
func (s *FileManagerService) List(path string, maxEntries int) (*Listing, error) {
if maxEntries <= 0 || maxEntries > 5000 {
maxEntries = 1000
}
roots, err := s.allowedRoots()
if err != nil {
return nil, err
}
rootList := make([]Root, 0, len(roots))
seen := map[string]struct{}{}
for label, p := range roots {
if _, ok := seen[p]; ok {
continue
}
seen[p] = struct{}{}
rootList = append(rootList, Root{Label: label, Path: p})
}
sort.Slice(rootList, func(i, j int) bool { return rootList[i].Label < rootList[j].Label })
if path == "" {
// Listing the (virtual) root: just hand back the labels.
return &Listing{Path: "", Roots: rootList}, nil
}
abs, err := filepath.Abs(path)
if err != nil {
return nil, err
}
if !s.withinAllowed(abs, roots) {
return nil, ErrPathOutOfBounds
}
entries, err := os.ReadDir(abs)
if err != nil {
return nil, err
}
out := &Listing{Path: abs, Roots: rootList}
parent := filepath.Dir(abs)
if parent != abs && s.withinAllowed(parent, roots) {
out.Parent = parent
}
for i, e := range entries {
if i >= maxEntries {
break
}
name := e.Name()
if strings.HasPrefix(name, ".") {
continue
}
full := filepath.Join(abs, name)
info, err := e.Info()
if err != nil {
continue
}
out.Entries = append(out.Entries, Entry{
Name: name,
Path: full,
IsDir: e.IsDir(),
Size: info.Size(),
Modified: info.ModTime().Unix(),
})
}
sort.Slice(out.Entries, func(i, j int) bool {
if out.Entries[i].IsDir != out.Entries[j].IsDir {
return out.Entries[i].IsDir
}
return strings.ToLower(out.Entries[i].Name) < strings.ToLower(out.Entries[j].Name)
})
return out, nil
}
// allowedRoots returns the union of {libraries, data_dir, cache_dir,
// media.movies/tv/anime} as label → absolute-path.
func (s *FileManagerService) allowedRoots() (map[string]string, error) {
roots := map[string]string{}
add := func(label, p string) {
if p == "" {
return
}
abs, err := filepath.Abs(p)
if err != nil {
return
}
if _, err := os.Stat(abs); err != nil {
return
}
roots[label] = abs
}
add("data", s.cfg.App.DataDir)
add("cache", s.cfg.Cache.CacheDir)
add("movies", s.cfg.Media.MoviesDir)
add("tv", s.cfg.Media.TVDir)
add("anime", s.cfg.Media.AnimeDir)
libs, err := s.repo.Library.List(context.Background()) // librarian list is fast; ctx not propagated from request
if err == nil {
for _, l := range libs {
add("library:"+l.Name, l.Path)
}
}
return roots, nil
}
// withinAllowed reports whether path lives under any allowed root.
func (s *FileManagerService) withinAllowed(path string, roots map[string]string) bool {
for _, r := range roots {
rel, err := filepath.Rel(r, path)
if err != nil {
continue
}
if !strings.HasPrefix(rel, "..") && !filepath.IsAbs(rel) {
return true
}
}
return false
}
+241
View File
@@ -0,0 +1,241 @@
// Package service — periodic scheduled jobs.
//
// SchedulerService runs five recurring background jobs that keep the
// library up-to-date without operator intervention:
//
// library_scan every 60 min — re-scan every enabled library so
// newly-copied files are picked up.
// subscription_pull every 30 min — re-poll RSS feeds (in addition to
// the existing SubscriptionService
// internal timer).
// download_sync every 30 s — refresh the qBittorrent torrent
// list (already covered by the
// download poller, kept here as a
// watchdog).
// transcode_cleanup every 24 h — purge HLS transcode artefacts
// older than 24 h.
// recycle_purge every 24 h — empty the recycle bin of rows
// soft-deleted more than 30 days
// ago.
//
// Each job runs at most once at a time (an in-flight run blocks the
// next tick). All work happens on a long-lived background context so
// the operator can keep clicking around the UI while the watchdog runs.
package service
import (
"context"
"sync"
"time"
"go.uber.org/zap"
"gorm.io/gorm"
"github.com/ShukeBta/MediaStationGo/internal/model"
"github.com/ShukeBta/MediaStationGo/internal/repository"
)
// SchedulerService runs the periodic jobs.
type SchedulerService struct {
log *zap.Logger
repo *repository.Container
scanner *ScannerService
transcoder *TranscoderService
hub *Hub
cacheDir string
mu sync.Mutex
stopCh chan struct{}
jobs []*scheduledJob
}
// scheduledJob is one recurring task.
type scheduledJob struct {
name string
interval time.Duration
run func(ctx context.Context) error
lastRun time.Time
lastErr string
}
// NewSchedulerService is the constructor.
func NewSchedulerService(
log *zap.Logger,
repo *repository.Container,
scanner *ScannerService,
transcoder *TranscoderService,
hub *Hub,
cacheDir string,
) *SchedulerService {
return &SchedulerService{
log: log,
repo: repo,
scanner: scanner,
transcoder: transcoder,
hub: hub,
cacheDir: cacheDir,
stopCh: make(chan struct{}),
}
}
// Start kicks off every job in its own goroutine and returns immediately.
func (s *SchedulerService) Start(ctx context.Context) {
s.jobs = []*scheduledJob{
{
name: "library_scan",
interval: 60 * time.Minute,
run: s.jobScanLibraries,
},
{
name: "transcode_cleanup",
interval: 24 * time.Hour,
run: s.jobCleanTranscodeCache,
},
{
name: "recycle_purge",
interval: 24 * time.Hour,
run: s.jobPurgeRecycleBin,
},
}
for _, j := range s.jobs {
go s.loop(ctx, j)
}
}
// Stop signals every job loop to exit on the next tick.
func (s *SchedulerService) Stop() {
s.mu.Lock()
defer s.mu.Unlock()
select {
case <-s.stopCh:
// already closed
default:
close(s.stopCh)
}
}
// JobStatus is a snapshot suitable for the admin UI.
type JobStatus struct {
Name string `json:"name"`
Interval string `json:"interval"`
LastRun time.Time `json:"last_run,omitempty"`
LastErr string `json:"last_err,omitempty"`
}
// Status returns the current state of every registered job.
func (s *SchedulerService) Status() []JobStatus {
s.mu.Lock()
defer s.mu.Unlock()
out := make([]JobStatus, 0, len(s.jobs))
for _, j := range s.jobs {
out = append(out, JobStatus{
Name: j.name,
Interval: j.interval.String(),
LastRun: j.lastRun,
LastErr: j.lastErr,
})
}
return out
}
// RunNow triggers a single run of the named job synchronously.
func (s *SchedulerService) RunNow(ctx context.Context, name string) error {
for _, j := range s.jobs {
if j.name == name {
return s.runOnce(ctx, j)
}
}
return nil
}
func (s *SchedulerService) loop(ctx context.Context, j *scheduledJob) {
t := time.NewTicker(j.interval)
defer t.Stop()
// Run once shortly after startup so the initial state is fresh.
first := time.NewTimer(15 * time.Second)
defer first.Stop()
for {
select {
case <-ctx.Done():
return
case <-s.stopCh:
return
case <-first.C:
case <-t.C:
}
if err := s.runOnce(ctx, j); err != nil {
s.log.Warn("scheduled job failed",
zap.String("name", j.name), zap.Error(err))
}
}
}
func (s *SchedulerService) runOnce(ctx context.Context, j *scheduledJob) error {
err := j.run(ctx)
s.mu.Lock()
j.lastRun = time.Now()
if err != nil {
j.lastErr = err.Error()
} else {
j.lastErr = ""
}
s.mu.Unlock()
if s.hub != nil {
s.hub.Publish("scheduler", map[string]any{
"name": j.name,
"ok": err == nil,
"error": j.lastErr,
})
}
return err
}
// jobScanLibraries re-walks every enabled library.
func (s *SchedulerService) jobScanLibraries(ctx context.Context) error {
libs, err := s.repo.Library.List(ctx)
if err != nil {
return err
}
for _, l := range libs {
if !l.Enabled {
continue
}
if _, err := s.scanner.ScanLibrary(ctx, l.ID); err != nil {
s.log.Warn("scheduled scan failed",
zap.String("library", l.ID), zap.Error(err))
}
}
return nil
}
// jobCleanTranscodeCache deletes HLS artefacts older than 24h.
func (s *SchedulerService) jobCleanTranscodeCache(ctx context.Context) error {
if s.cacheDir == "" {
return nil
}
cutoff := time.Now().Add(-24 * time.Hour)
return walkAndPrune(s.cacheDir+"/hls", cutoff)
}
// jobPurgeRecycleBin permanently deletes media rows soft-deleted >30 days
// ago. The on-disk file is left untouched (delete is operator-driven).
func (s *SchedulerService) jobPurgeRecycleBin(ctx context.Context) error {
cutoff := time.Now().Add(-30 * 24 * time.Hour)
res := s.repo.DB.WithContext(ctx).
Unscoped().
Where("deleted_at IS NOT NULL AND deleted_at < ?", cutoff).
Delete(&model.Media{})
if res.Error != nil && !isMissingTableErr(res.Error) {
return res.Error
}
return nil
}
// isMissingTableErr lets the test harness ignore "no such table" errors
// that show up before AutoMigrate has run.
func isMissingTableErr(err error) bool {
if err == nil {
return false
}
return err == gorm.ErrInvalidDB
}
+31
View File
@@ -43,6 +43,14 @@ type Container struct {
Audit *AuditService
NFO *NFOService
AI *AIService
APIConfig *APIConfigService
Crypto *CryptoService
Duplicate *DuplicateService
FileManager *FileManagerService
DLNA *DLNAService
Scheduler *SchedulerService
Storage *StorageService
Emby *EmbyService
stopCtx context.Context
stopCancel context.CancelFunc
@@ -67,6 +75,14 @@ func New(cfg *config.Config, log *zap.Logger, repos *repository.Container) *Cont
watcher := NewWatcherService(log, repos, scanner)
nfo := NewNFOService(log, repos)
ai := NewAIService(cfg, log)
crypto := NewCryptoService(cfg.Secrets.JWTSecret, log)
apiConfig := NewAPIConfigService(log, repos, crypto)
duplicate := NewDuplicateService(log, repos, hub)
filemanager := NewFileManagerService(cfg, log, repos)
dlna := NewDLNAService(log)
storage := NewStorageService(log, repos)
emby := NewEmbyService(cfg, log, repos)
scheduler := NewSchedulerService(log, repos, scanner, transcoder, hub, cfg.Cache.CacheDir)
ctx, cancel := context.WithCancel(context.Background())
@@ -98,6 +114,14 @@ func New(cfg *config.Config, log *zap.Logger, repos *repository.Container) *Cont
Audit: NewAuditService(log, repos),
NFO: nfo,
AI: ai,
APIConfig: apiConfig,
Crypto: crypto,
Duplicate: duplicate,
FileManager: filemanager,
DLNA: dlna,
Scheduler: scheduler,
Storage: storage,
Emby: emby,
stopCtx: ctx,
stopCancel: cancel,
}
@@ -111,6 +135,10 @@ func (c *Container) Boot() {
}
c.Downloads.Start(c.stopCtx)
c.Subscription.Start(c.stopCtx)
if err := c.APIConfig.SeedDefaults(c.stopCtx); err != nil {
c.Log.Warn("api config seed failed", zap.Error(err))
}
c.Scheduler.Start(c.stopCtx)
}
// Close releases any resources held by services (websocket hub, ffmpeg
@@ -119,6 +147,9 @@ func (c *Container) Close() {
if c.stopCancel != nil {
c.stopCancel()
}
if c.Scheduler != nil {
c.Scheduler.Stop()
}
if c.Watcher != nil {
c.Watcher.Stop()
}
+115
View File
@@ -0,0 +1,115 @@
// Package service — disk usage breakdown.
//
// StorageService aggregates "how much disk does each library use" for
// the React Storage tab. Numbers are computed from the in-DB
// media.size_bytes column so we never hit the disk on the hot path.
package service
import (
"context"
"go.uber.org/zap"
"github.com/ShukeBta/MediaStationGo/internal/repository"
)
// StorageService is the read-only aggregator.
type StorageService struct {
log *zap.Logger
repo *repository.Container
}
// NewStorageService is the constructor.
func NewStorageService(log *zap.Logger, repo *repository.Container) *StorageService {
return &StorageService{log: log, repo: repo}
}
// Breakdown is what /api/storage returns.
type Breakdown struct {
TotalBytes int64 `json:"total_bytes"`
TotalSeconds int64 `json:"total_seconds"`
ByLibrary []LibraryUsage `json:"by_library"`
ByContainer []ContainerStat `json:"by_container"`
}
// LibraryUsage is per-library disk + duration totals.
type LibraryUsage struct {
LibraryID string `json:"library_id"`
Name string `json:"name"`
Type string `json:"type"`
Path string `json:"path"`
MediaCount int64 `json:"media_count"`
TotalBytes int64 `json:"total_bytes"`
TotalSeconds int64 `json:"total_seconds"`
}
// ContainerStat counts media items per container (mp4 / mkv / …).
type ContainerStat struct {
Container string `json:"container"`
Count int64 `json:"count"`
Bytes int64 `json:"bytes"`
}
// Compute returns the full breakdown.
func (s *StorageService) Compute(ctx context.Context) (*Breakdown, error) {
libs, err := s.repo.Library.List(ctx)
if err != nil {
return nil, err
}
out := &Breakdown{ByLibrary: make([]LibraryUsage, 0, len(libs))}
for _, l := range libs {
var usage LibraryUsage
usage.LibraryID = l.ID
usage.Name = l.Name
usage.Type = l.Type
usage.Path = l.Path
row := struct {
Count int64
Size int64
Seconds int64
}{}
err := s.repo.DB.WithContext(ctx).
Table("media").
Where("library_id = ? AND deleted_at IS NULL", l.ID).
Select("COUNT(*) as count, COALESCE(SUM(size_bytes),0) as size, COALESCE(SUM(duration_sec),0) as seconds").
Scan(&row).Error
if err != nil {
return nil, err
}
usage.MediaCount = row.Count
usage.TotalBytes = row.Size
usage.TotalSeconds = row.Seconds
out.TotalBytes += row.Size
out.TotalSeconds += row.Seconds
out.ByLibrary = append(out.ByLibrary, usage)
}
rows, err := s.containerStats(ctx)
if err != nil {
return nil, err
}
out.ByContainer = rows
return out, nil
}
func (s *StorageService) containerStats(ctx context.Context) ([]ContainerStat, error) {
rows, err := s.repo.DB.WithContext(ctx).
Table("media").
Where("deleted_at IS NULL").
Select("COALESCE(NULLIF(container,''),'unknown') as container, COUNT(*) as count, COALESCE(SUM(size_bytes),0) as bytes").
Group("container").
Rows()
if err != nil {
return nil, err
}
defer rows.Close()
out := []ContainerStat{}
for rows.Next() {
var c ContainerStat
if err := rows.Scan(&c.Container, &c.Count, &c.Bytes); err != nil {
return nil, err
}
out = append(out, c)
}
return out, nil
}
+8
View File
@@ -55,6 +55,10 @@ var ErrMediaNotFound = errors.New("media not found")
// ServeFile streams the file backing the given media ID using
// http.ServeContent so HEAD / Range / If-Modified-Since are handled for free.
//
// When the media row has a STRMURL set we redirect (302) to that URL
// instead of opening a local file. This lets WebDAV / Alist / S3 / HTTP
// direct links flow through the rest of the player UI unchanged.
func (s *StreamService) ServeFile(w http.ResponseWriter, r *http.Request, mediaID string) error {
m, err := s.repo.Media.FindByID(r.Context(), mediaID)
if err != nil {
@@ -63,6 +67,10 @@ func (s *StreamService) ServeFile(w http.ResponseWriter, r *http.Request, mediaI
if m == nil {
return ErrMediaNotFound
}
if strings.TrimSpace(m.STRMURL) != "" {
http.Redirect(w, r, m.STRMURL, http.StatusFound)
return nil
}
f, err := os.Open(m.Path)
if err != nil {
return ErrMediaNotFound