Fix subscription restore and media library entry links

This commit is contained in:
ShukeBta
2026-06-18 10:54:10 +08:00
parent 6c50697a1a
commit 0a3748e564
12 changed files with 392 additions and 9 deletions
+1
View File
@@ -97,6 +97,7 @@ func registerAuthenticatedRoutes(api *gin.RouterGroup, cfg *config.Config, svc *
authed.GET("/subscriptions/history", requirePermission(svc, "can_manage_subscriptions"), listSubscriptionHistoryHandler(svc))
authed.POST("/subscriptions", requirePermission(svc, "can_manage_subscriptions"), createSubscriptionHandler(svc))
authed.DELETE("/subscriptions/:id", requirePermission(svc, "can_manage_subscriptions"), deleteSubscriptionHandler(svc))
authed.POST("/subscriptions/:id/restore", requirePermission(svc, "can_manage_subscriptions"), restoreSubscriptionHandler(svc))
authed.POST("/subscriptions/:id/run", requirePermission(svc, "can_manage_subscriptions"), runSubscriptionHandler(svc))
// Stats / dashboard.
+13
View File
@@ -130,3 +130,16 @@ func runSubscriptionHandler(svc *service.Container) gin.HandlerFunc {
c.JSON(http.StatusOK, gin.H{"queued": n})
}
}
func restoreSubscriptionHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
sub, err := svc.Subscription.Restore(c.Request.Context(), c.Param("id"))
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
enriched := []model.Subscription{*sub}
svc.Subscription.EnrichProgress(c.Request.Context(), enriched)
c.JSON(http.StatusOK, enriched[0])
}
}
+7
View File
@@ -103,6 +103,13 @@ type Media struct {
// Used to expose WebDAV / Alist / S3 / HTTP direct links as media items.
STRMURL string `gorm:"size:2048" json:"strm_url,omitempty"`
LibraryName string `gorm:"-" json:"library_name,omitempty"`
LibraryPath string `gorm:"-" json:"library_path,omitempty"`
DisplayLibraryID string `gorm:"-" json:"display_library_id,omitempty"`
DisplayLibraryName string `gorm:"-" json:"display_library_name,omitempty"`
DisplayLibraryPath string `gorm:"-" json:"display_library_path,omitempty"`
// FileHash is a sparse-sample MD5 used for duplicate detection.
// Computed on-demand by the duplicate finder; format: "<hex>-<size>".
FileHash string `gorm:"index;size:64" json:"file_hash,omitempty"`
@@ -134,6 +134,9 @@ func TestListMediaVisibleIncludesMergedCloudLibraryItems(t *testing.T) {
if got := mediaTitles(items); !slices.Equal(got, []string{"云盘剧", "本地剧"}) {
t.Fatalf("items = %#v, want local+cloud only", got)
}
if cloudItem := mediaByTitle(items, "云盘剧"); cloudItem == nil || cloudItem.DisplayLibraryID != local.ID {
t.Fatalf("cloud item display library = %#v, want merged local library %s", cloudItem, local.ID)
}
items, total, err = svc.ListMediaVisible(t.Context(), local.ID, 1, 20, MediaVisibility{
IncludeNSFW: true,
@@ -156,6 +159,49 @@ func TestListMediaVisibleIncludesMergedCloudLibraryItems(t *testing.T) {
if got := mediaTitles(searchItems); !slices.Equal(got, []string{"云盘剧", "本地剧"}) {
t.Fatalf("profile-limited merged search items=%#v, want local+hidden cloud", got)
}
if cloudItem := mediaByTitle(searchItems, "云盘剧"); cloudItem == nil || cloudItem.DisplayLibraryID != local.ID {
t.Fatalf("search cloud item display library = %#v, want merged local library %s", cloudItem, local.ID)
}
}
func TestListMediaVisibleUsesSpecificCloudChildLibraryAsDisplayTarget(t *testing.T) {
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
if err != nil {
t.Fatal(err)
}
if err := db.AutoMigrate(&model.Library{}, &model.Media{}); err != nil {
t.Fatal(err)
}
repos := repository.New(db)
root := model.Library{Name: "OpenList", Path: "cloud://openlist", Type: "tv", Enabled: true}
child := model.Library{Name: "OpenList · 国产剧", Path: BuildCloudLibraryPath("openlist", "/国产剧", "/国产剧"), Type: "tv", Enabled: true}
for _, lib := range []*model.Library{&root, &child} {
if err := repos.Library.Create(t.Context(), lib); err != nil {
t.Fatal(err)
}
}
if err := repos.DB.Create(&model.Media{
LibraryID: root.ID,
Title: "折腰",
Path: "cloud://openlist/国产剧/折腰 (2025)/Season 1/折腰.S01E01.mkv",
}).Error; err != nil {
t.Fatal(err)
}
svc := NewMediaService(&config.Config{}, zap.NewNop(), repos)
items, total, err := svc.ListMediaVisible(t.Context(), root.ID, 1, 20, MediaVisibility{IncludeNSFW: true})
if err != nil {
t.Fatal(err)
}
if total != 1 || len(items) != 1 {
t.Fatalf("items total=%d len=%d, want one root cloud item", total, len(items))
}
if items[0].DisplayLibraryID != child.ID {
t.Fatalf("display library = %q, want child cloud library %q", items[0].DisplayLibraryID, child.ID)
}
if items[0].DisplayLibraryPath != child.Path {
t.Fatalf("display library path = %q, want %q", items[0].DisplayLibraryPath, child.Path)
}
}
func TestStartAllCloudLibraryScansIncludesMergedCloudMounts(t *testing.T) {
@@ -201,3 +247,12 @@ func mediaTitles(items []model.Media) []string {
slices.Sort(out)
return out
}
func mediaByTitle(items []model.Media, title string) *model.Media {
for i := range items {
if items[i].Title == title {
return &items[i]
}
}
return nil
}
+154 -3
View File
@@ -381,12 +381,14 @@ func (s *MediaService) ListMediaVisible(ctx context.Context, libraryID string, p
cacheKey := s.mediaListCacheKey(libraryID, libraryIDs, page, pageSize, filter)
var cached mediaListCacheValue
if s.cache != nil && s.cache.GetJSON(ctx, cacheKey, &cached) {
s.attachLibraryMetadata(ctx, cached.Items)
return cached.Items, cached.Total, nil
}
items, total, err := s.repo.Media.ListByLibrariesFiltered(ctx, libraryIDs, (page-1)*pageSize, pageSize, filter)
if err != nil {
return nil, 0, err
}
s.attachLibraryMetadata(ctx, items)
if s.cache != nil {
s.cache.SetJSON(ctx, cacheKey, mediaListCacheValue{Items: items, Total: total}, time.Duration(s.mediaCacheTTLSeconds())*time.Second)
}
@@ -438,6 +440,138 @@ func (s *MediaService) invalidateMediaCache(ctx context.Context) {
}
}
func (s *MediaService) attachLibraryMetadata(ctx context.Context, items []model.Media) {
if s == nil || s.repo == nil || s.repo.Library == nil || len(items) == 0 {
return
}
libs, err := s.repo.Library.List(ctx)
if err != nil {
return
}
byID := make(map[string]model.Library, len(libs))
for _, lib := range libs {
byID[lib.ID] = lib
}
resolver := newMediaDisplayLibraryResolver(ctx, s.repo, libs)
for i := range items {
if lib, ok := byID[items[i].LibraryID]; ok {
items[i].LibraryName = lib.Name
items[i].LibraryPath = lib.Path
}
if lib, ok := resolver.DisplayLibraryForMedia(items[i]); ok {
items[i].DisplayLibraryID = lib.ID
items[i].DisplayLibraryName = lib.Name
items[i].DisplayLibraryPath = lib.Path
}
}
}
type mediaDisplayLibraryResolver struct {
byID map[string]model.Library
displayByID map[string]model.Library
displayByMergeKey map[string]model.Library
displayLibraries []model.Library
}
func newMediaDisplayLibraryResolver(ctx context.Context, repo *repository.Container, libs []model.Library) mediaDisplayLibraryResolver {
displayLibraries := FilterDisplayCloudLibraries(ctx, repo, append([]model.Library(nil), libs...))
resolver := mediaDisplayLibraryResolver{
byID: make(map[string]model.Library, len(libs)),
displayByID: make(map[string]model.Library, len(displayLibraries)),
displayByMergeKey: make(map[string]model.Library, len(displayLibraries)),
displayLibraries: displayLibraries,
}
for _, lib := range libs {
resolver.byID[lib.ID] = lib
}
for _, lib := range displayLibraries {
resolver.displayByID[lib.ID] = lib
if key, ok := CloudLibraryMergeKey(lib); ok {
if _, exists := resolver.displayByMergeKey[key]; !exists {
resolver.displayByMergeKey[key] = lib
}
}
}
return resolver
}
func (r mediaDisplayLibraryResolver) DisplayLibraryForMedia(media model.Media) (model.Library, bool) {
if lib, ok := r.bestPathDisplayLibrary(media); ok {
return lib, true
}
if lib, ok := r.displayByID[media.LibraryID]; ok {
return lib, true
}
own, hasOwn := r.byID[media.LibraryID]
if hasOwn {
if key, ok := CloudLibraryMergeKey(own); ok {
if lib, exists := r.displayByMergeKey[key]; exists {
return lib, true
}
}
return own, true
}
return model.Library{}, false
}
func (r mediaDisplayLibraryResolver) bestPathDisplayLibrary(media model.Media) (model.Library, bool) {
if strings.HasPrefix(strings.ToLower(strings.TrimSpace(media.Path)), "cloud://") {
mediaInfo, ok := ParseCloudLibraryMount(media.Path)
if !ok {
return model.Library{}, false
}
var best model.Library
bestDepth := 0
for _, lib := range r.displayLibraries {
info, ok := ParseCloudLibraryMount(lib.Path)
if !ok || info.Provider != mediaInfo.Provider || !lib.Enabled {
continue
}
dir := strings.Trim(firstNonEmpty(info.DisplayDir, info.ScanDir), "/")
if dir == "" {
continue
}
mediaDir := strings.Trim(firstNonEmpty(mediaInfo.DisplayDir, mediaInfo.ScanDir), "/")
if mediaDir != dir && !cloudMountAncestor(dir, mediaDir) {
continue
}
depth := len(strings.Split(dir, "/"))
if depth > bestDepth {
best = lib
bestDepth = depth
}
}
if bestDepth > 0 {
return best, true
}
return model.Library{}, false
}
mediaPath := cleanPathForVolumeMapping(media.Path)
var best model.Library
bestLen := 0
for _, lib := range r.displayLibraries {
if _, ok := ParseCloudLibraryMount(lib.Path); ok || !lib.Enabled {
continue
}
libPath := cleanPathForVolumeMapping(lib.Path)
if libPath == "" || libPath == "." {
continue
}
if mediaPath != libPath && !strings.HasPrefix(mediaPath, strings.TrimRight(libPath, "/")+"/") {
continue
}
if len(libPath) > bestLen {
best = lib
bestLen = len(libPath)
}
}
if bestLen > 0 {
return best, true
}
return model.Library{}, false
}
func groupMediaVersions(items []model.Media) []MediaItem {
if len(items) == 0 {
return nil
@@ -585,11 +719,16 @@ func (s *MediaService) SearchMediaVisible(ctx context.Context, query string, lim
limit = maxMediaSearchLimit
}
visibility = ExpandMediaVisibilityForMergedCloudLibraries(ctx, s.repo, visibility)
return s.repo.Media.SearchFiltered(ctx, query, limit, repository.MediaQueryFilter{
items, err := s.repo.Media.SearchFiltered(ctx, query, limit, repository.MediaQueryFilter{
IncludeNSFW: visibility.IncludeNSFW,
AllowedLibraryIDs: visibility.AllowedLibraryIDs,
HiddenLibraryIDs: visibility.HiddenLibraryIDs,
})
if err != nil {
return nil, err
}
s.attachLibraryMetadata(ctx, items)
return items, nil
}
func (s *MediaService) SearchMediaVisibleGrouped(ctx context.Context, query string, limit int, visibility MediaVisibility) ([]MediaItem, error) {
@@ -611,11 +750,16 @@ func (s *MediaService) SearchMediaVisiblePage(ctx context.Context, query string,
page = 1
}
visibility = ExpandMediaVisibilityForMergedCloudLibraries(ctx, s.repo, visibility)
return s.repo.Media.SearchFilteredPage(ctx, query, (page-1)*pageSize, pageSize, repository.MediaQueryFilter{
items, total, err := s.repo.Media.SearchFilteredPage(ctx, query, (page-1)*pageSize, pageSize, repository.MediaQueryFilter{
IncludeNSFW: visibility.IncludeNSFW,
AllowedLibraryIDs: visibility.AllowedLibraryIDs,
HiddenLibraryIDs: visibility.HiddenLibraryIDs,
})
if err != nil {
return nil, 0, err
}
s.attachLibraryMetadata(ctx, items)
return items, total, nil
}
func (s *MediaService) SearchMediaVisiblePageGrouped(ctx context.Context, query string, page, pageSize int, visibility MediaVisibility) ([]MediaItem, int64, error) {
@@ -629,7 +773,14 @@ func (s *MediaService) SearchMediaVisiblePageGrouped(ctx context.Context, query
// GetMedia returns a single media row.
func (s *MediaService) GetMedia(ctx context.Context, id string) (*model.Media, error) {
return s.repo.Media.FindByID(ctx, id)
media, err := s.repo.Media.FindByID(ctx, id)
if err != nil || media == nil {
return media, err
}
items := []model.Media{*media}
s.attachLibraryMetadata(ctx, items)
*media = items[0]
return media, nil
}
const maxRecycleBinRecords = 200
+30
View File
@@ -130,6 +130,36 @@ func (s *SubscriptionService) History(ctx context.Context) ([]model.Subscription
return s.repo.Subscription.History(ctx)
}
// Restore moves an archived subscription back to the active management list.
// It also clears the per-subscription seen state so an unfinished historical
// rule can match resources again when it is run next.
func (s *SubscriptionService) Restore(ctx context.Context, id string) (*model.Subscription, error) {
var sub model.Subscription
if err := s.repo.DB.WithContext(ctx).Where("id = ?", id).First(&sub).Error; err != nil {
return nil, err
}
if err := s.repo.DB.WithContext(ctx).Model(&model.Subscription{}).
Where("id = ?", id).
Updates(map[string]any{
"enabled": true,
"archive_reason": "",
}).Error; err != nil {
return nil, err
}
if err := s.repo.DB.WithContext(ctx).
Exec("UPDATE subscriptions SET archived_at = NULL WHERE id = ?", id).Error; err != nil {
return nil, err
}
if s.repo.Setting != nil {
_ = s.repo.Setting.Delete(ctx, fmt.Sprintf("subscription.%s.seen", id))
}
var restored model.Subscription
if err := s.repo.DB.WithContext(ctx).Where("id = ?", id).First(&restored).Error; err != nil {
return nil, err
}
return &restored, nil
}
// Delete removes a subscription.
func (s *SubscriptionService) Delete(ctx context.Context, id string) error {
var sub model.Subscription
+58
View File
@@ -9,6 +9,7 @@ import (
"strings"
"sync/atomic"
"testing"
"time"
"github.com/glebarez/sqlite"
"go.uber.org/zap"
@@ -810,6 +811,63 @@ func TestSubscriptionArchiveKeepsWashSubscriptionActive(t *testing.T) {
}
}
func TestRestoreArchivedSubscriptionReturnsToActiveAndClearsSeenState(t *testing.T) {
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
if err != nil {
t.Fatal(err)
}
if err := db.AutoMigrate(&model.Subscription{}, &model.Setting{}); err != nil {
t.Fatal(err)
}
repos := repository.New(db)
svc := NewSubscriptionService(nil, zap.NewNop(), repos, nil, nil, NewHub(zap.NewNop()))
sub := &model.Subscription{
Name: "南部档案 自动订阅",
FeedURL: "https://rss.example/feed",
Filter: "南部档案",
MediaType: "tv",
TotalEpisodes: 33,
}
if err := repos.Subscription.Create(t.Context(), sub); err != nil {
t.Fatal(err)
}
archivedAt := time.Now()
if err := repos.Subscription.Archive(t.Context(), sub.ID, "已下载 1/33 集,缺 33 集", archivedAt); err != nil {
t.Fatal(err)
}
if err := repos.Setting.Set(t.Context(), "subscription."+sub.ID+".seen", "old-guid"); err != nil {
t.Fatal(err)
}
restored, err := svc.Restore(t.Context(), sub.ID)
if err != nil {
t.Fatal(err)
}
if restored.ArchivedAt != nil || restored.ArchiveReason != "" || !restored.Enabled {
t.Fatalf("restored subscription not active: archived=%v reason=%q enabled=%v", restored.ArchivedAt, restored.ArchiveReason, restored.Enabled)
}
active, err := repos.Subscription.List(t.Context())
if err != nil {
t.Fatal(err)
}
if len(active) != 1 || active[0].ID != sub.ID {
t.Fatalf("active subscriptions = %#v, want restored subscription", active)
}
history, err := repos.Subscription.History(t.Context())
if err != nil {
t.Fatal(err)
}
if len(history) != 0 {
t.Fatalf("history subscriptions = %d, want 0 after restore", len(history))
}
seen, err := repos.Setting.Get(t.Context(), "subscription."+sub.ID+".seen")
if err != nil {
t.Fatal(err)
}
if seen != "" {
t.Fatalf("seen state = %q, want cleared", seen)
}
}
func TestSubscriptionRunOneDeduplicatesDuplicateRSSGUIDInSameFeed(t *testing.T) {
rss := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Content-Type", "application/rss+xml")
+3
View File
@@ -39,6 +39,9 @@ export const subscriptionsAPI = {
remove: (id: string) => api.delete(`/subscriptions/${id}`).then((r) => r.data),
restore: (id: string) =>
api.post<Subscription>(`/subscriptions/${id}/restore`).then((r) => r.data),
runNow: (id: string) =>
api.post<{ queued: number }>(`/subscriptions/${id}/run`).then((r) => r.data),
}
+2 -2
View File
@@ -8,7 +8,7 @@ import { playbackAPI, type HistoryItem } from '../api/playback'
import { imageURL } from '../api/client'
import { MediaCard } from '../components/MediaCard'
import type { Library, Media } from '../types'
import { groupSeries } from '../utils/groupSeries'
import { groupSeries, seriesCardLink } from '../utils/groupSeries'
const hasArtwork = (media?: Media | null) => !!(media?.poster_url || media?.backdrop_url)
const asArray = <T,>(value: unknown): T[] => (Array.isArray(value) ? value as T[] : [])
@@ -220,7 +220,7 @@ export function HomePage() {
key={card.key}
media={card.rep}
count={card.count}
linkTo={card.count > 1 ? `/library/${card.rep.library_id}?series=${encodeURIComponent(card.key)}` : undefined}
linkTo={card.count > 1 ? seriesCardLink(card) : undefined}
/>
))}
</div>
+33 -1
View File
@@ -1,6 +1,6 @@
import { FormEvent, useEffect, useState } from 'react'
import toast from 'react-hot-toast'
import { Archive, CalendarClock, CheckCircle2, Film, Pencil, Play, Plus, Save, ShieldCheck, Trash2 } from 'lucide-react'
import { Archive, CalendarClock, CheckCircle2, Film, Pencil, Play, Plus, RotateCcw, Save, ShieldCheck, Trash2 } from 'lucide-react'
import { subscriptionsAPI } from '../api/subscriptions'
import { imageURL } from '../api/client'
@@ -117,6 +117,22 @@ export function SubscriptionsPage() {
window.scrollTo({ top: 0, behavior: 'smooth' })
}
const restoreHistorySubscription = async (subscription: Subscription, runAfterRestore = false) => {
try {
const restored = await subscriptionsAPI.restore(subscription.id)
if (runAfterRestore) {
const result = await subscriptionsAPI.runNow(restored.id)
toast.success(`已恢复订阅并加入 ${result.queued} 项`)
} else {
toast.success('已恢复到正在订阅')
}
await refresh()
} catch (err: unknown) {
const msg = (err as { response?: { data?: { error?: string } } })?.response?.data?.error || '恢复订阅失败'
toast.error(msg)
}
}
return (
<div className="space-y-6">
<h1 className="font-display text-3xl font-bold text-ink-600">RSS 订阅</h1>
@@ -344,6 +360,22 @@ export function SubscriptionsPage() {
{subscription.archived_at ? new Date(subscription.archived_at).toLocaleString() : '完成时间未知'}
</p>
<p className="mt-1 text-xs text-ink-50">{subscriptionProgressLabel(subscription)}</p>
<div className="mt-3 flex flex-wrap gap-2">
<button
className="rounded-xl border border-gray-300 bg-white px-3 py-1.5 text-xs font-semibold text-ink-100 hover:bg-gray-50"
onClick={() => restoreHistorySubscription(subscription)}
>
<RotateCcw size={13} className="mr-1 inline" />
恢复订阅
</button>
<button
className="rounded-xl border border-primary-400/40 bg-white px-3 py-1.5 text-xs font-semibold text-brand-500 hover:bg-primary-400/10"
onClick={() => restoreHistorySubscription(subscription, true)}
>
<Play size={13} className="mr-1 inline" />
恢复并运行
</button>
</div>
</div>
</div>
</article>
+5
View File
@@ -106,6 +106,11 @@ export interface Library {
export interface Media {
id: string
library_id: string
library_name?: string
library_path?: string
display_library_id?: string
display_library_name?: string
display_library_path?: string
series_id?: string
title: string
original_name?: string
+31 -3
View File
@@ -16,7 +16,7 @@ import type { Media } from '../types'
*
* 同一组内取最早 created_at 的那条作为代表卡片,并带 count 表示集数。
*/
export type SeriesCard = { key: string; rep: Media; count: number }
export type SeriesCard = { key: string; rep: Media; linkMedia: Media; count: number }
export function getSeriesKey(media: Media): string {
if (media.series_id) return `series:${media.series_id}`
@@ -74,9 +74,12 @@ export function groupSeries(items: Media[] = []): SeriesCard[] {
const g = groups.get(key)
if (!g) {
groups.set(key, { key, rep: m, count: 1 })
groups.set(key, { key, rep: m, linkMedia: m, count: 1 })
} else {
g.count += 1
if (betterSeriesLinkMedia(m, g.linkMedia)) {
g.linkMedia = m
}
const currentArtwork = artworkScore(m)
const representativeArtwork = artworkScore(g.rep)
if (currentArtwork > representativeArtwork) {
@@ -93,11 +96,36 @@ export function groupSeries(items: Media[] = []): SeriesCard[] {
export function seriesCardLink(card: SeriesCard): string {
if (card.count > 1) {
return `/library/${card.rep.library_id}?series=${encodeURIComponent(card.key)}`
return `/library/${targetLibraryID(card.linkMedia)}`
}
return `/media/${card.rep.id}`
}
function betterSeriesLinkMedia(candidate: Media, current: Media): boolean {
const candidateScore = librarySpecificityScore(candidate)
const currentScore = librarySpecificityScore(current)
if (candidateScore !== currentScore) return candidateScore > currentScore
return artworkScore(candidate) > artworkScore(current)
}
function librarySpecificityScore(media: Media): number {
const rawPath = (media.display_library_path || media.library_path || '').trim()
if (!rawPath) return 0
const normalized = rawPath.replace(/\\/g, '/').replace(/\/+$/, '')
const lower = normalized.toLowerCase()
if (lower.startsWith('cloud://')) {
const rest = normalized.slice('cloud://'.length)
const slash = rest.indexOf('/')
if (slash < 0 || slash === rest.length - 1) return 0
return 100 + rest.slice(slash + 1).split('/').filter(Boolean).length
}
return 200 + normalized.split('/').filter(Boolean).length
}
function targetLibraryID(media: Media): string {
return media.display_library_id || media.library_id
}
export function artworkScore(media: Media): number {
const poster = (media.poster_url ?? '').toLowerCase()
const backdrop = (media.backdrop_url ?? '').toLowerCase()