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")