feat(jellyfin): watch from one's own Jellyfin server and learn the words of its subtitles
Animelon's way of learning from video, from the person's own Jellyfin
server: connected per device (Settings, or a studyset's Add material ›
Watch from Jellyfin) with a name and password or a Quick Connect code,
over HTTPS only (plain HTTP only to a server on the same machine); the
token stays in the system's keyring. Shows, films and videos tied to a
studyset follow the person in their settings as the server's and the
item's ids alone (settings schema 9), named by the server when shown.
The Watch screen plays the video (video_player on Android, media_kit's
libmpv on Linux and Windows) with the line in the studyset's front
language cut into the dictionary's words with furigana, and the back
language's line beneath; a word tapped pauses the video and becomes a
card with the line and its translation as its first hint, tagged with
the show. The video's words are listed, most frequent first. The sound is
the track in the studyset's language, whichever the file marks default,
and the menu switches it; the subtitles read are a whole track, never a
forced one (which only covers what the sound doesn't say), and captions
for the hard of hearing only without plain ones. The line and its
translation take the video's width in a box of a fixed height, so the
video keeps its size; in the dark theme the video's colours glow around
it, as YouTube's ambient mode, and the words' panel takes the colour of
the episode's picture under the pointer.
A line is cut into words with one request: DictionaryEndpoint.withKeys
looks up every stretch of it that may be a word, as it stands and as
every word it may be a conjugation of, in the new dictionary_key table
(one row per key, indexed; migration dictionary-keys adds the table and
its index, nothing else), which the loader fills beside the entries.
The instance gets the text of each line looked up and keeps none of it,
as the Watch screen says.
Linux builds need libmpv-dev, and the app links libmpv: the snap stages
Ubuntu's libmpv2, makes the update-alternatives links for BLAS and LAPACK
that ffmpeg's speech recognition needs (staging never runs install
scripts), and removes what the GNOME runtime and core24 already hold, as
older copies of cairo, pango and librsvg hid the runtime's from its GTK.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-13 11:31:37 +02:00
|
|
|
import 'dart:convert';
|
|
|
|
|
import 'dart:io';
|
|
|
|
|
|
|
|
|
|
import 'package:flutter_test/flutter_test.dart';
|
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>
2026-10-13 19:46:54 +02:00
|
|
|
import 'package:halkyon_learn_flutter/src/core/video/subtitles.dart';
|
feat(jellyfin): watch from one's own Jellyfin server and learn the words of its subtitles
Animelon's way of learning from video, from the person's own Jellyfin
server: connected per device (Settings, or a studyset's Add material ›
Watch from Jellyfin) with a name and password or a Quick Connect code,
over HTTPS only (plain HTTP only to a server on the same machine); the
token stays in the system's keyring. Shows, films and videos tied to a
studyset follow the person in their settings as the server's and the
item's ids alone (settings schema 9), named by the server when shown.
The Watch screen plays the video (video_player on Android, media_kit's
libmpv on Linux and Windows) with the line in the studyset's front
language cut into the dictionary's words with furigana, and the back
language's line beneath; a word tapped pauses the video and becomes a
card with the line and its translation as its first hint, tagged with
the show. The video's words are listed, most frequent first. The sound is
the track in the studyset's language, whichever the file marks default,
and the menu switches it; the subtitles read are a whole track, never a
forced one (which only covers what the sound doesn't say), and captions
for the hard of hearing only without plain ones. The line and its
translation take the video's width in a box of a fixed height, so the
video keeps its size; in the dark theme the video's colours glow around
it, as YouTube's ambient mode, and the words' panel takes the colour of
the episode's picture under the pointer.
A line is cut into words with one request: DictionaryEndpoint.withKeys
looks up every stretch of it that may be a word, as it stands and as
every word it may be a conjugation of, in the new dictionary_key table
(one row per key, indexed; migration dictionary-keys adds the table and
its index, nothing else), which the loader fills beside the entries.
The instance gets the text of each line looked up and keeps none of it,
as the Watch screen says.
Linux builds need libmpv-dev, and the app links libmpv: the snap stages
Ubuntu's libmpv2, makes the update-alternatives links for BLAS and LAPACK
that ffmpeg's speech recognition needs (staging never runs install
scripts), and removes what the GNOME runtime and core24 already hold, as
older copies of cairo, pango and librsvg hid the runtime's from its GTK.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-13 11:31:37 +02:00
|
|
|
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';
|
|
|
|
|
import 'package:halkyon_learn_flutter/src/feature/jellyfin/data/jellyfin_store.dart';
|
|
|
|
|
import 'package:halkyon_learn_flutter/src/feature/jellyfin/model/jellyfin.dart';
|
feat(jellyfin): a show's tags follow it whatever it is called
A word met on Jellyfin is tagged with the show and the episode by the
names Jellyfin gives them, so a server switching its metadata language,
another server, or a rename in Manage tags made a second set of tags for
the same show. Such a tag now also keeps what it stands for: the ids the
show (or the artist, or the film) has with the metadata providers and on
the server, and the episode's number or the song's own id. New words
find their tag by those first, keeping its name; a Jellyfin tag from
before, found by its name, takes them, and a rename keeps them.
They stay in tag schema 2, a deliberate exception to ADR 0002's rule 3:
schema 3 would hide every new Jellyfin tag from v0.17, while an app
that rewrites the tag only drops the ids, and the tag is found by its
name again.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-16 22:34:28 +02:00
|
|
|
import 'package:halkyon_learn_flutter/src/feature/tag/model/tag.dart';
|
feat(jellyfin): watch from one's own Jellyfin server and learn the words of its subtitles
Animelon's way of learning from video, from the person's own Jellyfin
server: connected per device (Settings, or a studyset's Add material ›
Watch from Jellyfin) with a name and password or a Quick Connect code,
over HTTPS only (plain HTTP only to a server on the same machine); the
token stays in the system's keyring. Shows, films and videos tied to a
studyset follow the person in their settings as the server's and the
item's ids alone (settings schema 9), named by the server when shown.
The Watch screen plays the video (video_player on Android, media_kit's
libmpv on Linux and Windows) with the line in the studyset's front
language cut into the dictionary's words with furigana, and the back
language's line beneath; a word tapped pauses the video and becomes a
card with the line and its translation as its first hint, tagged with
the show. The video's words are listed, most frequent first. The sound is
the track in the studyset's language, whichever the file marks default,
and the menu switches it; the subtitles read are a whole track, never a
forced one (which only covers what the sound doesn't say), and captions
for the hard of hearing only without plain ones. The line and its
translation take the video's width in a box of a fixed height, so the
video keeps its size; in the dark theme the video's colours glow around
it, as YouTube's ambient mode, and the words' panel takes the colour of
the episode's picture under the pointer.
A line is cut into words with one request: DictionaryEndpoint.withKeys
looks up every stretch of it that may be a word, as it stands and as
every word it may be a conjugation of, in the new dictionary_key table
(one row per key, indexed; migration dictionary-keys adds the table and
its index, nothing else), which the loader fills beside the entries.
The instance gets the text of each line looked up and keeps none of it,
as the Watch screen says.
Linux builds need libmpv-dev, and the app links libmpv: the snap stages
Ubuntu's libmpv2, makes the update-alternatives links for BLAS and LAPACK
that ffmpeg's speech recognition needs (staging never runs install
scripts), and removes what the GNOME runtime and core24 already hold, as
older copies of cairo, pango and librsvg hid the runtime's from its GTK.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-13 11:31:37 +02:00
|
|
|
import 'package:http/http.dart' as http;
|
|
|
|
|
import 'package:http/testing.dart';
|
|
|
|
|
|
|
|
|
|
import '../../support/fake_jellyfin.dart';
|
|
|
|
|
import '../../support/sample_episode.dart';
|
|
|
|
|
|
|
|
|
|
/// A server answering as Jellyfin v12.2.0 does (its answers recorded),
|
|
|
|
|
/// noting each request.
|
|
|
|
|
MockClient jellyfin12(List<http.Request> requests) => MockClient((
|
|
|
|
|
request,
|
|
|
|
|
) async {
|
|
|
|
|
requests.add(request);
|
|
|
|
|
http.Response json(Object? body, [int status = 200]) => http.Response.bytes(
|
|
|
|
|
utf8.encode(jsonEncode(body)),
|
|
|
|
|
status,
|
|
|
|
|
headers: {
|
|
|
|
|
'content-type': 'application/json; charset=utf-8',
|
|
|
|
|
},
|
|
|
|
|
);
|
|
|
|
|
final token = request.headers['Authorization'] ?? '';
|
|
|
|
|
final signedIn = token.contains('Token="t-1"');
|
|
|
|
|
return switch ((request.method, request.url.path)) {
|
|
|
|
|
// v12 writes this one in camelCase, the rest in PascalCase.
|
|
|
|
|
('GET', '/System/Info/Public') => json({
|
|
|
|
|
'localAddress': 'http://172.17.0.2:8096',
|
|
|
|
|
'serverName': 'Living room',
|
|
|
|
|
'version': '12.2.0',
|
|
|
|
|
'productName': 'Jellyfin Server',
|
|
|
|
|
'id': 'e39b17f2',
|
|
|
|
|
'startupWizardCompleted': true,
|
|
|
|
|
}),
|
2026-10-14 01:52:42 +02:00
|
|
|
// The people the server shows on its login screen, in either case of
|
|
|
|
|
// field names; whether one has a password, which servers before v10.11
|
|
|
|
|
// said, counts for nothing.
|
feat(jellyfin): the instance suggests a Jellyfin server, signed in to with a tap
An instance now names a Jellyfin server for its people to learn from
(instance.info().jellyfinUrl): its operator's JELLYFIN_URL (`none`: none),
else https://jellyfin. on the domain registered for its public host, as
learn.halkyon.eu suggests jellyfin.halkyon.eu, once that answers as a
Jellyfin server. The registered domain comes from Mozilla's Public Suffix
List, kept in the server's data folder, so an instance at example.co.uk
never names someone else's jellyfin.co.uk. The instance looks as it
starts and again hourly when asked; the app never reaches for a guessed
address.
In the app, Add material offers From Jellyfin as soon as the instance
suggests a server, before anyone signed in to one. The sign-in fills the
server in and finds it by itself, and lists the people the server shows
on its login screen: one without a password, as a demo's, signs in with
a tap; one with a password is named in the form, which asks for it.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-13 23:25:33 +02:00
|
|
|
('GET', '/Users/Public') => json([
|
|
|
|
|
{'Name': 'Demo', 'Id': 'u-2', 'HasPassword': false},
|
|
|
|
|
{'name': 'mika', 'id': 'u-1', 'hasPassword': true},
|
|
|
|
|
]),
|
feat(jellyfin): watch from one's own Jellyfin server and learn the words of its subtitles
Animelon's way of learning from video, from the person's own Jellyfin
server: connected per device (Settings, or a studyset's Add material ›
Watch from Jellyfin) with a name and password or a Quick Connect code,
over HTTPS only (plain HTTP only to a server on the same machine); the
token stays in the system's keyring. Shows, films and videos tied to a
studyset follow the person in their settings as the server's and the
item's ids alone (settings schema 9), named by the server when shown.
The Watch screen plays the video (video_player on Android, media_kit's
libmpv on Linux and Windows) with the line in the studyset's front
language cut into the dictionary's words with furigana, and the back
language's line beneath; a word tapped pauses the video and becomes a
card with the line and its translation as its first hint, tagged with
the show. The video's words are listed, most frequent first. The sound is
the track in the studyset's language, whichever the file marks default,
and the menu switches it; the subtitles read are a whole track, never a
forced one (which only covers what the sound doesn't say), and captions
for the hard of hearing only without plain ones. The line and its
translation take the video's width in a box of a fixed height, so the
video keeps its size; in the dark theme the video's colours glow around
it, as YouTube's ambient mode, and the words' panel takes the colour of
the episode's picture under the pointer.
A line is cut into words with one request: DictionaryEndpoint.withKeys
looks up every stretch of it that may be a word, as it stands and as
every word it may be a conjugation of, in the new dictionary_key table
(one row per key, indexed; migration dictionary-keys adds the table and
its index, nothing else), which the loader fills beside the entries.
The instance gets the text of each line looked up and keeps none of it,
as the Watch screen says.
Linux builds need libmpv-dev, and the app links libmpv: the snap stages
Ubuntu's libmpv2, makes the update-alternatives links for BLAS and LAPACK
that ffmpeg's speech recognition needs (staging never runs install
scripts), and removes what the GNOME runtime and core24 already hold, as
older copies of cairo, pango and librsvg hid the runtime's from its GTK.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-13 11:31:37 +02:00
|
|
|
('POST', '/Users/AuthenticateByName') =>
|
|
|
|
|
(jsonDecode(request.body) as Map<String, Object?>)['Pw'] == 'secret'
|
|
|
|
|
? json({
|
|
|
|
|
'User': {'Name': 'mika', 'Id': 'u-1', 'ServerId': 'e39b17f2'},
|
|
|
|
|
'AccessToken': 't-1',
|
|
|
|
|
'ServerId': 'e39b17f2',
|
|
|
|
|
})
|
|
|
|
|
: json({'error': 'Invalid user or password'}, 401),
|
|
|
|
|
_ when !signedIn => json(null, 401),
|
|
|
|
|
('GET', '/UserViews') => json({
|
|
|
|
|
'Items': [
|
|
|
|
|
{
|
|
|
|
|
'Name': 'Anime',
|
|
|
|
|
'Id': 'lib-1',
|
|
|
|
|
'Type': 'CollectionFolder',
|
|
|
|
|
'CollectionType': 'tvshows',
|
|
|
|
|
'ImageTags': <String, String>{},
|
|
|
|
|
},
|
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>
2026-10-13 19:46:54 +02:00
|
|
|
{
|
|
|
|
|
'Name': 'Music',
|
|
|
|
|
'Id': 'lib-2',
|
|
|
|
|
'Type': 'CollectionFolder',
|
|
|
|
|
'CollectionType': 'music',
|
|
|
|
|
},
|
|
|
|
|
{'Name': 'A book', 'Id': 'book-1', 'Type': 'Book'},
|
feat(jellyfin): watch from one's own Jellyfin server and learn the words of its subtitles
Animelon's way of learning from video, from the person's own Jellyfin
server: connected per device (Settings, or a studyset's Add material ›
Watch from Jellyfin) with a name and password or a Quick Connect code,
over HTTPS only (plain HTTP only to a server on the same machine); the
token stays in the system's keyring. Shows, films and videos tied to a
studyset follow the person in their settings as the server's and the
item's ids alone (settings schema 9), named by the server when shown.
The Watch screen plays the video (video_player on Android, media_kit's
libmpv on Linux and Windows) with the line in the studyset's front
language cut into the dictionary's words with furigana, and the back
language's line beneath; a word tapped pauses the video and becomes a
card with the line and its translation as its first hint, tagged with
the show. The video's words are listed, most frequent first. The sound is
the track in the studyset's language, whichever the file marks default,
and the menu switches it; the subtitles read are a whole track, never a
forced one (which only covers what the sound doesn't say), and captions
for the hard of hearing only without plain ones. The line and its
translation take the video's width in a box of a fixed height, so the
video keeps its size; in the dark theme the video's colours glow around
it, as YouTube's ambient mode, and the words' panel takes the colour of
the episode's picture under the pointer.
A line is cut into words with one request: DictionaryEndpoint.withKeys
looks up every stretch of it that may be a word, as it stands and as
every word it may be a conjugation of, in the new dictionary_key table
(one row per key, indexed; migration dictionary-keys adds the table and
its index, nothing else), which the loader fills beside the entries.
The instance gets the text of each line looked up and keeps none of it,
as the Watch screen says.
Linux builds need libmpv-dev, and the app links libmpv: the snap stages
Ubuntu's libmpv2, makes the update-alternatives links for BLAS and LAPACK
that ffmpeg's speech recognition needs (staging never runs install
scripts), and removes what the GNOME runtime and core24 already hold, as
older copies of cairo, pango and librsvg hid the runtime's from its GTK.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-13 11:31:37 +02:00
|
|
|
],
|
|
|
|
|
'TotalRecordCount': 2,
|
|
|
|
|
}),
|
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>
2026-10-13 19:46:54 +02:00
|
|
|
('GET', '/Items/song-1') => json({
|
|
|
|
|
'Name': '幽霊船戦',
|
|
|
|
|
'Id': 'song-1',
|
|
|
|
|
'Type': 'Audio',
|
feat(jellyfin): a show's tags follow it whatever it is called
A word met on Jellyfin is tagged with the show and the episode by the
names Jellyfin gives them, so a server switching its metadata language,
another server, or a rename in Manage tags made a second set of tags for
the same show. Such a tag now also keeps what it stands for: the ids the
show (or the artist, or the film) has with the metadata providers and on
the server, and the episode's number or the song's own id. New words
find their tag by those first, keeping its name; a Jellyfin tag from
before, found by its name, takes them, and a rename keeps them.
They stay in tag schema 2, a deliberate exception to ADR 0002's rule 3:
schema 3 would hide every new Jellyfin tag from v0.17, while an app
that rewrites the tag only drops the ids, and the tag is found by its
name again.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-16 22:34:28 +02:00
|
|
|
'AlbumArtists': [
|
|
|
|
|
{'Name': '宝鐘マリン', 'Id': 'artist-1'},
|
|
|
|
|
],
|
|
|
|
|
'ProviderIds': {'MusicBrainzTrack': 'mb-track-1'},
|
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>
2026-10-13 19:46:54 +02:00
|
|
|
'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?>[]},
|
|
|
|
|
],
|
|
|
|
|
}),
|
feat(jellyfin): watch from one's own Jellyfin server and learn the words of its subtitles
Animelon's way of learning from video, from the person's own Jellyfin
server: connected per device (Settings, or a studyset's Add material ›
Watch from Jellyfin) with a name and password or a Quick Connect code,
over HTTPS only (plain HTTP only to a server on the same machine); the
token stays in the system's keyring. Shows, films and videos tied to a
studyset follow the person in their settings as the server's and the
item's ids alone (settings schema 9), named by the server when shown.
The Watch screen plays the video (video_player on Android, media_kit's
libmpv on Linux and Windows) with the line in the studyset's front
language cut into the dictionary's words with furigana, and the back
language's line beneath; a word tapped pauses the video and becomes a
card with the line and its translation as its first hint, tagged with
the show. The video's words are listed, most frequent first. The sound is
the track in the studyset's language, whichever the file marks default,
and the menu switches it; the subtitles read are a whole track, never a
forced one (which only covers what the sound doesn't say), and captions
for the hard of hearing only without plain ones. The line and its
translation take the video's width in a box of a fixed height, so the
video keeps its size; in the dark theme the video's colours glow around
it, as YouTube's ambient mode, and the words' panel takes the colour of
the episode's picture under the pointer.
A line is cut into words with one request: DictionaryEndpoint.withKeys
looks up every stretch of it that may be a word, as it stands and as
every word it may be a conjugation of, in the new dictionary_key table
(one row per key, indexed; migration dictionary-keys adds the table and
its index, nothing else), which the loader fills beside the entries.
The instance gets the text of each line looked up and keeps none of it,
as the Watch screen says.
Linux builds need libmpv-dev, and the app links libmpv: the snap stages
Ubuntu's libmpv2, makes the update-alternatives links for BLAS and LAPACK
that ffmpeg's speech recognition needs (staging never runs install
scripts), and removes what the GNOME runtime and core24 already hold, as
older copies of cairo, pango and librsvg hid the runtime's from its GTK.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-13 11:31:37 +02:00
|
|
|
('GET', '/Items/ep-1') => json({
|
|
|
|
|
'Name': 'Sample Journey S01E01',
|
|
|
|
|
'Id': 'ep-1',
|
|
|
|
|
'Type': 'Episode',
|
|
|
|
|
'SeriesName': 'Sample Journey',
|
feat(jellyfin): a show's tags follow it whatever it is called
A word met on Jellyfin is tagged with the show and the episode by the
names Jellyfin gives them, so a server switching its metadata language,
another server, or a rename in Manage tags made a second set of tags for
the same show. Such a tag now also keeps what it stands for: the ids the
show (or the artist, or the film) has with the metadata providers and on
the server, and the episode's number or the song's own id. New words
find their tag by those first, keeping its name; a Jellyfin tag from
before, found by its name, takes them, and a rename keeps them.
They stay in tag schema 2, a deliberate exception to ADR 0002's rule 3:
schema 3 would hide every new Jellyfin tag from v0.17, while an app
that rewrites the tag only drops the ids, and the tag is found by its
name again.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-16 22:34:28 +02:00
|
|
|
'SeriesId': 'series-1',
|
|
|
|
|
'ProviderIds': {'Tmdb': '9001', 'Tvdb': ''},
|
feat(jellyfin): watch from one's own Jellyfin server and learn the words of its subtitles
Animelon's way of learning from video, from the person's own Jellyfin
server: connected per device (Settings, or a studyset's Add material ›
Watch from Jellyfin) with a name and password or a Quick Connect code,
over HTTPS only (plain HTTP only to a server on the same machine); the
token stays in the system's keyring. Shows, films and videos tied to a
studyset follow the person in their settings as the server's and the
item's ids alone (settings schema 9), named by the server when shown.
The Watch screen plays the video (video_player on Android, media_kit's
libmpv on Linux and Windows) with the line in the studyset's front
language cut into the dictionary's words with furigana, and the back
language's line beneath; a word tapped pauses the video and becomes a
card with the line and its translation as its first hint, tagged with
the show. The video's words are listed, most frequent first. The sound is
the track in the studyset's language, whichever the file marks default,
and the menu switches it; the subtitles read are a whole track, never a
forced one (which only covers what the sound doesn't say), and captions
for the hard of hearing only without plain ones. The line and its
translation take the video's width in a box of a fixed height, so the
video keeps its size; in the dark theme the video's colours glow around
it, as YouTube's ambient mode, and the words' panel takes the colour of
the episode's picture under the pointer.
A line is cut into words with one request: DictionaryEndpoint.withKeys
looks up every stretch of it that may be a word, as it stands and as
every word it may be a conjugation of, in the new dictionary_key table
(one row per key, indexed; migration dictionary-keys adds the table and
its index, nothing else), which the loader fills beside the entries.
The instance gets the text of each line looked up and keeps none of it,
as the Watch screen says.
Linux builds need libmpv-dev, and the app links libmpv: the snap stages
Ubuntu's libmpv2, makes the update-alternatives links for BLAS and LAPACK
that ffmpeg's speech recognition needs (staging never runs install
scripts), and removes what the GNOME runtime and core24 already hold, as
older copies of cairo, pango and librsvg hid the runtime's from its GTK.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-13 11:31:37 +02:00
|
|
|
'IndexNumber': 1,
|
|
|
|
|
'ParentIndexNumber': 1,
|
|
|
|
|
'RunTimeTicks': 520230000,
|
|
|
|
|
'ImageTags': {'Primary': '898f'},
|
|
|
|
|
'UserData': {'PlaybackPositionTicks': 260115000, 'Played': false},
|
|
|
|
|
}),
|
|
|
|
|
('POST', '/Items/ep-1/PlaybackInfo') => json({
|
|
|
|
|
'MediaSources': [
|
|
|
|
|
{
|
|
|
|
|
'Id': 'ep-1',
|
|
|
|
|
'Container': 'mkv',
|
|
|
|
|
'SupportsDirectPlay': true,
|
|
|
|
|
'RunTimeTicks': 520230000,
|
|
|
|
|
'MediaStreams': [
|
|
|
|
|
{'Index': 0, 'Type': 'Video', 'Codec': 'h264'},
|
|
|
|
|
{
|
|
|
|
|
'Index': 1,
|
|
|
|
|
'Type': 'Audio',
|
|
|
|
|
'Language': 'ger',
|
|
|
|
|
'Codec': 'aac',
|
|
|
|
|
'IsDefault': true,
|
|
|
|
|
'DisplayTitle': 'German - AAC - Stereo - Default',
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
'index': 4,
|
|
|
|
|
'type': 'Audio',
|
|
|
|
|
'language': 'jpn',
|
|
|
|
|
'codec': 'aac',
|
|
|
|
|
'isDefault': true,
|
|
|
|
|
'title': 'Japanese [Original]',
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
'Index': 2,
|
|
|
|
|
'Type': 'Subtitle',
|
|
|
|
|
'Language': 'jpn',
|
|
|
|
|
'Codec': 'ass',
|
|
|
|
|
'IsTextSubtitleStream': true,
|
|
|
|
|
'IsDefault': true,
|
|
|
|
|
'DisplayTitle': '日本語 - Japanese - Default - ASS',
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
'Index': 3,
|
|
|
|
|
'Type': 'Subtitle',
|
|
|
|
|
'Language': 'eng',
|
|
|
|
|
'Codec': 'PGSSUB',
|
|
|
|
|
'IsTextSubtitleStream': false,
|
|
|
|
|
'DisplayTitle': 'English - PGSSUB',
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
'Index': 5,
|
|
|
|
|
'Type': 'Subtitle',
|
|
|
|
|
'Language': 'jpn',
|
|
|
|
|
'Codec': 'subrip',
|
|
|
|
|
'IsTextSubtitleStream': true,
|
|
|
|
|
'IsForced': true,
|
|
|
|
|
'DisplayTitle': '日本語 - Japanese - Forced - SUBRIP',
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
'Index': 6,
|
|
|
|
|
'Type': 'Subtitle',
|
|
|
|
|
'Language': 'jpn',
|
|
|
|
|
'Codec': 'subrip',
|
|
|
|
|
'IsTextSubtitleStream': true,
|
|
|
|
|
'Title': 'closedcaptions',
|
|
|
|
|
'DisplayTitle': 'closedcaptions - 日本語 - SUBRIP',
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
'Index': 7,
|
|
|
|
|
'Type': 'Subtitle',
|
|
|
|
|
'Language': 'eng',
|
|
|
|
|
'Codec': 'subrip',
|
|
|
|
|
'IsTextSubtitleStream': true,
|
|
|
|
|
'IsHearingImpaired': true,
|
|
|
|
|
'DisplayTitle': 'English - SDH - SUBRIP',
|
|
|
|
|
},
|
|
|
|
|
],
|
|
|
|
|
},
|
|
|
|
|
],
|
|
|
|
|
'PlaySessionId': 'ps-1',
|
|
|
|
|
}),
|
|
|
|
|
('GET', '/Videos/ep-1/ep-1/Subtitles/2/0/Stream.vtt') =>
|
|
|
|
|
http.Response.bytes(
|
|
|
|
|
utf8.encode('WEBVTT\n\n00:00:01.000 --> 00:00:05.000\n今日は\n'),
|
|
|
|
|
200,
|
|
|
|
|
),
|
feat(jellyfin): a show's tags follow it whatever it is called
A word met on Jellyfin is tagged with the show and the episode by the
names Jellyfin gives them, so a server switching its metadata language,
another server, or a rename in Manage tags made a second set of tags for
the same show. Such a tag now also keeps what it stands for: the ids the
show (or the artist, or the film) has with the metadata providers and on
the server, and the episode's number or the song's own id. New words
find their tag by those first, keeping its name; a Jellyfin tag from
before, found by its name, takes them, and a rename keeps them.
They stay in tag schema 2, a deliberate exception to ADR 0002's rule 3:
schema 3 would hide every new Jellyfin tag from v0.17, while an app
that rewrites the tag only drops the ids, and the tag is found by its
name again.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-16 22:34:28 +02:00
|
|
|
('GET', '/Items/series-1') => json({
|
|
|
|
|
'Name': 'Sample Journey',
|
|
|
|
|
'Id': 'series-1',
|
|
|
|
|
'Type': 'Series',
|
|
|
|
|
'providerIds': {'Tmdb': '219109', 'Tvdb': '445566'},
|
|
|
|
|
}),
|
feat(jellyfin): watch from one's own Jellyfin server and learn the words of its subtitles
Animelon's way of learning from video, from the person's own Jellyfin
server: connected per device (Settings, or a studyset's Add material ›
Watch from Jellyfin) with a name and password or a Quick Connect code,
over HTTPS only (plain HTTP only to a server on the same machine); the
token stays in the system's keyring. Shows, films and videos tied to a
studyset follow the person in their settings as the server's and the
item's ids alone (settings schema 9), named by the server when shown.
The Watch screen plays the video (video_player on Android, media_kit's
libmpv on Linux and Windows) with the line in the studyset's front
language cut into the dictionary's words with furigana, and the back
language's line beneath; a word tapped pauses the video and becomes a
card with the line and its translation as its first hint, tagged with
the show. The video's words are listed, most frequent first. The sound is
the track in the studyset's language, whichever the file marks default,
and the menu switches it; the subtitles read are a whole track, never a
forced one (which only covers what the sound doesn't say), and captions
for the hard of hearing only without plain ones. The line and its
translation take the video's width in a box of a fixed height, so the
video keeps its size; in the dark theme the video's colours glow around
it, as YouTube's ambient mode, and the words' panel takes the colour of
the episode's picture under the pointer.
A line is cut into words with one request: DictionaryEndpoint.withKeys
looks up every stretch of it that may be a word, as it stands and as
every word it may be a conjugation of, in the new dictionary_key table
(one row per key, indexed; migration dictionary-keys adds the table and
its index, nothing else), which the loader fills beside the entries.
The instance gets the text of each line looked up and keeps none of it,
as the Watch screen says.
Linux builds need libmpv-dev, and the app links libmpv: the snap stages
Ubuntu's libmpv2, makes the update-alternatives links for BLAS and LAPACK
that ffmpeg's speech recognition needs (staging never runs install
scripts), and removes what the GNOME runtime and core24 already hold, as
older copies of cairo, pango and librsvg hid the runtime's from its GTK.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-13 11:31:37 +02:00
|
|
|
('POST', '/Sessions/Playing/Stopped') => json(null, 204),
|
|
|
|
|
_ => json(null, 404),
|
|
|
|
|
};
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
void main() {
|
|
|
|
|
group('HttpJellyfinGateway', () {
|
|
|
|
|
late List<http.Request> requests;
|
|
|
|
|
late HttpJellyfinGateway gateway;
|
|
|
|
|
|
|
|
|
|
setUp(() {
|
|
|
|
|
requests = [];
|
|
|
|
|
gateway = HttpJellyfinGateway(
|
|
|
|
|
jellyfin12(requests),
|
|
|
|
|
device: (id: 'device-1', name: 'Linux', version: '0.13.0'),
|
|
|
|
|
);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
Future<JellyfinAccount> signIn() async => await gateway.signIn(
|
|
|
|
|
await gateway.server(Uri.parse('http://jellyfin.local:8096')),
|
|
|
|
|
'mika',
|
|
|
|
|
'secret',
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
test(
|
|
|
|
|
'finds a server and signs in, naming the app and the device',
|
|
|
|
|
() async {
|
|
|
|
|
final server = await gateway.server(
|
|
|
|
|
Uri.parse('http://jellyfin.local:8096'),
|
|
|
|
|
);
|
|
|
|
|
expect(server.name, 'Living room');
|
|
|
|
|
expect(server.version, '12.2.0');
|
|
|
|
|
|
|
|
|
|
final account = await gateway.signIn(server, 'mika', 'secret');
|
|
|
|
|
expect(account.userId, 'u-1');
|
|
|
|
|
expect(account.token, 't-1');
|
|
|
|
|
expect(
|
|
|
|
|
requests.last.headers['Authorization'],
|
|
|
|
|
'MediaBrowser Client="Halkyon Learn", Device="Linux", '
|
|
|
|
|
'DeviceId="device-1", Version="0.13.0"',
|
|
|
|
|
);
|
|
|
|
|
await expectLater(
|
|
|
|
|
gateway.signIn(server, 'mika', 'wrong'),
|
|
|
|
|
throwsA(
|
|
|
|
|
isA<JellyfinException>().having(
|
|
|
|
|
(error) => error.problem,
|
|
|
|
|
'problem',
|
|
|
|
|
JellyfinProblem.wrongCredentials,
|
|
|
|
|
),
|
|
|
|
|
),
|
|
|
|
|
);
|
|
|
|
|
},
|
|
|
|
|
);
|
|
|
|
|
|
2026-10-14 01:52:42 +02:00
|
|
|
test('lists the people a server shows on its login screen', () async {
|
feat(jellyfin): the instance suggests a Jellyfin server, signed in to with a tap
An instance now names a Jellyfin server for its people to learn from
(instance.info().jellyfinUrl): its operator's JELLYFIN_URL (`none`: none),
else https://jellyfin. on the domain registered for its public host, as
learn.halkyon.eu suggests jellyfin.halkyon.eu, once that answers as a
Jellyfin server. The registered domain comes from Mozilla's Public Suffix
List, kept in the server's data folder, so an instance at example.co.uk
never names someone else's jellyfin.co.uk. The instance looks as it
starts and again hourly when asked; the app never reaches for a guessed
address.
In the app, Add material offers From Jellyfin as soon as the instance
suggests a server, before anyone signed in to one. The sign-in fills the
server in and finds it by itself, and lists the people the server shows
on its login screen: one without a password, as a demo's, signs in with
a tap; one with a password is named in the form, which asks for it.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-13 23:25:33 +02:00
|
|
|
final server = await gateway.server(
|
|
|
|
|
Uri.parse('http://jellyfin.local:8096'),
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
expect(await gateway.publicUsers(server), const [
|
2026-10-14 01:52:42 +02:00
|
|
|
JellyfinPublicUser(id: 'u-2', name: 'Demo'),
|
|
|
|
|
JellyfinPublicUser(id: 'u-1', name: 'mika'),
|
feat(jellyfin): the instance suggests a Jellyfin server, signed in to with a tap
An instance now names a Jellyfin server for its people to learn from
(instance.info().jellyfinUrl): its operator's JELLYFIN_URL (`none`: none),
else https://jellyfin. on the domain registered for its public host, as
learn.halkyon.eu suggests jellyfin.halkyon.eu, once that answers as a
Jellyfin server. The registered domain comes from Mozilla's Public Suffix
List, kept in the server's data folder, so an instance at example.co.uk
never names someone else's jellyfin.co.uk. The instance looks as it
starts and again hourly when asked; the app never reaches for a guessed
address.
In the app, Add material offers From Jellyfin as soon as the instance
suggests a server, before anyone signed in to one. The sign-in fills the
server in and finds it by itself, and lists the people the server shows
on its login screen: one without a password, as a demo's, signs in with
a tap; one with a password is named in the form, which asks for it.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-13 23:25:33 +02:00
|
|
|
]);
|
|
|
|
|
});
|
|
|
|
|
|
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>
2026-10-13 19:46:54 +02:00
|
|
|
test('lists libraries of videos and of music, leaving out what is '
|
|
|
|
|
'neither', () async {
|
feat(jellyfin): watch from one's own Jellyfin server and learn the words of its subtitles
Animelon's way of learning from video, from the person's own Jellyfin
server: connected per device (Settings, or a studyset's Add material ›
Watch from Jellyfin) with a name and password or a Quick Connect code,
over HTTPS only (plain HTTP only to a server on the same machine); the
token stays in the system's keyring. Shows, films and videos tied to a
studyset follow the person in their settings as the server's and the
item's ids alone (settings schema 9), named by the server when shown.
The Watch screen plays the video (video_player on Android, media_kit's
libmpv on Linux and Windows) with the line in the studyset's front
language cut into the dictionary's words with furigana, and the back
language's line beneath; a word tapped pauses the video and becomes a
card with the line and its translation as its first hint, tagged with
the show. The video's words are listed, most frequent first. The sound is
the track in the studyset's language, whichever the file marks default,
and the menu switches it; the subtitles read are a whole track, never a
forced one (which only covers what the sound doesn't say), and captions
for the hard of hearing only without plain ones. The line and its
translation take the video's width in a box of a fixed height, so the
video keeps its size; in the dark theme the video's colours glow around
it, as YouTube's ambient mode, and the words' panel takes the colour of
the episode's picture under the pointer.
A line is cut into words with one request: DictionaryEndpoint.withKeys
looks up every stretch of it that may be a word, as it stands and as
every word it may be a conjugation of, in the new dictionary_key table
(one row per key, indexed; migration dictionary-keys adds the table and
its index, nothing else), which the loader fills beside the entries.
The instance gets the text of each line looked up and keeps none of it,
as the Watch screen says.
Linux builds need libmpv-dev, and the app links libmpv: the snap stages
Ubuntu's libmpv2, makes the update-alternatives links for BLAS and LAPACK
that ffmpeg's speech recognition needs (staging never runs install
scripts), and removes what the GNOME runtime and core24 already hold, as
older copies of cairo, pango and librsvg hid the runtime's from its GTK.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-13 11:31:37 +02:00
|
|
|
final account = await signIn();
|
|
|
|
|
|
|
|
|
|
final libraries = await gateway.libraries(account);
|
|
|
|
|
|
|
|
|
|
expect(libraries, [
|
|
|
|
|
const JellyfinItem(
|
|
|
|
|
id: 'lib-1',
|
|
|
|
|
name: 'Anime',
|
|
|
|
|
kind: JellyfinItemKind.library,
|
|
|
|
|
),
|
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>
2026-10-13 19:46:54 +02:00
|
|
|
const JellyfinItem(
|
|
|
|
|
id: 'lib-2',
|
|
|
|
|
name: 'Music',
|
|
|
|
|
kind: JellyfinItemKind.library,
|
|
|
|
|
),
|
feat(jellyfin): watch from one's own Jellyfin server and learn the words of its subtitles
Animelon's way of learning from video, from the person's own Jellyfin
server: connected per device (Settings, or a studyset's Add material ›
Watch from Jellyfin) with a name and password or a Quick Connect code,
over HTTPS only (plain HTTP only to a server on the same machine); the
token stays in the system's keyring. Shows, films and videos tied to a
studyset follow the person in their settings as the server's and the
item's ids alone (settings schema 9), named by the server when shown.
The Watch screen plays the video (video_player on Android, media_kit's
libmpv on Linux and Windows) with the line in the studyset's front
language cut into the dictionary's words with furigana, and the back
language's line beneath; a word tapped pauses the video and becomes a
card with the line and its translation as its first hint, tagged with
the show. The video's words are listed, most frequent first. The sound is
the track in the studyset's language, whichever the file marks default,
and the menu switches it; the subtitles read are a whole track, never a
forced one (which only covers what the sound doesn't say), and captions
for the hard of hearing only without plain ones. The line and its
translation take the video's width in a box of a fixed height, so the
video keeps its size; in the dark theme the video's colours glow around
it, as YouTube's ambient mode, and the words' panel takes the colour of
the episode's picture under the pointer.
A line is cut into words with one request: DictionaryEndpoint.withKeys
looks up every stretch of it that may be a word, as it stands and as
every word it may be a conjugation of, in the new dictionary_key table
(one row per key, indexed; migration dictionary-keys adds the table and
its index, nothing else), which the loader fills beside the entries.
The instance gets the text of each line looked up and keeps none of it,
as the Watch screen says.
Linux builds need libmpv-dev, and the app links libmpv: the snap stages
Ubuntu's libmpv2, makes the update-alternatives links for BLAS and LAPACK
that ffmpeg's speech recognition needs (staging never runs install
scripts), and removes what the GNOME runtime and core24 already hold, as
older copies of cairo, pango and librsvg hid the runtime's from its GTK.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-13 11:31:37 +02:00
|
|
|
]);
|
|
|
|
|
expect(requests.last.headers['Authorization'], contains('Token="t-1"'));
|
|
|
|
|
expect(requests.last.url.queryParameters['userId'], 'u-1');
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('a playback has its subtitle and audio tracks, which are forced '
|
|
|
|
|
'and which for the hard of hearing, and where to resume; the stream '
|
|
|
|
|
'is the file as stored; subtitles come as WebVTT', () async {
|
|
|
|
|
final account = await signIn();
|
|
|
|
|
|
|
|
|
|
final playback = await gateway.playback(account, 'ep-1');
|
|
|
|
|
|
|
|
|
|
expect(playback.playSessionId, 'ps-1');
|
|
|
|
|
expect(playback.resumeAt.inSeconds, 26);
|
|
|
|
|
expect(playback.subtitles, [
|
|
|
|
|
const JellyfinSubtitleTrack(
|
|
|
|
|
index: 2,
|
|
|
|
|
title: '日本語 - Japanese - Default - ASS',
|
|
|
|
|
language: 'jpn',
|
|
|
|
|
isDefault: true,
|
|
|
|
|
),
|
|
|
|
|
const JellyfinSubtitleTrack(
|
|
|
|
|
index: 3,
|
|
|
|
|
title: 'English - PGSSUB',
|
|
|
|
|
language: 'eng',
|
|
|
|
|
isText: false,
|
|
|
|
|
),
|
|
|
|
|
const JellyfinSubtitleTrack(
|
|
|
|
|
index: 5,
|
|
|
|
|
title: '日本語 - Japanese - Forced - SUBRIP',
|
|
|
|
|
language: 'jpn',
|
|
|
|
|
isForced: true,
|
|
|
|
|
),
|
|
|
|
|
// Captions for the hard of hearing are often marked by their name
|
|
|
|
|
// alone.
|
|
|
|
|
const JellyfinSubtitleTrack(
|
|
|
|
|
index: 6,
|
|
|
|
|
title: 'closedcaptions - 日本語 - SUBRIP',
|
|
|
|
|
language: 'jpn',
|
|
|
|
|
isHearingImpaired: true,
|
|
|
|
|
),
|
|
|
|
|
const JellyfinSubtitleTrack(
|
|
|
|
|
index: 7,
|
|
|
|
|
title: 'English - SDH - SUBRIP',
|
|
|
|
|
language: 'eng',
|
|
|
|
|
isHearingImpaired: true,
|
|
|
|
|
),
|
|
|
|
|
]);
|
|
|
|
|
expect(playback.audio, [
|
|
|
|
|
const JellyfinAudioTrack(
|
|
|
|
|
index: 1,
|
|
|
|
|
title: 'German - AAC - Stereo - Default',
|
|
|
|
|
language: 'ger',
|
|
|
|
|
isDefault: true,
|
|
|
|
|
),
|
|
|
|
|
const JellyfinAudioTrack(
|
|
|
|
|
index: 4,
|
|
|
|
|
title: 'Japanese [Original]',
|
|
|
|
|
language: 'jpn',
|
|
|
|
|
isDefault: true,
|
|
|
|
|
),
|
|
|
|
|
]);
|
|
|
|
|
final stream = gateway.streamOf(account, playback);
|
|
|
|
|
expect(stream.path, '/Videos/ep-1/stream');
|
|
|
|
|
expect(stream.queryParameters, containsPair('static', 'true'));
|
|
|
|
|
expect(
|
|
|
|
|
await gateway.subtitles(account, playback, playback.subtitles.first),
|
|
|
|
|
contains('今日は'),
|
|
|
|
|
);
|
|
|
|
|
});
|
|
|
|
|
|
feat(jellyfin): a show's tags follow it whatever it is called
A word met on Jellyfin is tagged with the show and the episode by the
names Jellyfin gives them, so a server switching its metadata language,
another server, or a rename in Manage tags made a second set of tags for
the same show. Such a tag now also keeps what it stands for: the ids the
show (or the artist, or the film) has with the metadata providers and on
the server, and the episode's number or the song's own id. New words
find their tag by those first, keeping its name; a Jellyfin tag from
before, found by its name, takes them, and a rename keeps them.
They stay in tag schema 2, a deliberate exception to ADR 0002's rule 3:
schema 3 would hide every new Jellyfin tag from v0.17, while an app
that rewrites the tag only drops the ids, and the tag is found by its
name again.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-16 22:34:28 +02:00
|
|
|
test('an item says what the metadata providers call it, and an episode '
|
|
|
|
|
'its show, a song its artist', () async {
|
|
|
|
|
final account = await signIn();
|
|
|
|
|
|
|
|
|
|
final episode = await gateway.item(account, 'ep-1');
|
|
|
|
|
expect(episode.providerIds, {'tmdb': '9001'});
|
|
|
|
|
expect(episode.seriesId, 'series-1');
|
|
|
|
|
expect((await gateway.item(account, 'series-1')).providerIds, {
|
|
|
|
|
'tmdb': '219109',
|
|
|
|
|
'tvdb': '445566',
|
|
|
|
|
});
|
|
|
|
|
final song = await gateway.item(account, 'song-1');
|
|
|
|
|
expect(song.artistId, 'artist-1');
|
|
|
|
|
expect(song.providerIds, {'musicbrainztrack': 'mb-track-1'});
|
|
|
|
|
});
|
|
|
|
|
|
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>
2026-10-13 19:46:54 +02:00
|
|
|
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',
|
|
|
|
|
);
|
|
|
|
|
});
|
|
|
|
|
|
feat(jellyfin): watch from one's own Jellyfin server and learn the words of its subtitles
Animelon's way of learning from video, from the person's own Jellyfin
server: connected per device (Settings, or a studyset's Add material ›
Watch from Jellyfin) with a name and password or a Quick Connect code,
over HTTPS only (plain HTTP only to a server on the same machine); the
token stays in the system's keyring. Shows, films and videos tied to a
studyset follow the person in their settings as the server's and the
item's ids alone (settings schema 9), named by the server when shown.
The Watch screen plays the video (video_player on Android, media_kit's
libmpv on Linux and Windows) with the line in the studyset's front
language cut into the dictionary's words with furigana, and the back
language's line beneath; a word tapped pauses the video and becomes a
card with the line and its translation as its first hint, tagged with
the show. The video's words are listed, most frequent first. The sound is
the track in the studyset's language, whichever the file marks default,
and the menu switches it; the subtitles read are a whole track, never a
forced one (which only covers what the sound doesn't say), and captions
for the hard of hearing only without plain ones. The line and its
translation take the video's width in a box of a fixed height, so the
video keeps its size; in the dark theme the video's colours glow around
it, as YouTube's ambient mode, and the words' panel takes the colour of
the episode's picture under the pointer.
A line is cut into words with one request: DictionaryEndpoint.withKeys
looks up every stretch of it that may be a word, as it stands and as
every word it may be a conjugation of, in the new dictionary_key table
(one row per key, indexed; migration dictionary-keys adds the table and
its index, nothing else), which the loader fills beside the entries.
The instance gets the text of each line looked up and keeps none of it,
as the Watch screen says.
Linux builds need libmpv-dev, and the app links libmpv: the snap stages
Ubuntu's libmpv2, makes the update-alternatives links for BLAS and LAPACK
that ffmpeg's speech recognition needs (staging never runs install
scripts), and removes what the GNOME runtime and core24 already hold, as
older copies of cairo, pango and librsvg hid the runtime's from its GTK.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-13 11:31:37 +02:00
|
|
|
test('a token the server no longer takes is signed out; nothing '
|
|
|
|
|
'answering is unreachable', () async {
|
|
|
|
|
final server = await gateway.server(
|
|
|
|
|
Uri.parse('http://jellyfin.local:8096'),
|
|
|
|
|
);
|
|
|
|
|
final stale = JellyfinAccount(
|
|
|
|
|
server: server,
|
|
|
|
|
userId: 'u-1',
|
|
|
|
|
userName: 'mika',
|
|
|
|
|
token: 'revoked',
|
|
|
|
|
);
|
|
|
|
|
await expectLater(
|
|
|
|
|
gateway.libraries(stale),
|
|
|
|
|
throwsA(
|
|
|
|
|
isA<JellyfinException>().having(
|
|
|
|
|
(error) => error.problem,
|
|
|
|
|
'problem',
|
|
|
|
|
JellyfinProblem.signedOut,
|
|
|
|
|
),
|
|
|
|
|
),
|
|
|
|
|
);
|
|
|
|
|
final offline = HttpJellyfinGateway(
|
|
|
|
|
MockClient((_) => throw const SocketException('down')),
|
|
|
|
|
device: (id: 'd', name: 'n', version: 'v'),
|
|
|
|
|
);
|
|
|
|
|
await expectLater(
|
|
|
|
|
offline.server(Uri.parse('http://jellyfin.local:8096')),
|
|
|
|
|
throwsA(
|
|
|
|
|
isA<JellyfinException>().having(
|
|
|
|
|
(error) => error.problem,
|
|
|
|
|
'problem',
|
|
|
|
|
JellyfinProblem.unreachable,
|
|
|
|
|
),
|
|
|
|
|
),
|
|
|
|
|
);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('reports where a video stopped, in ticks', () async {
|
|
|
|
|
final account = await signIn();
|
|
|
|
|
final playback = await gateway.playback(account, 'ep-1');
|
|
|
|
|
|
|
|
|
|
await gateway.report(
|
|
|
|
|
account,
|
|
|
|
|
playback,
|
|
|
|
|
const Duration(seconds: 21),
|
|
|
|
|
stopped: true,
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
final body = jsonDecode(requests.last.body) as Map<String, Object?>;
|
|
|
|
|
expect(requests.last.url.path, '/Sessions/Playing/Stopped');
|
|
|
|
|
expect(body['PositionTicks'], 210000000);
|
|
|
|
|
expect(body['PlaySessionId'], 'ps-1');
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
group('JellyfinRepository', () {
|
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>
2026-10-13 19:46:54 +02:00
|
|
|
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], ['一', '二']);
|
|
|
|
|
});
|
|
|
|
|
|
feat(jellyfin): watch from one's own Jellyfin server and learn the words of its subtitles
Animelon's way of learning from video, from the person's own Jellyfin
server: connected per device (Settings, or a studyset's Add material ›
Watch from Jellyfin) with a name and password or a Quick Connect code,
over HTTPS only (plain HTTP only to a server on the same machine); the
token stays in the system's keyring. Shows, films and videos tied to a
studyset follow the person in their settings as the server's and the
item's ids alone (settings schema 9), named by the server when shown.
The Watch screen plays the video (video_player on Android, media_kit's
libmpv on Linux and Windows) with the line in the studyset's front
language cut into the dictionary's words with furigana, and the back
language's line beneath; a word tapped pauses the video and becomes a
card with the line and its translation as its first hint, tagged with
the show. The video's words are listed, most frequent first. The sound is
the track in the studyset's language, whichever the file marks default,
and the menu switches it; the subtitles read are a whole track, never a
forced one (which only covers what the sound doesn't say), and captions
for the hard of hearing only without plain ones. The line and its
translation take the video's width in a box of a fixed height, so the
video keeps its size; in the dark theme the video's colours glow around
it, as YouTube's ambient mode, and the words' panel takes the colour of
the episode's picture under the pointer.
A line is cut into words with one request: DictionaryEndpoint.withKeys
looks up every stretch of it that may be a word, as it stands and as
every word it may be a conjugation of, in the new dictionary_key table
(one row per key, indexed; migration dictionary-keys adds the table and
its index, nothing else), which the loader fills beside the entries.
The instance gets the text of each line looked up and keeps none of it,
as the Watch screen says.
Linux builds need libmpv-dev, and the app links libmpv: the snap stages
Ubuntu's libmpv2, makes the update-alternatives links for BLAS and LAPACK
that ffmpeg's speech recognition needs (staging never runs install
scripts), and removes what the GNOME runtime and core24 already hold, as
older copies of cairo, pango and librsvg hid the runtime's from its GTK.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-13 11:31:37 +02:00
|
|
|
test('an address is reached over HTTPS only, plain HTTP refused '
|
|
|
|
|
'before anything is sent', () async {
|
|
|
|
|
final asked = <Uri>[];
|
|
|
|
|
final repository = JellyfinRepository(
|
|
|
|
|
(_) => _Asking(asked),
|
|
|
|
|
MemoryJellyfinStore(),
|
|
|
|
|
deviceName: 'Test',
|
|
|
|
|
appVersion: '1',
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
final server = await repository.find('jellyfin.example:8920');
|
|
|
|
|
expect(server.address, Uri.parse('https://jellyfin.example:8920'));
|
|
|
|
|
expect(asked, [Uri.parse('https://jellyfin.example:8920')]);
|
|
|
|
|
|
|
|
|
|
asked.clear();
|
|
|
|
|
await expectLater(
|
|
|
|
|
repository.find('http://jellyfin.local:8096'),
|
|
|
|
|
throwsA(
|
|
|
|
|
isA<JellyfinException>().having(
|
|
|
|
|
(error) => error.problem,
|
|
|
|
|
'problem',
|
|
|
|
|
JellyfinProblem.notEncrypted,
|
|
|
|
|
),
|
|
|
|
|
),
|
|
|
|
|
);
|
|
|
|
|
await expectLater(
|
|
|
|
|
repository.find('httpsonly.local:8096'),
|
|
|
|
|
throwsA(
|
|
|
|
|
isA<JellyfinException>().having(
|
|
|
|
|
(error) => error.problem,
|
|
|
|
|
'problem',
|
|
|
|
|
JellyfinProblem.unreachable,
|
|
|
|
|
),
|
|
|
|
|
),
|
|
|
|
|
);
|
|
|
|
|
expect(asked, [Uri.parse('https://httpsonly.local:8096')]);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('a server on this machine may answer over plain HTTP: nothing '
|
|
|
|
|
'leaves it', () async {
|
|
|
|
|
final asked = <Uri>[];
|
|
|
|
|
final repository = JellyfinRepository(
|
|
|
|
|
(_) => _Asking(asked),
|
|
|
|
|
MemoryJellyfinStore(),
|
|
|
|
|
deviceName: 'Test',
|
|
|
|
|
appVersion: '1',
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
expect(
|
|
|
|
|
(await repository.find('localhost:8096')).address,
|
|
|
|
|
Uri.parse('http://localhost:8096'),
|
|
|
|
|
);
|
|
|
|
|
expect(asked, [
|
|
|
|
|
Uri.parse('https://localhost:8096'),
|
|
|
|
|
Uri.parse('http://localhost:8096'),
|
|
|
|
|
]);
|
|
|
|
|
expect(
|
|
|
|
|
(await repository.find('http://127.0.0.1:8096')).address,
|
|
|
|
|
Uri.parse('http://127.0.0.1:8096'),
|
|
|
|
|
);
|
|
|
|
|
expect(
|
|
|
|
|
(await repository.find('http://[::1]:8096')).address.host,
|
|
|
|
|
'::1',
|
|
|
|
|
);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('signing in is kept on the device; signing out forgets it and '
|
|
|
|
|
'ends the session on the server', () async {
|
|
|
|
|
final store = MemoryJellyfinStore();
|
|
|
|
|
final gateway = FakeJellyfinGateway();
|
|
|
|
|
final repository = JellyfinRepository(
|
|
|
|
|
(_) => gateway,
|
|
|
|
|
store,
|
|
|
|
|
deviceName: 'Test',
|
|
|
|
|
appVersion: '1',
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
await repository.signIn(fakeJellyfinServer, 'mika', 'secret');
|
|
|
|
|
expect(store.kept?.account?.token, 'token-1');
|
|
|
|
|
|
|
|
|
|
await repository.signOut();
|
|
|
|
|
expect(store.kept?.account, isNull);
|
|
|
|
|
expect(gateway.signedOut, ['token-1']);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('Quick Connect signs in once the code is approved', () async {
|
|
|
|
|
final gateway = FakeJellyfinGateway();
|
|
|
|
|
final repository = JellyfinRepository(
|
|
|
|
|
(_) => gateway,
|
|
|
|
|
MemoryJellyfinStore(),
|
|
|
|
|
deviceName: 'Test',
|
|
|
|
|
appVersion: '1',
|
|
|
|
|
);
|
|
|
|
|
final code = await repository.startQuickConnect(fakeJellyfinServer);
|
|
|
|
|
|
|
|
|
|
expect(
|
|
|
|
|
await repository.finishQuickConnect(fakeJellyfinServer, code.secret),
|
|
|
|
|
isFalse,
|
|
|
|
|
);
|
|
|
|
|
gateway.approved = true;
|
|
|
|
|
expect(
|
|
|
|
|
await repository.finishQuickConnect(fakeJellyfinServer, code.secret),
|
|
|
|
|
isTrue,
|
|
|
|
|
);
|
|
|
|
|
expect(repository.account?.token, 'token-2');
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('a token the server refuses signs the device out', () async {
|
|
|
|
|
final gateway = sampleJellyfin()..fail = JellyfinProblem.signedOut;
|
|
|
|
|
final repository = JellyfinRepository(
|
|
|
|
|
(_) => gateway,
|
|
|
|
|
signedInJellyfin(),
|
|
|
|
|
deviceName: 'Test',
|
|
|
|
|
appVersion: '1',
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
await expectLater(
|
|
|
|
|
repository.libraries(),
|
|
|
|
|
throwsA(isA<JellyfinException>()),
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
expect(repository.account, isNull);
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('what the device keeps comes back as it was kept', () {
|
|
|
|
|
final state = JellyfinState(
|
|
|
|
|
deviceId: 'device-1',
|
|
|
|
|
account: JellyfinAccount(
|
|
|
|
|
server: fakeJellyfinServer,
|
|
|
|
|
userId: 'user-1',
|
|
|
|
|
userName: 'mika',
|
|
|
|
|
token: 'token-1',
|
|
|
|
|
),
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
expect(
|
|
|
|
|
jellyfinStateFromJson(jsonDecode(jsonEncode(jellyfinStateToJson(state)))),
|
|
|
|
|
state,
|
|
|
|
|
);
|
|
|
|
|
expect(jellyfinStateFromJson('not a state').deviceId, hasLength(32));
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('a line of two is one: Japanese joined as it is, others with a '
|
|
|
|
|
'space', () {
|
|
|
|
|
expect(oneLine('昨日、駅で\n友達に会いました。'), '昨日、駅で友達に会いました。');
|
|
|
|
|
expect(oneLine('Yesterday I met\na friend.'), 'Yesterday I met a friend.');
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
group('the tracks in a language', () {
|
|
|
|
|
const forced = JellyfinSubtitleTrack(
|
|
|
|
|
index: 1,
|
|
|
|
|
title: 'Japanese (forced)',
|
|
|
|
|
language: 'jpn',
|
|
|
|
|
isDefault: true,
|
|
|
|
|
isForced: true,
|
|
|
|
|
);
|
|
|
|
|
const captions = JellyfinSubtitleTrack(
|
|
|
|
|
index: 2,
|
|
|
|
|
title: 'Japanese (SDH)',
|
|
|
|
|
language: 'jpn',
|
|
|
|
|
isHearingImpaired: true,
|
|
|
|
|
);
|
|
|
|
|
const full = JellyfinSubtitleTrack(
|
|
|
|
|
index: 3,
|
|
|
|
|
title: 'Japanese',
|
|
|
|
|
language: 'jpn',
|
|
|
|
|
);
|
|
|
|
|
const english = JellyfinSubtitleTrack(
|
|
|
|
|
index: 4,
|
|
|
|
|
title: 'English',
|
|
|
|
|
language: 'eng',
|
|
|
|
|
isDefault: true,
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
test('the subtitles read are whole: never a forced track, which only '
|
|
|
|
|
"covers what the sound doesn't say; captions for the hard of "
|
|
|
|
|
'hearing only without plain ones', () {
|
|
|
|
|
expect(subtitleTrackIn([forced, captions, full, english], 'ja'), full);
|
|
|
|
|
expect(subtitleTrackIn([forced, captions, english], 'ja'), captions);
|
|
|
|
|
expect(subtitleTrackIn([forced, english], 'ja'), isNull);
|
|
|
|
|
expect(subtitleTrackIn([forced, captions, full, english], 'en'), english);
|
|
|
|
|
expect(subtitleTrackIn([full], null), isNull);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('the sound is in the language, whichever track the file marks '
|
|
|
|
|
'default; a commentary or a description only without another', () {
|
|
|
|
|
const german = JellyfinAudioTrack(
|
|
|
|
|
index: 1,
|
|
|
|
|
title: 'German',
|
|
|
|
|
language: 'ger',
|
|
|
|
|
isDefault: true,
|
|
|
|
|
);
|
|
|
|
|
const commentary = JellyfinAudioTrack(
|
|
|
|
|
index: 2,
|
|
|
|
|
title: "Director's commentary",
|
|
|
|
|
language: 'jpn',
|
|
|
|
|
isDefault: true,
|
|
|
|
|
);
|
|
|
|
|
const japanese = JellyfinAudioTrack(
|
|
|
|
|
index: 3,
|
|
|
|
|
title: 'Japanese [Original]',
|
|
|
|
|
language: 'jpn',
|
|
|
|
|
);
|
|
|
|
|
expect(audioTrackIn([german, commentary, japanese], 'ja'), japanese);
|
|
|
|
|
expect(audioTrackIn([german, commentary], 'ja'), commentary);
|
|
|
|
|
expect(audioTrackIn([german], 'ja'), isNull);
|
|
|
|
|
expect(audioTrackIn([german, japanese], null), isNull);
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
feat(jellyfin): a show's tags follow it whatever it is called
A word met on Jellyfin is tagged with the show and the episode by the
names Jellyfin gives them, so a server switching its metadata language,
another server, or a rename in Manage tags made a second set of tags for
the same show. Such a tag now also keeps what it stands for: the ids the
show (or the artist, or the film) has with the metadata providers and on
the server, and the episode's number or the song's own id. New words
find their tag by those first, keeping its name; a Jellyfin tag from
before, found by its name, takes them, and a rename keeps them.
They stay in tag schema 2, a deliberate exception to ADR 0002's rule 3:
schema 3 would hide every new Jellyfin tag from v0.17, while an app
that rewrites the tag only drops the ids, and the tag is found by its
name again.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-16 22:34:28 +02:00
|
|
|
group('the tags a word met on Jellyfin gets', () {
|
|
|
|
|
const series = JellyfinItem(
|
|
|
|
|
id: 'series-1',
|
|
|
|
|
name: 'あかね噺',
|
|
|
|
|
kind: JellyfinItemKind.series,
|
|
|
|
|
providerIds: {'tmdb': '219109'},
|
|
|
|
|
);
|
|
|
|
|
const episode = JellyfinItem(
|
|
|
|
|
id: 'ep-1',
|
|
|
|
|
name: '第1話',
|
|
|
|
|
kind: JellyfinItemKind.episode,
|
|
|
|
|
seriesName: 'あかね噺',
|
|
|
|
|
seriesId: 'series-1',
|
|
|
|
|
seasonNumber: 1,
|
|
|
|
|
episodeNumber: 1,
|
|
|
|
|
providerIds: {'tmdb': '9001'},
|
|
|
|
|
);
|
|
|
|
|
const show = JellyfinTagId(
|
|
|
|
|
ids: {'tmdb': '219109', 'jellyfin:server-1': 'series-1'},
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
test("an episode: its show, by the show's ids, and itself, by them and "
|
|
|
|
|
'its number', () {
|
|
|
|
|
expect(jellyfinTagsOf(episode, serverId: 'server-1', whole: series), (
|
|
|
|
|
whole: (name: 'あかね噺', id: show),
|
|
|
|
|
part: (
|
|
|
|
|
name: 'あかね噺 S1 E1',
|
|
|
|
|
id: JellyfinTagId(ids: show.ids, part: 'S1E1'),
|
|
|
|
|
),
|
|
|
|
|
));
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('without its show read, the show is known by its id on the '
|
|
|
|
|
'server', () {
|
|
|
|
|
expect(
|
|
|
|
|
jellyfinTagsOf(episode, serverId: 'server-1').whole.id,
|
|
|
|
|
const JellyfinTagId(ids: {'jellyfin:server-1': 'series-1'}),
|
|
|
|
|
);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('a film is one tag, by its own ids', () {
|
|
|
|
|
expect(
|
|
|
|
|
jellyfinTagsOf(
|
|
|
|
|
const JellyfinItem(
|
|
|
|
|
id: 'film-1',
|
|
|
|
|
name: 'CODA',
|
|
|
|
|
kind: JellyfinItemKind.movie,
|
|
|
|
|
providerIds: {'tmdb': '776503', 'imdb': 'tt10366460'},
|
|
|
|
|
),
|
|
|
|
|
serverId: 'server-1',
|
|
|
|
|
),
|
|
|
|
|
(
|
|
|
|
|
whole: (
|
|
|
|
|
name: 'CODA',
|
|
|
|
|
id: const JellyfinTagId(
|
|
|
|
|
ids: {
|
|
|
|
|
'tmdb': '776503',
|
|
|
|
|
'imdb': 'tt10366460',
|
|
|
|
|
'jellyfin:server-1': 'film-1',
|
|
|
|
|
},
|
|
|
|
|
),
|
|
|
|
|
),
|
|
|
|
|
part: null,
|
|
|
|
|
),
|
|
|
|
|
);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test("a song: its artist, by the artist's ids, and itself, by its "
|
|
|
|
|
'own', () {
|
|
|
|
|
const song = JellyfinItem(
|
|
|
|
|
id: 'song-1',
|
|
|
|
|
name: 'Ahoy!!',
|
|
|
|
|
kind: JellyfinItemKind.audio,
|
|
|
|
|
artist: '宝鐘マリン',
|
|
|
|
|
artistId: 'artist-1',
|
|
|
|
|
providerIds: {'musicbrainztrack': 'mb-track-1'},
|
|
|
|
|
);
|
|
|
|
|
const marine = JellyfinItem(
|
|
|
|
|
id: 'artist-1',
|
|
|
|
|
name: '宝鐘マリン',
|
|
|
|
|
kind: JellyfinItemKind.artist,
|
|
|
|
|
providerIds: {'musicbrainzartist': 'mb-artist-1'},
|
|
|
|
|
);
|
|
|
|
|
const artist = JellyfinTagId(
|
|
|
|
|
ids: {
|
|
|
|
|
'musicbrainzartist': 'mb-artist-1',
|
|
|
|
|
'jellyfin:server-1': 'artist-1',
|
|
|
|
|
},
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
expect(jellyfinTagsOf(song, serverId: 'server-1', whole: marine), (
|
|
|
|
|
whole: (name: '宝鐘マリン', id: artist),
|
|
|
|
|
part: (
|
|
|
|
|
name: '宝鐘マリン · Ahoy!!',
|
|
|
|
|
id: JellyfinTagId(
|
|
|
|
|
ids: artist.ids,
|
|
|
|
|
part: 'musicbrainztrack:mb-track-1',
|
|
|
|
|
),
|
|
|
|
|
),
|
|
|
|
|
));
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
feat(jellyfin): watch from one's own Jellyfin server and learn the words of its subtitles
Animelon's way of learning from video, from the person's own Jellyfin
server: connected per device (Settings, or a studyset's Add material ›
Watch from Jellyfin) with a name and password or a Quick Connect code,
over HTTPS only (plain HTTP only to a server on the same machine); the
token stays in the system's keyring. Shows, films and videos tied to a
studyset follow the person in their settings as the server's and the
item's ids alone (settings schema 9), named by the server when shown.
The Watch screen plays the video (video_player on Android, media_kit's
libmpv on Linux and Windows) with the line in the studyset's front
language cut into the dictionary's words with furigana, and the back
language's line beneath; a word tapped pauses the video and becomes a
card with the line and its translation as its first hint, tagged with
the show. The video's words are listed, most frequent first. The sound is
the track in the studyset's language, whichever the file marks default,
and the menu switches it; the subtitles read are a whole track, never a
forced one (which only covers what the sound doesn't say), and captions
for the hard of hearing only without plain ones. The line and its
translation take the video's width in a box of a fixed height, so the
video keeps its size; in the dark theme the video's colours glow around
it, as YouTube's ambient mode, and the words' panel takes the colour of
the episode's picture under the pointer.
A line is cut into words with one request: DictionaryEndpoint.withKeys
looks up every stretch of it that may be a word, as it stands and as
every word it may be a conjugation of, in the new dictionary_key table
(one row per key, indexed; migration dictionary-keys adds the table and
its index, nothing else), which the loader fills beside the entries.
The instance gets the text of each line looked up and keeps none of it,
as the Watch screen says.
Linux builds need libmpv-dev, and the app links libmpv: the snap stages
Ubuntu's libmpv2, makes the update-alternatives links for BLAS and LAPACK
that ffmpeg's speech recognition needs (staging never runs install
scripts), and removes what the GNOME runtime and core24 already hold, as
older copies of cairo, pango and librsvg hid the runtime's from its GTK.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-13 11:31:37 +02:00
|
|
|
test("a studyset's language finds Jellyfin's", () {
|
|
|
|
|
expect(jellyfinLanguagesOf('ja'), {'jpn'});
|
|
|
|
|
expect(jellyfinLanguagesOf('de-AT'), {'ger', 'deu'});
|
|
|
|
|
expect(jellyfinLanguagesOf('tlh'), {'tlh'});
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test("Jellyfin's language finds every name a player may give it", () {
|
|
|
|
|
expect(languageNamesOf('jpn'), {'jpn', 'ja'});
|
|
|
|
|
expect(languageNamesOf('ger'), {'ger', 'deu', 'de'});
|
|
|
|
|
expect(languageNamesOf('tlh'), {'tlh'});
|
|
|
|
|
expect(languageNamesOf(null), isEmpty);
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// A gateway noting the addresses tried, finding a server at
|
|
|
|
|
/// `jellyfin.example` over HTTPS and on this machine over plain HTTP.
|
|
|
|
|
class _Asking(final List<Uri> _asked) extends FakeJellyfinGateway {
|
|
|
|
|
@override
|
|
|
|
|
Future<JellyfinServer> server(Uri address) async {
|
|
|
|
|
_asked.add(address);
|
|
|
|
|
final found = address.scheme == 'https'
|
|
|
|
|
? address.host == 'jellyfin.example'
|
|
|
|
|
: const {'localhost', '127.0.0.1', '::1'}.contains(address.host);
|
|
|
|
|
if (!found) {
|
|
|
|
|
throw JellyfinException(problem: JellyfinProblem.unreachable);
|
|
|
|
|
}
|
|
|
|
|
return JellyfinServer(
|
|
|
|
|
address: address,
|
|
|
|
|
id: fakeJellyfinServer.id,
|
|
|
|
|
name: fakeJellyfinServer.name,
|
|
|
|
|
version: fakeJellyfinServer.version,
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
}
|