Files
plezy/lib/services/image_cache_service.dart
T

223 lines
7.5 KiB
Dart

import 'dart:async';
import 'dart:collection';
import 'package:cached_network_image_ce/cached_network_image.dart' show FileResponse;
// CE's public conditional export hides the IO-only httpClientFactory parameter
// behind a narrower unsupported-platform stub.
// ignore: implementation_imports
import 'package:cached_network_image_ce/src/cache/default_cache_manager.dart' as ce_cache;
import 'package:flutter/foundation.dart' show visibleForTesting;
import 'package:http/http.dart' as http;
import 'package:path_provider/path_provider.dart';
import '../utils/media_server_http_client.dart';
import 'device_performance.dart';
final _artworkHttpClient = MediaServerHttpClient(usePlexApiClient: true);
@visibleForTesting
int artworkRequestConcurrencyForTier({required bool reduced}) => reduced ? 3 : 6;
// Top-level fields are initialized lazily. The first artwork request happens
// after DevicePerformance has resolved the hardware tier during bootstrap.
final _artworkRequestLimiter = _RequestLimiter(artworkRequestConcurrencyForTier(reduced: DevicePerformance.isReduced));
Future<void> closeArtworkHttpClientGracefully({Duration drainTimeout = const Duration(seconds: 5)}) {
return _artworkHttpClient.closeGracefully(drainTimeout: drainTimeout);
}
/// Shared cache manager for media-server image artwork. Used for both Plex and
/// Jellyfin artwork (the class name predates Jellyfin support — it's
/// backend-neutral).
///
/// Uses the platform-native HTTP client so iOS/macOS (CupertinoClient) and
/// Android (CronetClient) benefit from HTTP/2, while the wrapper below keeps
/// image fan-out bounded so weak TV devices don't decode a whole rail at once.
/// On Linux this uses the same finite-connection tuning as Plex API traffic.
class PlexImageCacheManager extends ce_cache.DefaultCacheManager {
static final PlexImageCacheManager instance = PlexImageCacheManager._();
PlexImageCacheManager._()
: super(
stalePeriod: const Duration(days: 14),
maxNrOfCacheObjects: 3000,
httpClientFactory: () => _SharedHttpClient(_artworkHttpClient.inner, _artworkRequestLimiter),
cacheDirectoryProvider: getApplicationCacheDirectory,
);
@override
Stream<FileResponse> getImageFile(
String url, {
String? key,
Map<String, String>? headers,
bool withProgress = false,
int? maxHeight,
int? maxWidth,
}) {
// Plezy already requests server-sized artwork URLs. Avoid CE's disk-resize
// path, which decodes downloaded images before writing resized PNG copies.
return getFileStream(url, key: key, headers: headers, withProgress: withProgress);
}
}
/// CE closes each factory-created client after a download. Wrap the app-wide
/// shared client so image requests reuse its platform transport without
/// transferring ownership of its lifecycle, and cap artwork fan-out globally.
class _SharedHttpClient extends http.BaseClient {
final http.Client _inner;
final _RequestLimiter _limiter;
final Duration _unclaimedResponseTimeout;
_SharedHttpClient(this._inner, this._limiter, {this._unclaimedResponseTimeout = const Duration(seconds: 2)});
@override
Future<http.StreamedResponse> send(http.BaseRequest request) async {
final permit = await _limiter.acquire();
var released = false;
void release() {
if (released) return;
released = true;
permit.release();
}
try {
final response = await _inner.send(request);
// CE's cache manager throws for any status other than 200/202 without
// listening to the body, so _releaseWhenDone would never fire and the
// permit would leak; six stale-thumb 404s then wedge all artwork loading
// until restart (#1473). Release now, drain the (tiny) error body in the
// background so the platform client reclaims the connection, and hand CE
// an empty body it never reads anyway. Status set mirrors CE 4.6.4
// _downloadFile; recheck if the pinned dep is ever bumped.
if (response.statusCode != 200 && response.statusCode != 202) {
release();
unawaited(response.stream.drain<void>().catchError((_) {}));
return http.StreamedResponse(
const Stream<List<int>>.empty(),
response.statusCode,
contentLength: 0,
request: response.request,
headers: response.headers,
isRedirect: response.isRedirect,
persistentConnection: response.persistentConnection,
reasonPhrase: response.reasonPhrase,
);
}
return http.StreamedResponse(
_releaseWhenDone(response.stream, release, claimTimeout: _unclaimedResponseTimeout),
response.statusCode,
contentLength: response.contentLength,
request: response.request,
headers: response.headers,
isRedirect: response.isRedirect,
persistentConnection: response.persistentConnection,
reasonPhrase: response.reasonPhrase,
);
} catch (_) {
release();
rethrow;
}
}
@override
void close() {}
}
// ignore: unused-code
/// Test hook: builds the throttled artwork client with an isolated limiter.
@visibleForTesting
http.Client createArtworkHttpClientForTest(
http.Client inner, {
int maxConcurrent = 6,
Duration unclaimedResponseTimeout = const Duration(seconds: 2),
}) => _SharedHttpClient(inner, _RequestLimiter(maxConcurrent), unclaimedResponseTimeout: unclaimedResponseTimeout);
Stream<List<int>> _releaseWhenDone(
Stream<List<int>> stream,
void Function() release, {
required Duration claimTimeout,
}) {
var claimed = false;
var abandoned = false;
// A cache request can be cancelled after response headers arrive but before
// CE subscribes to the body (for example when a rail card is disposed).
// An async* wrapper that is never listened to never enters its `finally`, so
// without this guard the permit is lost permanently and artwork wedges once
// every slot has leaked. Give CE ample time to claim the body, then release
// the slot and cancel the orphaned transport request.
final claimTimer = Timer(claimTimeout, () {
if (claimed) return;
abandoned = true;
release();
_cancelUnclaimedBody(stream);
});
return (() async* {
if (abandoned) {
throw http.ClientException('Artwork response body was abandoned before it was consumed');
}
claimed = true;
claimTimer.cancel();
try {
await for (final chunk in stream) {
yield chunk;
}
} finally {
release();
}
})();
}
void _cancelUnclaimedBody(Stream<List<int>> stream) {
try {
final subscription = stream.listen((_) {}, onError: (_, _) {});
unawaited(subscription.cancel().catchError((_) {}));
} catch (_) {
// The body may already have terminated while the timeout callback ran.
}
}
class _RequestLimiter {
final int maxConcurrent;
final Queue<Completer<_RequestPermit>> _queue = Queue<Completer<_RequestPermit>>();
int _active = 0;
_RequestLimiter(this.maxConcurrent);
Future<_RequestPermit> acquire() {
if (_active < maxConcurrent) {
_active++;
return Future.value(_RequestPermit(this));
}
final completer = Completer<_RequestPermit>();
_queue.add(completer);
return completer.future;
}
void _release() {
if (_queue.isNotEmpty) {
_queue.removeFirst().complete(_RequestPermit(this));
return;
}
if (_active > 0) _active--;
}
}
class _RequestPermit {
final _RequestLimiter _limiter;
bool _released = false;
_RequestPermit(this._limiter);
void release() {
if (_released) return;
_released = true;
_limiter._release();
}
}