Update deployment docs and media library UI

This commit is contained in:
ShukeBta
2026-05-28 17:32:55 +08:00
parent 68a5a1e3c0
commit b5f1f1954e
33 changed files with 1667 additions and 874 deletions
+6
View File
@@ -8,8 +8,14 @@ docs/
data/
cache/
logs/
.tmp_*
.tmp-deploy-*
.tmp-deploy-data/
.mediastation.pid
*.db
*.db-journal
*.db-shm
*.db-wal
*.log
.env
.env.*
+3
View File
@@ -25,6 +25,9 @@ web/tsconfig.tsbuildinfo
data/
cache/
logs/
.tmp-deploy-data/
.tmp-deploy-server.*
.mediastation.pid
*.log
*.db
*.db-journal
-1
View File
@@ -1 +0,0 @@
39f30642a462624f2aa03896465943891e2d28db9f9a03aca94fd9af58d0c108
-1
View File
@@ -1 +0,0 @@
29292
+7 -1
View File
@@ -1,4 +1,4 @@
.PHONY: all build build-server build-web dev dev-web run docker docker-stop clean install-web tidy test vet smoke
.PHONY: all build build-server build-web dev dev-web run deploy docker docker-stop docker-push clean install-web tidy test vet smoke
# ---- 默认目标 ----
all: build
@@ -26,6 +26,9 @@ dev-web:
run: build
./bin/mediastation-go
deploy:
./scripts/deploy.sh
# ---- Docker ----
docker:
docker compose up --build -d
@@ -33,6 +36,9 @@ docker:
docker-stop:
docker compose down
docker-push:
./scripts/docker-build-push.sh
# ---- 工具命令 ----
install-web:
cd web && npm install
+161 -303
View File
@@ -1,346 +1,204 @@
<p align="center">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="https://img.shields.io/badge/MediaStationGo-你的私人媒体中心-111827?style=for-the-badge&logo=data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNCAyNCI+PHBhdGggZmlsbD0id2hpdGUiIGQ9Ik0xMiAyQzYuNDggMiAyIDYuNDggMiAxMnM0LjQ4IDEwIDEwIDEwIDEwLTQuNDggMTAtMTBTMTcuNTIgMiAxMiAyem0tMiAxNWwtNS01IDEuNDEtMS40MUwxMCAxNC4xN2w3LjU5LTcuNTlMMTkgOGwtOSA5eiIvPjwvc3ZnPg=="/>
<img src="https://img.shields.io/badge/MediaStationGo-你的私人媒体中心-1F2937?style=for-the-badge&logo=data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNCAyNCI+PHBhdGggZmlsbD0id2hpdGUiIGQ9Ik0xMiAyQzYuNDggMiAyIDYuNDggMiAxMnM0LjQ4IDEwIDEwIDEwIDEwLTQuNDggMTAtMTBTMTcuNTIgMiAxMiAyem0tMiAxNWwtNS01IDEuNDEtMS40MUwxMCAxNC4xN2w3LjU5LTcuNTlMMTkgOGwtOSA5eiIvPjwvc3ZnPg=="/>
</picture>
</p>
# MediaStationGo
<h3 align="center"><samp><a href="https://github.com/ShukeBta/MediaStation">MediaStation</a> 的 Go 语言重写版</samp></h3>
<h6 align="center"><samp>轻量 · 快速 · 单二进制部署 · NAS 友好</samp></h6>
> 轻量、漂亮、NAS 友好的私人媒体中心。Go 单二进制后端 + React 前端,覆盖媒体库管理、刮削、播放、订阅下载、Emby API 兼容、AI 搜索与推荐。
<p align="center">
<a href="README_EN.md"><img src="https://img.shields.io/badge/English-README-blue?style=flat-square" alt="English"></a>
<a href="https://t.me/MediaStationGo"><img src="https://img.shields.io/badge/Telegram-群组-26A5E4?style=flat-square&logo=telegram&logoColor=white" alt="Telegram 群组"></a>
</p>
[English](README_EN.md) · [Docker 部署](#docker-部署推荐) · [一键脚本](#一键脚本部署) · [开发构建](#开发与构建)
<p align="center">
<img src="https://img.shields.io/badge/Go-1.25-00ADD8?style=for-the-badge&logo=go&logoColor=white" alt="Go">
<img src="https://img.shields.io/badge/React-18-61DAFB?style=for-the-badge&logo=react&logoColor=black" alt="React">
<img src="https://img.shields.io/badge/TypeScript-5-3178C6?style=for-the-badge&logo=typescript&logoColor=white" alt="TypeScript">
<img src="https://img.shields.io/badge/SQLite-WAL-003B57?style=for-the-badge&logo=sqlite&logoColor=white" alt="SQLite">
<img src="https://img.shields.io/badge/Docker-Alpine-2496ED?style=for-the-badge&logo=docker&logoColor=white" alt="Docker">
<img src="https://img.shields.io/badge/License-GPLv3-8B5CF6?style=for-the-badge&logo=gnu&logoColor=white" alt="GPL v3">
</p>
## 亮点
---
- **现代 UI**:统一浅色高级视觉方案,首页聚焦「本周力荐 / 继续观看 / 最近入库」,媒体库页按电影、剧集、动漫、综艺等分类展示,并自动用库内海报生成文件夹封面。
- **媒体库扫描与整理**:递归扫描、ffprobe 元数据、季/集识别、综艺按节目/季/集展示、重复扫描去重、本地 NFO/图片优先。
- **多源刮削**:TMDb、TheTVDB、Bangumi、豆瓣、Fanart.tv、JavDB/JavBus 成人内容页面直爬补全;优先读取本地 NFO、poster、fanart、DMM/JAV 图片。
- **播放体验**:直链播放、Range 拖动、HLS 转码、外挂字幕、播放历史、继续观看、外部播放器入口。
- **外部客户端兼容**:提供 Emby/Jellyfin 风格 API,便于 Infuse、VidHub、SenPlayer 等客户端访问。
- **PT 与下载**:站点管理、M-Team `x-api-key`、跨站搜索、订阅、qBittorrent 下载、下载后智能分类整理。
- **AI 助手**:OpenAI 兼容接口,支持自然语言搜索、智能推荐;后台 API 配置实时生效。
- **部署简单**:裸机一键脚本、Docker Compose、多架构镜像构建/推送脚本。
<p align="center">
<b>📖 <a href="#-为什么选择-mediastationgo">为什么选择</a></b>
&nbsp;·&nbsp;
<b>🚀 <a href="#-快速开始">快速开始</a></b>
&nbsp;·&nbsp;
<b>✨ <a href="#-功能特性">功能特性</a></b>
&nbsp;·&nbsp;
<b>🏗️ <a href="#-项目结构">项目结构</a></b>
&nbsp;·&nbsp;
<b>⚙️ <a href="#-配置说明">配置说明</a></b>
&nbsp;·&nbsp;
<b>🗺️ <a href="#-路线图">路线图</a></b>
</p>
## 功能模块
---
| 模块 | 能力 |
| --- | --- |
| 媒体库 | 电影、电视剧、动漫、综艺、音乐、成人内容;自动封面、合集分季分集 |
| 刮削 | 本地 NFO/图片优先,TMDb/TheTVDB/Bangumi/豆瓣/Fanart/JavBus/JavDB 补全 |
| 播放 | 直链、HLS、字幕、续播、外部播放器、历史与收藏 |
| 发现 | TMDb / 豆瓣 / Bangumi 推荐入口,多源发现与订阅 |
| 下载 | qBittorrent、PT 站点、RSS/搜索订阅、自动整理 |
| 兼容 | Emby API、DLNA、外部客户端与三端 UI |
| 运维 | 任务、统计、存储、重复文件、回收站、NFO 导出 |
| AI | OpenAI 兼容 Base URL/API Key,智能搜索与推荐 |
## 🤔 为什么选择 MediaStationGo?
> MediaStationGo 是 [MediaStation](https://github.com/ShukeBta/MediaStation) 从零开始的 Go 语言重写版,在保持完整功能体验的同时,将部署复杂度压缩到极致。
<table>
<tr>
<td width="50%">
### 原版 MediaStation
- 🐍 Python / FastAPI + Vue
- 📦 依赖 Python 运行时和虚拟环境
- 🐳 必须使用 Docker 或复杂的 Python 环境
- 📊 部署包 > 500 MB
- 🔧 需要 pip / npm 双构建链
</td>
<td width="50%">
### MediaStationGo ✨
- 🚀 Go 1.25 + React 18
- 📦 **单一静态二进制**(约 30 MB)
- 🐳 可选 Docker,也支持裸机直接运行
- 🔥 零外部依赖(CGO 禁用)
- ⚡ `go build` 一条命令编译
</td>
</tr>
</table>
| 指标 | 原版 | MediaStationGo |
|------|:----:|:----:|
| 二进制体积 | — | ≈ 30 MB |
| 内存占用(空闲) | ~200 MB | ~30 MB |
| 冷启动时间 | ~3s | ~0.3s |
| 部署步骤 | 5+ | 1 |
| 前端体积 (gzip) | ~250 KB | ~83 KB |
---
## ✨ 功能特性
<details open>
<summary><b>🔐 认证与用户管理</b></summary>
<br>
| 功能 | 说明 |
|------|------|
| JWT 双角色认证 | admin / user 角色隔离,支持 refresh token |
| 一键管理员创建 | 首次运行自动种子 `admin` / `admin123` |
| 个人信息管理 | 邮箱、头像、密码修改 |
| 用户管理面板 | 角色提升 / 降级、账户启用 / 禁用 |
| 审计日志 | 记录登录、媒体库操作、下载等敏感行为 |
</details>
<details open>
<summary><b>📚 媒体库管理</b></summary>
<br>
| 功能 | 说明 |
|------|------|
| 媒体库 CRUD | 支持 movie / tv / anime / music 四种类型 |
| 递归扫描 | 文件系统遍历 + ffprobe 元数据提取 |
| 智能文件名清洗 | 年份 + 季/集号自动识别 |
| 多源刮削 | TMDb → TheTVDB → Bangumi 链式刮削 |
| 高清海报升级 | Fanart.tv 可选降级 |
| 图片代理 | TMDb / Bangumi / 豆瓣 / Fanart 代理 + 磁盘缓存 |
| 电视剧分组 | 按季分组 + 剧集列表视图 |
| 实时文件监听 | fsnotify 驱动,5 秒防抖合并 |
</details>
<details open>
<summary><b>🎬 播放体验</b></summary>
<br>
| 功能 | 说明 |
|------|------|
| 直链播放 | HTTP Range 支持,拖动秒开 |
| HLS 按需转码 | 单文件单作业,支持硬件加速 |
| 外挂字幕 | .srt / .vtt / .ass / .ssa 自动识别 → WebVTT 实时转换 |
| 续播 | 每 10 秒自动存位,首页「继续观看」 |
| 收藏 & 播放列表 | 一键切换收藏 / 有序播放列表 |
</details>
<details open>
<summary><b>🌐 PT 站点管理</b></summary>
<br>
| 功能 | 说明 |
|------|------|
| 6 种站点类型 | nexusphp · gazelle · unit3d · mteam · discuz · custom_rss |
| 3 种认证方式 | Cookie / API Key / Auth Header |
| 站点配置 | 完整 CRUD + 连接测试 + 启用开关 |
| 跨站搜索 | 一键搜索所有已配置站点的种子 |
| 扩展配置 | Extra JSON:UA / RSS URL / 超时 / 优先级 / 代理 / 下载器 |
</details>
<details open>
<summary><b>🤖 自动化</b></summary>
<br>
| 功能 | 说明 |
|------|------|
| 下载集成 | qBittorrent Web UI API(添加 / 列表 / 删除) |
| RSS 订阅 | 正则过滤 + GUID 去重 + 10 分钟轮询 |
| 文件整理 | 下载完成后自动分类 move / copy / hardlink / symlink |
</details>
<details open>
<summary><b>📊 运维监控</b></summary>
<br>
| 功能 | 说明 |
|------|------|
| 实时事件推送 | WebSocket 推送扫描 / 刮削 / 转码 / 下载 / 订阅状态 |
| 系统仪表盘 | CPU / 内存 / 磁盘 / 媒体库数量 / Goroutines |
| 任务面板 | 实时展示 ffmpeg 作业 + qBittorrent 种子 |
| NFO 导出 | Kodi / Jellyfin 兼容格式,单文件或整库 |
| 硬件加速 | Software / NVENC / Intel QSV / VAAPI 编码器配置 |
| GitHub Actions | CI 自动化 + GHCR 多架构镜像发布 |
</details>
<details open>
<summary><b>🧠 AI 智能</b></summary>
<br>
| 功能 | 说明 |
|------|------|
| TMDb 发现 | 首页热门 / 流行推荐 |
| AI 搜索 | OpenAI 兼容接口 → 自然语言转结构化查询 |
| AI 推荐 | 基于观看历史的个性化推荐 |
</details>
---
## 🚀 快速开始
### 🐳 Docker 部署(推荐)
## Docker 部署(推荐)
```bash
git clone https://github.com/ShukeBta/MediaStationGo.git
cd MediaStationGo
# 编辑 docker-compose.yml,将媒体目录挂载到 /media
cp config.example.yaml config.yaml
docker compose up -d
```
> 🌐 服务启动后自动识别 **局域网 IP** 和 **公网 IP**(NAS / 服务器均可),日志示例:
> ```json
> {"msg":"server is ready","local":"http://192.168.1.4:8080","public":"http://1.2.3.4:8080"}
> ```
> 使用 `admin` / `admin123` 登录。
默认地址:`http://<服务器IP>:8080`
### 💻 裸机部署
默认账号:`admin` / `admin123`
| 前置要求 | 版本 |
|----------|------|
| Go | ≥ 1.25 |
| Node.js | ≥ 20 |
| FFmpeg | 任意 |
> 首次登录后请立即修改管理员密码,并在「媒体与用户」中添加媒体目录。
### docker-compose 关键挂载
```yaml
volumes:
- ./data:/data
- ./cache:/cache
- /your/media/path:/media:ro
```
### 硬件转码
- Intel QSV/VAAPI:挂载 `/dev/dri:/dev/dri`
- NVIDIA NVENC:宿主机安装 NVIDIA Container Toolkit,并启用 `gpus: all`
- 软件转码:无需额外配置
## 一键脚本部署
### Linux / macOS
```bash
git clone https://github.com/ShukeBta/MediaStationGo.git
cd MediaStationGo
chmod +x scripts/deploy.sh
PORT=18080 DATA_DIR=/opt/mediastation/data CACHE_DIR=/opt/mediastation/cache ./scripts/deploy.sh
```
# 编译后端 + 构建前端
make build
### Windows PowerShell
# 启动服务
```powershell
git clone https://github.com/ShukeBta/MediaStationGo.git
cd MediaStationGo
.\scripts\deploy.ps1 -Port 18080 -DataDir D:\MediaStationGo\data -CacheDir D:\MediaStationGo\cache
```
脚本会自动:
1. 安装前端依赖并构建 `web/dist`
2. 编译 Go 服务端到 `bin/`
3. 创建数据与缓存目录
4. 停止旧进程并启动新进程
5. 调用 `/api/health` 做健康检查
## Docker 镜像打包与推送
默认推送到 `ghcr.io/shukebta/mediastation-go:latest`:
```bash
docker login ghcr.io
IMAGE=ghcr.io/shukebta/mediastation-go TAG=latest ./scripts/docker-build-push.sh
```
Windows:
```powershell
docker login ghcr.io
.\scripts\docker-build-push.ps1 -Image ghcr.io/shukebta/mediastation-go -Tag latest
```
仅本地构建不推送:
```bash
PUSH=0 TAG=dev ./scripts/docker-build-push.sh
```
```powershell
.\scripts\docker-build-push.ps1 -Tag dev -Load
```
## 开发与构建
### 环境要求
| 组件 | 版本 |
| --- | --- |
| Go | 1.25+ |
| Node.js | 20+ |
| FFmpeg / ffprobe | 推荐安装 |
| Docker | 可选 |
### 本地构建
```bash
cp config.example.yaml config.yaml
cd web && npm ci && npm run build
cd ..
go build -o bin/mediastation-go ./cmd/server
./bin/mediastation-go
```
### 🛠️ 本地开发
### 常用命令
```bash
# 终端 1:Go 后端(端口 8080,DEBUG 模式)
make dev
# 终端 2:Vite 前端(端口 3000,API 自动代理)
make dev-web
make build # 构建前后端
make test # Go 测试
make docker # docker compose up --build -d
make deploy # Linux 一键部署
make docker-push # buildx 多架构推送
```
---
Windows 可直接使用:
## ⚙️ 配置说明
配置加载优先级:**默认值** < `config.yaml` < `config/*.yaml` < **环境变量**(`MEDIASTATION_` 前缀)
### 常用环境变量
| 环境变量 | 默认值 | 说明 |
|----------|--------|------|
| `MEDIASTATION_APP_PORT` | `8080` | HTTP 监听端口 |
| `MEDIASTATION_APP_DATA_DIR` | `./data` | 数据目录(DB / 缓存 / JWT) |
| `MEDIASTATION_APP_WEB_DIR` | `./web/dist` | 前端 SPA 静态文件 |
| `MEDIASTATION_DATABASE_DB_PATH` | `./data/mediastation.db` | SQLite 数据库路径 |
| `MEDIASTATION_SECRETS_JWT_SECRET` | *(自动生成)* | JWT 签名密钥 |
| `MEDIASTATION_SECRETS_TMDB_API_KEY` | *(空)* | TMDb 刮削(必填) |
| `MEDIASTATION_SECRETS_BANGUMI_ACCESS_TOKEN` | *(空)* | Bangumi 速率提升 |
| `MEDIASTATION_APP_CORS_ORIGINS` | *(空)* | 跨域白名单(JSON 数组) |
| `ADMIN_INITIAL_PASSWORD` | `admin123` | 初始管理员密码 |
### 运行时设置
管理后台 → 系统设置,存储在 `settings` 表中:
| 键 | 说明 |
|----|------|
| `qbittorrent.url` | qBittorrent Web UI 地址 |
| `qbittorrent.username` | 用户名 |
| `qbittorrent.password` | 密码 |
| `qbittorrent.savepath` | 默认保存路径(可选) |
> 💡 修改后点击 **下载 → 重新加载配置** 或 `POST /api/downloads/reload`。
📖 完整配置模板:[`config.example.yaml`](config.example.yaml)
---
## 🏗️ 项目结构
```
MediaStationGo/
├── cmd/server/main.go ← 应用入口
├── internal/
│ ├── config/ ← Viper 配置层
│ ├── database/ ← GORM + SQLite (WAL) 初始化
│ ├── model/ ← 数据模型 + AutoMigrate 注册
│ ├── repository/ ← 数据访问层
│ ├── service/ ← 业务逻辑(核心)
│ │ ├── auth.go 登录 / 注册 / JWT
│ │ ├── media.go 媒体库 + 媒体 CRUD
│ │ ├── scanner.go 文件扫描 + ffprobe
│ │ ├── scraper.go 刮削调度 + 文件名清洗
│ │ ├── tmdb.go / bangumi.go 第三方数据源
│ │ ├── site.go 站点管理 CRUD + 跨站搜索
│ │ ├── site_adapter.go 6 种 PT 站点适配器
│ │ ├── stream.go 直链 + HLS 播放
│ │ ├── transcoder.go ffmpeg 转码作业管理
│ │ ├── subtitle.go 外挂字幕 → WebVTT
│ │ ├── image_proxy.go 图片代理缓存
│ │ ├── playback.go 播放历史 / 收藏 / 播放列表
│ │ ├── watcher.go fsnotify 文件监听
│ │ ├── qbittorrent.go qBittorrent API 客户端
│ │ ├── downloads.go 下载管理
│ │ ├── subscription.go RSS 订阅轮询
│ │ ├── organizer.go 媒体文件自动整理
│ │ ├── stats.go 系统仪表盘
│ │ ├── profile.go 用户信息
│ │ ├── audit.go 审计日志
│ │ └── ws_hub.go WebSocket 发布/订阅
│ ├── middleware/ ← JWT / CORS / admin 中间件
│ └── handler/ ← HTTP 路由(按功能拆分)
├── web/ ← React 18 + Vite + Tailwind CSS
│ ├── src/api/ axios 接口封装
│ ├── src/components/ 通用组件(Card / Layout / APIConfigsPanel)
│ ├── src/hooks/ useWebSocket 等
│ ├── src/pages/ 首页 · 媒体库 · 搜索 · 播放器 · 下载 · 管理 · 站点
│ ├── src/stores/ Zustand(auth)
│ └── src/types/ 前端类型定义
├── Dockerfile ← 多阶段多架构构建
├── docker-compose.yml ← NAS 一键部署
├── Makefile ← build / dev / docker / test
├── config.example.yaml ← 完整配置模板
└── .github/workflows/ ← CI + GHCR 发布
```powershell
.\scripts\deploy.ps1
```
---
## 配置说明
## 🗺️ 路线图
配置优先级从低到高:
| 特性 | 状态 |
|------|:---:|
| Jellyfin / Emby 双向兼容层 | 🔨 进行中 |
| DLNA / Chromecast 投屏 | 📋 计划中 |
| 在线字幕搜索 | 📋 计划中 |
| 多码率 ABR 转码 | 📋 计划中 |
| STRM 直链播放 (WebDAV / Alist / S3) | ✅ 已完成 |
1. 内置默认值
2. `config.yaml`
3. `config/*.yaml`
4. `MEDIASTATION_` 环境变量
5. 后台数据库配置(API Key、站点、下载器等运行时配置)
---
常用环境变量:
## 🤝 贡献
| 变量 | 默认值 | 说明 |
| --- | --- | --- |
| `MEDIASTATION_APP_PORT` | `8080` | Web 服务端口 |
| `MEDIASTATION_APP_DATA_DIR` | `./data` | 数据目录 |
| `MEDIASTATION_DATABASE_DB_PATH` | `./data/mediastation.db` | SQLite 数据库 |
| `MEDIASTATION_APP_WEB_DIR` | `./web/dist` | 前端静态资源 |
| `MEDIASTATION_CACHE_CACHE_DIR` | `./cache` | 图片/转码缓存 |
| `MEDIASTATION_SECRETS_JWT_SECRET` | 自动生成 | JWT 与密钥加密种子 |
欢迎提交 Issue 和 Pull Request!在提交 PR 之前请阅读 [贡献指南](CONTRIBUTING.md)。
## API 与外部服务
---
在「外部 API 配置」中可配置:
## 📄 许可证
- TMDb:影视元数据
- Bangumi:番剧与动漫
- TheTVDB:剧集补充
- Fanart.tv:高清艺术图
- OpenAI Compatible:AI 搜索/推荐
- Adult/JAV:JavBus/JavDB 页面直爬,不需要 API
[GNU General Public License v3.0](LICENSE)
M-Team 站点请使用「控制台 → 实验室 → 存取令牌」生成 API Access Token,并通过 `x-api-key` 使用;不要使用 Cookie 调用开放 API。
> ⚠️ 许可证授权功能由独立服务器 [MediaStationLicenseServer](https://github.com/ShukeBta/MediaStationLicenseServer) 管理,本项目不内置授权/验权逻辑。
## 隐私与仓库安全
---
项目默认忽略以下个人/运行数据:
<p align="center">
<sub>Made with ❤️ by MediaStationGo Team</sub>
</p>
- `data/`、`cache/`、`logs/`
- `.tmp-deploy-data/`、`.tmp-deploy-server.*`
- `config.yaml`、`.env*`
- `*.db`、`*.db-wal`、`*.log`
- `web/dist/`、`node_modules/`、`bin/`
推送代码前建议执行:
```bash
git status --short
git ls-files | grep -E 'data/|cache/|\\.db|\\.log|jwt_secret|config.yaml|\\.env' || true
```
## 许可证
本项目遵循 `GPL-3.0` 许可证。详见 [LICENSE](LICENSE)。
+161 -302
View File
@@ -1,345 +1,204 @@
<p align="center">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="https://img.shields.io/badge/MediaStationGo-Your_Private_Media_Center-111827?style=for-the-badge&logo=data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNCAyNCI+PHBhdGggZmlsbD0id2hpdGUiIGQ9Ik0xMiAyQzYuNDggMiAyIDYuNDggMiAxMnM0LjQ4IDEwIDEwIDEwIDEwLTQuNDggMTAtMTBTMTcuNTIgMiAxMiAyem0tMiAxNWwtNS01IDEuNDEtMS40MUwxMCAxNC4xN2w3LjU5LTcuNTlMMTkgOGwtOSA5eiIvPjwvc3ZnPg=="/>
<img src="https://img.shields.io/badge/MediaStationGo-Your_Private_Media_Center-1F2937?style=for-the-badge&logo=data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNCAyNCI+PHBhdGggZmlsbD0id2hpdGUiIGQ9Ik0xMiAyQzYuNDggMiAyIDYuNDggMiAxMnM0LjQ4IDEwIDEwIDEwIDEwLTQuNDggMTAtMTBTMTcuNTIgMiAxMiAyem0tMiAxNWwtNS01IDEuNDEtMS40MUwxMCAxNC4xN2w3LjU5LTcuNTlMMTkgOGwtOSA5eiIvPjwvc3ZnPg=="/>
</picture>
</p>
# MediaStationGo
<h3 align="center"><samp>A Go rewrite of <a href="https://github.com/ShukeBta/MediaStation">MediaStation</a></samp></h3>
<h6 align="center"><samp>Lightweight · Fast · Single Binary · NAS Ready</samp></h6>
> A lightweight, polished, NAS-friendly private media center. Go single-binary backend + React frontend for library management, scraping, playback, subscriptions, downloads, Emby-compatible APIs, AI search, and recommendations.
<p align="center">
<a href="README.md"><img src="https://img.shields.io/badge/中文-README-blue?style=flat-square" alt="Chinese"></a>
</p>
[中文](README.md) · [Docker Deploy](#docker-deploy-recommended) · [One-Click Scripts](#one-click-deploy-scripts) · [Development](#development-and-build)
<p align="center">
<img src="https://img.shields.io/badge/Go-1.25-00ADD8?style=for-the-badge&logo=go&logoColor=white" alt="Go">
<img src="https://img.shields.io/badge/React-18-61DAFB?style=for-the-badge&logo=react&logoColor=black" alt="React">
<img src="https://img.shields.io/badge/TypeScript-5-3178C6?style=for-the-badge&logo=typescript&logoColor=white" alt="TypeScript">
<img src="https://img.shields.io/badge/SQLite-WAL-003B57?style=for-the-badge&logo=sqlite&logoColor=white" alt="SQLite">
<img src="https://img.shields.io/badge/Docker-Alpine-2496ED?style=for-the-badge&logo=docker&logoColor=white" alt="Docker">
<img src="https://img.shields.io/badge/License-GPLv3-8B5CF6?style=for-the-badge&logo=gnu&logoColor=white" alt="GPL v3">
</p>
## Highlights
---
- **Modern UI**: A unified bright premium visual system. The home page focuses on Featured, Continue Watching, and Recently Added. The library page groups movies, series, anime, variety shows, and music with automatically generated cover-folder cards.
- **Library scanning and organization**: Recursive scanning, ffprobe metadata, season/episode recognition, variety-show grouping by show/season/episode, duplicate prevention, and local NFO/image-first metadata.
- **Multi-source scraping**: TMDb, TheTVDB, Bangumi, Douban, Fanart.tv, and adult metadata enrichment from JavDB/JavBus pages. Local NFO, poster, fanart, DMM/JAV images are always preferred.
- **Playback experience**: Direct streaming, HTTP Range seeking, HLS transcoding, external subtitles, watch history, Continue Watching, and external-player entry points.
- **External client compatibility**: Emby/Jellyfin-style APIs for Infuse, VidHub, SenPlayer, and other third-party clients.
- **PT and downloads**: Site management, M-Team `x-api-key`, cross-site search, subscriptions, qBittorrent downloads, and intelligent post-download organization.
- **AI assistant**: OpenAI-compatible endpoint configuration for natural-language search and recommendations. Admin-side API settings take effect at runtime.
- **Simple deployment**: Bare-metal one-click scripts, Docker Compose, and multi-architecture Docker image build/push scripts.
<p align="center">
<b>📖 <a href="#-why-mediastationgo">Why</a></b>
&nbsp;·&nbsp;
<b>🚀 <a href="#-quick-start">Quick Start</a></b>
&nbsp;·&nbsp;
<b>✨ <a href="#-features">Features</a></b>
&nbsp;·&nbsp;
<b>🏗️ <a href="#-project-layout">Layout</a></b>
&nbsp;·&nbsp;
<b>⚙️ <a href="#-configuration">Config</a></b>
&nbsp;·&nbsp;
<b>🗺️ <a href="#-roadmap">Roadmap</a></b>
</p>
## Feature Modules
---
| Module | Capabilities |
| --- | --- |
| Libraries | Movies, TV, anime, variety, music, adult content; automatic covers and season/episode grouping |
| Scraping | Local NFO/images first, then TMDb/TheTVDB/Bangumi/Douban/Fanart/JavBus/JavDB enrichment |
| Playback | Direct play, HLS, subtitles, resume, external players, history, favorites |
| Discovery | TMDb / Douban / Bangumi recommendation sources and subscription entry points |
| Downloads | qBittorrent, PT sites, RSS/search subscriptions, automatic organization |
| Compatibility | Emby API, DLNA-ready structure, external clients, responsive three-end UI |
| Operations | Tasks, statistics, storage, duplicate files, recycle bin, NFO export |
| AI | OpenAI-compatible Base URL/API Key, smart search, recommendations |
## 🤔 Why MediaStationGo?
> MediaStationGo is a from-scratch Go rewrite of [MediaStation](https://github.com/ShukeBta/MediaStation) — same full-featured media center experience, radically simpler deployment.
<table>
<tr>
<td width="50%">
### Original MediaStation
- 🐍 Python / FastAPI + Vue
- 📦 Requires Python runtime & virtualenv
- 🐳 Docker mandatory or complex Python setup
- 📊 Deployment footprint > 500 MB
- 🔧 pip + npm dual build chains
</td>
<td width="50%">
### MediaStationGo ✨
- 🚀 Go 1.25 + React 18
- 📦 **Single static binary** (~30 MB)
- 🐳 Docker optional — runs natively bare-metal
- 🔥 Zero external dependencies (CGO off)
- ⚡ One-command build: `go build`
</td>
</tr>
</table>
| Metric | Original | MediaStationGo |
|--------|:---:|:---:|
| Binary size | — | ≈ 30 MB |
| Memory (idle) | ~200 MB | ~30 MB |
| Cold start | ~3s | ~0.3s |
| Deploy steps | 5+ | 1 |
| Frontend (gzip) | ~250 KB | ~83 KB |
---
## ✨ Features
<details open>
<summary><b>🔐 Authentication & Users</b></summary>
<br>
| Feature | Description |
|---------|-------------|
| JWT dual-role auth | admin / user with refresh token support |
| One-click admin bootstrap | Auto-seeded `admin` / `admin123` on first run |
| Profile management | Email, avatar, password change |
| User admin panel | Role promotion / demotion, enable / disable |
| Audit log | Login, library ops, downloads — all tracked |
</details>
<details open>
<summary><b>📚 Library Management</b></summary>
<br>
| Feature | Description |
|---------|-------------|
| Library CRUD | movie / tv / anime / music types supported |
| Recursive scanning | Filesystem walk + ffprobe metadata extraction |
| Smart filename parsing | Year + season/episode auto-detection |
| Multi-source scraping | TMDb → TheTVDB → Bangumi chain |
| HD poster upgrade | Optional Fanart.tv high-res fallback |
| Image proxy | TMDb / Bangumi / Douban / Fanart with disk cache |
| TV grouping | Season grouping with episode listing |
| Live fs watching | fsnotify-powered, 5-second coalesced debounce |
</details>
<details open>
<summary><b>🎬 Playback</b></summary>
<br>
| Feature | Description |
|---------|-------------|
| Direct streaming | HTTP Range support for instant seeking |
| HLS on-demand | Per-media ffmpeg job with HW acceleration |
| External subtitles | .srt / .vtt / .ass / .ssa → real-time WebVTT |
| Resume playback | Auto-saved every 10s + Continue Watching on home |
| Favorites & playlists | One-tap toggle / ordered playlists (CRUD) |
</details>
<details open>
<summary><b>🌐 PT Site Management</b></summary>
<br>
| Feature | Description |
|---------|-------------|
| 6 site types | nexusphp · gazelle · unit3d · mteam · discuz · custom_rss |
| 3 auth methods | Cookie / API Key / Auth Header |
| Site config | Full CRUD + connection test + enable toggle |
| Cross-site search | One-click search across all configured trackers |
| Extended config | Extra JSON: UA / RSS URL / timeout / priority / proxy / downloader |
</details>
<details open>
<summary><b>🤖 Automation</b></summary>
<br>
| Feature | Description |
|---------|-------------|
| Download client | qBittorrent Web UI API (add / list / delete) |
| RSS subscriptions | Regex filter + GUID dedup + 10-min polling |
| File organizer | Auto-categorize downloads: move / copy / hardlink / symlink |
</details>
<details open>
<summary><b>📊 Operations & Monitoring</b></summary>
<br>
| Feature | Description |
|---------|-------------|
| Live events | WebSocket push for scan / scrape / transcode / download / subscribe |
| Dashboard | CPU / Memory / Disk / Library counts / Goroutines |
| Task panel | Real-time ffmpeg jobs + qBittorrent torrents |
| NFO export | Kodi / Jellyfin compatible — single media or whole library |
| HW acceleration | Software / NVENC / Intel QSV / VAAPI encoder profiles |
| CI/CD | GitHub Actions + multi-arch GHCR release |
</details>
<details open>
<summary><b>🧠 AI & Discovery</b></summary>
<br>
| Feature | Description |
|---------|-------------|
| TMDb Discover | Trending / popular rails on homepage |
| AI smart search | OpenAI-compatible → natural language → structured query |
| AI recommendations | Personalized picks from your watch history |
</details>
---
## 🚀 Quick Start
### 🐳 Docker (Recommended)
## Docker Deploy (Recommended)
```bash
git clone https://github.com/ShukeBta/MediaStationGo.git
cd MediaStationGo
# Edit docker-compose.yml to mount your media at /media
cp config.example.yaml config.yaml
docker compose up -d
```
> 🌐 The server auto-detects both your **local network IP** and **public IP** on startup (NAS / VPS supported):
> ```json
> {"msg":"server is ready","local":"http://192.168.1.4:8080","public":"http://1.2.3.4:8080"}
> ```
> Log in with `admin` / `admin123`.
Default URL: `http://<server-ip>:8080`
### 💻 Bare Metal
Default account: `admin` / `admin123`
| Prerequisite | Version |
|-------------|---------|
| Go | ≥ 1.25 |
| Node.js | ≥ 20 |
| FFmpeg | Any |
> Change the administrator password immediately after the first login, then add media folders from the admin UI.
### Key docker-compose Mounts
```yaml
volumes:
- ./data:/data
- ./cache:/cache
- /your/media/path:/media:ro
```
### Hardware Transcoding
- Intel QSV/VAAPI: mount `/dev/dri:/dev/dri`
- NVIDIA NVENC: install NVIDIA Container Toolkit on the host and enable `gpus: all`
- Software transcoding: no additional setup required
## One-Click Deploy Scripts
### Linux / macOS
```bash
git clone https://github.com/ShukeBta/MediaStationGo.git
cd MediaStationGo
chmod +x scripts/deploy.sh
PORT=18080 DATA_DIR=/opt/mediastation/data CACHE_DIR=/opt/mediastation/cache ./scripts/deploy.sh
```
# Build backend + frontend
make build
### Windows PowerShell
# Start the server
```powershell
git clone https://github.com/ShukeBta/MediaStationGo.git
cd MediaStationGo
.\scripts\deploy.ps1 -Port 18080 -DataDir D:\MediaStationGo\data -CacheDir D:\MediaStationGo\cache
```
The scripts automatically:
1. Install frontend dependencies and build `web/dist`
2. Compile the Go server into `bin/`
3. Create data and cache directories
4. Stop any previously started process
5. Start the service and verify `/api/health`
## Docker Image Build and Push
The default image is `ghcr.io/shukebta/mediastation-go:latest`:
```bash
docker login ghcr.io
IMAGE=ghcr.io/shukebta/mediastation-go TAG=latest ./scripts/docker-build-push.sh
```
Windows:
```powershell
docker login ghcr.io
.\scripts\docker-build-push.ps1 -Image ghcr.io/shukebta/mediastation-go -Tag latest
```
Build locally without pushing:
```bash
PUSH=0 TAG=dev ./scripts/docker-build-push.sh
```
```powershell
.\scripts\docker-build-push.ps1 -Tag dev -Load
```
## Development and Build
### Requirements
| Component | Version |
| --- | --- |
| Go | 1.25+ |
| Node.js | 20+ |
| FFmpeg / ffprobe | Recommended |
| Docker | Optional |
### Local Build
```bash
cp config.example.yaml config.yaml
cd web && npm ci && npm run build
cd ..
go build -o bin/mediastation-go ./cmd/server
./bin/mediastation-go
```
### 🛠️ Local Development
### Common Commands
```bash
# Terminal 1: Go backend (port 8080, DEBUG mode)
make dev
# Terminal 2: Vite frontend (port 3000, proxies API calls)
make dev-web
make build # Build frontend and backend
make test # Run Go tests
make docker # docker compose up --build -d
make deploy # Linux one-click deploy
make docker-push # Multi-arch buildx push
```
---
Windows users can run:
## ⚙️ Configuration
Config precedence: **defaults** < `config.yaml` < `config/*.yaml` < **env vars** (prefix `MEDIASTATION_`)
### Key Environment Variables
| Variable | Default | Purpose |
|----------|---------|---------|
| `MEDIASTATION_APP_PORT` | `8080` | HTTP listen port |
| `MEDIASTATION_APP_DATA_DIR` | `./data` | Data root (DB / cache / JWT) |
| `MEDIASTATION_APP_WEB_DIR` | `./web/dist` | SPA bundle directory |
| `MEDIASTATION_DATABASE_DB_PATH` | `./data/mediastation.db` | SQLite file path |
| `MEDIASTATION_SECRETS_JWT_SECRET` | *(auto)* | JWT signing key |
| `MEDIASTATION_SECRETS_TMDB_API_KEY` | *(empty)* | TMDb scraping (required) |
| `MEDIASTATION_SECRETS_BANGUMI_ACCESS_TOKEN` | *(empty)* | Bangumi rate limit boost |
| `MEDIASTATION_APP_CORS_ORIGINS` | *(empty)* | Allow-list (JSON array) |
| `ADMIN_INITIAL_PASSWORD` | `admin123` | Initial admin password |
### Runtime Settings
Admin panel → Settings tab, stored in the `settings` table:
| Key | Purpose |
|-----|---------|
| `qbittorrent.url` | qBittorrent Web UI URL |
| `qbittorrent.username` | Username |
| `qbittorrent.password` | Password |
| `qbittorrent.savepath` | Default save path (optional) |
> 💡 After editing, hit **Downloads → Reload Config** or `POST /api/downloads/reload`.
📖 Full config template: [`config.example.yaml`](config.example.yaml)
---
## 🏗️ Project Layout
```
MediaStationGo/
├── cmd/server/main.go ← Entry point
├── internal/
│ ├── config/ ← Viper configuration layer
│ ├── database/ ← GORM + SQLite (WAL) bootstrap
│ ├── model/ ← Data models + AutoMigrate registry
│ ├── repository/ ← Data access layer
│ ├── service/ ← Business logic (core)
│ │ ├── auth.go login / register / JWT
│ │ ├── media.go library + media CRUD
│ │ ├── scanner.go filesystem walker + ffprobe
│ │ ├── scraper.go scrape orchestrator + filename cleaner
│ │ ├── tmdb.go / bangumi.go third-party providers
│ │ ├── site.go site CRUD + cross-site search
│ │ ├── site_adapter.go 6 PT site adapters
│ │ ├── stream.go direct play + HLS
│ │ ├── transcoder.go per-media ffmpeg job manager
│ │ ├── subtitle.go external subs → WebVTT
│ │ ├── image_proxy.go cached image proxy
│ │ ├── playback.go history / favorites / playlists
│ │ ├── watcher.go fsnotify debouncer
│ │ ├── qbittorrent.go qBittorrent v2 API client
│ │ ├── downloads.go download orchestrator
│ │ ├── subscription.go RSS poller
│ │ ├── organizer.go media file organizer
│ │ ├── stats.go dashboard snapshot
│ │ ├── profile.go user profile mutations
│ │ ├── audit.go audit log writer
│ │ └── ws_hub.go WebSocket pub/sub broker
│ ├── middleware/ ← JWT / CORS / admin middleware
│ └── handler/ ← HTTP route definitions (by concern)
├── web/ ← React 18 + Vite + Tailwind CSS
│ ├── src/api/ axios service wrappers
│ ├── src/components/ Card / Layout / APIConfigsPanel / etc.
│ ├── src/hooks/ useWebSocket & friends
│ ├── src/pages/ Home · Library · Search · Player · Downloads · Admin · Sites
│ ├── src/stores/ Zustand (auth)
│ └── src/types/ TypeScript domain types
├── Dockerfile ← Multi-stage, multi-arch build
├── docker-compose.yml ← NAS-friendly one-command deploy
├── Makefile ← build / dev / docker / test
├── config.example.yaml ← Full configuration reference
└── .github/workflows/ ← CI + GHCR publish
```powershell
.\scripts\deploy.ps1
```
---
## Configuration
## 🗺️ Roadmap
Configuration precedence, from low to high:
| Feature | Status |
|---------|:---:|
| Jellyfin / Emby compatibility layer | 🔨 In Progress |
| DLNA / Chromecast casting | 📋 Planned |
| Online subtitle search providers | 📋 Planned |
| Multi-bitrate ABR transcode | 📋 Planned |
| STRM direct streaming (WebDAV / Alist / S3) | ✅ Complete |
1. Built-in defaults
2. `config.yaml`
3. `config/*.yaml`
4. `MEDIASTATION_` environment variables
5. Runtime settings stored in the database, such as API keys, sites, and download clients
---
Common environment variables:
## 🤝 Contributing
| Variable | Default | Description |
| --- | --- | --- |
| `MEDIASTATION_APP_PORT` | `8080` | Web service port |
| `MEDIASTATION_APP_DATA_DIR` | `./data` | Data directory |
| `MEDIASTATION_DATABASE_DB_PATH` | `./data/mediastation.db` | SQLite database path |
| `MEDIASTATION_APP_WEB_DIR` | `./web/dist` | Frontend static bundle |
| `MEDIASTATION_CACHE_CACHE_DIR` | `./cache` | Image/transcode cache |
| `MEDIASTATION_SECRETS_JWT_SECRET` | Auto-generated | JWT and encryption seed |
Issues and PRs are welcome! Please read the [Contribution Guidelines](CONTRIBUTING.md) before submitting.
## APIs and External Services
---
Configure external services from the admin UI:
## 📄 License
- TMDb: movie and TV metadata
- Bangumi: anime and series metadata
- TheTVDB: additional TV metadata
- Fanart.tv: high-resolution artwork
- OpenAI Compatible: AI search and recommendations
- Adult/JAV: JavBus/JavDB page scraping, no API key required
[GNU General Public License v3.0](LICENSE)
For M-Team, generate an API Access Token from `Control Panel → Lab → Access Token` and send it as `x-api-key`. Do not use cookies for the open API.
> ⚠️ License activation/validation is handled by a separate server: [MediaStationLicenseServer](https://github.com/ShukeBta/MediaStationLicenseServer). This project contains no license generation or validation logic.
## Privacy and Repository Safety
---
The project ignores personal and runtime data by default:
<p align="center">
<sub>Made with ❤️ by MediaStationGo Team</sub>
</p>
- `data/`, `cache/`, `logs/`
- `.tmp-deploy-data/`, `.tmp-deploy-server.*`, `.mediastation.pid`
- `config.yaml`, `.env*`
- `*.db`, `*.db-wal`, `*.log`
- `web/dist/`, `node_modules/`, `bin/`
Before pushing code, run:
```bash
git status --short
git ls-files | grep -E 'data/|cache/|\\.db|\\.log|jwt_secret|config.yaml|\\.env' || true
```
## License
This project is licensed under `GPL-3.0`. See [LICENSE](LICENSE) for details.
+1 -5
View File
@@ -72,10 +72,6 @@ func aiRecommendHandler(svc *service.Container) gin.HandlerFunc {
func aiStatusHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{
"enabled": svc.AI.Enabled(),
"provider": svc.Cfg.AI.Provider,
"model": svc.Cfg.AI.Model,
})
c.JSON(http.StatusOK, svc.AI.Status(c.Request.Context()))
}
}
+27 -27
View File
@@ -34,7 +34,7 @@ func NewSiteHTTPClient(timeoutSeconds int, useProxy bool) *http.Client {
}
if useProxy {
tr.Proxy = func(r *http.Request) (*url.URL, error) {
return http.ProxyFromEnvironment(r)
return ProxyFromEnvironmentOrSystem(r)
}
}
return &http.Client{
@@ -47,17 +47,17 @@ func NewSiteHTTPClient(timeoutSeconds int, useProxy bool) *http.Client {
// These mimic a real Chrome browser to avoid WAF/bot detection.
func HTTPHeaderPresets() map[string]string {
return map[string]string{
"User-Agent": model.DefaultUserAgent,
"Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7",
"Accept-Language": "zh-CN,zh;q=0.9,en;q=0.8",
"Accept-Encoding": "gzip, deflate, br",
"Connection": "keep-alive",
"User-Agent": model.DefaultUserAgent,
"Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7",
"Accept-Language": "zh-CN,zh;q=0.9,en;q=0.8",
"Accept-Encoding": "gzip, deflate, br",
"Connection": "keep-alive",
"Upgrade-Insecure-Requests": "1",
"Sec-Fetch-Dest": "document",
"Sec-Fetch-Mode": "navigate",
"Sec-Fetch-Site": "none",
"Sec-Fetch-User": "?1",
"Cache-Control": "max-age=0",
"Sec-Fetch-Dest": "document",
"Sec-Fetch-Mode": "navigate",
"Sec-Fetch-Site": "none",
"Sec-Fetch-User": "?1",
"Cache-Control": "max-age=0",
}
}
@@ -65,12 +65,12 @@ func HTTPHeaderPresets() map[string]string {
// FlareSolverrRequest represents a request to FlareSolverr.
type FlareSolverrRequest struct {
Cmd string `json:"cmd"`
URL string `json:"url"`
Session string `json:"session,omitempty"`
MaxTimeout int `json:"maxTimeout,omitempty"`
Proxy *FlareSolverrProxy `json:"proxy,omitempty"`
Cookies []FlareSolverrCookie `json:"cookies,omitempty"`
Cmd string `json:"cmd"`
URL string `json:"url"`
Session string `json:"session,omitempty"`
MaxTimeout int `json:"maxTimeout,omitempty"`
Proxy *FlareSolverrProxy `json:"proxy,omitempty"`
Cookies []FlareSolverrCookie `json:"cookies,omitempty"`
}
// FlareSolverrProxy represents proxy config for FlareSolverr.
@@ -90,19 +90,19 @@ type FlareSolverrCookie struct {
// FlareSolverrResponse represents FlareSolverr's response.
type FlareSolverrResponse struct {
Status string `json:"status"`
Message string `json:"message"`
Status string `json:"status"`
Message string `json:"message"`
Solution *FlareSolverrSolution `json:"solution,omitempty"`
}
// FlareSolverrSolution contains the solved challenge result.
type FlareSolverrSolution struct {
URL string `json:"url"`
Status int `json:"status"`
Headers map[string]string `json:"headers"`
URL string `json:"url"`
Status int `json:"status"`
Headers map[string]string `json:"headers"`
Cookies []FlareSolverrCookie `json:"cookies"`
UserAgent string `json:"userAgent"`
Response string `json:"response"`
UserAgent string `json:"userAgent"`
Response string `json:"response"`
}
// FetchURLWithFlareSolverr uses FlareSolverr to fetch a URL,
@@ -123,10 +123,10 @@ func FetchURLWithFlareSolverr(flareSolverrURL string, targetURL string, cookieSt
// Build request
reqBody := FlareSolverrRequest{
Cmd: "request.get",
URL: targetURL,
Cmd: "request.get",
URL: targetURL,
MaxTimeout: timeout * 1000,
Cookies: cookies,
Cookies: cookies,
}
if proxyURL != "" {
reqBody.Proxy = &FlareSolverrProxy{URL: proxyURL}
+75
View File
@@ -0,0 +1,75 @@
package helper
import (
"net/http"
"net/url"
"strings"
)
// ProxyFromEnvironmentOrSystem mirrors http.ProxyFromEnvironment, then falls
// back to the current user's OS proxy settings where supported.
func ProxyFromEnvironmentOrSystem(req *http.Request) (*url.URL, error) {
if proxy, err := http.ProxyFromEnvironment(req); proxy != nil || err != nil {
return proxy, err
}
return systemProxyForRequest(req)
}
func proxyURLFromProxyServer(proxyServer, requestScheme string) (*url.URL, error) {
proxyServer = strings.TrimSpace(proxyServer)
if proxyServer == "" {
return nil, nil
}
if !strings.Contains(proxyServer, "=") {
return normalizeProxyURL(proxyServer, "http")
}
entries := strings.Split(proxyServer, ";")
values := map[string]string{}
first := ""
for _, entry := range entries {
key, value, ok := strings.Cut(strings.TrimSpace(entry), "=")
if !ok {
continue
}
key = strings.ToLower(strings.TrimSpace(key))
value = strings.TrimSpace(value)
if key == "" || value == "" {
continue
}
if first == "" {
first = value
}
values[key] = value
}
if value := values[strings.ToLower(requestScheme)]; value != "" {
return normalizeProxyURL(value, strings.ToLower(requestScheme))
}
if value := values["http"]; value != "" {
return normalizeProxyURL(value, "http")
}
if value := values["https"]; value != "" {
return normalizeProxyURL(value, "http")
}
if value := values["socks"]; value != "" {
return normalizeProxyURL(value, "socks")
}
return normalizeProxyURL(first, "http")
}
func normalizeProxyURL(raw, proxyKind string) (*url.URL, error) {
raw = strings.TrimSpace(raw)
if raw == "" {
return nil, nil
}
if !strings.Contains(raw, "://") {
scheme := "http"
if strings.EqualFold(proxyKind, "socks") {
scheme = "socks5"
}
raw = scheme + "://" + raw
}
return url.Parse(raw)
}
+12
View File
@@ -0,0 +1,12 @@
//go:build !windows
package helper
import (
"net/http"
"net/url"
)
func systemProxyForRequest(_ *http.Request) (*url.URL, error) {
return nil, nil
}
+73
View File
@@ -0,0 +1,73 @@
//go:build windows
package helper
import (
"net"
"net/http"
"net/url"
"path/filepath"
"strings"
"golang.org/x/sys/windows/registry"
)
const windowsInternetSettingsKey = `Software\Microsoft\Windows\CurrentVersion\Internet Settings`
func systemProxyForRequest(req *http.Request) (*url.URL, error) {
if req == nil || req.URL == nil {
return nil, nil
}
key, err := registry.OpenKey(registry.CURRENT_USER, windowsInternetSettingsKey, registry.QUERY_VALUE)
if err != nil {
return nil, nil
}
defer key.Close()
enabled, _, err := key.GetIntegerValue("ProxyEnable")
if err != nil || enabled == 0 {
return nil, nil
}
proxyServer, _, err := key.GetStringValue("ProxyServer")
if err != nil || strings.TrimSpace(proxyServer) == "" {
return nil, nil
}
if proxyOverride, _, err := key.GetStringValue("ProxyOverride"); err == nil {
if windowsProxyBypass(req.URL.Hostname(), proxyOverride) {
return nil, nil
}
}
return proxyURLFromProxyServer(proxyServer, req.URL.Scheme)
}
func windowsProxyBypass(host, override string) bool {
host = strings.ToLower(strings.TrimSpace(host))
if host == "" {
return false
}
if parsed := net.ParseIP(host); parsed != nil && parsed.IsLoopback() {
return true
}
for _, rule := range strings.Split(override, ";") {
rule = strings.ToLower(strings.TrimSpace(rule))
if rule == "" {
continue
}
if rule == "<local>" && !strings.Contains(host, ".") {
return true
}
if ok, _ := filepath.Match(rule, host); ok {
return true
}
if strings.HasPrefix(rule, "*.") && strings.HasSuffix(host, strings.TrimPrefix(rule, "*")) {
return true
}
if host == rule {
return true
}
}
return false
}
+55 -4
View File
@@ -37,6 +37,15 @@ var adultExcludedPrefixes = map[string]struct{}{
"WEB": {}, "X264": {}, "X265": {},
}
var defaultAdultBases = []string{
"https://javdb.com",
"https://javbus.sbs",
"https://www.javbus.com",
"https://www.cdnbus.cyou",
"https://www.javsee.cyou",
"https://www.busjav.cyou",
}
type AdultProvider struct {
log *zap.Logger
client *http.Client
@@ -69,7 +78,7 @@ func (p *AdultProvider) Search(ctx context.Context, code string) (*Match, error)
base = strings.TrimRight(base, "/")
var match *Match
var err error
if strings.Contains(base, "javbus") {
if adultSourceKind(base) == "javbus" {
match, err = p.scrapeJavBus(ctx, base, code)
} else {
match, err = p.scrapeJavDB(ctx, base, code)
@@ -91,7 +100,7 @@ func (p *AdultProvider) Search(ctx context.Context, code string) (*Match, error)
}
func (p *AdultProvider) resolveBases(ctx context.Context) []string {
out := []string{"https://javdb.com", "https://www.javbus.com"}
out := append([]string{}, defaultAdultBases...)
if p.apiConfig == nil {
return out
}
@@ -102,20 +111,44 @@ func (p *AdultProvider) resolveBases(ctx context.Context) []string {
if !resolved.Enabled && (resolved.BaseURL != "" || resolved.Extra != "" || resolved.APIKey != "") {
return nil
}
configured := []string{}
if resolved.BaseURL != "" {
out = []string{resolved.BaseURL}
configured = append(configured, resolved.BaseURL)
}
if resolved.Extra != "" {
for _, part := range strings.Split(resolved.Extra, ",") {
part = strings.TrimSpace(part)
if strings.HasPrefix(part, "http://") || strings.HasPrefix(part, "https://") {
out = append(out, part)
configured = append(configured, part)
}
}
}
if len(configured) > 0 {
out = append(configured, out...)
}
return dedupeStrings(out)
}
func adultSourceKind(base string) string {
u, err := url.Parse(strings.TrimSpace(base))
host := ""
if err == nil {
host = strings.ToLower(u.Hostname())
}
if host == "" {
host = strings.ToLower(base)
}
if strings.Contains(host, "javdb") {
return "javdb"
}
for _, needle := range []string{"javbus", "cdnbus", "javsee", "busjav"} {
if strings.Contains(host, needle) {
return "javbus"
}
}
return "javdb"
}
func (p *AdultProvider) scrapeJavDB(ctx context.Context, base, code string) (*Match, error) {
searchURL := base + "/search?q=" + url.QueryEscape(code) + "&f=all"
body, err := p.fetchText(ctx, searchURL, base)
@@ -200,6 +233,9 @@ func parseAdultDetailHTML(body, code, source, detailURL string) *Match {
if m := adultSamplePattern.FindStringSubmatch(body); len(m) > 1 {
match.BackdropURL = absolutizeURL(detailURL, m[1])
}
if dmmPoster := adultDMMPosterFromSampleURL(match.BackdropURL); dmmPoster != "" {
match.PosterURL = dmmPoster
}
match.Year = firstYearInText(body)
match.Rating = firstRatingInText(body)
return match
@@ -292,6 +328,21 @@ func firstAdultImage(body string, classNeedles ...string) string {
return ""
}
func adultDMMPosterFromSampleURL(raw string) string {
u, err := url.Parse(strings.TrimSpace(raw))
if err != nil || u.Host == "" || !strings.Contains(strings.ToLower(u.Host), "dmm.co.jp") {
return ""
}
lowerPath := strings.ToLower(u.Path)
for _, suffix := range []string{"jp-1.jpg", "jp.jpg"} {
if strings.HasSuffix(lowerPath, suffix) {
u.Path = u.Path[:len(u.Path)-len(suffix)] + "pl.jpg"
return u.String()
}
}
return ""
}
func adultAttrs(raw string) map[string]string {
out := map[string]string{}
for _, found := range adultAttrPattern.FindAllStringSubmatch(raw, -1) {
+58 -1
View File
@@ -1,6 +1,9 @@
package service
import "testing"
import (
"context"
"testing"
)
func TestNormalizeAdultCode(t *testing.T) {
cases := map[string]string{
@@ -40,3 +43,57 @@ func TestParseAdultDetailHTML(t *testing.T) {
t.Fatalf("year = %d, want 2024", got.Year)
}
}
func TestParseAdultDetailHTMLDerivesDMMPoster(t *testing.T) {
html := `<html>
<h3>NACR-833 测试标题</h3>
<a class="sample-box" href="https://pics.dmm.co.jp/digital/video/h_237nacr00833/h_237nacr00833jp-1.jpg"></a>
</html>`
got := parseAdultDetailHTML(html, "NACR-833", "javbus", "https://www.javbus.com/NACR-833")
if got == nil {
t.Fatal("parseAdultDetailHTML returned nil")
}
if got.PosterURL != "https://pics.dmm.co.jp/digital/video/h_237nacr00833/h_237nacr00833pl.jpg" {
t.Fatalf("PosterURL = %q", got.PosterURL)
}
}
func TestAdultSourceKindRecognizesJavBusMirrors(t *testing.T) {
cases := map[string]string{
"https://javdb.com": "javdb",
"https://javbus.sbs": "javbus",
"https://www.javbus.com": "javbus",
"https://www.cdnbus.cyou": "javbus",
"https://www.javsee.cyou": "javbus",
"https://www.busjav.cyou": "javbus",
"www.cdnbus.cyou": "javbus",
"https://example.invalid": "javdb",
}
for in, want := range cases {
if got := adultSourceKind(in); got != want {
t.Fatalf("adultSourceKind(%q) = %q, want %q", in, got, want)
}
}
}
func TestAdultProviderDefaultBases(t *testing.T) {
provider := &AdultProvider{}
got := provider.resolveBases(context.Background())
want := []string{
"https://javdb.com",
"https://javbus.sbs",
"https://www.javbus.com",
"https://www.cdnbus.cyou",
"https://www.javsee.cyou",
"https://www.busjav.cyou",
}
if len(got) != len(want) {
t.Fatalf("resolveBases len = %d, want %d: %v", len(got), len(want), got)
}
for i := range want {
if got[i] != want[i] {
t.Fatalf("resolveBases[%d] = %q, want %q", i, got[i], want[i])
}
}
}
+94 -20
View File
@@ -31,21 +31,23 @@ import (
// AIService talks to an OpenAI-compatible chat-completions endpoint.
type AIService struct {
cfg *config.Config
log *zap.Logger
client *http.Client
cfg *config.Config
log *zap.Logger
client *http.Client
apiConfig *APIConfigService
}
// NewAIService is the constructor.
func NewAIService(cfg *config.Config, log *zap.Logger) *AIService {
func NewAIService(cfg *config.Config, log *zap.Logger, apiConfig *APIConfigService) *AIService {
timeout := time.Duration(cfg.AI.Timeout) * time.Second
if timeout <= 0 {
timeout = 30 * time.Second
}
return &AIService{
cfg: cfg,
log: log,
client: &http.Client{Timeout: timeout},
cfg: cfg,
log: log,
apiConfig: apiConfig,
client: NewExternalHTTPClient(timeout),
}
}
@@ -54,6 +56,76 @@ func (a *AIService) Enabled() bool {
return a.cfg.AI.Enabled && strings.TrimSpace(a.cfg.AI.APIKey) != ""
}
// EnabledFor reports whether the AI integration is configured for a request.
func (a *AIService) EnabledFor(ctx context.Context) bool {
return a.resolveRuntimeConfig(ctx).Enabled
}
// AIStatus is returned to the UI for connection-state display.
type AIStatus struct {
Enabled bool `json:"enabled"`
Provider string `json:"provider"`
Model string `json:"model"`
}
// Status resolves live database-backed AI config for the UI.
func (a *AIService) Status(ctx context.Context) AIStatus {
cfg := a.resolveRuntimeConfig(ctx)
return AIStatus{Enabled: cfg.Enabled, Provider: cfg.Provider, Model: cfg.Model}
}
type aiRuntimeConfig struct {
Enabled bool
Provider string
APIBase string
APIKey string
Model string
}
func (a *AIService) resolveRuntimeConfig(ctx context.Context) aiRuntimeConfig {
out := aiRuntimeConfig{
Enabled: a.cfg.AI.Enabled && strings.TrimSpace(a.cfg.AI.APIKey) != "",
Provider: strings.TrimSpace(a.cfg.AI.Provider),
APIBase: strings.TrimSpace(a.cfg.AI.APIBase),
APIKey: strings.TrimSpace(a.cfg.AI.APIKey),
Model: strings.TrimSpace(a.cfg.AI.Model),
}
if out.Provider == "" {
out.Provider = "openai"
}
if out.APIBase == "" {
out.APIBase = "https://api.openai.com/v1"
}
if out.Model == "" {
out.Model = "gpt-4o-mini"
}
if a.apiConfig != nil {
resolved, err := a.apiConfig.Resolve(ctx, "openai")
if err != nil {
if a.log != nil {
a.log.Warn("ai: failed to resolve openai api config", zap.Error(err))
}
return out
}
if resolved.BaseURL != "" {
out.APIBase = strings.TrimSpace(resolved.BaseURL)
}
if resolved.APIKey != "" {
out.APIKey = strings.TrimSpace(resolved.APIKey)
}
if resolved.Enabled && out.APIKey != "" {
out.Enabled = true
out.Provider = "openai"
return out
}
if !resolved.Enabled && (resolved.APIKey != "" || resolved.BaseURL != "" || resolved.Extra != "") {
out.Enabled = false
}
}
return out
}
// SearchIntent is the structured output the smart search endpoint returns.
type SearchIntent struct {
Query string `json:"query"`
@@ -67,7 +139,8 @@ type SearchIntent struct {
// SmartSearch turns a natural-language query into a structured intent.
// Returns a best-effort intent on parse failure (raw query passes through).
func (a *AIService) SmartSearch(ctx context.Context, raw string) (*SearchIntent, error) {
if !a.Enabled() {
runtime := a.resolveRuntimeConfig(ctx)
if !runtime.Enabled {
return &SearchIntent{Query: raw}, nil
}
const sys = "You are a media-library search assistant. Read the user's query and " +
@@ -75,7 +148,7 @@ func (a *AIService) SmartSearch(ctx context.Context, raw string) (*SearchIntent,
"genre (string, optional), type (movie|tv|anime|music, optional), sort " +
"(recent|rating|random, optional), language (zh|en, optional). Respond with " +
"JSON only, no commentary."
out, err := a.complete(ctx, sys, raw)
out, err := a.complete(ctx, runtime, sys, raw)
if err != nil {
return &SearchIntent{Query: raw}, err
}
@@ -95,7 +168,8 @@ func (a *AIService) SmartSearch(ctx context.Context, raw string) (*SearchIntent,
// history. The first call is intentionally best-effort: a future iteration
// may chain media DB lookups onto each suggestion.
func (a *AIService) Recommend(ctx context.Context, history []string, max int) ([]string, error) {
if !a.Enabled() || len(history) == 0 {
runtime := a.resolveRuntimeConfig(ctx)
if !runtime.Enabled || len(history) == 0 {
return nil, nil
}
if max <= 0 || max > 20 {
@@ -104,7 +178,7 @@ func (a *AIService) Recommend(ctx context.Context, history []string, max int) ([
sys := fmt.Sprintf("You are a film / TV recommendation assistant. Reply with %d "+
"comma-separated titles only, no commentary, in the same language as the input.", max)
usr := "I recently watched: " + strings.Join(history, "; ")
out, err := a.complete(ctx, sys, usr)
out, err := a.complete(ctx, runtime, sys, usr)
if err != nil {
return nil, err
}
@@ -121,9 +195,9 @@ func (a *AIService) Recommend(ctx context.Context, history []string, max int) ([
}
// complete is the shared helper — POST /v1/chat/completions.
func (a *AIService) complete(ctx context.Context, system, user string) (string, error) {
func (a *AIService) complete(ctx context.Context, runtime aiRuntimeConfig, system, user string) (string, error) {
payload := map[string]any{
"model": a.cfg.AI.Model,
"model": runtime.Model,
"temperature": 0.2,
"messages": []map[string]string{
{"role": "system", "content": system},
@@ -131,13 +205,13 @@ func (a *AIService) complete(ctx context.Context, system, user string) (string,
},
}
body, _ := json.Marshal(payload)
endpoint := strings.TrimRight(a.cfg.AI.APIBase, "/") + "/chat/completions"
endpoint := strings.TrimRight(runtime.APIBase, "/") + "/chat/completions"
req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, bytes.NewReader(body))
if err != nil {
return "", err
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+a.cfg.AI.APIKey)
req.Header.Set("Authorization", "Bearer "+runtime.APIKey)
resp, err := a.client.Do(req)
if err != nil {
return "", err
@@ -165,7 +239,6 @@ func (a *AIService) complete(ctx context.Context, system, user string) (string,
return strings.TrimSpace(out.Choices[0].Message.Content), nil
}
// ChatTurn is one message in a multi-turn assistant transcript.
type ChatTurn struct {
Role string `json:"role"`
@@ -176,7 +249,8 @@ type ChatTurn struct {
// we return a deterministic offline reply so the assistant UI still
// has something to render.
func (a *AIService) Chat(ctx context.Context, history []ChatTurn) (string, error) {
if !a.Enabled() || len(history) == 0 {
runtime := a.resolveRuntimeConfig(ctx)
if !runtime.Enabled || len(history) == 0 {
return offlineReply(history), nil
}
// Build a chat/completions payload preserving the history order.
@@ -191,18 +265,18 @@ func (a *AIService) Chat(ctx context.Context, history []ChatTurn) (string, error
msgs = append(msgs, map[string]string{"role": t.Role, "content": t.Content})
}
payload := map[string]any{
"model": a.cfg.AI.Model,
"model": runtime.Model,
"temperature": 0.4,
"messages": msgs,
}
body, _ := json.Marshal(payload)
endpoint := strings.TrimRight(a.cfg.AI.APIBase, "/") + "/chat/completions"
endpoint := strings.TrimRight(runtime.APIBase, "/") + "/chat/completions"
req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, bytes.NewReader(body))
if err != nil {
return "", err
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+a.cfg.AI.APIKey)
req.Header.Set("Authorization", "Bearer "+runtime.APIKey)
resp, err := a.client.Do(req)
if err != nil {
return "", err
+84
View File
@@ -0,0 +1,84 @@
package service
import (
"context"
"testing"
"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 TestAIStatusUsesDatabaseOpenAIConfig(t *testing.T) {
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
if err != nil {
t.Fatal(err)
}
if err := db.AutoMigrate(&model.APIConfig{}); err != nil {
t.Fatal(err)
}
repo := &repository.Container{DB: db}
crypto := NewCryptoService("test-secret", zap.NewNop())
apiConfig := NewAPIConfigService(zap.NewNop(), repo, crypto)
key := "sk-test"
baseURL := "https://example.test/v1"
enabled := true
if _, err := apiConfig.Update(context.Background(), "openai", APIConfigPatch{
APIKey: &key,
BaseURL: &baseURL,
Enabled: &enabled,
}); err != nil {
t.Fatal(err)
}
ai := NewAIService(&config.Config{
AI: config.AIConfig{
Enabled: false,
Model: "gpt-4o-mini",
},
}, zap.NewNop(), apiConfig)
status := ai.Status(context.Background())
if !status.Enabled {
t.Fatalf("AI status disabled, want enabled from database config")
}
if status.Provider != "openai" {
t.Fatalf("provider = %q, want openai", status.Provider)
}
}
func TestAIStatusHonorsDisabledDatabaseOpenAIConfig(t *testing.T) {
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
if err != nil {
t.Fatal(err)
}
if err := db.AutoMigrate(&model.APIConfig{}); err != nil {
t.Fatal(err)
}
repo := &repository.Container{DB: db}
apiConfig := NewAPIConfigService(zap.NewNop(), repo, NewCryptoService("test-secret", zap.NewNop()))
key := "sk-test"
enabled := false
if _, err := apiConfig.Update(context.Background(), "openai", APIConfigPatch{
APIKey: &key,
Enabled: &enabled,
}); err != nil {
t.Fatal(err)
}
ai := NewAIService(&config.Config{
AI: config.AIConfig{
Enabled: true,
APIKey: "sk-file",
Model: "gpt-4o-mini",
},
}, zap.NewNop(), apiConfig)
if ai.Status(context.Background()).Enabled {
t.Fatalf("AI status enabled, want disabled when database config is explicitly disabled")
}
}
+1 -1
View File
@@ -41,7 +41,7 @@ func (s *APIConfigService) SeedDefaults(ctx context.Context) error {
{Provider: "thetvdb", BaseURL: "https://api4.thetvdb.com/v4", Description: "TheTVDB (tv)", Enabled: true},
{Provider: "fanart", BaseURL: "https://webservice.fanart.tv/v3", Description: "Fanart.tv (artwork)", Enabled: true},
{Provider: "douban", Description: "Douban cookie (zh metadata)", Enabled: true},
{Provider: "adult", BaseURL: "https://javdb.com", Extra: "https://www.javbus.com", Description: "Adult / 番号元数据(JavDB/JavBus)", Enabled: true},
{Provider: "adult", BaseURL: "https://javdb.com", Extra: "https://javbus.sbs,https://www.javbus.com,https://www.cdnbus.cyou,https://www.javsee.cyou,https://www.busjav.cyou", Description: "Adult / 番号元数据(JavDB/JavBus)", Enabled: true},
{Provider: "openai", BaseURL: "https://api.openai.com/v1", Description: "OpenAI-compatible (smart search)", Enabled: true},
}
for i := range defaults {
+6
View File
@@ -187,6 +187,8 @@ func (e *EmbyService) libraryAsView(l *model.Library) map[string]any {
collectionType = "tvshows"
case "anime":
collectionType = "tvshows" // Emby 没有专门的 anime CollectionType
case "variety":
collectionType = "tvshows"
case "music":
collectionType = "music"
}
@@ -286,6 +288,10 @@ func (e *EmbyService) Items(ctx context.Context, p ItemsParams) (map[string]any,
return map[string]any{"Items": items, "TotalRecordCount": len(items), "StartIndex": 0}, nil
}
if p.ParentID == "" && p.SearchTerm == "" && !p.Recursive && len(p.IncludeItemTypes) == 0 {
return e.Views(ctx)
}
if season, ok, err := e.findSeasonGroup(ctx, p.ParentID); err != nil {
return nil, err
} else if ok {
+27
View File
@@ -100,6 +100,33 @@ func TestEmbyItemsExposeSeriesSeasonEpisodeHierarchy(t *testing.T) {
}
}
func TestEmbyRootItemsExposeLibraries(t *testing.T) {
svc := newTestEmbyService(t)
for _, lib := range []model.Library{
{Name: "电影", Path: `F:\downloads\电影`, Type: "movie", Enabled: true},
{Name: "综艺", Path: `F:\downloads\综艺`, Type: "variety", Enabled: true},
} {
if err := svc.repo.Library.Create(t.Context(), &lib); err != nil {
t.Fatalf("create library: %v", err)
}
}
root, err := svc.Items(t.Context(), ItemsParams{Limit: 50})
if err != nil {
t.Fatalf("root items: %v", err)
}
items := root["Items"].([]map[string]any)
if len(items) != 2 {
t.Fatalf("expected root items to expose libraries, got %#v", items)
}
if items[0]["Type"] != "CollectionFolder" || items[1]["Type"] != "CollectionFolder" {
t.Fatalf("root should return collection folders: %#v", items)
}
if items[1]["CollectionType"] != "tvshows" {
t.Fatalf("variety libraries should use tvshows collection type: %#v", items[1])
}
}
func newTestEmbyService(t *testing.T) *EmbyService {
t.Helper()
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
+79 -14
View File
@@ -320,19 +320,27 @@ func mergeArtworkMetadata(meta *LocalMetadata, mediaPath, showBaseDir string) {
return
}
mediaDir := filepath.Dir(mediaPath)
if meta.PosterURL == "" {
meta.PosterURL = firstExistingImage(mediaDir, localPosterCandidates(mediaPath)...)
if localPoster := firstExistingImage(mediaDir, localPosterCandidates(mediaPath)...); localPoster != "" {
meta.PosterURL = localPoster
} else if meta.PosterURL == "" {
meta.PosterURL = firstAdultLooseImage(mediaDir, "poster")
}
if meta.BackdropURL == "" {
dirs := []string{mediaDir, showBaseDir}
for _, dir := range dirs {
if dir == "" {
continue
}
if img := firstExistingImage(dir, localBackdropCandidates(mediaPath)...); img != "" {
meta.BackdropURL = img
break
}
dirs := []string{mediaDir, showBaseDir}
for _, dir := range dirs {
if dir == "" {
continue
}
if img := firstExistingImage(dir, localBackdropCandidates(mediaPath)...); img != "" {
meta.BackdropURL = img
break
}
if meta.BackdropURL == "" {
meta.BackdropURL = firstAdultLooseImage(dir, "backdrop")
}
}
if !isLocalPath(meta.PosterURL) {
if dmmPoster := adultDMMPosterFromSampleURL(meta.BackdropURL); dmmPoster != "" {
meta.PosterURL = dmmPoster
}
}
if meta.PosterURL != "" || meta.BackdropURL != "" {
@@ -468,17 +476,38 @@ func adultArtworkNameCandidates(mediaPath, kind string) []string {
}
compact := strings.ReplaceAll(code, "-", "")
bases := []string{code, compact}
bases = append(bases, adultDMMNameCandidates(code)...)
out := make([]string, 0, len(bases)*6)
for _, base := range bases {
if base == "" {
continue
}
if kind == "poster" {
out = append(out, base, base+"-poster", base+".poster", base+"-cover", base+".cover", base+"-thumb", base+".thumb")
out = append(out, base, base+"-poster", base+".poster", base+"-cover", base+".cover", base+"-thumb", base+".thumb", base+"pl", base+"-pl")
} else {
out = append(out, base+"-fanart", base+".fanart", base+"-backdrop", base+".backdrop", base+"-background", base+"-landscape")
out = append(out, base+"-fanart", base+".fanart", base+"-backdrop", base+".backdrop", base+"-background", base+"-landscape", base+"jp", base+"jp-1")
}
}
return out
}
func adultDMMNameCandidates(code string) []string {
parts := adultStandardPattern.FindStringSubmatch(code)
if len(parts) < 3 {
return nil
}
prefix := strings.ToLower(parts[1])
num := strings.TrimLeft(parts[2], "0")
if num == "" {
num = "0"
}
padded := num
for len(padded) < 5 {
padded = "0" + padded
}
return []string{prefix + padded}
}
func firstExistingImage(dir string, names ...string) string {
if dir == "" {
return ""
@@ -494,6 +523,37 @@ func firstExistingImage(dir string, names ...string) string {
return ""
}
func firstAdultLooseImage(dir, kind string) string {
if dir == "" {
return ""
}
matches, _ := filepath.Glob(filepath.Join(dir, "*"))
preferred := []string{}
fallback := []string{}
for _, path := range matches {
ext := strings.ToLower(filepath.Ext(path))
if ext != ".jpg" && ext != ".jpeg" && ext != ".png" && ext != ".webp" {
continue
}
name := strings.ToLower(strings.TrimSuffix(filepath.Base(path), ext))
if kind == "poster" {
if strings.Contains(name, "poster") || strings.Contains(name, "cover") || strings.Contains(name, "thumb") || strings.HasSuffix(name, "pl") {
preferred = append(preferred, path)
}
} else if strings.Contains(name, "fanart") || strings.Contains(name, "backdrop") || strings.Contains(name, "background") || strings.Contains(name, "landscape") || strings.Contains(name, "jp") {
preferred = append(preferred, path)
}
fallback = append(fallback, path)
}
if len(preferred) > 0 {
return filepath.Clean(preferred[0])
}
if kind == "poster" && len(fallback) == 1 {
return filepath.Clean(fallback[0])
}
return ""
}
func fileExists(path string) bool {
info, err := os.Stat(path)
return err == nil && !info.IsDir()
@@ -507,6 +567,11 @@ func isHTTPURL(raw string) bool {
return (u.Scheme == "http" || u.Scheme == "https") && u.Host != ""
}
func isLocalPath(raw string) bool {
raw = strings.TrimSpace(raw)
return raw != "" && !isHTTPURL(raw)
}
func firstText(values ...string) string {
for _, value := range values {
if text := cleanXMLText(value); text != "" {
+63
View File
@@ -253,6 +253,69 @@ func TestReadAdultNFOByCodeForStackedFile(t *testing.T) {
}
}
func TestReadAdultMetadataPrefersLocalDMMPosterOverRemoteNFO(t *testing.T) {
root := t.TempDir()
dir := filepath.Join(root, "IPX-641-C")
if err := os.MkdirAll(dir, 0o755); err != nil {
t.Fatal(err)
}
mediaPath := filepath.Join(dir, "ipx-641-C.mp4")
if err := os.WriteFile(mediaPath, []byte("x"), 0o644); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(nfoPath(mediaPath), []byte(`<movie>
<title>本地标题</title>
<originaltitle>IPX-641</originaltitle>
<thumb>https://www.javbus.com/pics/cover/remote.jpg</thumb>
<fanart>https://pics.dmm.co.jp/digital/video/ipx00641/ipx00641jp-1.jpg</fanart>
</movie>`), 0o644); err != nil {
t.Fatal(err)
}
poster := filepath.Join(dir, "ipx00641pl.jpg")
if err := os.WriteFile(poster, []byte("jpg"), 0o644); err != nil {
t.Fatal(err)
}
got, err := ReadLocalMetadata(mediaPath, root, false)
if err != nil {
t.Fatal(err)
}
if got == nil || got.PosterURL != poster {
t.Fatalf("poster_url = %q, want local %q", got.PosterURL, poster)
}
if got.BackdropURL != "https://pics.dmm.co.jp/digital/video/ipx00641/ipx00641jp-1.jpg" {
t.Fatalf("backdrop should keep remote NFO fallback, got %q", got.BackdropURL)
}
}
func TestReadAdultMetadataDerivesDMMPosterFromRemoteFanart(t *testing.T) {
root := t.TempDir()
dir := filepath.Join(root, "NACR-833")
if err := os.MkdirAll(dir, 0o755); err != nil {
t.Fatal(err)
}
mediaPath := filepath.Join(dir, "NACR-833.mp4")
if err := os.WriteFile(mediaPath, []byte("x"), 0o644); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(nfoPath(mediaPath), []byte(`<movie>
<title>本地标题</title>
<originaltitle>NACR-833</originaltitle>
<thumb>https://www.javbus.com/pics/cover/an5p_b.jpg</thumb>
<fanart>https://pics.dmm.co.jp/digital/video/h_237nacr00833/h_237nacr00833jp-1.jpg</fanart>
</movie>`), 0o644); err != nil {
t.Fatal(err)
}
got, err := ReadLocalMetadata(mediaPath, root, false)
if err != nil {
t.Fatal(err)
}
if got == nil || got.PosterURL != "https://pics.dmm.co.jp/digital/video/h_237nacr00833/h_237nacr00833pl.jpg" {
t.Fatalf("unexpected metadata: %+v", got)
}
}
func TestReadAdultArtworkByCodeWithoutNFO(t *testing.T) {
root := t.TempDir()
mediaPath := filepath.Join(root, "SSIS-001-CD1.mp4")
+1 -1
View File
@@ -98,7 +98,7 @@ func New(cfg *config.Config, log *zap.Logger, repos *repository.Container) *Cont
scanner := NewScannerService(cfg, log, repos, hub, probe, scraper)
watcher := NewWatcherService(log, repos, scanner)
nfo := NewNFOService(log, repos)
ai := NewAIService(cfg, log)
ai := NewAIService(cfg, log, apiConfig)
duplicate := NewDuplicateService(log, repos, hub)
filemanager := NewFileManagerService(cfg, log, repos)
dlna := NewDLNAService(log)
+25
View File
@@ -215,6 +215,17 @@ func (s *SiteService) FindByID(ctx context.Context, id string) (*model.Site, err
// Update applies a partial patch to an existing site.
func (s *SiteService) Update(ctx context.Context, id string, updates map[string]any) error {
if id == "" {
return errors.New("site id required")
}
if raw, ok := updates["url"].(string); ok {
updates["url"] = strings.TrimRight(strings.TrimSpace(raw), "/")
}
for _, key := range []string{"api_key", "cookie", "auth_header"} {
if raw, ok := updates[key].(string); ok && strings.TrimSpace(raw) == "" {
delete(updates, key)
}
}
return s.repo.DB.WithContext(ctx).Model(&model.Site{}).Where("id = ?", id).Updates(updates).Error
}
@@ -264,6 +275,20 @@ func (s *SiteService) TestConnection(ctx context.Context, id string) (bool, stri
}).Error
return true, "连接成功", nil
} else {
if site.Type == "mteam" {
s.log.Warn("site adapter authenticate failed",
zap.String("site", site.Name),
zap.String("type", site.Type),
zap.Error(authErr))
now := time.Now()
_ = s.repo.DB.WithContext(ctx).Model(&model.Site{}).Where("id = ?", id).
Updates(map[string]any{
"login_status": "fail",
"last_error": authErr.Error(),
"last_check_at": &now,
}).Error
return false, authErr.Error(), nil
}
s.log.Warn("site adapter authenticate failed, falling back to generic test",
zap.String("site", site.Name),
zap.String("type", site.Type),
+36 -29
View File
@@ -8,7 +8,6 @@ import (
"io"
"net/http"
"net/url"
"os"
"regexp"
"strconv"
"strings"
@@ -27,11 +26,11 @@ type SiteConfig struct {
Cookie string
APIKey string
AuthHeader string
UserAgent string // 自定义 User-Agent
Timeout time.Duration // 请求超时
Extra map[string]string // JSON 扩展配置
FlareSolverrURL string // FlareSolverr 服务地址(用于浏览器模拟绕过 Cloudflare/WAF)
UseProxy bool // 通过 HTTP(S)_PROXY 环境变量出站
UserAgent string // 自定义 User-Agent
Timeout time.Duration // 请求超时
Extra map[string]string // JSON 扩展配置
FlareSolverrURL string // FlareSolverr 服务地址(用于浏览器模拟绕过 Cloudflare/WAF)
UseProxy bool // 通过 HTTP(S)_PROXY 环境变量出站
}
// SiteSearchResult 站点搜索结果(按站点分组的批量搜索结果)。
@@ -855,22 +854,15 @@ func NewMTeamAdapter() *MTeamAdapter {
}
func (a *MTeamAdapter) Authenticate(ctx context.Context, cfg SiteConfig) error {
if strings.TrimSpace(cfg.APIKey) == "" {
return fmt.Errorf("M-Team 需要填写 API Access Token(控制台 → 实验室 → 存取令牌),不能使用 Cookie 访问开放 API")
}
// 与 ShukeBta/MediaStation 参考实现对齐:
// 用 camelCase 参数(pageNumber / pageSize),同时接受 code 为字符串 "0"
// 或数值 0;兼容 M-Team v3 API 不同版本的返回。
u := cfg.URL + "/api/torrent/search"
payload := `{"pageNumber":1,"pageSize":1,"keyword":"test"}`
payload := `{"pageNumber":1,"pageSize":1,"mode":"all"}`
data, status, err := doRequestJSON(ctx, a.client, "POST", u, cfg, []byte(payload))
// 调试开关:MEDIASTATION_DEBUG_MTEAM=1 时把请求/响应详情写入 stderr。
if os.Getenv("MEDIASTATION_DEBUG_MTEAM") == "1" {
preview := string(data)
if len(preview) > 800 {
preview = preview[:800] + "..."
}
fmt.Fprintf(os.Stderr,
"[DEBUG mteam.Authenticate] url=%s status=%d err=%v body=%s\n",
u, status, err, preview)
}
if err != nil {
return fmt.Errorf("authenticate: %w", err)
}
@@ -891,19 +883,12 @@ func (a *MTeamAdapter) Authenticate(ctx context.Context, cfg SiteConfig) error {
if err := json.Unmarshal(data, &resp); err != nil {
return fmt.Errorf("parse response: %w (body=%s)", err, preview)
}
codeStr := ""
switch v := resp["code"].(type) {
case string:
codeStr = v
case float64:
codeStr = strconv.Itoa(int(v))
}
if codeStr == "0" || codeStr == "200" {
if mteamCodeOK(resp["code"]) {
return nil
}
msg, _ := resp["message"].(string)
if msg == "" {
msg = fmt.Sprintf("code=%s", codeStr)
msg = fmt.Sprintf("code=%s", mteamCodeString(resp["code"]))
}
return fmt.Errorf("authentication failed: %s (body=%s)", msg, preview)
}
@@ -959,8 +944,8 @@ func (a *MTeamAdapter) Browse(ctx context.Context, cfg SiteConfig, category stri
}
func (a *MTeamAdapter) GetDetail(ctx context.Context, cfg SiteConfig, id string) (*TorrentDetail, error) {
u := cfg.URL + "/api/torrent/detail?id=" + id
data, status, err := doRequest(ctx, a.client, "GET", u, cfg, nil)
u := cfg.URL + "/api/torrent/detail?id=" + url.QueryEscape(id)
data, status, err := doRequestJSON(ctx, a.client, "POST", u, cfg, nil)
if err != nil {
return nil, fmt.Errorf("detail: %w", err)
}
@@ -1513,7 +1498,11 @@ func doRequestJSON(ctx context.Context, client *http.Client, method, rawURL stri
req.ContentLength = int64(len(body))
}
resp, err := client.Do(req)
httpClient := client
if cfg.UseProxy {
httpClient = newHTTPClient(cfg, cfg.Timeout)
}
resp, err := httpClient.Do(req)
if err != nil {
return nil, 0, err
}
@@ -1526,6 +1515,24 @@ func doRequestJSON(ctx context.Context, client *http.Client, method, rawURL stri
return data, resp.StatusCode, nil
}
func mteamCodeOK(code any) bool {
codeStr := mteamCodeString(code)
return codeStr == "0" || codeStr == "200"
}
func mteamCodeString(code any) string {
switch v := code.(type) {
case string:
return strings.TrimSpace(v)
case float64:
return strconv.Itoa(int(v))
case int:
return strconv.Itoa(v)
default:
return ""
}
}
// parseSizeString 将带单位的字符串转换为字节数。
func parseSizeString(value string, unit string) int64 {
v, err := strconv.ParseFloat(value, 64)
+77
View File
@@ -0,0 +1,77 @@
package service
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
)
func TestMTeamAuthenticateRequiresAPIKey(t *testing.T) {
adapter := NewMTeamAdapter()
err := adapter.Authenticate(context.Background(), SiteConfig{
URL: "https://api.m-team.cc",
AuthType: "api_key",
})
if err == nil || !strings.Contains(err.Error(), "API Access Token") {
t.Fatalf("Authenticate error = %v, want API Access Token hint", err)
}
}
func TestMTeamAuthenticateUsesOpenAPIKeyHeader(t *testing.T) {
var gotPath string
var gotKey string
var gotPayload map[string]any
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
gotPath = r.URL.Path
gotKey = r.Header.Get("x-api-key")
if err := json.NewDecoder(r.Body).Decode(&gotPayload); err != nil {
t.Fatalf("decode request body: %v", err)
}
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"code":"0","message":"SUCCESS","data":{"total":"0","data":[]}}`))
}))
defer server.Close()
adapter := NewMTeamAdapter()
err := adapter.Authenticate(context.Background(), SiteConfig{
URL: server.URL,
AuthType: "api_key",
APIKey: "token-123",
Timeout: 5 * time.Second,
})
if err != nil {
t.Fatalf("Authenticate returned error: %v", err)
}
if gotPath != "/api/torrent/search" {
t.Fatalf("path = %q, want /api/torrent/search", gotPath)
}
if gotKey != "token-123" {
t.Fatalf("x-api-key = %q, want token-123", gotKey)
}
if gotPayload["mode"] != "all" || gotPayload["keyword"] != nil {
t.Fatalf("payload = %#v, want mode all without keyword probe", gotPayload)
}
}
func TestMTeamAuthenticateReportsAPIMessage(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"code":1,"message":"key無效","data":null}`))
}))
defer server.Close()
adapter := NewMTeamAdapter()
err := adapter.Authenticate(context.Background(), SiteConfig{
URL: server.URL,
AuthType: "api_key",
APIKey: "bad-token",
Timeout: 5 * time.Second,
})
if err == nil || !strings.Contains(err.Error(), "key無效") {
t.Fatalf("Authenticate error = %v, want key invalid message", err)
}
}
+54
View File
@@ -0,0 +1,54 @@
package service
import (
"context"
"testing"
"github.com/glebarez/sqlite"
"go.uber.org/zap"
"gorm.io/gorm"
"github.com/ShukeBta/MediaStationGo/internal/model"
"github.com/ShukeBta/MediaStationGo/internal/repository"
)
func TestSiteUpdateKeepsSecretsWhenPatchIsBlank(t *testing.T) {
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
if err != nil {
t.Fatal(err)
}
if err := db.AutoMigrate(&model.Site{}); err != nil {
t.Fatal(err)
}
svc := NewSiteService(zap.NewNop(), &repository.Container{DB: db}, "")
site := &model.Site{
Name: "M-Team",
Type: "mteam",
URL: "https://api.m-team.cc",
AuthType: "api_key",
APIKey: "token-123",
Enabled: true,
}
if err := svc.Create(context.Background(), site); err != nil {
t.Fatal(err)
}
if err := svc.Update(context.Background(), site.ID, map[string]any{
"url": "https://api.m-team.cc/",
"api_key": "",
"cookie": "",
}); err != nil {
t.Fatal(err)
}
got, err := svc.FindByID(context.Background(), site.ID)
if err != nil {
t.Fatal(err)
}
if got.APIKey != "token-123" {
t.Fatalf("APIKey = %q, want original token", got.APIKey)
}
if got.URL != "https://api.m-team.cc" {
t.Fatalf("URL = %q, want trimmed URL", got.URL)
}
}
+74
View File
@@ -0,0 +1,74 @@
param(
[int]$Port = 18080,
[string]$HostAddress = "0.0.0.0",
[string]$DataDir = "",
[string]$CacheDir = "",
[switch]$SkipBuild,
[switch]$Foreground
)
$ErrorActionPreference = "Stop"
$Root = Resolve-Path (Join-Path $PSScriptRoot "..")
if (-not $DataDir) { $DataDir = Join-Path $Root "data" }
if (-not $CacheDir) { $CacheDir = Join-Path $Root "cache" }
$WebDir = Join-Path $Root "web\dist"
$BinDir = Join-Path $Root "bin"
$Exe = Join-Path $BinDir "mediastation-go.exe"
$PidFile = Join-Path $Root ".mediastation.pid"
$LogDir = Join-Path $Root "logs"
$OutLog = Join-Path $LogDir "mediastation.out.log"
$ErrLog = Join-Path $LogDir "mediastation.err.log"
New-Item -ItemType Directory -Force -Path $DataDir, $CacheDir, $BinDir, $LogDir | Out-Null
if (-not $SkipBuild) {
Write-Host "[1/3] Building frontend"
Push-Location (Join-Path $Root "web")
npm ci
npm run build
Pop-Location
Write-Host "[2/3] Building server"
Push-Location $Root
$env:CGO_ENABLED = "0"
go build -trimpath -ldflags="-s -w" -o $Exe ./cmd/server
Pop-Location
} else {
Write-Host "[1/3] Build skipped"
}
if (Test-Path $PidFile) {
$oldPid = 0
$pidText = Get-Content $PidFile -Raw
if ([int]::TryParse($pidText.Trim(), [ref]$oldPid)) {
$oldProc = Get-Process -Id $oldPid -ErrorAction SilentlyContinue
if ($oldProc) {
Write-Host "[3/3] Stopping old process $oldPid"
Stop-Process -Id $oldPid -Force
Start-Sleep -Seconds 1
}
}
}
$env:MEDIASTATION_APP_HOST = $HostAddress
$env:MEDIASTATION_APP_PORT = "$Port"
$env:MEDIASTATION_APP_DATA_DIR = $DataDir
$env:MEDIASTATION_APP_WEB_DIR = $WebDir
$env:MEDIASTATION_DATABASE_DB_PATH = (Join-Path $DataDir "mediastation.db")
$env:MEDIASTATION_CACHE_CACHE_DIR = $CacheDir
Write-Host "[3/3] Starting MediaStationGo on http://$HostAddress`:$Port"
if ($Foreground) {
& $Exe
} else {
$proc = Start-Process -FilePath $Exe `
-WorkingDirectory $Root `
-WindowStyle Hidden `
-RedirectStandardOutput $OutLog `
-RedirectStandardError $ErrLog `
-PassThru
$proc.Id | Set-Content $PidFile
Start-Sleep -Seconds 2
Invoke-WebRequest -UseBasicParsing -Uri "http://127.0.0.1:$Port/api/health" -TimeoutSec 10 | Out-Null
Write-Host "Started. PID=$($proc.Id), logs=$OutLog / $ErrLog"
}
+78
View File
@@ -0,0 +1,78 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
PORT="${PORT:-18080}"
HOST="${HOST:-0.0.0.0}"
DATA_DIR="${DATA_DIR:-$ROOT_DIR/data}"
CACHE_DIR="${CACHE_DIR:-$ROOT_DIR/cache}"
WEB_DIR="${WEB_DIR:-$ROOT_DIR/web/dist}"
BIN_DIR="${BIN_DIR:-$ROOT_DIR/bin}"
BIN_PATH="${BIN_PATH:-$BIN_DIR/mediastation-go}"
PID_FILE="${PID_FILE:-$ROOT_DIR/.mediastation.pid}"
LOG_FILE="${LOG_FILE:-$ROOT_DIR/logs/mediastation.log}"
SKIP_BUILD="${SKIP_BUILD:-0}"
DETACH="${DETACH:-1}"
usage() {
cat <<EOF
MediaStationGo one-click deploy
Usage:
PORT=18080 DATA_DIR=/opt/mediastation/data ./scripts/deploy.sh
Environment:
PORT HTTP port, default 18080
HOST Listen host, default 0.0.0.0
DATA_DIR Persistent data directory, default ./data
CACHE_DIR Cache directory, default ./cache
WEB_DIR Built frontend directory, default ./web/dist
SKIP_BUILD 1 to skip npm/go build
DETACH 1 to run in background, 0 to run in foreground
EOF
}
if [[ "${1:-}" == "-h" || "${1:-}" == "--help" ]]; then
usage
exit 0
fi
mkdir -p "$DATA_DIR" "$CACHE_DIR" "$BIN_DIR" "$(dirname "$LOG_FILE")"
if [[ "$SKIP_BUILD" != "1" ]]; then
echo "[1/3] Building frontend"
(cd "$ROOT_DIR/web" && npm ci && npm run build)
echo "[2/3] Building server"
(cd "$ROOT_DIR" && CGO_ENABLED=0 go build -trimpath -ldflags="-s -w" -o "$BIN_PATH" ./cmd/server)
else
echo "[1/3] Build skipped"
fi
if [[ -f "$PID_FILE" ]]; then
old_pid="$(cat "$PID_FILE" || true)"
if [[ -n "$old_pid" ]] && kill -0 "$old_pid" 2>/dev/null; then
echo "[3/3] Stopping old process $old_pid"
kill "$old_pid" || true
sleep 1
fi
fi
export MEDIASTATION_APP_HOST="$HOST"
export MEDIASTATION_APP_PORT="$PORT"
export MEDIASTATION_APP_DATA_DIR="$DATA_DIR"
export MEDIASTATION_APP_WEB_DIR="$WEB_DIR"
export MEDIASTATION_DATABASE_DB_PATH="$DATA_DIR/mediastation.db"
export MEDIASTATION_CACHE_CACHE_DIR="$CACHE_DIR"
echo "[3/3] Starting MediaStationGo on http://$HOST:$PORT"
if [[ "$DETACH" == "1" ]]; then
nohup "$BIN_PATH" >>"$LOG_FILE" 2>&1 &
echo $! > "$PID_FILE"
sleep 2
if command -v curl >/dev/null 2>&1; then
curl -fsS "http://127.0.0.1:$PORT/api/health" >/dev/null
fi
echo "Started. PID=$(cat "$PID_FILE"), log=$LOG_FILE"
else
exec "$BIN_PATH"
fi
+33
View File
@@ -0,0 +1,33 @@
param(
[string]$Image = "ghcr.io/shukebta/mediastation-go",
[string]$Tag = "latest",
[string]$Platforms = "linux/amd64,linux/arm64",
[switch]$Load
)
$ErrorActionPreference = "Stop"
$Builder = "mediastation-builder"
docker buildx inspect $Builder *> $null
if ($LASTEXITCODE -ne 0) {
docker buildx create --name $Builder --use | Out-Null
} else {
docker buildx use $Builder | Out-Null
}
$args = @(
"buildx", "build",
"--platform", $Platforms,
"-t", "$Image`:$Tag"
)
if ($Load) {
$args += "--load"
} else {
$args += "--push"
}
$args += "."
Write-Host "Building $Image`:$Tag for $Platforms"
docker @args
+31
View File
@@ -0,0 +1,31 @@
#!/usr/bin/env bash
set -euo pipefail
IMAGE="${IMAGE:-ghcr.io/shukebta/mediastation-go}"
TAG="${TAG:-latest}"
PLATFORMS="${PLATFORMS:-linux/amd64,linux/arm64}"
PUSH="${PUSH:-1}"
BUILDER="${BUILDER:-mediastation-builder}"
if ! docker buildx inspect "$BUILDER" >/dev/null 2>&1; then
docker buildx create --name "$BUILDER" --use >/dev/null
else
docker buildx use "$BUILDER" >/dev/null
fi
args=(
buildx build
--platform "$PLATFORMS"
-t "$IMAGE:$TAG"
)
if [[ "$PUSH" == "1" ]]; then
args+=(--push)
else
args+=(--load)
fi
args+=(.)
echo "Building $IMAGE:$TAG for $PLATFORMS"
docker "${args[@]}"
+56 -119
View File
@@ -1,10 +1,8 @@
import { useEffect, useMemo, useState } from 'react'
import { useEffect, useMemo, useState } from 'react'
import { Link } from 'react-router-dom'
import { motion } from 'framer-motion'
import {
ArrowRight, Play, Clock, Film, Sparkles,
Library as LibraryIcon, Tv, Music, PlayCircle, ChevronRight
} from 'lucide-react'
import { ArrowRight, Clock, Film, Play, Sparkles } from 'lucide-react'
import { libraryAPI, mediaAPI } from '../api/library'
import { playbackAPI, type HistoryItem } from '../api/playback'
import { imageURL } from '../api/client'
@@ -12,17 +10,11 @@ import { MediaCard } from '../components/MediaCard'
import type { Library, Media } from '../types'
import { groupSeries } from '../utils/groupSeries'
type LibraryRow = { library: Library; cards: ReturnType<typeof groupSeries> }
const TYPE_ICONS: Record<string, React.ReactNode> = {
movie: <Film size={18} />, tv: <Tv size={18} />, variety: <Tv size={18} />, anime: <PlayCircle size={18} />, music: <Music size={18} />,
}
const TYPE_LABELS: Record<string, string> = { movie: '电影', tv: '电视剧', variety: '综艺', anime: '动漫', music: '音乐' }
const hasArtwork = (media?: Media | null) => !!(media?.poster_url || media?.backdrop_url)
export function HomePage() {
const [libraries, setLibraries] = useState<Library[]>([])
const [rows, setRows] = useState<LibraryRow[]>([])
const [recent, setRecent] = useState<Media[]>([])
const [history, setHistory] = useState<HistoryItem[]>([])
const [loading, setLoading] = useState(true)
@@ -31,48 +23,36 @@ export function HomePage() {
async function load() {
setLoading(true)
try {
const [libs, hist] = await Promise.all([
const [libs, recentItems, hist] = await Promise.all([
libraryAPI.list().catch(() => [] as Library[]),
mediaAPI.search('', 120).then((d) => d.items).catch(() => [] as Media[]),
playbackAPI.recentHistory().catch(() => [] as HistoryItem[]),
])
if (cancelled) return
setLibraries(libs)
setRecent(recentItems)
setHistory(hist.filter((h) => !h.completed && !!h.media))
const perLib = await Promise.all(libs.map(async (lib) => {
try {
const page = await libraryAPI.listMedia(lib.id, 1, 60)
return { library: lib, cards: groupSeries(page.items).slice(0, 14) } as LibraryRow
} catch { return { library: lib, cards: [] } as LibraryRow }
}))
if (cancelled) return
setRows(perLib)
} finally { if (!cancelled) setLoading(false) }
} finally {
if (!cancelled) setLoading(false)
}
}
load()
return () => { cancelled = true }
}, [])
const [fallback, setFallback] = useState<Media[]>([])
useEffect(() => {
if (loading || libraries.length > 0) return
mediaAPI.search('', 60).then((d) => setFallback(d.items)).catch(() => undefined)
}, [loading, libraries.length])
const fallbackCards = useMemo(() => groupSeries(fallback).slice(0, 16), [fallback])
const recentCards = useMemo(() => groupSeries(recent).slice(0, 24), [recent])
const featuredItem = useMemo(() => {
const candidates = [
...(history.map((h) => h.media).filter(Boolean) as Media[]),
...rows.flatMap((row) => row.cards.map((card) => card.rep)),
...fallbackCards.map((card) => card.rep),
...fallback,
...recentCards.map((card) => card.rep),
...recent,
]
return candidates.find(hasArtwork) ?? candidates[0] ?? null
}, [history, rows, fallbackCards, fallback])
}, [history, recentCards, recent])
const featuredVisual = featuredItem?.backdrop_url || featuredItem?.poster_url || ''
const featuredPoster = featuredItem?.poster_url || featuredItem?.backdrop_url || ''
const featuredMark = (featuredItem?.title || 'MS').trim().slice(0, 4).toUpperCase()
const empty = !loading && history.length === 0 && rows.every((r) => r.cards.length === 0) && fallbackCards.length === 0
const empty = !loading && libraries.length === 0 && recentCards.length === 0 && history.length === 0
if (loading) {
return (
@@ -82,7 +62,7 @@ export function HomePage() {
<div className="h-10 w-10 rounded-full border-2 border-gray-100 border-t-gray-900 animate-spin" />
<Film className="absolute h-4 w-4 text-brand-500" />
</div>
<span className="text-sm font-semibold tracking-widest text-gray-500 uppercase">站点舱准备中…</span>
<span className="text-sm font-semibold tracking-widest text-gray-500 uppercase">首页内容准备中…</span>
</motion.div>
</div>
)
@@ -96,7 +76,7 @@ export function HomePage() {
</div>
<p className="text-xl font-bold text-gray-900">您的家庭影视站暂无内容</p>
<p className="mt-2 text-sm text-gray-500 leading-relaxed">
立即前往管理后台配置您的第一个媒体库,添加影视目录并触发后台刮削扫描任务。
前往管理后台添加媒体目录,扫描后首页将展示本周力荐、继续观看和最近入库。
</p>
<Link to="/admin" className="mt-8 btn-primary">
前往管理后台
@@ -107,10 +87,8 @@ export function HomePage() {
return (
<div className="space-y-12">
{/* ─── Premium Swiss-Editorial Billboard Hero ─── */}
{featuredItem && (
<section className="relative overflow-hidden rounded-[2rem] bg-white border border-gray-200/90 shadow-[0_24px_80px_rgba(15,23,42,0.08)]">
{/* Background Artwork */}
<div className="absolute inset-0 z-0">
<div className="h-full w-full bg-[radial-gradient(circle_at_80%_20%,rgba(212,175,55,0.22),transparent_34%),linear-gradient(135deg,#fff7ed,#f8fafc_52%,#eef2ff)]" />
{featuredVisual && (
@@ -123,11 +101,9 @@ export function HomePage() {
/>
)}
<div className="absolute inset-0 bg-[linear-gradient(90deg,#ffffff_0%,rgba(255,255,255,0.96)_37%,rgba(255,255,255,0.62)_68%,rgba(255,255,255,0.2)_100%)]" />
<div className="absolute inset-0 bg-[radial-gradient(circle_at_82%_50%,rgba(201,149,74,0.24),transparent_32%)]" />
<div className="absolute inset-x-0 bottom-0 h-32 bg-gradient-to-t from-white to-transparent" />
</div>
{/* Billboard Content */}
<div className="relative z-10 grid gap-8 px-6 py-8 sm:px-8 md:grid-cols-[minmax(0,1fr)_280px] md:px-12 md:py-12 lg:grid-cols-[minmax(0,1fr)_340px] lg:px-14 lg:py-14">
<div className="flex min-w-0 flex-col justify-center space-y-5">
<div className="inline-flex w-fit items-center gap-2 rounded-full bg-white/82 px-3.5 py-1.5 text-xs font-bold uppercase tracking-widest text-[#a8732d] border border-[#ead6b6] shadow-sm backdrop-blur">
@@ -145,17 +121,10 @@ export function HomePage() {
</h1>
</div>
{featuredItem.overview ? (
<p className="max-w-2xl text-gray-600 text-sm sm:text-base leading-relaxed line-clamp-3 font-semibold">
{featuredItem.overview}
</p>
) : (
<p className="max-w-2xl text-gray-500 text-sm italic">
家庭私人媒体中心收藏。极高视听品质,支持多端原生无损解码及HLS转码播放。
</p>
)}
<p className="max-w-2xl text-gray-600 text-sm sm:text-base leading-relaxed line-clamp-3 font-semibold">
{featuredItem.overview || '家庭私人媒体中心收藏。支持多端播放、外部播放器、智能刮削与订阅下载。'}
</p>
{/* Metadata Badges */}
<div className="flex flex-wrap items-center gap-3 text-xs text-gray-500 font-bold">
{featuredItem.year > 0 && (
<span className="bg-white/85 px-2.5 py-1 rounded-xl text-gray-900 border border-gray-200 shadow-sm">{featuredItem.year} 年</span>
@@ -172,7 +141,6 @@ export function HomePage() {
)}
</div>
{/* Buttons */}
<div className="flex flex-wrap items-center gap-4 pt-2">
<Link to={`/media/${featuredItem.id}`} className="inline-flex items-center justify-center gap-2 rounded-xl bg-[#111827] px-6 py-3.5 text-sm font-bold text-white shadow-lg shadow-gray-900/15 hover:bg-[#1f2937] hover:-translate-y-0.5 transition-all">
<Play size={16} fill="currentColor" />
@@ -187,7 +155,6 @@ export function HomePage() {
<div className="relative order-first mx-auto flex w-full max-w-[220px] items-center md:order-none md:max-w-[260px] lg:max-w-[310px]">
<div className="absolute -right-6 top-5 h-32 w-32 rounded-full bg-[#d4af37]/20 blur-3xl" />
<div className="absolute -left-5 bottom-8 h-28 w-28 rounded-full bg-brand-200/30 blur-3xl" />
<div className="relative aspect-[2/3] w-full overflow-hidden rounded-[1.7rem] border border-white/70 bg-white p-2 shadow-[0_32px_80px_rgba(15,23,42,0.20)]">
<div className="flex h-full w-full flex-col items-center justify-center rounded-[1.25rem] bg-[linear-gradient(135deg,#f9fafb,#fff7ed)] text-center">
<Film className="mb-4 h-12 w-12 text-[#c9954a]" />
@@ -202,17 +169,12 @@ export function HomePage() {
onError={(event) => { event.currentTarget.style.display = 'none' }}
/>
)}
<div className="absolute inset-x-4 bottom-4 rounded-2xl bg-white/88 px-4 py-3 shadow-lg backdrop-blur">
<p className="truncate text-xs font-black tracking-[0.22em] text-[#a8732d]">MEDIASTATION PICK</p>
<p className="truncate text-sm font-extrabold text-gray-950">{featuredItem.title}</p>
</div>
</div>
</div>
</div>
</section>
)}
{/* ─── Continue Watching Section ─── */}
{history.length > 0 && (
<section className="space-y-5">
<div className="flex items-center gap-2.5">
@@ -225,69 +187,44 @@ export function HomePage() {
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 md:grid-cols-3 xl:grid-cols-4">
{history.slice(0, 8).map((h) => {
const m = h.media!
const pct = h.duration_ms > 0 ? h.position_ms / h.duration_ms : 0
return <ContinueCard key={h.id} media={m} progress={pct} />
const media = h.media!
const progress = h.duration_ms > 0 ? h.position_ms / h.duration_ms : 0
return <ContinueCard key={h.id} media={media} progress={progress} />
})}
</div>
</section>
)}
{/* ─── Media Libraries Grid Sections ─── */}
<div className="space-y-12">
{rows.filter((r) => r.cards.length > 0).map((row) => (
<section key={row.library.id} className="space-y-5">
<div className="flex items-center justify-between border-b border-gray-200/80 pb-3">
<div className="flex items-center gap-2.5">
<span className="p-1.5 rounded-xl bg-gray-100 text-gray-900 border border-gray-200/50">
{TYPE_ICONS[row.library.type] ?? <LibraryIcon size={18} />}
</span>
<h2 className="font-display text-xl font-extrabold tracking-tight text-gray-900">
{row.library.name}
</h2>
<span className="rounded-full bg-gray-100 border border-gray-200/80 px-2 py-0.5 text-[10px] font-bold text-gray-500 uppercase tracking-wider">
{TYPE_LABELS[row.library.type] ?? row.library.type}
</span>
</div>
<Link to={`/library/${row.library.id}`} className="group inline-flex items-center gap-1 text-xs font-bold text-gray-600 hover:text-brand-600 transition-colors">
<span>浏览全部</span>
<ChevronRight size={14} className="transition-transform group-hover:translate-x-0.5" />
</Link>
</div>
<div className="grid grid-cols-2 gap-5 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-6 2xl:grid-cols-7">
{row.cards.map((s) => (
<MediaCard
key={s.rep.id}
media={s.rep}
count={s.count}
linkTo={s.count > 1 ? `/library/${row.library.id}?series=${encodeURIComponent(s.key)}` : undefined}
/>
))}
</div>
</section>
))}
{/* ─── Fallback Recents Section ─── */}
{libraries.length === 0 && fallbackCards.length > 0 && (
<section className="space-y-5">
<div className="flex items-center justify-between border-b border-gray-200/80 pb-3">
<div className="flex items-center gap-2.5">
<span className="p-1.5 rounded-xl bg-gray-100 text-gray-900 border border-gray-200/50">
<Clock size={18} />
</span>
<h2 className="font-display text-xl font-extrabold tracking-tight text-gray-900">最近添加</h2>
{recentCards.length > 0 && (
<section className="space-y-5">
<div className="flex items-center justify-between border-b border-gray-200/80 pb-3">
<div className="flex items-center gap-2.5">
<span className="p-1.5 rounded-xl bg-gray-100 text-gray-900 border border-gray-200/50">
<Clock size={18} />
</span>
<div>
<h2 className="font-display text-xl font-extrabold tracking-tight text-gray-900">最近入库</h2>
<p className="text-xs text-gray-500">按整部电影、剧集、番剧和综艺合集展示新增内容。</p>
</div>
</div>
<Link to="/poster-wall" className="group inline-flex items-center gap-1 text-xs font-bold text-gray-600 hover:text-brand-600 transition-colors">
<span>海报墙</span>
<ArrowRight size={14} className="transition-transform group-hover:translate-x-0.5" />
</Link>
</div>
<div className="grid grid-cols-2 gap-5 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-6 2xl:grid-cols-7">
{fallbackCards.map((s) => (
<MediaCard key={s.rep.id} media={s.rep} count={s.count} />
))}
</div>
</section>
)}
</div>
<div className="grid grid-cols-2 gap-5 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-6 2xl:grid-cols-7">
{recentCards.map((card) => (
<MediaCard
key={card.key}
media={card.rep}
count={card.count}
linkTo={card.count > 1 ? `/library/${card.rep.library_id}?series=${encodeURIComponent(card.key)}` : undefined}
/>
))}
</div>
</section>
)}
</div>
)
}
@@ -297,12 +234,12 @@ function ContinueCard({ media, progress }: { media: Media; progress: number }) {
<Link to={`/media/${media.id}`} className="group flex items-center gap-4 rounded-2xl bg-white p-3.5 border border-gray-200/80 shadow-[0_1px_3px_rgba(0,0,0,0.01)] transition-all duration-300 hover:shadow-md hover:border-brand-500/30">
<div className="relative h-18 w-12 shrink-0 overflow-hidden rounded-xl bg-gray-50 border border-gray-100">
{media.poster_url ? (
<img
src={imageURL(media.poster_url)}
alt=""
loading="lazy"
className="h-full w-full object-cover transition-transform duration-500 group-hover:scale-105"
referrerPolicy="no-referrer"
<img
src={imageURL(media.poster_url)}
alt=""
loading="lazy"
className="h-full w-full object-cover transition-transform duration-500 group-hover:scale-105"
referrerPolicy="no-referrer"
/>
) : (
<div className="flex h-full items-center justify-center text-gray-500 bg-gray-50">
+201 -38
View File
@@ -1,12 +1,19 @@
import { useEffect, useState } from 'react'
import { useEffect, useMemo, useState, type ReactNode } from 'react'
import { Link } from 'react-router-dom'
import { motion } from 'framer-motion'
import { Film, Library as LibraryIcon, Music, PlayCircle, Tv } from 'lucide-react'
import { ArrowRight, Film, Library as LibraryIcon, Music, PlayCircle, Tv } from 'lucide-react'
import { imageURL } from '../api/client'
import { libraryAPI } from '../api/library'
import type { Library } from '../types'
import type { Library, Media } from '../types'
const TYPE_ICONS: Record<string, React.ReactNode> = {
type LibraryPreview = {
library: Library
items: Media[]
total: number
}
const TYPE_ICONS: Record<string, ReactNode> = {
movie: <Film size={20} />,
tv: <Tv size={20} />,
anime: <PlayCircle size={20} />,
@@ -22,60 +29,216 @@ const TYPE_LABELS: Record<string, string> = {
music: '音乐',
}
const TYPE_DESCRIPTIONS: Record<string, string> = {
movie: '电影、动画电影、纪录片电影',
tv: '电视剧、短剧、纪录片剧集',
anime: '番剧、动画、OVA 与剧场版',
variety: '综艺、真人秀、晚会与节目合集',
music: '音乐、MV、演唱会与音频内容',
}
export function LibrariesPage() {
const [libraries, setLibraries] = useState<Library[]>([])
const [previews, setPreviews] = useState<LibraryPreview[]>([])
const [loading, setLoading] = useState(true)
const [activeType, setActiveType] = useState('all')
useEffect(() => {
libraryAPI.list().then(setLibraries).finally(() => setLoading(false))
let cancelled = false
async function load() {
setLoading(true)
try {
const libs = await libraryAPI.list()
const rows = await Promise.all(libs.map(async (library) => {
try {
const page = await libraryAPI.listMedia(library.id, 1, 16)
return { library, items: page.items, total: page.total } satisfies LibraryPreview
} catch {
return { library, items: [], total: 0 } satisfies LibraryPreview
}
}))
if (!cancelled) setPreviews(rows)
} finally {
if (!cancelled) setLoading(false)
}
}
load()
return () => { cancelled = true }
}, [])
const categories = useMemo(() => {
const map = new Map<string, { type: string; libraries: number; total: number }>()
for (const preview of previews) {
const type = preview.library.type || 'other'
const current = map.get(type) ?? { type, libraries: 0, total: 0 }
current.libraries += 1
current.total += preview.total
map.set(type, current)
}
return Array.from(map.values()).sort((a, b) => b.total - a.total)
}, [previews])
const filtered = activeType === 'all'
? previews
: previews.filter((preview) => preview.library.type === activeType)
if (loading) {
return <p className="px-2 py-8 text-sm text-sand-500">媒体库加载中…</p>
}
return (
<div className="space-y-6">
<div>
<h1 className="font-display text-3xl font-bold text-ink-600">媒体库</h1>
<p className="mt-1 text-sm text-ink-50">所有电影、剧集、番剧、综艺分类统一在这里查看。</p>
<div className="space-y-8">
<div className="flex flex-wrap items-end justify-between gap-4">
<div>
<h1 className="font-display text-3xl font-bold text-ink-600">媒体库</h1>
<p className="mt-1 text-sm text-ink-50">按影视分类浏览所有目录;每个文件夹会自动使用库内海报生成封面。</p>
</div>
<Link to="/admin" className="btn-outline">
管理媒体库
<ArrowRight size={14} />
</Link>
</div>
{libraries.length === 0 ? (
{previews.length === 0 ? (
<div className="flex flex-col items-center justify-center rounded-3xl border border-dashed border-sand-200 bg-white py-24 text-center">
<LibraryIcon className="mb-4 h-12 w-12 text-gray-400" />
<p className="text-sm text-ink-50">暂无媒体库,请到管理后台添加目录。</p>
</div>
) : (
<div className="grid gap-4 sm:grid-cols-2 xl:grid-cols-3">
{libraries.map((lib, index) => (
<motion.div
key={lib.id}
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: index * 0.03 }}
>
<Link
to={`/library/${lib.id}`}
className="group block rounded-3xl border border-sand-200 bg-white p-5 shadow-card transition-all hover:-translate-y-0.5 hover:border-brand-200 hover:shadow-card-hover"
<>
<section className="grid gap-3 sm:grid-cols-2 xl:grid-cols-5">
<CategoryCard
active={activeType === 'all'}
icon={<LibraryIcon size={20} />}
label="全部媒体"
description="电影、剧集、番剧、综艺统一视图"
total={previews.reduce((sum, preview) => sum + preview.total, 0)}
libraries={previews.length}
onClick={() => setActiveType('all')}
/>
{categories.map((category) => (
<CategoryCard
key={category.type}
active={activeType === category.type}
icon={TYPE_ICONS[category.type] ?? <LibraryIcon size={20} />}
label={TYPE_LABELS[category.type] ?? category.type}
description={TYPE_DESCRIPTIONS[category.type] ?? '自定义媒体分类'}
total={category.total}
libraries={category.libraries}
onClick={() => setActiveType(category.type)}
/>
))}
</section>
<section className="grid gap-5 sm:grid-cols-2 xl:grid-cols-3">
{filtered.map((preview, index) => (
<motion.div
key={preview.library.id}
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: index * 0.03 }}
>
<div className="mb-6 flex items-start justify-between gap-4">
<div className="flex h-12 w-12 items-center justify-center rounded-2xl bg-brand-50 text-brand-600">
{TYPE_ICONS[lib.type] ?? <LibraryIcon size={20} />}
</div>
<span className="rounded-full bg-sand-100 px-3 py-1 text-xs font-semibold text-sand-600">
{TYPE_LABELS[lib.type] ?? lib.type}
</span>
</div>
<h2 className="font-display text-xl font-bold text-ink-600 group-hover:text-brand-600">
{lib.name}
</h2>
<p className="mt-2 line-clamp-2 break-all text-xs text-ink-50">{lib.path}</p>
</Link>
</motion.div>
))}
</div>
<LibraryCard preview={preview} />
</motion.div>
))}
</section>
</>
)}
</div>
)
}
function CategoryCard({
active,
icon,
label,
description,
total,
libraries,
onClick,
}: {
active: boolean
icon: ReactNode
label: string
description: string
total: number
libraries: number
onClick: () => void
}) {
return (
<button
type="button"
onClick={onClick}
className={
'rounded-3xl border p-4 text-left transition-all ' +
(active
? 'border-brand-300 bg-brand-50 shadow-card'
: 'border-sand-200 bg-white hover:-translate-y-0.5 hover:border-brand-200 hover:shadow-card')
}
>
<div className="mb-4 flex items-center justify-between">
<span className="flex h-11 w-11 items-center justify-center rounded-2xl bg-white text-brand-600 shadow-sm">
{icon}
</span>
<span className="rounded-full bg-white px-2.5 py-1 text-[11px] font-bold text-sand-600">
{libraries} 库
</span>
</div>
<h2 className="font-display text-lg font-bold text-ink-600">{label}</h2>
<p className="mt-1 line-clamp-2 text-xs text-ink-50">{description}</p>
<p className="mt-4 text-xs font-bold text-brand-600">{total.toLocaleString()} 个条目</p>
</button>
)
}
function LibraryCard({ preview }: { preview: LibraryPreview }) {
const library = preview.library
const artwork = preview.items
.map((item) => item.poster_url || item.backdrop_url)
.filter(Boolean)
.slice(0, 6) as string[]
return (
<Link
to={`/library/${library.id}`}
className="group block overflow-hidden rounded-[1.7rem] border border-sand-200 bg-white shadow-card transition-all hover:-translate-y-0.5 hover:border-brand-200 hover:shadow-card-hover"
>
<div className="relative aspect-[16/9] overflow-hidden bg-[linear-gradient(135deg,#fff7ed,#f8fafc)]">
{artwork.length > 0 ? (
<div className="grid h-full grid-cols-3 gap-1 p-1.5">
{artwork.map((src, index) => (
<img
key={`${src}-${index}`}
src={imageURL(src)}
alt=""
loading="lazy"
referrerPolicy="no-referrer"
className={`h-full w-full rounded-xl object-cover ${index === 0 ? 'col-span-2 row-span-2' : ''}`}
onError={(event) => { event.currentTarget.style.visibility = 'hidden' }}
/>
))}
</div>
) : (
<div className="flex h-full items-center justify-center text-brand-500">
{TYPE_ICONS[library.type] ?? <LibraryIcon size={42} />}
</div>
)}
<div className="absolute inset-0 bg-gradient-to-t from-ink-600/70 via-ink-600/10 to-transparent" />
<div className="absolute left-4 top-4 rounded-full bg-white/90 px-3 py-1 text-xs font-bold text-ink-600 shadow-sm backdrop-blur">
{TYPE_LABELS[library.type] ?? library.type}
</div>
<div className="absolute bottom-4 left-4 right-4">
<h2 className="font-display text-2xl font-black text-white drop-shadow-sm group-hover:text-brand-100">
{library.name}
</h2>
<p className="mt-1 text-xs font-semibold text-white/85">{preview.total.toLocaleString()} 个媒体条目</p>
</div>
</div>
<div className="flex items-center justify-between gap-3 p-5">
<p className="line-clamp-1 break-all text-xs text-ink-50">{library.path}</p>
<span className="shrink-0 rounded-xl bg-sand-100 px-3 py-1 text-xs font-bold text-sand-600 transition-colors group-hover:bg-brand-100 group-hover:text-brand-700">
进入
</span>
</div>
</Link>
)
}
+8 -7
View File
@@ -143,9 +143,6 @@ export function SitesPage() {
url: form.url.trim(),
type: form.type,
auth_type: form.auth_type,
cookie: form.cookie || '',
api_key: form.api_key || '',
auth_header: form.auth_header || '',
enabled: form.enabled,
is_default: form.is_default,
extra: form.extra || '',
@@ -158,6 +155,9 @@ export function SitesPage() {
browser_emulation: !!form.browser_emulation,
downloader: form.downloader || '',
}
if (!editingId || form.cookie.trim()) payload.cookie = form.cookie.trim()
if (!editingId || form.api_key.trim()) payload.api_key = form.api_key.trim()
if (!editingId || form.auth_header.trim()) payload.auth_header = form.auth_header.trim()
try {
if (editingId) {
await sitesAPI.update(editingId, payload)
@@ -465,8 +465,9 @@ export function SitesPage() {
<div className="p-3 rounded-xl border border-green-500/30 bg-green-500/5">
<div className="text-sm font-medium text-green-400 mb-1">馒头站点配置指南</div>
<div className="text-xs text-ink-50 space-y-1">
<div><b>站点地址:</b><code className="text-green-300">https://api2.m-team.cc</code></div>
<div><b>认证方式:</b>推荐使用「API Key / Passkey」</div>
<div><b>正式站地址:</b><code className="text-green-300">https://api.m-team.cc</code></div>
<div><b>测试站地址:</b><code className="text-green-300">https://test2.m-team.cc</code>(开发测试环境)</div>
<div><b>认证方式:</b>必须使用「API Access Token」,不要使用 Cookie</div>
<div className="pl-3 text-sand-500">
1. 登录馒头站 → 控制台 → 实验室 → 存取令牌<br />
2. 点击「创建令牌」,复制生成的 Token<br />
@@ -520,13 +521,13 @@ export function SitesPage() {
<input
type="password"
className="input-base w-full font-mono text-sm"
placeholder="输入 API Key 或 Passkey"
placeholder={editingId ? '留空则保留原令牌,输入新值则替换' : '输入 API Access Token'}
value={form.api_key}
onChange={(e) => setForm((f) => ({ ...f, api_key: e.target.value }))}
/>
<p className="text-xs text-sand-500 mt-1">
{form.type === 'mteam'
? '馒头:控制台 → 实验室 → 存取令牌'
? '馒头:控制台 → 实验室 → 存取令牌;第三方工具通过 x-api-key 请求头访问'
: '站点的访问 API Key'}
</p>
</div>