mirror of
https://github.com/truewhile/MeBox.git
synced 2026-10-10 23:36:37 +08:00
Fix subscription restore and media library entry links
This commit is contained in:
@@ -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.GET("/subscriptions/history", requirePermission(svc, "can_manage_subscriptions"), listSubscriptionHistoryHandler(svc))
|
||||||
authed.POST("/subscriptions", requirePermission(svc, "can_manage_subscriptions"), createSubscriptionHandler(svc))
|
authed.POST("/subscriptions", requirePermission(svc, "can_manage_subscriptions"), createSubscriptionHandler(svc))
|
||||||
authed.DELETE("/subscriptions/:id", requirePermission(svc, "can_manage_subscriptions"), deleteSubscriptionHandler(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))
|
authed.POST("/subscriptions/:id/run", requirePermission(svc, "can_manage_subscriptions"), runSubscriptionHandler(svc))
|
||||||
|
|
||||||
// Stats / dashboard.
|
// Stats / dashboard.
|
||||||
|
|||||||
@@ -130,3 +130,16 @@ func runSubscriptionHandler(svc *service.Container) gin.HandlerFunc {
|
|||||||
c.JSON(http.StatusOK, gin.H{"queued": n})
|
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])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -103,6 +103,13 @@ type Media struct {
|
|||||||
// Used to expose WebDAV / Alist / S3 / HTTP direct links as media items.
|
// Used to expose WebDAV / Alist / S3 / HTTP direct links as media items.
|
||||||
STRMURL string `gorm:"size:2048" json:"strm_url,omitempty"`
|
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.
|
// FileHash is a sparse-sample MD5 used for duplicate detection.
|
||||||
// Computed on-demand by the duplicate finder; format: "<hex>-<size>".
|
// Computed on-demand by the duplicate finder; format: "<hex>-<size>".
|
||||||
FileHash string `gorm:"index;size:64" json:"file_hash,omitempty"`
|
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{"云盘剧", "本地剧"}) {
|
if got := mediaTitles(items); !slices.Equal(got, []string{"云盘剧", "本地剧"}) {
|
||||||
t.Fatalf("items = %#v, want local+cloud only", got)
|
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{
|
items, total, err = svc.ListMediaVisible(t.Context(), local.ID, 1, 20, MediaVisibility{
|
||||||
IncludeNSFW: true,
|
IncludeNSFW: true,
|
||||||
@@ -156,6 +159,49 @@ func TestListMediaVisibleIncludesMergedCloudLibraryItems(t *testing.T) {
|
|||||||
if got := mediaTitles(searchItems); !slices.Equal(got, []string{"云盘剧", "本地剧"}) {
|
if got := mediaTitles(searchItems); !slices.Equal(got, []string{"云盘剧", "本地剧"}) {
|
||||||
t.Fatalf("profile-limited merged search items=%#v, want local+hidden cloud", got)
|
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) {
|
func TestStartAllCloudLibraryScansIncludesMergedCloudMounts(t *testing.T) {
|
||||||
@@ -201,3 +247,12 @@ func mediaTitles(items []model.Media) []string {
|
|||||||
slices.Sort(out)
|
slices.Sort(out)
|
||||||
return 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
@@ -381,12 +381,14 @@ func (s *MediaService) ListMediaVisible(ctx context.Context, libraryID string, p
|
|||||||
cacheKey := s.mediaListCacheKey(libraryID, libraryIDs, page, pageSize, filter)
|
cacheKey := s.mediaListCacheKey(libraryID, libraryIDs, page, pageSize, filter)
|
||||||
var cached mediaListCacheValue
|
var cached mediaListCacheValue
|
||||||
if s.cache != nil && s.cache.GetJSON(ctx, cacheKey, &cached) {
|
if s.cache != nil && s.cache.GetJSON(ctx, cacheKey, &cached) {
|
||||||
|
s.attachLibraryMetadata(ctx, cached.Items)
|
||||||
return cached.Items, cached.Total, nil
|
return cached.Items, cached.Total, nil
|
||||||
}
|
}
|
||||||
items, total, err := s.repo.Media.ListByLibrariesFiltered(ctx, libraryIDs, (page-1)*pageSize, pageSize, filter)
|
items, total, err := s.repo.Media.ListByLibrariesFiltered(ctx, libraryIDs, (page-1)*pageSize, pageSize, filter)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, 0, err
|
return nil, 0, err
|
||||||
}
|
}
|
||||||
|
s.attachLibraryMetadata(ctx, items)
|
||||||
if s.cache != nil {
|
if s.cache != nil {
|
||||||
s.cache.SetJSON(ctx, cacheKey, mediaListCacheValue{Items: items, Total: total}, time.Duration(s.mediaCacheTTLSeconds())*time.Second)
|
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 {
|
func groupMediaVersions(items []model.Media) []MediaItem {
|
||||||
if len(items) == 0 {
|
if len(items) == 0 {
|
||||||
return nil
|
return nil
|
||||||
@@ -585,11 +719,16 @@ func (s *MediaService) SearchMediaVisible(ctx context.Context, query string, lim
|
|||||||
limit = maxMediaSearchLimit
|
limit = maxMediaSearchLimit
|
||||||
}
|
}
|
||||||
visibility = ExpandMediaVisibilityForMergedCloudLibraries(ctx, s.repo, visibility)
|
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,
|
IncludeNSFW: visibility.IncludeNSFW,
|
||||||
AllowedLibraryIDs: visibility.AllowedLibraryIDs,
|
AllowedLibraryIDs: visibility.AllowedLibraryIDs,
|
||||||
HiddenLibraryIDs: visibility.HiddenLibraryIDs,
|
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) {
|
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
|
page = 1
|
||||||
}
|
}
|
||||||
visibility = ExpandMediaVisibilityForMergedCloudLibraries(ctx, s.repo, visibility)
|
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,
|
IncludeNSFW: visibility.IncludeNSFW,
|
||||||
AllowedLibraryIDs: visibility.AllowedLibraryIDs,
|
AllowedLibraryIDs: visibility.AllowedLibraryIDs,
|
||||||
HiddenLibraryIDs: visibility.HiddenLibraryIDs,
|
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) {
|
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.
|
// GetMedia returns a single media row.
|
||||||
func (s *MediaService) GetMedia(ctx context.Context, id string) (*model.Media, error) {
|
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
|
const maxRecycleBinRecords = 200
|
||||||
|
|||||||
@@ -130,6 +130,36 @@ func (s *SubscriptionService) History(ctx context.Context) ([]model.Subscription
|
|||||||
return s.repo.Subscription.History(ctx)
|
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.
|
// Delete removes a subscription.
|
||||||
func (s *SubscriptionService) Delete(ctx context.Context, id string) error {
|
func (s *SubscriptionService) Delete(ctx context.Context, id string) error {
|
||||||
var sub model.Subscription
|
var sub model.Subscription
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"sync/atomic"
|
"sync/atomic"
|
||||||
"testing"
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/glebarez/sqlite"
|
"github.com/glebarez/sqlite"
|
||||||
"go.uber.org/zap"
|
"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) {
|
func TestSubscriptionRunOneDeduplicatesDuplicateRSSGUIDInSameFeed(t *testing.T) {
|
||||||
rss := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
rss := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||||
w.Header().Set("Content-Type", "application/rss+xml")
|
w.Header().Set("Content-Type", "application/rss+xml")
|
||||||
|
|||||||
@@ -39,6 +39,9 @@ export const subscriptionsAPI = {
|
|||||||
|
|
||||||
remove: (id: string) => api.delete(`/subscriptions/${id}`).then((r) => r.data),
|
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) =>
|
runNow: (id: string) =>
|
||||||
api.post<{ queued: number }>(`/subscriptions/${id}/run`).then((r) => r.data),
|
api.post<{ queued: number }>(`/subscriptions/${id}/run`).then((r) => r.data),
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import { playbackAPI, type HistoryItem } from '../api/playback'
|
|||||||
import { imageURL } from '../api/client'
|
import { imageURL } from '../api/client'
|
||||||
import { MediaCard } from '../components/MediaCard'
|
import { MediaCard } from '../components/MediaCard'
|
||||||
import type { Library, Media } from '../types'
|
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 hasArtwork = (media?: Media | null) => !!(media?.poster_url || media?.backdrop_url)
|
||||||
const asArray = <T,>(value: unknown): T[] => (Array.isArray(value) ? value as T[] : [])
|
const asArray = <T,>(value: unknown): T[] => (Array.isArray(value) ? value as T[] : [])
|
||||||
@@ -220,7 +220,7 @@ export function HomePage() {
|
|||||||
key={card.key}
|
key={card.key}
|
||||||
media={card.rep}
|
media={card.rep}
|
||||||
count={card.count}
|
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>
|
</div>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { FormEvent, useEffect, useState } from 'react'
|
import { FormEvent, useEffect, useState } from 'react'
|
||||||
import toast from 'react-hot-toast'
|
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 { subscriptionsAPI } from '../api/subscriptions'
|
||||||
import { imageURL } from '../api/client'
|
import { imageURL } from '../api/client'
|
||||||
@@ -117,6 +117,22 @@ export function SubscriptionsPage() {
|
|||||||
window.scrollTo({ top: 0, behavior: 'smooth' })
|
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 (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<h1 className="font-display text-3xl font-bold text-ink-600">RSS 订阅</h1>
|
<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() : '完成时间未知'}
|
{subscription.archived_at ? new Date(subscription.archived_at).toLocaleString() : '完成时间未知'}
|
||||||
</p>
|
</p>
|
||||||
<p className="mt-1 text-xs text-ink-50">{subscriptionProgressLabel(subscription)}</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>
|
||||||
</div>
|
</div>
|
||||||
</article>
|
</article>
|
||||||
|
|||||||
@@ -106,6 +106,11 @@ export interface Library {
|
|||||||
export interface Media {
|
export interface Media {
|
||||||
id: string
|
id: string
|
||||||
library_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
|
series_id?: string
|
||||||
title: string
|
title: string
|
||||||
original_name?: string
|
original_name?: string
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ import type { Media } from '../types'
|
|||||||
*
|
*
|
||||||
* 同一组内取最早 created_at 的那条作为代表卡片,并带 count 表示集数。
|
* 同一组内取最早 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 {
|
export function getSeriesKey(media: Media): string {
|
||||||
if (media.series_id) return `series:${media.series_id}`
|
if (media.series_id) return `series:${media.series_id}`
|
||||||
@@ -74,9 +74,12 @@ export function groupSeries(items: Media[] = []): SeriesCard[] {
|
|||||||
|
|
||||||
const g = groups.get(key)
|
const g = groups.get(key)
|
||||||
if (!g) {
|
if (!g) {
|
||||||
groups.set(key, { key, rep: m, count: 1 })
|
groups.set(key, { key, rep: m, linkMedia: m, count: 1 })
|
||||||
} else {
|
} else {
|
||||||
g.count += 1
|
g.count += 1
|
||||||
|
if (betterSeriesLinkMedia(m, g.linkMedia)) {
|
||||||
|
g.linkMedia = m
|
||||||
|
}
|
||||||
const currentArtwork = artworkScore(m)
|
const currentArtwork = artworkScore(m)
|
||||||
const representativeArtwork = artworkScore(g.rep)
|
const representativeArtwork = artworkScore(g.rep)
|
||||||
if (currentArtwork > representativeArtwork) {
|
if (currentArtwork > representativeArtwork) {
|
||||||
@@ -93,11 +96,36 @@ export function groupSeries(items: Media[] = []): SeriesCard[] {
|
|||||||
|
|
||||||
export function seriesCardLink(card: SeriesCard): string {
|
export function seriesCardLink(card: SeriesCard): string {
|
||||||
if (card.count > 1) {
|
if (card.count > 1) {
|
||||||
return `/library/${card.rep.library_id}?series=${encodeURIComponent(card.key)}`
|
return `/library/${targetLibraryID(card.linkMedia)}`
|
||||||
}
|
}
|
||||||
return `/media/${card.rep.id}`
|
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 {
|
export function artworkScore(media: Media): number {
|
||||||
const poster = (media.poster_url ?? '').toLowerCase()
|
const poster = (media.poster_url ?? '').toLowerCase()
|
||||||
const backdrop = (media.backdrop_url ?? '').toLowerCase()
|
const backdrop = (media.backdrop_url ?? '').toLowerCase()
|
||||||
|
|||||||
Reference in New Issue
Block a user