This commit is contained in:
@@ -0,0 +1,139 @@
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
import { Book } from '@/types/book';
|
||||
import { AppService } from '@/types/system';
|
||||
import { getCoverFilename } from './book';
|
||||
import { processDiscordCover } from './image';
|
||||
|
||||
type CacheEntry = {
|
||||
url: string | null;
|
||||
timestamp: number;
|
||||
};
|
||||
|
||||
const coverUrlCache = new Map<string, CacheEntry>();
|
||||
const CACHE_DURATION = 60 * 60 * 1000; // 1 hour in milliseconds
|
||||
|
||||
type BookPresence = {
|
||||
bookHash: string;
|
||||
title: string;
|
||||
author: string | null;
|
||||
coverUrl: string | null;
|
||||
sessionStart: number;
|
||||
};
|
||||
|
||||
/**
|
||||
* Get an HTTPS cover URL suitable for Discord Rich Presence
|
||||
* - Caches successful uploads for session
|
||||
* - Caches failures (undefined) for 1 hour to avoid retries
|
||||
* - Processes cover with Readest icon overlay
|
||||
*/
|
||||
const getCoverUrlForDiscord = async (
|
||||
book: Book,
|
||||
appService: AppService,
|
||||
): Promise<string | undefined> => {
|
||||
const cached = coverUrlCache.get(book.hash);
|
||||
if (cached) {
|
||||
const isExpired = Date.now() - cached.timestamp > CACHE_DURATION;
|
||||
if (!isExpired) {
|
||||
return cached.url ?? undefined;
|
||||
}
|
||||
if (!cached.url) {
|
||||
coverUrlCache.delete(book.hash);
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const fp = getCoverFilename(book);
|
||||
|
||||
const exists = await appService.exists(fp, 'Books');
|
||||
if (!exists) {
|
||||
coverUrlCache.set(book.hash, { url: null, timestamp: Date.now() });
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const cacheKey = `drp_${book.hash}.jpg`;
|
||||
// Check if processed image exists in cache
|
||||
const cachedExists = await appService.exists(cacheKey, 'Cache');
|
||||
if (cachedExists) {
|
||||
const downloadUrl = await appService.uploadFileToCloud(
|
||||
cacheKey,
|
||||
cacheKey,
|
||||
'Cache',
|
||||
() => {},
|
||||
book.hash,
|
||||
true,
|
||||
);
|
||||
|
||||
if (downloadUrl) {
|
||||
coverUrlCache.set(book.hash, { url: downloadUrl, timestamp: Date.now() });
|
||||
return downloadUrl;
|
||||
}
|
||||
}
|
||||
|
||||
const fullPath = await appService.resolveFilePath(fp, 'Books');
|
||||
const coverUrl = await appService.getImageURL(fullPath);
|
||||
const iconUrl = '/icon-tiny.png';
|
||||
|
||||
const processedBlob = await processDiscordCover(coverUrl, iconUrl);
|
||||
console.log('Processed Discord cover for book:', book.title);
|
||||
const arrayBuffer = await processedBlob.arrayBuffer();
|
||||
await appService.writeFile(cacheKey, 'Cache', arrayBuffer);
|
||||
const downloadUrl = await appService.uploadFileToCloud(
|
||||
cacheKey,
|
||||
cacheKey,
|
||||
'Cache',
|
||||
() => {},
|
||||
book.hash,
|
||||
true,
|
||||
);
|
||||
|
||||
if (downloadUrl) {
|
||||
coverUrlCache.set(book.hash, { url: downloadUrl, timestamp: Date.now() });
|
||||
return downloadUrl;
|
||||
}
|
||||
coverUrlCache.set(book.hash, { url: null, timestamp: Date.now() });
|
||||
return undefined;
|
||||
} catch (error) {
|
||||
console.warn('Failed to process/upload cover for Discord:', error);
|
||||
coverUrlCache.set(book.hash, { url: null, timestamp: Date.now() });
|
||||
return undefined;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Update Discord Rich Presence with current book information
|
||||
*/
|
||||
export const updateDiscordPresence = async (
|
||||
book: Book,
|
||||
sessionStart: number,
|
||||
appService: AppService,
|
||||
): Promise<void> => {
|
||||
if (!appService?.isDesktopApp) return;
|
||||
|
||||
try {
|
||||
const coverUrl = await getCoverUrlForDiscord(book, appService);
|
||||
const bookPresence: BookPresence = {
|
||||
bookHash: book.hash,
|
||||
title: book.title,
|
||||
author: book.author || null,
|
||||
coverUrl: coverUrl || null,
|
||||
sessionStart,
|
||||
};
|
||||
|
||||
await invoke('update_book_presence', { presence: bookPresence });
|
||||
} catch (error) {
|
||||
console.warn('Failed to update Discord presence:', error);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Clear Discord Rich Presence
|
||||
*/
|
||||
export const clearDiscordPresence = async (appService: AppService): Promise<void> => {
|
||||
if (!appService?.isDesktopApp) return;
|
||||
|
||||
try {
|
||||
await invoke('clear_book_presence');
|
||||
} catch (error) {
|
||||
console.warn('Failed to clear Discord presence:', error);
|
||||
}
|
||||
};
|
||||
@@ -1,3 +1,131 @@
|
||||
/**
|
||||
* Process book cover for Discord Rich Presence:
|
||||
* - Fit to 512x512 with transparent background
|
||||
* - Add Readest icon overlay at bottom right (10px padding)
|
||||
* - Return as JPEG blob
|
||||
*/
|
||||
export async function processDiscordCover(coverUrl: string, iconUrl: string): Promise<Blob> {
|
||||
const SIZE = 512;
|
||||
const ICON_WIDTH = 224;
|
||||
const ICON_HEIGHT = 182;
|
||||
const PADDING = 10;
|
||||
|
||||
try {
|
||||
const coverResponse = await fetch(coverUrl);
|
||||
const coverBlob = await coverResponse.blob();
|
||||
const coverImg = new Image();
|
||||
coverImg.crossOrigin = 'anonymous';
|
||||
|
||||
const iconResponse = await fetch(iconUrl);
|
||||
const iconBlob = await iconResponse.blob();
|
||||
const iconImg = new Image();
|
||||
iconImg.crossOrigin = 'anonymous';
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
let coverLoaded = false;
|
||||
let iconLoaded = false;
|
||||
|
||||
const checkBothLoaded = () => {
|
||||
if (!coverLoaded || !iconLoaded) return;
|
||||
|
||||
try {
|
||||
const canvas = document.createElement('canvas');
|
||||
const ctx = canvas.getContext('2d');
|
||||
|
||||
if (!ctx) {
|
||||
reject(new Error('Failed to get canvas context'));
|
||||
return;
|
||||
}
|
||||
|
||||
canvas.width = SIZE;
|
||||
canvas.height = SIZE;
|
||||
|
||||
// Calculate cover dimensions to fit in 512x512
|
||||
const aspectRatio = coverImg.width / coverImg.height;
|
||||
let drawWidth, drawHeight, offsetX, offsetY;
|
||||
|
||||
if (aspectRatio > 1) {
|
||||
// Wider than tall
|
||||
drawWidth = SIZE;
|
||||
drawHeight = SIZE / aspectRatio;
|
||||
offsetX = 0;
|
||||
offsetY = (SIZE - drawHeight) / 2;
|
||||
} else {
|
||||
// Taller than wide
|
||||
drawHeight = SIZE;
|
||||
drawWidth = SIZE * aspectRatio;
|
||||
offsetX = (SIZE - drawWidth) / 2;
|
||||
offsetY = 0;
|
||||
}
|
||||
|
||||
// Draw cover image centered
|
||||
ctx.imageSmoothingEnabled = true;
|
||||
ctx.imageSmoothingQuality = 'high';
|
||||
ctx.drawImage(coverImg, offsetX, offsetY, drawWidth, drawHeight);
|
||||
|
||||
// Draw icon at bottom right
|
||||
ctx.drawImage(
|
||||
iconImg,
|
||||
SIZE - ICON_WIDTH - PADDING,
|
||||
SIZE - ICON_HEIGHT - PADDING,
|
||||
ICON_WIDTH,
|
||||
ICON_HEIGHT,
|
||||
);
|
||||
|
||||
// Convert to JPEG blob
|
||||
canvas.toBlob(
|
||||
(blob) => {
|
||||
if (blob) {
|
||||
resolve(blob);
|
||||
} else {
|
||||
reject(new Error('Failed to create blob'));
|
||||
}
|
||||
},
|
||||
'image/jpeg',
|
||||
0.9,
|
||||
);
|
||||
} catch (error) {
|
||||
reject(new Error(`Failed to process cover: ${error}`));
|
||||
}
|
||||
};
|
||||
|
||||
coverImg.onload = () => {
|
||||
coverLoaded = true;
|
||||
checkBothLoaded();
|
||||
};
|
||||
|
||||
iconImg.onload = () => {
|
||||
iconLoaded = true;
|
||||
checkBothLoaded();
|
||||
};
|
||||
|
||||
coverImg.onerror = () => reject(new Error('Failed to load cover image'));
|
||||
iconImg.onerror = () => reject(new Error('Failed to load icon image'));
|
||||
|
||||
const coverObjectUrl = URL.createObjectURL(coverBlob);
|
||||
const iconObjectUrl = URL.createObjectURL(iconBlob);
|
||||
|
||||
coverImg.src = coverObjectUrl;
|
||||
iconImg.src = iconObjectUrl;
|
||||
|
||||
coverImg.onload = function () {
|
||||
URL.revokeObjectURL(coverObjectUrl);
|
||||
coverLoaded = true;
|
||||
checkBothLoaded();
|
||||
};
|
||||
|
||||
iconImg.onload = function () {
|
||||
URL.revokeObjectURL(iconObjectUrl);
|
||||
iconLoaded = true;
|
||||
checkBothLoaded();
|
||||
};
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error processing Discord cover:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function fetchImageAsBase64(
|
||||
url: string,
|
||||
options: {
|
||||
|
||||
@@ -2,13 +2,17 @@ import { s3Storage } from './s3';
|
||||
import { r2Storage } from './r2';
|
||||
import { getStorageType } from './storage';
|
||||
|
||||
export const getDownloadSignedUrl = async (fileKey: string, expiresIn: number) => {
|
||||
export const getDownloadSignedUrl = async (
|
||||
fileKey: string,
|
||||
expiresIn: number,
|
||||
bucketName?: string,
|
||||
) => {
|
||||
const storageType = getStorageType();
|
||||
if (storageType === 'r2') {
|
||||
const bucketName = process.env['R2_BUCKET_NAME'] || '';
|
||||
bucketName = bucketName || process.env['R2_BUCKET_NAME'] || '';
|
||||
return await r2Storage.getDownloadSignedUrl(bucketName, fileKey, expiresIn);
|
||||
} else {
|
||||
const bucketName = process.env['S3_BUCKET_NAME'] || '';
|
||||
bucketName = bucketName || process.env['S3_BUCKET_NAME'] || '';
|
||||
return await s3Storage.getDownloadSignedUrl(bucketName, fileKey, expiresIn);
|
||||
}
|
||||
};
|
||||
@@ -17,24 +21,25 @@ export const getUploadSignedUrl = async (
|
||||
fileKey: string,
|
||||
contentLength: number,
|
||||
expiresIn: number,
|
||||
bucketName?: string,
|
||||
) => {
|
||||
const storageType = getStorageType();
|
||||
if (storageType === 'r2') {
|
||||
const bucketName = process.env['R2_BUCKET_NAME'] || '';
|
||||
bucketName = bucketName || process.env['R2_BUCKET_NAME'] || '';
|
||||
return await r2Storage.getUploadSignedUrl(bucketName, fileKey, contentLength, expiresIn);
|
||||
} else {
|
||||
const bucketName = process.env['S3_BUCKET_NAME'] || '';
|
||||
bucketName = bucketName || process.env['S3_BUCKET_NAME'] || '';
|
||||
return await s3Storage.getUploadSignedUrl(bucketName, fileKey, contentLength, expiresIn);
|
||||
}
|
||||
};
|
||||
|
||||
export const deleteObject = async (fileKey: string) => {
|
||||
export const deleteObject = async (fileKey: string, bucketName?: string) => {
|
||||
const storageType = getStorageType();
|
||||
if (storageType === 'r2') {
|
||||
const bucketName = process.env['R2_BUCKET_NAME'] || '';
|
||||
bucketName = bucketName || process.env['R2_BUCKET_NAME'] || '';
|
||||
return await r2Storage.deleteObject(bucketName, fileKey);
|
||||
} else {
|
||||
const bucketName = process.env['S3_BUCKET_NAME'] || '';
|
||||
bucketName = bucketName || process.env['S3_BUCKET_NAME'] || '';
|
||||
return await s3Storage.deleteObject(bucketName, fileKey);
|
||||
}
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user