mirror of
https://github.com/truewhile/MeBox.git
synced 2026-10-06 21:36:37 +08:00
fix library display and docker compose update
This commit is contained in:
@@ -31,6 +31,7 @@ func listLibrariesHandler(svc *service.Container) gin.HandlerFunc {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
libs = service.FilterDeprecatedNativeCloudLibraries(libs)
|
libs = service.FilterDeprecatedNativeCloudLibraries(libs)
|
||||||
|
libs = service.FilterInternalCloudAutoCategoryLibraries(libs)
|
||||||
role, _ := c.Get(middleware.CtxUserRole)
|
role, _ := c.Get(middleware.CtxUserRole)
|
||||||
includeHidden := role == "admin" && (c.Query("include_hidden") == "1" || c.Query("all") == "1")
|
includeHidden := role == "admin" && (c.Query("include_hidden") == "1" || c.Query("all") == "1")
|
||||||
if !includeHidden {
|
if !includeHidden {
|
||||||
@@ -62,6 +63,7 @@ func getLibraryHandler(svc *service.Container) gin.HandlerFunc {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
libs := service.FilterDeprecatedNativeCloudLibraries([]model.Library{*lib})
|
libs := service.FilterDeprecatedNativeCloudLibraries([]model.Library{*lib})
|
||||||
|
libs = service.FilterInternalCloudAutoCategoryLibraries(libs)
|
||||||
if len(libs) == 0 {
|
if len(libs) == 0 {
|
||||||
c.JSON(http.StatusNotFound, gin.H{"error": "not found"})
|
c.JSON(http.StatusNotFound, gin.H{"error": "not found"})
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -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) {
|
func TestGetLibraryAllowsEmptyLibrary(t *testing.T) {
|
||||||
gin.SetMode(gin.TestMode)
|
gin.SetMode(gin.TestMode)
|
||||||
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
|
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
|
||||||
|
|||||||
@@ -136,7 +136,7 @@ func schemaHandler(_ *service.Container) gin.HandlerFunc {
|
|||||||
"label": "系统更新",
|
"label": "系统更新",
|
||||||
"items": []gin.H{
|
"items": []gin.H{
|
||||||
{"key": "system.update.image", "type": "text", "label": "应用镜像"},
|
{"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": "自定义更新命令"},
|
{"key": "system.update.command", "type": "textarea", "label": "自定义更新命令"},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ func FilterDisplayCloudLibraries(ctx context.Context, repo *repository.Container
|
|||||||
return libs
|
return libs
|
||||||
}
|
}
|
||||||
libs = FilterDeprecatedNativeCloudLibraries(libs)
|
libs = FilterDeprecatedNativeCloudLibraries(libs)
|
||||||
|
libs = FilterInternalCloudAutoCategoryLibraries(libs)
|
||||||
counts := cloudLibraryMediaCounts(ctx, repo, libs)
|
counts := cloudLibraryMediaCounts(ctx, repo, libs)
|
||||||
collapsed := make([]model.Library, 0, len(libs))
|
collapsed := make([]model.Library, 0, len(libs))
|
||||||
byKey := make(map[string]int, len(libs))
|
byKey := make(map[string]int, len(libs))
|
||||||
@@ -35,6 +36,20 @@ func FilterDisplayCloudLibraries(ctx context.Context, repo *repository.Container
|
|||||||
return mergeDisplayCloudLibraries(collapsed)
|
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 {
|
func FilterScannableCloudLibraries(ctx context.Context, repo *repository.Container, libs []model.Library) []model.Library {
|
||||||
if len(libs) == 0 {
|
if len(libs) == 0 {
|
||||||
return libs
|
return libs
|
||||||
|
|||||||
@@ -248,8 +248,8 @@ func TestAutoCategoryCloudLibrariesDoNotShadowRootOrScan(t *testing.T) {
|
|||||||
t.Fatalf("auto category should not shadow root scan: %#v", shadow)
|
t.Fatalf("auto category should not shadow root scan: %#v", shadow)
|
||||||
}
|
}
|
||||||
display := FilterDisplayCloudLibraries(t.Context(), repos, libs)
|
display := FilterDisplayCloudLibraries(t.Context(), repos, libs)
|
||||||
if len(display) != 2 {
|
if len(display) != 1 || display[0].ID != root.ID {
|
||||||
t.Fatalf("display libraries = %#v, want root plus auto category", display)
|
t.Fatalf("display libraries = %#v, want only user-mounted root", display)
|
||||||
}
|
}
|
||||||
scannable := FilterScannableCloudLibraries(t.Context(), repos, libs)
|
scannable := FilterScannableCloudLibraries(t.Context(), repos, libs)
|
||||||
if len(scannable) != 1 || scannable[0].ID != root.ID {
|
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) {
|
func TestStartAllCloudLibraryScansSkipsDeprecatedQuarkMounts(t *testing.T) {
|
||||||
db := newServiceTestDB(t, &model.Library{}, &model.Media{})
|
db := newServiceTestDB(t, &model.Library{}, &model.Media{})
|
||||||
repos := repository.New(db)
|
repos := repository.New(db)
|
||||||
|
|||||||
@@ -30,18 +30,23 @@ func MergedLibraryIDsForLibrary(ctx context.Context, repo *repository.Container,
|
|||||||
func MergedLibraryIDs(libs []model.Library, lib model.Library) []string {
|
func MergedLibraryIDs(libs []model.Library, lib model.Library) []string {
|
||||||
ids := []string{}
|
ids := []string{}
|
||||||
seen := map[string]struct{}{}
|
seen := map[string]struct{}{}
|
||||||
add := func(id string) {
|
add := func(more ...string) {
|
||||||
id = strings.TrimSpace(id)
|
for _, id := range more {
|
||||||
if id == "" {
|
id = strings.TrimSpace(id)
|
||||||
return
|
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)
|
add(lib.ID)
|
||||||
|
if rootAutoIDs := cloudRootAutoCategoryLibraryIDs(libs, lib); len(rootAutoIDs) > 0 {
|
||||||
|
add(rootAutoIDs...)
|
||||||
|
}
|
||||||
key, ok := CloudLibraryMergeKey(lib)
|
key, ok := CloudLibraryMergeKey(lib)
|
||||||
if !ok {
|
if !ok {
|
||||||
return ids
|
return ids
|
||||||
@@ -64,6 +69,24 @@ func MergedLibraryIDs(libs []model.Library, lib model.Library) []string {
|
|||||||
return ids
|
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 {
|
func ExpandMediaVisibilityForMergedCloudLibraries(ctx context.Context, repo *repository.Container, visibility MediaVisibility) MediaVisibility {
|
||||||
if repo == nil || repo.Library == nil {
|
if repo == nil || repo.Library == nil {
|
||||||
return visibility
|
return visibility
|
||||||
|
|||||||
@@ -22,7 +22,11 @@ func (s *MediaService) attachLibraryMetadata(ctx context.Context, items []model.
|
|||||||
}
|
}
|
||||||
resolver := newMediaDisplayLibraryResolver(ctx, s.repo, libs)
|
resolver := newMediaDisplayLibraryResolver(ctx, s.repo, libs)
|
||||||
for i := range items {
|
for i := range items {
|
||||||
|
var own model.Library
|
||||||
|
var hasOwn bool
|
||||||
if lib, ok := byID[items[i].LibraryID]; ok {
|
if lib, ok := byID[items[i].LibraryID]; ok {
|
||||||
|
own = lib
|
||||||
|
hasOwn = true
|
||||||
items[i].LibraryName = lib.Name
|
items[i].LibraryName = lib.Name
|
||||||
items[i].LibraryPath = lib.Path
|
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].DisplayLibraryID = lib.ID
|
||||||
items[i].DisplayLibraryName = lib.Name
|
items[i].DisplayLibraryName = lib.Name
|
||||||
items[i].DisplayLibraryPath = lib.Path
|
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]
|
own, hasOwn := r.byID[media.LibraryID]
|
||||||
if hasOwn {
|
if hasOwn {
|
||||||
|
if CloudLibraryAutoCategory(own) {
|
||||||
|
if lib, ok := r.rootCloudDisplayLibraryForAutoCategory(own); ok {
|
||||||
|
return lib, true
|
||||||
|
}
|
||||||
|
}
|
||||||
if key, ok := CloudLibraryMergeKey(own); ok {
|
if key, ok := CloudLibraryMergeKey(own); ok {
|
||||||
if lib, exists := r.displayByMergeKey[key]; exists {
|
if lib, exists := r.displayByMergeKey[key]; exists {
|
||||||
return lib, true
|
return lib, true
|
||||||
@@ -82,6 +95,23 @@ func (r mediaDisplayLibraryResolver) DisplayLibraryForMedia(media model.Media) (
|
|||||||
return model.Library{}, false
|
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) {
|
func (r mediaDisplayLibraryResolver) bestPathDisplayLibrary(media model.Media) (model.Library, bool) {
|
||||||
if strings.HasPrefix(strings.ToLower(strings.TrimSpace(media.Path)), "cloud://") {
|
if strings.HasPrefix(strings.ToLower(strings.TrimSpace(media.Path)), "cloud://") {
|
||||||
mediaInfo, ok := ParseCloudLibraryMount(media.Path)
|
mediaInfo, ok := ParseCloudLibraryMount(media.Path)
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ package service
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"html"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/url"
|
"net/url"
|
||||||
"regexp"
|
"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) {
|
func (a *NexusPHPAdapter) Search(ctx context.Context, cfg SiteConfig, keyword string, page int) (*SiteSearchResult, error) {
|
||||||
params := url.Values{}
|
params := url.Values{}
|
||||||
|
params.Set("searchstr", keyword)
|
||||||
params.Set("search", keyword)
|
params.Set("search", keyword)
|
||||||
|
params.Set("search_area", "0")
|
||||||
|
params.Set("search_mode", "0")
|
||||||
params.Set("page", strconv.Itoa(page))
|
params.Set("page", strconv.Itoa(page))
|
||||||
params.Set("inclbookmarked", "0")
|
params.Set("inclbookmarked", "0")
|
||||||
params.Set("incldead", "0")
|
params.Set("incldead", "0")
|
||||||
@@ -119,11 +123,7 @@ func parseNexusPHPHTML(html, siteName, baseURL string) (*SiteSearchResult, error
|
|||||||
Page: 1,
|
Page: 1,
|
||||||
}
|
}
|
||||||
|
|
||||||
// Extract table rows from torrent table
|
for _, row := range nexusPHPTorrentRows(html) {
|
||||||
rowRegex := regexp.MustCompile(`<tr[^>]*>\s*<td[^>]*class="rowfollow"[^>]*>.*?</tr>`)
|
|
||||||
matches := rowRegex.FindAllString(html, -1)
|
|
||||||
|
|
||||||
for _, row := range matches {
|
|
||||||
item := parseNexusPHPRow(row, baseURL)
|
item := parseNexusPHPRow(row, baseURL)
|
||||||
if item.ID != "" {
|
if item.ID != "" {
|
||||||
result.Items = append(result.Items, item)
|
result.Items = append(result.Items, item)
|
||||||
@@ -139,40 +139,41 @@ func parseNexusPHPRow(row, baseURL string) TorrentItem {
|
|||||||
item := TorrentItem{}
|
item := TorrentItem{}
|
||||||
|
|
||||||
// Extract torrent ID and title
|
// Extract torrent ID and title
|
||||||
idRegex := regexp.MustCompile(`details\.php\?id=(\d+)[^"]*"[^>]*>([^<]+)`)
|
if link := firstNexusPHPLink(row, "details.php"); link != nil {
|
||||||
idMatches := idRegex.FindStringSubmatch(row)
|
item.ID = link.query.Get("id")
|
||||||
if len(idMatches) >= 3 {
|
item.Title = nexusPHPTitleFromLink(*link)
|
||||||
item.ID = idMatches[1]
|
item.Subtitle = nexusPHPSubtitle(row)
|
||||||
item.Title = strings.TrimSpace(idMatches[2])
|
item.DetailURL = resolveSiteURL(baseURL, link.href)
|
||||||
item.DetailURL = baseURL + "/details.php?id=" + item.ID
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Extract download link
|
// Extract download link
|
||||||
dlRegex := regexp.MustCompile(`download\.php\?id=(\d+)`)
|
if link := firstNexusPHPLink(row, "download.php"); link != nil {
|
||||||
if dlMatches := dlRegex.FindStringSubmatch(row); len(dlMatches) >= 2 {
|
item.DownloadURL = resolveSiteURL(baseURL, link.href)
|
||||||
item.DownloadURL = baseURL + "/download.php?id=" + dlMatches[1]
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Extract size
|
// 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 {
|
if sizeMatches := sizeRegex.FindStringSubmatch(row); len(sizeMatches) >= 3 {
|
||||||
item.Size = parseSizeString(sizeMatches[1], sizeMatches[2])
|
item.Size = parseSizeString(sizeMatches[1], sizeMatches[2])
|
||||||
}
|
}
|
||||||
|
|
||||||
// Extract seeders and leechers
|
// Extract seeders and leechers
|
||||||
seedersRegex := regexp.MustCompile(`seeders[^"]*"[^>]*>(\d+)<`)
|
if value, ok := nexusPHPIntByClass(row, "seeders"); ok {
|
||||||
if m := seedersRegex.FindStringSubmatch(row); len(m) >= 2 {
|
item.Seeders = value
|
||||||
item.Seeders, _ = strconv.Atoi(m[1])
|
|
||||||
}
|
}
|
||||||
leechersRegex := regexp.MustCompile(`leechers[^"]*"[^>]*>(\d+)<`)
|
if value, ok := nexusPHPIntByClass(row, "leechers"); ok {
|
||||||
if m := leechersRegex.FindStringSubmatch(row); len(m) >= 2 {
|
item.Leechers = value
|
||||||
item.Leechers, _ = strconv.Atoi(m[1])
|
}
|
||||||
|
if value, ok := nexusPHPIntByClass(row, "snatched"); ok {
|
||||||
|
item.Snatched = value
|
||||||
}
|
}
|
||||||
|
|
||||||
// Extract snatched
|
// Extract snatched
|
||||||
snatchedRegex := regexp.MustCompile(`snatched[^"]*"[^>]*>(\d+)`)
|
snatchedRegex := regexp.MustCompile(`snatched[^"]*"[^>]*>(\d+)`)
|
||||||
if m := snatchedRegex.FindStringSubmatch(row); len(m) >= 2 {
|
if item.Snatched == 0 {
|
||||||
item.Snatched, _ = strconv.Atoi(m[1])
|
if m := snatchedRegex.FindStringSubmatch(row); len(m) >= 2 {
|
||||||
|
item.Snatched, _ = strconv.Atoi(m[1])
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check for free flag
|
// Check for free flag
|
||||||
@@ -196,6 +197,104 @@ func parseNexusPHPRow(row, baseURL string) TorrentItem {
|
|||||||
return item
|
return item
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type nexusPHPLink struct {
|
||||||
|
href string
|
||||||
|
attrs string
|
||||||
|
text string
|
||||||
|
query url.Values
|
||||||
|
}
|
||||||
|
|
||||||
|
func nexusPHPTorrentRows(pageHTML string) []string {
|
||||||
|
rowRegex := regexp.MustCompile(`(?is)<tr\b[^>]*>.*?</tr>`)
|
||||||
|
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)<a\b([^>]*href\s*=\s*["']([^"']*` + regexp.QuoteMeta(path) + `[^"']*)["'][^>]*)>(.*?)</a>`)
|
||||||
|
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)<span\b[^>]*(?:class|id)\s*=\s*["'][^"']*(?:subtitle|small_descr|descr|sub)[^"']*["'][^>]*>(.*?)</span>`),
|
||||||
|
regexp.MustCompile(`(?is)<font\b[^>]*(?:class|id)\s*=\s*["'][^"']*(?:subtitle|small_descr|descr|sub)[^"']*["'][^>]*>(.*?)</font>`),
|
||||||
|
} {
|
||||||
|
if match := pattern.FindStringSubmatch(row); len(match) >= 2 {
|
||||||
|
return cleanNexusPHPText(match[1])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func nexusPHPIntByClass(row, className string) (int, bool) {
|
||||||
|
pattern := regexp.MustCompile(`(?is)<td\b[^>]*(?:class|id)\s*=\s*["'][^"']*` + regexp.QuoteMeta(className) + `[^"']*["'][^>]*>(.*?)</td>`)
|
||||||
|
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 解析种子详情页。
|
// parseNexusPHPDetailHTML 解析种子详情页。
|
||||||
func parseNexusPHPDetailHTML(html, id, baseURL string) (*TorrentDetail, error) {
|
func parseNexusPHPDetailHTML(html, id, baseURL string) (*TorrentDetail, error) {
|
||||||
detail := &TorrentDetail{
|
detail := &TorrentDetail{
|
||||||
|
|||||||
@@ -30,7 +30,7 @@ func parseSizeString(value string, unit string) int64 {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return 0
|
return 0
|
||||||
}
|
}
|
||||||
switch strings.ToLower(unit) {
|
switch strings.TrimSuffix(strings.ToLower(unit), "i") {
|
||||||
case "kb":
|
case "kb":
|
||||||
return int64(v * 1024)
|
return int64(v * 1024)
|
||||||
case "mb":
|
case "mb":
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import (
|
|||||||
"errors"
|
"errors"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
|
"net/url"
|
||||||
"strings"
|
"strings"
|
||||||
"sync/atomic"
|
"sync/atomic"
|
||||||
"testing"
|
"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(`<table class="torrents"><tr><td><a href="details.php?id=123" title="测试资源">测试资源</a></td><td><a href="download.php?id=123">下载</a></td></tr></table>`))
|
||||||
|
}))
|
||||||
|
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 := `
|
||||||
|
<table class="torrents">
|
||||||
|
<tr class="torrent">
|
||||||
|
<td class="cat"><a href="torrents.php?cat=401" title="电影">电影</a></td>
|
||||||
|
<td>
|
||||||
|
<a class="torrent-title" href="/details.php?id=456&hit=1" title="Some & Movie 2026 2160p">ignored</a>
|
||||||
|
<span class="subtitle">副标题 & 描述</span>
|
||||||
|
<a href="/download.php?id=456&passkey=abc">下载</a>
|
||||||
|
</td>
|
||||||
|
<td>12.5 GiB</td>
|
||||||
|
<td class="seeders"><a>33</a></td>
|
||||||
|
<td class="leechers"><span>4</span></td>
|
||||||
|
<td class="snatched">99</td>
|
||||||
|
</tr>
|
||||||
|
</table>`
|
||||||
|
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) {
|
func TestMTeamAPIRateLimits(t *testing.T) {
|
||||||
search := mteamAPIRateLimits(mteamAPIEndpointSearch)
|
search := mteamAPIRateLimits(mteamAPIEndpointSearch)
|
||||||
if len(search) != 1 || search[0].Limit != 1500 || search[0].Window != 24*time.Hour {
|
if len(search) != 1 || search[0].Limit != 1500 || search[0].Window != 24*time.Hour {
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ const (
|
|||||||
SystemUpdateImageSettingKey = "system.update.image"
|
SystemUpdateImageSettingKey = "system.update.image"
|
||||||
SystemUpdateWatchtowerImageSettingKey = "system.update.watchtower_image"
|
SystemUpdateWatchtowerImageSettingKey = "system.update.watchtower_image"
|
||||||
SystemUpdateCommandSettingKey = "system.update.command"
|
SystemUpdateCommandSettingKey = "system.update.command"
|
||||||
|
SystemUpdateComposeDirSettingKey = "system.update.compose_dir"
|
||||||
|
|
||||||
DefaultSystemUpdateImage = "ghcr.io/shukebta/mediastation-go:latest"
|
DefaultSystemUpdateImage = "ghcr.io/shukebta/mediastation-go:latest"
|
||||||
DefaultSystemUpdateWatchtowerImage = "containrrr/watchtower:latest"
|
DefaultSystemUpdateWatchtowerImage = "containrrr/watchtower:latest"
|
||||||
@@ -33,6 +34,10 @@ var ErrSystemUpdateRunning = errors.New("system update already running")
|
|||||||
type SystemUpdateStatus struct {
|
type SystemUpdateStatus struct {
|
||||||
Image string `json:"image"`
|
Image string `json:"image"`
|
||||||
WatchtowerImage string `json:"watchtower_image,omitempty"`
|
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"`
|
ContainerID string `json:"container_id,omitempty"`
|
||||||
ContainerName string `json:"container_name,omitempty"`
|
ContainerName string `json:"container_name,omitempty"`
|
||||||
CurrentImageID string `json:"current_image_id,omitempty"`
|
CurrentImageID string `json:"current_image_id,omitempty"`
|
||||||
@@ -199,6 +204,7 @@ func (s *SystemUpdateService) baseStatus(ctx context.Context) SystemUpdateStatus
|
|||||||
return SystemUpdateStatus{
|
return SystemUpdateStatus{
|
||||||
Image: image,
|
Image: image,
|
||||||
WatchtowerImage: watchtowerImage,
|
WatchtowerImage: watchtowerImage,
|
||||||
|
UpdateMode: "compose",
|
||||||
ContainerID: currentContainerID(),
|
ContainerID: currentContainerID(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -23,14 +23,17 @@ func (s *SystemUpdateService) rawUpdateCommand(ctx context.Context) string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func defaultSystemUpdateCommand() 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 {
|
func renderSystemUpdateCommand(template string, status SystemUpdateStatus) string {
|
||||||
replacements := map[string]string{
|
replacements := map[string]string{
|
||||||
"{{image}}": shellQuote(status.Image),
|
"{{image}}": shellQuote(status.Image),
|
||||||
"{{watchtower_image}}": shellQuote(firstNonEmpty(status.WatchtowerImage, DefaultSystemUpdateWatchtowerImage)),
|
"{{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_id}}": shellQuote(status.ContainerID),
|
||||||
"{{container_name}}": shellQuote(status.ContainerName),
|
"{{container_name}}": shellQuote(status.ContainerName),
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -5,7 +5,6 @@ import (
|
|||||||
"os"
|
"os"
|
||||||
"os/exec"
|
"os/exec"
|
||||||
"regexp"
|
"regexp"
|
||||||
"runtime"
|
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
@@ -16,48 +15,62 @@ func (s *SystemUpdateService) check(ctx context.Context) SystemUpdateStatus {
|
|||||||
status.CheckedAt = &now
|
status.CheckedAt = &now
|
||||||
|
|
||||||
customCommand := s.rawUpdateCommand(ctx)
|
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")
|
dockerPath, err := exec.LookPath("docker")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return systemUpdateCustomFallback(status, systemUpdateFallback{
|
if status.CanApply {
|
||||||
command: customCommand,
|
status.Details = err.Error()
|
||||||
customMessage: "当前环境无法检查 Docker;将执行自定义更新命令",
|
return status
|
||||||
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(),
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
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)
|
checkCtx, cancel := context.WithTimeout(ctx, systemUpdateCheckTimeout)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
if out, err := runSystemUpdateCommand(checkCtx, dockerPath, "version", "--format", "{{.Server.Version}}"); err != nil {
|
if out, err := runSystemUpdateCommand(checkCtx, dockerPath, "version", "--format", "{{.Server.Version}}"); err != nil {
|
||||||
details := strings.TrimSpace(out + "\n" + err.Error())
|
details := strings.TrimSpace(out + "\n" + err.Error())
|
||||||
return systemUpdateCustomFallback(status, systemUpdateFallback{
|
status.Details = details
|
||||||
command: customCommand,
|
if status.CanApply {
|
||||||
customMessage: "无法连接 Docker 引擎;将执行自定义更新命令",
|
status.Message = "Docker Compose 更新命令已就绪;当前无法读取 Docker 引擎摘要,执行时如失败请检查 Docker 权限"
|
||||||
customDetails: details,
|
return status
|
||||||
defaultMessage: "无法连接 Docker 引擎,请检查 Docker socket 权限",
|
}
|
||||||
defaultDetails: details,
|
status.Message = "无法连接 Docker 引擎,请检查 Docker 权限"
|
||||||
})
|
return status
|
||||||
}
|
}
|
||||||
status.DockerAvailable = true
|
status.DockerAvailable = true
|
||||||
|
|
||||||
containerID := currentContainerID()
|
containerID := currentContainerID()
|
||||||
status.ContainerID = containerID
|
status.ContainerID = containerID
|
||||||
if containerID == "" {
|
if containerID == "" {
|
||||||
status.Message = "无法识别当前容器 ID;请配置自定义更新命令"
|
if status.CanApply {
|
||||||
if customCommand != "" {
|
status.Message = "已识别 Docker Compose 安装目录;无法识别当前容器 ID,将使用默认容器名 mediastation-go 重启"
|
||||||
status.CanApply = true
|
|
||||||
status.Message = "无法识别当前容器 ID;将执行自定义更新命令"
|
|
||||||
}
|
}
|
||||||
return status
|
return status
|
||||||
}
|
}
|
||||||
@@ -69,6 +82,18 @@ func (s *SystemUpdateService) check(ctx context.Context) SystemUpdateStatus {
|
|||||||
return status
|
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 {
|
type systemUpdateFallback struct {
|
||||||
command string
|
command string
|
||||||
customMessage string
|
customMessage string
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
package service
|
package service
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"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) {
|
func TestParseContainerInspectLine(t *testing.T) {
|
||||||
name, imageID := parseContainerInspectLine("/mediastation-go|sha256:abc")
|
name, imageID := parseContainerInspectLine("/mediastation-go|sha256:abc")
|
||||||
if name != "mediastation-go" || imageID != "sha256:abc" {
|
if name != "mediastation-go" || imageID != "sha256:abc" {
|
||||||
|
|||||||
@@ -4,6 +4,10 @@ import type { AccessLog, Setting, User } from '../types'
|
|||||||
export interface SystemUpdateStatus {
|
export interface SystemUpdateStatus {
|
||||||
image: string
|
image: string
|
||||||
watchtower_image?: string
|
watchtower_image?: string
|
||||||
|
update_mode?: string
|
||||||
|
compose_dir?: string
|
||||||
|
compose_file?: string
|
||||||
|
compose_command?: string
|
||||||
container_id?: string
|
container_id?: string
|
||||||
container_name?: string
|
container_name?: string
|
||||||
current_image_id?: string
|
current_image_id?: string
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
import { useEffect, useRef } from 'react'
|
import { useEffect, useRef, useState } from 'react'
|
||||||
import { Link, useLocation } from 'react-router-dom'
|
import { Link, useLocation } from 'react-router-dom'
|
||||||
import { AnimatePresence, motion } from 'framer-motion'
|
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 clsx from 'clsx'
|
||||||
|
import toast from 'react-hot-toast'
|
||||||
|
|
||||||
|
import { adminAPI } from '../api/admin'
|
||||||
import type { PlayProfile } from '../types'
|
import type { PlayProfile } from '../types'
|
||||||
|
|
||||||
type LayoutUser = {
|
type LayoutUser = {
|
||||||
@@ -39,6 +41,7 @@ export function LayoutUserMenu({
|
|||||||
const location = useLocation()
|
const location = useLocation()
|
||||||
const rootRef = useRef<HTMLDivElement>(null)
|
const rootRef = useRef<HTMLDivElement>(null)
|
||||||
const lastLocationRef = useRef(`${location.pathname}${location.search}`)
|
const lastLocationRef = useRef(`${location.pathname}${location.search}`)
|
||||||
|
const [updating, setUpdating] = useState(false)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isOpen) return undefined
|
if (!isOpen) return undefined
|
||||||
@@ -68,6 +71,22 @@ export function LayoutUserMenu({
|
|||||||
if (isOpen) onClose()
|
if (isOpen) onClose()
|
||||||
}, [isOpen, location.pathname, location.search, 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 (
|
return (
|
||||||
<div ref={rootRef} className="relative" data-testid="layout-user-menu">
|
<div ref={rootRef} className="relative" data-testid="layout-user-menu">
|
||||||
<button
|
<button
|
||||||
@@ -102,6 +121,17 @@ export function LayoutUserMenu({
|
|||||||
{user?.role === 'admin' && (
|
{user?.role === 'admin' && (
|
||||||
<UserMenuLink to="/admin" icon={<Settings size={16} />} label="管理主控制台" onClick={onClose} />
|
<UserMenuLink to="/admin" icon={<Settings size={16} />} label="管理主控制台" onClick={onClose} />
|
||||||
)}
|
)}
|
||||||
|
{user?.role === 'admin' && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={applySystemUpdate}
|
||||||
|
disabled={updating}
|
||||||
|
className="flex w-full items-center gap-3 rounded-xl px-3 py-2 text-sm text-[var(--app-subtle)] transition-colors hover:bg-[var(--app-hover)] hover:text-[var(--app-text)] disabled:cursor-not-allowed disabled:opacity-60"
|
||||||
|
>
|
||||||
|
{updating ? <Loader2 size={16} className="animate-spin" /> : <RotateCw size={16} />}
|
||||||
|
<span>一键更新系统</span>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
<div className="my-1.5 border-t border-[var(--app-border)]" />
|
<div className="my-1.5 border-t border-[var(--app-border)]" />
|
||||||
<div className="px-3 py-2">
|
<div className="px-3 py-2">
|
||||||
<p className="mb-2 text-[10px] font-bold uppercase tracking-wider text-[var(--app-muted)]">
|
<p className="mb-2 text-[10px] font-bold uppercase tracking-wider text-[var(--app-muted)]">
|
||||||
|
|||||||
@@ -100,7 +100,7 @@ export function SystemUpdatePanel() {
|
|||||||
</div>
|
</div>
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
<div className="flex flex-wrap items-center gap-2">
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
<h2 className="font-display text-xl font-semibold text-ink-600">Docker 热更新</h2>
|
<h2 className="font-display text-xl font-semibold text-ink-600">Docker Compose 一键更新</h2>
|
||||||
<span className={`inline-flex items-center gap-1 rounded-lg border px-2 py-1 text-xs ${tone.className}`}>
|
<span className={`inline-flex items-center gap-1 rounded-lg border px-2 py-1 text-xs ${tone.className}`}>
|
||||||
<ToneIcon size={13} />
|
<ToneIcon size={13} />
|
||||||
{tone.label}
|
{tone.label}
|
||||||
@@ -130,7 +130,7 @@ export function SystemUpdatePanel() {
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="btn-primary"
|
className="btn-primary"
|
||||||
disabled={applying || status?.running || !status?.can_apply}
|
disabled={applying || status?.running}
|
||||||
onClick={applyUpdate}
|
onClick={applyUpdate}
|
||||||
>
|
>
|
||||||
{applying || status?.running ? <Loader2 size={16} className="animate-spin" /> : <RotateCw size={16} />}
|
{applying || status?.running ? <Loader2 size={16} className="animate-spin" /> : <RotateCw size={16} />}
|
||||||
@@ -140,13 +140,15 @@ export function SystemUpdatePanel() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid gap-3 md:grid-cols-2 xl:grid-cols-4">
|
<div className="grid gap-3 md:grid-cols-2 xl:grid-cols-4">
|
||||||
<UpdateFact icon={Server} label="Docker" value={status?.docker_available ? '可连接' : '未就绪'} />
|
<UpdateFact icon={Server} label="更新方式" value={status?.update_mode === 'compose' ? 'Docker Compose' : '自定义命令'} />
|
||||||
|
<UpdateFact label="Compose 目录" value={status?.compose_dir || '自动识别'} mono />
|
||||||
<UpdateFact label="应用镜像" value={status?.image || '-'} mono />
|
<UpdateFact label="应用镜像" value={status?.image || '-'} mono />
|
||||||
<UpdateFact label="当前容器" value={status?.container_name || status?.container_id || '-'} mono />
|
<UpdateFact label="当前容器" value={status?.container_name || status?.container_id || '-'} mono />
|
||||||
<UpdateFact label="最近检查" value={formatDate(status?.checked_at)} />
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid gap-3 md:grid-cols-2">
|
<div className="grid gap-3 md:grid-cols-2">
|
||||||
|
<DigestRow label="Compose 文件" digest={status?.compose_file} />
|
||||||
|
<DigestRow label="Docker 连接" digest={status?.docker_available ? '可连接' : '未连接或未检查'} />
|
||||||
<DigestRow label="本地摘要" digest={status?.local_digest} />
|
<DigestRow label="本地摘要" digest={status?.local_digest} />
|
||||||
<DigestRow label="远端摘要" digest={status?.remote_digest} />
|
<DigestRow label="远端摘要" digest={status?.remote_digest} />
|
||||||
</div>
|
</div>
|
||||||
@@ -217,7 +219,7 @@ function updateTone(status: SystemUpdateStatus | null): {
|
|||||||
if (status.running) {
|
if (status.running) {
|
||||||
return { label: '更新中', className: 'border-yellow-400/50 text-yellow-600', icon: Loader2 }
|
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 }
|
return { label: '不可更新', className: 'border-red-400/50 text-red-500', icon: AlertTriangle }
|
||||||
}
|
}
|
||||||
if (status.update_available === true) {
|
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
|
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 {
|
function apiErrorMessage(err: unknown, fallback: string): string {
|
||||||
return (err as UpdateErrorPayload).response?.data?.error || fallback
|
return (err as UpdateErrorPayload).response?.data?.error || fallback
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import type { SettingGroup } from './settingsGroupTypes'
|
|||||||
export const systemUpdateSettingsGroup: SettingGroup = {
|
export const systemUpdateSettingsGroup: SettingGroup = {
|
||||||
key: 'system-update',
|
key: 'system-update',
|
||||||
label: '系统更新',
|
label: '系统更新',
|
||||||
description: 'Docker 部署可在这里检查并拉取最新版镜像。',
|
description: 'Docker Compose 部署可在这里检查并拉取最新版镜像。',
|
||||||
items: [
|
items: [
|
||||||
{
|
{
|
||||||
key: 'system.update.image',
|
key: 'system.update.image',
|
||||||
@@ -14,20 +14,19 @@ export const systemUpdateSettingsGroup: SettingGroup = {
|
|||||||
hint: '用于检查远端摘要;保持 latest 即可跟随主分支镜像。',
|
hint: '用于检查远端摘要;保持 latest 即可跟随主分支镜像。',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'system.update.watchtower_image',
|
key: 'system.update.compose_dir',
|
||||||
label: 'Watchtower 镜像',
|
label: 'Docker Compose 安装目录',
|
||||||
type: 'text',
|
type: 'text',
|
||||||
defaultValue: 'containrrr/watchtower:latest',
|
placeholder: '/vol1/1000/docker/mediastation-go',
|
||||||
placeholder: 'containrrr/watchtower:latest',
|
hint: '留空时自动查找 docker-compose.yml / compose.yml 所在目录。',
|
||||||
hint: '默认使用一次性 Watchtower 更新当前容器。',
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'system.update.command',
|
key: 'system.update.command',
|
||||||
label: '自定义更新命令',
|
label: '自定义更新命令',
|
||||||
type: 'textarea',
|
type: 'textarea',
|
||||||
placeholder:
|
placeholder:
|
||||||
'docker run --rm -v /var/run/docker.sock:/var/run/docker.sock {{watchtower_image}} --run-once --cleanup {{container}}',
|
'cd {{compose_dir}} && {{compose_command}} pull && {{compose_command}} up -d && docker image prune -f && docker restart {{container}}',
|
||||||
hint: '留空时使用默认命令。支持 {{image}}、{{watchtower_image}}、{{container}}、{{container_id}}、{{container_name}}。',
|
hint: '留空时使用 Docker Compose 默认命令。支持 {{image}}、{{compose_dir}}、{{compose_file}}、{{compose_command}}、{{container}}、{{container_id}}、{{container_name}}。',
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user