Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

125 changes: 125 additions & 0 deletions app/frontend/js/components/settings-view.js
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,15 @@ export function createSettingsView(Alpine) {
progress: null,
},

networkCache: {
enabled: false,
persistent: false,
maxGb: 2,
usedBytes: 0,
fileCount: 0,
isPurging: false,
},

audioDevices: [],
selectedAudioDevice: 'default',
audioDevicesLoading: false,
Expand Down Expand Up @@ -143,6 +152,33 @@ export function createSettingsView(Alpine) {
return this.lastfm.enabled ? 'translate-x-6' : 'translate-x-1';
},

networkCacheToggleTrackClass() {
return this.networkCache.enabled ? 'bg-primary' : 'bg-muted';
},

networkCacheToggleThumbClass() {
return this.networkCache.enabled ? 'translate-x-6' : 'translate-x-1';
},

networkCachePersistentTrackClass() {
return this.networkCache.persistent ? 'bg-primary' : 'bg-muted';
},

networkCachePersistentThumbClass() {
return this.networkCache.persistent ? 'translate-x-6' : 'translate-x-1';
},

purgeButtonText() {
return this.networkCache.isPurging ? 'Clearing...' : 'Clear Cache';
},

formatCacheSize(bytes) {
if (bytes === 0) return '0 B';
const units = ['B', 'KB', 'MB', 'GB'];
const i = Math.floor(Math.log(bytes) / Math.log(1024));
return `${(bytes / Math.pow(1024, i)).toFixed(i > 0 ? 1 : 0)} ${units[i]}`;
},

connectButtonText() {
return this.lastfm.isConnecting ? 'Connecting...' : 'Connect';
},
Expand Down Expand Up @@ -172,6 +208,7 @@ export function createSettingsView(Alpine) {
await this.loadAudioDevices();
await this.loadWatchedFolders();
await this.loadLastfmSettings();
await this.loadNetworkCacheStatus();
this.loadColumnSettings();
this.deduplicateAcrossDirectories = window.settings.get(
'library.deduplicateAcrossDirectories',
Expand Down Expand Up @@ -396,6 +433,94 @@ export function createSettingsView(Alpine) {
}
},

// ============================================
// Network Cache methods
// ============================================

async loadNetworkCacheStatus() {
if (!window.__TAURI__) return;

try {
const { invoke } = window.__TAURI__.core;
const status = await invoke('network_cache_status');
this.networkCache.enabled = status.enabled;
this.networkCache.persistent = status.persistent;
this.networkCache.maxGb = status.max_bytes / 1_073_741_824;
this.networkCache.usedBytes = status.used_bytes;
this.networkCache.fileCount = status.file_count;
} catch (error) {
console.error('[settings] Failed to load network cache status:', error);
}
},

async toggleNetworkCache() {
if (!window.__TAURI__) return;

try {
const newValue = !this.networkCache.enabled;
await window.settings.set('network_cache_enabled', newValue);
this.networkCache.enabled = newValue;
Alpine.store('ui').toast(
`Network file caching ${newValue ? 'enabled' : 'disabled'}`,
'success',
);
} catch (error) {
console.error('[settings] Failed to toggle network cache:', error);
Alpine.store('ui').toast('Failed to update network cache setting', 'error');
}
},

async toggleNetworkCachePersistent() {
if (!window.__TAURI__) return;

try {
const newValue = !this.networkCache.persistent;
await window.settings.set('network_cache_persistent', newValue);
this.networkCache.persistent = newValue;
Alpine.store('ui').toast(
`Persistent cache ${newValue ? 'enabled' : 'disabled'}`,
'success',
);
} catch (error) {
console.error('[settings] Failed to toggle persistent cache:', error);
Alpine.store('ui').toast('Failed to update persistent cache setting', 'error');
}
},

async updateNetworkCacheMaxGb() {
if (!window.__TAURI__) return;

try {
const clamped = Math.max(0.5, Math.min(20, this.networkCache.maxGb));
if (clamped !== this.networkCache.maxGb) {
this.networkCache.maxGb = clamped;
}

await window.settings.set('network_cache_max_gb', this.networkCache.maxGb);
} catch (error) {
console.error('[settings] Failed to update cache size limit:', error);
Alpine.store('ui').toast('Failed to update cache size limit', 'error');
}
},

async purgeNetworkCache() {
if (!window.__TAURI__) return;

this.networkCache.isPurging = true;
try {
const { invoke } = window.__TAURI__.core;
await invoke('network_cache_purge');
this.networkCache.usedBytes = 0;
this.networkCache.fileCount = 0;
Alpine.store('ui').toast('Network cache cleared', 'success');
} catch (error) {
console.error('[settings] Failed to purge network cache:', error);
Alpine.store('ui').toast('Failed to clear network cache', 'error');
} finally {
this.networkCache.isPurging = false;
}
},

// ============================================
// Last.fm methods
// ============================================
Expand Down
177 changes: 177 additions & 0 deletions app/frontend/tests/network-cache-settings.spec.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,177 @@
import { expect, test } from '@playwright/test';
import { waitForAlpine } from './fixtures/helpers.js';

test.describe('Network Cache Settings', () => {
test.beforeEach(async ({ page }) => {
await page.setViewportSize({ width: 1624, height: 1057 });

await page.addInitScript(() => {
const cacheState = {
enabled: false,
persistent: false,
max_bytes: 2147483648,
used_bytes: 0,
file_count: 0,
};

window.__TAURI__ = {
core: {
invoke: (cmd, args) => {
if (cmd === 'network_cache_status') {
return Promise.resolve({ ...cacheState });
}
if (cmd === 'network_cache_purge') {
cacheState.used_bytes = 0;
cacheState.file_count = 0;
return Promise.resolve(null);
}
if (cmd === 'audio_list_devices') {
return Promise.resolve({ devices: [] });
}
if (cmd === 'audio_set_device') {
return Promise.resolve(null);
}
if (cmd === 'app_get_info') {
return Promise.resolve({
version: 'test',
build: 'test',
platform: 'test',
});
}
if (cmd === 'watched_folders_list') {
return Promise.resolve([]);
}
if (cmd === 'lastfm_get_settings') {
return Promise.resolve({
enabled: false,
authenticated: false,
scrobble_threshold: 90,
});
}
if (cmd === 'settings_get') {
if (args?.key === 'network_cache_enabled') {
return Promise.resolve({
key: 'network_cache_enabled',
value: cacheState.enabled,
});
}
return Promise.resolve({ key: args?.key, value: null });
}
if (cmd === 'settings_set') {
if (args?.key === 'network_cache_enabled') {
cacheState.enabled = args.value;
}
if (args?.key === 'network_cache_persistent') {
cacheState.persistent = args.value;
}
return Promise.resolve({ key: args?.key, value: args?.value });
}
return Promise.resolve(null);
},
},
event: {
listen: () => Promise.resolve(() => {}),
},
dialog: {
confirm: () => Promise.resolve(true),
},
};
});

await page.goto('/');
await waitForAlpine(page);

// Navigate to Settings > Audio
await page.click('[data-testid="sidebar-settings"]');
await page.waitForSelector('[data-testid="settings-nav-audio"]', {
state: 'visible',
});
await page.click('[data-testid="settings-nav-audio"]');
await page.waitForSelector('[data-testid="settings-section-audio"]', {
state: 'visible',
});
});

test('should show Audio nav item in settings', async ({ page }) => {
const navItem = page.locator('[data-testid="settings-nav-audio"]');
await expect(navItem).toBeVisible();
await expect(navItem).toHaveText('Audio');
});

test('should display the Audio section', async ({ page }) => {
const section = page.locator(
'[data-testid="settings-section-audio"]',
);
await expect(section).toBeVisible();
});

test('should show network cache toggle defaulting to off', async ({ page }) => {
const toggle = page.locator('[data-testid="network-cache-toggle"]');
await expect(toggle).toBeVisible();

// Default is off, so the toggle should have the muted class
const classes = await toggle.getAttribute('class');
expect(classes).toContain('bg-muted');
});

test('should show sub-settings when cache is enabled', async ({ page }) => {
// Persistent toggle, slider, and purge should not be visible initially
const persistentToggle = page.locator(
'[data-testid="network-cache-persistent-toggle"]',
);
await expect(persistentToggle).not.toBeVisible();

const slider = page.locator(
'[data-testid="network-cache-size-slider"]',
);
await expect(slider).not.toBeVisible();

const purgeButton = page.locator(
'[data-testid="network-cache-purge"]',
);
await expect(purgeButton).not.toBeVisible();

// Enable the cache
await page.click('[data-testid="network-cache-toggle"]');

// Now sub-settings should be visible
await expect(persistentToggle).toBeVisible();
await expect(slider).toBeVisible();
await expect(purgeButton).toBeVisible();
});

test('should have range slider with correct min/max', async ({ page }) => {
// Enable cache to show slider
await page.click('[data-testid="network-cache-toggle"]');

const slider = page.locator(
'[data-testid="network-cache-size-slider"]',
);
await expect(slider).toBeVisible();
await expect(slider).toHaveAttribute('min', '0.5');
await expect(slider).toHaveAttribute('max', '20');
await expect(slider).toHaveAttribute('step', '0.5');
});

test('should show cache status when enabled', async ({ page }) => {
// Enable cache
await page.click('[data-testid="network-cache-toggle"]');

// Cache status card should show "Used" and "Files" labels
const usedText = page.getByText('Used', { exact: true });
await expect(usedText).toBeVisible();

const filesText = page.getByText('Files', { exact: true });
await expect(filesText).toBeVisible();
});

test('should show purge button as disabled when cache is empty', async ({ page }) => {
// Enable cache
await page.click('[data-testid="network-cache-toggle"]');

const purgeButton = page.locator(
'[data-testid="network-cache-purge"]',
);
await expect(purgeButton).toBeDisabled();
});
});
Loading
Loading