make e2e is green on chromium: 92 passed. The harness is rebuilt on
what v3 actually offers, and three of the four things it replaced turn
out to be better than what they replaced.
The headless launch is v3's own server mode. scripts/dev-headless.sh
ran a `-tags dev` binary whose app_dev.go parsed -devserver/-assetdir
out of os.Args; that file went with v2, so the harness had no server at
all. `-tags dev,server` is a first-class mode and needs no display, so
Xvfb is gone from the script and from CI.
The bridge hooks two places, neither of them EventsOn. Inbound is
window._wails.dispatchWailsEvent, wrapped by pre-creating the object
the runtime keeps and putting an accessor on the one property.
Outbound is fetch: v3 routes every runtime call through one POST, so
the bridge sees binding calls and event emits from any module, needs no
walk of an object graph, and cannot miss a call made before it looked.
__yjEvents.call posts to that endpoint by method name, so it depends on
nothing in the app's bundle and works on a page with no init script.
That is what lets seed-sandbox.sh drop playwright-cli entirely — it
drove AddLibrary through a browser only because window.go was v2's one
way in — and with it a global npm install and a second Chromium in CI.
measure.mjs and one spec lose their window.go walks and read the
bridge's log instead; e2e/support/method-ids.mjs derives id -> name
from frontend/bindings/ (phase 6b option 1, so it cannot go stale
silently). Plain .mjs because measure.mjs runs under bare node and one
derivation beats two that can disagree.
Four bugs surfaced, and the migration is how.
The cross-service wiring never ran headless. It hung off
Common.ApplicationStarted, which server mode never emits —
setupCommonEvents is an explicit no-op there — so the queue had no
TrackLoader and playing a track changed the queue and then silently did
nothing. It is a service registered last now (backend/startup.go):
services start in registration order, which is the ordering the wiring
needs, in every mode.
Six specs called SetQueue with 3 of its 4 arguments. v2 accepted that
and filled the gap; v3 answers "expects 4 arguments, got 3".
requested-badge's cleanup read window.go and returned early on
`if (!svc)` — the silent cleanup its own comment was written to
prevent, one migration later. It posts to the runtime endpoint now,
which any page can do.
SearchIndex.Search trusted a startup latch, so rows a spec staged
afterwards were unsearchable and three specs passed only when an
earlier one happened to flip it. shelves.go fixed exactly this and left
hasCatalogRows behind; the search path now uses it as the fallback,
with the latch still the fast path.
Two spec edits are deletions of assertions about v2. harness.spec
checked Object.keys(window.go) and that a bad call *hung*; it now
checks the real runtime is loaded and that the backend rejects with a
TypeError naming the argument. album-actions asserted a tracklist
legend that dcc40b1 deleted on main — that spec has been failing since,
and what replaced it is covered in frontend/test/components.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UDCbcCZQepnpSQYJ6SxxZm
775 lines
25 KiB
Go
775 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 Linux, no-op elsewhere).
|
|
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("MPRIS Pause failed", "err", err)
|
|
}
|
|
},
|
|
OnPlayPause: func() {
|
|
if yj.player.IsPlaying() {
|
|
if err := yj.player.Pause(); err != nil {
|
|
yj.logger.Warn("MPRIS PlayPause(pause) failed", "err", err)
|
|
}
|
|
} else {
|
|
yj.queue.Play()
|
|
}
|
|
},
|
|
OnStop: func() {
|
|
if err := yj.player.Pause(); err != nil {
|
|
yj.logger.Warn("MPRIS 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("MPRIS Seek failed", "err", err)
|
|
}
|
|
},
|
|
OnVolume: func(vol float64) {
|
|
yj.player.SetVolume(
|
|
player.UserVolume(
|
|
vol * float64(player.MaxUserVol),
|
|
),
|
|
)
|
|
},
|
|
}); 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)
|
|
}
|