feat: Emby 兼容层完整实现 + 多模块功能增强

## Emby/Jellyfin 兼容层 (emby_compat.go / emby.go)
- 新增 SystemInfoPublic、FindUser、Items、Item、LatestItems、ResumeItems
- 新增 SetFavorite、MarkPlayed、RecordProgress 用户播放状态同步
- 新增 itemPayload、mediaSource、mediaStreams 媒体信息组装
- 双前缀路由 /emby/* 和 / 根路径,兼容 Infuse/Yamby/Senplayer/Kodi
- 新增 Ping、SystemEndpoint、AuthByName 端点
- emby.go 扩展对应 handler 函数

## 站点适配器 (site_adapter.go / site.go)
- SiteConfig 扩展 UserAgent/Timeout/Extra/FlareSolverrURL 字段
- doRequest() GET 请求支持 FlareSolverr 代理绕过 Cloudflare/WAF
- MTeam api_key 认证改为 Authorization: Bearer 格式
- Search() 重构为 sync.WaitGroup 并发执行,提升多站搜索性能
- siteModelToConfig() 改为 SiteService 方法,按 BrowserEmulation 填充 FlareSolverrURL

## 图片代理 (image_proxy.go)
- 重构图片代理服务,支持更多来源和缓存策略

## 下载管理 (downloads.go / download_clients.go / qbittorrent.go)
- 下载任务增强:状态管理、进度追踪优化
- qBittorrent 客户端连接稳定性改进

## 刮削与数据库 (scraper.go / tmdb.go / repository.go)
- 刮削器增强 TMDB 集成,补全元数据字段
- repository 扩展查询方法

## 前端 (web/src/)
- HomePage: 首页布局重构,按媒体库分组展示,系列聚合优化
- DiscoverPage: 发现页增强,错误处理改进(API key 缺失/网络错误分离)
- PosterWallPage: 海报墙优化,系列聚合展示
- MediaCard: 媒体卡片优化
- PlayerPage: 播放器改进
- 新增 utils/groupSeries.ts: 系列聚合工具函数
- .gitignore: 添加 .tmp_* 临时文件排除规则
This commit is contained in:
ShukeBta
2026-05-26 16:09:13 +08:00
parent 5d1ae91419
commit 3a2db6bdd9
32 changed files with 2263 additions and 465 deletions
+18 -1
View File
@@ -8,6 +8,7 @@ import (
"context"
"errors"
"fmt"
"io"
"net/http"
"net/url"
"strings"
@@ -129,23 +130,39 @@ func (s *DownloadClientService) Test(ctx context.Context, id string) error {
}
switch c.Type {
case "qbittorrent":
host := strings.TrimRight(c.Host, "/")
body := url.Values{}
body.Set("username", c.Username)
body.Set("password", c.Password)
req, _ := http.NewRequestWithContext(
ctx, http.MethodPost,
strings.TrimRight(c.Host, "/")+"/api/v2/auth/login",
host+"/api/v2/auth/login",
strings.NewReader(body.Encode()),
)
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
// qBittorrent v4.6+ 默认开启 "host header validation" 并要求
// Referer 与 Host 同源,否则即使账户正确也会拒绝登录。
req.Header.Set("Referer", host)
req.Header.Set("Origin", host)
req.Header.Set("User-Agent", "MediaStationGo/0.1")
resp, err := s.client.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode == http.StatusForbidden {
return errors.New("qbittorrent: 403 — 用户名/密码错误,或 WebUI 启用了 IP 封禁")
}
if resp.StatusCode >= 400 {
return fmt.Errorf("qbittorrent returned %d", resp.StatusCode)
}
// 即使返回 200,body 内容仍可能是 "Fails." 表示登录失败。
raw, _ := io.ReadAll(io.LimitReader(resp.Body, 256))
text := strings.TrimSpace(string(raw))
if text == "Fails." {
return errors.New("qbittorrent: 用户名/密码错误")
}
// 正确响应是 "Ok." — 但部分版本会重定向或返回空体,不强校验。
return nil
case "aria2", "transmission":
req, _ := http.NewRequestWithContext(ctx, http.MethodGet, c.Host, nil)
+37 -13
View File
@@ -18,6 +18,7 @@ package service
import (
"context"
"errors"
"strings"
"sync"
"time"
@@ -67,19 +68,41 @@ func (d *DownloadService) Stop() {
close(d.stopCh)
}
// ReloadConfig rebuilds the qBittorrent client from the system settings.
// ReloadConfig rebuilds the qBittorrent client from the configured
// download clients (preferred) or the legacy Setting table (fallback).
//
// 配置来源优先级:
//
// 1. download_clients 表中 type=qbittorrent 且 is_default=true 且 enabled=true
// 的行(侧边栏「下载器」页面写入的数据)。
// 2. system Setting 表中的 qbittorrent.url / username / password
// (旧版「系统设置」表单写入的数据;保留作向后兼容)。
//
// 这避免了两套配置各跑各的:之前操作员明明已经在「下载器」页面填好
// 默认 qb,但实际下载链路读的还是 Setting 表,导致一直连不上。
func (d *DownloadService) ReloadConfig(ctx context.Context) error {
cfg := QBitConfig{}
for _, key := range []struct{ from, into *string }{} {
_ = key
// Path 1: download_clients 表
if d.repo.DownloadClient != nil {
if c, err := d.repo.DownloadClient.FindDefault(ctx); err == nil && c != nil && c.Type == "qbittorrent" {
cfg.BaseURL = strings.TrimRight(c.Host, "/")
cfg.Username = c.Username
cfg.Password = c.Password
}
}
get := func(k string) string {
v, _ := d.repo.Setting.Get(ctx, k)
return v
// Path 2: legacy Setting 表(仅在 client 表未配置时回退)
if cfg.BaseURL == "" {
get := func(k string) string {
v, _ := d.repo.Setting.Get(ctx, k)
return v
}
cfg.BaseURL = get("qbittorrent.url")
cfg.Username = get("qbittorrent.username")
cfg.Password = get("qbittorrent.password")
}
cfg.BaseURL = get("qbittorrent.url")
cfg.Username = get("qbittorrent.username")
cfg.Password = get("qbittorrent.password")
d.qb.Configure(cfg)
return nil
}
@@ -171,11 +194,12 @@ func (d *DownloadService) onTorrentComplete(ctx context.Context, hash string, sa
if d.organizer == nil || savePath == "" {
return
}
// Check if auto-organize after download is enabled
autoOrganize := d.organizer.isSmartClassifyEnabled(ctx)
// Also check dedicated config key
// 仅当显式开启 organizer.auto_after_download 时才在下载完成后整理。
// 之前的代码错误地把 organizer.smart_classify 也当成"自动整理"开关,
// 让操作员只想启用"分类子目录"就被动触发了文件 move。
autoOrganize := false
if v, err := d.repo.Setting.Get(ctx, "organizer.auto_after_download"); err == nil {
autoOrganize = autoOrganize || v == "true" || v == "1" || v == "on"
autoOrganize = v == "true" || v == "1" || v == "on"
}
if !autoOrganize {
d.log.Info("download completed, auto-organize disabled", zap.String("hash", hash))
+504 -77
View File
@@ -1,33 +1,36 @@
// Package service — minimal Emby/Jellyfin compatibility shim.
// Package service — Emby/Jellyfin compatibility shim.
//
// EmbyService produces JSON envelopes shaped like the most-consumed
// Emby-API endpoints so existing players (Infuse / Kodi NextPVR
// extension / iOS native clients) can talk to MediaStationGo without a
// custom plugin.
// Emby-API endpoints so existing players (Infuse / Yamby / Hills /
// Senplayer / Kodi NextPVR extension / iOS native clients) can talk to
// MediaStationGo without a custom plugin.
//
// Implemented surface (matches what nowen-video exposes):
//
// GET /emby/System/Info server identity
// GET /emby/Users list of users (admin only field)
// GET /emby/Users/{userId}/Views virtual root: one entry per library
// GET /emby/Users/{userId}/Items paginated media listing
// GET /emby/Items/{id} single item
// GET /emby/Items/{id}/PlaybackInfo stream URL (delegates to /api/stream)
//
// The shim is read-only — Emby write operations (mark watched, etc.) are
// not implemented; the React UI stays the canonical control plane.
// The shim is read-mostly: items, images, playback are fully covered;
//播放进度上报 / 收藏切换 是写路径但走我们自己的 PlaybackHistory /
// Favorite 表,所以 Emby 客户端的"标记已看 / 收藏"也会反向同步到
// 我们自己的 React UI。
package service
import (
"context"
"errors"
"fmt"
"strconv"
"strings"
"time"
"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"
)
// 用一个固定的 ServerId 字符串。Emby 客户端会缓存这个 id,第一次见到
// 该 id 后会把所有派生数据(cookie/收藏/历史)和它绑定。
const embyServerID = "mediastation-go-001"
// EmbyService produces Emby-shaped JSON.
type EmbyService struct {
cfg *config.Config
@@ -40,17 +43,46 @@ func NewEmbyService(cfg *config.Config, log *zap.Logger, repo *repository.Contai
return &EmbyService{cfg: cfg, log: log, repo: repo}
}
// SystemInfo returns the Emby identity payload.
// ─── System ──────────────────────────────────────────────────────────────────
// SystemInfo returns the full Emby identity payload.
func (e *EmbyService) SystemInfo() map[string]any {
return map[string]any{
"ServerName": "MediaStationGo",
"Version": "0.1.0",
"Id": "mediastation-go",
"OperatingSystem": "Linux",
"ProductName": "MediaStationGo",
"Id": embyServerID,
"ServerName": "MediaStationGo",
"Version": "10.8.13",
"ProductName": "MediaStationGo",
"OperatingSystem": "Linux",
"Architecture": "X64",
"LocalAddress": "",
"WanAddress": "",
"HasPendingRestart": false,
"IsShuttingDown": false,
"SupportsLibraryMonitor": true,
"SupportsHttps": false,
"SupportsAutoDiscovery": true,
"WebSocketPortNumber": e.cfg.App.Port,
"CompletedInstallations": []any{},
"CanSelfRestart": false,
"CanLaunchWebBrowser": false,
"CanRestart": false,
}
}
// SystemInfoPublic 是不需要认证的精简版(Emby Web 客户端登陆前会拉)。
func (e *EmbyService) SystemInfoPublic() map[string]any {
return map[string]any{
"Id": embyServerID,
"ServerName": "MediaStationGo",
"Version": "10.8.13",
"ProductName": "MediaStationGo",
"OperatingSystem": "Linux",
"StartupWizardCompleted": true,
}
}
// ─── Users ───────────────────────────────────────────────────────────────────
// ListUsers returns Emby-shaped users.
func (e *EmbyService) ListUsers(ctx context.Context) ([]map[string]any, error) {
users, err := e.repo.User.List(ctx)
@@ -64,23 +96,62 @@ func (e *EmbyService) ListUsers(ctx context.Context) ([]map[string]any, error) {
return out, nil
}
// FindUser 用 ID 查用户,用于 /Users/Me 与 /Users/{id}。
func (e *EmbyService) FindUser(ctx context.Context, id string) (map[string]any, error) {
u, err := e.repo.User.FindByID(ctx, id)
if err != nil || u == nil {
return nil, err
}
return e.userPayload(u), nil
}
func (e *EmbyService) userPayload(u *model.User) map[string]any {
return map[string]any{
"Id": u.ID,
"Name": u.Username,
"ServerId": "mediastation-go",
"HasPassword": true,
"Id": u.ID,
"Name": u.Username,
"ServerId": embyServerID,
"ServerName": "MediaStationGo",
"HasPassword": true,
"HasConfiguredPassword": true,
"HasConfiguredEasyPassword": false,
"EnableAutoLogin": false,
"LastLoginDate": u.LastLoginAt,
"LastActivityDate": u.UpdatedAt,
"Configuration": map[string]any{
"PlayDefaultAudioTrack": true,
"DisplayCollectionsView": true,
"DisplayMissingEpisodes": false,
"SubtitleMode": "Default",
"EnableNextEpisodeAutoPlay": true,
"AudioLanguagePreference": "",
"SubtitleLanguagePreference": "",
},
"Policy": map[string]any{
"IsAdministrator": u.Role == "admin",
"IsHidden": false,
"IsDisabled": false,
"EnableUserPreferenceAccess": true,
"IsAdministrator": u.Role == "admin",
"IsHidden": false,
"IsDisabled": !u.IsActive,
"EnableUserPreferenceAccess": true,
"EnableRemoteAccess": true,
"EnableMediaPlayback": true,
"EnableAudioPlaybackTranscoding": true,
"EnableVideoPlaybackTranscoding": true,
"EnablePlaybackRemuxing": true,
"EnableLiveTvAccess": false,
"EnableContentDownloading": true,
"EnableSyncTranscoding": true,
"EnableMediaConversion": true,
"EnableAllChannels": true,
"EnableAllFolders": true,
"EnableAllDevices": true,
"AuthenticationProviderId": "Emby.Server.Implementations.LocalAuthenticationProvider",
"PasswordResetProviderId": "Emby.Server.Implementations.LocalPasswordResetProvider",
},
}
}
// Views (Emby's name for libraries).
// ─── Views / MediaFolders ────────────────────────────────────────────────────
// Views 返回 Emby 中"虚拟根目录"——每个 library 一个条目。
func (e *EmbyService) Views(ctx context.Context) (map[string]any, error) {
libs, err := e.repo.Library.List(ctx)
if err != nil {
@@ -88,98 +159,454 @@ func (e *EmbyService) Views(ctx context.Context) (map[string]any, error) {
}
items := make([]map[string]any, 0, len(libs))
for _, l := range libs {
collectionType := "movies"
if l.Type == "tv" {
collectionType = "tvshows"
} else if l.Type == "music" {
collectionType = "music"
}
items = append(items, map[string]any{
"Id": l.ID,
"Name": l.Name,
"CollectionType": collectionType,
"ServerId": "mediastation-go",
"Type": "CollectionFolder",
})
items = append(items, e.libraryAsView(&l))
}
return map[string]any{"Items": items, "TotalRecordCount": len(items)}, nil
}
// Items paginates media in Emby's flat shape.
func (e *EmbyService) Items(ctx context.Context, libraryID string, limit, offset int) (map[string]any, error) {
if limit <= 0 || limit > 200 {
limit = 50
func (e *EmbyService) libraryAsView(l *model.Library) map[string]any {
collectionType := "movies"
switch l.Type {
case "tv":
collectionType = "tvshows"
case "anime":
collectionType = "tvshows" // Emby 没有专门的 anime CollectionType
case "music":
collectionType = "music"
}
if offset < 0 {
offset = 0
return map[string]any{
"Id": l.ID,
"Name": l.Name,
"CollectionType": collectionType,
"ServerId": embyServerID,
"Type": "CollectionFolder",
"IsFolder": true,
"ImageTags": map[string]string{},
"BackdropImageTags": []string{},
"UserData": map[string]any{
"PlaybackPositionTicks": 0,
"PlayCount": 0,
"IsFavorite": false,
"Played": false,
"UnplayedItemCount": 0,
},
}
}
// ─── Items ───────────────────────────────────────────────────────────────────
// ItemsParams 是 /Items 与 /Users/{uid}/Items 共用的查询参数。
type ItemsParams struct {
UserID string
ParentID string
IDs []string
SearchTerm string
IncludeItemTypes []string
Recursive bool
SortBy string
SortOrder string
Limit int
StartIndex int
}
// Items paginates media in Emby's flat shape.
func (e *EmbyService) Items(ctx context.Context, p ItemsParams) (map[string]any, error) {
if p.Limit <= 0 || p.Limit > 500 {
p.Limit = 50
}
if p.StartIndex < 0 {
p.StartIndex = 0
}
q := e.repo.DB.WithContext(ctx).Model(&model.Media{}).Where("deleted_at IS NULL")
if libraryID != "" {
q = q.Where("library_id = ?", libraryID)
if p.ParentID != "" {
// ParentID 既可能是 library_id 也可能是 series_id(剧集详情下钻)
q = q.Where("library_id = ? OR series_id = ?", p.ParentID, p.ParentID)
}
if len(p.IDs) > 0 {
q = q.Where("id IN ?", p.IDs)
}
if p.SearchTerm != "" {
q = q.Where("title LIKE ?", "%"+p.SearchTerm+"%")
}
var total int64
if err := q.Count(&total).Error; err != nil {
return nil, err
}
// 排序
order := "created_at desc"
switch strings.ToLower(p.SortBy) {
case "sortname", "name":
order = "title"
case "premieredate", "productionyear":
order = "year"
case "datecreated":
order = "created_at"
case "communityrating":
order = "rating"
}
if strings.EqualFold(p.SortOrder, "Descending") {
if !strings.HasSuffix(order, " desc") {
order = order + " desc"
}
}
var rows []model.Media
if err := q.Order("created_at desc").Offset(offset).Limit(limit).Find(&rows).Error; err != nil {
if err := q.Order(order).Offset(p.StartIndex).Limit(p.Limit).Find(&rows).Error; err != nil {
return nil, err
}
// User-data: 收藏 + 进度
userFavs := map[string]bool{}
userPos := map[string]int64{}
if p.UserID != "" {
var favs []model.Favorite
_ = e.repo.DB.WithContext(ctx).Where("user_id = ?", p.UserID).Find(&favs).Error
for _, f := range favs {
userFavs[f.MediaID] = true
}
var hist []model.PlaybackHistory
_ = e.repo.DB.WithContext(ctx).Where("user_id = ?", p.UserID).Find(&hist).Error
for _, h := range hist {
userPos[h.MediaID] = h.PositionMs
}
}
items := make([]map[string]any, 0, len(rows))
for _, m := range rows {
items = append(items, e.itemPayload(&m))
items = append(items, e.itemPayload(&m, userFavs[m.ID], userPos[m.ID]))
}
return map[string]any{
"Items": items,
"TotalRecordCount": total,
"StartIndex": offset,
"StartIndex": p.StartIndex,
}, nil
}
func (e *EmbyService) itemPayload(m *model.Media) map[string]any {
// Item 单条目详情。
func (e *EmbyService) Item(ctx context.Context, mediaID, userID string) (map[string]any, error) {
m, err := e.repo.Media.FindByID(ctx, mediaID)
if err != nil {
return nil, err
}
if m == nil {
return nil, nil
}
fav := false
pos := int64(0)
if userID != "" {
var f model.Favorite
ferr := e.repo.DB.WithContext(ctx).Where("user_id = ? AND media_id = ?", userID, mediaID).First(&f).Error
if ferr == nil {
fav = true
}
var h model.PlaybackHistory
herr := e.repo.DB.WithContext(ctx).Where("user_id = ? AND media_id = ?", userID, mediaID).
Order("watched_at desc").First(&h).Error
if herr == nil {
pos = h.PositionMs
}
}
return e.itemPayload(m, fav, pos), nil
}
// LatestItems 最近添加,全库或指定库。
func (e *EmbyService) LatestItems(ctx context.Context, userID, parentID string, limit int) ([]map[string]any, error) {
if limit <= 0 || limit > 100 {
limit = 20
}
q := e.repo.DB.WithContext(ctx).Model(&model.Media{}).Where("deleted_at IS NULL")
if parentID != "" {
q = q.Where("library_id = ?", parentID)
}
var rows []model.Media
if err := q.Order("created_at desc").Limit(limit).Find(&rows).Error; err != nil {
return nil, err
}
favs := map[string]bool{}
if userID != "" {
var fr []model.Favorite
_ = e.repo.DB.WithContext(ctx).Where("user_id = ?", userID).Find(&fr).Error
for _, f := range fr {
favs[f.MediaID] = true
}
}
out := make([]map[string]any, 0, len(rows))
for _, m := range rows {
out = append(out, e.itemPayload(&m, favs[m.ID], 0))
}
return out, nil
}
// ResumeItems 列出有未完成播放进度的媒体。
func (e *EmbyService) ResumeItems(ctx context.Context, userID string, limit int) (map[string]any, error) {
if limit <= 0 || limit > 100 {
limit = 20
}
type row struct {
MediaID string
PositionMs int64
DurationMs int64
}
var hist []model.PlaybackHistory
if err := e.repo.DB.WithContext(ctx).
Where("user_id = ? AND completed = ? AND position_ms > 0", userID, false).
Order("watched_at desc").Limit(limit).Find(&hist).Error; err != nil {
return nil, err
}
if len(hist) == 0 {
return map[string]any{"Items": []any{}, "TotalRecordCount": 0}, nil
}
ids := make([]string, 0, len(hist))
posByID := map[string]int64{}
for _, h := range hist {
ids = append(ids, h.MediaID)
posByID[h.MediaID] = h.PositionMs
}
var medias []model.Media
if err := e.repo.DB.WithContext(ctx).Where("id IN ?", ids).Find(&medias).Error; err != nil {
return nil, err
}
// 维持时间倒序
byID := map[string]*model.Media{}
for i := range medias {
byID[medias[i].ID] = &medias[i]
}
items := make([]map[string]any, 0, len(hist))
for _, h := range hist {
if m, ok := byID[h.MediaID]; ok {
items = append(items, e.itemPayload(m, false, posByID[h.MediaID]))
}
}
return map[string]any{"Items": items, "TotalRecordCount": len(items)}, nil
}
func (e *EmbyService) itemPayload(m *model.Media, fav bool, posMs int64) map[string]any {
itemType := "Movie"
if m.SeasonNum > 0 || m.EpisodeNum > 0 {
itemType = "Episode"
}
imageTags := map[string]string{}
backdropTags := []string{}
if m.PosterURL != "" {
imageTags["Primary"] = m.ID
}
if m.BackdropURL != "" {
backdropTags = append(backdropTags, m.ID+"-bd")
}
runTimeTicks := int64(m.DurationSec) * 10_000_000
durationMs := int64(m.DurationSec) * 1000
played := posMs > 0 && durationMs > 0 && posMs >= durationMs*9/10
pct := 0.0
if durationMs > 0 {
pct = float64(posMs) / float64(durationMs) * 100
}
return map[string]any{
"Id": m.ID,
"Name": m.Title,
"ServerId": "mediastation-go",
"OriginalTitle": m.OriginalName,
"ServerId": embyServerID,
"Type": itemType,
"MediaType": "Video",
"IsFolder": false,
"ProductionYear": m.Year,
"ParentIndexNumber": m.SeasonNum,
"IndexNumber": m.EpisodeNum,
"Overview": m.Overview,
"RunTimeTicks": int64(m.DurationSec) * 10_000_000,
"RunTimeTicks": runTimeTicks,
"CommunityRating": m.Rating,
"MediaSources": []map[string]any{{
"Id": m.ID,
"Path": m.Path,
"Container": m.Container,
"Size": m.SizeBytes,
}},
"Container": m.Container,
"Width": m.Width,
"Height": m.Height,
"DateCreated": m.CreatedAt,
"Path": m.Path,
"ParentId": m.LibraryID,
"SeriesId": m.SeriesID,
"ImageTags": imageTags,
"BackdropImageTags": backdropTags,
"Genres": splitCSV(m.Genres),
"ProviderIds": map[string]string{
"Tmdb": intToStr(m.TMDbID),
"Bangumi": intToStr(m.BangumiID),
},
"UserData": map[string]any{
"PlaybackPositionTicks": posMs * 10_000,
"PlayCount": 0,
"IsFavorite": fav,
"Played": played,
"PlayedPercentage": pct,
},
"MediaSources": []map[string]any{e.mediaSource(m, true)},
}
}
// PlaybackInfo returns the stream URL (caller must append ?token=).
// ─── Playback ────────────────────────────────────────────────────────────────
// PlaybackInfo returns a PlaybackInfoResponse usable by Emby clients.
func (e *EmbyService) PlaybackInfo(ctx context.Context, mediaID string) (map[string]any, error) {
m, err := e.repo.Media.FindByID(ctx, mediaID)
if err != nil || m == nil {
return nil, err
}
url := "/api/stream/" + m.ID
if m.STRMURL != "" {
url = m.STRMURL
}
return map[string]any{
"MediaSources": []map[string]any{{
"Id": m.ID,
"Path": url,
"Protocol": "Http",
"DirectStreamUrl": url,
"Container": m.Container,
"Size": m.SizeBytes,
}},
"PlaySessionId": m.ID,
"MediaSources": []map[string]any{e.mediaSource(m, false)},
"PlaySessionId": fmt.Sprintf("%s-%d", m.ID, time.Now().Unix()),
}, nil
}
// mediaSource 是 /Items 与 /PlaybackInfo 共享的 MediaSource 结构。
//
// asEmbedded=true:嵌在 /Items 列表里,不包含完整 stream URL(避免暴露
// 直链给搜索接口)。/PlaybackInfo 走 false 路径,URL 完整指向
// /api/stream/{id}(Emby 客户端会自动 append ?api_key=token)。
func (e *EmbyService) mediaSource(m *model.Media, asEmbedded bool) map[string]any {
src := map[string]any{
"Id": m.ID,
"Name": m.Title,
"Path": m.Path,
"Container": m.Container,
"Size": m.SizeBytes,
"Protocol": "Http",
"Type": "Default",
"IsRemote": false,
"SupportsTranscoding": true,
"SupportsDirectStream": true,
"SupportsDirectPlay": true,
"SupportsProbing": true,
"RunTimeTicks": int64(m.DurationSec) * 10_000_000,
"MediaStreams": e.mediaStreams(m),
}
if !asEmbedded {
// 完整 URL,让 Infuse 直接 GET。Emby 客户端会自动加 ?api_key=token。
src["DirectStreamUrl"] = "/api/stream/" + m.ID
}
if strings.TrimSpace(m.STRMURL) != "" {
// STRM 重定向:客户端直接拉远端,跳过我们这一层。
src["IsRemote"] = true
src["DirectStreamUrl"] = m.STRMURL
src["Path"] = m.STRMURL
}
return src
}
func (e *EmbyService) mediaStreams(m *model.Media) []map[string]any {
streams := []map[string]any{}
if m.VideoCodec != "" || m.Width > 0 {
streams = append(streams, map[string]any{
"Codec": m.VideoCodec,
"Type": "Video",
"Index": 0,
"Width": m.Width,
"Height": m.Height,
"AspectRatio": "",
"IsDefault": true,
"IsForced": false,
"IsExternal": false,
"DisplayTitle": fmt.Sprintf("%dx%d %s", m.Width, m.Height, m.VideoCodec),
})
}
if m.AudioCodec != "" {
streams = append(streams, map[string]any{
"Codec": m.AudioCodec,
"Type": "Audio",
"Index": 1,
"IsDefault": true,
"IsForced": false,
"IsExternal": false,
})
}
return streams
}
// ─── 收藏 / 已看(Emby 客户端写路径) ──────────────────────────────────────
// SetFavorite 把 mediaID 标为 userID 的收藏。
func (e *EmbyService) SetFavorite(ctx context.Context, userID, mediaID string, favorite bool) error {
if favorite {
var f model.Favorite
err := e.repo.DB.WithContext(ctx).
Where("user_id = ? AND media_id = ?", userID, mediaID).First(&f).Error
if errors.Is(err, gorm.ErrRecordNotFound) {
return e.repo.DB.WithContext(ctx).Create(&model.Favorite{
UserID: userID, MediaID: mediaID,
}).Error
}
return err
}
return e.repo.DB.WithContext(ctx).
Where("user_id = ? AND media_id = ?", userID, mediaID).
Delete(&model.Favorite{}).Error
}
// MarkPlayed 把 mediaID 标为已看(写一个 100% 进度的 history 行)。
func (e *EmbyService) MarkPlayed(ctx context.Context, userID, mediaID string, played bool) error {
if !played {
return e.repo.DB.WithContext(ctx).
Where("user_id = ? AND media_id = ?", userID, mediaID).
Delete(&model.PlaybackHistory{}).Error
}
m, err := e.repo.Media.FindByID(ctx, mediaID)
if err != nil || m == nil {
return errors.New("media not found")
}
dur := int64(m.DurationSec) * 1000
if dur <= 0 {
dur = 1
}
return e.repo.History.Upsert(ctx, &model.PlaybackHistory{
UserID: userID,
MediaID: mediaID,
PositionMs: dur,
DurationMs: dur,
WatchedAt: time.Now(),
Completed: true,
})
}
// RecordProgress 记录播放进度(来自 Emby 客户端的 /Sessions/Playing/Progress)。
func (e *EmbyService) RecordProgress(ctx context.Context, userID, mediaID string, positionTicks, runtimeTicks int64) error {
pos := positionTicks / 10_000
dur := runtimeTicks / 10_000
if dur <= 0 {
// runtimeTicks 缺失时回退到 media.DurationSec
if m, _ := e.repo.Media.FindByID(ctx, mediaID); m != nil {
dur = int64(m.DurationSec) * 1000
}
}
completed := dur > 0 && pos >= dur*9/10
return e.repo.History.Upsert(ctx, &model.PlaybackHistory{
UserID: userID,
MediaID: mediaID,
PositionMs: pos,
DurationMs: dur,
WatchedAt: time.Now(),
Completed: completed,
})
}
// ─── Helpers ─────────────────────────────────────────────────────────────────
func splitCSV(s string) []string {
if strings.TrimSpace(s) == "" {
return []string{}
}
parts := strings.Split(s, ",")
out := make([]string, 0, len(parts))
for _, p := range parts {
p = strings.TrimSpace(p)
if p != "" {
out = append(out, p)
}
}
return out
}
func intToStr(v int) string {
if v == 0 {
return ""
}
return strconv.Itoa(v)
}
+171 -92
View File
@@ -4,13 +4,16 @@
// networks). ImageProxy fronts a remote image URL so the browser only ever
// talks to the MediaStationGo origin. The proxy:
//
// - validates the URL belongs to a small allow-list of trusted hosts,
// - validates the URL scheme is http/https,
// - streams bytes through with a small disk cache under cache/images,
// - falls back to a transparent 1×1 PNG on upstream failure so the UI
// never breaks layout.
// never breaks layout,
// - honors HTTP(S)_PROXY environment variables so users behind GFW can
// route image fetches through their proxy.
package service
import (
"bytes"
"context"
"crypto/sha1"
"encoding/hex"
@@ -29,52 +32,113 @@ import (
"github.com/ShukeBta/MediaStationGo/internal/config"
)
// transparent1x1PNG is a baseline 67-byte PNG used as a fallback when the
// upstream image cannot be retrieved, so browser layouts never collapse.
var transparent1x1PNG = []byte{
0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a,
0x00, 0x00, 0x00, 0x0d, 0x49, 0x48, 0x44, 0x52,
0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01,
0x08, 0x06, 0x00, 0x00, 0x00, 0x1f, 0x15, 0xc4,
0x89, 0x00, 0x00, 0x00, 0x0d, 0x49, 0x44, 0x41,
0x54, 0x78, 0x9c, 0x63, 0x00, 0x01, 0x00, 0x00,
0x05, 0x00, 0x01, 0x0d, 0x0a, 0x2d, 0xb4, 0x00,
0x00, 0x00, 0x00, 0x49, 0x45, 0x4e, 0x44, 0xae,
0x42, 0x60, 0x82,
}
// knownImageHosts are hosts we explicitly recognize. The list is no longer
// a hard allow-list — it only short-circuits cases where we can be 100%
// sure the destination is a public image CDN. Other hosts are accepted as
// long as the scheme is http/https; this is required so users behind GFW
// can configure their own TMDb mirror via secrets.tmdb_image_proxy.
var knownImageHosts = map[string]struct{}{
"image.tmdb.org": {},
"www.themoviedb.org": {},
"lain.bgm.tv": {},
"img.bgm.tv": {},
"webdav.bgm.tv": {},
"img1.doubanio.com": {},
"img2.doubanio.com": {},
"img3.doubanio.com": {},
"img9.doubanio.com": {},
"assets.fanart.tv": {},
"artworks.thetvdb.com": {},
}
// ImageProxy fetches and caches remote images on behalf of the browser.
type ImageProxy struct {
cfg *config.Config
log *zap.Logger
client *http.Client
cacheDir string
allowHost map[string]struct{}
mu sync.Mutex
cfg *config.Config
log *zap.Logger
client *http.Client
cacheDir string
mu sync.Mutex
}
// NewImageProxy is the constructor.
func NewImageProxy(cfg *config.Config, log *zap.Logger) *ImageProxy {
// Honor HTTP(S)_PROXY env vars so deployments behind GFW can pull
// from image.tmdb.org via their HTTP proxy without extra config.
transport := &http.Transport{
Proxy: http.ProxyFromEnvironment,
MaxIdleConns: 32,
MaxIdleConnsPerHost: 8,
IdleConnTimeout: 90 * time.Second,
TLSHandshakeTimeout: 15 * time.Second,
ExpectContinueTimeout: 1 * time.Second,
}
return &ImageProxy{
cfg: cfg,
log: log,
cacheDir: filepath.Join(cfg.Cache.CacheDir, "images"),
client: &http.Client{Timeout: 20 * time.Second},
allowHost: map[string]struct{}{
"image.tmdb.org": {},
"www.themoviedb.org": {},
"lain.bgm.tv": {},
"img.bgm.tv": {},
"webdav.bgm.tv": {},
"img1.doubanio.com": {},
"img2.doubanio.com": {},
"img3.doubanio.com": {},
"img9.doubanio.com": {},
"assets.fanart.tv": {},
"artworks.thetvdb.com": {},
},
client: &http.Client{Timeout: 30 * time.Second, Transport: transport},
}
}
// validateURL parses raw and ensures the scheme is http/https. The host
// allow-list is now advisory — every reachable URL is accepted so users
// can freely configure mirror domains via tmdb_image_proxy.
func (p *ImageProxy) validateURL(raw string) (*url.URL, error) {
if raw == "" {
return nil, errors.New("missing url")
}
u, err := url.Parse(raw)
if err != nil || u.Host == "" {
return nil, errors.New("invalid url")
}
scheme := strings.ToLower(u.Scheme)
if scheme != "http" && scheme != "https" {
return nil, errors.New("unsupported scheme")
}
return u, nil
}
// detectContentType returns the MIME type of data using the first 512 bytes.
func detectContentType(data []byte) string {
if len(data) > 512 {
return http.DetectContentType(data[:512])
}
return http.DetectContentType(data)
}
// servePlaceholder writes a 1×1 transparent PNG to w. Used as a fallback
// when upstream fetch fails so the browser layout stays intact.
func servePlaceholder(w http.ResponseWriter) {
w.Header().Set("Content-Type", "image/png")
w.Header().Set("Cache-Control", "no-store")
w.WriteHeader(http.StatusOK)
_, _ = w.Write(transparent1x1PNG)
}
// Serve writes the requested image to w. Caller is expected to validate
// the JWT before invoking it.
func (p *ImageProxy) Serve(ctx context.Context, w http.ResponseWriter, raw string) error {
if raw == "" {
return errors.New("missing url")
}
u, err := url.Parse(raw)
if err != nil || u.Scheme == "" || u.Host == "" {
return errors.New("invalid url")
}
if _, ok := p.allowHost[strings.ToLower(u.Host)]; !ok {
return errors.New("host not allowed")
func (p *ImageProxy) Serve(ctx context.Context, w http.ResponseWriter, r *http.Request, raw string) error {
u, err := p.validateURL(raw)
if err != nil {
// Bad URL is the only request-side error; everything else falls
// through to the placeholder so the UI stays clean.
return err
}
host := strings.ToLower(u.Host)
// Cache key = sha1(url)
sum := sha1.Sum([]byte(raw))
@@ -82,77 +146,96 @@ func (p *ImageProxy) Serve(ctx context.Context, w http.ResponseWriter, raw strin
cachePath := filepath.Join(p.cacheDir, key)
// Cache hit.
if f, err := os.Open(cachePath); err == nil {
defer f.Close()
stat, _ := f.Stat()
if data, err := os.ReadFile(cachePath); err == nil && len(data) > 0 {
w.Header().Set("Content-Type", detectContentType(data))
w.Header().Set("Cache-Control", "public, max-age=604800")
http.ServeContent(w, &http.Request{}, key, stat.ModTime(), f)
stat, _ := os.Stat(cachePath)
modTime := time.Now()
if stat != nil {
modTime = stat.ModTime()
}
http.ServeContent(w, r, key, modTime, bytes.NewReader(data))
return nil
}
// Cache miss → fetch upstream.
if err := os.MkdirAll(p.cacheDir, 0o755); err != nil {
return err
p.log.Warn("imageproxy: mkdir failed", zap.String("dir", p.cacheDir), zap.Error(err))
servePlaceholder(w)
return nil
}
req, err := http.NewRequestWithContext(ctx, http.MethodGet, raw, nil)
if err != nil {
return err
p.log.Warn("imageproxy: build request failed", zap.String("url", raw), zap.Error(err))
servePlaceholder(w)
return nil
}
req.Header.Set("User-Agent", "MediaStationGo/0.1")
resp, err := p.client.Do(req)
if err != nil {
return err
p.log.Warn("imageproxy: upstream fetch failed",
zap.String("host", host), zap.Error(err))
servePlaceholder(w)
return nil
}
defer resp.Body.Close()
if resp.StatusCode >= 400 {
return errors.New("upstream returned " + resp.Status)
p.log.Warn("imageproxy: upstream returned non-OK",
zap.String("host", host), zap.String("status", resp.Status))
servePlaceholder(w)
return nil
}
data, err := io.ReadAll(io.LimitReader(resp.Body, 32<<20)) // 32 MiB cap
if err != nil || len(data) == 0 {
p.log.Warn("imageproxy: read upstream body failed",
zap.String("host", host), zap.Error(err))
servePlaceholder(w)
return nil
}
// Write to a temp file then rename for atomicity.
tmp, err := os.CreateTemp(p.cacheDir, "img-*.tmp")
if err != nil {
return err
}
if _, err := io.Copy(tmp, resp.Body); err != nil {
tmp.Close()
os.Remove(tmp.Name())
return err
}
tmp.Close()
if err := os.Rename(tmp.Name(), cachePath); err != nil {
os.Remove(tmp.Name())
}
// Now serve the freshly cached file.
f, err := os.Open(cachePath)
if err != nil {
return err
}
defer f.Close()
stat, _ := f.Stat()
for _, h := range []string{"Content-Type", "Content-Length", "ETag", "Last-Modified"} {
if v := resp.Header.Get(h); v != "" {
w.Header().Set(h, v)
p.mu.Lock()
tmp, tmpErr := os.CreateTemp(p.cacheDir, "img-*.tmp")
if tmpErr == nil {
if _, werr := tmp.Write(data); werr == nil {
tmp.Close()
if rerr := os.Rename(tmp.Name(), cachePath); rerr != nil {
_ = os.Remove(tmp.Name())
}
} else {
tmp.Close()
_ = os.Remove(tmp.Name())
}
}
p.mu.Unlock()
ctype := resp.Header.Get("Content-Type")
if ctype == "" {
ctype = detectContentType(data)
}
w.Header().Set("Content-Type", ctype)
if v := resp.Header.Get("Content-Length"); v != "" {
w.Header().Set("Content-Length", v)
}
if v := resp.Header.Get("ETag"); v != "" {
w.Header().Set("ETag", v)
}
if v := resp.Header.Get("Last-Modified"); v != "" {
w.Header().Set("Last-Modified", v)
}
w.Header().Set("Cache-Control", "public, max-age=604800")
http.ServeContent(w, &http.Request{}, key, stat.ModTime(), f)
http.ServeContent(w, r, key, time.Now(), bytes.NewReader(data))
return nil
}
// Fetch 拉取远程图片并返回字节和 Content-Type(带缓存)。
func (p *ImageProxy) Fetch(ctx context.Context, raw string) ([]byte, string, error) {
if raw == "" {
return nil, "", errors.New("missing url")
}
u, err := url.Parse(raw)
if err != nil || u.Scheme == "" || u.Host == "" {
return nil, "", errors.New("invalid url")
}
if _, ok := p.allowHost[strings.ToLower(u.Host)]; !ok {
return nil, "", errors.New("host not allowed")
u, err := p.validateURL(raw)
if err != nil {
return nil, "", err
}
// Cache lookup
@@ -160,10 +243,8 @@ func (p *ImageProxy) Fetch(ctx context.Context, raw string) ([]byte, string, err
key := hex.EncodeToString(sum[:])
cachePath := filepath.Join(p.cacheDir, key)
if data, err := os.ReadFile(cachePath); err == nil {
// Content-Type from file extension or upstream headers — use a simple detect
ctype := detectContentType(data)
return data, ctype, nil
if data, err := os.ReadFile(cachePath); err == nil && len(data) > 0 {
return data, detectContentType(data), nil
}
// Fetch upstream
@@ -186,34 +267,32 @@ func (p *ImageProxy) Fetch(ctx context.Context, raw string) ([]byte, string, err
return nil, "", errors.New("upstream returned " + resp.Status)
}
data, err := io.ReadAll(resp.Body)
data, err := io.ReadAll(io.LimitReader(resp.Body, 32<<20))
if err != nil {
return nil, "", err
}
// Write to cache
tmp, err := os.CreateTemp(p.cacheDir, "img-*.tmp")
if err == nil {
if _, err := tmp.Write(data); err == nil {
p.mu.Lock()
tmp, terr := os.CreateTemp(p.cacheDir, "img-*.tmp")
if terr == nil {
if _, werr := tmp.Write(data); werr == nil {
tmp.Close()
os.Rename(tmp.Name(), cachePath)
if rerr := os.Rename(tmp.Name(), cachePath); rerr != nil {
_ = os.Remove(tmp.Name())
}
} else {
tmp.Close()
os.Remove(tmp.Name())
_ = os.Remove(tmp.Name())
}
}
p.mu.Unlock()
ctype := resp.Header.Get("Content-Type")
if ctype == "" {
ctype = detectContentType(data)
}
// host is unused here but referenced for log clarity in the future.
_ = u
return data, ctype, nil
}
// detectContentType 通过前 512 字节检测 MIME 类型。
func detectContentType(data []byte) string {
if len(data) > 512 {
return http.DetectContentType(data[:512])
}
return http.DetectContentType(data)
}
+28 -1
View File
@@ -96,6 +96,17 @@ func (o *OrganizerService) OrganizeMedia(ctx context.Context, mediaID string) (s
return dst, nil
}
// Refuse to overwrite an existing different file. 当多个 release(如
// 不同字幕组、不同源)刮削后被统一改名,原本不重复的文件会被映射到
// 同一个目标路径,盲目 move 会导致后者覆盖前者,造成数据丢失。
if _, err := os.Stat(dst); err == nil {
o.log.Warn("organize skipped: destination already exists",
zap.String("media", m.ID),
zap.String("from", m.Path),
zap.String("to", dst))
return dst, nil
}
// Create directories.
if err := os.MkdirAll(filepath.Dir(dst), 0o755); err != nil {
return "", err
@@ -149,7 +160,13 @@ func (o *OrganizerService) OrganizeLibrary(ctx context.Context, libraryID string
// moveFile tries os.Rename first (instant on same fs), then falls back
// to copy + remove for cross-device moves.
//
// 重要:如果 dst 已经存在,moveFile 会直接报错而不是覆盖。OrganizeMedia
// 已经在调用前做过 stat 检查,这里是第二道防线。
func moveFile(src, dst string) error {
if _, err := os.Stat(dst); err == nil {
return fmt.Errorf("destination already exists: %s", dst)
}
if err := os.Rename(src, dst); err == nil {
return nil
}
@@ -158,9 +175,19 @@ func moveFile(src, dst string) error {
if err != nil {
return err
}
if err := os.WriteFile(dst, data, 0o644); err != nil {
// O_EXCL 保证不会覆盖已存在的目标。
f, err := os.OpenFile(dst, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o644)
if err != nil {
return err
}
if _, werr := f.Write(data); werr != nil {
f.Close()
os.Remove(dst)
return werr
}
if cerr := f.Close(); cerr != nil {
return cerr
}
return os.Remove(src)
}
+16 -2
View File
@@ -114,6 +114,10 @@ func (q *QBitClient) Login(ctx context.Context) error {
}
// AddTorrent submits a magnet URL or HTTP(S) URL to qBittorrent.
//
// qBittorrent 的 /api/v2/torrents/add 在很多失败场景下仍然返回 HTTP 200
// 但 body 里写 "Fails."。我们把这些情况也识别为错误并返回,避免
// "API 返回 200 → 我们告诉前端成功 → qb 中却没下载" 这种迷惑性失败。
func (q *QBitClient) AddTorrent(ctx context.Context, magnetOrURL, savePath string) error {
q.mu.Lock()
defer q.mu.Unlock()
@@ -137,16 +141,26 @@ func (q *QBitClient) AddTorrent(ctx context.Context, magnetOrURL, savePath strin
}
req.Header.Set("Content-Type", w.FormDataContentType())
req.Header.Set("Referer", q.cfg.BaseURL)
req.Header.Set("Origin", q.cfg.BaseURL)
resp, err := q.client.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
raw, _ := io.ReadAll(resp.Body)
bodyText := strings.TrimSpace(string(raw))
if resp.StatusCode >= 400 {
raw, _ := io.ReadAll(resp.Body)
return fmt.Errorf("qbittorrent add: %d: %s", resp.StatusCode, strings.TrimSpace(string(raw)))
return fmt.Errorf("qbittorrent add: HTTP %d: %s", resp.StatusCode, bodyText)
}
// qb 的成功响应是 "Ok." 或空体;任何 "Fails." 视为失败。
if strings.EqualFold(bodyText, "Fails.") {
return fmt.Errorf("qbittorrent add: 拒绝任务 (检查 URL 是否需要认证或 savePath 是否可写)")
}
q.log.Info("qbittorrent: torrent added",
zap.String("url", magnetOrURL),
zap.String("save_path", savePath),
zap.String("body", bodyText))
return nil
}
+59 -7
View File
@@ -60,12 +60,26 @@ var yearPattern = regexp.MustCompile(`(?:^|[^\d])(19\d{2}|20\d{2})(?:[^\d]|$)`)
// noiseTokens are stripped before search.
var noiseTokens = []string{
// 视频规格
"1080p", "2160p", "4k", "720p", "480p",
"hdrip", "bluray", "blu-ray", "webrip", "web-dl", "web",
"x264", "x265", "h264", "h265", "hevc", "avc",
"hdr", "sdr", "dts", "ddp", "atmos", "aac", "ac3", "flac",
"remux", "extended", "uncut", "directors-cut", "directors_cut",
"hkfree", "yify", "rarbg", "ettv", "fgt",
// 流媒体平台 / 字幕组 / 国家版本(动漫常见)
"netflix", "nf", "amzn", "hulu", "disney", "max", "hbo",
"linetv", "ourtv", "iqiyi", "youku", "bilibili", "qiyi", "krj",
"crunchyroll", "funimation", "anidb", "horriblesubs", "subsplease",
"erai-raws", "judas", "asw", "smcat", "leopard-raws", "ohys-raws",
// 中文字幕标记
"zm", "zw", "ch", "chs", "cht", "cn", "tc", "sc",
"中字", "繁字", "简中", "繁中", "国语", "粤语", "日语",
// 季数前缀残留 — ParseEpisode 已抽取过
"season",
}
// bracketedTag matches "[anything]" or "(anything)" segments.
@@ -94,12 +108,23 @@ func CleanQuery(raw string) (title string, year int) {
for _, t := range noiseTokens {
lower = strings.ReplaceAll(lower, t, " ")
}
for _, sep := range []string{".", "_", "-", "[", "]", "(", ")"} {
for _, sep := range []string{".", "_", "-", "[", "]", "(", ")", "×", "x"} {
lower = strings.ReplaceAll(lower, sep, " ")
}
fields := strings.Fields(lower)
title = strings.Join(fields, " ")
return strings.TrimSpace(title), year
// 拆分后丢掉过短(≤1)且全为 ASCII 数字 / 字母的"碎片",避免
// 「2」「0」「v」之类残留干扰 TMDb 搜索。中文字符不算碎片。
out := make([]string, 0, 8)
for _, w := range strings.Fields(lower) {
if len(w) <= 1 {
r := []rune(w)
if len(r) == 1 && r[0] < 128 {
continue
}
}
out = append(out, w)
}
title = strings.TrimSpace(strings.Join(out, " "))
return title, year
}
// EnrichOne runs the provider chain for a single media row.
@@ -126,7 +151,6 @@ func (s *ScraperService) EnrichOne(ctx context.Context, m *model.Media) error {
Update("scrape_status", "no_match").Error
return nil
}
// Optional Fanart upgrade.
if s.fanart != nil && s.fanart.Enabled() && match.TMDbID > 0 {
if a, err := s.fanart.MovieArtwork(ctx, match.TMDbID); err == nil && a != nil {
@@ -197,6 +221,16 @@ func (s *ScraperService) EnrichOne(ctx context.Context, m *model.Media) error {
// lookup runs the provider chain. When the library is missing we fall
// back to TMDb only.
//
// 库类型决定首选 provider:
//
// anime -> Bangumi -> TMDb /search/tv -> TMDb /search/movie
// tv -> TheTVDB -> TMDb /search/tv -> TMDb /search/movie
// movie -> TMDb /search/movie
// (空) -> TMDb /search/movie
//
// 任何 provider 错误都不会中止链式查询;只要返回 nil/err,就继续走下一个
// provider。这避免了 Bangumi token 未配置时 anime 库整体失败的问题。
func (s *ScraperService) lookup(ctx context.Context, lib *model.Library, query string, year int) *Match {
kind := ""
if lib != nil {
@@ -204,21 +238,35 @@ func (s *ScraperService) lookup(ctx context.Context, lib *model.Library, query s
}
switch kind {
case "anime":
if s.bangumi != nil {
if s.bangumi != nil && s.bangumi.Enabled() {
if m, err := s.bangumi.Search(ctx, query); err == nil && m != nil {
return m
} else if err != nil {
s.log.Debug("bangumi search failed", zap.String("query", query), zap.Error(err))
}
}
case "tv":
if s.thetvdb != nil && s.thetvdb.Enabled() {
if m, err := s.thetvdb.SearchSeries(ctx, query); err == nil && m != nil {
return m
} else if err != nil {
s.log.Debug("thetvdb search failed", zap.String("query", query), zap.Error(err))
}
}
}
if s.tmdb != nil && s.tmdb.Enabled() {
// anime / tv 先用 TMDb /search/tv(剧名通常是 TV 类目)。
if kind == "anime" || kind == "tv" {
if m, err := s.tmdb.SearchTV(ctx, query, year); err == nil && m != nil {
return m
} else if err != nil {
s.log.Debug("tmdb tv search failed", zap.String("query", query), zap.Error(err))
}
}
if m, err := s.tmdb.SearchMovie(ctx, query, year); err == nil && m != nil {
return m
} else if err != nil {
s.log.Debug("tmdb movie search failed", zap.String("query", query), zap.Error(err))
}
}
return nil
@@ -226,9 +274,13 @@ func (s *ScraperService) lookup(ctx context.Context, lib *model.Library, query s
// EnrichLibrary runs the provider chain for every "pending" media in a
// library. It throttles to 4 RPS and publishes a summary event when done.
//
// Pending status includes both the canonical "pending" string and the
// empty / NULL values, because MediaRepository.Upsert can wipe the GORM
// default when re-running a scan over an already-existing row.
func (s *ScraperService) EnrichLibrary(ctx context.Context, libraryID string) (int, error) {
var rows []model.Media
q := s.repo.DB.Where("scrape_status = ?", "pending")
q := s.repo.DB.Where("scrape_status IS NULL OR scrape_status = '' OR scrape_status = ?", "pending")
if libraryID != "" {
q = q.Where("library_id = ?", libraryID)
}
+53 -8
View File
@@ -81,8 +81,16 @@ func (s *SiteService) Delete(ctx context.Context, id string) error {
// TestConnection tries to reach the site's base URL with the configured
// credentials and reports success/failure.
// Now uses helper.TestSiteConnectivity with browser-like headers
// and optional FlareSolverr support.
//
// 测试逻辑(与参考项目 ShukeBta/MediaStation 对齐):
//
// 1. 优先调用对应站点适配器的 Authenticate(),让 PT 站点(M-Team / UNIT3D /
// Gazelle 等)使用各自的开放 API 验证,而不是去拉首页 HTML——后者通常
// 被 Cloudflare 直接 403 但 API 能正常访问。
// 2. 适配器不可用或站点类型未知时,回退到 helper.TestSiteConnectivity 的
// 通用浏览器头 GET 方案。
// 3. helper.TestSiteConnectivity 在全局 FlareSolverr 启用且站点开启了
// BrowserEmulation 时,会自动走 FlareSolverr。
func (s *SiteService) TestConnection(ctx context.Context, id string) (bool, string, error) {
site, err := s.FindByID(ctx, id)
if err != nil || site == nil {
@@ -94,25 +102,58 @@ func (s *SiteService) TestConnection(ctx context.Context, id string) (bool, stri
if timeout <= 0 {
timeout = 15
}
// Use configured FlareSolverr URL (may be empty if not configured)
flareSolverrURL := s.flareSolverrURL
// ── Path 1: site-aware adapter Authenticate ────────────────────────
// custom_rss 没有真适配器,跳过;其它类型先尝试针对性认证端点。
if adapter := NewSiteAdapter(site); adapter != nil && site.Type != "" && site.Type != "custom_rss" {
cfg := s.siteModelToConfig(site)
actx, cancel := context.WithTimeout(ctx, time.Duration(timeout)*time.Second)
defer cancel()
if authErr := adapter.Authenticate(actx, cfg); authErr == nil {
now := time.Now()
_ = s.repo.DB.WithContext(ctx).Model(&model.Site{}).Where("id = ?", id).
Updates(map[string]any{
"login_status": "ok",
"last_error": "",
"last_check_at": &now,
}).Error
return true, "连接成功", nil
} else {
s.log.Warn("site adapter authenticate failed, falling back to generic test",
zap.String("site", site.Name),
zap.String("type", site.Type),
zap.Error(authErr))
// 回退到通用 GET 测试 — 给 Cookie/RSS 类站点一个机会
}
}
// ── Path 2: generic GET with browser headers / FlareSolverr ───────
ok, msg, err := helper.TestSiteConnectivity(site, flareSolverrURL, timeout, s.log)
if err != nil {
now := time.Now()
_ = s.repo.DB.WithContext(ctx).Model(&model.Site{}).Where("id = ?", id).
Updates(map[string]any{"last_error": err.Error(), "last_check_at": &now}).Error
Updates(map[string]any{
"login_status": "fail",
"last_error": err.Error(),
"last_check_at": &now,
}).Error
return false, err.Error(), nil
}
loginStatus := "ok"
storedError := ""
if !ok {
loginStatus = "fail"
storedError = msg
}
now := time.Now()
_ = s.repo.DB.WithContext(ctx).Model(&model.Site{}).Where("id = ?", id).
Updates(map[string]any{"last_error": loginStatus, "last_check_at": &now}).Error
Updates(map[string]any{
"login_status": loginStatus,
"last_error": storedError,
"last_check_at": &now,
}).Error
return ok, msg, nil
}
@@ -172,8 +213,11 @@ func (s *SiteService) Search(ctx context.Context, keyword string) ([]SearchResul
result, err := adapter.Search(ctxWithTimeout, cfg, keyword, 1)
if err != nil {
s.log.Debug("site search failed",
zap.String("site", site.Name), zap.Error(err))
s.log.Warn("site search failed",
zap.String("site", site.Name),
zap.String("type", site.Type),
zap.String("url", site.URL),
zap.Error(err))
return
}
if result == nil {
@@ -249,5 +293,6 @@ func (svc *SiteService) siteModelToConfig(s *model.Site) SiteConfig {
Timeout: timeout,
Extra: extra,
FlareSolverrURL: flareSolverrURL,
UseProxy: s.UseProxy,
}
}
+206 -53
View File
@@ -8,6 +8,7 @@ import (
"io"
"net/http"
"net/url"
"os"
"regexp"
"strconv"
"strings"
@@ -30,6 +31,7 @@ type SiteConfig struct {
Timeout time.Duration // 请求超时
Extra map[string]string // JSON 扩展配置
FlareSolverrURL string // FlareSolverr 服务地址(用于浏览器模拟绕过 Cloudflare/WAF)
UseProxy bool // 通过 HTTP(S)_PROXY 环境变量出站
}
// SiteSearchResult 站点搜索结果(按站点分组的批量搜索结果)。
@@ -97,8 +99,14 @@ type SiteAdapter interface {
}
// newHTTPClient 创建带有认证头的 HTTP 客户端。
// 当 cfg.UseProxy 为 true 时,会读取 HTTP(S)_PROXY 环境变量;
// 否则忽略环境变量直连。
func newHTTPClient(cfg SiteConfig, timeout time.Duration) *http.Client {
return &http.Client{Timeout: timeout}
secs := int(timeout.Seconds())
if secs <= 0 {
secs = 30
}
return helper.NewSiteHTTPClient(secs, cfg.UseProxy)
}
// buildRequest 构建带认证的 HTTP 请求。
@@ -114,13 +122,11 @@ func buildRequest(ctx context.Context, method, rawURL string, cfg SiteConfig, bo
req.Header.Set("Cookie", cfg.Cookie)
}
case "api_key":
// MTeam 使用 Authorization: Bearer 格式
if cfg.Type == "mteam" {
if cfg.APIKey != "" {
req.Header.Set("Authorization", "Bearer "+cfg.APIKey)
}
} else if cfg.APIKey != "" {
req.Header.Set("X-API-Key", cfg.APIKey)
// 与参考项目(ShukeBta/MediaStation)的 ApplySiteAuthHeaders 对齐:
// M-Team / UNIT3D 等开放 API 的 PT 站点都使用 `x-api-key` 头部,
// 不要再为 mteam 单独走 Authorization: Bearer,否则服务端会 401。
if cfg.APIKey != "" {
req.Header.Set("x-api-key", cfg.APIKey)
}
case "auth_header":
if cfg.AuthHeader != "" {
@@ -169,7 +175,14 @@ func doRequest(ctx context.Context, client *http.Client, method, rawURL string,
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
}
resp, err := client.Do(req)
// 当站点开启了「使用代理」开关时,使用本次请求专用的、读取 HTTP(S)_PROXY
// 的 client;否则沿用适配器持有的全局 client。这与前端勾选行为对齐。
httpClient := client
if cfg.UseProxy {
httpClient = newHTTPClient(cfg, cfg.Timeout)
}
resp, err := httpClient.Do(req)
if err != nil {
return nil, 0, err
}
@@ -197,33 +210,32 @@ func NewNexusPHPAdapter() *NexusPHPAdapter {
}
func (a *NexusPHPAdapter) Authenticate(ctx context.Context, cfg SiteConfig) error {
resp, err := buildRequest(ctx, "GET", cfg.URL+"/index.php", cfg, nil)
if err != nil {
return fmt.Errorf("build request: %w", err)
}
httpResp, err := a.client.Do(resp)
// 走 doRequest 以便复用代理 / FlareSolverr / 浏览器头。
data, status, err := doRequest(ctx, a.client, "GET", cfg.URL+"/index.php", cfg, nil)
if err != nil {
return fmt.Errorf("request failed: %w", err)
}
defer httpResp.Body.Close()
if httpResp.StatusCode == http.StatusFound {
if status == http.StatusFound {
return fmt.Errorf("authentication failed: redirected to login page")
}
if httpResp.StatusCode != http.StatusOK {
return fmt.Errorf("authentication failed: status %d", httpResp.StatusCode)
if status == http.StatusUnauthorized || status == http.StatusForbidden {
return fmt.Errorf("authentication failed: status %d", status)
}
if status >= 400 {
return fmt.Errorf("authentication failed: status %d", status)
}
body, _ := io.ReadAll(httpResp.Body)
bodyStr := string(body)
// NexusPHP 登录页面通常包含 logout 或 userdetails
if strings.Contains(bodyStr, "userdetails") || strings.Contains(bodyStr, "logout") {
body := string(data)
// NexusPHP 登录后页面通常包含 logout 或 userdetails;
// 仅当二者都不存在且明确显示登录表单时才判失败。
if strings.Contains(body, "userdetails") || strings.Contains(body, "logout") || strings.Contains(body, "退出") {
return nil
}
// Check for common login indicators
if strings.Contains(bodyStr, "login") && !strings.Contains(bodyStr, "userdetails") {
if strings.Contains(body, "takelogin.php") || strings.Contains(body, "id=\"loginform\"") {
return fmt.Errorf("authentication failed: not logged in")
}
// 状态码 OK 但页面不含明显标记时不再武断判失败。
return nil
}
@@ -843,33 +855,68 @@ func NewMTeamAdapter() *MTeamAdapter {
}
func (a *MTeamAdapter) Authenticate(ctx context.Context, cfg SiteConfig) error {
// 与 ShukeBta/MediaStation 参考实现对齐:
// 用 camelCase 参数(pageNumber / pageSize),同时接受 code 为字符串 "0"
// 或数值 0;兼容 M-Team v3 API 不同版本的返回。
u := cfg.URL + "/api/torrent/search"
payload := `{"mode":"search","keyword":"","page":1,"pageSize":1}`
payload := `{"pageNumber":1,"pageSize":1,"keyword":"test"}`
data, status, err := doRequestJSON(ctx, a.client, "POST", u, cfg, []byte(payload))
// 调试开关:MEDIASTATION_DEBUG_MTEAM=1 时把请求/响应详情写入 stderr。
if os.Getenv("MEDIASTATION_DEBUG_MTEAM") == "1" {
preview := string(data)
if len(preview) > 800 {
preview = preview[:800] + "..."
}
fmt.Fprintf(os.Stderr,
"[DEBUG mteam.Authenticate] url=%s status=%d err=%v body=%s\n",
u, status, err, preview)
}
if err != nil {
return fmt.Errorf("authenticate: %w", err)
}
if status == http.StatusUnauthorized {
return fmt.Errorf("authentication failed: unauthorized")
preview := string(data)
if len(preview) > 400 {
preview = preview[:400] + "..."
}
if status == http.StatusUnauthorized || status == http.StatusForbidden {
return fmt.Errorf("authentication failed: status %d, body=%s", status, preview)
}
if status >= 300 && status < 400 {
return fmt.Errorf("authentication failed: HTTP %d (API Key 无效或未登录), body=%s", status, preview)
}
if status != http.StatusOK {
return fmt.Errorf("authenticate failed: status %d", status)
return fmt.Errorf("authenticate failed: status %d, body=%s", status, preview)
}
var resp map[string]interface{}
if err := json.Unmarshal(data, &resp); err == nil {
if code, ok := resp["code"].(float64); ok && code != 0 {
return fmt.Errorf("authentication failed: code %v", code)
}
if err := json.Unmarshal(data, &resp); err != nil {
return fmt.Errorf("parse response: %w (body=%s)", err, preview)
}
return nil
codeStr := ""
switch v := resp["code"].(type) {
case string:
codeStr = v
case float64:
codeStr = strconv.Itoa(int(v))
}
if codeStr == "0" || codeStr == "200" {
return nil
}
msg, _ := resp["message"].(string)
if msg == "" {
msg = fmt.Sprintf("code=%s", codeStr)
}
return fmt.Errorf("authentication failed: %s (body=%s)", msg, preview)
}
func (a *MTeamAdapter) Search(ctx context.Context, cfg SiteConfig, keyword string, page int) (*SiteSearchResult, error) {
// 与参考项目对齐:使用 camelCase 字段名,page 从 1 开始。
if page <= 0 {
page = 1
}
payload := map[string]interface{}{
"mode": "search",
"keyword": keyword,
"page": page,
"pageSize": 50,
"keyword": keyword,
"pageNumber": page,
"pageSize": 50,
}
body, _ := json.Marshal(payload)
@@ -886,11 +933,16 @@ func (a *MTeamAdapter) Search(ctx context.Context, cfg SiteConfig, keyword strin
}
func (a *MTeamAdapter) Browse(ctx context.Context, cfg SiteConfig, category string, page int) (*SiteSearchResult, error) {
if page <= 0 {
page = 1
}
payload := map[string]interface{}{
"mode": "browse",
"category": category,
"page": page,
"pageSize": 50,
"keyword": "",
"pageNumber": page,
"pageSize": 50,
}
if category != "" {
payload["categories"] = []string{category}
}
body, _ := json.Marshal(payload)
@@ -964,30 +1016,122 @@ func (a *MTeamAdapter) GetDetail(ctx context.Context, cfg SiteConfig, id string)
return detail, nil
}
// GetDownloadURL 解析 M-Team 种子的真实下载链接。
//
// M-Team v3 流程:
//
// POST /api/torrent/genDlToken?id={tid} (带 x-api-key)
// → {"code":"0","data":"https://api.m-team.cc/api/rss/dlv2?sign=..."}
//
// 拿到的 sign URL 可被任何下载客户端无认证地直接 GET。这是参考项目
// (ShukeBta/MediaStation) 的 _download_torrent_file 方法的子集。
func (a *MTeamAdapter) GetDownloadURL(ctx context.Context, cfg SiteConfig, id string) (string, error) {
return cfg.URL + "/api/torrent/detail?id=" + id, nil
u := cfg.URL + "/api/torrent/genDlToken?id=" + id
// genDlToken 是 POST 但参数走 query string;body 留空。
data, status, err := doRequestJSON(ctx, a.client, "POST", u, cfg, []byte("{}"))
if err != nil {
return "", fmt.Errorf("genDlToken: %w", err)
}
if status >= 300 {
return "", fmt.Errorf("genDlToken: HTTP %d", status)
}
var resp map[string]interface{}
if err := json.Unmarshal(data, &resp); err != nil {
return "", fmt.Errorf("genDlToken parse: %w", err)
}
codeStr := ""
switch v := resp["code"].(type) {
case string:
codeStr = v
case float64:
codeStr = strconv.Itoa(int(v))
}
if codeStr != "0" && codeStr != "200" {
msg, _ := resp["message"].(string)
if msg == "" {
msg = "unknown error"
}
return "", fmt.Errorf("genDlToken: %s", msg)
}
dl, _ := resp["data"].(string)
if dl == "" {
return "", fmt.Errorf("genDlToken: empty data field")
}
return dl, nil
}
// parseMTeamJSON 解析 MTeam JSON 响应。
// parseMTeamJSON 解析 MTeam v3 JSON 响应。
//
// 响应结构(与 ShukeBta/MediaStation 参考项目一致):
//
// {
// "code": "0", // 字符串 "0" 表示成功
// "message": "SUCCESS",
// "data": {
// "total": "123",
// "data": [ ... ] // 旧字段名 "lists" 已被替换为 "data"
// }
// }
func parseMTeamJSON(data []byte, siteName, baseURL string) (*SiteSearchResult, error) {
var resp struct {
Code int `json:"code"`
Data struct {
Total int `json:"total"`
Lists []map[string]interface{} `json:"lists"`
} `json:"data"`
}
if err := json.Unmarshal(data, &resp); err != nil {
// 用 map 反序列化以兼容 code/total 既可能是字符串又可能是数字。
var raw map[string]interface{}
if err := json.Unmarshal(data, &raw); err != nil {
return nil, fmt.Errorf("parse JSON: %w", err)
}
// code 兼容字符串与数字。
codeStr := ""
switch v := raw["code"].(type) {
case string:
codeStr = v
case float64:
codeStr = strconv.Itoa(int(v))
}
if codeStr != "" && codeStr != "0" && codeStr != "200" {
msg, _ := raw["message"].(string)
if msg == "" {
msg = fmt.Sprintf("code=%s", codeStr)
}
return nil, fmt.Errorf("mteam: %s", msg)
}
dataField, _ := raw["data"].(map[string]interface{})
if dataField == nil {
return &SiteSearchResult{SiteName: siteName, Items: []TorrentItem{}}, nil
}
// total 兼容字符串与数字。
total := 0
switch v := dataField["total"].(type) {
case string:
total, _ = strconv.Atoi(v)
case float64:
total = int(v)
}
// data.data(v3)优先;兜底兼容旧的 data.lists。
var rows []interface{}
switch v := dataField["data"].(type) {
case []interface{}:
rows = v
}
if rows == nil {
if v, ok := dataField["lists"].([]interface{}); ok {
rows = v
}
}
result := &SiteSearchResult{
SiteName: siteName,
Items: []TorrentItem{},
Total: resp.Data.Total,
Total: total,
}
for _, t := range resp.Data.Lists {
for _, rawT := range rows {
t, ok := rawT.(map[string]interface{})
if !ok {
continue
}
item := TorrentItem{}
if v, ok := t["id"].(string); ok {
item.ID = v
@@ -1007,6 +1151,11 @@ func parseMTeamJSON(data []byte, siteName, baseURL string) (*SiteSearchResult, e
}
if v, ok := t["size"].(float64); ok {
item.Size = int64(v)
} else if v, ok := t["size"].(string); ok {
// v3 API 把 size 序列化成字符串。
if n, err := strconv.ParseInt(v, 10, 64); err == nil {
item.Size = n
}
}
if v, ok := t["status"].(map[string]interface{}); ok {
if seeders, ok := v["seeders"].(float64); ok {
@@ -1027,6 +1176,10 @@ func parseMTeamJSON(data []byte, siteName, baseURL string) (*SiteSearchResult, e
}
item.DetailURL = baseURL + "/detail/" + item.ID
// 标记 download_url 指向 genDlToken;真正的下载链接由 handler 层
// 在用户点"下载"时通过 MTeamAdapter.GetDownloadURL 解析。
// 这样前端 SiteSearchPage 才知道这一行有可用的下载入口。
item.DownloadURL = baseURL + "/api/torrent/genDlToken?id=" + item.ID
result.Items = append(result.Items, item)
}
+80 -1
View File
@@ -58,7 +58,20 @@ func NewTMDbProvider(cfg *config.Config, log *zap.Logger, apiConfig *APIConfigSe
apiConfig: apiConfig,
base: base,
imgCDN: img,
client: &http.Client{Timeout: 15 * time.Second},
// 默认 8s 超时:首页同时调 trending + popular,15s 太久会让用户感觉
// 卡死。如果 TMDb 真有问题,handler 层会快速降级返回空列表。
// 同时让 client 显式读 HTTP(S)_PROXY 环境变量——这是 GFW 内部署最低
// 成本能拉到 TMDb 的方式。
client: &http.Client{
Timeout: 8 * time.Second,
Transport: &http.Transport{
Proxy: http.ProxyFromEnvironment,
MaxIdleConns: 16,
IdleConnTimeout: 60 * time.Second,
TLSHandshakeTimeout: 8 * time.Second,
ExpectContinueTimeout: 1 * time.Second,
},
},
}
}
@@ -197,6 +210,72 @@ func (t *TMDbProvider) SearchMovie(ctx context.Context, query string, year int)
return m, nil
}
// SearchTV issues `/search/tv` and returns the best match. Used by anime /
// tv libraries before falling back to SearchMovie.
func (t *TMDbProvider) SearchTV(ctx context.Context, query string, year int) (*Match, error) {
if query == "" {
return nil, errors.New("empty query")
}
apiKey := t.resolveAPIKey(ctx)
if apiKey == "" {
return nil, nil
}
base := t.resolveBaseURL(ctx)
q := url.Values{}
q.Set("api_key", apiKey)
q.Set("query", query)
q.Set("language", "zh-CN")
q.Set("include_adult", "false")
if year > 0 {
q.Set("first_air_date_year", fmt.Sprintf("%d", year))
}
u := base + "/search/tv?" + q.Encode()
type result struct {
ID int `json:"id"`
Name string `json:"name"`
OriginalName string `json:"original_name"`
Overview string `json:"overview"`
PosterPath string `json:"poster_path"`
BackdropPath string `json:"backdrop_path"`
FirstAirDate string `json:"first_air_date"`
VoteAverage float32 `json:"vote_average"`
}
type page struct {
Results []result `json:"results"`
}
var p page
if err := t.getJSON(ctx, u, &p); err != nil {
return nil, err
}
if len(p.Results) == 0 {
return nil, nil
}
r := p.Results[0]
m := &Match{
TMDbID: r.ID,
Title: r.Name,
Overview: r.Overview,
Rating: r.VoteAverage,
}
if m.Title == "" {
m.Title = r.OriginalName
}
if r.PosterPath != "" {
m.PosterURL = t.imgCDN + "/w500" + r.PosterPath
}
if r.BackdropPath != "" {
m.BackdropURL = t.imgCDN + "/w1280" + r.BackdropPath
}
if len(r.FirstAirDate) >= 4 {
fmt.Sscanf(r.FirstAirDate[:4], "%d", &m.Year)
}
return m, nil
}
func (t *TMDbProvider) getJSON(ctx context.Context, url string, out any) error {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {