mirror of
https://github.com/truewhile/MeBox.git
synced 2026-09-28 11:16:37 +08:00
Merge pull request #2 from ShukeBta/kiro/iter3-comprehensive
feat: downloads, RSS, subtitles, Bangumi, watcher, stats, profile, audit
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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: ""
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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=
|
||||
|
||||
@@ -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(), "")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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})
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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})
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 ""
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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})
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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, "")
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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"`
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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={
|
||||
|
||||
@@ -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),
|
||||
}
|
||||
@@ -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`, {
|
||||
|
||||
@@ -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),
|
||||
}
|
||||
@@ -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),
|
||||
}
|
||||
@@ -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),
|
||||
}
|
||||
@@ -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),
|
||||
}
|
||||
@@ -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)}`
|
||||
},
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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="管理后台" />
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -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])
|
||||
}
|
||||
@@ -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"
|
||||
|
||||
@@ -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
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user