fix library display and docker compose update

This commit is contained in:
ShukeBta
2026-06-27 16:54:33 +08:00
parent 41f39d9701
commit a2be6a2127
19 changed files with 701 additions and 88 deletions
+2
View File
@@ -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
+28
View File
@@ -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{})
+1 -1
View File
@@ -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": "自定义更新命令"},
},
},
+15
View File
@@ -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
+36 -2
View File
@@ -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)
+32 -9
View File
@@ -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
+30
View File
@@ -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)
+122 -23
View File
@@ -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(`<tr[^>]*>\s*<td[^>]*class="rowfollow"[^>]*>.*?</tr>`)
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)<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 解析种子详情页。
func parseNexusPHPDetailHTML(html, id, baseURL string) (*TorrentDetail, error) {
detail := &TorrentDetail{
+1 -1
View File
@@ -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":
+67
View File
@@ -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(`<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 &amp; Movie 2026 2160p">ignored</a>
<span class="subtitle">副标题 &amp; 描述</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) {
search := mteamAPIRateLimits(mteamAPIEndpointSearch)
if len(search) != 1 || search[0].Limit != 1500 || search[0].Window != 24*time.Hour {
+6
View File
@@ -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(),
}
}
+5 -2
View File
@@ -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),
}
+199
View File
@@ -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
}
+53 -28
View File
@@ -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
+54
View File
@@ -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" {
+4
View File
@@ -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
+32 -2
View File
@@ -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<HTMLDivElement>(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 (
<div ref={rootRef} className="relative" data-testid="layout-user-menu">
<button
@@ -102,6 +121,17 @@ export function LayoutUserMenu({
{user?.role === 'admin' && (
<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="px-3 py-2">
<p className="mb-2 text-[10px] font-bold uppercase tracking-wider text-[var(--app-muted)]">
+7 -12
View File
@@ -100,7 +100,7 @@ export function SystemUpdatePanel() {
</div>
<div className="min-w-0">
<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}`}>
<ToneIcon size={13} />
{tone.label}
@@ -130,7 +130,7 @@ export function SystemUpdatePanel() {
<button
type="button"
className="btn-primary"
disabled={applying || status?.running || !status?.can_apply}
disabled={applying || status?.running}
onClick={applyUpdate}
>
{applying || status?.running ? <Loader2 size={16} className="animate-spin" /> : <RotateCw size={16} />}
@@ -140,13 +140,15 @@ export function SystemUpdatePanel() {
</div>
<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?.container_name || status?.container_id || '-'} mono />
<UpdateFact label="最近检查" value={formatDate(status?.checked_at)} />
</div>
<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?.remote_digest} />
</div>
@@ -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
}
+7 -8
View File
@@ -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}}。',
},
],
}