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 GiB |
+ 33 |
+ 4 |
+ 99 |
+
+
`
+ 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 (
} label="管理主控制台" onClick={onClose} />
)}
+ {user?.role === 'admin' && (
+
+ )}
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}}。',
},
],
}