fix: eliminate cross-app consistency drift

This commit is contained in:
edde746
2026-07-13 23:13:53 +02:00
parent ec249d2ed4
commit d5f3e581df
142 changed files with 4751 additions and 1075 deletions
@@ -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',
]);
});
}