Files
yellowjacket/backend/app.go
logan de2b324e20
CI / check (push) Canceled after 0s
CI / e2e (push) Canceled after 0s
Search index maintenance / maintain-index (push) Canceled after 0s
Build & publish Arch package / arch-package (push) Successful in 2m30s
feat(explore): refuse 0.6 GB on someone's mobile data
Plan 016 B4. The catalog artifact is about 0.6 GB and the app fetched it
with no awareness of the connection: on a desktop that is a minute of
bandwidth, on a phone it can be a month's allowance. It is now skipped on
a cellular connection unless `AllowMeteredCatalogDownload` is on, with
the toggle in Settings' Search Index section, where the text explaining
what the catalog is already lives.

The file layout is dictated by the cgo rule rather than by taste.
`explore` is imported by `cmd/indexbuild`, which builds with
CGO_ENABLED=0 and must not link Wails, so `netpolicy.go` holds the policy
and the JSON parsing -- tested on every platform -- and the single
platform call is a closure injected from `app.go`, which already names
`application` legitimately.

Three rules in it are load-bearing. An unknown answer is not a metered
one: only mobile answers at all, and treating silence as metered would
have disabled the download for every desktop user in the world. Cellular
is the only signal available, because the runtime reports
`wifi|cellular|ethernet|none` and no metered flag -- so a metered Wi-Fi
cannot be detected and is not refused, which is documented rather than
implied. And the gate runs before the first status write, so declining is
a no-op instead of a job in the indicator and an error tier to dismiss.

Two corrections to the plan while implementing it: the portable API is
`application.Mobile.NetworkJSON()`, not `application.Android`'s, which
exists only under the `android` build tag; and the permission is read at
the moment a download would start, so enabling it takes effect on the
next attempt rather than the next launch.
2026-08-17 10:48:00 -04:00

798 lines
26 KiB
Go

// Package backend contains the main application logic.
package backend
//go:generate go tool templ generate
import (
"context"
"errors"
"fmt"
"log/slog"
"net/http"
"path/filepath"
"sync/atomic"
"time"
"github.com/wailsapp/wails/v3/pkg/application"
"yellowjacket/backend/assets"
"yellowjacket/backend/autotagservice"
"yellowjacket/backend/config"
"yellowjacket/backend/coverart"
"yellowjacket/backend/database"
"yellowjacket/backend/download"
"yellowjacket/backend/events"
"yellowjacket/backend/explore"
"yellowjacket/backend/frontendutil"
"yellowjacket/backend/home"
"yellowjacket/backend/jobs"
"yellowjacket/backend/library"
"yellowjacket/backend/maintenance"
"yellowjacket/backend/mediacontrols"
"yellowjacket/backend/player"
"yellowjacket/backend/playlist"
"yellowjacket/backend/profiling"
"yellowjacket/backend/queue"
"yellowjacket/backend/system"
"yellowjacket/backend/tagwriter"
"yellowjacket/backend/testctl"
)
// YellowJacketApp is the main application struct for Wails.
type YellowJacketApp struct {
// Services is what v3 binds to the frontend. Each entry's
// ServiceStartup runs before the app-level wiring in OnStartup.
Services []application.Service
FrontendUtil *frontendutil.FrontendUtil
logger *slog.Logger
assetHandler *assets.Handler
database *database.DB
library *library.Library
player *player.Player
playlist *playlist.Service
queue *queue.Queue
explore *explore.Service
autotag *autotagservice.Service
downloads *download.Manager
downloadSvc *download.Service
wanted *download.Reconciler
jobs *jobs.Registry
mediaControls mediacontrols.Handler
tagWriter *tagwriter.TagWriter
janitor *maintenance.Runner
appContext context.Context
appConfig *config.Config
startupErr error
// quitAsking guards the one quit-confirmation dialog; quitConfirmed
// records that the user already answered "quit anyway", so the
// Quit() issued from that callback is not questioned again.
quitAsking atomic.Bool
quitConfirmed atomic.Bool
}
// NewYellowJacketApp creates and initializes the application.
func NewYellowJacketApp(
logger *slog.Logger,
assetHandler *assets.Handler,
) (*YellowJacketApp, error) {
defer profiling.TimeOp(logger, "app.NewYellowJacketApp")()
// initialize anything that does not need access to the wails runtime here
yjApp := &YellowJacketApp{
logger: logger,
assetHandler: assetHandler,
appContext: context.Background(),
janitor: maintenance.NewRunner(logger),
}
// create database
db, err := database.NewDB(logger)
if err != nil {
return nil, fmt.Errorf("could not connect to local database: %w", err)
}
yjApp.database = db
// create config
appConfig, err := config.NewConfig(yjApp.logger)
if err != nil {
return nil, fmt.Errorf("could not get config: %w", err)
}
yjApp.appConfig = appConfig
// create frontendUtil
feUtil, err := frontendutil.NewFrontendUtil()
if err != nil {
return nil, fmt.Errorf("could not create frontendUtil: %w", err)
}
yjApp.FrontendUtil = feUtil
lib, err := library.NewLibrary(
yjApp.appContext,
yjApp.logger,
yjApp.appConfig.Library,
yjApp.database,
)
if err != nil {
return nil, fmt.Errorf("could not create library: %w", err)
}
yjApp.library = lib
// create cover art handler
coverHandler, err := coverart.NewHandler()
if err != nil {
return nil, fmt.Errorf("could not create cover art handler: %w", err)
}
yjApp.assetHandler.RegisterHandler(coverart.PathPrefix, coverHandler)
// Register artist image handler for serving cached artist photos.
artistImgDir, err := system.GetUserDataDirPath()
if err == nil {
artistImgHandler := http.StripPrefix(
"/artist-images/",
http.FileServer(http.Dir(filepath.Join(artistImgDir, "artist-images"))),
)
yjApp.assetHandler.RegisterHandler("/artist-images/", artistImgHandler)
}
// Dev-only /__test/ control surface: the residue of harness work the
// browser cannot reach (snapshot/restore the DB mid-run, force a
// backend event). Compiled out of non-dev builds entirely, and even
// in a dev build it registers nothing unless YJ_TESTCTL=1. The
// context is read lazily because it only exists after OnStartup.
testctl.Register(yjApp.assetHandler, testctl.Deps{
Logger: logger,
DB: yjApp.database,
Context: func() context.Context { return yjApp.appContext },
})
// create playlist service
yjApp.playlist = playlist.NewService(
yjApp.logger, yjApp.database, yjApp.appConfig,
)
yjApp.playlist.SetFavoritesConfig(yjApp.appConfig)
// create queue (before wails.Run so it can be bound)
yjApp.queue = queue.NewQueue(yjApp.logger, yjApp.database)
// create player (before wails.Run so it can be bound;
// speaker hardware is initialized later in OnStartup)
yjApp.player = player.NewPlayer(
yjApp.logger.WithGroup("player"), yjApp.database,
)
// create tag writer
yjApp.tagWriter = tagwriter.NewTagWriter(
yjApp.logger,
yjApp.database,
&playerAdapter{p: yjApp.player},
yjApp.library,
)
// create explore service
yjApp.explore = explore.NewExploreService(
yjApp.logger.WithGroup("explore"), yjApp.database,
)
// create the background job registry and wire it into the
// subsystems that run long jobs, so scans and index builds all
// report through one surface.
yjApp.jobs = jobs.NewRegistry(
yjApp.logger.WithGroup("jobs"),
jobs.NewStore(yjApp.database, yjApp.logger.WithGroup("jobs")),
)
yjApp.library.SetJobRegistry(yjApp.jobs)
yjApp.explore.SetJobRegistry(yjApp.jobs)
// Whether this connection is one to spend ~0.6 GB of catalog on
// (plan 016 B4). The probe is injected from here because `explore` is
// imported by `cmd/indexbuild`, which must not link Wails: naming
// `application` there is what `TestIndexToolsDoNotImportWails`
// forbids.
//
// `application.Mobile`, not `application.Android`: the latter exists
// only under the `android` build tag, while `Mobile` is the portable
// name whose desktop implementation is a stub returning "" — which
// parses to "unknown" and refuses nothing. Plan 016 named the tagged
// one; this is the same call by the name every build has.
yjApp.explore.SetNetworkPolicy(
func() explore.Network {
return explore.ParseNetworkJSON(application.Mobile.NetworkJSON())
},
yjApp.appConfig.GetAllowMeteredCatalogDownload,
)
// Let the release prefetch skip albums the user already owns in
// full — those open with no catalog call at all, so warming their
// tracklists spends the most expensive request in the app on
// nothing. Injected because neither package imports the other.
yjApp.explore.SetAlbumComplete(func(albumID int64) bool {
c, err := yjApp.library.GetAlbumCompleteness(albumID)
return err == nil && c.Known && c.Complete
})
// create autotag service (depends on explore + tagWriter)
yjApp.autotag = autotagservice.NewService(
yjApp.logger.WithGroup("autotag"),
yjApp.database,
yjApp.explore,
yjApp.tagWriter,
)
yjApp.autotag.SetJobRegistry(yjApp.jobs)
// Create the download subsystem. Acquiring music is optional: a
// failure here (unwritable data dir, say) must not stop the app
// from playing the library the user already has, so it is logged
// and the feature stays unavailable rather than fatal.
if err := yjApp.initDownloads(); err != nil {
yjApp.logger.Error(
"download clients unavailable", "error", err,
)
}
// application.NewService is generic over a concrete pointer type —
// the static analyser that generates bindings reads these calls, so
// a []any of the same values would generate nothing.
yjApp.Services = []application.Service{
application.NewService(yjApp.FrontendUtil),
application.NewService(yjApp.appConfig),
application.NewService(yjApp.library),
application.NewService(yjApp.playlist),
application.NewService(yjApp.queue),
application.NewService(yjApp.player),
application.NewService(yjApp.tagWriter),
application.NewService(yjApp.explore),
application.NewService(yjApp.autotag),
application.NewService(jobs.NewService(yjApp.jobs)),
application.NewService(home.NewService(
yjApp.logger.WithGroup("home"),
yjApp.database,
yjApp.library,
)),
}
if yjApp.downloadSvc != nil {
yjApp.Services = append(
yjApp.Services, application.NewService(yjApp.downloadSvc),
)
}
// Last, deliberately: services start in registration order, so this
// runs once every service above has taken its context. See
// startup.go for why the wiring is a service rather than an
// application-event hook.
yjApp.Services = append(
yjApp.Services,
application.NewService(&startupService{app: yjApp}),
)
return yjApp, nil
}
// initDownloads builds the download subsystem: staging area, secret
// store, importer and manager, plus the Wails-bound service.
func (yj *YellowJacketApp) initDownloads() error {
logger := yj.logger.WithGroup("download")
staging, err := download.NewStaging(logger)
if err != nil {
return fmt.Errorf("could not create download staging: %w", err)
}
secrets, err := download.NewFileSecretStore()
if err != nil {
return fmt.Errorf("could not create download secret store: %w", err)
}
store := download.NewStore(yj.database)
importer := download.NewImporter(logger, staging, yj.tagWriter, yj.library)
yj.downloads = download.NewManager(
logger, store, secrets, staging, importer, yj.library,
)
yj.downloads.SetJobRegistry(yj.jobs)
yj.downloadSvc = download.NewService(logger, yj.downloads, store, secrets)
// The wanted list needs the explore index to know what an artist
// released and what the library already owns, so it is wired here
// where both exist. The reconcile loop itself is not started until
// the Wails runtime is up.
yj.wanted = download.NewReconciler(
logger, store, yj.downloads, newExploreCatalog(yj.explore),
)
yj.downloadSvc.SetReconciler(yj.wanted)
return nil
}
// playerAdapter wraps *player.Player to satisfy the tagwriter.PlayerStopper
// interface, breaking the import cycle between tagwriter and player.
type playerAdapter struct{ p *player.Player }
func (a *playerAdapter) CurrentFilePath() string {
return a.p.GetCurrentTrackInfo().FilePath
}
func (a *playerAdapter) StopAndRelease() { a.p.UnloadTrack() }
// initDownloadRuntime brings the download subsystem up once the Wails
// runtime exists: it applies the user's import layout, builds providers
// from stored config, and clears staging left by a previous run.
//
// Provider construction and the sweep both touch the network and the
// filesystem, so they run in the background — a slow or unreachable
// download client must not delay the window appearing.
func (yj *YellowJacketApp) initDownloadRuntime(ctx context.Context) {
cfg := yj.appConfig.Downloads
if cfg == nil {
cfg = &download.UserConfig{}
cfg.ApplyDefaults()
}
yj.downloads.SetImportOptions(download.ImportOptions{
PathTemplate: cfg.PathTemplate,
})
yj.downloads.SetMaxConcurrent(cfg.MaxConcurrent)
yj.downloads.SetPreferences(cfg.AutoDownloadPrefs())
go func() {
if err := yj.downloads.Reload(ctx); err != nil {
yj.logger.Warn("could not load download providers", "error", err)
}
yj.downloads.Sweep(ctx)
}()
if yj.wanted == nil {
return
}
yj.wanted.SetInterval(cfg.WantedInterval())
yj.wanted.SetBatch(cfg.WantedBatch)
yj.wanted.SetOnChange(func() {
events.Emit(ctx, events.RequestsChanged)
})
yj.wanted.Start(ctx)
}
// WindowConfig returns the window configuration for use by the host.
func (yj *YellowJacketApp) WindowConfig() *config.WindowConfig {
return yj.appConfig.Window
}
// OnStartup wires the services to each other once the runtime exists.
//
// It is no longer where each service *gets* the context: every bound
// service implements v3's ServiceStartup, which the runtime calls
// before this runs. What is left here is the cross-service wiring —
// hooks, adapters and the callbacks that make one package drive
// another — which has no home inside any single service.
func (yj *YellowJacketApp) OnStartup(ctx context.Context) {
defer profiling.TimeOp(yj.logger, "app.OnStartup")()
yj.appContext = ctx
yj.playlist.EnsureDefaultPlaylist()
// Recover playlists that lost tracks from a pre-fix FullRescan.
go yj.playlist.RepopulateFromM3U()
// Backfill snapshots for smart playlists created before
// creation-time materialization existed.
go yj.playlist.MaterializeUnmaterializedSmartPlaylists()
// Initialize speaker hardware (player struct created in
// NewYellowJacketApp for Wails binding registration).
if err := yj.player.InitSpeaker(); err != nil {
yj.startupErr = errors.Join(
yj.startupErr,
fmt.Errorf("could not initialize speaker: %w", err),
)
}
// The job registry is not a bound service — it is wrapped by
// jobs.NewService for that — so it still takes the context by hand.
yj.jobs.SetContext(ctx)
if yj.downloadSvc != nil {
yj.initDownloadRuntime(ctx)
}
// Bring back jobs the user paused before the last shutdown, still
// paused. Must run before the soft scan in OnDomReady, which
// checks these records so it does not restart a paused library.
yj.library.RestorePausedScans()
yj.explore.AdoptPausedIndexBuild()
yj.queue.SetPlayer(yj.player)
yj.queue.SetFallbackSource(&queueFallbackAdapter{
config: yj.appConfig,
playlist: yj.playlist,
explore: yj.explore,
})
yj.queue.RestoreState()
// Wire cross-cutting rescan hooks so the library can
// orchestrate queue clearing and playlist restoration
// without depending on those packages directly.
yj.library.SetRescanHooks(library.RescanHooks{
PreClear: func() {
yj.queue.Clear()
// Stop the search index build so it doesn't fight
// with the rescan for DB access.
yj.explore.StopIndexBuild()
},
PostScan: func() {
yj.playlist.RestoreAllPlaylists()
// DON'T restart the index build here — queued
// library scans may still be running. The index
// build starts after ALL scans complete (via the
// scan hooks below).
},
})
// Wire scan hooks so the playlist service can resolve
// phantom tracks after each library scan completes.
yj.library.SetScanHooks(library.ScanHooks{
RepopulatePlaylists: yj.playlist.RepopulateFromM3U,
ResolvePhantoms: yj.playlist.ResolvePhantomTracksAfterScan,
OnAllScansComplete: func() {
// Fold the local library into the search index: every
// MB-verified owned artist/album/track is upserted and
// flagged in_library, straight from the library tables
// with no API calls. Deep discographies stay lazy.
yj.explore.PopulateLocalCrossReferences()
// Enrich any owned artists whose discography hasn't been
// fetched yet so their wider catalogue is searchable offline
// right after the scan. Background, bounded, resumable, and a
// no-op once every owned artist is covered.
yj.explore.BackfillLibraryDiscographies()
// Resolve any release-group MBIDs the scan could only find a
// release-level tag for (see updateMBIDs). Same shape as the
// discography backfill above: background, bounded, resumable.
yj.explore.BackfillReleaseGroupMBIDs()
// Start (or resume) the dump-based index build. Skips
// itself once the one-time import has completed, so this
// is cheap on every startup.
yj.explore.StartIndexBuild()
// Fold in any new incremental listen dumps to keep
// popularity fresh (weekly-gated, background, no API).
// No-op while the full import above is still running.
yj.explore.RefreshListenCounts()
// Refresh the lyric-search FTS index from the just-scanned
// library, then backfill any missing lyrics from LRCLIB in
// the background (bounded, resumable, idempotent).
yj.explore.RebuildLyricsIndex()
yj.explore.BackfillLibraryLyrics()
// Sweep the autotag queue for newly-discovered pending
// items so the user sees match scores ready when they
// next open the review page. The worker is idempotent
// (skips items that already have a score) and yields
// to foreground review activity.
yj.autotag.StartBackgroundPrefetch()
},
})
// Wire removal hooks so the library can stop playback and
// compact the queue during library removal without depending
// on the player or queue packages directly.
yj.library.SetRemovalHooks(library.RemovalHooks{
StopPlayback: func() { yj.player.UnloadTrack() },
CompactQueue: yj.queue.CompactAfterLibraryRemoval,
// Removal deletes owned content outside a scan, so force the
// gated library-sync steps to re-run on the next launch and
// clear stale in_library flags / orphaned lyric-index rows.
PostRemove: yj.explore.InvalidateLibrarySync,
})
// Register playback finished handler to drive queue auto-advance.
yj.player.SetPlaybackFinishedHandler(yj.queue.OnPlaybackFinished)
// Initialize OS media controls (MPRIS on desktop Linux, a
// MediaSession on Android, no-op elsewhere). The callbacks are the
// same on every platform; only what delivers them differs.
yj.mediaControls = mediacontrols.NewHandler(yj.logger)
if err := yj.mediaControls.Init(mediacontrols.Callbacks{
OnPlay: yj.queue.Play,
OnPause: func() {
if err := yj.player.Pause(); err != nil {
yj.logger.Warn("Media controls Pause failed", "err", err)
}
},
OnPlayPause: func() {
if yj.player.IsPlaying() {
if err := yj.player.Pause(); err != nil {
yj.logger.Warn(
"Media controls PlayPause(pause) failed", "err", err,
)
}
} else {
yj.queue.Play()
}
},
OnStop: func() {
if err := yj.player.Pause(); err != nil {
yj.logger.Warn("Media controls Stop failed", "err", err)
}
},
OnNext: yj.queue.Next,
OnPrevious: yj.queue.Previous,
OnSeek: func(positionSec int) {
if err := yj.player.Seek(positionSec); err != nil {
yj.logger.Warn("Media controls Seek failed", "err", err)
}
},
OnVolume: func(vol float64) {
yj.player.SetVolume(
player.UserVolume(
vol * float64(player.MaxUserVol),
),
)
},
OnDuck: yj.player.SetDuck,
}); err != nil {
yj.logger.Error(
"Failed to initialize media controls",
"err", err,
)
}
yj.player.SetMediaControls(yj.mediaControls)
}
// SaveWindowState captures the window's size while the window is still
// alive. It is registered on the WindowClosing event, because at
// shutdown there is no window left to measure.
func (yj *YellowJacketApp) SaveWindowState(window application.Window) {
if window == nil {
return
}
w, h := window.Size()
// Guard against a bogus size clobbering a good saved one. During
// teardown / hot-reload the runtime can report a zero or below-
// minimum size; persisting that would shrink the window to the
// minimum on next launch. Keep the previously-saved size instead.
if w < config.MinWidth || h < config.MinHeight {
yj.logger.Warn("window close: ignoring bogus window size",
"width", w,
"height", h,
"kept_width", yj.appConfig.Window.Width,
"kept_height", yj.appConfig.Window.Height,
)
return
}
yj.logger.Info("window close: saving window state",
"width", w,
"height", h,
"accentColor", yj.appConfig.Theme.AccentColor,
"backgroundShade", yj.appConfig.Theme.BackgroundShade,
)
yj.appConfig.Window.Width = w
yj.appConfig.Window.Height = h
if err := yj.appConfig.Save(); err != nil {
yj.logger.Error(
"Failed to save window state",
"err", err,
)
}
}
// ShouldQuit answers v3's quit veto: false keeps the app running.
//
// Quitting mid-apply cancels the service context and leaves a folder
// half-retagged with nothing recording where it stopped (errors.p4),
// which is the one case worth interrupting a quit for.
//
// The shape differs from v2's OnBeforeClose because v3's dialog is
// asynchronous — Show() returns immediately and the answer arrives on
// a button callback — so this cannot ask and answer in one call. It
// vetoes the quit, asks, and quits again from the callback if the user
// says so. quitConfirmed is what stops that second Quit() coming
// straight back here and asking a second time.
func (yj *YellowJacketApp) ShouldQuit() bool {
if yj.quitConfirmed.Load() {
return true
}
if yj.autotag == nil || !yj.autotag.WritesInFlight() {
return true
}
// A dialog already up must not spawn another on every close attempt.
if !yj.quitAsking.CompareAndSwap(false, true) {
return false
}
app := application.Get()
if app == nil {
// No runtime to ask through: never trap the user in the app.
return true
}
dialog := app.Dialog.Question()
dialog.SetTitle("Tags are still being written")
dialog.SetMessage(
"YellowJacket is rewriting tags on your files. " +
"Quitting now leaves that folder holding a mix of old and " +
"new tags.\n\nQuit anyway?",
)
quit := dialog.AddButton("Quit anyway")
quit.OnClick(func() {
yj.quitConfirmed.Store(true)
yj.quitAsking.Store(false)
app.Quit()
})
stay := dialog.AddButton("Keep writing")
stay.OnClick(func() { yj.quitAsking.Store(false) })
stay.SetAsDefault()
stay.SetAsCancel()
dialog.Show()
return false
}
// OnShutdown saves player state and cleans up resources before the
// application exits. v3 passes no context — the app is going away, so
// there is nothing left to scope work to.
func (yj *YellowJacketApp) OnShutdown() {
if yj.player != nil {
yj.player.SaveState()
}
if yj.queue != nil {
yj.queue.SaveState()
}
if yj.mediaControls != nil {
yj.mediaControls.Close()
}
}
// OnDomReady handles post-DOM initialization and startup error reporting.
// State synchronisation (player volume, track info, queue contents) is
// driven by the frontend: once its stores have registered their event
// listeners, index.ts calls Player.EmitCurrentState() and
// Queue.EmitCurrentState() via Wails bindings.
func (yj *YellowJacketApp) OnDomReady(_ context.Context) {
if yj.startupErr != nil {
yj.logger.Error("startup error", "err", yj.startupErr.Error())
// A startup failure is not a mid-write quit, so go straight out
// rather than through the ShouldQuit question.
yj.quitConfirmed.Store(true)
if app := application.Get(); app != nil {
app.Quit()
}
return
}
// Soft scan: compare file counts on disk vs DB for each library.
// Only libraries with mismatched counts get a full scan — unchanged
// libraries are silently skipped (no progress bar, no UI noise).
go func() {
if err := yj.library.SoftScanAllLibraries(); err != nil {
yj.logger.Error("soft scan failed", "err", err)
}
// If no scans were queued (library unchanged), start the
// index build directly. If scans WERE queued, the
// OnAllScansComplete hook starts it after they finish.
if yj.library.GetScanQueueLength() == 0 && !yj.library.IsScanActive() {
// Keep the search index's in_library flags and owned-entity
// rows in sync. Gated: the library is unchanged here, so
// this only does work on the first launch after an upgrade
// or index wipe — steady-state launches skip the write burst.
yj.explore.PopulateLocalCrossReferencesIfNeeded()
yj.explore.StartIndexBuild()
// Weekly-gated incremental popularity refresh (background,
// no API). No-op while the full import is running.
yj.explore.RefreshListenCounts()
// Same for the lyric-search index. The backfill keeps it in
// sync incrementally, so on an unchanged library the full
// rebuild is redundant and gated out; the backfill still runs
// to fill any remaining gaps.
yj.explore.RebuildLyricsIndexIfNeeded()
yj.explore.BackfillLibraryLyrics()
// Continue enriching any owned artists still missing their
// discography (e.g. a prior run was capped or interrupted).
// Cheap no-op once every owned artist is covered.
yj.explore.BackfillLibraryDiscographies()
// Same continuation for release-group MBID resolution.
yj.explore.BackfillReleaseGroupMBIDs()
}
// Kick off the autotag prefetch worker so any unscored
// pending items get their match scores filled in while
// the user does other things. Idempotent — re-running on
// every app launch is fine; previously-scored items are
// skipped (the worker filters score IS NULL).
yj.autotag.StartBackgroundPrefetch()
// Start the janitor last: its sweeps compare against live data,
// so running them after the scan and index work has settled
// avoids deleting something a running import is about to
// reference. Each job enforces its own minimum interval, so the
// daily tick is a cheap no-op most of the time.
yj.startJanitor()
}()
}
// janitorTick is how often the maintenance runner wakes up. Individual
// jobs enforce their own minimum intervals, so most ticks do nothing.
const janitorTick = 6 * time.Hour
// startJanitor registers the maintenance jobs and starts the background
// runner. Every job is registered here rather than at each package's
// init, so the full set of janitorial work is one visible list — a cache
// that forgets to register is missing from this function, which is
// harder to overlook than a function nobody calls.
func (yj *YellowJacketApp) startJanitor() {
coversDir, err := coverart.CoversDir()
if err != nil {
yj.logger.Warn("janitor: could not resolve covers directory",
"err", err)
return
}
dataDir, err := system.GetUserDataDirPath()
if err != nil {
yj.logger.Warn("janitor: could not resolve user data directory",
"err", err)
return
}
yj.janitor.Register(maintenance.ExpiredHTTPCacheJob(yj.database))
yj.janitor.Register(maintenance.OrphanedCoverFilesJob(
yj.database, coversDir, library.CoverArtFileSet,
))
yj.janitor.Register(maintenance.OrphanedArtistImagesJob(
yj.database,
filepath.Join(dataDir, explore.ArtistImageDirName),
explore.ArtistImageDir,
))
yj.janitor.Register(maintenance.StrayArtistImageFilesJob(
filepath.Join(dataDir, explore.ArtistImageDirName),
explore.ArtistImageKeepNames(),
))
yj.janitor.Register(maintenance.ExpiredProxyCacheJob(
filepath.Join(dataDir, explore.CoverArtCacheDirName),
))
yj.logger.Info("janitor started", "jobs", yj.janitor.JobNames())
yj.janitor.Start(yj.appContext, janitorTick)
}