Files
jellyfin-plugin-floatplane/doc/research/reference-sources/floaty_fp_download_url_helper.dart
claude 9e33a343ab
Some checks failed
release / build-release (push) Has been cancelled
Floatplane channel plugin for Jellyfin 10.10.x: browse, HLS proxy, cookie session, CI release
- IChannel: creators -> sub-channels -> video posts (public listing API, search mapped)
- IRequiresMediaInfoCallback -> signed proxy URLs; stream controller rewrites HLS so the
  AES-128 key is fetched server-side with the session cookie, segments stay on the CDN
- delivery/info cached per token lifetime and throttled (~2/min)
- config page: paste sails.sid + validate, extra creators, quality, search test
- scripts/package.sh -> plugin folder + zip + md5 + manifest.json; Gitea release workflow
- research docs, HAR analysis (HAR itself gitignored), install-pi5.sh

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-25 09:42:27 +01:00

213 lines
6.8 KiB
Dart

import 'dart:convert';
import 'package:floaty/features/api/repositories/fpapi.dart';
import 'package:floaty/features/download/controllers/fp_rate_limit_manager.dart';
import 'package:logging/logging.dart';
/// Helper for managing download URL expiry and refresh
class FPDownloadUrlHelper {
static final Logger _log = Logger('FPDownloadUrlHelper');
/// Check if a download URL is expired
static bool isUrlExpired(String url) {
try {
final uri = Uri.parse(url);
final expiresParam = uri.queryParameters['expires'];
if (expiresParam == null) {
// No expires param, assume not expired
return false;
}
final expiresEpoch = int.tryParse(expiresParam);
if (expiresEpoch == null) return false;
final expiresAt =
DateTime.fromMillisecondsSinceEpoch(expiresEpoch * 1000);
final now = DateTime.now();
// Consider expired if within 1 minute of expiry
return now.isAfter(expiresAt.subtract(const Duration(minutes: 1)));
} catch (e) {
_log.warning('Error checking URL expiry: $e');
return false;
}
}
/// Get expiry time from URL
static DateTime? getExpiryTime(String url) {
try {
final uri = Uri.parse(url);
final expiresParam = uri.queryParameters['expires'];
if (expiresParam == null) return null;
final expiresEpoch = int.tryParse(expiresParam);
if (expiresEpoch == null) return null;
return DateTime.fromMillisecondsSinceEpoch(expiresEpoch * 1000);
} catch (e) {
return null;
}
}
/// Refresh a download URL by fetching new delivery info
/// Returns the new URL or null if rate limited or failed
static Future<RefreshResult> refreshDownloadUrl({
required String attachmentId,
required String attachmentTitle,
required String qualityLabel,
required String whitelabel,
}) async {
try {
// Attempt the delivery request even if local counters indicate a rate limit.
final deliveryResponse = await fpApiRequests.getDelivery(
whitelabel,
'download',
attachmentId,
);
final response = deliveryResponse['body'] as String;
final headers = deliveryResponse['headers'] as Map<String, String>;
final statusCode = deliveryResponse['statusCode'] as int;
// If server indicates rate limiting, honor it and return the retry-after
if (statusCode == 429 || response.contains('error code: 1015')) {
final retryAfter =
fpRateLimitManager.parseRetryAfter(headers, response);
fpRateLimitManager.setRateLimited(retryAfter);
return RefreshResult(
success: false,
isRateLimited: true,
secondsUntilRetry: retryAfter,
);
}
if (response.isEmpty) {
return RefreshResult(success: false, error: 'Empty response');
}
// Record successful request locally so counters stay accurate
fpRateLimitManager.recordRequest();
final decoded = jsonDecode(response);
final newUrl = _parseDownloadUrl(decoded, attachmentTitle, qualityLabel);
if (newUrl == null) {
return RefreshResult(success: false, error: 'Could not parse new URL');
}
return RefreshResult(success: true, newUrl: newUrl);
} catch (e) {
_log.severe('Error refreshing download URL: $e');
return RefreshResult(success: false, error: e.toString());
}
}
/// Parse download URL from delivery response
static String? _parseDownloadUrl(
Map<String, dynamic> decoded,
String attachmentTitle,
String qualityLabel,
) {
// Try new format first (groups/origins/variants)
final groups = decoded['groups'] as List?;
if (groups != null && groups.isNotEmpty) {
final group = groups[0] as Map<String, dynamic>;
final origins = group['origins'] as List?;
final variants = group['variants'] as List?;
if (origins != null && origins.isNotEmpty && variants != null) {
final baseUrl = (origins[0] as Map<String, dynamic>)['url'] as String;
for (var variant in variants) {
if (variant['enabled'] == true) {
final label = variant['label'] as String;
// Match by quality label
if (label == qualityLabel) {
final variantUrl = variant['url'] as String;
// Convert HLS URL to direct download URL
String downloadUrl = '$baseUrl$variantUrl';
// Remove /chunk.m3u8 if present
if (downloadUrl.contains('/chunk.m3u8')) {
downloadUrl = downloadUrl.replaceFirst('/chunk.m3u8', '');
}
// Remove /Videos/ prefix if present
downloadUrl = downloadUrl.replaceFirst('/Videos/', '/');
// Add required download parameters
final filename = Uri.encodeComponent('$attachmentTitle ($label)');
downloadUrl += '&attachment=true&filename=$filename';
return downloadUrl;
}
}
}
}
}
// Fall back to old format (cdn/resource/qualityLevels)
final cdn = decoded['cdn'] as String?;
final resource = decoded['resource'] as Map<String, dynamic>?;
if (cdn != null && resource != null) {
final data = resource['data'] as Map<String, dynamic>?;
final qualityLevels = data?['qualityLevels'] as List?;
final qualityLevelParams =
data?['qualityLevelParams'] as Map<String, dynamic>?;
final uri = resource['uri'] as String?;
if (qualityLevels != null && qualityLevelParams != null && uri != null) {
for (var quality in qualityLevels) {
final qualityName = quality['name'] as String;
final label = quality['label'] as String;
if (label == qualityLabel) {
final params =
qualityLevelParams[qualityName] as Map<String, dynamic>?;
if (params != null) {
final videoFile = params['1'] as String?;
final token = params['2'] as String?;
if (videoFile != null && token != null) {
final downloadUri = uri
.replaceFirst('{qualityLevelParams.1}', videoFile)
.replaceFirst('{qualityLevelParams.2}', token);
// Add required download parameters
final filename =
Uri.encodeComponent('$attachmentTitle ($qualityLabel)');
return '$cdn$downloadUri&attachment=true&filename=$filename';
}
}
}
}
}
}
return null;
}
}
/// Result of URL refresh operation
class RefreshResult {
final bool success;
final String? newUrl;
final bool isRateLimited;
final int secondsUntilRetry;
final String? error;
RefreshResult({
required this.success,
this.newUrl,
this.isRateLimited = false,
this.secondsUntilRetry = 0,
this.error,
});
}