Files
yellowjacket/backend/app.go
T
logan da38b865fc feat(android): playback that survives the screen locking
An app that plays audio becomes a music player at the point where the
screen can lock, a call can interrupt, and the headphones can come out.
None of that existed: the foreground service was typed for media but
had no MediaSession, no transport notification and no audio focus, so
oto would happily keep writing to a stream nobody could hear.

The apparent blocker is that Wails' androidBridge* helpers are
unexported, so Go cannot call arbitrary Java. It does not need to.
StartForegroundService(json) *is* exported, and build/android/ is our
tree, so widening the JSON WailsBridge already accepts is a local edit;
coming back, WailsBridge.emitEvent lands on the application event bus,
which Go subscribes to with app.Event.On. One document out, one command
event back, and no new JNI. No new Gradle dependency either: minSdk is
21, which is exactly when android.media.session.MediaSession and
Notification.MediaStyle arrived, so androidx.media buys two
Build.VERSION branches' worth of nothing.

Four things in it are load-bearing.

**A duck is not a volume change.** Player.SetDuck holds the attenuation
as an offset and re-applies the user's level through setVolumeLocked,
so it cannot accumulate across repeated ducks and getUserVolume -- which
feeds the event, the persisted state and every relative change -- still
reports what the user chose. Writing through to the volume would let
one notification tone permanently turn the music down.

**The duck path is pre-Oreo only.** From API 26 the framework ducks the
app itself and sends no CAN_DUCK focus change; asking to be told
instead (setWillPauseWhenDucked) would mean pausing for every
notification tone, and doing both would attenuate twice.

**An unchanged payload is not an event**, the rule emitStatus already
states one package over: every push crosses JNI and re-delivers an
Intent, and the player pushes state on several paths that can agree.

**After the first start, an update is startService.** From Android 12 a
background app may not *start* a foreground service but may keep
feeding one it already has, which is every track change with the screen
off. Relatedly, every path through onStartCommand calls startForeground
-- one that returns without it is killed.

The contract with Java lives in androidpayload.go *without* the android
build tag, and is tested. Everything left in android.go is untested by
construction: make lint and make test are three tag sets on
linux/amd64, so the only thing that compiles it is the cross-compiler
in make android, and the only thing that can run it is a phone.

None of the behaviour above has been observed on a device. The APK
builds and both halves compile; that is the whole of what is verified.
2026-08-16 22:26:03 -04:00

780 lines
25 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)
// 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)
}