commit d5cf5fb4b20af4e0da5d55e18460def3bd5b1d6f Author: Kiro Date: Thu May 14 15:26:29 2026 +0000 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). diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..92fdeea --- /dev/null +++ b/.dockerignore @@ -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/ diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..0e934e6 --- /dev/null +++ b/.env.example @@ -0,0 +1,21 @@ +# MediaStationGo — environment variables (copy to `.env` if needed). +# +# Every config key is overridable via MEDIASTATION_=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= diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..6435991 --- /dev/null +++ b/.github/workflows/ci.yml @@ -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 diff --git a/.github/workflows/docker-publish.yml b/.github/workflows/docker-publish.yml new file mode 100644 index 0000000..98f5c02 --- /dev/null +++ b/.github/workflows/docker-publish.yml @@ -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 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..d19d024 --- /dev/null +++ b/.gitignore @@ -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 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..e27bfcb --- /dev/null +++ b/CONTRIBUTING.md @@ -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! diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..933381a --- /dev/null +++ b/Dockerfile @@ -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"] diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..94f8079 --- /dev/null +++ b/LICENSE @@ -0,0 +1,9 @@ + GNU GENERAL PUBLIC LICENSE + Version 3, 29 June 2007 + + Copyright (C) 2007 Free Software Foundation, Inc. + 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 diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..77fdbad --- /dev/null +++ b/Makefile @@ -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/ diff --git a/README.md b/README.md new file mode 100644 index 0000000..a3f3710 --- /dev/null +++ b/README.md @@ -0,0 +1,139 @@ +

🎬 MediaStationGo

+

A Go rewrite of MediaStation — your private home media center.

+ +

+ Go + React + TypeScript + SQLite + Docker + License +

+ +--- + +## 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 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). diff --git a/cmd/server/main.go b/cmd/server/main.go new file mode 100644 index 0000000..44cd6cb --- /dev/null +++ b/cmd/server/main.go @@ -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() +} diff --git a/config.example.yaml b/config.example.yaml new file mode 100644 index 0000000..ffb64d6 --- /dev/null +++ b/config.example.yaml @@ -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 diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..ae473de --- /dev/null +++ b/docker-compose.yml @@ -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://: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 diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..445fbbc --- /dev/null +++ b/go.mod @@ -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 +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..c4d0064 --- /dev/null +++ b/go.sum @@ -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= diff --git a/internal/config/config.go b/internal/config/config.go new file mode 100644 index 0000000..66ef30a --- /dev/null +++ b/internal/config/config.go @@ -0,0 +1,241 @@ +// Package config loads layered configuration from defaults, config files and +// environment variables, mirroring the conventions used by nowen-video. +// +// Priority (low -> high): +// 1. Built-in defaults +// 2. config.yaml in the working directory (nested format) +// 3. config/*.yaml shard files (per-module) +// 4. Environment variables prefixed with MEDIASTATION_ +// +// Environment variable example: +// +// MEDIASTATION_APP_PORT=8080 +// MEDIASTATION_SECRETS_JWT_SECRET=please-change-me +// MEDIASTATION_DATABASE_DB_PATH=/data/mediastation.db +package config + +import ( + "crypto/rand" + "encoding/hex" + "fmt" + "os" + "path/filepath" + "strings" + + "github.com/spf13/viper" +) + +// EnvPrefix is the prefix used for all env-var-driven overrides. +const EnvPrefix = "MEDIASTATION" + +// Config is the root config aggregate. +type Config struct { + App AppConfig `mapstructure:"app"` + Database DatabaseConfig `mapstructure:"database"` + Secrets SecretsConfig `mapstructure:"secrets"` + Logging LoggingConfig `mapstructure:"logging"` + Cache CacheConfig `mapstructure:"cache"` + Media MediaConfig `mapstructure:"media"` + AI AIConfig `mapstructure:"ai"` +} + +// AppConfig holds runtime app parameters. +type AppConfig struct { + Port int `mapstructure:"port"` + Debug bool `mapstructure:"debug"` + Env string `mapstructure:"env"` + DataDir string `mapstructure:"data_dir"` + WebDir string `mapstructure:"web_dir"` + FFmpegPath string `mapstructure:"ffmpeg_path"` + FFprobePath string `mapstructure:"ffprobe_path"` + VAAPIDevice string `mapstructure:"vaapi_device"` + CORSOrigins []string `mapstructure:"cors_origins"` + ServerURL string `mapstructure:"server_url"` +} + +// DatabaseConfig configures GORM + SQLite. +type DatabaseConfig struct { + DBPath string `mapstructure:"db_path"` + WALMode bool `mapstructure:"wal_mode"` + BusyTimeout int `mapstructure:"busy_timeout"` + CacheSize int `mapstructure:"cache_size"` + MaxOpenConns int `mapstructure:"max_open_conns"` + MaxIdleConns int `mapstructure:"max_idle_conns"` +} + +// SecretsConfig holds JWT / 3rd-party API keys (do NOT commit values). +type SecretsConfig struct { + JWTSecret string `mapstructure:"jwt_secret"` + TMDbAPIKey string `mapstructure:"tmdb_api_key"` + TMDbAPIProxy string `mapstructure:"tmdb_api_proxy"` + TMDbImageProxy string `mapstructure:"tmdb_image_proxy"` + BangumiToken string `mapstructure:"bangumi_access_token"` + TheTVDBAPIKey string `mapstructure:"thetvdb_api_key"` + FanartAPIKey string `mapstructure:"fanart_tv_api_key"` + DoubanCookie string `mapstructure:"douban_cookie"` +} + +// LoggingConfig configures Zap. +type LoggingConfig struct { + Level string `mapstructure:"level"` + Format string `mapstructure:"format"` + OutputPath string `mapstructure:"output_path"` + EnableRotation bool `mapstructure:"enable_rotation"` + MaxSizeMB int `mapstructure:"max_size_mb"` + MaxAgeDays int `mapstructure:"max_age_days"` + MaxBackups int `mapstructure:"max_backups"` +} + +// CacheConfig controls the on-disk transcode/scrape cache. +type CacheConfig struct { + CacheDir string `mapstructure:"cache_dir"` + MaxDiskUsageMB int `mapstructure:"max_disk_usage_mb"` + TTLHours int `mapstructure:"ttl_hours"` + AutoCleanup bool `mapstructure:"auto_cleanup"` + CleanupIntervalMin int `mapstructure:"cleanup_interval_min"` +} + +// MediaConfig holds default library locations (used by the bootstrap library). +type MediaConfig struct { + MoviesDir string `mapstructure:"movies_dir"` + TVDir string `mapstructure:"tv_dir"` + AnimeDir string `mapstructure:"anime_dir"` +} + +// AIConfig configures the optional LLM provider. +type AIConfig struct { + Enabled bool `mapstructure:"enabled"` + Provider string `mapstructure:"provider"` + APIBase string `mapstructure:"api_base"` + APIKey string `mapstructure:"api_key"` + Model string `mapstructure:"model"` + Timeout int `mapstructure:"timeout"` + MaxConcurrent int `mapstructure:"max_concurrent"` +} + +// Load reads configuration from defaults / files / environment. +// +// It always returns a usable Config, even if no files are present. +func Load() (*Config, error) { + v := viper.New() + setDefaults(v) + + v.SetConfigName("config") + v.SetConfigType("yaml") + v.AddConfigPath(".") + v.AddConfigPath("./config") + if err := v.ReadInConfig(); err != nil { + var notFound viper.ConfigFileNotFoundError + if !asConfigFileNotFound(err, ¬Found) { + return nil, fmt.Errorf("read main config: %w", err) + } + } + + // Merge sharded files under ./config/*.yaml. + if entries, err := os.ReadDir("config"); err == nil { + for _, e := range entries { + if e.IsDir() || !strings.HasSuffix(e.Name(), ".yaml") { + continue + } + s := viper.New() + s.SetConfigFile(filepath.Join("config", e.Name())) + if err := s.ReadInConfig(); err == nil { + _ = v.MergeConfigMap(s.AllSettings()) + } + } + } + + v.SetEnvPrefix(EnvPrefix) + v.SetEnvKeyReplacer(strings.NewReplacer(".", "_")) + v.AutomaticEnv() + + cfg := &Config{} + if err := v.Unmarshal(cfg); err != nil { + return nil, fmt.Errorf("decode config: %w", err) + } + if err := cfg.normalize(); err != nil { + return nil, err + } + return cfg, nil +} + +func setDefaults(v *viper.Viper) { + v.SetDefault("app.port", 8080) + v.SetDefault("app.debug", false) + v.SetDefault("app.env", "production") + v.SetDefault("app.data_dir", "./data") + v.SetDefault("app.web_dir", "./web/dist") + v.SetDefault("app.ffmpeg_path", "ffmpeg") + v.SetDefault("app.ffprobe_path", "ffprobe") + v.SetDefault("app.vaapi_device", "/dev/dri/renderD128") + v.SetDefault("app.cors_origins", []string{}) + v.SetDefault("app.server_url", "") + + v.SetDefault("database.db_path", "./data/mediastation.db") + v.SetDefault("database.wal_mode", true) + v.SetDefault("database.busy_timeout", 5000) + v.SetDefault("database.cache_size", -20000) + v.SetDefault("database.max_open_conns", 1) + v.SetDefault("database.max_idle_conns", 1) + + v.SetDefault("secrets.jwt_secret", "") + + v.SetDefault("logging.level", "info") + v.SetDefault("logging.format", "console") + v.SetDefault("logging.max_size_mb", 100) + v.SetDefault("logging.max_age_days", 30) + v.SetDefault("logging.max_backups", 10) + + v.SetDefault("cache.cache_dir", "./cache") + v.SetDefault("cache.cleanup_interval_min", 60) + + v.SetDefault("ai.enabled", false) + v.SetDefault("ai.provider", "openai") + v.SetDefault("ai.api_base", "https://api.openai.com/v1") + v.SetDefault("ai.model", "gpt-4o-mini") + v.SetDefault("ai.timeout", 30) + v.SetDefault("ai.max_concurrent", 3) +} + +// normalize fills derived defaults and self-heals empty critical fields. +func (c *Config) normalize() error { + if c.App.DataDir == "" { + c.App.DataDir = "./data" + } + if c.Database.DBPath == "" { + c.Database.DBPath = filepath.Join(c.App.DataDir, "mediastation.db") + } + if c.Cache.CacheDir == "" { + c.Cache.CacheDir = filepath.Join(c.App.DataDir, "cache") + } + if c.Secrets.JWTSecret == "" { + // Persist an auto-generated secret to keep sessions stable across + // restarts even when the operator forgot to configure one. + path := filepath.Join(c.App.DataDir, ".jwt_secret") + if data, err := os.ReadFile(path); err == nil && len(data) > 0 { + c.Secrets.JWTSecret = strings.TrimSpace(string(data)) + } else { + buf := make([]byte, 32) + if _, err := rand.Read(buf); err != nil { + return fmt.Errorf("generate jwt secret: %w", err) + } + c.Secrets.JWTSecret = hex.EncodeToString(buf) + _ = os.MkdirAll(c.App.DataDir, 0o755) + _ = os.WriteFile(path, []byte(c.Secrets.JWTSecret), 0o600) + } + } + return nil +} + +// asConfigFileNotFound is a small helper around errors.As that avoids importing +// errors in this short file. +func asConfigFileNotFound(err error, target *viper.ConfigFileNotFoundError) bool { + if err == nil { + return false + } + if v, ok := err.(viper.ConfigFileNotFoundError); ok { + *target = v + return true + } + return false +} diff --git a/internal/config/config_test.go b/internal/config/config_test.go new file mode 100644 index 0000000..0a53364 --- /dev/null +++ b/internal/config/config_test.go @@ -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) + } +} diff --git a/internal/database/database.go b/internal/database/database.go new file mode 100644 index 0000000..52fb04f --- /dev/null +++ b/internal/database/database.go @@ -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...) +} diff --git a/internal/handler/admin.go b/internal/handler/admin.go new file mode 100644 index 0000000..e07bb9b --- /dev/null +++ b/internal/handler/admin.go @@ -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) + } +} diff --git a/internal/handler/auth.go b/internal/handler/auth.go new file mode 100644 index 0000000..4fdab54 --- /dev/null +++ b/internal/handler/auth.go @@ -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) + } +} diff --git a/internal/handler/handler.go b/internal/handler/handler.go new file mode 100644 index 0000000..669531b --- /dev/null +++ b/internal/handler/handler.go @@ -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"}) +} diff --git a/internal/handler/media.go b/internal/handler/media.go new file mode 100644 index 0000000..25f71be --- /dev/null +++ b/internal/handler/media.go @@ -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 + } + } +} diff --git a/internal/handler/ws.go b/internal/handler/ws.go new file mode 100644 index 0000000..6d04fb3 --- /dev/null +++ b/internal/handler/ws.go @@ -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 + } + } + } + } +} diff --git a/internal/middleware/middleware.go b/internal/middleware/middleware.go new file mode 100644 index 0000000..3688b95 --- /dev/null +++ b/internal/middleware/middleware.go @@ -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