feat(library): measure every track's loudness in the background (M464 #4995)
release / go (push) Failing after 1m18s
release / web (push) Successful in 1m27s
release / govulncheck (push) Successful in 35s
release / integration (push) Failing after 4m13s
release / Attach APK to the Release (tag releases only) (push) Canceled after 0s
release / Build + push container image (push) Canceled after 0s
release / Verify release artifacts (tag releases only) (push) Canceled after 0s
release / android (push) Canceled after 5m47s
release / Build signed APK (releases and dev) (push) Canceled after 4m21s
release / go (push) Failing after 1m18s
release / web (push) Successful in 1m27s
release / govulncheck (push) Successful in 35s
release / integration (push) Failing after 4m13s
release / Attach APK to the Release (tag releases only) (push) Canceled after 0s
release / Build + push container image (push) Canceled after 0s
release / Verify release artifacts (tag releases only) (push) Canceled after 0s
release / android (push) Canceled after 5m47s
release / Build signed APK (releases and dev) (push) Canceled after 4m21s
The first step of loudness normalization: the server measures each track with ffmpeg's EBU R128 filter (true peak, mono as dual mono) and stores the integrated loudness, true peak and loudness range in track_loudness (migration 0065). It also keeps a histogram of the 400 ms gating blocks at 0.1 LU, so album loudness can be computed exactly later with no second decode (#4996). The histogram reproduces ffmpeg's own figure (-10.68 against -10.7 on the captured fixture), and the analyzer logs a warning if the two ever drift. - A background worker, cloned from the fingerprint backfill, measures every track, new ones included. Measuring inline in the scan was dropped: the analysis decodes the whole file, and a large import could pass the scan's one-hour stuck threshold. The scan only deletes a changed file's measurement; the worker ticks every 10 minutes. - Timeouts, the cancel/missing-binary split and settled verdicts follow the fingerprint runner. Silence and undecodable files are stored as verdicts; stalls are retried. The deadline scales with track length. - loudness_settings (enabled, files at once) and an admin card with the coverage gauge, under GET/PUT /api/admin/library/loudness-settings and GET /api/admin/library/loudness. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,82 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
|
||||
"git.fabledsword.com/bvandeusen/minstrel/internal/apierror"
|
||||
"git.fabledsword.com/bvandeusen/minstrel/internal/library"
|
||||
)
|
||||
|
||||
// loudnessCoverageResp is the wire shape for GET /api/admin/library/loudness
|
||||
// (M464 #4995). measured + silent + unreadable + pending = total; missing
|
||||
// tracks are not counted. Enabled travels with the counts because with
|
||||
// analysis off, pending never shrinks, and a gauge implying progress would be
|
||||
// promising work nothing is doing.
|
||||
type loudnessCoverageResp struct {
|
||||
Total int64 `json:"total"`
|
||||
Measured int64 `json:"measured"`
|
||||
Silent int64 `json:"silent"`
|
||||
Unreadable int64 `json:"unreadable"`
|
||||
Pending int64 `json:"pending"`
|
||||
Enabled bool `json:"enabled"`
|
||||
}
|
||||
|
||||
// handleGetLoudnessCoverage implements GET /api/admin/library/loudness: how
|
||||
// far the loudness backfill has got. Always 200; zeros on an empty library.
|
||||
func (h *handlers) handleGetLoudnessCoverage(w http.ResponseWriter, r *http.Request) {
|
||||
row, err := library.LoudnessCoverage(r.Context(), h.pool)
|
||||
if err != nil {
|
||||
writeErrWithLog(w, h.logger, "admin: get loudness coverage", apierror.InternalMsg("lookup failed", err))
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, loudnessCoverageResp{
|
||||
Total: row.Total,
|
||||
Measured: row.Measured,
|
||||
Silent: row.Silent,
|
||||
Unreadable: row.Unreadable,
|
||||
Pending: row.Pending,
|
||||
Enabled: h.loudnessSettings.Get().Enabled,
|
||||
})
|
||||
}
|
||||
|
||||
// loudnessSettingsBody is the wire shape for GET and PUT
|
||||
// /api/admin/library/loudness-settings.
|
||||
type loudnessSettingsBody struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
BackfillConcurrency int32 `json:"backfill_concurrency"`
|
||||
}
|
||||
|
||||
func loudnessSettingsBodyOf(s library.LoudnessSettings) loudnessSettingsBody {
|
||||
return loudnessSettingsBody{Enabled: s.Enabled, BackfillConcurrency: s.BackfillConcurrency}
|
||||
}
|
||||
|
||||
// handleGetLoudnessSettings implements GET /api/admin/library/loudness-settings.
|
||||
func (h *handlers) handleGetLoudnessSettings(w http.ResponseWriter, _ *http.Request) {
|
||||
writeJSON(w, http.StatusOK, loudnessSettingsBodyOf(h.loudnessSettings.Get()))
|
||||
}
|
||||
|
||||
// handleUpdateLoudnessSettings implements PUT /api/admin/library/loudness-settings.
|
||||
// A whole-row write: a body that leaves out the concurrency decodes it as zero,
|
||||
// which is refused rather than saved.
|
||||
func (h *handlers) handleUpdateLoudnessSettings(w http.ResponseWriter, r *http.Request) {
|
||||
var req loudnessSettingsBody
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
writeErr(w, apierror.BadRequest("invalid_body", "malformed JSON"))
|
||||
return
|
||||
}
|
||||
saved, err := h.loudnessSettings.Set(r.Context(), library.LoudnessSettings{
|
||||
Enabled: req.Enabled,
|
||||
BackfillConcurrency: req.BackfillConcurrency,
|
||||
})
|
||||
if err != nil {
|
||||
if errors.Is(err, library.ErrLoudnessSettingOutOfRange) {
|
||||
writeErr(w, apierror.BadRequest("invalid_setting", err.Error()))
|
||||
return
|
||||
}
|
||||
writeErrWithLog(w, h.logger, "admin loudness settings: update failed", apierror.Internal(err))
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, loudnessSettingsBodyOf(saved))
|
||||
}
|
||||
+8
-1
@@ -34,7 +34,7 @@ import (
|
||||
// Mount attaches /api/* handlers to r. Public endpoints (login) are outside
|
||||
// RequireUser; everything else is gated by the middleware. The events writer
|
||||
// is shared with the Subsonic mount so /rest/scrobble feeds the same store.
|
||||
func Mount(r chi.Router, pool *pgxpool.Pool, logger *slog.Logger, events *playevents.Writer, recCfg config.RecommendationConfig, recSettings *recsettings.Service, lidarrCfg *lidarrconfig.Service, lidarrReqs *lidarrrequests.Service, lidarrQuar *lidarrquarantine.Service, tracksSvc *tracks.Service, playlistsSvc *playlists.Service, coverEnricher *coverart.Enricher, coverSettings *coverart.SettingsService, tagSettings *tags.SettingsService, scanner *library.Scanner, scanCfg library.RunScanConfig, dataDir string, sender mailer.Sender, bus *eventbus.Bus, playlistScheduler *playlists.Scheduler, streamSecret []byte, netSettings *netsettings.Service, reacqSettings *reacquisition.SettingsService, fpSettings *library.FingerprintSettingsService) {
|
||||
func Mount(r chi.Router, pool *pgxpool.Pool, logger *slog.Logger, events *playevents.Writer, recCfg config.RecommendationConfig, recSettings *recsettings.Service, lidarrCfg *lidarrconfig.Service, lidarrReqs *lidarrrequests.Service, lidarrQuar *lidarrquarantine.Service, tracksSvc *tracks.Service, playlistsSvc *playlists.Service, coverEnricher *coverart.Enricher, coverSettings *coverart.SettingsService, tagSettings *tags.SettingsService, scanner *library.Scanner, scanCfg library.RunScanConfig, dataDir string, sender mailer.Sender, bus *eventbus.Bus, playlistScheduler *playlists.Scheduler, streamSecret []byte, netSettings *netsettings.Service, reacqSettings *reacquisition.SettingsService, fpSettings *library.FingerprintSettingsService, loudSettings *library.LoudnessSettingsService) {
|
||||
rng := rand.New(rand.NewSource(rand.Int63()))
|
||||
setupToken, err := auth.NewSetupToken()
|
||||
if err != nil {
|
||||
@@ -65,6 +65,7 @@ func Mount(r chi.Router, pool *pgxpool.Pool, logger *slog.Logger, events *playev
|
||||
netSettings: netSettings,
|
||||
reacqSettings: reacqSettings,
|
||||
fingerprintSettings: fpSettings,
|
||||
loudnessSettings: loudSettings,
|
||||
librarySize: recommendation.NewLibrarySize(nil),
|
||||
loginGuard: auth.NewLoginGuard(),
|
||||
setupToken: setupToken,
|
||||
@@ -237,6 +238,9 @@ func Mount(r chi.Router, pool *pgxpool.Pool, logger *slog.Logger, events *playev
|
||||
admin.Get("/library/fingerprints", h.handleGetFingerprintCoverage)
|
||||
admin.Get("/library/fingerprint-settings", h.handleGetFingerprintSettings)
|
||||
admin.Put("/library/fingerprint-settings", h.handleUpdateFingerprintSettings)
|
||||
admin.Get("/library/loudness", h.handleGetLoudnessCoverage)
|
||||
admin.Get("/library/loudness-settings", h.handleGetLoudnessSettings)
|
||||
admin.Put("/library/loudness-settings", h.handleUpdateLoudnessSettings)
|
||||
// Duplicates report (#3912): proposals from the duplicate sweep, a
|
||||
// trigger to sweep now, dismissal, and the merge (#3911), which deletes
|
||||
// the removed copies' files after moving their history onto the kept one.
|
||||
@@ -331,6 +335,9 @@ type handlers struct {
|
||||
// instance the scanner and the fingerprint workers read, so a save from the
|
||||
// admin card reaches them without a restart. Nil serves the defaults.
|
||||
fingerprintSettings *library.FingerprintSettingsService
|
||||
// loudnessSettings is the loudness analysis policy (M464 #4995), the same
|
||||
// instance the loudness backfill reads. Nil serves the defaults.
|
||||
loudnessSettings *library.LoudnessSettingsService
|
||||
// setupToken must accompany the first registration while no users exist
|
||||
// (see auth.SetupToken). requireSetupToken is set by Mount, the only
|
||||
// production constructor; tests that build handlers directly leave it
|
||||
|
||||
Reference in New Issue
Block a user