mirror of
https://github.com/truewhile/MeBox.git
synced 2026-09-29 11:36:36 +08:00
3a2db6bdd9
## 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_* 临时文件排除规则
613 lines
19 KiB
Go
613 lines
19 KiB
Go
// Package service — Emby/Jellyfin compatibility shim.
|
||
//
|
||
// EmbyService produces JSON envelopes shaped like the most-consumed
|
||
// Emby-API endpoints so existing players (Infuse / Yamby / Hills /
|
||
// Senplayer / Kodi NextPVR extension / iOS native clients) can talk to
|
||
// MediaStationGo without a custom plugin.
|
||
//
|
||
// 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
|
||
log *zap.Logger
|
||
repo *repository.Container
|
||
}
|
||
|
||
// NewEmbyService is the constructor.
|
||
func NewEmbyService(cfg *config.Config, log *zap.Logger, repo *repository.Container) *EmbyService {
|
||
return &EmbyService{cfg: cfg, log: log, repo: repo}
|
||
}
|
||
|
||
// ─── System ──────────────────────────────────────────────────────────────────
|
||
|
||
// SystemInfo returns the full Emby identity payload.
|
||
func (e *EmbyService) SystemInfo() map[string]any {
|
||
return map[string]any{
|
||
"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)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
out := make([]map[string]any, 0, len(users))
|
||
for _, u := range users {
|
||
out = append(out, e.userPayload(&u))
|
||
}
|
||
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": 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": !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 / 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 {
|
||
return nil, err
|
||
}
|
||
items := make([]map[string]any, 0, len(libs))
|
||
for _, l := range libs {
|
||
items = append(items, e.libraryAsView(&l))
|
||
}
|
||
return map[string]any{"Items": items, "TotalRecordCount": len(items)}, nil
|
||
}
|
||
|
||
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"
|
||
}
|
||
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 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(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, userFavs[m.ID], userPos[m.ID]))
|
||
}
|
||
return map[string]any{
|
||
"Items": items,
|
||
"TotalRecordCount": total,
|
||
"StartIndex": p.StartIndex,
|
||
}, nil
|
||
}
|
||
|
||
// 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,
|
||
"OriginalTitle": m.OriginalName,
|
||
"ServerId": embyServerID,
|
||
"Type": itemType,
|
||
"MediaType": "Video",
|
||
"IsFolder": false,
|
||
"ProductionYear": m.Year,
|
||
"ParentIndexNumber": m.SeasonNum,
|
||
"IndexNumber": m.EpisodeNum,
|
||
"Overview": m.Overview,
|
||
"RunTimeTicks": runTimeTicks,
|
||
"CommunityRating": m.Rating,
|
||
"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)},
|
||
}
|
||
}
|
||
|
||
// ─── 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
|
||
}
|
||
return map[string]any{
|
||
"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)
|
||
}
|