mirror of
https://github.com/truewhile/MeBox.git
synced 2026-10-01 03:56:38 +08:00
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:
@@ -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, ¬Found) {
|
||||
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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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...)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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"})
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 ""
|
||||
}
|
||||
@@ -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{},
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
@@ -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.
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user