mirror of
https://github.com/truewhile/MeBox.git
synced 2026-09-29 03:26: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
|
||||
}
|
||||
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
|
||||
|
||||
@@ -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{})
|
||||
|
||||
@@ -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": "自定义更新命令"},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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{
|
||||
|
||||
@@ -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":
|
||||
|
||||
@@ -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 & 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) {
|
||||
search := mteamAPIRateLimits(mteamAPIEndpointSearch)
|
||||
if len(search) != 1 || search[0].Limit != 1500 || search[0].Window != 24*time.Hour {
|
||||
|
||||
@@ -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(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
}
|
||||
|
||||
@@ -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/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
|
||||
|
||||
@@ -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,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
|
||||
|
||||
@@ -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)]">
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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}}。',
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user