feat(12-02): replace config-page library section with full library management UI

- Add library list with name, path, track count per library
- Add Library button opens folder picker, auto-creates library
- Inline rename with Enter/Escape via overflow menu
- Removal confirmation dialog with real impact counts (tracks, playlists, queue)
- Toast notification with removal summary after library removal
- Add GetAllLibrariesWithTrackCounts + Info type to backend Library struct
- Add Wails binding stubs for AddLibrary, RenameLibrary, RemoveLibrary, GetRemovalImpact
- Add Info, RemovalImpact, RemovalSummary types to models.ts
- Remove old single-directory library config UI (GetLibraryDirectory/SetLibraryDirectory)
This commit is contained in:
2026-03-12 19:50:59 -04:00
parent 27c773b3d0
commit ffc5d9639c
5 changed files with 575 additions and 59 deletions
+39
View File
@@ -443,3 +443,42 @@ func (l *Library) GetAllGenresWithCounts() (
return genres, nil return genres, nil
} }
// Info contains library metadata enriched with track count
// for the frontend settings UI.
type Info struct {
ID int64 `json:"id"`
Name string `json:"name"`
Path string `json:"path"`
TrackCount int64 `json:"trackCount"`
}
// GetAllLibrariesWithTrackCounts returns all libraries with their
// audio file counts. Typically 1-5 libraries so the loop is trivial.
func (l *Library) GetAllLibrariesWithTrackCounts() ([]Info, error) {
libs, err := l.db.Queries.GetAllLibraries(l.ctx)
if err != nil {
return nil, fmt.Errorf("could not get libraries: %w", err)
}
result := make([]Info, 0, len(libs))
for _, lib := range libs {
count, countErr := l.db.Queries.CountAudioFilesByLibrary(l.ctx, lib.ID)
if countErr != nil {
l.logger.Error("could not count tracks for library",
"libraryID", lib.ID, "error", countErr)
count = 0
}
result = append(result, Info{
ID: lib.ID,
Name: lib.Name,
Path: lib.Path,
TrackCount: count,
})
}
return result, nil
}
@@ -8,16 +8,21 @@ import {
CancelCurrentScan, CancelCurrentScan,
CancelAllScans, CancelAllScans,
ScanAllLibraries, ScanAllLibraries,
ScanLibrary,
PauseScan, PauseScan,
ResumeScan, ResumeScan,
AddLibrary,
RenameLibrary,
RemoveLibrary,
GetRemovalImpact,
GetAllLibrariesWithTrackCounts,
} from '@go/library/Library'; } from '@go/library/Library';
import { import {
GetLibraryDirectory,
SetLibraryDirectory,
GetScanConcurrency, GetScanConcurrency,
SetScanConcurrency, SetScanConcurrency,
} from '@go/config/Config'; } from '@go/config/Config';
import { DirectoryPicker } from '@go/frontendutil/FrontendUtil'; import { DirectoryPicker } from '@go/frontendutil/FrontendUtil';
import type { library } from '@go/models';
import { ThemeController } from '@store/controllers/theme-controller'; import { ThemeController } from '@store/controllers/theme-controller';
import { TrackListController } from '@store/controllers/tracklist-controller'; import { TrackListController } from '@store/controllers/tracklist-controller';
import { FavoritesController } from '@store/controllers/favorites-controller'; import { FavoritesController } from '@store/controllers/favorites-controller';
@@ -337,8 +342,15 @@ export class ConfigPage extends LitElement {
@state() private playlists: playlist.Summary[] = []; @state() private playlists: playlist.Summary[] = [];
// --- Library state --- // --- Library state ---
@state() private libraryDirectory = ''; @state() private libraries: library.Info[] = [];
@state() private selectedDirectory = ''; @state() private editingLibraryId: number | null = null;
@state() private editingName = '';
@state() private removingLibraryId: number | null = null;
@state() private removalImpact: library.RemovalImpact | null = null;
@state() private isRemoving = false;
@state() private toastMessage = '';
@state() private toastVisible = false;
@state() private activeMenuId: number | null = null;
@state() private scanning = false; @state() private scanning = false;
@state() private statusMessage = ''; @state() private statusMessage = '';
@state() private scanProgress: ScanProgress | null = null; @state() private scanProgress: ScanProgress | null = null;
@@ -357,6 +369,7 @@ export class ConfigPage extends LitElement {
existingAction: string; existingAction: string;
} | null = null; } | null = null;
private toastTimer?: ReturnType<typeof setTimeout>;
private cancelScanStarted?: () => void; private cancelScanStarted?: () => void;
private cancelScanProgress?: () => void; private cancelScanProgress?: () => void;
private cancelScanComplete?: () => void; private cancelScanComplete?: () => void;
@@ -365,6 +378,9 @@ export class ConfigPage extends LitElement {
private cancelScanCancelled?: () => void; private cancelScanCancelled?: () => void;
private cancelScanQueued?: () => void; private cancelScanQueued?: () => void;
private cancelScanQueueDrained?: () => void; private cancelScanQueueDrained?: () => void;
private cancelLibraryAdded?: () => void;
private cancelLibraryRenamed?: () => void;
private cancelLibraryRemoved?: () => void;
static override styles = css` static override styles = css`
:host { :host {
@@ -812,6 +828,182 @@ export class ConfigPage extends LitElement {
color: var(--yj-accent, #ffd43b); color: var(--yj-accent, #ffd43b);
} }
/* Library management */
.library-list {
display: flex;
flex-direction: column;
gap: 0;
}
.library-row {
display: flex;
align-items: center;
gap: 0.75em;
padding: 0.6em 0.5em;
border-bottom: 1px solid var(--yj-border-subtle, #333);
}
.library-row:last-child {
border-bottom: none;
}
.library-row:hover {
background: var(--yj-bg-elevated, #343a40);
border-radius: 4px;
}
.library-name {
flex: 1;
cursor: pointer;
font-size: 0.9em;
font-weight: 500;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.library-name:hover {
color: var(--yj-accent, #ffd43b);
}
.library-path {
color: var(--yj-text-tertiary, #868e96);
font-size: var(--yj-text-sm, 13px);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
max-width: 300px;
}
.library-count {
color: var(--yj-text-tertiary, #868e96);
font-size: var(--yj-text-sm, 13px);
white-space: nowrap;
flex-shrink: 0;
}
.edit-input {
flex: 1;
padding: 0.3em 0.5em;
background: var(--yj-bg-elevated, #343a40);
border: 1px solid var(--yj-accent, #ffd43b);
border-radius: 4px;
color: var(--yj-text-primary, #fff);
font-size: 0.9em;
font-family: inherit;
outline: none;
}
.overflow-wrapper {
position: relative;
flex-shrink: 0;
}
.overflow-btn {
cursor: pointer;
border: none;
background: transparent;
color: var(--yj-text-tertiary, #868e96);
font-size: 1.1em;
padding: 0.2em 0.4em;
letter-spacing: 2px;
border-radius: 4px;
}
.overflow-btn:hover {
background: var(--yj-bg-overlay, #495057);
color: var(--yj-text-primary, #fff);
}
.overflow-menu {
position: absolute;
top: 100%;
right: 0;
background: var(--yj-bg-surface, #2a2a2a);
border: 1px solid var(--yj-border, #444);
border-radius: 6px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
z-index: 100;
min-width: 120px;
padding: 4px 0;
}
.overflow-item {
padding: 0.5em 1em;
font-size: var(--yj-text-sm, 13px);
cursor: pointer;
white-space: nowrap;
}
.overflow-item:hover {
background: var(--yj-bg-elevated, #343a40);
}
.overflow-item--danger {
color: var(--yj-error, #e03131);
}
.overflow-item--danger:hover {
background: color-mix(
in srgb,
var(--yj-error, #e03131) 10%,
var(--yj-bg-elevated, #343a40)
);
}
.add-library-btn {
margin-top: 0.75em;
margin-bottom: 1em;
}
.toast {
position: fixed;
bottom: 80px;
left: 50%;
transform: translateX(-50%);
background: var(--yj-bg-surface, #2a2a2a);
color: var(--yj-text-primary, #fff);
padding: 0.75em 1.5em;
border-radius: 8px;
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.4);
font-size: var(--yj-text-sm, 13px);
z-index: 2000;
border: 1px solid var(--yj-border, #444);
animation: toast-in 0.2s ease-out;
}
@keyframes toast-in {
from {
opacity: 0;
transform: translateX(-50%)
translateY(10px);
}
to {
opacity: 1;
transform: translateX(-50%)
translateY(0);
}
}
.spinner {
display: inline-block;
width: 14px;
height: 14px;
border: 2px solid rgba(255, 255, 255, 0.3);
border-top-color: #fff;
border-radius: 50%;
animation: spin 0.6s linear infinite;
vertical-align: middle;
margin-right: 4px;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
/* Keyboard shortcuts section */ /* Keyboard shortcuts section */
.shortcut-category { .shortcut-category {
margin-bottom: 16px; margin-bottom: 16px;
@@ -880,7 +1072,7 @@ export class ConfigPage extends LitElement {
override connectedCallback(): void { override connectedCallback(): void {
super.connectedCallback(); super.connectedCallback();
this.loadLibraryConfig(); void this.loadLibraries();
void this.loadPlaylists(); void this.loadPlaylists();
this.scrollMode = this.scrollMode =
localStorage.getItem(SCROLL_STORAGE_KEY) || 'hover'; localStorage.getItem(SCROLL_STORAGE_KEY) || 'hover';
@@ -917,6 +1109,20 @@ export class ConfigPage extends LitElement {
Events.LibraryScanQueueDrained, Events.LibraryScanQueueDrained,
this.handleScanQueueDrained, this.handleScanQueueDrained,
); );
this.cancelLibraryAdded = EventsOn(
Events.LibraryAdded,
() => void this.loadLibraries(),
);
this.cancelLibraryRenamed = EventsOn(
Events.LibraryRenamed,
() => void this.loadLibraries(),
);
this.cancelLibraryRemoved = EventsOn(
Events.LibraryRemoved,
() => void this.loadLibraries(),
);
document.addEventListener('click', this.handleDocumentClick);
} }
override disconnectedCallback(): void { override disconnectedCallback(): void {
@@ -929,21 +1135,27 @@ export class ConfigPage extends LitElement {
this.cancelScanCancelled?.(); this.cancelScanCancelled?.();
this.cancelScanQueued?.(); this.cancelScanQueued?.();
this.cancelScanQueueDrained?.(); this.cancelScanQueueDrained?.();
this.cancelLibraryAdded?.();
this.cancelLibraryRenamed?.();
this.cancelLibraryRemoved?.();
document.removeEventListener('click', this.handleDocumentClick);
if (this.toastTimer) clearTimeout(this.toastTimer);
} }
private async loadLibraryConfig(): Promise<void> { private async loadLibraries(): Promise<void> {
try { try {
const [dir, mode] = await Promise.all([ const [libs, mode] = await Promise.all([
GetLibraryDirectory(), GetAllLibrariesWithTrackCounts(),
GetScanConcurrency(), GetScanConcurrency(),
]); ]);
this.libraryDirectory = dir; this.libraries = libs ?? [];
this.selectedDirectory = dir;
this.concurrencyMode = mode; this.concurrencyMode = mode;
} catch (err) { } catch (err) {
console.error( console.error(
'Failed to load library config:', 'Failed to load libraries:',
err, err,
); );
} }
@@ -1072,37 +1284,121 @@ export class ConfigPage extends LitElement {
this.cancelMetrics = null; this.cancelMetrics = null;
}; };
private handleDirectoryBrowse = async (): Promise<void> => { private handleAddLibrary = async (): Promise<void> => {
try { try {
const dir = await DirectoryPicker(); const dir = await DirectoryPicker();
if (dir) { if (dir) {
this.selectedDirectory = dir; await AddLibrary(dir);
} }
} catch (err) { } catch (err) {
console.error( console.error(
'Directory picker failed:', 'Failed to add library:',
err, err,
); );
} }
}; };
private handleSaveDirectory = async (): Promise<void> => { private handleStartRename = (id: number, name: string): void => {
if (!this.selectedDirectory) return; this.editingLibraryId = id;
this.editingName = name;
this.activeMenuId = null;
};
private handleRenameKeyDown = async (e: KeyboardEvent): Promise<void> => {
if (e.key === 'Enter') {
e.preventDefault();
if (this.editingLibraryId !== null && this.editingName.trim()) {
try { try {
await SetLibraryDirectory( await RenameLibrary(this.editingLibraryId, this.editingName.trim());
this.selectedDirectory,
);
this.libraryDirectory =
this.selectedDirectory;
this.statusMessage =
'Library directory saved. A scan will start automatically if the directory changed.';
} catch (err) { } catch (err) {
this.statusMessage = `Failed to save directory: ${err}`; console.error('Failed to rename library:', err);
}
}
this.editingLibraryId = null;
this.editingName = '';
} else if (e.key === 'Escape') {
this.editingLibraryId = null;
this.editingName = '';
} }
}; };
private handleRenameInput = (e: InputEvent): void => {
this.editingName = (e.target as HTMLInputElement).value;
};
private handleRescanLibrary = (id: number): void => {
this.activeMenuId = null;
void ScanLibrary(id);
};
private handleRemoveClick = async (id: number): Promise<void> => {
this.activeMenuId = null;
try {
const impact = await GetRemovalImpact(id);
this.removalImpact = impact;
this.removingLibraryId = id;
} catch (err) {
console.error('Failed to get removal impact:', err);
}
};
private handleConfirmRemove = async (): Promise<void> => {
if (this.removingLibraryId === null) return;
const id = this.removingLibraryId;
const lib = this.libraries.find((l) => l.id === id);
const libName = lib?.name ?? 'Library';
this.isRemoving = true;
try {
const summary = await RemoveLibrary(id);
this.removingLibraryId = null;
this.removalImpact = null;
this.isRemoving = false;
this.showToast(
`Removed '${libName}' (${summary?.tracksDeleted ?? 0} tracks deleted)`,
);
} catch (err) {
this.isRemoving = false;
console.error('Failed to remove library:', err);
}
};
private handleCancelRemove = (): void => {
this.removingLibraryId = null;
this.removalImpact = null;
this.isRemoving = false;
};
private toggleOverflowMenu = (id: number, e: Event): void => {
e.stopPropagation();
this.activeMenuId = this.activeMenuId === id ? null : id;
};
private handleDocumentClick = (): void => {
if (this.activeMenuId !== null) {
this.activeMenuId = null;
}
};
private showToast(message: string): void {
this.toastMessage = message;
this.toastVisible = true;
if (this.toastTimer) clearTimeout(this.toastTimer);
this.toastTimer = setTimeout(() => {
this.toastVisible = false;
}, 4000);
}
private handleConcurrencyChange = ( private handleConcurrencyChange = (
e: CustomEvent<ConfigFieldChangeEvent>, e: CustomEvent<ConfigFieldChangeEvent>,
): void => { ): void => {
@@ -1456,13 +1752,6 @@ export class ConfigPage extends LitElement {
// COMPUTED // COMPUTED
// =================================================================== // ===================================================================
private get directoryChanged(): boolean {
return (
this.selectedDirectory !==
this.libraryDirectory
);
}
private get hasRescanPhases(): boolean { private get hasRescanPhases(): boolean {
if (!this.metrics) return false; if (!this.metrics) return false;
@@ -1901,36 +2190,88 @@ export class ConfigPage extends LitElement {
// --- Library section --- // --- Library section ---
private renderLibrarySection() { private renderLibrarySection() {
const removingLib = this.libraries.find(
(l) => l.id === this.removingLibraryId,
);
return html` return html`
<config-section <config-section
heading="Library" heading="Libraries"
description="Configure your music library location and scanning behaviour." description="Manage your music library folders. Each library is scanned independently."
> >
<config-field <div class="library-list">
.schema=${{ ${this.libraries.map(
key: 'libraryDirectory', (lib) => html`
label: 'Library Directory', <div class="library-row">
description: ${this.editingLibraryId === lib.id
'The root directory containing your music files.',
type: 'directory' as const,
}}
.value=${this.selectedDirectory}
@config-browse=${this.handleDirectoryBrowse}
></config-field>
${this.directoryChanged
? html` ? html`
<div class="save-row"> <input
<button class="edit-input"
class="btn-success" type="text"
?disabled=${this.scanning} .value=${this.editingName}
@click=${this.handleSaveDirectory} @input=${this.handleRenameInput}
@keydown=${this.handleRenameKeyDown}
@click=${(e: Event) => e.stopPropagation()}
/>
`
: html`
<span
class="library-name"
@click=${() => this.handleStartRename(lib.id, lib.name)}
> >
Save Directory ${lib.name}
</span>
`}
<span class="library-path">${lib.path}</span>
<span class="library-count">
${lib.trackCount} tracks
</span>
<div class="overflow-wrapper">
<button
class="overflow-btn"
@click=${(e: Event) => this.toggleOverflowMenu(lib.id, e)}
>
\u22EF
</button> </button>
${this.activeMenuId === lib.id
? html`
<div
class="overflow-menu"
@click=${(e: Event) => e.stopPropagation()}
>
<div
class="overflow-item"
@click=${() => this.handleStartRename(lib.id, lib.name)}
>
Rename
</div>
<div
class="overflow-item"
@click=${() => this.handleRescanLibrary(lib.id)}
>
Rescan
</div>
<div
class="overflow-item overflow-item--danger"
@click=${() => void this.handleRemoveClick(lib.id)}
>
Remove
</div>
</div> </div>
` `
: nothing} : nothing}
</div>
</div>
`,
)}
</div>
<button
class="btn-primary add-library-btn"
@click=${this.handleAddLibrary}
>
Add Library
</button>
<config-field <config-field
.schema=${{ .schema=${{
@@ -2143,7 +2484,57 @@ export class ConfigPage extends LitElement {
</div> </div>
` `
: nothing} : nothing}
${this.removingLibraryId !== null && this.removalImpact
? html`
<div
class="cancel-dialog-overlay"
@click=${this.handleCancelRemove}
>
<div
class="cancel-dialog"
@click=${(e: Event) => e.stopPropagation()}
>
<div class="cancel-dialog-title">
Remove Library
</div>
<div class="cancel-dialog-message">
Remove '${removingLib?.name}'?
This will delete
${this.removalImpact.trackCount}
tracks, affect
${this.removalImpact.playlistsAffected}
playlists, and remove
${this.removalImpact.queueItemCount}
queue items.
</div>
<div class="cancel-dialog-actions">
<button
class="btn-ghost"
?disabled=${this.isRemoving}
@click=${this.handleCancelRemove}
>
Cancel
</button>
<button
class="btn-danger"
?disabled=${this.isRemoving}
@click=${this.handleConfirmRemove}
>
${this.isRemoving
? html`<span class="spinner"></span> Removing\u2026`
: 'Remove'}
</button>
</div>
</div>
</div>
`
: nothing}
</config-section> </config-section>
${this.toastVisible
? html`<div class="toast">${this.toastMessage}</div>`
: nothing}
`; `;
} }
+10
View File
@@ -3,6 +3,8 @@
import {library} from '../models'; import {library} from '../models';
import {context} from '../models'; import {context} from '../models';
export function AddLibrary(arg1:string):Promise<library.Info>;
export function CancelAllScans():Promise<void>; export function CancelAllScans():Promise<void>;
export function CancelCurrentScan():Promise<void>; export function CancelCurrentScan():Promise<void>;
@@ -13,6 +15,8 @@ export function FullRescan():Promise<library.ScanMetrics>;
export function GetAlbumTracks(arg1:number):Promise<Array<library.Track>>; export function GetAlbumTracks(arg1:number):Promise<Array<library.Track>>;
export function GetAllLibrariesWithTrackCounts():Promise<Array<library.Info>>;
export function GetAlbumsByArtist(arg1:number):Promise<Array<library.Album>>; export function GetAlbumsByArtist(arg1:number):Promise<Array<library.Album>>;
export function GetAllAlbums():Promise<Array<library.Album>>; export function GetAllAlbums():Promise<Array<library.Album>>;
@@ -23,6 +27,8 @@ export function GetAllGenresWithCounts():Promise<Array<library.GenreWithCount>>;
export function GetAllTracks():Promise<Array<library.Track>>; export function GetAllTracks():Promise<Array<library.Track>>;
export function GetRemovalImpact(arg1:number):Promise<library.RemovalImpact>;
export function GetScanQueueLength():Promise<number>; export function GetScanQueueLength():Promise<number>;
export function GetTracksByGenre(arg1:string):Promise<Array<library.Track>>; export function GetTracksByGenre(arg1:string):Promise<Array<library.Track>>;
@@ -33,6 +39,10 @@ export function IsScanPaused():Promise<boolean>;
export function PauseScan():Promise<void>; export function PauseScan():Promise<void>;
export function RemoveLibrary(arg1:number):Promise<library.RemovalSummary>;
export function RenameLibrary(arg1:number, arg2:string):Promise<void>;
export function QueuedLibraryNames():Promise<Array<string>>; export function QueuedLibraryNames():Promise<Array<string>>;
export function ResumeScan():Promise<void>; export function ResumeScan():Promise<void>;
+20
View File
@@ -2,6 +2,10 @@
// Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL // Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL
// This file is automatically generated. DO NOT EDIT // This file is automatically generated. DO NOT EDIT
export function AddLibrary(arg1) {
return window['go']['library']['Library']['AddLibrary'](arg1);
}
export function CancelAllScans() { export function CancelAllScans() {
return window['go']['library']['Library']['CancelAllScans'](); return window['go']['library']['Library']['CancelAllScans']();
} }
@@ -22,6 +26,10 @@ export function GetAlbumTracks(arg1) {
return window['go']['library']['Library']['GetAlbumTracks'](arg1); return window['go']['library']['Library']['GetAlbumTracks'](arg1);
} }
export function GetAllLibrariesWithTrackCounts() {
return window['go']['library']['Library']['GetAllLibrariesWithTrackCounts']();
}
export function GetAlbumsByArtist(arg1) { export function GetAlbumsByArtist(arg1) {
return window['go']['library']['Library']['GetAlbumsByArtist'](arg1); return window['go']['library']['Library']['GetAlbumsByArtist'](arg1);
} }
@@ -42,6 +50,10 @@ export function GetAllTracks() {
return window['go']['library']['Library']['GetAllTracks'](); return window['go']['library']['Library']['GetAllTracks']();
} }
export function GetRemovalImpact(arg1) {
return window['go']['library']['Library']['GetRemovalImpact'](arg1);
}
export function GetScanQueueLength() { export function GetScanQueueLength() {
return window['go']['library']['Library']['GetScanQueueLength'](); return window['go']['library']['Library']['GetScanQueueLength']();
} }
@@ -62,6 +74,14 @@ export function PauseScan() {
return window['go']['library']['Library']['PauseScan'](); return window['go']['library']['Library']['PauseScan']();
} }
export function RemoveLibrary(arg1) {
return window['go']['library']['Library']['RemoveLibrary'](arg1);
}
export function RenameLibrary(arg1, arg2) {
return window['go']['library']['Library']['RenameLibrary'](arg1, arg2);
}
export function QueuedLibraryNames() { export function QueuedLibraryNames() {
return window['go']['library']['Library']['QueuedLibraryNames'](); return window['go']['library']['Library']['QueuedLibraryNames']();
} }
+56
View File
@@ -40,6 +40,62 @@ export namespace library {
this.Name = source["Name"]; this.Name = source["Name"];
} }
} }
export class Info {
id: number;
name: string;
path: string;
trackCount: number;
static createFrom(source: any = {}) {
return new Info(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.id = source["id"];
this.name = source["name"];
this.path = source["path"];
this.trackCount = source["trackCount"];
}
}
export class RemovalImpact {
trackCount: number;
playlistsAffected: number;
queueItemCount: number;
static createFrom(source: any = {}) {
return new RemovalImpact(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.trackCount = source["trackCount"];
this.playlistsAffected = source["playlistsAffected"];
this.queueItemCount = source["queueItemCount"];
}
}
export class RemovalSummary {
tracksDeleted: number;
artistsRemoved: number;
albumsRemoved: number;
genresRemoved: number;
playlistsAffected: number;
queueItemsRemoved: number;
static createFrom(source: any = {}) {
return new RemovalSummary(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.tracksDeleted = source["tracksDeleted"];
this.artistsRemoved = source["artistsRemoved"];
this.albumsRemoved = source["albumsRemoved"];
this.genresRemoved = source["genresRemoved"];
this.playlistsAffected = source["playlistsAffected"];
this.queueItemsRemoved = source["queueItemsRemoved"];
}
}
export class GenreWithCount { export class GenreWithCount {
Name: string; Name: string;
TrackCount: number; TrackCount: number;