mirror of
https://github.com/truewhile/MeBox.git
synced 2026-09-29 11:36:36 +08:00
4b747c74ca
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.
226 lines
8.6 KiB
Go
226 lines
8.6 KiB
Go
// Package model defines GORM data models and the registry used by
|
|
// auto-migration. Each subsystem in MediaStationGo owns a slice of tables
|
|
// here; AllModels returns the union for db.AutoMigrate.
|
|
package model
|
|
|
|
import (
|
|
"time"
|
|
|
|
"github.com/google/uuid"
|
|
"gorm.io/gorm"
|
|
)
|
|
|
|
// Base captures the fields embedded in every domain entity:
|
|
//
|
|
// - ID: UUID v4 string primary key.
|
|
// - CreatedAt / UpdatedAt: managed by GORM.
|
|
// - DeletedAt: soft-delete (queries auto-filter on it).
|
|
type Base struct {
|
|
ID string `gorm:"primaryKey;type:varchar(36)" json:"id"`
|
|
CreatedAt time.Time `json:"created_at"`
|
|
UpdatedAt time.Time `json:"updated_at"`
|
|
DeletedAt gorm.DeletedAt `gorm:"index" json:"-"`
|
|
}
|
|
|
|
// BeforeCreate generates a UUID if the caller did not supply one.
|
|
func (b *Base) BeforeCreate(_ *gorm.DB) error {
|
|
if b.ID == "" {
|
|
b.ID = uuid.NewString()
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// User is a local account. The first registered admin (or seeded admin)
|
|
// gains the "admin" role; everyone else defaults to "user".
|
|
type User struct {
|
|
Base
|
|
Username string `gorm:"uniqueIndex;size:64;not null" json:"username"`
|
|
PasswordHash string `gorm:"size:128;not null" json:"-"`
|
|
Role string `gorm:"size:16;not null;default:user" json:"role"`
|
|
Email string `gorm:"size:128" json:"email,omitempty"`
|
|
AvatarURL string `gorm:"size:255" json:"avatar_url,omitempty"`
|
|
ForcePasswordReset bool `gorm:"default:false" json:"force_password_reset"`
|
|
LastLoginAt *time.Time `json:"last_login_at,omitempty"`
|
|
}
|
|
|
|
// Library represents a user-defined media root directory.
|
|
type Library struct {
|
|
Base
|
|
Name string `gorm:"size:128;not null" json:"name"`
|
|
Path string `gorm:"size:1024;not null" json:"path"`
|
|
Type string `gorm:"size:16;not null;default:movie" json:"type"` // movie / tv / anime / music
|
|
Enabled bool `gorm:"default:true" json:"enabled"`
|
|
}
|
|
|
|
// Media is a single playable item. Series episodes link to a SeriesID; movies
|
|
// have SeriesID == "".
|
|
type Media struct {
|
|
Base
|
|
LibraryID string `gorm:"index;size:36" json:"library_id"`
|
|
SeriesID string `gorm:"index;size:36" json:"series_id,omitempty"`
|
|
Title string `gorm:"size:255;not null" json:"title"`
|
|
OriginalName string `gorm:"size:255" json:"original_name,omitempty"`
|
|
Path string `gorm:"uniqueIndex;size:1024;not null" json:"path"`
|
|
SizeBytes int64 `json:"size_bytes"`
|
|
DurationSec int `json:"duration_sec"`
|
|
Width int `json:"width"`
|
|
Height int `json:"height"`
|
|
VideoCodec string `gorm:"size:32" json:"video_codec,omitempty"`
|
|
AudioCodec string `gorm:"size:32" json:"audio_codec,omitempty"`
|
|
Container string `gorm:"size:16" json:"container,omitempty"`
|
|
PosterURL string `gorm:"size:1024" json:"poster_url,omitempty"`
|
|
BackdropURL string `gorm:"size:1024" json:"backdrop_url,omitempty"`
|
|
Overview string `gorm:"type:text" json:"overview,omitempty"`
|
|
Rating float32 `json:"rating"`
|
|
Year int `json:"year"`
|
|
SeasonNum int `json:"season_num"`
|
|
EpisodeNum int `json:"episode_num"`
|
|
ScrapeStatus string `gorm:"size:16;default:pending" json:"scrape_status"`
|
|
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.
|
|
type Series struct {
|
|
Base
|
|
LibraryID string `gorm:"index;size:36" json:"library_id"`
|
|
Title string `gorm:"size:255;not null" json:"title"`
|
|
PosterURL string `gorm:"size:1024" json:"poster_url,omitempty"`
|
|
BackdropURL string `gorm:"size:1024" json:"backdrop_url,omitempty"`
|
|
Overview string `gorm:"type:text" json:"overview,omitempty"`
|
|
Rating float32 `json:"rating"`
|
|
Year int `json:"year"`
|
|
TMDbID int `json:"tmdb_id"`
|
|
BangumiID int `json:"bangumi_id"`
|
|
}
|
|
|
|
// PlaybackHistory records the current playback position for resume support.
|
|
type PlaybackHistory struct {
|
|
Base
|
|
UserID string `gorm:"index;size:36;not null" json:"user_id"`
|
|
MediaID string `gorm:"index;size:36;not null" json:"media_id"`
|
|
PositionMs int64 `json:"position_ms"`
|
|
DurationMs int64 `json:"duration_ms"`
|
|
WatchedAt time.Time `json:"watched_at"`
|
|
Completed bool `json:"completed"`
|
|
}
|
|
|
|
// Favorite marks a media item as favourite for a given user.
|
|
type Favorite struct {
|
|
Base
|
|
UserID string `gorm:"index;size:36;not null;uniqueIndex:uniq_user_media" json:"user_id"`
|
|
MediaID string `gorm:"index;size:36;not null;uniqueIndex:uniq_user_media" json:"media_id"`
|
|
}
|
|
|
|
// Playlist is a user-curated, ordered list of media items.
|
|
type Playlist struct {
|
|
Base
|
|
UserID string `gorm:"index;size:36;not null" json:"user_id"`
|
|
Name string `gorm:"size:128;not null" json:"name"`
|
|
IsPublic bool `gorm:"default:false" json:"is_public"`
|
|
}
|
|
|
|
// PlaylistItem is the join table between Playlists and Media with ordering.
|
|
type PlaylistItem struct {
|
|
Base
|
|
PlaylistID string `gorm:"index;size:36;not null" json:"playlist_id"`
|
|
MediaID string `gorm:"index;size:36;not null" json:"media_id"`
|
|
Position int `json:"position"`
|
|
}
|
|
|
|
// DownloadTask is an outstanding (or completed) torrent / HTTP download.
|
|
type DownloadTask struct {
|
|
Base
|
|
UserID string `gorm:"index;size:36" json:"user_id"`
|
|
Source string `gorm:"size:32;not null" json:"source"` // qbittorrent / transmission / http
|
|
URL string `gorm:"size:2048;not null" json:"url"`
|
|
SavePath string `gorm:"size:1024" json:"save_path"`
|
|
Status string `gorm:"size:32;default:queued" json:"status"`
|
|
Progress float32 `json:"progress"`
|
|
}
|
|
|
|
// Subscription is an automation rule that polls an RSS feed and queues
|
|
// matching torrents into the configured download client.
|
|
type Subscription struct {
|
|
Base
|
|
UserID string `gorm:"index;size:36" json:"user_id"`
|
|
Name string `gorm:"size:128;not null" json:"name"`
|
|
FeedURL string `gorm:"size:2048;not null" json:"feed_url"`
|
|
Filter string `gorm:"size:512" json:"filter"`
|
|
Enabled bool `gorm:"default:true" json:"enabled"`
|
|
LastRunAt *time.Time `json:"last_run_at,omitempty"`
|
|
}
|
|
|
|
// Setting is a single key/value system-wide preference (used by the admin UI).
|
|
type Setting struct {
|
|
Key string `gorm:"primaryKey;size:128" json:"key"`
|
|
Value string `gorm:"type:text" json:"value"`
|
|
UpdatedAt time.Time `json:"updated_at"`
|
|
}
|
|
|
|
// AccessLog is a structured audit-trail entry. Stored in SQLite for the
|
|
// admin Activity panel.
|
|
type AccessLog struct {
|
|
Base
|
|
UserID string `gorm:"index;size:36" json:"user_id"`
|
|
Action string `gorm:"size:64;not null" json:"action"`
|
|
Target string `gorm:"size:255" json:"target"`
|
|
IP string `gorm:"size:64" json:"ip"`
|
|
Detail string `gorm:"type:text" json:"detail"`
|
|
}
|
|
|
|
// AllModels returns the slice consumed by gorm.AutoMigrate.
|
|
func AllModels() []interface{} {
|
|
return []interface{}{
|
|
&User{},
|
|
&Library{},
|
|
&Series{},
|
|
&Media{},
|
|
&PlaybackHistory{},
|
|
&Favorite{},
|
|
&Playlist{},
|
|
&PlaylistItem{},
|
|
&DownloadTask{},
|
|
&Subscription{},
|
|
&Setting{},
|
|
&AccessLog{},
|
|
&APIConfig{},
|
|
}
|
|
}
|