diff --git a/README.md b/README.md index 919e55c..e8a47a5 100644 --- a/README.md +++ b/README.md @@ -24,35 +24,59 @@ deployment model used by [`cropflre/nowen-video`](https://github.com/cropflre/no multi-arch Alpine Docker image. The goal is to keep the user-facing feature surface familiar (libraries, -scanning, scraping, direct play/HLS, multi-user, downloads) while making -deployment painless on NAS hardware. +scanning, scraping, direct play/HLS, multi-user, downloads, RSS) while +making deployment painless on NAS hardware. --- -## Features (current scaffold) +## Features -- ✅ JWT authentication with admin/user roles -- ✅ First-run admin seeding (`admin / admin123`) +### Authentication & users +- ✅ JWT auth with admin/user roles +- ✅ First-run admin seeding (`admin / admin123`, override via `ADMIN_INITIAL_PASSWORD`) +- ✅ Profile page (email / avatar / change password) +- ✅ Admin user table with role promotion / demotion +- ✅ Audit log written for sensitive actions (login, library CRUD, downloads, …) + +### Library management - ✅ Library CRUD + recursive filesystem scan -- ✅ ffprobe metadata extraction (duration / resolution / codecs) -- ✅ TMDb scraper with image proxy (poster / backdrop / overview / rating) +- ✅ ffprobe metadata extraction (duration / resolution / codecs / container) +- ✅ Scene-noise filename cleaner with year + season/episode parsing +- ✅ TMDb scraper (movies) + Bangumi scraper (anime), provider chain by library type +- ✅ Image proxy with disk cache for TMDb / Bangumi / Douban / Fanart / TheTVDB +- ✅ TV / anime libraries grouped by season with episode listing +- ✅ fsnotify-based filesystem watcher with 5 s coalescing debouncer + +### Playback - ✅ Direct-play streaming with HTTP `Range` support - ✅ HLS on-demand transcoding (single ffmpeg job per media) -- ✅ Playback history (resume) + Continue Watching row -- ✅ Favourites + Playlists (CRUD + ordered items) -- ✅ Real-time scan / scrape / transcode progress via WebSocket -- ✅ React SPA with code-splitting: Login / Home / Library / Search / - Favourites / Playlists / Media detail / Player (HLS + direct) / Admin -- ✅ Single-binary build, multi-arch Docker image, GitHub Actions CI +- ✅ External subtitle discovery (.srt / .vtt / .ass / .ssa) with on-the-fly WebVTT conversion +- ✅ Resume position written every 10 s + Continue Watching row on home +- ✅ Favourites (toggle) + ordered Playlists (CRUD) + +### Automation +- ✅ qBittorrent download integration (add / list / delete via Web UI API) +- ✅ RSS subscriptions with regex filters, GUID dedup and 10-minute polling + +### Operations +- ✅ Real-time scan / scrape / transcode / download / subscription events over WebSocket +- ✅ Operator dashboard at `/stats` (CPU / memory / disk / library counts / Goroutines) +- ✅ Single-binary build, multi-arch Docker image, GitHub Actions CI + GHCR publish + +### Frontend +- ✅ React SPA with code-splitting: Login / Home / Library / Search / Favourites / + Playlists / Media detail / Player (HLS + direct + subtitles) / Profile / + Downloads / Subscriptions / Stats / Admin +- ✅ Global toast notifications driven by the WebSocket hub +- ✅ Initial bundle ~250 KB / 83 KB gzipped (hls.js loaded only on first HLS playback) ### Roadmap | Area | Status | |------|--------| -| Bangumi / Douban / Fanart scraper providers | ⏳ | -| Hardware-accelerated transcoding (NVENC / QSV / VAAPI) | ⏳ | -| qBittorrent / Transmission / RSS automation | ⏳ | -| Subtitles (extract / search / sync) | ⏳ | +| Hardware-accelerated transcoding (NVENC / QSV / VAAPI) profiles | ⏳ | +| Douban / Fanart / TheTVDB providers | ⏳ | +| Subtitle search (online providers) | ⏳ | | Emby/Jellyfin compatibility layer | ⏳ | | DLNA / Chromecast | ⏳ | | AI metadata enhancement & smart search | ⏳ | @@ -95,7 +119,7 @@ make dev-web # vite dev server on :3000, proxies /api -> :8080 Configuration is layered — defaults < `config.yaml` < `config/*.yaml` < environment variables prefixed with `MEDIASTATION_`. -The most common keys: +### Most-used keys | Key | Default | Purpose | |------|---------|---------| @@ -104,9 +128,25 @@ The most common keys: | `MEDIASTATION_APP_WEB_DIR` | `./web/dist` | SPA bundle to serve | | `MEDIASTATION_DATABASE_DB_PATH` | `./data/mediastation.db` | SQLite file | | `MEDIASTATION_SECRETS_JWT_SECRET` | *(auto)* | JWT signing key | +| `MEDIASTATION_SECRETS_TMDB_API_KEY` | *(empty)* | Enables movie scraping | +| `MEDIASTATION_SECRETS_BANGUMI_ACCESS_TOKEN` | *(empty)* | Optional, raises Bangumi rate limit | | `MEDIASTATION_APP_CORS_ORIGINS` | *(empty)* | Allow-list, JSON array | | `ADMIN_INITIAL_PASSWORD` | `admin123` | Bootstrap admin password | +### Runtime settings (admin → 设置) + +These live in the `settings` table and can be edited from the admin UI: + +| Key | Purpose | +|-----|---------| +| `qbittorrent.url` | qBittorrent Web UI base URL | +| `qbittorrent.username` | qBittorrent user | +| `qbittorrent.password` | qBittorrent password | +| `qbittorrent.savepath` | Optional default save path for new torrents | + +After editing, hit **下载 → 重新加载配置** (or `POST /api/downloads/reload`) so +the qBittorrent client picks up the new credentials. + See [`config.example.yaml`](config.example.yaml) for the full surface. --- @@ -121,13 +161,35 @@ MediaStationGo/ │ ├── database/ GORM + SQLite (WAL) bootstrap │ ├── model/ GORM data models + AutoMigrate registry │ ├── repository/ Thin data-access layer -│ ├── service/ Business logic (auth/media/scan/stream/ws) +│ ├── service/ Business logic +│ │ ├── auth.go login / register / JWT / seed admin +│ │ ├── media.go library + media CRUD +│ │ ├── scanner.go fs walker + ffprobe + scrape kick +│ │ ├── ffprobe.go ffprobe wrapper +│ │ ├── tmdb.go TMDb provider +│ │ ├── bangumi.go Bangumi provider +│ │ ├── scraper.go orchestrator + filename cleaner +│ │ ├── stream.go direct play + HLS playlist / segment +│ │ ├── transcoder.go per-media ffmpeg HLS job manager +│ │ ├── subtitle.go external subtitle discovery + .vtt conversion +│ │ ├── image_proxy.go cached, allow-listed image proxy +│ │ ├── playback.go history / favourites / playlists +│ │ ├── watcher.go fsnotify debouncer +│ │ ├── qbittorrent.go qBittorrent v2 API client +│ │ ├── downloads.go download orchestrator + WS poller +│ │ ├── subscription.go RSS poller +│ │ ├── stats.go dashboard snapshot +│ │ ├── profile.go non-credential user mutations +│ │ ├── audit.go audit log writer +│ │ ├── ws_hub.go pub/sub broker for the WS +│ │ └── walk.go / episode_parser.go helpers │ ├── middleware/ Gin middleware (CORS / JWT / admin) -│ └── handler/ HTTP route definitions +│ └── handler/ HTTP route definitions (one file per concern) ├── web/ React 18 + Vite SPA -│ ├── src/api/ axios helpers -│ ├── src/components/ Layout, MediaCard, RequireAuth, … -│ ├── src/pages/ Home / Library / Search / Player / Admin +│ ├── src/api/ axios helpers (one per service) +│ ├── src/components/ Layout, MediaCard, GlobalEvents, RequireAuth +│ ├── src/hooks/ useWebSocket, … +│ ├── src/pages/ Home / Library / Search / Player / Downloads / … │ ├── src/stores/ Zustand (auth) │ └── src/types/ Domain types mirrored from Go ├── Dockerfile Multi-stage, multi-arch build diff --git a/cmd/server/main.go b/cmd/server/main.go index 44cd6cb..b7374e1 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -76,6 +76,7 @@ func main() { if err := services.Auth.SeedAdmin(context.Background()); err != nil { logger.Warn("seed admin failed", zap.Error(err)) } + services.Boot() router := buildRouter(cfg, logger, services) diff --git a/config.example.yaml b/config.example.yaml index ffb64d6..0a61efe 100644 --- a/config.example.yaml +++ b/config.example.yaml @@ -37,6 +37,8 @@ secrets: # persisted to {data_dir}/.jwt_secret so existing sessions survive restarts. jwt_secret: "" tmdb_api_key: "" + tmdb_api_proxy: "" # e.g. https://api.tmdb-mirror.example + tmdb_image_proxy: "" # e.g. https://image.tmdb-mirror.example/t/p bangumi_access_token: "" thetvdb_api_key: "" fanart_tv_api_key: "" diff --git a/go.mod b/go.mod index 445fbbc..822f296 100644 --- a/go.mod +++ b/go.mod @@ -3,11 +3,13 @@ module github.com/ShukeBta/MediaStationGo go 1.25.0 require ( + github.com/fsnotify/fsnotify v1.7.0 github.com/gin-gonic/gin v1.9.1 github.com/glebarez/sqlite v1.11.0 github.com/golang-jwt/jwt/v5 v5.2.0 github.com/google/uuid v1.6.0 github.com/gorilla/websocket v1.5.3 + github.com/shirou/gopsutil/v3 v3.24.5 github.com/spf13/viper v1.18.2 go.uber.org/zap v1.27.0 golang.org/x/crypto v0.21.0 @@ -18,38 +20,42 @@ require ( github.com/bytedance/sonic v1.9.1 // indirect github.com/chenzhuoyu/base64x v0.0.0-20221115062448-fe3a3abad311 // indirect github.com/dustin/go-humanize v1.0.1 // indirect - github.com/fsnotify/fsnotify v1.7.0 // indirect github.com/gabriel-vasile/mimetype v1.4.2 // indirect github.com/gin-contrib/sse v0.1.0 // indirect github.com/glebarez/go-sqlite v1.21.2 // indirect + github.com/go-ole/go-ole v1.2.6 // indirect github.com/go-playground/locales v0.14.1 // indirect github.com/go-playground/universal-translator v0.18.1 // indirect github.com/go-playground/validator/v10 v10.14.0 // indirect github.com/goccy/go-json v0.10.2 // indirect - github.com/google/go-cmp v0.6.0 // indirect github.com/hashicorp/hcl v1.0.0 // indirect github.com/jinzhu/inflection v1.0.0 // indirect github.com/jinzhu/now v1.1.5 // indirect github.com/json-iterator/go v1.1.12 // indirect github.com/klauspost/cpuid/v2 v2.2.4 // indirect github.com/leodido/go-urn v1.2.4 // indirect + github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 // indirect github.com/magiconair/properties v1.8.7 // indirect github.com/mattn/go-isatty v0.0.19 // indirect github.com/mitchellh/mapstructure v1.5.0 // indirect github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect github.com/modern-go/reflect2 v1.0.2 // indirect github.com/pelletier/go-toml/v2 v2.1.0 // indirect + github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c // indirect github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect github.com/sagikazarmark/locafero v0.4.0 // indirect github.com/sagikazarmark/slog-shim v0.1.0 // indirect + github.com/shoenig/go-m1cpu v0.1.6 // indirect github.com/sourcegraph/conc v0.3.0 // indirect github.com/spf13/afero v1.11.0 // indirect github.com/spf13/cast v1.6.0 // indirect github.com/spf13/pflag v1.0.5 // indirect - github.com/stretchr/testify v1.9.0 // indirect github.com/subosito/gotenv v1.6.0 // indirect + github.com/tklauser/go-sysconf v0.3.12 // indirect + github.com/tklauser/numcpus v0.6.1 // indirect github.com/twitchyliquid64/golang-asm v0.15.1 // indirect github.com/ugorji/go/codec v1.2.11 // indirect + github.com/yusufpapurcu/wmi v1.2.4 // indirect go.uber.org/multierr v1.10.0 // indirect golang.org/x/arch v0.3.0 // indirect golang.org/x/exp v0.0.0-20230905200255-921286631fa9 // indirect diff --git a/go.sum b/go.sum index c4d0064..11f96cf 100644 --- a/go.sum +++ b/go.sum @@ -24,6 +24,8 @@ github.com/glebarez/go-sqlite v1.21.2 h1:3a6LFC4sKahUunAmynQKLZceZCOzUthkRkEAl9g github.com/glebarez/go-sqlite v1.21.2/go.mod h1:sfxdZyhQjTM2Wry3gVYWaW072Ri1WMdWJi0k6+3382k= github.com/glebarez/sqlite v1.11.0 h1:wSG0irqzP6VurnMEpFGer5Li19RpIRi2qvQz++w0GMw= github.com/glebarez/sqlite v1.11.0/go.mod h1:h8/o8j5wiAsqSPoWELDUdJXhjAhsVliSn7bWZjOhrgQ= +github.com/go-ole/go-ole v1.2.6 h1:/Fpf6oFPoeFik9ty7siob0G6Ke8QvQEuVcuChpwXzpY= +github.com/go-ole/go-ole v1.2.6/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0= github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s= github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4= github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA= @@ -38,6 +40,7 @@ github.com/golang-jwt/jwt/v5 v5.2.0 h1:d/ix8ftRUorsN+5eMIlF4T6J8CAt9rch3My2winC1 github.com/golang-jwt/jwt/v5 v5.2.0/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk= github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk= github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.5.6/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= @@ -64,6 +67,8 @@ github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= github.com/leodido/go-urn v1.2.4 h1:XlAE/cm/ms7TE/VMVoduSpNBoyc2dOxHs5MZSwAN63Q= github.com/leodido/go-urn v1.2.4/go.mod h1:7ZrI8mTSeBSHl/UaRyKQW1qZeMgak41ANeCNaVckg+4= +github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 h1:6E+4a0GO5zZEnZ81pIr0yLvtUWk2if982qA3F3QD6H4= +github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0/go.mod h1:zJYVVT2jmtg6P3p1VtQj7WsuWi/y4VnjVBn7F8KPB3I= github.com/magiconair/properties v1.8.7 h1:IeQXZAiQcpL9mgcAe1Nu6cX9LLw6ExEHKjN0VQdvPDY= github.com/magiconair/properties v1.8.7/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0= github.com/mattn/go-isatty v0.0.19 h1:JITubQf0MOLdlGRuRq+jtsDlekdYPia9ZFsB8h/APPA= @@ -80,6 +85,8 @@ github.com/pelletier/go-toml/v2 v2.1.0/go.mod h1:tJU2Z3ZkXwnxa4DPO899bsyIoywizdU github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c h1:ncq/mPwQF4JjgDlrVEn3C11VoGHZN7m8qihwgMEtzYw= +github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c/go.mod h1:OmDBASR4679mdNQnz2pUhc2G8CO2JrUAVFDRBDP/hJE= github.com/remyoudompheng/bigfft v0.0.0-20200410134404-eec4a21b6bb0/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= @@ -89,6 +96,12 @@ github.com/sagikazarmark/locafero v0.4.0 h1:HApY1R9zGo4DBgr7dqsTH/JJxLTTsOt7u6ke github.com/sagikazarmark/locafero v0.4.0/go.mod h1:Pe1W6UlPYUk/+wc/6KFhbORCfqzgYEpgQ3O5fPuL3H4= github.com/sagikazarmark/slog-shim v0.1.0 h1:diDBnUNK9N/354PgrxMywXnAwEr1QZcOr6gto+ugjYE= github.com/sagikazarmark/slog-shim v0.1.0/go.mod h1:SrcSrq8aKtyuqEI1uvTDTK1arOWRIczQRv+GVI1AkeQ= +github.com/shirou/gopsutil/v3 v3.24.5 h1:i0t8kL+kQTvpAYToeuiVk3TgDeKOFioZO3Ztz/iZ9pI= +github.com/shirou/gopsutil/v3 v3.24.5/go.mod h1:bsoOS1aStSs9ErQ1WWfxllSeS1K5D+U30r2NfcubMVk= +github.com/shoenig/go-m1cpu v0.1.6 h1:nxdKQNcEB6vzgA2E2bvzKIYRuNj7XNJ4S/aRSwKzFtM= +github.com/shoenig/go-m1cpu v0.1.6/go.mod h1:1JJMcUBvfNwpq05QDQVAnx3gUHr9IYF7GNg9SUEw2VQ= +github.com/shoenig/test v0.6.4 h1:kVTaSd7WLz5WZ2IaoM0RSzRsUD+m8wRR+5qvntpn4LU= +github.com/shoenig/test v0.6.4/go.mod h1:byHiCGXqrVaflBLAMq/srcZIHynQPQgeyvkvXnjqq0k= github.com/sourcegraph/conc v0.3.0 h1:OQTbbt6P72L20UqAkXXuLOj79LfEanQ+YQFNpLA9ySo= github.com/sourcegraph/conc v0.3.0/go.mod h1:Sdozi7LEKbFPqYX2/J+iBAM6HpqSLTASQIKqDmF7Mt0= github.com/spf13/afero v1.11.0 h1:WJQKhtpdm3v2IzqG8VMqrr6Rf3UYpEF239Jy9wNepM8= @@ -113,10 +126,16 @@ github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsT github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8= github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU= +github.com/tklauser/go-sysconf v0.3.12 h1:0QaGUFOdQaIVdPgfITYzaTegZvdCjmYO52cSFAEVmqU= +github.com/tklauser/go-sysconf v0.3.12/go.mod h1:Ho14jnntGE1fpdOqQEEaiKRpvIavV0hSfmBq8nJbHYI= +github.com/tklauser/numcpus v0.6.1 h1:ng9scYS7az0Bk4OZLvrNXNSAO2Pxr1XXRAPyjhIx+Fk= +github.com/tklauser/numcpus v0.6.1/go.mod h1:1XfjsgE2zo8GVw7POkMbHENHzVg3GzmoZ9fESEdAacY= github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI= github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08= github.com/ugorji/go/codec v1.2.11 h1:BMaWp1Bb6fHwEtbplGBGJ498wD+LKlNSl25MjdZY4dU= github.com/ugorji/go/codec v1.2.11/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg= +github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0= +github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0= go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= go.uber.org/multierr v1.10.0 h1:S0h4aNzvfcFsC3dRF1jLoaov7oRaKqRGC/pUEJ2yvPQ= @@ -132,8 +151,12 @@ golang.org/x/exp v0.0.0-20230905200255-921286631fa9 h1:GoHiUyI/Tp2nVkLI2mCxVkOjs golang.org/x/exp v0.0.0-20230905200255-921286631fa9/go.mod h1:S2oDrQGGwySpoQPVqRShND87VCbxmc6bL1Yd2oYrm6k= golang.org/x/net v0.21.0 h1:AQyQV4dYCvJ7vGmJyKki9+PBdyvhkSd8EIx/qb0AYv4= golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44= +golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20201204225414-ed752295db88/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20220704084225-05e143d24a9e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.11.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.20.0 h1:Od9JTbYCk261bKm4M/mw7AklTlFYIa0bIp9BgSm1S8Y= golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ= diff --git a/internal/handler/auth.go b/internal/handler/auth.go index 4fdab54..620972a 100644 --- a/internal/handler/auth.go +++ b/internal/handler/auth.go @@ -41,6 +41,7 @@ func loginHandler(svc *service.Container) gin.HandlerFunc { "token": token, "user": u, }) + svc.Audit.Record(c.Request.Context(), u.ID, "auth.login", u.Username, c.ClientIP(), "") } } diff --git a/internal/handler/downloads.go b/internal/handler/downloads.go new file mode 100644 index 0000000..1034abe --- /dev/null +++ b/internal/handler/downloads.go @@ -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) + } +} diff --git a/internal/handler/handler.go b/internal/handler/handler.go index 783c4fa..be92ab6 100644 --- a/internal/handler/handler.go +++ b/internal/handler/handler.go @@ -32,8 +32,10 @@ func Register(r *gin.Engine, cfg *config.Config, log *zap.Logger, svc *service.C authed.Use(middleware.AuthRequired(cfg.Secrets.JWTSecret)) { authed.GET("/me", meHandler(svc)) + authed.PATCH("/me", updateProfileHandler(svc)) authed.POST("/me/password", changePasswordHandler(svc)) + // Libraries. authed.GET("/libraries", listLibrariesHandler(svc)) authed.POST("/libraries", middleware.AdminRequired(), createLibraryHandler(svc)) authed.DELETE("/libraries/:id", middleware.AdminRequired(), deleteLibraryHandler(svc)) @@ -41,10 +43,15 @@ func Register(r *gin.Engine, cfg *config.Config, log *zap.Logger, svc *service.C authed.POST("/libraries/:id/scrape", middleware.AdminRequired(), scrapeLibraryHandler(svc)) authed.GET("/libraries/:id/media", listMediaHandler(svc)) + authed.GET("/libraries/:id/seasons", listSeasonsHandler(svc)) + + // Media. authed.GET("/media/:id", getMediaHandler(svc)) authed.GET("/media", searchMediaHandler(svc)) authed.POST("/media/:id/scrape", middleware.AdminRequired(), scrapeOneHandler(svc)) authed.POST("/media/:id/probe", middleware.AdminRequired(), reprobeHandler(svc)) + authed.GET("/media/:id/subtitles", listSubtitlesHandler(svc)) + authed.GET("/subtitles/:id", serveSubtitleHandler(svc)) // Streaming. authed.GET("/stream/:id", streamHandler(svc)) @@ -69,6 +76,21 @@ func Register(r *gin.Engine, cfg *config.Config, log *zap.Logger, svc *service.C authed.DELETE("/playlists/:id/items/:media_id", removePlaylistItemHandler(svc)) authed.DELETE("/playlists/:id", deletePlaylistHandler(svc)) + // Downloads. + authed.GET("/downloads", listDownloadsHandler(svc)) + authed.POST("/downloads", addDownloadHandler(svc)) + authed.DELETE("/downloads/:hash", middleware.AdminRequired(), deleteDownloadHandler(svc)) + authed.POST("/downloads/reload", middleware.AdminRequired(), reloadDownloadConfigHandler(svc)) + + // Subscriptions. + authed.GET("/subscriptions", listSubscriptionsHandler(svc)) + authed.POST("/subscriptions", createSubscriptionHandler(svc)) + authed.DELETE("/subscriptions/:id", deleteSubscriptionHandler(svc)) + authed.POST("/subscriptions/:id/run", runSubscriptionHandler(svc)) + + // Stats / dashboard. + authed.GET("/stats", statsHandler(svc)) + authed.GET("/ws", wsHandler(svc)) } @@ -77,6 +99,7 @@ func Register(r *gin.Engine, cfg *config.Config, log *zap.Logger, svc *service.C admin.Use(middleware.AuthRequired(cfg.Secrets.JWTSecret), middleware.AdminRequired()) { admin.GET("/users", listUsersHandler(svc)) + admin.PATCH("/users/:id/role", adminUpdateRoleHandler(svc)) admin.DELETE("/users/:id", deleteUserHandler(svc)) admin.GET("/settings", listSettingsHandler(svc)) admin.PUT("/settings", updateSettingHandler(svc)) diff --git a/internal/handler/media.go b/internal/handler/media.go index 25f71be..f0659b7 100644 --- a/internal/handler/media.go +++ b/internal/handler/media.go @@ -40,6 +40,10 @@ func createLibraryHandler(svc *service.Container) gin.HandlerFunc { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } + uid, _ := c.Get("ctx_user_id") + svc.Audit.Record(c.Request.Context(), toString(uid), "library.create", l.ID, c.ClientIP(), l.Path) + // Refresh fsnotify watcher to pick up the new library root. + go func() { _ = svc.Watcher.Refresh(c.Request.Context()) }() c.JSON(http.StatusOK, l) } } @@ -51,6 +55,9 @@ func deleteLibraryHandler(svc *service.Container) gin.HandlerFunc { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } + uid, _ := c.Get("ctx_user_id") + svc.Audit.Record(c.Request.Context(), toString(uid), "library.delete", id, c.ClientIP(), "") + go func() { _ = svc.Watcher.Refresh(c.Request.Context()) }() c.Status(http.StatusNoContent) } } diff --git a/internal/handler/profile.go b/internal/handler/profile.go new file mode 100644 index 0000000..1099602 --- /dev/null +++ b/internal/handler/profile.go @@ -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) + } +} diff --git a/internal/handler/series.go b/internal/handler/series.go new file mode 100644 index 0000000..c63ab56 --- /dev/null +++ b/internal/handler/series.go @@ -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}) + } +} diff --git a/internal/handler/stats.go b/internal/handler/stats.go new file mode 100644 index 0000000..9d3665d --- /dev/null +++ b/internal/handler/stats.go @@ -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) + } +} diff --git a/internal/handler/subscriptions.go b/internal/handler/subscriptions.go new file mode 100644 index 0000000..3d24c58 --- /dev/null +++ b/internal/handler/subscriptions.go @@ -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}) + } +} diff --git a/internal/handler/subtitles.go b/internal/handler/subtitles.go new file mode 100644 index 0000000..94a5a58 --- /dev/null +++ b/internal/handler/subtitles.go @@ -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 + } + } +} diff --git a/internal/handler/util.go b/internal/handler/util.go new file mode 100644 index 0000000..4b8dce6 --- /dev/null +++ b/internal/handler/util.go @@ -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 "" +} diff --git a/internal/service/audit.go b/internal/service/audit.go new file mode 100644 index 0000000..80a80f8 --- /dev/null +++ b/internal/service/audit.go @@ -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)) + } +} diff --git a/internal/service/bangumi.go b/internal/service/bangumi.go new file mode 100644 index 0000000..12c2a36 --- /dev/null +++ b/internal/service/bangumi.go @@ -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) +} diff --git a/internal/service/downloads.go b/internal/service/downloads.go new file mode 100644 index 0000000..c51c9a5 --- /dev/null +++ b/internal/service/downloads.go @@ -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}) + } +} diff --git a/internal/service/episode_parser.go b/internal/service/episode_parser.go new file mode 100644 index 0000000..9772a91 --- /dev/null +++ b/internal/service/episode_parser.go @@ -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 +} diff --git a/internal/service/episode_parser_test.go b/internal/service/episode_parser_test.go new file mode 100644 index 0000000..5d491b4 --- /dev/null +++ b/internal/service/episode_parser_test.go @@ -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) + } + }) + } +} diff --git a/internal/service/profile.go b/internal/service/profile.go new file mode 100644 index 0000000..a7ffc37 --- /dev/null +++ b/internal/service/profile.go @@ -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) +} diff --git a/internal/service/qbittorrent.go b/internal/service/qbittorrent.go new file mode 100644 index 0000000..2af8321 --- /dev/null +++ b/internal/service/qbittorrent.go @@ -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) +} diff --git a/internal/service/scanner.go b/internal/service/scanner.go index db20c13..8aba30c 100644 --- a/internal/service/scanner.go +++ b/internal/service/scanner.go @@ -2,11 +2,13 @@ // // ScannerService walks the configured library roots looking for video // files, then upserts a model.Media row per file. Each upsert also runs -// ffprobe (when available) and queues a TMDb lookup for newly added rows. +// ffprobe (when available) and queues a metadata lookup for newly added +// rows. // -// The scan is synchronous from the HTTP layer's point of view, but it -// publishes WebSocket progress events on the "scan" topic so the React -// UI can render a live counter / spinner. +// For TV / anime libraries we extract season + episode numbers from the +// filename via ParseEpisode and store them on the Media row. A future +// pass groups episodes into Series rows; the current scaffold lets the +// frontend group by `series_id`. package service import ( @@ -73,15 +75,6 @@ type ScanResult struct { } // ScanLibrary walks the library root and persists discovered media files. -// -// Workflow per file: -// 1. fast filename-based title cleanup. -// 2. ffprobe → duration / resolution / codecs (best effort). -// 3. upsert into the media table. -// 4. publish progress over the WS hub. -// -// After the walk we kick off the TMDb scraper for every still-pending -// row in the same library. The scraper has its own throttle. func (s *ScannerService) ScanLibrary(ctx context.Context, libraryID string) (*ScanResult, error) { lib, err := s.repo.Library.FindByID(ctx, libraryID) if err != nil || lib == nil { @@ -113,6 +106,13 @@ func (s *ScannerService) ScanLibrary(ctx context.Context, libraryID string) (*Sc Container: strings.TrimPrefix(ext, "."), } + // Detect season/episode for TV / anime libraries. + if lib.Type == "tv" || lib.Type == "anime" { + s, e := ParseEpisode(path) + m.SeasonNum = s + m.EpisodeNum = e + } + // Best-effort ffprobe; failure does not abort the file. if s.probe != nil { if probe, err := s.probe.Probe(ctx, path); err == nil && probe != nil { @@ -157,8 +157,9 @@ func (s *ScannerService) ScanLibrary(ctx context.Context, libraryID string) (*Sc "probed": res.Probed, }) - // Fire-and-forget metadata enrichment when a TMDb key is configured. - if s.scraper != nil && s.scraper.tmdb != nil && s.scraper.tmdb.Enabled() { + // Fire-and-forget metadata enrichment when at least one provider is + // configured. + if s.scraper != nil && s.scraper.AnyEnabled() { go func(libID string) { if _, err := s.scraper.EnrichLibrary(context.Background(), libID); err != nil { s.log.Warn("scraper enrich failed", zap.Error(err)) diff --git a/internal/service/scraper.go b/internal/service/scraper.go index b4edd8c..72f59a4 100644 --- a/internal/service/scraper.go +++ b/internal/service/scraper.go @@ -1,12 +1,15 @@ // Package service — scraper orchestrator. // // ScraperService takes a Media row and tries to enrich it with metadata -// from one or more providers (currently TMDb only). It is invoked at the -// end of every scan cycle for media items whose `scrape_status` is still -// "pending"; it can also be re-triggered manually from the admin UI. +// from one or more providers. Selection is driven by the library type: // -// The orchestrator is deliberately stateless: it loops media → provider → -// repository, publishing scrape progress events to the WS hub. +// library.type == "anime" -> Bangumi (fallback: TMDb) +// library.type == "tv" -> TMDb (movies) — TV episodes inherit +// series metadata; episode-level scraping +// is left as a future step +// default -> TMDb +// +// The orchestrator publishes scrape progress events on the WS hub. package service import ( @@ -26,23 +29,33 @@ import ( // ScraperService coordinates metadata enrichment across providers. type ScraperService struct { - cfg *config.Config - log *zap.Logger - repo *repository.Container - tmdb *TMDbProvider - hub *Hub + cfg *config.Config + log *zap.Logger + repo *repository.Container + tmdb *TMDbProvider + bangumi *BangumiProvider + hub *Hub } // NewScraperService is the constructor. -func NewScraperService(cfg *config.Config, log *zap.Logger, repo *repository.Container, tmdb *TMDbProvider, hub *Hub) *ScraperService { - return &ScraperService{cfg: cfg, log: log, repo: repo, tmdb: tmdb, hub: hub} +func NewScraperService( + cfg *config.Config, + log *zap.Logger, + repo *repository.Container, + tmdb *TMDbProvider, + bangumi *BangumiProvider, + hub *Hub, +) *ScraperService { + return &ScraperService{ + cfg: cfg, log: log, repo: repo, + tmdb: tmdb, bangumi: bangumi, hub: hub, + } } -// yearPattern extracts a 4-digit year from a filename (1900-2099). +// yearPattern extracts a 4-digit year (1900-2099). var yearPattern = regexp.MustCompile(`(?:^|[^\d])(19\d{2}|20\d{2})(?:[^\d]|$)`) -// noiseTokens are aggressively stripped from filenames before search. -// Keep in sync with nowen-video's filename_parser.go intent. +// noiseTokens are stripped before search. var noiseTokens = []string{ "1080p", "2160p", "4k", "720p", "480p", "hdrip", "bluray", "blu-ray", "webrip", "web-dl", "web", @@ -52,8 +65,7 @@ var noiseTokens = []string{ "hkfree", "yify", "rarbg", "ettv", "fgt", } -// bracketedTag matches "[anything]" or "(anything)" segments, which are -// almost always release-group / encoder tags in scene filenames. +// bracketedTag matches "[anything]" or "(anything)" segments. var bracketedTag = regexp.MustCompile(`[\[\(][^\]\)]*[\]\)]`) // CleanQuery converts a filename like "Inception.2010.1080p.BluRay.x264.mkv" @@ -70,13 +82,18 @@ func CleanQuery(raw string) (title string, year int) { } } - // 2. Drop everything inside square / round brackets — those are tags. + // 2. Drop everything inside brackets. lower = bracketedTag.ReplaceAllString(lower, " ") + // 3. Drop episode markers (S01E02 / 1x02 / EP05 / 第03集). + lower = patSEnE.ReplaceAllString(lower, " ") + lower = patNxE.ReplaceAllString(lower, " ") + lower = patEP.ReplaceAllString(lower, " ") + lower = patCN.ReplaceAllString(lower, " ") + for _, t := range noiseTokens { lower = strings.ReplaceAll(lower, t, " ") } - // collapse separators / spaces for _, sep := range []string{".", "_", "-", "[", "]", "(", ")"} { lower = strings.ReplaceAll(lower, sep, " ") } @@ -85,11 +102,15 @@ func CleanQuery(raw string) (title string, year int) { return strings.TrimSpace(title), year } -// EnrichOne runs the provider chain for a single media row. +// EnrichOne runs the provider chain for a single media row. The library's +// type decides which provider goes first; a fallback runs when the primary +// returns nothing. func (s *ScraperService) EnrichOne(ctx context.Context, m *model.Media) error { - if s.tmdb == nil || !s.tmdb.Enabled() { - return nil + lib, err := s.repo.Library.FindByID(ctx, m.LibraryID) + if err != nil { + return err } + query := m.Title if query == "" { query, _ = CleanQuery(m.Path) @@ -100,10 +121,15 @@ func (s *ScraperService) EnrichOne(ctx context.Context, m *model.Media) error { if year == 0 { _, year = CleanQuery(filepath.Base(m.Path)) } - match, err := s.tmdb.SearchMovie(ctx, query, year) - if err != nil || match == nil { - return err + + match := s.lookup(ctx, lib, query, year) + if match == nil { + // Mark explicitly so we don't retry forever. + _ = s.repo.DB.Model(&model.Media{}).Where("id = ?", m.ID). + Update("scrape_status", "no_match").Error + return nil } + updates := map[string]any{ "title": match.Title, "overview": match.Overview, @@ -111,28 +137,51 @@ func (s *ScraperService) EnrichOne(ctx context.Context, m *model.Media) error { "backdrop_url": match.BackdropURL, "rating": match.Rating, "year": match.Year, - "tmdb_id": match.TMDbID, "scrape_status": "matched", } + if match.TMDbID > 0 { + updates["tmdb_id"] = match.TMDbID + } + if match.BangumiID > 0 { + updates["bangumi_id"] = match.BangumiID + } if err := s.repo.DB.Model(&model.Media{}).Where("id = ?", m.ID). Updates(updates).Error; err != nil { return err } s.hub.Publish("scrape", map[string]any{ - "media_id": m.ID, - "title": match.Title, - "tmdb_id": match.TMDbID, + "media_id": m.ID, + "title": match.Title, + "tmdb_id": match.TMDbID, + "bangumi_id": match.BangumiID, }) return nil } -// EnrichLibrary runs the provider chain for every "pending" media in a -// library. It throttles to 4 RPS to stay below TMDb's rate limit and -// publishes a summary event when done. -func (s *ScraperService) EnrichLibrary(ctx context.Context, libraryID string) (int, error) { - if s.tmdb == nil || !s.tmdb.Enabled() { - return 0, nil +// lookup runs the provider chain. When the library is missing we fall +// back to TMDb only. +func (s *ScraperService) lookup(ctx context.Context, lib *model.Library, query string, year int) *Match { + kind := "" + if lib != nil { + kind = lib.Type } + if kind == "anime" && s.bangumi != nil { + if m, err := s.bangumi.Search(ctx, query); err == nil && m != nil { + return m + } + s.log.Debug("bangumi miss, falling back to tmdb", zap.String("query", query)) + } + if s.tmdb != nil && s.tmdb.Enabled() { + if m, err := s.tmdb.SearchMovie(ctx, query, year); err == nil && m != nil { + return m + } + } + return nil +} + +// EnrichLibrary runs the provider chain for every "pending" media in a +// library. It throttles to 4 RPS and publishes a summary event when done. +func (s *ScraperService) EnrichLibrary(ctx context.Context, libraryID string) (int, error) { var rows []model.Media q := s.repo.DB.Where("scrape_status = ?", "pending") if libraryID != "" { @@ -162,3 +211,14 @@ func (s *ScraperService) EnrichLibrary(ctx context.Context, libraryID string) (i }) return matched, nil } + +// AnyEnabled reports whether at least one provider can run. +func (s *ScraperService) AnyEnabled() bool { + if s.tmdb != nil && s.tmdb.Enabled() { + return true + } + if s.bangumi != nil && s.bangumi.Enabled() { + return true + } + return false +} diff --git a/internal/service/service.go b/internal/service/service.go index 248734a..f4dc640 100644 --- a/internal/service/service.go +++ b/internal/service/service.go @@ -5,6 +5,8 @@ package service import ( + "context" + "go.uber.org/zap" "github.com/ShukeBta/MediaStationGo/internal/config" @@ -14,20 +16,31 @@ import ( // Container holds every service initialized at startup. Handlers receive a // pointer to it and pick the relevant fields. type Container struct { - Cfg *config.Config - Log *zap.Logger - Repo *repository.Container - WSHub *Hub - Auth *AuthService - Media *MediaService - Scan *ScannerService - Stream *StreamService - Transcoder *TranscoderService - FFprobe *FFprobeService - TMDb *TMDbProvider - Scraper *ScraperService - Playback *PlaybackService - ImageProxy *ImageProxy + Cfg *config.Config + Log *zap.Logger + Repo *repository.Container + WSHub *Hub + Auth *AuthService + Media *MediaService + Scan *ScannerService + Stream *StreamService + Transcoder *TranscoderService + FFprobe *FFprobeService + TMDb *TMDbProvider + Bangumi *BangumiProvider + Scraper *ScraperService + Playback *PlaybackService + ImageProxy *ImageProxy + Watcher *WatcherService + Downloads *DownloadService + Subscription *SubscriptionService + Subtitle *SubtitleService + Stats *StatsService + Profile *ProfileService + Audit *AuditService + + stopCtx context.Context + stopCancel context.CancelFunc } // New builds the service container. @@ -37,30 +50,69 @@ func New(cfg *config.Config, log *zap.Logger, repos *repository.Container) *Cont probe := NewFFprobeService(cfg, log) tmdb := NewTMDbProvider(cfg, log) - scraper := NewScraperService(cfg, log, repos, tmdb, hub) + bangumi := NewBangumiProvider(cfg, log) + scraper := NewScraperService(cfg, log, repos, tmdb, bangumi, hub) transcoder := NewTranscoderService(cfg, log, repos, hub) + scanner := NewScannerService(cfg, log, repos, hub, probe, scraper) + downloads := NewDownloadService(log, repos, hub) + subscription := NewSubscriptionService(log, repos, downloads, hub) + watcher := NewWatcherService(log, repos, scanner) + + ctx, cancel := context.WithCancel(context.Background()) return &Container{ - Cfg: cfg, - Log: log, - Repo: repos, - WSHub: hub, - Auth: NewAuthService(cfg, log, repos), - Media: NewMediaService(cfg, log, repos), - Scan: NewScannerService(cfg, log, repos, hub, probe, scraper), - Stream: NewStreamService(cfg, log, repos, transcoder), - Transcoder: transcoder, - FFprobe: probe, - TMDb: tmdb, - Scraper: scraper, - Playback: NewPlaybackService(log, repos), - ImageProxy: NewImageProxy(cfg, log), + Cfg: cfg, + Log: log, + Repo: repos, + WSHub: hub, + Auth: NewAuthService(cfg, log, repos), + Media: NewMediaService(cfg, log, repos), + Scan: scanner, + Stream: NewStreamService(cfg, log, repos, transcoder), + Transcoder: transcoder, + FFprobe: probe, + TMDb: tmdb, + Bangumi: bangumi, + Scraper: scraper, + Playback: NewPlaybackService(log, repos), + ImageProxy: NewImageProxy(cfg, log), + Watcher: watcher, + Downloads: downloads, + Subscription: subscription, + Subtitle: NewSubtitleService(log, repos), + Stats: NewStatsService(log, repos), + Profile: NewProfileService(log, repos), + Audit: NewAuditService(log, repos), + stopCtx: ctx, + stopCancel: cancel, } } +// Boot kicks off background workers (watcher, downloads poller, +// subscription scheduler). Called once after AutoMigrate. +func (c *Container) Boot() { + if err := c.Watcher.Start(c.stopCtx); err != nil { + c.Log.Warn("watcher start failed", zap.Error(err)) + } + c.Downloads.Start(c.stopCtx) + c.Subscription.Start(c.stopCtx) +} + // Close releases any resources held by services (websocket hub, ffmpeg -// transcodes). +// transcodes, fsnotify, background pollers). func (c *Container) Close() { + if c.stopCancel != nil { + c.stopCancel() + } + if c.Watcher != nil { + c.Watcher.Stop() + } + if c.Subscription != nil { + c.Subscription.Stop() + } + if c.Downloads != nil { + c.Downloads.Stop() + } if c.Transcoder != nil { c.Transcoder.StopAll() } diff --git a/internal/service/stats.go b/internal/service/stats.go new file mode 100644 index 0000000..23840db --- /dev/null +++ b/internal/service/stats.go @@ -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 +} diff --git a/internal/service/subscription.go b/internal/service/subscription.go new file mode 100644 index 0000000..8072cf6 --- /dev/null +++ b/internal/service/subscription.go @@ -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..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 +} diff --git a/internal/service/subtitle.go b/internal/service/subtitle.go new file mode 100644 index 0000000..45fc11e --- /dev/null +++ b/internal/service/subtitle.go @@ -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 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/?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 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, "") +} diff --git a/internal/service/subtitle_test.go b/internal/service/subtitle_test.go new file mode 100644 index 0000000..f180f06 --- /dev/null +++ b/internal/service/subtitle_test.go @@ -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) + } + } +} diff --git a/internal/service/tmdb.go b/internal/service/tmdb.go index 67b48b8..693d59a 100644 --- a/internal/service/tmdb.go +++ b/internal/service/tmdb.go @@ -61,9 +61,12 @@ func NewTMDbProvider(cfg *config.Config, log *zap.Logger) *TMDbProvider { // Enabled reports whether the operator has supplied an API key. func (t *TMDbProvider) Enabled() bool { return t.cfg.Secrets.TMDbAPIKey != "" } -// Match describes a successful metadata match. +// Match describes a successful metadata match. The same struct is reused +// across providers; provider-specific IDs sit side-by-side so the scraper +// orchestrator can write them all into a single update. type Match struct { TMDbID int `json:"tmdb_id"` + BangumiID int `json:"bangumi_id"` Title string `json:"title"` Overview string `json:"overview"` PosterURL string `json:"poster_url"` diff --git a/internal/service/watcher.go b/internal/service/watcher.go new file mode 100644 index 0000000..c24a3aa --- /dev/null +++ b/internal/service/watcher.go @@ -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)) + } + } + } +} diff --git a/web/src/App.tsx b/web/src/App.tsx index 7bf3adb..3212f5d 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -30,6 +30,16 @@ const PlayerPage = lazy(() => import('./pages/PlayerPage').then((m) => ({ default: m.PlayerPage })), ) const AdminPage = lazy(() => import('./pages/AdminPage').then((m) => ({ default: m.AdminPage }))) +const DownloadsPage = lazy(() => + import('./pages/DownloadsPage').then((m) => ({ default: m.DownloadsPage })), +) +const SubscriptionsPage = lazy(() => + import('./pages/SubscriptionsPage').then((m) => ({ default: m.SubscriptionsPage })), +) +const ProfilePage = lazy(() => + import('./pages/ProfilePage').then((m) => ({ default: m.ProfilePage })), +) +const StatsPage = lazy(() => import('./pages/StatsPage').then((m) => ({ default: m.StatsPage }))) // Fallback shown while a chunk is loading. const Loading = () =>

加载中…

@@ -55,6 +65,17 @@ export default function App() { } /> } /> } /> + } /> + } /> + } /> + + + + } + /> api.get('/downloads').then((r) => r.data), + + add: (url: string, savePath = '') => + api + .post('/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), +} diff --git a/web/src/api/library.ts b/web/src/api/library.ts index 0637f33..c44aac8 100644 --- a/web/src/api/library.ts +++ b/web/src/api/library.ts @@ -19,6 +19,9 @@ export const libraryAPI = { scan: (id: string) => api.post(`/libraries/${id}/scan`).then((r) => r.data), + scrape: (id: string) => + api.post(`/libraries/${id}/scrape`).then((r) => r.data), + listMedia: (id: string, page = 1, pageSize = 50) => api .get(`/libraries/${id}/media`, { diff --git a/web/src/api/profile.ts b/web/src/api/profile.ts new file mode 100644 index 0000000..ad166ff --- /dev/null +++ b/web/src/api/profile.ts @@ -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('/me', patch).then((r) => r.data), + + adminUpdateRole: (id: string, role: 'admin' | 'user') => + api.patch(`/admin/users/${id}/role`, { role }).then((r) => r.data), +} diff --git a/web/src/api/series.ts b/web/src/api/series.ts new file mode 100644 index 0000000..c421e08 --- /dev/null +++ b/web/src/api/series.ts @@ -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), +} diff --git a/web/src/api/stats.ts b/web/src/api/stats.ts new file mode 100644 index 0000000..fbafc98 --- /dev/null +++ b/web/src/api/stats.ts @@ -0,0 +1,6 @@ +import { api } from './client' +import type { StatsSnapshot } from '../types' + +export const statsAPI = { + snapshot: () => api.get('/stats').then((r) => r.data), +} diff --git a/web/src/api/subscriptions.ts b/web/src/api/subscriptions.ts new file mode 100644 index 0000000..b69bf7e --- /dev/null +++ b/web/src/api/subscriptions.ts @@ -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('/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), +} diff --git a/web/src/api/subtitles.ts b/web/src/api/subtitles.ts new file mode 100644 index 0000000..304d3a5 --- /dev/null +++ b/web/src/api/subtitles.ts @@ -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)}` + }, +} diff --git a/web/src/components/GlobalEvents.tsx b/web/src/components/GlobalEvents.tsx new file mode 100644 index 0000000..7f57246 --- /dev/null +++ b/web/src/components/GlobalEvents.tsx @@ -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 + 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 +} diff --git a/web/src/components/Layout.tsx b/web/src/components/Layout.tsx index 6734390..dd717e2 100644 --- a/web/src/components/Layout.tsx +++ b/web/src/components/Layout.tsx @@ -1,14 +1,18 @@ import { useEffect, useState } from 'react' import { Link, NavLink, Outlet, useNavigate } from 'react-router-dom' import { + Activity, + CloudDownload, Film, Heart, Home, ListMusic, LogOut, + Rss, Search, Settings, Library as LibraryIcon, + User as UserIcon, } from 'lucide-react' import clsx from 'clsx' @@ -42,7 +46,7 @@ export function Layout() { -