Merge pull request #2 from ShukeBta/kiro/iter3-comprehensive

feat: downloads, RSS, subtitles, Bangumi, watcher, stats, profile, audit
This commit is contained in:
Shuke
2026-05-15 00:25:34 +08:00
committed by GitHub
50 changed files with 3134 additions and 146 deletions
+85 -23
View File
@@ -24,35 +24,59 @@ deployment model used by [`cropflre/nowen-video`](https://github.com/cropflre/no
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.
scanning, scraping, direct play/HLS, multi-user, downloads, RSS) while
making deployment painless on NAS hardware.
---
## Features (current scaffold)
## Features
- ✅ JWT authentication with admin/user roles
- ✅ First-run admin seeding (`admin / admin123`)
### Authentication & users
- ✅ JWT auth with admin/user roles
- ✅ First-run admin seeding (`admin / admin123`, override via `ADMIN_INITIAL_PASSWORD`)
- ✅ Profile page (email / avatar / change password)
- ✅ Admin user table with role promotion / demotion
- ✅ Audit log written for sensitive actions (login, library CRUD, downloads, …)
### Library management
- ✅ Library CRUD + recursive filesystem scan
- ✅ ffprobe metadata extraction (duration / resolution / codecs)
- ✅ TMDb scraper with image proxy (poster / backdrop / overview / rating)
- ✅ ffprobe metadata extraction (duration / resolution / codecs / container)
- ✅ Scene-noise filename cleaner with year + season/episode parsing
- ✅ TMDb scraper (movies) + Bangumi scraper (anime), provider chain by library type
- ✅ Image proxy with disk cache for TMDb / Bangumi / Douban / Fanart / TheTVDB
- ✅ TV / anime libraries grouped by season with episode listing
- ✅ fsnotify-based filesystem watcher with 5 s coalescing debouncer
### Playback
- ✅ Direct-play streaming with HTTP `Range` support
- ✅ HLS on-demand transcoding (single ffmpeg job per media)
- ✅ Playback history (resume) + Continue Watching row
- ✅ Favourites + Playlists (CRUD + ordered items)
- ✅ Real-time scan / scrape / transcode progress via WebSocket
- ✅ React SPA with code-splitting: Login / Home / Library / Search /
Favourites / Playlists / Media detail / Player (HLS + direct) / Admin
- ✅ Single-binary build, multi-arch Docker image, GitHub Actions CI
- ✅ External subtitle discovery (.srt / .vtt / .ass / .ssa) with on-the-fly WebVTT conversion
- ✅ Resume position written every 10 s + Continue Watching row on home
- ✅ Favourites (toggle) + ordered Playlists (CRUD)
### Automation
- ✅ qBittorrent download integration (add / list / delete via Web UI API)
- ✅ RSS subscriptions with regex filters, GUID dedup and 10-minute polling
### Operations
- ✅ Real-time scan / scrape / transcode / download / subscription events over WebSocket
- ✅ Operator dashboard at `/stats` (CPU / memory / disk / library counts / Goroutines)
- ✅ Single-binary build, multi-arch Docker image, GitHub Actions CI + GHCR publish
### Frontend
- ✅ React SPA with code-splitting: Login / Home / Library / Search / Favourites /
Playlists / Media detail / Player (HLS + direct + subtitles) / Profile /
Downloads / Subscriptions / Stats / Admin
- ✅ Global toast notifications driven by the WebSocket hub
- ✅ Initial bundle ~250 KB / 83 KB gzipped (hls.js loaded only on first HLS playback)
### Roadmap
| Area | Status |
|------|--------|
| Bangumi / Douban / Fanart scraper providers | ⏳ |
| Hardware-accelerated transcoding (NVENC / QSV / VAAPI) | ⏳ |
| qBittorrent / Transmission / RSS automation | ⏳ |
| Subtitles (extract / search / sync) | ⏳ |
| Hardware-accelerated transcoding (NVENC / QSV / VAAPI) profiles | ⏳ |
| Douban / Fanart / TheTVDB providers | ⏳ |
| Subtitle search (online providers) | ⏳ |
| Emby/Jellyfin compatibility layer | ⏳ |
| DLNA / Chromecast | ⏳ |
| AI metadata enhancement & smart search | ⏳ |
@@ -95,7 +119,7 @@ make dev-web # vite dev server on :3000, proxies /api -> :8080
Configuration is layered — defaults < `config.yaml` < `config/*.yaml` <
environment variables prefixed with `MEDIASTATION_`.
The most common keys:
### Most-used keys
| Key | Default | Purpose |
|------|---------|---------|
@@ -104,9 +128,25 @@ The most common keys:
| `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_SECRETS_TMDB_API_KEY` | *(empty)* | Enables movie scraping |
| `MEDIASTATION_SECRETS_BANGUMI_ACCESS_TOKEN` | *(empty)* | Optional, raises Bangumi rate limit |
| `MEDIASTATION_APP_CORS_ORIGINS` | *(empty)* | Allow-list, JSON array |
| `ADMIN_INITIAL_PASSWORD` | `admin123` | Bootstrap admin password |
### Runtime settings (admin → 设置)
These live in the `settings` table and can be edited from the admin UI:
| Key | Purpose |
|-----|---------|
| `qbittorrent.url` | qBittorrent Web UI base URL |
| `qbittorrent.username` | qBittorrent user |
| `qbittorrent.password` | qBittorrent password |
| `qbittorrent.savepath` | Optional default save path for new torrents |
After editing, hit **下载 → 重新加载配置** (or `POST /api/downloads/reload`) so
the qBittorrent client picks up the new credentials.
See [`config.example.yaml`](config.example.yaml) for the full surface.
---
@@ -121,13 +161,35 @@ MediaStationGo/
│ ├── database/ GORM + SQLite (WAL) bootstrap
│ ├── model/ GORM data models + AutoMigrate registry
│ ├── repository/ Thin data-access layer
│ ├── service/ Business logic (auth/media/scan/stream/ws)
│ ├── service/ Business logic
│ │ ├── auth.go login / register / JWT / seed admin
│ │ ├── media.go library + media CRUD
│ │ ├── scanner.go fs walker + ffprobe + scrape kick
│ │ ├── ffprobe.go ffprobe wrapper
│ │ ├── tmdb.go TMDb provider
│ │ ├── bangumi.go Bangumi provider
│ │ ├── scraper.go orchestrator + filename cleaner
│ │ ├── stream.go direct play + HLS playlist / segment
│ │ ├── transcoder.go per-media ffmpeg HLS job manager
│ │ ├── subtitle.go external subtitle discovery + .vtt conversion
│ │ ├── image_proxy.go cached, allow-listed image proxy
│ │ ├── playback.go history / favourites / playlists
│ │ ├── watcher.go fsnotify debouncer
│ │ ├── qbittorrent.go qBittorrent v2 API client
│ │ ├── downloads.go download orchestrator + WS poller
│ │ ├── subscription.go RSS poller
│ │ ├── stats.go dashboard snapshot
│ │ ├── profile.go non-credential user mutations
│ │ ├── audit.go audit log writer
│ │ ├── ws_hub.go pub/sub broker for the WS
│ │ └── walk.go / episode_parser.go helpers
│ ├── middleware/ Gin middleware (CORS / JWT / admin)
│ └── handler/ HTTP route definitions
│ └── handler/ HTTP route definitions (one file per concern)
├── web/ React 18 + Vite SPA
│ ├── src/api/ axios helpers
│ ├── src/components/ Layout, MediaCard, RequireAuth, …
│ ├── src/pages/ Home / Library / Search / Player / Admin
│ ├── src/api/ axios helpers (one per service)
│ ├── src/components/ Layout, MediaCard, GlobalEvents, RequireAuth
│ ├── src/hooks/ useWebSocket, …
│ ├── src/pages/ Home / Library / Search / Player / Downloads / …
│ ├── src/stores/ Zustand (auth)
│ └── src/types/ Domain types mirrored from Go
├── Dockerfile Multi-stage, multi-arch build
+1
View File
@@ -76,6 +76,7 @@ func main() {
if err := services.Auth.SeedAdmin(context.Background()); err != nil {
logger.Warn("seed admin failed", zap.Error(err))
}
services.Boot()
router := buildRouter(cfg, logger, services)
+2
View File
@@ -37,6 +37,8 @@ secrets:
# persisted to {data_dir}/.jwt_secret so existing sessions survive restarts.
jwt_secret: ""
tmdb_api_key: ""
tmdb_api_proxy: "" # e.g. https://api.tmdb-mirror.example
tmdb_image_proxy: "" # e.g. https://image.tmdb-mirror.example/t/p
bangumi_access_token: ""
thetvdb_api_key: ""
fanart_tv_api_key: ""
+9 -3
View File
@@ -3,11 +3,13 @@ module github.com/ShukeBta/MediaStationGo
go 1.25.0
require (
github.com/fsnotify/fsnotify v1.7.0
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/shirou/gopsutil/v3 v3.24.5
github.com/spf13/viper v1.18.2
go.uber.org/zap v1.27.0
golang.org/x/crypto v0.21.0
@@ -18,38 +20,42 @@ 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-ole/go-ole v1.2.6 // 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/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 // 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/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c // 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/shoenig/go-m1cpu v0.1.6 // 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/tklauser/go-sysconf v0.3.12 // indirect
github.com/tklauser/numcpus v0.6.1 // indirect
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
github.com/ugorji/go/codec v1.2.11 // indirect
github.com/yusufpapurcu/wmi v1.2.4 // 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
+23
View File
@@ -24,6 +24,8 @@ github.com/glebarez/go-sqlite v1.21.2 h1:3a6LFC4sKahUunAmynQKLZceZCOzUthkRkEAl9g
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-ole/go-ole v1.2.6 h1:/Fpf6oFPoeFik9ty7siob0G6Ke8QvQEuVcuChpwXzpY=
github.com/go-ole/go-ole v1.2.6/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0=
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=
@@ -38,6 +40,7 @@ github.com/golang-jwt/jwt/v5 v5.2.0 h1:d/ix8ftRUorsN+5eMIlF4T6J8CAt9rch3My2winC1
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.5.6/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=
@@ -64,6 +67,8 @@ 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/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 h1:6E+4a0GO5zZEnZ81pIr0yLvtUWk2if982qA3F3QD6H4=
github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0/go.mod h1:zJYVVT2jmtg6P3p1VtQj7WsuWi/y4VnjVBn7F8KPB3I=
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=
@@ -80,6 +85,8 @@ github.com/pelletier/go-toml/v2 v2.1.0/go.mod h1:tJU2Z3ZkXwnxa4DPO899bsyIoywizdU
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/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c h1:ncq/mPwQF4JjgDlrVEn3C11VoGHZN7m8qihwgMEtzYw=
github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c/go.mod h1:OmDBASR4679mdNQnz2pUhc2G8CO2JrUAVFDRBDP/hJE=
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=
@@ -89,6 +96,12 @@ github.com/sagikazarmark/locafero v0.4.0 h1:HApY1R9zGo4DBgr7dqsTH/JJxLTTsOt7u6ke
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/shirou/gopsutil/v3 v3.24.5 h1:i0t8kL+kQTvpAYToeuiVk3TgDeKOFioZO3Ztz/iZ9pI=
github.com/shirou/gopsutil/v3 v3.24.5/go.mod h1:bsoOS1aStSs9ErQ1WWfxllSeS1K5D+U30r2NfcubMVk=
github.com/shoenig/go-m1cpu v0.1.6 h1:nxdKQNcEB6vzgA2E2bvzKIYRuNj7XNJ4S/aRSwKzFtM=
github.com/shoenig/go-m1cpu v0.1.6/go.mod h1:1JJMcUBvfNwpq05QDQVAnx3gUHr9IYF7GNg9SUEw2VQ=
github.com/shoenig/test v0.6.4 h1:kVTaSd7WLz5WZ2IaoM0RSzRsUD+m8wRR+5qvntpn4LU=
github.com/shoenig/test v0.6.4/go.mod h1:byHiCGXqrVaflBLAMq/srcZIHynQPQgeyvkvXnjqq0k=
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=
@@ -113,10 +126,16 @@ github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsT
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/tklauser/go-sysconf v0.3.12 h1:0QaGUFOdQaIVdPgfITYzaTegZvdCjmYO52cSFAEVmqU=
github.com/tklauser/go-sysconf v0.3.12/go.mod h1:Ho14jnntGE1fpdOqQEEaiKRpvIavV0hSfmBq8nJbHYI=
github.com/tklauser/numcpus v0.6.1 h1:ng9scYS7az0Bk4OZLvrNXNSAO2Pxr1XXRAPyjhIx+Fk=
github.com/tklauser/numcpus v0.6.1/go.mod h1:1XfjsgE2zo8GVw7POkMbHENHzVg3GzmoZ9fESEdAacY=
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=
github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0=
github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0=
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=
@@ -132,8 +151,12 @@ golang.org/x/exp v0.0.0-20230905200255-921286631fa9 h1:GoHiUyI/Tp2nVkLI2mCxVkOjs
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-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20201204225414-ed752295db88/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
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.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.11.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=
+1
View File
@@ -41,6 +41,7 @@ func loginHandler(svc *service.Container) gin.HandlerFunc {
"token": token,
"user": u,
})
svc.Audit.Record(c.Request.Context(), u.ID, "auth.login", u.Username, c.ClientIP(), "")
}
}
+70
View File
@@ -0,0 +1,70 @@
// Package handler — download manager endpoints.
package handler
import (
"net/http"
"github.com/gin-gonic/gin"
"github.com/ShukeBta/MediaStationGo/internal/middleware"
"github.com/ShukeBta/MediaStationGo/internal/service"
)
type addDownloadReq struct {
URL string `json:"url" binding:"required"`
SavePath string `json:"save_path"`
}
func addDownloadHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
var req addDownloadReq
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
uid, _ := c.Get(middleware.CtxUserID)
t, err := svc.Downloads.AddDownload(c.Request.Context(), uid.(string), req.URL, req.SavePath)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
svc.Audit.Record(c.Request.Context(), uid.(string), "download.add", req.URL, c.ClientIP(), "")
c.JSON(http.StatusOK, t)
}
}
func listDownloadsHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
rows, live, err := svc.Downloads.List(c.Request.Context())
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{
"tasks": rows,
"torrents": live,
})
}
}
func deleteDownloadHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
hash := c.Param("hash")
withFiles := c.Query("delete_files") == "true"
if err := svc.Downloads.Delete(c.Request.Context(), hash, withFiles); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.Status(http.StatusNoContent)
}
}
func reloadDownloadConfigHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
if err := svc.Downloads.ReloadConfig(c.Request.Context()); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.Status(http.StatusNoContent)
}
}
+23
View File
@@ -32,8 +32,10 @@ func Register(r *gin.Engine, cfg *config.Config, log *zap.Logger, svc *service.C
authed.Use(middleware.AuthRequired(cfg.Secrets.JWTSecret))
{
authed.GET("/me", meHandler(svc))
authed.PATCH("/me", updateProfileHandler(svc))
authed.POST("/me/password", changePasswordHandler(svc))
// Libraries.
authed.GET("/libraries", listLibrariesHandler(svc))
authed.POST("/libraries", middleware.AdminRequired(), createLibraryHandler(svc))
authed.DELETE("/libraries/:id", middleware.AdminRequired(), deleteLibraryHandler(svc))
@@ -41,10 +43,15 @@ func Register(r *gin.Engine, cfg *config.Config, log *zap.Logger, svc *service.C
authed.POST("/libraries/:id/scrape", middleware.AdminRequired(), scrapeLibraryHandler(svc))
authed.GET("/libraries/:id/media", listMediaHandler(svc))
authed.GET("/libraries/:id/seasons", listSeasonsHandler(svc))
// Media.
authed.GET("/media/:id", getMediaHandler(svc))
authed.GET("/media", searchMediaHandler(svc))
authed.POST("/media/:id/scrape", middleware.AdminRequired(), scrapeOneHandler(svc))
authed.POST("/media/:id/probe", middleware.AdminRequired(), reprobeHandler(svc))
authed.GET("/media/:id/subtitles", listSubtitlesHandler(svc))
authed.GET("/subtitles/:id", serveSubtitleHandler(svc))
// Streaming.
authed.GET("/stream/:id", streamHandler(svc))
@@ -69,6 +76,21 @@ func Register(r *gin.Engine, cfg *config.Config, log *zap.Logger, svc *service.C
authed.DELETE("/playlists/:id/items/:media_id", removePlaylistItemHandler(svc))
authed.DELETE("/playlists/:id", deletePlaylistHandler(svc))
// Downloads.
authed.GET("/downloads", listDownloadsHandler(svc))
authed.POST("/downloads", addDownloadHandler(svc))
authed.DELETE("/downloads/:hash", middleware.AdminRequired(), deleteDownloadHandler(svc))
authed.POST("/downloads/reload", middleware.AdminRequired(), reloadDownloadConfigHandler(svc))
// Subscriptions.
authed.GET("/subscriptions", listSubscriptionsHandler(svc))
authed.POST("/subscriptions", createSubscriptionHandler(svc))
authed.DELETE("/subscriptions/:id", deleteSubscriptionHandler(svc))
authed.POST("/subscriptions/:id/run", runSubscriptionHandler(svc))
// Stats / dashboard.
authed.GET("/stats", statsHandler(svc))
authed.GET("/ws", wsHandler(svc))
}
@@ -77,6 +99,7 @@ func Register(r *gin.Engine, cfg *config.Config, log *zap.Logger, svc *service.C
admin.Use(middleware.AuthRequired(cfg.Secrets.JWTSecret), middleware.AdminRequired())
{
admin.GET("/users", listUsersHandler(svc))
admin.PATCH("/users/:id/role", adminUpdateRoleHandler(svc))
admin.DELETE("/users/:id", deleteUserHandler(svc))
admin.GET("/settings", listSettingsHandler(svc))
admin.PUT("/settings", updateSettingHandler(svc))
+7
View File
@@ -40,6 +40,10 @@ func createLibraryHandler(svc *service.Container) gin.HandlerFunc {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
uid, _ := c.Get("ctx_user_id")
svc.Audit.Record(c.Request.Context(), toString(uid), "library.create", l.ID, c.ClientIP(), l.Path)
// Refresh fsnotify watcher to pick up the new library root.
go func() { _ = svc.Watcher.Refresh(c.Request.Context()) }()
c.JSON(http.StatusOK, l)
}
}
@@ -51,6 +55,9 @@ func deleteLibraryHandler(svc *service.Container) gin.HandlerFunc {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
uid, _ := c.Get("ctx_user_id")
svc.Audit.Record(c.Request.Context(), toString(uid), "library.delete", id, c.ClientIP(), "")
go func() { _ = svc.Watcher.Refresh(c.Request.Context()) }()
c.Status(http.StatusNoContent)
}
}
+48
View File
@@ -0,0 +1,48 @@
// Package handler — user profile endpoints.
package handler
import (
"net/http"
"github.com/gin-gonic/gin"
"github.com/ShukeBta/MediaStationGo/internal/middleware"
"github.com/ShukeBta/MediaStationGo/internal/service"
)
func updateProfileHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
var patch service.ProfileUpdate
if err := c.ShouldBindJSON(&patch); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
uid, _ := c.Get(middleware.CtxUserID)
u, err := svc.Profile.UpdateProfile(c.Request.Context(), uid.(string), patch)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, u)
}
}
type adminUpdateRoleReq struct {
Role string `json:"role" binding:"required"`
}
func adminUpdateRoleHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
var req adminUpdateRoleReq
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
u, err := svc.Profile.AdminUpdateRole(c.Request.Context(), c.Param("id"), req.Role)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, u)
}
}
+47
View File
@@ -0,0 +1,47 @@
// Package handler — TV series endpoints.
//
// These return episode lists grouped by season number for a library that
// holds TV episodes. Series rows are distinct from Movies — the front
// end uses /api/libraries/:id/seasons to render a season selector.
package handler
import (
"net/http"
"sort"
"github.com/gin-gonic/gin"
"gorm.io/gorm"
"github.com/ShukeBta/MediaStationGo/internal/model"
"github.com/ShukeBta/MediaStationGo/internal/service"
)
// seasonGroup is the JSON returned to the React UI per season.
type seasonGroup struct {
Season int `json:"season"`
Episodes []model.Media `json:"episodes"`
}
func listSeasonsHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
libID := c.Param("id")
var rows []model.Media
err := svc.Repo.DB.Where(&model.Media{LibraryID: libID}).
Order("season_num asc, episode_num asc").
Find(&rows).Error
if err != nil && err != gorm.ErrRecordNotFound {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
buckets := make(map[int][]model.Media)
for _, r := range rows {
buckets[r.SeasonNum] = append(buckets[r.SeasonNum], r)
}
out := make([]seasonGroup, 0, len(buckets))
for s, items := range buckets {
out = append(out, seasonGroup{Season: s, Episodes: items})
}
sort.Slice(out, func(i, j int) bool { return out[i].Season < out[j].Season })
c.JSON(http.StatusOK, gin.H{"seasons": out})
}
}
+21
View File
@@ -0,0 +1,21 @@
// Package handler — stats / dashboard endpoints.
package handler
import (
"net/http"
"github.com/gin-gonic/gin"
"github.com/ShukeBta/MediaStationGo/internal/service"
)
func statsHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
snap, err := svc.Stats.Compute(c.Request.Context(), svc.Cfg.App.DataDir)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, snap)
}
}
+78
View File
@@ -0,0 +1,78 @@
// Package handler — RSS subscription endpoints.
package handler
import (
"net/http"
"github.com/gin-gonic/gin"
"github.com/ShukeBta/MediaStationGo/internal/middleware"
"github.com/ShukeBta/MediaStationGo/internal/model"
"github.com/ShukeBta/MediaStationGo/internal/service"
)
type subscriptionReq struct {
Name string `json:"name" binding:"required"`
FeedURL string `json:"feed_url" binding:"required"`
Filter string `json:"filter"`
Enabled *bool `json:"enabled"`
}
func createSubscriptionHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
var req subscriptionReq
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
uid, _ := c.Get(middleware.CtxUserID)
enabled := true
if req.Enabled != nil {
enabled = *req.Enabled
}
s := &model.Subscription{
UserID: uid.(string),
Name: req.Name,
FeedURL: req.FeedURL,
Filter: req.Filter,
Enabled: enabled,
}
if err := svc.Subscription.Create(c.Request.Context(), s); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, s)
}
}
func listSubscriptionsHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
items, err := svc.Subscription.List(c.Request.Context())
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"items": items})
}
}
func deleteSubscriptionHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
if err := svc.Subscription.Delete(c.Request.Context(), c.Param("id")); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.Status(http.StatusNoContent)
}
}
func runSubscriptionHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
n, err := svc.Subscription.RunNow(c.Request.Context(), c.Param("id"))
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"queued": n})
}
}
+37
View File
@@ -0,0 +1,37 @@
// Package handler — subtitle endpoints.
package handler
import (
"net/http"
"github.com/gin-gonic/gin"
"github.com/ShukeBta/MediaStationGo/internal/service"
)
func listSubtitlesHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
tracks, err := svc.Subtitle.Discover(c.Request.Context(), c.Param("id"))
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"tracks": tracks})
}
}
func serveSubtitleHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
path := c.Query("path")
if path == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "missing path"})
return
}
c.Header("Content-Type", "text/vtt; charset=utf-8")
c.Header("Cache-Control", "public, max-age=3600")
if err := svc.Subtitle.Serve(c.Request.Context(), c.Param("id"), path, c.Writer); err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": err.Error()})
return
}
}
}
+14
View File
@@ -0,0 +1,14 @@
// Package handler — small utilities shared across handlers.
package handler
// toString converts a gin context value to a string, returning an empty
// string when the value is missing or of the wrong type.
func toString(v any) string {
if v == nil {
return ""
}
if s, ok := v.(string); ok {
return s
}
return ""
}
+41
View File
@@ -0,0 +1,41 @@
// Package service — audit log helper.
//
// AuditService writes structured AccessLog rows for sensitive actions
// (login, library CRUD, scrape / scan triggers, download enqueue, etc).
// It deliberately swallows write errors so audit failures never bubble
// up to the caller.
package service
import (
"context"
"go.uber.org/zap"
"github.com/ShukeBta/MediaStationGo/internal/model"
"github.com/ShukeBta/MediaStationGo/internal/repository"
)
// AuditService is the only sanctioned writer for the access_logs table.
type AuditService struct {
log *zap.Logger
repo *repository.Container
}
// NewAuditService is the constructor.
func NewAuditService(log *zap.Logger, repo *repository.Container) *AuditService {
return &AuditService{log: log, repo: repo}
}
// Record persists one audit row.
func (a *AuditService) Record(ctx context.Context, userID, action, target, ip, detail string) {
row := &model.AccessLog{
UserID: userID,
Action: action,
Target: target,
IP: ip,
Detail: detail,
}
if err := a.repo.Log.Create(ctx, row); err != nil {
a.log.Debug("audit write failed", zap.Error(err))
}
}
+126
View File
@@ -0,0 +1,126 @@
// Package service — Bangumi metadata provider.
//
// Bangumi (https://bgm.tv) is the Chinese anime / manga / game database
// most users in mainland China prefer. Its public REST API is documented
// at https://bangumi.github.io/api/.
//
// We implement the minimal subset needed to enrich anime libraries:
//
// GET /search/subject/{keywords}?type=2&responseGroup=small
// GET /v0/subjects/{id} (cover)
//
// The provider gracefully no-ops when bangumi_access_token is empty.
package service
import (
"context"
"encoding/json"
"fmt"
"net/http"
"net/url"
"strings"
"time"
"go.uber.org/zap"
"github.com/ShukeBta/MediaStationGo/internal/config"
)
// BangumiProvider talks to https://api.bgm.tv.
type BangumiProvider struct {
cfg *config.Config
log *zap.Logger
client *http.Client
base string
}
// NewBangumiProvider is the constructor.
func NewBangumiProvider(cfg *config.Config, log *zap.Logger) *BangumiProvider {
return &BangumiProvider{
cfg: cfg,
log: log,
base: "https://api.bgm.tv",
client: &http.Client{Timeout: 15 * time.Second},
}
}
// Enabled reports whether a token is configured. Bangumi works without
// auth for read-only endpoints, but configuring a token raises the rate
// limit so we treat presence as a soft "enabled" flag.
func (b *BangumiProvider) Enabled() bool {
// Bangumi search works without auth — keep the provider enabled
// unconditionally, but pass the token when we have one.
return true
}
// Search runs a Bangumi keyword search and returns the top match. Type 2
// = anime; pass 1 (book) / 6 (real) when extending later.
func (b *BangumiProvider) Search(ctx context.Context, query string) (*Match, error) {
if query == "" {
return nil, nil
}
u := fmt.Sprintf("%s/search/subject/%s?type=2&responseGroup=small",
b.base, url.PathEscape(query))
type subject struct {
ID int `json:"id"`
Name string `json:"name"`
NameCN string `json:"name_cn"`
Summary string `json:"summary"`
Air string `json:"air_date"`
Rating struct {
Score float32 `json:"score"`
} `json:"rating"`
Images struct {
Large string `json:"large"`
} `json:"images"`
}
type page struct {
Results int `json:"results"`
List []subject `json:"list"`
}
var p page
if err := b.getJSON(ctx, u, &p); err != nil {
return nil, err
}
if p.Results == 0 || len(p.List) == 0 {
return nil, nil
}
r := p.List[0]
title := r.NameCN
if title == "" {
title = r.Name
}
m := &Match{
BangumiID: r.ID,
Title: title,
Overview: r.Summary,
PosterURL: r.Images.Large,
Rating: r.Rating.Score,
}
if len(r.Air) >= 4 {
fmt.Sscanf(r.Air[:4], "%d", &m.Year)
}
return m, nil
}
func (b *BangumiProvider) getJSON(ctx context.Context, u string, out any) error {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, u, nil)
if err != nil {
return err
}
req.Header.Set("User-Agent", "MediaStationGo/0.1 (https://github.com/ShukeBta/MediaStationGo)")
if t := strings.TrimSpace(b.cfg.Secrets.BangumiToken); t != "" {
req.Header.Set("Authorization", "Bearer "+t)
}
resp, err := b.client.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode >= 400 {
return fmt.Errorf("bangumi %s: %d", u, resp.StatusCode)
}
return json.NewDecoder(resp.Body).Decode(out)
}
+148
View File
@@ -0,0 +1,148 @@
// Package service — download manager.
//
// DownloadService persists user-initiated downloads, dispatches them to
// the configured client (currently qBittorrent) and pushes live progress
// to the WS hub so the React UI can render a live table.
//
// Settings consumed (system Setting table):
//
// qbittorrent.url e.g. http://127.0.0.1:8080
// qbittorrent.username qBittorrent WebUI user
// qbittorrent.password qBittorrent WebUI password
// qbittorrent.savepath optional default save dir
//
// Settings can be updated at runtime via the admin UI; ReloadConfig()
// re-reads them and re-authenticates.
package service
import (
"context"
"errors"
"sync"
"time"
"go.uber.org/zap"
"github.com/ShukeBta/MediaStationGo/internal/model"
"github.com/ShukeBta/MediaStationGo/internal/repository"
)
// DownloadService is the single download orchestrator.
type DownloadService struct {
log *zap.Logger
repo *repository.Container
hub *Hub
qb *QBitClient
mu sync.Mutex
stopCh chan struct{}
pollOnce sync.Once
}
// NewDownloadService is the constructor.
func NewDownloadService(log *zap.Logger, repo *repository.Container, hub *Hub) *DownloadService {
return &DownloadService{
log: log,
repo: repo,
hub: hub,
qb: NewQBitClient(log, QBitConfig{}),
stopCh: make(chan struct{}),
}
}
// Start kicks off the background poller (idempotent).
func (d *DownloadService) Start(ctx context.Context) {
d.pollOnce.Do(func() {
_ = d.ReloadConfig(ctx)
go d.poll(ctx)
})
}
// Stop terminates the poller.
func (d *DownloadService) Stop() {
close(d.stopCh)
}
// ReloadConfig rebuilds the qBittorrent client from the system settings.
func (d *DownloadService) ReloadConfig(ctx context.Context) error {
cfg := QBitConfig{}
for _, key := range []struct{ from, into *string }{} {
_ = key
}
get := func(k string) string {
v, _ := d.repo.Setting.Get(ctx, k)
return v
}
cfg.BaseURL = get("qbittorrent.url")
cfg.Username = get("qbittorrent.username")
cfg.Password = get("qbittorrent.password")
d.qb.Configure(cfg)
return nil
}
// AddDownload accepts a magnet URL / HTTP URL and persists a tracking row.
func (d *DownloadService) AddDownload(ctx context.Context, userID, urlStr, savePath string) (*model.DownloadTask, error) {
if urlStr == "" {
return nil, errors.New("empty url")
}
if savePath == "" {
savePath, _ = d.repo.Setting.Get(ctx, "qbittorrent.savepath")
}
if err := d.qb.AddTorrent(ctx, urlStr, savePath); err != nil {
return nil, err
}
t := &model.DownloadTask{
UserID: userID,
Source: "qbittorrent",
URL: urlStr,
SavePath: savePath,
Status: "queued",
}
if err := d.repo.Download.Create(ctx, t); err != nil {
return nil, err
}
return t, nil
}
// List returns every persisted download task augmented with live data
// from qBittorrent when available.
func (d *DownloadService) List(ctx context.Context) ([]model.DownloadTask, []QBitTorrent, error) {
rows, err := d.repo.Download.List(ctx)
if err != nil {
return nil, nil, err
}
live, err := d.qb.List(ctx, "")
if err != nil {
// Network failure shouldn't break the page — return rows with no
// live data and let the UI render the persisted snapshot.
d.log.Debug("qbittorrent list failed", zap.Error(err))
return rows, nil, nil
}
return rows, live, nil
}
// Delete removes a torrent (and optionally its files) from qBittorrent.
func (d *DownloadService) Delete(ctx context.Context, hash string, withFiles bool) error {
return d.qb.Delete(ctx, hash, withFiles)
}
// poll fans out qBittorrent /torrents/info every 5 s as WS events. The
// payload is opaque to the client; the React store merges by hash.
func (d *DownloadService) poll(ctx context.Context) {
t := time.NewTicker(5 * time.Second)
defer t.Stop()
for {
select {
case <-ctx.Done():
return
case <-d.stopCh:
return
case <-t.C:
}
live, err := d.qb.List(ctx, "")
if err != nil {
continue
}
d.hub.Publish("download", map[string]any{"torrents": live})
}
}
+58
View File
@@ -0,0 +1,58 @@
// Package service — episode parser for TV series.
//
// Detects season + episode numbers from filenames. Recognised patterns:
//
// S01E02 / s1e2
// 1x02 / 01x02
// EP02 / E02
// 第2集 / 第02集
//
// When neither a season nor an episode marker is present, returns (0, 0).
package service
import (
"path/filepath"
"regexp"
"strconv"
"strings"
)
var (
patSEnE = regexp.MustCompile(`(?i)s(\d{1,2})e(\d{1,3})`)
patNxE = regexp.MustCompile(`(\d{1,2})x(\d{1,3})`)
patEP = regexp.MustCompile(`(?i)(?:^|[^a-z])(?:e|ep)\.?\s*(\d{1,3})(?:[^0-9]|$)`)
patCN = regexp.MustCompile(`第\s*(\d{1,3})\s*[集话話]`)
)
// ParseEpisode tries to extract (season, episode) from an arbitrary filename.
// Returns (0, 0) when nothing recognisable is found.
func ParseEpisode(path string) (season, episode int) {
name := strings.TrimSuffix(filepath.Base(path), filepath.Ext(path))
if m := patSEnE.FindStringSubmatch(name); len(m) == 3 {
season = mustAtoi(m[1])
episode = mustAtoi(m[2])
return
}
if m := patNxE.FindStringSubmatch(name); len(m) == 3 {
season = mustAtoi(m[1])
episode = mustAtoi(m[2])
return
}
if m := patEP.FindStringSubmatch(name); len(m) >= 2 {
season = 1
episode = mustAtoi(m[1])
return
}
if m := patCN.FindStringSubmatch(name); len(m) >= 2 {
season = 1
episode = mustAtoi(m[1])
return
}
return 0, 0
}
func mustAtoi(s string) int {
v, _ := strconv.Atoi(s)
return v
}
+29
View File
@@ -0,0 +1,29 @@
package service
import "testing"
func TestParseEpisode(t *testing.T) {
cases := []struct {
in string
wantS, wantE int
}{
{"Breaking.Bad.S01E02.1080p.mkv", 1, 2},
{"breaking.bad.s5e14.mkv", 5, 14},
{"Friends 1x02.mp4", 1, 2},
{"Friends 10x24 - The One Where.mkv", 10, 24},
{"Some Anime - EP05 [1080p].mkv", 1, 5},
{"Some Anime - E12.mkv", 1, 12},
{"日剧 第03集.mkv", 1, 3},
{"日剧 第12话.mkv", 1, 12},
{"Movie.2020.1080p.mkv", 0, 0},
}
for _, tc := range cases {
t.Run(tc.in, func(t *testing.T) {
s, e := ParseEpisode(tc.in)
if s != tc.wantS || e != tc.wantE {
t.Errorf("ParseEpisode(%q) = (%d, %d), want (%d, %d)",
tc.in, s, e, tc.wantS, tc.wantE)
}
})
}
}
+67
View File
@@ -0,0 +1,67 @@
// Package service — user profile management.
package service
import (
"context"
"errors"
"strings"
"go.uber.org/zap"
"github.com/ShukeBta/MediaStationGo/internal/model"
"github.com/ShukeBta/MediaStationGo/internal/repository"
)
// ProfileService handles non-credential user mutations.
type ProfileService struct {
log *zap.Logger
repo *repository.Container
}
// NewProfileService is the constructor.
func NewProfileService(log *zap.Logger, repo *repository.Container) *ProfileService {
return &ProfileService{log: log, repo: repo}
}
// ProfileUpdate is the patch object accepted by UpdateProfile. Empty
// fields are ignored so the same payload can be reused across screens.
type ProfileUpdate struct {
Email *string `json:"email,omitempty"`
AvatarURL *string `json:"avatar_url,omitempty"`
}
// UpdateProfile applies a non-credential patch to the user.
func (p *ProfileService) UpdateProfile(ctx context.Context, userID string, patch ProfileUpdate) (*model.User, error) {
if userID == "" {
return nil, errors.New("missing user id")
}
updates := map[string]any{}
if patch.Email != nil {
v := strings.TrimSpace(*patch.Email)
updates["email"] = v
}
if patch.AvatarURL != nil {
updates["avatar_url"] = strings.TrimSpace(*patch.AvatarURL)
}
if len(updates) > 0 {
if err := p.repo.DB.Model(&model.User{}).Where("id = ?", userID).
Updates(updates).Error; err != nil {
return nil, err
}
}
return p.repo.User.FindByID(ctx, userID)
}
// AdminUpdateRole lets administrators promote / demote another user. The
// caller is expected to gate the route with AdminRequired.
func (p *ProfileService) AdminUpdateRole(ctx context.Context, userID, role string) (*model.User, error) {
role = strings.ToLower(strings.TrimSpace(role))
if role != "admin" && role != "user" {
return nil, errors.New("role must be admin or user")
}
if err := p.repo.DB.Model(&model.User{}).Where("id = ?", userID).
Update("role", role).Error; err != nil {
return nil, err
}
return p.repo.User.FindByID(ctx, userID)
}
+233
View File
@@ -0,0 +1,233 @@
// Package service — qBittorrent Web UI client.
//
// QBitClient is a thin wrapper around the qBittorrent /api/v2 REST API
// (https://github.com/qbittorrent/qBittorrent/wiki/WebUI-API).
//
// We only need three operations for the download flow:
//
// POST /auth/login
// POST /torrents/add (multipart, accepts magnet URL or .torrent bytes)
// GET /torrents/info (filtered by hash)
//
// The client stores the SID cookie returned by /auth/login and reuses it
// across calls. Re-auth happens transparently on 403.
package service
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"mime/multipart"
"net/http"
"net/http/cookiejar"
"net/url"
"strings"
"sync"
"time"
"go.uber.org/zap"
)
// QBitConfig holds the connection settings (typically loaded from the
// system Setting table or an env var).
type QBitConfig struct {
BaseURL string
Username string
Password string
}
// QBitTorrent is the subset of /torrents/info we surface to the API.
type QBitTorrent struct {
Hash string `json:"hash"`
Name string `json:"name"`
State string `json:"state"`
Progress float32 `json:"progress"`
DLSpeed int64 `json:"dlspeed"`
UpSpeed int64 `json:"upspeed"`
NumSeeds int `json:"num_seeds"`
NumLeech int `json:"num_leechs"`
Size int64 `json:"size"`
SavePath string `json:"save_path"`
}
// QBitClient is a thread-safe qBittorrent v2 API client.
type QBitClient struct {
log *zap.Logger
mu sync.Mutex
cfg QBitConfig
client *http.Client
}
// NewQBitClient builds a fresh client, applying default URL if blank.
func NewQBitClient(log *zap.Logger, cfg QBitConfig) *QBitClient {
if cfg.BaseURL == "" {
cfg.BaseURL = "http://localhost:8080"
}
jar, _ := cookiejar.New(nil)
return &QBitClient{
log: log,
cfg: cfg,
client: &http.Client{Jar: jar, Timeout: 20 * time.Second},
}
}
// Configure rotates the client to a new endpoint and re-auths next call.
func (q *QBitClient) Configure(cfg QBitConfig) {
q.mu.Lock()
defer q.mu.Unlock()
q.cfg = cfg
jar, _ := cookiejar.New(nil)
q.client.Jar = jar
}
// Login performs POST /api/v2/auth/login.
func (q *QBitClient) Login(ctx context.Context) error {
if q.cfg.BaseURL == "" {
return errors.New("qbittorrent base url not configured")
}
form := url.Values{}
form.Set("username", q.cfg.Username)
form.Set("password", q.cfg.Password)
req, err := http.NewRequestWithContext(ctx, http.MethodPost,
strings.TrimRight(q.cfg.BaseURL, "/")+"/api/v2/auth/login",
strings.NewReader(form.Encode()),
)
if err != nil {
return err
}
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req.Header.Set("Referer", q.cfg.BaseURL)
resp, err := q.client.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
body, _ := io.ReadAll(resp.Body)
if resp.StatusCode >= 400 || strings.TrimSpace(string(body)) != "Ok." {
return fmt.Errorf("qbittorrent login failed: %s", strings.TrimSpace(string(body)))
}
return nil
}
// AddTorrent submits a magnet URL or HTTP(S) URL to qBittorrent.
func (q *QBitClient) AddTorrent(ctx context.Context, magnetOrURL, savePath string) error {
q.mu.Lock()
defer q.mu.Unlock()
if err := q.ensureAuth(ctx); err != nil {
return err
}
body := &bytes.Buffer{}
w := multipart.NewWriter(body)
_ = w.WriteField("urls", magnetOrURL)
if savePath != "" {
_ = w.WriteField("savepath", savePath)
}
_ = w.Close()
req, err := http.NewRequestWithContext(ctx, http.MethodPost,
strings.TrimRight(q.cfg.BaseURL, "/")+"/api/v2/torrents/add", body,
)
if err != nil {
return err
}
req.Header.Set("Content-Type", w.FormDataContentType())
req.Header.Set("Referer", q.cfg.BaseURL)
resp, err := q.client.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode >= 400 {
raw, _ := io.ReadAll(resp.Body)
return fmt.Errorf("qbittorrent add: %d: %s", resp.StatusCode, strings.TrimSpace(string(raw)))
}
return nil
}
// List returns every torrent (optionally filtered by status: all / downloading / completed).
func (q *QBitClient) List(ctx context.Context, filter string) ([]QBitTorrent, error) {
q.mu.Lock()
defer q.mu.Unlock()
if err := q.ensureAuth(ctx); err != nil {
return nil, err
}
u := strings.TrimRight(q.cfg.BaseURL, "/") + "/api/v2/torrents/info"
if filter != "" {
u += "?filter=" + url.QueryEscape(filter)
}
req, err := http.NewRequestWithContext(ctx, http.MethodGet, u, nil)
if err != nil {
return nil, err
}
req.Header.Set("Referer", q.cfg.BaseURL)
resp, err := q.client.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode >= 400 {
return nil, fmt.Errorf("qbittorrent list: %d", resp.StatusCode)
}
var out []QBitTorrent
if err := json.NewDecoder(resp.Body).Decode(&out); err != nil {
return nil, err
}
return out, nil
}
// Delete removes a torrent (optionally with its files).
func (q *QBitClient) Delete(ctx context.Context, hash string, deleteFiles bool) error {
q.mu.Lock()
defer q.mu.Unlock()
if err := q.ensureAuth(ctx); err != nil {
return err
}
form := url.Values{}
form.Set("hashes", hash)
if deleteFiles {
form.Set("deleteFiles", "true")
} else {
form.Set("deleteFiles", "false")
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost,
strings.TrimRight(q.cfg.BaseURL, "/")+"/api/v2/torrents/delete",
strings.NewReader(form.Encode()),
)
if err != nil {
return err
}
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req.Header.Set("Referer", q.cfg.BaseURL)
resp, err := q.client.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode >= 400 {
return fmt.Errorf("qbittorrent delete: %d", resp.StatusCode)
}
return nil
}
// ensureAuth makes sure we have a valid SID cookie. Cheap on the happy
// path; logs in transparently otherwise.
func (q *QBitClient) ensureAuth(ctx context.Context) error {
u, err := url.Parse(q.cfg.BaseURL)
if err != nil {
return err
}
if cookies := q.client.Jar.Cookies(u); len(cookies) > 0 {
for _, c := range cookies {
if strings.EqualFold(c.Name, "SID") && c.Value != "" {
return nil
}
}
}
return q.Login(ctx)
}
+16 -15
View File
@@ -2,11 +2,13 @@
//
// ScannerService walks the configured library roots looking for video
// files, then upserts a model.Media row per file. Each upsert also runs
// ffprobe (when available) and queues a TMDb lookup for newly added rows.
// ffprobe (when available) and queues a metadata lookup for newly added
// rows.
//
// The scan is synchronous from the HTTP layer's point of view, but it
// publishes WebSocket progress events on the "scan" topic so the React
// UI can render a live counter / spinner.
// For TV / anime libraries we extract season + episode numbers from the
// filename via ParseEpisode and store them on the Media row. A future
// pass groups episodes into Series rows; the current scaffold lets the
// frontend group by `series_id`.
package service
import (
@@ -73,15 +75,6 @@ type ScanResult struct {
}
// ScanLibrary walks the library root and persists discovered media files.
//
// Workflow per file:
// 1. fast filename-based title cleanup.
// 2. ffprobe → duration / resolution / codecs (best effort).
// 3. upsert into the media table.
// 4. publish progress over the WS hub.
//
// After the walk we kick off the TMDb scraper for every still-pending
// row in the same library. The scraper has its own throttle.
func (s *ScannerService) ScanLibrary(ctx context.Context, libraryID string) (*ScanResult, error) {
lib, err := s.repo.Library.FindByID(ctx, libraryID)
if err != nil || lib == nil {
@@ -113,6 +106,13 @@ func (s *ScannerService) ScanLibrary(ctx context.Context, libraryID string) (*Sc
Container: strings.TrimPrefix(ext, "."),
}
// Detect season/episode for TV / anime libraries.
if lib.Type == "tv" || lib.Type == "anime" {
s, e := ParseEpisode(path)
m.SeasonNum = s
m.EpisodeNum = e
}
// Best-effort ffprobe; failure does not abort the file.
if s.probe != nil {
if probe, err := s.probe.Probe(ctx, path); err == nil && probe != nil {
@@ -157,8 +157,9 @@ func (s *ScannerService) ScanLibrary(ctx context.Context, libraryID string) (*Sc
"probed": res.Probed,
})
// Fire-and-forget metadata enrichment when a TMDb key is configured.
if s.scraper != nil && s.scraper.tmdb != nil && s.scraper.tmdb.Enabled() {
// Fire-and-forget metadata enrichment when at least one provider is
// configured.
if s.scraper != nil && s.scraper.AnyEnabled() {
go func(libID string) {
if _, err := s.scraper.EnrichLibrary(context.Background(), libID); err != nil {
s.log.Warn("scraper enrich failed", zap.Error(err))
+95 -35
View File
@@ -1,12 +1,15 @@
// Package service — scraper orchestrator.
//
// ScraperService takes a Media row and tries to enrich it with metadata
// from one or more providers (currently TMDb only). It is invoked at the
// end of every scan cycle for media items whose `scrape_status` is still
// "pending"; it can also be re-triggered manually from the admin UI.
// from one or more providers. Selection is driven by the library type:
//
// The orchestrator is deliberately stateless: it loops media → provider →
// repository, publishing scrape progress events to the WS hub.
// library.type == "anime" -> Bangumi (fallback: TMDb)
// library.type == "tv" -> TMDb (movies) — TV episodes inherit
// series metadata; episode-level scraping
// is left as a future step
// default -> TMDb
//
// The orchestrator publishes scrape progress events on the WS hub.
package service
import (
@@ -26,23 +29,33 @@ import (
// ScraperService coordinates metadata enrichment across providers.
type ScraperService struct {
cfg *config.Config
log *zap.Logger
repo *repository.Container
tmdb *TMDbProvider
hub *Hub
cfg *config.Config
log *zap.Logger
repo *repository.Container
tmdb *TMDbProvider
bangumi *BangumiProvider
hub *Hub
}
// NewScraperService is the constructor.
func NewScraperService(cfg *config.Config, log *zap.Logger, repo *repository.Container, tmdb *TMDbProvider, hub *Hub) *ScraperService {
return &ScraperService{cfg: cfg, log: log, repo: repo, tmdb: tmdb, hub: hub}
func NewScraperService(
cfg *config.Config,
log *zap.Logger,
repo *repository.Container,
tmdb *TMDbProvider,
bangumi *BangumiProvider,
hub *Hub,
) *ScraperService {
return &ScraperService{
cfg: cfg, log: log, repo: repo,
tmdb: tmdb, bangumi: bangumi, hub: hub,
}
}
// yearPattern extracts a 4-digit year from a filename (1900-2099).
// yearPattern extracts a 4-digit year (1900-2099).
var yearPattern = regexp.MustCompile(`(?:^|[^\d])(19\d{2}|20\d{2})(?:[^\d]|$)`)
// noiseTokens are aggressively stripped from filenames before search.
// Keep in sync with nowen-video's filename_parser.go intent.
// noiseTokens are stripped before search.
var noiseTokens = []string{
"1080p", "2160p", "4k", "720p", "480p",
"hdrip", "bluray", "blu-ray", "webrip", "web-dl", "web",
@@ -52,8 +65,7 @@ var noiseTokens = []string{
"hkfree", "yify", "rarbg", "ettv", "fgt",
}
// bracketedTag matches "[anything]" or "(anything)" segments, which are
// almost always release-group / encoder tags in scene filenames.
// bracketedTag matches "[anything]" or "(anything)" segments.
var bracketedTag = regexp.MustCompile(`[\[\(][^\]\)]*[\]\)]`)
// CleanQuery converts a filename like "Inception.2010.1080p.BluRay.x264.mkv"
@@ -70,13 +82,18 @@ func CleanQuery(raw string) (title string, year int) {
}
}
// 2. Drop everything inside square / round brackets — those are tags.
// 2. Drop everything inside brackets.
lower = bracketedTag.ReplaceAllString(lower, " ")
// 3. Drop episode markers (S01E02 / 1x02 / EP05 / 第03集).
lower = patSEnE.ReplaceAllString(lower, " ")
lower = patNxE.ReplaceAllString(lower, " ")
lower = patEP.ReplaceAllString(lower, " ")
lower = patCN.ReplaceAllString(lower, " ")
for _, t := range noiseTokens {
lower = strings.ReplaceAll(lower, t, " ")
}
// collapse separators / spaces
for _, sep := range []string{".", "_", "-", "[", "]", "(", ")"} {
lower = strings.ReplaceAll(lower, sep, " ")
}
@@ -85,11 +102,15 @@ func CleanQuery(raw string) (title string, year int) {
return strings.TrimSpace(title), year
}
// EnrichOne runs the provider chain for a single media row.
// EnrichOne runs the provider chain for a single media row. The library's
// type decides which provider goes first; a fallback runs when the primary
// returns nothing.
func (s *ScraperService) EnrichOne(ctx context.Context, m *model.Media) error {
if s.tmdb == nil || !s.tmdb.Enabled() {
return nil
lib, err := s.repo.Library.FindByID(ctx, m.LibraryID)
if err != nil {
return err
}
query := m.Title
if query == "" {
query, _ = CleanQuery(m.Path)
@@ -100,10 +121,15 @@ func (s *ScraperService) EnrichOne(ctx context.Context, m *model.Media) error {
if year == 0 {
_, year = CleanQuery(filepath.Base(m.Path))
}
match, err := s.tmdb.SearchMovie(ctx, query, year)
if err != nil || match == nil {
return err
match := s.lookup(ctx, lib, query, year)
if match == nil {
// Mark explicitly so we don't retry forever.
_ = s.repo.DB.Model(&model.Media{}).Where("id = ?", m.ID).
Update("scrape_status", "no_match").Error
return nil
}
updates := map[string]any{
"title": match.Title,
"overview": match.Overview,
@@ -111,28 +137,51 @@ func (s *ScraperService) EnrichOne(ctx context.Context, m *model.Media) error {
"backdrop_url": match.BackdropURL,
"rating": match.Rating,
"year": match.Year,
"tmdb_id": match.TMDbID,
"scrape_status": "matched",
}
if match.TMDbID > 0 {
updates["tmdb_id"] = match.TMDbID
}
if match.BangumiID > 0 {
updates["bangumi_id"] = match.BangumiID
}
if err := s.repo.DB.Model(&model.Media{}).Where("id = ?", m.ID).
Updates(updates).Error; err != nil {
return err
}
s.hub.Publish("scrape", map[string]any{
"media_id": m.ID,
"title": match.Title,
"tmdb_id": match.TMDbID,
"media_id": m.ID,
"title": match.Title,
"tmdb_id": match.TMDbID,
"bangumi_id": match.BangumiID,
})
return nil
}
// EnrichLibrary runs the provider chain for every "pending" media in a
// library. It throttles to 4 RPS to stay below TMDb's rate limit and
// publishes a summary event when done.
func (s *ScraperService) EnrichLibrary(ctx context.Context, libraryID string) (int, error) {
if s.tmdb == nil || !s.tmdb.Enabled() {
return 0, nil
// lookup runs the provider chain. When the library is missing we fall
// back to TMDb only.
func (s *ScraperService) lookup(ctx context.Context, lib *model.Library, query string, year int) *Match {
kind := ""
if lib != nil {
kind = lib.Type
}
if kind == "anime" && s.bangumi != nil {
if m, err := s.bangumi.Search(ctx, query); err == nil && m != nil {
return m
}
s.log.Debug("bangumi miss, falling back to tmdb", zap.String("query", query))
}
if s.tmdb != nil && s.tmdb.Enabled() {
if m, err := s.tmdb.SearchMovie(ctx, query, year); err == nil && m != nil {
return m
}
}
return nil
}
// EnrichLibrary runs the provider chain for every "pending" media in a
// library. It throttles to 4 RPS and publishes a summary event when done.
func (s *ScraperService) EnrichLibrary(ctx context.Context, libraryID string) (int, error) {
var rows []model.Media
q := s.repo.DB.Where("scrape_status = ?", "pending")
if libraryID != "" {
@@ -162,3 +211,14 @@ func (s *ScraperService) EnrichLibrary(ctx context.Context, libraryID string) (i
})
return matched, nil
}
// AnyEnabled reports whether at least one provider can run.
func (s *ScraperService) AnyEnabled() bool {
if s.tmdb != nil && s.tmdb.Enabled() {
return true
}
if s.bangumi != nil && s.bangumi.Enabled() {
return true
}
return false
}
+82 -30
View File
@@ -5,6 +5,8 @@
package service
import (
"context"
"go.uber.org/zap"
"github.com/ShukeBta/MediaStationGo/internal/config"
@@ -14,20 +16,31 @@ import (
// 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
Transcoder *TranscoderService
FFprobe *FFprobeService
TMDb *TMDbProvider
Scraper *ScraperService
Playback *PlaybackService
ImageProxy *ImageProxy
Cfg *config.Config
Log *zap.Logger
Repo *repository.Container
WSHub *Hub
Auth *AuthService
Media *MediaService
Scan *ScannerService
Stream *StreamService
Transcoder *TranscoderService
FFprobe *FFprobeService
TMDb *TMDbProvider
Bangumi *BangumiProvider
Scraper *ScraperService
Playback *PlaybackService
ImageProxy *ImageProxy
Watcher *WatcherService
Downloads *DownloadService
Subscription *SubscriptionService
Subtitle *SubtitleService
Stats *StatsService
Profile *ProfileService
Audit *AuditService
stopCtx context.Context
stopCancel context.CancelFunc
}
// New builds the service container.
@@ -37,30 +50,69 @@ func New(cfg *config.Config, log *zap.Logger, repos *repository.Container) *Cont
probe := NewFFprobeService(cfg, log)
tmdb := NewTMDbProvider(cfg, log)
scraper := NewScraperService(cfg, log, repos, tmdb, hub)
bangumi := NewBangumiProvider(cfg, log)
scraper := NewScraperService(cfg, log, repos, tmdb, bangumi, hub)
transcoder := NewTranscoderService(cfg, log, repos, hub)
scanner := NewScannerService(cfg, log, repos, hub, probe, scraper)
downloads := NewDownloadService(log, repos, hub)
subscription := NewSubscriptionService(log, repos, downloads, hub)
watcher := NewWatcherService(log, repos, scanner)
ctx, cancel := context.WithCancel(context.Background())
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, probe, scraper),
Stream: NewStreamService(cfg, log, repos, transcoder),
Transcoder: transcoder,
FFprobe: probe,
TMDb: tmdb,
Scraper: scraper,
Playback: NewPlaybackService(log, repos),
ImageProxy: NewImageProxy(cfg, log),
Cfg: cfg,
Log: log,
Repo: repos,
WSHub: hub,
Auth: NewAuthService(cfg, log, repos),
Media: NewMediaService(cfg, log, repos),
Scan: scanner,
Stream: NewStreamService(cfg, log, repos, transcoder),
Transcoder: transcoder,
FFprobe: probe,
TMDb: tmdb,
Bangumi: bangumi,
Scraper: scraper,
Playback: NewPlaybackService(log, repos),
ImageProxy: NewImageProxy(cfg, log),
Watcher: watcher,
Downloads: downloads,
Subscription: subscription,
Subtitle: NewSubtitleService(log, repos),
Stats: NewStatsService(log, repos),
Profile: NewProfileService(log, repos),
Audit: NewAuditService(log, repos),
stopCtx: ctx,
stopCancel: cancel,
}
}
// Boot kicks off background workers (watcher, downloads poller,
// subscription scheduler). Called once after AutoMigrate.
func (c *Container) Boot() {
if err := c.Watcher.Start(c.stopCtx); err != nil {
c.Log.Warn("watcher start failed", zap.Error(err))
}
c.Downloads.Start(c.stopCtx)
c.Subscription.Start(c.stopCtx)
}
// Close releases any resources held by services (websocket hub, ffmpeg
// transcodes).
// transcodes, fsnotify, background pollers).
func (c *Container) Close() {
if c.stopCancel != nil {
c.stopCancel()
}
if c.Watcher != nil {
c.Watcher.Stop()
}
if c.Subscription != nil {
c.Subscription.Stop()
}
if c.Downloads != nil {
c.Downloads.Stop()
}
if c.Transcoder != nil {
c.Transcoder.StopAll()
}
+113
View File
@@ -0,0 +1,113 @@
// Package service — statistics aggregator.
//
// StatsService computes the dashboard numbers for the admin / home page:
// - total libraries, media items, users
// - total disk size and durations
// - top recently-watched media
// - process metadata (CPU / memory) via gopsutil
package service
import (
"context"
"runtime"
"time"
"github.com/shirou/gopsutil/v3/cpu"
"github.com/shirou/gopsutil/v3/disk"
"github.com/shirou/gopsutil/v3/mem"
"go.uber.org/zap"
"github.com/ShukeBta/MediaStationGo/internal/model"
"github.com/ShukeBta/MediaStationGo/internal/repository"
)
// StatsService computes aggregate stats.
type StatsService struct {
log *zap.Logger
repo *repository.Container
}
// NewStatsService is the constructor.
func NewStatsService(log *zap.Logger, repo *repository.Container) *StatsService {
return &StatsService{log: log, repo: repo}
}
// Snapshot is the JSON returned by /api/stats.
type Snapshot struct {
Libraries int64 `json:"libraries"`
MediaCount int64 `json:"media_count"`
UsersCount int64 `json:"users_count"`
TotalSizeBytes int64 `json:"total_size_bytes"`
TotalSeconds int64 `json:"total_seconds"`
RecentlyAdded []model.Media `json:"recently_added"`
Hardware Hardware `json:"hardware"`
GeneratedAt time.Time `json:"generated_at"`
}
// Hardware is the live CPU / memory / disk readings.
type Hardware struct {
CPUPercent float64 `json:"cpu_percent"`
MemoryUsed uint64 `json:"memory_used"`
MemoryTotal uint64 `json:"memory_total"`
DiskUsed uint64 `json:"disk_used"`
DiskTotal uint64 `json:"disk_total"`
GoVersion string `json:"go_version"`
Goroutines int `json:"goroutines"`
}
// Compute builds a fresh snapshot.
func (s *StatsService) Compute(ctx context.Context, dataDir string) (*Snapshot, error) {
snap := &Snapshot{GeneratedAt: time.Now()}
if err := s.repo.DB.Model(&model.Library{}).Count(&snap.Libraries).Error; err != nil {
return nil, err
}
if err := s.repo.DB.Model(&model.Media{}).Count(&snap.MediaCount).Error; err != nil {
return nil, err
}
if err := s.repo.DB.Model(&model.User{}).Count(&snap.UsersCount).Error; err != nil {
return nil, err
}
type sumRow struct {
Size int64
Seconds int64
}
var sum sumRow
if err := s.repo.DB.Model(&model.Media{}).
Select("COALESCE(SUM(size_bytes),0) as size, COALESCE(SUM(duration_sec),0) as seconds").
Scan(&sum).Error; err != nil {
return nil, err
}
snap.TotalSizeBytes = sum.Size
snap.TotalSeconds = sum.Seconds
if err := s.repo.DB.Model(&model.Media{}).
Order("created_at desc").Limit(12).
Find(&snap.RecentlyAdded).Error; err != nil {
return nil, err
}
snap.Hardware = readHardware(dataDir)
return snap, nil
}
func readHardware(dataDir string) Hardware {
hw := Hardware{
GoVersion: runtime.Version(),
Goroutines: runtime.NumGoroutine(),
}
if usage, err := cpu.Percent(0, false); err == nil && len(usage) > 0 {
hw.CPUPercent = usage[0]
}
if v, err := mem.VirtualMemory(); err == nil {
hw.MemoryUsed = v.Used
hw.MemoryTotal = v.Total
}
if dataDir == "" {
dataDir = "/"
}
if d, err := disk.Usage(dataDir); err == nil {
hw.DiskUsed = d.Used
hw.DiskTotal = d.Total
}
return hw
}
+243
View File
@@ -0,0 +1,243 @@
// Package service — RSS subscriptions for automated downloads.
//
// SubscriptionService periodically polls every Subscription row, fetches
// the configured RSS / Atom feed, and queues new items into the
// DownloadService. Items are deduplicated by GUID stored as a Setting key
// "subscription.<id>.last_guid" so the same episode is never re-queued.
package service
import (
"context"
"encoding/xml"
"errors"
"fmt"
"io"
"net/http"
"regexp"
"strings"
"time"
"go.uber.org/zap"
"github.com/ShukeBta/MediaStationGo/internal/model"
"github.com/ShukeBta/MediaStationGo/internal/repository"
)
// SubscriptionService runs the polling loop.
type SubscriptionService struct {
log *zap.Logger
repo *repository.Container
downloads *DownloadService
hub *Hub
stop chan struct{}
}
// NewSubscriptionService is the constructor.
func NewSubscriptionService(log *zap.Logger, repo *repository.Container, downloads *DownloadService, hub *Hub) *SubscriptionService {
return &SubscriptionService{
log: log, repo: repo, downloads: downloads, hub: hub,
stop: make(chan struct{}),
}
}
// Start runs the polling loop in the background.
func (s *SubscriptionService) Start(ctx context.Context) {
go s.loop(ctx)
}
// Stop shuts the loop down.
func (s *SubscriptionService) Stop() { close(s.stop) }
// rssFeed is the minimal RSS subset we need to decode.
type rssFeed struct {
XMLName xml.Name `xml:"rss"`
Channel struct {
Items []struct {
Title string `xml:"title"`
Link string `xml:"link"`
GUID string `xml:"guid"`
Description string `xml:"description"`
Enclosure struct {
URL string `xml:"url,attr"`
} `xml:"enclosure"`
} `xml:"item"`
} `xml:"channel"`
}
// Create persists a new subscription.
func (s *SubscriptionService) Create(ctx context.Context, sub *model.Subscription) error {
if sub.Name == "" || sub.FeedURL == "" {
return errors.New("name and feed_url required")
}
return s.repo.Subscription.Create(ctx, sub)
}
// List returns every subscription rule.
func (s *SubscriptionService) List(ctx context.Context) ([]model.Subscription, error) {
return s.repo.Subscription.List(ctx)
}
// Delete removes a subscription.
func (s *SubscriptionService) Delete(ctx context.Context, id string) error {
return s.repo.DB.Where("id = ?", id).Delete(&model.Subscription{}).Error
}
// RunNow forces a poll for one subscription, ignoring its schedule. Used
// by the admin UI's "test now" button.
func (s *SubscriptionService) RunNow(ctx context.Context, id string) (int, error) {
var sub model.Subscription
if err := s.repo.DB.Where("id = ?", id).First(&sub).Error; err != nil {
return 0, err
}
return s.runOne(ctx, &sub)
}
// loop polls every 10 minutes.
func (s *SubscriptionService) loop(ctx context.Context) {
t := time.NewTicker(10 * time.Minute)
defer t.Stop()
// First run shortly after startup.
first := time.NewTimer(30 * time.Second)
defer first.Stop()
for {
select {
case <-ctx.Done():
return
case <-s.stop:
return
case <-first.C:
case <-t.C:
}
s.runAll(ctx)
}
}
func (s *SubscriptionService) runAll(ctx context.Context) {
subs, err := s.repo.Subscription.List(ctx)
if err != nil {
s.log.Warn("subscription list failed", zap.Error(err))
return
}
for i := range subs {
if !subs[i].Enabled {
continue
}
if n, err := s.runOne(ctx, &subs[i]); err != nil {
s.log.Warn("subscription run failed",
zap.String("name", subs[i].Name), zap.Error(err))
} else if n > 0 {
s.log.Info("subscription queued items",
zap.String("name", subs[i].Name), zap.Int("count", n))
}
}
}
func (s *SubscriptionService) runOne(ctx context.Context, sub *model.Subscription) (int, error) {
feed, err := s.fetch(ctx, sub.FeedURL)
if err != nil {
return 0, err
}
filter := compileFilter(sub.Filter)
guidKey := fmt.Sprintf("subscription.%s.seen", sub.ID)
seenRaw, _ := s.repo.Setting.Get(ctx, guidKey)
seen := splitNonEmpty(seenRaw)
seenSet := make(map[string]struct{}, len(seen))
for _, g := range seen {
seenSet[g] = struct{}{}
}
queued := 0
for _, item := range feed.Channel.Items {
guid := item.GUID
if guid == "" {
guid = item.Link
}
if _, ok := seenSet[guid]; ok {
continue
}
if filter != nil && !filter.MatchString(item.Title) {
continue
}
download := item.Enclosure.URL
if download == "" {
download = item.Link
}
if download == "" {
continue
}
if _, err := s.downloads.AddDownload(ctx, sub.UserID, download, ""); err != nil {
s.log.Warn("subscription enqueue failed",
zap.String("title", item.Title), zap.Error(err))
continue
}
queued++
seen = append(seen, guid)
}
// Remember the last 200 GUIDs so the seen set doesn't grow forever.
if len(seen) > 200 {
seen = seen[len(seen)-200:]
}
_ = s.repo.Setting.Set(ctx, guidKey, strings.Join(seen, "\n"))
now := time.Now()
_ = s.repo.DB.Model(sub).Updates(map[string]any{"last_run_at": &now}).Error
if queued > 0 {
s.hub.Publish("subscription", map[string]any{
"id": sub.ID,
"name": sub.Name,
"queued": queued,
})
}
return queued, nil
}
func (s *SubscriptionService) fetch(ctx context.Context, feedURL string) (*rssFeed, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, feedURL, nil)
if err != nil {
return nil, err
}
req.Header.Set("User-Agent", "MediaStationGo/0.1")
resp, err := http.DefaultClient.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode >= 400 {
return nil, fmt.Errorf("rss %s: %d", feedURL, resp.StatusCode)
}
body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, err
}
var f rssFeed
if err := xml.Unmarshal(body, &f); err != nil {
return nil, err
}
return &f, nil
}
func compileFilter(pat string) *regexp.Regexp {
pat = strings.TrimSpace(pat)
if pat == "" {
return nil
}
if r, err := regexp.Compile("(?i)" + pat); err == nil {
return r
}
return nil
}
func splitNonEmpty(s string) []string {
if s == "" {
return nil
}
out := make([]string, 0)
for _, p := range strings.Split(s, "\n") {
p = strings.TrimSpace(p)
if p != "" {
out = append(out, p)
}
}
return out
}
+227
View File
@@ -0,0 +1,227 @@
// Package service — subtitle handling.
//
// SubtitleService finds external subtitle files next to a media file and
// converts SRT to WebVTT on the fly so the browser <track> element can
// load them directly.
//
// External-subtitle discovery rules (matching MediaStation Python defaults):
//
// 1. Same directory, same basename, different extension.
// 2. Same directory, ".sub/" or "subs/" subdirectory.
// 3. Sibling languages e.g. movie.zh.srt / movie.en.srt → exposed as
// ?lang=zh / ?lang=en.
//
// Supported extensions: .srt, .ass, .ssa, .vtt.
package service
import (
"context"
"errors"
"fmt"
"io"
"os"
"path/filepath"
"regexp"
"strings"
"go.uber.org/zap"
"github.com/ShukeBta/MediaStationGo/internal/repository"
)
// SubtitleService is the discovery + conversion entry point.
type SubtitleService struct {
log *zap.Logger
repo *repository.Container
}
// NewSubtitleService is the constructor.
func NewSubtitleService(log *zap.Logger, repo *repository.Container) *SubtitleService {
return &SubtitleService{log: log, repo: repo}
}
// SubtitleTrack describes one external subtitle file.
type SubtitleTrack struct {
Lang string `json:"lang"`
Label string `json:"label"`
Path string `json:"path"`
URL string `json:"url"`
Codec string `json:"codec"`
}
// extToCodec maps the file extension to the inner codec name.
var extToCodec = map[string]string{
".srt": "srt",
".vtt": "vtt",
".ass": "ass",
".ssa": "ssa",
}
// Discover lists every external subtitle file for a media row. The URL is
// relative; the caller should prepend /api/subtitles/<media_id>?path=...
// when serializing for the frontend.
func (s *SubtitleService) Discover(ctx context.Context, mediaID string) ([]SubtitleTrack, error) {
m, err := s.repo.Media.FindByID(ctx, mediaID)
if err != nil {
return nil, err
}
if m == nil {
return nil, errors.New("media not found")
}
dir := filepath.Dir(m.Path)
base := strings.TrimSuffix(filepath.Base(m.Path), filepath.Ext(m.Path))
candidates := make([]string, 0, 16)
candidates = append(candidates, dir)
for _, sub := range []string{"subs", "Subs", "sub", ".sub"} {
candidates = append(candidates, filepath.Join(dir, sub))
}
var tracks []SubtitleTrack
for _, c := range candidates {
entries, err := os.ReadDir(c)
if err != nil {
continue
}
for _, e := range entries {
if e.IsDir() {
continue
}
ext := strings.ToLower(filepath.Ext(e.Name()))
codec, ok := extToCodec[ext]
if !ok {
continue
}
fullName := strings.TrimSuffix(e.Name(), ext)
if !strings.HasPrefix(strings.ToLower(fullName), strings.ToLower(base)) &&
c == dir {
// In the same directory we require a basename match;
// inside subs/ subdirs we accept anything.
continue
}
lang := detectLang(fullName, base)
tracks = append(tracks, SubtitleTrack{
Lang: lang,
Label: lang,
Path: filepath.Join(c, e.Name()),
Codec: codec,
})
}
}
return tracks, nil
}
// langTag matches the .zh / .zh-cn / .chs language sub-extensions.
var langTag = regexp.MustCompile(`(?i)\.([a-z]{2,3}(?:[-_][a-z]{2,4})?)$`)
func detectLang(name, base string) string {
suffix := strings.TrimPrefix(name, base)
suffix = strings.TrimPrefix(suffix, ".")
if m := langTag.FindStringSubmatch("." + suffix); len(m) >= 2 {
return strings.ToLower(m[1])
}
if suffix == "" {
return "und" // undetermined
}
return strings.ToLower(suffix)
}
// Serve writes the subtitle file as WebVTT (.vtt). SRT/SSA files are
// converted minimally on the fly. Returns ErrSubtitleNotFound when the
// path is rejected (path traversal / not in the media directory).
func (s *SubtitleService) Serve(ctx context.Context, mediaID, sub string, w io.Writer) error {
m, err := s.repo.Media.FindByID(ctx, mediaID)
if err != nil || m == nil {
return errors.New("media not found")
}
abs, err := filepath.Abs(sub)
if err != nil {
return err
}
mediaDir, _ := filepath.Abs(filepath.Dir(m.Path))
if !strings.HasPrefix(abs, mediaDir) {
return fmt.Errorf("path escape")
}
f, err := os.Open(abs)
if err != nil {
return err
}
defer f.Close()
body, err := io.ReadAll(f)
if err != nil {
return err
}
switch strings.ToLower(filepath.Ext(abs)) {
case ".vtt":
_, err = w.Write(body)
case ".srt":
_, err = w.Write([]byte(srtToVTT(string(body))))
case ".ass", ".ssa":
_, err = w.Write([]byte(assToVTT(string(body))))
default:
return errors.New("unsupported subtitle format")
}
return err
}
// srtToVTT performs the minimal SRT → WebVTT transformation: prepend
// "WEBVTT\n\n" and replace ',' with '.' in the timecode separators.
func srtToVTT(body string) string {
body = strings.ReplaceAll(body, "\r\n", "\n")
out := strings.Builder{}
out.WriteString("WEBVTT\n\n")
for _, line := range strings.Split(body, "\n") {
if strings.Contains(line, "-->") {
line = strings.ReplaceAll(line, ",", ".")
}
out.WriteString(line)
out.WriteByte('\n')
}
return out.String()
}
// assToVTT extracts the dialogue lines from an ASS/SSA subtitle. Styling
// is dropped — the goal is to produce something usable in <track> rather
// than a pixel-perfect render.
func assToVTT(body string) string {
out := strings.Builder{}
out.WriteString("WEBVTT\n\n")
for i, line := range strings.Split(body, "\n") {
line = strings.TrimSpace(line)
if !strings.HasPrefix(line, "Dialogue:") {
continue
}
parts := strings.SplitN(line, ",", 10)
if len(parts) < 10 {
continue
}
fmt.Fprintf(&out, "%d\n%s --> %s\n%s\n\n",
i,
normaliseTimecode(parts[1]),
normaliseTimecode(parts[2]),
stripASSTags(parts[9]),
)
}
return out.String()
}
func normaliseTimecode(t string) string {
t = strings.TrimSpace(t)
parts := strings.Split(t, ":")
if len(parts) != 3 {
return t
}
hh := parts[0]
if len(hh) == 1 {
hh = "0" + hh
}
return hh + ":" + parts[1] + ":" + strings.ReplaceAll(parts[2], ".", ".")
}
var assTag = regexp.MustCompile(`\{[^}]*\}`)
func stripASSTags(s string) string {
return assTag.ReplaceAllString(s, "")
}
+36
View File
@@ -0,0 +1,36 @@
package service
import (
"strings"
"testing"
)
func TestSrtToVTT(t *testing.T) {
in := "1\n00:00:01,000 --> 00:00:02,500\nHello world\n\n"
out := srtToVTT(in)
if !strings.HasPrefix(out, "WEBVTT") {
t.Fatalf("missing WEBVTT prefix: %q", out)
}
if !strings.Contains(out, "00:00:01.000 --> 00:00:02.500") {
t.Fatalf("comma timecode not converted: %q", out)
}
if !strings.Contains(out, "Hello world") {
t.Fatalf("dialogue lost: %q", out)
}
}
func TestStripASSTags(t *testing.T) {
cases := []struct {
in, want string
}{
{"{\\an8}hello", "hello"},
{"plain", "plain"},
{"a{\\fad(0,500)}b{\\b1}c", "abc"},
}
for _, c := range cases {
got := stripASSTags(c.in)
if got != c.want {
t.Errorf("stripASSTags(%q) = %q, want %q", c.in, got, c.want)
}
}
}
+4 -1
View File
@@ -61,9 +61,12 @@ func NewTMDbProvider(cfg *config.Config, log *zap.Logger) *TMDbProvider {
// Enabled reports whether the operator has supplied an API key.
func (t *TMDbProvider) Enabled() bool { return t.cfg.Secrets.TMDbAPIKey != "" }
// Match describes a successful metadata match.
// Match describes a successful metadata match. The same struct is reused
// across providers; provider-specific IDs sit side-by-side so the scraper
// orchestrator can write them all into a single update.
type Match struct {
TMDbID int `json:"tmdb_id"`
BangumiID int `json:"bangumi_id"`
Title string `json:"title"`
Overview string `json:"overview"`
PosterURL string `json:"poster_url"`
+194
View File
@@ -0,0 +1,194 @@
// Package service — filesystem watcher.
//
// WatcherService observes every enabled library root with fsnotify and
// debounces incoming events into per-library re-scans. New / renamed
// files become Media rows; deletes remove them.
//
// The watcher runs in the background and is started after migrations
// complete. It survives library add / delete via Refresh().
package service
import (
"context"
"path/filepath"
"sync"
"time"
"github.com/fsnotify/fsnotify"
"go.uber.org/zap"
"github.com/ShukeBta/MediaStationGo/internal/repository"
)
// WatcherService is a thin orchestrator on top of fsnotify.
type WatcherService struct {
log *zap.Logger
repo *repository.Container
scanner *ScannerService
mu sync.Mutex
watcher *fsnotify.Watcher
watched map[string]string // dir -> libraryID
pending map[string]time.Time
stop chan struct{}
}
// NewWatcherService is the constructor.
func NewWatcherService(log *zap.Logger, repo *repository.Container, scanner *ScannerService) *WatcherService {
return &WatcherService{
log: log,
repo: repo,
scanner: scanner,
watched: make(map[string]string),
pending: make(map[string]time.Time),
stop: make(chan struct{}),
}
}
// Start initialises the underlying fsnotify watcher and registers every
// library root currently in the database.
func (w *WatcherService) Start(ctx context.Context) error {
fw, err := fsnotify.NewWatcher()
if err != nil {
return err
}
w.watcher = fw
if err := w.Refresh(ctx); err != nil {
w.log.Warn("watcher refresh failed", zap.Error(err))
}
go w.loop(ctx)
go w.debouncer(ctx)
return nil
}
// Stop tears down the watcher (called on graceful shutdown).
func (w *WatcherService) Stop() {
close(w.stop)
if w.watcher != nil {
_ = w.watcher.Close()
}
}
// Refresh reads the library list and adjusts the set of watched
// directories. Idempotent — safe to call after every CRUD.
func (w *WatcherService) Refresh(ctx context.Context) error {
libs, err := w.repo.Library.List(ctx)
if err != nil {
return err
}
w.mu.Lock()
defer w.mu.Unlock()
current := make(map[string]string)
for _, l := range libs {
if !l.Enabled {
continue
}
current[l.Path] = l.ID
}
// Remove disappeared paths.
for path := range w.watched {
if _, ok := current[path]; !ok {
_ = w.watcher.Remove(path)
delete(w.watched, path)
}
}
// Add new ones (top-level only — fsnotify is non-recursive).
for path, id := range current {
if _, ok := w.watched[path]; ok {
continue
}
if err := w.watcher.Add(path); err != nil {
w.log.Warn("watch add failed", zap.String("path", path), zap.Error(err))
continue
}
w.watched[path] = id
}
return nil
}
// loop drains fsnotify events and pushes the affected library into the
// pending map. The actual rescan happens in the debouncer goroutine.
func (w *WatcherService) loop(ctx context.Context) {
if w.watcher == nil {
return
}
for {
select {
case <-ctx.Done():
return
case <-w.stop:
return
case ev, ok := <-w.watcher.Events:
if !ok {
return
}
if ev.Op&(fsnotify.Create|fsnotify.Remove|fsnotify.Rename|fsnotify.Write) == 0 {
continue
}
lib := w.findLibrary(ev.Name)
if lib == "" {
continue
}
w.mu.Lock()
w.pending[lib] = time.Now()
w.mu.Unlock()
case err, ok := <-w.watcher.Errors:
if !ok {
return
}
w.log.Warn("watcher error", zap.Error(err))
}
}
}
// findLibrary maps a path back to the watching library ID, taking the
// shortest matching prefix.
func (w *WatcherService) findLibrary(path string) string {
w.mu.Lock()
defer w.mu.Unlock()
dir := filepath.Dir(path)
for {
if id, ok := w.watched[dir]; ok {
return id
}
parent := filepath.Dir(dir)
if parent == dir {
return ""
}
dir = parent
}
}
// debouncer drains the pending set every 5 s and triggers a rescan per
// library. Coalescing avoids storming the disk on bulk operations
// (mass-rename, large copies).
func (w *WatcherService) debouncer(ctx context.Context) {
t := time.NewTicker(5 * time.Second)
defer t.Stop()
for {
select {
case <-ctx.Done():
return
case <-w.stop:
return
case <-t.C:
}
w.mu.Lock()
due := make([]string, 0, len(w.pending))
now := time.Now()
for id, ts := range w.pending {
if now.Sub(ts) >= 5*time.Second {
due = append(due, id)
delete(w.pending, id)
}
}
w.mu.Unlock()
for _, id := range due {
w.log.Info("watcher triggered rescan", zap.String("library_id", id))
if _, err := w.scanner.ScanLibrary(ctx, id); err != nil {
w.log.Warn("watcher rescan failed", zap.Error(err))
}
}
}
}
+21
View File
@@ -30,6 +30,16 @@ const PlayerPage = lazy(() =>
import('./pages/PlayerPage').then((m) => ({ default: m.PlayerPage })),
)
const AdminPage = lazy(() => import('./pages/AdminPage').then((m) => ({ default: m.AdminPage })))
const DownloadsPage = lazy(() =>
import('./pages/DownloadsPage').then((m) => ({ default: m.DownloadsPage })),
)
const SubscriptionsPage = lazy(() =>
import('./pages/SubscriptionsPage').then((m) => ({ default: m.SubscriptionsPage })),
)
const ProfilePage = lazy(() =>
import('./pages/ProfilePage').then((m) => ({ default: m.ProfilePage })),
)
const StatsPage = lazy(() => import('./pages/StatsPage').then((m) => ({ default: m.StatsPage })))
// Fallback shown while a chunk is loading.
const Loading = () => <p className="px-6 py-8 text-slate-500">加载中…</p>
@@ -55,6 +65,17 @@ export default function App() {
<Route path="playlist/:id" element={<PlaylistDetailPage />} />
<Route path="media/:id" element={<MediaDetailPage />} />
<Route path="play/:id" element={<PlayerPage />} />
<Route path="downloads" element={<DownloadsPage />} />
<Route path="subscriptions" element={<SubscriptionsPage />} />
<Route path="profile" element={<ProfilePage />} />
<Route
path="stats"
element={
<RequireAdmin>
<StatsPage />
</RequireAdmin>
}
/>
<Route
path="admin"
element={
+23
View File
@@ -0,0 +1,23 @@
import { api } from './client'
import type { DownloadTask, QBitTorrent } from '../types'
export interface DownloadsState {
tasks: DownloadTask[]
torrents: QBitTorrent[] | null
}
export const downloadsAPI = {
list: () => api.get<DownloadsState>('/downloads').then((r) => r.data),
add: (url: string, savePath = '') =>
api
.post<DownloadTask>('/downloads', { url, save_path: savePath })
.then((r) => r.data),
remove: (hash: string, deleteFiles = false) =>
api
.delete(`/downloads/${hash}?delete_files=${deleteFiles ? 'true' : 'false'}`)
.then((r) => r.data),
reload: () => api.post('/downloads/reload').then((r) => r.data),
}
+3
View File
@@ -19,6 +19,9 @@ export const libraryAPI = {
scan: (id: string) =>
api.post<ScanResult>(`/libraries/${id}/scan`).then((r) => r.data),
scrape: (id: string) =>
api.post(`/libraries/${id}/scrape`).then((r) => r.data),
listMedia: (id: string, page = 1, pageSize = 50) =>
api
.get<MediaPage>(`/libraries/${id}/media`, {
+10
View File
@@ -0,0 +1,10 @@
import { api } from './client'
import type { User } from '../types'
export const profileAPI = {
update: (patch: { email?: string; avatar_url?: string }) =>
api.patch<User>('/me', patch).then((r) => r.data),
adminUpdateRole: (id: string, role: 'admin' | 'user') =>
api.patch<User>(`/admin/users/${id}/role`, { role }).then((r) => r.data),
}
+14
View File
@@ -0,0 +1,14 @@
import { api } from './client'
import type { Media } from '../types'
export interface SeasonGroup {
season: number
episodes: Media[]
}
export const seriesAPI = {
seasons: (libraryID: string) =>
api
.get<{ seasons: SeasonGroup[] }>(`/libraries/${libraryID}/seasons`)
.then((r) => r.data.seasons),
}
+6
View File
@@ -0,0 +1,6 @@
import { api } from './client'
import type { StatsSnapshot } from '../types'
export const statsAPI = {
snapshot: () => api.get<StatsSnapshot>('/stats').then((r) => r.data),
}
+15
View File
@@ -0,0 +1,15 @@
import { api } from './client'
import type { Subscription } from '../types'
export const subscriptionsAPI = {
list: () =>
api.get<{ items: Subscription[] }>('/subscriptions').then((r) => r.data.items),
create: (input: { name: string; feed_url: string; filter?: string; enabled?: boolean }) =>
api.post<Subscription>('/subscriptions', input).then((r) => r.data),
remove: (id: string) => api.delete(`/subscriptions/${id}`).then((r) => r.data),
runNow: (id: string) =>
api.post<{ queued: number }>(`/subscriptions/${id}/run`).then((r) => r.data),
}
+24
View File
@@ -0,0 +1,24 @@
import { api } from './client'
import { useAuthStore } from '../stores/auth'
export interface SubtitleTrack {
lang: string
label: string
path: string
url: string
codec: string
}
export const subtitlesAPI = {
list: (mediaId: string) =>
api
.get<{ tracks: SubtitleTrack[] }>(`/media/${mediaId}/subtitles`)
.then((r) => r.data.tracks),
url: (mediaId: string, path: string) => {
const token = useAuthStore.getState().token ?? ''
return `/api/subtitles/${encodeURIComponent(mediaId)}?path=${encodeURIComponent(
path,
)}&token=${encodeURIComponent(token)}`
},
}
+27
View File
@@ -0,0 +1,27 @@
import { useCallback } from 'react'
import toast from 'react-hot-toast'
import { useWebSocket } from '../hooks/useWebSocket'
// GlobalEvents subscribes to the WS hub and surfaces interesting events
// as toasts. Lives at the top of the component tree so every page sees
// the same stream without re-opening connections.
export function GlobalEvents() {
const onEvent = useCallback((topic: string, payload: unknown) => {
if (!payload || typeof payload !== 'object') return
const p = payload as Record<string, unknown>
if (topic === 'scan' && p.finished) {
toast.success(`扫描完成:已添加 ${p.added ?? 0} 项,已探测 ${p.probed ?? 0} 项`)
}
if (topic === 'scrape' && p.finished) {
toast.success(`刮削完成:成功匹配 ${p.matched ?? 0} 项`)
}
if (topic === 'subscription') {
const queued = (p.queued as number | undefined) ?? 0
if (queued > 0) toast.success(`订阅「${p.name}」已加入 ${queued} 项下载`)
}
}, [])
useWebSocket(onEvent)
return null
}
+17 -1
View File
@@ -1,14 +1,18 @@
import { useEffect, useState } from 'react'
import { Link, NavLink, Outlet, useNavigate } from 'react-router-dom'
import {
Activity,
CloudDownload,
Film,
Heart,
Home,
ListMusic,
LogOut,
Rss,
Search,
Settings,
Library as LibraryIcon,
User as UserIcon,
} from 'lucide-react'
import clsx from 'clsx'
@@ -42,7 +46,7 @@ export function Layout() {
</span>
</Link>
<nav className="flex-1 space-y-1">
<nav className="flex-1 space-y-1 overflow-y-auto pr-1">
<SidebarLink to="/" icon={<Home size={18} />} label="首页" end />
<SidebarLink to="/search" icon={<Search size={18} />} label="搜索" />
<SidebarLink to="/favourites" icon={<Heart size={18} />} label="收藏" />
@@ -63,11 +67,23 @@ export function Layout() {
/>
))}
<div className="mt-6 px-2 text-xs uppercase tracking-wider text-slate-500">
自动化
</div>
<SidebarLink to="/downloads" icon={<CloudDownload size={18} />} label="下载" />
<SidebarLink to="/subscriptions" icon={<Rss size={18} />} label="RSS 订阅" />
<div className="mt-6 px-2 text-xs uppercase tracking-wider text-slate-500">
账号
</div>
<SidebarLink to="/profile" icon={<UserIcon size={18} />} label="个人资料" />
{user?.role === 'admin' && (
<>
<div className="mt-6 px-2 text-xs uppercase tracking-wider text-slate-500">
管理
</div>
<SidebarLink to="/stats" icon={<Activity size={18} />} label="运行状态" />
<SidebarLink to="/admin" icon={<Settings size={18} />} label="管理后台" />
</>
)}
+45
View File
@@ -0,0 +1,45 @@
import { useEffect, useRef } from 'react'
import { useAuthStore } from '../stores/auth'
// useWebSocket opens a single connection to /api/ws and dispatches every
// message to the supplied handler. Auto-reconnects with a 3 s back-off
// while the auth token is present.
export function useWebSocket(onEvent: (topic: string, payload: unknown) => void) {
const ref = useRef<WebSocket | null>(null)
const token = useAuthStore((s) => s.token)
useEffect(() => {
if (!token) return
let closed = false
let timer: number | undefined
const open = () => {
const proto = window.location.protocol === 'https:' ? 'wss:' : 'ws:'
const url = `${proto}//${window.location.host}/api/ws?token=${encodeURIComponent(token)}`
const ws = new WebSocket(url)
ref.current = ws
ws.onmessage = (ev) => {
try {
const msg = JSON.parse(ev.data)
if (msg && typeof msg.topic === 'string') {
onEvent(msg.topic, msg.payload)
}
} catch {
// ignore malformed frames
}
}
ws.onclose = () => {
if (closed) return
timer = window.setTimeout(open, 3_000)
}
}
open()
return () => {
closed = true
if (timer) window.clearTimeout(timer)
ref.current?.close()
}
}, [token, onEvent])
}
+2
View File
@@ -4,12 +4,14 @@ import { BrowserRouter } from 'react-router-dom'
import { Toaster } from 'react-hot-toast'
import App from './App'
import { GlobalEvents } from './components/GlobalEvents'
import './index.css'
// Application root: BrowserRouter + global toast container.
ReactDOM.createRoot(document.getElementById('root') as HTMLElement).render(
<React.StrictMode>
<BrowserRouter>
<GlobalEvents />
<App />
<Toaster
position="top-right"
+177
View File
@@ -0,0 +1,177 @@
import { FormEvent, useEffect, useState } from 'react'
import toast from 'react-hot-toast'
import { Download, Trash2 } from 'lucide-react'
import { downloadsAPI } from '../api/downloads'
import { useAuthStore } from '../stores/auth'
import type { DownloadTask, QBitTorrent } from '../types'
function fmtBytes(n: number): string {
if (!n || n <= 0) return '0 B'
const u = ['B', 'KB', 'MB', 'GB', 'TB']
let v = n
let i = 0
while (v >= 1024 && i < u.length - 1) {
v /= 1024
i++
}
return `${v.toFixed(2)} ${u[i]}`
}
function fmtSpeed(n: number): string {
return `${fmtBytes(n)}/s`
}
// DownloadsPage: shows running torrents (live) + persisted task rows.
export function DownloadsPage() {
const role = useAuthStore((s) => s.user?.role)
const [tasks, setTasks] = useState<DownloadTask[]>([])
const [torrents, setTorrents] = useState<QBitTorrent[] | null>(null)
const [url, setURL] = useState('')
const [savePath, setSavePath] = useState('')
const refresh = () =>
downloadsAPI.list().then((d) => {
setTasks(d.tasks)
setTorrents(d.torrents)
})
useEffect(() => {
void refresh().catch(() => undefined)
const id = window.setInterval(() => void refresh().catch(() => undefined), 5_000)
return () => window.clearInterval(id)
}, [])
const onAdd = async (e: FormEvent) => {
e.preventDefault()
try {
await downloadsAPI.add(url, savePath)
toast.success('已加入下载队列')
setURL('')
setSavePath('')
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">
<h1 className="font-display text-3xl font-bold text-white">下载</h1>
<form onSubmit={onAdd} className="glass-panel grid gap-3 md:grid-cols-[1fr_1fr_auto]">
<input
required
className="input-base md:col-span-2"
placeholder="磁力链接 / .torrent URL"
value={url}
onChange={(e) => setURL(e.target.value)}
/>
<input
className="input-base"
placeholder="保存路径 (可选)"
value={savePath}
onChange={(e) => setSavePath(e.target.value)}
/>
<button type="submit" className="neon-button md:col-span-3">
<Download size={16} /> 添加
</button>
</form>
<section className="glass-panel">
<h2 className="mb-3 font-display text-lg font-semibold text-white">实时状态</h2>
{torrents === null && (
<p className="text-slate-500">尚未连接到 qBittorrent — 请在管理后台配置 qbittorrent.url / username / password。</p>
)}
{torrents && torrents.length === 0 && <p className="text-slate-500">暂无运行中任务。</p>}
{torrents && torrents.length > 0 && (
<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>速度</th>
<th>体积</th>
{role === 'admin' && <th />}
</tr>
</thead>
<tbody>
{torrents.map((t) => (
<tr key={t.hash} className="border-t border-white/5 align-top">
<td className="max-w-md truncate py-2 text-white" title={t.name}>
{t.name}
</td>
<td className="text-slate-300">{t.state}</td>
<td className="text-slate-300">
<div className="flex items-center gap-2">
<div className="h-1 w-24 overflow-hidden rounded bg-white/10">
<div
className="h-full bg-primary-400"
style={{ width: `${Math.round(t.progress * 100)}%` }}
/>
</div>
{(t.progress * 100).toFixed(1)}%
</div>
</td>
<td className="text-slate-300">
↓ {fmtSpeed(t.dlspeed)} / ↑ {fmtSpeed(t.upspeed)}
</td>
<td className="text-slate-300">{fmtBytes(t.size)}</td>
{role === 'admin' && (
<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(`删除「${t.name}」?`)) return
await downloadsAPI.remove(t.hash, false)
toast.success('已删除任务')
await refresh()
}}
>
<Trash2 size={12} />
</button>
</td>
)}
</tr>
))}
</tbody>
</table>
)}
</section>
{tasks.length > 0 && (
<section className="glass-panel">
<h2 className="mb-3 font-display text-lg font-semibold text-white">历史记录</h2>
<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>URL</th>
<th>保存路径</th>
<th>时间</th>
</tr>
</thead>
<tbody>
{tasks.map((t) => (
<tr key={t.id} className="border-t border-white/5">
<td className="py-2 text-slate-300">{t.source}</td>
<td className="max-w-md truncate text-slate-300" title={t.url}>
{t.url}
</td>
<td className="text-slate-300">{t.save_path || '—'}</td>
<td className="text-slate-500">
{new Date(t.created_at).toLocaleString()}
</td>
</tr>
))}
</tbody>
</table>
</section>
)}
</div>
)
}
+110 -26
View File
@@ -1,42 +1,67 @@
import { useEffect, useState } from 'react'
import { useEffect, useMemo, 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 { seriesAPI, type SeasonGroup } from '../api/series'
import type { Library, 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.
// LibraryPage renders one of two layouts depending on the library type:
//
// - movie / music / default → flat poster grid with pagination.
// - tv / anime → grouped by season with an episode list.
//
// Admins also get scan + scrape buttons in the header.
export function LibraryPage() {
const { id = '' } = useParams()
const role = useAuthStore((s) => s.user?.role)
const [library, setLibrary] = useState<Library | null>(null)
const [items, setItems] = useState<Media[]>([])
const [seasons, setSeasons] = useState<SeasonGroup[]>([])
const [total, setTotal] = useState(0)
const [loading, setLoading] = useState(true)
const [scanning, setScanning] = useState(false)
const [scraping, setScraping] = useState(false)
const isSeries = library?.type === 'tv' || library?.type === 'anime'
useEffect(() => {
if (!id) return
setLoading(true)
libraryAPI
.listMedia(id)
.then((d) => {
setItems(d.items)
setTotal(d.total)
})
.finally(() => setLoading(false))
Promise.all([
libraryAPI.list().then((all) => all.find((l) => l.id === id) ?? null),
]).then(([lib]) => setLibrary(lib))
}, [id])
useEffect(() => {
if (!id || !library) return
setLoading(true)
if (library.type === 'tv' || library.type === 'anime') {
seriesAPI
.seasons(id)
.then((s) => setSeasons(s))
.finally(() => setLoading(false))
} else {
libraryAPI
.listMedia(id)
.then((d) => {
setItems(d.items)
setTotal(d.total)
})
.finally(() => setLoading(false))
}
}, [id, library])
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)
toast.success(`扫描完成:新增 ${r.added} 项,探测 ${r.probed} 项`)
// Force a reload by toggling library state.
setLibrary((l) => (l ? { ...l } : l))
} catch {
toast.error('扫描失败')
} finally {
@@ -44,25 +69,84 @@ export function LibraryPage() {
}
}
const handleScrape = async () => {
setScraping(true)
try {
await libraryAPI.scrape(id)
toast.success('刮削已加入后台队列')
} catch (err: unknown) {
const msg =
(err as { response?: { data?: { error?: string } } })?.response?.data?.error ??
'刮削失败'
toast.error(msg)
} finally {
setScraping(false)
}
}
const heading = useMemo(
() => (library ? `${library.name}` : '媒体库'),
[library],
)
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>
<div className="flex flex-wrap items-center justify-between gap-3">
<div>
<h1 className="font-display text-3xl font-bold text-white">
{heading}
{!isSeries && <span className="text-slate-500"> ({total})</span>}
</h1>
{library && (
<p className="text-sm text-slate-400">
{library.type} · {library.path}
</p>
)}
</div>
{role === 'admin' && (
<button onClick={handleScan} disabled={scanning} className="neon-button">
{scanning ? '扫描中…' : '立即扫描'}
</button>
<div className="flex gap-2">
<button onClick={handleScan} disabled={scanning} className="neon-button">
{scanning ? '扫描中…' : '立即扫描'}
</button>
<button onClick={handleScrape} disabled={scraping} className="neon-button">
{scraping ? '刮削中…' : '刮削元数据'}
</button>
</div>
)}
</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} />
{!isSeries && (
<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>
)}
{isSeries &&
seasons.map((s) => (
<section key={s.season} className="space-y-3">
<h2 className="font-display text-xl font-semibold text-white">
{s.season > 0 ? `第 ${s.season} 季` : '未分季'} ({s.episodes.length})
</h2>
<div className="grid grid-cols-2 gap-4 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-6">
{s.episodes.map((e) => (
<div key={e.id} className="space-y-1">
<MediaCard media={e} />
{e.episode_num > 0 && (
<p className="px-1 text-xs text-slate-400">第 {e.episode_num} 集</p>
)}
</div>
))}
</div>
</section>
))}
</div>
{isSeries && !loading && seasons.length === 0 && (
<p className="text-slate-400">该库尚未发现任何剧集,触发一次扫描后再来看看。</p>
)}
</div>
)
}
+23 -12
View File
@@ -7,6 +7,7 @@ import toast from 'react-hot-toast'
import { mediaAPI } from '../api/library'
import { hlsURL, streamURL } from '../api/client'
import { playbackAPI } from '../api/playback'
import { subtitlesAPI, type SubtitleTrack } from '../api/subtitles'
import type { Media } from '../types'
type Mode = 'direct' | 'hls'
@@ -19,6 +20,9 @@ type Mode = 'direct' | 'hls'
// We pick a sensible default based on the source codec: H.264 + AAC in
// MP4 / WebM containers play directly; everything else (HEVC, MKV, AV1,
// AC3 audio, …) gets routed through ffmpeg → HLS.
//
// External subtitles next to the source file are auto-discovered and
// attached as <track> elements.
export function PlayerPage() {
const { id = '' } = useParams()
const [params, setParams] = useSearchParams()
@@ -30,6 +34,7 @@ export function PlayerPage() {
const [media, setMedia] = useState<Media | null>(null)
const [mode, setMode] = useState<Mode>('direct')
const [subs, setSubs] = useState<SubtitleTrack[]>([])
// Load metadata and pick a default mode.
useEffect(() => {
@@ -40,6 +45,10 @@ export function PlayerPage() {
const auto = pickMode(m)
setMode(forced ?? auto)
})
subtitlesAPI
.list(id)
.then(setSubs)
.catch(() => setSubs([]))
}, [id, params])
// Wire up the actual <video> element when we know the mode.
@@ -48,14 +57,9 @@ export function PlayerPage() {
teardownHls()
const video = ref.current
let cancelled = false
if (mode === 'hls') {
const url = hlsURL(media.id)
// Lazy import keeps the main bundle ~530 KB smaller until the user
// actually hits HLS playback.
void import('hls.js').then(({ default: HlsCtor }) => {
if (cancelled) return
if (HlsCtor.isSupported()) {
const hls = new HlsCtor({ enableWorker: true, lowLatencyMode: false })
hls.loadSource(url)
@@ -81,10 +85,7 @@ export function PlayerPage() {
video.src = streamURL(media.id)
void video.play().catch(() => undefined)
}
return () => {
cancelled = true
teardownHls()
}
return teardownHls
}, [media, mode, params, setParams])
// Persist resume position every 10 seconds while playing.
@@ -161,8 +162,20 @@ export function PlayerPage() {
ref={ref}
controls
autoPlay
crossOrigin="use-credentials"
className="max-h-screen w-full max-w-[1600px] bg-black"
/>
>
{subs.map((t, i) => (
<track
key={t.path}
kind="subtitles"
src={subtitlesAPI.url(media.id, t.path)}
srcLang={t.lang}
label={t.label || t.lang}
default={i === 0}
/>
))}
</video>
) : (
<p className="text-slate-500">加载中…</p>
)}
@@ -175,8 +188,6 @@ const directContainers = ['mp4', 'webm', 'm4v']
const directVideoCodecs = ['h264', 'avc', 'avc1']
const directAudioCodecs = ['aac', 'mp3', 'opus']
// pickMode picks 'direct' when the source is browser-friendly, otherwise
// 'hls' so the user gets a working stream out of the box.
function pickMode(m: Media): Mode {
const c = (m.container ?? '').toLowerCase()
const v = (m.video_codec ?? '').toLowerCase()
+114
View File
@@ -0,0 +1,114 @@
import { FormEvent, useState } from 'react'
import toast from 'react-hot-toast'
import { KeyRound, Save } from 'lucide-react'
import { authAPI } from '../api/auth'
import { profileAPI } from '../api/profile'
import { useAuthStore } from '../stores/auth'
export function ProfilePage() {
const user = useAuthStore((s) => s.user)
const setUser = useAuthStore((s) => s.setUser)
const [email, setEmail] = useState(user?.email ?? '')
const [avatar, setAvatar] = useState(user?.avatar_url ?? '')
const [oldPwd, setOldPwd] = useState('')
const [newPwd, setNewPwd] = useState('')
const onProfile = async (e: FormEvent) => {
e.preventDefault()
try {
const u = await profileAPI.update({ email, avatar_url: avatar })
setUser(u)
toast.success('资料已更新')
} catch {
toast.error('保存失败')
}
}
const onPwd = async (e: FormEvent) => {
e.preventDefault()
try {
await authAPI.changePassword(oldPwd, newPwd)
toast.success('密码已更新')
setOldPwd('')
setNewPwd('')
} catch (err: unknown) {
const msg =
(err as { response?: { data?: { error?: string } } })?.response?.data?.error ??
'密码更新失败'
toast.error(msg)
}
}
return (
<div className="space-y-6">
<h1 className="font-display text-3xl font-bold text-white">个人资料</h1>
<form onSubmit={onProfile} className="glass-panel space-y-4">
<h2 className="font-display text-lg font-semibold text-white">基本信息</h2>
<Field label="用户名">
<input className="input-base" value={user?.username ?? ''} disabled />
</Field>
<Field label="角色">
<input className="input-base" value={user?.role ?? ''} disabled />
</Field>
<Field label="电子邮箱">
<input
type="email"
className="input-base"
value={email}
onChange={(e) => setEmail(e.target.value)}
/>
</Field>
<Field label="头像 URL">
<input
className="input-base"
value={avatar}
onChange={(e) => setAvatar(e.target.value)}
/>
</Field>
<button type="submit" className="neon-button">
<Save size={16} /> 保存
</button>
</form>
<form onSubmit={onPwd} className="glass-panel space-y-4">
<h2 className="font-display text-lg font-semibold text-white">修改密码</h2>
<Field label="当前密码">
<input
required
type="password"
className="input-base"
value={oldPwd}
onChange={(e) => setOldPwd(e.target.value)}
autoComplete="current-password"
/>
</Field>
<Field label="新密码">
<input
required
type="password"
className="input-base"
minLength={6}
value={newPwd}
onChange={(e) => setNewPwd(e.target.value)}
autoComplete="new-password"
/>
</Field>
<button type="submit" className="neon-button">
<KeyRound size={16} /> 更新密码
</button>
</form>
</div>
)
}
function Field({ label, children }: { label: string; children: React.ReactNode }) {
return (
<label className="block">
<span className="mb-1 block text-sm text-slate-300">{label}</span>
{children}
</label>
)
}
+137
View File
@@ -0,0 +1,137 @@
import { useEffect, useState } from 'react'
import { Activity, Cpu, Database, Film, HardDrive, Users } from 'lucide-react'
import { statsAPI } from '../api/stats'
import { MediaCard } from '../components/MediaCard'
import type { StatsSnapshot } from '../types'
// fmtBytes is a tiny helper shared by the dashboard cards.
function fmtBytes(n: number): string {
if (!n || n <= 0) return '—'
const u = ['B', 'KB', 'MB', 'GB', 'TB', 'PB']
let v = n
let i = 0
while (v >= 1024 && i < u.length - 1) {
v /= 1024
i++
}
return `${v.toFixed(2)} ${u[i]}`
}
function fmtHours(seconds: number): string {
if (!seconds || seconds <= 0) return '—'
const h = Math.floor(seconds / 3600)
return `${h.toLocaleString()} h`
}
// StatsPage renders the operator dashboard. Refreshes every 10 s.
export function StatsPage() {
const [snap, setSnap] = useState<StatsSnapshot | null>(null)
const [loading, setLoading] = useState(true)
useEffect(() => {
let cancelled = false
const tick = () =>
statsAPI.snapshot().then((s) => {
if (!cancelled) setSnap(s)
})
tick().finally(() => setLoading(false))
const id = window.setInterval(tick, 10_000)
return () => {
cancelled = true
window.clearInterval(id)
}
}, [])
if (loading) return <p className="text-slate-500">加载中…</p>
if (!snap) return <p className="text-slate-500">无法获取统计数据</p>
const memPct =
snap.hardware.memory_total > 0
? (snap.hardware.memory_used / snap.hardware.memory_total) * 100
: 0
const diskPct =
snap.hardware.disk_total > 0
? (snap.hardware.disk_used / snap.hardware.disk_total) * 100
: 0
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">
快照时间:{new Date(snap.generated_at).toLocaleString()}
</p>
</header>
<section className="grid gap-4 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4">
<Tile icon={<Database size={20} />} label="媒体库" value={snap.libraries.toLocaleString()} />
<Tile icon={<Film size={20} />} label="媒体总数" value={snap.media_count.toLocaleString()} />
<Tile icon={<Users size={20} />} label="用户" value={snap.users_count.toLocaleString()} />
<Tile icon={<HardDrive size={20} />} label="入库容量" value={fmtBytes(snap.total_size_bytes)} />
<Tile icon={<Activity size={20} />} label="累计时长" value={fmtHours(snap.total_seconds)} />
<Tile icon={<Cpu size={20} />} label="CPU 占用" value={`${snap.hardware.cpu_percent.toFixed(1)}%`} />
<Tile icon={<Cpu size={20} />} label="内存占用" value={`${memPct.toFixed(1)}%`} />
<Tile icon={<HardDrive size={20} />} label="数据盘占用" value={`${diskPct.toFixed(1)}%`} />
</section>
<section className="space-y-3">
<h2 className="font-display text-xl font-semibold text-white">系统</h2>
<div className="glass-panel grid gap-2 text-sm">
<Row label="Go 运行时" value={snap.hardware.go_version} />
<Row label="Goroutines" value={snap.hardware.goroutines.toLocaleString()} />
<Row
label="内存"
value={`${fmtBytes(snap.hardware.memory_used)} / ${fmtBytes(snap.hardware.memory_total)}`}
/>
<Row
label="数据盘"
value={`${fmtBytes(snap.hardware.disk_used)} / ${fmtBytes(snap.hardware.disk_total)}`}
/>
</div>
</section>
{snap.recently_added.length > 0 && (
<section className="space-y-3">
<h2 className="font-display text-xl font-semibold text-white">最近入库</h2>
<div className="grid grid-cols-2 gap-4 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-6">
{snap.recently_added.map((m) => (
<MediaCard key={m.id} media={m} />
))}
</div>
</section>
)}
</div>
)
}
function Tile({
icon,
label,
value,
}: {
icon: React.ReactNode
label: string
value: string
}) {
return (
<div className="glass-panel flex items-center gap-3 !p-4">
<div className="rounded-lg border border-primary-400/40 bg-primary-400/10 p-2 text-primary-400">
{icon}
</div>
<div>
<p className="text-xs uppercase tracking-wider text-slate-500">{label}</p>
<p className="font-display text-lg font-semibold text-white">{value}</p>
</div>
</div>
)
}
function Row({ label, value }: { label: string; value: string }) {
return (
<div className="flex items-center justify-between border-b border-white/5 pb-1 text-sm last:border-0">
<span className="text-slate-400">{label}</span>
<span className="font-mono text-white">{value}</span>
</div>
)
}
+130
View File
@@ -0,0 +1,130 @@
import { FormEvent, useEffect, useState } from 'react'
import toast from 'react-hot-toast'
import { Play, Plus, Trash2 } from 'lucide-react'
import { subscriptionsAPI } from '../api/subscriptions'
import type { Subscription } from '../types'
export function SubscriptionsPage() {
const [items, setItems] = useState<Subscription[]>([])
const [name, setName] = useState('')
const [feed, setFeed] = useState('')
const [filter, setFilter] = useState('')
const [loading, setLoading] = useState(true)
const refresh = () =>
subscriptionsAPI
.list()
.then(setItems)
.finally(() => setLoading(false))
useEffect(() => {
refresh().catch(() => undefined)
}, [])
const onCreate = async (e: FormEvent) => {
e.preventDefault()
try {
await subscriptionsAPI.create({ name, feed_url: feed, filter })
toast.success('已创建订阅')
setName('')
setFeed('')
setFilter('')
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">
<h1 className="font-display text-3xl font-bold text-white">RSS 订阅</h1>
<p className="text-sm text-slate-400">
定期轮询 RSS 源(每 10 分钟一次),将匹配过滤器的项目自动加入下载队列。
</p>
<form onSubmit={onCreate} className="glass-panel grid gap-3 md:grid-cols-[1fr_1fr_1fr_auto]">
<input
required
className="input-base"
placeholder="名称(显示用)"
value={name}
onChange={(e) => setName(e.target.value)}
/>
<input
required
className="input-base"
placeholder="RSS 地址"
value={feed}
onChange={(e) => setFeed(e.target.value)}
/>
<input
className="input-base"
placeholder="过滤器(正则,可选)"
value={filter}
onChange={(e) => setFilter(e.target.value)}
/>
<button type="submit" className="neon-button">
<Plus size={16} /> 添加
</button>
</form>
{loading && <p className="text-slate-500">加载中…</p>}
{!loading && items.length === 0 && <p className="text-slate-400">暂无订阅。</p>}
{items.length > 0 && (
<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>RSS</th>
<th>过滤器</th>
<th>最近运行</th>
<th className="text-right">操作</th>
</tr>
</thead>
<tbody>
{items.map((s) => (
<tr key={s.id} className="border-t border-white/5">
<td className="py-2 text-white">{s.name}</td>
<td className="max-w-md truncate text-slate-300" title={s.feed_url}>
{s.feed_url}
</td>
<td className="text-slate-300">{s.filter || '—'}</td>
<td className="text-slate-500">
{s.last_run_at ? new Date(s.last_run_at).toLocaleString() : '—'}
</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 subscriptionsAPI.runNow(s.id)
toast.success(`已加入 ${r.queued} 项`)
}}
>
<Play size={12} />
</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(`删除订阅「${s.name}」?`)) return
await subscriptionsAPI.remove(s.id)
toast.success('已删除')
await refresh()
}}
>
<Trash2 size={12} />
</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
)
}
+58
View File
@@ -82,3 +82,61 @@ export interface AccessLog {
detail: string
created_at: string
}
export interface Subscription {
id: string
user_id: string
name: string
feed_url: string
filter: string
enabled: boolean
last_run_at?: string
created_at: string
updated_at: string
}
export interface DownloadTask {
id: string
user_id: string
source: string
url: string
save_path: string
status: string
progress: number
created_at: string
updated_at: string
}
export interface QBitTorrent {
hash: string
name: string
state: string
progress: number
dlspeed: number
upspeed: number
num_seeds: number
num_leechs: number
size: number
save_path: string
}
export interface Hardware {
cpu_percent: number
memory_used: number
memory_total: number
disk_used: number
disk_total: number
go_version: string
goroutines: number
}
export interface StatsSnapshot {
libraries: number
media_count: number
users_count: number
total_size_bytes: number
total_seconds: number
recently_added: Media[]
hardware: Hardware
generated_at: string
}