fix(organize): strip release tags/roman numerals/season markers; de-hardcode paths

feat(bot): button menu, capacity/open-reg quota, redemption codes, user mgmt,
account expiry + signin streak, device anti-sharing + inactivity cleanup,
one-click kick, self-service username/password

- Consolidate organize/rename defaults into Tools panel

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
shuk shuk
2026-05-30 17:57:17 +00:00
committed by Shuke
parent f1d87638f3
commit 22b64d3d47
22 changed files with 2353 additions and 133 deletions
+53 -53
View File
@@ -233,7 +233,7 @@ cd ~/MediaStationGo
mkdir -p data cache media downloads
```
4. 创建 `.env`,按你的 NAS 路径填写。推荐使用“NAS 绝对路径直读模式”,网页里就可以直接填写 `/vol1/...` 原始路径:
4. 创建 `.env`,按你的 NAS 路径填写。推荐使用“NAS 绝对路径直读模式”,网页里就可以直接填写 `/your-nas/...` 原始路径:
```bash
cat > .env <<'EOF'
@@ -246,13 +246,13 @@ MEDIASTATION_DATA_DIR=./data
MEDIASTATION_CACHE_DIR=./cache
# NAS / 飞牛直读模式:左侧宿主机路径与右侧容器路径保持一致。
# 这样添加媒体库时直接填写 /vol1/1000/Docker/moviepilot-v2/media/电视剧/国产剧。
MEDIASTATION_MEDIA_DIR=/vol1/1000/Docker/moviepilot-v2/media
MEDIASTATION_MEDIA_CONTAINER_DIR=/vol1/1000/Docker/moviepilot-v2/media
# 这样添加媒体库时直接填写 /your-nas/media/电视剧/国产剧。
MEDIASTATION_MEDIA_DIR=/your-nas/media
MEDIASTATION_MEDIA_CONTAINER_DIR=/your-nas/media
# 下载目录同样保持一致,方便 qBittorrent、MediaStationGo、站点订阅共用同一个保存路径。
MEDIASTATION_DOWNLOAD_DIR=/vol1/1000/qBittorrent/downloads
MEDIASTATION_DOWNLOAD_CONTAINER_DIR=/vol1/1000/qBittorrent/downloads
MEDIASTATION_DOWNLOAD_DIR=/your-nas/downloads
MEDIASTATION_DOWNLOAD_CONTAINER_DIR=/your-nas/downloads
# 如果 NAS 已使用 v2rayA redirect / 透明代理,不要在这里额外配置 HTTP_PROXY,避免叠加代理。
@@ -262,7 +262,7 @@ PGID=1000
EOF
```
> 重点:不要写 `./vol1/1000/...`。`./vol1` 代表当前部署目录下的 `vol1` 子目录,最终会变成类似 `/vol1/1000/Docker/MediaStationGo/vol1/...` 这种错误路径。正确写法必须以 `/vol1/...` 开头。
> 重点:不要把 NAS 绝对路径写成相对路径(如 `./media`)。相对路径代表当前部署目录下的子目录,最终会变成类似 `/your-nas/MediaStationGo/media/...` 这种错误路径。正确的 NAS 写法必须以 `/`(绝对路径)开头,例如 `/your-nas/media`。
如果你只是本地测试,没有现成 NAS 媒体目录,也可以改回部署目录下的测试目录:
@@ -322,9 +322,9 @@ vim docker-compose.yml
# MEDIASTATION_DOWNLOAD_DIR=/mnt/nas/downloads
#
# NAS / 飞牛路径必须使用绝对路径,例如:
# MEDIASTATION_MEDIA_DIR=/vol1/1000/Docker/moviepilot-v2/media
# MEDIASTATION_DOWNLOAD_DIR=/vol1/1000/qBittorrent/downloads
# 不要写成 ./vol1/...;./vol1 会被 Docker Compose 解析为当前部署目录下的相对路径。
# MEDIASTATION_MEDIA_DIR=/your-nas/media
# MEDIASTATION_DOWNLOAD_DIR=/your-nas/downloads
# 不要写成 ./your-nas/...;./your-nas 会被 Docker Compose 解析为当前部署目录下的相对路径。
#
# 注意:
# 1. 如果 qBittorrent/Transmission/Aria2 不在本 compose 内,请确保它们能访问同一份
@@ -360,10 +360,10 @@ services:
# 综艺:/media/Variety
# 如宿主机目录不同,请在 .env 中设置 MEDIASTATION_MEDIA_DIR。
# NAS / 飞牛等系统请写绝对路径,例如:
# MEDIASTATION_MEDIA_DIR=/vol1/1000/Docker/moviepilot-v2/media
# 不要写 ./vol1/...,否则会变成当前部署目录下的 vol1 子目录。
# MEDIASTATION_MEDIA_DIR=/your-nas/media
# 不要写相对路径(如 ./media),否则会变成当前部署目录下的子目录。
# 默认映射到容器 /media;如需在网页中直接填写宿主机绝对路径,可将
# MEDIASTATION_MEDIA_CONTAINER_DIR 设置为同一个 /vol1/... 路径。
# MEDIASTATION_MEDIA_CONTAINER_DIR 设置为同一个 /your-nas/... 路径。
- ${MEDIASTATION_MEDIA_DIR:-./media}:${MEDIASTATION_MEDIA_CONTAINER_DIR:-/media}:ro
# 下载保存目录。订阅/站点下载的保存路径建议使用:
@@ -373,10 +373,10 @@ services:
# /downloads/Variety
# 如果外部下载器也运行在 Docker 中,请给下载器挂载同一个宿主机目录。
# NAS / 飞牛等系统请写绝对路径,例如:
# MEDIASTATION_DOWNLOAD_DIR=/vol1/1000/qBittorrent/downloads
# 不要写 ./vol1/...,否则下载目录会被映射到当前部署目录下面。
# MEDIASTATION_DOWNLOAD_DIR=/your-nas/downloads
# 不要写 ./your-nas/...,否则下载目录会被映射到当前部署目录下面。
# 默认映射到容器 /downloads;如需下载器和应用都使用宿主机绝对路径,可将
# MEDIASTATION_DOWNLOAD_CONTAINER_DIR 设置为同一个 /vol1/... 路径。
# MEDIASTATION_DOWNLOAD_CONTAINER_DIR 设置为同一个 /your-nas/... 路径。
- ${MEDIASTATION_DOWNLOAD_DIR:-./downloads}:${MEDIASTATION_DOWNLOAD_CONTAINER_DIR:-/downloads}
environment:
@@ -391,8 +391,8 @@ services:
MEDIASTATION_CACHE_CACHE_DIR: /cache
# 宿主机到容器的路径映射提示。用于用户误填宿主机路径时自动转换为容器路径:
# /vol1/1000/Docker/moviepilot-v2/media/电视剧 -> /media/电视剧
# /vol1/1000/qBittorrent/downloads/国产剧 -> /downloads/国产剧
# /your-nas/media/电视剧 -> /media/电视剧
# /your-nas/downloads/国产剧 -> /downloads/国产剧
MEDIASTATION_MEDIA_DIR: ${MEDIASTATION_MEDIA_DIR:-./media}
MEDIASTATION_MEDIA_CONTAINER_DIR: ${MEDIASTATION_MEDIA_CONTAINER_DIR:-/media}
MEDIASTATION_DOWNLOAD_DIR: ${MEDIASTATION_DOWNLOAD_DIR:-./downloads}
@@ -520,10 +520,10 @@ MEDIASTATION_IMAGE_TAG=MediaStationGo-v0.0.32
MEDIASTATION_HTTP_PORT=18080
MEDIASTATION_DATA_DIR=./data
MEDIASTATION_CACHE_DIR=./cache
MEDIASTATION_MEDIA_DIR=/vol1/1000/Docker/moviepilot-v2/media
MEDIASTATION_MEDIA_CONTAINER_DIR=/vol1/1000/Docker/moviepilot-v2/media
MEDIASTATION_DOWNLOAD_DIR=/vol1/1000/qBittorrent/downloads
MEDIASTATION_DOWNLOAD_CONTAINER_DIR=/vol1/1000/qBittorrent/downloads
MEDIASTATION_MEDIA_DIR=/your-nas/media
MEDIASTATION_MEDIA_CONTAINER_DIR=/your-nas/media
MEDIASTATION_DOWNLOAD_DIR=/your-nas/downloads
MEDIASTATION_DOWNLOAD_CONTAINER_DIR=/your-nas/downloads
TZ=Asia/Shanghai
PUID=1000
PGID=1000
@@ -537,45 +537,45 @@ docker compose up -d
#### 宿主机路径与容器路径的关系
Docker Compose 左侧是宿主机真实目录,右侧是容器内目录。宿主机绝对路径会直接读取 NAS 原目录,不会复制到部署目录,也不会多套一层 `MediaStationGo/vol1`。
Docker Compose 左侧是宿主机真实目录,右侧是容器内目录。宿主机绝对路径会直接读取 NAS 原目录,不会复制到部署目录,也不会多套一层部署目录前缀。
```yaml
# 宿主机真实路径 # 容器内路径
- /vol1/1000/Docker/moviepilot-v2/media:/media:ro
- /vol1/1000/qBittorrent/downloads:/downloads
- /your-nas/media:/media:ro
- /your-nas/downloads:/downloads
```
容器内只需要使用 `/media` 和 `/downloads`。如果你看到 `/vol1/1000/Docker/MediaStationGo/vol1/...`,说明 `.env` 或 compose 里把路径写成了 `./vol1/...`,需要去掉前面的点,改为 `/vol1/...`。
容器内只需要使用 `/media` 和 `/downloads`。如果你看到 `/your-nas/MediaStationGo/your-nas/...`,说明 `.env` 或 compose 里把路径写成了 `./your-nas/...`,需要去掉前面的点,改为 `/your-nas/...`。
> 如果你在添加媒体库时填写了 `/vol1/1000/Docker/moviepilot-v2/media/电视剧/国产剧` 并提示不可访问,原因是应用在容器内运行,默认只能看到 `/media/电视剧/国产剧`。新版 compose 会把宿主机路径作为映射提示传入容器,尽量自动纠正;但最推荐、最稳定的填写方式仍然是容器路径 `/media/...`。
> 如果你在添加媒体库时填写了 `/your-nas/media/电视剧/国产剧` 并提示不可访问,原因是应用在容器内运行,默认只能看到 `/media/电视剧/国产剧`。新版 compose 会把宿主机路径作为映射提示传入容器,尽量自动纠正;但最推荐、最稳定的填写方式仍然是容器路径 `/media/...`。
如果你更希望“页面里直接填写 NAS 绝对路径”,可以把宿主机路径和容器路径设置成完全一致,这不会复制文件,也不会多占空间:
```env
MEDIASTATION_MEDIA_DIR=/vol1/1000/Docker/moviepilot-v2/media
MEDIASTATION_MEDIA_CONTAINER_DIR=/vol1/1000/Docker/moviepilot-v2/media
MEDIASTATION_DOWNLOAD_DIR=/vol1/1000/qBittorrent/downloads
MEDIASTATION_DOWNLOAD_CONTAINER_DIR=/vol1/1000/qBittorrent/downloads
MEDIASTATION_MEDIA_DIR=/your-nas/media
MEDIASTATION_MEDIA_CONTAINER_DIR=/your-nas/media
MEDIASTATION_DOWNLOAD_DIR=/your-nas/downloads
MEDIASTATION_DOWNLOAD_CONTAINER_DIR=/your-nas/downloads
```
对应 compose 实际效果等价于:
```yaml
- /vol1/1000/Docker/moviepilot-v2/media:/vol1/1000/Docker/moviepilot-v2/media:ro
- /vol1/1000/qBittorrent/downloads:/vol1/1000/qBittorrent/downloads
- /your-nas/media:/your-nas/media:ro
- /your-nas/downloads:/your-nas/downloads
```
这样添加媒体库时就可以直接填写 `/vol1/1000/Docker/moviepilot-v2/media/电视剧/国产剧`。
这样添加媒体库时就可以直接填写 `/your-nas/media/电视剧/国产剧`。
推荐添加媒体库路径示例:
| 媒体库 | 页面填写路径 | 类型 |
| --- | --- | --- |
| 华语电影 / 外语电影 / 动画电影 | `/vol1/1000/Docker/moviepilot-v2/media/电影` | 电影 |
| 国产剧 / 欧美剧 / 日韩剧 / 国漫 / 日番 / 综艺 | `/vol1/1000/Docker/moviepilot-v2/media/电视剧` | 电视剧 / 动漫 / 综艺 |
| 下载根目录 | `/vol1/1000/qBittorrent/downloads` | 下载器保存路径 |
| 华语电影 / 外语电影 / 动画电影 | `/your-nas/media/电影` | 电影 |
| 国产剧 / 欧美剧 / 日韩剧 / 国漫 / 日番 / 综艺 | `/your-nas/media/电视剧` | 电视剧 / 动漫 / 综艺 |
| 下载根目录 | `/your-nas/downloads` | 下载器保存路径 |
如果你只想添加更细的分类目录,也可以填 `/vol1/1000/Docker/moviepilot-v2/media/电视剧/国产剧`;系统会直接扫描这个目录,不会复制、不搬家。
如果你只想添加更细的分类目录,也可以填 `/your-nas/media/电视剧/国产剧`;系统会直接扫描这个目录,不会复制、不搬家。
> 安全策略:扫描和播放只读取原目录;“整理整个库”不会再搬动已经位于媒体库目录内的文件,避免本地 NFO、海报、字幕等元数据被迁移后丢失。下载完成后的自动整理仍然默认关闭,只有你手动打开后才会移动下载目录中的新文件。
@@ -597,23 +597,23 @@ MEDIASTATION_DOWNLOAD_CONTAINER_DIR=/vol1/1000/qBittorrent/downloads
#### NAS 绝对路径写法
NAS、飞牛、绿联、群晖、威联通等系统里,媒体目录通常是系统绝对路径。compose 中必须写 `/vol1/...`、`/volume1/...`、`/mnt/...` 这类从根目录开始的路径,不要写成 `./vol1/...`。
NAS、飞牛、绿联、群晖、威联通等系统里,媒体目录通常是系统绝对路径。compose 中必须写 `/your-nas/...`、`/volume1/...`、`/mnt/...` 这类从根目录开始的路径,不要写成 `./your-nas/...`。
```yaml
# 正确:宿主机绝对路径
- /vol1/1000/Docker/moviepilot-v2/media:/media:ro
- /vol1/1000/qBittorrent/downloads:/downloads
- /your-nas/media:/media:ro
- /your-nas/downloads:/downloads
# 错误:这是相对当前 compose 目录的路径
- ./vol1/1000/Docker/moviepilot-v2/media:/media:ro
- ./vol1/1000/qBittorrent/downloads:/downloads
- ./your-nas/media:/media:ro
- ./your-nas/downloads:/downloads
```
也可以放到 `.env` 里统一管理:
```env
MEDIASTATION_MEDIA_DIR=/vol1/1000/Docker/moviepilot-v2/media
MEDIASTATION_DOWNLOAD_DIR=/vol1/1000/qBittorrent/downloads
MEDIASTATION_MEDIA_DIR=/your-nas/media
MEDIASTATION_DOWNLOAD_DIR=/your-nas/downloads
```
容器内媒体库建议添加 `/media/电影` 和 `/media/电视剧` 两个根目录;整理后会自动进入 `/media/电影/动画电影`、`/media/电视剧/国产剧` 等分类目录。下载器保存根目录填写 `/downloads`,订阅下载会自动落到 `/downloads/动画电影`、`/downloads/国产剧` 等分类目录。
@@ -705,10 +705,10 @@ qBittorrent 容器:/downloads
| `MEDIASTATION_HTTP_PORT` | `18080` | 宿主机访问端口 |
| `MEDIASTATION_DATA_DIR` | `./data` | 数据持久化目录 |
| `MEDIASTATION_CACHE_DIR` | `./cache` | 图片和转码缓存目录 |
| `MEDIASTATION_MEDIA_DIR` | `./media` | 媒体库宿主机目录;NAS 建议写 `/vol1/1000/Docker/moviepilot-v2/media` 这种绝对路径 |
| `MEDIASTATION_MEDIA_CONTAINER_DIR` | `/media` | 容器内媒体路径;如想页面直接填写 `/vol1/...`,可设置成与 `MEDIASTATION_MEDIA_DIR` 相同 |
| `MEDIASTATION_DOWNLOAD_DIR` | `./downloads` | 下载保存宿主机目录;NAS 建议写 `/vol1/1000/qBittorrent/downloads` 这种绝对路径 |
| `MEDIASTATION_DOWNLOAD_CONTAINER_DIR` | `/downloads` | 容器内下载路径;如想下载器保存路径直接使用 `/vol1/...`,可设置成与 `MEDIASTATION_DOWNLOAD_DIR` 相同 |
| `MEDIASTATION_MEDIA_DIR` | `./media` | 媒体库宿主机目录;NAS 建议写 `/your-nas/media` 这种绝对路径 |
| `MEDIASTATION_MEDIA_CONTAINER_DIR` | `/media` | 容器内媒体路径;如想页面直接填写 `/your-nas/...`,可设置成与 `MEDIASTATION_MEDIA_DIR` 相同 |
| `MEDIASTATION_DOWNLOAD_DIR` | `./downloads` | 下载保存宿主机目录;NAS 建议写 `/your-nas/downloads` 这种绝对路径 |
| `MEDIASTATION_DOWNLOAD_CONTAINER_DIR` | `/downloads` | 容器内下载路径;如想下载器保存路径直接使用 `/your-nas/...`,可设置成与 `MEDIASTATION_DOWNLOAD_DIR` 相同 |
| `PUID` / `PGID` | `1000` / `1000` | Linux/NAS 文件权限映射 |
| `TZ` | `Asia/Shanghai` | 容器时区 |
@@ -1005,9 +1005,9 @@ MediaStationGo 的智能分类分为两个阶段:
推荐宿主机目录:
```text
/vol1/1000/qBittorrent/downloads
/vol1/1000/Docker/moviepilot-v2/media/电影
/vol1/1000/Docker/moviepilot-v2/media/电视剧
/your-nas/downloads
/your-nas/media/电影
/your-nas/media/电视剧
```
对应容器内目录:
@@ -1246,7 +1246,7 @@ docker compose up -d
如果服务器在国内网络或 NAS 网络环境中,建议为 Docker daemon 配置可访问 GHCR 的代理;仅设置终端代理通常不一定会被 Docker 服务进程继承。默认 compose 已使用 `pull_policy: missing`,避免容器重启时反复访问 GHCR。
另外,你示例中的路径如果是 NAS 绝对路径,建议写成 `/vol1/...`,不要写 `./vol1/...`;前者是系统根目录路径,后者是当前 compose 目录下面的相对路径。
另外,你示例中的路径如果是 NAS 绝对路径,建议写成 `/your-nas/...`,不要写 `./your-nas/...`;前者是系统根目录路径,后者是当前 compose 目录下面的相对路径。
### Q: Docker 部署后浏览器打不开?
+37 -37
View File
@@ -231,7 +231,7 @@ cd ~/MediaStationGo
mkdir -p data cache media downloads
```
4. Create `.env`. For NAS deployments, the recommended mode is mapping the host absolute path to the exact same container path, so the web UI can use `/vol1/...` directly:
4. Create `.env`. For NAS deployments, the recommended mode is mapping the host absolute path to the exact same container path, so the web UI can use `/your-nas/...` directly:
```bash
cat > .env <<'EOF'
@@ -239,10 +239,10 @@ MEDIASTATION_IMAGE_TAG=MediaStationGo-v0.0.32
MEDIASTATION_HTTP_PORT=18080
MEDIASTATION_DATA_DIR=./data
MEDIASTATION_CACHE_DIR=./cache
MEDIASTATION_MEDIA_DIR=/vol1/1000/Docker/moviepilot-v2/media
MEDIASTATION_MEDIA_CONTAINER_DIR=/vol1/1000/Docker/moviepilot-v2/media
MEDIASTATION_DOWNLOAD_DIR=/vol1/1000/qBittorrent/downloads
MEDIASTATION_DOWNLOAD_CONTAINER_DIR=/vol1/1000/qBittorrent/downloads
MEDIASTATION_MEDIA_DIR=/your-nas/media
MEDIASTATION_MEDIA_CONTAINER_DIR=/your-nas/media
MEDIASTATION_DOWNLOAD_DIR=/your-nas/downloads
MEDIASTATION_DOWNLOAD_CONTAINER_DIR=/your-nas/downloads
# If the NAS already uses v2rayA redirect / transparent proxy, do not add HTTP_PROXY here.
TZ=Asia/Shanghai
PUID=1000
@@ -250,7 +250,7 @@ PGID=1000
EOF
```
> Important: do not write `./vol1/1000/...`. `./vol1` means a `vol1` subdirectory under the current compose project, which can become a wrong path such as `/vol1/1000/Docker/MediaStationGo/vol1/...`. Correct NAS paths must start with `/vol1/...`.
> Important: do not write NAS absolute paths as relative paths (e.g. `./media`). A relative path means a subdirectory under the current compose project, which can become a wrong path such as `/your-nas/MediaStationGo/media/...`. Correct NAS paths must start with `/` (absolute), e.g. `/your-nas/media`.
For local testing without existing NAS folders, use the deploy-directory mounts instead:
@@ -348,10 +348,10 @@ MEDIASTATION_IMAGE_TAG=MediaStationGo-v0.0.32
MEDIASTATION_HTTP_PORT=18080
MEDIASTATION_DATA_DIR=./data
MEDIASTATION_CACHE_DIR=./cache
MEDIASTATION_MEDIA_DIR=/vol1/1000/Docker/moviepilot-v2/media
MEDIASTATION_MEDIA_CONTAINER_DIR=/vol1/1000/Docker/moviepilot-v2/media
MEDIASTATION_DOWNLOAD_DIR=/vol1/1000/qBittorrent/downloads
MEDIASTATION_DOWNLOAD_CONTAINER_DIR=/vol1/1000/qBittorrent/downloads
MEDIASTATION_MEDIA_DIR=/your-nas/media
MEDIASTATION_MEDIA_CONTAINER_DIR=/your-nas/media
MEDIASTATION_DOWNLOAD_DIR=/your-nas/downloads
MEDIASTATION_DOWNLOAD_CONTAINER_DIR=/your-nas/downloads
TZ=Asia/Shanghai
PUID=1000
PGID=1000
@@ -365,35 +365,35 @@ docker compose up -d
#### Host Paths vs Container Paths
The left side of a Docker Compose volume is the real host directory, and the right side is the path inside the container. Absolute NAS host paths are read directly; they are not copied into the deployment folder and will not be nested under `MediaStationGo/vol1`.
The left side of a Docker Compose volume is the real host directory, and the right side is the path inside the container. Absolute NAS host paths are read directly; they are not copied into the deployment folder and will not be nested under the compose project directory.
```yaml
# Real host path # Container path
- /vol1/1000/Docker/moviepilot-v2/media:/media:ro
- /vol1/1000/qBittorrent/downloads:/downloads
- /your-nas/media:/media:ro
- /your-nas/downloads:/downloads
```
Inside the app, use only `/media` and `/downloads`. If you see `/vol1/1000/Docker/MediaStationGo/vol1/...`, your `.env` or compose file probably uses `./vol1/...`; remove the dot and use `/vol1/...`.
Inside the app, use only `/media` and `/downloads`. If you see `/your-nas/MediaStationGo/your-nas/...`, your `.env` or compose file probably uses `./your-nas/...`; remove the dot and use `/your-nas/...`.
> If adding `/vol1/1000/Docker/moviepilot-v2/media/电视剧/国产剧` as a library reports inaccessible, the app is running inside the container and normally sees `/media/电视剧/国产剧`. The updated compose passes host-path hints so the backend can auto-convert common mistakes, but `/media/...` remains the recommended and most stable input.
> If adding `/your-nas/media/电视剧/国产剧` as a library reports inaccessible, the app is running inside the container and normally sees `/media/电视剧/国产剧`. The updated compose passes host-path hints so the backend can auto-convert common mistakes, but `/media/...` remains the recommended and most stable input.
If you prefer entering the NAS absolute path directly in the web UI, map the host path to the exact same container path. This does not copy files or use extra disk space:
```env
MEDIASTATION_MEDIA_DIR=/vol1/1000/Docker/moviepilot-v2/media
MEDIASTATION_MEDIA_CONTAINER_DIR=/vol1/1000/Docker/moviepilot-v2/media
MEDIASTATION_DOWNLOAD_DIR=/vol1/1000/qBittorrent/downloads
MEDIASTATION_DOWNLOAD_CONTAINER_DIR=/vol1/1000/qBittorrent/downloads
MEDIASTATION_MEDIA_DIR=/your-nas/media
MEDIASTATION_MEDIA_CONTAINER_DIR=/your-nas/media
MEDIASTATION_DOWNLOAD_DIR=/your-nas/downloads
MEDIASTATION_DOWNLOAD_CONTAINER_DIR=/your-nas/downloads
```
Equivalent compose mounts:
```yaml
- /vol1/1000/Docker/moviepilot-v2/media:/vol1/1000/Docker/moviepilot-v2/media:ro
- /vol1/1000/qBittorrent/downloads:/vol1/1000/qBittorrent/downloads
- /your-nas/media:/your-nas/media:ro
- /your-nas/downloads:/your-nas/downloads
```
With this mode, add libraries using paths such as `/vol1/1000/Docker/moviepilot-v2/media/电视剧/国产剧`.
With this mode, add libraries using paths such as `/your-nas/media/电视剧/国产剧`.
> Safety policy: scanning and playback read the original directory. “Organize entire library” no longer moves files already inside the library root, protecting local NFO, posters, subtitles, and other sidecar metadata. Auto-organize after downloads remains disabled by default.
@@ -415,23 +415,23 @@ then add libraries in the web UI using container paths:
#### NAS Absolute Path Syntax
On NAS systems, media folders are usually absolute host paths. In compose, use paths starting from the filesystem root, such as `/vol1/...`, `/volume1/...`, or `/mnt/...`; do not use `./vol1/...` unless that directory really exists under the compose project directory.
On NAS systems, media folders are usually absolute host paths. In compose, use paths starting from the filesystem root, such as `/your-nas/...`, `/volume1/...`, or `/mnt/...`; do not use `./your-nas/...` unless that directory really exists under the compose project directory.
```yaml
# Correct: absolute host paths
- /vol1/1000/Docker/moviepilot-v2/media:/media:ro
- /vol1/1000/qBittorrent/downloads:/downloads
- /your-nas/media:/media:ro
- /your-nas/downloads:/downloads
# Wrong: relative to the compose directory
- ./vol1/1000/Docker/moviepilot-v2/media:/media:ro
- ./vol1/1000/qBittorrent/downloads:/downloads
- ./your-nas/media:/media:ro
- ./your-nas/downloads:/downloads
```
You can also keep the paths in `.env`:
```env
MEDIASTATION_MEDIA_DIR=/vol1/1000/Docker/moviepilot-v2/media
MEDIASTATION_DOWNLOAD_DIR=/vol1/1000/qBittorrent/downloads
MEDIASTATION_MEDIA_DIR=/your-nas/media
MEDIASTATION_DOWNLOAD_DIR=/your-nas/downloads
```
Inside MediaStationGo, add `/media/电影` and `/media/电视剧` as media library roots. Organized files will land in category folders such as `/media/电影/动画电影` and `/media/电视剧/国产剧`. Use `/downloads` as the download root; subscriptions will save to folders such as `/downloads/动画电影` and `/downloads/国产剧`.
@@ -519,10 +519,10 @@ The repository includes a heavily commented `docker-compose.yml`. Common variabl
| `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 `/vol1/1000/Docker/moviepilot-v2/media` |
| `MEDIASTATION_MEDIA_CONTAINER_DIR` | `/media` | Container media path; set it equal to `MEDIASTATION_MEDIA_DIR` if you want to enter `/vol1/...` directly in the web UI |
| `MEDIASTATION_DOWNLOAD_DIR` | `./downloads` | Host download target; on NAS use an absolute path such as `/vol1/1000/qBittorrent/downloads` |
| `MEDIASTATION_DOWNLOAD_CONTAINER_DIR` | `/downloads` | Container download path; set it equal to `MEDIASTATION_DOWNLOAD_DIR` if your downloader save path should also be `/vol1/...` |
| `MEDIASTATION_MEDIA_DIR` | `./media` | Host media library root; on NAS use an absolute path such as `/your-nas/media` |
| `MEDIASTATION_MEDIA_CONTAINER_DIR` | `/media` | Container media path; set it equal to `MEDIASTATION_MEDIA_DIR` if you want to enter `/your-nas/...` directly in the web UI |
| `MEDIASTATION_DOWNLOAD_DIR` | `./downloads` | Host download target; on NAS use an absolute path such as `/your-nas/downloads` |
| `MEDIASTATION_DOWNLOAD_CONTAINER_DIR` | `/downloads` | Container download path; set it equal to `MEDIASTATION_DOWNLOAD_DIR` if your downloader save path should also be `/your-nas/...` |
| `PUID` / `PGID` | `1000` / `1000` | Linux/NAS file permission mapping |
| `TZ` | `Asia/Shanghai` | Container timezone |
@@ -818,9 +818,9 @@ MediaStationGo separates download classification from final media organization:
Recommended host directories:
```text
/vol1/1000/qBittorrent/downloads
/vol1/1000/Docker/moviepilot-v2/media/电影
/vol1/1000/Docker/moviepilot-v2/media/电视剧
/your-nas/downloads
/your-nas/media/电影
/your-nas/media/电视剧
```
Container paths:
@@ -1059,7 +1059,7 @@ 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 `/vol1/...` instead of `./vol1/...`; the latter is relative to the compose directory.
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.
+22 -22
View File
@@ -27,13 +27,13 @@
#
# NAS / 飞牛推荐直读模式:
# 如果你希望网页里直接填写 NAS 原始路径,例如
# /vol1/1000/Docker/moviepilot-v2/media/电视剧/国产剧,
# 请在 .env 中把宿主机路径和容器路径设置成完全相同:
# /your-nas/media/电视剧/国产剧,
# 请在 .env 中把宿主机路径和容器路径设置成完全相同(请替换为你自己的实际路径):
#
# MEDIASTATION_MEDIA_DIR=/vol1/1000/Docker/moviepilot-v2/media
# MEDIASTATION_MEDIA_CONTAINER_DIR=/vol1/1000/Docker/moviepilot-v2/media
# MEDIASTATION_DOWNLOAD_DIR=/vol1/1000/qBittorrent/downloads
# MEDIASTATION_DOWNLOAD_CONTAINER_DIR=/vol1/1000/qBittorrent/downloads
# MEDIASTATION_MEDIA_DIR=/your-nas/media
# MEDIASTATION_MEDIA_CONTAINER_DIR=/your-nas/media
# MEDIASTATION_DOWNLOAD_DIR=/your-nas/downloads
# MEDIASTATION_DOWNLOAD_CONTAINER_DIR=/your-nas/downloads
#
# 这只是 Docker bind mount,不会复制文件,不会占用双倍空间。
#
@@ -43,10 +43,10 @@
# MEDIASTATION_MEDIA_DIR=./media
# MEDIASTATION_DOWNLOAD_DIR=./downloads
#
# NAS / 飞牛路径必须使用绝对路径,例如:
# MEDIASTATION_MEDIA_DIR=/vol1/1000/Docker/moviepilot-v2/media
# MEDIASTATION_DOWNLOAD_DIR=/vol1/1000/qBittorrent/downloads
# 不要写成 ./vol1/...;./vol1 会被 Docker Compose 解析为当前部署目录下的相对路径。
# NAS / 飞牛路径必须使用绝对路径(请替换为你自己的实际路径),例如:
# MEDIASTATION_MEDIA_DIR=/your-nas/media
# MEDIASTATION_DOWNLOAD_DIR=/your-nas/downloads
# 不要写成相对路径(如 ./media)当作 NAS 绝对路径;相对路径会被 Docker Compose 解析为当前部署目录下的子目录。
#
# 注意:
# 1. 如果 qBittorrent/Transmission/Aria2 不在本 compose 内,请确保它们能访问同一份
@@ -84,25 +84,25 @@ services:
# 媒体库根目录。
# 默认:宿主机目录映射到容器 /media,添加媒体库时填写 /media/电影、/media/电视剧。
# NAS 直读:把 MEDIASTATION_MEDIA_CONTAINER_DIR 设置成与 MEDIASTATION_MEDIA_DIR 相同,
# 添加媒体库时即可直接填写 /vol1/.../media/电影、/vol1/.../media/电视剧。
# 添加媒体库时即可直接填写 /your-nas/media/电影、/your-nas/media/电视剧。
# 如宿主机目录不同,请在 .env 中设置 MEDIASTATION_MEDIA_DIR。
# NAS / 飞牛等系统请写绝对路径,例如:
# MEDIASTATION_MEDIA_DIR=/vol1/1000/Docker/moviepilot-v2/media
# 不要写 ./vol1/...,否则会变成当前部署目录下的 vol1 子目录。
# NAS / 飞牛等系统请写绝对路径(替换为你自己的实际路径),例如:
# MEDIASTATION_MEDIA_DIR=/your-nas/media
# 不要写相对路径,否则会变成当前部署目录下的子目录。
# 默认映射到容器 /media;如需在网页中直接填写宿主机绝对路径,可将
# MEDIASTATION_MEDIA_CONTAINER_DIR 设置为同一个 /vol1/... 路径。
# MEDIASTATION_MEDIA_CONTAINER_DIR 设置为同一个绝对路径。
- ${MEDIASTATION_MEDIA_DIR:-./media}:${MEDIASTATION_MEDIA_CONTAINER_DIR:-/media}:ro
# 下载保存目录。
# 默认:宿主机目录映射到容器 /downloads,订阅保存根目录填写 /downloads。
# NAS 直读:把 MEDIASTATION_DOWNLOAD_CONTAINER_DIR 设置成与 MEDIASTATION_DOWNLOAD_DIR 相同,
# 下载器保存根目录可直接填写 /vol1/1000/qBittorrent/downloads。
# 下载器保存根目录可直接填写 /your-nas/downloads。
# 如果外部下载器也运行在 Docker 中,请给下载器挂载同一个宿主机目录。
# NAS / 飞牛等系统请写绝对路径,例如:
# MEDIASTATION_DOWNLOAD_DIR=/vol1/1000/qBittorrent/downloads
# 不要写 ./vol1/...,否则下载目录会被映射到当前部署目录下面。
# NAS / 飞牛等系统请写绝对路径(替换为你自己的实际路径),例如:
# MEDIASTATION_DOWNLOAD_DIR=/your-nas/downloads
# 不要写相对路径,否则下载目录会被映射到当前部署目录下面。
# 默认映射到容器 /downloads;如需下载器和应用都使用宿主机绝对路径,可将
# MEDIASTATION_DOWNLOAD_CONTAINER_DIR 设置为同一个 /vol1/... 路径。
# MEDIASTATION_DOWNLOAD_CONTAINER_DIR 设置为同一个绝对路径。
- ${MEDIASTATION_DOWNLOAD_DIR:-./downloads}:${MEDIASTATION_DOWNLOAD_CONTAINER_DIR:-/downloads}
environment:
@@ -117,8 +117,8 @@ services:
MEDIASTATION_CACHE_CACHE_DIR: /cache
# 宿主机到容器的路径映射提示。用于用户误填宿主机路径时自动转换为容器路径:
# /vol1/1000/Docker/moviepilot-v2/media/电视剧 -> /media/电视剧
# /vol1/1000/qBittorrent/downloads/国产剧 -> /downloads/国产剧
# /your-nas/media/电视剧 -> /media/电视剧
# /your-nas/downloads/国产剧 -> /downloads/国产剧
MEDIASTATION_MEDIA_DIR: ${MEDIASTATION_MEDIA_DIR:-./media}
MEDIASTATION_MEDIA_CONTAINER_DIR: ${MEDIASTATION_MEDIA_CONTAINER_DIR:-/media}
MEDIASTATION_DOWNLOAD_DIR: ${MEDIASTATION_DOWNLOAD_DIR:-./downloads}
+20
View File
@@ -206,6 +206,14 @@ func embyAuthByNameHandler(svc *service.Container) gin.HandlerFunc {
embyError(c, http.StatusUnauthorized, err.Error())
return
}
// 记录登录设备会话并执行防共享检测(登录客户端数 / 设备指纹)。
if svc.Device != nil {
svc.Device.RecordLogin(c.Request.Context(), resp.User.ID,
c.GetHeader("X-Emby-Device-Id"),
c.GetHeader("X-Emby-Device-Name"),
c.GetHeader("X-Emby-Client"),
c.ClientIP())
}
userPayload, _ := svc.Emby.FindUser(c.Request.Context(), resp.User.ID)
// Emby/Jellyfin 客户端没有 refresh token 机制:它们把这里返回的
// AccessToken 长期保存并反复使用。若返回 60 分钟的普通 access
@@ -622,7 +630,19 @@ func embyPlayingProgressHandler(svc *service.Container) gin.HandlerFunc {
c.Status(http.StatusOK) // Emby 期望 2xx;不是关键操作
return
}
// 被「一键踢下线」的设备拒绝继续播放,直到重新登录。
if svc.Device != nil && svc.Device.IsDeviceKicked(c.Request.Context(), uid, c.GetHeader("X-Emby-Device-Id")) {
c.Status(http.StatusUnauthorized)
return
}
_ = svc.Emby.RecordProgress(c.Request.Context(), uid, req.ItemId, req.PositionTicks, req.RunTimeTicks)
// 标记该设备正在播放并执行并发播放防共享检测。
if svc.Device != nil {
svc.Device.RecordPlayback(c.Request.Context(), uid,
c.GetHeader("X-Emby-Device-Id"),
c.GetHeader("X-Emby-Device-Name"),
c.GetHeader("X-Emby-Client"))
}
c.Status(http.StatusNoContent)
}
}
+104
View File
@@ -0,0 +1,104 @@
// Package model — Telegram Bot 相关数据模型:注册兑换码、签到记录、用户设备会话。
package model
import (
"time"
"github.com/google/uuid"
"gorm.io/gorm"
)
// RegistrationCodeKind 区分兑换码用途。
const (
// RegistrationCodeRegister 用于注册一个新账号(兑换后自动绑定 / 创建账号)。
RegistrationCodeRegister = "register"
// RegistrationCodeRenew 用于给已有账号续期(延长到期时间 DurationDays 天)。
RegistrationCodeRenew = "renew"
)
// RegistrationCode 是一次性兑换码。管理员生成后发给用户,用户通过 Bot 兑换:
// - register:创建并绑定一个新账号;兑换时按 DurationDays 设置账号有效期。
// - renew:给当前绑定账号延长 DurationDays 天有效期。
//
// 兑换成功后记录 UsedByUserID + UsedAt,之后不可再用。ExpiresAt 是兑换码本身
// 的有效期(过期后即使未使用也不能再兑换)。
type RegistrationCode struct {
Base
Code string `gorm:"uniqueIndex;size:32;not null" json:"code"`
Kind string `gorm:"size:16;not null;default:register" json:"kind"`
DurationDays int `gorm:"default:0" json:"duration_days"` // 账号有效期天数;0 表示永久
CreatedByID string `gorm:"size:36" json:"created_by_id,omitempty"`
UsedByUserID string `gorm:"index;size:36" json:"used_by_user_id,omitempty"`
UsedAt *time.Time `json:"used_at,omitempty"`
ExpiresAt *time.Time `json:"expires_at,omitempty"` // 兑换码本身的有效期
}
// BeforeCreate 生成 UUID。
func (c *RegistrationCode) BeforeCreate(_ *gorm.DB) error {
if c.ID == "" {
c.ID = uuid.NewString()
}
return nil
}
// IsUsed 报告兑换码是否已被使用。
func (c *RegistrationCode) IsUsed() bool { return c.UsedAt != nil }
// IsExpired 报告兑换码自身是否过期(与账号有效期无关)。
func (c *RegistrationCode) IsExpired() bool {
return c.ExpiresAt != nil && time.Now().After(*c.ExpiresAt)
}
// SignIn 记录单个用户的连续签到天数(不挂钩积分)。
type SignIn struct {
Base
UserID string `gorm:"uniqueIndex;size:36;not null" json:"user_id"`
LastSignIn time.Time `json:"last_sign_in"`
StreakDays int `gorm:"default:0" json:"streak_days"` // 当前连续签到天数
TotalDays int `gorm:"default:0" json:"total_days"` // 累计签到天数
}
// BeforeCreate 生成 UUID。
func (s *SignIn) BeforeCreate(_ *gorm.DB) error {
if s.ID == "" {
s.ID = uuid.NewString()
}
return nil
}
// UserDevice 记录一个用户在某台设备/客户端上的会话指纹,用于设备管控:
// - 登录设备数:某用户名下 UserDevice 行数(近期活跃)。
// - 并发播放数:LastPlayAt 在并发窗口内的 UserDevice 行数。
// - 设备指纹:首次见到的 Fingerprint;后续同一 DeviceID 上报不同指纹则告警。
// - 观看时长:结合 PlaybackHistory 统计随机窗口内的观看时长。
type UserDevice struct {
Base
UserID string `gorm:"index;size:36;not null;uniqueIndex:uniq_user_device" json:"user_id"`
DeviceID string `gorm:"size:128;not null;uniqueIndex:uniq_user_device" json:"device_id"`
DeviceName string `gorm:"size:128" json:"device_name,omitempty"`
Client string `gorm:"size:128" json:"client,omitempty"`
Fingerprint string `gorm:"size:64" json:"fingerprint,omitempty"`
LastIP string `gorm:"size:64" json:"last_ip,omitempty"`
FirstSeenAt time.Time `json:"first_seen_at"`
LastSeenAt time.Time `gorm:"index" json:"last_seen_at"`
LastPlayAt *time.Time `gorm:"index" json:"last_play_at,omitempty"`
Warnings int `gorm:"default:0" json:"warnings"` // 指纹不匹配累计告警次数
Kicked bool `gorm:"default:false" json:"kicked"` // 被一键踢下线(强制重新登录)
}
// BeforeCreate 生成 UUID。
func (d *UserDevice) BeforeCreate(_ *gorm.DB) error {
if d.ID == "" {
d.ID = uuid.NewString()
}
return nil
}
// BotModels 返回 Bot 相关模型,供 AutoMigrate 使用。
func BotModels() []interface{} {
return []interface{}{
&RegistrationCode{},
&SignIn{},
&UserDevice{},
}
}
+14 -2
View File
@@ -44,8 +44,17 @@ type User struct {
ForcePasswordReset bool `gorm:"default:false" json:"force_password_reset"`
IsActive bool `gorm:"default:true" json:"is_active"`
LastLoginAt *time.Time `json:"last_login_at,omitempty"`
IsDefaultAdmin bool `gorm:"-" json:"is_default_admin,omitempty"`
IsProtected bool `gorm:"-" json:"is_protected,omitempty"`
// ExpiredAt is the account expiry time. Nil means the account never
// expires. When set and in the past, the account is treated as expired
// (login blocked) until an admin or a redemption code renews it.
ExpiredAt *time.Time `json:"expired_at,omitempty"`
// ShareWarnings counts anti-account-sharing warnings (too many concurrent
// playbacks / logged-in clients / device-fingerprint mismatches). Once it
// exceeds the configured threshold a re-offence deletes the account.
ShareWarnings int `gorm:"default:0" json:"share_warnings"`
LastShareWarnAt *time.Time `json:"last_share_warn_at,omitempty"`
IsDefaultAdmin bool `gorm:"-" json:"is_default_admin,omitempty"`
IsProtected bool `gorm:"-" json:"is_protected,omitempty"`
}
// Library 表示用户定义的媒体根目录。
@@ -331,5 +340,8 @@ func AllModels() []interface{} {
&StorageConfig{},
&AssistantSession{},
&AssistantMessage{},
&RegistrationCode{},
&SignIn{},
&UserDevice{},
}
}
+177
View File
@@ -0,0 +1,177 @@
package repository
import (
"context"
"errors"
"time"
"gorm.io/gorm"
"github.com/ShukeBta/MediaStationGo/internal/model"
)
// ─── RegistrationCode ─────────────────────────────────────────────────────────
// RegistrationCodeRepository persists model.RegistrationCode records.
type RegistrationCodeRepository struct{ db *gorm.DB }
// Create inserts a new code.
func (r *RegistrationCodeRepository) Create(ctx context.Context, c *model.RegistrationCode) error {
return r.db.WithContext(ctx).Create(c).Error
}
// FindByCode returns the code row, or (nil, nil) when absent.
func (r *RegistrationCodeRepository) FindByCode(ctx context.Context, code string) (*model.RegistrationCode, error) {
var c model.RegistrationCode
err := r.db.WithContext(ctx).Where("code = ?", code).First(&c).Error
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, nil
}
if err != nil {
return nil, err
}
return &c, nil
}
// MarkUsed atomically marks an unused, unexpired code as consumed by userID.
// It returns gorm.ErrRecordNotFound when the code was already used so callers
// can avoid double-spend races.
func (r *RegistrationCodeRepository) MarkUsed(ctx context.Context, id, userID string) error {
now := time.Now()
res := r.db.WithContext(ctx).Model(&model.RegistrationCode{}).
Where("id = ? AND used_at IS NULL", id).
Updates(map[string]any{"used_by_user_id": userID, "used_at": &now})
if res.Error != nil {
return res.Error
}
if res.RowsAffected == 0 {
return gorm.ErrRecordNotFound
}
return nil
}
// List returns the most recent codes (newest first).
func (r *RegistrationCodeRepository) List(ctx context.Context, limit int) ([]model.RegistrationCode, error) {
if limit <= 0 {
limit = 50
}
var rows []model.RegistrationCode
err := r.db.WithContext(ctx).Order("created_at desc").Limit(limit).Find(&rows).Error
return rows, err
}
// CountUnused returns the number of codes that are still redeemable.
func (r *RegistrationCodeRepository) CountUnused(ctx context.Context) (int64, error) {
var n int64
err := r.db.WithContext(ctx).Model(&model.RegistrationCode{}).
Where("used_at IS NULL").Count(&n).Error
return n, err
}
// ─── SignIn ───────────────────────────────────────────────────────────────────
// SignInRepository persists model.SignIn records.
type SignInRepository struct{ db *gorm.DB }
// Get returns the sign-in row for a user, or (nil, nil) when absent.
func (r *SignInRepository) Get(ctx context.Context, userID string) (*model.SignIn, error) {
var s model.SignIn
err := r.db.WithContext(ctx).Where("user_id = ?", userID).First(&s).Error
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, nil
}
if err != nil {
return nil, err
}
return &s, nil
}
// Save inserts or updates a sign-in row.
func (r *SignInRepository) Save(ctx context.Context, s *model.SignIn) error {
return r.db.WithContext(ctx).Save(s).Error
}
// ─── UserDevice ───────────────────────────────────────────────────────────────
// UserDeviceRepository persists model.UserDevice records.
type UserDeviceRepository struct{ db *gorm.DB }
// Find returns the device row for (user, device), or (nil, nil) when absent.
func (r *UserDeviceRepository) Find(ctx context.Context, userID, deviceID string) (*model.UserDevice, error) {
var d model.UserDevice
err := r.db.WithContext(ctx).
Where("user_id = ? AND device_id = ?", userID, deviceID).First(&d).Error
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, nil
}
if err != nil {
return nil, err
}
return &d, nil
}
// Create inserts a new device row.
func (r *UserDeviceRepository) Create(ctx context.Context, d *model.UserDevice) error {
return r.db.WithContext(ctx).Create(d).Error
}
// Save persists changes to an existing device row.
func (r *UserDeviceRepository) Save(ctx context.Context, d *model.UserDevice) error {
return r.db.WithContext(ctx).Save(d).Error
}
// ListByUser returns all device rows for a user, newest activity first.
func (r *UserDeviceRepository) ListByUser(ctx context.Context, userID string) ([]model.UserDevice, error) {
var rows []model.UserDevice
err := r.db.WithContext(ctx).Where("user_id = ?", userID).
Order("last_seen_at desc").Find(&rows).Error
return rows, err
}
// CountActiveClients counts distinct logged-in devices for a user that were
// seen on or after `since` (used for the "max logged-in clients" rule).
func (r *UserDeviceRepository) CountActiveClients(ctx context.Context, userID string, since time.Time) (int64, error) {
var n int64
err := r.db.WithContext(ctx).Model(&model.UserDevice{}).
Where("user_id = ? AND last_seen_at >= ? AND kicked = ?", userID, since, false).
Count(&n).Error
return n, err
}
// CountConcurrentPlaying counts devices for a user whose last playback ping was
// on or after `since` (used for the "max concurrent playback" rule).
func (r *UserDeviceRepository) CountConcurrentPlaying(ctx context.Context, userID string, since time.Time) (int64, error) {
var n int64
err := r.db.WithContext(ctx).Model(&model.UserDevice{}).
Where("user_id = ? AND last_play_at IS NOT NULL AND last_play_at >= ?", userID, since).
Count(&n).Error
return n, err
}
// Delete removes a single device row by primary key.
func (r *UserDeviceRepository) Delete(ctx context.Context, id string) error {
return r.db.WithContext(ctx).Unscoped().Delete(&model.UserDevice{}, "id = ?", id).Error
}
// DeleteByUser removes every device row for a user (used on account deletion).
func (r *UserDeviceRepository) DeleteByUser(ctx context.Context, userID string) error {
return r.db.WithContext(ctx).Unscoped().Where("user_id = ?", userID).Delete(&model.UserDevice{}).Error
}
// SetKicked marks a device as kicked (forces re-login on next request).
func (r *UserDeviceRepository) SetKicked(ctx context.Context, id string, kicked bool) error {
return r.db.WithContext(ctx).Model(&model.UserDevice{}).Where("id = ?", id).
Update("kicked", kicked).Error
}
// WatchedMillisSince approximates the total watched milliseconds for a user
// since `since`, using the last known playback position per media. Playback
// history keeps one row per (user, media), so this is an activity proxy rather
// than an exact watch-time integral — sufficient for the inactivity rule.
func (r *UserDeviceRepository) WatchedMillisSince(ctx context.Context, userID string, since time.Time) (int64, error) {
var total int64
err := r.db.WithContext(ctx).Model(&model.PlaybackHistory{}).
Where("user_id = ? AND watched_at >= ?", userID, since).
Select("COALESCE(SUM(position_ms), 0)").Scan(&total).Error
return total, err
}
+6
View File
@@ -41,6 +41,9 @@ type Container struct {
PlayProfile *PlayProfileRepository
StorageConfig *StorageConfigRepository
Assistant *AssistantRepository
RegCode *RegistrationCodeRepository
SignIn *SignInRepository
UserDevice *UserDeviceRepository
}
// New 将每个 repository 连接到单个 *gorm.DB。
@@ -68,6 +71,9 @@ func New(db *gorm.DB) *Container {
PlayProfile: &PlayProfileRepository{db: db},
StorageConfig: &StorageConfigRepository{db: db},
Assistant: &AssistantRepository{db: db},
RegCode: &RegistrationCodeRepository{db: db},
SignIn: &SignInRepository{db: db},
UserDevice: &UserDeviceRepository{db: db},
}
}
+5
View File
@@ -38,6 +38,7 @@ var (
ErrUsernameTaken = errors.New("username already taken")
ErrUserInactive = errors.New("user account is inactive")
ErrUserLimitReached = errors.New("user limit reached")
ErrUserExpired = errors.New("user account has expired")
)
// MaxUsers is kept for compatibility with tests and callers; dynamic runtime
@@ -150,6 +151,10 @@ func (s *AuthService) Login(ctx context.Context, username, password string) (*Lo
if !u.IsActive {
return nil, ErrUserInactive
}
// 账号到期则停用登录,直到管理员或兑换码续期。
if u.ExpiredAt != nil && time.Now().After(*u.ExpiredAt) {
return nil, ErrUserExpired
}
if err := bcrypt.CompareHashAndPassword([]byte(u.PasswordHash), []byte(password)); err != nil {
return nil, ErrInvalidCredentials
}
+265
View File
@@ -0,0 +1,265 @@
package service
import (
"context"
"crypto/rand"
"fmt"
"strconv"
"strings"
"time"
"github.com/ShukeBta/MediaStationGo/internal/model"
)
// ── 容量 / 开注名额 ──────────────────────────────────────────────────────────
// capacityInfo 描述当前用户容量(随凭证授权实时变化)与开注名额状态。
type capacityInfo struct {
UsedUsers int64
MaxUsers int64 // 来自 LicensedMaxUsers,随授权实时变化
OpenRegOn bool
OpenRegLimit int // 0 = 不限(仅受 MaxUsers 约束)
OpenRegUsed int
}
// Remaining 返回还能注册多少个账号(同时受授权上限与开注名额约束)。
func (c capacityInfo) Remaining() int64 {
byLicense := c.MaxUsers - c.UsedUsers
if byLicense < 0 {
byLicense = 0
}
if c.OpenRegLimit > 0 {
byQuota := int64(c.OpenRegLimit - c.OpenRegUsed)
if byQuota < 0 {
byQuota = 0
}
if byQuota < byLicense {
return byQuota
}
}
return byLicense
}
// loadCapacity reads live capacity + open-reg quota state.
func (s *TelegramBotService) loadCapacity(ctx context.Context) capacityInfo {
used, _ := s.repo.User.Count(ctx)
info := capacityInfo{
UsedUsers: used,
MaxUsers: LicensedMaxUsers(ctx, s.repo),
OpenRegOn: s.openRegEnabled(ctx),
OpenRegLimit: s.intSetting(ctx, SettingOpenRegLimit, 0),
OpenRegUsed: s.intSetting(ctx, SettingOpenRegUsed, 0),
}
return info
}
func (s *TelegramBotService) intSetting(ctx context.Context, key string, fallback int) int {
v, err := s.repo.Setting.Get(ctx, key)
if err != nil {
return fallback
}
return parseIntSettingDefault(v, fallback)
}
// openRegEnabled reports whether bot registration is currently open. It honours
// both the new open-reg switch and the legacy registration switch.
func (s *TelegramBotService) openRegEnabled(ctx context.Context) bool {
if v, _ := s.repo.Setting.Get(ctx, SettingOpenRegEnabled); v != "" {
return parseBoolSetting(v, false)
}
return s.registrationEnabled(ctx)
}
// openRegistration opens registration for `limit` new accounts (0 = unlimited,
// bounded only by the license). Resets the used counter.
func (s *TelegramBotService) openRegistration(ctx context.Context, limit int) error {
if limit < 0 {
limit = 0
}
if err := s.repo.Setting.Set(ctx, SettingOpenRegEnabled, "true"); err != nil {
return err
}
if err := s.repo.Setting.Set(ctx, SettingOpenRegLimit, strconv.Itoa(limit)); err != nil {
return err
}
if err := s.repo.Setting.Set(ctx, SettingOpenRegUsed, "0"); err != nil {
return err
}
// 与旧开关同步,兼容系统设置页。
return s.setRegistrationEnabled(ctx, true)
}
// closeRegistration disables bot registration.
func (s *TelegramBotService) closeRegistration(ctx context.Context) error {
if err := s.repo.Setting.Set(ctx, SettingOpenRegEnabled, "false"); err != nil {
return err
}
return s.setRegistrationEnabled(ctx, false)
}
// consumeOpenRegSlot increments the used counter and auto-closes registration
// once the quota is exhausted. Call after a successful bot registration.
func (s *TelegramBotService) consumeOpenRegSlot(ctx context.Context) {
limit := s.intSetting(ctx, SettingOpenRegLimit, 0)
used := s.intSetting(ctx, SettingOpenRegUsed, 0) + 1
_ = s.repo.Setting.Set(ctx, SettingOpenRegUsed, strconv.Itoa(used))
if limit > 0 && used >= limit {
_ = s.closeRegistration(ctx)
}
}
// ── 兑换码 ──────────────────────────────────────────────────────────────────
// generateCode creates a random redemption code of the given kind. durationDays
// sets the account validity granted on redeem (0 = permanent). validDays sets
// how long the code itself stays redeemable (0 = never expires).
func (s *TelegramBotService) generateCode(ctx context.Context, kind string, durationDays, validDays int, createdBy string) (*model.RegistrationCode, error) {
if kind != model.RegistrationCodeRegister && kind != model.RegistrationCodeRenew {
kind = model.RegistrationCodeRegister
}
code := &model.RegistrationCode{
Code: randomCode(12),
Kind: kind,
DurationDays: durationDays,
CreatedByID: createdBy,
}
if validDays > 0 {
exp := time.Now().Add(time.Duration(validDays) * 24 * time.Hour)
code.ExpiresAt = &exp
}
if err := s.repo.RegCode.Create(ctx, code); err != nil {
return nil, err
}
return code, nil
}
// lookupRedeemableCode validates a code without consuming it. Callers mark it
// used only after the dependent action (account create / renew) succeeds, so a
// failed action never burns a code.
func (s *TelegramBotService) lookupRedeemableCode(ctx context.Context, raw, wantKind string) (*model.RegistrationCode, string) {
code := strings.TrimSpace(raw)
if code == "" {
return nil, "请提供兑换码。"
}
rc, err := s.repo.RegCode.FindByCode(ctx, code)
if err != nil || rc == nil {
return nil, "兑换码无效。"
}
if rc.IsUsed() {
return nil, "兑换码已被使用。"
}
if rc.IsExpired() {
return nil, "兑换码已过期。"
}
if wantKind != "" && rc.Kind != wantKind {
switch rc.Kind {
case model.RegistrationCodeRenew:
return nil, "这是续期兑换码,请在「我的账号」里使用它续期。"
default:
return nil, "这是注册兑换码,请用于注册新账号。"
}
}
return rc, ""
}
// ── 续期 ────────────────────────────────────────────────────────────────────
// renewUser extends a user's expiry by durationDays. A nil/zero current expiry
// starts from now; a future expiry is extended from that point. durationDays<=0
// sets the account to never expire (permanent).
func renewExpiry(current *time.Time, durationDays int) *time.Time {
if durationDays <= 0 {
return nil // permanent
}
base := time.Now()
if current != nil && current.After(base) {
base = *current
}
exp := base.Add(time.Duration(durationDays) * 24 * time.Hour)
return &exp
}
// applyRenewal renews a user account and clears any expiry-related suspension.
func (s *TelegramBotService) applyRenewal(ctx context.Context, userID string, durationDays int) error {
u, err := s.repo.User.FindByID(ctx, userID)
if err != nil || u == nil {
return fmt.Errorf("user not found")
}
exp := renewExpiry(u.ExpiredAt, durationDays)
updates := map[string]any{"expired_at": exp, "is_active": true}
return s.repo.User.UpdateFields(ctx, userID, updates)
}
// ── 签到 ────────────────────────────────────────────────────────────────────
// signInResult 描述一次签到的结果。
type signInResult struct {
AlreadySigned bool
Streak int
Total int
}
// signIn records a daily sign-in for the user, tracking consecutive-day streaks
// only (no points). A second sign-in on the same calendar day is a no-op.
func (s *TelegramBotService) signIn(ctx context.Context, userID string) (signInResult, error) {
now := time.Now()
today := now.Truncate(24 * time.Hour)
rec, err := s.repo.SignIn.Get(ctx, userID)
if err != nil {
return signInResult{}, err
}
if rec == nil {
rec = &model.SignIn{UserID: userID, LastSignIn: now, StreakDays: 1, TotalDays: 1}
if err := s.repo.SignIn.Save(ctx, rec); err != nil {
return signInResult{}, err
}
return signInResult{Streak: 1, Total: 1}, nil
}
last := rec.LastSignIn.Truncate(24 * time.Hour)
switch {
case last.Equal(today):
return signInResult{AlreadySigned: true, Streak: rec.StreakDays, Total: rec.TotalDays}, nil
case last.Equal(today.Add(-24 * time.Hour)):
rec.StreakDays++
default:
rec.StreakDays = 1 // streak broken
}
rec.TotalDays++
rec.LastSignIn = now
if err := s.repo.SignIn.Save(ctx, rec); err != nil {
return signInResult{}, err
}
return signInResult{Streak: rec.StreakDays, Total: rec.TotalDays}, nil
}
// ── helpers ─────────────────────────────────────────────────────────────────
const codeAlphabet = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789" // no ambiguous 0/O/1/I
func randomCode(n int) string {
b := make([]byte, n)
if _, err := rand.Read(b); err != nil {
// crypto/rand failure is extremely unlikely; fall back to time noise.
seed := time.Now().UnixNano()
for i := range b {
b[i] = byte(seed >> (uint(i%8) * 8))
}
}
out := make([]byte, n)
for i := range b {
out[i] = codeAlphabet[int(b[i])%len(codeAlphabet)]
}
return string(out)
}
// formatExpiry renders a user's expiry status for display.
func formatExpiry(t *time.Time) string {
if t == nil {
return "永久有效"
}
if time.Now().After(*t) {
return "已过期(" + t.Format("2006-01-02") + ")"
}
days := int(time.Until(*t).Hours() / 24)
return fmt.Sprintf("%s(剩 %d 天)", t.Format("2006-01-02"), days)
}
+261
View File
@@ -0,0 +1,261 @@
package service
import (
"context"
"testing"
"time"
"github.com/glebarez/sqlite"
"go.uber.org/zap"
"gorm.io/gorm"
"github.com/ShukeBta/MediaStationGo/internal/config"
"github.com/ShukeBta/MediaStationGo/internal/model"
"github.com/ShukeBta/MediaStationGo/internal/repository"
)
func newBotTestService(t *testing.T) (*repository.Container, *TelegramBotService) {
t.Helper()
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
if err != nil {
t.Fatal(err)
}
if err := db.AutoMigrate(model.AllModels()...); err != nil {
t.Fatal(err)
}
repos := repository.New(db)
cfg := &config.Config{}
cfg.Secrets.JWTSecret = "test-secret"
log := zap.NewNop()
perms := NewPermissionService(log, repos)
tokenSvc := NewTokenService(cfg, log, repos)
auth := NewAuthService(cfg, log, repos, tokenSvc, perms)
crypto := NewCryptoService("test-secret", log)
bot := NewTelegramBotService(log, repos, crypto, auth)
return repos, bot
}
// ── pure logic ──────────────────────────────────────────────────────────────
func TestRenewExpiry(t *testing.T) {
// 永久(0 天)→ nil
if got := renewExpiry(nil, 0); got != nil {
t.Fatalf("expected nil for permanent, got %v", got)
}
// 从现在起 +30 天(当前为空)
got := renewExpiry(nil, 30)
if got == nil || got.Before(time.Now().Add(29*24*time.Hour)) {
t.Fatalf("expected ~30d expiry, got %v", got)
}
// 已有未来到期 → 在原到期基础上叠加
future := time.Now().Add(10 * 24 * time.Hour)
got = renewExpiry(&future, 30)
if got == nil || got.Before(future.Add(29*24*time.Hour)) {
t.Fatalf("expected stacking on future expiry, got %v", got)
}
// 已过期 → 从现在起算
past := time.Now().Add(-10 * 24 * time.Hour)
got = renewExpiry(&past, 5)
if got == nil || got.Before(time.Now().Add(4*24*time.Hour)) {
t.Fatalf("expected fresh window from now, got %v", got)
}
}
func TestCapacityRemaining(t *testing.T) {
cases := []struct {
name string
c capacityInfo
want int64
}{
{"license only", capacityInfo{UsedUsers: 5, MaxUsers: 20}, 15},
{"quota tighter", capacityInfo{UsedUsers: 5, MaxUsers: 100, OpenRegLimit: 10, OpenRegUsed: 3}, 7},
{"license tighter", capacityInfo{UsedUsers: 95, MaxUsers: 100, OpenRegLimit: 50, OpenRegUsed: 0}, 5},
{"full", capacityInfo{UsedUsers: 20, MaxUsers: 20}, 0},
{"quota exhausted", capacityInfo{UsedUsers: 1, MaxUsers: 100, OpenRegLimit: 5, OpenRegUsed: 5}, 0},
}
for _, tc := range cases {
if got := tc.c.Remaining(); got != tc.want {
t.Errorf("%s: Remaining()=%d want %d", tc.name, got, tc.want)
}
}
}
func TestRandomWindowDays(t *testing.T) {
for i := 0; i < 200; i++ {
d := randomWindowDays(3, 5)
if d < 3 || d > 5 {
t.Fatalf("randomWindowDays(3,5)=%d out of range", d)
}
}
if d := randomWindowDays(4, 4); d != 4 {
t.Fatalf("randomWindowDays(4,4)=%d want 4", d)
}
}
func TestFingerprintStability(t *testing.T) {
a := fingerprint("Infuse", "iPhone")
b := fingerprint("infuse", " iPhone ")
if a != b {
t.Fatalf("fingerprint should be case/space-insensitive: %s != %s", a, b)
}
if a == fingerprint("Infuse", "iPad") {
t.Fatal("different device names must yield different fingerprints")
}
}
// ── DB-backed ─────────────────────────────────────────────────────────────
func TestSignInStreak(t *testing.T) {
ctx := context.Background()
repos, bot := newBotTestService(t)
u := &model.User{Username: "alice", PasswordHash: "x", Role: "user"}
if err := repos.User.Create(ctx, u); err != nil {
t.Fatal(err)
}
res, err := bot.signIn(ctx, u.ID)
if err != nil || res.Streak != 1 || res.Total != 1 {
t.Fatalf("first sign-in: %+v err=%v", res, err)
}
// 同日重复签到 → 不增长
res, _ = bot.signIn(ctx, u.ID)
if !res.AlreadySigned || res.Streak != 1 {
t.Fatalf("same-day re-signin should be no-op: %+v", res)
}
// 模拟昨天签到 → 连续 +1
rec, _ := repos.SignIn.Get(ctx, u.ID)
rec.LastSignIn = time.Now().Add(-24 * time.Hour)
_ = repos.SignIn.Save(ctx, rec)
res, _ = bot.signIn(ctx, u.ID)
if res.Streak != 2 || res.Total != 2 {
t.Fatalf("consecutive day should bump streak: %+v", res)
}
// 中断(前天)→ 重置为 1
rec, _ = repos.SignIn.Get(ctx, u.ID)
rec.LastSignIn = time.Now().Add(-72 * time.Hour)
_ = repos.SignIn.Save(ctx, rec)
res, _ = bot.signIn(ctx, u.ID)
if res.Streak != 1 {
t.Fatalf("broken streak should reset to 1: %+v", res)
}
}
func TestRegistrationCodeRedeemOnce(t *testing.T) {
ctx := context.Background()
repos, bot := newBotTestService(t)
code, err := bot.generateCode(ctx, model.RegistrationCodeRenew, 30, 0, "")
if err != nil {
t.Fatal(err)
}
// 首次校验通过
rc, msg := bot.lookupRedeemableCode(ctx, code.Code, model.RegistrationCodeRenew)
if rc == nil {
t.Fatalf("expected valid code, got msg=%q", msg)
}
// 标记使用后不可再用
if err := repos.RegCode.MarkUsed(ctx, rc.ID, "user-1"); err != nil {
t.Fatal(err)
}
if _, msg := bot.lookupRedeemableCode(ctx, code.Code, model.RegistrationCodeRenew); msg == "" {
t.Fatal("used code must not validate again")
}
// 第二次 MarkUsed 应失败(防止双花)
if err := repos.RegCode.MarkUsed(ctx, rc.ID, "user-2"); err == nil {
t.Fatal("double-spend should be rejected")
}
// 类型不匹配应被拒
reg, _ := bot.generateCode(ctx, model.RegistrationCodeRegister, 0, 0, "")
if _, msg := bot.lookupRedeemableCode(ctx, reg.Code, model.RegistrationCodeRenew); msg == "" {
t.Fatal("register code should not validate as renew")
}
}
func TestRenewalClearsExpiry(t *testing.T) {
ctx := context.Background()
repos, bot := newBotTestService(t)
past := time.Now().Add(-time.Hour)
u := &model.User{Username: "bob", PasswordHash: "x", Role: "user", IsActive: false, ExpiredAt: &past}
if err := repos.User.Create(ctx, u); err != nil {
t.Fatal(err)
}
if err := bot.applyRenewal(ctx, u.ID, 30); err != nil {
t.Fatal(err)
}
got, _ := repos.User.FindByID(ctx, u.ID)
if !got.IsActive {
t.Fatal("renewal should re-activate account")
}
if got.ExpiredAt == nil || got.ExpiredAt.Before(time.Now()) {
t.Fatalf("renewal should set future expiry, got %v", got.ExpiredAt)
}
}
func TestDeviceKickAndConcurrency(t *testing.T) {
ctx := context.Background()
repos, _ := newBotTestService(t)
dev := NewDeviceService(zap.NewNop(), repos)
u := &model.User{Username: "carol", PasswordHash: "x", Role: "user", IsActive: true}
if err := repos.User.Create(ctx, u); err != nil {
t.Fatal(err)
}
dev.RecordLogin(ctx, u.ID, "dev-1", "iPhone", "Infuse", "1.2.3.4")
dev.RecordPlayback(ctx, u.ID, "dev-1", "iPhone", "Infuse")
devices, _ := dev.ListDevices(ctx, u.ID)
if len(devices) != 1 {
t.Fatalf("expected 1 device, got %d", len(devices))
}
// 踢下线后命中 kicked
if err := dev.KickDevice(ctx, u.ID, "dev-1"); err != nil {
t.Fatal(err)
}
if !dev.IsDeviceKicked(ctx, u.ID, "dev-1") {
t.Fatal("device should be kicked")
}
// 重新登录清除 kicked
dev.RecordLogin(ctx, u.ID, "dev-1", "iPhone", "Infuse", "1.2.3.4")
if dev.IsDeviceKicked(ctx, u.ID, "dev-1") {
t.Fatal("re-login should clear kicked flag")
}
// 并发播放计数
now := time.Now()
for i, id := range []string{"d1", "d2", "d3", "d4"} {
_ = repos.UserDevice.Create(ctx, &model.UserDevice{
UserID: u.ID, DeviceID: id, FirstSeenAt: now, LastSeenAt: now, LastPlayAt: &now,
})
_ = i
}
n, err := repos.UserDevice.CountConcurrentPlaying(ctx, u.ID, now.Add(-time.Minute))
if err != nil {
t.Fatal(err)
}
if n < 4 {
t.Fatalf("expected >=4 concurrent playing, got %d", n)
}
}
func TestProtectedAdminNeverViolated(t *testing.T) {
ctx := context.Background()
repos, _ := newBotTestService(t)
dev := NewDeviceService(zap.NewNop(), repos)
admin := &model.User{Username: "root", PasswordHash: "x", Role: "admin", IsActive: true}
if err := repos.User.Create(ctx, admin); err != nil {
t.Fatal(err)
}
_ = repos.Setting.Set(ctx, SettingAntiShareEnabled, "true")
cfg := loadBotConfig(ctx, repos)
// 多次违规也不应删除/警告管理员
for i := 0; i < 5; i++ {
dev.registerViolation(ctx, admin.ID, "test", cfg)
}
got, _ := repos.User.FindByID(ctx, admin.ID)
if got == nil {
t.Fatal("admin must never be auto-deleted")
}
if got.ShareWarnings != 0 {
t.Fatalf("admin should accrue no warnings, got %d", got.ShareWarnings)
}
}
+103
View File
@@ -0,0 +1,103 @@
package service
import (
"context"
"strconv"
"github.com/ShukeBta/MediaStationGo/internal/repository"
)
// Bot / 设备管控相关的设置键。全部存储在 settings 表,可由管理员在 Bot 或
// 系统设置页调整。带安全默认值:所有"自动删号"策略默认关闭。
const (
// 开放注册(开注名额)。
SettingOpenRegEnabled = "telegram.openreg_enabled" // 是否开放注册
SettingOpenRegLimit = "telegram.openreg_limit" // 本轮开注名额上限(0=不限)
SettingOpenRegUsed = "telegram.openreg_used" // 本轮已用名额
// 防共享(警告制:并发播放 / 登录客户端 / 设备指纹)。
SettingAntiShareEnabled = "device.antishare_enabled" // 总开关(默认关)
SettingMaxConcurrentPlay = "device.max_concurrent_play" // 最大并发播放设备
SettingMaxLoggedClients = "device.max_logged_clients" // 最大同时登录客户端
SettingWarnThreshold = "device.warn_threshold" // 警告几次后删号
SettingPlayWindowSeconds = "device.play_window_seconds" // 并发播放判定窗口(秒)
SettingClientActiveDays = "device.client_active_days" // 登录设备活跃天数窗口
// 不活跃清理(独立开关)。
SettingInactiveEnabled = "device.inactive_enabled" // 总开关(默认关)
SettingInactiveMinHours = "device.inactive_min_hours" // 窗口内最低观看小时
SettingInactiveWindowMin = "device.inactive_window_days_min" // 随机窗口下限(天)
SettingInactiveWindowMax = "device.inactive_window_days_max" // 随机窗口上限(天)
SettingInactiveGraceDays = "device.inactive_grace_days" // 新号宽限期(天)
)
// botConfig 是设备管控的已解析配置(含默认值)。
type botConfig struct {
AntiShareEnabled bool
MaxConcurrentPlay int
MaxLoggedClients int
WarnThreshold int
PlayWindowSeconds int
ClientActiveDays int
InactiveEnabled bool
InactiveMinHours int
InactiveWindowMin int
InactiveWindowMax int
InactiveGraceDays int
}
// defaultBotConfig returns the safe defaults requested by the operator.
func defaultBotConfig() botConfig {
return botConfig{
AntiShareEnabled: false, // 自动删号默认关闭,需管理员显式开启
MaxConcurrentPlay: 3,
MaxLoggedClients: 3,
WarnThreshold: 2, // 两次警告后再犯删号
PlayWindowSeconds: 90,
ClientActiveDays: 30,
InactiveEnabled: false, // 默认关闭
InactiveMinHours: 6,
InactiveWindowMin: 3,
InactiveWindowMax: 5,
InactiveGraceDays: 7,
}
}
// loadBotConfig reads the device-management configuration from settings,
// falling back to safe defaults for any missing/invalid key.
func loadBotConfig(ctx context.Context, repo *repository.Container) botConfig {
cfg := defaultBotConfig()
get := func(key string) string {
v, err := repo.Setting.Get(ctx, key)
if err != nil {
return ""
}
return v
}
cfg.AntiShareEnabled = parseBoolSetting(get(SettingAntiShareEnabled), cfg.AntiShareEnabled)
cfg.InactiveEnabled = parseBoolSetting(get(SettingInactiveEnabled), cfg.InactiveEnabled)
cfg.MaxConcurrentPlay = parseIntSettingDefault(get(SettingMaxConcurrentPlay), cfg.MaxConcurrentPlay)
cfg.MaxLoggedClients = parseIntSettingDefault(get(SettingMaxLoggedClients), cfg.MaxLoggedClients)
cfg.WarnThreshold = parseIntSettingDefault(get(SettingWarnThreshold), cfg.WarnThreshold)
cfg.PlayWindowSeconds = parseIntSettingDefault(get(SettingPlayWindowSeconds), cfg.PlayWindowSeconds)
cfg.ClientActiveDays = parseIntSettingDefault(get(SettingClientActiveDays), cfg.ClientActiveDays)
cfg.InactiveMinHours = parseIntSettingDefault(get(SettingInactiveMinHours), cfg.InactiveMinHours)
cfg.InactiveWindowMin = parseIntSettingDefault(get(SettingInactiveWindowMin), cfg.InactiveWindowMin)
cfg.InactiveWindowMax = parseIntSettingDefault(get(SettingInactiveWindowMax), cfg.InactiveWindowMax)
cfg.InactiveGraceDays = parseIntSettingDefault(get(SettingInactiveGraceDays), cfg.InactiveGraceDays)
if cfg.InactiveWindowMax < cfg.InactiveWindowMin {
cfg.InactiveWindowMax = cfg.InactiveWindowMin
}
return cfg
}
// parseIntSettingDefault parses an int setting, returning fallback on error.
func parseIntSettingDefault(value string, fallback int) int {
n, err := strconv.Atoi(value)
if err != nil {
return fallback
}
return n
}
+311
View File
@@ -0,0 +1,311 @@
package service
import (
"context"
"crypto/sha256"
"encoding/hex"
"fmt"
"math/rand"
"strings"
"time"
"go.uber.org/zap"
"github.com/ShukeBta/MediaStationGo/internal/model"
"github.com/ShukeBta/MediaStationGo/internal/repository"
)
// DeviceService implements device/session tracking and the two decoupled
// enforcement policies requested by the operator:
//
// ① 防共享 (anti-sharing, warning-based): too many concurrent playbacks,
// too many logged-in clients, or a device-fingerprint mismatch each emit a
// warning. After the configured number of warnings, a re-offence deletes
// the account. These three sub-rules share one per-user warning counter.
// ② 不活跃清理 (inactivity cleanup, independent toggle): watching less than
// the configured hours over a random 3–5 day window deletes the account.
//
// Safeguards (always enforced): admin / protected accounts are never auto
// disabled or deleted; a Telegram notification is sent before any destructive
// action; every threshold is configurable and both policies default to OFF.
type DeviceService struct {
log *zap.Logger
repo *repository.Container
// notifyUser sends a Telegram message to the local user (resolved to their
// Telegram binding). Wired by the bot service; nil disables notifications.
notifyUser func(ctx context.Context, userID, text string)
}
// NewDeviceService constructs a DeviceService.
func NewDeviceService(log *zap.Logger, repo *repository.Container) *DeviceService {
return &DeviceService{log: log, repo: repo}
}
// SetNotifier wires the per-user Telegram notification callback.
func (s *DeviceService) SetNotifier(fn func(ctx context.Context, userID, text string)) {
s.notifyUser = fn
}
// fingerprint derives a stable short hash from the client + device name. A
// changed fingerprint for the same device id signals the session was cloned
// onto different hardware/software.
func fingerprint(client, deviceName string) string {
sum := sha256.Sum256([]byte(strings.ToLower(strings.TrimSpace(client)) + "|" + strings.ToLower(strings.TrimSpace(deviceName))))
return hex.EncodeToString(sum[:])[:16]
}
// isProtected reports whether a user must never be auto disabled/deleted.
// Admins are always protected; the earliest admin (default admin) too.
func (s *DeviceService) isProtected(ctx context.Context, u *model.User) bool {
if u == nil {
return true
}
if u.Role == "admin" {
return true
}
if first, err := s.repo.User.FirstAdmin(ctx); err == nil && first != nil && first.ID == u.ID {
return true
}
return false
}
// RecordLogin records (or refreshes) a device session at authentication time
// and runs the logged-in-client + fingerprint anti-share checks. It is safe to
// call on every Emby/Jellyfin AuthenticateByName request.
func (s *DeviceService) RecordLogin(ctx context.Context, userID, deviceID, deviceName, client, ip string) {
if userID == "" {
return
}
if deviceID == "" {
// Fall back to a fingerprint-derived id so headless clients still count.
deviceID = "fp-" + fingerprint(client, deviceName)
}
fp := fingerprint(client, deviceName)
now := time.Now()
existing, _ := s.repo.UserDevice.Find(ctx, userID, deviceID)
mismatch := false
if existing == nil {
_ = s.repo.UserDevice.Create(ctx, &model.UserDevice{
UserID: userID,
DeviceID: deviceID,
DeviceName: deviceName,
Client: client,
Fingerprint: fp,
LastIP: ip,
FirstSeenAt: now,
LastSeenAt: now,
})
} else {
if existing.Fingerprint != "" && existing.Fingerprint != fp {
mismatch = true
}
existing.DeviceName = deviceName
existing.Client = client
existing.Fingerprint = fp
existing.LastIP = ip
existing.LastSeenAt = now
existing.Kicked = false
_ = s.repo.UserDevice.Save(ctx, existing)
}
cfg := loadBotConfig(ctx, s.repo)
if !cfg.AntiShareEnabled {
return
}
if mismatch {
s.registerViolation(ctx, userID, fmt.Sprintf("设备指纹变更(设备:%s)", deviceLabel(deviceName, client)), cfg)
return
}
since := now.Add(-time.Duration(cfg.ClientActiveDays) * 24 * time.Hour)
if n, err := s.repo.UserDevice.CountActiveClients(ctx, userID, since); err == nil && int(n) > cfg.MaxLoggedClients {
s.registerViolation(ctx, userID, fmt.Sprintf("同时登录客户端 %d 台,超过上限 %d 台", n, cfg.MaxLoggedClients), cfg)
}
}
// RecordPlayback marks a device as actively playing and runs the concurrent
// playback anti-share check. Call from playback-progress reporting.
func (s *DeviceService) RecordPlayback(ctx context.Context, userID, deviceID, deviceName, client string) {
if userID == "" {
return
}
if deviceID == "" {
deviceID = "fp-" + fingerprint(client, deviceName)
}
now := time.Now()
existing, _ := s.repo.UserDevice.Find(ctx, userID, deviceID)
if existing == nil {
existing = &model.UserDevice{
UserID: userID,
DeviceID: deviceID,
DeviceName: deviceName,
Client: client,
Fingerprint: fingerprint(client, deviceName),
FirstSeenAt: now,
LastSeenAt: now,
}
existing.LastPlayAt = &now
_ = s.repo.UserDevice.Create(ctx, existing)
} else {
existing.LastSeenAt = now
existing.LastPlayAt = &now
_ = s.repo.UserDevice.Save(ctx, existing)
}
cfg := loadBotConfig(ctx, s.repo)
if !cfg.AntiShareEnabled {
return
}
since := now.Add(-time.Duration(cfg.PlayWindowSeconds) * time.Second)
if n, err := s.repo.UserDevice.CountConcurrentPlaying(ctx, userID, since); err == nil && int(n) > cfg.MaxConcurrentPlay {
s.registerViolation(ctx, userID, fmt.Sprintf("同时播放 %d 台,超过上限 %d 台", n, cfg.MaxConcurrentPlay), cfg)
}
}
// registerViolation increments the shared anti-share warning counter for a
// user, notifies them, and deletes the account once warnings exceed the
// threshold. Protected accounts are never auto-deleted. Violations are
// debounced so a single burst counts at most once per minute.
func (s *DeviceService) registerViolation(ctx context.Context, userID, reason string, cfg botConfig) {
u, err := s.repo.User.FindByID(ctx, userID)
if err != nil || u == nil {
return
}
if s.isProtected(ctx, u) {
s.log.Info("anti-share: skipping protected account", zap.String("user", u.Username), zap.String("reason", reason))
return
}
now := time.Now()
if u.LastShareWarnAt != nil && now.Sub(*u.LastShareWarnAt) < time.Minute {
return // debounce burst
}
warnings := u.ShareWarnings + 1
if warnings > cfg.WarnThreshold {
// Exhausted warnings → delete (notify first).
s.notify(ctx, userID, fmt.Sprintf("⛔️ 账号 <b>%s</b> 因多次触发防共享规则(%s)已被删除。如有疑问请联系管理员。", u.Username, reason))
s.log.Warn("anti-share: deleting account after warnings",
zap.String("user", u.Username), zap.Int("warnings", u.ShareWarnings), zap.String("reason", reason))
_ = s.repo.UserDevice.DeleteByUser(ctx, userID)
_ = s.repo.User.Delete(ctx, userID)
return
}
_ = s.repo.User.UpdateFields(ctx, userID, map[string]any{
"share_warnings": warnings,
"last_share_warn_at": &now,
})
left := cfg.WarnThreshold + 1 - warnings
s.notify(ctx, userID, fmt.Sprintf("⚠️ 账号 <b>%s</b> 触发防共享规则:%s\n这是第 <b>%d</b> 次警告,再违规 <b>%d</b> 次将删除账号。请使用 Bot 的「我的设备」一键踢下线多余设备。", u.Username, reason, warnings, left))
s.log.Info("anti-share: warning issued", zap.String("user", u.Username), zap.Int("warnings", warnings), zap.String("reason", reason))
}
// SweepInactiveUsers runs the inactivity cleanup policy once. It picks a random
// window in [min,max] days, then deletes non-protected users (past their grace
// period) who watched less than the configured hours in that window. Returns
// the number of accounts removed. No-op when the policy is disabled.
func (s *DeviceService) SweepInactiveUsers(ctx context.Context) (int, error) {
cfg := loadBotConfig(ctx, s.repo)
if !cfg.InactiveEnabled {
return 0, nil
}
windowDays := randomWindowDays(cfg.InactiveWindowMin, cfg.InactiveWindowMax)
since := time.Now().Add(-time.Duration(windowDays) * 24 * time.Hour)
graceCutoff := time.Now().Add(-time.Duration(cfg.InactiveGraceDays) * 24 * time.Hour)
minMs := int64(cfg.InactiveMinHours) * 3600 * 1000
users, err := s.repo.User.List(ctx)
if err != nil {
return 0, err
}
removed := 0
for i := range users {
u := &users[i]
if s.isProtected(ctx, u) || !u.IsActive {
continue
}
if u.CreatedAt.After(graceCutoff) {
continue // still within new-account grace period
}
watched, err := s.repo.UserDevice.WatchedMillisSince(ctx, u.ID, since)
if err != nil {
continue
}
if watched >= minMs {
continue // active enough → keep
}
s.notify(ctx, u.ID, fmt.Sprintf("⛔️ 账号 <b>%s</b> 因近 %d 天观看时长不足 %d 小时(不活跃)已被清理。如需恢复请联系管理员。", u.Username, windowDays, cfg.InactiveMinHours))
s.log.Warn("inactivity: deleting inactive account",
zap.String("user", u.Username), zap.Int("window_days", windowDays), zap.Int64("watched_ms", watched))
_ = s.repo.UserDevice.DeleteByUser(ctx, u.ID)
if err := s.repo.User.Delete(ctx, u.ID); err == nil {
removed++
}
}
return removed, nil
}
// KickDevice marks a device as kicked so the next request from it is rejected
// (the client must log in again). Returns the affected device for messaging.
func (s *DeviceService) KickDevice(ctx context.Context, userID, deviceID string) error {
d, err := s.repo.UserDevice.Find(ctx, userID, deviceID)
if err != nil {
return err
}
if d == nil {
return fmt.Errorf("device not found")
}
return s.repo.UserDevice.SetKicked(ctx, d.ID, true)
}
// ListDevices returns the device sessions for a user.
func (s *DeviceService) ListDevices(ctx context.Context, userID string) ([]model.UserDevice, error) {
return s.repo.UserDevice.ListByUser(ctx, userID)
}
// IsDeviceKicked reports whether a (user, device) pair was kicked and should be
// forced to re-authenticate.
func (s *DeviceService) IsDeviceKicked(ctx context.Context, userID, deviceID string) bool {
if userID == "" || deviceID == "" {
return false
}
d, err := s.repo.UserDevice.Find(ctx, userID, deviceID)
return err == nil && d != nil && d.Kicked
}
func (s *DeviceService) notify(ctx context.Context, userID, text string) {
if s.notifyUser != nil {
s.notifyUser(ctx, userID, text)
}
}
// randomWindowDays returns a random integer in [min,max]. The window is
// intentionally non-fixed per the operator's requirement ("随机触发").
func randomWindowDays(min, max int) int {
if min < 1 {
min = 1
}
if max < min {
max = min
}
if max == min {
return min
}
return min + rand.Intn(max-min+1)
}
func deviceLabel(name, client string) string {
name = strings.TrimSpace(name)
client = strings.TrimSpace(client)
switch {
case name != "" && client != "":
return name + " / " + client
case name != "":
return name
case client != "":
return client
default:
return "未知设备"
}
}
+2
View File
@@ -26,6 +26,8 @@ var (
patEP = regexp.MustCompile(`(?i)(?:^|[^a-z])(?:e|ep)\.?\s*(\d{1,3})(?:[^0-9]|$)`)
patCN = regexp.MustCompile(`第\s*(\d{1,3})\s*[集话話期]`)
patSeasonFolder = regexp.MustCompile(`(?i)(?:^|[^a-z])(?:s|season)\.?\s*(\d{1,2})(?:[^0-9]|$)|第\s*(\d{1,2})\s*季`)
// patCNSeason 匹配中文季/部标记,支持阿拉伯数字与中文数字(如「第二季」「第2部」)。
patCNSeason = regexp.MustCompile(`第\s*[0-9一二三四五六七八九十百零两]+\s*[季部]`)
)
// ParseEpisode tries to extract (season, episode) from an arbitrary filename.
+1 -1
View File
@@ -8,7 +8,7 @@ import (
func TestResolveAccessibleLibraryPathMapsConfiguredHostMediaDir(t *testing.T) {
root := t.TempDir()
hostRoot := filepath.Join(root, "nas", "moviepilot-v2", "media")
hostRoot := filepath.Join(root, "nas", "host", "media")
containerRoot := filepath.Join(root, "container", "media")
containerLibrary := filepath.Join(containerRoot, "电视剧", "国产剧")
if err := os.MkdirAll(containerLibrary, 0o755); err != nil {
+61
View File
@@ -0,0 +1,61 @@
package service
import "testing"
// TestOrganizeNaming locks in the rename pipeline used by OrganizeDirectory:
// CleanQuery (title/year) + ParseEpisode (season/episode) + titleCaseWords.
// These cases previously regressed (release tags such as BD/UHD leaking into
// the title, Roman-numeral sequels becoming "Ii", Chinese season markers like
// 第二季 polluting the title).
func TestOrganizeNaming(t *testing.T) {
cases := []struct {
file string
wantTitle string // titleCaseWords(CleanQuery) output
wantYear int
wantSeason int
wantEp int
}{
{"流浪地球2.2023.2160p.WEB-DL.H265.mkv", "流浪地球2", 2023, 0, 0},
{"[阳光电影www.ygdy8.com].复仇者联盟4.2019.BD.1080p.mkv", "复仇者联盟4", 2019, 0, 0},
{"狂飙.S01E05.2023.1080p.WEB-DL.mp4", "狂飙", 2023, 1, 5},
{"The.Wandering.Earth.II.2023.2160p.mkv", "The Wandering Earth II", 2023, 0, 0},
{"庆余年第二季.Joy.of.Life.S02E01.2024.mp4", "庆余年 Joy Of Life", 2024, 2, 1},
{"三体.Three-Body.2023.S01E03.4K.mkv", "三体 Three Body", 2023, 1, 3},
{"Friends.S03E12.1994.720p.mkv", "Friends", 1994, 3, 12},
{"Oppenheimer.2023.2160p.UHD.BluRay.mkv", "Oppenheimer", 2023, 0, 0},
{"Rocky.IV.1985.1080p.BluRay.mkv", "Rocky IV", 1985, 0, 0},
}
for _, tc := range cases {
t.Run(tc.file, func(t *testing.T) {
title, year := CleanQuery(tc.file)
gotTitle := sanitizeFilename(titleCaseWords(title))
season, ep := ParseEpisode(tc.file)
if gotTitle != tc.wantTitle {
t.Errorf("title = %q, want %q", gotTitle, tc.wantTitle)
}
if year != tc.wantYear {
t.Errorf("year = %d, want %d", year, tc.wantYear)
}
if season != tc.wantSeason || ep != tc.wantEp {
t.Errorf("season/ep = %d/%d, want %d/%d", season, ep, tc.wantSeason, tc.wantEp)
}
})
}
}
// TestTitleCaseWordsRomanNumerals verifies sequel numerals are upper-cased
// while ordinary words that merely resemble numerals are not.
func TestTitleCaseWordsRomanNumerals(t *testing.T) {
cases := map[string]string{
"wandering earth ii": "Wandering Earth II",
"rocky iv": "Rocky IV",
"final fantasy vii": "Final Fantasy VII",
"the mix tape": "The Mix Tape", // "mix" must NOT become "MIX"
"sid and nancy": "Sid And Nancy",
}
for in, want := range cases {
if got := titleCaseWords(in); got != want {
t.Errorf("titleCaseWords(%q) = %q, want %q", in, got, want)
}
}
}
+22 -3
View File
@@ -5,8 +5,8 @@
// Unlike OrganizeLibraryWithOptions, which only touches model.Media rows that
// already belong to a registered library, OrganizeDirectory walks the source
// directory on disk directly. This lets operators organize the whole download
// directory (/downloads or a NAS direct-read path like
// /vol1/1000/qBittorrent/downloads) even though it is not a registered library.
// directory (/downloads or a NAS direct-read path configured by the operator)
// even though it is not a registered library.
//
// Two protections requested by operators:
//
@@ -302,10 +302,16 @@ func (o *OrganizerService) existingByFolder(destDir, episodeTag string) []string
}
// titleCaseWords upper-cases the first letter of each ASCII word; CJK and other
// non-ASCII leading characters are left untouched.
// non-ASCII leading characters are left untouched. Roman numerals (ii, iii, iv,
// …) are fully upper-cased so sequels like "Wandering Earth II" keep their
// canonical casing instead of becoming "Ii".
func titleCaseWords(s string) string {
fields := strings.Fields(s)
for i, w := range fields {
if isRomanNumeral(w) {
fields[i] = strings.ToUpper(w)
continue
}
r := []rune(w)
if len(r) > 0 && r[0] < 128 {
r[0] = unicode.ToUpper(r[0])
@@ -315,6 +321,19 @@ func titleCaseWords(s string) string {
return strings.Join(fields, " ")
}
// sequelNumerals is a conservative whitelist of multi-letter Roman numerals
// used for movie/series sequels. A whitelist avoids false positives on normal
// English words that happen to be valid numerals (e.g. "mix", "civ", "mi").
var sequelNumerals = map[string]struct{}{
"ii": {}, "iii": {}, "iv": {}, "vi": {}, "vii": {}, "viii": {},
"ix": {}, "xi": {}, "xii": {}, "xiii": {}, "xiv": {}, "xv": {},
}
func isRomanNumeral(w string) bool {
_, ok := sequelNumerals[strings.ToLower(w)]
return ok
}
// replaceVersions removes the existing lower-resolution files (and their NFO
// sidecars + DB rows) and transfers src into dst.
func (o *OrganizerService) replaceVersions(ctx context.Context, src string, existing []string, dst string, mode TransferMode) error {
+11 -5
View File
@@ -67,12 +67,15 @@ var yearPattern = regexp.MustCompile(`(?:^|[^\d])(19\d{2}|20\d{2})(?:[^\d]|$)`)
// noiseTokens are stripped before search.
var noiseTokens = []string{
// 视频规格
"1080p", "2160p", "4k", "720p", "480p",
"1080p", "2160p", "4k", "720p", "480p", "uhd", "ds4k", "fhd",
"bd", "bdrip", "brrip", "dvd", "dvdrip", "hdtv", "pdtv", "webdl",
"hdrip", "bluray", "blu-ray", "webrip", "web-dl", "web",
"x264", "x265", "h264", "h265", "hevc", "avc",
"hdr", "sdr", "dts", "ddp", "atmos", "aac", "ac3", "flac",
"remux", "extended", "uncut", "directors-cut", "directors_cut",
"hkfree", "yify", "rarbg", "ettv", "fgt",
"x264", "x265", "h264", "h265", "hevc", "avc", "10bit", "8bit", "hi10p", "hi10",
"hdr", "hdr10", "sdr", "dts", "ddp", "ddp5", "dd5", "dd2", "eac3", "truehd",
"dovi", "atmos", "aac", "ac3", "flac",
"remux", "extended", "uncut", "remastered", "repack", "proper", "internal",
"limited", "imax", "directors-cut", "directors_cut",
"hkfree", "yify", "rarbg", "ettv", "fgt", "tgx", "ctrlhd", "ntb", "flux",
// 流媒体平台 / 字幕组 / 国家版本(动漫常见)
"netflix", "nf", "amzn", "hulu", "disney", "max", "hbo",
@@ -126,6 +129,9 @@ func CleanQuery(raw string) (title string, year int) {
lower = patNxE.ReplaceAllString(lower, " ")
lower = patEP.ReplaceAllString(lower, " ")
lower = patCN.ReplaceAllString(lower, " ")
// 去掉中文季/部标记(如「第二季」「第2部」),避免残留在标题里既污染
// 搜索查询又导致整理后的目录名重复季信息。
lower = patCNSeason.ReplaceAllString(lower, " ")
for _, pat := range multiWordNoise {
lower = pat.ReplaceAllString(lower, " ")
+33
View File
@@ -68,6 +68,7 @@ type Container struct {
DownloadMgr *DownloadManager
Notify *NotifyService
Site *SiteService
Device *DeviceService
stopCtx context.Context
stopCancel context.CancelFunc
@@ -120,7 +121,12 @@ func New(cfg *config.Config, log *zap.Logger, repos *repository.Container) *Cont
// 初始化认证相关服务
tokenSvc := NewTokenService(cfg, log, repos)
authSvc := NewAuthService(cfg, log, repos, tokenSvc, permissions)
deviceSvc := NewDeviceService(log, repos)
telegramBot := NewTelegramBotService(log, repos, crypto, authSvc)
telegramBot.SetDeviceService(deviceSvc)
// Allow the device-enforcement service to DM users (warnings / deletions)
// through their Telegram binding before any destructive action.
deviceSvc.SetNotifier(telegramBot.NotifyUserByID)
apiConfigSvc := NewApiConfigService(cfg, log, repos, crypto)
downloadMgr := NewDownloadManager(log, repos, crypto)
notifySvc := NewNotifyService(log, repos, crypto)
@@ -207,6 +213,7 @@ func New(cfg *config.Config, log *zap.Logger, repos *repository.Container) *Cont
DownloadMgr: downloadMgr,
Notify: notifySvc,
Site: siteSvc,
Device: deviceSvc,
stopCtx: ctx,
stopCancel: cancel,
}
@@ -231,6 +238,32 @@ func (c *Container) Boot() {
// 启动调度器定时任务
c.Scheduler.Start(c.stopCtx)
// 不活跃清理巡检:随机 3~5 天窗口、默认关闭,由管理员在 Bot 设备策略开启。
// 每天触发一次评估(窗口天数随机,不固定)。
if c.Device != nil {
go c.runInactivitySweeper(c.stopCtx)
}
}
// runInactivitySweeper periodically runs the inactivity-cleanup policy. The
// policy itself is a no-op unless an admin enabled it; this just provides the
// daily trigger with a non-fixed random window (handled inside the sweep).
func (c *Container) runInactivitySweeper(ctx context.Context) {
ticker := time.NewTicker(24 * time.Hour)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
if n, err := c.Device.SweepInactiveUsers(ctx); err != nil {
c.Log.Warn("inactivity sweep failed", zap.Error(err))
} else if n > 0 {
c.Log.Info("inactivity sweep removed accounts", zap.Int("count", n))
}
}
}
}
// Close 释放 services 持有的任何资源(websocket hub, ffmpeg 转码, fsnotify, 后台轮询器)。
+78 -4
View File
@@ -76,9 +76,46 @@ type TelegramBotService struct {
repo *repository.Container
crypto *CryptoService
auth *AuthService
device *DeviceService
pollingMu sync.Mutex
pollingCancel map[string]context.CancelFunc // bot_token -> cancel
pendingMu sync.Mutex
pending map[int64]pendingInput // telegram_user_id -> awaited text input
}
// pendingInput tracks a button-initiated action that awaits the user's next
// text message (e.g. tapping「注册」then sending "用户名 密码").
type pendingInput struct {
Kind string // register / redeem_register / redeem_renew / setname / setpass / openreg_limit / gencode_user
CreatedAt time.Time
}
// SetDeviceService wires the device-management service used by the device
// menu (list / kick) and enforcement notifications.
func (s *TelegramBotService) SetDeviceService(d *DeviceService) { s.device = d }
// NotifyUserByID sends a Telegram message to the local user identified by
// userID, resolved through their Telegram binding. Used by enforcement to warn
// users before destructive actions. No-op when the user has no binding.
func (s *TelegramBotService) NotifyUserByID(ctx context.Context, userID, text string) {
if userID == "" || strings.TrimSpace(text) == "" {
return
}
var binding model.TelegramBinding
if err := s.repo.DB.WithContext(ctx).Where("user_id = ?", userID).First(&binding).Error; err != nil {
return
}
channel := s.findChannelByChatID(ctx, int(binding.ChatID))
if channel == nil {
channels, err := s.repo.NotifyChannel.ListByType(ctx, "telegram")
if err != nil || len(channels) == 0 {
return
}
channel = &channels[0]
}
_ = s.reply(ctx, channel, int(binding.ChatID), telegramCommandReply{Text: text})
}
// NewTelegramBotService 创建 Telegram Bot 服务。
@@ -89,6 +126,7 @@ func NewTelegramBotService(log *zap.Logger, repo *repository.Container, crypto *
crypto: crypto,
auth: auth,
pollingCancel: make(map[string]context.CancelFunc),
pending: make(map[int64]pendingInput),
}
}
@@ -127,7 +165,23 @@ func (s *TelegramBotService) HandleWebhook(ctx context.Context, body []byte) err
msg := update.Message
text := strings.TrimSpace(msg.Text)
// Button-initiated text prompts (register / redeem / change name·password /
// open-reg limit) arrive as ordinary messages. Consume them here before the
// command gate so the button-driven menu can collect free-form input.
if !telegramIsCommandText(text) {
if msg.Chat.Type == "" || msg.Chat.Type == "private" {
if channel := s.findChannelForMessage(ctx, msg); channel != nil {
if reply, handled := s.handlePendingText(ctx, channel, msg, text); handled {
if reply.Text != "" {
if err := s.reply(ctx, channel, msg.Chat.ID, reply); err != nil {
s.log.Error("reply failed", zap.Error(err))
}
}
return nil
}
}
}
return nil
}
if msg.Chat.Type != "" && msg.Chat.Type != "private" && !telegramSupportedCommand(telegramCommandName(text)) {
@@ -184,7 +238,15 @@ func (s *TelegramBotService) executeCommand(ctx context.Context, channel *model.
switch cmd {
case "/start":
if len(args) == 0 {
return s.mainMenu(ctx, channel, msg), nil
}
return s.cmdStart(ctx, msg, args), nil
case "/menu":
return s.mainMenu(ctx, channel, msg), nil
case "/cancel":
s.takePending(int64(msg.From.ID))
return telegramCommandReply{Text: "已取消当前操作。"}, nil
case "/help":
return telegramCommandReply{Text: s.cmdHelp(ctx, msg)}, nil
case "/hideadult", "/hide_adult", "/adult":
@@ -242,7 +304,7 @@ func telegramCommandName(text string) string {
func telegramSupportedCommand(cmd string) bool {
switch cmd {
case "/start", "/help", "/hideadult", "/hide_adult", "/adult",
case "/start", "/menu", "/cancel", "/help", "/hideadult", "/hide_adult", "/adult",
"/register", "/reg", "/signup", "/registration", "/reg_switch",
"/status", "/search", "/downloads", "/stats":
return true
@@ -315,9 +377,13 @@ func (s *TelegramBotService) cmdStart(ctx context.Context, msg *TelegramMessage,
// cmdRegister 处理 /register 命令:在管理员开启注册后,普通用户可通过 Bot
// 注册一个新的媒体中心账号,并自动绑定到当前 Telegram 账号。
func (s *TelegramBotService) cmdRegister(ctx context.Context, channel *model.NotifyChannel, msg *TelegramMessage, args []string) telegramCommandReply {
if !s.registrationEnabled(ctx) {
if !s.openRegEnabled(ctx) {
return telegramCommandReply{Text: "注册功能未开放,请联系管理员开启后再试。"}
}
// 开注名额已用尽则拦截(容量随凭证授权实时变化,名额单独计数)。
if c := s.loadCapacity(ctx); c.Remaining() <= 0 {
return telegramCommandReply{Text: "注册名额已满,请等待管理员重新开放或扩容授权。"}
}
if s.auth == nil {
return telegramCommandReply{Text: "注册功能暂不可用,请联系管理员。"}
}
@@ -347,6 +413,8 @@ func (s *TelegramBotService) cmdRegister(ctx context.Context, channel *model.Not
return telegramCommandReply{Text: "注册失败:" + err.Error()}
}
}
// 注册成功,扣减一个开注名额(名额用尽自动关闭注册)。
s.consumeOpenRegSlot(ctx)
if err := s.upsertTelegramBinding(ctx, msg, user.ID); err != nil {
return telegramCommandReply{Text: fmt.Sprintf("账号 <b>%s</b> 注册成功,但自动绑定失败:%s\n请稍后使用 <code>/start %s 密码</code> 重新绑定。", user.Username, err.Error(), user.Username)}
}
@@ -836,12 +904,18 @@ func (s *TelegramBotService) handleCallback(ctx context.Context, cb *TelegramCal
}
// 立即应答回调,关闭按钮上的加载状态,避免客户端长时间转圈。
s.answerCallback(ctx, channel, cb.ID)
switch strings.TrimSpace(cb.Data) {
case "adult_toggle":
data := strings.TrimSpace(cb.Data)
if data == "adult_toggle" {
reply := s.cmdHideAdult(ctx, &msg, nil)
if reply.Text != "" {
return s.reply(ctx, channel, cb.Message.Chat.ID, reply)
}
return nil
}
if reply, handled := s.handleMenuCallback(ctx, channel, &msg, data); handled {
if reply.Text != "" {
return s.reply(ctx, channel, cb.Message.Chat.ID, reply)
}
}
return nil
}
+608
View File
@@ -0,0 +1,608 @@
package service
import (
"context"
"fmt"
"strconv"
"strings"
"time"
"github.com/ShukeBta/MediaStationGo/internal/model"
)
// pendingTTL bounds how long a button-initiated text prompt stays valid.
const pendingTTL = 5 * time.Minute
func (s *TelegramBotService) setPending(userID int64, kind string) {
s.pendingMu.Lock()
s.pending[userID] = pendingInput{Kind: kind, CreatedAt: time.Now()}
s.pendingMu.Unlock()
}
func (s *TelegramBotService) takePending(userID int64) (pendingInput, bool) {
s.pendingMu.Lock()
defer s.pendingMu.Unlock()
p, ok := s.pending[userID]
if ok {
delete(s.pending, userID)
}
if ok && time.Since(p.CreatedAt) > pendingTTL {
return pendingInput{}, false
}
return p, ok
}
// boundUser resolves the local user bound to a Telegram account, or nil.
func (s *TelegramBotService) boundUser(ctx context.Context, telegramUserID int) *model.User {
binding := s.telegramBinding(ctx, telegramUserID)
if binding == nil {
return nil
}
u, _ := s.repo.User.FindByID(ctx, binding.UserID)
return u
}
// mainMenu builds the button-based menu, tailored to the user's binding and
// admin status. Ordinary users only see self-service actions; admins get an
// extra management section.
func (s *TelegramBotService) mainMenu(ctx context.Context, channel *model.NotifyChannel, msg *TelegramMessage) telegramCommandReply {
isAdmin := s.telegramUserIsAdmin(ctx, channel, msg.From.ID)
user := s.boundUser(ctx, msg.From.ID)
var rows [][]telegramInlineButton
var header string
if user == nil {
header = "<b>MediaStationGo</b>\n\n你还没有绑定媒体中心账号。"
rows = append(rows, []telegramInlineButton{{Text: "🔗 绑定账号", Data: "act_bind"}})
if s.openRegEnabled(ctx) {
rows = append(rows, []telegramInlineButton{{Text: "📝 注册新账号", Data: "act_register"}})
}
rows = append(rows, []telegramInlineButton{{Text: "🎟 兑换码注册", Data: "act_redeem_register"}})
} else {
adult := map[bool]string{true: "已隐藏", false: "已显示"}[user.HideAdult]
header = fmt.Sprintf("<b>MediaStationGo</b>\n\n账号:<b>%s</b>\n到期:<b>%s</b>\n成人目录:<b>%s</b>",
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"},
},
[]telegramInlineButton{
{Text: "✏️ 改用户名", Data: "act_setname"},
{Text: "🔑 改密码", Data: "act_setpass"},
},
[]telegramInlineButton{{Text: "🎟 兑换码续期", Data: "act_redeem_renew"}},
)
}
if isAdmin {
rows = append(rows,
[]telegramInlineButton{{Text: "—— 管理员 ——", Data: "noop"}},
[]telegramInlineButton{
{Text: "📊 容量/状态", Data: "adm_capacity"},
{Text: "👥 用户管理", Data: "adm_users"},
},
[]telegramInlineButton{
{Text: "🔓 开注设置", Data: "adm_openreg"},
{Text: "🎟 生成兑换码", Data: "adm_gencode"},
},
[]telegramInlineButton{{Text: "⚙️ 设备策略", Data: "adm_devicepolicy"}},
)
}
return telegramCommandReply{Text: header, Buttons: rows}
}
// 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)
switch {
case data == "noop":
return telegramCommandReply{}, true
case data == "menu_main":
return s.mainMenu(ctx, channel, msg), true
case data == "act_bind":
return telegramCommandReply{Text: "请发送:<code>/start 用户名 密码</code> 绑定已有账号。"}, true
case data == "act_register":
if !s.openRegEnabled(ctx) {
return telegramCommandReply{Text: "注册功能未开放,请联系管理员。"}, true
}
s.setPending(int64(msg.From.ID), "register")
return telegramCommandReply{Text: "请发送新账号的 <b>用户名 密码</b>(空格分隔),例如:<code>alice mypass123</code>"}, true
case data == "act_redeem_register":
s.setPending(int64(msg.From.ID), "redeem_register")
return telegramCommandReply{Text: "请发送你的<b>注册兑换码</b>,例如:<code>ABCD2345EFGH</code>\n(兑换后会要求设置用户名密码)"}, true
case data == "act_redeem_renew":
s.setPending(int64(msg.From.ID), "redeem_renew")
return telegramCommandReply{Text: "请发送你的<b>续期兑换码</b>,将为当前绑定账号续期。"}, true
case data == "act_account":
return s.replyAccount(ctx, msg), true
case data == "act_signin":
return s.replySignIn(ctx, msg), true
case data == "act_devices":
return s.replyDevices(ctx, msg), true
case data == "act_setname":
s.setPending(int64(msg.From.ID), "setname")
return telegramCommandReply{Text: "请发送新的<b>用户名</b>。"}, true
case data == "act_setpass":
s.setPending(int64(msg.From.ID), "setpass")
return telegramCommandReply{Text: "请发送新的<b>密码</b>(至少 6 位)。"}, true
case strings.HasPrefix(data, "kick:"):
return s.replyKick(ctx, msg, strings.TrimPrefix(data, "kick:")), true
}
// ── 管理员专属 ──
if !isAdmin {
return telegramCommandReply{Text: "此功能仅管理员可用。"}, true
}
switch {
case data == "adm_capacity":
return s.replyCapacity(ctx), true
case data == "adm_openreg":
return s.replyOpenRegMenu(ctx), true
case data == "adm_openreg_close":
_ = s.closeRegistration(ctx)
return telegramCommandReply{Text: "已关闭注册。"}, true
case strings.HasPrefix(data, "adm_openreg_set:"):
n, _ := strconv.Atoi(strings.TrimPrefix(data, "adm_openreg_set:"))
if err := s.openRegistration(ctx, n); err != nil {
return telegramCommandReply{Text: "开注失败:" + err.Error()}, true
}
label := "不限"
if n > 0 {
label = fmt.Sprintf("%d 个名额", n)
}
return telegramCommandReply{Text: "已开放注册:" + label + "。"}, true
case data == "adm_gencode":
return s.replyGenCodeMenu(), true
case strings.HasPrefix(data, "gc:"):
return s.replyGenCode(ctx, msg, data), true
case data == "adm_users":
return s.replyUserList(ctx), true
case strings.HasPrefix(data, "usr:"):
return s.replyUserActions(ctx, strings.TrimPrefix(data, "usr:")), true
case strings.HasPrefix(data, "uban:"):
return s.replyUserBan(ctx, strings.TrimPrefix(data, "uban:"), false), true
case strings.HasPrefix(data, "uunban:"):
return s.replyUserBan(ctx, strings.TrimPrefix(data, "uunban:"), true), true
case strings.HasPrefix(data, "udel:"):
return s.replyUserDelete(ctx, strings.TrimPrefix(data, "udel:")), true
case strings.HasPrefix(data, "urenew:"):
return s.replyUserRenew(ctx, strings.TrimPrefix(data, "urenew:")), true
case data == "adm_devicepolicy":
return s.replyDevicePolicy(ctx), true
case strings.HasPrefix(data, "dp_toggle:"):
return s.replyDevicePolicyToggle(ctx, strings.TrimPrefix(data, "dp_toggle:")), true
}
return telegramCommandReply{}, false
}
// handlePendingText consumes a button-initiated text prompt. Returns (reply,
// handled). handled=false means there was no pending prompt for this user.
func (s *TelegramBotService) handlePendingText(ctx context.Context, channel *model.NotifyChannel, msg *TelegramMessage, text string) (telegramCommandReply, bool) {
p, ok := s.takePending(int64(msg.From.ID))
if !ok {
return telegramCommandReply{}, false
}
switch p.Kind {
case "register":
return s.cmdRegister(ctx, channel, msg, strings.Fields(text)), true
case "redeem_register":
return s.redeemRegisterFlow(ctx, channel, msg, text), true
case "redeem_renew":
return s.redeemRenewFlow(ctx, msg, text), true
case "setname":
return s.selfSetName(ctx, msg, text), true
case "setpass":
return s.selfSetPass(ctx, msg, text), true
case "openreg_limit":
n, err := strconv.Atoi(strings.TrimSpace(text))
if err != nil || n < 0 {
return telegramCommandReply{Text: "请输入有效的非负整数。"}, true
}
if err := s.openRegistration(ctx, n); err != nil {
return telegramCommandReply{Text: "开注失败:" + err.Error()}, true
}
return telegramCommandReply{Text: fmt.Sprintf("已开放注册:%d 个名额。", n)}, true
}
return telegramCommandReply{}, false
}
// ── 用户自助 ──────────────────────────────────────────────────────────────
func (s *TelegramBotService) replyAccount(ctx context.Context, msg *TelegramMessage) telegramCommandReply {
user := s.boundUser(ctx, msg.From.ID)
if user == nil {
return telegramCommandReply{Text: "请先绑定账号:<code>/start 用户名 密码</code>"}
}
streak := 0
if rec, _ := s.repo.SignIn.Get(ctx, user.ID); rec != nil {
streak = rec.StreakDays
}
devices, _ := s.repo.UserDevice.ListByUser(ctx, user.ID)
text := fmt.Sprintf("<b>我的账号</b>\n\n用户名:<b>%s</b>\n状态:<b>%s</b>\n到期:<b>%s</b>\n连续签到:<b>%d 天</b>\n登录设备:<b>%d 台</b>",
user.Username,
map[bool]string{true: "正常", false: "已禁用"}[user.IsActive],
formatExpiry(user.ExpiredAt), streak, len(devices))
return telegramCommandReply{Text: text, Buttons: [][]telegramInlineButton{{{Text: "⬅️ 返回菜单", Data: "menu_main"}}}}
}
func (s *TelegramBotService) replySignIn(ctx context.Context, msg *TelegramMessage) telegramCommandReply {
user := s.boundUser(ctx, msg.From.ID)
if user == nil {
return telegramCommandReply{Text: "请先绑定账号后再签到。"}
}
res, err := s.signIn(ctx, user.ID)
if err != nil {
return telegramCommandReply{Text: "签到失败:" + err.Error()}
}
if res.AlreadySigned {
return telegramCommandReply{Text: fmt.Sprintf("今天已经签到过啦~\n连续签到 <b>%d</b> 天,累计 <b>%d</b> 天。", res.Streak, res.Total)}
}
return telegramCommandReply{Text: fmt.Sprintf("签到成功 ✅\n连续签到 <b>%d</b> 天,累计 <b>%d</b> 天。", res.Streak, res.Total)}
}
func (s *TelegramBotService) replyDevices(ctx context.Context, msg *TelegramMessage) telegramCommandReply {
user := s.boundUser(ctx, msg.From.ID)
if user == nil {
return telegramCommandReply{Text: "请先绑定账号。"}
}
devices, _ := s.repo.UserDevice.ListByUser(ctx, user.ID)
if len(devices) == 0 {
return telegramCommandReply{Text: "当前没有记录到登录设备。"}
}
var sb strings.Builder
sb.WriteString("<b>我的登录设备</b>\n点击下方按钮可一键踢下线:\n")
var rows [][]telegramInlineButton
for i, d := range devices {
status := ""
if d.Kicked {
status = "(已踢下线)"
}
sb.WriteString(fmt.Sprintf("\n%d. <b>%s</b>%s\n 最近活跃:%s", i+1, deviceLabel(d.DeviceName, d.Client), status, d.LastSeenAt.Format("01-02 15:04")))
if !d.Kicked {
rows = append(rows, []telegramInlineButton{{Text: "🚫 踢下线:" + deviceLabel(d.DeviceName, d.Client), Data: "kick:" + d.ID}})
}
}
rows = append(rows, []telegramInlineButton{{Text: "⬅️ 返回菜单", Data: "menu_main"}})
return telegramCommandReply{Text: sb.String(), Buttons: rows}
}
func (s *TelegramBotService) replyKick(ctx context.Context, msg *TelegramMessage, deviceRowID string) telegramCommandReply {
user := s.boundUser(ctx, msg.From.ID)
if user == nil {
return telegramCommandReply{Text: "请先绑定账号。"}
}
// Verify the device belongs to this user before kicking.
var d model.UserDevice
if err := s.repo.DB.WithContext(ctx).Where("id = ? AND user_id = ?", deviceRowID, user.ID).First(&d).Error; err != nil {
return telegramCommandReply{Text: "未找到该设备。"}
}
if err := s.repo.UserDevice.SetKicked(ctx, d.ID, true); err != nil {
return telegramCommandReply{Text: "操作失败:" + err.Error()}
}
return s.replyDevices(ctx, msg)
}
func (s *TelegramBotService) selfSetName(ctx context.Context, msg *TelegramMessage, newName string) telegramCommandReply {
user := s.boundUser(ctx, msg.From.ID)
if user == nil {
return telegramCommandReply{Text: "请先绑定账号。"}
}
newName = strings.TrimSpace(newName)
if len(newName) < 2 || strings.ContainsAny(newName, " \t\n") {
return telegramCommandReply{Text: "用户名至少 2 位且不能含空格,请重试。"}
}
if existing, _ := s.repo.User.FindByUsername(ctx, newName); existing != nil && existing.ID != user.ID {
return telegramCommandReply{Text: "该用户名已被占用,请换一个。"}
}
if err := s.repo.User.UpdateFields(ctx, user.ID, map[string]any{"username": newName}); err != nil {
return telegramCommandReply{Text: "修改失败:" + err.Error()}
}
return telegramCommandReply{Text: fmt.Sprintf("用户名已修改为 <b>%s</b>。请用新用户名登录。", newName)}
}
func (s *TelegramBotService) selfSetPass(ctx context.Context, msg *TelegramMessage, newPass string) telegramCommandReply {
user := s.boundUser(ctx, msg.From.ID)
if user == nil {
return telegramCommandReply{Text: "请先绑定账号。"}
}
newPass = strings.TrimSpace(newPass)
if s.auth == nil {
return telegramCommandReply{Text: "服务暂不可用。"}
}
if err := s.auth.ResetPassword(ctx, user.ID, newPass); err != nil {
return telegramCommandReply{Text: "修改失败:" + err.Error()}
}
return telegramCommandReply{Text: "密码已修改,请用新密码重新登录第三方客户端。"}
}
// ── 兑换码流程 ───────────────────────────────────────────────────────────────
func (s *TelegramBotService) redeemRegisterFlow(ctx context.Context, channel *model.NotifyChannel, msg *TelegramMessage, raw string) telegramCommandReply {
rc, errMsg := s.lookupRedeemableCode(ctx, raw, model.RegistrationCodeRegister)
if rc == nil {
return telegramCommandReply{Text: errMsg}
}
if s.auth == nil {
return telegramCommandReply{Text: "注册服务暂不可用。"}
}
if binding := s.telegramBinding(ctx, msg.From.ID); binding != nil {
if u, _ := s.repo.User.FindByID(ctx, binding.UserID); u != nil {
return telegramCommandReply{Text: fmt.Sprintf("当前 Telegram 已绑定账号 <b>%s</b>,无需再用注册码。", u.Username)}
}
}
// Generate a memorable default account from the code; users can rename via
//「改用户名/改密码」afterwards. We avoid asking for two more text turns here.
username := "u" + strings.ToLower(rc.Code[:8])
password := randomCode(10)
user, _, err := s.auth.Register(ctx, username, password)
if err != nil {
return telegramCommandReply{Text: "注册失败:" + err.Error()}
}
if err := s.repo.RegCode.MarkUsed(ctx, rc.ID, user.ID); err != nil {
// Code was raced; roll back the just-created account to avoid free signups.
_ = s.repo.User.Delete(ctx, user.ID)
return telegramCommandReply{Text: "兑换码刚刚被使用,请换一个。"}
}
if rc.DurationDays > 0 {
_ = s.applyRenewal(ctx, user.ID, rc.DurationDays)
}
_ = s.upsertTelegramBinding(ctx, msg, user.ID)
return telegramCommandReply{
Text: fmt.Sprintf("兑换成功并已创建账号:\n用户名:<b>%s</b>\n密码:<b>%s</b>\n到期:<b>%s</b>\n\n请尽快用「改用户名/改密码」修改为你自己的凭据。",
username, password, formatExpiry(s.userExpiry(ctx, user.ID))),
Buttons: [][]telegramInlineButton{{{Text: "⬅️ 返回菜单", Data: "menu_main"}}},
}
}
func (s *TelegramBotService) redeemRenewFlow(ctx context.Context, msg *TelegramMessage, raw string) telegramCommandReply {
user := s.boundUser(ctx, msg.From.ID)
if user == nil {
return telegramCommandReply{Text: "请先绑定账号再续期。"}
}
rc, errMsg := s.lookupRedeemableCode(ctx, raw, model.RegistrationCodeRenew)
if rc == nil {
return telegramCommandReply{Text: errMsg}
}
if err := s.repo.RegCode.MarkUsed(ctx, rc.ID, user.ID); err != nil {
return telegramCommandReply{Text: "兑换码刚刚被使用,请换一个。"}
}
if err := s.applyRenewal(ctx, user.ID, rc.DurationDays); err != nil {
return telegramCommandReply{Text: "续期失败:" + err.Error()}
}
return telegramCommandReply{Text: fmt.Sprintf("续期成功 ✅ 当前到期:<b>%s</b>", formatExpiry(s.userExpiry(ctx, user.ID)))}
}
func (s *TelegramBotService) userExpiry(ctx context.Context, userID string) *time.Time {
if u, _ := s.repo.User.FindByID(ctx, userID); u != nil {
return u.ExpiredAt
}
return nil
}
// ── 管理员:容量 / 开注 / 兑换码 / 用户管理 / 设备策略 ─────────────────────────
func (s *TelegramBotService) replyCapacity(ctx context.Context) telegramCommandReply {
c := s.loadCapacity(ctx)
quota := "未开放"
if c.OpenRegOn {
if c.OpenRegLimit > 0 {
quota = fmt.Sprintf("已开放(%d/%d 名额)", c.OpenRegUsed, c.OpenRegLimit)
} else {
quota = "已开放(不限名额,受授权上限约束)"
}
}
text := fmt.Sprintf("<b>容量 / 状态</b>\n\n授权上限:<b>%d</b> 人(随凭证授权实时变化)\n已用:<b>%d</b> 人\n剩余可注册:<b>%d</b> 人\n开注状态:<b>%s</b>",
c.MaxUsers, c.UsedUsers, c.Remaining(), quota)
return telegramCommandReply{Text: text, Buttons: [][]telegramInlineButton{{{Text: "⬅️ 返回菜单", Data: "menu_main"}}}}
}
func (s *TelegramBotService) replyOpenRegMenu(ctx context.Context) telegramCommandReply {
c := s.loadCapacity(ctx)
state := "未开放"
if c.OpenRegOn {
state = fmt.Sprintf("已开放(%d/%d)", c.OpenRegUsed, c.OpenRegLimit)
}
return telegramCommandReply{
Text: "<b>开注设置</b>\n当前:" + state + "\n选择要开放的名额:",
Buttons: [][]telegramInlineButton{
{{Text: "5 个", Data: "adm_openreg_set:5"}, {Text: "10 个", Data: "adm_openreg_set:10"}, {Text: "20 个", Data: "adm_openreg_set:20"}},
{{Text: "不限名额", Data: "adm_openreg_set:0"}, {Text: "关闭注册", Data: "adm_openreg_close"}},
{{Text: "⬅️ 返回菜单", Data: "menu_main"}},
},
}
}
func (s *TelegramBotService) replyGenCodeMenu() telegramCommandReply {
return telegramCommandReply{
Text: "<b>生成兑换码</b>\n选择类型与时长:",
Buttons: [][]telegramInlineButton{
{{Text: "注册码·30天", Data: "gc:register:30"}, {Text: "注册码·永久", Data: "gc:register:0"}},
{{Text: "续期码·30天", Data: "gc:renew:30"}, {Text: "续期码·90天", Data: "gc:renew:90"}},
{{Text: "⬅️ 返回菜单", Data: "menu_main"}},
},
}
}
func (s *TelegramBotService) replyGenCode(ctx context.Context, msg *TelegramMessage, data string) telegramCommandReply {
parts := strings.Split(data, ":") // gc:<kind>:<days>
if len(parts) != 3 {
return telegramCommandReply{Text: "参数错误。"}
}
kind := parts[1]
days, _ := strconv.Atoi(parts[2])
createdBy := ""
if u := s.boundUser(ctx, msg.From.ID); u != nil {
createdBy = u.ID
}
code, err := s.generateCode(ctx, kind, days, 0, createdBy)
if err != nil {
return telegramCommandReply{Text: "生成失败:" + err.Error()}
}
kindLabel := map[string]string{model.RegistrationCodeRegister: "注册码", model.RegistrationCodeRenew: "续期码"}[code.Kind]
dur := "永久"
if days > 0 {
dur = fmt.Sprintf("%d 天", days)
}
return telegramCommandReply{
Text: fmt.Sprintf("已生成%s(%s):\n\n<code>%s</code>\n\n发给用户在 Bot 中兑换即可。", kindLabel, dur, code.Code),
Buttons: [][]telegramInlineButton{{{Text: "再生成一个", Data: "adm_gencode"}, {Text: "⬅️ 返回菜单", Data: "menu_main"}}},
}
}
func (s *TelegramBotService) replyUserList(ctx context.Context) telegramCommandReply {
users, err := s.repo.User.List(ctx)
if err != nil {
return telegramCommandReply{Text: "读取用户失败:" + err.Error()}
}
if len(users) == 0 {
return telegramCommandReply{Text: "暂无用户。"}
}
var rows [][]telegramInlineButton
limit := len(users)
if limit > 12 {
limit = 12
}
for i := 0; i < limit; i++ {
u := users[i]
flag := ""
if !u.IsActive {
flag = "🚫"
}
if u.Role == "admin" {
flag = "👑"
}
rows = append(rows, []telegramInlineButton{{Text: flag + " " + u.Username, Data: "usr:" + u.ID}})
}
rows = append(rows, []telegramInlineButton{{Text: "⬅️ 返回菜单", Data: "menu_main"}})
return telegramCommandReply{Text: fmt.Sprintf("<b>用户管理</b>(共 %d 人,显示前 %d)\n点击用户进行操作:", len(users), limit), Buttons: rows}
}
func (s *TelegramBotService) replyUserActions(ctx context.Context, userID string) telegramCommandReply {
u, err := s.repo.User.FindByID(ctx, userID)
if err != nil || u == nil {
return telegramCommandReply{Text: "用户不存在。"}
}
protected := u.Role == "admin"
if first, _ := s.repo.User.FirstAdmin(ctx); first != nil && first.ID == u.ID {
protected = true
}
text := fmt.Sprintf("<b>%s</b>\n角色:%s\n状态:%s\n到期:%s\n防共享警告:%d 次",
u.Username, u.Role, map[bool]string{true: "正常", false: "已禁用"}[u.IsActive], formatExpiry(u.ExpiredAt), u.ShareWarnings)
if protected {
return telegramCommandReply{Text: text + "\n\n(受保护账号,不可禁用/删除)", Buttons: [][]telegramInlineButton{{{Text: "⬅️ 返回", Data: "adm_users"}}}}
}
banBtn := telegramInlineButton{Text: "🚫 禁用", Data: "uban:" + u.ID}
if !u.IsActive {
banBtn = telegramInlineButton{Text: "✅ 解禁", Data: "uunban:" + u.ID}
}
return telegramCommandReply{
Text: text,
Buttons: [][]telegramInlineButton{
{banBtn, {Text: "⏳ 续期30天", Data: "urenew:" + u.ID + ":30"}},
{{Text: "🗑 删除用户", Data: "udel:" + u.ID}},
{{Text: "⬅️ 返回", Data: "adm_users"}},
},
}
}
func (s *TelegramBotService) replyUserBan(ctx context.Context, userID string, unban bool) telegramCommandReply {
if !unban {
if reason := s.protectReason(ctx, userID); reason != "" {
return telegramCommandReply{Text: reason}
}
}
if err := s.repo.User.UpdateFields(ctx, userID, map[string]any{"is_active": unban}); err != nil {
return telegramCommandReply{Text: "操作失败:" + err.Error()}
}
return s.replyUserActions(ctx, userID)
}
func (s *TelegramBotService) replyUserDelete(ctx context.Context, userID string) telegramCommandReply {
if reason := s.protectReason(ctx, userID); reason != "" {
return telegramCommandReply{Text: reason}
}
u, _ := s.repo.User.FindByID(ctx, userID)
_ = s.repo.UserDevice.DeleteByUser(ctx, userID)
if err := s.repo.User.Delete(ctx, userID); err != nil {
return telegramCommandReply{Text: "删除失败:" + err.Error()}
}
name := userID
if u != nil {
name = u.Username
}
return telegramCommandReply{Text: fmt.Sprintf("已删除用户 <b>%s</b>。", name), Buttons: [][]telegramInlineButton{{{Text: "⬅️ 返回", Data: "adm_users"}}}}
}
func (s *TelegramBotService) replyUserRenew(ctx context.Context, payload string) telegramCommandReply {
parts := strings.Split(payload, ":") // <id>:<days>
if len(parts) != 2 {
return telegramCommandReply{Text: "参数错误。"}
}
days, _ := strconv.Atoi(parts[1])
if err := s.applyRenewal(ctx, parts[0], days); err != nil {
return telegramCommandReply{Text: "续期失败:" + err.Error()}
}
return s.replyUserActions(ctx, parts[0])
}
// protectReason returns a non-empty message when a user must not be
// disabled/deleted (admins and the default admin are protected).
func (s *TelegramBotService) protectReason(ctx context.Context, userID string) string {
u, err := s.repo.User.FindByID(ctx, userID)
if err != nil || u == nil {
return "用户不存在。"
}
if u.Role == "admin" {
return "管理员账号受保护,不可禁用/删除。"
}
if first, _ := s.repo.User.FirstAdmin(ctx); first != nil && first.ID == u.ID {
return "默认管理员账号受保护,不可禁用/删除。"
}
return ""
}
func (s *TelegramBotService) replyDevicePolicy(ctx context.Context) telegramCommandReply {
cfg := loadBotConfig(ctx, s.repo)
text := fmt.Sprintf(
"<b>设备策略</b>\n\n① 防共享(警告制):<b>%s</b>\n 并发播放上限 %d / 登录客户端上限 %d / 警告 %d 次后删号\n\n② 不活跃清理:<b>%s</b>\n 随机 %d~%d 天窗口观看 < %d 小时则删号(新号 %d 天宽限)\n\n两套策略默认关闭、互不干扰;删号前会先通过 Bot 通知用户;管理员/受保护账号永不自动处理。",
onOff(cfg.AntiShareEnabled), cfg.MaxConcurrentPlay, cfg.MaxLoggedClients, cfg.WarnThreshold,
onOff(cfg.InactiveEnabled), cfg.InactiveWindowMin, cfg.InactiveWindowMax, cfg.InactiveMinHours, cfg.InactiveGraceDays)
return telegramCommandReply{
Text: text,
Buttons: [][]telegramInlineButton{
{{Text: toggleLabel("防共享", cfg.AntiShareEnabled), Data: "dp_toggle:antishare"}},
{{Text: toggleLabel("不活跃清理", cfg.InactiveEnabled), Data: "dp_toggle:inactive"}},
{{Text: "⬅️ 返回菜单", Data: "menu_main"}},
},
}
}
func (s *TelegramBotService) replyDevicePolicyToggle(ctx context.Context, which string) telegramCommandReply {
cfg := loadBotConfig(ctx, s.repo)
switch which {
case "antishare":
_ = s.repo.Setting.Set(ctx, SettingAntiShareEnabled, strconv.FormatBool(!cfg.AntiShareEnabled))
case "inactive":
_ = s.repo.Setting.Set(ctx, SettingInactiveEnabled, strconv.FormatBool(!cfg.InactiveEnabled))
}
return s.replyDevicePolicy(ctx)
}
func onOff(b bool) string {
return map[bool]string{true: "已开启", false: "已关闭"}[b]
}
func toggleLabel(name string, enabled bool) string {
if enabled {
return "关闭" + name
}
return "开启" + name
}
+159 -6
View File
@@ -48,6 +48,10 @@ export function ToolsPage() {
)
}
function isOn(v: string | undefined): boolean {
return v === 'true' || v === '1' || v === 'on'
}
function OrganizePanel() {
const [libraries, setLibraries] = useState<Library[]>([])
const [libraryID, setLibraryID] = useState('')
@@ -61,6 +65,25 @@ function OrganizePanel() {
const [smartClassify, setSmartClassify] = useState(false)
const [loadingSettings, setLoadingSettings] = useState(true)
// 整理 & 重命名「默认设置」内联编辑器:与设置页共用同一批 organize.* 配置键,
// 这样管理员无需在「设置」和「工具」之间来回跳转即可调默认值并直接执行整理。
const [showDefaults, setShowDefaults] = useState(false)
const [savingDefaults, setSavingDefaults] = useState(false)
const ORGANIZE_KEYS = [
'organizer.auto_after_download',
'organizer.smart_classify',
'organize.source_dir',
'organize.target_dir',
'organize.transfer_mode',
'organize.keep_seeding',
'organize.movie_format',
'organize.tv_format',
'organize.anime_format',
] as const
const [defaults, setDefaults] = useState<Record<string, string>>({})
const setDefault = (key: string, value: string) =>
setDefaults((prev) => ({ ...prev, [key]: value }))
// 单次整理覆盖项:留空则沿用设置页的默认源目录/目的地目录与转移方式。
const [sourcePath, setSourcePath] = useState('')
const [destPath, setDestPath] = useState('')
@@ -93,11 +116,14 @@ function OrganizePanel() {
useEffect(() => {
const loadSettings = async () => {
try {
const settings = await adminAPI.listSettings()
const setting = (settings as Setting[]).find(s => s.key === 'organizer.smart_classify')
if (setting) {
setSmartClassify(setting.value === 'true' || setting.value === '1' || setting.value === 'on')
}
const settings = (await adminAPI.listSettings()) as Setting[]
const byKey: Record<string, string> = {}
for (const s of settings) byKey[s.key] = s.value
const next: Record<string, string> = {}
for (const k of ORGANIZE_KEYS) next[k] = byKey[k] ?? ''
setDefaults(next)
const sc = byKey['organizer.smart_classify']
setSmartClassify(sc === 'true' || sc === '1' || sc === 'on')
} catch {
// ignore
} finally {
@@ -105,8 +131,26 @@ function OrganizePanel() {
}
}
loadSettings()
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [])
const onSaveDefaults = async (e: FormEvent) => {
e.preventDefault()
setSavingDefaults(true)
try {
for (const k of ORGANIZE_KEYS) {
await adminAPI.updateSetting(k, defaults[k] ?? '')
}
const sc = defaults['organizer.smart_classify']
setSmartClassify(sc === 'true' || sc === '1' || sc === 'on')
toast.success('整理默认设置已保存')
} catch (err) {
toast.error('保存失败')
} finally {
setSavingDefaults(false)
}
}
const onOrganizeLibrary = async (e: FormEvent) => {
e.preventDefault()
if (!libraryID) return
@@ -204,8 +248,117 @@ function OrganizePanel() {
</div>
)}
<div className="rounded-xl border border-gray-200 bg-white/40">
<button
type="button"
onClick={() => setShowDefaults((v) => !v)}
className="flex w-full items-center justify-between px-3 py-2 text-left text-sm font-medium text-ink-600"
>
<span className="flex items-center gap-2">
<Wrench size={14} className="text-brand-500" />
整理 &amp; 重命名 默认设置(与设置页同步,改这里即可,无需再去设置页)
</span>
<span className="text-xs text-sand-500">{showDefaults ? '收起 ▲' : '展开 ▼'}</span>
</button>
{showDefaults && (
<form onSubmit={onSaveDefaults} className="space-y-3 border-t border-gray-200 p-3">
<div className="grid gap-3 sm:grid-cols-2">
<label className="space-y-1">
<span className="text-xs text-ink-50">默认整理源目录(待整理)</span>
<input
className="input-base w-full"
placeholder="留空则默认整理整个媒体库"
value={defaults['organize.source_dir'] ?? ''}
onChange={(e) => setDefault('organize.source_dir', e.target.value)}
/>
</label>
<label className="space-y-1">
<span className="text-xs text-ink-50">默认整理目的地目录</span>
<input
className="input-base w-full"
placeholder="留空则按媒体库归类"
value={defaults['organize.target_dir'] ?? ''}
onChange={(e) => setDefault('organize.target_dir', e.target.value)}
/>
</label>
<label className="space-y-1">
<span className="text-xs text-ink-50">默认转移方式</span>
<select
className="input-base w-full"
value={defaults['organize.transfer_mode'] ?? ''}
onChange={(e) => setDefault('organize.transfer_mode', e.target.value)}
>
<option value="">未设置</option>
<option value="move">移动(删除源文件)</option>
<option value="copy">复制(保留源文件)</option>
<option value="hardlink">硬链接(保留源,做种不中断)</option>
<option value="symlink">软链接(符号链接,保留源)</option>
</select>
</label>
<label className="space-y-1">
<span className="text-xs text-ink-50">电影命名格式</span>
<input
className="input-base w-full"
placeholder="{title} ({year})"
value={defaults['organize.movie_format'] ?? ''}
onChange={(e) => setDefault('organize.movie_format', e.target.value)}
/>
</label>
<label className="space-y-1">
<span className="text-xs text-ink-50">剧集命名格式</span>
<input
className="input-base w-full"
placeholder="{title} - S{season:02d}E{episode:02d}"
value={defaults['organize.tv_format'] ?? ''}
onChange={(e) => setDefault('organize.tv_format', e.target.value)}
/>
</label>
<label className="space-y-1">
<span className="text-xs text-ink-50">动漫命名格式</span>
<input
className="input-base w-full"
placeholder="{title} - {episode:02d}"
value={defaults['organize.anime_format'] ?? ''}
onChange={(e) => setDefault('organize.anime_format', e.target.value)}
/>
</label>
</div>
<div className="flex flex-wrap items-center gap-4">
<label className="flex items-center gap-2 text-xs text-ink-600">
<input
type="checkbox"
checked={isOn(defaults['organizer.auto_after_download'])}
onChange={(e) => setDefault('organizer.auto_after_download', e.target.checked ? 'true' : 'false')}
/>
入库时自动整理
</label>
<label className="flex items-center gap-2 text-xs text-ink-600">
<input
type="checkbox"
checked={isOn(defaults['organizer.smart_classify'])}
onChange={(e) => setDefault('organizer.smart_classify', e.target.checked ? 'true' : 'false')}
/>
智能分类(按元数据分目录)
</label>
<label className="flex items-center gap-2 text-xs text-ink-600">
<input
type="checkbox"
checked={isOn(defaults['organize.keep_seeding'])}
onChange={(e) => setDefault('organize.keep_seeding', e.target.checked ? 'true' : 'false')}
/>
保种(整理后继续做种)
</label>
<button type="submit" disabled={savingDefaults} className="neon-button ml-auto">
{savingDefaults ? <Loader2 size={16} className="animate-spin" /> : null}
保存默认设置
</button>
</div>
</form>
)}
</div>
<p className="text-xs text-sand-500">
整理是「<b>从源目录整理到目的地目录</b>」:源目录是待整理文件当前所在的位置,目的地目录是整理后输出的位置。两者留空则分别沿用设置页的默认值(默认源目录 = 媒体库路径)。
整理是「<b>从源目录整理到目的地目录</b>」:源目录是待整理文件当前所在的位置,目的地目录是整理后输出的位置。两者留空则分别沿用上方默认设置(默认源目录 = 媒体库路径)。
</p>
<div className="grid gap-3 rounded-xl border border-gray-200 bg-gray-50 p-3 sm:grid-cols-3">