feat: finish the upload, playback, and test gaps from the docs
ci / api / api (push) Successful in 40s
ci / db / db (push) Successful in 1m3s
ci / contract / contract (сгенерированный код совпадает с openapi.yaml) (push) Failing after 1m48s
ci / docker / docker (образы собираются — их сервер собирает сам при обновлении) (push) Successful in 46s
ci / ts / ts (packages + web) (push) Failing after 4m57s
ci / mobile / mobile (Metro-бандл Android) (push) Successful in 5m7s

Quality upgrades, batch covers, staging preview, and resume after reload are in the queue. Loudness and crossfade work on web and Android, and the documented checks now have suites.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
mrleo1nidandCursor committed 2026-10-21 09:37:53 +03:00
1 parent 2b76060ea3
commit 066ba24834
61 files changed
+2818 -463

No files matched your search

+5 -3
View File
@@ -164,6 +164,9 @@ func newApp(ctx context.Context, cfg config.API, pool *pgxpool.Pool, rdb *redis.
uploads.Config{StagingDir: cfg.UploadsDir, FFmpeg: cfg.FFmpegPath}, log)
upSvc.Run(ctx)
wh := wave.NewHandler(pool, &wave.Recommender{URL: cfg.RecommenderURL, Token: cfg.InternalToken,
Client: &http.Client{}}, libStore, func() library.DTO { return dto }, log)
wh.Warm = pb.Prewarm
return server.NewRouter(server.Deps{
Log: log,
System: sys,
@@ -172,11 +175,10 @@ func newApp(ctx context.Context, cfg config.API, pool *pgxpool.Pool, rdb *redis.
Library: library.NewHandler(libSvc, libStore, dto, covers, cfg.XAccel),
Search: search.NewHandler(meili, libStore, indexer, search.NewAffinity(pool), dto, log),
Playback: pb,
Wave: wave.NewHandler(pool, &wave.Recommender{URL: cfg.RecommenderURL, Token: cfg.InternalToken,
Client: &http.Client{}}, libStore, func() library.DTO { return dto }, log),
Wave: wh,
App: appupdate.NewHandler(appupdate.Config{GiteaURL: cfg.GiteaURL, Repo: cfg.GiteaRepo, Token: cfg.GiteaToken,
Dir: cfg.UpdatesDir, MinSupported: cfg.MinAndroidVersionCode}, log),
Uploads: uploads.NewHandler(upSvc),
Uploads: uploads.NewHandler(upSvc, signer, cfg.XAccel),
TUS: uploads.NewTUS(uploads.NewStore(pool), cfg.UploadsDir, upSvc.Complete, log),
}), nil
}
File diff suppressed because it is too large. Load diff
+32
View File
@@ -196,6 +196,38 @@ func (s streamResponse) VisitStreamTrackResponse(w http.ResponseWriter) error {
return nil
}
// Prewarm заранее транскодирует пачку волны (docs/playback.md, задача pretranscode).
func (h *Handler) Prewarm(ctx context.Context, ids []uuid.UUID) {
for _, id := range ids {
if ctx.Err() != nil {
return
}
f, err := h.store.StreamFile(ctx, id)
if err != nil || f.SourceStatus != "mounted" {
continue
}
codec, bitrate := "", 0
if f.Codec != nil {
codec = *f.Codec
}
if f.Bitrate != nil {
bitrate = *f.Bitrate
}
target := Target(QualityHigh, f.Format, codec, bitrate)
if target == "" {
continue
}
root, err := h.rootFor(ctx, f.SourceID)
if err != nil {
continue
}
name := CacheName(f.FileID, f.Size, target)
if _, err := h.transcoder.Ensure(ctx, filepath.Join(root, filepath.FromSlash(f.RelPath)), name, target); err != nil {
h.log.WarnContext(ctx, "pretranscode failed", "track_id", id, "err", err)
}
}
}
// --- треки пачкой ---
// GetTracksBatch реализует gen.StrictServerInterface.
+141
View File
@@ -0,0 +1,141 @@
package uploads
import (
"context"
"encoding/base64"
"os"
"path"
"path/filepath"
"strings"
"github.com/google/uuid"
"pipu/api/internal/auth"
"pipu/api/internal/platform/apperr"
)
// BatchPatch — правка на всю пачку. Nil-поля не меняют файлы.
type BatchPatch struct {
Artist *string
Album *string
Year *int
SetYear bool
Cover []byte
ClearCover bool
}
const maxCoverBytes = 8 << 20
// UpdateBatch применяет исполнителя, альбом, год и обложку к файлам пачки на рассмотрении.
func (s *Service) UpdateBatch(ctx context.Context, id uuid.UUID, in BatchPatch) ([]File, error) {
if _, err := principal(ctx, auth.PermReviewUploads); err != nil {
return nil, err
}
files, err := s.store.PendingInBatch(ctx, id)
if err != nil {
return nil, err
}
for _, f := range files {
m := Meta{}
if f.Meta != nil {
m = *f.Meta
}
if in.Artist != nil {
m.Artist = *in.Artist
m.AlbumArtist = *in.Artist
}
if in.Album != nil {
m.Album = *in.Album
}
if in.SetYear {
m.Year = in.Year
}
if m.Title == "" {
m.Title = strings.TrimSuffix(f.OriginalName, path.Ext(f.OriginalName))
}
if m.Artist == "" || m.Title == "" {
continue
}
if _, err := s.Update(ctx, f.ID, UpdateInput{Meta: &m}); err != nil {
return nil, err
}
}
if in.ClearCover || in.Cover != nil {
if err := s.writeCover(ctx, id, files, in); err != nil {
return nil, err
}
}
return s.store.PendingInBatch(ctx, id)
}
func (s *Service) writeCover(ctx context.Context, id uuid.UUID, files []File, in BatchPatch) error {
prev := ""
if len(files) > 0 && files[0].CoverPath != nil {
prev = *files[0].CoverPath
}
if in.ClearCover || in.Cover == nil {
if prev != "" {
_ = os.Remove(s.stagingAbs(prev))
}
return s.store.SetCover(ctx, id, nil)
}
if len(in.Cover) > maxCoverBytes {
return apperr.Validation("cover_base64", "validation.cover_too_large", "cover is larger than 8 MB", nil)
}
ext, ok := coverExt(in.Cover)
if !ok {
return apperr.Validation("cover_base64", "validation.cover_type", "cover must be jpeg, png or webp", nil)
}
rel := path.Join("covers", id.String()+ext)
abs := s.stagingAbs(rel)
if err := os.MkdirAll(filepath.Dir(abs), 0o755); err != nil {
return err
}
if err := os.WriteFile(abs, in.Cover, 0o644); err != nil {
return err
}
if prev != "" && prev != rel {
_ = os.Remove(s.stagingAbs(prev))
}
return s.store.SetCover(ctx, id, &rel)
}
func coverExt(b []byte) (string, bool) {
switch {
case len(b) >= 3 && b[0] == 0xff && b[1] == 0xd8 && b[2] == 0xff:
return ".jpg", true
case len(b) >= 8 && string(b[:8]) == "\x89PNG\r\n\x1a\n":
return ".png", true
case len(b) >= 12 && string(b[:4]) == "RIFF" && string(b[8:12]) == "WEBP":
return ".webp", true
default:
return "", false
}
}
// DecodeCover декодирует обложку из base64.
func DecodeCover(s string) ([]byte, error) {
b, err := base64.StdEncoding.DecodeString(s)
if err != nil {
return nil, apperr.Validation("cover_base64", "validation.cover_type", "cover is not base64", nil)
}
return b, nil
}
// Unseen — сколько решений автор ещё не открывал.
func (s *Service) Unseen(ctx context.Context) (int, error) {
p, err := principal(ctx, auth.PermSuggestTracks)
if err != nil {
return 0, err
}
return s.store.UnseenDecisions(ctx, p.UserID)
}
// MarkSeen отмечает предложения просмотренными.
func (s *Service) MarkSeen(ctx context.Context) error {
p, err := principal(ctx, auth.PermSuggestTracks)
if err != nil {
return err
}
return s.store.MarkSeen(ctx, p.UserID, s.now())
}
+73 -12
View File
@@ -10,17 +10,22 @@ import (
"pipu/api/internal/api/gen"
"pipu/api/internal/auth"
"pipu/api/internal/platform/apperr"
"pipu/api/internal/platform/sign"
)
// Handler — эндпоинты очереди на добавление (часть gen.StrictServerInterface).
type Handler struct {
svc *Service
svc *Service
signer *sign.Signer
xAccel bool
}
// NewHandler создаёт обработчик.
func NewHandler(svc *Service) *Handler { return &Handler{svc: svc} }
// NewHandler создаёт обработчик. signer подписывает ссылки прослушивания из staging.
func NewHandler(svc *Service, signer *sign.Signer, xAccel bool) *Handler {
return &Handler{svc: svc, signer: signer, xAccel: xAccel}
}
func fileDTO(ctx context.Context, f File) gen.UploadFile {
func (h *Handler) fileDTO(ctx context.Context, f File) gen.UploadFile {
out := gen.UploadFile{
Id: f.ID, BatchId: f.BatchID, BatchComment: f.BatchComment,
Author: gen.UserRef{Id: f.AuthorID, Username: f.AuthorName},
@@ -28,7 +33,7 @@ func fileDTO(ctx context.Context, f File) gen.UploadFile {
SizeBytes: f.Size, ReceivedBytes: f.Received, Format: f.Format, Codec: f.Codec, Bitrate: f.Bitrate,
SampleRate: f.SampleRate, BitDepth: f.BitDepth, DurationMs: f.DurationMs, Tags: f.Tags,
MetadataEdited: f.MetadataEdited, MatchedArtistId: f.MatchedArtistID, MatchedAlbumId: f.MatchedAlbumID,
DuplicateOfTrackId: f.DuplicateOfTrackID, PossibleDuplicate: f.PossibleDuplicate,
DuplicateOfTrackId: f.DuplicateOfTrackID, PossibleDuplicate: f.PossibleDuplicate, QualityUpgrade: f.QualityUpgrade,
TargetSourceId: f.TargetSourceID, TargetRelPath: f.TargetRelPath, StatusReason: f.StatusReason,
ReviewedAt: f.ReviewedAt, CreatedAt: f.CreatedAt, UpdatedAt: f.UpdatedAt,
}
@@ -47,6 +52,12 @@ func fileDTO(ctx context.Context, f File) gen.UploadFile {
u := TusURL(f.ID)
out.TusUrl = &u
}
if h.signer != nil && f.StagingPath != "" && f.Status.KeepsStaging() {
if p, ok := auth.PrincipalFrom(ctx); ok && auth.Can(p.Role, auth.PermReviewUploads) {
u := h.streamURL(f.ID)
out.StreamUrl = &u
}
}
return out
}
@@ -64,10 +75,10 @@ func metaFromDTO(m gen.UploadMeta) Meta {
}
}
func pageDTO(ctx context.Context, files []File, next string) gen.UploadFilePage {
func (h *Handler) pageDTO(ctx context.Context, files []File, next string) gen.UploadFilePage {
out := gen.UploadFilePage{Items: make([]gen.UploadFile, 0, len(files))}
for _, f := range files {
out.Items = append(out.Items, fileDTO(ctx, f))
out.Items = append(out.Items, h.fileDTO(ctx, f))
}
if next != "" {
out.NextCursor = &next
@@ -96,7 +107,7 @@ func deref[T any](p *T, def T) T {
func settingsDTO(s Settings) gen.UploadSettings {
return gen.UploadSettings{
QueueLimitBytes: s.QueueLimitBytes, PerUserLimitBytes: s.PerUserLimitBytes, MaxFileBytes: s.MaxFileBytes,
DefaultSourceId: s.DefaultSourceID,
DefaultSourceId: s.DefaultSourceID, AllowQualityUpgrade: s.AllowQualityUpgrade,
}
}
@@ -174,7 +185,7 @@ func (h *Handler) ListMyUploads(ctx context.Context, req gen.ListMyUploadsReques
if err != nil {
return nil, err
}
return gen.ListMyUploads200JSONResponse(pageDTO(ctx, files, next)), nil
return gen.ListMyUploads200JSONResponse(h.pageDTO(ctx, files, next)), nil
}
// WithdrawUpload реализует gen.StrictServerInterface.
@@ -195,7 +206,7 @@ func (h *Handler) ListUploads(ctx context.Context, req gen.ListUploadsRequestObj
if err != nil {
return nil, err
}
return gen.ListUploads200JSONResponse(pageDTO(ctx, files, next)), nil
return gen.ListUploads200JSONResponse(h.pageDTO(ctx, files, next)), nil
}
// UpdateUpload реализует gen.StrictServerInterface.
@@ -209,7 +220,7 @@ func (h *Handler) UpdateUpload(ctx context.Context, req gen.UpdateUploadRequestO
if err != nil {
return nil, err
}
return gen.UpdateUpload200JSONResponse(fileDTO(ctx, f)), nil
return gen.UpdateUpload200JSONResponse(h.fileDTO(ctx, f)), nil
}
// RetryUploadImport реализует gen.StrictServerInterface.
@@ -218,7 +229,7 @@ func (h *Handler) RetryUploadImport(ctx context.Context, req gen.RetryUploadImpo
if err != nil {
return nil, err
}
return gen.RetryUploadImport200JSONResponse(fileDTO(ctx, f)), nil
return gen.RetryUploadImport200JSONResponse(h.fileDTO(ctx, f)), nil
}
// ReviewUploads реализует gen.StrictServerInterface.
@@ -272,9 +283,59 @@ func (h *Handler) UpdateUploadSettings(ctx context.Context, req gen.UpdateUpload
QueueLimitBytes: b.QueueLimitBytes, MaxFileBytes: b.MaxFileBytes,
PerUserLimitBytes: b.PerUserLimitBytes, ClearPerUser: b.PerUserLimitBytes == nil,
DefaultSourceID: b.DefaultSourceId, ClearDefault: b.DefaultSourceId == nil,
AllowQualityUpgrade: b.AllowQualityUpgrade,
})
if err != nil {
return nil, err
}
return gen.UpdateUploadSettings200JSONResponse(settingsDTO(s)), nil
}
// UnseenUploadDecisions реализует gen.StrictServerInterface.
func (h *Handler) UnseenUploadDecisions(ctx context.Context, _ gen.UnseenUploadDecisionsRequestObject) (gen.UnseenUploadDecisionsResponseObject, error) {
n, err := h.svc.Unseen(ctx)
if err != nil {
return nil, err
}
return gen.UnseenUploadDecisions200JSONResponse{Count: n}, nil
}
// MarkUploadsSeen реализует gen.StrictServerInterface.
func (h *Handler) MarkUploadsSeen(ctx context.Context, _ gen.MarkUploadsSeenRequestObject) (gen.MarkUploadsSeenResponseObject, error) {
if err := h.svc.MarkSeen(ctx); err != nil {
return nil, err
}
return gen.MarkUploadsSeen204Response{}, nil
}
// UpdateUploadBatch реализует gen.StrictServerInterface.
func (h *Handler) UpdateUploadBatch(ctx context.Context, req gen.UpdateUploadBatchRequestObject) (gen.UpdateUploadBatchResponseObject, error) {
b := req.Body
in := BatchPatch{}
if b.Artist != nil {
in.Artist = b.Artist
}
if b.Album != nil {
in.Album = b.Album
}
if b.Year != nil {
in.SetYear = true
in.Year = b.Year
}
if b.CoverBase64 != nil {
if *b.CoverBase64 == "" {
in.ClearCover = true
} else {
raw, err := DecodeCover(*b.CoverBase64)
if err != nil {
return nil, err
}
in.Cover = raw
}
}
files, err := h.svc.UpdateBatch(ctx, req.Id, in)
if err != nil {
return nil, err
}
return gen.UpdateUploadBatch200JSONResponse{Items: h.pageDTO(ctx, files, "").Items}, nil
}
+7 -4
View File
@@ -138,22 +138,25 @@ type File struct {
DuplicateOfTrackID *uuid.UUID
DuplicateLevel *string
PossibleDuplicate bool
QualityUpgrade bool
TargetSourceID *uuid.UUID
TargetRelPath *string
StatusReason *string
ReviewedBy *uuid.UUID
ReviewedAt *time.Time
ImportedFileID *uuid.UUID
CoverPath *string
CreatedAt time.Time
UpdatedAt time.Time
}
// Settings — лимиты очереди и хранилище по умолчанию (app_settings, меняет админ).
type Settings struct {
QueueLimitBytes int64 `json:"queue_limit_bytes"`
PerUserLimitBytes *int64 `json:"per_user_limit_bytes,omitempty"` // nil — выключен
MaxFileBytes int64 `json:"max_file_bytes"`
DefaultSourceID *uuid.UUID `json:"default_source_id,omitempty"`
QueueLimitBytes int64 `json:"queue_limit_bytes"`
PerUserLimitBytes *int64 `json:"per_user_limit_bytes,omitempty"` // nil — выключен
MaxFileBytes int64 `json:"max_file_bytes"`
DefaultSourceID *uuid.UUID `json:"default_source_id,omitempty"`
AllowQualityUpgrade bool `json:"allow_quality_upgrade"`
}
// DefaultSettings — значения по умолчанию (docs/uploads.md#3-квота-очереди).
+31
View File
@@ -0,0 +1,31 @@
package uploads
import "strings"
// BetterQuality — загружаемый файл заметно лучше уже лежащего в библиотеке (docs/uploads.md#4).
// Lossless вместо lossy, либо тот же класс и битрейт выше хотя бы на 32 кбит/с и на 20%.
func BetterQuality(inCodec, inFormat string, inBitrate int, exCodec, exFormat string, exBitrate int) bool {
inLoss, exLoss := lossless(inCodec, inFormat), lossless(exCodec, exFormat)
if inLoss && !exLoss {
return true
}
if inLoss == exLoss && inBitrate > 0 && exBitrate > 0 && inBitrate >= exBitrate+32_000 && inBitrate*5 >= exBitrate*6 {
return true
}
return false
}
func lossless(codec, format string) bool {
switch strings.ToLower(codec) {
case "flac", "alac", "wavpack", "ape", "tta":
return true
}
if strings.HasPrefix(strings.ToLower(codec), "pcm_") {
return true
}
switch strings.ToLower(format) {
case "flac", "wav", "aiff", "aif", "wv":
return true
}
return false
}
@@ -0,0 +1,21 @@
package uploads
import "testing"
func TestBetterQuality(t *testing.T) {
if !BetterQuality("flac", "flac", 900000, "mp3", "mp3", 320000) {
t.Fatal("flac should replace mp3")
}
if BetterQuality("mp3", "mp3", 128000, "flac", "flac", 900000) {
t.Fatal("mp3 is not an upgrade over flac")
}
if !BetterQuality("mp3", "mp3", 320000, "mp3", "mp3", 128000) {
t.Fatal("much higher bitrate is an upgrade")
}
if BetterQuality("mp3", "mp3", 180000, "mp3", "mp3", 160000) {
t.Fatal("small bitrate step is not an upgrade")
}
if BetterQuality("flac", "flac", 0, "flac", "flac", 0) {
t.Fatal("same class without bitrate is not an upgrade")
}
}
+28 -6
View File
@@ -435,12 +435,13 @@ func (s *Service) GetSettings(ctx context.Context) (Settings, error) {
// SettingsInput — правка настроек; nil — не менять. ClearPerUser / ClearDefault — сбросить.
type SettingsInput struct {
QueueLimitBytes *int64
PerUserLimitBytes *int64
MaxFileBytes *int64
DefaultSourceID *uuid.UUID
ClearPerUser bool
ClearDefault bool
QueueLimitBytes *int64
PerUserLimitBytes *int64
MaxFileBytes *int64
DefaultSourceID *uuid.UUID
AllowQualityUpgrade *bool
ClearPerUser bool
ClearDefault bool
}
// UpdateSettings меняет лимиты; уменьшение ниже занятого ничего не удаляет (docs/uploads.md#3).
@@ -484,6 +485,9 @@ func (s *Service) UpdateSettings(ctx context.Context, in SettingsInput) (Setting
}
st.DefaultSourceID = in.DefaultSourceID
}
if in.AllowQualityUpgrade != nil {
st.AllowQualityUpgrade = *in.AllowQualityUpgrade
}
if err := s.store.SaveSettings(ctx, st); err != nil {
return Settings{}, err
}
@@ -662,3 +666,21 @@ func (s *Service) removeStaging(f File) {
// 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
}
+92 -4
View File
@@ -230,8 +230,8 @@ func (st *Store) SetReceived(ctx context.Context, id uuid.UUID, offset int64) er
const fileColumns = `f.id, f.batch_id, f.author_id, u.username, b.comment, f.status, f.original_name, f.client_rel_path,
f.size_bytes, f.received_bytes, f.sha256, coalesce(f.staging_path, ''), f.format, f.codec, f.bitrate, f.sample_rate,
f.bit_depth, f.duration_ms, f.tags, f.metadata, f.metadata_edited, f.matched_artist_id, f.matched_album_id,
f.duplicate_of_track_id, f.duplicate_level, f.possible_duplicate, f.target_source_id, f.target_rel_path,
f.status_reason, f.reviewed_by, f.reviewed_at, f.imported_file_id, f.created_at, f.updated_at`
f.duplicate_of_track_id, f.duplicate_level, f.possible_duplicate, f.quality_upgrade, f.target_source_id, f.target_rel_path,
f.status_reason, f.reviewed_by, f.reviewed_at, f.imported_file_id, b.cover_path, f.created_at, f.updated_at`
const fileFrom = ` FROM upload_files f JOIN upload_batches b ON b.id = f.batch_id JOIN users u ON u.id = f.author_id`
@@ -243,8 +243,8 @@ func scanFile(row pgx.Row) (File, error) {
err := row.Scan(&f.ID, &f.BatchID, &f.AuthorID, &f.AuthorName, &f.BatchComment, &f.Status, &f.OriginalName,
&f.ClientRelPath, &f.Size, &f.Received, &f.SHA256, &f.StagingPath, &f.Format, &f.Codec, &f.Bitrate, &f.SampleRate,
&f.BitDepth, &f.DurationMs, &tags, &meta, &f.MetadataEdited, &f.MatchedArtistID, &f.MatchedAlbumID,
&f.DuplicateOfTrackID, &f.DuplicateLevel, &f.PossibleDuplicate, &f.TargetSourceID, &f.TargetRelPath,
&f.StatusReason, &f.ReviewedBy, &f.ReviewedAt, &f.ImportedFileID, &f.CreatedAt, &f.UpdatedAt)
&f.DuplicateOfTrackID, &f.DuplicateLevel, &f.PossibleDuplicate, &f.QualityUpgrade, &f.TargetSourceID, &f.TargetRelPath,
&f.StatusReason, &f.ReviewedBy, &f.ReviewedAt, &f.ImportedFileID, &f.CoverPath, &f.CreatedAt, &f.UpdatedAt)
if errors.Is(err, pgx.ErrNoRows) {
return File{}, ErrNotFound
}
@@ -526,3 +526,91 @@ func MarkImported(ctx context.Context, tx pgx.Tx, uploadID, mediaFileID uuid.UUI
}
return nil
}
// ReconcileQuota пересчитывает занятое место по статусам файлов.
func (st *Store) ReconcileQuota(ctx context.Context) error {
_, err := st.pool.Exec(ctx, `
UPDATE upload_quota SET used_bytes = coalesce((
SELECT sum(size_bytes) FROM upload_files WHERE status::text = ANY($1)
), 0)`, occupyingStatuses)
if err != nil {
return fmt.Errorf("uploads: reconcile quota: %w", err)
}
return nil
}
// BestAudio — лучший доступный файл трека (для сравнения качества).
func (st *Store) BestAudio(ctx context.Context, trackID uuid.UUID) (codec, format string, bitrate int, err error) {
err = st.pool.QueryRow(ctx, `
SELECT coalesce(codec, ''), coalesce(format, ''), coalesce(bitrate, 0)
FROM media_files
WHERE track_id = $1 AND missing_since IS NULL
ORDER BY (codec IN ('flac', 'alac', 'wavpack', 'ape', 'tta') OR codec LIKE 'pcm_%') DESC,
bitrate DESC NULLS LAST
LIMIT 1`, trackID).Scan(&codec, &format, &bitrate)
if errors.Is(err, pgx.ErrNoRows) {
return "", "", 0, nil
}
if err != nil {
return "", "", 0, fmt.Errorf("uploads: best audio: %w", err)
}
return codec, format, bitrate, nil
}
// PendingInBatch — файлы пачки, которые ещё можно править.
func (st *Store) PendingInBatch(ctx context.Context, batchID uuid.UUID) ([]File, error) {
rows, err := st.pool.Query(ctx, `SELECT `+fileColumns+fileFrom+`
WHERE f.batch_id = $1 AND f.status IN ('pending_review', 'import_failed')
ORDER BY f.created_at, f.id`, batchID)
if err != nil {
return nil, fmt.Errorf("uploads: batch files: %w", err)
}
defer rows.Close()
var out []File
for rows.Next() {
f, err := scanFile(rows)
if err != nil {
return nil, err
}
out = append(out, f)
}
return out, rows.Err()
}
// SetCover записывает путь обложки пачки (nil — снять).
func (st *Store) SetCover(ctx context.Context, batchID uuid.UUID, path *string) error {
tag, err := st.pool.Exec(ctx, `UPDATE upload_batches SET cover_path = $2 WHERE id = $1`, batchID, path)
if err != nil {
return fmt.Errorf("uploads: set cover: %w", err)
}
if tag.RowsAffected() == 0 {
return ErrNotFound
}
return nil
}
// UnseenDecisions — решения по файлам автора новее последнего визита.
func (st *Store) UnseenDecisions(ctx context.Context, userID uuid.UUID) (int, error) {
var n int
err := st.pool.QueryRow(ctx, `
SELECT count(*) FROM upload_files f
LEFT JOIN upload_visits v ON v.user_id = f.author_id
WHERE f.author_id = $1
AND f.status IN ('imported', 'rejected', 'duplicate', 'invalid', 'expired', 'import_failed')
AND f.updated_at > coalesce(v.seen_at, '-infinity')`, userID).Scan(&n)
if err != nil {
return 0, fmt.Errorf("uploads: unseen: %w", err)
}
return n, nil
}
// MarkSeen отмечает визит автора.
func (st *Store) MarkSeen(ctx context.Context, userID uuid.UUID, at time.Time) error {
_, err := st.pool.Exec(ctx, `
INSERT INTO upload_visits (user_id, seen_at) VALUES ($1, $2)
ON CONFLICT (user_id) DO UPDATE SET seen_at = EXCLUDED.seen_at`, userID, at)
if err != nil {
return fmt.Errorf("uploads: mark seen: %w", err)
}
return nil
}
+92
View File
@@ -0,0 +1,92 @@
package uploads
import (
"context"
"net/http"
"os"
"path/filepath"
"strconv"
"time"
"github.com/google/uuid"
"pipu/api/internal/api/gen"
"pipu/api/internal/platform/apperr"
"pipu/api/internal/platform/httpx"
)
// uploadStream — отдача файла staging с Range (сам api или X-Accel-Redirect).
type uploadStream struct {
req *http.Request
file string
format string
accel string
xAccel bool
}
func (s uploadStream) VisitStreamUploadResponse(w http.ResponseWriter) error {
w.Header().Set("Content-Type", contentType(s.format))
w.Header().Set("Cache-Control", "private, max-age=600")
if s.xAccel && s.accel != "" {
w.Header().Set("X-Accel-Redirect", s.accel)
w.WriteHeader(http.StatusOK)
return nil
}
f, err := os.Open(s.file)
if err != nil {
return err
}
defer func() { _ = f.Close() }()
info, err := f.Stat()
if err != nil {
return err
}
if s.req == nil {
http.ServeContent(w, &http.Request{}, "", info.ModTime(), f)
return nil
}
http.ServeContent(w, s.req, "", info.ModTime(), f)
return nil
}
func (h *Handler) streamURL(id uuid.UUID) string {
exp := time.Now().Add(12 * time.Hour).Unix()
sig := h.signer.Sign("upload", id.String(), strconv.FormatInt(exp, 10))
return "/api/v1/admin/uploads/files/" + id.String() + "/stream?exp=" + strconv.FormatInt(exp, 10) + "&sig=" + sig
}
// StreamUpload реализует gen.StrictServerInterface.
func (h *Handler) StreamUpload(ctx context.Context, req gen.StreamUploadRequestObject) (gen.StreamUploadResponseObject, error) {
if h.signer == nil || !h.signer.Verify(req.Params.Sig, "upload", req.Id.String(), strconv.FormatInt(req.Params.Exp, 10)) || time.Now().Unix() > req.Params.Exp {
return nil, apperr.Forbidden("uploads.link_invalid", "link is invalid or expired")
}
f, err := h.svc.OpenStaging(ctx, req.Id)
if err != nil {
return nil, err
}
format := ""
if f.Format != nil {
format = *f.Format
}
return uploadStream{
req: httpx.RequestFrom(ctx), file: f.Abs, format: format, xAccel: h.xAccel,
accel: "/_internal/uploads/" + filepath.ToSlash(f.StagingPath),
}, nil
}
func contentType(format string) string {
switch format {
case "mp3":
return "audio/mpeg"
case "flac":
return "audio/flac"
case "m4a":
return "audio/mp4"
case "ogg", "opus":
return "audio/ogg"
case "wav":
return "audio/wav"
default:
return "application/octet-stream"
}
}
+46 -7
View File
@@ -41,7 +41,16 @@ func (s *Service) Run(ctx context.Context) {
s.reconcile(ctx)
go s.loop(ctx, s.wakeProcess, pollEvery, func(ctx context.Context) bool { return s.processNext(ctx) })
go s.loop(ctx, s.wakeImport, pollEvery, func(ctx context.Context) bool { return s.importNext(ctx) })
go s.loop(ctx, nil, cleanupEvery, func(ctx context.Context) bool { s.expire(ctx); return false })
go s.loop(ctx, nil, cleanupEvery, func(ctx context.Context) bool {
s.expire(ctx)
if err := s.store.ReconcileQuota(ctx); err != nil {
s.log.Error("uploads: reconcile quota", "err", err)
}
return false
})
if err := s.store.ReconcileQuota(ctx); err != nil {
s.log.Error("uploads: reconcile quota", "err", err)
}
wake(s.wakeProcess)
wake(s.wakeImport)
}
@@ -98,6 +107,7 @@ type outcome struct {
dupTrack *uuid.UUID
dupLevel string
possible bool
quality bool
place placement
}
@@ -144,8 +154,12 @@ func (s *Service) analyze(ctx context.Context, f File, abs string) outcome {
cands, err := s.store.FingerprintCandidates(ctx, probe.DurationMs, FingerprintTolerance, f.ID)
if err == nil {
if c, ok := bestMatch(fp.Points, cands); ok {
o.status, o.reason, o.dupTrack, o.dupLevel = StatusDuplicate, CodeDuplicate, c.TrackID, DupFingerprint
return o
if s.keepAsUpgrade(ctx, &o, c.TrackID, probe) {
// лучшее качество — дальше как обычный файл на рассмотрение
} else {
o.status, o.reason, o.dupTrack, o.dupLevel = StatusDuplicate, CodeDuplicate, c.TrackID, DupFingerprint
return o
}
}
}
} else if !errors.Is(err, library.ErrNoFPCalc) {
@@ -183,6 +197,26 @@ func (s *Service) analyze(ctx context.Context, f File, abs string) outcome {
return o
}
// keepAsUpgrade — совпадение по отпечатку, но файл лучше библиотечного и режим замены включён.
// true — не считать дублем, оставить на рассмотрение.
func (s *Service) keepAsUpgrade(ctx context.Context, o *outcome, track *uuid.UUID, probe library.Probe) bool {
if track == nil {
return false
}
st, err := s.store.Settings(ctx)
if err != nil || !st.AllowQualityUpgrade {
return false
}
codec, format, bitrate, err := s.store.BestAudio(ctx, *track)
if err != nil || !BetterQuality(probe.Codec, probe.Format, probe.Bitrate, codec, format, bitrate) {
return false
}
o.quality = true
o.dupTrack = track
o.dupLevel = DupFingerprint
return true
}
// bestMatch — отпечаток, похожий не меньше порога library.SameRecording.
func bestMatch(points []uint32, cands []PrintCandidate) (PrintCandidate, bool) {
best, score := PrintCandidate{}, 0.0
@@ -245,11 +279,11 @@ func (s *Service) saveOutcome(ctx context.Context, f File, o outcome) error {
UPDATE upload_files SET sha256 = coalesce($2, sha256), format = NULLIF($3, ''), codec = NULLIF($4, ''),
bitrate = NULLIF($5, 0), sample_rate = NULLIF($6, 0), bit_depth = NULLIF($7, 0), duration_ms = NULLIF($8, 0),
tags = $9, metadata = $10, fingerprint = $11, duplicate_of_track_id = $12, duplicate_level = NULLIF($13, ''),
possible_duplicate = $14, target_source_id = $15, target_rel_path = $16, status_reason = NULLIF($17, ''),
possible_duplicate = $14, quality_upgrade = $20, target_source_id = $15, target_rel_path = $16, status_reason = NULLIF($17, ''),
matched_artist_id = $18, matched_album_id = $19
WHERE id = $1`,
f.ID, o.sha, p.Format, p.Codec, p.Bitrate, p.SampleRate, p.BitDepth, p.DurationMs, tags, metaJSON, points,
o.dupTrack, o.dupLevel, o.possible, o.place.SourceID, o.place.RelPath, o.reason, o.place.ArtistID, o.place.AlbumID)
o.dupTrack, o.dupLevel, o.possible, o.place.SourceID, o.place.RelPath, o.reason, o.place.ArtistID, o.place.AlbumID, o.quality)
return err
})
}
@@ -295,7 +329,7 @@ func (s *Service) importFile(ctx context.Context, f File) error {
}
rel := *pl.RelPath
stagingRel, sum := f.StagingPath, f.SHA256
if f.MetadataEdited {
if f.MetadataEdited || (f.CoverPath != nil && *f.CoverPath != "") {
tagged, tsum, err := s.writeTags(ctx, f, *f.Meta, path.Ext(rel))
if err != nil {
return fmt.Errorf("write tags: %w", err)
@@ -352,7 +386,12 @@ func (s *Service) writeTags(ctx context.Context, f File, m Meta, ext string) (st
}
rel := f.StagingPath + ".tagged" + strings.ToLower(ext)
out := s.stagingAbs(rel)
args := []string{"-v", "error", "-y", "-i", s.stagingAbs(f.StagingPath), "-map", "0", "-c", "copy", "-map_metadata", "0"}
args := []string{"-v", "error", "-y", "-i", s.stagingAbs(f.StagingPath)}
if f.CoverPath != nil && *f.CoverPath != "" {
args = append(args, "-i", s.stagingAbs(*f.CoverPath), "-map", "0:a", "-map", "1", "-c", "copy", "-disposition:v:0", "attached_pic", "-map_metadata", "0")
} else {
args = append(args, "-map", "0", "-c", "copy", "-map_metadata", "0")
}
set := func(k, v string) { args = append(args, "-metadata", k+"="+v) }
set("title", m.Title)
set("artist", m.Artist)
+5
View File
@@ -141,6 +141,8 @@ type Handler struct {
catalog *library.Store
dto func() library.DTO
log *slog.Logger
// Warm заранее готовит транскоды выданной пачки (docs/playback.md).
Warm func(ctx context.Context, trackIDs []uuid.UUID)
}
// NewHandler создаёт обработчик.
@@ -328,6 +330,9 @@ func (h *Handler) serve(ctx context.Context, s session, fb []Feedback) (gen.Wave
if err := h.record(ctx, s, picks, modelVer); err != nil {
return gen.WaveBatch{}, err
}
if h.Warm != nil && len(ids) > 0 {
go h.Warm(context.WithoutCancel(ctx), ids)
}
return gen.WaveBatch{SessionId: s.ID, Activity: s.Activity, Mood: s.Mood, Tracks: tracks, Fallback: fallback}, nil
}
@@ -0,0 +1,14 @@
-- +goose Up
-- Остаток очереди: замена на лучшее качество, обложка пачки, визит автора (docs/uploads.md).
ALTER TABLE upload_files ADD COLUMN quality_upgrade boolean NOT NULL DEFAULT false;
ALTER TABLE upload_batches ADD COLUMN cover_path text;
CREATE TABLE upload_visits (
user_id uuid PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE,
seen_at timestamptz NOT NULL
);
-- +goose Down
DROP TABLE upload_visits;
ALTER TABLE upload_batches DROP COLUMN cover_path;
ALTER TABLE upload_files DROP COLUMN quality_upgrade;