mirror of
https://github.com/truewhile/MeBox.git
synced 2026-09-28 03:06:38 +08:00
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:
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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 ──────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -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])
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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}`
|
||||
}
|
||||
Reference in New Issue
Block a user