jellyfin_library_service_test.dart 8.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261
  1. import 'dart:typed_data';
  2. import 'package:cardash_launcher/data/models/jellyfin_library.dart';
  3. import 'package:cardash_launcher/data/models/jellyfin_models.dart';
  4. import 'package:cardash_launcher/data/services/jellyfin_library_service.dart';
  5. import 'package:flutter_test/flutter_test.dart';
  6. import 'jellyfin_fakes.dart';
  7. void main() {
  8. test(
  9. 'recently played dedupes tracks by album and takes the newest five',
  10. () async {
  11. final gateway = FakeJellyfinLibraryGateway()
  12. ..played = [
  13. track(
  14. 'a2',
  15. albumId: 'album-a',
  16. albumName: 'Album A',
  17. artist: 'Artist A',
  18. lastPlayed: DateTime.utc(2026, 10, 31, 12),
  19. ),
  20. track(
  21. 'a1',
  22. albumId: 'album-a',
  23. albumName: 'Album A',
  24. artist: 'Artist A',
  25. lastPlayed: DateTime.utc(2026, 10, 31, 11),
  26. ),
  27. track('b1', albumId: 'album-b', albumName: 'Album B', artist: 'B'),
  28. track('c1', albumId: 'album-c', albumName: 'Album C', artist: 'C'),
  29. track('d1', albumId: 'album-d', albumName: 'Album D', artist: 'D'),
  30. track('e1', albumId: 'album-e', albumName: 'Album E', artist: 'E'),
  31. track('f1', albumId: 'album-f', albumName: 'Album F', artist: 'F'),
  32. ];
  33. final service = JellyfinLibraryService(
  34. connectionProvider: () => testJellyfinConnection,
  35. gateway: gateway,
  36. );
  37. final albums = await service.latestPlayed();
  38. expect(albums.map((album) => album.id), [
  39. 'album-a',
  40. 'album-b',
  41. 'album-c',
  42. 'album-d',
  43. 'album-e',
  44. ]);
  45. expect(albums.first.name, 'Album A');
  46. expect(albums.first.lastPlayed, DateTime.utc(2026, 10, 31, 12));
  47. expect(gateway.playedCalls, 1);
  48. },
  49. );
  50. test('latest added delegates to the album query', () async {
  51. final gateway = FakeJellyfinLibraryGateway()
  52. ..added = [album('one'), album('two')];
  53. final service = JellyfinLibraryService(
  54. connectionProvider: () => testJellyfinConnection,
  55. gateway: gateway,
  56. );
  57. final albums = await service.latestAdded(limit: 2);
  58. expect(albums.map((entry) => entry.id), ['one', 'two']);
  59. expect(gateway.addedCalls, 1);
  60. });
  61. test('genre families are cached and can be forced', () async {
  62. final gateway = FakeJellyfinLibraryGateway()
  63. ..genres = const ['Metal', 'Jazz', 'Рок'];
  64. final service = JellyfinLibraryService(
  65. connectionProvider: () => testJellyfinConnection,
  66. gateway: gateway,
  67. );
  68. final first = await service.genreFamilies();
  69. expect(first.map((family) => family.label), ['Metal', 'Rock', 'Jazz']);
  70. expect(gateway.genreCalls, 1);
  71. await service.genreFamilies();
  72. expect(gateway.genreCalls, 1);
  73. await service.genreFamilies(force: true);
  74. expect(gateway.genreCalls, 2);
  75. });
  76. test('random albums expand only the selected families', () async {
  77. final gateway = FakeJellyfinLibraryGateway()
  78. ..genres = const ['Metal', 'Melodic Metal', 'Метъл', 'Jazz', 'Рок']
  79. ..random = [album('r1'), album('r2')];
  80. final service = JellyfinLibraryService(
  81. connectionProvider: () => testJellyfinConnection,
  82. gateway: gateway,
  83. );
  84. final albums = await service.randomAlbums(
  85. selections: const [
  86. JellyfinGenreSelection(id: 'metal', label: 'Metal'),
  87. JellyfinGenreSelection(id: 'jazz', label: 'Jazz'),
  88. ],
  89. );
  90. expect(albums.map((entry) => entry.id), ['r1', 'r2']);
  91. expect(
  92. gateway.lastRandomGenres,
  93. containsAll(['Metal', 'Melodic Metal', 'Метъл', 'Jazz']),
  94. );
  95. expect(gateway.lastRandomGenres, isNot(contains('Рок')));
  96. });
  97. test('no selected genres means no random request', () async {
  98. final gateway = FakeJellyfinLibraryGateway();
  99. final service = JellyfinLibraryService(
  100. connectionProvider: () => testJellyfinConnection,
  101. gateway: gateway,
  102. );
  103. final albums = await service.randomAlbums(selections: const []);
  104. expect(albums, isEmpty);
  105. expect(gateway.randomCalls, 0);
  106. });
  107. test('stream URL carries no token and uses the universal endpoint', () {
  108. final gateway = FakeJellyfinLibraryGateway();
  109. final service = JellyfinLibraryService(
  110. connectionProvider: () => testJellyfinConnection,
  111. gateway: gateway,
  112. );
  113. final url = service.streamUrl(
  114. track('a1', mediaSourceId: 'source-a1'),
  115. maxStreamingBitrate: 320000,
  116. );
  117. final text = url.toString();
  118. expect(text, contains('Audio/a1/universal'));
  119. expect(text, contains('MediaSourceId=source-a1'));
  120. expect(text, contains('MaxStreamingBitrate=320000'));
  121. expect(text, contains('EnableRedirection=true'));
  122. expect(text, isNot(contains('test-token')));
  123. expect(text, isNot(contains('api_key')));
  124. });
  125. test('audio quality requests an MP3 transcode at the selected cap', () {
  126. final service = JellyfinLibraryService(
  127. connectionProvider: () => testJellyfinConnection,
  128. gateway: FakeJellyfinLibraryGateway(),
  129. audioQualityProvider: () => JellyfinAudioQuality.kbps128,
  130. );
  131. final query = service
  132. .streamUrl(track('a1', mediaSourceId: 'source-a1'))
  133. .queryParameters;
  134. expect(query['MaxStreamingBitrate'], '128000');
  135. expect(query['Container'], 'mp3');
  136. expect(query['AudioCodec'], 'mp3');
  137. expect(query['TranscodingContainer'], 'mp3');
  138. });
  139. test('direct quality leaves the source codecs and has no bitrate cap', () {
  140. final service = JellyfinLibraryService(
  141. connectionProvider: () => testJellyfinConnection,
  142. gateway: FakeJellyfinLibraryGateway(),
  143. );
  144. final query = service
  145. .streamUrl(
  146. track('a1', mediaSourceId: 'source-a1'),
  147. quality: JellyfinAudioQuality.direct,
  148. )
  149. .queryParameters;
  150. expect(query.containsKey('MaxStreamingBitrate'), isFalse);
  151. expect(query['Container'], contains('flac'));
  152. expect(query['AudioCodec'], contains('flac'));
  153. });
  154. test('buildQueue produces a native queue with artwork and auth', () async {
  155. final gateway = FakeJellyfinLibraryGateway()
  156. ..tracks = {
  157. 'album-a': [
  158. track('a1', duration: const Duration(seconds: 60)),
  159. track('a2', duration: const Duration(seconds: 120)),
  160. ],
  161. }
  162. ..artwork = Uint8List.fromList([1, 2, 3]);
  163. final service = JellyfinLibraryService(
  164. connectionProvider: () => testJellyfinConnection,
  165. gateway: gateway,
  166. );
  167. final queue = await service.buildQueue(
  168. album('album-a', imageTag: 'tag-a'),
  169. playSessionId: 'session-1',
  170. );
  171. expect(queue, isNotNull);
  172. expect(queue!.items.length, 2);
  173. expect(queue.items.first.trackId, 'a1');
  174. expect(queue.items.first.durationMs, 60000);
  175. expect(queue.items.first.url, contains('Audio/a1/universal'));
  176. expect(queue.playSessionId, 'session-1');
  177. expect(queue.authorization, contains('Token="test-token"'));
  178. expect(queue.artwork, [1, 2, 3]);
  179. expect(queue.items.first.url, isNot(contains('test-token')));
  180. });
  181. test(
  182. 'buildQueue returns null for an album with no playable tracks',
  183. () async {
  184. final gateway = FakeJellyfinLibraryGateway();
  185. final service = JellyfinLibraryService(
  186. connectionProvider: () => testJellyfinConnection,
  187. gateway: gateway,
  188. );
  189. final queue = await service.buildQueue(album('empty'));
  190. expect(queue, isNull);
  191. },
  192. );
  193. test(
  194. 'image URL and headers are available without exposing the token in the URL',
  195. () {
  196. final gateway = FakeJellyfinLibraryGateway();
  197. final service = JellyfinLibraryService(
  198. connectionProvider: () => testJellyfinConnection,
  199. gateway: gateway,
  200. );
  201. final url = service.imageUrl('album-a', 'tag-a', maxHeight: 300);
  202. expect(url, isNotNull);
  203. expect(url.toString(), contains('Items/album-a/Images/Primary'));
  204. expect(url.toString(), contains('maxHeight=300'));
  205. expect(url.toString(), contains('tag=tag-a'));
  206. expect(url.toString(), isNot(contains('test-token')));
  207. expect(
  208. service.imageHeaders['Authorization'],
  209. contains('Token="test-token"'),
  210. );
  211. },
  212. );
  213. test('a missing connection fails without touching the gateway', () async {
  214. final gateway = FakeJellyfinLibraryGateway();
  215. final service = JellyfinLibraryService(
  216. connectionProvider: () => null,
  217. gateway: gateway,
  218. );
  219. expect(service.isConfigured, isFalse);
  220. await expectLater(
  221. service.latestPlayed(),
  222. throwsA(isA<JellyfinException>()),
  223. );
  224. expect(gateway.playedCalls, 0);
  225. });
  226. }