feat(discord): support show reading status with Discord Rich Presence, closes #1538 (#2998)

This commit is contained in:
Huang Xin
2026-01-19 17:42:19 +01:00
committed by GitHub
parent 1704736bc8
commit d9a6cffe78
50 changed files with 794 additions and 85 deletions
+139
View File
@@ -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);
}
};
+128
View File
@@ -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: {
+13 -8
View File
@@ -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);
}
};