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