fix: eliminate cross-app consistency drift
This commit is contained in:
@@ -90,6 +90,25 @@ void main() {
|
||||
await expectLater(svc.probe('https://jf.example.com'), throwsA(isA<MediaServerUrlException>()));
|
||||
});
|
||||
|
||||
test('applies the shared jellyfinProbe timeout to the injected auth client', () {
|
||||
fakeAsync((async) {
|
||||
final response = Completer<http.Response>();
|
||||
final svc = _service(handler: (_) => response.future);
|
||||
Object? probeError;
|
||||
|
||||
unawaited(_captureError(svc.probe('https://jf.example.com')).then((error) => probeError = error));
|
||||
async.flushMicrotasks();
|
||||
|
||||
async.elapse(MediaServerTimeouts.jellyfinProbe - const Duration(milliseconds: 1));
|
||||
async.flushMicrotasks();
|
||||
expect(probeError, isNull);
|
||||
|
||||
async.elapse(const Duration(milliseconds: 2));
|
||||
async.flushMicrotasks();
|
||||
expect(probeError, isA<MediaServerUrlException>());
|
||||
});
|
||||
});
|
||||
|
||||
test('registers base URL redaction before the first probe request', () async {
|
||||
final svc = _service(
|
||||
handler: (req) {
|
||||
|
||||
@@ -229,6 +229,26 @@ void main() {
|
||||
expect(info.defaultSubtitleStreamIndex, 4);
|
||||
});
|
||||
|
||||
test('coerces Jellyfin string and numeric stream scalars', () {
|
||||
final info = jellyfinMediaSourceToMediaSourceInfo({
|
||||
'Id': 'src-flexible',
|
||||
'DefaultAudioStreamIndex': '2',
|
||||
'DefaultSubtitleStreamIndex': 3.0,
|
||||
'MediaStreams': [
|
||||
{'Index': '2', 'Type': 'Audio', 'Channels': '6'},
|
||||
{'Index': 3.0, 'Type': 'Subtitle'},
|
||||
],
|
||||
});
|
||||
|
||||
expect(info.defaultAudioStreamIndex, 2);
|
||||
expect(info.defaultSubtitleStreamIndex, 3);
|
||||
expect(info.audioTracks.single.id, 2);
|
||||
expect(info.audioTracks.single.channels, 6);
|
||||
expect(info.audioTracks.single.selected, isTrue);
|
||||
expect(info.subtitleTracks.single.id, 3);
|
||||
expect(info.subtitleTracks.single.selected, isTrue);
|
||||
});
|
||||
|
||||
test('falls back to Language when DisplayLanguage absent', () {
|
||||
final info = jellyfinMediaSourceToMediaSourceInfo({
|
||||
'MediaStreams': [
|
||||
@@ -506,6 +526,30 @@ void main() {
|
||||
expect(versions[1].videoResolution, '1080');
|
||||
});
|
||||
|
||||
test('coerces source scalars and rounds bps to kbps without zero sentinels', () {
|
||||
final versions = jellyfinSourcesToVersions([
|
||||
{
|
||||
'Id': 'rounded',
|
||||
'Width': '1920',
|
||||
'Height': 1080.9,
|
||||
'Bitrate': '1500',
|
||||
'Size': '987654321',
|
||||
'MediaStreams': [
|
||||
{'Type': 'Video', 'Codec': 'h264'},
|
||||
],
|
||||
},
|
||||
{'Id': 'missing-bitrate'},
|
||||
{'Id': 'zero-bitrate', 'Bitrate': 0},
|
||||
{'Id': 'negative-bitrate', 'Bitrate': -1000},
|
||||
]);
|
||||
|
||||
expect(versions.first.width, 1920);
|
||||
expect(versions.first.height, 1080);
|
||||
expect(versions.first.bitrate, 2);
|
||||
expect(versions.first.parts.single.sizeBytes, 987654321);
|
||||
expect(versions.skip(1).map((version) => version.bitrate), everyElement(isNull));
|
||||
});
|
||||
|
||||
test('handles missing MediaStreams + missing Height gracefully', () {
|
||||
final versions = jellyfinSourcesToVersions([
|
||||
{'Id': 'x', 'Name': 'X', 'Container': 'mkv'},
|
||||
|
||||
@@ -142,4 +142,151 @@ void main() {
|
||||
expect(captured?.url.queryParameters, containsPair('genre[].tag.tag-', 'Science%20Fiction'));
|
||||
expect(captured?.url.queryParameters, containsPair('genre.locked', '1'));
|
||||
});
|
||||
|
||||
test('cached child fetch rejects decodable HTTP error responses before caching', () async {
|
||||
for (final statusCode in [404, 500]) {
|
||||
final parentId = 'parent-$statusCode';
|
||||
final endpoint = '/library/metadata/$parentId/children';
|
||||
var requestCount = 0;
|
||||
final client = makeClient((request) async {
|
||||
requestCount++;
|
||||
return http.Response(
|
||||
jsonEncode({
|
||||
'MediaContainer': {
|
||||
'size': 1,
|
||||
'totalSize': 1,
|
||||
'Metadata': [
|
||||
{'ratingKey': 'error-child', 'type': 'season', 'title': 'Must Not Parse'},
|
||||
],
|
||||
},
|
||||
}),
|
||||
statusCode,
|
||||
headers: {'content-type': 'application/json'},
|
||||
);
|
||||
});
|
||||
addTearDown(client.close);
|
||||
|
||||
await expectLater(
|
||||
client.fetchChildren(parentId),
|
||||
throwsA(
|
||||
isA<MediaServerHttpException>()
|
||||
.having((error) => error.statusCode, 'statusCode', statusCode)
|
||||
.having((error) => error.requestUri?.path, 'request path', endpoint),
|
||||
),
|
||||
);
|
||||
|
||||
expect(requestCount, 1);
|
||||
expect(await PlexApiCache.instance.get(ServerId('server-id'), endpoint), isNull);
|
||||
}
|
||||
});
|
||||
|
||||
test('HTTP failure falls back to the existing child cache without replacing it', () async {
|
||||
const parentId = 'cached-parent';
|
||||
const endpoint = '/library/metadata/$parentId/children';
|
||||
final cachedResponse = {
|
||||
'MediaContainer': {
|
||||
'size': 1,
|
||||
'totalSize': 1,
|
||||
'Metadata': [
|
||||
{'ratingKey': 'cached-child', 'type': 'season', 'title': 'Cached Season'},
|
||||
],
|
||||
},
|
||||
};
|
||||
await PlexApiCache.instance.put(ServerId('server-id'), endpoint, cachedResponse);
|
||||
var requestCount = 0;
|
||||
final client = makeClient((request) async {
|
||||
requestCount++;
|
||||
return http.Response(
|
||||
jsonEncode({
|
||||
'MediaContainer': {
|
||||
'size': 1,
|
||||
'totalSize': 1,
|
||||
'Metadata': [
|
||||
{'ratingKey': 'error-child', 'type': 'season', 'title': 'Must Not Replace Cache'},
|
||||
],
|
||||
},
|
||||
}),
|
||||
500,
|
||||
headers: {'content-type': 'application/json'},
|
||||
);
|
||||
});
|
||||
addTearDown(client.close);
|
||||
|
||||
final children = await client.fetchChildren(parentId);
|
||||
|
||||
expect(requestCount, 1);
|
||||
expect(children.map((child) => child.id), ['cached-child']);
|
||||
expect(children.single.title, 'Cached Season');
|
||||
expect(await PlexApiCache.instance.get(ServerId('server-id'), endpoint), cachedResponse);
|
||||
});
|
||||
|
||||
test('successful child fetch parses and caches the response', () async {
|
||||
const parentId = 'fresh-parent';
|
||||
const endpoint = '/library/metadata/$parentId/children';
|
||||
final responseData = {
|
||||
'MediaContainer': {
|
||||
'size': 1,
|
||||
'totalSize': 1,
|
||||
'Metadata': [
|
||||
{'ratingKey': 'fresh-child', 'type': 'season', 'title': 'Fresh Season'},
|
||||
],
|
||||
},
|
||||
};
|
||||
var requestCount = 0;
|
||||
final client = makeClient((request) async {
|
||||
requestCount++;
|
||||
return http.Response(jsonEncode(responseData), 200, headers: {'content-type': 'application/json'});
|
||||
});
|
||||
addTearDown(client.close);
|
||||
|
||||
final children = await client.fetchChildren(parentId);
|
||||
|
||||
expect(requestCount, 1);
|
||||
expect(children.map((child) => child.id), ['fresh-child']);
|
||||
expect(children.single.title, 'Fresh Season');
|
||||
expect(await PlexApiCache.instance.get(ServerId('server-id'), endpoint), responseData);
|
||||
});
|
||||
|
||||
test('child retrieval walks every page and caches the combined result', () async {
|
||||
const parentId = 'paged-parent';
|
||||
const endpoint = '/library/metadata/$parentId/children';
|
||||
final requests = <Uri>[];
|
||||
final client = makeClient((request) async {
|
||||
requests.add(request.url);
|
||||
final start = int.parse(request.url.queryParameters['X-Plex-Container-Start']!);
|
||||
final metadata = start == 0
|
||||
? [
|
||||
{'ratingKey': 'season-1', 'type': 'season', 'title': 'Season 1'},
|
||||
{'ratingKey': 'season-2', 'type': 'season', 'title': 'Season 2'},
|
||||
]
|
||||
: [
|
||||
{'ratingKey': 'season-3', 'type': 'season', 'title': 'Season 3'},
|
||||
];
|
||||
return http.Response(
|
||||
jsonEncode({
|
||||
'MediaContainer': {'size': metadata.length, 'totalSize': 3, 'Metadata': metadata},
|
||||
}),
|
||||
200,
|
||||
headers: {'content-type': 'application/json'},
|
||||
);
|
||||
});
|
||||
addTearDown(client.close);
|
||||
|
||||
final children = await client.fetchChildren(parentId);
|
||||
final cached = await PlexApiCache.instance.get(ServerId('server-id'), endpoint);
|
||||
final cachedContainer = cached!['MediaContainer'] as Map<String, dynamic>;
|
||||
final cachedMetadata = cachedContainer['Metadata'] as List<dynamic>;
|
||||
|
||||
expect(children.map((child) => child.id), ['season-1', 'season-2', 'season-3']);
|
||||
expect(requests.map((uri) => uri.queryParameters['X-Plex-Container-Start']), ['0', '2']);
|
||||
expect(requests.every((uri) => uri.queryParameters['X-Plex-Container-Size'] == '200'), isTrue);
|
||||
expect(requests.every((uri) => uri.queryParameters['includeStreams'] == '1'), isTrue);
|
||||
expect(cachedContainer['size'], 3);
|
||||
expect(cachedContainer['totalSize'], 3);
|
||||
expect(cachedMetadata.map((item) => (item as Map<String, dynamic>)['ratingKey']), [
|
||||
'season-1',
|
||||
'season-2',
|
||||
'season-3',
|
||||
]);
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user