Update MediaStationGo branding and deployment docs

This commit is contained in:
ShukeBta
2026-06-17 16:01:16 +08:00
parent 3368ac2947
commit ab4637beed
62 changed files with 1035 additions and 599 deletions
+204 -5
View File
@@ -490,10 +490,10 @@ func (e *EmbyService) mediaItems(ctx context.Context, p ItemsParams) (map[string
return emptyItemsEnvelope(p.StartIndex), nil
}
if filterBySeasonNumbers && containsItemType(p.IncludeItemTypes, "Movie") && !containsItemType(p.IncludeItemTypes, "Episode") {
q = q.Where("season_num = 0 AND episode_num = 0")
q = e.filterMovieItems(ctx, q)
}
if filterBySeasonNumbers && containsItemType(p.IncludeItemTypes, "Episode") && !containsItemType(p.IncludeItemTypes, "Movie") {
q = q.Where("season_num > 0 OR episode_num > 0")
q = e.filterEpisodeItems(ctx, q)
}
var total int64
@@ -519,10 +519,23 @@ func (e *EmbyService) mediaItems(ctx context.Context, p ItemsParams) (map[string
}
}
fetchLimit := p.Limit
fetchOffset := p.StartIndex
if fetchLimit > 0 && e.shouldCollapseMediaVersions(ctx, p) {
// Duplicates across merged local/cloud libraries collapse into one Emby
// item with multiple MediaSources. Fetch a wider window so duplicates do
// not consume the whole requested page.
fetchOffset = 0
fetchLimit = p.StartIndex + maxInt(p.Limit*4, p.Limit)
}
var rows []model.Media
if err := q.Order(order).Offset(p.StartIndex).Limit(p.Limit).Find(&rows).Error; err != nil {
if err := q.Order(order).Offset(fetchOffset).Limit(fetchLimit).Find(&rows).Error; err != nil {
return nil, err
}
if e.shouldCollapseMediaVersions(ctx, p) {
rows = e.collapseMediaVersionRows(ctx, rows)
rows = pageSlice(rows, p.StartIndex, p.Limit)
}
items, err := e.payloadsForMedia(ctx, rows, p.UserID)
if err != nil {
return nil, err
@@ -610,6 +623,7 @@ func (e *EmbyService) episodeItems(ctx context.Context, rows []model.Media, p It
}
func (e *EmbyService) payloadsForMedia(ctx context.Context, rows []model.Media, userID string) ([]map[string]any, error) {
rows = e.collapseMediaVersionRows(ctx, rows)
userFavs := map[string]bool{}
userPos := map[string]int64{}
if userID != "" && len(rows) > 0 {
@@ -643,6 +657,44 @@ func (e *EmbyService) payloadsForMedia(ctx context.Context, rows []model.Media,
return items, nil
}
func (e *EmbyService) shouldCollapseMediaVersions(ctx context.Context, p ItemsParams) bool {
if containsItemType(p.IncludeItemTypes, "Series") || containsItemType(p.IncludeItemTypes, "Season") {
return false
}
if containsItemType(p.IncludeItemTypes, "Episode") && !containsItemType(p.IncludeItemTypes, "Movie") {
return true
}
if p.ParentID == "" {
return true
}
episodic, err := e.libraryIsEpisodic(ctx, p.ParentID)
return err == nil && !episodic
}
func (e *EmbyService) collapseMediaVersionRows(ctx context.Context, rows []model.Media) []model.Media {
if len(rows) < 2 {
return rows
}
out := make([]model.Media, 0, len(rows))
indexByKey := make(map[string]int, len(rows))
for _, row := range rows {
key := e.mediaVersionKey(ctx, &row)
if key == "" {
out = append(out, row)
continue
}
if idx, ok := indexByKey[key]; ok {
if preferMediaVersion(row, out[idx]) {
out[idx] = row
}
continue
}
indexByKey[key] = len(out)
out = append(out, row)
}
return out
}
// Item 单条目详情。
func (e *EmbyService) Item(ctx context.Context, mediaID, userID string) (map[string]any, error) {
if lib, err := e.repo.Library.FindByID(ctx, mediaID); err != nil {
@@ -908,7 +960,7 @@ func (e *EmbyService) itemPayload(ctx context.Context, m *model.Media, fav bool,
"Played": played,
"PlayedPercentage": pct,
},
"MediaSources": []map[string]any{e.mediaSource(ctx, m, true, false)},
"MediaSources": e.mediaSourcesForItem(ctx, m, true, false),
}
}
@@ -984,6 +1036,35 @@ func embyLibraryTypeIsEpisodic(typ string) bool {
}
}
func (e *EmbyService) filterMovieItems(ctx context.Context, q *gorm.DB) *gorm.DB {
episodicIDs := e.episodicLibraryIDs(ctx)
if len(episodicIDs) == 0 {
return q
}
return q.Where("(media.season_num = 0 AND media.episode_num = 0) OR media.library_id NOT IN ?", episodicIDs)
}
func (e *EmbyService) filterEpisodeItems(ctx context.Context, q *gorm.DB) *gorm.DB {
episodicIDs := e.episodicLibraryIDs(ctx)
if len(episodicIDs) == 0 {
return q.Where("1 = 0")
}
return q.Where("media.library_id IN ? AND (media.season_num > 0 OR media.episode_num > 0)", episodicIDs)
}
func (e *EmbyService) episodicLibraryIDs(ctx context.Context) []string {
if e == nil || e.repo == nil || e.repo.DB == nil {
return nil
}
var ids []string
if err := e.repo.DB.WithContext(ctx).Model(&model.Library{}).
Where("LOWER(type) IN ?", []string{"tv", "anime", "variety"}).
Pluck("id", &ids).Error; err != nil {
return nil
}
return ids
}
func (e *EmbyService) rememberSeriesGroup(group embySeriesGroup) {
if e == nil || strings.TrimSpace(group.ID) == "" {
return
@@ -1712,7 +1793,7 @@ func (e *EmbyService) PlaybackInfo(ctx context.Context, mediaID, userID string)
}
e.ensureCloudTrackMetadata(ctx, m)
return map[string]any{
"MediaSources": []map[string]any{e.mediaSource(ctx, m, false, e.directPlayOnly(ctx))},
"MediaSources": e.mediaSourcesForItem(ctx, m, false, e.directPlayOnly(ctx)),
"PlaySessionId": fmt.Sprintf("%s-%d", m.ID, time.Now().Unix()),
}, nil
}
@@ -1936,6 +2017,124 @@ func (e *EmbyService) mediaSource(ctx context.Context, m *model.Media, asEmbedde
return src
}
func (e *EmbyService) mediaSourcesForItem(ctx context.Context, m *model.Media, asEmbedded, directOnly bool) []map[string]any {
siblings := e.mediaVersionSiblings(ctx, m)
if len(siblings) == 0 {
return []map[string]any{e.mediaSource(ctx, m, asEmbedded, directOnly)}
}
sources := make([]map[string]any, 0, len(siblings))
for i := range siblings {
media := siblings[i]
sources = append(sources, e.mediaSource(ctx, &media, asEmbedded, directOnly))
}
return sources
}
func (e *EmbyService) mediaVersionSiblings(ctx context.Context, m *model.Media) []model.Media {
if e == nil || e.repo == nil || e.repo.DB == nil || m == nil || strings.TrimSpace(m.ID) == "" {
return nil
}
libraryIDs := e.mergedLibraryIDs(ctx, m.LibraryID)
if len(libraryIDs) == 0 {
libraryIDs = []string{m.LibraryID}
}
q := e.repo.DB.WithContext(ctx).Model(&model.Media{}).
Where("library_id IN ?", libraryIDs).
Where("season_num = ? AND episode_num = ?", m.SeasonNum, m.EpisodeNum)
if m.TMDbID > 0 {
q = q.Where("tm_db_id = ?", m.TMDbID)
} else if m.BangumiID > 0 {
q = q.Where("bangumi_id = ?", m.BangumiID)
} else {
title := strings.TrimSpace(m.Title)
if title == "" {
title = strings.TrimSpace(m.OriginalName)
}
if title == "" {
return []model.Media{*m}
}
q = q.Where("LOWER(title) = ?", strings.ToLower(title))
if m.Year > 0 {
q = q.Where("year = ?", m.Year)
}
}
var rows []model.Media
if err := q.Find(&rows).Error; err != nil || len(rows) == 0 {
return []model.Media{*m}
}
rows = e.collapseExactPathRows(rows)
sort.SliceStable(rows, func(i, j int) bool {
if rows[i].ID == m.ID {
return true
}
if rows[j].ID == m.ID {
return false
}
return preferMediaVersion(rows[i], rows[j])
})
return rows
}
func (e *EmbyService) collapseExactPathRows(rows []model.Media) []model.Media {
if len(rows) < 2 {
return rows
}
out := rows[:0]
seen := map[string]struct{}{}
for _, row := range rows {
path := strings.TrimSpace(row.Path)
if path != "" {
if _, ok := seen[path]; ok {
continue
}
seen[path] = struct{}{}
}
out = append(out, row)
}
return out
}
func (e *EmbyService) mediaVersionKey(ctx context.Context, m *model.Media) string {
if e == nil || m == nil {
return ""
}
ids := e.mergedLibraryIDs(ctx, m.LibraryID)
sort.Strings(ids)
libraryGroup := strings.Join(ids, ",")
if libraryGroup == "" {
libraryGroup = strings.TrimSpace(m.LibraryID)
}
if m.TMDbID > 0 {
return fmt.Sprintf("%s|tmdb:%d|s:%d|e:%d", libraryGroup, m.TMDbID, m.SeasonNum, m.EpisodeNum)
}
if m.BangumiID > 0 {
return fmt.Sprintf("%s|bangumi:%d|s:%d|e:%d", libraryGroup, m.BangumiID, m.SeasonNum, m.EpisodeNum)
}
title := strings.ToLower(strings.TrimSpace(m.Title))
if title == "" {
title = strings.ToLower(strings.TrimSpace(m.OriginalName))
}
if title == "" {
return ""
}
return fmt.Sprintf("%s|title:%s|y:%d|s:%d|e:%d", libraryGroup, title, m.Year, m.SeasonNum, m.EpisodeNum)
}
func preferMediaVersion(candidate, current model.Media) bool {
candidateCloud := strings.TrimSpace(candidate.STRMURL) != "" || strings.HasPrefix(strings.ToLower(strings.TrimSpace(candidate.Path)), "cloud://")
currentCloud := strings.TrimSpace(current.STRMURL) != "" || strings.HasPrefix(strings.ToLower(strings.TrimSpace(current.Path)), "cloud://")
if candidateCloud != currentCloud {
return !candidateCloud
}
if candidate.Width != current.Width {
return candidate.Width > current.Width
}
if candidate.SizeBytes != current.SizeBytes {
return candidate.SizeBytes > current.SizeBytes
}
return candidate.CreatedAt.After(current.CreatedAt)
}
func embySTRMStreamURL(mediaID string) string {
return "/api/stream/" + url.PathEscape(strings.TrimSpace(mediaID))
}