Improve disk UI and build performance
This commit is contained in:
@@ -2,8 +2,12 @@ package api
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
|
||||
"jukebox_maker/internal/config"
|
||||
"jukebox_maker/internal/copier"
|
||||
"jukebox_maker/internal/disk"
|
||||
)
|
||||
@@ -12,19 +16,47 @@ func (s *Server) handleCopyStart(w http.ResponseWriter, r *http.Request) {
|
||||
diskID := r.PathValue("diskID")
|
||||
diskInfo, ok := s.deps.Watcher.DiskByID(diskID)
|
||||
if !ok || diskInfo.State != disk.DiskKnown {
|
||||
jsonErr(w, http.StatusUnprocessableEntity, "no known disk connected")
|
||||
jsonErr(w, http.StatusUnprocessableEntity, "no initialized disk connected")
|
||||
return
|
||||
}
|
||||
|
||||
cfg := s.deps.Config
|
||||
var enabledSources []string
|
||||
hasEnabledSources := false
|
||||
for _, src := range cfg.Sources {
|
||||
if src.Enabled {
|
||||
enabledSources = append(enabledSources, src.Path)
|
||||
hasEnabledSources = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if len(enabledSources) == 0 {
|
||||
jsonErr(w, http.StatusUnprocessableEntity, "no sources enabled")
|
||||
if !hasEnabledSources {
|
||||
jsonErr(w, http.StatusUnprocessableEntity, "no source folders selected")
|
||||
return
|
||||
}
|
||||
|
||||
var req struct {
|
||||
Mode string `json:"mode"`
|
||||
}
|
||||
if r.Body != nil {
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil && !errors.Is(err, io.EOF) {
|
||||
jsonErr(w, http.StatusBadRequest, "invalid JSON: "+err.Error())
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
overwriteMode := cfg.OverwriteMode
|
||||
switch req.Mode {
|
||||
case "", "add":
|
||||
overwriteMode = config.OverwriteSkip
|
||||
case "replace":
|
||||
overwriteMode = config.OverwriteDelete
|
||||
default:
|
||||
jsonErr(w, http.StatusBadRequest, "invalid copy mode")
|
||||
return
|
||||
}
|
||||
|
||||
reserveBytes := int64(cfg.ReserveFreeGB * 1e9)
|
||||
if diskInfo.FreeBytes <= reserveBytes {
|
||||
jsonErr(w, http.StatusUnprocessableEntity, "free space is below reserve threshold")
|
||||
return
|
||||
}
|
||||
|
||||
@@ -33,9 +65,9 @@ func (s *Server) handleCopyStart(w http.ResponseWriter, r *http.Request) {
|
||||
MountPath: diskInfo.MountPath,
|
||||
MediaPath: s.deps.MediaPath,
|
||||
DestFolder: cfg.DestFolder,
|
||||
EnabledSources: enabledSources,
|
||||
SourceRules: cfg.Sources,
|
||||
ReserveFreeGB: cfg.ReserveFreeGB,
|
||||
OverwriteMode: cfg.OverwriteMode,
|
||||
OverwriteMode: overwriteMode,
|
||||
FileSelectMode: cfg.FileSelectMode,
|
||||
}
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ package api
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"jukebox_maker/internal/disk"
|
||||
)
|
||||
@@ -14,6 +15,7 @@ func (s *Server) handleDiskStatus(w http.ResponseWriter, r *http.Request) {
|
||||
TotalBytes int64 `json:"total_bytes"`
|
||||
FreeBytes int64 `json:"free_bytes"`
|
||||
MountPath string `json:"mount_path"`
|
||||
LastCopiedAt string `json:"last_copied_at,omitempty"`
|
||||
ActiveTaskID string `json:"active_task_id,omitempty"`
|
||||
}
|
||||
|
||||
@@ -28,6 +30,9 @@ func (s *Server) handleDiskStatus(w http.ResponseWriter, r *http.Request) {
|
||||
MountPath: info.MountPath,
|
||||
}
|
||||
if info.DiskID != "" {
|
||||
if lastCopiedAt, ok, err := s.deps.Copier.LastCopiedAt(info.DiskID); err == nil && ok {
|
||||
item.LastCopiedAt = lastCopiedAt.Format(time.RFC3339)
|
||||
}
|
||||
if t, ok := s.deps.Tasks.ActiveTaskByDisk(info.DiskID); ok {
|
||||
item.ActiveTaskID = t.ID
|
||||
}
|
||||
|
||||
@@ -1,26 +1,79 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
)
|
||||
|
||||
func (s *Server) handleSources(w http.ResponseWriter, r *http.Request) {
|
||||
entries, err := os.ReadDir(s.deps.MediaPath)
|
||||
relPath, err := normalizeSourcePath(r.URL.Query().Get("path"))
|
||||
if err != nil {
|
||||
jsonOK(w, map[string][]string{"items": {}})
|
||||
jsonErr(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
var items []string
|
||||
for _, e := range entries {
|
||||
if e.IsDir() && e.Name()[0] != '.' {
|
||||
items = append(items, e.Name())
|
||||
}
|
||||
}
|
||||
if items == nil {
|
||||
items = []string{}
|
||||
absPath := s.deps.MediaPath
|
||||
if relPath != "" {
|
||||
absPath = filepath.Join(absPath, relPath)
|
||||
}
|
||||
|
||||
jsonOK(w, map[string][]string{"items": items})
|
||||
entries, err := os.ReadDir(absPath)
|
||||
if err != nil {
|
||||
jsonOK(w, map[string]any{"path": relPath, "items": []map[string]string{}})
|
||||
return
|
||||
}
|
||||
|
||||
type item struct {
|
||||
Name string `json:"name"`
|
||||
Path string `json:"path"`
|
||||
}
|
||||
|
||||
var items []item
|
||||
for _, e := range entries {
|
||||
if !e.IsDir() || strings.HasPrefix(e.Name(), ".") {
|
||||
continue
|
||||
}
|
||||
childPath := e.Name()
|
||||
if relPath != "" {
|
||||
childPath = filepath.Join(relPath, childPath)
|
||||
}
|
||||
items = append(items, item{
|
||||
Name: e.Name(),
|
||||
Path: filepath.ToSlash(childPath),
|
||||
})
|
||||
}
|
||||
|
||||
sort.Slice(items, func(i, j int) bool {
|
||||
return strings.ToLower(items[i].Name) < strings.ToLower(items[j].Name)
|
||||
})
|
||||
|
||||
jsonOK(w, map[string]any{
|
||||
"path": relPath,
|
||||
"items": items,
|
||||
})
|
||||
}
|
||||
|
||||
func normalizeSourcePath(raw string) (string, error) {
|
||||
raw, _ = url.QueryUnescape(raw)
|
||||
raw = strings.TrimSpace(raw)
|
||||
raw = filepath.ToSlash(raw)
|
||||
raw = strings.TrimPrefix(raw, "/")
|
||||
if raw == "" || raw == "." {
|
||||
return "", nil
|
||||
}
|
||||
|
||||
clean := filepath.Clean(raw)
|
||||
clean = filepath.ToSlash(clean)
|
||||
if clean == "." {
|
||||
return "", nil
|
||||
}
|
||||
if clean == ".." || strings.HasPrefix(clean, "../") {
|
||||
return "", errors.New("invalid source path")
|
||||
}
|
||||
return clean, nil
|
||||
}
|
||||
|
||||
@@ -16,6 +16,7 @@ import (
|
||||
type Deps struct {
|
||||
Config *config.Config
|
||||
ConfigPath string
|
||||
Version string
|
||||
Watcher *watcher.Watcher
|
||||
Copier *copier.Copier
|
||||
Tasks *task.Store
|
||||
@@ -77,7 +78,7 @@ func (s *Server) handleDashboard(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
func (s *Server) handleSettings(w http.ResponseWriter, r *http.Request) {
|
||||
s.render(w, s.settings, map[string]any{"Title": "Настройки", "Page": "settings"})
|
||||
s.render(w, s.settings, map[string]any{"Title": "Settings", "Page": "settings"})
|
||||
}
|
||||
|
||||
func (s *Server) handleHealth(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -86,7 +87,13 @@ func (s *Server) handleHealth(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
func (s *Server) render(w http.ResponseWriter, tmpl *template.Template, data any) {
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
if err := tmpl.ExecuteTemplate(w, "layout", data); err != nil {
|
||||
payload := map[string]any{"Version": s.deps.Version}
|
||||
if incoming, ok := data.(map[string]any); ok {
|
||||
for k, v := range incoming {
|
||||
payload[k] = v
|
||||
}
|
||||
}
|
||||
if err := tmpl.ExecuteTemplate(w, "layout", payload); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,6 +8,8 @@ import (
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
@@ -22,7 +24,7 @@ type Options struct {
|
||||
MountPath string
|
||||
MediaPath string
|
||||
DestFolder string // subfolder on disk, default "media"
|
||||
EnabledSources []string
|
||||
SourceRules []config.SourceFolder
|
||||
ReserveFreeGB float64
|
||||
OverwriteMode config.OverwriteMode
|
||||
FileSelectMode config.FileSelectMode
|
||||
@@ -62,6 +64,14 @@ func (c *Copier) getDB(diskID string) *db.DB {
|
||||
return c.dbs[diskID]
|
||||
}
|
||||
|
||||
func (c *Copier) LastCopiedAt(diskID string) (time.Time, bool, error) {
|
||||
database := c.getDB(diskID)
|
||||
if database == nil {
|
||||
return time.Time{}, false, nil
|
||||
}
|
||||
return database.LastCopiedAt(diskID)
|
||||
}
|
||||
|
||||
func (c *Copier) Start(ctx context.Context, opts Options) (string, error) {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
@@ -79,6 +89,15 @@ func (c *Copier) Start(ctx context.Context, opts Options) (string, error) {
|
||||
opts.DestFolder = "media"
|
||||
}
|
||||
|
||||
_, free, err := disk.DiskUsage(opts.MountPath)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
reserveBytes := int64(opts.ReserveFreeGB * 1e9)
|
||||
if free <= reserveBytes {
|
||||
return "", errors.New("free space is below reserve threshold")
|
||||
}
|
||||
|
||||
t := c.tasks.Create("copy", opts.DiskID)
|
||||
copyCtx, cancel := context.WithCancel(ctx)
|
||||
c.cancels[opts.DiskID] = cancel
|
||||
@@ -116,12 +135,12 @@ func (c *Copier) run(ctx context.Context, taskID string, opts Options, database
|
||||
})
|
||||
}
|
||||
|
||||
setStatus(task.StatusRunning, "Подготовка…", 0)
|
||||
setStatus(task.StatusRunning, "Preparing...", 0)
|
||||
|
||||
destRoot := filepath.Join(opts.MountPath, opts.DestFolder)
|
||||
|
||||
if opts.OverwriteMode == config.OverwriteDelete {
|
||||
setStatus(task.StatusRunning, "Удаление данных с диска…", 0)
|
||||
setStatus(task.StatusRunning, "Replacing destination media...", 0)
|
||||
if err := os.RemoveAll(destRoot); err != nil {
|
||||
fail(err)
|
||||
return
|
||||
@@ -130,7 +149,7 @@ func (c *Copier) run(ctx context.Context, taskID string, opts Options, database
|
||||
|
||||
var copiedPaths map[string]struct{}
|
||||
if opts.FileSelectMode == config.SelectNew {
|
||||
setStatus(task.StatusRunning, "Загрузка истории…", 0)
|
||||
setStatus(task.StatusRunning, "Loading copy history...", 0)
|
||||
var err error
|
||||
copiedPaths, err = database.CopiedPaths(opts.DiskID)
|
||||
if err != nil {
|
||||
@@ -139,14 +158,14 @@ func (c *Copier) run(ctx context.Context, taskID string, opts Options, database
|
||||
}
|
||||
}
|
||||
|
||||
setStatus(task.StatusRunning, "Сканирование источников…", 0)
|
||||
files, err := buildFileList(opts.MediaPath, opts.EnabledSources, copiedPaths)
|
||||
setStatus(task.StatusRunning, "Scanning sources...", 0)
|
||||
files, err := buildFileList(opts.MediaPath, opts.SourceRules, copiedPaths)
|
||||
if err != nil {
|
||||
fail(err)
|
||||
return
|
||||
}
|
||||
if len(files) == 0 {
|
||||
setStatus(task.StatusSuccess, "Нет новых файлов для копирования.", 100)
|
||||
setStatus(task.StatusSuccess, "No files to copy.", 100)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -161,7 +180,7 @@ func (c *Copier) run(ctx context.Context, taskID string, opts Options, database
|
||||
reserveBytes := int64(opts.ReserveFreeGB * 1e9)
|
||||
available := free - reserveBytes
|
||||
if available <= 0 {
|
||||
setStatus(task.StatusSuccess, "Недостаточно свободного места на диске.", 100)
|
||||
setStatus(task.StatusFailed, "Free space is below the reserved threshold.", 100)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -181,7 +200,7 @@ func (c *Copier) run(ctx context.Context, taskID string, opts Options, database
|
||||
case <-ctx.Done():
|
||||
c.tasks.Update(taskID, func(t *task.Task) {
|
||||
t.Status = task.StatusCanceled
|
||||
t.Message = "Отменено"
|
||||
t.Message = "Canceled"
|
||||
t.SpeedBPS = 0
|
||||
t.ETASec = 0
|
||||
})
|
||||
@@ -204,7 +223,7 @@ func (c *Copier) run(ctx context.Context, taskID string, opts Options, database
|
||||
}
|
||||
|
||||
prog := int(float64(doneBytes) / float64(totalBytes) * 100)
|
||||
msg := fmt.Sprintf("Копирование %s (%d/%d)", filepath.Base(f.srcAbs), i+1, total)
|
||||
msg := fmt.Sprintf("Copying %s (%d/%d)", filepath.Base(f.srcAbs), i+1, total)
|
||||
|
||||
c.tasks.Update(taskID, func(t *task.Task) {
|
||||
t.Status = task.StatusRunning
|
||||
@@ -219,7 +238,7 @@ func (c *Copier) run(ctx context.Context, taskID string, opts Options, database
|
||||
if errors.Is(err, context.Canceled) {
|
||||
c.tasks.Update(taskID, func(t *task.Task) {
|
||||
t.Status = task.StatusCanceled
|
||||
t.Message = "Отменено"
|
||||
t.Message = "Canceled"
|
||||
t.SpeedBPS = 0
|
||||
t.ETASec = 0
|
||||
})
|
||||
@@ -238,7 +257,7 @@ func (c *Copier) run(ctx context.Context, taskID string, opts Options, database
|
||||
})
|
||||
}
|
||||
|
||||
setStatus(task.StatusSuccess, fmt.Sprintf("Готово. Скопировано файлов: %d.", copied), 100)
|
||||
setStatus(task.StatusSuccess, fmt.Sprintf("Done. Copied %d files.", copied), 100)
|
||||
}
|
||||
|
||||
type fileEntry struct {
|
||||
@@ -247,15 +266,35 @@ type fileEntry struct {
|
||||
size int64
|
||||
}
|
||||
|
||||
func buildFileList(mediaPath string, sources []string, skip map[string]struct{}) ([]fileEntry, error) {
|
||||
func buildFileList(mediaPath string, rules []config.SourceFolder, skip map[string]struct{}) ([]fileEntry, error) {
|
||||
roots, ruleMap := normalizeSourceRules(rules)
|
||||
|
||||
var result []fileEntry
|
||||
for _, src := range sources {
|
||||
for _, src := range roots {
|
||||
dir := filepath.Join(mediaPath, src)
|
||||
err := filepath.WalkDir(dir, func(path string, d os.DirEntry, err error) error {
|
||||
if err != nil || d.IsDir() {
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
if path == dir {
|
||||
return nil
|
||||
}
|
||||
rel, relErr := filepath.Rel(mediaPath, path)
|
||||
if relErr != nil {
|
||||
return nil
|
||||
}
|
||||
rel = filepath.ToSlash(rel)
|
||||
if !isPathEnabled(rel, ruleMap) && !hasEnabledDescendant(rel, ruleMap) {
|
||||
return filepath.SkipDir
|
||||
}
|
||||
return nil
|
||||
}
|
||||
rel, _ := filepath.Rel(mediaPath, path)
|
||||
rel = filepath.ToSlash(rel)
|
||||
if !isPathEnabled(rel, ruleMap) {
|
||||
return nil
|
||||
}
|
||||
if _, skipped := skip[rel]; skipped {
|
||||
return nil
|
||||
}
|
||||
@@ -273,6 +312,68 @@ func buildFileList(mediaPath string, sources []string, skip map[string]struct{})
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func normalizeSourceRules(rules []config.SourceFolder) ([]string, map[string]bool) {
|
||||
ruleMap := make(map[string]bool, len(rules))
|
||||
for _, rule := range rules {
|
||||
src := filepath.ToSlash(filepath.Clean(strings.TrimSpace(rule.Path)))
|
||||
src = strings.TrimPrefix(src, "./")
|
||||
src = strings.TrimPrefix(src, "/")
|
||||
if src == "" || src == "." {
|
||||
continue
|
||||
}
|
||||
if src == ".." || strings.HasPrefix(src, "../") {
|
||||
continue
|
||||
}
|
||||
ruleMap[src] = rule.Enabled
|
||||
}
|
||||
|
||||
var roots []string
|
||||
for src, enabled := range ruleMap {
|
||||
if !enabled || hasEnabledAncestor(src, ruleMap) {
|
||||
continue
|
||||
}
|
||||
roots = append(roots, src)
|
||||
}
|
||||
sort.Strings(roots)
|
||||
return roots, ruleMap
|
||||
}
|
||||
|
||||
func hasEnabledAncestor(path string, ruleMap map[string]bool) bool {
|
||||
for parent := parentSourcePath(path); parent != ""; parent = parentSourcePath(parent) {
|
||||
if ruleMap[parent] {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func hasEnabledDescendant(path string, ruleMap map[string]bool) bool {
|
||||
prefix := path + "/"
|
||||
for other, enabled := range ruleMap {
|
||||
if enabled && strings.HasPrefix(other, prefix) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func isPathEnabled(path string, ruleMap map[string]bool) bool {
|
||||
for current := path; current != ""; current = parentSourcePath(current) {
|
||||
if enabled, ok := ruleMap[current]; ok {
|
||||
return enabled
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func parentSourcePath(path string) string {
|
||||
idx := strings.LastIndex(path, "/")
|
||||
if idx < 0 {
|
||||
return ""
|
||||
}
|
||||
return path[:idx]
|
||||
}
|
||||
|
||||
// rsyncFile copies src to dst using rsync with resume support.
|
||||
// --partial keeps partial files on interruption.
|
||||
// --append-verify resumes partial transfers and verifies checksums.
|
||||
|
||||
@@ -47,6 +47,10 @@ func (d *DB) migrate() error {
|
||||
);
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_copy_history_disk_path
|
||||
ON copy_history (disk_id, source_path);
|
||||
CREATE TABLE IF NOT EXISTS disk_stats (
|
||||
disk_id TEXT PRIMARY KEY,
|
||||
last_copied_at DATETIME NOT NULL
|
||||
);
|
||||
`)
|
||||
return err
|
||||
}
|
||||
@@ -65,11 +69,26 @@ func (d *DB) RecordCopy(rec CopyRecord) error {
|
||||
if t.IsZero() {
|
||||
t = time.Now().UTC()
|
||||
}
|
||||
_, err := d.sql.Exec(
|
||||
tx, err := d.sql.Begin()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer tx.Rollback()
|
||||
|
||||
if _, err := tx.Exec(
|
||||
`INSERT OR IGNORE INTO copy_history (disk_id, source_path, file_size, copied_at) VALUES (?,?,?,?)`,
|
||||
rec.DiskID, rec.SourcePath, rec.FileSize, t.Format(time.RFC3339),
|
||||
)
|
||||
return err
|
||||
); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := tx.Exec(
|
||||
`INSERT INTO disk_stats (disk_id, last_copied_at) VALUES (?, ?)
|
||||
ON CONFLICT(disk_id) DO UPDATE SET last_copied_at=excluded.last_copied_at`,
|
||||
rec.DiskID, t.Format(time.RFC3339),
|
||||
); err != nil {
|
||||
return err
|
||||
}
|
||||
return tx.Commit()
|
||||
}
|
||||
|
||||
func (d *DB) CopiedPaths(diskID string) (map[string]struct{}, error) {
|
||||
@@ -90,3 +109,23 @@ func (d *DB) CopiedPaths(diskID string) (map[string]struct{}, error) {
|
||||
}
|
||||
return m, rows.Err()
|
||||
}
|
||||
|
||||
func (d *DB) LastCopiedAt(diskID string) (time.Time, bool, error) {
|
||||
var raw string
|
||||
err := d.sql.QueryRow(
|
||||
`SELECT last_copied_at FROM disk_stats WHERE disk_id=?`,
|
||||
diskID,
|
||||
).Scan(&raw)
|
||||
if err == sql.ErrNoRows {
|
||||
return time.Time{}, false, nil
|
||||
}
|
||||
if err != nil {
|
||||
return time.Time{}, false, err
|
||||
}
|
||||
|
||||
t, err := time.Parse(time.RFC3339, raw)
|
||||
if err != nil {
|
||||
return time.Time{}, false, err
|
||||
}
|
||||
return t, true, nil
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ package watcher
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"sync"
|
||||
@@ -121,22 +122,17 @@ func (w *Watcher) probe() {
|
||||
}
|
||||
|
||||
func discoverDisks(root string) map[string]disk.DiskInfo {
|
||||
candidates := []string{root}
|
||||
|
||||
if entries, err := filepath.Glob(filepath.Join(root, "*")); err == nil {
|
||||
for _, path := range entries {
|
||||
candidates = append(candidates, path)
|
||||
}
|
||||
entries, err := os.ReadDir(root)
|
||||
if err != nil {
|
||||
return map[string]disk.DiskInfo{}
|
||||
}
|
||||
|
||||
disks := make(map[string]disk.DiskInfo)
|
||||
seen := make(map[string]struct{}, len(candidates))
|
||||
for _, mountPath := range candidates {
|
||||
if _, ok := seen[mountPath]; ok {
|
||||
for _, entry := range entries {
|
||||
if !entry.IsDir() {
|
||||
continue
|
||||
}
|
||||
seen[mountPath] = struct{}{}
|
||||
|
||||
mountPath := filepath.Join(root, entry.Name())
|
||||
info, _ := disk.Probe(mountPath)
|
||||
if info.State == disk.DiskAbsent {
|
||||
continue
|
||||
|
||||
Reference in New Issue
Block a user