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