feat(discord): self-host rich presence posters
This commit is contained in:
@@ -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;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user