mirror of
https://github.com/truewhile/MeBox.git
synced 2026-09-28 11:16:37 +08:00
Update deployment docs and media library UI
This commit is contained in:
@@ -8,8 +8,14 @@ docs/
|
|||||||
data/
|
data/
|
||||||
cache/
|
cache/
|
||||||
logs/
|
logs/
|
||||||
|
.tmp_*
|
||||||
|
.tmp-deploy-*
|
||||||
|
.tmp-deploy-data/
|
||||||
|
.mediastation.pid
|
||||||
*.db
|
*.db
|
||||||
*.db-journal
|
*.db-journal
|
||||||
|
*.db-shm
|
||||||
|
*.db-wal
|
||||||
*.log
|
*.log
|
||||||
.env
|
.env
|
||||||
.env.*
|
.env.*
|
||||||
|
|||||||
@@ -25,6 +25,9 @@ web/tsconfig.tsbuildinfo
|
|||||||
data/
|
data/
|
||||||
cache/
|
cache/
|
||||||
logs/
|
logs/
|
||||||
|
.tmp-deploy-data/
|
||||||
|
.tmp-deploy-server.*
|
||||||
|
.mediastation.pid
|
||||||
*.log
|
*.log
|
||||||
*.db
|
*.db
|
||||||
*.db-journal
|
*.db-journal
|
||||||
|
|||||||
@@ -1 +0,0 @@
|
|||||||
39f30642a462624f2aa03896465943891e2d28db9f9a03aca94fd9af58d0c108
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
29292
|
|
||||||
@@ -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
|
all: build
|
||||||
@@ -26,6 +26,9 @@ dev-web:
|
|||||||
run: build
|
run: build
|
||||||
./bin/mediastation-go
|
./bin/mediastation-go
|
||||||
|
|
||||||
|
deploy:
|
||||||
|
./scripts/deploy.sh
|
||||||
|
|
||||||
# ---- Docker ----
|
# ---- Docker ----
|
||||||
docker:
|
docker:
|
||||||
docker compose up --build -d
|
docker compose up --build -d
|
||||||
@@ -33,6 +36,9 @@ docker:
|
|||||||
docker-stop:
|
docker-stop:
|
||||||
docker compose down
|
docker compose down
|
||||||
|
|
||||||
|
docker-push:
|
||||||
|
./scripts/docker-build-push.sh
|
||||||
|
|
||||||
# ---- 工具命令 ----
|
# ---- 工具命令 ----
|
||||||
install-web:
|
install-web:
|
||||||
cd web && npm install
|
cd web && npm install
|
||||||
|
|||||||
@@ -1,346 +1,204 @@
|
|||||||
<p align="center">
|
# MediaStationGo
|
||||||
<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>
|
|
||||||
|
|
||||||
<h3 align="center"><samp><a href="https://github.com/ShukeBta/MediaStation">MediaStation</a> 的 Go 语言重写版</samp></h3>
|
> 轻量、漂亮、NAS 友好的私人媒体中心。Go 单二进制后端 + React 前端,覆盖媒体库管理、刮削、播放、订阅下载、Emby API 兼容、AI 搜索与推荐。
|
||||||
<h6 align="center"><samp>轻量 · 快速 · 单二进制部署 · NAS 友好</samp></h6>
|
|
||||||
|
|
||||||
<p align="center">
|
[English](README_EN.md) · [Docker 部署](#docker-部署推荐) · [一键脚本](#一键脚本部署) · [开发构建](#开发与构建)
|
||||||
<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>
|
|
||||||
|
|
||||||
<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>
|
|
||||||
·
|
|
||||||
<b>🚀 <a href="#-快速开始">快速开始</a></b>
|
|
||||||
·
|
|
||||||
<b>✨ <a href="#-功能特性">功能特性</a></b>
|
|
||||||
·
|
|
||||||
<b>🏗️ <a href="#-项目结构">项目结构</a></b>
|
|
||||||
·
|
|
||||||
<b>⚙️ <a href="#-配置说明">配置说明</a></b>
|
|
||||||
·
|
|
||||||
<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?
|
## Docker 部署(推荐)
|
||||||
|
|
||||||
> 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 部署(推荐)
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
git clone https://github.com/ShukeBta/MediaStationGo.git
|
git clone https://github.com/ShukeBta/MediaStationGo.git
|
||||||
cd MediaStationGo
|
cd MediaStationGo
|
||||||
|
cp config.example.yaml config.yaml
|
||||||
# 编辑 docker-compose.yml,将媒体目录挂载到 /media
|
|
||||||
docker compose up -d
|
docker compose up -d
|
||||||
```
|
```
|
||||||
|
|
||||||
> 🌐 服务启动后自动识别 **局域网 IP** 和 **公网 IP**(NAS / 服务器均可),日志示例:
|
默认地址:`http://<服务器IP>:8080`
|
||||||
> ```json
|
|
||||||
> {"msg":"server is ready","local":"http://192.168.1.4:8080","public":"http://1.2.3.4:8080"}
|
|
||||||
> ```
|
|
||||||
> 使用 `admin` / `admin123` 登录。
|
|
||||||
|
|
||||||
### 💻 裸机部署
|
默认账号:`admin` / `admin123`
|
||||||
|
|
||||||
| 前置要求 | 版本 |
|
> 首次登录后请立即修改管理员密码,并在「媒体与用户」中添加媒体目录。
|
||||||
|----------|------|
|
|
||||||
| Go | ≥ 1.25 |
|
### docker-compose 关键挂载
|
||||||
| Node.js | ≥ 20 |
|
|
||||||
| FFmpeg | 任意 |
|
```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
|
```bash
|
||||||
git clone https://github.com/ShukeBta/MediaStationGo.git
|
git clone https://github.com/ShukeBta/MediaStationGo.git
|
||||||
cd MediaStationGo
|
cd MediaStationGo
|
||||||
|
chmod +x scripts/deploy.sh
|
||||||
|
PORT=18080 DATA_DIR=/opt/mediastation/data CACHE_DIR=/opt/mediastation/cache ./scripts/deploy.sh
|
||||||
|
```
|
||||||
|
|
||||||
# 编译后端 + 构建前端
|
### Windows PowerShell
|
||||||
make build
|
|
||||||
|
|
||||||
# 启动服务
|
```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
|
./bin/mediastation-go
|
||||||
```
|
```
|
||||||
|
|
||||||
### 🛠️ 本地开发
|
### 常用命令
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# 终端 1:Go 后端(端口 8080,DEBUG 模式)
|
make build # 构建前后端
|
||||||
make dev
|
make test # Go 测试
|
||||||
|
make docker # docker compose up --build -d
|
||||||
# 终端 2:Vite 前端(端口 3000,API 自动代理)
|
make deploy # Linux 一键部署
|
||||||
make dev-web
|
make docker-push # buildx 多架构推送
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
Windows 可直接使用:
|
||||||
|
|
||||||
## ⚙️ 配置说明
|
```powershell
|
||||||
|
.\scripts\deploy.ps1
|
||||||
配置加载优先级:**默认值** < `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 发布
|
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
## 配置说明
|
||||||
|
|
||||||
## 🗺️ 路线图
|
配置优先级从低到高:
|
||||||
|
|
||||||
| 特性 | 状态 |
|
1. 内置默认值
|
||||||
|------|:---:|
|
2. `config.yaml`
|
||||||
| Jellyfin / Emby 双向兼容层 | 🔨 进行中 |
|
3. `config/*.yaml`
|
||||||
| DLNA / Chromecast 投屏 | 📋 计划中 |
|
4. `MEDIASTATION_` 环境变量
|
||||||
| 在线字幕搜索 | 📋 计划中 |
|
5. 后台数据库配置(API Key、站点、下载器等运行时配置)
|
||||||
| 多码率 ABR 转码 | 📋 计划中 |
|
|
||||||
| STRM 直链播放 (WebDAV / Alist / S3) | ✅ 已完成 |
|
|
||||||
|
|
||||||
---
|
常用环境变量:
|
||||||
|
|
||||||
## 🤝 贡献
|
| 变量 | 默认值 | 说明 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `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">
|
- `data/`、`cache/`、`logs/`
|
||||||
<sub>Made with ❤️ by MediaStationGo Team</sub>
|
- `.tmp-deploy-data/`、`.tmp-deploy-server.*`
|
||||||
</p>
|
- `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
@@ -1,345 +1,204 @@
|
|||||||
<p align="center">
|
# MediaStationGo
|
||||||
<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>
|
|
||||||
|
|
||||||
<h3 align="center"><samp>A Go rewrite of <a href="https://github.com/ShukeBta/MediaStation">MediaStation</a></samp></h3>
|
> 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.
|
||||||
<h6 align="center"><samp>Lightweight · Fast · Single Binary · NAS Ready</samp></h6>
|
|
||||||
|
|
||||||
<p align="center">
|
[中文](README.md) · [Docker Deploy](#docker-deploy-recommended) · [One-Click Scripts](#one-click-deploy-scripts) · [Development](#development-and-build)
|
||||||
<a href="README.md"><img src="https://img.shields.io/badge/中文-README-blue?style=flat-square" alt="Chinese"></a>
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<p align="center">
|
## Highlights
|
||||||
<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>
|
|
||||||
|
|
||||||
---
|
- **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">
|
## Feature Modules
|
||||||
<b>📖 <a href="#-why-mediastationgo">Why</a></b>
|
|
||||||
·
|
|
||||||
<b>🚀 <a href="#-quick-start">Quick Start</a></b>
|
|
||||||
·
|
|
||||||
<b>✨ <a href="#-features">Features</a></b>
|
|
||||||
·
|
|
||||||
<b>🏗️ <a href="#-project-layout">Layout</a></b>
|
|
||||||
·
|
|
||||||
<b>⚙️ <a href="#-configuration">Config</a></b>
|
|
||||||
·
|
|
||||||
<b>🗺️ <a href="#-roadmap">Roadmap</a></b>
|
|
||||||
</p>
|
|
||||||
|
|
||||||
---
|
| 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?
|
## Docker Deploy (Recommended)
|
||||||
|
|
||||||
> 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)
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
git clone https://github.com/ShukeBta/MediaStationGo.git
|
git clone https://github.com/ShukeBta/MediaStationGo.git
|
||||||
cd MediaStationGo
|
cd MediaStationGo
|
||||||
|
cp config.example.yaml config.yaml
|
||||||
# Edit docker-compose.yml to mount your media at /media
|
|
||||||
docker compose up -d
|
docker compose up -d
|
||||||
```
|
```
|
||||||
|
|
||||||
> 🌐 The server auto-detects both your **local network IP** and **public IP** on startup (NAS / VPS supported):
|
Default URL: `http://<server-ip>:8080`
|
||||||
> ```json
|
|
||||||
> {"msg":"server is ready","local":"http://192.168.1.4:8080","public":"http://1.2.3.4:8080"}
|
|
||||||
> ```
|
|
||||||
> Log in with `admin` / `admin123`.
|
|
||||||
|
|
||||||
### 💻 Bare Metal
|
Default account: `admin` / `admin123`
|
||||||
|
|
||||||
| Prerequisite | Version |
|
> Change the administrator password immediately after the first login, then add media folders from the admin UI.
|
||||||
|-------------|---------|
|
|
||||||
| Go | ≥ 1.25 |
|
### Key docker-compose Mounts
|
||||||
| Node.js | ≥ 20 |
|
|
||||||
| FFmpeg | Any |
|
```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
|
```bash
|
||||||
git clone https://github.com/ShukeBta/MediaStationGo.git
|
git clone https://github.com/ShukeBta/MediaStationGo.git
|
||||||
cd MediaStationGo
|
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
|
### Windows PowerShell
|
||||||
make build
|
|
||||||
|
|
||||||
# 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
|
./bin/mediastation-go
|
||||||
```
|
```
|
||||||
|
|
||||||
### 🛠️ Local Development
|
### Common Commands
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Terminal 1: Go backend (port 8080, DEBUG mode)
|
make build # Build frontend and backend
|
||||||
make dev
|
make test # Run Go tests
|
||||||
|
make docker # docker compose up --build -d
|
||||||
# Terminal 2: Vite frontend (port 3000, proxies API calls)
|
make deploy # Linux one-click deploy
|
||||||
make dev-web
|
make docker-push # Multi-arch buildx push
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
Windows users can run:
|
||||||
|
|
||||||
## ⚙️ Configuration
|
```powershell
|
||||||
|
.\scripts\deploy.ps1
|
||||||
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
|
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
## Configuration
|
||||||
|
|
||||||
## 🗺️ Roadmap
|
Configuration precedence, from low to high:
|
||||||
|
|
||||||
| Feature | Status |
|
1. Built-in defaults
|
||||||
|---------|:---:|
|
2. `config.yaml`
|
||||||
| Jellyfin / Emby compatibility layer | 🔨 In Progress |
|
3. `config/*.yaml`
|
||||||
| DLNA / Chromecast casting | 📋 Planned |
|
4. `MEDIASTATION_` environment variables
|
||||||
| Online subtitle search providers | 📋 Planned |
|
5. Runtime settings stored in the database, such as API keys, sites, and download clients
|
||||||
| Multi-bitrate ABR transcode | 📋 Planned |
|
|
||||||
| STRM direct streaming (WebDAV / Alist / S3) | ✅ Complete |
|
|
||||||
|
|
||||||
---
|
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">
|
- `data/`, `cache/`, `logs/`
|
||||||
<sub>Made with ❤️ by MediaStationGo Team</sub>
|
- `.tmp-deploy-data/`, `.tmp-deploy-server.*`, `.mediastation.pid`
|
||||||
</p>
|
- `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.
|
||||||
|
|||||||
@@ -72,10 +72,6 @@ func aiRecommendHandler(svc *service.Container) gin.HandlerFunc {
|
|||||||
|
|
||||||
func aiStatusHandler(svc *service.Container) gin.HandlerFunc {
|
func aiStatusHandler(svc *service.Container) gin.HandlerFunc {
|
||||||
return func(c *gin.Context) {
|
return func(c *gin.Context) {
|
||||||
c.JSON(http.StatusOK, gin.H{
|
c.JSON(http.StatusOK, svc.AI.Status(c.Request.Context()))
|
||||||
"enabled": svc.AI.Enabled(),
|
|
||||||
"provider": svc.Cfg.AI.Provider,
|
|
||||||
"model": svc.Cfg.AI.Model,
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+27
-27
@@ -34,7 +34,7 @@ func NewSiteHTTPClient(timeoutSeconds int, useProxy bool) *http.Client {
|
|||||||
}
|
}
|
||||||
if useProxy {
|
if useProxy {
|
||||||
tr.Proxy = func(r *http.Request) (*url.URL, error) {
|
tr.Proxy = func(r *http.Request) (*url.URL, error) {
|
||||||
return http.ProxyFromEnvironment(r)
|
return ProxyFromEnvironmentOrSystem(r)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return &http.Client{
|
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.
|
// These mimic a real Chrome browser to avoid WAF/bot detection.
|
||||||
func HTTPHeaderPresets() map[string]string {
|
func HTTPHeaderPresets() map[string]string {
|
||||||
return map[string]string{
|
return map[string]string{
|
||||||
"User-Agent": model.DefaultUserAgent,
|
"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": "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-Language": "zh-CN,zh;q=0.9,en;q=0.8",
|
||||||
"Accept-Encoding": "gzip, deflate, br",
|
"Accept-Encoding": "gzip, deflate, br",
|
||||||
"Connection": "keep-alive",
|
"Connection": "keep-alive",
|
||||||
"Upgrade-Insecure-Requests": "1",
|
"Upgrade-Insecure-Requests": "1",
|
||||||
"Sec-Fetch-Dest": "document",
|
"Sec-Fetch-Dest": "document",
|
||||||
"Sec-Fetch-Mode": "navigate",
|
"Sec-Fetch-Mode": "navigate",
|
||||||
"Sec-Fetch-Site": "none",
|
"Sec-Fetch-Site": "none",
|
||||||
"Sec-Fetch-User": "?1",
|
"Sec-Fetch-User": "?1",
|
||||||
"Cache-Control": "max-age=0",
|
"Cache-Control": "max-age=0",
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -65,12 +65,12 @@ func HTTPHeaderPresets() map[string]string {
|
|||||||
|
|
||||||
// FlareSolverrRequest represents a request to FlareSolverr.
|
// FlareSolverrRequest represents a request to FlareSolverr.
|
||||||
type FlareSolverrRequest struct {
|
type FlareSolverrRequest struct {
|
||||||
Cmd string `json:"cmd"`
|
Cmd string `json:"cmd"`
|
||||||
URL string `json:"url"`
|
URL string `json:"url"`
|
||||||
Session string `json:"session,omitempty"`
|
Session string `json:"session,omitempty"`
|
||||||
MaxTimeout int `json:"maxTimeout,omitempty"`
|
MaxTimeout int `json:"maxTimeout,omitempty"`
|
||||||
Proxy *FlareSolverrProxy `json:"proxy,omitempty"`
|
Proxy *FlareSolverrProxy `json:"proxy,omitempty"`
|
||||||
Cookies []FlareSolverrCookie `json:"cookies,omitempty"`
|
Cookies []FlareSolverrCookie `json:"cookies,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// FlareSolverrProxy represents proxy config for FlareSolverr.
|
// FlareSolverrProxy represents proxy config for FlareSolverr.
|
||||||
@@ -90,19 +90,19 @@ type FlareSolverrCookie struct {
|
|||||||
|
|
||||||
// FlareSolverrResponse represents FlareSolverr's response.
|
// FlareSolverrResponse represents FlareSolverr's response.
|
||||||
type FlareSolverrResponse struct {
|
type FlareSolverrResponse struct {
|
||||||
Status string `json:"status"`
|
Status string `json:"status"`
|
||||||
Message string `json:"message"`
|
Message string `json:"message"`
|
||||||
Solution *FlareSolverrSolution `json:"solution,omitempty"`
|
Solution *FlareSolverrSolution `json:"solution,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// FlareSolverrSolution contains the solved challenge result.
|
// FlareSolverrSolution contains the solved challenge result.
|
||||||
type FlareSolverrSolution struct {
|
type FlareSolverrSolution struct {
|
||||||
URL string `json:"url"`
|
URL string `json:"url"`
|
||||||
Status int `json:"status"`
|
Status int `json:"status"`
|
||||||
Headers map[string]string `json:"headers"`
|
Headers map[string]string `json:"headers"`
|
||||||
Cookies []FlareSolverrCookie `json:"cookies"`
|
Cookies []FlareSolverrCookie `json:"cookies"`
|
||||||
UserAgent string `json:"userAgent"`
|
UserAgent string `json:"userAgent"`
|
||||||
Response string `json:"response"`
|
Response string `json:"response"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// FetchURLWithFlareSolverr uses FlareSolverr to fetch a URL,
|
// FetchURLWithFlareSolverr uses FlareSolverr to fetch a URL,
|
||||||
@@ -123,10 +123,10 @@ func FetchURLWithFlareSolverr(flareSolverrURL string, targetURL string, cookieSt
|
|||||||
|
|
||||||
// Build request
|
// Build request
|
||||||
reqBody := FlareSolverrRequest{
|
reqBody := FlareSolverrRequest{
|
||||||
Cmd: "request.get",
|
Cmd: "request.get",
|
||||||
URL: targetURL,
|
URL: targetURL,
|
||||||
MaxTimeout: timeout * 1000,
|
MaxTimeout: timeout * 1000,
|
||||||
Cookies: cookies,
|
Cookies: cookies,
|
||||||
}
|
}
|
||||||
if proxyURL != "" {
|
if proxyURL != "" {
|
||||||
reqBody.Proxy = &FlareSolverrProxy{URL: proxyURL}
|
reqBody.Proxy = &FlareSolverrProxy{URL: proxyURL}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
//go:build !windows
|
||||||
|
|
||||||
|
package helper
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
)
|
||||||
|
|
||||||
|
func systemProxyForRequest(_ *http.Request) (*url.URL, error) {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -37,6 +37,15 @@ var adultExcludedPrefixes = map[string]struct{}{
|
|||||||
"WEB": {}, "X264": {}, "X265": {},
|
"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 {
|
type AdultProvider struct {
|
||||||
log *zap.Logger
|
log *zap.Logger
|
||||||
client *http.Client
|
client *http.Client
|
||||||
@@ -69,7 +78,7 @@ func (p *AdultProvider) Search(ctx context.Context, code string) (*Match, error)
|
|||||||
base = strings.TrimRight(base, "/")
|
base = strings.TrimRight(base, "/")
|
||||||
var match *Match
|
var match *Match
|
||||||
var err error
|
var err error
|
||||||
if strings.Contains(base, "javbus") {
|
if adultSourceKind(base) == "javbus" {
|
||||||
match, err = p.scrapeJavBus(ctx, base, code)
|
match, err = p.scrapeJavBus(ctx, base, code)
|
||||||
} else {
|
} else {
|
||||||
match, err = p.scrapeJavDB(ctx, base, code)
|
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 {
|
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 {
|
if p.apiConfig == nil {
|
||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
@@ -102,20 +111,44 @@ func (p *AdultProvider) resolveBases(ctx context.Context) []string {
|
|||||||
if !resolved.Enabled && (resolved.BaseURL != "" || resolved.Extra != "" || resolved.APIKey != "") {
|
if !resolved.Enabled && (resolved.BaseURL != "" || resolved.Extra != "" || resolved.APIKey != "") {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
configured := []string{}
|
||||||
if resolved.BaseURL != "" {
|
if resolved.BaseURL != "" {
|
||||||
out = []string{resolved.BaseURL}
|
configured = append(configured, resolved.BaseURL)
|
||||||
}
|
}
|
||||||
if resolved.Extra != "" {
|
if resolved.Extra != "" {
|
||||||
for _, part := range strings.Split(resolved.Extra, ",") {
|
for _, part := range strings.Split(resolved.Extra, ",") {
|
||||||
part = strings.TrimSpace(part)
|
part = strings.TrimSpace(part)
|
||||||
if strings.HasPrefix(part, "http://") || strings.HasPrefix(part, "https://") {
|
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)
|
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) {
|
func (p *AdultProvider) scrapeJavDB(ctx context.Context, base, code string) (*Match, error) {
|
||||||
searchURL := base + "/search?q=" + url.QueryEscape(code) + "&f=all"
|
searchURL := base + "/search?q=" + url.QueryEscape(code) + "&f=all"
|
||||||
body, err := p.fetchText(ctx, searchURL, base)
|
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 {
|
if m := adultSamplePattern.FindStringSubmatch(body); len(m) > 1 {
|
||||||
match.BackdropURL = absolutizeURL(detailURL, m[1])
|
match.BackdropURL = absolutizeURL(detailURL, m[1])
|
||||||
}
|
}
|
||||||
|
if dmmPoster := adultDMMPosterFromSampleURL(match.BackdropURL); dmmPoster != "" {
|
||||||
|
match.PosterURL = dmmPoster
|
||||||
|
}
|
||||||
match.Year = firstYearInText(body)
|
match.Year = firstYearInText(body)
|
||||||
match.Rating = firstRatingInText(body)
|
match.Rating = firstRatingInText(body)
|
||||||
return match
|
return match
|
||||||
@@ -292,6 +328,21 @@ func firstAdultImage(body string, classNeedles ...string) string {
|
|||||||
return ""
|
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 {
|
func adultAttrs(raw string) map[string]string {
|
||||||
out := map[string]string{}
|
out := map[string]string{}
|
||||||
for _, found := range adultAttrPattern.FindAllStringSubmatch(raw, -1) {
|
for _, found := range adultAttrPattern.FindAllStringSubmatch(raw, -1) {
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
package service
|
package service
|
||||||
|
|
||||||
import "testing"
|
import (
|
||||||
|
"context"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
func TestNormalizeAdultCode(t *testing.T) {
|
func TestNormalizeAdultCode(t *testing.T) {
|
||||||
cases := map[string]string{
|
cases := map[string]string{
|
||||||
@@ -40,3 +43,57 @@ func TestParseAdultDetailHTML(t *testing.T) {
|
|||||||
t.Fatalf("year = %d, want 2024", got.Year)
|
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
@@ -31,21 +31,23 @@ import (
|
|||||||
|
|
||||||
// AIService talks to an OpenAI-compatible chat-completions endpoint.
|
// AIService talks to an OpenAI-compatible chat-completions endpoint.
|
||||||
type AIService struct {
|
type AIService struct {
|
||||||
cfg *config.Config
|
cfg *config.Config
|
||||||
log *zap.Logger
|
log *zap.Logger
|
||||||
client *http.Client
|
client *http.Client
|
||||||
|
apiConfig *APIConfigService
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewAIService is the constructor.
|
// 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
|
timeout := time.Duration(cfg.AI.Timeout) * time.Second
|
||||||
if timeout <= 0 {
|
if timeout <= 0 {
|
||||||
timeout = 30 * time.Second
|
timeout = 30 * time.Second
|
||||||
}
|
}
|
||||||
return &AIService{
|
return &AIService{
|
||||||
cfg: cfg,
|
cfg: cfg,
|
||||||
log: log,
|
log: log,
|
||||||
client: &http.Client{Timeout: timeout},
|
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) != ""
|
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.
|
// SearchIntent is the structured output the smart search endpoint returns.
|
||||||
type SearchIntent struct {
|
type SearchIntent struct {
|
||||||
Query string `json:"query"`
|
Query string `json:"query"`
|
||||||
@@ -67,7 +139,8 @@ type SearchIntent struct {
|
|||||||
// SmartSearch turns a natural-language query into a structured intent.
|
// SmartSearch turns a natural-language query into a structured intent.
|
||||||
// Returns a best-effort intent on parse failure (raw query passes through).
|
// Returns a best-effort intent on parse failure (raw query passes through).
|
||||||
func (a *AIService) SmartSearch(ctx context.Context, raw string) (*SearchIntent, error) {
|
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
|
return &SearchIntent{Query: raw}, nil
|
||||||
}
|
}
|
||||||
const sys = "You are a media-library search assistant. Read the user's query and " +
|
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 " +
|
"genre (string, optional), type (movie|tv|anime|music, optional), sort " +
|
||||||
"(recent|rating|random, optional), language (zh|en, optional). Respond with " +
|
"(recent|rating|random, optional), language (zh|en, optional). Respond with " +
|
||||||
"JSON only, no commentary."
|
"JSON only, no commentary."
|
||||||
out, err := a.complete(ctx, sys, raw)
|
out, err := a.complete(ctx, runtime, sys, raw)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return &SearchIntent{Query: raw}, err
|
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
|
// history. The first call is intentionally best-effort: a future iteration
|
||||||
// may chain media DB lookups onto each suggestion.
|
// may chain media DB lookups onto each suggestion.
|
||||||
func (a *AIService) Recommend(ctx context.Context, history []string, max int) ([]string, error) {
|
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
|
return nil, nil
|
||||||
}
|
}
|
||||||
if max <= 0 || max > 20 {
|
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 "+
|
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)
|
"comma-separated titles only, no commentary, in the same language as the input.", max)
|
||||||
usr := "I recently watched: " + strings.Join(history, "; ")
|
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 {
|
if err != nil {
|
||||||
return nil, err
|
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.
|
// 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{
|
payload := map[string]any{
|
||||||
"model": a.cfg.AI.Model,
|
"model": runtime.Model,
|
||||||
"temperature": 0.2,
|
"temperature": 0.2,
|
||||||
"messages": []map[string]string{
|
"messages": []map[string]string{
|
||||||
{"role": "system", "content": system},
|
{"role": "system", "content": system},
|
||||||
@@ -131,13 +205,13 @@ func (a *AIService) complete(ctx context.Context, system, user string) (string,
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
body, _ := json.Marshal(payload)
|
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))
|
req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, bytes.NewReader(body))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
req.Header.Set("Content-Type", "application/json")
|
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)
|
resp, err := a.client.Do(req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
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
|
return strings.TrimSpace(out.Choices[0].Message.Content), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
// ChatTurn is one message in a multi-turn assistant transcript.
|
// ChatTurn is one message in a multi-turn assistant transcript.
|
||||||
type ChatTurn struct {
|
type ChatTurn struct {
|
||||||
Role string `json:"role"`
|
Role string `json:"role"`
|
||||||
@@ -176,7 +249,8 @@ type ChatTurn struct {
|
|||||||
// we return a deterministic offline reply so the assistant UI still
|
// we return a deterministic offline reply so the assistant UI still
|
||||||
// has something to render.
|
// has something to render.
|
||||||
func (a *AIService) Chat(ctx context.Context, history []ChatTurn) (string, error) {
|
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
|
return offlineReply(history), nil
|
||||||
}
|
}
|
||||||
// Build a chat/completions payload preserving the history order.
|
// 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})
|
msgs = append(msgs, map[string]string{"role": t.Role, "content": t.Content})
|
||||||
}
|
}
|
||||||
payload := map[string]any{
|
payload := map[string]any{
|
||||||
"model": a.cfg.AI.Model,
|
"model": runtime.Model,
|
||||||
"temperature": 0.4,
|
"temperature": 0.4,
|
||||||
"messages": msgs,
|
"messages": msgs,
|
||||||
}
|
}
|
||||||
body, _ := json.Marshal(payload)
|
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))
|
req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, bytes.NewReader(body))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
req.Header.Set("Content-Type", "application/json")
|
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)
|
resp, err := a.client.Do(req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
|
|||||||
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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: "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: "fanart", BaseURL: "https://webservice.fanart.tv/v3", Description: "Fanart.tv (artwork)", Enabled: true},
|
||||||
{Provider: "douban", Description: "Douban cookie (zh metadata)", 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},
|
{Provider: "openai", BaseURL: "https://api.openai.com/v1", Description: "OpenAI-compatible (smart search)", Enabled: true},
|
||||||
}
|
}
|
||||||
for i := range defaults {
|
for i := range defaults {
|
||||||
|
|||||||
@@ -187,6 +187,8 @@ func (e *EmbyService) libraryAsView(l *model.Library) map[string]any {
|
|||||||
collectionType = "tvshows"
|
collectionType = "tvshows"
|
||||||
case "anime":
|
case "anime":
|
||||||
collectionType = "tvshows" // Emby 没有专门的 anime CollectionType
|
collectionType = "tvshows" // Emby 没有专门的 anime CollectionType
|
||||||
|
case "variety":
|
||||||
|
collectionType = "tvshows"
|
||||||
case "music":
|
case "music":
|
||||||
collectionType = "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
|
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 {
|
if season, ok, err := e.findSeasonGroup(ctx, p.ParentID); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
} else if ok {
|
} else if ok {
|
||||||
|
|||||||
@@ -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 {
|
func newTestEmbyService(t *testing.T) *EmbyService {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
|
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
|
||||||
|
|||||||
@@ -320,19 +320,27 @@ func mergeArtworkMetadata(meta *LocalMetadata, mediaPath, showBaseDir string) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
mediaDir := filepath.Dir(mediaPath)
|
mediaDir := filepath.Dir(mediaPath)
|
||||||
if meta.PosterURL == "" {
|
if localPoster := firstExistingImage(mediaDir, localPosterCandidates(mediaPath)...); localPoster != "" {
|
||||||
meta.PosterURL = firstExistingImage(mediaDir, localPosterCandidates(mediaPath)...)
|
meta.PosterURL = localPoster
|
||||||
|
} else if meta.PosterURL == "" {
|
||||||
|
meta.PosterURL = firstAdultLooseImage(mediaDir, "poster")
|
||||||
}
|
}
|
||||||
if meta.BackdropURL == "" {
|
dirs := []string{mediaDir, showBaseDir}
|
||||||
dirs := []string{mediaDir, showBaseDir}
|
for _, dir := range dirs {
|
||||||
for _, dir := range dirs {
|
if dir == "" {
|
||||||
if dir == "" {
|
continue
|
||||||
continue
|
}
|
||||||
}
|
if img := firstExistingImage(dir, localBackdropCandidates(mediaPath)...); img != "" {
|
||||||
if img := firstExistingImage(dir, localBackdropCandidates(mediaPath)...); img != "" {
|
meta.BackdropURL = img
|
||||||
meta.BackdropURL = img
|
break
|
||||||
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 != "" {
|
if meta.PosterURL != "" || meta.BackdropURL != "" {
|
||||||
@@ -468,17 +476,38 @@ func adultArtworkNameCandidates(mediaPath, kind string) []string {
|
|||||||
}
|
}
|
||||||
compact := strings.ReplaceAll(code, "-", "")
|
compact := strings.ReplaceAll(code, "-", "")
|
||||||
bases := []string{code, compact}
|
bases := []string{code, compact}
|
||||||
|
bases = append(bases, adultDMMNameCandidates(code)...)
|
||||||
out := make([]string, 0, len(bases)*6)
|
out := make([]string, 0, len(bases)*6)
|
||||||
for _, base := range bases {
|
for _, base := range bases {
|
||||||
|
if base == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
if kind == "poster" {
|
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 {
|
} 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
|
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 {
|
func firstExistingImage(dir string, names ...string) string {
|
||||||
if dir == "" {
|
if dir == "" {
|
||||||
return ""
|
return ""
|
||||||
@@ -494,6 +523,37 @@ func firstExistingImage(dir string, names ...string) string {
|
|||||||
return ""
|
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 {
|
func fileExists(path string) bool {
|
||||||
info, err := os.Stat(path)
|
info, err := os.Stat(path)
|
||||||
return err == nil && !info.IsDir()
|
return err == nil && !info.IsDir()
|
||||||
@@ -507,6 +567,11 @@ func isHTTPURL(raw string) bool {
|
|||||||
return (u.Scheme == "http" || u.Scheme == "https") && u.Host != ""
|
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 {
|
func firstText(values ...string) string {
|
||||||
for _, value := range values {
|
for _, value := range values {
|
||||||
if text := cleanXMLText(value); text != "" {
|
if text := cleanXMLText(value); text != "" {
|
||||||
|
|||||||
@@ -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) {
|
func TestReadAdultArtworkByCodeWithoutNFO(t *testing.T) {
|
||||||
root := t.TempDir()
|
root := t.TempDir()
|
||||||
mediaPath := filepath.Join(root, "SSIS-001-CD1.mp4")
|
mediaPath := filepath.Join(root, "SSIS-001-CD1.mp4")
|
||||||
|
|||||||
@@ -98,7 +98,7 @@ func New(cfg *config.Config, log *zap.Logger, repos *repository.Container) *Cont
|
|||||||
scanner := NewScannerService(cfg, log, repos, hub, probe, scraper)
|
scanner := NewScannerService(cfg, log, repos, hub, probe, scraper)
|
||||||
watcher := NewWatcherService(log, repos, scanner)
|
watcher := NewWatcherService(log, repos, scanner)
|
||||||
nfo := NewNFOService(log, repos)
|
nfo := NewNFOService(log, repos)
|
||||||
ai := NewAIService(cfg, log)
|
ai := NewAIService(cfg, log, apiConfig)
|
||||||
duplicate := NewDuplicateService(log, repos, hub)
|
duplicate := NewDuplicateService(log, repos, hub)
|
||||||
filemanager := NewFileManagerService(cfg, log, repos)
|
filemanager := NewFileManagerService(cfg, log, repos)
|
||||||
dlna := NewDLNAService(log)
|
dlna := NewDLNAService(log)
|
||||||
|
|||||||
@@ -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.
|
// Update applies a partial patch to an existing site.
|
||||||
func (s *SiteService) Update(ctx context.Context, id string, updates map[string]any) error {
|
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
|
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
|
}).Error
|
||||||
return true, "连接成功", nil
|
return true, "连接成功", nil
|
||||||
} else {
|
} 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",
|
s.log.Warn("site adapter authenticate failed, falling back to generic test",
|
||||||
zap.String("site", site.Name),
|
zap.String("site", site.Name),
|
||||||
zap.String("type", site.Type),
|
zap.String("type", site.Type),
|
||||||
|
|||||||
@@ -8,7 +8,6 @@ import (
|
|||||||
"io"
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/url"
|
"net/url"
|
||||||
"os"
|
|
||||||
"regexp"
|
"regexp"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
@@ -27,11 +26,11 @@ type SiteConfig struct {
|
|||||||
Cookie string
|
Cookie string
|
||||||
APIKey string
|
APIKey string
|
||||||
AuthHeader string
|
AuthHeader string
|
||||||
UserAgent string // 自定义 User-Agent
|
UserAgent string // 自定义 User-Agent
|
||||||
Timeout time.Duration // 请求超时
|
Timeout time.Duration // 请求超时
|
||||||
Extra map[string]string // JSON 扩展配置
|
Extra map[string]string // JSON 扩展配置
|
||||||
FlareSolverrURL string // FlareSolverr 服务地址(用于浏览器模拟绕过 Cloudflare/WAF)
|
FlareSolverrURL string // FlareSolverr 服务地址(用于浏览器模拟绕过 Cloudflare/WAF)
|
||||||
UseProxy bool // 通过 HTTP(S)_PROXY 环境变量出站
|
UseProxy bool // 通过 HTTP(S)_PROXY 环境变量出站
|
||||||
}
|
}
|
||||||
|
|
||||||
// SiteSearchResult 站点搜索结果(按站点分组的批量搜索结果)。
|
// SiteSearchResult 站点搜索结果(按站点分组的批量搜索结果)。
|
||||||
@@ -855,22 +854,15 @@ func NewMTeamAdapter() *MTeamAdapter {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (a *MTeamAdapter) Authenticate(ctx context.Context, cfg SiteConfig) error {
|
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 参考实现对齐:
|
// 与 ShukeBta/MediaStation 参考实现对齐:
|
||||||
// 用 camelCase 参数(pageNumber / pageSize),同时接受 code 为字符串 "0"
|
// 用 camelCase 参数(pageNumber / pageSize),同时接受 code 为字符串 "0"
|
||||||
// 或数值 0;兼容 M-Team v3 API 不同版本的返回。
|
// 或数值 0;兼容 M-Team v3 API 不同版本的返回。
|
||||||
u := cfg.URL + "/api/torrent/search"
|
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))
|
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 {
|
if err != nil {
|
||||||
return fmt.Errorf("authenticate: %w", err)
|
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 {
|
if err := json.Unmarshal(data, &resp); err != nil {
|
||||||
return fmt.Errorf("parse response: %w (body=%s)", err, preview)
|
return fmt.Errorf("parse response: %w (body=%s)", err, preview)
|
||||||
}
|
}
|
||||||
codeStr := ""
|
if mteamCodeOK(resp["code"]) {
|
||||||
switch v := resp["code"].(type) {
|
|
||||||
case string:
|
|
||||||
codeStr = v
|
|
||||||
case float64:
|
|
||||||
codeStr = strconv.Itoa(int(v))
|
|
||||||
}
|
|
||||||
if codeStr == "0" || codeStr == "200" {
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
msg, _ := resp["message"].(string)
|
msg, _ := resp["message"].(string)
|
||||||
if msg == "" {
|
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)
|
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) {
|
func (a *MTeamAdapter) GetDetail(ctx context.Context, cfg SiteConfig, id string) (*TorrentDetail, error) {
|
||||||
u := cfg.URL + "/api/torrent/detail?id=" + id
|
u := cfg.URL + "/api/torrent/detail?id=" + url.QueryEscape(id)
|
||||||
data, status, err := doRequest(ctx, a.client, "GET", u, cfg, nil)
|
data, status, err := doRequestJSON(ctx, a.client, "POST", u, cfg, nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("detail: %w", err)
|
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))
|
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 {
|
if err != nil {
|
||||||
return nil, 0, err
|
return nil, 0, err
|
||||||
}
|
}
|
||||||
@@ -1526,6 +1515,24 @@ func doRequestJSON(ctx context.Context, client *http.Client, method, rawURL stri
|
|||||||
return data, resp.StatusCode, nil
|
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 将带单位的字符串转换为字节数。
|
// parseSizeString 将带单位的字符串转换为字节数。
|
||||||
func parseSizeString(value string, unit string) int64 {
|
func parseSizeString(value string, unit string) int64 {
|
||||||
v, err := strconv.ParseFloat(value, 64)
|
v, err := strconv.ParseFloat(value, 64)
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
@@ -1,10 +1,8 @@
|
|||||||
import { useEffect, useMemo, useState } from 'react'
|
import { useEffect, useMemo, useState } from 'react'
|
||||||
import { Link } from 'react-router-dom'
|
import { Link } from 'react-router-dom'
|
||||||
import { motion } from 'framer-motion'
|
import { motion } from 'framer-motion'
|
||||||
import {
|
import { ArrowRight, Clock, Film, Play, Sparkles } from 'lucide-react'
|
||||||
ArrowRight, Play, Clock, Film, Sparkles,
|
|
||||||
Library as LibraryIcon, Tv, Music, PlayCircle, ChevronRight
|
|
||||||
} from 'lucide-react'
|
|
||||||
import { libraryAPI, mediaAPI } from '../api/library'
|
import { libraryAPI, mediaAPI } from '../api/library'
|
||||||
import { playbackAPI, type HistoryItem } from '../api/playback'
|
import { playbackAPI, type HistoryItem } from '../api/playback'
|
||||||
import { imageURL } from '../api/client'
|
import { imageURL } from '../api/client'
|
||||||
@@ -12,17 +10,11 @@ import { MediaCard } from '../components/MediaCard'
|
|||||||
import type { Library, Media } from '../types'
|
import type { Library, Media } from '../types'
|
||||||
import { groupSeries } from '../utils/groupSeries'
|
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)
|
const hasArtwork = (media?: Media | null) => !!(media?.poster_url || media?.backdrop_url)
|
||||||
|
|
||||||
export function HomePage() {
|
export function HomePage() {
|
||||||
const [libraries, setLibraries] = useState<Library[]>([])
|
const [libraries, setLibraries] = useState<Library[]>([])
|
||||||
const [rows, setRows] = useState<LibraryRow[]>([])
|
const [recent, setRecent] = useState<Media[]>([])
|
||||||
const [history, setHistory] = useState<HistoryItem[]>([])
|
const [history, setHistory] = useState<HistoryItem[]>([])
|
||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
|
|
||||||
@@ -31,48 +23,36 @@ export function HomePage() {
|
|||||||
async function load() {
|
async function load() {
|
||||||
setLoading(true)
|
setLoading(true)
|
||||||
try {
|
try {
|
||||||
const [libs, hist] = await Promise.all([
|
const [libs, recentItems, hist] = await Promise.all([
|
||||||
libraryAPI.list().catch(() => [] as Library[]),
|
libraryAPI.list().catch(() => [] as Library[]),
|
||||||
|
mediaAPI.search('', 120).then((d) => d.items).catch(() => [] as Media[]),
|
||||||
playbackAPI.recentHistory().catch(() => [] as HistoryItem[]),
|
playbackAPI.recentHistory().catch(() => [] as HistoryItem[]),
|
||||||
])
|
])
|
||||||
if (cancelled) return
|
if (cancelled) return
|
||||||
setLibraries(libs)
|
setLibraries(libs)
|
||||||
|
setRecent(recentItems)
|
||||||
setHistory(hist.filter((h) => !h.completed && !!h.media))
|
setHistory(hist.filter((h) => !h.completed && !!h.media))
|
||||||
const perLib = await Promise.all(libs.map(async (lib) => {
|
} finally {
|
||||||
try {
|
if (!cancelled) setLoading(false)
|
||||||
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) }
|
|
||||||
}
|
}
|
||||||
load()
|
load()
|
||||||
return () => { cancelled = true }
|
return () => { cancelled = true }
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
const [fallback, setFallback] = useState<Media[]>([])
|
const recentCards = useMemo(() => groupSeries(recent).slice(0, 24), [recent])
|
||||||
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 featuredItem = useMemo(() => {
|
const featuredItem = useMemo(() => {
|
||||||
const candidates = [
|
const candidates = [
|
||||||
...(history.map((h) => h.media).filter(Boolean) as Media[]),
|
...(history.map((h) => h.media).filter(Boolean) as Media[]),
|
||||||
...rows.flatMap((row) => row.cards.map((card) => card.rep)),
|
...recentCards.map((card) => card.rep),
|
||||||
...fallbackCards.map((card) => card.rep),
|
...recent,
|
||||||
...fallback,
|
|
||||||
]
|
]
|
||||||
return candidates.find(hasArtwork) ?? candidates[0] ?? null
|
return candidates.find(hasArtwork) ?? candidates[0] ?? null
|
||||||
}, [history, rows, fallbackCards, fallback])
|
}, [history, recentCards, recent])
|
||||||
const featuredVisual = featuredItem?.backdrop_url || featuredItem?.poster_url || ''
|
const featuredVisual = featuredItem?.backdrop_url || featuredItem?.poster_url || ''
|
||||||
const featuredPoster = featuredItem?.poster_url || featuredItem?.backdrop_url || ''
|
const featuredPoster = featuredItem?.poster_url || featuredItem?.backdrop_url || ''
|
||||||
const featuredMark = (featuredItem?.title || 'MS').trim().slice(0, 4).toUpperCase()
|
const featuredMark = (featuredItem?.title || 'MS').trim().slice(0, 4).toUpperCase()
|
||||||
|
const empty = !loading && libraries.length === 0 && recentCards.length === 0 && history.length === 0
|
||||||
const empty = !loading && history.length === 0 && rows.every((r) => r.cards.length === 0) && fallbackCards.length === 0
|
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return (
|
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" />
|
<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" />
|
<Film className="absolute h-4 w-4 text-brand-500" />
|
||||||
</div>
|
</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>
|
</motion.div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
@@ -96,7 +76,7 @@ export function HomePage() {
|
|||||||
</div>
|
</div>
|
||||||
<p className="text-xl font-bold text-gray-900">您的家庭影视站暂无内容</p>
|
<p className="text-xl font-bold text-gray-900">您的家庭影视站暂无内容</p>
|
||||||
<p className="mt-2 text-sm text-gray-500 leading-relaxed">
|
<p className="mt-2 text-sm text-gray-500 leading-relaxed">
|
||||||
立即前往管理后台配置您的第一个媒体库,添加影视目录并触发后台刮削扫描任务。
|
前往管理后台添加媒体目录,扫描后首页将展示本周力荐、继续观看和最近入库。
|
||||||
</p>
|
</p>
|
||||||
<Link to="/admin" className="mt-8 btn-primary">
|
<Link to="/admin" className="mt-8 btn-primary">
|
||||||
前往管理后台
|
前往管理后台
|
||||||
@@ -107,10 +87,8 @@ export function HomePage() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-12">
|
<div className="space-y-12">
|
||||||
{/* ─── Premium Swiss-Editorial Billboard Hero ─── */}
|
|
||||||
{featuredItem && (
|
{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)]">
|
<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="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)]" />
|
<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 && (
|
{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-[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 className="absolute inset-x-0 bottom-0 h-32 bg-gradient-to-t from-white to-transparent" />
|
||||||
</div>
|
</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="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="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">
|
<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>
|
</h1>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{featuredItem.overview ? (
|
<p className="max-w-2xl text-gray-600 text-sm sm:text-base leading-relaxed line-clamp-3 font-semibold">
|
||||||
<p className="max-w-2xl text-gray-600 text-sm sm:text-base leading-relaxed line-clamp-3 font-semibold">
|
{featuredItem.overview || '家庭私人媒体中心收藏。支持多端播放、外部播放器、智能刮削与订阅下载。'}
|
||||||
{featuredItem.overview}
|
</p>
|
||||||
</p>
|
|
||||||
) : (
|
|
||||||
<p className="max-w-2xl text-gray-500 text-sm italic">
|
|
||||||
家庭私人媒体中心收藏。极高视听品质,支持多端原生无损解码及HLS转码播放。
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Metadata Badges */}
|
|
||||||
<div className="flex flex-wrap items-center gap-3 text-xs text-gray-500 font-bold">
|
<div className="flex flex-wrap items-center gap-3 text-xs text-gray-500 font-bold">
|
||||||
{featuredItem.year > 0 && (
|
{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>
|
<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>
|
</div>
|
||||||
|
|
||||||
{/* Buttons */}
|
|
||||||
<div className="flex flex-wrap items-center gap-4 pt-2">
|
<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">
|
<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" />
|
<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="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 -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="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">
|
<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]" />
|
<Film className="mb-4 h-12 w-12 text-[#c9954a]" />
|
||||||
@@ -202,17 +169,12 @@ export function HomePage() {
|
|||||||
onError={(event) => { event.currentTarget.style.display = 'none' }}
|
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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* ─── Continue Watching Section ─── */}
|
|
||||||
{history.length > 0 && (
|
{history.length > 0 && (
|
||||||
<section className="space-y-5">
|
<section className="space-y-5">
|
||||||
<div className="flex items-center gap-2.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">
|
<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) => {
|
{history.slice(0, 8).map((h) => {
|
||||||
const m = h.media!
|
const media = h.media!
|
||||||
const pct = h.duration_ms > 0 ? h.position_ms / h.duration_ms : 0
|
const progress = h.duration_ms > 0 ? h.position_ms / h.duration_ms : 0
|
||||||
return <ContinueCard key={h.id} media={m} progress={pct} />
|
return <ContinueCard key={h.id} media={media} progress={progress} />
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* ─── Media Libraries Grid Sections ─── */}
|
{recentCards.length > 0 && (
|
||||||
<div className="space-y-12">
|
<section className="space-y-5">
|
||||||
{rows.filter((r) => r.cards.length > 0).map((row) => (
|
<div className="flex items-center justify-between border-b border-gray-200/80 pb-3">
|
||||||
<section key={row.library.id} className="space-y-5">
|
<div className="flex items-center gap-2.5">
|
||||||
<div className="flex items-center justify-between border-b border-gray-200/80 pb-3">
|
<span className="p-1.5 rounded-xl bg-gray-100 text-gray-900 border border-gray-200/50">
|
||||||
<div className="flex items-center gap-2.5">
|
<Clock size={18} />
|
||||||
<span className="p-1.5 rounded-xl bg-gray-100 text-gray-900 border border-gray-200/50">
|
</span>
|
||||||
{TYPE_ICONS[row.library.type] ?? <LibraryIcon size={18} />}
|
<div>
|
||||||
</span>
|
<h2 className="font-display text-xl font-extrabold tracking-tight text-gray-900">最近入库</h2>
|
||||||
<h2 className="font-display text-xl font-extrabold tracking-tight text-gray-900">
|
<p className="text-xs text-gray-500">按整部电影、剧集、番剧和综艺合集展示新增内容。</p>
|
||||||
{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>
|
|
||||||
</div>
|
</div>
|
||||||
</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">
|
<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) => (
|
{recentCards.map((card) => (
|
||||||
<MediaCard key={s.rep.id} media={s.rep} count={s.count} />
|
<MediaCard
|
||||||
))}
|
key={card.key}
|
||||||
</div>
|
media={card.rep}
|
||||||
</section>
|
count={card.count}
|
||||||
)}
|
linkTo={card.count > 1 ? `/library/${card.rep.library_id}?series=${encodeURIComponent(card.key)}` : undefined}
|
||||||
</div>
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
</div>
|
</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">
|
<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">
|
<div className="relative h-18 w-12 shrink-0 overflow-hidden rounded-xl bg-gray-50 border border-gray-100">
|
||||||
{media.poster_url ? (
|
{media.poster_url ? (
|
||||||
<img
|
<img
|
||||||
src={imageURL(media.poster_url)}
|
src={imageURL(media.poster_url)}
|
||||||
alt=""
|
alt=""
|
||||||
loading="lazy"
|
loading="lazy"
|
||||||
className="h-full w-full object-cover transition-transform duration-500 group-hover:scale-105"
|
className="h-full w-full object-cover transition-transform duration-500 group-hover:scale-105"
|
||||||
referrerPolicy="no-referrer"
|
referrerPolicy="no-referrer"
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<div className="flex h-full items-center justify-center text-gray-500 bg-gray-50">
|
<div className="flex h-full items-center justify-center text-gray-500 bg-gray-50">
|
||||||
|
|||||||
+201
-38
@@ -1,12 +1,19 @@
|
|||||||
import { useEffect, useState } from 'react'
|
import { useEffect, useMemo, useState, type ReactNode } from 'react'
|
||||||
import { Link } from 'react-router-dom'
|
import { Link } from 'react-router-dom'
|
||||||
import { motion } from 'framer-motion'
|
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 { 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} />,
|
movie: <Film size={20} />,
|
||||||
tv: <Tv size={20} />,
|
tv: <Tv size={20} />,
|
||||||
anime: <PlayCircle size={20} />,
|
anime: <PlayCircle size={20} />,
|
||||||
@@ -22,60 +29,216 @@ const TYPE_LABELS: Record<string, string> = {
|
|||||||
music: '音乐',
|
music: '音乐',
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const TYPE_DESCRIPTIONS: Record<string, string> = {
|
||||||
|
movie: '电影、动画电影、纪录片电影',
|
||||||
|
tv: '电视剧、短剧、纪录片剧集',
|
||||||
|
anime: '番剧、动画、OVA 与剧场版',
|
||||||
|
variety: '综艺、真人秀、晚会与节目合集',
|
||||||
|
music: '音乐、MV、演唱会与音频内容',
|
||||||
|
}
|
||||||
|
|
||||||
export function LibrariesPage() {
|
export function LibrariesPage() {
|
||||||
const [libraries, setLibraries] = useState<Library[]>([])
|
const [previews, setPreviews] = useState<LibraryPreview[]>([])
|
||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [activeType, setActiveType] = useState('all')
|
||||||
|
|
||||||
useEffect(() => {
|
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) {
|
if (loading) {
|
||||||
return <p className="px-2 py-8 text-sm text-sand-500">媒体库加载中…</p>
|
return <p className="px-2 py-8 text-sm text-sand-500">媒体库加载中…</p>
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-8">
|
||||||
<div>
|
<div className="flex flex-wrap items-end justify-between gap-4">
|
||||||
<h1 className="font-display text-3xl font-bold text-ink-600">媒体库</h1>
|
<div>
|
||||||
<p className="mt-1 text-sm text-ink-50">所有电影、剧集、番剧、综艺分类统一在这里查看。</p>
|
<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>
|
</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">
|
<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" />
|
<LibraryIcon className="mb-4 h-12 w-12 text-gray-400" />
|
||||||
<p className="text-sm text-ink-50">暂无媒体库,请到管理后台添加目录。</p>
|
<p className="text-sm text-ink-50">暂无媒体库,请到管理后台添加目录。</p>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="grid gap-4 sm:grid-cols-2 xl:grid-cols-3">
|
<>
|
||||||
{libraries.map((lib, index) => (
|
<section className="grid gap-3 sm:grid-cols-2 xl:grid-cols-5">
|
||||||
<motion.div
|
<CategoryCard
|
||||||
key={lib.id}
|
active={activeType === 'all'}
|
||||||
initial={{ opacity: 0, y: 12 }}
|
icon={<LibraryIcon size={20} />}
|
||||||
animate={{ opacity: 1, y: 0 }}
|
label="全部媒体"
|
||||||
transition={{ delay: index * 0.03 }}
|
description="电影、剧集、番剧、综艺统一视图"
|
||||||
>
|
total={previews.reduce((sum, preview) => sum + preview.total, 0)}
|
||||||
<Link
|
libraries={previews.length}
|
||||||
to={`/library/${lib.id}`}
|
onClick={() => setActiveType('all')}
|
||||||
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"
|
/>
|
||||||
|
{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">
|
<LibraryCard preview={preview} />
|
||||||
<div className="flex h-12 w-12 items-center justify-center rounded-2xl bg-brand-50 text-brand-600">
|
</motion.div>
|
||||||
{TYPE_ICONS[lib.type] ?? <LibraryIcon size={20} />}
|
))}
|
||||||
</div>
|
</section>
|
||||||
<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>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|||||||
@@ -143,9 +143,6 @@ export function SitesPage() {
|
|||||||
url: form.url.trim(),
|
url: form.url.trim(),
|
||||||
type: form.type,
|
type: form.type,
|
||||||
auth_type: form.auth_type,
|
auth_type: form.auth_type,
|
||||||
cookie: form.cookie || '',
|
|
||||||
api_key: form.api_key || '',
|
|
||||||
auth_header: form.auth_header || '',
|
|
||||||
enabled: form.enabled,
|
enabled: form.enabled,
|
||||||
is_default: form.is_default,
|
is_default: form.is_default,
|
||||||
extra: form.extra || '',
|
extra: form.extra || '',
|
||||||
@@ -158,6 +155,9 @@ export function SitesPage() {
|
|||||||
browser_emulation: !!form.browser_emulation,
|
browser_emulation: !!form.browser_emulation,
|
||||||
downloader: form.downloader || '',
|
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 {
|
try {
|
||||||
if (editingId) {
|
if (editingId) {
|
||||||
await sitesAPI.update(editingId, payload)
|
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="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-sm font-medium text-green-400 mb-1">馒头站点配置指南</div>
|
||||||
<div className="text-xs text-ink-50 space-y-1">
|
<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><code className="text-green-300">https://api.m-team.cc</code></div>
|
||||||
<div><b>认证方式:</b>推荐使用「API Key / Passkey」</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">
|
<div className="pl-3 text-sand-500">
|
||||||
1. 登录馒头站 → 控制台 → 实验室 → 存取令牌<br />
|
1. 登录馒头站 → 控制台 → 实验室 → 存取令牌<br />
|
||||||
2. 点击「创建令牌」,复制生成的 Token<br />
|
2. 点击「创建令牌」,复制生成的 Token<br />
|
||||||
@@ -520,13 +521,13 @@ export function SitesPage() {
|
|||||||
<input
|
<input
|
||||||
type="password"
|
type="password"
|
||||||
className="input-base w-full font-mono text-sm"
|
className="input-base w-full font-mono text-sm"
|
||||||
placeholder="输入 API Key 或 Passkey"
|
placeholder={editingId ? '留空则保留原令牌,输入新值则替换' : '输入 API Access Token'}
|
||||||
value={form.api_key}
|
value={form.api_key}
|
||||||
onChange={(e) => setForm((f) => ({ ...f, api_key: e.target.value }))}
|
onChange={(e) => setForm((f) => ({ ...f, api_key: e.target.value }))}
|
||||||
/>
|
/>
|
||||||
<p className="text-xs text-sand-500 mt-1">
|
<p className="text-xs text-sand-500 mt-1">
|
||||||
{form.type === 'mteam'
|
{form.type === 'mteam'
|
||||||
? '馒头:控制台 → 实验室 → 存取令牌'
|
? '馒头:控制台 → 实验室 → 存取令牌;第三方工具通过 x-api-key 请求头访问'
|
||||||
: '站点的访问 API Key'}
|
: '站点的访问 API Key'}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user