mirror of
https://github.com/truewhile/MeBox.git
synced 2026-09-28 11:16:37 +08:00
2b99f5f108
Mounted Emby libraries were always appended to web/Emby library lists and detail/play routes without checking allowed_library_ids, so restricted users could still see and open them. Filter remotes with the same visibility policy as local libraries across list/detail/series/stream and Emby Views/Items/search/playback, and label mounts in the admin ACL UI. Co-authored-by: Cursor Agent <cursoragent@cursor.com> Co-authored-by: truewhile <truewhile@users.noreply.github.com>
326 lines
10 KiB
Go
326 lines
10 KiB
Go
package service
|
|
|
|
import (
|
|
"context"
|
|
"sort"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/truewhile/MeBox/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": "MeBox",
|
|
"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": "MeBox",
|
|
"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": "MeBox",
|
|
"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 挂载的媒体库(联邦聚合)。顺序遵循用户置顶偏好:置顶库靠前,
|
|
// 未置顶保持原有 sort_order / 远程挂载顺序。
|
|
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) {
|
|
id, _ := remote["Id"].(string)
|
|
if !LibraryIDAllowed(visibility, id) {
|
|
continue
|
|
}
|
|
items = append(items, remote)
|
|
}
|
|
items = sortViewItemsByPinnedIDs(items, e.pinnedLibraryIDsForUser(ctx, userID))
|
|
return map[string]any{"Items": items, "TotalRecordCount": len(items), "StartIndex": 0}, nil
|
|
}
|
|
|
|
func (e *EmbyService) pinnedLibraryIDsForUser(ctx context.Context, userID string) []string {
|
|
if e == nil || e.repo == nil || e.repo.User == nil || strings.TrimSpace(userID) == "" {
|
|
return nil
|
|
}
|
|
user, err := e.repo.User.FindByID(ctx, userID)
|
|
if err != nil || user == nil {
|
|
return nil
|
|
}
|
|
return user.DecodePinnedLibraryIDs()
|
|
}
|
|
|
|
func sortViewItemsByPinnedIDs(items []map[string]any, pinnedIDs []string) []map[string]any {
|
|
if len(items) < 2 || len(pinnedIDs) == 0 {
|
|
return items
|
|
}
|
|
rank := make(map[string]int, len(pinnedIDs))
|
|
for i, id := range pinnedIDs {
|
|
if id == "" {
|
|
continue
|
|
}
|
|
if _, exists := rank[id]; !exists {
|
|
rank[id] = i
|
|
}
|
|
}
|
|
if len(rank) == 0 {
|
|
return items
|
|
}
|
|
sorted := append([]map[string]any(nil), items...)
|
|
sort.SliceStable(sorted, func(i, j int) bool {
|
|
iID, _ := sorted[i]["Id"].(string)
|
|
jID, _ := sorted[j]["Id"].(string)
|
|
iRank, iPinned := rank[iID]
|
|
jRank, jPinned := rank[jID]
|
|
if iPinned != jPinned {
|
|
return iPinned
|
|
}
|
|
if iPinned && jPinned {
|
|
return iRank < jRank
|
|
}
|
|
return false
|
|
})
|
|
return sorted
|
|
}
|
|
|
|
// remoteViews 返回全部启用挂载的远程媒体库视图(只有显式挂载的库才出现)。
|
|
func (e *EmbyService) remoteViews(ctx context.Context) []map[string]any {
|
|
if e == nil || e.remote == nil {
|
|
return nil
|
|
}
|
|
views, err := e.remote.RemoteLibraries(ctx)
|
|
if err != nil || len(views) == 0 {
|
|
return nil
|
|
}
|
|
out := make([]map[string]any, 0, len(views))
|
|
for _, v := range views {
|
|
out = append(out, remoteLibraryViewPayload(v))
|
|
}
|
|
return out
|
|
}
|
|
|
|
// remoteLibraryViewPayload 把挂载库展示信息标准化为 Emby View payload
|
|
// (ID 用挂载伪装,名称=挂载显示名)。
|
|
func remoteLibraryViewPayload(v RemoteLibraryView) map[string]any {
|
|
encoded := EncodeEmbyRemoteID(v.MountID, v.RemoteID)
|
|
collectionType := v.CollectionType
|
|
if !isSupportedEmbyCollectionType(collectionType) {
|
|
collectionType = "mixed"
|
|
}
|
|
name := strings.TrimSpace(v.Library.Name)
|
|
imageTags := map[string]string{}
|
|
if strings.TrimSpace(v.Library.CoverURL) != "" {
|
|
imageTags["Primary"] = encoded
|
|
}
|
|
return map[string]any{
|
|
"Id": encoded,
|
|
"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,
|
|
},
|
|
}
|
|
}
|
|
|
|
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,
|
|
},
|
|
}
|
|
}
|