feat(jellyfin): songs and their lyrics, read as subtitles are

A music library's artists and albums open as shows and seasons do, and
can be tied to a studyset. A song plays as sound alone (`/Audio/{id}/
stream`, as stored) with its lyrics as Jellyfin keeps them (`/Audio/{id}/
Lyrics`: an LRC file beside it, or its tags), cut into the dictionary's
words as a video's subtitles are, with no translation. Timed lyrics
follow the song: the line sung large and bright among the others faded,
scrolled to as it comes, a line tapped played from its start; untimed
ones show whole. A song without lyrics shows greyed and doesn't open
(`HasLyrics`), as there is nothing in it to read. Its cover stands where
the picture would, glowing as a video does, and its words are tagged
with the artist, shown, and "artist · title", hidden.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
This commit is contained in:
Tizian 「ティツィアーン」 2026-10-13 19:46:54 +02:00
commit 22f5a4fcd1
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64

View file

@ -2,6 +2,7 @@ import 'dart:convert';
import 'dart:io';
import 'package:flutter_test/flutter_test.dart';
import 'package:halkyon_learn_flutter/src/core/video/subtitles.dart';
import 'package:halkyon_learn_flutter/src/feature/jellyfin/bloc/watch_cubit.dart';
import 'package:halkyon_learn_flutter/src/feature/jellyfin/data/jellyfin_gateway.dart';
import 'package:halkyon_learn_flutter/src/feature/jellyfin/data/jellyfin_repository.dart';
@ -56,10 +57,35 @@ MockClient jellyfin12(List<http.Request> requests) => MockClient((
'CollectionType': 'tvshows',
'ImageTags': <String, String>{},
},
{'Name': 'Music', 'Id': 'lib-2', 'Type': 'MusicAlbum'},
{
'Name': 'Music',
'Id': 'lib-2',
'Type': 'CollectionFolder',
'CollectionType': 'music',
},
{'Name': 'A book', 'Id': 'book-1', 'Type': 'Book'},
],
'TotalRecordCount': 2,
}),
('GET', '/Items/song-1') => json({
'Name': '幽霊船戦',
'Id': 'song-1',
'Type': 'Audio',
'AlbumArtist': '宝鐘マリン',
'Album': 'Ahoy!!',
'HasLyrics': true,
'IndexNumber': 3,
'ParentIndexNumber': 1,
'RunTimeTicks': 2000000000,
'ImageTags': {'Primary': 'a1'},
}),
('GET', '/Audio/song-1/Lyrics') => json({
'Metadata': <String, Object?>{},
'Lyrics': [
{'Text': '海賊なら Yo-Ho!', 'Start': 68200000, 'Cues': <Object?>[]},
{'Text': 'さぁ今旗のもとへ', 'Start': 95800000, 'Cues': <Object?>[]},
],
}),
('GET', '/Items/ep-1') => json({
'Name': 'Sample Journey S01E01',
'Id': 'ep-1',
@ -204,7 +230,8 @@ void main() {
},
);
test('lists libraries, leaving out what is no video', () async {
test('lists libraries of videos and of music, leaving out what is '
'neither', () async {
final account = await signIn();
final libraries = await gateway.libraries(account);
@ -215,6 +242,11 @@ void main() {
name: 'Anime',
kind: JellyfinItemKind.library,
),
const JellyfinItem(
id: 'lib-2',
name: 'Music',
kind: JellyfinItemKind.library,
),
]);
expect(requests.last.headers['Authorization'], contains('Token="t-1"'));
expect(requests.last.url.queryParameters['userId'], 'u-1');
@ -286,6 +318,38 @@ void main() {
);
});
test('a song is one, with its artist and whether it has lyrics, its '
'lyrics timed line by line, none for a song without; a song streams '
'as sound', () async {
final account = await signIn();
final song = await gateway.item(account, 'song-1');
expect(song.kind, JellyfinItemKind.audio);
expect(song.artist, '宝鐘マリン');
expect(song.hasLyrics, isTrue);
expect(song.seasonNumber, isNull);
expect(await gateway.lyrics(account, 'song-1'), [
(text: '海賊なら Yo-Ho!', start: const Duration(milliseconds: 6820)),
(text: 'さぁ今旗のもとへ', start: const Duration(milliseconds: 9580)),
]);
expect(await gateway.lyrics(account, 'song-2'), isEmpty);
expect(
gateway
.streamOf(
account,
const JellyfinPlayback(
itemId: 'song-1',
mediaSourceId: 'song-1',
playSessionId: 'ps-2',
subtitles: [],
audioOnly: true,
),
)
.path,
'/Audio/song-1/stream',
);
});
test('a token the server no longer takes is signed out; nothing '
'answering is unreachable', () async {
final server = await gateway.server(
@ -342,6 +406,44 @@ void main() {
});
group('JellyfinRepository', () {
test("a song's lyrics are lines, each until the next, the last until "
'the song ends; empty ones left out; untimed lyrics are text '
'alone', () async {
JellyfinRepository with_(List<({String text, Duration? start})> lines) =>
JellyfinRepository(
(_) => FakeJellyfinGateway(lyricsOf: {'song-1': lines}),
signedInJellyfin(),
deviceName: 'Test',
appVersion: '1',
);
final timed = await with_(const [
(text: '一', start: Duration(seconds: 1)),
(text: ' ', start: Duration(seconds: 4)),
(text: '二', start: Duration(seconds: 6)),
]).lyrics('song-1', runTime: const Duration(minutes: 3));
expect(timed.synced, isTrue);
expect(timed.lines, const [
SubtitleCue(
start: Duration(seconds: 1),
end: Duration(seconds: 6),
text: '一',
),
SubtitleCue(
start: Duration(seconds: 6),
end: Duration(minutes: 3),
text: '二',
),
]);
final untimed = await with_(const [
(text: '一', start: null),
(text: '二', start: null),
]).lyrics('song-1');
expect(untimed.synced, isFalse);
expect([for (final line in untimed.lines) line.text], ['一', '二']);
});
test('an address is reached over HTTPS only, plain HTTP refused '
'before anything is sent', () async {
final asked = <Uri>[];