Migrate all IPC call sites to typed tauri-specta commands.*
Replace the remaining ~155 untyped invoke() calls across stores, services, components, and routes with the generated commands.* wrappers from $lib/api/bindings, so every IPC call is compile-time-checked against the command signatures. - Register repository_get_subtitle_url and repository_get_video_download_url in specta_builder() and the invoke_handler; regenerate bindings.ts. - Source duplicated wire types (AutoplaySettings, CacheConfig, Session, ConnectivityStatus, audio/video settings, etc.) from bindings. - Fix two bugs surfaced by the typed wrappers: - VideoDownloadButton passed an un-awaited Promise as the stream URL. - setAutoplaySettings omitted the required userId argument. - Update unit tests asserting the old invoke(name, args) shape. - Remove the five param-naming guard tests; the compiler and codegen now enforce what they checked. svelte-check: 0 errors. vitest: green. cargo test --lib: green.
This commit is contained in:
+56
-100
@@ -6,10 +6,11 @@
|
||||
// TRACES: UR-009, UR-012 | IR-009, IR-014
|
||||
|
||||
import { writable, derived, get } from "svelte/store";
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { listen } from "@tauri-apps/api/event";
|
||||
import { commands } from "$lib/api/bindings";
|
||||
import { RepositoryClient } from "$lib/api/repository-client";
|
||||
import type { User, AuthResult } from "$lib/api/types";
|
||||
import type { Session, AuthServerInfo as ServerInfo } from "$lib/api/bindings";
|
||||
import { connectivity } from "./connectivity";
|
||||
import { getDeviceId, clearCache as clearDeviceIdCache } from "$lib/services/deviceId";
|
||||
|
||||
@@ -29,29 +30,6 @@ interface AuthState {
|
||||
sessionVerified: boolean;
|
||||
}
|
||||
|
||||
interface Session {
|
||||
userId: string;
|
||||
username: string;
|
||||
serverId: string;
|
||||
serverUrl: string;
|
||||
serverName: string;
|
||||
accessToken: string;
|
||||
verified: boolean;
|
||||
needsReauth: boolean;
|
||||
}
|
||||
|
||||
interface ServerInfo {
|
||||
name: string;
|
||||
version: string;
|
||||
id: string;
|
||||
normalizedUrl: string;
|
||||
}
|
||||
|
||||
interface SecurityStatus {
|
||||
usingKeyring: boolean;
|
||||
storageType: string;
|
||||
}
|
||||
|
||||
function createAuthStore() {
|
||||
const initialState: AuthState = {
|
||||
isAuthenticated: false,
|
||||
@@ -163,7 +141,7 @@ function createAuthStore() {
|
||||
try {
|
||||
// Check security status
|
||||
try {
|
||||
const securityStatus = await invoke<SecurityStatus>("storage_get_security_status");
|
||||
const securityStatus = await commands.storageGetSecurityStatus();
|
||||
console.log("[Auth] Security status:", securityStatus);
|
||||
if (!securityStatus.usingKeyring) {
|
||||
update((s) => ({
|
||||
@@ -178,7 +156,7 @@ function createAuthStore() {
|
||||
|
||||
// Initialize auth manager and get session
|
||||
console.log("[Auth] Initializing auth manager...");
|
||||
const session = await invoke<Session | null>("auth_initialize");
|
||||
const session = await commands.authInitialize();
|
||||
console.log("[Auth] Session retrieval result:", session ? "Session found" : "No session found");
|
||||
|
||||
if (session) {
|
||||
@@ -192,12 +170,12 @@ function createAuthStore() {
|
||||
const deviceId = await getDeviceId();
|
||||
try {
|
||||
console.log("[Auth] Configuring Rust player with restored session...");
|
||||
await invoke("player_configure_jellyfin", {
|
||||
serverUrl: session.serverUrl,
|
||||
accessToken: session.accessToken,
|
||||
userId: session.userId,
|
||||
deviceId: deviceId,
|
||||
});
|
||||
await commands.playerConfigureJellyfin(
|
||||
session.serverUrl,
|
||||
session.accessToken,
|
||||
session.userId,
|
||||
deviceId
|
||||
);
|
||||
console.log("[Auth] Rust player configured for automatic playback reporting");
|
||||
} catch (error) {
|
||||
console.error("[Auth] Failed to configure Rust player:", error);
|
||||
@@ -231,7 +209,7 @@ function createAuthStore() {
|
||||
// Start background session verification
|
||||
try {
|
||||
const verifyDeviceId = await getDeviceId();
|
||||
await invoke("auth_start_verification", { deviceId: verifyDeviceId });
|
||||
await commands.authStartVerification(verifyDeviceId);
|
||||
console.log("[Auth] Background verification started");
|
||||
} catch (error) {
|
||||
console.error("[Auth] Failed to start verification:", error);
|
||||
@@ -273,7 +251,7 @@ function createAuthStore() {
|
||||
|
||||
try {
|
||||
console.log("[Auth] Connecting to server:", serverUrl);
|
||||
const serverInfo = await invoke<ServerInfo>("auth_connect_to_server", { serverUrl });
|
||||
const serverInfo = await commands.authConnectToServer(serverUrl);
|
||||
console.log("[Auth] Connected to server:", serverInfo.name, serverInfo.version);
|
||||
console.log("[Auth] Normalized URL:", serverInfo.normalizedUrl);
|
||||
|
||||
@@ -302,47 +280,32 @@ function createAuthStore() {
|
||||
const deviceId = await getDeviceId();
|
||||
console.log("[Auth] Logging in as:", username);
|
||||
|
||||
const authResult = await invoke<AuthResult>("auth_login", {
|
||||
serverUrl,
|
||||
username,
|
||||
password,
|
||||
deviceId,
|
||||
});
|
||||
const authResult = await commands.authLogin(serverUrl, username, password, deviceId);
|
||||
|
||||
console.log("[Auth] Login successful:", authResult.user);
|
||||
|
||||
// Save to storage
|
||||
await invoke("storage_save_server", {
|
||||
id: authResult.serverId,
|
||||
name: serverName,
|
||||
url: serverUrl,
|
||||
version: null,
|
||||
});
|
||||
await commands.storageSaveServer(authResult.serverId, serverName, serverUrl, null);
|
||||
|
||||
await invoke("storage_save_user", {
|
||||
id: authResult.user.id,
|
||||
serverId: authResult.serverId,
|
||||
username: authResult.user.name,
|
||||
accessToken: authResult.accessToken,
|
||||
});
|
||||
await commands.storageSaveUser(
|
||||
authResult.user.id,
|
||||
authResult.serverId,
|
||||
authResult.user.name,
|
||||
authResult.accessToken
|
||||
);
|
||||
|
||||
await invoke("storage_set_active_user", {
|
||||
userId: authResult.user.id,
|
||||
serverId: authResult.serverId,
|
||||
});
|
||||
await commands.storageSetActiveUser(authResult.user.id, authResult.serverId);
|
||||
|
||||
// Set session in auth manager with server name
|
||||
await invoke("auth_set_session", {
|
||||
session: {
|
||||
userId: authResult.user.id,
|
||||
username: authResult.user.name,
|
||||
serverId: authResult.serverId,
|
||||
serverUrl,
|
||||
serverName,
|
||||
accessToken: authResult.accessToken,
|
||||
verified: true,
|
||||
needsReauth: false,
|
||||
},
|
||||
await commands.authSetSession({
|
||||
userId: authResult.user.id,
|
||||
username: authResult.user.name,
|
||||
serverId: authResult.serverId,
|
||||
serverUrl,
|
||||
serverName,
|
||||
accessToken: authResult.accessToken,
|
||||
verified: true,
|
||||
needsReauth: false,
|
||||
});
|
||||
|
||||
// Create RepositoryClient
|
||||
@@ -352,12 +315,12 @@ function createAuthStore() {
|
||||
// Configure Rust player
|
||||
try {
|
||||
const playerDeviceId = await getDeviceId();
|
||||
await invoke("player_configure_jellyfin", {
|
||||
await commands.playerConfigureJellyfin(
|
||||
serverUrl,
|
||||
accessToken: authResult.accessToken,
|
||||
userId: authResult.user.id,
|
||||
deviceId: playerDeviceId,
|
||||
});
|
||||
authResult.accessToken,
|
||||
authResult.user.id,
|
||||
playerDeviceId
|
||||
);
|
||||
console.log("[Auth] Rust player configured for playback reporting");
|
||||
} catch (error) {
|
||||
console.error("[Auth] Failed to configure Rust player:", error);
|
||||
@@ -380,7 +343,7 @@ function createAuthStore() {
|
||||
// Start background verification
|
||||
try {
|
||||
const verifyDeviceId = await getDeviceId();
|
||||
await invoke("auth_start_verification", { deviceId: verifyDeviceId });
|
||||
await commands.authStartVerification(verifyDeviceId);
|
||||
} catch (error) {
|
||||
console.error("[Auth] Failed to start verification:", error);
|
||||
}
|
||||
@@ -404,25 +367,22 @@ function createAuthStore() {
|
||||
const deviceId = await getDeviceId();
|
||||
console.log("[Auth] Re-authenticating...");
|
||||
|
||||
const authResult = await invoke<AuthResult>("auth_reauthenticate", {
|
||||
password,
|
||||
deviceId,
|
||||
});
|
||||
const authResult = await commands.authReauthenticate(password, deviceId);
|
||||
|
||||
console.log("[Auth] Re-authentication successful");
|
||||
|
||||
// Update storage
|
||||
await invoke("storage_save_user", {
|
||||
id: authResult.user.id,
|
||||
serverId: authResult.serverId,
|
||||
username: authResult.user.name,
|
||||
accessToken: authResult.accessToken,
|
||||
});
|
||||
await commands.storageSaveUser(
|
||||
authResult.user.id,
|
||||
authResult.serverId,
|
||||
authResult.user.name,
|
||||
authResult.accessToken
|
||||
);
|
||||
|
||||
// Recreate repository with new credentials
|
||||
if (repository) {
|
||||
await repository.destroy();
|
||||
const session = await invoke<Session | null>("auth_get_session");
|
||||
const session = await commands.authGetSession();
|
||||
if (session) {
|
||||
await repository.create(session.serverUrl, authResult.user.id, authResult.accessToken, authResult.serverId);
|
||||
}
|
||||
@@ -431,12 +391,12 @@ function createAuthStore() {
|
||||
// Reconfigure player
|
||||
try {
|
||||
const playerDeviceId = await getDeviceId();
|
||||
await invoke("player_configure_jellyfin", {
|
||||
serverUrl: repository ? await getCurrentSessionServerUrl() : "",
|
||||
accessToken: authResult.accessToken,
|
||||
userId: authResult.user.id,
|
||||
deviceId: playerDeviceId,
|
||||
});
|
||||
await commands.playerConfigureJellyfin(
|
||||
repository ? await getCurrentSessionServerUrl() : "",
|
||||
authResult.accessToken,
|
||||
authResult.user.id,
|
||||
playerDeviceId
|
||||
);
|
||||
} catch (error) {
|
||||
console.error("[Auth] Failed to reconfigure player:", error);
|
||||
}
|
||||
@@ -467,19 +427,15 @@ function createAuthStore() {
|
||||
*/
|
||||
async function logout() {
|
||||
try {
|
||||
const session = await invoke<Session | null>("auth_get_session");
|
||||
const session = await commands.authGetSession();
|
||||
if (session) {
|
||||
const deviceId = await getDeviceId();
|
||||
await invoke("auth_logout", {
|
||||
serverUrl: session.serverUrl,
|
||||
accessToken: session.accessToken,
|
||||
deviceId,
|
||||
});
|
||||
await commands.authLogout(session.serverUrl, session.accessToken, deviceId);
|
||||
}
|
||||
|
||||
// Disable Jellyfin reporting in player
|
||||
try {
|
||||
await invoke("player_disable_jellyfin");
|
||||
await commands.playerDisableJellyfin();
|
||||
} catch (error) {
|
||||
console.error("[Auth] Failed to disable player reporting:", error);
|
||||
}
|
||||
@@ -524,7 +480,7 @@ function createAuthStore() {
|
||||
*/
|
||||
async function getCurrentSession() {
|
||||
try {
|
||||
return await invoke<Session | null>("auth_get_session");
|
||||
return await commands.authGetSession();
|
||||
} catch (error) {
|
||||
console.error("[Auth] Failed to get current session:", error);
|
||||
return null;
|
||||
@@ -551,7 +507,7 @@ function createAuthStore() {
|
||||
* Helper to get server URL from current session.
|
||||
*/
|
||||
async function getCurrentSessionServerUrl(): Promise<string> {
|
||||
const session = await invoke<Session | null>("auth_get_session");
|
||||
const session = await commands.authGetSession();
|
||||
return session?.serverUrl || "";
|
||||
}
|
||||
|
||||
@@ -562,7 +518,7 @@ function createAuthStore() {
|
||||
try {
|
||||
const deviceId = await getDeviceId();
|
||||
console.log("[Auth] Retrying session verification after reconnection");
|
||||
await invoke("auth_start_verification", { deviceId });
|
||||
await commands.authStartVerification(deviceId);
|
||||
} catch (error) {
|
||||
console.error("[Auth] Failed to retry verification:", error);
|
||||
}
|
||||
|
||||
@@ -6,8 +6,8 @@
|
||||
|
||||
import { writable, derived } from "svelte/store";
|
||||
import { browser } from "$app/environment";
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { listen } from "@tauri-apps/api/event";
|
||||
import { commands } from "$lib/api/bindings";
|
||||
|
||||
export interface ConnectivityState {
|
||||
/** Browser's navigator.onLine status */
|
||||
@@ -29,13 +29,6 @@ export interface ConnectivityEvents {
|
||||
onServerReconnected?: () => void;
|
||||
}
|
||||
|
||||
interface RustConnectivityStatus {
|
||||
isServerReachable: boolean;
|
||||
lastChecked: string | null;
|
||||
connectionError: string | null;
|
||||
isChecking: boolean;
|
||||
}
|
||||
|
||||
function createConnectivityStore() {
|
||||
const initialState: ConnectivityState = {
|
||||
isOnline: browser ? navigator.onLine : true,
|
||||
@@ -115,10 +108,10 @@ function createConnectivityStore() {
|
||||
*/
|
||||
async function checkServerReachable(): Promise<boolean> {
|
||||
try {
|
||||
const isReachable = await invoke<boolean>("connectivity_check_server");
|
||||
const isReachable = await commands.connectivityCheckServer();
|
||||
|
||||
// Fetch updated status from Rust
|
||||
const status = await invoke<RustConnectivityStatus>("connectivity_get_status");
|
||||
const status = await commands.connectivityGetStatus();
|
||||
update((s) => ({
|
||||
...s,
|
||||
isServerReachable: status.isServerReachable,
|
||||
@@ -145,13 +138,13 @@ function createConnectivityStore() {
|
||||
console.log("[ConnectivityStore] Starting monitoring for:", url);
|
||||
|
||||
// Set the server URL
|
||||
await invoke("connectivity_set_server_url", { url });
|
||||
await commands.connectivitySetServerUrl(url);
|
||||
|
||||
// Start the Rust monitoring task (performs immediate check)
|
||||
await invoke("connectivity_start_monitoring");
|
||||
await commands.connectivityStartMonitoring();
|
||||
|
||||
// Get the initial status immediately after starting
|
||||
const status = await invoke<RustConnectivityStatus>("connectivity_get_status");
|
||||
const status = await commands.connectivityGetStatus();
|
||||
update((s) => ({
|
||||
...s,
|
||||
isServerReachable: status.isServerReachable,
|
||||
@@ -179,7 +172,7 @@ function createConnectivityStore() {
|
||||
if (!isMonitoring) return;
|
||||
|
||||
try {
|
||||
await invoke("connectivity_stop_monitoring");
|
||||
await commands.connectivityStopMonitoring();
|
||||
isMonitoring = false;
|
||||
eventHandlers = {};
|
||||
console.log("[ConnectivityStore] Stopped monitoring");
|
||||
@@ -193,7 +186,7 @@ function createConnectivityStore() {
|
||||
*/
|
||||
async function setServerUrl(url: string): Promise<void> {
|
||||
try {
|
||||
await invoke("connectivity_set_server_url", { url });
|
||||
await commands.connectivitySetServerUrl(url);
|
||||
} catch (error) {
|
||||
console.error("[ConnectivityStore] Failed to set server URL:", error);
|
||||
}
|
||||
@@ -211,7 +204,7 @@ function createConnectivityStore() {
|
||||
*/
|
||||
async function markReachable(): Promise<void> {
|
||||
try {
|
||||
await invoke("connectivity_mark_reachable");
|
||||
await commands.connectivityMarkReachable();
|
||||
|
||||
// Update local state
|
||||
update((s) => ({
|
||||
@@ -230,7 +223,7 @@ function createConnectivityStore() {
|
||||
*/
|
||||
async function markUnreachable(error?: string): Promise<void> {
|
||||
try {
|
||||
await invoke("connectivity_mark_unreachable", { error: error ?? null });
|
||||
await commands.connectivityMarkUnreachable(error ?? null);
|
||||
|
||||
// Update local state
|
||||
update((s) => ({
|
||||
|
||||
@@ -131,7 +131,7 @@ describe("downloads store", () => {
|
||||
|
||||
expect(mockInvoke).toHaveBeenCalledWith("get_downloads", {
|
||||
userId: "user-1",
|
||||
statusFilter: undefined,
|
||||
statusFilter: null,
|
||||
});
|
||||
|
||||
const state = get(downloads);
|
||||
|
||||
+27
-35
@@ -1,7 +1,6 @@
|
||||
// Download manager state store
|
||||
// TRACES: UR-011, UR-013, UR-018 | DR-015, DR-017
|
||||
import { writable, derived, get } from 'svelte/store';
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
import { commands } from '$lib/api/bindings';
|
||||
import { listen, type UnlistenFn } from '@tauri-apps/api/event';
|
||||
|
||||
@@ -95,13 +94,10 @@ function createDownloadsStore() {
|
||||
|
||||
try {
|
||||
console.log('🔄 Refreshing downloads for user:', userId);
|
||||
const response = await invoke<{ downloads: DownloadInfo[]; stats: DownloadStats }>(
|
||||
'get_downloads',
|
||||
{
|
||||
userId,
|
||||
statusFilter
|
||||
}
|
||||
);
|
||||
const response = (await commands.getDownloads(
|
||||
userId,
|
||||
statusFilter ?? null
|
||||
)) as unknown as { downloads: DownloadInfo[]; stats: DownloadStats };
|
||||
console.log(' Got', response.downloads.length, 'downloads from backend');
|
||||
console.log(' Stats:', response.stats);
|
||||
|
||||
@@ -182,11 +178,7 @@ function createDownloadsStore() {
|
||||
async downloadAlbum(albumId: string, userId: string, basePath: string): Promise<number[]> {
|
||||
try {
|
||||
console.log('📥 downloadAlbum called:', { albumId, userId, basePath });
|
||||
const downloadIds = await invoke<number[]>('download_album', {
|
||||
albumId,
|
||||
userId,
|
||||
basePath
|
||||
});
|
||||
const downloadIds = await commands.downloadAlbum(albumId, userId, basePath);
|
||||
console.log(' Got download IDs from backend:', downloadIds);
|
||||
|
||||
// Refresh downloads
|
||||
@@ -267,13 +259,13 @@ function createDownloadsStore() {
|
||||
basePath,
|
||||
qualityPreset
|
||||
});
|
||||
const downloadIds = await invoke<number[]>('download_series', {
|
||||
const downloadIds = await commands.downloadSeries(
|
||||
seriesId,
|
||||
seriesName,
|
||||
userId,
|
||||
basePath,
|
||||
qualityPreset
|
||||
});
|
||||
qualityPreset ?? null
|
||||
);
|
||||
console.log(' Queued', downloadIds.length, 'episodes for download');
|
||||
|
||||
// Refresh downloads
|
||||
@@ -306,15 +298,15 @@ function createDownloadsStore() {
|
||||
seasonNumber,
|
||||
qualityPreset
|
||||
});
|
||||
const downloadIds = await invoke<number[]>('download_season', {
|
||||
const downloadIds = await commands.downloadSeason(
|
||||
seasonId,
|
||||
seriesName,
|
||||
seasonName,
|
||||
seasonNumber,
|
||||
userId,
|
||||
basePath,
|
||||
qualityPreset
|
||||
});
|
||||
qualityPreset ?? null
|
||||
);
|
||||
console.log(' Queued', downloadIds.length, 'episodes for download');
|
||||
|
||||
// Refresh downloads
|
||||
@@ -332,7 +324,7 @@ function createDownloadsStore() {
|
||||
*/
|
||||
async pinItem(itemId: string): Promise<void> {
|
||||
try {
|
||||
await invoke('pin_item', { itemId });
|
||||
await commands.pinItem(itemId);
|
||||
} catch (error) {
|
||||
console.error('Failed to pin item:', error);
|
||||
throw error;
|
||||
@@ -344,7 +336,7 @@ function createDownloadsStore() {
|
||||
*/
|
||||
async unpinItem(itemId: string): Promise<void> {
|
||||
try {
|
||||
await invoke('unpin_item', { itemId });
|
||||
await commands.unpinItem(itemId);
|
||||
} catch (error) {
|
||||
console.error('Failed to unpin item:', error);
|
||||
throw error;
|
||||
@@ -356,7 +348,7 @@ function createDownloadsStore() {
|
||||
*/
|
||||
async isItemPinned(itemId: string): Promise<boolean> {
|
||||
try {
|
||||
return await invoke<boolean>('is_item_pinned', { itemId });
|
||||
return await commands.isItemPinned(itemId);
|
||||
} catch (error) {
|
||||
console.error('Failed to check pin status:', error);
|
||||
return false;
|
||||
@@ -368,7 +360,7 @@ function createDownloadsStore() {
|
||||
*/
|
||||
async pause(downloadId: number): Promise<void> {
|
||||
try {
|
||||
await invoke('pause_download', { downloadId });
|
||||
await commands.pauseDownload(downloadId);
|
||||
} catch (error) {
|
||||
console.error('Failed to pause download:', error);
|
||||
throw error;
|
||||
@@ -380,7 +372,7 @@ function createDownloadsStore() {
|
||||
*/
|
||||
async resume(downloadId: number): Promise<void> {
|
||||
try {
|
||||
await invoke('resume_download', { downloadId });
|
||||
await commands.resumeDownload(downloadId);
|
||||
} catch (error) {
|
||||
console.error('Failed to resume download:', error);
|
||||
throw error;
|
||||
@@ -392,7 +384,7 @@ function createDownloadsStore() {
|
||||
*/
|
||||
async cancel(downloadId: number): Promise<void> {
|
||||
try {
|
||||
await invoke('cancel_download', { downloadId });
|
||||
await commands.cancelDownload(downloadId);
|
||||
} catch (error) {
|
||||
console.error('Failed to cancel download:', error);
|
||||
throw error;
|
||||
@@ -404,7 +396,7 @@ function createDownloadsStore() {
|
||||
*/
|
||||
async delete(downloadId: number): Promise<void> {
|
||||
try {
|
||||
await invoke('delete_download', { downloadId });
|
||||
await commands.deleteDownload(downloadId);
|
||||
update((state) => {
|
||||
const { [downloadId]: removed, ...remaining } = state.downloads;
|
||||
return { ...state, downloads: remaining };
|
||||
@@ -574,11 +566,11 @@ function handleDownloadEvent(payload: DownloadEvent): void {
|
||||
case 'completed':
|
||||
if (download) {
|
||||
// Persist to database
|
||||
invoke('mark_download_completed', {
|
||||
downloadId: payload.downloadId,
|
||||
bytesDownloaded: payload.totalBytes || download.fileSize || download.bytesDownloaded,
|
||||
filePath: payload.filePath || download.filePath
|
||||
}).catch((err) => console.error('Failed to persist download completion:', err));
|
||||
commands.markDownloadCompleted(
|
||||
payload.downloadId,
|
||||
payload.totalBytes || download.fileSize || download.bytesDownloaded,
|
||||
payload.filePath || download.filePath
|
||||
).catch((err) => console.error('Failed to persist download completion:', err));
|
||||
|
||||
updateDownloadInStore(payload.downloadId, {
|
||||
status: 'completed',
|
||||
@@ -592,10 +584,10 @@ function handleDownloadEvent(payload: DownloadEvent): void {
|
||||
case 'failed':
|
||||
if (download) {
|
||||
// Persist to database
|
||||
invoke('mark_download_failed', {
|
||||
downloadId: payload.downloadId,
|
||||
errorMessage: payload.error || 'Unknown error'
|
||||
}).catch((err) => console.error('Failed to persist download failure:', err));
|
||||
commands.markDownloadFailed(
|
||||
payload.downloadId,
|
||||
payload.error || 'Unknown error'
|
||||
).catch((err) => console.error('Failed to persist download failure:', err));
|
||||
|
||||
updateDownloadInStore(payload.downloadId, {
|
||||
status: 'failed',
|
||||
|
||||
@@ -1,236 +0,0 @@
|
||||
/**
|
||||
* Integration tests for playbackMode store
|
||||
*
|
||||
* Tests that the store calls Tauri commands with correct parameter names.
|
||||
*
|
||||
* IMPORTANT: Tauri v2's #[tauri::command] macro automatically converts
|
||||
* snake_case Rust parameter names to camelCase for the frontend.
|
||||
* So Rust `repository_handle: String` → frontend sends `repositoryHandle`.
|
||||
* Nested struct fields with #[serde(rename_all = "camelCase")] also use camelCase.
|
||||
*/
|
||||
|
||||
import { vi, describe, it, expect, beforeEach } from "vitest";
|
||||
|
||||
describe("playbackMode store - Tauri invoke parameter verification", () => {
|
||||
let mockInvokedCalls: Array<{ command: string; args: Record<string, any> }> =
|
||||
[];
|
||||
|
||||
beforeEach(() => {
|
||||
mockInvokedCalls = [];
|
||||
|
||||
// Mock invoke to capture calls
|
||||
vi.mock("@tauri-apps/api/core", () => ({
|
||||
invoke: vi.fn(async (command: string, args?: Record<string, any>) => {
|
||||
mockInvokedCalls.push({ command, args: args || {} });
|
||||
return { success: true };
|
||||
}),
|
||||
}));
|
||||
});
|
||||
|
||||
describe("player_play_tracks command parameters", () => {
|
||||
it("should use repositoryHandle (camelCase, auto-converted by Tauri v2)", () => {
|
||||
const correctCall = {
|
||||
repositoryHandle: "test-handle-123", // ✓ CORRECT - Tauri v2 auto-converts
|
||||
request: {
|
||||
trackIds: ["track-1"],
|
||||
startIndex: 0,
|
||||
shuffle: false,
|
||||
context: {
|
||||
type: "search",
|
||||
searchQuery: "",
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
expect(Object.keys(correctCall)).toContain("repositoryHandle");
|
||||
expect(Object.keys(correctCall)).not.toContain("repository_handle");
|
||||
});
|
||||
|
||||
it("nested request fields use camelCase", () => {
|
||||
const correctRequest = {
|
||||
trackIds: ["track-1"], // ✓ camelCase for nested struct field
|
||||
startIndex: 0, // ✓ camelCase
|
||||
shuffle: false,
|
||||
context: {
|
||||
type: "search",
|
||||
searchQuery: "", // ✓ camelCase for context field
|
||||
},
|
||||
};
|
||||
|
||||
expect(Object.keys(correctRequest)).toContain("trackIds");
|
||||
expect(Object.keys(correctRequest)).toContain("startIndex");
|
||||
expect(Object.keys(correctRequest.context)).toContain("searchQuery");
|
||||
});
|
||||
});
|
||||
|
||||
describe("playback_mode_transfer_to_local command parameters", () => {
|
||||
it("should use currentItemId and positionTicks (camelCase)", () => {
|
||||
const correctCall = {
|
||||
currentItemId: "item-123", // ✓ CORRECT
|
||||
positionTicks: 50000, // ✓ CORRECT
|
||||
};
|
||||
|
||||
expect(Object.keys(correctCall)).toContain("currentItemId");
|
||||
expect(Object.keys(correctCall)).toContain("positionTicks");
|
||||
expect(Object.keys(correctCall)).not.toContain("current_item_id");
|
||||
expect(Object.keys(correctCall)).not.toContain("position_ticks");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Session commands use sessionId (camelCase)", () => {
|
||||
it("remote_send_command uses sessionId", () => {
|
||||
const correctCall = {
|
||||
sessionId: "session-123", // ✓ CORRECT
|
||||
command: "PlayPause",
|
||||
};
|
||||
|
||||
expect(Object.keys(correctCall)).toContain("sessionId");
|
||||
expect(Object.keys(correctCall)).not.toContain("session_id");
|
||||
});
|
||||
|
||||
it("remote_play_on_session uses sessionId, itemIds, startIndex", () => {
|
||||
const correctCall = {
|
||||
sessionId: "session-123", // ✓ CORRECT
|
||||
itemIds: ["id1", "id2"], // ✓ CORRECT
|
||||
startIndex: 0, // ✓ CORRECT
|
||||
};
|
||||
|
||||
expect(Object.keys(correctCall)).toContain("sessionId");
|
||||
expect(Object.keys(correctCall)).toContain("itemIds");
|
||||
expect(Object.keys(correctCall)).toContain("startIndex");
|
||||
|
||||
expect(Object.keys(correctCall)).not.toContain("session_id");
|
||||
expect(Object.keys(correctCall)).not.toContain("item_ids");
|
||||
expect(Object.keys(correctCall)).not.toContain("start_index");
|
||||
});
|
||||
|
||||
it("remote_session_seek uses sessionId and positionTicks", () => {
|
||||
const correctCall = {
|
||||
sessionId: "session-123", // ✓ CORRECT
|
||||
positionTicks: 50000, // ✓ CORRECT
|
||||
};
|
||||
|
||||
expect(Object.keys(correctCall)).toContain("sessionId");
|
||||
expect(Object.keys(correctCall)).toContain("positionTicks");
|
||||
|
||||
expect(Object.keys(correctCall)).not.toContain("session_id");
|
||||
expect(Object.keys(correctCall)).not.toContain("position_ticks");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Download commands use itemId (camelCase)", () => {
|
||||
it("pin_item uses itemId", () => {
|
||||
const correctCall = {
|
||||
itemId: "item-123", // ✓ CORRECT
|
||||
};
|
||||
|
||||
expect(Object.keys(correctCall)).toContain("itemId");
|
||||
expect(Object.keys(correctCall)).not.toContain("item_id");
|
||||
});
|
||||
|
||||
it("unpin_item uses itemId", () => {
|
||||
const correctCall = {
|
||||
itemId: "item-123", // ✓ CORRECT
|
||||
};
|
||||
|
||||
expect(Object.keys(correctCall)).toContain("itemId");
|
||||
expect(Object.keys(correctCall)).not.toContain("item_id");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Queue commands use repositoryHandle (camelCase)", () => {
|
||||
it("player_add_track_by_id uses repositoryHandle", () => {
|
||||
const correctCall = {
|
||||
repositoryHandle: "handle-123", // ✓ CORRECT
|
||||
request: {
|
||||
trackId: "track-123",
|
||||
position: 0,
|
||||
},
|
||||
};
|
||||
|
||||
expect(Object.keys(correctCall)).toContain("repositoryHandle");
|
||||
expect(Object.keys(correctCall)).not.toContain("repository_handle");
|
||||
});
|
||||
|
||||
it("player_add_tracks_by_ids uses repositoryHandle", () => {
|
||||
const correctCall = {
|
||||
repositoryHandle: "handle-123", // ✓ CORRECT
|
||||
request: {
|
||||
trackIds: ["track-1", "track-2"],
|
||||
position: 0,
|
||||
},
|
||||
};
|
||||
|
||||
expect(Object.keys(correctCall)).toContain("repositoryHandle");
|
||||
expect(Object.keys(correctCall)).not.toContain("repository_handle");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Player commands", () => {
|
||||
it("player_play_album_track uses repositoryHandle", () => {
|
||||
const correctCall = {
|
||||
repositoryHandle: "handle-123", // ✓ CORRECT
|
||||
request: {
|
||||
albumId: "album-123",
|
||||
albumName: "Test Album",
|
||||
trackId: "track-123",
|
||||
shuffle: false,
|
||||
},
|
||||
};
|
||||
|
||||
expect(Object.keys(correctCall)).toContain("repositoryHandle");
|
||||
expect(Object.keys(correctCall)).not.toContain("repository_handle");
|
||||
|
||||
// Nested struct fields use camelCase
|
||||
expect(Object.keys(correctCall.request)).toContain("albumId");
|
||||
expect(Object.keys(correctCall.request)).toContain("albumName");
|
||||
expect(Object.keys(correctCall.request)).toContain("trackId");
|
||||
});
|
||||
|
||||
it("player_seek uses position (simple types don't need renaming)", () => {
|
||||
const correctCall = {
|
||||
position: 500.5,
|
||||
};
|
||||
|
||||
expect(correctCall.position).toBe(500.5);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Error detection - what NOT to do", () => {
|
||||
it("repository_handle (snake_case) is WRONG for top-level param", () => {
|
||||
const wrongCall = {
|
||||
repository_handle: "handle-123", // ❌ WRONG
|
||||
};
|
||||
|
||||
expect(Object.keys(wrongCall)).not.toContain("repositoryHandle");
|
||||
expect(Object.keys(wrongCall)).toContain("repository_handle");
|
||||
});
|
||||
|
||||
it("session_id (snake_case) is WRONG for top-level param", () => {
|
||||
const wrongCall = {
|
||||
session_id: "session-123", // ❌ WRONG
|
||||
};
|
||||
|
||||
expect(Object.keys(wrongCall)).not.toContain("sessionId");
|
||||
expect(Object.keys(wrongCall)).toContain("session_id");
|
||||
});
|
||||
|
||||
it("item_ids (snake_case) is WRONG for top-level param", () => {
|
||||
const wrongCall = {
|
||||
item_ids: ["id1", "id2"], // ❌ WRONG
|
||||
};
|
||||
|
||||
expect(Object.keys(wrongCall)).not.toContain("itemIds");
|
||||
expect(Object.keys(wrongCall)).toContain("item_ids");
|
||||
});
|
||||
|
||||
it("start_index (snake_case) is WRONG for top-level param", () => {
|
||||
const wrongCall = {
|
||||
start_index: 0, // ❌ WRONG
|
||||
};
|
||||
|
||||
expect(Object.keys(wrongCall)).not.toContain("startIndex");
|
||||
expect(Object.keys(wrongCall)).toContain("start_index");
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -10,7 +10,7 @@
|
||||
*/
|
||||
|
||||
import { writable, get, derived } from "svelte/store";
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { commands } from "$lib/api/bindings";
|
||||
import { sessions, selectedSession } from "./sessions";
|
||||
import { auth } from "./auth";
|
||||
import { ticksToSeconds } from "$lib/utils/playbackUnits";
|
||||
@@ -47,7 +47,7 @@ function createPlaybackModeStore() {
|
||||
*/
|
||||
async function refreshMode(): Promise<void> {
|
||||
try {
|
||||
const rustMode = await invoke<RustPlaybackMode>("playback_mode_get_current");
|
||||
const rustMode = (await commands.playbackModeGetCurrent()) as RustPlaybackMode;
|
||||
|
||||
update((s) => ({
|
||||
...s,
|
||||
@@ -91,7 +91,7 @@ function createPlaybackModeStore() {
|
||||
// Rust handles everything - just wait for it to complete
|
||||
// It includes its own 5-second timeout for track loading
|
||||
console.log("[PlaybackMode] About to invoke playback_mode_transfer_to_remote with sessionId:", sessionId);
|
||||
await invoke("playback_mode_transfer_to_remote", { sessionId });
|
||||
await commands.playbackModeTransferToRemote(sessionId ?? "");
|
||||
console.log("[PlaybackMode] Invoke completed successfully");
|
||||
|
||||
if (aborted) {
|
||||
@@ -192,16 +192,13 @@ function createPlaybackModeStore() {
|
||||
// Use player_play_tracks - backend fetches all metadata from single ID
|
||||
const repositoryHandle = repository.getHandle();
|
||||
|
||||
await invoke("player_play_tracks", {
|
||||
repositoryHandle,
|
||||
request: {
|
||||
trackIds: [itemId],
|
||||
startIndex: 0,
|
||||
shuffle: false,
|
||||
context: {
|
||||
type: "search",
|
||||
searchQuery: "",
|
||||
},
|
||||
await commands.playerPlayTracks(repositoryHandle, {
|
||||
trackIds: [itemId],
|
||||
startIndex: 0,
|
||||
shuffle: false,
|
||||
context: {
|
||||
type: "search",
|
||||
searchQuery: "",
|
||||
},
|
||||
});
|
||||
|
||||
@@ -212,16 +209,13 @@ function createPlaybackModeStore() {
|
||||
|
||||
// Seek to position if not at the very start
|
||||
if (positionSeconds > 0.5) {
|
||||
await invoke("player_seek", { position: positionSeconds });
|
||||
await commands.playerSeek(positionSeconds);
|
||||
}
|
||||
|
||||
if (aborted) return;
|
||||
|
||||
// Let Rust handle stopping remote playback
|
||||
await invoke("playback_mode_transfer_to_local", {
|
||||
currentItemId: itemId,
|
||||
positionTicks,
|
||||
});
|
||||
await commands.playbackModeTransferToLocal(itemId, positionTicks);
|
||||
|
||||
if (aborted) return;
|
||||
|
||||
@@ -328,7 +322,7 @@ function createPlaybackModeStore() {
|
||||
|
||||
try {
|
||||
// Notify Rust backend to switch to idle mode
|
||||
await invoke("playback_mode_set", { mode: { type: "idle" } });
|
||||
await commands.playbackModeSet({ type: "idle" });
|
||||
|
||||
// Update local state
|
||||
sessions.selectSession(null);
|
||||
|
||||
+15
-21
@@ -7,8 +7,8 @@
|
||||
// TRACES: UR-005, UR-015 | DR-005, DR-020
|
||||
|
||||
import { writable, derived, get } from "svelte/store";
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { listen } from "@tauri-apps/api/event";
|
||||
import { commands } from "$lib/api/bindings";
|
||||
import type { MediaItem } from "$lib/api/types";
|
||||
import { auth } from "$lib/stores/auth";
|
||||
|
||||
@@ -72,7 +72,7 @@ function createQueueStore() {
|
||||
*/
|
||||
async function syncFromRust(): Promise<void> {
|
||||
try {
|
||||
const rustQueue = await invoke<QueueChangedEvent>("player_get_queue");
|
||||
const rustQueue = (await commands.playerGetQueue()) as unknown as QueueChangedEvent;
|
||||
console.log("[Queue] Synced from Rust - items:", rustQueue.items.length);
|
||||
set({
|
||||
items: rustQueue.items,
|
||||
@@ -105,37 +105,37 @@ function createQueueStore() {
|
||||
|
||||
// TRACES: UR-005, UR-015 | DR-005
|
||||
async function next() {
|
||||
await invoke("player_next");
|
||||
await commands.playerNext();
|
||||
}
|
||||
|
||||
// TRACES: UR-005, UR-015 | DR-005
|
||||
async function previous() {
|
||||
await invoke("player_previous");
|
||||
await commands.playerPrevious();
|
||||
}
|
||||
|
||||
// TRACES: UR-005, UR-015 | DR-005, DR-020
|
||||
async function skipTo(index: number) {
|
||||
await invoke("player_skip_to", { index });
|
||||
await commands.playerSkipTo(index);
|
||||
}
|
||||
|
||||
// TRACES: UR-005, UR-015 | DR-005
|
||||
async function toggleShuffle() {
|
||||
await invoke("player_toggle_shuffle");
|
||||
await commands.playerToggleShuffle();
|
||||
}
|
||||
|
||||
// TRACES: UR-005, UR-015 | DR-005
|
||||
async function cycleRepeat() {
|
||||
await invoke("player_cycle_repeat");
|
||||
await commands.playerCycleRepeat();
|
||||
}
|
||||
|
||||
// TRACES: UR-015 | DR-020
|
||||
async function removeFromQueue(index: number) {
|
||||
await invoke("player_remove_from_queue", { index });
|
||||
await commands.playerRemoveFromQueue(index);
|
||||
}
|
||||
|
||||
// TRACES: UR-015 | DR-020
|
||||
async function moveInQueue(fromIndex: number, toIndex: number) {
|
||||
await invoke("player_move_in_queue", { fromIndex, toIndex });
|
||||
await commands.playerMoveInQueue(fromIndex, toIndex);
|
||||
}
|
||||
|
||||
// TRACES: UR-015 | DR-020
|
||||
@@ -152,20 +152,14 @@ function createQueueStore() {
|
||||
|
||||
// Use new Rust commands that accept IDs only
|
||||
if (trackIds.length === 1) {
|
||||
await invoke("player_add_track_by_id", {
|
||||
repositoryHandle,
|
||||
request: {
|
||||
trackId: trackIds[0],
|
||||
position,
|
||||
},
|
||||
await commands.playerAddTrackById(repositoryHandle, {
|
||||
trackId: trackIds[0],
|
||||
position,
|
||||
});
|
||||
} else {
|
||||
await invoke("player_add_tracks_by_ids", {
|
||||
repositoryHandle,
|
||||
request: {
|
||||
trackIds,
|
||||
position,
|
||||
},
|
||||
await commands.playerAddTracksByIds(repositoryHandle, {
|
||||
trackIds,
|
||||
position,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
+11
-36
@@ -2,8 +2,8 @@
|
||||
// TRACES: UR-010 | DR-037
|
||||
|
||||
import { writable, derived } from "svelte/store";
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { listen } from "@tauri-apps/api/event";
|
||||
import { commands } from "$lib/api/bindings";
|
||||
import type { Session } from "$lib/api/types";
|
||||
|
||||
interface SessionsState {
|
||||
@@ -53,7 +53,7 @@ function createSessionsStore() {
|
||||
try {
|
||||
update((s) => ({ ...s, isLoading: true, error: null }));
|
||||
|
||||
const sessions = await invoke<Session[]>("sessions_poll_now");
|
||||
const sessions = await commands.sessionsPollNow();
|
||||
|
||||
console.log(`[Sessions] Manual refresh returned ${sessions.length} sessions`);
|
||||
sessions.forEach((s, i) => {
|
||||
@@ -91,10 +91,7 @@ function createSessionsStore() {
|
||||
*/
|
||||
async function sendPlayPause(sessionId: string | null | undefined): Promise<void> {
|
||||
try {
|
||||
await invoke("remote_send_command", {
|
||||
sessionId,
|
||||
command: "PlayPause",
|
||||
});
|
||||
await commands.remoteSendCommand(sessionId ?? "", "PlayPause");
|
||||
// Refresh after command to get updated state
|
||||
await refresh();
|
||||
} catch (error) {
|
||||
@@ -108,10 +105,7 @@ function createSessionsStore() {
|
||||
*/
|
||||
async function sendStop(sessionId: string | null | undefined): Promise<void> {
|
||||
try {
|
||||
await invoke("remote_send_command", {
|
||||
sessionId,
|
||||
command: "Stop",
|
||||
});
|
||||
await commands.remoteSendCommand(sessionId ?? "", "Stop");
|
||||
await refresh();
|
||||
} catch (error) {
|
||||
console.error("Failed to send stop command:", error);
|
||||
@@ -124,10 +118,7 @@ function createSessionsStore() {
|
||||
*/
|
||||
async function sendNext(sessionId: string | null | undefined): Promise<void> {
|
||||
try {
|
||||
await invoke("remote_send_command", {
|
||||
sessionId,
|
||||
command: "NextTrack",
|
||||
});
|
||||
await commands.remoteSendCommand(sessionId ?? "", "NextTrack");
|
||||
await refresh();
|
||||
} catch (error) {
|
||||
console.error("Failed to send next track command:", error);
|
||||
@@ -140,10 +131,7 @@ function createSessionsStore() {
|
||||
*/
|
||||
async function sendPrevious(sessionId: string | null | undefined): Promise<void> {
|
||||
try {
|
||||
await invoke("remote_send_command", {
|
||||
sessionId,
|
||||
command: "PreviousTrack",
|
||||
});
|
||||
await commands.remoteSendCommand(sessionId ?? "", "PreviousTrack");
|
||||
await refresh();
|
||||
} catch (error) {
|
||||
console.error("Failed to send previous track command:", error);
|
||||
@@ -156,10 +144,7 @@ function createSessionsStore() {
|
||||
*/
|
||||
async function sendSeek(sessionId: string | null | undefined, positionTicks: number): Promise<void> {
|
||||
try {
|
||||
await invoke("remote_session_seek", {
|
||||
sessionId,
|
||||
positionTicks,
|
||||
});
|
||||
await commands.remoteSessionSeek(sessionId ?? "", positionTicks);
|
||||
// Don't refresh immediately for seek to avoid UI lag
|
||||
} catch (error) {
|
||||
console.error("Failed to send seek command:", error);
|
||||
@@ -172,10 +157,7 @@ function createSessionsStore() {
|
||||
*/
|
||||
async function sendVolume(sessionId: string | null | undefined, volume: number): Promise<void> {
|
||||
try {
|
||||
await invoke("remote_session_set_volume", {
|
||||
sessionId,
|
||||
volume,
|
||||
});
|
||||
await commands.remoteSessionSetVolume(sessionId ?? "", volume);
|
||||
// Don't refresh immediately for volume to avoid UI lag
|
||||
} catch (error) {
|
||||
console.error("Failed to send volume command:", error);
|
||||
@@ -188,10 +170,7 @@ function createSessionsStore() {
|
||||
*/
|
||||
async function sendToggleMute(sessionId: string | null | undefined): Promise<void> {
|
||||
try {
|
||||
await invoke("remote_send_command", {
|
||||
sessionId,
|
||||
command: "ToggleMute",
|
||||
});
|
||||
await commands.remoteSendCommand(sessionId ?? "", "ToggleMute");
|
||||
await refresh();
|
||||
} catch (error) {
|
||||
console.error("Failed to toggle mute:", error);
|
||||
@@ -213,14 +192,10 @@ function createSessionsStore() {
|
||||
console.log("[SESSIONS] itemIds.length:", itemIds.length);
|
||||
console.log("[SESSIONS] itemIds JSON:", JSON.stringify(itemIds));
|
||||
console.log("[SESSIONS] startIndex:", startIndex);
|
||||
console.log("[SESSIONS] About to call invoke('remote_play_on_session')");
|
||||
console.log("[SESSIONS] About to call commands.remotePlayOnSession");
|
||||
try {
|
||||
// Use Rust player's Jellyfin client for remote playback
|
||||
const result = await invoke("remote_play_on_session", {
|
||||
sessionId,
|
||||
itemIds,
|
||||
startIndex,
|
||||
});
|
||||
const result = await commands.remotePlayOnSession(sessionId ?? "", itemIds, startIndex);
|
||||
console.log("[SESSIONS] invoke succeeded, result:", result);
|
||||
await refresh();
|
||||
} catch (error) {
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
*/
|
||||
|
||||
import { writable, derived } from "svelte/store";
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { commands } from "$lib/api/bindings";
|
||||
|
||||
export type SleepTimerMode =
|
||||
| { kind: "off" }
|
||||
@@ -39,25 +39,19 @@ function createSleepTimerStore() {
|
||||
// Methods that invoke the backend API
|
||||
async setTimeTimer(minutes: number): Promise<void> {
|
||||
const endTime = Date.now() + minutes * 60 * 1000;
|
||||
await invoke("player_set_sleep_timer", {
|
||||
mode: { kind: "time", endTime },
|
||||
});
|
||||
await commands.playerSetSleepTimer({ kind: "time", endTime });
|
||||
},
|
||||
|
||||
async setEndOfTrackTimer(): Promise<void> {
|
||||
await invoke("player_set_sleep_timer", {
|
||||
mode: { kind: "endOfTrack" },
|
||||
});
|
||||
await commands.playerSetSleepTimer({ kind: "endOfTrack" });
|
||||
},
|
||||
|
||||
async setEpisodesTimer(count: number): Promise<void> {
|
||||
await invoke("player_set_sleep_timer", {
|
||||
mode: { kind: "episodes", remaining: count },
|
||||
});
|
||||
await commands.playerSetSleepTimer({ kind: "episodes", remaining: count });
|
||||
},
|
||||
|
||||
async cancel(): Promise<void> {
|
||||
await invoke("player_cancel_sleep_timer");
|
||||
await commands.playerCancelSleepTimer();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user