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
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:
1 parent
2b76060ea3
commit
066ba24834
61 files changed
+2818
-463
No files matched your search
@@ -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
@@ -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.
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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-квота-очереди).
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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;
|
||||
Reference in new issue
Block a user