ci / module boundaries / boundaries (push) Successful in 7s
ci / db / db (push) Successful in 2m33s
ci / api / api (push) Successful in 1m10s
ci / mobile / mobile (Metro-бандл Android) (push) Successful in 3m45s
ci / ts / ts (packages + web) (push) Successful in 6m29s
655 lines
20 KiB
Go
655 lines
20 KiB
Go
package uploads
|
||
|
||
import (
|
||
"context"
|
||
"encoding/hex"
|
||
"errors"
|
||
"log/slog"
|
||
"os"
|
||
"path"
|
||
"path/filepath"
|
||
"strings"
|
||
"time"
|
||
|
||
"github.com/google/uuid"
|
||
|
||
"pipu/api/internal/acoustid"
|
||
"pipu/api/internal/auth"
|
||
"pipu/api/internal/library"
|
||
"pipu/api/internal/library/meta"
|
||
"pipu/api/internal/musicbrainz"
|
||
"pipu/api/internal/platform/apperr"
|
||
)
|
||
|
||
// Config — пути и бинарники очереди.
|
||
type Config struct {
|
||
StagingDir string // каталог staging (тот же, что /uploads у storage-agent)
|
||
FFmpeg string // запись правленых тегов при импорте
|
||
}
|
||
|
||
// Service — очередь на добавление.
|
||
type Service struct {
|
||
store *Store
|
||
lib SourceCatalog
|
||
*PlacementPlanner
|
||
scanner ImportedScanner
|
||
agent library.Agent
|
||
prober library.Prober
|
||
fpcalc library.FPCalc
|
||
cfg Config
|
||
log *slog.Logger
|
||
|
||
wakeProcess chan struct{}
|
||
wakeImport chan struct{}
|
||
now func() time.Time
|
||
catalogue ReleaseCatalogue
|
||
identifier AudioIdentifier
|
||
encodedFingerprint func(context.Context, string) (string, error)
|
||
artistImages ArtistImages
|
||
}
|
||
|
||
type ArtistImages interface {
|
||
ApplyArtistImage(context.Context, uuid.UUID, []byte) error
|
||
}
|
||
|
||
func (s *Service) SetArtistImages(images ArtistImages) { s.artistImages = images }
|
||
|
||
type ReleaseCatalogue interface {
|
||
Search(context.Context, musicbrainz.Query) ([]musicbrainz.Release, error)
|
||
Cover(context.Context, musicbrainz.Release) (musicbrainz.Cover, error)
|
||
}
|
||
|
||
func (s *Service) SetReleaseCatalogue(c ReleaseCatalogue) { s.catalogue = c }
|
||
|
||
type AudioIdentifier interface {
|
||
Lookup(context.Context, string, int) ([]acoustid.Match, error)
|
||
}
|
||
|
||
func (s *Service) SetAudioIdentifier(c AudioIdentifier) {
|
||
s.identifier = c
|
||
s.encodedFingerprint = s.fpcalc.EncodedFingerprint
|
||
}
|
||
|
||
// NewService создаёт сервис очереди.
|
||
func NewService(store *Store, lib SourceCatalog, roots RootResolver, scanner ImportedScanner, agent library.Agent,
|
||
prober library.Prober, fpcalc library.FPCalc, cfg Config, log *slog.Logger) *Service {
|
||
return &Service{
|
||
store: store, lib: lib, PlacementPlanner: &PlacementPlanner{store: store, lib: lib, roots: roots}, scanner: scanner, agent: agent, prober: prober, fpcalc: fpcalc, cfg: cfg,
|
||
log: log, wakeProcess: make(chan struct{}, 1), wakeImport: make(chan struct{}, 1),
|
||
now: func() time.Time { return time.Now().UTC() },
|
||
}
|
||
}
|
||
|
||
func wake(ch chan struct{}) {
|
||
select {
|
||
case ch <- struct{}{}:
|
||
default:
|
||
}
|
||
}
|
||
|
||
func principal(ctx context.Context, perm auth.Permission) (auth.Principal, error) {
|
||
p, ok := auth.PrincipalFrom(ctx)
|
||
if !ok {
|
||
return auth.Principal{}, apperr.Unauthorized(auth.CodeUnauthorized, "not authenticated")
|
||
}
|
||
if !p.Allowed(perm) {
|
||
return auth.Principal{}, apperr.Forbidden(auth.CodeForbidden, "not allowed")
|
||
}
|
||
return p, nil
|
||
}
|
||
|
||
func notFound(err error) error {
|
||
if errors.Is(err, ErrNotFound) {
|
||
return apperr.NotFound(CodeNotFound, "upload not found")
|
||
}
|
||
if errors.Is(err, ErrWrongStatus) {
|
||
return apperr.Conflict(CodeWrongStatus, "upload is in another status")
|
||
}
|
||
return err
|
||
}
|
||
|
||
// --- Квота и предпроверка ---
|
||
|
||
// Quota — лимиты и занятое место (видят модераторы и админ).
|
||
type Quota struct {
|
||
Settings
|
||
UsedBytes int64
|
||
UserUsedBytes int64
|
||
}
|
||
|
||
// Quota — состояние очереди для текущего пользователя.
|
||
func (s *Service) Quota(ctx context.Context) (Quota, error) {
|
||
p, err := principal(ctx, auth.PermSuggestTracks)
|
||
if err != nil {
|
||
return Quota{}, err
|
||
}
|
||
st, err := s.store.Settings(ctx)
|
||
if err != nil {
|
||
return Quota{}, err
|
||
}
|
||
used, userUsed, err := s.store.Usage(ctx, p.UserID)
|
||
if err != nil {
|
||
return Quota{}, err
|
||
}
|
||
return Quota{Settings: st, UsedBytes: used, UserUsedBytes: userUsed}, nil
|
||
}
|
||
|
||
// PrecheckFile — файл перед загрузкой: хэш и теги считает клиент.
|
||
type PrecheckFile struct {
|
||
ClientID string
|
||
Size int64
|
||
SHA256 string
|
||
Artist string
|
||
Title string
|
||
DurationMs int
|
||
}
|
||
|
||
// PrecheckResult — итог по файлу: accepted / duplicate / too_large; possible — совпали только метаданные.
|
||
type PrecheckResult struct {
|
||
ClientID string
|
||
Result string
|
||
Track *uuid.UUID
|
||
Possible bool
|
||
}
|
||
|
||
// Precheck отсеивает дубли до загрузки (ничего не резервирует, docs/uploads.md#4).
|
||
func (s *Service) Precheck(ctx context.Context, files []PrecheckFile) ([]PrecheckResult, error) {
|
||
if _, err := principal(ctx, auth.PermSuggestTracks); err != nil {
|
||
return nil, err
|
||
}
|
||
st, err := s.store.Settings(ctx)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
out := make([]PrecheckResult, 0, len(files))
|
||
seen := map[string]bool{}
|
||
for _, f := range files {
|
||
r := PrecheckResult{ClientID: f.ClientID, Result: "accepted"}
|
||
sum, _ := hex.DecodeString(f.SHA256)
|
||
switch {
|
||
case f.Size > st.MaxFileBytes:
|
||
r.Result = "too_large"
|
||
case len(sum) == 32 && seen[string(sum)]:
|
||
r.Result = "duplicate"
|
||
default:
|
||
track, dup, err := s.store.DuplicateBySHA(ctx, sum, uuid.Nil)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
if dup {
|
||
r.Result, r.Track = "duplicate", track
|
||
} else if f.Artist != "" && f.Title != "" && f.DurationMs > 0 {
|
||
track, err := s.store.MetadataMatch(ctx, meta.Key(f.Artist), meta.Key(f.Title), f.DurationMs, 2000)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
r.Possible, r.Track = track != nil, track
|
||
}
|
||
}
|
||
if len(sum) == 32 {
|
||
seen[string(sum)] = true
|
||
}
|
||
out = append(out, r)
|
||
}
|
||
return out, nil
|
||
}
|
||
|
||
// --- Создание пачки ---
|
||
|
||
// CreateFile — файл новой пачки.
|
||
type CreateFile struct {
|
||
ClientID string
|
||
Name string
|
||
RelPath *string
|
||
Size int64
|
||
SHA256 string
|
||
}
|
||
|
||
// Create создаёт пачку и резервирует место; не поместившиеся и дубли отклоняются с кодом.
|
||
func (s *Service) Create(ctx context.Context, comment *string, files []CreateFile, cover []byte, artistImages ...[]byte) (uuid.UUID, []Reserved, error) {
|
||
p, err := principal(ctx, auth.PermSuggestTracks)
|
||
if err != nil {
|
||
return uuid.Nil, nil, err
|
||
}
|
||
if len(files) == 0 || len(files) > 500 {
|
||
return uuid.Nil, nil, apperr.Validation("files", "validation.files_count", "1..500 files", map[string]any{"max": 500})
|
||
}
|
||
st, err := s.store.Settings(ctx)
|
||
if err != nil {
|
||
return uuid.Nil, nil, err
|
||
}
|
||
var (
|
||
accepted []NewFile
|
||
rejected []Reserved
|
||
)
|
||
for _, f := range files {
|
||
sum, err := hex.DecodeString(f.SHA256)
|
||
name := path.Base(strings.ReplaceAll(f.Name, "\\", "/"))
|
||
ext := strings.TrimPrefix(strings.ToLower(path.Ext(name)), ".")
|
||
switch {
|
||
case err != nil || len(sum) != 32:
|
||
return uuid.Nil, nil, apperr.Validation("sha256", "validation.sha256_invalid", "sha256 must be 64 hex characters", nil)
|
||
case f.Size <= 0:
|
||
return uuid.Nil, nil, apperr.Validation("size", "validation.size_invalid", "size must be positive", nil)
|
||
case !AllowedExtensions[ext]:
|
||
rejected = append(rejected, Reserved{ClientID: f.ClientID, Rejected: CodeBadExtension})
|
||
continue
|
||
}
|
||
accepted = append(accepted, NewFile{ClientID: f.ClientID, Name: name, RelPath: cleanClientPath(f.RelPath), Size: f.Size, SHA256: sum})
|
||
}
|
||
coverPath, err := s.createCover(cover)
|
||
if err != nil {
|
||
return uuid.Nil, nil, err
|
||
}
|
||
keepCover := false
|
||
var artistImagePath *string
|
||
defer func() {
|
||
if coverPath != nil && !keepCover {
|
||
_ = os.Remove(s.stagingAbs(*coverPath))
|
||
}
|
||
if artistImagePath != nil && !keepCover {
|
||
_ = os.Remove(s.stagingAbs(*artistImagePath))
|
||
}
|
||
}()
|
||
if len(artistImages) > 0 {
|
||
artistImagePath, err = s.createCover(artistImages[0])
|
||
if err != nil {
|
||
return uuid.Nil, nil, err
|
||
}
|
||
}
|
||
batch, res, err := s.store.CreateBatch(ctx, p.UserID, comment, accepted, st, coverPath, artistImagePath)
|
||
if err != nil {
|
||
return uuid.Nil, nil, err
|
||
}
|
||
for _, r := range res {
|
||
if r.Rejected == "" {
|
||
keepCover = true
|
||
}
|
||
}
|
||
return batch, append(res, rejected...), nil
|
||
}
|
||
|
||
// cleanClientPath — относительный путь из перетащенной папки (только для подсказки метаданных).
|
||
func cleanClientPath(p *string) *string {
|
||
if p == nil {
|
||
return nil
|
||
}
|
||
v := strings.Trim(strings.ReplaceAll(*p, "\\", "/"), "/")
|
||
if v == "" || len(v) > 1000 {
|
||
return nil
|
||
}
|
||
return &v
|
||
}
|
||
|
||
// Complete — tus принял файл целиком: в обработку.
|
||
func (s *Service) Complete(ctx context.Context, f File) error {
|
||
err := s.store.ChangeStatus(ctx, f.ID, []Status{StatusUploading}, StatusProcessing)
|
||
if err == nil {
|
||
wake(s.wakeProcess)
|
||
}
|
||
return err
|
||
}
|
||
|
||
// --- Списки ---
|
||
|
||
// ListMine — предложения текущего пользователя.
|
||
func (s *Service) ListMine(ctx context.Context, statuses []Status, cursor string, limit int, batch *uuid.UUID) ([]File, string, error) {
|
||
p, err := principal(ctx, auth.PermSuggestTracks)
|
||
if err != nil {
|
||
return nil, "", err
|
||
}
|
||
return s.list(ctx, ListFilter{AuthorID: &p.UserID, BatchID: batch, Statuses: statuses, Cursor: cursor, Limit: limit})
|
||
}
|
||
|
||
// ListAll — вся очередь (админ).
|
||
func (s *Service) ListAll(ctx context.Context, fl ListFilter) ([]File, string, error) {
|
||
if _, err := principal(ctx, auth.PermReviewUploads); err != nil {
|
||
return nil, "", err
|
||
}
|
||
files, next, err := s.list(ctx, fl)
|
||
if err != nil {
|
||
return nil, "", err
|
||
}
|
||
for i := range files {
|
||
s.previewPlacement(ctx, &files[i])
|
||
}
|
||
return files, next, nil
|
||
}
|
||
|
||
func (s *Service) list(ctx context.Context, fl ListFilter) ([]File, string, error) {
|
||
files, next, err := s.store.List(ctx, fl)
|
||
if err != nil && strings.Contains(err.Error(), "bad cursor") {
|
||
return nil, "", apperr.Validation("cursor", "validation.cursor_invalid", "bad cursor", nil)
|
||
}
|
||
return files, next, err
|
||
}
|
||
|
||
// --- Действия автора ---
|
||
|
||
// Withdraw — автор отзывает файл до решения.
|
||
func (s *Service) Withdraw(ctx context.Context, id uuid.UUID) error {
|
||
p, err := principal(ctx, auth.PermSuggestTracks)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
f, err := s.store.File(ctx, id)
|
||
if err != nil || f.AuthorID != p.UserID {
|
||
return apperr.NotFound(CodeNotFound, "upload not found")
|
||
}
|
||
if err := s.store.ChangeStatus(ctx, id, []Status{StatusUploading, StatusProcessing, StatusPendingReview}, StatusWithdrawn); err != nil {
|
||
return notFound(err)
|
||
}
|
||
s.removeStaging(ctx, f)
|
||
return nil
|
||
}
|
||
|
||
// --- Админ ---
|
||
|
||
// UpdateInput — правка файла админом. nil — не менять.
|
||
type UpdateInput struct {
|
||
Meta *Meta
|
||
TargetSourceID *uuid.UUID
|
||
}
|
||
|
||
// Update правит метаданные и хранилище; путь в хранилище пересчитывается.
|
||
func (s *Service) Update(ctx context.Context, id uuid.UUID, in UpdateInput) (File, error) {
|
||
if _, err := principal(ctx, auth.PermReviewUploads); err != nil {
|
||
return File{}, err
|
||
}
|
||
f, err := s.store.File(ctx, id)
|
||
if err != nil {
|
||
return File{}, notFound(err)
|
||
}
|
||
if f.Status != StatusPendingReview && f.Status != StatusImportFailed {
|
||
return File{}, apperr.Conflict(CodeWrongStatus, "only files waiting for review can be edited")
|
||
}
|
||
m := Meta{}
|
||
if f.Meta != nil {
|
||
m = *f.Meta
|
||
}
|
||
edited := f.MetadataEdited
|
||
if in.Meta != nil {
|
||
next := normalizeMeta(*in.Meta)
|
||
if next.Title == "" || next.Artist == "" {
|
||
return File{}, apperr.Validation("metadata", CodeMissingField, "artist and title are required", nil)
|
||
}
|
||
edited = edited || next != m
|
||
m = next
|
||
}
|
||
target := f.TargetSourceID
|
||
if in.TargetSourceID != nil {
|
||
target = in.TargetSourceID
|
||
}
|
||
pl := s.plan(ctx, f, m, target)
|
||
if err := s.store.UpdateMetadata(ctx, id, m, edited, pl); err != nil {
|
||
return File{}, notFound(err)
|
||
}
|
||
updated, err := s.store.File(ctx, id)
|
||
if err == nil {
|
||
s.previewPlacement(ctx, &updated)
|
||
}
|
||
return updated, err
|
||
}
|
||
|
||
func normalizeMeta(m Meta) Meta {
|
||
m.Artist, m.AlbumArtist, m.Album = meta.Clean(m.Artist), meta.Clean(m.AlbumArtist), meta.Clean(m.Album)
|
||
m.Title, m.Genre = meta.Clean(m.Title), meta.Clean(m.Genre)
|
||
if m.AlbumArtist == "" {
|
||
m.AlbumArtist = m.Artist
|
||
}
|
||
for _, p := range []**int{&m.Year, &m.Track, &m.Disc} {
|
||
if *p != nil && **p <= 0 {
|
||
*p = nil
|
||
}
|
||
}
|
||
return m
|
||
}
|
||
|
||
func nullable(s string) *string {
|
||
if s == "" {
|
||
return nil
|
||
}
|
||
return &s
|
||
}
|
||
|
||
// ReviewResult — итог решения по одному файлу.
|
||
type ReviewResult struct {
|
||
ID uuid.UUID
|
||
Error error
|
||
}
|
||
|
||
// Review — массовое решение: approve / reject (с причиной, её видит автор).
|
||
func (s *Service) Review(ctx context.Context, ids []uuid.UUID, approve bool, reason string) ([]ReviewResult, error) {
|
||
p, err := principal(ctx, auth.PermReviewUploads)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
reason = strings.TrimSpace(reason)
|
||
out := make([]ReviewResult, 0, len(ids))
|
||
for _, id := range ids {
|
||
out = append(out, ReviewResult{ID: id, Error: s.reviewOne(ctx, p.UserID, id, approve, reason)})
|
||
}
|
||
if approve {
|
||
wake(s.wakeImport)
|
||
}
|
||
return out, nil
|
||
}
|
||
|
||
func (s *Service) reviewOne(ctx context.Context, reviewer, id uuid.UUID, approve bool, reason string) error {
|
||
f, err := s.store.File(ctx, id)
|
||
if err != nil {
|
||
return notFound(err)
|
||
}
|
||
if !approve {
|
||
if err := s.store.Decide(ctx, id, reviewer, false, reason); err != nil {
|
||
return notFound(err)
|
||
}
|
||
s.removeStaging(ctx, f)
|
||
s.store.Audit(ctx, &reviewer, "upload.reject", id, map[string]any{"reason": reason})
|
||
return nil
|
||
}
|
||
if f.Meta == nil {
|
||
return apperr.Conflict(CodeMissingField, "file has no metadata")
|
||
}
|
||
pl := s.plan(ctx, f, *f.Meta, f.TargetSourceID)
|
||
if pl.Reason != "" && pl.RelPath == nil {
|
||
return apperr.Conflict(pl.Reason, "cannot place the file into a storage")
|
||
}
|
||
reason = ""
|
||
if err := s.store.Decide(ctx, id, reviewer, true, reason); err != nil {
|
||
return notFound(err)
|
||
}
|
||
s.store.Audit(ctx, &reviewer, "upload.approve", id, map[string]any{"target": pl.RelPath})
|
||
return nil
|
||
}
|
||
|
||
// Retry — повторить импорт после import_failed.
|
||
func (s *Service) Retry(ctx context.Context, id uuid.UUID) (File, error) {
|
||
if _, err := principal(ctx, auth.PermReviewUploads); err != nil {
|
||
return File{}, err
|
||
}
|
||
if err := s.store.ChangeStatus(ctx, id, []Status{StatusImportFailed}, StatusApproved); err != nil {
|
||
return File{}, notFound(err)
|
||
}
|
||
wake(s.wakeImport)
|
||
return s.store.File(ctx, id)
|
||
}
|
||
|
||
// GetSettings — лимиты очереди (админ, не аудитор загрузок).
|
||
func (s *Service) GetSettings(ctx context.Context) (Settings, error) {
|
||
if _, err := principal(ctx, auth.PermManageLibrary); err != nil {
|
||
return Settings{}, err
|
||
}
|
||
return s.store.Settings(ctx)
|
||
}
|
||
|
||
// SettingsInput — правка настроек; nil — не менять. ClearPerUser / ClearDefault — сбросить.
|
||
type SettingsInput struct {
|
||
QueueLimitBytes *int64
|
||
PerUserLimitBytes *int64
|
||
MaxFileBytes *int64
|
||
DefaultSourceID *uuid.UUID
|
||
AllowQualityUpgrade *bool
|
||
ClearPerUser bool
|
||
ClearDefault bool
|
||
}
|
||
|
||
// UpdateSettings меняет лимиты; уменьшение ниже занятого ничего не удаляет (docs/uploads.md#3).
|
||
func (s *Service) UpdateSettings(ctx context.Context, in SettingsInput) (Settings, error) {
|
||
p, err := principal(ctx, auth.PermManageLibrary)
|
||
if err != nil {
|
||
return Settings{}, err
|
||
}
|
||
st, err := s.store.Settings(ctx)
|
||
if err != nil {
|
||
return Settings{}, err
|
||
}
|
||
if in.QueueLimitBytes != nil {
|
||
if *in.QueueLimitBytes < 0 {
|
||
return Settings{}, apperr.Validation("queue_limit_bytes", "validation.limit_invalid", "must be ≥ 0", nil)
|
||
}
|
||
st.QueueLimitBytes = *in.QueueLimitBytes
|
||
}
|
||
if in.MaxFileBytes != nil {
|
||
if *in.MaxFileBytes <= 0 {
|
||
return Settings{}, apperr.Validation("max_file_bytes", "validation.limit_invalid", "must be > 0", nil)
|
||
}
|
||
st.MaxFileBytes = *in.MaxFileBytes
|
||
}
|
||
switch {
|
||
case in.ClearPerUser:
|
||
st.PerUserLimitBytes = nil
|
||
case in.PerUserLimitBytes != nil:
|
||
if *in.PerUserLimitBytes <= 0 {
|
||
return Settings{}, apperr.Validation("per_user_limit_bytes", "validation.limit_invalid", "must be > 0", nil)
|
||
}
|
||
st.PerUserLimitBytes = in.PerUserLimitBytes
|
||
}
|
||
switch {
|
||
case in.ClearDefault:
|
||
st.DefaultSourceID = nil
|
||
case in.DefaultSourceID != nil:
|
||
src, err := s.lib.GetSource(ctx, *in.DefaultSourceID)
|
||
if err != nil || !src.AcceptsUploads {
|
||
return Settings{}, apperr.Validation("default_source_id", CodeNoTarget, "storage must accept uploads", nil)
|
||
}
|
||
st.DefaultSourceID = in.DefaultSourceID
|
||
}
|
||
if in.AllowQualityUpgrade != nil {
|
||
st.AllowQualityUpgrade = *in.AllowQualityUpgrade
|
||
}
|
||
if err := s.store.SaveSettings(ctx, st); err != nil {
|
||
return Settings{}, err
|
||
}
|
||
s.store.Audit(ctx, &p.UserID, "uploads.settings", uuid.Nil, map[string]any{"settings": st})
|
||
return st, nil
|
||
}
|
||
|
||
// extFor — расширение по реальному формату (кодек из ffprobe), иначе — по имени файла.
|
||
func extFor(f File) string {
|
||
orig := strings.TrimPrefix(strings.ToLower(path.Ext(f.OriginalName)), ".")
|
||
if f.Codec == nil {
|
||
return orig
|
||
}
|
||
switch c := *f.Codec; {
|
||
case c == "flac":
|
||
return "flac"
|
||
case c == "mp3":
|
||
return "mp3"
|
||
case c == "aac" || c == "alac":
|
||
return "m4a"
|
||
case c == "vorbis":
|
||
return "ogg"
|
||
case c == "opus":
|
||
return "opus"
|
||
case c == "wavpack":
|
||
return "wv"
|
||
case strings.HasPrefix(c, "pcm_"):
|
||
if orig == "aiff" || orig == "aif" {
|
||
return orig
|
||
}
|
||
return "wav"
|
||
}
|
||
return orig
|
||
}
|
||
|
||
// reuseExisting заменяет сегменты каталогов на уже существующие с тем же ключом имени («КИНО» → «Кино»).
|
||
// Сравнение по списку каталога, а не os.Stat: на ФС без учёта регистра Stat нашёл бы папку, не дав её написания.
|
||
func reuseExisting(root, rel string) string {
|
||
segs := strings.Split(rel, "/")
|
||
cur := root
|
||
for i := 0; i < len(segs)-1; i++ {
|
||
entries, err := os.ReadDir(cur)
|
||
if err != nil {
|
||
break
|
||
}
|
||
key, match := meta.Key(segs[i]), ""
|
||
for _, e := range entries {
|
||
if !e.IsDir() {
|
||
continue
|
||
}
|
||
if e.Name() == segs[i] {
|
||
match = e.Name()
|
||
break
|
||
}
|
||
if match == "" && meta.Key(e.Name()) == key {
|
||
match = e.Name()
|
||
}
|
||
}
|
||
if match == "" {
|
||
break // дальше каталогов ещё нет
|
||
}
|
||
segs[i] = match
|
||
cur = filepath.Join(cur, match)
|
||
}
|
||
return strings.Join(segs, "/")
|
||
}
|
||
|
||
// removeStaging удаляет файл staging (и временные копии с тегами).
|
||
func (s *Service) removeStaging(ctx context.Context, f File) {
|
||
if f.StagingPath == "" {
|
||
return
|
||
}
|
||
p := filepath.Join(s.cfg.StagingDir, filepath.FromSlash(f.StagingPath))
|
||
matches, _ := filepath.Glob(p + "*")
|
||
for _, m := range matches {
|
||
_ = os.Remove(m)
|
||
}
|
||
_ = os.Remove(filepath.Dir(p)) // каталог пачки, если опустел
|
||
// A mixed batch can retain an unassigned original cover even when this file has none.
|
||
if s.store != nil {
|
||
if covers, err := s.store.UnusedReleaseCovers(ctx, f.BatchID); err == nil {
|
||
for _, cover := range covers {
|
||
_ = os.Remove(s.stagingAbs(cover))
|
||
}
|
||
}
|
||
if cover, err := s.store.UnusedBatchCover(ctx, f.BatchID); err == nil && cover != nil {
|
||
_ = os.Remove(s.stagingAbs(*cover))
|
||
}
|
||
if image, err := s.store.UnusedArtistImage(ctx, f.BatchID); err == nil && image != nil {
|
||
_ = os.Remove(s.stagingAbs(*image))
|
||
}
|
||
}
|
||
}
|
||
|
||
// TusURL — адрес tus для файла.
|
||
func TusURL(id uuid.UUID) string { return "/api/v1/uploads/files/" + id.String() }
|
||
|
||
// Staged — файл очереди и абсолютный путь в staging.
|
||
type Staged struct {
|
||
File
|
||
Abs string
|
||
}
|
||
|
||
// OpenStaging возвращает файл, который ещё лежит в staging.
|
||
func (s *Service) OpenStaging(ctx context.Context, id uuid.UUID) (Staged, error) {
|
||
f, err := s.store.File(ctx, id)
|
||
if err != nil {
|
||
return Staged{}, notFound(err)
|
||
}
|
||
if f.StagingPath == "" || !f.Status.KeepsStaging() {
|
||
return Staged{}, apperr.NotFound(CodeNotFound, "file is not in staging")
|
||
}
|
||
return Staged{File: f, Abs: s.stagingAbs(f.StagingPath)}, nil
|
||
}
|