From 04fc50a4b0c30910289f1d8a916d24ff86608bf2 Mon Sep 17 00:00:00 2001 From: truewhile <62226914+truewhile@users.noreply.github.com> Date: Wed, 2 Sep 2026 14:14:32 +0800 Subject: [PATCH] Add library favourite buttons and fix remote favourites list (#14) * Add favourite buttons to library list and series detail pages Co-authored-by: truewhile * Fix favourites list for remote Emby mounted media Co-authored-by: truewhile * Route favourites to library series detail when appropriate Co-authored-by: truewhile * Fix favourites link to library series for remote anime paths Co-authored-by: truewhile --------- Co-authored-by: Cursor Agent Co-authored-by: truewhile --- internal/service/emby_remote_web.go | 3 ++ internal/service/playback.go | 48 +++++++++++++++++++++++---- internal/service/playback_test.go | 46 +++++++++++++++++++++++++ web/src/pages/FavouritesPage.tsx | 5 +-- web/src/pages/MediaDetailPageModel.ts | 11 +++--- web/src/utils/groupSeries.ts | 4 +-- web/src/utils/mediaNavigation.ts | 47 ++++++++++++++++++++++++++ 7 files changed, 147 insertions(+), 17 deletions(-) create mode 100644 internal/service/playback_test.go create mode 100644 web/src/utils/mediaNavigation.ts diff --git a/internal/service/emby_remote_web.go b/internal/service/emby_remote_web.go index 8d4750b..b321084 100644 --- a/internal/service/emby_remote_web.go +++ b/internal/service/emby_remote_web.go @@ -300,6 +300,9 @@ func (r *EmbyRemoteService) MapRemoteItemToMedia(ctx context.Context, mount *mod media.SeasonNum = 0 media.EpisodeNum = 0 } + if mount != nil && strings.TrimSpace(mount.RemoteViewID) != "" { + media.DisplayLibraryID = EncodeEmbyRemoteID(mount.ID, mount.RemoteViewID) + } return media } diff --git a/internal/service/playback.go b/internal/service/playback.go index 1ed4766..0b70e5e 100644 --- a/internal/service/playback.go +++ b/internal/service/playback.go @@ -9,6 +9,7 @@ package service import ( "context" "errors" + "sort" "time" "go.uber.org/zap" @@ -126,14 +127,47 @@ func (p *PlaybackService) ListFavourites(ctx context.Context, userID string) ([] if len(favs) == 0 { return nil, nil } - ids := make([]string, len(favs)) - for i, f := range favs { - ids[i] = f.MediaID + sort.Slice(favs, func(i, j int) bool { + return favs[i].CreatedAt.After(favs[j].CreatedAt) + }) + + localIDs := make([]string, 0, len(favs)) + for _, fav := range favs { + if !IsEmbyRemoteID(fav.MediaID) { + localIDs = append(localIDs, fav.MediaID) + } } - var out []model.Media - err = p.repo.DB.Where("id IN ?", ids). - Order("created_at desc").Find(&out).Error - return out, err + mediaByID := map[string]model.Media{} + if len(localIDs) > 0 { + var mediaRows []model.Media + if err := p.repo.DB.WithContext(ctx).Where("id IN ?", localIDs).Find(&mediaRows).Error; err != nil { + return nil, err + } + for _, media := range mediaRows { + mediaByID[media.ID] = media + } + } + + out := make([]model.Media, 0, len(favs)) + for _, fav := range favs { + if media, ok := mediaByID[fav.MediaID]; ok { + out = append(out, media) + continue + } + if p.remote != nil && IsEmbyRemoteID(fav.MediaID) { + mountID, remoteID, _ := DecodeEmbyRemoteID(fav.MediaID) + mount, acct, _ := p.remote.ResolveMount(ctx, mountID) + if mount == nil || acct == nil { + continue + } + remoteMedia, err := p.remote.RemoteMediaDetail(ctx, mount, acct, remoteID) + if err != nil || remoteMedia == nil { + continue + } + out = append(out, *remoteMedia) + } + } + return out, nil } // ─── Playlists ────────────────────────────────────────────────────────────── diff --git a/internal/service/playback_test.go b/internal/service/playback_test.go new file mode 100644 index 0000000..12005e3 --- /dev/null +++ b/internal/service/playback_test.go @@ -0,0 +1,46 @@ +package service + +import ( + "context" + "testing" + "time" + + "github.com/ShukeBta/MMTL/internal/model" + "github.com/ShukeBta/MMTL/internal/repository" + "go.uber.org/zap" +) + +func TestListFavouritesIncludesLocalAndRemoteIDs(t *testing.T) { + db := newServiceTestDB(t, &model.Favorite{}, &model.Media{}) + repos := repository.New(db) + userID := "user-1" + local := model.Media{ + Base: model.Base{ID: "local-movie-1"}, + Title: "Local Movie", + } + if err := db.Create(&local).Error; err != nil { + t.Fatalf("create local media: %v", err) + } + remoteID := EncodeEmbyRemoteID("mount-1", "remote-series-1") + favs := []model.Favorite{ + {Base: model.Base{CreatedAt: time.Now().Add(-time.Minute)}, UserID: userID, MediaID: remoteID}, + {Base: model.Base{CreatedAt: time.Now()}, UserID: userID, MediaID: local.ID}, + } + for i := range favs { + if err := db.Create(&favs[i]).Error; err != nil { + t.Fatalf("create favorite %d: %v", i, err) + } + } + + svc := NewPlaybackService(zap.NewNop(), repos) + items, err := svc.ListFavourites(context.Background(), userID) + if err != nil { + t.Fatalf("ListFavourites: %v", err) + } + if len(items) != 1 { + t.Fatalf("expected one hydrated local favourite without remote service, got %d: %#v", len(items), items) + } + if items[0].ID != local.ID { + t.Fatalf("expected local favourite first by created_at desc, got %#v", items[0]) + } +} diff --git a/web/src/pages/FavouritesPage.tsx b/web/src/pages/FavouritesPage.tsx index 7d632dd..a068774 100644 --- a/web/src/pages/FavouritesPage.tsx +++ b/web/src/pages/FavouritesPage.tsx @@ -4,6 +4,7 @@ import toast from 'react-hot-toast' import { playbackAPI } from '../api/playback' import { MediaCard } from '../components/MediaCard' import type { Media } from '../types' +import { favouriteMediaLink } from '../utils/mediaNavigation' export function FavouritesPage() { const [items, setItems] = useState([]) @@ -66,7 +67,7 @@ export function FavouritesPage() {

还没有任何收藏

- 点击媒体详情页的「收藏」按钮添加喜欢的内容 + 在媒体库或详情页点击「加入收藏」即可添加喜欢的内容

)} @@ -74,7 +75,7 @@ export function FavouritesPage() { {items.length > 0 && (
{items.map((m) => ( - + ))}
)} diff --git a/web/src/pages/MediaDetailPageModel.ts b/web/src/pages/MediaDetailPageModel.ts index 3d34f35..a8e5693 100644 --- a/web/src/pages/MediaDetailPageModel.ts +++ b/web/src/pages/MediaDetailPageModel.ts @@ -1,14 +1,13 @@ import type { Media } from '../types' -import { getSeriesKey, isEpisodeLike } from '../utils/groupSeries' +import { mediaLibraryTarget } from '../utils/mediaNavigation' export function mediaLibraryBackTarget(media: Media): string { + const seriesTarget = mediaLibraryTarget(media) + if (seriesTarget) return seriesTarget + const libraryID = media.display_library_id || media.library_id if (!libraryID) return '' - if (!isEpisodeLike(media)) return `/library/${encodeURIComponent(libraryID)}` - - const seriesKey = getSeriesKey(media) - const target = `/library/${encodeURIComponent(libraryID)}` - return seriesKey ? `${target}?series=${encodeURIComponent(seriesKey)}` : target + return `/library/${encodeURIComponent(libraryID)}` } export function mediaDetailScrapeMediaType(media: Media): string | undefined { diff --git a/web/src/utils/groupSeries.ts b/web/src/utils/groupSeries.ts index dc4682b..be21b6e 100644 --- a/web/src/utils/groupSeries.ts +++ b/web/src/utils/groupSeries.ts @@ -84,12 +84,12 @@ export function isEpisodeLike(media: Media): boolean { // 剧集类目录名(电视剧/动漫及其二级分类)。媒体路径落在这些目录下时, 即便 // 季集号未识别出来, 也应按剧集对待, 跳转到 /library 分类视图而非 /media 单页。 const EPISODIC_PATH_RE = - /[\\/](?:电视剧|剧集|连续剧|短剧|国产剧|国剧|大陆剧|华语剧|国产电视剧|大陆电视剧|华语电视剧|欧美剧|欧美电视剧|美剧|英剧|日韩剧|日韩电视剧|日剧|韩剧|港剧|台剧|港台剧|泰剧|综艺|纪录片|儿童|动漫|番剧|国漫|日番|韩漫|美漫|欧美动漫|欧美动画|其他动漫|tv|series|shows?|season[\s._-]*\d|s\d{1,2}(?:[\s._-]|[\\/])|special[\s._-]*episodes?|specials?|sp|ovas?|oads?|extras?|bonus(?:es)?|omake|特别篇|特別篇|番外篇?|特典|外传|外傳|总集篇|總集篇)[\\/]/i + /[\\/](?:电视剧|剧集|连续剧|短剧|国产剧|国剧|大陆剧|华语剧|国产电视剧|大陆电视剧|华语电视剧|欧美剧|欧美电视剧|美剧|英剧|日韩剧|日韩电视剧|日剧|韩剧|港剧|台剧|港台剧|泰剧|综艺|纪录片|儿童|动漫|番剧|国漫|日番|韩漫|美漫|欧美动漫|欧美动画|其他动漫|anime|tv|series|shows?|season[\s._-]*\d|s\d{1,2}(?:[\s._-]|[\\/])|special[\s._-]*episodes?|specials?|sp|ovas?|oads?|extras?|bonus(?:es)?|omake|特别篇|特別篇|番外篇?|特典|外传|外傳|总集篇|總集篇)[\\/]/i const SEASON_FOLDER_RE = /^(?:s\d{1,2}|season[\s._-]*\d{1,2}|第\s*[0-9一二三四五六七八九十百零两]+\s*季|special[\s._-]*episodes?|specials?|sp|ovas?|oads?|extras?|bonus(?:es)?|omake|特别篇|特別篇|番外篇?|特典|外传|外傳|总集篇|總集篇)$/i -function pathLooksEpisodic(media: Media): boolean { +export function pathLooksEpisodic(media: Media): boolean { const path = (media.path || media.display_library_path || media.library_path || '') return EPISODIC_PATH_RE.test(path) } diff --git a/web/src/utils/mediaNavigation.ts b/web/src/utils/mediaNavigation.ts new file mode 100644 index 0000000..2afb507 --- /dev/null +++ b/web/src/utils/mediaNavigation.ts @@ -0,0 +1,47 @@ +import type { Media } from '../types' +import { isRemoteEmbyID } from './remoteEmby' +import { getSeriesKey, isEpisodeLike, pathLooksEpisodic } from './groupSeries' + +function pathLooksLikeMovie(media: Media): boolean { + const path = (media.path || media.display_library_path || media.library_path || '').toLowerCase() + return /[\\/](?:movie|movies|film|films|电影)[\\/]/i.test(path) +} + +function isRemoteEmbyLibrarySeries(media: Media): boolean { + if (!isRemoteEmbyID(media.id) || isEpisodeLike(media)) return false + const libraryID = media.display_library_id || media.library_id + if (!libraryID) return false + return !pathLooksLikeMovie(media) +} + +/** Whether opening the library series detail view is a better target than /media/:id. */ +export function prefersLibrarySeriesView(media: Media): boolean { + if (isEpisodeLike(media)) return true + if (pathLooksEpisodic(media)) return true + if (media.series_id) return true + if (isRemoteEmbyLibrarySeries(media)) return true + return false +} + +/** Series key used by /library/:id?series=... for both local and remote libraries. */ +export function resolveLibrarySeriesKey(media: Media): string { + if (isRemoteEmbyID(media.id) && !isEpisodeLike(media)) { + return media.id + } + return getSeriesKey(media) +} + +/** Best library URL for a media item, or null when /media/:id is preferred. */ +export function mediaLibraryTarget(media: Media): string | null { + const libraryID = media.display_library_id || media.library_id + if (!libraryID || !prefersLibrarySeriesView(media)) return null + + const seriesKey = resolveLibrarySeriesKey(media) + const base = `/library/${encodeURIComponent(libraryID)}` + return seriesKey ? `${base}?series=${encodeURIComponent(seriesKey)}` : base +} + +/** Link target for favourites / cards that should open the library series view when possible. */ +export function favouriteMediaLink(media: Media): string { + return mediaLibraryTarget(media) ?? `/media/${media.id}` +}