package uploads import ( "context" "encoding/hex" "encoding/json" "errors" "fmt" "log/slog" "os" "path" "path/filepath" "strings" "time" "github.com/google/uuid" "github.com/jackc/pgx/v5" "pipu/api/internal/auth" "pipu/api/internal/library" "pipu/api/internal/library/meta" "pipu/api/internal/platform/apperr" ) // Config — пути и бинарники очереди. type Config struct { StagingDir string // каталог staging (тот же, что /uploads у storage-agent) FFmpeg string // запись правленых тегов при импорте } // Service — очередь на добавление. type Service struct { store *Store lib *library.Store libSvc *library.Service scanner *library.Scanner agent library.Agent prober library.Prober fpcalc library.FPCalc cfg Config log *slog.Logger wakeProcess chan struct{} wakeImport chan struct{} now func() time.Time } // NewService создаёт сервис очереди. func NewService(store *Store, lib *library.Store, libSvc *library.Service, scanner *library.Scanner, agent library.Agent, prober library.Prober, fpcalc library.FPCalc, cfg Config, log *slog.Logger) *Service { return &Service{ store: store, lib: lib, libSvc: libSvc, 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 !auth.Can(p.Role, 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) (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}) } batch, res, err := s.store.CreateBatch(ctx, p.UserID, comment, accepted, st) if err != nil { return uuid.Nil, nil, err } 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.Transition(ctx, f.ID, []Status{StatusUploading}, StatusProcessing, nil) if err == nil { wake(s.wakeProcess) } return err } // --- Списки --- // ListMine — предложения текущего пользователя. func (s *Service) ListMine(ctx context.Context, statuses []Status, cursor string, limit int) ([]File, string, error) { p, err := principal(ctx, auth.PermSuggestTracks) if err != nil { return nil, "", err } return s.list(ctx, ListFilter{AuthorID: &p.UserID, 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 } return s.list(ctx, fl) } 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.Transition(ctx, id, []Status{StatusUploading, StatusProcessing, StatusPendingReview}, StatusWithdrawn, nil); err != nil { return notFound(err) } s.removeStaging(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) raw, _ := json.Marshal(m) _, err = s.store.pool.Exec(ctx, ` UPDATE upload_files SET metadata = $2, metadata_edited = $3, target_source_id = $4, target_rel_path = $5, status_reason = $6, matched_artist_id = $7, matched_album_id = $8, updated_at = now() WHERE id = $1 AND status IN ('pending_review', 'import_failed')`, id, raw, edited, pl.SourceID, pl.RelPath, nullable(pl.Reason), pl.ArtistID, pl.AlbumID) if err != nil { return File{}, fmt.Errorf("uploads: update: %w", err) } return s.store.File(ctx, id) } 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) } mark := func(tx pgx.Tx) error { _, err := tx.Exec(ctx, `UPDATE upload_files SET reviewed_by = $2, reviewed_at = now(), status_reason = $3 WHERE id = $1`, id, reviewer, nullable(reason)) return err } if !approve { if err := s.store.Transition(ctx, id, []Status{StatusPendingReview, StatusImportFailed}, StatusRejected, mark); err != nil { return notFound(err) } s.removeStaging(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 != "" { return apperr.Conflict(pl.Reason, "cannot place the file into a storage") } reason = "" if err := s.store.Transition(ctx, id, []Status{StatusPendingReview}, StatusApproved, mark); 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.Transition(ctx, id, []Status{StatusImportFailed}, StatusApproved, nil); 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.PermReviewUploads); 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 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.PermReviewUploads) 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 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 } // --- Раскладка в хранилище --- // placement — куда ляжет файл; Reason — почему не получится (код), путь тогда nil. type placement struct { SourceID *uuid.UUID RelPath *string Reason string ArtistID *uuid.UUID AlbumID *uuid.UUID src library.Source root string } // pickSource — хранилище: выбранное админом → по умолчанию → единственное принимающее загрузки. func (s *Service) pickSource(ctx context.Context, chosen *uuid.UUID) (library.Source, string) { id := chosen if id == nil { st, err := s.store.Settings(ctx) if err == nil { id = st.DefaultSourceID } } if id != nil { src, err := s.lib.GetSource(ctx, *id) if err != nil || !src.AcceptsUploads { return library.Source{}, CodeNoTarget } return src, "" } sources, err := s.lib.ListSources(ctx) if err != nil { return library.Source{}, CodeNoTarget } var found []library.Source for _, src := range sources { if src.AcceptsUploads { found = append(found, src) } } if len(found) != 1 { return library.Source{}, CodeNoTarget } return found[0], "" } // plan считает целевой путь по write_template хранилища; существующие папки исполнителя / альбома // переиспользуются в их написании (docs/uploads.md#очистка-имён). func (s *Service) plan(ctx context.Context, f File, m Meta, chosen *uuid.UUID) placement { var ( pl placement discs int // дисков в альбоме: в каталоге, в пачке, у самого файла ) if artist, album, maxDisc, err := s.store.MatchCatalog(ctx, meta.Key(m.AlbumArtist), meta.Key(m.Album)); err == nil { pl.ArtistID, pl.AlbumID, discs = artist, album, maxDisc } src, reason := s.pickSource(ctx, chosen) if reason != "" { pl.Reason = reason return pl } pl.SourceID, pl.src = &src.ID, src if src.WriteTemplate == nil { pl.Reason = CodeTemplate return pl } tpl, err := meta.ParseTemplate(*src.WriteTemplate) if err != nil { pl.Reason = CodeTemplate return pl } if n, err := s.store.BatchMaxDisc(ctx, f.BatchID, m.Album); err == nil { discs = max(discs, n) } if m.Disc != nil { discs = max(discs, *m.Disc) } rel, err := tpl.Render(meta.TemplateInput{Fields: m.Fields(), DiscCount: discs, Ext: extFor(f)}) if err != nil { var mf *meta.MissingFieldError if errors.As(err, &mf) { pl.Reason = CodeMissingField } else { pl.Reason = CodeTemplate } return pl } root, err := s.libSvc.Root(src) if err != nil { pl.Reason = CodeNoTarget return pl } rel = reuseExisting(root, rel) pl.RelPath, pl.root = &rel, root return pl } // 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(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)) // каталог пачки, если опустел } // TusURL — адрес tus для файла. func TusURL(id uuid.UUID) string { return "/api/v1/uploads/files/" + id.String() }