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
+20
View File
@@ -0,0 +1,20 @@
.git
.gitignore
.dockerignore
README.md
CONTRIBUTING.md
LICENSE
docs/
data/
cache/
logs/
*.db
*.db-journal
*.log
.env
.env.*
.idea/
.vscode/
node_modules/
web/dist/
bin/
+21
View File
@@ -0,0 +1,21 @@
# MediaStationGo — environment variables (copy to `.env` if needed).
#
# Every config key is overridable via MEDIASTATION_<UPPER_KEY>=value.
# Below are the values most people change.
MEDIASTATION_APP_PORT=8080
MEDIASTATION_APP_DEBUG=false
MEDIASTATION_APP_DATA_DIR=./data
# Comma-or-array allow-list. Empty = allow * (dev).
# MEDIASTATION_APP_CORS_ORIGINS=https://media.example.com
# Override the seeded admin password (used only when no admin exists).
ADMIN_INITIAL_PASSWORD=admin123
# Strong random JWT secret (auto-generated when empty).
# MEDIASTATION_SECRETS_JWT_SECRET=please-change-me
# 3rd-party scrape providers.
# MEDIASTATION_SECRETS_TMDB_API_KEY=
# MEDIASTATION_SECRETS_BANGUMI_ACCESS_TOKEN=
+49
View File
@@ -0,0 +1,49 @@
name: CI
on:
push:
branches: [main]
pull_request:
branches: [main]
jobs:
backend:
name: Backend (Go)
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-go@v5
with:
go-version: '1.25'
cache: true
- name: go vet
run: go vet ./...
- name: go build
run: go build ./...
- name: go test
run: go test ./...
frontend:
name: Frontend (Node)
runs-on: ubuntu-latest
defaults:
run:
working-directory: web
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'npm'
cache-dependency-path: web/package-lock.json
- name: Install
run: npm ci
- name: Type-check & build
run: npm run build
+48
View File
@@ -0,0 +1,48 @@
name: Publish Docker image
on:
push:
branches: [main]
tags: ['v*']
workflow_dispatch:
permissions:
contents: read
packages: write
jobs:
docker:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: docker/setup-qemu-action@v3
- uses: docker/setup-buildx-action@v3
- name: Log in to GHCR
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Extract image metadata
id: meta
uses: docker/metadata-action@v5
with:
images: ghcr.io/${{ github.repository }}
tags: |
type=ref,event=branch
type=ref,event=tag
type=sha,prefix=
- name: Build & push
uses: docker/build-push-action@v6
with:
context: .
platforms: linux/amd64,linux/arm64
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
cache-from: type=gha
cache-to: type=gha,mode=max
+47
View File
@@ -0,0 +1,47 @@
# Binaries
bin/
*.exe
*.dll
*.so
*.dylib
# Test binary, built with `go test -c`
*.test
*.out
# Go workspace
go.work
# Dependency directories
node_modules/
# Build artifacts
web/dist/
web/.vite/
web/coverage/
web/tsconfig.tsbuildinfo
# Data / runtime
data/
cache/
logs/
*.log
*.db
*.db-journal
*.db-shm
*.db-wal
# Editor / OS
.idea/
.vscode/
.DS_Store
Thumbs.db
# Env files
.env
.env.local
.env.*.local
# Local configs (keep examples)
config/secrets.yaml
config.yaml
+41
View File
@@ -0,0 +1,41 @@
# Contributing to MediaStationGo
Thanks for taking the time to contribute! This document is intentionally short —
prefer asking in an issue/PR over re-reading rules.
## Local development
```bash
# Backend (Go 1.25+ required)
make dev # MEDIASTATION_APP_DEBUG=true go run ./cmd/server
# Frontend (Node 20+ required)
make dev-web # vite dev server on :3000, proxies /api -> :8080
```
Default admin: `admin / admin123` — change it after first login.
## Code style
- **Go**: keep packages small, services thin, errors wrapped with `%w`. Run
`go vet ./...` and `go test ./...` before pushing.
- **TypeScript**: `strict: true` is on. Prefer `interface` over `type` for
object shapes. Components live next to their page when not reused.
- Comments should explain *why*, not *what*. Avoid emoji in code.
## Commit / PR conventions
- Conventional commits encouraged: `feat:`, `fix:`, `docs:`, `refactor:`, `chore:`.
- One concern per PR. Update the README/docs when behaviour changes.
- CI must pass: `go vet`, `go test`, frontend `tsc -b && vite build`.
## Issue triage
When filing a bug please include:
1. The steps to reproduce.
2. The expected and actual behaviour.
3. Logs from `docker logs mediastation-go` (or stdout if running locally).
4. Your deployment context (NAS model, OS, Docker version).
Thanks for keeping the project healthy!
+88
View File
@@ -0,0 +1,88 @@
# syntax=docker/dockerfile:1.6
# =============================================================================
# Multi-architecture build for MediaStationGo.
#
# Stage 1 (frontend) : Node 20 -> static SPA bundle
# Stage 2 (backend) : Go 1.25 -> single static binary (CGO_ENABLED=0)
# Stage 3 (runtime) : Alpine 3.19 -> ffmpeg + tzdata + non-root user
#
# Build:
# docker buildx build --platform linux/amd64,linux/arm64 \
# -t mediastation-go:latest --push .
# =============================================================================
# ---- Stage 1: frontend (always build on the host architecture) -------------
FROM --platform=$BUILDPLATFORM node:20-alpine AS frontend
WORKDIR /app/web
COPY web/package*.json ./
RUN npm ci
COPY web/ .
RUN npm run build
# ---- Stage 2: backend (cross-compiled to TARGETPLATFORM) -------------------
FROM --platform=$BUILDPLATFORM golang:1.25-alpine AS backend
ARG TARGETOS
ARG TARGETARCH
WORKDIR /app
COPY go.mod go.sum ./
RUN go mod download
COPY . .
COPY --from=frontend /app/web/dist ./web/dist
RUN CGO_ENABLED=0 GOOS=${TARGETOS} GOARCH=${TARGETARCH} \
go build -trimpath -ldflags="-s -w" -o mediastation-go ./cmd/server
# ---- Stage 3: runtime ------------------------------------------------------
FROM alpine:3.19
RUN apk add --no-cache \
ffmpeg \
tzdata \
ca-certificates \
wget \
su-exec \
&& rm -rf /var/cache/apk/*
# Non-root user for the long-running process.
RUN addgroup -S mediastation && adduser -S mediastation -G mediastation
WORKDIR /app
COPY --from=backend /app/mediastation-go /usr/local/bin/mediastation-go
COPY --from=frontend /app/web/dist /app/web/dist
RUN mkdir -p /data /cache /media \
&& chown -R mediastation:mediastation /data /cache /media
# Default environment (overridable via docker-compose / `docker run -e`).
ENV MEDIASTATION_APP_PORT=8080 \
MEDIASTATION_APP_DATA_DIR=/data \
MEDIASTATION_APP_WEB_DIR=/app/web/dist \
MEDIASTATION_DATABASE_DB_PATH=/data/mediastation.db \
MEDIASTATION_CACHE_CACHE_DIR=/cache \
MEDIASTATION_LOGGING_LEVEL=info \
TZ=Asia/Shanghai
EXPOSE 8080
HEALTHCHECK --interval=30s --timeout=5s --start-period=15s --retries=3 \
CMD wget -q --spider http://127.0.0.1:8080/api/health || exit 1
# Tiny entrypoint that lets us swap to a different UID/GID via PUID/PGID
# (handy on NAS deployments where bind-mounted volumes belong to a non-root
# user). When PUID == 0 we skip su-exec entirely and run as root.
RUN printf '#!/bin/sh\n\
PUID=${PUID:-$(id -u mediastation)}\n\
PGID=${PGID:-$(id -g mediastation)}\n\
if [ "$PUID" != "$(id -u mediastation)" ] || [ "$PGID" != "$(id -g mediastation)" ]; then\n\
deluser mediastation 2>/dev/null || true\n\
delgroup mediastation 2>/dev/null || true\n\
addgroup -g "$PGID" -S mediastation\n\
adduser -u "$PUID" -G mediastation -S mediastation\n\
fi\n\
chown -R mediastation:mediastation /data /cache 2>/dev/null || true\n\
chown mediastation:mediastation /media 2>/dev/null || true\n\
if [ "$PUID" = "0" ]; then\n\
exec mediastation-go\n\
fi\n\
exec su-exec mediastation mediastation-go\n' > /entrypoint.sh \
&& chmod +x /entrypoint.sh
CMD ["/entrypoint.sh"]
+9
View File
@@ -0,0 +1,9 @@
GNU GENERAL PUBLIC LICENSE
Version 3, 29 June 2007
Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
Everyone is permitted to copy and distribute verbatim copies
of this license document, but changing it is not allowed.
This project is licensed under the GNU GPL v3.0.
For the full license text, see https://www.gnu.org/licenses/gpl-3.0.txt
+50
View File
@@ -0,0 +1,50 @@
.PHONY: all build build-server build-web dev dev-web run docker docker-stop clean install-web tidy test vet
# ---- 默认目标 ----
all: build
# ---- 全量构建(前端 + 后端) ----
build: build-web build-server
# ---- 后端构建 ----
# 使用纯 Go SQLite,可以 CGO_ENABLED=0 直接交叉编译。
build-server:
CGO_ENABLED=0 go build -trimpath -ldflags="-s -w" -o bin/mediastation-go ./cmd/server
# ---- 前端构建 ----
build-web:
cd web && npm install --silent && npm run build
# ---- 开发模式 ----
dev:
MEDIASTATION_APP_DEBUG=true go run ./cmd/server
dev-web:
cd web && npm run dev
# ---- 一键运行 ----
run: build
./bin/mediastation-go
# ---- Docker ----
docker:
docker compose up --build -d
docker-stop:
docker compose down
# ---- 工具命令 ----
install-web:
cd web && npm install
tidy:
go mod tidy
test:
go test ./...
vet:
go vet ./...
clean:
rm -rf bin/ web/dist/ web/node_modules/
+139
View File
@@ -0,0 +1,139 @@
<h1 align="center">🎬 MediaStationGo</h1>
<p align="center">A Go rewrite of <a href="https://github.com/ShukeBta/MediaStation">MediaStation</a> — your private home media center.</p>
<p align="center">
<img src="https://img.shields.io/badge/Go-1.25-00ADD8?style=flat-square&logo=go" alt="Go">
<img src="https://img.shields.io/badge/React-18-61DAFB?style=flat-square&logo=react" alt="React">
<img src="https://img.shields.io/badge/TypeScript-5-3178C6?style=flat-square&logo=typescript" alt="TypeScript">
<img src="https://img.shields.io/badge/SQLite-WAL-003B57?style=flat-square&logo=sqlite" alt="SQLite">
<img src="https://img.shields.io/badge/Docker-Alpine_3.19-2496ED?style=flat-square&logo=docker" alt="Docker">
<img src="https://img.shields.io/badge/License-GPL--3.0-blue?style=flat-square" alt="License">
</p>
---
## Why a rewrite?
The original MediaStation is a Python/FastAPI + Vue project. **MediaStationGo**
is a from-scratch reimplementation that adopts the lighter, single-binary
deployment model used by [`cropflre/nowen-video`](https://github.com/cropflre/nowen-video):
- **Backend**: Go 1.25 + Gin + GORM + SQLite (WAL).
- **Frontend**: React 18 + Vite + Tailwind + Zustand.
- **Distribution**: one ~30 MB static binary (CGO disabled), or a
multi-arch Alpine Docker image.
The goal is to keep the user-facing feature surface familiar (libraries,
scanning, scraping, direct play/HLS, multi-user, downloads) while making
deployment painless on NAS hardware.
---
## Features (current scaffold)
- ✅ JWT authentication with admin/user roles
- ✅ First-run admin seeding (`admin / admin123`)
- ✅ Library CRUD + recursive filesystem scan
- ✅ Direct-play streaming with HTTP `Range` support
- ✅ Real-time scan progress via WebSocket
- ✅ React SPA: Login / Home / Library / Search / Media detail / Player / Admin
- ✅ Single-binary build, multi-arch Docker image, GitHub Actions CI
### Roadmap
| Area | Status |
|------|--------|
| ffprobe-driven metadata extraction | ⏳ |
| TMDb / Bangumi / Douban scraper chain | ⏳ |
| HLS on-demand transcoding (NVENC / QSV / VAAPI) | ⏳ |
| qBittorrent / Transmission / RSS automation | ⏳ |
| Playlists, favourites, watch history UI | ⏳ |
| Emby/Jellyfin compatibility layer | ⏳ |
| DLNA / Chromecast | ⏳ |
| AI metadata enhancement & smart search | ⏳ |
---
## Quick start
### Docker
```bash
git clone https://github.com/ShukeBta/MediaStationGo.git
cd MediaStationGo
# (optional) edit docker-compose.yml to mount your media root at /media
docker compose up -d
```
Open <http://localhost:8080> and log in with `admin / admin123`.
### Bare metal
```bash
# requirements: Go 1.25+, Node 20+, ffmpeg
make build # produces bin/mediastation-go and web/dist
./bin/mediastation-go
```
### Local development
```bash
make dev # backend on :8080, MEDIASTATION_APP_DEBUG=true
make dev-web # vite dev server on :3000, proxies /api -> :8080
```
---
## Configuration
Configuration is layered — defaults < `config.yaml` < `config/*.yaml` <
environment variables prefixed with `MEDIASTATION_`.
The most common keys:
| Key | Default | Purpose |
|------|---------|---------|
| `MEDIASTATION_APP_PORT` | `8080` | HTTP listen port |
| `MEDIASTATION_APP_DATA_DIR` | `./data` | DB / cache / JWT secret root |
| `MEDIASTATION_APP_WEB_DIR` | `./web/dist` | SPA bundle to serve |
| `MEDIASTATION_DATABASE_DB_PATH` | `./data/mediastation.db` | SQLite file |
| `MEDIASTATION_SECRETS_JWT_SECRET` | *(auto)* | JWT signing key |
| `MEDIASTATION_APP_CORS_ORIGINS` | *(empty)* | Allow-list, JSON array |
| `ADMIN_INITIAL_PASSWORD` | `admin123` | Bootstrap admin password |
See [`config.example.yaml`](config.example.yaml) for the full surface.
---
## Project layout
```
MediaStationGo/
├── cmd/server/main.go Application entry point
├── internal/
│ ├── config/ Viper-based config loader
│ ├── database/ GORM + SQLite (WAL) bootstrap
│ ├── model/ GORM data models + AutoMigrate registry
│ ├── repository/ Thin data-access layer
│ ├── service/ Business logic (auth/media/scan/stream/ws)
│ ├── middleware/ Gin middleware (CORS / JWT / admin)
│ └── handler/ HTTP route definitions
├── web/ React 18 + Vite SPA
│ ├── src/api/ axios helpers
│ ├── src/components/ Layout, MediaCard, RequireAuth, …
│ ├── src/pages/ Home / Library / Search / Player / Admin
│ ├── src/stores/ Zustand (auth)
│ └── src/types/ Domain types mirrored from Go
├── Dockerfile Multi-stage, multi-arch build
├── docker-compose.yml NAS-friendly deployment
├── Makefile build / dev / docker / test
├── config.example.yaml Full configuration template
└── .github/workflows/ CI + GHCR publish
```
---
## License
Released under the [GNU GPL v3.0](LICENSE).
+149
View File
@@ -0,0 +1,149 @@
// Package main is the MediaStationGo HTTP server entry point.
//
// MediaStationGo is a Go rewrite of the original Python MediaStation project,
// adopting the same tech stack as cropflre/nowen-video:
//
// Backend: Go 1.25 + Gin + GORM + SQLite (WAL) + Viper + Zap + JWT
// Frontend: React 18 + Vite + Tailwind + Zustand + HLS.js
//
// The binary embeds the SPA build artifacts at /app/web/dist and serves them
// alongside the JSON REST API at /api/* and the WebSocket hub at /api/ws.
package main
import (
"context"
"errors"
"fmt"
"net/http"
"os"
"os/signal"
"path/filepath"
"syscall"
"time"
"github.com/gin-gonic/gin"
"go.uber.org/zap"
"github.com/ShukeBta/MediaStationGo/internal/config"
"github.com/ShukeBta/MediaStationGo/internal/database"
"github.com/ShukeBta/MediaStationGo/internal/handler"
"github.com/ShukeBta/MediaStationGo/internal/middleware"
"github.com/ShukeBta/MediaStationGo/internal/repository"
"github.com/ShukeBta/MediaStationGo/internal/service"
)
// version is overwritten at build time via -ldflags="-X main.version=...".
var version = "dev"
func main() {
cfg, err := config.Load()
if err != nil {
fmt.Fprintf(os.Stderr, "config load failed: %v\n", err)
os.Exit(1)
}
logger, err := newLogger(cfg)
if err != nil {
fmt.Fprintf(os.Stderr, "logger init failed: %v\n", err)
os.Exit(1)
}
defer func() { _ = logger.Sync() }()
logger.Info("starting MediaStationGo",
zap.String("version", version),
zap.Int("port", cfg.App.Port),
zap.String("data_dir", cfg.App.DataDir),
)
// Ensure data / cache / web dirs exist.
for _, d := range []string{cfg.App.DataDir, cfg.Cache.CacheDir} {
if err := os.MkdirAll(d, 0o755); err != nil {
logger.Fatal("create dir failed", zap.String("dir", d), zap.Error(err))
}
}
db, err := database.Open(cfg, logger)
if err != nil {
logger.Fatal("database open failed", zap.Error(err))
}
if err := database.AutoMigrate(db); err != nil {
logger.Fatal("auto-migrate failed", zap.Error(err))
}
repos := repository.New(db)
services := service.New(cfg, logger, repos)
if err := services.Auth.SeedAdmin(context.Background()); err != nil {
logger.Warn("seed admin failed", zap.Error(err))
}
router := buildRouter(cfg, logger, services)
srv := &http.Server{
Addr: fmt.Sprintf(":%d", cfg.App.Port),
Handler: router,
ReadHeaderTimeout: 15 * time.Second,
}
go func() {
logger.Info("listening", zap.String("addr", srv.Addr))
if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
logger.Fatal("listen failed", zap.Error(err))
}
}()
// Graceful shutdown.
stop := make(chan os.Signal, 1)
signal.Notify(stop, syscall.SIGINT, syscall.SIGTERM)
<-stop
logger.Info("shutdown requested")
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
defer cancel()
if err := srv.Shutdown(ctx); err != nil {
logger.Error("graceful shutdown failed", zap.Error(err))
}
services.Close()
logger.Info("MediaStationGo stopped")
}
func buildRouter(cfg *config.Config, logger *zap.Logger, svc *service.Container) *gin.Engine {
if !cfg.App.Debug {
gin.SetMode(gin.ReleaseMode)
}
r := gin.New()
r.Use(gin.Recovery())
r.Use(middleware.RequestLogger(logger))
r.Use(middleware.CORS(cfg.App.CORSOrigins))
handler.Register(r, cfg, logger, svc)
// Static SPA fallback.
if cfg.App.WebDir != "" {
serveSPA(r, cfg.App.WebDir)
}
return r
}
// serveSPA serves the React build artifacts and falls back to index.html for
// non-API, non-asset paths so client-side routing keeps working.
func serveSPA(r *gin.Engine, webDir string) {
r.Static("/assets", filepath.Join(webDir, "assets"))
r.StaticFile("/favicon.ico", filepath.Join(webDir, "favicon.ico"))
r.NoRoute(func(c *gin.Context) {
path := c.Request.URL.Path
// Do not swallow API or WebSocket routes; let them 404 naturally.
if len(path) >= 5 && path[:5] == "/api/" {
c.Status(http.StatusNotFound)
return
}
c.File(filepath.Join(webDir, "index.html"))
})
}
func newLogger(cfg *config.Config) (*zap.Logger, error) {
if cfg.App.Debug {
return zap.NewDevelopment()
}
return zap.NewProduction()
}
+72
View File
@@ -0,0 +1,72 @@
# ============================================================================
# MediaStationGo configuration template.
#
# Loading priority (low -> high):
# 1. Built-in defaults
# 2. config.yaml (this file, copied to the working directory)
# 3. config/*.yaml shard files (per-module)
# 4. Environment variables prefixed with MEDIASTATION_, e.g.
# MEDIASTATION_APP_PORT=8080
# MEDIASTATION_SECRETS_JWT_SECRET=...
#
# Copy this file to `config.yaml` and edit the values you actually need.
# ============================================================================
app:
port: 8080
debug: false
env: production
data_dir: ./data
web_dir: ./web/dist
ffmpeg_path: ffmpeg
ffprobe_path: ffprobe
vaapi_device: /dev/dri/renderD128
cors_origins: [] # leave empty to allow * in dev; set strict allow-list in prod
server_url: "" # public URL of the server (used for DLNA / casting)
database:
db_path: ./data/mediastation.db
wal_mode: true
busy_timeout: 5000
cache_size: -20000 # KB (negative)
max_open_conns: 1
max_idle_conns: 1
secrets:
# When empty, a 32-byte random secret is generated on first start and
# persisted to {data_dir}/.jwt_secret so existing sessions survive restarts.
jwt_secret: ""
tmdb_api_key: ""
bangumi_access_token: ""
thetvdb_api_key: ""
fanart_tv_api_key: ""
logging:
level: info # debug | info | warn | error
format: console # console | json
output_path: "" # empty = stdout
enable_rotation: false
max_size_mb: 100
max_age_days: 30
max_backups: 10
cache:
cache_dir: ./cache
max_disk_usage_mb: 0 # 0 = unlimited
ttl_hours: 0
auto_cleanup: false
cleanup_interval_min: 60
media:
movies_dir: ""
tv_dir: ""
anime_dir: ""
ai:
enabled: false
provider: openai # openai | deepseek | qwen | ollama
api_base: https://api.openai.com/v1
api_key: ""
model: gpt-4o-mini
timeout: 30
max_concurrent: 3
+52
View File
@@ -0,0 +1,52 @@
version: '3.8'
# ============================================================================
# MediaStationGo — recommended NAS deployment.
#
# 1. Edit the volume mappings under `volumes:` so that /media points at your
# actual library root (e.g. /volume1/Media).
# 2. (Optional) set MEDIASTATION_SECRETS_JWT_SECRET to a strong random string;
# otherwise one is auto-generated and persisted to /data/.jwt_secret.
# 3. `docker compose up -d` and visit http://<host>:8080 (admin / admin123).
# ============================================================================
services:
mediastation-go:
build:
context: .
image: ghcr.io/shukebta/mediastation-go:latest
container_name: mediastation-go
restart: unless-stopped
ports:
- "8080:8080"
volumes:
- ./data:/data
- ./cache:/cache
# Mount your media library into /media. Read-only is fine.
# - /volume1/Media:/media:ro
environment:
MEDIASTATION_APP_PORT: 8080
MEDIASTATION_APP_DATA_DIR: /data
MEDIASTATION_APP_WEB_DIR: /app/web/dist
MEDIASTATION_DATABASE_DB_PATH: /data/mediastation.db
MEDIASTATION_CACHE_CACHE_DIR: /cache
MEDIASTATION_LOGGING_LEVEL: info
# MEDIASTATION_SECRETS_JWT_SECRET: change-me-to-a-strong-random-string
# MEDIASTATION_APP_CORS_ORIGINS: '["https://media.example.com"]'
PUID: "1000"
PGID: "1000"
TZ: Asia/Shanghai
# Hardware-accelerated transcoding (uncomment when you have an Intel iGPU).
# devices:
# - /dev/dri:/dev/dri
healthcheck:
test: ["CMD-SHELL", "wget -qO- http://127.0.0.1:8080/api/health || exit 1"]
interval: 30s
timeout: 10s
retries: 5
start_period: 30s
+66
View File
@@ -0,0 +1,66 @@
module github.com/ShukeBta/MediaStationGo
go 1.25.0
require (
github.com/gin-gonic/gin v1.9.1
github.com/glebarez/sqlite v1.11.0
github.com/golang-jwt/jwt/v5 v5.2.0
github.com/google/uuid v1.6.0
github.com/gorilla/websocket v1.5.3
github.com/spf13/viper v1.18.2
go.uber.org/zap v1.27.0
golang.org/x/crypto v0.21.0
gorm.io/gorm v1.25.7
)
require (
github.com/bytedance/sonic v1.9.1 // indirect
github.com/chenzhuoyu/base64x v0.0.0-20221115062448-fe3a3abad311 // indirect
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/fsnotify/fsnotify v1.7.0 // indirect
github.com/gabriel-vasile/mimetype v1.4.2 // indirect
github.com/gin-contrib/sse v0.1.0 // indirect
github.com/glebarez/go-sqlite v1.21.2 // indirect
github.com/go-playground/locales v0.14.1 // indirect
github.com/go-playground/universal-translator v0.18.1 // indirect
github.com/go-playground/validator/v10 v10.14.0 // indirect
github.com/goccy/go-json v0.10.2 // indirect
github.com/google/go-cmp v0.6.0 // indirect
github.com/hashicorp/hcl v1.0.0 // indirect
github.com/jinzhu/inflection v1.0.0 // indirect
github.com/jinzhu/now v1.1.5 // indirect
github.com/json-iterator/go v1.1.12 // indirect
github.com/klauspost/cpuid/v2 v2.2.4 // indirect
github.com/leodido/go-urn v1.2.4 // indirect
github.com/magiconair/properties v1.8.7 // indirect
github.com/mattn/go-isatty v0.0.19 // indirect
github.com/mitchellh/mapstructure v1.5.0 // indirect
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
github.com/modern-go/reflect2 v1.0.2 // indirect
github.com/pelletier/go-toml/v2 v2.1.0 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
github.com/sagikazarmark/locafero v0.4.0 // indirect
github.com/sagikazarmark/slog-shim v0.1.0 // indirect
github.com/sourcegraph/conc v0.3.0 // indirect
github.com/spf13/afero v1.11.0 // indirect
github.com/spf13/cast v1.6.0 // indirect
github.com/spf13/pflag v1.0.5 // indirect
github.com/stretchr/testify v1.9.0 // indirect
github.com/subosito/gotenv v1.6.0 // indirect
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
github.com/ugorji/go/codec v1.2.11 // indirect
go.uber.org/multierr v1.10.0 // indirect
golang.org/x/arch v0.3.0 // indirect
golang.org/x/exp v0.0.0-20230905200255-921286631fa9 // indirect
golang.org/x/net v0.21.0 // indirect
golang.org/x/sys v0.20.0 // indirect
golang.org/x/text v0.14.0 // indirect
google.golang.org/protobuf v1.31.0 // indirect
gopkg.in/ini.v1 v1.67.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
modernc.org/libc v1.22.5 // indirect
modernc.org/mathutil v1.5.0 // indirect
modernc.org/memory v1.5.0 // indirect
modernc.org/sqlite v1.23.1 // indirect
)
+163
View File
@@ -0,0 +1,163 @@
github.com/bytedance/sonic v1.5.0/go.mod h1:ED5hyg4y6t3/9Ku1R6dU/4KyJ48DZ4jPhfY1O2AihPM=
github.com/bytedance/sonic v1.9.1 h1:6iJ6NqdoxCDr6mbY8h18oSO+cShGSMRGCEo7F2h0x8s=
github.com/bytedance/sonic v1.9.1/go.mod h1:i736AoUSYt75HyZLoJW9ERYxcy6eaN6h4BZXU064P/U=
github.com/chenzhuoyu/base64x v0.0.0-20211019084208-fb5309c8db06/go.mod h1:DH46F32mSOjUmXrMHnKwZdA8wcEefY7UVqBKYGjpdQY=
github.com/chenzhuoyu/base64x v0.0.0-20221115062448-fe3a3abad311 h1:qSGYFH7+jGhDF8vLC+iwCD4WpbV1EBDSzWkJODFLams=
github.com/chenzhuoyu/base64x v0.0.0-20221115062448-fe3a3abad311/go.mod h1:b583jCggY9gE99b6G5LEC39OIiVsWj+R97kbl5odCEk=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8=
github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=
github.com/fsnotify/fsnotify v1.7.0 h1:8JEhPFa5W2WU7YfeZzPNqzMP6Lwt7L2715Ggo0nosvA=
github.com/fsnotify/fsnotify v1.7.0/go.mod h1:40Bi/Hjc2AVfZrqy+aj+yEI+/bRxZnMJyTJwOpGvigM=
github.com/gabriel-vasile/mimetype v1.4.2 h1:w5qFW6JKBz9Y393Y4q372O9A7cUSequkh1Q7OhCmWKU=
github.com/gabriel-vasile/mimetype v1.4.2/go.mod h1:zApsH/mKG4w07erKIaJPFiX0Tsq9BFQgN3qGY5GnNgA=
github.com/gin-contrib/sse v0.1.0 h1:Y/yl/+YNO8GZSjAhjMsSuLt29uWRFHdHYUb5lYOV9qE=
github.com/gin-contrib/sse v0.1.0/go.mod h1:RHrZQHXnP2xjPF+u1gW/2HnVO7nvIa9PG3Gm+fLHvGI=
github.com/gin-gonic/gin v1.9.1 h1:4idEAncQnU5cB7BeOkPtxjfCSye0AAm1R0RVIqJ+Jmg=
github.com/gin-gonic/gin v1.9.1/go.mod h1:hPrL7YrpYKXt5YId3A/Tnip5kqbEAP+KLuI3SUcPTeU=
github.com/glebarez/go-sqlite v1.21.2 h1:3a6LFC4sKahUunAmynQKLZceZCOzUthkRkEAl9gAXWo=
github.com/glebarez/go-sqlite v1.21.2/go.mod h1:sfxdZyhQjTM2Wry3gVYWaW072Ri1WMdWJi0k6+3382k=
github.com/glebarez/sqlite v1.11.0 h1:wSG0irqzP6VurnMEpFGer5Li19RpIRi2qvQz++w0GMw=
github.com/glebarez/sqlite v1.11.0/go.mod h1:h8/o8j5wiAsqSPoWELDUdJXhjAhsVliSn7bWZjOhrgQ=
github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY=
github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY=
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
github.com/go-playground/validator/v10 v10.14.0 h1:vgvQWe3XCz3gIeFDm/HnTIbj6UGmg/+t63MyGU2n5js=
github.com/go-playground/validator/v10 v10.14.0/go.mod h1:9iXMNT7sEkjXb0I+enO7QXmzG6QCsPWY4zveKFVRSyU=
github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU=
github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I=
github.com/golang-jwt/jwt/v5 v5.2.0 h1:d/ix8ftRUorsN+5eMIlF4T6J8CAt9rch3My2winC1Jw=
github.com/golang-jwt/jwt/v5 v5.2.0/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk=
github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
github.com/google/pprof v0.0.0-20221118152302-e6195bd50e26 h1:Xim43kblpZXfIBQsbuBVKCudVG457BR2GZFIz3uw3hQ=
github.com/google/pprof v0.0.0-20221118152302-e6195bd50e26/go.mod h1:dDKJzRmX4S37WGHujM7tX//fmj1uioxKzKxz3lo4HJo=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
github.com/hashicorp/hcl v1.0.0 h1:0Anlzjpi4vEasTeNFn2mLJgTSwt0+6sfsiTG8qcWGx4=
github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ=
github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E=
github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc=
github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ=
github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8=
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=
github.com/klauspost/cpuid/v2 v2.2.4 h1:acbojRNwl3o09bUq+yDCtZFc1aiwaAAxtcn8YkZXnvk=
github.com/klauspost/cpuid/v2 v2.2.4/go.mod h1:RVVoqg1df56z8g3pUjL/3lE5UfnlrJX8tyFgg4nqhuY=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/leodido/go-urn v1.2.4 h1:XlAE/cm/ms7TE/VMVoduSpNBoyc2dOxHs5MZSwAN63Q=
github.com/leodido/go-urn v1.2.4/go.mod h1:7ZrI8mTSeBSHl/UaRyKQW1qZeMgak41ANeCNaVckg+4=
github.com/magiconair/properties v1.8.7 h1:IeQXZAiQcpL9mgcAe1Nu6cX9LLw6ExEHKjN0VQdvPDY=
github.com/magiconair/properties v1.8.7/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0=
github.com/mattn/go-isatty v0.0.19 h1:JITubQf0MOLdlGRuRq+jtsDlekdYPia9ZFsB8h/APPA=
github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY=
github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo=
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
github.com/pelletier/go-toml/v2 v2.1.0 h1:FnwAJ4oYMvbT/34k9zzHuZNrhlz48GB3/s6at6/MHO4=
github.com/pelletier/go-toml/v2 v2.1.0/go.mod h1:tJU2Z3ZkXwnxa4DPO899bsyIoywizdUvyaeZurnPPDc=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/remyoudompheng/bigfft v0.0.0-20200410134404-eec4a21b6bb0/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8=
github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs=
github.com/sagikazarmark/locafero v0.4.0 h1:HApY1R9zGo4DBgr7dqsTH/JJxLTTsOt7u6keLGt6kNQ=
github.com/sagikazarmark/locafero v0.4.0/go.mod h1:Pe1W6UlPYUk/+wc/6KFhbORCfqzgYEpgQ3O5fPuL3H4=
github.com/sagikazarmark/slog-shim v0.1.0 h1:diDBnUNK9N/354PgrxMywXnAwEr1QZcOr6gto+ugjYE=
github.com/sagikazarmark/slog-shim v0.1.0/go.mod h1:SrcSrq8aKtyuqEI1uvTDTK1arOWRIczQRv+GVI1AkeQ=
github.com/sourcegraph/conc v0.3.0 h1:OQTbbt6P72L20UqAkXXuLOj79LfEanQ+YQFNpLA9ySo=
github.com/sourcegraph/conc v0.3.0/go.mod h1:Sdozi7LEKbFPqYX2/J+iBAM6HpqSLTASQIKqDmF7Mt0=
github.com/spf13/afero v1.11.0 h1:WJQKhtpdm3v2IzqG8VMqrr6Rf3UYpEF239Jy9wNepM8=
github.com/spf13/afero v1.11.0/go.mod h1:GH9Y3pIexgf1MTIWtNGyogA5MwRIDXGUr+hbWNoBjkY=
github.com/spf13/cast v1.6.0 h1:GEiTHELF+vaR5dhz3VqZfFSzZjYbgeKDpBxQVS4GYJ0=
github.com/spf13/cast v1.6.0/go.mod h1:ancEpBxwJDODSW/UG4rDrAqiKolqNNh2DX3mk86cAdo=
github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA=
github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/spf13/viper v1.18.2 h1:LUXCnvUvSM6FXAsj6nnfc8Q2tp1dIgUfY9Kc8GsSOiQ=
github.com/spf13/viper v1.18.2/go.mod h1:EKmWIqdnk5lOcmR72yw6hS+8OPYcwD0jteitLMVB+yk=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
github.com/stretchr/testify v1.8.2/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8=
github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU=
github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
github.com/ugorji/go/codec v1.2.11 h1:BMaWp1Bb6fHwEtbplGBGJ498wD+LKlNSl25MjdZY4dU=
github.com/ugorji/go/codec v1.2.11/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg=
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
go.uber.org/multierr v1.10.0 h1:S0h4aNzvfcFsC3dRF1jLoaov7oRaKqRGC/pUEJ2yvPQ=
go.uber.org/multierr v1.10.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y=
go.uber.org/zap v1.27.0 h1:aJMhYGrd5QSmlpLMr2MftRKl7t8J8PTZPA732ud/XR8=
go.uber.org/zap v1.27.0/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E=
golang.org/x/arch v0.0.0-20210923205945-b76863e36670/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8=
golang.org/x/arch v0.3.0 h1:02VY4/ZcO/gBOH6PUaoiptASxtXU10jazRCP865E97k=
golang.org/x/arch v0.3.0/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8=
golang.org/x/crypto v0.21.0 h1:X31++rzVUdKhX5sWmSOFZxx8UW/ldWx55cbf08iNAMA=
golang.org/x/crypto v0.21.0/go.mod h1:0BP7YvVV9gBbVKyeTG0Gyn+gZm94bibOW5BjDEYAOMs=
golang.org/x/exp v0.0.0-20230905200255-921286631fa9 h1:GoHiUyI/Tp2nVkLI2mCxVkOjsbSXD66ic0XW0js0R9g=
golang.org/x/exp v0.0.0-20230905200255-921286631fa9/go.mod h1:S2oDrQGGwySpoQPVqRShND87VCbxmc6bL1Yd2oYrm6k=
golang.org/x/net v0.21.0 h1:AQyQV4dYCvJ7vGmJyKki9+PBdyvhkSd8EIx/qb0AYv4=
golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44=
golang.org/x/sys v0.0.0-20220704084225-05e143d24a9e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.20.0 h1:Od9JTbYCk261bKm4M/mw7AklTlFYIa0bIp9BgSm1S8Y=
golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ=
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw=
google.golang.org/protobuf v1.31.0 h1:g0LDEJHgrBl9N9r17Ru3sqWhkIx2NB67okBHPwC7hs8=
google.golang.org/protobuf v1.31.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo=
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/ini.v1 v1.67.0 h1:Dgnx+6+nfE+IfzjUEISNeydPJh9AXNNsWbGP9KzCsOA=
gopkg.in/ini.v1 v1.67.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gorm.io/gorm v1.25.7 h1:VsD6acwRjz2zFxGO50gPO6AkNs7KKnvfzUjHQhZDz/A=
gorm.io/gorm v1.25.7/go.mod h1:hbnx/Oo0ChWMn1BIhpy1oYozzpM15i4YPuHDmfYtwg8=
modernc.org/libc v1.22.5 h1:91BNch/e5B0uPbJFgqbxXuOnxBQjlS//icfQEGmvyjE=
modernc.org/libc v1.22.5/go.mod h1:jj+Z7dTNX8fBScMVNRAYZ/jF91K8fdT2hYMThc3YjBY=
modernc.org/mathutil v1.5.0 h1:rV0Ko/6SfM+8G+yKiyI830l3Wuz1zRutdslNoQ0kfiQ=
modernc.org/mathutil v1.5.0/go.mod h1:mZW8CKdRPY1v87qxC/wUdX5O1qDzXMP5TH3wjfpga6E=
modernc.org/memory v1.5.0 h1:N+/8c5rE6EqugZwHii4IFsaJ7MUhoWX07J5tC/iI5Ds=
modernc.org/memory v1.5.0/go.mod h1:PkUhL0Mugw21sHPeskwZW4D6VscE/GQJOnIpCnW6pSU=
modernc.org/sqlite v1.23.1 h1:nrSBg4aRQQwq59JpvGEQ15tNxoO5pX/kUjcRNwSAGQM=
modernc.org/sqlite v1.23.1/go.mod h1:OrDj17Mggn6MhE+iPbBNf7RGKODDE9NFT0f3EwDzJqk=
rsc.io/pdf v0.1.1/go.mod h1:n8OzWcQ6Sp37PL01nO98y4iUCRdTGarVfzxY20ICaU4=
+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.
}
}
}
+20
View File
@@ -0,0 +1,20 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="theme-color" content="#00F0FF" />
<meta name="description" content="MediaStationGo — 个人家庭媒体中心" />
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link
href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&family=Orbitron:wght@500;700;900&display=swap"
rel="stylesheet"
/>
<title>MediaStationGo</title>
</head>
<body class="font-body antialiased">
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
+3133
View File
File diff suppressed because it is too large Load Diff
+34
View File
@@ -0,0 +1,34 @@
{
"name": "mediastationgo-web",
"private": true,
"version": "0.1.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"preview": "vite preview",
"lint": "eslint ."
},
"dependencies": {
"axios": "^1.7.2",
"clsx": "^2.1.1",
"framer-motion": "^12.38.0",
"hls.js": "^1.5.11",
"lucide-react": "^0.379.0",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-hot-toast": "^2.6.0",
"react-router-dom": "^6.23.1",
"zustand": "^4.5.2"
},
"devDependencies": {
"@types/react": "^18.3.3",
"@types/react-dom": "^18.3.0",
"@vitejs/plugin-react": "^4.3.0",
"autoprefixer": "^10.4.19",
"postcss": "^8.4.38",
"tailwindcss": "^3.4.4",
"typescript": "^5.4.5",
"vite": "^5.3.1"
}
}
+6
View File
@@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}
+56
View File
@@ -0,0 +1,56 @@
import { Navigate, Route, Routes } from 'react-router-dom'
import { Layout } from './components/Layout'
import { RequireAuth, RequireAdmin } from './components/RequireAuth'
import { AdminPage } from './pages/AdminPage'
import { HomePage } from './pages/HomePage'
import { LibraryPage } from './pages/LibraryPage'
import { LoginPage } from './pages/LoginPage'
import { MediaDetailPage } from './pages/MediaDetailPage'
import { PlayerPage } from './pages/PlayerPage'
import { SearchPage } from './pages/SearchPage'
// Top-level route table.
//
// Public:
// /login
//
// Authenticated:
// / → home (continue watching + recently added)
// /library/:id → library content grid
// /search → keyword search
// /media/:id → media detail
// /play/:id → fullscreen player
//
// Admin only:
// /admin → users / settings / activity log
export default function App() {
return (
<Routes>
<Route path="/login" element={<LoginPage />} />
<Route
path="/"
element={
<RequireAuth>
<Layout />
</RequireAuth>
}
>
<Route index element={<HomePage />} />
<Route path="library/:id" element={<LibraryPage />} />
<Route path="search" element={<SearchPage />} />
<Route path="media/:id" element={<MediaDetailPage />} />
<Route path="play/:id" element={<PlayerPage />} />
<Route
path="admin"
element={
<RequireAdmin>
<AdminPage />
</RequireAdmin>
}
/>
</Route>
<Route path="*" element={<Navigate to="/" replace />} />
</Routes>
)
}
+15
View File
@@ -0,0 +1,15 @@
import { api } from './client'
import type { AccessLog, Setting, User } from '../types'
export const adminAPI = {
listUsers: () => api.get<User[]>('/admin/users').then((r) => r.data),
deleteUser: (id: string) => api.delete(`/admin/users/${id}`).then((r) => r.data),
listSettings: () => api.get<Setting[]>('/admin/settings').then((r) => r.data),
updateSetting: (key: string, value: string) =>
api.put('/admin/settings', { key, value }).then((r) => r.data),
recentLogs: () => api.get<AccessLog[]>('/admin/logs').then((r) => r.data),
}
+22
View File
@@ -0,0 +1,22 @@
import { api } from './client'
import type { User } from '../types'
export interface LoginResponse {
token: string
user: User
}
export const authAPI = {
login: (username: string, password: string) =>
api.post<LoginResponse>('/auth/login', { username, password }).then((r) => r.data),
register: (username: string, password: string) =>
api.post<User>('/auth/register', { username, password }).then((r) => r.data),
me: () => api.get<User>('/me').then((r) => r.data),
changePassword: (oldPassword: string, newPassword: string) =>
api
.post('/me/password', { old_password: oldPassword, new_password: newPassword })
.then((r) => r.data),
}
+39
View File
@@ -0,0 +1,39 @@
import axios, { AxiosError } from 'axios'
import { useAuthStore } from '../stores/auth'
// Single axios instance used by every API helper. Adds the JWT to outgoing
// requests and routes 401s back to the login page.
export const api = axios.create({
baseURL: '/api',
timeout: 30000,
})
api.interceptors.request.use((config) => {
const token = useAuthStore.getState().token
if (token) {
config.headers = config.headers ?? {}
config.headers.Authorization = `Bearer ${token}`
}
return config
})
api.interceptors.response.use(
(resp) => resp,
(err: AxiosError) => {
if (err.response?.status === 401) {
useAuthStore.getState().logout()
if (typeof window !== 'undefined' && window.location.pathname !== '/login') {
window.location.href = '/login'
}
}
return Promise.reject(err)
},
)
// Helper that returns a streaming URL with the JWT appended as a query
// parameter so <video src> works without an Authorization header.
export function streamURL(mediaId: string): string {
const token = useAuthStore.getState().token ?? ''
return `/api/stream/${encodeURIComponent(mediaId)}?token=${encodeURIComponent(token)}`
}
+35
View File
@@ -0,0 +1,35 @@
import { api } from './client'
import type { Library, Media, ScanResult } from '../types'
export interface MediaPage {
items: Media[]
total: number
page: number
page_size: number
}
export const libraryAPI = {
list: () => api.get<Library[]>('/libraries').then((r) => r.data),
create: (name: string, path: string, type: string) =>
api.post<Library>('/libraries', { name, path, type }).then((r) => r.data),
remove: (id: string) => api.delete(`/libraries/${id}`).then((r) => r.data),
scan: (id: string) =>
api.post<ScanResult>(`/libraries/${id}/scan`).then((r) => r.data),
listMedia: (id: string, page = 1, pageSize = 50) =>
api
.get<MediaPage>(`/libraries/${id}/media`, {
params: { page, page_size: pageSize },
})
.then((r) => r.data),
}
export const mediaAPI = {
search: (q: string, limit = 50) =>
api.get<{ items: Media[] }>('/media', { params: { q, limit } }).then((r) => r.data),
get: (id: string) => api.get<Media>(`/media/${id}`).then((r) => r.data),
}
+116
View File
@@ -0,0 +1,116 @@
import { useEffect, useState } from 'react'
import { Link, NavLink, Outlet, useNavigate } from 'react-router-dom'
import {
Film,
Home,
LogOut,
Search,
Settings,
Library as LibraryIcon,
} from 'lucide-react'
import clsx from 'clsx'
import { libraryAPI } from '../api/library'
import { useAuthStore } from '../stores/auth'
import type { Library } from '../types'
// Top-level chrome: a fixed left rail + scrollable content area.
export function Layout() {
const navigate = useNavigate()
const user = useAuthStore((s) => s.user)
const logout = useAuthStore((s) => s.logout)
const [libraries, setLibraries] = useState<Library[]>([])
useEffect(() => {
libraryAPI.list().then(setLibraries).catch(() => undefined)
}, [])
const handleLogout = () => {
logout()
navigate('/login')
}
return (
<div className="flex min-h-full">
<aside className="hidden w-64 shrink-0 flex-col border-r border-white/5 bg-surface-900/80 px-4 py-6 backdrop-blur md:flex">
<Link to="/" className="mb-8 flex items-center gap-2 px-2">
<Film className="h-6 w-6 text-primary-400" />
<span className="font-display text-xl font-bold tracking-wide text-white">
MediaStationGo
</span>
</Link>
<nav className="flex-1 space-y-1">
<SidebarLink to="/" icon={<Home size={18} />} label="首页" end />
<SidebarLink to="/search" icon={<Search size={18} />} label="搜索" />
<div className="mt-6 px-2 text-xs uppercase tracking-wider text-slate-500">
媒体库
</div>
{libraries.length === 0 && (
<div className="px-2 py-1 text-sm text-slate-500">暂无媒体库</div>
)}
{libraries.map((lib) => (
<SidebarLink
key={lib.id}
to={`/library/${lib.id}`}
icon={<LibraryIcon size={18} />}
label={lib.name}
/>
))}
{user?.role === 'admin' && (
<>
<div className="mt-6 px-2 text-xs uppercase tracking-wider text-slate-500">
管理
</div>
<SidebarLink to="/admin" icon={<Settings size={18} />} label="管理后台" />
</>
)}
</nav>
<button
onClick={handleLogout}
className="mt-4 flex items-center gap-2 rounded-lg px-3 py-2 text-sm text-slate-400 transition hover:bg-white/5 hover:text-white"
>
<LogOut size={16} />
退出登录 ({user?.username})
</button>
</aside>
<main className="flex-1 overflow-y-auto px-6 py-8">
<Outlet />
</main>
</div>
)
}
function SidebarLink({
to,
icon,
label,
end,
}: {
to: string
icon: React.ReactNode
label: string
end?: boolean
}) {
return (
<NavLink
to={to}
end={end}
className={({ isActive }) =>
clsx(
'flex items-center gap-3 rounded-lg px-3 py-2 text-sm transition',
isActive
? 'bg-primary-400/10 text-primary-400'
: 'text-slate-300 hover:bg-white/5 hover:text-white',
)
}
>
{icon}
<span className="truncate">{label}</span>
</NavLink>
)
}
+33
View File
@@ -0,0 +1,33 @@
import { Link } from 'react-router-dom'
import { Film } from 'lucide-react'
import type { Media } from '../types'
// Compact poster tile used by every listing surface (home / library / search).
export function MediaCard({ media }: { media: Media }) {
return (
<Link
to={`/media/${media.id}`}
className="group block overflow-hidden rounded-xl border border-white/5 bg-surface-800/60 transition hover:border-primary-400/50 hover:shadow-lg hover:shadow-primary-400/20"
>
<div className="aspect-[2/3] w-full bg-surface-900">
{media.poster_url ? (
<img
src={media.poster_url}
alt={media.title}
loading="lazy"
className="h-full w-full object-cover transition group-hover:scale-105"
/>
) : (
<div className="flex h-full w-full items-center justify-center text-slate-600">
<Film size={48} />
</div>
)}
</div>
<div className="px-3 py-2">
<p className="truncate text-sm font-medium text-white">{media.title}</p>
{media.year > 0 && <p className="text-xs text-slate-400">{media.year}</p>}
</div>
</Link>
)
}
+23
View File
@@ -0,0 +1,23 @@
import { ReactNode } from 'react'
import { Navigate, useLocation } from 'react-router-dom'
import { useAuthStore } from '../stores/auth'
// Route guard: redirects to /login when no token is present.
export function RequireAuth({ children }: { children: ReactNode }) {
const token = useAuthStore((s) => s.token)
const location = useLocation()
if (!token) {
return <Navigate to="/login" replace state={{ from: location }} />
}
return <>{children}</>
}
// Route guard: only allows users with role === "admin".
export function RequireAdmin({ children }: { children: ReactNode }) {
const user = useAuthStore((s) => s.user)
if (user?.role !== 'admin') {
return <Navigate to="/" replace />
}
return <>{children}</>
}
+47
View File
@@ -0,0 +1,47 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
:root {
color-scheme: dark;
}
html,
body,
#root {
height: 100%;
}
body {
background: radial-gradient(
ellipse at 20% 50%,
rgba(0, 240, 255, 0.08) 0%,
transparent 50%
),
radial-gradient(
ellipse at 80% 20%,
rgba(168, 85, 247, 0.08) 0%,
transparent 50%
),
#060a13;
color: #f5f7fa;
margin: 0;
}
@layer components {
.glass-panel {
@apply rounded-2xl border border-white/10 bg-surface-800/60 p-6 shadow-lg backdrop-blur-md;
}
.neon-button {
@apply inline-flex items-center justify-center gap-2 rounded-lg border border-primary-400/40
bg-primary-400/10 px-4 py-2 font-medium text-primary-400 transition
hover:border-primary-400 hover:bg-primary-400/20 hover:text-white
active:scale-95 disabled:cursor-not-allowed disabled:opacity-50;
}
.input-base {
@apply w-full rounded-lg border border-white/10 bg-surface-900/60 px-4 py-2 text-sm
text-white placeholder:text-slate-500 focus:border-primary-400 focus:outline-none;
}
}
+22
View File
@@ -0,0 +1,22 @@
import React from 'react'
import ReactDOM from 'react-dom/client'
import { BrowserRouter } from 'react-router-dom'
import { Toaster } from 'react-hot-toast'
import App from './App'
import './index.css'
// Application root: BrowserRouter + global toast container.
ReactDOM.createRoot(document.getElementById('root') as HTMLElement).render(
<React.StrictMode>
<BrowserRouter>
<App />
<Toaster
position="top-right"
toastOptions={{
className: '!bg-surface-800 !text-white !border !border-white/10',
}}
/>
</BrowserRouter>
</React.StrictMode>,
)
+253
View File
@@ -0,0 +1,253 @@
import { FormEvent, useEffect, useState } from 'react'
import toast from 'react-hot-toast'
import { Trash2 } from 'lucide-react'
import { adminAPI } from '../api/admin'
import { libraryAPI } from '../api/library'
import type { Library, User } from '../types'
// Single-page admin console. We split the surface into three panels:
// • Libraries — create / delete / scan
// • Users — list / delete (cannot delete self)
// • Settings — generic key/value store viewer
//
// Each panel manages its own loading / refresh state.
export function AdminPage() {
const [tab, setTab] = useState<'library' | 'users' | 'settings'>('library')
return (
<div className="space-y-6">
<h1 className="font-display text-3xl font-bold text-white">管理后台</h1>
<div className="flex gap-2 border-b border-white/10">
{(['library', 'users', 'settings'] as const).map((k) => (
<button
key={k}
onClick={() => setTab(k)}
className={
'border-b-2 px-4 py-2 text-sm transition ' +
(tab === k
? 'border-primary-400 text-primary-400'
: 'border-transparent text-slate-400 hover:text-white')
}
>
{k === 'library' ? '媒体库' : k === 'users' ? '用户' : '设置'}
</button>
))}
</div>
{tab === 'library' && <LibraryPanel />}
{tab === 'users' && <UsersPanel />}
{tab === 'settings' && <SettingsPanel />}
</div>
)
}
function LibraryPanel() {
const [libs, setLibs] = useState<Library[]>([])
const [name, setName] = useState('')
const [path, setPath] = useState('')
const [type, setType] = useState('movie')
const refresh = () => libraryAPI.list().then(setLibs)
useEffect(() => {
refresh().catch(() => undefined)
}, [])
const handleCreate = async (e: FormEvent) => {
e.preventDefault()
try {
await libraryAPI.create(name, path, type)
toast.success('媒体库已创建')
setName('')
setPath('')
await refresh()
} catch (err: unknown) {
const msg =
(err as { response?: { data?: { error?: string } } })?.response?.data?.error ??
'创建失败'
toast.error(msg)
}
}
return (
<div className="space-y-6">
<form onSubmit={handleCreate} className="glass-panel grid gap-3 md:grid-cols-4">
<input
required
className="input-base"
placeholder="名称"
value={name}
onChange={(e) => setName(e.target.value)}
/>
<input
required
className="input-base md:col-span-2"
placeholder="服务器路径,如 /media/movies"
value={path}
onChange={(e) => setPath(e.target.value)}
/>
<select className="input-base" value={type} onChange={(e) => setType(e.target.value)}>
<option value="movie">电影</option>
<option value="tv">电视剧</option>
<option value="anime">动漫</option>
<option value="music">音乐</option>
</select>
<button type="submit" className="neon-button md:col-span-4">
新建媒体库
</button>
</form>
<div className="glass-panel">
<table className="w-full text-left text-sm">
<thead className="text-xs uppercase tracking-wider text-slate-500">
<tr>
<th className="py-2">名称</th>
<th>路径</th>
<th>类型</th>
<th className="text-right">操作</th>
</tr>
</thead>
<tbody>
{libs.map((l) => (
<tr key={l.id} className="border-t border-white/5">
<td className="py-2 text-white">{l.name}</td>
<td className="text-slate-300">{l.path}</td>
<td className="text-slate-300">{l.type}</td>
<td className="space-x-2 py-2 text-right">
<button
className="rounded border border-primary-400/40 px-2 py-1 text-xs text-primary-400 hover:bg-primary-400/10"
onClick={async () => {
const r = await libraryAPI.scan(l.id)
toast.success(`扫描完成,新增 ${r.added}`)
}}
>
扫描
</button>
<button
className="rounded border border-red-400/40 px-2 py-1 text-xs text-red-400 hover:bg-red-400/10"
onClick={async () => {
if (!confirm(`确定删除「${l.name}」?`)) return
await libraryAPI.remove(l.id)
toast.success('已删除')
await refresh()
}}
>
<Trash2 size={12} />
</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
)
}
function UsersPanel() {
const [users, setUsers] = useState<User[]>([])
const refresh = () => adminAPI.listUsers().then(setUsers)
useEffect(() => {
refresh().catch(() => undefined)
}, [])
return (
<div className="glass-panel">
<table className="w-full text-left text-sm">
<thead className="text-xs uppercase tracking-wider text-slate-500">
<tr>
<th className="py-2">用户名</th>
<th>角色</th>
<th>最近登录</th>
<th className="text-right">操作</th>
</tr>
</thead>
<tbody>
{users.map((u) => (
<tr key={u.id} className="border-t border-white/5">
<td className="py-2 text-white">{u.username}</td>
<td className="text-slate-300">{u.role}</td>
<td className="text-slate-400">
{u.last_login_at ? new Date(u.last_login_at).toLocaleString() : '从未登录'}
</td>
<td className="py-2 text-right">
<button
className="rounded border border-red-400/40 px-2 py-1 text-xs text-red-400 hover:bg-red-400/10"
onClick={async () => {
if (!confirm(`确定删除「${u.username}」?`)) return
await adminAPI.deleteUser(u.id)
toast.success('已删除')
await refresh()
}}
>
<Trash2 size={12} />
</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
)
}
function SettingsPanel() {
const [items, setItems] = useState<{ key: string; value: string }[]>([])
const [k, setK] = useState('')
const [v, setV] = useState('')
const refresh = () => adminAPI.listSettings().then(setItems)
useEffect(() => {
refresh().catch(() => undefined)
}, [])
const onSave = async (e: FormEvent) => {
e.preventDefault()
await adminAPI.updateSetting(k, v)
toast.success('已保存')
setK('')
setV('')
await refresh()
}
return (
<div className="space-y-4">
<form onSubmit={onSave} className="glass-panel grid gap-3 md:grid-cols-3">
<input
required
className="input-base"
placeholder="键 (如 tmdb_api_key)"
value={k}
onChange={(e) => setK(e.target.value)}
/>
<input
required
className="input-base"
placeholder="值"
value={v}
onChange={(e) => setV(e.target.value)}
/>
<button type="submit" className="neon-button">
保存
</button>
</form>
<div className="glass-panel">
<table className="w-full text-left text-sm">
<thead className="text-xs uppercase tracking-wider text-slate-500">
<tr>
<th className="py-2">键</th>
<th>值</th>
</tr>
</thead>
<tbody>
{items.map((it) => (
<tr key={it.key} className="border-t border-white/5">
<td className="py-2 text-white">{it.key}</td>
<td className="text-slate-300">{it.value}</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
)
}
+44
View File
@@ -0,0 +1,44 @@
import { useEffect, useState } from 'react'
import { mediaAPI } from '../api/library'
import { MediaCard } from '../components/MediaCard'
import type { Media } from '../types'
// Landing page: shows the most recently added media items across every
// library. Once the scraper / continue-watching subsystems land we will
// fold in additional rows here.
export function HomePage() {
const [items, setItems] = useState<Media[]>([])
const [loading, setLoading] = useState(true)
useEffect(() => {
mediaAPI
.search('', 24)
.then((d) => setItems(d.items))
.finally(() => setLoading(false))
}, [])
return (
<div className="space-y-8">
<header>
<h1 className="font-display text-3xl font-bold text-white">最近添加</h1>
<p className="text-sm text-slate-400">最新被扫描入库的媒体内容</p>
</header>
{loading && <p className="text-slate-500">加载中…</p>}
{!loading && items.length === 0 && (
<div className="glass-panel">
<p className="text-slate-300">
还没有任何媒体。前往 <span className="text-primary-400">管理后台</span>{' '}
创建媒体库,然后触发一次扫描。
</p>
</div>
)}
<div className="grid grid-cols-2 gap-4 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-6">
{items.map((m) => (
<MediaCard key={m.id} media={m} />
))}
</div>
</div>
)
}
+68
View File
@@ -0,0 +1,68 @@
import { useEffect, useState } from 'react'
import { useParams } from 'react-router-dom'
import toast from 'react-hot-toast'
import { libraryAPI } from '../api/library'
import type { Media } from '../types'
import { MediaCard } from '../components/MediaCard'
import { useAuthStore } from '../stores/auth'
// Browse a single library. Admins also get a "scan" button that triggers
// a fresh scan run on the backend.
export function LibraryPage() {
const { id = '' } = useParams()
const role = useAuthStore((s) => s.user?.role)
const [items, setItems] = useState<Media[]>([])
const [total, setTotal] = useState(0)
const [loading, setLoading] = useState(true)
const [scanning, setScanning] = useState(false)
useEffect(() => {
if (!id) return
setLoading(true)
libraryAPI
.listMedia(id)
.then((d) => {
setItems(d.items)
setTotal(d.total)
})
.finally(() => setLoading(false))
}, [id])
const handleScan = async () => {
setScanning(true)
try {
const r = await libraryAPI.scan(id)
toast.success(`扫描完成:新增 ${r.added} 个文件`)
const d = await libraryAPI.listMedia(id)
setItems(d.items)
setTotal(d.total)
} catch {
toast.error('扫描失败')
} finally {
setScanning(false)
}
}
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<h1 className="font-display text-3xl font-bold text-white">
媒体库 <span className="text-slate-500">({total})</span>
</h1>
{role === 'admin' && (
<button onClick={handleScan} disabled={scanning} className="neon-button">
{scanning ? '扫描中…' : '立即扫描'}
</button>
)}
</div>
{loading && <p className="text-slate-500">加载中…</p>}
<div className="grid grid-cols-2 gap-4 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-6">
{items.map((m) => (
<MediaCard key={m.id} media={m} />
))}
</div>
</div>
)
}
+84
View File
@@ -0,0 +1,84 @@
import { FormEvent, useState } from 'react'
import { useNavigate } from 'react-router-dom'
import toast from 'react-hot-toast'
import { Film } from 'lucide-react'
import { authAPI } from '../api/auth'
import { useAuthStore } from '../stores/auth'
// Single-form login screen. The first install seeds admin/admin123 — we
// surface that hint when no JWT exists yet.
export function LoginPage() {
const navigate = useNavigate()
const setSession = useAuthStore((s) => s.setSession)
const [username, setUsername] = useState('')
const [password, setPassword] = useState('')
const [loading, setLoading] = useState(false)
const handleSubmit = async (e: FormEvent) => {
e.preventDefault()
setLoading(true)
try {
const data = await authAPI.login(username, password)
setSession(data.token, data.user)
toast.success(`欢迎回来,${data.user.username}`)
navigate('/')
} catch (err: unknown) {
const msg =
(err as { response?: { data?: { error?: string } } })?.response?.data?.error ??
'登录失败,请检查用户名与密码'
toast.error(msg)
} finally {
setLoading(false)
}
}
return (
<div className="flex min-h-full items-center justify-center px-4 py-10">
<form onSubmit={handleSubmit} className="glass-panel w-full max-w-md">
<div className="mb-8 flex flex-col items-center gap-2">
<Film className="h-10 w-10 text-primary-400" />
<h1 className="font-display text-2xl font-bold tracking-wide text-white">
MediaStationGo
</h1>
<p className="text-sm text-slate-400">登录到你的家庭媒体中心</p>
</div>
<label className="mb-4 block">
<span className="mb-1 block text-sm text-slate-300">用户名</span>
<input
className="input-base"
value={username}
onChange={(e) => setUsername(e.target.value)}
autoComplete="username"
required
/>
</label>
<label className="mb-6 block">
<span className="mb-1 block text-sm text-slate-300">密码</span>
<input
type="password"
className="input-base"
value={password}
onChange={(e) => setPassword(e.target.value)}
autoComplete="current-password"
required
/>
</label>
<button
type="submit"
disabled={loading}
className="neon-button w-full justify-center"
>
{loading ? '登录中…' : '登录'}
</button>
<p className="mt-6 text-center text-xs text-slate-500">
首次部署默认账号:<code className="text-primary-400">admin / admin123</code>
</p>
</form>
</div>
)
}
+87
View File
@@ -0,0 +1,87 @@
import { useEffect, useState } from 'react'
import { Link, useParams } from 'react-router-dom'
import { Play } from 'lucide-react'
import { mediaAPI } from '../api/library'
import type { Media } from '../types'
function fmtDuration(sec: number): string {
if (!sec || sec <= 0) return '—'
const h = Math.floor(sec / 3600)
const m = Math.floor((sec % 3600) / 60)
return h > 0 ? `${h}h ${m}m` : `${m}m`
}
function fmtSize(bytes: number): string {
if (!bytes || bytes <= 0) return '—'
const units = ['B', 'KB', 'MB', 'GB', 'TB']
let v = bytes
let i = 0
while (v >= 1024 && i < units.length - 1) {
v /= 1024
i++
}
return `${v.toFixed(2)} ${units[i]}`
}
// Detail screen for a single media item: poster, summary and a Play CTA.
export function MediaDetailPage() {
const { id = '' } = useParams()
const [media, setMedia] = useState<Media | null>(null)
const [loading, setLoading] = useState(true)
useEffect(() => {
if (!id) return
setLoading(true)
mediaAPI
.get(id)
.then(setMedia)
.finally(() => setLoading(false))
}, [id])
if (loading) return <p className="text-slate-500">加载中…</p>
if (!media) return <p className="text-slate-500">媒体不存在</p>
return (
<div className="space-y-8">
<div className="flex flex-col gap-6 md:flex-row">
<div className="aspect-[2/3] w-48 shrink-0 overflow-hidden rounded-xl bg-surface-800">
{media.poster_url ? (
<img src={media.poster_url} alt={media.title} className="h-full w-full object-cover" />
) : (
<div className="flex h-full w-full items-center justify-center text-slate-600">
无海报
</div>
)}
</div>
<div className="flex-1 space-y-4">
<h1 className="font-display text-3xl font-bold text-white">{media.title}</h1>
{media.year > 0 && <p className="text-slate-400">{media.year}</p>}
{media.overview && <p className="text-slate-300">{media.overview}</p>}
<div className="flex flex-wrap gap-2 text-xs text-slate-400">
{media.video_codec && <Badge>{media.video_codec.toUpperCase()}</Badge>}
{media.width > 0 && (
<Badge>
{media.width}×{media.height}
</Badge>
)}
<Badge>{fmtDuration(media.duration_sec)}</Badge>
<Badge>{fmtSize(media.size_bytes)}</Badge>
{media.container && <Badge>{media.container.toUpperCase()}</Badge>}
</div>
<Link to={`/play/${media.id}`} className="neon-button">
<Play size={18} /> 播放
</Link>
</div>
</div>
</div>
)
}
function Badge({ children }: { children: React.ReactNode }) {
return (
<span className="rounded border border-white/10 bg-white/5 px-2 py-0.5">{children}</span>
)
}
+53
View File
@@ -0,0 +1,53 @@
import { useEffect, useRef, useState } from 'react'
import { useNavigate, useParams } from 'react-router-dom'
import { ArrowLeft } from 'lucide-react'
import { mediaAPI } from '../api/library'
import { streamURL } from '../api/client'
import type { Media } from '../types'
// Fullscreen, dark-themed video page. We currently use direct play via
// /api/stream/:id — the future task is wiring HLS.js for transcoded streams.
export function PlayerPage() {
const { id = '' } = useParams()
const navigate = useNavigate()
const ref = useRef<HTMLVideoElement>(null)
const [media, setMedia] = useState<Media | null>(null)
useEffect(() => {
if (!id) return
mediaAPI.get(id).then(setMedia)
}, [id])
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
if (e.key === 'Escape') navigate(-1)
}
window.addEventListener('keydown', onKey)
return () => window.removeEventListener('keydown', onKey)
}, [navigate])
return (
<div className="-m-6 flex min-h-screen flex-col bg-black md:-m-8">
<button
onClick={() => navigate(-1)}
className="absolute left-4 top-4 z-10 flex items-center gap-2 rounded-full bg-black/60 px-3 py-1.5 text-sm text-white backdrop-blur transition hover:bg-black/80"
>
<ArrowLeft size={16} /> 返回
</button>
<div className="flex flex-1 items-center justify-center">
{media ? (
<video
ref={ref}
src={streamURL(media.id)}
controls
autoPlay
className="max-h-screen w-full max-w-[1600px] bg-black"
/>
) : (
<p className="text-slate-500">加载中…</p>
)}
</div>
</div>
)
}
+42
View File
@@ -0,0 +1,42 @@
import { ChangeEvent, useEffect, useState } from 'react'
import { mediaAPI } from '../api/library'
import { MediaCard } from '../components/MediaCard'
import type { Media } from '../types'
// Search-as-you-type with a 300ms debounce.
export function SearchPage() {
const [q, setQ] = useState('')
const [items, setItems] = useState<Media[]>([])
const [loading, setLoading] = useState(false)
useEffect(() => {
const t = setTimeout(() => {
setLoading(true)
mediaAPI
.search(q, 60)
.then((d) => setItems(d.items))
.finally(() => setLoading(false))
}, 300)
return () => clearTimeout(t)
}, [q])
return (
<div className="space-y-6">
<h1 className="font-display text-3xl font-bold text-white">搜索</h1>
<input
autoFocus
className="input-base"
placeholder="按标题搜索…"
value={q}
onChange={(e: ChangeEvent<HTMLInputElement>) => setQ(e.target.value)}
/>
{loading && <p className="text-slate-500">搜索中…</p>}
<div className="grid grid-cols-2 gap-4 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-6">
{items.map((m) => (
<MediaCard key={m.id} media={m} />
))}
</div>
</div>
)
}
+27
View File
@@ -0,0 +1,27 @@
import { create } from 'zustand'
import { persist } from 'zustand/middleware'
import type { User } from '../types'
// Single source of truth for the authenticated user + JWT.
// Persisted to localStorage so a page reload does not drop the session.
interface AuthState {
token: string | null
user: User | null
setSession: (token: string, user: User) => void
setUser: (user: User) => void
logout: () => void
}
export const useAuthStore = create<AuthState>()(
persist(
(set) => ({
token: null,
user: null,
setSession: (token, user) => set({ token, user }),
setUser: (user) => set({ user }),
logout: () => set({ token: null, user: null }),
}),
{ name: 'mediastationgo-auth' },
),
)
+74
View File
@@ -0,0 +1,74 @@
// Domain types mirrored from the Go backend (internal/model).
export interface User {
id: string
username: string
role: 'admin' | 'user'
email?: string
avatar_url?: string
force_password_reset: boolean
last_login_at?: string
created_at: string
updated_at: string
}
export interface Library {
id: string
name: string
path: string
type: string
enabled: boolean
created_at: string
updated_at: string
}
export interface Media {
id: string
library_id: string
series_id?: string
title: string
original_name?: string
path: string
size_bytes: number
duration_sec: number
width: number
height: number
video_codec?: string
audio_codec?: string
container?: string
poster_url?: string
backdrop_url?: string
overview?: string
rating: number
year: number
season_num: number
episode_num: number
scrape_status: string
tmdb_id: number
bangumi_id: number
nsfw: boolean
created_at: string
updated_at: string
}
export interface ScanResult {
library_id: string
visited: number
added: number
}
export interface Setting {
key: string
value: string
updated_at: string
}
export interface AccessLog {
id: string
user_id: string
action: string
target: string
ip: string
detail: string
created_at: string
}
+1
View File
@@ -0,0 +1 @@
/// <reference types="vite/client" />
+30
View File
@@ -0,0 +1,30 @@
/** @type {import('tailwindcss').Config} */
export default {
content: ['./index.html', './src/**/*.{js,ts,jsx,tsx}'],
theme: {
extend: {
fontFamily: {
display: ['Orbitron', 'sans-serif'],
body: ['Inter', 'PingFang SC', 'Microsoft YaHei', 'sans-serif'],
},
colors: {
primary: {
400: '#00F0FF',
500: '#00D4E0',
600: '#00A8B8',
},
accent: {
400: '#A855F7',
500: '#8A2BE2',
},
surface: {
700: '#1a2332',
800: '#121a27',
900: '#0b1120',
950: '#060a13',
},
},
},
},
plugins: [],
}
+24
View File
@@ -0,0 +1,24 @@
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"isolatedModules": true,
"moduleDetection": "force",
"noEmit": true,
"jsx": "react-jsx",
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
"baseUrl": ".",
"paths": {
"@/*": ["src/*"]
}
},
"include": ["src"]
}
+28
View File
@@ -0,0 +1,28 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
import path from 'path'
// Vite dev server proxies /api/* to the Go backend on :8080.
// WebSocket upgrade is forwarded automatically (ws: true).
export default defineConfig({
plugins: [react()],
resolve: {
alias: {
'@': path.resolve(__dirname, './src'),
},
},
server: {
port: 3000,
proxy: {
'/api': {
target: 'http://localhost:8080',
changeOrigin: true,
ws: true,
},
},
},
build: {
outDir: 'dist',
sourcemap: false,
},
})