mirror of
https://github.com/truewhile/MeBox.git
synced 2026-09-29 03:26:37 +08:00
0eb3f104f4
- 新增 emby_remote 提供方:通过 STRM 账号体系配置远程 Emby 地址/用户名/密码
- EmbyService 聚合远程媒体库 Views/Items/详情/最近添加/全局搜索,远程数据不落库
- 条目 ID 统一伪装为 embyremote~{account}~{id},请求按账号路由回远程
- 播放支持账号级 proxy_play 配置:默认客户端直连远程;开启后由 MMTL 反向代理流/字幕
- 图片/元数据全部实时透传远程,播放状态(已看/收藏)透传回远程 Emby
- cloud 包新增 emby Provider(Ping/List/Resolve),账号测试/目录浏览自动生效
- 前端 STRM 页面支持 Emby 远程挂载账号(地址+凭据+代理开关)
314 lines
10 KiB
Go
314 lines
10 KiB
Go
package service
|
|
|
|
import (
|
|
"context"
|
|
"strings"
|
|
"time"
|
|
|
|
"go.uber.org/zap"
|
|
|
|
"github.com/ShukeBta/MMTL/internal/model"
|
|
)
|
|
|
|
// SystemInfo returns the full Emby identity payload.
|
|
func (e *EmbyService) SystemInfo() map[string]any {
|
|
return map[string]any{
|
|
"Id": embyServerID,
|
|
"ServerId": embyServerID,
|
|
"ServerName": "MMTL",
|
|
"Version": embyCompatVersion,
|
|
"ServerVersion": embyCompatVersion,
|
|
"ProductName": "Emby Server",
|
|
"OperatingSystem": "Windows",
|
|
"Architecture": "X64",
|
|
"LocalAddress": "",
|
|
"WanAddress": "",
|
|
"HasPendingRestart": false,
|
|
"IsShuttingDown": false,
|
|
"SupportsLibraryMonitor": true,
|
|
"SupportsHttps": false,
|
|
"SupportsAutoDiscovery": true,
|
|
"HttpServerPortNumber": e.cfg.App.Port,
|
|
"HttpsPortNumber": 0,
|
|
"PublishedServerUrl": "",
|
|
"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,
|
|
"ServerId": embyServerID,
|
|
"ServerName": "MMTL",
|
|
"Version": embyCompatVersion,
|
|
"ServerVersion": embyCompatVersion,
|
|
"ProductName": "Emby Server",
|
|
"OperatingSystem": "Windows",
|
|
"LocalAddress": "",
|
|
"WanAddress": "",
|
|
"HttpServerPortNumber": e.cfg.App.Port,
|
|
"HttpsPortNumber": 0,
|
|
"SupportsHttps": false,
|
|
"SupportsAutoDiscovery": true,
|
|
"StartupWizardCompleted": true,
|
|
}
|
|
}
|
|
|
|
// 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 {
|
|
canDownload := u.Role == "admin"
|
|
return map[string]any{
|
|
"Id": u.ID,
|
|
"Name": u.Username,
|
|
"ServerId": embyServerID,
|
|
"ServerName": "MMTL",
|
|
"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": canDownload,
|
|
"EnableSyncTranscoding": canDownload,
|
|
"EnableMediaConversion": canDownload,
|
|
"EnableAllChannels": true,
|
|
"EnableAllFolders": true,
|
|
"EnableAllDevices": true,
|
|
"AuthenticationProviderId": embyLocalAuthenticationProviderID,
|
|
"PasswordResetProviderId": embyLocalPasswordResetProviderID,
|
|
},
|
|
}
|
|
}
|
|
|
|
// Views 返回 Emby 中"虚拟根目录"——每个 library 一个条目,外加所有启用的
|
|
// 远程 Emby 挂载的媒体库(联邦聚合)。
|
|
func (e *EmbyService) Views(ctx context.Context, userID string) (map[string]any, error) {
|
|
libs, err := e.repo.Library.List(ctx)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
libs = FilterDisplayCloudLibraries(ctx, e.repo, libs)
|
|
visibility := e.mediaVisibility(ctx, userID)
|
|
items := make([]map[string]any, 0, len(libs)+4)
|
|
for _, l := range libs {
|
|
if !e.libraryVisibleFromCachedVisibility(l, visibility) {
|
|
continue
|
|
}
|
|
items = append(items, e.libraryAsView(ctx, &l))
|
|
}
|
|
for _, remote := range e.remoteViews(ctx) {
|
|
items = append(items, remote)
|
|
}
|
|
return map[string]any{"Items": items, "TotalRecordCount": len(items), "StartIndex": 0}, nil
|
|
}
|
|
|
|
// remoteViews 拉取全部启用远程 Emby 账号的媒体库视图。单个账号失败只跳过
|
|
// 该账号(日志记录),不影响其他来源。
|
|
func (e *EmbyService) remoteViews(ctx context.Context) []map[string]any {
|
|
if e == nil || e.remote == nil {
|
|
return nil
|
|
}
|
|
accounts, err := e.remote.ListAccounts(ctx)
|
|
if err != nil || len(accounts) == 0 {
|
|
return nil
|
|
}
|
|
out := make([]map[string]any, 0, len(accounts)*2)
|
|
for i := range accounts {
|
|
acct := &accounts[i]
|
|
views, err := e.remote.RemoteViews(ctx, acct)
|
|
if err != nil {
|
|
if e.log != nil {
|
|
e.log.Warn("fetch remote emby views failed",
|
|
zap.String("account", acct.Name), zap.Error(err))
|
|
}
|
|
continue
|
|
}
|
|
for _, v := range views {
|
|
if view := e.remoteLibraryAsView(v, acct); view != nil {
|
|
out = append(out, view)
|
|
}
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
// remoteLibraryAsView 把远程媒体的 CollectionFolder 视图标准化为本地视图
|
|
// 同构的 payload(ID 伪装、名称前缀账号名以便区分多个远程来源)。
|
|
func (e *EmbyService) remoteLibraryAsView(raw map[string]any, acct *model.StrmAccount) map[string]any {
|
|
if raw == nil {
|
|
return nil
|
|
}
|
|
remoteID, _ := raw["Id"].(string)
|
|
if remoteID == "" {
|
|
return nil
|
|
}
|
|
name, _ := raw["Name"].(string)
|
|
if name == "" {
|
|
name = acct.Name
|
|
}
|
|
collectionType, _ := raw["CollectionType"].(string)
|
|
if !isSupportedEmbyCollectionType(collectionType) {
|
|
collectionType = "mixed"
|
|
}
|
|
encoded := EncodeEmbyRemoteID(acct.ID, remoteID)
|
|
imageTags := map[string]string{}
|
|
if primary := embyRemoteImageTagsPrimary(raw); primary != "" {
|
|
imageTags["Primary"] = encoded
|
|
}
|
|
return map[string]any{
|
|
"Id": encoded,
|
|
"Name": acct.Name + " · " + name,
|
|
"CollectionType": collectionType,
|
|
"ServerId": embyServerID,
|
|
"Type": "CollectionFolder",
|
|
"IsFolder": true,
|
|
"Path": "",
|
|
"SortName": strings.ToLower(name),
|
|
"DateCreated": time.Now().UTC().Format(time.RFC3339),
|
|
"CanDelete": false,
|
|
"CanDownload": false,
|
|
"DisplayPreferencesId": encoded,
|
|
"PrimaryImageItemId": encoded,
|
|
"PrimaryImageAspectRatio": 1.7777777777777777,
|
|
"RecursiveItemCount": 0,
|
|
"ChildCount": 0,
|
|
"SpecialFeatureCount": 0,
|
|
"EnableMediaSourceDisplay": true,
|
|
"PlayAccess": "Full",
|
|
"ExternalUrls": []any{},
|
|
"ProviderIds": map[string]string{},
|
|
"Genres": []string{},
|
|
"Tags": []string{},
|
|
"ImageTags": imageTags,
|
|
"BackdropImageTags": []string{},
|
|
"UserData": map[string]any{
|
|
"PlaybackPositionTicks": 0,
|
|
"PlayCount": 0,
|
|
"IsFavorite": false,
|
|
"Played": false,
|
|
"UnplayedItemCount": 0,
|
|
},
|
|
}
|
|
}
|
|
|
|
// embyRemoteImageTagsPrimary 提取远程 View 的 ImageTags.Primary 值。
|
|
func embyRemoteImageTagsPrimary(raw map[string]any) string {
|
|
switch tags := raw["ImageTags"].(type) {
|
|
case map[string]any:
|
|
if s, ok := tags["Primary"].(string); ok {
|
|
return s
|
|
}
|
|
case map[string]string:
|
|
return tags["Primary"]
|
|
}
|
|
return ""
|
|
}
|
|
|
|
func isSupportedEmbyCollectionType(t string) bool {
|
|
switch t {
|
|
case "movies", "tvshows", "music", "mixed", "homevideos", "boxsets":
|
|
return true
|
|
}
|
|
return false
|
|
}
|
|
|
|
func (e *EmbyService) libraryAsView(ctx context.Context, l *model.Library) map[string]any {
|
|
collectionType := "movies"
|
|
switch l.Type {
|
|
case "tv":
|
|
collectionType = "tvshows"
|
|
case "anime":
|
|
collectionType = "tvshows" // Emby 没有专门的 anime CollectionType
|
|
case "variety":
|
|
collectionType = "tvshows"
|
|
case "music":
|
|
collectionType = "music"
|
|
}
|
|
// 库封面:与剧集/电影一样,只有在能解析出封面时才广告 ImageTags.Primary,
|
|
// 否则客户端会认为该库无主图而不去请求 /Images/Primary。
|
|
imageTags := map[string]string{}
|
|
if e.LibraryHasCover(ctx, l.ID) {
|
|
imageTags["Primary"] = l.ID
|
|
}
|
|
return map[string]any{
|
|
"Id": l.ID,
|
|
"Name": l.Name,
|
|
"CollectionType": collectionType,
|
|
"ServerId": embyServerID,
|
|
"Type": "CollectionFolder",
|
|
"IsFolder": true,
|
|
"Path": l.Path,
|
|
"SortName": strings.ToLower(l.Name),
|
|
"DateCreated": l.CreatedAt.UTC().Format(time.RFC3339),
|
|
"CanDelete": false,
|
|
"CanDownload": false,
|
|
"DisplayPreferencesId": l.ID,
|
|
"PrimaryImageItemId": l.ID,
|
|
"PrimaryImageAspectRatio": 1.7777777777777777,
|
|
"RecursiveItemCount": 0,
|
|
"ChildCount": 0,
|
|
"SpecialFeatureCount": 0,
|
|
"EnableMediaSourceDisplay": true,
|
|
"PlayAccess": "Full",
|
|
"ExternalUrls": []any{},
|
|
"ProviderIds": map[string]string{},
|
|
"Genres": []string{},
|
|
"Tags": []string{},
|
|
"ImageTags": imageTags,
|
|
"BackdropImageTags": []string{},
|
|
"UserData": map[string]any{
|
|
"PlaybackPositionTicks": 0,
|
|
"PlayCount": 0,
|
|
"IsFavorite": false,
|
|
"Played": false,
|
|
"UnplayedItemCount": 0,
|
|
},
|
|
}
|
|
}
|