feat: bootstrap MediaStationGo (Go + React rewrite of MediaStation)

Adopt the cropflre/nowen-video tech stack and rebuild the project from
scratch:

  - Backend: Go 1.25 + Gin + GORM + SQLite (WAL) + JWT + WebSocket hub.
    Layered packages config / database / model / repository / service /
    middleware / handler. Default admin (admin/admin123) seeded on first
    run; /api routes for auth, libraries, media, stream and admin
    panels. WebSocket scan-progress events at /api/ws.
  - Frontend: React 18 + Vite 5 + Tailwind 3.4 + Zustand + axios +
    react-router 6 + lucide-react + framer-motion + hls.js. Pages for
    Login / Home / Library / Search / MediaDetail / Player / Admin
    (Library, Users, Settings tabs).
  - Distribution: multi-arch Dockerfile (frontend -> backend -> Alpine
    runtime), docker-compose.yml, GitHub Actions for CI and GHCR
    publish, Makefile, env-prefixed config (MEDIASTATION_*).
  - Docs: README, CONTRIBUTING, .env.example, config.example.yaml.

Backend builds, vets and tests pass. Frontend builds via tsc -b && vite
build (250 kB JS / 16 kB CSS, gzipped 84 / 4 kB).
This commit is contained in:
Kiro
2026-05-14 15:26:29 +00:00
commit d5cf5fb4b2
60 changed files with 7592 additions and 0 deletions
+241
View File
@@ -0,0 +1,241 @@
// Package config loads layered configuration from defaults, config files and
// environment variables, mirroring the conventions used by nowen-video.
//
// Priority (low -> high):
// 1. Built-in defaults
// 2. config.yaml in the working directory (nested format)
// 3. config/*.yaml shard files (per-module)
// 4. Environment variables prefixed with MEDIASTATION_
//
// Environment variable example:
//
// MEDIASTATION_APP_PORT=8080
// MEDIASTATION_SECRETS_JWT_SECRET=please-change-me
// MEDIASTATION_DATABASE_DB_PATH=/data/mediastation.db
package config
import (
"crypto/rand"
"encoding/hex"
"fmt"
"os"
"path/filepath"
"strings"
"github.com/spf13/viper"
)
// EnvPrefix is the prefix used for all env-var-driven overrides.
const EnvPrefix = "MEDIASTATION"
// Config is the root config aggregate.
type Config struct {
App AppConfig `mapstructure:"app"`
Database DatabaseConfig `mapstructure:"database"`
Secrets SecretsConfig `mapstructure:"secrets"`
Logging LoggingConfig `mapstructure:"logging"`
Cache CacheConfig `mapstructure:"cache"`
Media MediaConfig `mapstructure:"media"`
AI AIConfig `mapstructure:"ai"`
}
// AppConfig holds runtime app parameters.
type AppConfig struct {
Port int `mapstructure:"port"`
Debug bool `mapstructure:"debug"`
Env string `mapstructure:"env"`
DataDir string `mapstructure:"data_dir"`
WebDir string `mapstructure:"web_dir"`
FFmpegPath string `mapstructure:"ffmpeg_path"`
FFprobePath string `mapstructure:"ffprobe_path"`
VAAPIDevice string `mapstructure:"vaapi_device"`
CORSOrigins []string `mapstructure:"cors_origins"`
ServerURL string `mapstructure:"server_url"`
}
// DatabaseConfig configures GORM + SQLite.
type DatabaseConfig struct {
DBPath string `mapstructure:"db_path"`
WALMode bool `mapstructure:"wal_mode"`
BusyTimeout int `mapstructure:"busy_timeout"`
CacheSize int `mapstructure:"cache_size"`
MaxOpenConns int `mapstructure:"max_open_conns"`
MaxIdleConns int `mapstructure:"max_idle_conns"`
}
// SecretsConfig holds JWT / 3rd-party API keys (do NOT commit values).
type SecretsConfig struct {
JWTSecret string `mapstructure:"jwt_secret"`
TMDbAPIKey string `mapstructure:"tmdb_api_key"`
TMDbAPIProxy string `mapstructure:"tmdb_api_proxy"`
TMDbImageProxy string `mapstructure:"tmdb_image_proxy"`
BangumiToken string `mapstructure:"bangumi_access_token"`
TheTVDBAPIKey string `mapstructure:"thetvdb_api_key"`
FanartAPIKey string `mapstructure:"fanart_tv_api_key"`
DoubanCookie string `mapstructure:"douban_cookie"`
}
// LoggingConfig configures Zap.
type LoggingConfig struct {
Level string `mapstructure:"level"`
Format string `mapstructure:"format"`
OutputPath string `mapstructure:"output_path"`
EnableRotation bool `mapstructure:"enable_rotation"`
MaxSizeMB int `mapstructure:"max_size_mb"`
MaxAgeDays int `mapstructure:"max_age_days"`
MaxBackups int `mapstructure:"max_backups"`
}
// CacheConfig controls the on-disk transcode/scrape cache.
type CacheConfig struct {
CacheDir string `mapstructure:"cache_dir"`
MaxDiskUsageMB int `mapstructure:"max_disk_usage_mb"`
TTLHours int `mapstructure:"ttl_hours"`
AutoCleanup bool `mapstructure:"auto_cleanup"`
CleanupIntervalMin int `mapstructure:"cleanup_interval_min"`
}
// MediaConfig holds default library locations (used by the bootstrap library).
type MediaConfig struct {
MoviesDir string `mapstructure:"movies_dir"`
TVDir string `mapstructure:"tv_dir"`
AnimeDir string `mapstructure:"anime_dir"`
}
// AIConfig configures the optional LLM provider.
type AIConfig struct {
Enabled bool `mapstructure:"enabled"`
Provider string `mapstructure:"provider"`
APIBase string `mapstructure:"api_base"`
APIKey string `mapstructure:"api_key"`
Model string `mapstructure:"model"`
Timeout int `mapstructure:"timeout"`
MaxConcurrent int `mapstructure:"max_concurrent"`
}
// Load reads configuration from defaults / files / environment.
//
// It always returns a usable Config, even if no files are present.
func Load() (*Config, error) {
v := viper.New()
setDefaults(v)
v.SetConfigName("config")
v.SetConfigType("yaml")
v.AddConfigPath(".")
v.AddConfigPath("./config")
if err := v.ReadInConfig(); err != nil {
var notFound viper.ConfigFileNotFoundError
if !asConfigFileNotFound(err, &notFound) {
return nil, fmt.Errorf("read main config: %w", err)
}
}
// Merge sharded files under ./config/*.yaml.
if entries, err := os.ReadDir("config"); err == nil {
for _, e := range entries {
if e.IsDir() || !strings.HasSuffix(e.Name(), ".yaml") {
continue
}
s := viper.New()
s.SetConfigFile(filepath.Join("config", e.Name()))
if err := s.ReadInConfig(); err == nil {
_ = v.MergeConfigMap(s.AllSettings())
}
}
}
v.SetEnvPrefix(EnvPrefix)
v.SetEnvKeyReplacer(strings.NewReplacer(".", "_"))
v.AutomaticEnv()
cfg := &Config{}
if err := v.Unmarshal(cfg); err != nil {
return nil, fmt.Errorf("decode config: %w", err)
}
if err := cfg.normalize(); err != nil {
return nil, err
}
return cfg, nil
}
func setDefaults(v *viper.Viper) {
v.SetDefault("app.port", 8080)
v.SetDefault("app.debug", false)
v.SetDefault("app.env", "production")
v.SetDefault("app.data_dir", "./data")
v.SetDefault("app.web_dir", "./web/dist")
v.SetDefault("app.ffmpeg_path", "ffmpeg")
v.SetDefault("app.ffprobe_path", "ffprobe")
v.SetDefault("app.vaapi_device", "/dev/dri/renderD128")
v.SetDefault("app.cors_origins", []string{})
v.SetDefault("app.server_url", "")
v.SetDefault("database.db_path", "./data/mediastation.db")
v.SetDefault("database.wal_mode", true)
v.SetDefault("database.busy_timeout", 5000)
v.SetDefault("database.cache_size", -20000)
v.SetDefault("database.max_open_conns", 1)
v.SetDefault("database.max_idle_conns", 1)
v.SetDefault("secrets.jwt_secret", "")
v.SetDefault("logging.level", "info")
v.SetDefault("logging.format", "console")
v.SetDefault("logging.max_size_mb", 100)
v.SetDefault("logging.max_age_days", 30)
v.SetDefault("logging.max_backups", 10)
v.SetDefault("cache.cache_dir", "./cache")
v.SetDefault("cache.cleanup_interval_min", 60)
v.SetDefault("ai.enabled", false)
v.SetDefault("ai.provider", "openai")
v.SetDefault("ai.api_base", "https://api.openai.com/v1")
v.SetDefault("ai.model", "gpt-4o-mini")
v.SetDefault("ai.timeout", 30)
v.SetDefault("ai.max_concurrent", 3)
}
// normalize fills derived defaults and self-heals empty critical fields.
func (c *Config) normalize() error {
if c.App.DataDir == "" {
c.App.DataDir = "./data"
}
if c.Database.DBPath == "" {
c.Database.DBPath = filepath.Join(c.App.DataDir, "mediastation.db")
}
if c.Cache.CacheDir == "" {
c.Cache.CacheDir = filepath.Join(c.App.DataDir, "cache")
}
if c.Secrets.JWTSecret == "" {
// Persist an auto-generated secret to keep sessions stable across
// restarts even when the operator forgot to configure one.
path := filepath.Join(c.App.DataDir, ".jwt_secret")
if data, err := os.ReadFile(path); err == nil && len(data) > 0 {
c.Secrets.JWTSecret = strings.TrimSpace(string(data))
} else {
buf := make([]byte, 32)
if _, err := rand.Read(buf); err != nil {
return fmt.Errorf("generate jwt secret: %w", err)
}
c.Secrets.JWTSecret = hex.EncodeToString(buf)
_ = os.MkdirAll(c.App.DataDir, 0o755)
_ = os.WriteFile(path, []byte(c.Secrets.JWTSecret), 0o600)
}
}
return nil
}
// asConfigFileNotFound is a small helper around errors.As that avoids importing
// errors in this short file.
func asConfigFileNotFound(err error, target *viper.ConfigFileNotFoundError) bool {
if err == nil {
return false
}
if v, ok := err.(viper.ConfigFileNotFoundError); ok {
*target = v
return true
}
return false
}
+64
View File
@@ -0,0 +1,64 @@
package config
import (
"os"
"path/filepath"
"testing"
)
// TestLoadDefaults asserts that a Load on a clean working directory yields
// usable, normalized defaults.
func TestLoadDefaults(t *testing.T) {
dir := t.TempDir()
t.Setenv("HOME", dir)
wd, _ := os.Getwd()
defer func() { _ = os.Chdir(wd) }()
if err := os.Chdir(dir); err != nil {
t.Fatalf("chdir: %v", err)
}
cfg, err := Load()
if err != nil {
t.Fatalf("Load() error: %v", err)
}
if cfg.App.Port != 8080 {
t.Fatalf("expected default port 8080, got %d", cfg.App.Port)
}
if cfg.Database.DBPath == "" {
t.Fatalf("expected non-empty DBPath")
}
if cfg.Secrets.JWTSecret == "" {
t.Fatalf("expected auto-generated JWT secret")
}
// Re-loading must reuse the persisted secret on disk.
cfg2, err := Load()
if err != nil {
t.Fatalf("second Load() error: %v", err)
}
if cfg.Secrets.JWTSecret != cfg2.Secrets.JWTSecret {
t.Fatalf("expected JWT secret to persist across Load() calls")
}
if _, err := os.Stat(filepath.Join(cfg.App.DataDir, ".jwt_secret")); err != nil {
t.Fatalf("expected jwt secret file: %v", err)
}
}
// TestEnvOverride checks that MEDIASTATION_* env vars override the defaults.
func TestEnvOverride(t *testing.T) {
dir := t.TempDir()
wd, _ := os.Getwd()
defer func() { _ = os.Chdir(wd) }()
if err := os.Chdir(dir); err != nil {
t.Fatalf("chdir: %v", err)
}
t.Setenv("MEDIASTATION_APP_PORT", "9090")
cfg, err := Load()
if err != nil {
t.Fatalf("Load() error: %v", err)
}
if cfg.App.Port != 9090 {
t.Fatalf("expected port 9090 from env, got %d", cfg.App.Port)
}
}
+83
View File
@@ -0,0 +1,83 @@
// Package database wires up GORM against SQLite (WAL mode) and exposes the
// auto-migration entry point used at startup.
package database
import (
"fmt"
"path/filepath"
"github.com/glebarez/sqlite"
"go.uber.org/zap"
"gorm.io/gorm"
"gorm.io/gorm/logger"
"github.com/ShukeBta/MediaStationGo/internal/config"
"github.com/ShukeBta/MediaStationGo/internal/model"
)
// Open initialises the SQLite database file applying WAL pragmas for
// better concurrent read performance — same defaults as nowen-video.
func Open(cfg *config.Config, log *zap.Logger) (*gorm.DB, error) {
dsn := buildDSN(cfg)
gormLogger := logger.New(
zapStdLogger{log: log},
logger.Config{
SlowThreshold: 0,
LogLevel: logger.Warn,
IgnoreRecordNotFoundError: true,
Colorful: false,
},
)
db, err := gorm.Open(sqlite.Open(dsn), &gorm.Config{
Logger: gormLogger,
PrepareStmt: true,
DisableForeignKeyConstraintWhenMigrating: false,
})
if err != nil {
return nil, fmt.Errorf("gorm open: %w", err)
}
sqlDB, err := db.DB()
if err != nil {
return nil, fmt.Errorf("gorm sqldb: %w", err)
}
if cfg.Database.MaxOpenConns > 0 {
sqlDB.SetMaxOpenConns(cfg.Database.MaxOpenConns)
}
if cfg.Database.MaxIdleConns > 0 {
sqlDB.SetMaxIdleConns(cfg.Database.MaxIdleConns)
}
return db, nil
}
func buildDSN(cfg *config.Config) string {
dbPath := cfg.Database.DBPath
if !filepath.IsAbs(dbPath) {
// keep as-is to respect user-provided relative paths.
dbPath = filepath.Clean(dbPath)
}
dsn := dbPath + "?_pragma=foreign_keys(1)"
if cfg.Database.WALMode {
dsn += "&_pragma=journal_mode(WAL)"
}
if cfg.Database.BusyTimeout > 0 {
dsn += fmt.Sprintf("&_pragma=busy_timeout(%d)", cfg.Database.BusyTimeout)
}
if cfg.Database.CacheSize != 0 {
dsn += fmt.Sprintf("&_pragma=cache_size(%d)", cfg.Database.CacheSize)
}
return dsn
}
// AutoMigrate creates tables for every model registered in the model package.
func AutoMigrate(db *gorm.DB) error {
return db.AutoMigrate(model.AllModels()...)
}
// zapStdLogger adapts a *zap.Logger to GORM's tiny logger interface.
type zapStdLogger struct{ log *zap.Logger }
func (z zapStdLogger) Printf(format string, args ...interface{}) {
z.log.Sugar().Infof(format, args...)
}
+73
View File
@@ -0,0 +1,73 @@
// Package handler — admin endpoints (users / settings / logs).
package handler
import (
"net/http"
"github.com/gin-gonic/gin"
"github.com/ShukeBta/MediaStationGo/internal/service"
)
func listUsersHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
users, err := svc.Repo.User.List(c.Request.Context())
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, users)
}
}
func deleteUserHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
if err := svc.Repo.User.Delete(c.Request.Context(), c.Param("id")); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.Status(http.StatusNoContent)
}
}
type settingReq struct {
Key string `json:"key" binding:"required"`
Value string `json:"value"`
}
func listSettingsHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
settings, err := svc.Repo.Setting.All(c.Request.Context())
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, settings)
}
}
func updateSettingHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
var req settingReq
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
if err := svc.Repo.Setting.Set(c.Request.Context(), req.Key, req.Value); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.Status(http.StatusNoContent)
}
}
func recentLogsHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
rows, err := svc.Repo.Log.Recent(c.Request.Context(), 200)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, rows)
}
}
+98
View File
@@ -0,0 +1,98 @@
// Package handler — auth-related HTTP endpoints.
package handler
import (
"errors"
"net/http"
"github.com/gin-gonic/gin"
"github.com/ShukeBta/MediaStationGo/internal/middleware"
"github.com/ShukeBta/MediaStationGo/internal/service"
)
type loginReq struct {
Username string `json:"username" binding:"required"`
Password string `json:"password" binding:"required"`
}
type registerReq struct {
Username string `json:"username" binding:"required"`
Password string `json:"password" binding:"required,min=6"`
}
func loginHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
var req loginReq
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
u, token, err := svc.Auth.Login(c.Request.Context(), req.Username, req.Password)
if err != nil {
if errors.Is(err, service.ErrInvalidCredentials) {
c.JSON(http.StatusUnauthorized, gin.H{"error": "invalid credentials"})
return
}
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{
"token": token,
"user": u,
})
}
}
func registerHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
var req registerReq
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
u, err := svc.Auth.Register(c.Request.Context(), req.Username, req.Password)
if err != nil {
if errors.Is(err, service.ErrUsernameTaken) {
c.JSON(http.StatusConflict, gin.H{"error": "username taken"})
return
}
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, u)
}
}
func meHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
uid, _ := c.Get(middleware.CtxUserID)
u, err := svc.Repo.User.FindByID(c.Request.Context(), uid.(string))
if err != nil || u == nil {
c.JSON(http.StatusNotFound, gin.H{"error": "not found"})
return
}
c.JSON(http.StatusOK, u)
}
}
type changePwdReq struct {
OldPassword string `json:"old_password" binding:"required"`
NewPassword string `json:"new_password" binding:"required,min=6"`
}
func changePasswordHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
var req changePwdReq
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
uid, _ := c.Get(middleware.CtxUserID)
if err := svc.Auth.ChangePassword(c.Request.Context(), uid.(string), req.OldPassword, req.NewPassword); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
c.Status(http.StatusNoContent)
}
}
+70
View File
@@ -0,0 +1,70 @@
// Package handler wires the HTTP routes to the service container.
//
// All routes are mounted under /api/* (matching the original MediaStation
// surface) so the frontend dev-server can proxy a single prefix.
package handler
import (
"github.com/gin-gonic/gin"
"go.uber.org/zap"
"github.com/ShukeBta/MediaStationGo/internal/config"
"github.com/ShukeBta/MediaStationGo/internal/middleware"
"github.com/ShukeBta/MediaStationGo/internal/service"
)
// Register attaches every API route to the engine.
func Register(r *gin.Engine, cfg *config.Config, log *zap.Logger, svc *service.Container) {
api := r.Group("/api")
{
api.GET("/health", healthCheck)
api.GET("/version", versionInfo)
// Public auth.
auth := api.Group("/auth")
{
auth.POST("/login", loginHandler(svc))
auth.POST("/register", registerHandler(svc))
}
// Authenticated endpoints.
authed := api.Group("/")
authed.Use(middleware.AuthRequired(cfg.Secrets.JWTSecret))
{
authed.GET("/me", meHandler(svc))
authed.POST("/me/password", changePasswordHandler(svc))
authed.GET("/libraries", listLibrariesHandler(svc))
authed.POST("/libraries", middleware.AdminRequired(), createLibraryHandler(svc))
authed.DELETE("/libraries/:id", middleware.AdminRequired(), deleteLibraryHandler(svc))
authed.POST("/libraries/:id/scan", middleware.AdminRequired(), scanLibraryHandler(svc))
authed.GET("/libraries/:id/media", listMediaHandler(svc))
authed.GET("/media/:id", getMediaHandler(svc))
authed.GET("/media", searchMediaHandler(svc))
authed.GET("/stream/:id", streamHandler(svc))
authed.GET("/ws", wsHandler(svc))
}
// Admin-only endpoints.
admin := api.Group("/admin")
admin.Use(middleware.AuthRequired(cfg.Secrets.JWTSecret), middleware.AdminRequired())
{
admin.GET("/users", listUsersHandler(svc))
admin.DELETE("/users/:id", deleteUserHandler(svc))
admin.GET("/settings", listSettingsHandler(svc))
admin.PUT("/settings", updateSettingHandler(svc))
admin.GET("/logs", recentLogsHandler(svc))
}
}
}
func healthCheck(c *gin.Context) {
c.JSON(200, gin.H{"status": "ok"})
}
func versionInfo(c *gin.Context) {
c.JSON(200, gin.H{"name": "MediaStationGo", "version": "0.1.0"})
}
+132
View File
@@ -0,0 +1,132 @@
// Package handler — library / media HTTP endpoints.
package handler
import (
"errors"
"net/http"
"strconv"
"github.com/gin-gonic/gin"
"github.com/ShukeBta/MediaStationGo/internal/service"
)
type createLibraryReq struct {
Name string `json:"name" binding:"required"`
Path string `json:"path" binding:"required"`
Type string `json:"type"`
}
func listLibrariesHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
libs, err := svc.Media.ListLibraries(c.Request.Context())
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, libs)
}
}
func createLibraryHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
var req createLibraryReq
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
l, err := svc.Media.CreateLibrary(c.Request.Context(), req.Name, req.Path, req.Type)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, l)
}
}
func deleteLibraryHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
id := c.Param("id")
if err := svc.Media.DeleteLibrary(c.Request.Context(), id); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.Status(http.StatusNoContent)
}
}
func scanLibraryHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
id := c.Param("id")
// Run synchronously: small libraries return immediately, big ones can
// hit the (configurable) HTTP timeout. A future task queue can move
// this to a background worker.
res, err := svc.Scan.ScanLibrary(c.Request.Context(), id)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, res)
}
}
func listMediaHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
id := c.Param("id")
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
size, _ := strconv.Atoi(c.DefaultQuery("page_size", "50"))
items, total, err := svc.Media.ListMedia(c.Request.Context(), id, page, size)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{
"items": items,
"total": total,
"page": page,
"page_size": size,
})
}
}
func getMediaHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
m, err := svc.Media.GetMedia(c.Request.Context(), c.Param("id"))
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
if m == nil {
c.JSON(http.StatusNotFound, gin.H{"error": "not found"})
return
}
c.JSON(http.StatusOK, m)
}
}
func searchMediaHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
q := c.Query("q")
limit, _ := strconv.Atoi(c.DefaultQuery("limit", "50"))
items, err := svc.Media.SearchMedia(c.Request.Context(), q, limit)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"items": items})
}
}
func streamHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
err := svc.Stream.ServeFile(c.Writer, c.Request, c.Param("id"))
if errors.Is(err, service.ErrMediaNotFound) {
c.JSON(http.StatusNotFound, gin.H{"error": "not found"})
return
}
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
}
}
+74
View File
@@ -0,0 +1,74 @@
// Package handler — WebSocket endpoint.
//
// Clients connect to /api/ws?token=... (the token is the same JWT used for
// REST calls). The first message they send is a JSON {"action":"subscribe",
// "topics":["scan","scrape","transcode"]}. Subsequent server-pushed events
// arrive as {"topic":"...","payload":{...}}.
package handler
import (
"encoding/json"
"net/http"
"time"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
"github.com/gorilla/websocket"
"github.com/ShukeBta/MediaStationGo/internal/service"
)
var wsUpgrader = websocket.Upgrader{
ReadBufferSize: 1024,
WriteBufferSize: 1024,
// Allow any origin: the AuthRequired middleware already validated the
// JWT before we got here, and we never serve sensitive cross-domain
// state through the socket.
CheckOrigin: func(_ *http.Request) bool { return true },
}
func wsHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
conn, err := wsUpgrader.Upgrade(c.Writer, c.Request, nil)
if err != nil {
return
}
defer conn.Close()
id := uuid.NewString()
sub := svc.WSHub.Subscribe(id, nil)
defer svc.WSHub.Unsubscribe(id)
// Reader: accept ping + subscription updates.
go func() {
for {
if _, _, err := conn.NextReader(); err != nil {
_ = conn.Close()
return
}
}
}()
// Writer: drain hub events into the socket with a periodic ping.
ticker := time.NewTicker(30 * time.Second)
defer ticker.Stop()
for {
select {
case ev, ok := <-sub.Out:
if !ok {
return
}
data, _ := json.Marshal(ev)
_ = conn.SetWriteDeadline(time.Now().Add(10 * time.Second))
if err := conn.WriteMessage(websocket.TextMessage, data); err != nil {
return
}
case <-ticker.C:
_ = conn.SetWriteDeadline(time.Now().Add(10 * time.Second))
if err := conn.WriteMessage(websocket.PingMessage, nil); err != nil {
return
}
}
}
}
}
+117
View File
@@ -0,0 +1,117 @@
// Package middleware exposes Gin middlewares used by the HTTP server:
// request logging, CORS, JWT authentication and admin guard.
package middleware
import (
"errors"
"net/http"
"strings"
"time"
"github.com/gin-gonic/gin"
"github.com/golang-jwt/jwt/v5"
"go.uber.org/zap"
)
// Context keys for values produced by the auth middleware.
const (
CtxUserID = "ctx_user_id"
CtxUserRole = "ctx_user_role"
)
// RequestLogger logs one structured line per request.
func RequestLogger(log *zap.Logger) gin.HandlerFunc {
return func(c *gin.Context) {
start := time.Now()
c.Next()
log.Info("http",
zap.String("method", c.Request.Method),
zap.String("path", c.Request.URL.Path),
zap.Int("status", c.Writer.Status()),
zap.Duration("dur", time.Since(start)),
zap.String("ip", c.ClientIP()),
)
}
}
// CORS implements a permissive cross-origin policy when origins is empty
// (development convenience) and a strict allow-list otherwise.
func CORS(origins []string) gin.HandlerFunc {
allowAll := len(origins) == 0
allowed := make(map[string]struct{}, len(origins))
for _, o := range origins {
allowed[strings.TrimSpace(o)] = struct{}{}
}
return func(c *gin.Context) {
origin := c.GetHeader("Origin")
if allowAll {
c.Header("Access-Control-Allow-Origin", "*")
} else if _, ok := allowed[origin]; ok && origin != "" {
c.Header("Access-Control-Allow-Origin", origin)
c.Header("Access-Control-Allow-Credentials", "true")
c.Header("Vary", "Origin")
}
c.Header("Access-Control-Allow-Methods", "GET, POST, PUT, PATCH, DELETE, OPTIONS")
c.Header("Access-Control-Allow-Headers", "Authorization, Content-Type, X-Requested-With")
if c.Request.Method == http.MethodOptions {
c.AbortWithStatus(http.StatusNoContent)
return
}
c.Next()
}
}
// Claims is the JWT payload we issue.
type Claims struct {
UserID string `json:"uid"`
Role string `json:"role"`
jwt.RegisteredClaims
}
// AuthRequired parses and validates a JWT from the Authorization header
// (Bearer ...) or the `token` query parameter (used by <video>.src).
func AuthRequired(secret string) gin.HandlerFunc {
return func(c *gin.Context) {
raw := extractToken(c)
if raw == "" {
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "missing token"})
return
}
claims := &Claims{}
_, err := jwt.ParseWithClaims(raw, claims, func(t *jwt.Token) (interface{}, error) {
if _, ok := t.Method.(*jwt.SigningMethodHMAC); !ok {
return nil, errors.New("unexpected signing method")
}
return []byte(secret), nil
})
if err != nil || claims.UserID == "" {
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "invalid token"})
return
}
c.Set(CtxUserID, claims.UserID)
c.Set(CtxUserRole, claims.Role)
c.Next()
}
}
// AdminRequired must run AFTER AuthRequired; it enforces role == "admin".
func AdminRequired() gin.HandlerFunc {
return func(c *gin.Context) {
role, _ := c.Get(CtxUserRole)
if role != "admin" {
c.AbortWithStatusJSON(http.StatusForbidden, gin.H{"error": "admin only"})
return
}
c.Next()
}
}
func extractToken(c *gin.Context) string {
if h := c.GetHeader("Authorization"); strings.HasPrefix(h, "Bearer ") {
return strings.TrimSpace(strings.TrimPrefix(h, "Bearer "))
}
if q := c.Query("token"); q != "" {
return q
}
return ""
}
+189
View File
@@ -0,0 +1,189 @@
// 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"`
}
// 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{},
}
}
+391
View File
@@ -0,0 +1,391 @@
// Package repository implements a thin GORM-based data-access layer over the
// types declared in internal/model. Each method takes a context.Context so we
// can plug in cancellation / tracing later.
//
// Repositories are intentionally narrow: they only know how to persist data,
// not how to interpret it. Domain logic lives in internal/service.
package repository
import (
"context"
"errors"
"time"
"gorm.io/gorm"
"github.com/ShukeBta/MediaStationGo/internal/model"
)
// Container is the registry of all repositories injected into services.
type Container struct {
DB *gorm.DB
User *UserRepository
Library *LibraryRepository
Media *MediaRepository
Series *SeriesRepository
History *HistoryRepository
Favorite *FavoriteRepository
Playlist *PlaylistRepository
Download *DownloadRepository
Subscription *SubscriptionRepository
Setting *SettingRepository
Log *AccessLogRepository
}
// New wires every repository to a single *gorm.DB.
func New(db *gorm.DB) *Container {
return &Container{
DB: db,
User: &UserRepository{db: db},
Library: &LibraryRepository{db: db},
Media: &MediaRepository{db: db},
Series: &SeriesRepository{db: db},
History: &HistoryRepository{db: db},
Favorite: &FavoriteRepository{db: db},
Playlist: &PlaylistRepository{db: db},
Download: &DownloadRepository{db: db},
Subscription: &SubscriptionRepository{db: db},
Setting: &SettingRepository{db: db},
Log: &AccessLogRepository{db: db},
}
}
// ─── User ────────────────────────────────────────────────────────────────────
// UserRepository persists model.User records.
type UserRepository struct{ db *gorm.DB }
// Create inserts a new user. Caller must pre-hash the password.
func (r *UserRepository) Create(ctx context.Context, u *model.User) error {
return r.db.WithContext(ctx).Create(u).Error
}
// FindByUsername returns the user matching username, or (nil, nil) when absent.
func (r *UserRepository) FindByUsername(ctx context.Context, username string) (*model.User, error) {
var u model.User
err := r.db.WithContext(ctx).Where("username = ?", username).First(&u).Error
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, nil
}
if err != nil {
return nil, err
}
return &u, nil
}
// FindByID returns the user with the matching primary key, or (nil, nil).
func (r *UserRepository) FindByID(ctx context.Context, id string) (*model.User, error) {
var u model.User
err := r.db.WithContext(ctx).Where("id = ?", id).First(&u).Error
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, nil
}
if err != nil {
return nil, err
}
return &u, nil
}
// CountAdmins returns the number of users that hold the admin role.
func (r *UserRepository) CountAdmins(ctx context.Context) (int64, error) {
var n int64
err := r.db.WithContext(ctx).Model(&model.User{}).
Where("role = ?", "admin").Count(&n).Error
return n, err
}
// List returns all users ordered by creation time desc.
func (r *UserRepository) List(ctx context.Context) ([]model.User, error) {
var users []model.User
err := r.db.WithContext(ctx).Order("created_at desc").Find(&users).Error
return users, err
}
// UpdatePassword sets a new password hash and clears ForcePasswordReset.
func (r *UserRepository) UpdatePassword(ctx context.Context, id, hash string) error {
return r.db.WithContext(ctx).Model(&model.User{}).Where("id = ?", id).
Updates(map[string]any{"password_hash": hash, "force_password_reset": false}).Error
}
// TouchLogin updates the last login timestamp.
func (r *UserRepository) TouchLogin(ctx context.Context, id string) error {
now := time.Now()
return r.db.WithContext(ctx).Model(&model.User{}).Where("id = ?", id).
Update("last_login_at", &now).Error
}
// Delete removes a user (soft-delete via gorm.DeletedAt).
func (r *UserRepository) Delete(ctx context.Context, id string) error {
return r.db.WithContext(ctx).Delete(&model.User{}, "id = ?", id).Error
}
// ─── Library ─────────────────────────────────────────────────────────────────
// LibraryRepository persists model.Library records.
type LibraryRepository struct{ db *gorm.DB }
// Create persists a new library row.
func (r *LibraryRepository) Create(ctx context.Context, l *model.Library) error {
return r.db.WithContext(ctx).Create(l).Error
}
// List returns all enabled+disabled libraries.
func (r *LibraryRepository) List(ctx context.Context) ([]model.Library, error) {
var ls []model.Library
err := r.db.WithContext(ctx).Order("created_at asc").Find(&ls).Error
return ls, err
}
// FindByID returns the library, or (nil, nil) when missing.
func (r *LibraryRepository) FindByID(ctx context.Context, id string) (*model.Library, error) {
var l model.Library
err := r.db.WithContext(ctx).Where("id = ?", id).First(&l).Error
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, nil
}
if err != nil {
return nil, err
}
return &l, nil
}
// Delete removes a library and (soft) cascades to its media via repository
// callers — we do not run CASCADE here to keep this method narrow.
func (r *LibraryRepository) Delete(ctx context.Context, id string) error {
return r.db.WithContext(ctx).Delete(&model.Library{}, "id = ?", id).Error
}
// ─── Media ───────────────────────────────────────────────────────────────────
// MediaRepository persists model.Media records.
type MediaRepository struct{ db *gorm.DB }
// Upsert inserts or updates a media row keyed by Path (unique index).
func (r *MediaRepository) Upsert(ctx context.Context, m *model.Media) error {
return r.db.WithContext(ctx).Where("path = ?", m.Path).
Assign(*m).FirstOrCreate(m).Error
}
// FindByID returns the media row or (nil, nil).
func (r *MediaRepository) FindByID(ctx context.Context, id string) (*model.Media, error) {
var m model.Media
err := r.db.WithContext(ctx).Where("id = ?", id).First(&m).Error
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, nil
}
if err != nil {
return nil, err
}
return &m, nil
}
// ListByLibrary returns paginated media items for a library.
func (r *MediaRepository) ListByLibrary(ctx context.Context, libraryID string, offset, limit int) ([]model.Media, int64, error) {
var items []model.Media
var total int64
q := r.db.WithContext(ctx).Model(&model.Media{}).Where("library_id = ?", libraryID)
if err := q.Count(&total).Error; err != nil {
return nil, 0, err
}
err := q.Order("created_at desc").Offset(offset).Limit(limit).Find(&items).Error
return items, total, err
}
// Search runs a LIKE search against the title field. Empty query returns the
// most recently added items.
func (r *MediaRepository) Search(ctx context.Context, query string, limit int) ([]model.Media, error) {
var items []model.Media
q := r.db.WithContext(ctx).Model(&model.Media{}).Limit(limit)
if query != "" {
like := "%" + query + "%"
q = q.Where("title LIKE ? OR original_name LIKE ?", like, like)
}
err := q.Order("created_at desc").Find(&items).Error
return items, err
}
// DeleteByLibrary purges all media tied to a library.
func (r *MediaRepository) DeleteByLibrary(ctx context.Context, libraryID string) error {
return r.db.WithContext(ctx).Where("library_id = ?", libraryID).Delete(&model.Media{}).Error
}
// ─── Series ──────────────────────────────────────────────────────────────────
// SeriesRepository persists model.Series records.
type SeriesRepository struct{ db *gorm.DB }
// FindByID returns the series or (nil, nil).
func (r *SeriesRepository) FindByID(ctx context.Context, id string) (*model.Series, error) {
var s model.Series
err := r.db.WithContext(ctx).Where("id = ?", id).First(&s).Error
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, nil
}
if err != nil {
return nil, err
}
return &s, nil
}
// List returns all series (ordered by title).
func (r *SeriesRepository) List(ctx context.Context) ([]model.Series, error) {
var s []model.Series
err := r.db.WithContext(ctx).Order("title asc").Find(&s).Error
return s, err
}
// ─── Playback History ────────────────────────────────────────────────────────
// HistoryRepository persists model.PlaybackHistory entries. The application
// upserts on (UserID, MediaID) so resume always reads the latest position.
type HistoryRepository struct{ db *gorm.DB }
// Upsert atomically inserts/updates the resume position.
func (r *HistoryRepository) Upsert(ctx context.Context, h *model.PlaybackHistory) error {
var existing model.PlaybackHistory
err := r.db.WithContext(ctx).
Where("user_id = ? AND media_id = ?", h.UserID, h.MediaID).
First(&existing).Error
if errors.Is(err, gorm.ErrRecordNotFound) {
return r.db.WithContext(ctx).Create(h).Error
}
if err != nil {
return err
}
existing.PositionMs = h.PositionMs
existing.DurationMs = h.DurationMs
existing.WatchedAt = h.WatchedAt
existing.Completed = h.Completed
return r.db.WithContext(ctx).Save(&existing).Error
}
// ListByUser returns the most recent history rows for the user.
func (r *HistoryRepository) ListByUser(ctx context.Context, userID string, limit int) ([]model.PlaybackHistory, error) {
var rows []model.PlaybackHistory
err := r.db.WithContext(ctx).Where("user_id = ?", userID).
Order("watched_at desc").Limit(limit).Find(&rows).Error
return rows, err
}
// ─── Favorite ────────────────────────────────────────────────────────────────
// FavoriteRepository persists model.Favorite records.
type FavoriteRepository struct{ db *gorm.DB }
// Toggle flips the favourite flag for (user, media). Returns the new state.
func (r *FavoriteRepository) Toggle(ctx context.Context, userID, mediaID string) (bool, error) {
var f model.Favorite
err := r.db.WithContext(ctx).Where("user_id = ? AND media_id = ?", userID, mediaID).First(&f).Error
if errors.Is(err, gorm.ErrRecordNotFound) {
fav := model.Favorite{UserID: userID, MediaID: mediaID}
return true, r.db.WithContext(ctx).Create(&fav).Error
}
if err != nil {
return false, err
}
return false, r.db.WithContext(ctx).Delete(&f).Error
}
// ListByUser returns all favourite media IDs for a user.
func (r *FavoriteRepository) ListByUser(ctx context.Context, userID string) ([]model.Favorite, error) {
var rows []model.Favorite
err := r.db.WithContext(ctx).Where("user_id = ?", userID).Find(&rows).Error
return rows, err
}
// ─── Playlist ────────────────────────────────────────────────────────────────
// PlaylistRepository persists model.Playlist + PlaylistItem.
type PlaylistRepository struct{ db *gorm.DB }
// Create inserts a new playlist.
func (r *PlaylistRepository) Create(ctx context.Context, p *model.Playlist) error {
return r.db.WithContext(ctx).Create(p).Error
}
// ListByUser returns playlists owned by a user.
func (r *PlaylistRepository) ListByUser(ctx context.Context, userID string) ([]model.Playlist, error) {
var rows []model.Playlist
err := r.db.WithContext(ctx).Where("user_id = ?", userID).
Order("created_at desc").Find(&rows).Error
return rows, err
}
// ─── Download ────────────────────────────────────────────────────────────────
// DownloadRepository persists model.DownloadTask records.
type DownloadRepository struct{ db *gorm.DB }
// Create inserts a new download task.
func (r *DownloadRepository) Create(ctx context.Context, t *model.DownloadTask) error {
return r.db.WithContext(ctx).Create(t).Error
}
// List returns all download tasks (admin view).
func (r *DownloadRepository) List(ctx context.Context) ([]model.DownloadTask, error) {
var rows []model.DownloadTask
err := r.db.WithContext(ctx).Order("created_at desc").Find(&rows).Error
return rows, err
}
// ─── Subscription ────────────────────────────────────────────────────────────
// SubscriptionRepository persists model.Subscription records.
type SubscriptionRepository struct{ db *gorm.DB }
// Create inserts a new subscription rule.
func (r *SubscriptionRepository) Create(ctx context.Context, s *model.Subscription) error {
return r.db.WithContext(ctx).Create(s).Error
}
// List returns all subscription rules.
func (r *SubscriptionRepository) List(ctx context.Context) ([]model.Subscription, error) {
var rows []model.Subscription
err := r.db.WithContext(ctx).Order("created_at desc").Find(&rows).Error
return rows, err
}
// ─── Setting ─────────────────────────────────────────────────────────────────
// SettingRepository persists key/value preferences.
type SettingRepository struct{ db *gorm.DB }
// Get returns the value or empty string when absent.
func (r *SettingRepository) Get(ctx context.Context, key string) (string, error) {
var s model.Setting
err := r.db.WithContext(ctx).Where("key = ?", key).First(&s).Error
if errors.Is(err, gorm.ErrRecordNotFound) {
return "", nil
}
return s.Value, err
}
// Set upserts a setting value.
func (r *SettingRepository) Set(ctx context.Context, key, value string) error {
s := model.Setting{Key: key, Value: value, UpdatedAt: time.Now()}
return r.db.WithContext(ctx).Save(&s).Error
}
// All returns every key/value pair (used by the admin UI).
func (r *SettingRepository) All(ctx context.Context) ([]model.Setting, error) {
var rows []model.Setting
err := r.db.WithContext(ctx).Find(&rows).Error
return rows, err
}
// ─── Access Log ──────────────────────────────────────────────────────────────
// AccessLogRepository persists model.AccessLog records.
type AccessLogRepository struct{ db *gorm.DB }
// Create inserts one structured audit-trail entry.
func (r *AccessLogRepository) Create(ctx context.Context, l *model.AccessLog) error {
return r.db.WithContext(ctx).Create(l).Error
}
// Recent returns the latest access-log entries (admin Activity panel).
func (r *AccessLogRepository) Recent(ctx context.Context, limit int) ([]model.AccessLog, error) {
var rows []model.AccessLog
err := r.db.WithContext(ctx).Order("created_at desc").Limit(limit).Find(&rows).Error
return rows, err
}
+163
View File
@@ -0,0 +1,163 @@
// Package service — authentication / user management.
package service
import (
"context"
"errors"
"fmt"
"os"
"strings"
"time"
"github.com/golang-jwt/jwt/v5"
"go.uber.org/zap"
"golang.org/x/crypto/bcrypt"
"github.com/ShukeBta/MediaStationGo/internal/config"
"github.com/ShukeBta/MediaStationGo/internal/middleware"
"github.com/ShukeBta/MediaStationGo/internal/model"
"github.com/ShukeBta/MediaStationGo/internal/repository"
)
// AuthService handles registration, login, and JWT issuance.
type AuthService struct {
cfg *config.Config
log *zap.Logger
repo *repository.Container
}
// NewAuthService is the constructor.
func NewAuthService(cfg *config.Config, log *zap.Logger, repo *repository.Container) *AuthService {
return &AuthService{cfg: cfg, log: log, repo: repo}
}
// Common service-level errors.
var (
ErrInvalidCredentials = errors.New("invalid username or password")
ErrUsernameTaken = errors.New("username already taken")
)
// SeedAdmin makes sure at least one admin user exists. It mirrors the
// MediaStation behaviour: if no admin row is found we create
// `admin / admin123` (overridable through ADMIN_INITIAL_PASSWORD) and warn.
func (s *AuthService) SeedAdmin(ctx context.Context) error {
n, err := s.repo.User.CountAdmins(ctx)
if err != nil {
return err
}
if n > 0 {
return nil
}
pwd := os.Getenv("ADMIN_INITIAL_PASSWORD")
if pwd == "" {
pwd = "admin123"
}
hash, err := hashPassword(pwd)
if err != nil {
return err
}
user := &model.User{
Username: "admin",
PasswordHash: hash,
Role: "admin",
ForcePasswordReset: pwd == "admin123",
}
if err := s.repo.User.Create(ctx, user); err != nil {
return err
}
s.log.Warn("default admin created — change the password after first login",
zap.String("username", "admin"),
zap.String("password_source", "ADMIN_INITIAL_PASSWORD or admin123"),
)
return nil
}
// Register creates a new user. The first registered user is auto-promoted to
// admin to support fresh installs that did not run SeedAdmin.
func (s *AuthService) Register(ctx context.Context, username, password string) (*model.User, error) {
username = strings.TrimSpace(username)
if username == "" || password == "" {
return nil, fmt.Errorf("username and password required")
}
if existing, err := s.repo.User.FindByUsername(ctx, username); err != nil {
return nil, err
} else if existing != nil {
return nil, ErrUsernameTaken
}
hash, err := hashPassword(password)
if err != nil {
return nil, err
}
role := "user"
if n, err := s.repo.User.CountAdmins(ctx); err == nil && n == 0 {
role = "admin"
}
u := &model.User{Username: username, PasswordHash: hash, Role: role}
if err := s.repo.User.Create(ctx, u); err != nil {
return nil, err
}
return u, nil
}
// Login validates credentials and returns the user + a fresh JWT.
func (s *AuthService) Login(ctx context.Context, username, password string) (*model.User, string, error) {
u, err := s.repo.User.FindByUsername(ctx, username)
if err != nil {
return nil, "", err
}
if u == nil {
return nil, "", ErrInvalidCredentials
}
if err := bcrypt.CompareHashAndPassword([]byte(u.PasswordHash), []byte(password)); err != nil {
return nil, "", ErrInvalidCredentials
}
token, err := s.IssueToken(u)
if err != nil {
return nil, "", err
}
_ = s.repo.User.TouchLogin(ctx, u.ID)
return u, token, nil
}
// ChangePassword updates the user password if the old one matches.
func (s *AuthService) ChangePassword(ctx context.Context, userID, oldPwd, newPwd string) error {
u, err := s.repo.User.FindByID(ctx, userID)
if err != nil {
return err
}
if u == nil {
return ErrInvalidCredentials
}
if err := bcrypt.CompareHashAndPassword([]byte(u.PasswordHash), []byte(oldPwd)); err != nil {
return ErrInvalidCredentials
}
hash, err := hashPassword(newPwd)
if err != nil {
return err
}
return s.repo.User.UpdatePassword(ctx, userID, hash)
}
// IssueToken signs a JWT for the given user (24h validity).
func (s *AuthService) IssueToken(u *model.User) (string, error) {
claims := middleware.Claims{
UserID: u.ID,
Role: u.Role,
RegisteredClaims: jwt.RegisteredClaims{
IssuedAt: jwt.NewNumericDate(time.Now()),
ExpiresAt: jwt.NewNumericDate(time.Now().Add(24 * time.Hour)),
Issuer: "mediastationgo",
Subject: u.ID,
},
}
t := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
return t.SignedString([]byte(s.cfg.Secrets.JWTSecret))
}
func hashPassword(p string) (string, error) {
h, err := bcrypt.GenerateFromPassword([]byte(p), bcrypt.DefaultCost)
if err != nil {
return "", err
}
return string(h), nil
}
+88
View File
@@ -0,0 +1,88 @@
// Package service — library / media bookkeeping.
package service
import (
"context"
"errors"
"fmt"
"os"
"path/filepath"
"go.uber.org/zap"
"github.com/ShukeBta/MediaStationGo/internal/config"
"github.com/ShukeBta/MediaStationGo/internal/model"
"github.com/ShukeBta/MediaStationGo/internal/repository"
)
// MediaService offers high-level CRUD over libraries and media items.
type MediaService struct {
cfg *config.Config
log *zap.Logger
repo *repository.Container
}
// NewMediaService is the constructor.
func NewMediaService(cfg *config.Config, log *zap.Logger, repo *repository.Container) *MediaService {
return &MediaService{cfg: cfg, log: log, repo: repo}
}
// CreateLibrary persists a library after validating that its path exists.
func (s *MediaService) CreateLibrary(ctx context.Context, name, path, kind string) (*model.Library, error) {
if name == "" || path == "" {
return nil, errors.New("name and path required")
}
abs, err := filepath.Abs(path)
if err != nil {
return nil, fmt.Errorf("invalid path: %w", err)
}
if info, err := os.Stat(abs); err != nil || !info.IsDir() {
return nil, fmt.Errorf("path is not an accessible directory: %s", abs)
}
if kind == "" {
kind = "movie"
}
lib := &model.Library{Name: name, Path: abs, Type: kind, Enabled: true}
if err := s.repo.Library.Create(ctx, lib); err != nil {
return nil, err
}
return lib, nil
}
// ListLibraries returns every library configured on the server.
func (s *MediaService) ListLibraries(ctx context.Context) ([]model.Library, error) {
return s.repo.Library.List(ctx)
}
// DeleteLibrary removes a library and its media rows. The on-disk files are
// left untouched.
func (s *MediaService) DeleteLibrary(ctx context.Context, id string) error {
if err := s.repo.Media.DeleteByLibrary(ctx, id); err != nil {
return err
}
return s.repo.Library.Delete(ctx, id)
}
// ListMedia paginates media items inside a library.
func (s *MediaService) ListMedia(ctx context.Context, libraryID string, page, pageSize int) ([]model.Media, int64, error) {
if pageSize <= 0 || pageSize > 200 {
pageSize = 50
}
if page < 1 {
page = 1
}
return s.repo.Media.ListByLibrary(ctx, libraryID, (page-1)*pageSize, pageSize)
}
// SearchMedia performs a simple LIKE search across titles.
func (s *MediaService) SearchMedia(ctx context.Context, query string, limit int) ([]model.Media, error) {
if limit <= 0 || limit > 200 {
limit = 50
}
return s.repo.Media.Search(ctx, query, limit)
}
// GetMedia returns a single media row.
func (s *MediaService) GetMedia(ctx context.Context, id string) (*model.Media, error) {
return s.repo.Media.FindByID(ctx, id)
}
+111
View File
@@ -0,0 +1,111 @@
// Package service — filesystem scanner.
//
// ScannerService walks the configured library roots looking for video files,
// then upserts a model.Media row per file. A future iteration will plug
// ffprobe / a metadata-provider chain on top of this skeleton, but the
// scaffold keeps the surface narrow and synchronous so handlers can call
// "POST /api/libraries/:id/scan" today.
package service
import (
"context"
"path/filepath"
"strings"
"go.uber.org/zap"
"github.com/ShukeBta/MediaStationGo/internal/config"
"github.com/ShukeBta/MediaStationGo/internal/model"
"github.com/ShukeBta/MediaStationGo/internal/repository"
)
// videoExtensions lists the file extensions treated as media. Matches the
// MediaStation Python defaults.
var videoExtensions = map[string]struct{}{
".mkv": {},
".mp4": {},
".m4v": {},
".avi": {},
".mov": {},
".webm": {},
".ts": {},
".rmvb": {},
".rm": {},
".3gp": {},
".mpg": {},
".mpeg": {},
".strm": {},
}
// ScannerService walks libraries on disk and upserts model.Media rows.
type ScannerService struct {
cfg *config.Config
log *zap.Logger
repo *repository.Container
hub *Hub
}
// NewScannerService is the constructor.
func NewScannerService(cfg *config.Config, log *zap.Logger, repo *repository.Container, hub *Hub) *ScannerService {
return &ScannerService{cfg: cfg, log: log, repo: repo, hub: hub}
}
// ScanResult summarises a scan run.
type ScanResult struct {
LibraryID string `json:"library_id"`
Visited int `json:"visited"`
Added int `json:"added"`
}
// ScanLibrary walks the library root and persists discovered media files.
//
// This is a synchronous skeleton: large libraries should call it in a
// goroutine. WebSocket progress events are pushed to the hub on the
// "scan" topic so the React UI can display a progress indicator.
func (s *ScannerService) ScanLibrary(ctx context.Context, libraryID string) (*ScanResult, error) {
lib, err := s.repo.Library.FindByID(ctx, libraryID)
if err != nil || lib == nil {
return nil, err
}
res := &ScanResult{LibraryID: lib.ID}
walkFn := func(path string, info walkInfo) error {
if info.isDir {
return nil
}
ext := strings.ToLower(filepath.Ext(path))
if _, ok := videoExtensions[ext]; !ok {
return nil
}
res.Visited++
title := strings.TrimSuffix(filepath.Base(path), ext)
m := &model.Media{
LibraryID: lib.ID,
Title: title,
Path: path,
SizeBytes: info.size,
Container: strings.TrimPrefix(ext, "."),
}
if err := s.repo.Media.Upsert(ctx, m); err != nil {
s.log.Warn("upsert media failed", zap.String("path", path), zap.Error(err))
return nil
}
res.Added++
s.hub.Publish("scan", map[string]any{
"library_id": lib.ID,
"path": path,
"visited": res.Visited,
"added": res.Added,
})
return nil
}
if err := walk(lib.Path, walkFn); err != nil {
return res, err
}
s.hub.Publish("scan", map[string]any{
"library_id": lib.ID,
"finished": true,
"visited": res.Visited,
"added": res.Added,
})
return res, nil
}
+48
View File
@@ -0,0 +1,48 @@
// Package service contains the business logic of MediaStationGo. Handlers
// deserialize the HTTP request, call into a Service method, then serialize
// the response. Services own all cross-cutting policy (auth, scanning,
// transcoding, etc.) and never deal with HTTP types directly.
package service
import (
"go.uber.org/zap"
"github.com/ShukeBta/MediaStationGo/internal/config"
"github.com/ShukeBta/MediaStationGo/internal/repository"
)
// Container holds every service initialized at startup. Handlers receive a
// pointer to it and pick the relevant fields.
type Container struct {
Cfg *config.Config
Log *zap.Logger
Repo *repository.Container
WSHub *Hub
Auth *AuthService
Media *MediaService
Scan *ScannerService
Stream *StreamService
}
// New builds the service container.
func New(cfg *config.Config, log *zap.Logger, repos *repository.Container) *Container {
hub := NewHub(log)
go hub.Run()
return &Container{
Cfg: cfg,
Log: log,
Repo: repos,
WSHub: hub,
Auth: NewAuthService(cfg, log, repos),
Media: NewMediaService(cfg, log, repos),
Scan: NewScannerService(cfg, log, repos, hub),
Stream: NewStreamService(cfg, log, repos),
}
}
// Close releases any resources held by services (e.g. the websocket hub).
func (c *Container) Close() {
if c.WSHub != nil {
c.WSHub.Stop()
}
}
+58
View File
@@ -0,0 +1,58 @@
// Package service — direct-play / range request streaming.
package service
import (
"errors"
"net/http"
"os"
"go.uber.org/zap"
"github.com/ShukeBta/MediaStationGo/internal/config"
"github.com/ShukeBta/MediaStationGo/internal/repository"
)
// StreamService serves media files with proper Range support so browsers can
// seek into the stream.
//
// HLS / on-demand transcoding is intentionally omitted from this initial
// scaffold. The HTTP handler returns 501 (NotImplemented) for that path,
// while direct-play already works for browser-friendly containers (mp4 /
// webm / m4v).
type StreamService struct {
cfg *config.Config
log *zap.Logger
repo *repository.Container
}
// NewStreamService is the constructor.
func NewStreamService(cfg *config.Config, log *zap.Logger, repo *repository.Container) *StreamService {
return &StreamService{cfg: cfg, log: log, repo: repo}
}
// ErrMediaNotFound is returned when the media row or its file is missing.
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.
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 {
return err
}
if m == nil {
return ErrMediaNotFound
}
f, err := os.Open(m.Path)
if err != nil {
return ErrMediaNotFound
}
defer f.Close()
stat, err := f.Stat()
if err != nil {
return err
}
w.Header().Set("Accept-Ranges", "bytes")
http.ServeContent(w, r, stat.Name(), stat.ModTime(), f)
return nil
}
+35
View File
@@ -0,0 +1,35 @@
// Package service — internal filesystem walker shared by scanner / watcher.
package service
import (
"io/fs"
"path/filepath"
)
// walkInfo is a tiny abstraction over os.FileInfo so that callers do not
// need to depend on os/io packages directly.
type walkInfo struct {
isDir bool
size int64
}
// walk traverses root depth-first calling fn for every entry. Hidden
// directories (starting with ".") are skipped.
func walk(root string, fn func(string, walkInfo) error) error {
return filepath.WalkDir(root, func(path string, d fs.DirEntry, err error) error {
if err != nil {
return nil // best effort — keep walking
}
name := d.Name()
if d.IsDir() && name != "." && len(name) > 1 && name[0] == '.' {
return filepath.SkipDir
}
info := walkInfo{isDir: d.IsDir()}
if !d.IsDir() {
if fi, err := d.Info(); err == nil {
info.size = fi.Size()
}
}
return fn(path, info)
})
}
+127
View File
@@ -0,0 +1,127 @@
// Package service — WebSocket hub used to push real-time progress events
// (scan / scrape / transcode) to subscribed clients.
package service
import (
"sync"
"go.uber.org/zap"
)
// Event is the JSON payload pushed to subscribers.
type Event struct {
Topic string `json:"topic"`
Payload any `json:"payload"`
}
// Subscriber is a single connected client; the hub writes events into Out
// and closes Done when the connection should be torn down.
type Subscriber struct {
ID string
Out chan Event
topics map[string]struct{}
}
// Hub is a fan-out broker: services publish on a topic and every subscriber
// that opted into that topic receives the event.
type Hub struct {
log *zap.Logger
mu sync.RWMutex
subs map[string]*Subscriber
in chan Event
stop chan struct{}
closed bool
}
// NewHub builds a Hub. Caller must invoke Run in its own goroutine.
func NewHub(log *zap.Logger) *Hub {
return &Hub{
log: log,
subs: make(map[string]*Subscriber),
in: make(chan Event, 256),
stop: make(chan struct{}),
}
}
// Run is the blocking event loop. Publish events with Hub.Publish.
func (h *Hub) Run() {
for {
select {
case <-h.stop:
return
case ev := <-h.in:
h.fanout(ev)
}
}
}
// Stop terminates the hub goroutine and disconnects every subscriber.
func (h *Hub) Stop() {
h.mu.Lock()
defer h.mu.Unlock()
if h.closed {
return
}
h.closed = true
close(h.stop)
for _, sub := range h.subs {
close(sub.Out)
}
h.subs = nil
}
// Publish sends an event to every interested subscriber. Non-blocking: the
// event is dropped if the hub is full to avoid stalling the producer.
func (h *Hub) Publish(topic string, payload any) {
select {
case h.in <- Event{Topic: topic, Payload: payload}:
default:
h.log.Warn("ws hub overflow, dropping event", zap.String("topic", topic))
}
}
// Subscribe registers a new connection for a given topic set. Pass an empty
// list to receive every topic.
func (h *Hub) Subscribe(id string, topics []string) *Subscriber {
sub := &Subscriber{
ID: id,
Out: make(chan Event, 32),
topics: map[string]struct{}{},
}
for _, t := range topics {
sub.topics[t] = struct{}{}
}
h.mu.Lock()
h.subs[id] = sub
h.mu.Unlock()
return sub
}
// Unsubscribe disconnects the subscriber and closes its outbound channel.
func (h *Hub) Unsubscribe(id string) {
h.mu.Lock()
defer h.mu.Unlock()
sub, ok := h.subs[id]
if !ok {
return
}
delete(h.subs, id)
close(sub.Out)
}
func (h *Hub) fanout(ev Event) {
h.mu.RLock()
defer h.mu.RUnlock()
for _, sub := range h.subs {
if len(sub.topics) > 0 {
if _, ok := sub.topics[ev.Topic]; !ok {
continue
}
}
select {
case sub.Out <- ev:
default:
// Slow consumer: drop the event for this subscriber.
}
}
}