Add library favourite buttons and fix remote favourites list (#14)

* Add favourite buttons to library list and series detail pages

Co-authored-by: truewhile <truewhile@users.noreply.github.com>

* Fix favourites list for remote Emby mounted media

Co-authored-by: truewhile <truewhile@users.noreply.github.com>

* Route favourites to library series detail when appropriate

Co-authored-by: truewhile <truewhile@users.noreply.github.com>

* Fix favourites link to library series for remote anime paths

Co-authored-by: truewhile <truewhile@users.noreply.github.com>

---------

Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: truewhile <truewhile@users.noreply.github.com>
This commit is contained in:
truewhile
2026-09-02 14:14:32 +08:00
committed by GitHub
parent dab9a1073a
commit 04fc50a4b0
7 changed files with 147 additions and 17 deletions
+3
View File
@@ -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
}
+41 -7
View File
@@ -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 ──────────────────────────────────────────────────────────────
+46
View File
@@ -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])
}
}
+3 -2
View File
@@ -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<Media[]>([])
@@ -66,7 +67,7 @@ export function FavouritesPage() {
<div className="glass-panel flex flex-col items-center gap-3 p-10 text-center">
<p className="text-lg text-ink-100">还没有任何收藏</p>
<p className="text-sm text-sand-500">
点击媒体详情页的「收藏」按钮添加喜欢的内容
在媒体库或详情页点击「加入收藏」即可添加喜欢的内容
</p>
</div>
)}
@@ -74,7 +75,7 @@ export function FavouritesPage() {
{items.length > 0 && (
<div className="grid grid-cols-2 gap-4 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-6">
{items.map((m) => (
<MediaCard key={m.id} media={m} />
<MediaCard key={m.id} media={m} linkTo={favouriteMediaLink(m)} />
))}
</div>
)}
+5 -6
View File
@@ -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 {
+2 -2
View File
@@ -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)
}
+47
View File
@@ -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}`
}