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
+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 {