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