feat(discord): self-host rich presence posters

This commit is contained in:
edde746
2026-05-11 04:19:41 +02:00
parent 4b243d38b9
commit bdbd667584
3 changed files with 596 additions and 56 deletions
+57 -30
View File
@@ -1,18 +1,17 @@
import 'dart:async';
import 'package:dart_discord_presence/dart_discord_presence.dart';
import 'package:http/http.dart' as http;
import '../media/media_item.dart';
import '../media/media_kind.dart';
import '../media/media_server_client.dart';
import '../utils/app_logger.dart';
import '../utils/future_extensions.dart';
import '../utils/media_image_helper.dart';
import '../utils/platform_detector.dart';
import '../utils/media_server_http_client.dart';
import 'settings_service.dart';
/// Cached Litterbox URL with expiry timestamp
/// Cached poster URL with expiry timestamp.
class _CachedUrl {
final String url;
final DateTime expiresAt;
@@ -28,12 +27,14 @@ class _CachedUrl {
/// when video is playing. Gracefully handles Discord not running.
class DiscordRPCService {
static const String _applicationId = '1453773470306402439';
static const String _litterboxUrl = 'https://litterbox.catbox.moe/resources/internals/api.php';
static const String _posterUploadUrl = 'https://ice.plezy.app/posters';
static const Duration _posterCacheTtl = Duration(hours: 3);
static const int _maxPosterUploadBytes = 5 * 1024 * 1024;
/// Cache of thumbnail paths to Litterbox URLs with expiry (1 hour). Keyed
/// by `<backendId>:<thumbPath>` so the same path on different backends
/// doesn't collide.
static final Map<String, _CachedUrl> _litterboxCache = {};
/// Cache of thumbnail paths to hosted poster URLs. Keyed by
/// `<backendId>:<thumbPath>` so the same path on different backends doesn't
/// collide.
static final Map<String, _CachedUrl> _posterUrlCache = {};
static DiscordRPCService? _instance;
static DiscordRPCService get instance {
@@ -292,17 +293,13 @@ class DiscordRPCService {
// Check cache first (with expiry check). Key by backend so the same
// path on Plex and Jellyfin doesn't collide.
final cacheKey = '${client.backend.id}:$thumbPath';
final cached = _litterboxCache[cacheKey];
final cached = _posterUrlCache[cacheKey];
if (cached != null && !cached.isExpired) {
appLogger.d('Using cached Litterbox URL for: $cacheKey');
appLogger.d('Using cached poster URL for: $cacheKey');
return cached.url;
}
// Build the image URL. Both backends embed auth in the URL — Plex via
// `?X-Plex-Token=...`, Jellyfin via `?api_key=...` — so no extra
// headers are needed for the fetch below. We still pass [streamHeaders]
// for Plex installs that prefer header-based auth.
final imageUrl = client.thumbnailUrl(thumbPath);
final imageUrl = _buildTranscodedThumbnailUrl(metadata, client, thumbPath);
if (imageUrl.isEmpty) return null;
final imageBytes = await httpClient.getBytes(
@@ -311,29 +308,59 @@ class DiscordRPCService {
timeout: const Duration(seconds: 10),
);
if (imageBytes.isEmpty) return null;
if (imageBytes.length > _maxPosterUploadBytes) {
appLogger.d('Discord poster upload skipped: transcoded image is ${imageBytes.length} bytes');
return null;
}
final uploadRequest = http.MultipartRequest('POST', Uri.parse(_litterboxUrl))
..fields['reqtype'] = 'fileupload'
..fields['time'] = '1h'
..files.add(http.MultipartFile.fromBytes('fileToUpload', imageBytes, filename: 'thumbnail.jpg'));
final uploadResponse = await httpClient.post(
_posterUploadUrl,
body: imageBytes,
headers: {'Content-Type': 'application/octet-stream'},
timeout: const Duration(seconds: 15),
);
final uploadStreamed = await httpClient.inner
.send(uploadRequest)
.namedTimeout(const Duration(seconds: 15), operation: 'Litterbox upload');
final uploadedUrl = (await uploadStreamed.stream.bytesToString()).trim();
if (uploadedUrl.startsWith('http')) {
// Cache the URL with 1 hour expiry (matching Litterbox)
_litterboxCache[cacheKey] = _CachedUrl(uploadedUrl, DateTime.now().add(const Duration(hours: 1)));
appLogger.d('Uploaded and cached thumbnail: $uploadedUrl');
return uploadedUrl;
final uploadedUrl = switch (uploadResponse.data) {
{'url': final String url} when uploadResponse.statusCode >= 200 && uploadResponse.statusCode < 300 => url,
_ => null,
};
final hostedUrl = _absolutePosterUrl(uploadedUrl);
if (hostedUrl != null) {
_posterUrlCache[cacheKey] = _CachedUrl(hostedUrl, DateTime.now().add(_posterCacheTtl));
appLogger.d('Uploaded and cached thumbnail: $hostedUrl');
return hostedUrl;
}
} catch (e) {
appLogger.d('Failed to upload thumbnail to Litterbox', error: e);
appLogger.d('Failed to upload thumbnail to Plezy poster host', error: e);
}
return null;
}
String _buildTranscodedThumbnailUrl(MediaItem metadata, MediaServerClient client, String thumbPath) {
final useEpisodeThumb = metadata.kind == MediaKind.episode && metadata.grandparentThumbPath == null;
return MediaImageHelper.getOptimizedImageUrl(
client: client,
thumbPath: thumbPath,
maxWidth: useEpisodeThumb ? 960 : 512,
maxHeight: useEpisodeThumb ? 540 : 768,
devicePixelRatio: 1,
imageType: useEpisodeThumb ? ImageType.thumb : ImageType.poster,
);
}
String? _absolutePosterUrl(String? url) {
if (url == null || url.isEmpty) return null;
final uri = Uri.tryParse(url);
if (uri == null) return null;
if (uri.hasScheme && (uri.scheme == 'http' || uri.scheme == 'https')) {
return url;
}
if (uri.hasScheme || uri.hasAuthority || !url.startsWith('/posters/')) {
return null;
}
return Uri.parse(_posterUploadUrl).resolve(url).toString();
}
Future<void> _updatePresence() async {
if (_rpc == null || !_isConnected || _currentMetadata == null) return;