feat: data lifecycle rewrite, download clients, wanted list, and central catalog index
Build & publish Arch package / arch-package (push) Successful in 2m12s
Search index maintenance / maintain-index (push) Successful in 2h22m28s

Ships the fresh-start schema cleanup: rebuilt explore catalog index
pipeline (dump import, artifact fetch/build, incremental listen-count
refresh), a new download subsystem (Lidarr/Prowlarr/qBittorrent/SABnzbd/
slskd/yt-dlp providers, staging, reconciliation, wanted list), and the
supporting schema/query/store changes across backend and frontend.

Also includes two smaller follow-ups: bump the central index's
rebuild-after cadence from 90 to 180 days, and remove the Explore
"library only" online/offline toggle entirely (frontend-only, no
backend counterpart) rather than carry unused UI/state.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Y2Agd9af5hE7qzti2ackiS
This commit is contained in:
2026-08-06 17:12:01 -04:00
co-authored by Claude Sonnet 5
parent d0d86f85d5
commit e190fd75b9
165 changed files with 31088 additions and 5192 deletions
+48
View File
@@ -0,0 +1,48 @@
// Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL
// This file is automatically generated. DO NOT EDIT
import {download} from '../models';
import {context} from '../models';
export function AddProvider(arg1:string,arg2:string,arg3:Record<string, string>):Promise<number>;
export function AddWant(arg1:download.WantRequest):Promise<number>;
export function Cancel(arg1:string):Promise<void>;
export function Candidates(arg1:string):Promise<Array<download.Candidate>>;
export function ClearFinished():Promise<void>;
export function ClearSatisfiedWants():Promise<void>;
export function DeleteProvider(arg1:number):Promise<void>;
export function ImportExternalWants(arg1:number,arg2:number):Promise<number>;
export function IsWanted(arg1:string,arg2:number):Promise<boolean>;
export function ListProviders():Promise<Array<download.Config>>;
export function ListRequests(arg1:number):Promise<Array<download.RequestView>>;
export function ListWants():Promise<Array<download.Want>>;
export function PauseWant(arg1:number,arg2:boolean):Promise<void>;
export function Pick(arg1:string,arg2:string):Promise<void>;
export function ProviderKinds():Promise<Array<download.Descriptor>>;
export function ReconcileWanted():Promise<download.Summary>;
export function RemoveWant(arg1:number):Promise<void>;
export function SetContext(arg1:context.Context):Promise<void>;
export function SetReconciler(arg1:download.Reconciler):Promise<void>;
export function Start(arg1:download.SearchRequest):Promise<download.StartResult>;
export function TestProvider(arg1:number):Promise<void>;
export function UpdateProvider(arg1:number,arg2:string,arg3:boolean,arg4:number,arg5:Record<string, string>):Promise<void>;
+91
View File
@@ -0,0 +1,91 @@
// @ts-check
// Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL
// This file is automatically generated. DO NOT EDIT
export function AddProvider(arg1, arg2, arg3) {
return window['go']['download']['Service']['AddProvider'](arg1, arg2, arg3);
}
export function AddWant(arg1) {
return window['go']['download']['Service']['AddWant'](arg1);
}
export function Cancel(arg1) {
return window['go']['download']['Service']['Cancel'](arg1);
}
export function Candidates(arg1) {
return window['go']['download']['Service']['Candidates'](arg1);
}
export function ClearFinished() {
return window['go']['download']['Service']['ClearFinished']();
}
export function ClearSatisfiedWants() {
return window['go']['download']['Service']['ClearSatisfiedWants']();
}
export function DeleteProvider(arg1) {
return window['go']['download']['Service']['DeleteProvider'](arg1);
}
export function ImportExternalWants(arg1, arg2) {
return window['go']['download']['Service']['ImportExternalWants'](arg1, arg2);
}
export function IsWanted(arg1, arg2) {
return window['go']['download']['Service']['IsWanted'](arg1, arg2);
}
export function ListProviders() {
return window['go']['download']['Service']['ListProviders']();
}
export function ListRequests(arg1) {
return window['go']['download']['Service']['ListRequests'](arg1);
}
export function ListWants() {
return window['go']['download']['Service']['ListWants']();
}
export function PauseWant(arg1, arg2) {
return window['go']['download']['Service']['PauseWant'](arg1, arg2);
}
export function Pick(arg1, arg2) {
return window['go']['download']['Service']['Pick'](arg1, arg2);
}
export function ProviderKinds() {
return window['go']['download']['Service']['ProviderKinds']();
}
export function ReconcileWanted() {
return window['go']['download']['Service']['ReconcileWanted']();
}
export function RemoveWant(arg1) {
return window['go']['download']['Service']['RemoveWant'](arg1);
}
export function SetContext(arg1) {
return window['go']['download']['Service']['SetContext'](arg1);
}
export function SetReconciler(arg1) {
return window['go']['download']['Service']['SetReconciler'](arg1);
}
export function Start(arg1) {
return window['go']['download']['Service']['Start'](arg1);
}
export function TestProvider(arg1) {
return window['go']['download']['Service']['TestProvider'](arg1);
}
export function UpdateProvider(arg1, arg2, arg3, arg4, arg5) {
return window['go']['download']['Service']['UpdateProvider'](arg1, arg2, arg3, arg4, arg5);
}
+13
View File
@@ -1,6 +1,7 @@
// Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL
// This file is automatically generated. DO NOT EDIT
import {explore} from '../models';
import {time} from '../models';
import {context} from '../models';
import {jobs} from '../models';
@@ -18,6 +19,8 @@ export function CAALimiter():Promise<explore.RateLimiter>;
export function CheckLibraryMBIDs(arg1:Array<string>):Promise<Record<string, string>>;
export function CoreCatalogImported():Promise<boolean>;
export function CoverArtGroupURL(arg1:string):Promise<string>;
export function CoverArtURL(arg1:string):Promise<string>;
@@ -52,6 +55,12 @@ export function GetTrackThumbnail(arg1:string,arg2:string,arg3:string,arg4:strin
export function GetTrackThumbnails(arg1:Array<explore.TrackThumbnailRequest>):Promise<Record<string, string>>;
export function IndexBaselineSeries():Promise<number>;
export function IndexImportComplete():Promise<boolean>;
export function IndexLastImported():Promise<time.Time>;
export function InvalidateIndexDiscographies():Promise<void>;
export function InvalidateLibrarySync():Promise<void>;
@@ -70,12 +79,16 @@ export function PopulateLocalCrossReferencesIfNeeded():Promise<void>;
export function PrefetchReleases(arg1:Array<string>):Promise<void>;
export function PrepareIndexRebuild():Promise<void>;
export function RebuildLyricsIndex():Promise<void>;
export function RebuildLyricsIndexIfNeeded():Promise<void>;
export function RecordSearchClick(arg1:string,arg2:string,arg3:string):Promise<void>;
export function RefreshIndexNow(arg1:time.Duration):Promise<void>;
export function RefreshListenCounts():Promise<void>;
export function ResolveReleaseGroupMBIDs(arg1:Array<string>):Promise<Record<string, string>>;
+24
View File
@@ -30,6 +30,10 @@ export function CheckLibraryMBIDs(arg1) {
return window['go']['explore']['Service']['CheckLibraryMBIDs'](arg1);
}
export function CoreCatalogImported() {
return window['go']['explore']['Service']['CoreCatalogImported']();
}
export function CoverArtGroupURL(arg1) {
return window['go']['explore']['Service']['CoverArtGroupURL'](arg1);
}
@@ -98,6 +102,18 @@ export function GetTrackThumbnails(arg1) {
return window['go']['explore']['Service']['GetTrackThumbnails'](arg1);
}
export function IndexBaselineSeries() {
return window['go']['explore']['Service']['IndexBaselineSeries']();
}
export function IndexImportComplete() {
return window['go']['explore']['Service']['IndexImportComplete']();
}
export function IndexLastImported() {
return window['go']['explore']['Service']['IndexLastImported']();
}
export function InvalidateIndexDiscographies() {
return window['go']['explore']['Service']['InvalidateIndexDiscographies']();
}
@@ -134,6 +150,10 @@ export function PrefetchReleases(arg1) {
return window['go']['explore']['Service']['PrefetchReleases'](arg1);
}
export function PrepareIndexRebuild() {
return window['go']['explore']['Service']['PrepareIndexRebuild']();
}
export function RebuildLyricsIndex() {
return window['go']['explore']['Service']['RebuildLyricsIndex']();
}
@@ -146,6 +166,10 @@ export function RecordSearchClick(arg1, arg2, arg3) {
return window['go']['explore']['Service']['RecordSearchClick'](arg1, arg2, arg3);
}
export function RefreshIndexNow(arg1) {
return window['go']['explore']['Service']['RefreshIndexNow'](arg1);
}
export function RefreshListenCounts() {
return window['go']['explore']['Service']['RefreshListenCounts']();
}
+586 -3
View File
@@ -287,6 +287,571 @@ export namespace autotagservice {
}
export namespace download {
export class QualityScore {
overall: number;
formatRank: number;
bitrate: number;
health: number;
priority: number;
mixed: boolean;
static createFrom(source: any = {}) {
return new QualityScore(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.overall = source["overall"];
this.formatRank = source["formatRank"];
this.bitrate = source["bitrate"];
this.health = source["health"];
this.priority = source["priority"];
this.mixed = source["mixed"];
}
}
export class MatchScore {
overall: number;
titleFit: number;
artistFit: number;
albumFit: number;
completeness: number;
anchored: boolean;
static createFrom(source: any = {}) {
return new MatchScore(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.overall = source["overall"];
this.titleFit = source["titleFit"];
this.artistFit = source["artistFit"];
this.albumFit = source["albumFit"];
this.completeness = source["completeness"];
this.anchored = source["anchored"];
}
}
export class CandidateFile {
path: string;
size: number;
format: string;
bitrate?: number;
isAudio: boolean;
matchedTo?: number;
static createFrom(source: any = {}) {
return new CandidateFile(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.path = source["path"];
this.size = source["size"];
this.format = source["format"];
this.bitrate = source["bitrate"];
this.isAudio = source["isAudio"];
this.matchedTo = source["matchedTo"];
}
}
export class Candidate {
id: string;
providerId: number;
kind: string;
protocol: string;
title: string;
artist?: string;
origin?: string;
files: CandidateFile[];
totalSize: number;
health: number;
match: MatchScore;
quality: QualityScore;
score: number;
static createFrom(source: any = {}) {
return new Candidate(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.id = source["id"];
this.providerId = source["providerId"];
this.kind = source["kind"];
this.protocol = source["protocol"];
this.title = source["title"];
this.artist = source["artist"];
this.origin = source["origin"];
this.files = this.convertValues(source["files"], CandidateFile);
this.totalSize = source["totalSize"];
this.health = source["health"];
this.match = this.convertValues(source["match"], MatchScore);
this.quality = this.convertValues(source["quality"], QualityScore);
this.score = source["score"];
}
convertValues(a: any, classs: any, asMap: boolean = false): any {
if (!a) {
return a;
}
if (a.slice && a.map) {
return (a as any[]).map(elem => this.convertValues(elem, classs));
} else if ("object" === typeof a) {
if (asMap) {
for (const key of Object.keys(a)) {
a[key] = new classs(a[key]);
}
return a;
}
return new classs(a);
}
return a;
}
}
export class Caps {
canSearch: boolean;
canTransport: boolean;
canDelegate: boolean;
canList: boolean;
canResume: boolean;
canCancel: boolean;
reportsSize: boolean;
transports: string[];
static createFrom(source: any = {}) {
return new Caps(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.canSearch = source["canSearch"];
this.canTransport = source["canTransport"];
this.canDelegate = source["canDelegate"];
this.canList = source["canList"];
this.canResume = source["canResume"];
this.canCancel = source["canCancel"];
this.reportsSize = source["reportsSize"];
this.transports = source["transports"];
}
}
export class Config {
id: number;
kind: string;
name: string;
enabled: boolean;
priority: number;
settings: Record<string, string>;
static createFrom(source: any = {}) {
return new Config(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.id = source["id"];
this.kind = source["kind"];
this.name = source["name"];
this.enabled = source["enabled"];
this.priority = source["priority"];
this.settings = source["settings"];
}
}
export class Field {
key: string;
label: string;
placeholder?: string;
help?: string;
secret: boolean;
required: boolean;
default?: string;
static createFrom(source: any = {}) {
return new Field(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.key = source["key"];
this.label = source["label"];
this.placeholder = source["placeholder"];
this.help = source["help"];
this.secret = source["secret"];
this.required = source["required"];
this.default = source["default"];
}
}
export class Descriptor {
kind: string;
name: string;
summary: string;
caps: Caps;
fields: Field[];
requiresExternal?: string;
static createFrom(source: any = {}) {
return new Descriptor(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.kind = source["kind"];
this.name = source["name"];
this.summary = source["summary"];
this.caps = this.convertValues(source["caps"], Caps);
this.fields = this.convertValues(source["fields"], Field);
this.requiresExternal = source["requiresExternal"];
}
convertValues(a: any, classs: any, asMap: boolean = false): any {
if (!a) {
return a;
}
if (a.slice && a.map) {
return (a as any[]).map(elem => this.convertValues(elem, classs));
} else if ("object" === typeof a) {
if (asMap) {
for (const key of Object.keys(a)) {
a[key] = new classs(a[key]);
}
return a;
}
return new classs(a);
}
return a;
}
}
export class ExpectedTrack {
position: number;
discNumber: number;
title: string;
artist: string;
lengthMillis: number;
static createFrom(source: any = {}) {
return new ExpectedTrack(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.position = source["position"];
this.discNumber = source["discNumber"];
this.title = source["title"];
this.artist = source["artist"];
this.lengthMillis = source["lengthMillis"];
}
}
export class Item {
id: string;
requestId: string;
providerId: number;
transportId?: number;
externalId?: string;
candidate: Candidate;
state: string;
bytesDone: number;
bytesTotal: number;
error?: string;
createdAt: time.Time;
updatedAt: time.Time;
static createFrom(source: any = {}) {
return new Item(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.id = source["id"];
this.requestId = source["requestId"];
this.providerId = source["providerId"];
this.transportId = source["transportId"];
this.externalId = source["externalId"];
this.candidate = this.convertValues(source["candidate"], Candidate);
this.state = source["state"];
this.bytesDone = source["bytesDone"];
this.bytesTotal = source["bytesTotal"];
this.error = source["error"];
this.createdAt = this.convertValues(source["createdAt"], time.Time);
this.updatedAt = this.convertValues(source["updatedAt"], time.Time);
}
convertValues(a: any, classs: any, asMap: boolean = false): any {
if (!a) {
return a;
}
if (a.slice && a.map) {
return (a as any[]).map(elem => this.convertValues(elem, classs));
} else if ("object" === typeof a) {
if (asMap) {
for (const key of Object.keys(a)) {
a[key] = new classs(a[key]);
}
return a;
}
return new classs(a);
}
return a;
}
}
export class Reconciler {
static createFrom(source: any = {}) {
return new Reconciler(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
}
}
export class RequestView {
id: string;
releaseMbid?: string;
releaseGroupMbid?: string;
recordingMbid?: string;
wantId?: number;
source?: string;
artist: string;
album: string;
query?: string;
expected?: ExpectedTrack[];
libraryId: number;
createdAt: time.Time;
state: string;
error?: string;
items: Item[];
static createFrom(source: any = {}) {
return new RequestView(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.id = source["id"];
this.releaseMbid = source["releaseMbid"];
this.releaseGroupMbid = source["releaseGroupMbid"];
this.recordingMbid = source["recordingMbid"];
this.wantId = source["wantId"];
this.source = source["source"];
this.artist = source["artist"];
this.album = source["album"];
this.query = source["query"];
this.expected = this.convertValues(source["expected"], ExpectedTrack);
this.libraryId = source["libraryId"];
this.createdAt = this.convertValues(source["createdAt"], time.Time);
this.state = source["state"];
this.error = source["error"];
this.items = this.convertValues(source["items"], Item);
}
convertValues(a: any, classs: any, asMap: boolean = false): any {
if (!a) {
return a;
}
if (a.slice && a.map) {
return (a as any[]).map(elem => this.convertValues(elem, classs));
} else if ("object" === typeof a) {
if (asMap) {
for (const key of Object.keys(a)) {
a[key] = new classs(a[key]);
}
return a;
}
return new classs(a);
}
return a;
}
}
export class SearchRequest {
libraryId: number;
releaseMbid: string;
releaseGroupMbid: string;
artist: string;
album: string;
query: string;
expected: ExpectedTrack[];
static createFrom(source: any = {}) {
return new SearchRequest(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.libraryId = source["libraryId"];
this.releaseMbid = source["releaseMbid"];
this.releaseGroupMbid = source["releaseGroupMbid"];
this.artist = source["artist"];
this.album = source["album"];
this.query = source["query"];
this.expected = this.convertValues(source["expected"], ExpectedTrack);
}
convertValues(a: any, classs: any, asMap: boolean = false): any {
if (!a) {
return a;
}
if (a.slice && a.map) {
return (a as any[]).map(elem => this.convertValues(elem, classs));
} else if ("object" === typeof a) {
if (asMap) {
for (const key of Object.keys(a)) {
a[key] = new classs(a[key]);
}
return a;
}
return new classs(a);
}
return a;
}
}
export class StartResult {
requestId: string;
candidates: Candidate[];
autoPicked: boolean;
static createFrom(source: any = {}) {
return new StartResult(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.requestId = source["requestId"];
this.candidates = this.convertValues(source["candidates"], Candidate);
this.autoPicked = source["autoPicked"];
}
convertValues(a: any, classs: any, asMap: boolean = false): any {
if (!a) {
return a;
}
if (a.slice && a.map) {
return (a as any[]).map(elem => this.convertValues(elem, classs));
} else if ("object" === typeof a) {
if (asMap) {
for (const key of Object.keys(a)) {
a[key] = new classs(a[key]);
}
return a;
}
return new classs(a);
}
return a;
}
}
export class Summary {
expanded: number;
satisfied: number;
attempted: number;
started: number;
synced: number;
static createFrom(source: any = {}) {
return new Summary(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.expanded = source["expanded"];
this.satisfied = source["satisfied"];
this.attempted = source["attempted"];
this.started = source["started"];
this.synced = source["synced"];
}
}
export class Want {
id: number;
mbid: string;
entity: string;
libraryId: number;
artist: string;
title: string;
scope: string;
secondary: boolean;
state: string;
parentId?: number;
attempts: number;
lastError?: string;
lastTriedAt?: time.Time;
nextTryAt?: time.Time;
externalIds?: Record<string, string>;
createdAt: time.Time;
updatedAt: time.Time;
static createFrom(source: any = {}) {
return new Want(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.id = source["id"];
this.mbid = source["mbid"];
this.entity = source["entity"];
this.libraryId = source["libraryId"];
this.artist = source["artist"];
this.title = source["title"];
this.scope = source["scope"];
this.secondary = source["secondary"];
this.state = source["state"];
this.parentId = source["parentId"];
this.attempts = source["attempts"];
this.lastError = source["lastError"];
this.lastTriedAt = this.convertValues(source["lastTriedAt"], time.Time);
this.nextTryAt = this.convertValues(source["nextTryAt"], time.Time);
this.externalIds = source["externalIds"];
this.createdAt = this.convertValues(source["createdAt"], time.Time);
this.updatedAt = this.convertValues(source["updatedAt"], time.Time);
}
convertValues(a: any, classs: any, asMap: boolean = false): any {
if (!a) {
return a;
}
if (a.slice && a.map) {
return (a as any[]).map(elem => this.convertValues(elem, classs));
} else if ("object" === typeof a) {
if (asMap) {
for (const key of Object.keys(a)) {
a[key] = new classs(a[key]);
}
return a;
}
return new classs(a);
}
return a;
}
}
export class WantRequest {
mbid: string;
entity: string;
libraryId: number;
artist: string;
title: string;
scope: string;
secondary: boolean;
static createFrom(source: any = {}) {
return new WantRequest(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.mbid = source["mbid"];
this.entity = source["entity"];
this.libraryId = source["libraryId"];
this.artist = source["artist"];
this.title = source["title"];
this.scope = source["scope"];
this.secondary = source["secondary"];
}
}
}
export namespace explore {
export class TierStatus {
@@ -295,6 +860,7 @@ export namespace explore {
total: number;
completed: number;
error?: string;
detail?: string;
static createFrom(source: any = {}) {
return new TierStatus(source);
@@ -307,6 +873,7 @@ export namespace explore {
this.total = source["total"];
this.completed = source["completed"];
this.error = source["error"];
this.detail = source["detail"];
}
}
export class IndexStatus {
@@ -1635,8 +2202,7 @@ export namespace sqlcgen {
ID: number;
Name: string;
Path: string;
// Go type: time
CreatedAt: any;
CreatedAt: time.Time;
AutotagWarningAcked: number;
static createFrom(source: any = {}) {
@@ -1648,7 +2214,7 @@ export namespace sqlcgen {
this.ID = source["ID"];
this.Name = source["Name"];
this.Path = source["Path"];
this.CreatedAt = this.convertValues(source["CreatedAt"], null);
this.CreatedAt = this.convertValues(source["CreatedAt"], time.Time);
this.AutotagWarningAcked = source["AutotagWarningAcked"];
}
@@ -1730,6 +2296,23 @@ export namespace tagwriter {
}
export namespace time {
export class Time {
static createFrom(source: any = {}) {
return new Time(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
}
}
}
export namespace tracklist {
export class Column {
+2
View File
@@ -12,3 +12,5 @@ export function SetContext(arg1:context.Context):Promise<void>;
export function WriteTrackTags(arg1:number,arg2:tagwriter.TagChanges):Promise<void>;
export function WriteTrackTagsByPath(arg1:string,arg2:tagwriter.TagChanges):Promise<void>;
export function WriteUntrackedFileTags(arg1:string,arg2:tagwriter.TagChanges):Promise<void>;
@@ -21,3 +21,7 @@ export function WriteTrackTags(arg1, arg2) {
export function WriteTrackTagsByPath(arg1, arg2) {
return window['go']['tagwriter']['TagWriter']['WriteTrackTagsByPath'](arg1, arg2);
}
export function WriteUntrackedFileTags(arg1, arg2) {
return window['go']['tagwriter']['TagWriter']['WriteUntrackedFileTags'](arg1, arg2);
}