mirror of
https://github.com/truewhile/MeBox.git
synced 2026-10-09 06:46:37 +08:00
feat: downloads, RSS, subtitles, Bangumi, watcher, stats, profile, audit
Backend
- service/bangumi.go: Bangumi (bgm.tv) scraper for anime libraries.
- service/episode_parser.go: SxxExx / NxE / EPxx / 第NN集 parser with
unit tests; consumed by the scanner for tv/anime libraries.
- service/scraper.go: provider chain orchestrator picks Bangumi for
anime libraries (TMDb fallback), TMDb for everything else; emits
'no_match' rows so we don't retry forever; AnyEnabled() for the
scanner kick.
- service/scanner.go: writes season/episode numbers for tv/anime libs
and reports a 'probed' counter alongside 'added'.
- service/transcoder.go (existing): unchanged, stays per-media.
- service/subtitle.go: discovers external subtitles next to the source
file (.srt/.vtt/.ass/.ssa) and converts SRT/ASS to WebVTT on the fly.
- service/qbittorrent.go: thread-safe qBittorrent v2 Web UI client
(login / add / list / delete) with cookie-jar reuse.
- service/downloads.go: persists download tasks, reads runtime
qbittorrent.* settings, polls /torrents/info every 5 s and pushes
the result to WS subscribers ('download' topic).
- service/subscription.go: 10-minute RSS poller with regex filter,
GUID dedup persisted in the settings table, and 'subscription' WS
events on enqueue.
- service/watcher.go: fsnotify watcher with 5 s coalescing debouncer
that triggers per-library rescans on create/rename/remove.
- service/stats.go: dashboard snapshot — totals, recently added,
gopsutil-driven CPU/mem/disk readings.
- service/profile.go: non-credential profile patch + admin role mutator.
- service/audit.go: best-effort writer for the access_logs table.
- service/service.go: container wires every new service; Boot() spins
up watcher / downloads poller / subscription scheduler; Close()
tears them down on graceful shutdown.
Handlers
- new files: downloads.go, subscriptions.go, subtitles.go, series.go,
stats.go, profile.go, util.go.
- handler.go: registers PATCH /me, /libraries/:id/seasons,
/media/:id/subtitles, /subtitles/:id, /downloads*, /subscriptions*,
/stats, /admin/users/:id/role.
- auth.go / media.go: write audit rows for login + library CRUD and
refresh the watcher when libraries change.
Frontend
- api: new helpers for downloads, subscriptions, profile, series, stats,
subtitles; library helper gained scrape().
- hooks/useWebSocket.ts: shared connection with 3 s reconnect.
- components/GlobalEvents.tsx: surfaces scan / scrape / subscription
completion as toasts (mounted at app root).
- pages: Library now switches to a season-grouped layout for tv/anime
libraries; Player attaches WebVTT <track> elements; new pages for
Downloads (live torrent table), Subscriptions, Profile, Stats.
- components/Layout.tsx + App.tsx: sidebar groups (媒体库 / 自动化 /
账号 / 管理) and routes for the new pages; /stats and /admin remain
admin-only.
- types/index.ts: new types — Subscription, DownloadTask, QBitTorrent,
Hardware, StatsSnapshot.
Verified: go build, go vet, go test (incl. ParseEpisode + srtToVTT +
stripASSTags) all pass; tsc -b && vite build emits 17 route chunks plus
the deferred hls chunk; main bundle 247 KB / 83 KB gzipped.
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.
|
multi-arch Alpine Docker image.
|
||||||
|
|
||||||
The goal is to keep the user-facing feature surface familiar (libraries,
|
The goal is to keep the user-facing feature surface familiar (libraries,
|
||||||
scanning, scraping, direct play/HLS, multi-user, downloads) while making
|
scanning, scraping, direct play/HLS, multi-user, downloads, RSS) while
|
||||||
deployment painless on NAS hardware.
|
making deployment painless on NAS hardware.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Features (current scaffold)
|
## Features
|
||||||
|
|
||||||
- ✅ JWT authentication with admin/user roles
|
### Authentication & users
|
||||||
- ✅ First-run admin seeding (`admin / admin123`)
|
- ✅ 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
|
- ✅ Library CRUD + recursive filesystem scan
|
||||||
- ✅ ffprobe metadata extraction (duration / resolution / codecs)
|
- ✅ ffprobe metadata extraction (duration / resolution / codecs / container)
|
||||||
- ✅ TMDb scraper with image proxy (poster / backdrop / overview / rating)
|
- ✅ 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
|
- ✅ Direct-play streaming with HTTP `Range` support
|
||||||
- ✅ HLS on-demand transcoding (single ffmpeg job per media)
|
- ✅ HLS on-demand transcoding (single ffmpeg job per media)
|
||||||
- ✅ Playback history (resume) + Continue Watching row
|
- ✅ External subtitle discovery (.srt / .vtt / .ass / .ssa) with on-the-fly WebVTT conversion
|
||||||
- ✅ Favourites + Playlists (CRUD + ordered items)
|
- ✅ Resume position written every 10 s + Continue Watching row on home
|
||||||
- ✅ Real-time scan / scrape / transcode progress via WebSocket
|
- ✅ Favourites (toggle) + ordered Playlists (CRUD)
|
||||||
- ✅ React SPA with code-splitting: Login / Home / Library / Search /
|
|
||||||
Favourites / Playlists / Media detail / Player (HLS + direct) / Admin
|
### Automation
|
||||||
- ✅ Single-binary build, multi-arch Docker image, GitHub Actions CI
|
- ✅ 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
|
### Roadmap
|
||||||
|
|
||||||
| Area | Status |
|
| Area | Status |
|
||||||
|------|--------|
|
|------|--------|
|
||||||
| Bangumi / Douban / Fanart scraper providers | ⏳ |
|
| Hardware-accelerated transcoding (NVENC / QSV / VAAPI) profiles | ⏳ |
|
||||||
| Hardware-accelerated transcoding (NVENC / QSV / VAAPI) | ⏳ |
|
| Douban / Fanart / TheTVDB providers | ⏳ |
|
||||||
| qBittorrent / Transmission / RSS automation | ⏳ |
|
| Subtitle search (online providers) | ⏳ |
|
||||||
| Subtitles (extract / search / sync) | ⏳ |
|
|
||||||
| Emby/Jellyfin compatibility layer | ⏳ |
|
| Emby/Jellyfin compatibility layer | ⏳ |
|
||||||
| DLNA / Chromecast | ⏳ |
|
| DLNA / Chromecast | ⏳ |
|
||||||
| AI metadata enhancement & smart search | ⏳ |
|
| 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` <
|
Configuration is layered — defaults < `config.yaml` < `config/*.yaml` <
|
||||||
environment variables prefixed with `MEDIASTATION_`.
|
environment variables prefixed with `MEDIASTATION_`.
|
||||||
|
|
||||||
The most common keys:
|
### Most-used keys
|
||||||
|
|
||||||
| Key | Default | Purpose |
|
| Key | Default | Purpose |
|
||||||
|------|---------|---------|
|
|------|---------|---------|
|
||||||
@@ -104,9 +128,25 @@ The most common keys:
|
|||||||
| `MEDIASTATION_APP_WEB_DIR` | `./web/dist` | SPA bundle to serve |
|
| `MEDIASTATION_APP_WEB_DIR` | `./web/dist` | SPA bundle to serve |
|
||||||
| `MEDIASTATION_DATABASE_DB_PATH` | `./data/mediastation.db` | SQLite file |
|
| `MEDIASTATION_DATABASE_DB_PATH` | `./data/mediastation.db` | SQLite file |
|
||||||
| `MEDIASTATION_SECRETS_JWT_SECRET` | *(auto)* | JWT signing key |
|
| `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 |
|
| `MEDIASTATION_APP_CORS_ORIGINS` | *(empty)* | Allow-list, JSON array |
|
||||||
| `ADMIN_INITIAL_PASSWORD` | `admin123` | Bootstrap admin password |
|
| `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.
|
See [`config.example.yaml`](config.example.yaml) for the full surface.
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -121,13 +161,35 @@ MediaStationGo/
|
|||||||
│ ├── database/ GORM + SQLite (WAL) bootstrap
|
│ ├── database/ GORM + SQLite (WAL) bootstrap
|
||||||
│ ├── model/ GORM data models + AutoMigrate registry
|
│ ├── model/ GORM data models + AutoMigrate registry
|
||||||
│ ├── repository/ Thin data-access layer
|
│ ├── 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)
|
│ ├── middleware/ Gin middleware (CORS / JWT / admin)
|
||||||
│ └── handler/ HTTP route definitions
|
│ └── handler/ HTTP route definitions (one file per concern)
|
||||||
├── web/ React 18 + Vite SPA
|
├── web/ React 18 + Vite SPA
|
||||||
│ ├── src/api/ axios helpers
|
│ ├── src/api/ axios helpers (one per service)
|
||||||
│ ├── src/components/ Layout, MediaCard, RequireAuth, …
|
│ ├── src/components/ Layout, MediaCard, GlobalEvents, RequireAuth
|
||||||
│ ├── src/pages/ Home / Library / Search / Player / Admin
|
│ ├── src/hooks/ useWebSocket, …
|
||||||
|
│ ├── src/pages/ Home / Library / Search / Player / Downloads / …
|
||||||
│ ├── src/stores/ Zustand (auth)
|
│ ├── src/stores/ Zustand (auth)
|
||||||
│ └── src/types/ Domain types mirrored from Go
|
│ └── src/types/ Domain types mirrored from Go
|
||||||
├── Dockerfile Multi-stage, multi-arch build
|
├── Dockerfile Multi-stage, multi-arch build
|
||||||
|
|||||||
@@ -76,6 +76,7 @@ func main() {
|
|||||||
if err := services.Auth.SeedAdmin(context.Background()); err != nil {
|
if err := services.Auth.SeedAdmin(context.Background()); err != nil {
|
||||||
logger.Warn("seed admin failed", zap.Error(err))
|
logger.Warn("seed admin failed", zap.Error(err))
|
||||||
}
|
}
|
||||||
|
services.Boot()
|
||||||
|
|
||||||
router := buildRouter(cfg, logger, services)
|
router := buildRouter(cfg, logger, services)
|
||||||
|
|
||||||
|
|||||||
@@ -37,6 +37,8 @@ secrets:
|
|||||||
# persisted to {data_dir}/.jwt_secret so existing sessions survive restarts.
|
# persisted to {data_dir}/.jwt_secret so existing sessions survive restarts.
|
||||||
jwt_secret: ""
|
jwt_secret: ""
|
||||||
tmdb_api_key: ""
|
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: ""
|
bangumi_access_token: ""
|
||||||
thetvdb_api_key: ""
|
thetvdb_api_key: ""
|
||||||
fanart_tv_api_key: ""
|
fanart_tv_api_key: ""
|
||||||
|
|||||||
@@ -3,11 +3,13 @@ module github.com/ShukeBta/MediaStationGo
|
|||||||
go 1.25.0
|
go 1.25.0
|
||||||
|
|
||||||
require (
|
require (
|
||||||
|
github.com/fsnotify/fsnotify v1.7.0
|
||||||
github.com/gin-gonic/gin v1.9.1
|
github.com/gin-gonic/gin v1.9.1
|
||||||
github.com/glebarez/sqlite v1.11.0
|
github.com/glebarez/sqlite v1.11.0
|
||||||
github.com/golang-jwt/jwt/v5 v5.2.0
|
github.com/golang-jwt/jwt/v5 v5.2.0
|
||||||
github.com/google/uuid v1.6.0
|
github.com/google/uuid v1.6.0
|
||||||
github.com/gorilla/websocket v1.5.3
|
github.com/gorilla/websocket v1.5.3
|
||||||
|
github.com/shirou/gopsutil/v3 v3.24.5
|
||||||
github.com/spf13/viper v1.18.2
|
github.com/spf13/viper v1.18.2
|
||||||
go.uber.org/zap v1.27.0
|
go.uber.org/zap v1.27.0
|
||||||
golang.org/x/crypto v0.21.0
|
golang.org/x/crypto v0.21.0
|
||||||
@@ -18,38 +20,42 @@ require (
|
|||||||
github.com/bytedance/sonic v1.9.1 // indirect
|
github.com/bytedance/sonic v1.9.1 // indirect
|
||||||
github.com/chenzhuoyu/base64x v0.0.0-20221115062448-fe3a3abad311 // indirect
|
github.com/chenzhuoyu/base64x v0.0.0-20221115062448-fe3a3abad311 // indirect
|
||||||
github.com/dustin/go-humanize v1.0.1 // 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/gabriel-vasile/mimetype v1.4.2 // indirect
|
||||||
github.com/gin-contrib/sse v0.1.0 // indirect
|
github.com/gin-contrib/sse v0.1.0 // indirect
|
||||||
github.com/glebarez/go-sqlite v1.21.2 // 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/locales v0.14.1 // indirect
|
||||||
github.com/go-playground/universal-translator v0.18.1 // indirect
|
github.com/go-playground/universal-translator v0.18.1 // indirect
|
||||||
github.com/go-playground/validator/v10 v10.14.0 // indirect
|
github.com/go-playground/validator/v10 v10.14.0 // indirect
|
||||||
github.com/goccy/go-json v0.10.2 // 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/hashicorp/hcl v1.0.0 // indirect
|
||||||
github.com/jinzhu/inflection v1.0.0 // indirect
|
github.com/jinzhu/inflection v1.0.0 // indirect
|
||||||
github.com/jinzhu/now v1.1.5 // indirect
|
github.com/jinzhu/now v1.1.5 // indirect
|
||||||
github.com/json-iterator/go v1.1.12 // indirect
|
github.com/json-iterator/go v1.1.12 // indirect
|
||||||
github.com/klauspost/cpuid/v2 v2.2.4 // indirect
|
github.com/klauspost/cpuid/v2 v2.2.4 // indirect
|
||||||
github.com/leodido/go-urn v1.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/magiconair/properties v1.8.7 // indirect
|
||||||
github.com/mattn/go-isatty v0.0.19 // indirect
|
github.com/mattn/go-isatty v0.0.19 // indirect
|
||||||
github.com/mitchellh/mapstructure v1.5.0 // indirect
|
github.com/mitchellh/mapstructure v1.5.0 // indirect
|
||||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
|
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
|
||||||
github.com/modern-go/reflect2 v1.0.2 // indirect
|
github.com/modern-go/reflect2 v1.0.2 // indirect
|
||||||
github.com/pelletier/go-toml/v2 v2.1.0 // 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/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
||||||
github.com/sagikazarmark/locafero v0.4.0 // indirect
|
github.com/sagikazarmark/locafero v0.4.0 // indirect
|
||||||
github.com/sagikazarmark/slog-shim v0.1.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/sourcegraph/conc v0.3.0 // indirect
|
||||||
github.com/spf13/afero v1.11.0 // indirect
|
github.com/spf13/afero v1.11.0 // indirect
|
||||||
github.com/spf13/cast v1.6.0 // indirect
|
github.com/spf13/cast v1.6.0 // indirect
|
||||||
github.com/spf13/pflag v1.0.5 // 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/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/twitchyliquid64/golang-asm v0.15.1 // indirect
|
||||||
github.com/ugorji/go/codec v1.2.11 // 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
|
go.uber.org/multierr v1.10.0 // indirect
|
||||||
golang.org/x/arch v0.3.0 // indirect
|
golang.org/x/arch v0.3.0 // indirect
|
||||||
golang.org/x/exp v0.0.0-20230905200255-921286631fa9 // 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/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 h1:wSG0irqzP6VurnMEpFGer5Li19RpIRi2qvQz++w0GMw=
|
||||||
github.com/glebarez/sqlite v1.11.0/go.mod h1:h8/o8j5wiAsqSPoWELDUdJXhjAhsVliSn7bWZjOhrgQ=
|
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 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
|
||||||
github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
|
github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
|
||||||
github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
|
github.com/go-playground/locales v0.14.1 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-jwt/jwt/v5 v5.2.0/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
|
||||||
github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk=
|
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.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 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
|
||||||
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
|
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
|
||||||
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
|
github.com/google/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/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 h1:XlAE/cm/ms7TE/VMVoduSpNBoyc2dOxHs5MZSwAN63Q=
|
||||||
github.com/leodido/go-urn v1.2.4/go.mod h1:7ZrI8mTSeBSHl/UaRyKQW1qZeMgak41ANeCNaVckg+4=
|
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 h1:IeQXZAiQcpL9mgcAe1Nu6cX9LLw6ExEHKjN0VQdvPDY=
|
||||||
github.com/magiconair/properties v1.8.7/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0=
|
github.com/magiconair/properties v1.8.7/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0=
|
||||||
github.com/mattn/go-isatty v0.0.19 h1:JITubQf0MOLdlGRuRq+jtsDlekdYPia9ZFsB8h/APPA=
|
github.com/mattn/go-isatty v0.0.19 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.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 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
|
||||||
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
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-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 h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
|
||||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
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/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 h1:diDBnUNK9N/354PgrxMywXnAwEr1QZcOr6gto+ugjYE=
|
||||||
github.com/sagikazarmark/slog-shim v0.1.0/go.mod h1:SrcSrq8aKtyuqEI1uvTDTK1arOWRIczQRv+GVI1AkeQ=
|
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 h1:OQTbbt6P72L20UqAkXXuLOj79LfEanQ+YQFNpLA9ySo=
|
||||||
github.com/sourcegraph/conc v0.3.0/go.mod h1:Sdozi7LEKbFPqYX2/J+iBAM6HpqSLTASQIKqDmF7Mt0=
|
github.com/sourcegraph/conc v0.3.0/go.mod h1:Sdozi7LEKbFPqYX2/J+iBAM6HpqSLTASQIKqDmF7Mt0=
|
||||||
github.com/spf13/afero v1.11.0 h1:WJQKhtpdm3v2IzqG8VMqrr6Rf3UYpEF239Jy9wNepM8=
|
github.com/spf13/afero v1.11.0 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/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 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8=
|
||||||
github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU=
|
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 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
|
||||||
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
|
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 h1:BMaWp1Bb6fHwEtbplGBGJ498wD+LKlNSl25MjdZY4dU=
|
||||||
github.com/ugorji/go/codec v1.2.11/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg=
|
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 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
|
||||||
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
|
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
|
||||||
go.uber.org/multierr v1.10.0 h1:S0h4aNzvfcFsC3dRF1jLoaov7oRaKqRGC/pUEJ2yvPQ=
|
go.uber.org/multierr v1.10.0 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/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 h1:AQyQV4dYCvJ7vGmJyKki9+PBdyvhkSd8EIx/qb0AYv4=
|
||||||
golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44=
|
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.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.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 h1:Od9JTbYCk261bKm4M/mw7AklTlFYIa0bIp9BgSm1S8Y=
|
||||||
golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||||
golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ=
|
golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ=
|
||||||
|
|||||||
@@ -41,6 +41,7 @@ func loginHandler(svc *service.Container) gin.HandlerFunc {
|
|||||||
"token": token,
|
"token": token,
|
||||||
"user": u,
|
"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.Use(middleware.AuthRequired(cfg.Secrets.JWTSecret))
|
||||||
{
|
{
|
||||||
authed.GET("/me", meHandler(svc))
|
authed.GET("/me", meHandler(svc))
|
||||||
|
authed.PATCH("/me", updateProfileHandler(svc))
|
||||||
authed.POST("/me/password", changePasswordHandler(svc))
|
authed.POST("/me/password", changePasswordHandler(svc))
|
||||||
|
|
||||||
|
// Libraries.
|
||||||
authed.GET("/libraries", listLibrariesHandler(svc))
|
authed.GET("/libraries", listLibrariesHandler(svc))
|
||||||
authed.POST("/libraries", middleware.AdminRequired(), createLibraryHandler(svc))
|
authed.POST("/libraries", middleware.AdminRequired(), createLibraryHandler(svc))
|
||||||
authed.DELETE("/libraries/:id", middleware.AdminRequired(), deleteLibraryHandler(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.POST("/libraries/:id/scrape", middleware.AdminRequired(), scrapeLibraryHandler(svc))
|
||||||
|
|
||||||
authed.GET("/libraries/:id/media", listMediaHandler(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/:id", getMediaHandler(svc))
|
||||||
authed.GET("/media", searchMediaHandler(svc))
|
authed.GET("/media", searchMediaHandler(svc))
|
||||||
authed.POST("/media/:id/scrape", middleware.AdminRequired(), scrapeOneHandler(svc))
|
authed.POST("/media/:id/scrape", middleware.AdminRequired(), scrapeOneHandler(svc))
|
||||||
authed.POST("/media/:id/probe", middleware.AdminRequired(), reprobeHandler(svc))
|
authed.POST("/media/:id/probe", middleware.AdminRequired(), reprobeHandler(svc))
|
||||||
|
authed.GET("/media/:id/subtitles", listSubtitlesHandler(svc))
|
||||||
|
authed.GET("/subtitles/:id", serveSubtitleHandler(svc))
|
||||||
|
|
||||||
// Streaming.
|
// Streaming.
|
||||||
authed.GET("/stream/:id", streamHandler(svc))
|
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/items/:media_id", removePlaylistItemHandler(svc))
|
||||||
authed.DELETE("/playlists/:id", deletePlaylistHandler(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))
|
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.Use(middleware.AuthRequired(cfg.Secrets.JWTSecret), middleware.AdminRequired())
|
||||||
{
|
{
|
||||||
admin.GET("/users", listUsersHandler(svc))
|
admin.GET("/users", listUsersHandler(svc))
|
||||||
|
admin.PATCH("/users/:id/role", adminUpdateRoleHandler(svc))
|
||||||
admin.DELETE("/users/:id", deleteUserHandler(svc))
|
admin.DELETE("/users/:id", deleteUserHandler(svc))
|
||||||
admin.GET("/settings", listSettingsHandler(svc))
|
admin.GET("/settings", listSettingsHandler(svc))
|
||||||
admin.PUT("/settings", updateSettingHandler(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()})
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
return
|
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)
|
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()})
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
return
|
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)
|
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
|
// ScannerService walks the configured library roots looking for video
|
||||||
// files, then upserts a model.Media row per file. Each upsert also runs
|
// 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
|
// For TV / anime libraries we extract season + episode numbers from the
|
||||||
// publishes WebSocket progress events on the "scan" topic so the React
|
// filename via ParseEpisode and store them on the Media row. A future
|
||||||
// UI can render a live counter / spinner.
|
// pass groups episodes into Series rows; the current scaffold lets the
|
||||||
|
// frontend group by `series_id`.
|
||||||
package service
|
package service
|
||||||
|
|
||||||
import (
|
import (
|
||||||
@@ -73,15 +75,6 @@ type ScanResult struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ScanLibrary walks the library root and persists discovered media files.
|
// 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) {
|
func (s *ScannerService) ScanLibrary(ctx context.Context, libraryID string) (*ScanResult, error) {
|
||||||
lib, err := s.repo.Library.FindByID(ctx, libraryID)
|
lib, err := s.repo.Library.FindByID(ctx, libraryID)
|
||||||
if err != nil || lib == nil {
|
if err != nil || lib == nil {
|
||||||
@@ -113,6 +106,13 @@ func (s *ScannerService) ScanLibrary(ctx context.Context, libraryID string) (*Sc
|
|||||||
Container: strings.TrimPrefix(ext, "."),
|
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.
|
// Best-effort ffprobe; failure does not abort the file.
|
||||||
if s.probe != nil {
|
if s.probe != nil {
|
||||||
if probe, err := s.probe.Probe(ctx, path); err == nil && 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,
|
"probed": res.Probed,
|
||||||
})
|
})
|
||||||
|
|
||||||
// Fire-and-forget metadata enrichment when a TMDb key is configured.
|
// Fire-and-forget metadata enrichment when at least one provider is
|
||||||
if s.scraper != nil && s.scraper.tmdb != nil && s.scraper.tmdb.Enabled() {
|
// configured.
|
||||||
|
if s.scraper != nil && s.scraper.AnyEnabled() {
|
||||||
go func(libID string) {
|
go func(libID string) {
|
||||||
if _, err := s.scraper.EnrichLibrary(context.Background(), libID); err != nil {
|
if _, err := s.scraper.EnrichLibrary(context.Background(), libID); err != nil {
|
||||||
s.log.Warn("scraper enrich failed", zap.Error(err))
|
s.log.Warn("scraper enrich failed", zap.Error(err))
|
||||||
|
|||||||
+95
-35
@@ -1,12 +1,15 @@
|
|||||||
// Package service — scraper orchestrator.
|
// Package service — scraper orchestrator.
|
||||||
//
|
//
|
||||||
// ScraperService takes a Media row and tries to enrich it with metadata
|
// 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
|
// from one or more providers. Selection is driven by the library type:
|
||||||
// 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.
|
|
||||||
//
|
//
|
||||||
// The orchestrator is deliberately stateless: it loops media → provider →
|
// library.type == "anime" -> Bangumi (fallback: TMDb)
|
||||||
// repository, publishing scrape progress events to the WS hub.
|
// 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
|
package service
|
||||||
|
|
||||||
import (
|
import (
|
||||||
@@ -26,23 +29,33 @@ import (
|
|||||||
|
|
||||||
// ScraperService coordinates metadata enrichment across providers.
|
// ScraperService coordinates metadata enrichment across providers.
|
||||||
type ScraperService struct {
|
type ScraperService struct {
|
||||||
cfg *config.Config
|
cfg *config.Config
|
||||||
log *zap.Logger
|
log *zap.Logger
|
||||||
repo *repository.Container
|
repo *repository.Container
|
||||||
tmdb *TMDbProvider
|
tmdb *TMDbProvider
|
||||||
hub *Hub
|
bangumi *BangumiProvider
|
||||||
|
hub *Hub
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewScraperService is the constructor.
|
// NewScraperService is the constructor.
|
||||||
func NewScraperService(cfg *config.Config, log *zap.Logger, repo *repository.Container, tmdb *TMDbProvider, hub *Hub) *ScraperService {
|
func NewScraperService(
|
||||||
return &ScraperService{cfg: cfg, log: log, repo: repo, tmdb: tmdb, hub: hub}
|
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]|$)`)
|
var yearPattern = regexp.MustCompile(`(?:^|[^\d])(19\d{2}|20\d{2})(?:[^\d]|$)`)
|
||||||
|
|
||||||
// noiseTokens are aggressively stripped from filenames before search.
|
// noiseTokens are stripped before search.
|
||||||
// Keep in sync with nowen-video's filename_parser.go intent.
|
|
||||||
var noiseTokens = []string{
|
var noiseTokens = []string{
|
||||||
"1080p", "2160p", "4k", "720p", "480p",
|
"1080p", "2160p", "4k", "720p", "480p",
|
||||||
"hdrip", "bluray", "blu-ray", "webrip", "web-dl", "web",
|
"hdrip", "bluray", "blu-ray", "webrip", "web-dl", "web",
|
||||||
@@ -52,8 +65,7 @@ var noiseTokens = []string{
|
|||||||
"hkfree", "yify", "rarbg", "ettv", "fgt",
|
"hkfree", "yify", "rarbg", "ettv", "fgt",
|
||||||
}
|
}
|
||||||
|
|
||||||
// bracketedTag matches "[anything]" or "(anything)" segments, which are
|
// bracketedTag matches "[anything]" or "(anything)" segments.
|
||||||
// almost always release-group / encoder tags in scene filenames.
|
|
||||||
var bracketedTag = regexp.MustCompile(`[\[\(][^\]\)]*[\]\)]`)
|
var bracketedTag = regexp.MustCompile(`[\[\(][^\]\)]*[\]\)]`)
|
||||||
|
|
||||||
// CleanQuery converts a filename like "Inception.2010.1080p.BluRay.x264.mkv"
|
// 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, " ")
|
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 {
|
for _, t := range noiseTokens {
|
||||||
lower = strings.ReplaceAll(lower, t, " ")
|
lower = strings.ReplaceAll(lower, t, " ")
|
||||||
}
|
}
|
||||||
// collapse separators / spaces
|
|
||||||
for _, sep := range []string{".", "_", "-", "[", "]", "(", ")"} {
|
for _, sep := range []string{".", "_", "-", "[", "]", "(", ")"} {
|
||||||
lower = strings.ReplaceAll(lower, sep, " ")
|
lower = strings.ReplaceAll(lower, sep, " ")
|
||||||
}
|
}
|
||||||
@@ -85,11 +102,15 @@ func CleanQuery(raw string) (title string, year int) {
|
|||||||
return strings.TrimSpace(title), year
|
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 {
|
func (s *ScraperService) EnrichOne(ctx context.Context, m *model.Media) error {
|
||||||
if s.tmdb == nil || !s.tmdb.Enabled() {
|
lib, err := s.repo.Library.FindByID(ctx, m.LibraryID)
|
||||||
return nil
|
if err != nil {
|
||||||
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
query := m.Title
|
query := m.Title
|
||||||
if query == "" {
|
if query == "" {
|
||||||
query, _ = CleanQuery(m.Path)
|
query, _ = CleanQuery(m.Path)
|
||||||
@@ -100,10 +121,15 @@ func (s *ScraperService) EnrichOne(ctx context.Context, m *model.Media) error {
|
|||||||
if year == 0 {
|
if year == 0 {
|
||||||
_, year = CleanQuery(filepath.Base(m.Path))
|
_, year = CleanQuery(filepath.Base(m.Path))
|
||||||
}
|
}
|
||||||
match, err := s.tmdb.SearchMovie(ctx, query, year)
|
|
||||||
if err != nil || match == nil {
|
match := s.lookup(ctx, lib, query, year)
|
||||||
return err
|
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{
|
updates := map[string]any{
|
||||||
"title": match.Title,
|
"title": match.Title,
|
||||||
"overview": match.Overview,
|
"overview": match.Overview,
|
||||||
@@ -111,28 +137,51 @@ func (s *ScraperService) EnrichOne(ctx context.Context, m *model.Media) error {
|
|||||||
"backdrop_url": match.BackdropURL,
|
"backdrop_url": match.BackdropURL,
|
||||||
"rating": match.Rating,
|
"rating": match.Rating,
|
||||||
"year": match.Year,
|
"year": match.Year,
|
||||||
"tmdb_id": match.TMDbID,
|
|
||||||
"scrape_status": "matched",
|
"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).
|
if err := s.repo.DB.Model(&model.Media{}).Where("id = ?", m.ID).
|
||||||
Updates(updates).Error; err != nil {
|
Updates(updates).Error; err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
s.hub.Publish("scrape", map[string]any{
|
s.hub.Publish("scrape", map[string]any{
|
||||||
"media_id": m.ID,
|
"media_id": m.ID,
|
||||||
"title": match.Title,
|
"title": match.Title,
|
||||||
"tmdb_id": match.TMDbID,
|
"tmdb_id": match.TMDbID,
|
||||||
|
"bangumi_id": match.BangumiID,
|
||||||
})
|
})
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// EnrichLibrary runs the provider chain for every "pending" media in a
|
// lookup runs the provider chain. When the library is missing we fall
|
||||||
// library. It throttles to 4 RPS to stay below TMDb's rate limit and
|
// back to TMDb only.
|
||||||
// publishes a summary event when done.
|
func (s *ScraperService) lookup(ctx context.Context, lib *model.Library, query string, year int) *Match {
|
||||||
func (s *ScraperService) EnrichLibrary(ctx context.Context, libraryID string) (int, error) {
|
kind := ""
|
||||||
if s.tmdb == nil || !s.tmdb.Enabled() {
|
if lib != nil {
|
||||||
return 0, 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
|
var rows []model.Media
|
||||||
q := s.repo.DB.Where("scrape_status = ?", "pending")
|
q := s.repo.DB.Where("scrape_status = ?", "pending")
|
||||||
if libraryID != "" {
|
if libraryID != "" {
|
||||||
@@ -162,3 +211,14 @@ func (s *ScraperService) EnrichLibrary(ctx context.Context, libraryID string) (i
|
|||||||
})
|
})
|
||||||
return matched, nil
|
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
|
package service
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
|
|
||||||
"go.uber.org/zap"
|
"go.uber.org/zap"
|
||||||
|
|
||||||
"github.com/ShukeBta/MediaStationGo/internal/config"
|
"github.com/ShukeBta/MediaStationGo/internal/config"
|
||||||
@@ -14,20 +16,31 @@ import (
|
|||||||
// Container holds every service initialized at startup. Handlers receive a
|
// Container holds every service initialized at startup. Handlers receive a
|
||||||
// pointer to it and pick the relevant fields.
|
// pointer to it and pick the relevant fields.
|
||||||
type Container struct {
|
type Container struct {
|
||||||
Cfg *config.Config
|
Cfg *config.Config
|
||||||
Log *zap.Logger
|
Log *zap.Logger
|
||||||
Repo *repository.Container
|
Repo *repository.Container
|
||||||
WSHub *Hub
|
WSHub *Hub
|
||||||
Auth *AuthService
|
Auth *AuthService
|
||||||
Media *MediaService
|
Media *MediaService
|
||||||
Scan *ScannerService
|
Scan *ScannerService
|
||||||
Stream *StreamService
|
Stream *StreamService
|
||||||
Transcoder *TranscoderService
|
Transcoder *TranscoderService
|
||||||
FFprobe *FFprobeService
|
FFprobe *FFprobeService
|
||||||
TMDb *TMDbProvider
|
TMDb *TMDbProvider
|
||||||
Scraper *ScraperService
|
Bangumi *BangumiProvider
|
||||||
Playback *PlaybackService
|
Scraper *ScraperService
|
||||||
ImageProxy *ImageProxy
|
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.
|
// 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)
|
probe := NewFFprobeService(cfg, log)
|
||||||
tmdb := NewTMDbProvider(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)
|
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{
|
return &Container{
|
||||||
Cfg: cfg,
|
Cfg: cfg,
|
||||||
Log: log,
|
Log: log,
|
||||||
Repo: repos,
|
Repo: repos,
|
||||||
WSHub: hub,
|
WSHub: hub,
|
||||||
Auth: NewAuthService(cfg, log, repos),
|
Auth: NewAuthService(cfg, log, repos),
|
||||||
Media: NewMediaService(cfg, log, repos),
|
Media: NewMediaService(cfg, log, repos),
|
||||||
Scan: NewScannerService(cfg, log, repos, hub, probe, scraper),
|
Scan: scanner,
|
||||||
Stream: NewStreamService(cfg, log, repos, transcoder),
|
Stream: NewStreamService(cfg, log, repos, transcoder),
|
||||||
Transcoder: transcoder,
|
Transcoder: transcoder,
|
||||||
FFprobe: probe,
|
FFprobe: probe,
|
||||||
TMDb: tmdb,
|
TMDb: tmdb,
|
||||||
Scraper: scraper,
|
Bangumi: bangumi,
|
||||||
Playback: NewPlaybackService(log, repos),
|
Scraper: scraper,
|
||||||
ImageProxy: NewImageProxy(cfg, log),
|
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
|
// Close releases any resources held by services (websocket hub, ffmpeg
|
||||||
// transcodes).
|
// transcodes, fsnotify, background pollers).
|
||||||
func (c *Container) Close() {
|
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 {
|
if c.Transcoder != nil {
|
||||||
c.Transcoder.StopAll()
|
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.
|
// Enabled reports whether the operator has supplied an API key.
|
||||||
func (t *TMDbProvider) Enabled() bool { return t.cfg.Secrets.TMDbAPIKey != "" }
|
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 {
|
type Match struct {
|
||||||
TMDbID int `json:"tmdb_id"`
|
TMDbID int `json:"tmdb_id"`
|
||||||
|
BangumiID int `json:"bangumi_id"`
|
||||||
Title string `json:"title"`
|
Title string `json:"title"`
|
||||||
Overview string `json:"overview"`
|
Overview string `json:"overview"`
|
||||||
PosterURL string `json:"poster_url"`
|
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 })),
|
import('./pages/PlayerPage').then((m) => ({ default: m.PlayerPage })),
|
||||||
)
|
)
|
||||||
const AdminPage = lazy(() => import('./pages/AdminPage').then((m) => ({ default: m.AdminPage })))
|
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.
|
// Fallback shown while a chunk is loading.
|
||||||
const Loading = () => <p className="px-6 py-8 text-slate-500">加载中…</p>
|
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="playlist/:id" element={<PlaylistDetailPage />} />
|
||||||
<Route path="media/:id" element={<MediaDetailPage />} />
|
<Route path="media/:id" element={<MediaDetailPage />} />
|
||||||
<Route path="play/:id" element={<PlayerPage />} />
|
<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
|
<Route
|
||||||
path="admin"
|
path="admin"
|
||||||
element={
|
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) =>
|
scan: (id: string) =>
|
||||||
api.post<ScanResult>(`/libraries/${id}/scan`).then((r) => r.data),
|
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) =>
|
listMedia: (id: string, page = 1, pageSize = 50) =>
|
||||||
api
|
api
|
||||||
.get<MediaPage>(`/libraries/${id}/media`, {
|
.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 { useEffect, useState } from 'react'
|
||||||
import { Link, NavLink, Outlet, useNavigate } from 'react-router-dom'
|
import { Link, NavLink, Outlet, useNavigate } from 'react-router-dom'
|
||||||
import {
|
import {
|
||||||
|
Activity,
|
||||||
|
CloudDownload,
|
||||||
Film,
|
Film,
|
||||||
Heart,
|
Heart,
|
||||||
Home,
|
Home,
|
||||||
ListMusic,
|
ListMusic,
|
||||||
LogOut,
|
LogOut,
|
||||||
|
Rss,
|
||||||
Search,
|
Search,
|
||||||
Settings,
|
Settings,
|
||||||
Library as LibraryIcon,
|
Library as LibraryIcon,
|
||||||
|
User as UserIcon,
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
import clsx from 'clsx'
|
import clsx from 'clsx'
|
||||||
|
|
||||||
@@ -42,7 +46,7 @@ export function Layout() {
|
|||||||
</span>
|
</span>
|
||||||
</Link>
|
</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="/" icon={<Home size={18} />} label="首页" end />
|
||||||
<SidebarLink to="/search" icon={<Search size={18} />} label="搜索" />
|
<SidebarLink to="/search" icon={<Search size={18} />} label="搜索" />
|
||||||
<SidebarLink to="/favourites" icon={<Heart 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' && (
|
{user?.role === 'admin' && (
|
||||||
<>
|
<>
|
||||||
<div className="mt-6 px-2 text-xs uppercase tracking-wider text-slate-500">
|
<div className="mt-6 px-2 text-xs uppercase tracking-wider text-slate-500">
|
||||||
管理
|
管理
|
||||||
</div>
|
</div>
|
||||||
|
<SidebarLink to="/stats" icon={<Activity size={18} />} label="运行状态" />
|
||||||
<SidebarLink to="/admin" icon={<Settings 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 { Toaster } from 'react-hot-toast'
|
||||||
|
|
||||||
import App from './App'
|
import App from './App'
|
||||||
|
import { GlobalEvents } from './components/GlobalEvents'
|
||||||
import './index.css'
|
import './index.css'
|
||||||
|
|
||||||
// Application root: BrowserRouter + global toast container.
|
// Application root: BrowserRouter + global toast container.
|
||||||
ReactDOM.createRoot(document.getElementById('root') as HTMLElement).render(
|
ReactDOM.createRoot(document.getElementById('root') as HTMLElement).render(
|
||||||
<React.StrictMode>
|
<React.StrictMode>
|
||||||
<BrowserRouter>
|
<BrowserRouter>
|
||||||
|
<GlobalEvents />
|
||||||
<App />
|
<App />
|
||||||
<Toaster
|
<Toaster
|
||||||
position="top-right"
|
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 { useParams } from 'react-router-dom'
|
||||||
import toast from 'react-hot-toast'
|
import toast from 'react-hot-toast'
|
||||||
|
|
||||||
import { libraryAPI } from '../api/library'
|
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 { MediaCard } from '../components/MediaCard'
|
||||||
import { useAuthStore } from '../stores/auth'
|
import { useAuthStore } from '../stores/auth'
|
||||||
|
|
||||||
// Browse a single library. Admins also get a "scan" button that triggers
|
// LibraryPage renders one of two layouts depending on the library type:
|
||||||
// a fresh scan run on the backend.
|
//
|
||||||
|
// - 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() {
|
export function LibraryPage() {
|
||||||
const { id = '' } = useParams()
|
const { id = '' } = useParams()
|
||||||
const role = useAuthStore((s) => s.user?.role)
|
const role = useAuthStore((s) => s.user?.role)
|
||||||
|
|
||||||
|
const [library, setLibrary] = useState<Library | null>(null)
|
||||||
const [items, setItems] = useState<Media[]>([])
|
const [items, setItems] = useState<Media[]>([])
|
||||||
|
const [seasons, setSeasons] = useState<SeasonGroup[]>([])
|
||||||
const [total, setTotal] = useState(0)
|
const [total, setTotal] = useState(0)
|
||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
const [scanning, setScanning] = useState(false)
|
const [scanning, setScanning] = useState(false)
|
||||||
|
const [scraping, setScraping] = useState(false)
|
||||||
|
|
||||||
|
const isSeries = library?.type === 'tv' || library?.type === 'anime'
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!id) return
|
if (!id) return
|
||||||
setLoading(true)
|
setLoading(true)
|
||||||
libraryAPI
|
Promise.all([
|
||||||
.listMedia(id)
|
libraryAPI.list().then((all) => all.find((l) => l.id === id) ?? null),
|
||||||
.then((d) => {
|
]).then(([lib]) => setLibrary(lib))
|
||||||
setItems(d.items)
|
|
||||||
setTotal(d.total)
|
|
||||||
})
|
|
||||||
.finally(() => setLoading(false))
|
|
||||||
}, [id])
|
}, [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 () => {
|
const handleScan = async () => {
|
||||||
setScanning(true)
|
setScanning(true)
|
||||||
try {
|
try {
|
||||||
const r = await libraryAPI.scan(id)
|
const r = await libraryAPI.scan(id)
|
||||||
toast.success(`扫描完成:新增 ${r.added} 个文件`)
|
toast.success(`扫描完成:新增 ${r.added} 项,探测 ${r.probed} 项`)
|
||||||
const d = await libraryAPI.listMedia(id)
|
// Force a reload by toggling library state.
|
||||||
setItems(d.items)
|
setLibrary((l) => (l ? { ...l } : l))
|
||||||
setTotal(d.total)
|
|
||||||
} catch {
|
} catch {
|
||||||
toast.error('扫描失败')
|
toast.error('扫描失败')
|
||||||
} finally {
|
} 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 (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||||
<h1 className="font-display text-3xl font-bold text-white">
|
<div>
|
||||||
媒体库 <span className="text-slate-500">({total})</span>
|
<h1 className="font-display text-3xl font-bold text-white">
|
||||||
</h1>
|
{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' && (
|
{role === 'admin' && (
|
||||||
<button onClick={handleScan} disabled={scanning} className="neon-button">
|
<div className="flex gap-2">
|
||||||
{scanning ? '扫描中…' : '立即扫描'}
|
<button onClick={handleScan} disabled={scanning} className="neon-button">
|
||||||
</button>
|
{scanning ? '扫描中…' : '立即扫描'}
|
||||||
|
</button>
|
||||||
|
<button onClick={handleScrape} disabled={scraping} className="neon-button">
|
||||||
|
{scraping ? '刮削中…' : '刮削元数据'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{loading && <p className="text-slate-500">加载中…</p>}
|
{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) => (
|
{!isSeries && (
|
||||||
<MediaCard key={m.id} media={m} />
|
<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>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import toast from 'react-hot-toast'
|
|||||||
import { mediaAPI } from '../api/library'
|
import { mediaAPI } from '../api/library'
|
||||||
import { hlsURL, streamURL } from '../api/client'
|
import { hlsURL, streamURL } from '../api/client'
|
||||||
import { playbackAPI } from '../api/playback'
|
import { playbackAPI } from '../api/playback'
|
||||||
|
import { subtitlesAPI, type SubtitleTrack } from '../api/subtitles'
|
||||||
import type { Media } from '../types'
|
import type { Media } from '../types'
|
||||||
|
|
||||||
type Mode = 'direct' | 'hls'
|
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
|
// We pick a sensible default based on the source codec: H.264 + AAC in
|
||||||
// MP4 / WebM containers play directly; everything else (HEVC, MKV, AV1,
|
// MP4 / WebM containers play directly; everything else (HEVC, MKV, AV1,
|
||||||
// AC3 audio, …) gets routed through ffmpeg → HLS.
|
// 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() {
|
export function PlayerPage() {
|
||||||
const { id = '' } = useParams()
|
const { id = '' } = useParams()
|
||||||
const [params, setParams] = useSearchParams()
|
const [params, setParams] = useSearchParams()
|
||||||
@@ -30,6 +34,7 @@ export function PlayerPage() {
|
|||||||
|
|
||||||
const [media, setMedia] = useState<Media | null>(null)
|
const [media, setMedia] = useState<Media | null>(null)
|
||||||
const [mode, setMode] = useState<Mode>('direct')
|
const [mode, setMode] = useState<Mode>('direct')
|
||||||
|
const [subs, setSubs] = useState<SubtitleTrack[]>([])
|
||||||
|
|
||||||
// Load metadata and pick a default mode.
|
// Load metadata and pick a default mode.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -40,6 +45,10 @@ export function PlayerPage() {
|
|||||||
const auto = pickMode(m)
|
const auto = pickMode(m)
|
||||||
setMode(forced ?? auto)
|
setMode(forced ?? auto)
|
||||||
})
|
})
|
||||||
|
subtitlesAPI
|
||||||
|
.list(id)
|
||||||
|
.then(setSubs)
|
||||||
|
.catch(() => setSubs([]))
|
||||||
}, [id, params])
|
}, [id, params])
|
||||||
|
|
||||||
// Wire up the actual <video> element when we know the mode.
|
// Wire up the actual <video> element when we know the mode.
|
||||||
@@ -48,14 +57,9 @@ export function PlayerPage() {
|
|||||||
teardownHls()
|
teardownHls()
|
||||||
|
|
||||||
const video = ref.current
|
const video = ref.current
|
||||||
let cancelled = false
|
|
||||||
|
|
||||||
if (mode === 'hls') {
|
if (mode === 'hls') {
|
||||||
const url = hlsURL(media.id)
|
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 }) => {
|
void import('hls.js').then(({ default: HlsCtor }) => {
|
||||||
if (cancelled) return
|
|
||||||
if (HlsCtor.isSupported()) {
|
if (HlsCtor.isSupported()) {
|
||||||
const hls = new HlsCtor({ enableWorker: true, lowLatencyMode: false })
|
const hls = new HlsCtor({ enableWorker: true, lowLatencyMode: false })
|
||||||
hls.loadSource(url)
|
hls.loadSource(url)
|
||||||
@@ -81,10 +85,7 @@ export function PlayerPage() {
|
|||||||
video.src = streamURL(media.id)
|
video.src = streamURL(media.id)
|
||||||
void video.play().catch(() => undefined)
|
void video.play().catch(() => undefined)
|
||||||
}
|
}
|
||||||
return () => {
|
return teardownHls
|
||||||
cancelled = true
|
|
||||||
teardownHls()
|
|
||||||
}
|
|
||||||
}, [media, mode, params, setParams])
|
}, [media, mode, params, setParams])
|
||||||
|
|
||||||
// Persist resume position every 10 seconds while playing.
|
// Persist resume position every 10 seconds while playing.
|
||||||
@@ -161,8 +162,20 @@ export function PlayerPage() {
|
|||||||
ref={ref}
|
ref={ref}
|
||||||
controls
|
controls
|
||||||
autoPlay
|
autoPlay
|
||||||
|
crossOrigin="use-credentials"
|
||||||
className="max-h-screen w-full max-w-[1600px] bg-black"
|
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>
|
<p className="text-slate-500">加载中…</p>
|
||||||
)}
|
)}
|
||||||
@@ -175,8 +188,6 @@ const directContainers = ['mp4', 'webm', 'm4v']
|
|||||||
const directVideoCodecs = ['h264', 'avc', 'avc1']
|
const directVideoCodecs = ['h264', 'avc', 'avc1']
|
||||||
const directAudioCodecs = ['aac', 'mp3', 'opus']
|
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 {
|
function pickMode(m: Media): Mode {
|
||||||
const c = (m.container ?? '').toLowerCase()
|
const c = (m.container ?? '').toLowerCase()
|
||||||
const v = (m.video_codec ?? '').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
|
detail: string
|
||||||
created_at: 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