package uploads import ( "context" "crypto/sha256" "encoding/hex" "fmt" "net/http" "os" "path" "path/filepath" "regexp" "strings" "pipu/api/internal/library" "pipu/api/internal/storageagent" ) var ( albumFieldRe = regexp.MustCompile(`\{album[:}]`) artistFieldRe = regexp.MustCompile(`\{(albumartist|artist)[:}]`) ) // Имена картинок, которые сканер библиотеки уже считает обложкой альбома / фотографией исполнителя. var ( coverSidecarNames = []string{"cover", "folder", "front", "album", "albumart"} artistSidecarNames = []string{"artist"} ) // sidecarDirs — каталоги альбома и исполнителя внутри rel по структуре шаблона записи. // "" — шаблон не позволяет определить такой каталог (плоская раскладка, необязательные сегменты). func sidecarDirs(template, rel string) (albumDir, artistDir string) { tpl, got := strings.Split(template, "/"), strings.Split(rel, "/") if len(tpl) != len(got) { return "", "" } album, artist := -1, -1 for i, seg := range tpl[:len(tpl)-1] { if album < 0 && albumFieldRe.MatchString(seg) { album = i } if artist < 0 && artistFieldRe.MatchString(seg) { artist = i } } if album >= 0 { albumDir = strings.Join(got[:album+1], "/") } if artist >= 0 && artist != album { artistDir = strings.Join(got[:artist+1], "/") } return albumDir, artistDir } func imageExt(data []byte) string { switch http.DetectContentType(data) { case "image/jpeg": return ".jpg" case "image/png": return ".png" case "image/webp": return ".webp" } return "" } func hasSidecar(dir string, names []string) bool { entries, err := os.ReadDir(dir) if err != nil { return false } for _, e := range entries { if e.IsDir() { continue } ext := strings.ToLower(filepath.Ext(e.Name())) base := strings.ToLower(strings.TrimSuffix(e.Name(), filepath.Ext(e.Name()))) if ext != ".jpg" && ext != ".jpeg" && ext != ".png" && ext != ".webp" { continue } for _, n := range names { if base == n { return true } } } return false } // placeSidecars кладёт cover.* в папку альбома и artist.* в папку исполнителя рядом с импортированным треком. // Существующие картинки не трогаются; сбой не отменяет импорт трека — картинку добавит следующий файл пачки. func (s *Service) placeSidecars(ctx context.Context, f File, pl placement, rel string) { if pl.src.WriteTemplate == nil { return } albumDir, artistDir := sidecarDirs(*pl.src.WriteTemplate, rel) if albumDir != "" && f.CoverPath != nil && *f.CoverPath != "" { s.placeSidecar(ctx, pl, *f.CoverPath, albumDir, "cover", coverSidecarNames) } if artistDir != "" && f.ArtistImagePath != nil && *f.ArtistImagePath != "" { s.placeSidecar(ctx, pl, *f.ArtistImagePath, artistDir, "artist", artistSidecarNames) } } func (s *Service) placeSidecar(ctx context.Context, pl placement, staging, dir, name string, existing []string) { log := s.log.With("source_id", pl.src.ID, "dir", dir, "image", name) data, err := os.ReadFile(s.stagingAbs(staging)) if err != nil { log.Warn("upload sidecar: read staging", "err", err) return } ext := imageExt(data) if ext == "" || hasSidecar(filepath.Join(pl.root, filepath.FromSlash(dir)), existing) { return } sum := sha256.Sum256(data) req := storageagent.WriteRequest{SourceID: pl.src.ID.String(), RelPath: path.Join(dir, name+ext), StagingPath: staging, SHA256: hex.EncodeToString(sum[:])} if pl.src.Type == library.SourceLocal { lp := "" if pl.src.LocalPath != nil { lp = *pl.src.LocalPath } req.LocalPath = &lp } if _, err := s.agent.WriteFile(ctx, req); err != nil { log.Warn("upload sidecar: write", "err", fmt.Errorf("storage-agent: %w", err)) } }