@@ -1037,9 +402,11 @@ cd web && npm run build
---
-## 📄 许可证与非商用声明
+## 许可证与非商用声明
-本项目基础许可证遵循 `GPL-3.0`,详见 [LICENSE](LICENSE)。项目维护者同时声明并倡议:
+本项目基础许可证遵循 `GPL-3.0`,详见 [LICENSE](LICENSE)。
+
+项目维护者同时声明并倡议:
- 本项目主要面向个人学习、家庭 NAS、自建影音、非商业研究与社区共建场景。
- 未经作者明确书面许可,不得将本项目或衍生版本用于商业售卖、商业托管、付费 SaaS、预装售卖设备、闭源二次分发或其他商业化牟利用途。
diff --git a/README_EN.md b/README_EN.md
index 9e6e0d4..a4f8b36 100644
--- a/README_EN.md
+++ b/README_EN.md
@@ -7,987 +7,365 @@
A lightweight, polished, NAS-friendly private media center
- Go single-binary backend · React frontend · Docker-first deployment · Emby-compatible APIs · Multi-source metadata · PT subscriptions
+ Docker-first setup · Media library · Metadata · Playback · Downloads · Emby-compatible clients · Cloud playback
中文 ·
Quick Start ·
- Docker Deploy ·
- Screenshots ·
+ Docker Compose ·
+ FAQ ·
Live Demo
-
-
---
-## ✨ Overview
+## What is it?
-MediaStationGo is an open-source media center for personal libraries, family NAS setups, and home-theater enthusiasts. It combines library management, metadata scraping, web playback, third-party client compatibility, PT site search, subscription-based downloads, and AI-assisted recommendations into one lightweight Go service with a polished React interface.
+MediaStationGo is a self-hosted media center for personal libraries, home NAS, and home-theater users.
-It is designed for users who want to:
+It helps you:
-- Manage movies, TV shows, anime, variety shows, adult content, and music from one place.
-- Prefer local NFO/images first, then enrich missing metadata from TMDb, Douban, Bangumi, TheTVDB, Fanart, JavBus, and JavDB.
-- Search PT sites, subscribe to resources, enqueue downloads, and organize completed files from one panel.
-- Connect external clients such as Infuse, VidHub, SenPlayer, and other Emby/Jellyfin-compatible apps.
-- Deploy easily on NAS, Windows, Linux, macOS, or Docker without leaking private tracker tokens to the frontend.
+- Manage movies, TV shows, anime, variety shows, music, and adult libraries.
+- Scan files and enrich posters, summaries, years, seasons, and episodes.
+- Play in the web UI or external apps such as Infuse, VidHub, SenPlayer, and Emby clients.
+- Connect qBittorrent for search, subscriptions, downloads, and post-download organization.
+- Connect OpenList, CloudDrive2, WebDAV, and other storage backends with STRMURL or 302 redirect playback.
+- Run on NAS, mini PCs, VPS, Linux, Windows Docker Desktop, or any Docker-friendly host.
-> The project is under active development. For production use, pin a release image tag and back up `/data` regularly.
+> The project is moving fast. Keep a backup of the `data` directory before upgrades.
---
-## 🌱 Open Source Promise
+## Who is it for?
-MediaStationGo follows a fully open-source path. The core media library, metadata scraping, playback, subscriptions, downloads, external-client compatibility, and operations features are developed in this repository. The project learns from excellent open-source projects such as MoviePilot in areas like site aggregation, subscription downloads, post-download organization, and Emby/Jellyfin client compatibility, while keeping MediaStationGo as an independent implementation and avoiding incompatible code reuse.
-
-The current base license is `GPL-3.0`, and contributions are welcome under that license: site adapters, scraping rules, UI improvements, documentation, and bug fixes all help the project grow. The maintainers also state the intended usage boundary: MediaStationGo is provided for personal learning, home NAS, self-hosted media, and non-commercial scenarios. Without explicit written permission from the author, do not use this project or derivative versions for commercial resale, paid hosting, paid SaaS, pre-installed commercial devices, closed-source redistribution, or other profit-oriented commercial use.
-
-> Note: GPL-3.0 is a free software license, and the formal grant is defined by the repository [LICENSE](LICENSE) file. The non-commercial commitment above expresses the maintainer's intended usage boundary and commercial cooperation requirements. For commercial cooperation, enterprise deployment, or redistribution, contact the author for additional authorization first.
-
-### Source Availability and Docker Support Boundary
-
-- The public repository currently uses `GPL-3.0` as its base license. If a component is GPL-derived, distributing it only as a Docker image does not remove the corresponding source-distribution obligations.
-- The project can still define its official support scope as **Docker-first / Docker-only support**: Docker Compose, GHCR images, and container deployment docs are maintained as the supported path, while bare-metal binaries can be community/best-effort.
-- If some future functionality needs to be closed-source, keep it as a separate plugin, private service, or independently implemented module whose license boundary is clean. GPL-covered code should remain available under GPL terms.
-- The README non-commercial statement describes the maintainer's intended usage boundary and commercial authorization requirement; the formal code license remains governed by [LICENSE](LICENSE).
+- **Beginners** who want to edit one `docker-compose.yml` and start the service.
+- **NAS users** who want a low-resource media center for local disks and cloud storage.
+- **PT/download users** who want downloads, organization, metadata, and playback in one panel.
+- **External-player users** who want an Emby-style API for third-party apps.
+- **Developers** who want to study or extend a Go + React self-hosted media app.
---
-## 🚀 Live Demo
+## Live Demo
-- Demo: [https://mgo.3jzs.com](https://mgo.3jzs.com)
+- URL: [https://mgo.3jzs.com](https://mgo.3jzs.com)
- Username: `admin`
- Password: `admin123`
-> The demo is for feature preview only. Do not store private API keys, tracker tokens, or personal media information there.
+> The demo is for feature preview only. Do not save private API keys, tracker cookies, or personal data there.
---
-## 🧭 Feature Overview
+## Quick Start
-| Area | Capabilities |
-| --- | --- |
-| Libraries | Movies, TV, anime, variety, music, adult content; folder covers, series, seasons, and episodes |
-| Scanning | Recursive scanning, ffprobe probing, filename parsing, season/episode recognition, duplicate prevention |
-| Local metadata | NFO, poster, fanart, season poster, episode image, local adult artwork first |
-| Online metadata | TMDb, TheTVDB, Bangumi, Douban, Fanart.tv, JavBus/JavDB page scraping |
-| Playback | Direct streaming, HTTP Range seeking, HLS transcoding, external subtitles, progress, resume, external players |
-| Discovery | TMDb / Douban / Bangumi recommendation rails and subscription entry points |
-| PT sites | Site management, M-Team API token, cross-site search, download URL resolution |
-| Subscriptions | RSS/search subscriptions, resolution/quality/effects/release-group rules, wash toggle and priorities |
-| Downloads | qBittorrent task cards, status, speed, progress, uploaded/downloaded size, private URL redaction |
-| Compatibility | Emby-style APIs for Infuse, VidHub, SenPlayer, and similar clients |
-| Operations | Runtime status, task queue, duplicate files, recycle bin, file manager, storage settings, notifications |
-| AI | OpenAI-compatible API settings, AI search, recommendations, and operations assistant |
-
----
-
-
-
-## 🖼️ Screenshots
-
-> Screenshots below were captured from a running local instance. Personal media, local paths, accounts, API keys, tokens, and secrets have been visually redacted.
-
-
-Core Experience
-
-| Login & Home | Library Overview |
-| --- | --- |
-|
|
|
-| Libraries | Library Detail |
-|
|
|
-| Poster Wall | Media Detail |
-|
|
|
-| Player | Discover |
-|
|
|
-| Smart Search | DLNA Cast |
-|
|
|
-
-
-
-
-Personal Space & Playback
-
-| AI Assistant | Favorites |
-| --- | --- |
-|
|
|
-| Playlists | Watch History |
-|
|
|
-| Profile | Downloads |
-|
|
|
-
-
-
-
-Downloads, Subscriptions & Sites
-
-| Download Clients | Subscriptions |
-| --- | --- |
-|
|
|
-| Site Search | Sites & Downloaders |
-|
|
|
-
-
-
-
-Administration & Operations
-
-| Media & Users | Tools |
-| --- | --- |
-|
|
|
-| Storage & Files | Runtime Status |
-|
|
|
-| Settings | Tasks |
-|
|
|
-| Duplicates | Recycle Bin |
-|
|
|
-| Scheduler | File Manager |
-|
|
|
-| STRM | Storage Config |
-|
|
|
-| Notifications | Operations Assistant |
-|
|
|
-
-
-
----
-
-## 🧱 Tech Stack
-
-| Layer | Technology | Notes |
-| --- | --- | --- |
-| Backend | Go 1.25+ | Single-binary deployment, low resource usage |
-| Web framework | Gin | REST APIs, auth middleware, static file serving |
-| Database | SQLite + GORM | Simple backup and migration for personal/NAS usage |
-| Frontend | React 18 + TypeScript | Typed component-based UI |
-| Build tool | Vite | Fast frontend development and production builds |
-| Styling | Tailwind CSS | Unified bright visual system and responsive layout |
-| State | Zustand | Lightweight auth and permission state |
-| Playback | HTML5 Video / HLS / FFmpeg | Direct play, Range seek, HLS transcoding, subtitles |
-| Metadata | TMDb / Douban / Bangumi / TheTVDB / Fanart / JavBus / JavDB | Posters, backdrops, descriptions, ratings, episode metadata |
-| Downloads | qBittorrent / PT site adapters | Search, subscriptions, task cards, private URL redaction |
-| Compatibility | Emby-style API / DLNA | External clients and player integrations |
-| Deployment | Docker / Docker Compose / Shell / PowerShell | NAS, Linux, Windows, and macOS friendly |
-| CI/CD | GitHub Actions / GHCR | Version tags or manual Actions runs publish multi-arch Docker images and release packages |
-
----
-
-
-
-## 📦 Quick Start
-
-
-
-### Docker Compose (Recommended)
-
-The recommended beginner path is now **editing real paths directly in `docker-compose.yml`**. This is easier for new NAS, VPS, and Docker users because the host paths are visible in one file.
-
-#### 1. Create a deployment directory
-
-```bash
-mkdir -p ~/MediaStationGo
-cd ~/MediaStationGo
-mkdir -p data cache media downloads
-```
-
-#### 2. Download compose
+Docker Compose is the recommended path. Beginners do not need `.env`, bare-metal binaries, or source builds.
```bash
+mkdir -p MediaStationGo
+cd MediaStationGo
curl -fsSL https://raw.githubusercontent.com/ShukeBta/MediaStationGo/main/docker-compose.yml -o docker-compose.yml
```
-If GitHub Raw is slow, create `docker-compose.yml` manually and paste the template from the repository root.
-
-#### 3. Edit real host paths
-
-Open the compose file:
+Edit `docker-compose.yml`:
```bash
vi docker-compose.yml
```
-Find the media and download volume lines, then replace the left side with your real NAS/server paths:
-
-```yaml
- volumes:
- - ./data:/data
- - ./cache:/cache
- - /vol1/1000/Docker/moviepilot-v2/media:/media:ro
- - /vol1/1000/qBittorrent/downloads:/downloads
-```
-
-Then find the path mapping variables under `environment` and write the same real paths there:
-
-```yaml
- environment:
- MEDIASTATION_MEDIA_DIR: /vol1/1000/Docker/moviepilot-v2/media
- MEDIASTATION_MEDIA_CONTAINER_DIR: /media
- MEDIASTATION_DOWNLOAD_DIR: /vol1/1000/qBittorrent/downloads
- MEDIASTATION_DOWNLOAD_CONTAINER_DIR: /downloads
-```
-
-| Location | Meaning |
-| --- | --- |
-| Media volume left side | Real host/NAS media directory, e.g. `/volume1/media`, `/mnt/media`, `/vol1/1000/Docker/moviepilot-v2/media` |
-| Download volume left side | Real host/NAS download directory, e.g. `/volume1/downloads`, `/mnt/downloads`, `/vol1/1000/qBittorrent/downloads` |
-| `MEDIASTATION_MEDIA_DIR` | Must match the media volume left side |
-| `MEDIASTATION_DOWNLOAD_DIR` | Must match the download volume left side |
-
-> Do not write NAS absolute paths as `./vol1/...`. A leading `./` means a directory under the current compose project.
-
-#### 4. Start
+Start:
```bash
-docker compose pull
docker compose up -d
```
-If your system only has the legacy command:
-
-```bash
-docker-compose pull
-docker-compose up -d
-```
-
-#### 5. Open the app
+Open:
```text
-http://:18080
+http://SERVER_IP:18080
```
-Default account:
+Default login:
```text
-admin / admin123
+Username: admin
+Password: admin123
```
-Change the admin password immediately after first login.
-
-#### 6. Paths inside the web UI
-
-The simple compose maps host directories to stable container paths:
-
-| Host directory | Container path | Use in the web UI |
-| --- | --- | --- |
-| `MEDIASTATION_MEDIA_DIR` | `/media` | `/media/Movies`, `/media/TV`, `/media/TV/CDrama` |
-| `MEDIASTATION_DOWNLOAD_DIR` | `/downloads` | Downloader save root: `/downloads` |
-
-This is a Docker bind mount. It does not copy files and does not consume double disk space.
-
-#### 7. Update
-
-Use the update helper to pull the new image and remove old MediaStationGo image layers:
-
-```bash
-curl -fsSL https://cdn.jsdelivr.net/gh/ShukeBta/MediaStationGo@main/scripts/docker-compose-update.sh -o docker-compose-update.sh
-chmod +x docker-compose-update.sh
-./docker-compose-update.sh
-```
-
-#### Advanced options
-
-The default compose intentionally stays small. If you need the container to expose the same raw host paths, Telegram proxy variables, hardware acceleration, or additional transcoding options, see:
-
-```text
-docker-compose.advanced.yml
-```
-
-### Optional: Use `.env` for Paths
-
-If you already know Docker Compose well, or if you reuse the same compose file across multiple machines, you can put paths in `.env`. This is optional, not the beginner path:
-
-```bash
-cat > .env <<'EOF'
-MEDIASTATION_MEDIA_DIR=/vol1/1000/Docker/moviepilot-v2/media
-MEDIASTATION_DOWNLOAD_DIR=/vol1/1000/qBittorrent/downloads
-MEDIASTATION_HTTP_PORT=18080
-TZ=Asia/Shanghai
-PUID=1000
-PGID=1000
-EOF
-```
-
-When using `.env`, keep the default variable-style volume lines in `docker-compose.yml`:
-
-```yaml
-- ${MEDIASTATION_MEDIA_DIR:-./media}:/media:ro
-- ${MEDIASTATION_DOWNLOAD_DIR:-./downloads}:/downloads
-```
-
-### Fixed Version Deployment
-
-For production, pin a release tag so `latest` does not change unexpectedly. Without `.env`, edit the image line directly:
-
-```yaml
-image: ghcr.io/shukebta/mediastation-go:MediaStationGo-v0.0.32
-```
-
-If you use `.env`, you can also set:
-
-```env
-MEDIASTATION_IMAGE_TAG=MediaStationGo-v0.0.32
-```
-
-Then run:
-
-```bash
-docker compose pull
-docker compose up -d
-```
-
-### External Network and v2rayA
-
-If your NAS already uses v2rayA redirect / transparent proxy with routing rules, MediaStationGo usually does not need extra `HTTP_PROXY` / `HTTPS_PROXY` variables.
-
-Avoid stacking proxy variables in `.env`, `docker-compose.yml`, or the Docker daemon unless you intentionally use an application-level proxy. Stacked proxies can break GHCR pulls and site APIs.
-
-### qBittorrent URL
-
-If qBittorrent runs on the same NAS/host, do not use `127.0.0.1` from inside MediaStationGo. Use:
-
-```text
-http://host.docker.internal:8085
-```
-
-If qBittorrent also runs in Docker, mount the same host download directory as `/downloads` in both containers. Use `/downloads` as the subscription save root.
-
-With smart classification enabled, downloads are saved to folders such as `/downloads/动画电影`, `/downloads/国产剧`, and `/downloads/综艺`.
-
-### Cloud Drives, OpenList, and CloudDrive2
-
-MediaStationGo supports OpenList, Alist, WebDAV, 115, Quark, and CloudDrive2 as external storage backends. For most users, OpenList, CloudDrive2, or Alist is the recommended bridge layer: they can integrate many cloud drives such as 115, 123Pan, Aliyun Drive, and Quark, while MediaStationGo can use OpenList/Alist APIs or WebDAV endpoints for browsing, library mounting, local-media upload, and authenticated proxy playback.
-
-Typical OpenList setup:
-
-1. Mount your 115 / 123Pan / Aliyun / Quark drive inside OpenList.
-2. Select `OpenList` in MediaStationGo → External Storage.
-3. Set `OpenList Server URL` to the management/API endpoint, for example `http://NAS-IP:5244`.
-4. Set `WebDAV URL` to the WebDAV endpoint, for example `http://NAS-IP:5244/dav/`.
-5. Do not use `https://` unless OpenList is actually behind an HTTPS reverse proxy. `Propfind "https://...": http: server gave HTTP response to HTTPS client` means an HTTP service was entered as HTTPS.
-
-Typical CloudDrive2 setup:
-
-1. Mount your 115 / 123Pan / Aliyun / Quark drive inside CloudDrive2.
-2. Select `CloudDrive2` in MediaStationGo → External Storage.
-3. Fill the CloudDrive2 WebDAV URL, for example `http://host.docker.internal:19798/dav` or `http://NAS-IP:19798/dav`.
-4. Save the config, then browse the cloud directory or mount it as a media library.
-
-The native 115 adapter still supports cookie / QR login, directory browsing, and 302 playback. For local file upload, prefer OpenList / CloudDrive2 / Alist bridging instead of maintaining every provider's private chunk-upload protocol inside this project.
-
---
-## 🐳 Docker Compose Configuration
+## Docker Compose Recommended
-The default repository `docker-compose.yml` is intentionally simple. Beginners should write real host paths directly on the left side of `volumes`, then keep `MEDIASTATION_MEDIA_DIR` and `MEDIASTATION_DOWNLOAD_DIR` aligned with those same paths. `.env` variables are optional for advanced reuse:
+The repository `docker-compose.yml` is intentionally simple and does not require `.env`.
-| Variable | Default | Description |
+Focus on this part:
+
+```yaml
+volumes:
+ - ./data:/data
+ - ./cache:/cache
+ - ./media:/media:ro
+ - ./downloads:/downloads
+```
+
+Meaning:
+
+| Host path | Container path | Purpose |
| --- | --- | --- |
-| `MEDIASTATION_IMAGE_TAG` | `latest` | Image tag. Pin a release for production. |
-| `MEDIASTATION_HTTP_PORT` | `18080` | Host access port |
-| `MEDIASTATION_DATA_DIR` | `./data` | Persistent data directory |
-| `MEDIASTATION_CACHE_DIR` | `./cache` | Image and transcoding cache |
-| `MEDIASTATION_MEDIA_DIR` | `./media` | Host media library root; on NAS use an absolute path such as `/your-nas/media` |
-| `MEDIASTATION_DOWNLOAD_DIR` | `./downloads` | Host download target; on NAS use an absolute path such as `/your-nas/downloads` |
-| `PUID` / `PGID` | `1000` / `1000` | Linux/NAS file permission mapping |
-| `TZ` | `Asia/Shanghai` | Container timezone |
+| `./data` | `/data` | Database, users, settings; back this up |
+| `./cache` | `/cache` | Cache; safe to clean when needed |
+| `./media` | `/media` | Media libraries; use `/media/...` in the web UI |
+| `./downloads` | `/downloads` | Download directory and organization source |
-View logs:
+If your NAS paths are:
+
+```text
+/vol1/1000/Media
+/vol1/1000/Downloads
+```
+
+change the compose file to:
+
+```yaml
+volumes:
+ - ./data:/data
+ - ./cache:/cache
+ - /vol1/1000/Media:/media:ro
+ - /vol1/1000/Downloads:/downloads
+
+environment:
+ MEDIASTATION_MEDIA_DIR: /vol1/1000/Media
+ MEDIASTATION_DOWNLOAD_DIR: /vol1/1000/Downloads
+```
+
+Rules:
+
+- The left side of `volumes` is the real path on your host/NAS.
+- The right side is the container path. Keep `/media` and `/downloads` unless you know why you are changing them.
+- In the web UI, create libraries with container paths such as `/media/Movies` or `/media/TV`.
+- Do not write NAS absolute paths as `./vol1/...`; `./` means a folder under the current compose directory.
+- On Windows Docker Desktop, paths like `D:/Media:/media:ro` and `D:/Downloads:/downloads` are fine.
+
+### Minimal compose example
+
+The root `docker-compose.yml` follows this style:
+
+```yaml
+services:
+ mediastation-go:
+ image: ghcr.io/shukebta/mediastation-go:latest
+ container_name: mediastation-go
+ restart: unless-stopped
+ init: true
+
+ # Browser: http://SERVER_IP:18080
+ ports:
+ - "18080:8080"
+
+ # Let the container reach qBittorrent running on the host:
+ # qB URL example: http://host.docker.internal:8085
+ extra_hosts:
+ - "host.docker.internal:host-gateway"
+
+ volumes:
+ # Application data. Back this up before upgrades.
+ - ./data:/data
+ - ./cache:/cache
+
+ # Beginners can keep ./media and ./downloads.
+ # NAS users should replace the left side with real absolute paths.
+ - ./media:/media:ro
+ - ./downloads:/downloads
+
+ environment:
+ TZ: Asia/Shanghai
+ PUID: "1000"
+ PGID: "1000"
+
+ MEDIASTATION_APP_HOST: 0.0.0.0
+ MEDIASTATION_APP_PORT: 8080
+ MEDIASTATION_APP_WEB_DIR: /app/web/dist
+ MEDIASTATION_APP_DATA_DIR: /data
+ MEDIASTATION_DATABASE_DB_PATH: /data/mediastation.db
+ MEDIASTATION_CACHE_CACHE_DIR: /cache
+
+ # If you changed ./media or ./downloads above,
+ # set these to the same real host paths.
+ MEDIASTATION_MEDIA_DIR: ./media
+ MEDIASTATION_MEDIA_CONTAINER_DIR: /media
+ MEDIASTATION_DOWNLOAD_DIR: ./downloads
+ MEDIASTATION_DOWNLOAD_CONTAINER_DIR: /downloads
+```
+
+---
+
+## First-time Setup
+
+1. **Create a library**
+ - Go to the library page.
+ - Use a container path such as `/media/Movies`.
+ - Start a scan.
+
+2. **Connect qBittorrent**
+ - Go to download client settings.
+ - If qBittorrent runs on the host, try `http://host.docker.internal:8085`.
+
+3. **Configure metadata providers**
+ - Go to system settings / external APIs.
+ - Add TMDb, Bangumi, TheTVDB, Fanart, Douban, or other providers when needed.
+
+4. **Use external players**
+ - Add the server as an Emby/Jellyfin-compatible server.
+ - Server URL: `http://SERVER_IP:18080`.
+ - Log in with your MediaStationGo account.
+
+5. **Use cloud playback**
+ - Configure OpenList, CloudDrive2, WebDAV, or another provider in storage settings.
+ - Choose STRMURL or 302 redirect playback in the admin settings.
+ - The enabled option takes priority. If both are disabled, playback falls back to the normal server playback path.
+
+---
+
+## Update, Backup, Logs
+
+### Update
+
+```bash
+docker compose pull
+docker compose up -d
+```
+
+### Logs
```bash
docker logs -f mediastation-go
```
-Update:
+### Backup
-```bash
-curl -fsSL https://cdn.jsdelivr.net/gh/ShukeBta/MediaStationGo@main/scripts/docker-compose-update.sh -o docker-compose-update.sh
-chmod +x docker-compose-update.sh
-./docker-compose-update.sh
+Back up:
+
+```text
+data/
```
-Note: plain `docker compose pull && docker compose up -d` switches to the new image but does not remove old images. The helper keeps the currently running MediaStationGo image, removes unused older images from the same repository, and runs `docker image prune -f` for dangling layers. To aggressively remove all unused images, run `PRUNE_ALL_UNUSED=1 ./docker-compose-update.sh`.
+It contains the database, users, settings, and runtime state. `cache/` is usually not important.
-Stop:
+### Stop
```bash
docker compose down
```
-Back up data:
-
-```bash
-tar -czf mediastationgo-data-backup.tgz ./data
-```
-
---
-## 🖥️ One-Click Deployment Scripts
+## FAQ
-If you do not want Docker, run MediaStationGo directly on the host. The scripts build the frontend, compile the backend, start the service, and verify `/api/health`.
+### 1. The web page does not open
-### Linux / macOS
-
-```bash
-git clone https://github.com/ShukeBta/MediaStationGo.git
-cd MediaStationGo
-chmod +x scripts/deploy.sh
-PORT=18080 DATA_DIR=/opt/mediastation/data CACHE_DIR=/opt/mediastation/cache ./scripts/deploy.sh
-```
-
-### Windows PowerShell
-
-```powershell
-git clone https://github.com/ShukeBta/MediaStationGo.git
-cd MediaStationGo
-.\scripts\deploy.ps1 -Port 18080 -DataDir D:\MediaStationGo\data -CacheDir D:\MediaStationGo\cache
-```
-
-The scripts will:
-
-1. Install frontend dependencies and build `web/dist`
-2. Compile the Go backend into `bin/`
-3. Create data and cache directories
-4. Stop any previous process and start a new one
-5. Verify the service through `/api/health`
-
----
-
-## 🧩 Release Package Deployment
-
-Each release provides multi-platform archives:
-
-| Platform | Package example |
-| --- | --- |
-| Linux x86_64 | `MediaStationGo-v0.0.32-linux-amd64.tar.gz` |
-| Linux ARM64 | `MediaStationGo-v0.0.32-linux-arm64.tar.gz` |
-| Windows x86_64 | `MediaStationGo-v0.0.32-windows-amd64.zip` |
-| macOS Intel | `MediaStationGo-v0.0.32-darwin-amd64.tar.gz` |
-| macOS Apple Silicon | `MediaStationGo-v0.0.32-darwin-arm64.tar.gz` |
-
-Linux example:
-
-```bash
-tar -xzf MediaStationGo-v0.0.32-linux-amd64.tar.gz
-cd MediaStationGo-v0.0.32-linux-amd64
-MEDIASTATION_APP_PORT=18080 ./mediastation-go
-```
-
-Windows example:
-
-```powershell
-Expand-Archive .\MediaStationGo-v0.0.32-windows-amd64.zip
-cd .\MediaStationGo-v0.0.32-windows-amd64
-$env:MEDIASTATION_APP_PORT = "18080"
-.\mediastation-go.exe
-```
-
-> Release binaries listen on `8080` by default. Set `MEDIASTATION_APP_PORT=18080` as shown above if you want the same port as the Docker examples.
-
----
-
-## 🛠️ Local Development
-
-### Requirements
-
-| Component | Version | Purpose |
-| --- | --- | --- |
-| Go | 1.25+ | Backend build and tests |
-| Node.js | 20+ | Frontend build |
-| FFmpeg / ffprobe | Recommended | Media probing and transcoding |
-| Docker | Optional | Container deployment and multi-arch builds |
-| qBittorrent | Optional | Download integration testing |
-
-### Build Locally
-
-```bash
-cp config.example.yaml config.yaml
-cd web
-npm ci
-npm run build
-cd ..
-go build -o bin/mediastation-go ./cmd/server
-./bin/mediastation-go
-```
-
-Windows:
-
-```powershell
-Copy-Item config.example.yaml config.yaml
-Set-Location web
-npm ci
-npm run build
-Set-Location ..
-go build -o bin\mediastation-go.exe .\cmd\server
-.\bin\mediastation-go.exe
-```
-
-### Common Commands
-
-```bash
-make build # Build frontend and backend
-make test # Run Go tests
-make smoke # Smoke test
-make docker # docker compose up -d
-make deploy # Linux one-click deploy
-make docker-push # Multi-arch buildx push
-```
-
----
-
-## 🏗️ Repository Layout
-
-```text
-MediaStationGo/
-├── cmd/server/ # Server entry point
-├── internal/
-│ ├── config/ # Config loading and defaults
-│ ├── database/ # SQLite initialization and migrations
-│ ├── handler/ # HTTP API, Emby API, admin endpoints
-│ ├── middleware/ # Auth, permission, logging middleware
-│ ├── model/ # GORM models
-│ ├── repository/ # Data access layer
-│ └── service/ # Scanner, scraper, playback, downloads, subscriptions
-├── web/
-│ ├── public/ # favicon and static public assets
-│ ├── src/ # React pages, components, API clients, stores
-│ └── dist/ # Frontend build output, ignored by git
-├── scripts/ # Deploy, package, Docker scripts
-├── docs/ # Design docs, screenshots, architecture notes
-├── docker-compose.yml # Default Docker Compose deployment
-├── Dockerfile # Multi-stage image build
-├── config.example.yaml # Config template
-└── README.md / README_EN.md # Documentation
-```
-
----
-
-## ⚙️ Configuration
-
-Configuration precedence, from low to high:
-
-1. Built-in defaults
-2. `config.yaml`
-3. `config/*.yaml`
-4. `MEDIASTATION_` environment variables
-5. Runtime settings stored in the database
-
-Common variables:
-
-| Variable | Default | Description |
-| --- | --- | --- |
-| `MEDIASTATION_APP_HOST` | `0.0.0.0` | Listen address |
-| `MEDIASTATION_APP_PORT` | `8080` | Listen port |
-| `MEDIASTATION_APP_WEB_DIR` | `./web/dist` | Frontend static bundle |
-| `MEDIASTATION_APP_DATA_DIR` | `./data` | App data directory |
-| `MEDIASTATION_DATABASE_DB_PATH` | `./data/mediastation.db` | SQLite database path |
-| `MEDIASTATION_CACHE_CACHE_DIR` | `./cache` | Image/transcode cache |
-| `MEDIASTATION_SECRETS_JWT_SECRET` | Auto-generated | JWT and encrypted settings seed |
-| `MEDIASTATION_APP_CORS_ORIGINS` | empty | Extra CORS origins |
-| `MEDIASTATION_TELEGRAM_API_BASE_URL` | `https://api.telegram.org` | Telegram Bot API base URL; use a reverse proxy when Telegram is blocked or slow |
-| `MEDIASTATION_TELEGRAM_PROXY_URL` | empty | Telegram outbound proxy, e.g. `http://172.17.0.1:7890` or `socks5://172.17.0.1:1080` |
-
-Runtime settings from the admin UI:
-
-- API keys: TMDb, Bangumi, TheTVDB, Fanart, OpenAI Compatible.
-- Sites: M-Team, NexusPHP, Unit3D, custom RSS.
-- Download clients: qBittorrent, Transmission, Aria2.
-- Notifications: Telegram, Bark, Webhook, Email. Telegram channels can use a per-channel API base URL or proxy, and test errors redact Bot Tokens automatically.
-- Playback profiles, permissions, scheduler tasks, storage settings.
-
----
-
-## 👥 Users and Permissions
-
-- The default administrator is created on first startup as `admin / admin123`. This account can be renamed, but it cannot be deleted or demoted and always keeps the highest privileges.
-- The open-source edition allows up to 20 users by default to reduce abuse on home NAS or public test instances. Binding a private license server can raise the quota according to the activated license policy.
-- Users created from the admin panel are “viewer users” by default: they can log in through the Web UI and Emby-compatible clients, browse libraries, play media, use external players, favorite items, and keep watch history.
-- Viewer users cannot scan libraries, rescrape metadata, delete media, probe media tracks, export NFO files, manage files, manage STRM links, manage download clients, create download tasks, or create/run subscriptions.
-- Because playback necessarily streams media data to the client, MediaStationGo can block download-management features and torrent/download tasks, but it cannot fully prevent an authorized browser or external player from saving an already authorized stream at the protocol level.
-
----
-
-## 🔐 Private License Server
-
-MediaStationGo includes a server-side bridge for the private standalone `MediaStationLicenseServer`:
-
-- License server: `ShukeBta/MediaStationLicenseServer`; a local backup may live at `C:\Users\Administrator\WorkBuddy\license_server_backup`.
-- MediaStationGo exposes `/api/license/activate`, `/api/license/status`, and `/api/license/heartbeat`; these backend routes proxy the License Server and do not expose the HMAC secret to browsers.
-- License Server public endpoints are `/api/v1/activate`, `/api/v1/status/:fingerprint`, and `/api/v1/heartbeat`.
-- Configure `license.server_url` and `license.hmac_secret` under Settings → License Server, then bind a key on the License page.
-- Without a valid license, MediaStationGo stays in open-source mode. With a valid license, the current implementation raises the user quota to the licensed tier.
-
-Example environment variables:
-
-```bash
-MEDIASTATION_LICENSE_SERVER_URL=http://127.0.0.1:8001
-MEDIASTATION_LICENSE_HMAC_SECRET=must-match-LICENSE_HMAC_SECRET
-```
-
----
-
-## 🎞️ On-Demand FFmpeg / ffprobe
-
-MediaStationGo does not keep `ffmpeg` or `ffprobe` running as resident daemons. They are launched only when needed:
-
-- `ffprobe` runs during library scanning or manual media-track probing.
-- `ffmpeg` runs when browser direct play is not suitable and HLS transcoding is required.
-- Admin tool-status checks or manual tool installation may briefly execute version checks/install logic.
-
-When playback stops, a transcode job is cancelled, or the service shuts down, the corresponding transcoding process is stopped. If there is no scanning, probing, or transcoding, `ffmpeg/ffprobe` should not continuously consume CPU.
-
-The default HLS profile is NAS-friendly: `MEDIASTATION_TRANSCODER_ENABLED=true` is the global switch, and disabling it prevents ffmpeg transcode jobs from starting; `MEDIASTATION_TRANSCODER_HARDWARE_ACCEL=false` is the hardware acceleration switch, and hardware encoders are used only when it is enabled together with `MEDIASTATION_TRANSCODER_ENCODER=nvenc/qsv/vaapi`; `MEDIASTATION_TRANSCODER_REALTIME=true` throttles input to playback speed, `MEDIASTATION_TRANSCODER_THREADS=2` caps software encoding threads, `MEDIASTATION_TRANSCODER_MAX_CONCURRENT=1` limits simultaneous transcodes, and `MEDIASTATION_TRANSCODER_IDLE_TIMEOUT_SECONDS=120` stops ffmpeg after the player stops requesting segments.
-
-The default Docker image now uses a trimmed runtime layer and does not bundle Intel VAAPI / mesa driver packages by default, reducing the Docker Hub vulnerability-scan surface. If you need a custom Intel VAAPI/QSV image, build with `docker buildx build --build-arg WITH_VAAPI=true ...`; NVIDIA NVENC still mainly depends on NVIDIA Container Toolkit on the host and GPU access when the container runs.
-
----
-
-## 🔍 Metadata Strategy
-
-MediaStationGo avoids unnecessary repeated scraping and tries not to overwrite good local metadata:
-
-1. Read local NFO, poster, fanart, season poster, and episode images first.
-2. Parse media type, title, year, season, and episode from file paths.
-3. Fill missing data through TMDb, TheTVDB, Bangumi, and Douban.
-4. Use Fanart.tv for higher-quality artwork when available.
-5. Adult content prefers local NFO/images, then enriches from public JavBus/JavDB pages.
-6. Existing local metadata is not blindly overwritten.
-
-Recommended layout:
-
-```text
-/media/Movies/Inception (2010)/Inception (2010).mkv
-/media/TV/Some Show/Season 01/Some Show S01E01.mkv
-/media/Anime/Anime Title/Season 01/Anime Title S01E01.mkv
-/media/Variety/Show Name/Season 2026/Show Name S2026E01.mkv
-/media/Adult/ABCD-123/ABCD-123.mp4
-```
-
-Common local artwork names:
-
-```text
-poster.jpg
-fanart.jpg
-folder.jpg
-season01-poster.jpg
-S01E01-thumb.jpg
-movie.nfo
-tvshow.nfo
-episode.nfo
-```
-
-### Smart Classification Directory Rules
-
-MediaStationGo separates download classification from final media organization:
-
-1. Download stage: subscriptions and site-search downloads are saved under the downloader root by category.
-2. Organization stage: manual or automatic organization moves files into the media library root, then into type and category folders.
-
-Recommended host directories:
-
-```text
-/your-nas/downloads
-/your-nas/media/电影
-/your-nas/media/电视剧
-```
-
-Container paths:
-
-```text
-/downloads
-/media/电影
-/media/电视剧
-```
-
-Download classification examples:
-
-```text
-/downloads/动画电影
-/downloads/国产剧
-/downloads/国漫
-/downloads/华语电影
-/downloads/日番
-/downloads/外语电影
-/downloads/综艺
-```
-
-Organized media examples:
-
-```text
-/media/电视剧/国产剧/Show Name (2026)/Season 01/Show Name - S01E01 - 第 1 集.mkv
-/media/电视剧/国漫/Anime Name (2026)/Season 01/Anime Name - S01E01 - 第 1 集.mkv
-/media/电视剧/欧美剧/Show Name (2026)/Season 01/Show Name - S01E01 - 第 1 集.mkv
-/media/电视剧/日番/Anime Name (2026)/Season 01/Anime Name - S01E01 - 第 1 集.mkv
-/media/电视剧/日韩剧/Show Name (2026)/Season 01/Show Name - S01E01 - 第 1 集.mkv
-/media/电视剧/综艺/Variety Name (2026)/Season 2026/Variety Name - S2026E01 - 第 1 集.mp4
-/media/电影/动画电影/Movie Name (2026)/Movie Name (2026) - 1080p.mkv
-/media/电影/华语电影/Movie Name (2026)/Movie Name (2026) - 1080p.mkv
-/media/电影/外语电影/Movie Name (2026)/Movie Name (2026) - 1080p.mkv
-```
-
-If the library root is set directly to `/media`, the organizer automatically adds the `电影/` or `电视剧/` type folder when smart classification is enabled. If the library root is already `/media/电影` or `/media/电视剧`, it will not add the type folder again.
-
-Automatic and manual organization are separate switches:
-
-- `downloads.smart_classify`: controls whether subscription/site-search downloads are automatically routed into category-specific save paths and qB categories; enabled by default.
-- `organizer.smart_classify`: controls smart category folders only.
-- `organizer.auto_after_download` / `organize.auto`: controls whether completed downloads are organized automatically.
-- If auto organization is disabled, use the Tools page to organize a library or a single media item manually.
-
-### Organization and Scraping Naming Templates
-
-Use separate organization templates by media type. TV shows, anime, and variety shows should keep title, year, season folder, season/episode number, and episode title. Movies should keep title, year, part marker, and video format.
-
-Recommended template for TV / anime / variety:
-
-```jinja
-{{title}}{% if year %} ({{year}}){% endif %}/Season {{season}}/{{title}} - {{season_episode}}{% if part %}-{{part}}{% endif %}{% if episode %} - 第 {{episode}} 集{% endif %}{{fileExt}}
-```
-
-Example output:
-
-```text
-Some Show (2024)/Season 01/Some Show - S01E01 - 第 1 集.mkv
-Some Anime (2025)/Season 02/Some Anime - S02E03 - 第 3 集.mkv
-Some Variety (2026)/Season 2026/Some Variety - S2026E01 - 第 1 集.mp4
-```
-
-Recommended template for movies:
-
-```jinja
-{{title}}{% if year %} ({{year}}){% endif %}/{{title}}{% if year %} ({{year}}){% endif %}{% if part %}-{{part}}{% endif %}{% if videoFormat %} - {{videoFormat}}{% endif %}{{fileExt}}
-```
-
-Example output:
-
-```text
-Inception (2010)/Inception (2010) - 1080p.mkv
-Dune (2021)/Dune (2021)-CD1 - 2160p.mkv
-```
-
-Common variables:
-
-| Variable | Description |
-| --- | --- |
-| `title` | Media title, preferably from local NFO or online metadata |
-| `year` | Year, appended when available |
-| `season` | Season number, used for `Season 01` folders |
-| `season_episode` | Season/episode code, such as `S01E01` or `S2026E01` |
-| `episode` | Episode number, used for Chinese episode titles |
-| `part` | Part marker, such as `CD1` or `Part1` |
-| `videoFormat` | Video format, such as `1080p`, `2160p`, or `WEB-DL` |
-| `fileExt` | Original file extension, such as `.mkv` or `.mp4` |
-
----
-
-## 🔎 Discovery, Search & Subscriptions
-
-### Multi-source discovery
-
-Discover supports:
-
-- TMDb: trending, popular movies, popular TV, top-rated movies.
-- Douban: hot movies, top movies, hot TV.
-- Bangumi: calendar and anime entries.
-
-### Smart search
-
-Smart search can combine:
-
-- Existing local library content.
-- Online results from TMDb, Douban, and Bangumi.
-- Subscription keywords and media types.
-
-### Subscription rules
-
-| Rule | Description |
-| --- | --- |
-| Media type | Movie, TV, anime, variety, or auto-detect |
-| Search mode | Keyword or IMDB ID |
-| Resolution | Best, 2160p, 1080p, 720p |
-| Quality | REMUX, BluRay, WEB-DL, HDTV |
-| Effects | HDR, Dolby Vision, Atmos |
-| Release groups | Preferred release groups |
-| Exclude words | Filter CAM, TS, low-quality releases |
-| Wash | Disabled by default; can prioritize resolution, quality, effects, or seeders |
-
-Download and subscription cards show only safe display metadata such as title, poster, speed, progress, and size. Raw torrent URLs are hidden to prevent tracker token leaks in multi-user deployments.
-
----
-
-## 🔌 External Clients & Emby Compatibility
-
-MediaStationGo exposes Emby/Jellyfin-style APIs for clients such as:
-
-- Infuse
-- VidHub
-- SenPlayer
-- Other Emby/Jellyfin-compatible players
-
-Server URL:
-
-```text
-http://:18080
-```
-
-If a client cannot connect, check:
-
-1. Docker port mapping, typically `18080:8080`.
-2. Firewall access from LAN to port `18080`.
-3. Username/password.
-4. Reverse proxy handling of `/api`, video streams, and Range requests.
-
-### Functional Reference to MoviePilot
-
-For external-client compatibility and media workflow integration, MediaStationGo references MoviePilot's mature product direction: a unified media library, subscription downloads, post-download organization, and Emby/Jellyfin-compatible APIs that connect the Web management interface with clients such as Infuse, VidHub, and SenPlayer. MediaStationGo does not aim to replace Emby/Jellyfin; instead, it provides the common browsing, playback, poster wall, season/episode hierarchy, progress tracking, and external-client access capabilities inside a lightweight Go service.
-
-Current compatibility focus:
-
-- Library, collection, season, and episode hierarchy output.
-- Basic metadata output such as posters, backdrops, descriptions, year, and ratings.
-- Stream URLs, HTTP Range support, playback progress, and resume.
-- Emby/Jellyfin-style endpoints required for external-client login, browsing, and playback.
-
-Still being improved:
-
-- More complete Emby/Jellyfin device capability negotiation.
-- More detailed transcoding profiles and subtitle capability declarations.
-- Multi-user permissions, library filtering, and playback-history synchronization.
-- Closed-loop integration with subscriptions, post-download organization, and upgrade rules.
-
-> MoviePilot is licensed under GPL-3.0. MediaStationGo references its public product ideas and interaction patterns only, and does not copy private data, secrets, tracker accounts, or incompatible implementations.
-
----
-
-## 🧠 AI and External Services
-
-Configure external services in the admin UI:
-
-| Service | Purpose |
-| --- | --- |
-| TMDb | Movie/TV posters, backdrops, descriptions |
-| Bangumi | Anime and Chinese anime metadata |
-| TheTVDB | Additional TV/season/episode metadata |
-| Fanart.tv | High-quality logos and artwork |
-| Douban | Chinese movie/TV search and recommendation supplement |
-| OpenAI Compatible | AI search, recommendations, operations assistant |
-
-For M-Team, use an API Access Token:
-
-```text
-Control Panel → Lab → Access Token
-HTTP Header: x-api-key
-```
-
-Avoid using cookies for open API calls to reduce account risk.
-
----
-
----
-
-## 🔐 Privacy and Safety
-
-The repository ignores personal/runtime data by default:
-
-- `data/`, `cache/`, `logs/`
-- `.tmp-deploy-data/`, `.tmp-deploy-server.*`
-- `.mediastation.pid`
-- `config.yaml`, `.env*`
-- `*.db`, `*.db-wal`, `*.log`
-- `web/dist/`, `node_modules/`, `bin/`
-- API keys, cookies, tokens, passwords, certificates, and secret files
-
-Before pushing, run:
-
-```bash
-git status --short
-git ls-files | grep -E 'data/|cache/|\.db|\.log|jwt_secret|config.yaml|\.env|token|apikey|password' || true
-```
-
----
-
-## ❓ FAQ
-
-### Pulling the GHCR image fails with `EOF`.
-
-`EOF` usually means the connection from your server/NAS to GHCR was interrupted. It is normally a network or registry connectivity issue, not a compose syntax issue. Try:
-
-```bash
-# 1. Clear any stale GHCR login state
-docker logout ghcr.io || true
-
-# 2. Pull the image directly
-docker pull ghcr.io/shukebta/mediastation-go:latest
-
-# 3. On x86_64/AMD64 hosts, retry with an explicit platform
-docker pull --platform linux/amd64 ghcr.io/shukebta/mediastation-go:latest
-
-# 4. Start after the pull succeeds
-docker compose up -d
-```
-
-If the server is behind a restricted network, configure a Docker daemon proxy that can reach GHCR. A shell-only proxy is often not inherited by the Docker service. The default compose file uses `pull_policy: missing` to avoid contacting GHCR on every container restart.
-
-If your media path is an absolute NAS path, use `/your-nas/...` instead of `./your-nas/...`; the latter is relative to the compose directory.
-
-### The Docker deployment starts but the browser cannot open the site.
-
-Check container status and logs:
+Check the container:
```bash
docker ps
-docker logs -f mediastation-go
+docker logs --tail=100 mediastation-go
```
-Use the host port, usually `http://:18080`.
+Then open:
-### External clients report that the server does not respond.
+```text
+http://SERVER_IP:18080
+```
-Check firewall rules, Docker port mappings, reverse proxy configuration, and LAN access to `18080`. The container listens on `8080`; the host default is `18080`.
+### 2. The library cannot find files
-### Posters are missing.
+Most cases are path mistakes.
-Check:
+- Docker maps media to `/media`.
+- In the web UI, use `/media/Movies`, not the original NAS path.
+- Docker maps downloads to `/downloads`; use `/downloads` as the organization source when possible.
-1. Local `poster.jpg`, `fanart.jpg`, and NFO files.
-2. TMDb / Bangumi / Douban connectivity.
-3. Proxy settings if the host is behind a restricted network.
-4. Whether file names contain clear title, year, season, and episode information.
+### 3. qBittorrent cannot connect
-### Why are raw download URLs hidden?
+If qBittorrent is on the host, try:
-PT download URLs often include private tokens. Download and subscription views intentionally hide raw URLs and only show safe metadata such as title, poster, speed, progress, and size.
+```text
+http://host.docker.internal:8085
+```
-### Which Docker package should be kept?
+If qBittorrent is on another machine, use that machine's LAN IP.
-Keep `ghcr.io/shukebta/mediastation-go`. The old `mediastationgo` package can be removed to avoid users pulling the wrong image.
+### 4. NAS CPU usage is high
+
+Suggested settings:
+
+- Set `ffprobe.max_concurrent` to `1`.
+- Enable automatic organization, scrape-after-scan, and boot cloud scan only when you really need them.
+- Avoid frequent full-library scans on large libraries. Prefer manual scan or scheduled night sync.
+
+### 5. Should I use `.env`?
+
+Beginners should not. Editing `docker-compose.yml` directly is easier to understand.
+
+`.env` is useful only for advanced users who reuse the same compose file on multiple machines. The repository keeps `docker-compose.simple.env.example`, but it is not the main path.
---
-## 🗺️ Roadmap
+## Features
-- Broader Emby/Jellyfin client compatibility.
-- Stronger adult metadata handling from local files and public pages.
-- More granular subscription wash and post-download organization rules.
-- Better mobile and TV interaction patterns.
-- Plugin-style site adapters and notification providers.
-- More end-to-end tests and automated screenshot generation.
+| Area | Features |
+| --- | --- |
+| Libraries | Movies, TV shows, anime, variety, music, adult content |
+| Metadata | NFO, local artwork, TMDb, TheTVDB, Bangumi, Douban, Fanart, JavBus/JavDB |
+| Playback | Web playback, HTTP Range, HLS transcoding, direct links, STRMURL, 302 redirect |
+| External clients | Emby-style APIs for many third-party apps |
+| Downloads | qBittorrent, site search, subscriptions, post-download organization |
+| File manager | Browse, organize, copy, move, hardlink, symlink |
+| Operations | Task queue, recycle bin, duplicate files, notifications, logs |
+| AI | OpenAI-compatible API, AI search, recommendations, assistant |
---
-## 🤝 Contributing
+## Screenshots
-Issues, pull requests, site adapters, scraping rules, UI improvements, and documentation fixes are welcome.
+
+Preview
-Before submitting changes, please run:
+| Login | Home |
+| --- | --- |
+|
|
|
+
+| Libraries | Player |
+| --- | --- |
+|
|
|
+
+
+
+---
+
+## Development
+
+Regular users should use Docker. Developers can run:
+
+```bash
+go run ./cmd/server
+```
+
+Frontend:
+
+```bash
+cd web
+npm install
+npm run dev
+```
+
+Tests:
```bash
go test ./...
@@ -996,13 +374,15 @@ cd web && npm run build
---
-## 👥 Developer Group
+## Community and Friends
-- Telegram:
+- Telegram group:
+- NodeSeek: [https://www.nodeseek.com/](https://www.nodeseek.com/)
+- LINUX DO: [https://linux.do/](https://linux.do/)
---
-## 🍜 Donation
+## Donation
If MediaStationGo saves you time, feel free to buy the author a bowl of noodles.
@@ -1010,7 +390,7 @@ If MediaStationGo saves you time, feel free to buy the author a bowl of noodles.
---
-## ⭐ Star History
+## Star History
@@ -1022,14 +402,16 @@ If MediaStationGo saves you time, feel free to buy the author a bowl of noodles.
---
-## 📄 License and Non-Commercial Statement
+## License and Non-Commercial Statement
-This project uses `GPL-3.0` as its base license. See [LICENSE](LICENSE) for details. The maintainers also state and request the following usage boundary:
+This project uses `GPL-3.0` as its base license. See [LICENSE](LICENSE).
+
+The maintainers also state and request:
- The project is intended for personal learning, home NAS, self-hosted media, non-commercial research, and community collaboration.
- Without explicit written permission from the author, do not use this project or derivative versions for commercial resale, paid hosting, paid SaaS, pre-installed commercial devices, closed-source redistribution, or other profit-oriented commercial use.
-- For commercial cooperation, enterprise deployment, custom development, integrated redistribution, or commercial authorization, contact the author first to confirm the authorization scope.
-- If there is any interpretive difference between the README non-commercial statement and the formal `GPL-3.0` license text, the code license is governed by [LICENSE](LICENSE); commercial usage should additionally obtain author permission.
+- For commercial cooperation, enterprise deployment, custom development, integrated redistribution, or commercial authorization, contact the author first.
+- If there is any interpretive difference between this README and the formal `GPL-3.0` license text, the code license is governed by [LICENSE](LICENSE); commercial usage should additionally obtain author permission.
---
diff --git a/docker-compose.simple.env.example b/docker-compose.simple.env.example
index e839ded..03c1887 100644
--- a/docker-compose.simple.env.example
+++ b/docker-compose.simple.env.example
@@ -1,10 +1,14 @@
-# MediaStationGo 新手部署 .env 示例
-# 只改下面两个路径即可;必须是宿主机/NAS真实路径。
+# MediaStationGo 可选 .env 示例
+#
+# 新手不建议使用 .env;请直接改 docker-compose.yml,更直观。
+# 这个文件只给进阶用户复用多台机器配置时参考。
+#
+# 如果你确实要用 .env,下面两个路径必须是宿主机/NAS真实路径。
MEDIASTATION_MEDIA_DIR=/your-nas/media
MEDIASTATION_DOWNLOAD_DIR=/your-nas/downloads
-# 可选:固定镜像版本。不写则使用 latest。
+# 可选:固定镜像版本。不写则使用 compose 里的 latest。
# MEDIASTATION_IMAGE_TAG=MediaStationGo-v0.0.32
# 可选:修改访问端口。不写则使用 18080。
diff --git a/docker-compose.yml b/docker-compose.yml
index d77f696..fa6768b 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -1,53 +1,68 @@
-# MediaStationGo 极简 Docker Compose 部署文件
+# MediaStationGo 最简单 Docker Compose 部署文件
#
-# 新手推荐:直接在本文件里填写 NAS/服务器真实路径,不必先创建 .env。
-# 需要改两组位置:
-# 1. volumes 里媒体库和下载目录左侧的宿主机路径。
-# 2. environment 里 MEDIASTATION_MEDIA_DIR / MEDIASTATION_DOWNLOAD_DIR 的同名真实路径。
+# 新手建议:
+# 1. 不用 .env。
+# 2. 直接改本文件。
+# 3. 第一次可以不改路径,先用当前目录下的 ./media 和 ./downloads 体验。
#
-# 示例:
-# - /vol1/1000/Docker/moviepilot-v2/media:/media:ro
-# - /vol1/1000/qBittorrent/downloads:/downloads
-# MEDIASTATION_MEDIA_DIR: /vol1/1000/Docker/moviepilot-v2/media
-# MEDIASTATION_DOWNLOAD_DIR: /vol1/1000/qBittorrent/downloads
+# 启动:
+# docker compose up -d
#
-# .env 是可选高级写法,适合多环境复用;README 后半部分有说明。
+# 访问:
+# http://服务器IP:18080
#
-# 启动:docker compose up -d
-# 访问:http://<服务器IP>:18080
-# 默认账号:admin / admin123
+# 默认账号:
+# admin / admin123
services:
mediastation-go:
- image: ghcr.io/shukebta/mediastation-go:${MEDIASTATION_IMAGE_TAG:-latest}
+ image: ghcr.io/shukebta/mediastation-go:latest
container_name: mediastation-go
restart: unless-stopped
init: true
- pull_policy: missing
+ # 浏览器访问端口。
+ # 如果 18080 被占用,可以改成 "19011:8080" 之类。
ports:
- - "${MEDIASTATION_HTTP_PORT:-18080}:8080"
+ - "18080:8080"
- # 让容器可以用 http://host.docker.internal:端口 访问宿主机上的 qBittorrent 等服务。
+ # 让容器可以访问宿主机上的 qBittorrent。
+ # qB 地址通常可填:http://host.docker.internal:8085
extra_hosts:
- "host.docker.internal:host-gateway"
volumes:
- # 程序数据与缓存:放在部署目录下,方便备份。
- - ${MEDIASTATION_DATA_DIR:-./data}:/data
- - ${MEDIASTATION_CACHE_DIR:-./cache}:/cache
+ # 程序数据:数据库、账号、设置都在这里。升级前主要备份它。
+ - ./data:/data
- # 媒体库与下载目录:左边是宿主机/NAS真实路径,右边是容器内路径。
- # 新手部署时推荐直接把左边改成真实绝对路径,不要写 ./vol1/...。
- # Web 页面里添加媒体库请填写 /media/电影、/media/电视剧 等容器路径。
- - ${MEDIASTATION_MEDIA_DIR:-./media}:/media:ro
- - ${MEDIASTATION_DOWNLOAD_DIR:-./downloads}:/downloads
+ # 缓存目录:海报缓存、临时文件等。通常不用备份。
+ - ./cache:/cache
+
+ # 媒体库目录。
+ # 新手可先把影片放到当前目录的 ./media。
+ # NAS 用户把左边改成真实路径,例如:
+ # - /vol1/1000/Media:/media:ro
+ # Windows Docker Desktop 示例:
+ # - D:/Media:/media:ro
+ - ./media:/media:ro
+
+ # 下载目录。
+ # qB 下载目录、手动整理、自动整理会经常用到。
+ # NAS 示例:
+ # - /vol1/1000/Downloads:/downloads
+ # Windows Docker Desktop 示例:
+ # - D:/Downloads:/downloads
+ - ./downloads:/downloads
environment:
- TZ: ${TZ:-Asia/Shanghai}
- PUID: ${PUID:-1000}
- PGID: ${PGID:-1000}
+ TZ: Asia/Shanghai
+ # Linux/NAS 用户权限。一般 1000 就可以。
+ # 如果写入文件权限不对,再改成宿主机实际用户的 uid/gid。
+ PUID: "1000"
+ PGID: "1000"
+
+ # 程序基础配置,通常不用改。
MEDIASTATION_APP_HOST: 0.0.0.0
MEDIASTATION_APP_PORT: 8080
MEDIASTATION_APP_WEB_DIR: /app/web/dist
@@ -55,20 +70,24 @@ services:
MEDIASTATION_DATABASE_DB_PATH: /data/mediastation.db
MEDIASTATION_CACHE_CACHE_DIR: /cache
- # 路径提示:这里要和 volumes 左侧真实路径保持一致。
- # 用于把宿主机路径自动换算成容器路径,避免误填 NAS 原始路径时报不可访问。
- MEDIASTATION_MEDIA_DIR: ${MEDIASTATION_MEDIA_DIR:-./media}
+ # 路径换算配置。
+ # 如果上面 volumes 的 ./media 改成 /vol1/1000/Media,
+ # 这里也要改成同一个宿主机真实路径。
+ MEDIASTATION_MEDIA_DIR: ./media
MEDIASTATION_MEDIA_CONTAINER_DIR: /media
- MEDIASTATION_DOWNLOAD_DIR: ${MEDIASTATION_DOWNLOAD_DIR:-./downloads}
+
+ # 如果上面 volumes 的 ./downloads 改成 /vol1/1000/Downloads,
+ # 这里也要改成同一个宿主机真实路径。
+ MEDIASTATION_DOWNLOAD_DIR: ./downloads
MEDIASTATION_DOWNLOAD_CONTAINER_DIR: /downloads
- # NAS 友好的低负载转码默认值;可在系统设置里继续调整。
- MEDIASTATION_TRANSCODER_ENABLED: ${MEDIASTATION_TRANSCODER_ENABLED:-true}
- MEDIASTATION_TRANSCODER_HARDWARE_ACCEL: ${MEDIASTATION_TRANSCODER_HARDWARE_ACCEL:-false}
- MEDIASTATION_TRANSCODER_REALTIME: ${MEDIASTATION_TRANSCODER_REALTIME:-true}
- MEDIASTATION_TRANSCODER_THREADS: ${MEDIASTATION_TRANSCODER_THREADS:-2}
- MEDIASTATION_TRANSCODER_MAX_CONCURRENT: ${MEDIASTATION_TRANSCODER_MAX_CONCURRENT:-1}
- MEDIASTATION_TRANSCODER_IDLE_TIMEOUT_SECONDS: ${MEDIASTATION_TRANSCODER_IDLE_TIMEOUT_SECONDS:-120}
+ # NAS 友好的低负载默认值。
+ MEDIASTATION_TRANSCODER_ENABLED: "true"
+ MEDIASTATION_TRANSCODER_HARDWARE_ACCEL: "false"
+ MEDIASTATION_TRANSCODER_REALTIME: "true"
+ MEDIASTATION_TRANSCODER_THREADS: "2"
+ MEDIASTATION_TRANSCODER_MAX_CONCURRENT: "1"
+ MEDIASTATION_TRANSCODER_IDLE_TIMEOUT_SECONDS: "120"
healthcheck:
test: ["CMD-SHELL", "busybox wget -qO- http://127.0.0.1:8080/api/health || exit 1"]
@@ -77,8 +96,7 @@ services:
retries: 5
start_period: 30s
- # 限制容器日志体积:默认 json-file 驱动不设上限,
- # 长期运行会持续吃宿主机磁盘与 IO。
+ # 限制 Docker 日志大小,避免长期运行把磁盘写满。
logging:
driver: json-file
options:
diff --git a/internal/service/telegram_api_test.go b/internal/service/telegram_api_test.go
index 2ed8a26..026e3e8 100644
--- a/internal/service/telegram_api_test.go
+++ b/internal/service/telegram_api_test.go
@@ -96,14 +96,20 @@ func TestTelegramTargetChatIDsUsesLegacyPrivateChatID(t *testing.T) {
func TestRegisterTelegramBotCommands(t *testing.T) {
var gotPath string
- var payload struct {
+ var payloads []struct {
Commands []telegramBotCommand `json:"commands"`
+ Scope map[string]any `json:"scope"`
}
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
gotPath = r.URL.Path
+ var payload struct {
+ Commands []telegramBotCommand `json:"commands"`
+ Scope map[string]any `json:"scope"`
+ }
if err := json.NewDecoder(r.Body).Decode(&payload); err != nil {
t.Fatalf("decode payload: %v", err)
}
+ payloads = append(payloads, payload)
_, _ = w.Write([]byte(`{"ok":true}`))
}))
defer server.Close()
@@ -118,9 +124,55 @@ func TestRegisterTelegramBotCommands(t *testing.T) {
if gotPath != "/bot123456:ABC/setMyCommands" {
t.Fatalf("path = %q", gotPath)
}
- if len(payload.Commands) == 0 || payload.Commands[0].Command != "start" {
- t.Fatalf("commands not registered: %#v", payload.Commands)
+ if len(payloads) < 3 {
+ t.Fatalf("expected default/private/group command registrations, got %d", len(payloads))
}
+ if len(payloads[0].Commands) == 0 || payloads[0].Commands[0].Command != "start" {
+ t.Fatalf("commands not registered: %#v", payloads[0].Commands)
+ }
+ var groupCommands []telegramBotCommand
+ for _, payload := range payloads {
+ if payload.Scope["type"] == "all_group_chats" {
+ groupCommands = payload.Commands
+ break
+ }
+ }
+ if len(groupCommands) == 0 {
+ t.Fatal("group command scope was not registered")
+ }
+ for _, command := range groupCommands {
+ if command.Command == "users" || command.Command == "status" || command.Command == "cleanup" || command.Command == "register" || command.Command == "redeem" {
+ t.Fatalf("group commands must not expose private/admin command %q", command.Command)
+ }
+ }
+}
+
+func TestTelegramCommandMenusSeparateGroupAndAdminCommands(t *testing.T) {
+ groupNames := telegramCommandNames(telegramGroupBotCommandMenu())
+ for _, forbidden := range []string{"status", "search", "downloads", "stats", "users", "cleanup", "cleanup_rule", "register", "redeem"} {
+ if groupNames[forbidden] {
+ t.Fatalf("group menu should not expose %s", forbidden)
+ }
+ }
+ for _, required := range []string{"start", "menu", "help", "account", "signin", "devices", "kick", "hideadult"} {
+ if !groupNames[required] {
+ t.Fatalf("group menu should include %s", required)
+ }
+ }
+ adminNames := telegramCommandNames(telegramAdminBotCommandMenu())
+ for _, required := range []string{"users", "status", "cleanup_rule"} {
+ if !adminNames[required] {
+ t.Fatalf("admin menu should include %s", required)
+ }
+ }
+}
+
+func telegramCommandNames(commands []telegramBotCommand) map[string]bool {
+ names := make(map[string]bool, len(commands))
+ for _, command := range commands {
+ names[command.Command] = true
+ }
+ return names
}
func TestTelegramProxyCandidatesDefaultLocalFallbacks(t *testing.T) {
diff --git a/internal/service/telegram_bot.go b/internal/service/telegram_bot.go
index 8126d9c..54d414a 100644
--- a/internal/service/telegram_bot.go
+++ b/internal/service/telegram_bot.go
@@ -251,12 +251,31 @@ func telegramCommandName(text string) string {
return cmd
}
+func telegramIsGroupChat(chatType string) bool {
+ return chatType != "" && chatType != "private"
+}
+
+func telegramGroupPrivateAdminHint() string {
+ return "管理命令请私聊 Bot 使用 /menu 或对应管理员命令,避免在群组公开管理面板。"
+}
+
+func telegramGroupPrivateUserHint(action string) string {
+ action = strings.TrimSpace(action)
+ if action == "" {
+ action = "此操作"
+ }
+ return action + "包含账号凭据或敏感信息,请私聊 Bot 操作;群组内仅开放账号状态、签到、设备与成人目录开关。"
+}
+
// cmdStart 处理 /start 命令。
func (s *TelegramBotService) cmdStart(ctx context.Context, msg *TelegramMessage, args []string) telegramCommandReply {
name := msg.From.FirstName
if msg.From.Username != "" {
name = "@" + msg.From.Username
}
+ if telegramIsGroupChat(msg.Chat.Type) && len(args) > 0 {
+ return telegramCommandReply{Text: telegramGroupPrivateUserHint("绑定账号")}
+ }
if len(args) == 0 {
if binding := s.telegramBinding(ctx, msg.From.ID); binding != nil {
user, _ := s.repo.User.FindByID(ctx, binding.UserID)
@@ -420,6 +439,21 @@ func (s *TelegramBotService) cmdRegistrationToggle(ctx context.Context, args []s
// cmdHelp 处理 /help 命令。
func (s *TelegramBotService) cmdHelp(ctx context.Context, msg *TelegramMessage) string {
channel := s.findChannelForMessage(ctx, msg)
+ if telegramIsGroupChat(msg.Chat.Type) {
+ adminHint := ""
+ if s.telegramUserIsAdmin(ctx, channel, msg.From.ID) {
+ adminHint = "\n\n管理员命令和管理面板请私聊 Bot 使用,避免在群组公开。"
+ }
+ return "MediaStationGo 群组可用命令\n\n" +
+ "/menu — 打开群组自助菜单\n" +
+ "/account — 查看账号状态\n" +
+ "/signin — 签到\n" +
+ "/devices — 查看登录设备\n" +
+ "/kick all|编号 — 踢下线设备\n" +
+ "/hideadult on|off — 隐藏或显示成人目录\n\n" +
+ "绑定、注册、兑换、改名、改密等包含敏感信息的操作请私聊 Bot。" +
+ adminHint
+ }
if !s.telegramUserIsAdmin(ctx, channel, msg.From.ID) {
register := ""
if s.openRegEnabled(ctx) {
diff --git a/internal/service/telegram_bot_user_test.go b/internal/service/telegram_bot_user_test.go
index ca92824..1de7c5d 100644
--- a/internal/service/telegram_bot_user_test.go
+++ b/internal/service/telegram_bot_user_test.go
@@ -134,6 +134,74 @@ func TestTelegramRegisterRespectsAdminToggle(t *testing.T) {
}
}
+func TestTelegramGroupHidesAdminPanelFromRegularUsers(t *testing.T) {
+ ctx := t.Context()
+ _, bot := newBotTestService(t)
+ channel := &model.NotifyChannel{Name: "Telegram", Type: "telegram", Enabled: true, Config: `{"group_chat_id":"-100123","admin_user_ids":"9001"}`}
+ msg := &TelegramMessage{
+ From: TelegramUser{ID: 9002, Username: "viewer", FirstName: "Viewer"},
+ Chat: TelegramChat{ID: -100123, Type: "supergroup"},
+ }
+
+ menu := bot.mainMenu(ctx, channel, msg)
+ if strings.Contains(menu.Text, "管理员") || telegramReplyHasButtonPrefix(menu, "adm_") {
+ t.Fatalf("regular group user must not see admin panel: text=%q buttons=%#v", menu.Text, menu.Buttons)
+ }
+
+ reply, err := bot.executeCommand(ctx, channel, msg, "/users")
+ if err != nil {
+ t.Fatal(err)
+ }
+ if reply.Text != "" || len(reply.Buttons) != 0 {
+ t.Fatalf("regular group user admin command should be ignored, got %#v", reply)
+ }
+
+ reply, err = bot.executeCommand(ctx, channel, msg, "/start viewer secret-pass")
+ if err != nil {
+ t.Fatal(err)
+ }
+ if !strings.Contains(reply.Text, "请私聊 Bot") {
+ t.Fatalf("group credential command should point to private chat, got %q", reply.Text)
+ }
+}
+
+func TestTelegramGroupAdminMenuDoesNotExposeButtonsInGroup(t *testing.T) {
+ ctx := t.Context()
+ _, bot := newBotTestService(t)
+ channel := &model.NotifyChannel{Name: "Telegram", Type: "telegram", Enabled: true, Config: `{"group_chat_id":"-100123","admin_user_ids":"9001"}`}
+ msg := &TelegramMessage{
+ From: TelegramUser{ID: 9001, Username: "admin", FirstName: "Admin"},
+ Chat: TelegramChat{ID: -100123, Type: "group"},
+ }
+
+ menu := bot.mainMenu(ctx, channel, msg)
+ if telegramReplyHasButtonPrefix(menu, "adm_") {
+ t.Fatalf("admin group menu must not expose admin buttons publicly: %#v", menu.Buttons)
+ }
+ if !strings.Contains(menu.Text, "请私聊 Bot") {
+ t.Fatalf("admin group menu should tell admins to use private chat, got %q", menu.Text)
+ }
+
+ reply, handled := bot.handleMenuCallback(ctx, channel, msg, "adm_users")
+ if !handled {
+ t.Fatal("admin callback should be handled")
+ }
+ if !strings.Contains(reply.Text, "请私聊 Bot") || telegramReplyHasButtonPrefix(reply, "adm_") {
+ t.Fatalf("group admin callback should not render admin panel publicly: %#v", reply)
+ }
+}
+
+func telegramReplyHasButtonPrefix(reply telegramCommandReply, prefix string) bool {
+ for _, row := range reply.Buttons {
+ for _, button := range row {
+ if strings.HasPrefix(button.Data, prefix) {
+ return true
+ }
+ }
+ }
+ return false
+}
+
func TestTelegramStartClearsStaleUserBinding(t *testing.T) {
repos, auth, _, _ := newAuthTestServices(t)
if err := repos.DB.Create(&model.TelegramBinding{
diff --git a/internal/service/telegram_commands.go b/internal/service/telegram_commands.go
index a69be43..5d51b57 100644
--- a/internal/service/telegram_commands.go
+++ b/internal/service/telegram_commands.go
@@ -16,31 +16,32 @@ type telegramCommandDefinition struct {
Aliases []string
AdminOnly bool
AdminOnlyText string
+ GroupAllowed bool
Handle telegramCommandHandler
}
func (s *TelegramBotService) telegramCommandDefinitions(ctx context.Context, channel *model.NotifyChannel, msg *TelegramMessage) []telegramCommandDefinition {
adminOnly := "此命令仅管理员可用。"
return []telegramCommandDefinition{
- {Aliases: []string{"/start"}, Handle: func(args []string) (telegramCommandReply, error) {
+ {Aliases: []string{"/start"}, GroupAllowed: true, Handle: func(args []string) (telegramCommandReply, error) {
if len(args) == 0 {
return s.mainMenu(ctx, channel, msg), nil
}
return s.cmdStart(ctx, msg, args), nil
}},
- {Aliases: []string{"/menu"}, Handle: func(args []string) (telegramCommandReply, error) { return s.mainMenu(ctx, channel, msg), nil }},
- {Aliases: []string{"/cancel"}, Handle: func(args []string) (telegramCommandReply, error) {
+ {Aliases: []string{"/menu"}, GroupAllowed: true, Handle: func(args []string) (telegramCommandReply, error) { return s.mainMenu(ctx, channel, msg), nil }},
+ {Aliases: []string{"/cancel"}, GroupAllowed: true, Handle: func(args []string) (telegramCommandReply, error) {
s.takePending(int64(msg.From.ID))
return telegramCommandReply{Text: "已取消当前操作。"}, nil
}},
- {Aliases: []string{"/help"}, Handle: func(args []string) (telegramCommandReply, error) {
+ {Aliases: []string{"/help"}, GroupAllowed: true, Handle: func(args []string) (telegramCommandReply, error) {
return telegramCommandReply{Text: s.cmdHelp(ctx, msg)}, nil
}},
- {Aliases: []string{"/hideadult", "/hide_adult", "/adult"}, Handle: func(args []string) (telegramCommandReply, error) { return s.cmdHideAdult(ctx, msg, args), nil }},
- {Aliases: []string{"/account", "/me"}, Handle: func(args []string) (telegramCommandReply, error) { return s.replyAccount(ctx, msg), nil }},
- {Aliases: []string{"/signin", "/checkin"}, Handle: func(args []string) (telegramCommandReply, error) { return s.replySignIn(ctx, msg), nil }},
- {Aliases: []string{"/devices"}, Handle: func(args []string) (telegramCommandReply, error) { return s.replyDevices(ctx, msg), nil }},
- {Aliases: []string{"/kick"}, Handle: func(args []string) (telegramCommandReply, error) { return s.cmdKick(ctx, msg, args), nil }},
+ {Aliases: []string{"/hideadult", "/hide_adult", "/adult"}, GroupAllowed: true, Handle: func(args []string) (telegramCommandReply, error) { return s.cmdHideAdult(ctx, msg, args), nil }},
+ {Aliases: []string{"/account", "/me"}, GroupAllowed: true, Handle: func(args []string) (telegramCommandReply, error) { return s.replyAccount(ctx, msg), nil }},
+ {Aliases: []string{"/signin", "/checkin"}, GroupAllowed: true, Handle: func(args []string) (telegramCommandReply, error) { return s.replySignIn(ctx, msg), nil }},
+ {Aliases: []string{"/devices"}, GroupAllowed: true, Handle: func(args []string) (telegramCommandReply, error) { return s.replyDevices(ctx, msg), nil }},
+ {Aliases: []string{"/kick"}, GroupAllowed: true, Handle: func(args []string) (telegramCommandReply, error) { return s.cmdKick(ctx, msg, args), nil }},
{Aliases: []string{"/setname", "/rename"}, Handle: func(args []string) (telegramCommandReply, error) { return s.cmdSetName(ctx, msg, args), nil }},
{Aliases: []string{"/setpass", "/passwd", "/password"}, Handle: func(args []string) (telegramCommandReply, error) { return s.cmdSetPass(ctx, msg, args), nil }},
{Aliases: []string{"/redeem"}, Handle: func(args []string) (telegramCommandReply, error) { return s.cmdRedeem(ctx, channel, msg, args), nil }},
@@ -102,6 +103,12 @@ func (s *TelegramBotService) executeCommand(ctx context.Context, channel *model.
if !ok {
return telegramCommandReply{Text: fmt.Sprintf("未知命令: %s\n\n输入 /help 查看可用命令列表。", cmd)}, nil
}
+ if telegramIsGroupChat(msg.Chat.Type) && !def.GroupAllowed {
+ if def.AdminOnly && s.telegramUserIsAdmin(ctx, channel, msg.From.ID) {
+ return telegramCommandReply{Text: telegramGroupPrivateAdminHint()}, nil
+ }
+ return telegramCommandReply{}, nil
+ }
if def.AdminOnly && !s.telegramUserIsAdmin(ctx, channel, msg.From.ID) {
return telegramCommandReply{Text: def.AdminOnlyText}, nil
}
@@ -133,6 +140,10 @@ type telegramBotCommand struct {
}
func telegramBotCommandMenu() []telegramBotCommand {
+ return telegramPrivateBotCommandMenu()
+}
+
+func telegramPrivateBotCommandMenu() []telegramBotCommand {
return []telegramBotCommand{
{Command: "start", Description: "绑定账号或打开主菜单"},
{Command: "menu", Description: "打开功能菜单"},
@@ -144,22 +155,62 @@ func telegramBotCommandMenu() []telegramBotCommand {
{Command: "hideadult", Description: "隐藏/显示成人媒体库"},
{Command: "redeem", Description: "兑换注册码或续期码"},
{Command: "register", Description: "注册新账号"},
- {Command: "status", Description: "系统运行状态(管理员)"},
- {Command: "search", Description: "搜索媒体库(管理员)"},
- {Command: "downloads", Description: "下载列表(管理员)"},
- {Command: "stats", Description: "媒体库统计(管理员)"},
- {Command: "users", Description: "用户管理(管理员)"},
- {Command: "cleanup", Description: "删号规则巡检(管理员)"},
- {Command: "cleanup_rule", Description: "保号规则管理(管理员)"},
}
}
+func telegramGroupBotCommandMenu() []telegramBotCommand {
+ return []telegramBotCommand{
+ {Command: "start", Description: "打开群组自助菜单"},
+ {Command: "menu", Description: "打开群组自助菜单"},
+ {Command: "help", Description: "查看群组可用命令"},
+ {Command: "account", Description: "查看账号状态"},
+ {Command: "signin", Description: "签到"},
+ {Command: "devices", Description: "查看登录设备"},
+ {Command: "kick", Description: "踢下线设备"},
+ {Command: "hideadult", Description: "隐藏/显示成人媒体库"},
+ }
+}
+
+func telegramAdminBotCommandMenu() []telegramBotCommand {
+ commands := append([]telegramBotCommand{}, telegramPrivateBotCommandMenu()...)
+ commands = append(commands,
+ telegramBotCommand{Command: "status", Description: "系统运行状态(管理员)"},
+ telegramBotCommand{Command: "search", Description: "搜索媒体库(管理员)"},
+ telegramBotCommand{Command: "downloads", Description: "下载列表(管理员)"},
+ telegramBotCommand{Command: "stats", Description: "媒体库统计(管理员)"},
+ telegramBotCommand{Command: "users", Description: "用户管理(管理员)"},
+ telegramBotCommand{Command: "cleanup", Description: "删号规则巡检(管理员)"},
+ telegramBotCommand{Command: "cleanup_rule", Description: "保号规则管理(管理员)"},
+ )
+ return commands
+}
+
func registerTelegramBotCommands(ctx context.Context, cfg map[string]string) error {
if strings.TrimSpace(cfg["bot_token"]) == "" {
return nil
}
- payload := map[string]interface{}{
- "commands": telegramBotCommandMenu(),
+ normalizeTelegramConfig(cfg)
+ if err := telegramSetBotCommands(ctx, cfg, telegramPrivateBotCommandMenu(), nil); err != nil {
+ return err
+ }
+ if err := telegramSetBotCommands(ctx, cfg, telegramPrivateBotCommandMenu(), map[string]interface{}{"type": "all_private_chats"}); err != nil {
+ return err
+ }
+ if err := telegramSetBotCommands(ctx, cfg, telegramGroupBotCommandMenu(), map[string]interface{}{"type": "all_group_chats"}); err != nil {
+ return err
+ }
+
+ adminCommands := telegramAdminBotCommandMenu()
+ for _, adminID := range telegramConfiguredUserIDs(cfg["admin_user_ids"]) {
+ _ = telegramSetBotCommands(ctx, cfg, adminCommands, map[string]interface{}{"type": "chat", "chat_id": adminID})
+ }
+ return nil
+}
+
+func telegramSetBotCommands(ctx context.Context, cfg map[string]string, commands []telegramBotCommand, scope map[string]interface{}) error {
+ payload := map[string]interface{}{"commands": commands}
+ if scope != nil {
+ payload["scope"] = scope
}
return telegramPostJSON(ctx, cfg, "setMyCommands", payload, 15*time.Second)
}
diff --git a/internal/service/telegram_menu.go b/internal/service/telegram_menu.go
index a2dc0bb..539a62f 100644
--- a/internal/service/telegram_menu.go
+++ b/internal/service/telegram_menu.go
@@ -55,11 +55,36 @@ func (s *TelegramBotService) boundUser(ctx context.Context, telegramUserID int)
// extra management section.
func (s *TelegramBotService) mainMenu(ctx context.Context, channel *model.NotifyChannel, msg *TelegramMessage) telegramCommandReply {
isAdmin := s.telegramUserIsAdmin(ctx, channel, msg.From.ID)
+ isGroup := telegramIsGroupChat(msg.Chat.Type)
user := s.boundUser(ctx, msg.From.ID)
var rows [][]telegramInlineButton
var header string
+ if isGroup {
+ if user == nil {
+ header = "MediaStationGo 群组自助菜单\n\n你还没有绑定媒体中心账号。绑定、注册、兑换等包含敏感信息的操作请私聊 Bot。"
+ } else {
+ adult := map[bool]string{true: "已隐藏", false: "已显示"}[user.HideAdult]
+ header = fmt.Sprintf("MediaStationGo 群组自助菜单\n\n账号:%s\n到期:%s\n成人目录:%s",
+ user.Username, formatExpiry(user.ExpiredAt), adult)
+ rows = append(rows,
+ []telegramInlineButton{
+ {Text: "👤 我的账号", Data: "act_account"},
+ {Text: "📅 签到", Data: "act_signin"},
+ },
+ []telegramInlineButton{
+ {Text: "📱 我的设备", Data: "act_devices"},
+ {Text: map[bool]string{true: "🔞 显示成人目录", false: "🔞 隐藏成人目录"}[user.HideAdult], Data: "adult_toggle"},
+ },
+ )
+ }
+ if isAdmin {
+ header += "\n\n" + telegramGroupPrivateAdminHint()
+ }
+ return telegramCommandReply{Text: header, Buttons: rows}
+ }
+
if user == nil {
header = "MediaStationGo\n\n你还没有绑定媒体中心账号。"
rows = append(rows, []telegramInlineButton{{Text: "🔗 绑定账号", Data: "act_bind"}})
@@ -109,6 +134,7 @@ func (s *TelegramBotService) mainMenu(ctx context.Context, channel *model.Notify
// handleMenuCallback routes inline-button taps. Returns (reply, handled).
func (s *TelegramBotService) handleMenuCallback(ctx context.Context, channel *model.NotifyChannel, msg *TelegramMessage, data string) (telegramCommandReply, bool) {
isAdmin := s.telegramUserIsAdmin(ctx, channel, msg.From.ID)
+ isGroup := telegramIsGroupChat(msg.Chat.Type)
switch {
case data == "noop":
@@ -116,17 +142,29 @@ func (s *TelegramBotService) handleMenuCallback(ctx context.Context, channel *mo
case data == "menu_main":
return s.mainMenu(ctx, channel, msg), true
case data == "act_bind":
+ if isGroup {
+ return telegramCommandReply{Text: telegramGroupPrivateUserHint("绑定账号")}, true
+ }
return telegramCommandReply{Text: "请发送:/start 用户名 密码 绑定已有账号。"}, true
case data == "act_register":
+ if isGroup {
+ return telegramCommandReply{Text: telegramGroupPrivateUserHint("注册账号")}, true
+ }
if !s.openRegEnabled(ctx) {
return telegramCommandReply{Text: "注册功能未开放,请联系管理员。"}, true
}
s.setPending(int64(msg.From.ID), "register")
return telegramCommandReply{Text: "请发送新账号的 用户名 密码(空格分隔),例如:alice mypass123"}, true
case data == "act_redeem_register":
+ if isGroup {
+ return telegramCommandReply{Text: telegramGroupPrivateUserHint("兑换码注册")}, true
+ }
s.setPending(int64(msg.From.ID), "redeem_register")
return telegramCommandReply{Text: "请发送你的注册兑换码,例如:ABCD2345EFGH\n(兑换后会要求设置用户名密码)"}, true
case data == "act_redeem_renew":
+ if isGroup {
+ return telegramCommandReply{Text: telegramGroupPrivateUserHint("兑换码续期")}, true
+ }
s.setPending(int64(msg.From.ID), "redeem_renew")
return telegramCommandReply{Text: "请发送你的续期兑换码,将为当前绑定账号续期。"}, true
case data == "act_account":
@@ -136,9 +174,15 @@ func (s *TelegramBotService) handleMenuCallback(ctx context.Context, channel *mo
case data == "act_devices":
return s.replyDevices(ctx, msg), true
case data == "act_setname":
+ if isGroup {
+ return telegramCommandReply{Text: telegramGroupPrivateUserHint("修改用户名")}, true
+ }
s.setPending(int64(msg.From.ID), "setname")
return telegramCommandReply{Text: "请发送:当前密码 新用户名。"}, true
case data == "act_setpass":
+ if isGroup {
+ return telegramCommandReply{Text: telegramGroupPrivateUserHint("修改密码")}, true
+ }
s.setPending(int64(msg.From.ID), "setpass")
return telegramCommandReply{Text: "请发送:当前密码 新密码(新密码至少 6 位)。"}, true
case strings.HasPrefix(data, "kick:"):
@@ -146,6 +190,12 @@ func (s *TelegramBotService) handleMenuCallback(ctx context.Context, channel *mo
}
// ── 管理员专属 ──
+ if isGroup {
+ if isAdmin {
+ return telegramCommandReply{Text: telegramGroupPrivateAdminHint()}, true
+ }
+ return telegramCommandReply{}, true
+ }
if !isAdmin {
return telegramCommandReply{Text: "此功能仅管理员可用。"}, true
}