fix: stabilize library paths and subscription matching

This commit is contained in:
ShukeBta
2026-06-28 12:15:10 +08:00
parent e4abb03ca5
commit a97dc35790
102 changed files with 4582 additions and 1519 deletions
+1 -2
View File
@@ -31,7 +31,6 @@ func listLibrariesHandler(svc *service.Container) gin.HandlerFunc {
return
}
libs = service.FilterDeprecatedNativeCloudLibraries(libs)
libs = service.FilterInternalCloudAutoCategoryLibraries(libs)
role, _ := c.Get(middleware.CtxUserRole)
includeHidden := role == "admin" && (c.Query("include_hidden") == "1" || c.Query("all") == "1")
if !includeHidden {
@@ -45,6 +44,7 @@ func listLibrariesHandler(svc *service.Container) gin.HandlerFunc {
}
libs = filtered
} else {
libs = service.FilterMergedCloudAutoCategoryLibraries(libs)
libs = service.NormalizeCloudLibraryDisplayNames(libs)
}
c.JSON(http.StatusOK, libs)
@@ -63,7 +63,6 @@ func getLibraryHandler(svc *service.Container) gin.HandlerFunc {
return
}
libs := service.FilterDeprecatedNativeCloudLibraries([]model.Library{*lib})
libs = service.FilterInternalCloudAutoCategoryLibraries(libs)
if len(libs) == 0 {
c.JSON(http.StatusNotFound, gin.H{"error": "not found"})
return
+27 -3
View File
@@ -95,7 +95,7 @@ func TestListLibrariesIncludeHiddenNormalizesCloudDisplayNames(t *testing.T) {
}
}
func TestListLibrariesIncludeHiddenHidesInternalAutoCategoryLibraries(t *testing.T) {
func TestListLibrariesShowsAutoCategoryLibraries(t *testing.T) {
gin.SetMode(gin.TestMode)
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
if err != nil {
@@ -118,8 +118,32 @@ func TestListLibrariesIncludeHiddenHidesInternalAutoCategoryLibraries(t *testing
}
all := requestLibraries(t, svc, "admin", "admin", "/api/libraries?include_hidden=1")
if len(all) != 1 || all[0].ID != root.ID {
t.Fatalf("include_hidden list = %#v, want only user-mounted cloud library", all)
if len(all) != 2 {
t.Fatalf("include_hidden list = %#v, want root plus auto category library", all)
}
ids := map[string]bool{}
for _, lib := range all {
ids[lib.ID] = true
}
if !ids[root.ID] || !ids[auto.ID] {
t.Fatalf("include_hidden list = %#v, want root %s and auto category %s", all, root.ID, auto.ID)
}
visible := requestLibraries(t, svc, "user-1", "user", "/api/libraries")
if len(visible) != 2 {
t.Fatalf("visible list = %#v, want root plus auto category library", visible)
}
ids = map[string]bool{}
for _, lib := range visible {
ids[lib.ID] = true
}
if !ids[root.ID] || !ids[auto.ID] {
t.Fatalf("visible list = %#v, want root %s and auto category %s", visible, root.ID, auto.ID)
}
got := requestLibrary(t, svc, "user-1", "user", "/api/libraries/"+auto.ID, auto.ID)
if got.ID != auto.ID || got.Name != auto.Name {
t.Fatalf("auto category detail = %#v, want accessible category library", got)
}
}
+84
View File
@@ -0,0 +1,84 @@
package handler
import (
"net/http"
"github.com/gin-gonic/gin"
"github.com/ShukeBta/MediaStationGo/internal/service"
)
func recognitionWordsService(svc *service.Container) *service.RecognitionWordsService {
if svc == nil {
return nil
}
if svc.RecognitionWords != nil {
return svc.RecognitionWords
}
return service.NewRecognitionWordsService(svc.Log, svc.Repo)
}
func getRecognitionWordsHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
rw := recognitionWordsService(svc)
if rw == nil {
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "recognition words service unavailable"})
return
}
c.JSON(http.StatusOK, rw.Config(c.Request.Context()))
}
}
func saveRecognitionWordsHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
rw := recognitionWordsService(svc)
if rw == nil {
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "recognition words service unavailable"})
return
}
var req service.RecognitionWordsConfig
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
if err := rw.SaveConfig(c.Request.Context(), req); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, rw.Config(c.Request.Context()))
}
}
func syncRecognitionWordsHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
rw := recognitionWordsService(svc)
if rw == nil {
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "recognition words service unavailable"})
return
}
cfg, err := rw.SyncShared(c.Request.Context())
if err != nil {
c.JSON(http.StatusBadGateway, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, cfg)
}
}
func testRecognitionWordsHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
rw := recognitionWordsService(svc)
if rw == nil {
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "recognition words service unavailable"})
return
}
var req struct {
Input string `json:"input"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, rw.Test(c.Request.Context(), req.Input))
}
}
+8
View File
@@ -25,6 +25,7 @@ func registerAdminRoutes(api *gin.RouterGroup, cfg *config.Config, svc *service.
registerAdminRepairRoutes(admin, svc)
registerAdminAPIConfigRoutes(admin, svc)
registerAdminSchedulerRoutes(admin, svc)
registerAdminRecognitionWordRoutes(admin, svc)
}
func registerAdminUserRoutes(admin *gin.RouterGroup, svc *service.Container) {
@@ -130,3 +131,10 @@ func registerAdminSchedulerRoutes(admin *gin.RouterGroup, svc *service.Container
admin.GET("/scheduler", schedulerStatusHandler(svc))
admin.POST("/scheduler/:name/run", schedulerRunHandler(svc))
}
func registerAdminRecognitionWordRoutes(admin *gin.RouterGroup, svc *service.Container) {
admin.GET("/recognition-words", getRecognitionWordsHandler(svc))
admin.PUT("/recognition-words", saveRecognitionWordsHandler(svc))
admin.POST("/recognition-words/sync", syncRecognitionWordsHandler(svc))
admin.POST("/recognition-words/test", testRecognitionWordsHandler(svc))
}
+4 -9
View File
@@ -6,6 +6,7 @@ package handler
import (
"net/http"
"strconv"
"github.com/gin-gonic/gin"
@@ -23,19 +24,13 @@ func siteResourceHandler(svc *service.Container) gin.HandlerFunc {
c.JSON(http.StatusBadRequest, gin.H{"error": "keyword required"})
return
}
all, err := svc.Site.Search(c.Request.Context(), keyword)
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
items, err := svc.Site.SearchSite(c.Request.Context(), c.Param("id"), keyword, page)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
want := c.Param("id")
filtered := make([]service.SearchResult, 0, len(all))
for _, r := range all {
if r.SiteID == want {
filtered = append(filtered, r)
}
}
c.JSON(http.StatusOK, gin.H{"items": filtered})
c.JSON(http.StatusOK, gin.H{"items": items, "total": len(items)})
}
}
+17
View File
@@ -5,6 +5,7 @@ import (
"net/http"
"github.com/gin-gonic/gin"
"go.uber.org/zap"
"github.com/ShukeBta/MediaStationGo/internal/model"
"github.com/ShukeBta/MediaStationGo/internal/service"
@@ -56,9 +57,17 @@ func updateSubscriptionHandler(svc *service.Container) gin.HandlerFunc {
Model(&model.Subscription{}).
Where("id = ?", c.Param("id")).
Updates(updates).Error; err != nil {
logSubscriptionWarn(svc, "subscription update failed",
zap.String("user_id", subscriptionRequestUserID(c)),
zap.String("subscription_id", c.Param("id")),
zap.Error(err))
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
logSubscriptionInfo(svc, "subscription updated",
zap.String("user_id", subscriptionRequestUserID(c)),
zap.String("subscription_id", c.Param("id")),
zap.Strings("fields", subscriptionUpdateFieldNames(updates)))
c.Status(http.StatusNoContent)
}
}
@@ -146,6 +155,14 @@ func subscriptionPatchUpdates(patch subscriptionPatchReq) map[string]any {
return updates
}
func subscriptionUpdateFieldNames(updates map[string]any) []string {
names := make([]string, 0, len(updates))
for name := range updates {
names = append(names, name)
}
return names
}
// searchSubscriptionHandler runs a one-off keyword search against the
// configured tracker sites for the given subscription. We treat the
// subscription's filter as the search term; this lets the UI preview
+54
View File
@@ -0,0 +1,54 @@
package handler
import (
"net/url"
"strings"
"github.com/gin-gonic/gin"
"go.uber.org/zap"
"github.com/ShukeBta/MediaStationGo/internal/middleware"
"github.com/ShukeBta/MediaStationGo/internal/service"
)
func subscriptionRequestUserID(c *gin.Context) string {
if c == nil {
return ""
}
if uid, ok := c.Get(middleware.CtxUserID); ok {
if userID, ok := uid.(string); ok {
return userID
}
}
return ""
}
func subscriptionFeedKind(feedURL string) string {
raw := strings.TrimSpace(feedURL)
if raw == "" {
return "empty"
}
lower := strings.ToLower(raw)
if strings.HasPrefix(lower, "site-search://") {
return "site-search"
}
parsed, err := url.Parse(raw)
if err == nil && parsed.Scheme != "" {
return parsed.Scheme
}
return "unknown"
}
func logSubscriptionInfo(svc *service.Container, msg string, fields ...zap.Field) {
if svc == nil || svc.Log == nil {
return
}
svc.Log.Info(msg, fields...)
}
func logSubscriptionWarn(svc *service.Container, msg string, fields ...zap.Field) {
if svc == nil || svc.Log == nil {
return
}
svc.Log.Warn(msg, fields...)
}
+53
View File
@@ -6,6 +6,7 @@ import (
"net/http"
"github.com/gin-gonic/gin"
"go.uber.org/zap"
"github.com/ShukeBta/MediaStationGo/internal/middleware"
"github.com/ShukeBta/MediaStationGo/internal/model"
@@ -80,9 +81,24 @@ func createSubscriptionHandler(svc *service.Container) gin.HandlerFunc {
}
enrichSubscriptionArtwork(c.Request.Context(), svc, s)
if err := svc.Subscription.Create(c.Request.Context(), s); err != nil {
logSubscriptionWarn(svc, "subscription create failed",
zap.String("user_id", s.UserID),
zap.String("name", req.Name),
zap.String("feed_kind", subscriptionFeedKind(req.FeedURL)),
zap.Bool("enabled", enabled),
zap.Error(err))
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
logSubscriptionInfo(svc, "subscription created",
zap.String("user_id", s.UserID),
zap.String("subscription_id", s.ID),
zap.String("name", s.Name),
zap.String("feed_kind", subscriptionFeedKind(s.FeedURL)),
zap.String("media_type", s.MediaType),
zap.String("media_category", s.MediaCategory),
zap.Bool("enabled", s.Enabled),
zap.Bool("wash_enabled", s.WashEnabled))
enriched := []model.Subscription{*s}
svc.Subscription.EnrichManagementProgress(c.Request.Context(), enriched)
*s = enriched[0]
@@ -94,11 +110,18 @@ func listSubscriptionsHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
items, err := svc.Subscription.List(c.Request.Context())
if err != nil {
logSubscriptionWarn(svc, "subscription list failed",
zap.String("user_id", subscriptionRequestUserID(c)),
zap.Error(err))
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
svc.Subscription.EnrichManagementProgress(c.Request.Context(), items)
go enrichAndPersistSubscriptions(context.Background(), svc, append([]model.Subscription(nil), items...))
logSubscriptionInfo(svc, "subscription list returned",
zap.String("user_id", subscriptionRequestUserID(c)),
zap.Int("count", len(items)),
zap.Bool("history", false))
c.JSON(http.StatusOK, gin.H{"items": items})
}
}
@@ -107,10 +130,17 @@ func listSubscriptionHistoryHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
items, err := svc.Subscription.History(c.Request.Context())
if err != nil {
logSubscriptionWarn(svc, "subscription history list failed",
zap.String("user_id", subscriptionRequestUserID(c)),
zap.Error(err))
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
svc.Subscription.EnrichManagementProgress(c.Request.Context(), items)
logSubscriptionInfo(svc, "subscription list returned",
zap.String("user_id", subscriptionRequestUserID(c)),
zap.Int("count", len(items)),
zap.Bool("history", true))
c.JSON(http.StatusOK, gin.H{"items": items})
}
}
@@ -118,9 +148,16 @@ func listSubscriptionHistoryHandler(svc *service.Container) gin.HandlerFunc {
func deleteSubscriptionHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
if err := svc.Subscription.Delete(c.Request.Context(), c.Param("id")); err != nil {
logSubscriptionWarn(svc, "subscription delete failed",
zap.String("user_id", subscriptionRequestUserID(c)),
zap.String("subscription_id", c.Param("id")),
zap.Error(err))
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
logSubscriptionInfo(svc, "subscription deleted",
zap.String("user_id", subscriptionRequestUserID(c)),
zap.String("subscription_id", c.Param("id")))
c.Status(http.StatusNoContent)
}
}
@@ -129,9 +166,17 @@ func runSubscriptionHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
n, err := svc.Subscription.RunNow(c.Request.Context(), c.Param("id"))
if err != nil {
logSubscriptionWarn(svc, "subscription run now failed",
zap.String("user_id", subscriptionRequestUserID(c)),
zap.String("subscription_id", c.Param("id")),
zap.Error(err))
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
logSubscriptionInfo(svc, "subscription run now completed",
zap.String("user_id", subscriptionRequestUserID(c)),
zap.String("subscription_id", c.Param("id")),
zap.Int("queued", n))
c.JSON(http.StatusOK, gin.H{"queued": n})
}
}
@@ -140,9 +185,17 @@ 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 {
logSubscriptionWarn(svc, "subscription restore failed",
zap.String("user_id", subscriptionRequestUserID(c)),
zap.String("subscription_id", c.Param("id")),
zap.Error(err))
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
logSubscriptionInfo(svc, "subscription restored",
zap.String("user_id", subscriptionRequestUserID(c)),
zap.String("subscription_id", sub.ID),
zap.String("name", sub.Name))
enriched := []model.Subscription{*sub}
svc.Subscription.EnrichManagementProgress(c.Request.Context(), enriched)
c.JSON(http.StatusOK, enriched[0])