From a2be6a2127258cfff58a80a401393f468fefde0f Mon Sep 17 00:00:00 2001 From: ShukeBta <272197458+ShukeBta@users.noreply.github.com> Date: Sat, 27 Jun 2026 16:54:33 +0800 Subject: [PATCH] fix library display and docker compose update --- internal/handler/media.go | 2 + internal/handler/media_test.go | 28 +++ internal/handler/system_extra.go | 2 +- internal/service/cloud_mount_filter.go | 15 ++ internal/service/cloud_mount_filter_test.go | 38 +++- internal/service/cloud_mount_merge.go | 41 +++- internal/service/media_display_library.go | 30 +++ internal/service/site_adapter_nexusphp.go | 145 +++++++++++--- internal/service/site_adapter_parse.go | 2 +- internal/service/site_adapter_test.go | 67 +++++++ internal/service/system_update.go | 6 + internal/service/system_update_command.go | 7 +- internal/service/system_update_compose.go | 199 ++++++++++++++++++++ internal/service/system_update_docker.go | 81 +++++--- internal/service/system_update_test.go | 54 ++++++ web/src/api/admin.ts | 4 + web/src/components/LayoutUserMenu.tsx | 34 +++- web/src/pages/SystemUpdatePanel.tsx | 19 +- web/src/pages/settingsGroupSystemUpdate.ts | 15 +- 19 files changed, 701 insertions(+), 88 deletions(-) create mode 100644 internal/service/system_update_compose.go diff --git a/internal/handler/media.go b/internal/handler/media.go index f648a6b..e800cfa 100644 --- a/internal/handler/media.go +++ b/internal/handler/media.go @@ -31,6 +31,7 @@ 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 { @@ -62,6 +63,7 @@ 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 diff --git a/internal/handler/media_test.go b/internal/handler/media_test.go index 6874197..c77c030 100644 --- a/internal/handler/media_test.go +++ b/internal/handler/media_test.go @@ -95,6 +95,34 @@ func TestListLibrariesIncludeHiddenNormalizesCloudDisplayNames(t *testing.T) { } } +func TestListLibrariesIncludeHiddenHidesInternalAutoCategoryLibraries(t *testing.T) { + gin.SetMode(gin.TestMode) + 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: "movie", Enabled: true} + auto := model.Library{Name: "欧美剧", Path: service.BuildCloudAutoCategoryLibraryPath("openlist", "电视剧/欧美剧"), Type: "tv", Enabled: true} + for _, lib := range []*model.Library{&root, &auto} { + if err := repos.Library.Create(t.Context(), lib); err != nil { + t.Fatal(err) + } + } + svc := &service.Container{ + Repo: repos, + Media: service.NewMediaService(&config.Config{}, zap.NewNop(), repos), + } + + 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) + } +} + func TestGetLibraryAllowsEmptyLibrary(t *testing.T) { gin.SetMode(gin.TestMode) db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{}) diff --git a/internal/handler/system_extra.go b/internal/handler/system_extra.go index 5c26c12..6576d4b 100644 --- a/internal/handler/system_extra.go +++ b/internal/handler/system_extra.go @@ -136,7 +136,7 @@ func schemaHandler(_ *service.Container) gin.HandlerFunc { "label": "系统更新", "items": []gin.H{ {"key": "system.update.image", "type": "text", "label": "应用镜像"}, - {"key": "system.update.watchtower_image", "type": "text", "label": "Watchtower 镜像"}, + {"key": "system.update.compose_dir", "type": "text", "label": "Docker Compose 安装目录"}, {"key": "system.update.command", "type": "textarea", "label": "自定义更新命令"}, }, }, diff --git a/internal/service/cloud_mount_filter.go b/internal/service/cloud_mount_filter.go index c66da70..0f0159b 100644 --- a/internal/service/cloud_mount_filter.go +++ b/internal/service/cloud_mount_filter.go @@ -13,6 +13,7 @@ func FilterDisplayCloudLibraries(ctx context.Context, repo *repository.Container return libs } libs = FilterDeprecatedNativeCloudLibraries(libs) + libs = FilterInternalCloudAutoCategoryLibraries(libs) counts := cloudLibraryMediaCounts(ctx, repo, libs) collapsed := make([]model.Library, 0, len(libs)) byKey := make(map[string]int, len(libs)) @@ -35,6 +36,20 @@ func FilterDisplayCloudLibraries(ctx context.Context, repo *repository.Container return mergeDisplayCloudLibraries(collapsed) } +func FilterInternalCloudAutoCategoryLibraries(libs []model.Library) []model.Library { + if len(libs) == 0 { + return libs + } + out := make([]model.Library, 0, len(libs)) + for _, lib := range libs { + if CloudLibraryAutoCategory(lib) { + continue + } + out = append(out, lib) + } + return out +} + func FilterScannableCloudLibraries(ctx context.Context, repo *repository.Container, libs []model.Library) []model.Library { if len(libs) == 0 { return libs diff --git a/internal/service/cloud_mount_filter_test.go b/internal/service/cloud_mount_filter_test.go index bc4b582..4855cbb 100644 --- a/internal/service/cloud_mount_filter_test.go +++ b/internal/service/cloud_mount_filter_test.go @@ -248,8 +248,8 @@ func TestAutoCategoryCloudLibrariesDoNotShadowRootOrScan(t *testing.T) { t.Fatalf("auto category should not shadow root scan: %#v", shadow) } display := FilterDisplayCloudLibraries(t.Context(), repos, libs) - if len(display) != 2 { - t.Fatalf("display libraries = %#v, want root plus auto category", display) + if len(display) != 1 || display[0].ID != root.ID { + t.Fatalf("display libraries = %#v, want only user-mounted root", display) } scannable := FilterScannableCloudLibraries(t.Context(), repos, libs) if len(scannable) != 1 || scannable[0].ID != root.ID { @@ -266,6 +266,40 @@ func TestAutoCategoryCloudLibrariesDoNotShadowRootOrScan(t *testing.T) { } } +func TestRootCloudLibraryIncludesHiddenAutoCategoryMedia(t *testing.T) { + db := newServiceTestDB(t, &model.Library{}, &model.Media{}) + repos := repository.New(db) + root := model.Library{Name: "OpenList", Path: "cloud://openlist", Type: "movie", Enabled: true} + auto := model.Library{Name: "欧美剧", Path: BuildCloudAutoCategoryLibraryPath("openlist", "电视剧/欧美剧"), Type: "tv", Enabled: true} + for _, lib := range []*model.Library{&root, &auto} { + if err := repos.Library.Create(t.Context(), lib); err != nil { + t.Fatal(err) + } + } + if err := repos.DB.Create(&model.Media{ + LibraryID: auto.ID, + Title: "The Show", + Path: "cloud://openlist/电视剧/欧美剧/The Show/The.Show.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("root cloud items total=%d len=%d, want hidden auto-category media", total, len(items)) + } + if items[0].LibraryName != root.Name || items[0].LibraryPath != root.Path { + t.Fatalf("media library metadata = (%q, %q), want user-mounted root", items[0].LibraryName, items[0].LibraryPath) + } + if items[0].DisplayLibraryID != root.ID || items[0].DisplayLibraryPath != root.Path { + t.Fatalf("display library = (%q, %q), want user-mounted root", items[0].DisplayLibraryID, items[0].DisplayLibraryPath) + } +} + func TestStartAllCloudLibraryScansSkipsDeprecatedQuarkMounts(t *testing.T) { db := newServiceTestDB(t, &model.Library{}, &model.Media{}) repos := repository.New(db) diff --git a/internal/service/cloud_mount_merge.go b/internal/service/cloud_mount_merge.go index a403dad..70bfd2a 100644 --- a/internal/service/cloud_mount_merge.go +++ b/internal/service/cloud_mount_merge.go @@ -30,18 +30,23 @@ func MergedLibraryIDsForLibrary(ctx context.Context, repo *repository.Container, func MergedLibraryIDs(libs []model.Library, lib model.Library) []string { ids := []string{} seen := map[string]struct{}{} - add := func(id string) { - id = strings.TrimSpace(id) - if id == "" { - return + add := func(more ...string) { + for _, id := range more { + id = strings.TrimSpace(id) + if id == "" { + continue + } + if _, ok := seen[id]; ok { + continue + } + seen[id] = struct{}{} + ids = append(ids, id) } - if _, ok := seen[id]; ok { - return - } - seen[id] = struct{}{} - ids = append(ids, id) } add(lib.ID) + if rootAutoIDs := cloudRootAutoCategoryLibraryIDs(libs, lib); len(rootAutoIDs) > 0 { + add(rootAutoIDs...) + } key, ok := CloudLibraryMergeKey(lib) if !ok { return ids @@ -64,6 +69,24 @@ func MergedLibraryIDs(libs []model.Library, lib model.Library) []string { return ids } +func cloudRootAutoCategoryLibraryIDs(libs []model.Library, lib model.Library) []string { + mount, ok := ParseCloudLibraryMount(lib.Path) + if !ok || !cloudRootMountNeedsAutoCategory(mount) { + return nil + } + ids := make([]string, 0) + for _, candidate := range libs { + if candidate.ID == lib.ID || !candidate.Enabled || !CloudLibraryAutoCategory(candidate) { + continue + } + info, ok := ParseCloudLibraryMount(candidate.Path) + if ok && info.Provider == mount.Provider { + ids = appendUniqueLibraryIDs(ids, candidate.ID) + } + } + return ids +} + func ExpandMediaVisibilityForMergedCloudLibraries(ctx context.Context, repo *repository.Container, visibility MediaVisibility) MediaVisibility { if repo == nil || repo.Library == nil { return visibility diff --git a/internal/service/media_display_library.go b/internal/service/media_display_library.go index 7523f12..d26d50a 100644 --- a/internal/service/media_display_library.go +++ b/internal/service/media_display_library.go @@ -22,7 +22,11 @@ func (s *MediaService) attachLibraryMetadata(ctx context.Context, items []model. } resolver := newMediaDisplayLibraryResolver(ctx, s.repo, libs) for i := range items { + var own model.Library + var hasOwn bool if lib, ok := byID[items[i].LibraryID]; ok { + own = lib + hasOwn = true items[i].LibraryName = lib.Name items[i].LibraryPath = lib.Path } @@ -30,6 +34,10 @@ func (s *MediaService) attachLibraryMetadata(ctx context.Context, items []model. items[i].DisplayLibraryID = lib.ID items[i].DisplayLibraryName = lib.Name items[i].DisplayLibraryPath = lib.Path + if hasOwn && CloudLibraryAutoCategory(own) { + items[i].LibraryName = lib.Name + items[i].LibraryPath = lib.Path + } } } } @@ -72,6 +80,11 @@ func (r mediaDisplayLibraryResolver) DisplayLibraryForMedia(media model.Media) ( } own, hasOwn := r.byID[media.LibraryID] if hasOwn { + if CloudLibraryAutoCategory(own) { + if lib, ok := r.rootCloudDisplayLibraryForAutoCategory(own); ok { + return lib, true + } + } if key, ok := CloudLibraryMergeKey(own); ok { if lib, exists := r.displayByMergeKey[key]; exists { return lib, true @@ -82,6 +95,23 @@ func (r mediaDisplayLibraryResolver) DisplayLibraryForMedia(media model.Media) ( return model.Library{}, false } +func (r mediaDisplayLibraryResolver) rootCloudDisplayLibraryForAutoCategory(auto model.Library) (model.Library, bool) { + info, ok := ParseCloudLibraryMount(auto.Path) + if !ok { + return model.Library{}, false + } + for _, lib := range r.displayLibraries { + if !lib.Enabled { + continue + } + candidate, ok := ParseCloudLibraryMount(lib.Path) + if ok && candidate.Provider == info.Provider && cloudRootMountNeedsAutoCategory(candidate) { + return lib, 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) diff --git a/internal/service/site_adapter_nexusphp.go b/internal/service/site_adapter_nexusphp.go index 8af6a24..af630fa 100644 --- a/internal/service/site_adapter_nexusphp.go +++ b/internal/service/site_adapter_nexusphp.go @@ -4,6 +4,7 @@ package service import ( "context" "fmt" + "html" "net/http" "net/url" "regexp" @@ -58,7 +59,10 @@ func (a *NexusPHPAdapter) Authenticate(ctx context.Context, cfg SiteConfig) erro func (a *NexusPHPAdapter) Search(ctx context.Context, cfg SiteConfig, keyword string, page int) (*SiteSearchResult, error) { params := url.Values{} + params.Set("searchstr", keyword) params.Set("search", keyword) + params.Set("search_area", "0") + params.Set("search_mode", "0") params.Set("page", strconv.Itoa(page)) params.Set("inclbookmarked", "0") params.Set("incldead", "0") @@ -119,11 +123,7 @@ func parseNexusPHPHTML(html, siteName, baseURL string) (*SiteSearchResult, error Page: 1, } - // Extract table rows from torrent table - rowRegex := regexp.MustCompile(`]*>\s*]*class="rowfollow"[^>]*>.*?`) - matches := rowRegex.FindAllString(html, -1) - - for _, row := range matches { + for _, row := range nexusPHPTorrentRows(html) { item := parseNexusPHPRow(row, baseURL) if item.ID != "" { result.Items = append(result.Items, item) @@ -139,40 +139,41 @@ func parseNexusPHPRow(row, baseURL string) TorrentItem { item := TorrentItem{} // Extract torrent ID and title - idRegex := regexp.MustCompile(`details\.php\?id=(\d+)[^"]*"[^>]*>([^<]+)`) - idMatches := idRegex.FindStringSubmatch(row) - if len(idMatches) >= 3 { - item.ID = idMatches[1] - item.Title = strings.TrimSpace(idMatches[2]) - item.DetailURL = baseURL + "/details.php?id=" + item.ID + if link := firstNexusPHPLink(row, "details.php"); link != nil { + item.ID = link.query.Get("id") + item.Title = nexusPHPTitleFromLink(*link) + item.Subtitle = nexusPHPSubtitle(row) + item.DetailURL = resolveSiteURL(baseURL, link.href) } // Extract download link - dlRegex := regexp.MustCompile(`download\.php\?id=(\d+)`) - if dlMatches := dlRegex.FindStringSubmatch(row); len(dlMatches) >= 2 { - item.DownloadURL = baseURL + "/download.php?id=" + dlMatches[1] + if link := firstNexusPHPLink(row, "download.php"); link != nil { + item.DownloadURL = resolveSiteURL(baseURL, link.href) } // Extract size - sizeRegex := regexp.MustCompile(`(?i)(\d+\.?\d*)\s*(GB|MB|TB|KB)`) + sizeRegex := regexp.MustCompile(`(?i)(\d+\.?\d*)\s*(GiB|MiB|TiB|KiB|GB|MB|TB|KB)`) if sizeMatches := sizeRegex.FindStringSubmatch(row); len(sizeMatches) >= 3 { item.Size = parseSizeString(sizeMatches[1], sizeMatches[2]) } // Extract seeders and leechers - seedersRegex := regexp.MustCompile(`seeders[^"]*"[^>]*>(\d+)<`) - if m := seedersRegex.FindStringSubmatch(row); len(m) >= 2 { - item.Seeders, _ = strconv.Atoi(m[1]) + if value, ok := nexusPHPIntByClass(row, "seeders"); ok { + item.Seeders = value } - leechersRegex := regexp.MustCompile(`leechers[^"]*"[^>]*>(\d+)<`) - if m := leechersRegex.FindStringSubmatch(row); len(m) >= 2 { - item.Leechers, _ = strconv.Atoi(m[1]) + if value, ok := nexusPHPIntByClass(row, "leechers"); ok { + item.Leechers = value + } + if value, ok := nexusPHPIntByClass(row, "snatched"); ok { + item.Snatched = value } // Extract snatched snatchedRegex := regexp.MustCompile(`snatched[^"]*"[^>]*>(\d+)`) - if m := snatchedRegex.FindStringSubmatch(row); len(m) >= 2 { - item.Snatched, _ = strconv.Atoi(m[1]) + if item.Snatched == 0 { + if m := snatchedRegex.FindStringSubmatch(row); len(m) >= 2 { + item.Snatched, _ = strconv.Atoi(m[1]) + } } // Check for free flag @@ -196,6 +197,104 @@ func parseNexusPHPRow(row, baseURL string) TorrentItem { return item } +type nexusPHPLink struct { + href string + attrs string + text string + query url.Values +} + +func nexusPHPTorrentRows(pageHTML string) []string { + rowRegex := regexp.MustCompile(`(?is)]*>.*?`) + rows := rowRegex.FindAllString(pageHTML, -1) + out := make([]string, 0, len(rows)) + for _, row := range rows { + if strings.Contains(strings.ToLower(row), "details.php") { + out = append(out, row) + } + } + return out +} + +func firstNexusPHPLink(row, path string) *nexusPHPLink { + pattern := regexp.MustCompile(`(?is)]*href\s*=\s*["']([^"']*` + regexp.QuoteMeta(path) + `[^"']*)["'][^>]*)>(.*?)`) + for _, match := range pattern.FindAllStringSubmatch(row, -1) { + if len(match) < 4 { + continue + } + href := html.UnescapeString(strings.TrimSpace(match[2])) + parsed, err := url.Parse(href) + if err != nil { + continue + } + return &nexusPHPLink{ + href: href, + attrs: match[1], + text: cleanNexusPHPText(match[3]), + query: parsed.Query(), + } + } + return nil +} + +func nexusPHPTitleFromLink(link nexusPHPLink) string { + for _, attr := range []string{"title", "data-title"} { + if value := htmlAttr(link.attrs, attr); value != "" { + return value + } + } + return link.text +} + +func nexusPHPSubtitle(row string) string { + for _, pattern := range []*regexp.Regexp{ + regexp.MustCompile(`(?is)]*(?:class|id)\s*=\s*["'][^"']*(?:subtitle|small_descr|descr|sub)[^"']*["'][^>]*>(.*?)`), + regexp.MustCompile(`(?is)]*(?:class|id)\s*=\s*["'][^"']*(?:subtitle|small_descr|descr|sub)[^"']*["'][^>]*>(.*?)`), + } { + if match := pattern.FindStringSubmatch(row); len(match) >= 2 { + return cleanNexusPHPText(match[1]) + } + } + return "" +} + +func nexusPHPIntByClass(row, className string) (int, bool) { + pattern := regexp.MustCompile(`(?is)]*(?:class|id)\s*=\s*["'][^"']*` + regexp.QuoteMeta(className) + `[^"']*["'][^>]*>(.*?)`) + if match := pattern.FindStringSubmatch(row); len(match) >= 2 { + text := cleanNexusPHPText(match[1]) + valueMatch := regexp.MustCompile(`\d+`).FindString(text) + if valueMatch != "" { + value, _ := strconv.Atoi(valueMatch) + return value, true + } + } + return 0, false +} + +func htmlAttr(attrs, name string) string { + pattern := regexp.MustCompile(`(?is)\b` + regexp.QuoteMeta(name) + `\s*=\s*["']([^"']*)["']`) + if match := pattern.FindStringSubmatch(attrs); len(match) >= 2 { + return cleanNexusPHPText(match[1]) + } + return "" +} + +func cleanNexusPHPText(value string) string { + return strings.Join(strings.Fields(html.UnescapeString(stripHTML(value))), " ") +} + +func resolveSiteURL(baseURL, href string) string { + base, err := url.Parse(strings.TrimRight(baseURL, "/") + "/") + if err != nil { + return strings.TrimSpace(href) + } + ref, err := url.Parse(strings.TrimSpace(href)) + if err != nil { + return strings.TrimSpace(href) + } + return base.ResolveReference(ref).String() +} + // parseNexusPHPDetailHTML 解析种子详情页。 func parseNexusPHPDetailHTML(html, id, baseURL string) (*TorrentDetail, error) { detail := &TorrentDetail{ diff --git a/internal/service/site_adapter_parse.go b/internal/service/site_adapter_parse.go index e941076..8e25c50 100644 --- a/internal/service/site_adapter_parse.go +++ b/internal/service/site_adapter_parse.go @@ -30,7 +30,7 @@ func parseSizeString(value string, unit string) int64 { if err != nil { return 0 } - switch strings.ToLower(unit) { + switch strings.TrimSuffix(strings.ToLower(unit), "i") { case "kb": return int64(v * 1024) case "mb": diff --git a/internal/service/site_adapter_test.go b/internal/service/site_adapter_test.go index c5cc1d6..6b4a03d 100644 --- a/internal/service/site_adapter_test.go +++ b/internal/service/site_adapter_test.go @@ -6,6 +6,7 @@ import ( "errors" "net/http" "net/http/httptest" + "net/url" "strings" "sync/atomic" "testing" @@ -180,6 +181,72 @@ func TestBuildRequestAPIKeyHeaderBySite(t *testing.T) { } } +func TestNexusPHPSearchUsesSearchstr(t *testing.T) { + var gotQuery string + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotQuery = r.URL.RawQuery + _, _ = w.Write([]byte(`
测试资源下载
`)) + })) + defer server.Close() + + adapter := NewNexusPHPAdapter() + result, err := adapter.Search(t.Context(), SiteConfig{ + Name: "Nexus", + URL: server.URL, + AuthType: "cookie", + Cookie: "uid=1; pass=token", + Timeout: 5 * time.Second, + }, "测试", 2) + if err != nil { + t.Fatalf("Search returned error: %v", err) + } + values, err := url.ParseQuery(gotQuery) + if err != nil { + t.Fatal(err) + } + if values.Get("searchstr") != "测试" || values.Get("search") != "测试" || values.Get("page") != "2" { + t.Fatalf("query = %q", gotQuery) + } + if len(result.Items) != 1 || result.Items[0].Title != "测试资源" { + t.Fatalf("items = %#v", result.Items) + } +} + +func TestParseNexusPHPHTMLModernRows(t *testing.T) { + page := ` + + + + + + + + + +
电影 + ignored + 副标题 & 描述 + 下载 + 12.5 GiB33499
` + result, err := parseNexusPHPHTML(page, "Nexus", "https://pt.example") + if err != nil { + t.Fatal(err) + } + if len(result.Items) != 1 { + t.Fatalf("items = %#v", result.Items) + } + item := result.Items[0] + if item.ID != "456" || item.Title != "Some & Movie 2026 2160p" || item.Subtitle != "副标题 & 描述" { + t.Fatalf("parsed item = %#v", item) + } + if item.DetailURL != "https://pt.example/details.php?id=456&hit=1" || item.DownloadURL != "https://pt.example/download.php?id=456&passkey=abc" { + t.Fatalf("urls = detail %q download %q", item.DetailURL, item.DownloadURL) + } + if item.Seeders != 33 || item.Leechers != 4 || item.Snatched != 99 { + t.Fatalf("stats = %#v", item) + } +} + func TestMTeamAPIRateLimits(t *testing.T) { search := mteamAPIRateLimits(mteamAPIEndpointSearch) if len(search) != 1 || search[0].Limit != 1500 || search[0].Window != 24*time.Hour { diff --git a/internal/service/system_update.go b/internal/service/system_update.go index dbebe46..10bca98 100644 --- a/internal/service/system_update.go +++ b/internal/service/system_update.go @@ -18,6 +18,7 @@ const ( SystemUpdateImageSettingKey = "system.update.image" SystemUpdateWatchtowerImageSettingKey = "system.update.watchtower_image" SystemUpdateCommandSettingKey = "system.update.command" + SystemUpdateComposeDirSettingKey = "system.update.compose_dir" DefaultSystemUpdateImage = "ghcr.io/shukebta/mediastation-go:latest" DefaultSystemUpdateWatchtowerImage = "containrrr/watchtower:latest" @@ -33,6 +34,10 @@ var ErrSystemUpdateRunning = errors.New("system update already running") type SystemUpdateStatus struct { Image string `json:"image"` WatchtowerImage string `json:"watchtower_image,omitempty"` + UpdateMode string `json:"update_mode,omitempty"` + ComposeDir string `json:"compose_dir,omitempty"` + ComposeFile string `json:"compose_file,omitempty"` + ComposeCommand string `json:"compose_command,omitempty"` ContainerID string `json:"container_id,omitempty"` ContainerName string `json:"container_name,omitempty"` CurrentImageID string `json:"current_image_id,omitempty"` @@ -199,6 +204,7 @@ func (s *SystemUpdateService) baseStatus(ctx context.Context) SystemUpdateStatus return SystemUpdateStatus{ Image: image, WatchtowerImage: watchtowerImage, + UpdateMode: "compose", ContainerID: currentContainerID(), } } diff --git a/internal/service/system_update_command.go b/internal/service/system_update_command.go index c681c33..7720950 100644 --- a/internal/service/system_update_command.go +++ b/internal/service/system_update_command.go @@ -23,14 +23,17 @@ func (s *SystemUpdateService) rawUpdateCommand(ctx context.Context) string { } func defaultSystemUpdateCommand() string { - return "docker run --rm -v /var/run/docker.sock:/var/run/docker.sock {{watchtower_image}} --run-once --cleanup {{container}}" + return "cd {{compose_dir}} && {{compose_command}} pull && {{compose_command}} up -d && docker image prune -f && docker restart {{container}}" } func renderSystemUpdateCommand(template string, status SystemUpdateStatus) string { replacements := map[string]string{ "{{image}}": shellQuote(status.Image), "{{watchtower_image}}": shellQuote(firstNonEmpty(status.WatchtowerImage, DefaultSystemUpdateWatchtowerImage)), - "{{container}}": shellQuote(firstNonEmpty(status.ContainerName, status.ContainerID)), + "{{compose_dir}}": shellQuote(status.ComposeDir), + "{{compose_file}}": shellQuote(status.ComposeFile), + "{{compose_command}}": firstNonEmpty(status.ComposeCommand, "docker compose"), + "{{container}}": shellQuote(firstNonEmpty(status.ContainerName, status.ContainerID, "mediastation-go")), "{{container_id}}": shellQuote(status.ContainerID), "{{container_name}}": shellQuote(status.ContainerName), } diff --git a/internal/service/system_update_compose.go b/internal/service/system_update_compose.go new file mode 100644 index 0000000..5849ee6 --- /dev/null +++ b/internal/service/system_update_compose.go @@ -0,0 +1,199 @@ +package service + +import ( + "context" + "os" + "path/filepath" + "runtime" + "strings" +) + +type systemUpdateComposeTarget struct { + Dir string + File string + Command string +} + +var systemUpdateComposeFiles = []string{ + "docker-compose.yml", + "docker-compose.yaml", + "compose.yml", + "compose.yaml", +} + +func (s *SystemUpdateService) resolveComposeTarget(ctx context.Context) (systemUpdateComposeTarget, string) { + if target := s.composeTargetFromConfiguredDir(ctx); target.Dir != "" { + return target, "" + } + if target := s.discoverComposeTarget(); target.Dir != "" { + return target, "" + } + return systemUpdateComposeTarget{}, "未找到 docker-compose.yml / compose.yml,请在系统更新设置中填写 Docker Compose 安装目录" +} + +func (s *SystemUpdateService) composeTargetFromConfiguredDir(ctx context.Context) systemUpdateComposeTarget { + configured := s.setting(ctx, SystemUpdateComposeDirSettingKey, firstNonEmpty(os.Getenv("MEDIASTATION_UPDATE_COMPOSE_DIR"), os.Getenv("MEDIASTATION_UPDATE_WORKDIR"))) + return composeTargetInDir(configured) +} + +func (s *SystemUpdateService) discoverComposeTarget() systemUpdateComposeTarget { + for _, dir := range s.composeCandidateDirs() { + if target := composeTargetInDir(dir); target.Dir != "" { + return target + } + } + for _, root := range s.composeSearchRoots() { + if target := findComposeTargetUnder(root, 5, 400); target.Dir != "" { + return target + } + } + return systemUpdateComposeTarget{} +} + +func (s *SystemUpdateService) composeCandidateDirs() []string { + var dirs []string + add := func(values ...string) { + for _, value := range values { + value = strings.TrimSpace(value) + if value != "" { + dirs = append(dirs, value) + } + } + } + if wd, err := os.Getwd(); err == nil { + add(wd) + add(parentDirs(wd, 4)...) + } + if exe, err := os.Executable(); err == nil { + add(filepath.Dir(exe)) + add(parentDirs(filepath.Dir(exe), 4)...) + } + if s != nil && s.cfg != nil { + add(s.cfg.App.DataDir) + add(parentDirs(s.cfg.App.DataDir, 4)...) + } + return uniqueExistingDirs(dirs) +} + +func (s *SystemUpdateService) composeSearchRoots() []string { + roots := []string{"/data", "/config", "/app", "/opt", "/srv", "/mnt", "/vol1", "/volume1"} + if runtime.GOOS == "windows" { + if wd, err := os.Getwd(); err == nil { + roots = []string{wd} + } + } + return uniqueExistingDirs(roots) +} + +func composeTargetInDir(dir string) systemUpdateComposeTarget { + dir = strings.TrimSpace(dir) + if dir == "" { + return systemUpdateComposeTarget{} + } + info, err := os.Stat(dir) + if err != nil || !info.IsDir() { + return systemUpdateComposeTarget{} + } + for _, name := range systemUpdateComposeFiles { + file := filepath.Join(dir, name) + if composeFileMatches(file) { + return systemUpdateComposeTarget{Dir: dir, File: file} + } + } + return systemUpdateComposeTarget{} +} + +func findComposeTargetUnder(root string, maxDepth, maxVisited int) systemUpdateComposeTarget { + root = strings.TrimSpace(root) + if root == "" || maxDepth < 0 || maxVisited <= 0 { + return systemUpdateComposeTarget{} + } + visited := 0 + var best systemUpdateComposeTarget + _ = filepath.WalkDir(root, func(path string, entry os.DirEntry, err error) error { + if err != nil || best.Dir != "" { + return nil + } + if !entry.IsDir() { + return nil + } + visited++ + if visited > maxVisited { + return filepath.SkipAll + } + if depthBeyond(root, path, maxDepth) { + return filepath.SkipDir + } + if target := composeTargetInDir(path); target.Dir != "" { + best = target + return filepath.SkipAll + } + return nil + }) + return best +} + +func composeFileMatches(file string) bool { + info, err := os.Stat(file) + if err != nil || info.IsDir() { + return false + } + raw, err := os.ReadFile(file) // #nosec G304 -- user-controlled compose path is an admin-only local update setting. + if err != nil { + return false + } + content := strings.ToLower(string(raw)) + return strings.Contains(content, "mediastation-go") || strings.Contains(content, strings.ToLower(DefaultSystemUpdateImage)) +} + +func preferredComposeCommand() string { + return "docker compose" +} + +func parentDirs(dir string, limit int) []string { + dir = strings.TrimSpace(dir) + if dir == "" || limit <= 0 { + return nil + } + parents := make([]string, 0, limit) + for i := 0; i < limit; i++ { + next := filepath.Dir(dir) + if next == dir || next == "." || next == "" { + break + } + parents = append(parents, next) + dir = next + } + return parents +} + +func uniqueExistingDirs(values []string) []string { + seen := map[string]struct{}{} + out := make([]string, 0, len(values)) + for _, value := range values { + value = strings.TrimSpace(value) + if value == "" { + continue + } + clean := filepath.Clean(value) + if _, ok := seen[strings.ToLower(clean)]; ok { + continue + } + info, err := os.Stat(clean) + if err != nil || !info.IsDir() { + continue + } + seen[strings.ToLower(clean)] = struct{}{} + out = append(out, clean) + } + return out +} + +func depthBeyond(root, path string, maxDepth int) bool { + rel, err := filepath.Rel(root, path) + if err != nil || rel == "." { + return false + } + rel = filepath.ToSlash(rel) + return strings.Count(rel, "/")+1 > maxDepth +} diff --git a/internal/service/system_update_docker.go b/internal/service/system_update_docker.go index 889b667..b904298 100644 --- a/internal/service/system_update_docker.go +++ b/internal/service/system_update_docker.go @@ -5,7 +5,6 @@ import ( "os" "os/exec" "regexp" - "runtime" "strings" "time" ) @@ -16,48 +15,62 @@ func (s *SystemUpdateService) check(ctx context.Context) SystemUpdateStatus { status.CheckedAt = &now customCommand := s.rawUpdateCommand(ctx) + composeTarget, composeErr := s.resolveComposeTarget(ctx) + status.ComposeDir = composeTarget.Dir + status.ComposeFile = composeTarget.File + status.ComposeCommand = composeTarget.Command + + if strings.TrimSpace(customCommand) != "" { + status.CanApply = true + status.Message = "将执行自定义更新命令" + } + dockerPath, err := exec.LookPath("docker") if err != nil { - return systemUpdateCustomFallback(status, systemUpdateFallback{ - command: customCommand, - customMessage: "当前环境无法检查 Docker;将执行自定义更新命令", - customDetails: err.Error(), - defaultMessage: "当前镜像内未安装 docker CLI,无法自动拉取并重启 Docker 镜像", - }) - } - if runtime.GOOS != "windows" { - if _, err := os.Stat("/var/run/docker.sock"); err != nil { - return systemUpdateCustomFallback(status, systemUpdateFallback{ - command: customCommand, - customMessage: "未检测到 Docker socket;将执行自定义更新命令", - customDetails: err.Error(), - defaultMessage: "未检测到 /var/run/docker.sock,请挂载 Docker socket 后再使用热更新", - defaultDetails: err.Error(), - }) + if status.CanApply { + status.Details = err.Error() + return status } + status.Message = "当前环境未安装 docker CLI,无法执行 Docker Compose 更新" + status.Details = err.Error() + return status + } + if status.ComposeCommand == "" { + status.ComposeCommand = availableComposeCommand(ctx, dockerPath) + } + if status.ComposeCommand == "" && strings.TrimSpace(customCommand) == "" { + status.Message = "当前环境未安装 docker compose 插件或 docker-compose 命令" + return status + } + + if strings.TrimSpace(customCommand) == "" { + if composeErr != "" { + status.Message = composeErr + return status + } + status.CanApply = true + status.Message = "已识别 Docker Compose 安装目录,可执行一键更新" } checkCtx, cancel := context.WithTimeout(ctx, systemUpdateCheckTimeout) defer cancel() if out, err := runSystemUpdateCommand(checkCtx, dockerPath, "version", "--format", "{{.Server.Version}}"); err != nil { details := strings.TrimSpace(out + "\n" + err.Error()) - return systemUpdateCustomFallback(status, systemUpdateFallback{ - command: customCommand, - customMessage: "无法连接 Docker 引擎;将执行自定义更新命令", - customDetails: details, - defaultMessage: "无法连接 Docker 引擎,请检查 Docker socket 权限", - defaultDetails: details, - }) + status.Details = details + if status.CanApply { + status.Message = "Docker Compose 更新命令已就绪;当前无法读取 Docker 引擎摘要,执行时如失败请检查 Docker 权限" + return status + } + status.Message = "无法连接 Docker 引擎,请检查 Docker 权限" + return status } status.DockerAvailable = true containerID := currentContainerID() status.ContainerID = containerID if containerID == "" { - status.Message = "无法识别当前容器 ID;请配置自定义更新命令" - if customCommand != "" { - status.CanApply = true - status.Message = "无法识别当前容器 ID;将执行自定义更新命令" + if status.CanApply { + status.Message = "已识别 Docker Compose 安装目录;无法识别当前容器 ID,将使用默认容器名 mediastation-go 重启" } return status } @@ -69,6 +82,18 @@ func (s *SystemUpdateService) check(ctx context.Context) SystemUpdateStatus { return status } +func availableComposeCommand(ctx context.Context, dockerPath string) string { + checkCtx, cancel := context.WithTimeout(ctx, 5*time.Second) + defer cancel() + if _, err := runSystemUpdateCommand(checkCtx, dockerPath, "compose", "version"); err == nil { + return "docker compose" + } + if _, err := exec.LookPath("docker-compose"); err == nil { + return "docker-compose" + } + return "" +} + type systemUpdateFallback struct { command string customMessage string diff --git a/internal/service/system_update_test.go b/internal/service/system_update_test.go index e4eb983..0e72923 100644 --- a/internal/service/system_update_test.go +++ b/internal/service/system_update_test.go @@ -1,6 +1,8 @@ package service import ( + "os" + "path/filepath" "strings" "testing" ) @@ -34,6 +36,58 @@ func TestRenderSystemUpdateCommand(t *testing.T) { } } +func TestDefaultSystemUpdateCommandUsesCompose(t *testing.T) { + status := SystemUpdateStatus{ + ComposeDir: "/opt/mediastation-go", + ComposeCommand: "docker compose", + ContainerName: "mediastation-go", + } + got := renderSystemUpdateCommand(defaultSystemUpdateCommand(), status) + for _, want := range []string{ + "cd " + shellQuote("/opt/mediastation-go"), + "docker compose pull", + "docker compose up -d", + "docker image prune -f", + "docker restart " + shellQuote("mediastation-go"), + } { + if !strings.Contains(got, want) { + t.Fatalf("default command %q does not contain %q", got, want) + } + } + if strings.Contains(got, "watchtower") { + t.Fatalf("default command should not use watchtower: %q", got) + } +} + +func TestComposeTargetInDirMatchesMediaStationCompose(t *testing.T) { + dir := t.TempDir() + if err := os.WriteFile(filepath.Join(dir, "docker-compose.yml"), []byte(` +services: + mediastation-go: + image: ghcr.io/shukebta/mediastation-go:latest +`), 0o600); err != nil { + t.Fatal(err) + } + target := composeTargetInDir(dir) + if target.Dir != dir || !strings.HasSuffix(target.File, "docker-compose.yml") { + t.Fatalf("compose target = %#v", target) + } +} + +func TestComposeTargetInDirIgnoresUnrelatedCompose(t *testing.T) { + dir := t.TempDir() + if err := os.WriteFile(filepath.Join(dir, "docker-compose.yml"), []byte(` +services: + redis: + image: redis:7 +`), 0o600); err != nil { + t.Fatal(err) + } + if target := composeTargetInDir(dir); target.Dir != "" { + t.Fatalf("unrelated compose should be ignored: %#v", target) + } +} + func TestParseContainerInspectLine(t *testing.T) { name, imageID := parseContainerInspectLine("/mediastation-go|sha256:abc") if name != "mediastation-go" || imageID != "sha256:abc" { diff --git a/web/src/api/admin.ts b/web/src/api/admin.ts index e65b5a3..1d5e59c 100644 --- a/web/src/api/admin.ts +++ b/web/src/api/admin.ts @@ -4,6 +4,10 @@ import type { AccessLog, Setting, User } from '../types' export interface SystemUpdateStatus { image: string watchtower_image?: string + update_mode?: string + compose_dir?: string + compose_file?: string + compose_command?: string container_id?: string container_name?: string current_image_id?: string diff --git a/web/src/components/LayoutUserMenu.tsx b/web/src/components/LayoutUserMenu.tsx index 64764ea..02830d7 100644 --- a/web/src/components/LayoutUserMenu.tsx +++ b/web/src/components/LayoutUserMenu.tsx @@ -1,9 +1,11 @@ -import { useEffect, useRef } from 'react' +import { useEffect, useRef, useState } from 'react' import { Link, useLocation } from 'react-router-dom' import { AnimatePresence, motion } from 'framer-motion' -import { ChevronDown, LogOut, Settings, User as UserIcon, UserCog } from 'lucide-react' +import { ChevronDown, Loader2, LogOut, RotateCw, Settings, User as UserIcon, UserCog } from 'lucide-react' import clsx from 'clsx' +import toast from 'react-hot-toast' +import { adminAPI } from '../api/admin' import type { PlayProfile } from '../types' type LayoutUser = { @@ -39,6 +41,7 @@ export function LayoutUserMenu({ const location = useLocation() const rootRef = useRef(null) const lastLocationRef = useRef(`${location.pathname}${location.search}`) + const [updating, setUpdating] = useState(false) useEffect(() => { if (!isOpen) return undefined @@ -68,6 +71,22 @@ export function LayoutUserMenu({ if (isOpen) onClose() }, [isOpen, location.pathname, location.search, onClose]) + const applySystemUpdate = async () => { + if (updating) return + setUpdating(true) + try { + const status = await adminAPI.systemUpdateApply() + toast.success(status.message || '系统更新任务已启动') + onClose() + } catch (err: unknown) { + const msg = + (err as { response?: { data?: { error?: string } } })?.response?.data?.error ?? '启动系统更新失败' + toast.error(msg) + } finally { + setUpdating(false) + } + } + return (
+ )}

diff --git a/web/src/pages/SystemUpdatePanel.tsx b/web/src/pages/SystemUpdatePanel.tsx index 5045e4b..a05cb94 100644 --- a/web/src/pages/SystemUpdatePanel.tsx +++ b/web/src/pages/SystemUpdatePanel.tsx @@ -100,7 +100,7 @@ export function SystemUpdatePanel() {

-

Docker 热更新

+

Docker Compose 一键更新

{tone.label} @@ -130,7 +130,7 @@ export function SystemUpdatePanel() {
- + + -
+ +
@@ -217,7 +219,7 @@ function updateTone(status: SystemUpdateStatus | null): { if (status.running) { return { label: '更新中', className: 'border-yellow-400/50 text-yellow-600', icon: Loader2 } } - if (!status.docker_available || !status.can_apply) { + if (!status.can_apply && status.message && status.message !== '尚未检查更新') { return { label: '不可更新', className: 'border-red-400/50 text-red-500', icon: AlertTriangle } } if (status.update_available === true) { @@ -234,13 +236,6 @@ function shortDigest(digest?: string): string { return digest.length > 24 ? `${digest.slice(0, 18)}…${digest.slice(-8)}` : digest } -function formatDate(value?: string): string { - if (!value) return '-' - const date = new Date(value) - if (Number.isNaN(date.getTime())) return value - return date.toLocaleString() -} - function apiErrorMessage(err: unknown, fallback: string): string { return (err as UpdateErrorPayload).response?.data?.error || fallback } diff --git a/web/src/pages/settingsGroupSystemUpdate.ts b/web/src/pages/settingsGroupSystemUpdate.ts index a55daa4..d54764d 100644 --- a/web/src/pages/settingsGroupSystemUpdate.ts +++ b/web/src/pages/settingsGroupSystemUpdate.ts @@ -3,7 +3,7 @@ import type { SettingGroup } from './settingsGroupTypes' export const systemUpdateSettingsGroup: SettingGroup = { key: 'system-update', label: '系统更新', - description: 'Docker 部署可在这里检查并拉取最新版镜像。', + description: 'Docker Compose 部署可在这里检查并拉取最新版镜像。', items: [ { key: 'system.update.image', @@ -14,20 +14,19 @@ export const systemUpdateSettingsGroup: SettingGroup = { hint: '用于检查远端摘要;保持 latest 即可跟随主分支镜像。', }, { - key: 'system.update.watchtower_image', - label: 'Watchtower 镜像', + key: 'system.update.compose_dir', + label: 'Docker Compose 安装目录', type: 'text', - defaultValue: 'containrrr/watchtower:latest', - placeholder: 'containrrr/watchtower:latest', - hint: '默认使用一次性 Watchtower 更新当前容器。', + placeholder: '/vol1/1000/docker/mediastation-go', + hint: '留空时自动查找 docker-compose.yml / compose.yml 所在目录。', }, { key: 'system.update.command', label: '自定义更新命令', type: 'textarea', placeholder: - 'docker run --rm -v /var/run/docker.sock:/var/run/docker.sock {{watchtower_image}} --run-once --cleanup {{container}}', - hint: '留空时使用默认命令。支持 {{image}}、{{watchtower_image}}、{{container}}、{{container_id}}、{{container_name}}。', + 'cd {{compose_dir}} && {{compose_command}} pull && {{compose_command}} up -d && docker image prune -f && docker restart {{container}}', + hint: '留空时使用 Docker Compose 默认命令。支持 {{image}}、{{compose_dir}}、{{compose_file}}、{{compose_command}}、{{container}}、{{container_id}}、{{container_name}}。', }, ], }