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>
This commit is contained in:
Tizian 「ティツィアーン」 2026-10-16 22:34:28 +02:00
commit 238a1a93ae
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64
14 changed files with 619 additions and 77 deletions

File diff suppressed because one or more lines are too long

View file

@ -83,38 +83,47 @@ class DictionaryAdder(
);
/// Gives every card of [studysetId] and inside it made from one of
/// [entries] the tag [tagName] (made for Jellyfin if new): the words of an
/// episode the studyset held before it was watched too. The tag as
/// something refers to it.
/// [entries] the tag [tag] made for Jellyfin (the one standing for what
/// it stands for, whatever it is called now): the words of an episode the
/// studyset held before it was watched too. The tag as something refers
/// to it.
Future<TagRef> tagHeld(
String studysetId,
Set<DictionaryRef> entries,
String tagName,
({String name, JellyfinTagId? id}) tag,
) => _queue.run(() async {
final tagId = await _tags.create(tagName, fromJellyfin: true);
final tagId = await _tags.create(
tag.name,
fromJellyfin: true,
jellyfinId: tag.id,
);
await _tags.addTags({
for (final card in await _cards.cardsIn(
await _studysets.subtree(studysetId),
))
if (entries.contains(card.content.entry)) card.id: {tagId},
});
return (id: tagId, name: tidyTagName(tagName));
return (
id: tagId,
name: (await _tags.read()).byId(tagId)?.name ?? tidyTagName(tag.name),
);
});
/// Adds [words] to [studysetId], at the end of where its words go, in
/// their order; each card also tagged [jellyfinTagNames], tags made for
/// Jellyfin if they are new (the show and the episode, or the artist and
/// the song, a word was met in).
/// their order; each card also tagged [jellyfinTags], tags made for
/// Jellyfin (the show and the episode, or the artist and the song, a word
/// was met in), each the one standing for what it stands for, whatever
/// it is called now.
Future<DictionaryAdded> add(
String studysetId,
List<DictionaryItem> words, {
List<String> jellyfinTagNames = const [],
}) => _queue.run(() => _add(studysetId, words, jellyfinTagNames));
List<({String name, JellyfinTagId? id})> jellyfinTags = const [],
}) => _queue.run(() => _add(studysetId, words, jellyfinTags));
Future<DictionaryAdded> _add(
String studysetId,
List<DictionaryItem> words,
List<String> jellyfinTagNames,
List<({String name, JellyfinTagId? id})> jellyfinTags,
) async {
final there = await _entriesIn(studysetId);
final fresh = [
@ -137,9 +146,13 @@ class DictionaryAdder(
await _tags.addTagsNamed(
{
for (final (index, word) in fresh.indexed)
ids[index]: {...word.tagNames, ...jellyfinTagNames},
ids[index]: {
...word.tagNames,
for (final tag in jellyfinTags) tag.name,
},
},
fromJellyfin: {...jellyfinTagNames},
fromJellyfin: {for (final tag in jellyfinTags) tag.name},
jellyfinIds: {for (final tag in jellyfinTags) tag.name: ?tag.id},
);
return (added: fresh.length, skipped: words.length - fresh.length);
}

View file

@ -281,6 +281,7 @@ class WatchCubit(
final JellyfinPlayback info;
try {
final item = await _jellyfin.item(itemId);
_whole = await _wholeOf(item);
info = await _jellyfin.playback(itemId);
_info = info;
final text = [
@ -554,7 +555,7 @@ class WatchCubit(
),
);
try {
await _adder.add(studysetId, [entry], jellyfinTagNames: _tagNames);
await _adder.add(studysetId, [entry], jellyfinTags: _tags);
return true;
} on Object catch (error, st) {
log(
@ -605,14 +606,47 @@ class WatchCubit(
/// Plays the line after the one shown.
Future<void> nextLine() => playLine((state.line ?? -1) + 1);
/// The show or the artist of the item, as asked for alone: what the
/// metadata providers call it names its tags whatever the server calls
/// it (decided 2026-10-16); null for none, or should asking fail.
JellyfinItem? _whole;
Future<JellyfinItem?> _wholeOf(JellyfinItem item) async {
final id = switch (item) {
JellyfinItem(:final seriesId?) => seriesId,
JellyfinItem(kind: JellyfinItemKind.audio, :final artistId?) => artistId,
_ => null,
};
if (id == null) {
return null;
}
try {
return await _jellyfin.item(id);
} on Object catch (error) {
log(
"The show or the artist couldn't be read",
name: 'Watch',
error: error,
);
return null;
}
}
/// The tags a card from this video gets.
List<String> get _tagNames => switch (state.item) {
final item? => switch (jellyfinTagsOf(item)) {
(:final whole, :final part) => [whole, ?part],
},
List<JellyfinTag> get _tags => switch (_tagsOf(state.item)) {
(:final whole, :final part)? => [whole, ?part],
null => const [],
};
({JellyfinTag whole, JellyfinTag? part})? _tagsOf(JellyfinItem? item) =>
item == null
? null
: jellyfinTagsOf(
item,
serverId: _jellyfin.account?.server.id,
whole: _whole,
);
/// Adds the words [WatchState.listed] that the studyset lacks, in the
/// list's order, tagged as a word added alone is (decided 2026-10-13:
/// what a studyset holding none of the video's words is offered in place
@ -625,7 +659,7 @@ class WatchCubit(
final added = await _adder.add(
studysetId,
fresh,
jellyfinTagNames: _tagNames,
jellyfinTags: _tags,
);
if (!isClosed) {
// New cards, never studied: nothing to read again.
@ -659,9 +693,10 @@ class WatchCubit(
if (_playback case final playback? when playback.state.value.playing) {
await playback.pause();
}
final (:whole, :part) = _tagsOf(item)!;
final tag = await _adder.tagHeld(studysetId, {
for (final word in words) word.entry.ref,
}, jellyfinTagsOf(item).part ?? item.name);
}, part ?? whole);
return StudyFocus(tag: tag, cram: cram);
}

View file

@ -649,6 +649,20 @@ class HttpJellyfinGateway(
hasImage: _map(json[_key(json, 'ImageTags')]).isNotEmpty,
artist: _string(json, 'AlbumArtist'),
hasLyrics: json[_key(json, 'HasLyrics')] == true,
providerIds: {
for (final MapEntry(:key, :value) in _map(
json[_key(json, 'ProviderIds')],
).entries)
if (value is String && value.isNotEmpty) key.toLowerCase(): value,
},
seriesId: _string(json, 'SeriesId'),
artistId: switch ([
..._list(json[_key(json, 'AlbumArtists')]),
..._list(json[_key(json, 'ArtistItems')]),
]) {
[final first, ...] => _string(_map(first), 'Id'),
_ => null,
},
);
}
}

View file

@ -1,4 +1,5 @@
import 'package:equatable/equatable.dart';
import 'package:halkyon_learn_flutter/src/feature/tag/model/tag.dart';
/// A Jellyfin server as it answers before anyone signs in: where it is,
/// its id and name, and the version it runs.
@ -116,6 +117,17 @@ final class const JellyfinItem({
/// For a song, whether it has lyrics: without, there is nothing to learn
/// from it, and it doesn't open.
final bool hasLyrics = false,
/// Its ids with the metadata providers the server knows, by provider in
/// lowercase (`tmdb`, `tvdb`, `musicbrainztrack`); given when the item
/// is asked for alone.
final Map<String, String> providerIds = const {},
/// For an episode, its show's id on the server.
final String? seriesId,
/// For a song, its artist's id on the server.
final String? artistId,
}) with Equatable {
@override
List<Object?> get props => [
@ -132,6 +144,9 @@ final class const JellyfinItem({
hasImage,
artist,
hasLyrics,
providerIds,
seriesId,
artistId,
];
}
@ -301,31 +316,75 @@ final class const JellyfinState({
List<Object?> get props => [deviceId, account];
}
/// A tag a card made from a Jellyfin item gets: its name, and what it
/// stands for whatever it is called; null where nothing says.
typedef JellyfinTag = ({String name, JellyfinTagId? id});
/// The tags a card made from [item] gets (decided 2026-10-13), each made
/// for Jellyfin (`Tag.fromJellyfin`, which the settings can hide on cards
/// being studied): the show's (or the film's or video's own) name, the
/// `whole`; and the episode's own, "あかね噺 S1 E1", the `part`, so the
/// episode's words can be studied together.
/// episode's words can be studied together. A song's are its artist's and
/// "宝鐘マリン · 幽霊船戦".
///
/// A song's are its artist's and "宝鐘マリン · 幽霊船戦".
({String whole, String? part}) jellyfinTagsOf(JellyfinItem item) =>
switch (item) {
JellyfinItem(kind: JellyfinItemKind.audio, artist: final artist?) => (
whole: artist,
part: '$artist · ${item.name}',
),
JellyfinItem(
seriesName: final series?,
seasonNumber: final season?,
episodeNumber: final episode?,
) =>
(whole: series, part: '$series S$season E$episode'),
JellyfinItem(seriesName: final series?) => (
whole: series,
part: '$series · ${item.name}',
),
_ => (whole: item.name, part: null),
};
/// Each says what it stands for (decided 2026-10-16), so a show renamed
/// keeps its tags: the whole by the ids of [whole], the show or the artist
/// asked for alone (else by its id on the server [serverId]), or a film's
/// own; the part by those and the episode's number, or by its own ids.
({JellyfinTag whole, JellyfinTag? part}) jellyfinTagsOf(
JellyfinItem item, {
required String? serverId,
JellyfinItem? whole,
}) {
Map<String, String> idsOf(JellyfinItem item) => {
...item.providerIds,
if (serverId != null) 'jellyfin:$serverId': item.id,
};
Map<String, String> wholeIds(String? id) => switch ((whole, id)) {
(final whole?, _) => idsOf(whole),
(null, final id?) when serverId != null => {'jellyfin:$serverId': id},
_ => const {},
};
JellyfinTagId? idOf(Map<String, String> ids, [String? part]) =>
ids.isEmpty ? null : JellyfinTagId(ids: ids, part: part);
// A song, or an episode without a number, by its own first id.
String ownPart() =>
switch ((item.providerIds.entries.toList()
..sort((a, b) => a.key.compareTo(b.key)))
.firstOrNull) {
final first? => '${first.key}:${first.value}',
null => 'jellyfin:$serverId/${item.id}',
};
switch (item) {
case JellyfinItem(kind: JellyfinItemKind.audio, artist: final artist?):
final ids = wholeIds(item.artistId);
return (
whole: (name: artist, id: idOf(ids)),
part: (name: '$artist · ${item.name}', id: idOf(ids, ownPart())),
);
case JellyfinItem(
seriesName: final series?,
seasonNumber: final season?,
episodeNumber: final episode?,
):
final ids = wholeIds(item.seriesId);
return (
whole: (name: series, id: idOf(ids)),
part: (
name: '$series S$season E$episode',
id: idOf(ids, 'S${season}E$episode'),
),
);
case JellyfinItem(seriesName: final series?):
final ids = wholeIds(item.seriesId);
return (
whole: (name: series, id: idOf(ids)),
part: (name: '$series · ${item.name}', id: idOf(ids, ownPart())),
);
case _:
return (whole: (name: item.name, id: idOf(idsOf(item))), part: null);
}
}
/// Jellyfin's languages (ISO 639-2, both the bibliographic and the
/// terminological codes) for a language as studysets name it (BCP 47), so

View file

@ -8,7 +8,11 @@ import 'package:halkyon_learn_flutter/src/feature/tag/model/tag.dart';
// Schema 1 holds the name and the colour; schema 2 adds `origin` (decided
// 2026-10-13): `jellyfin` for a tag the app made for where words were met on
// Jellyfin, written only for such a tag, so older apps still read the
// others.
// others; and since 2026-10-16 `jellyfin`, what such a tag stands for
// (`ids`, `part`), within schema 2 all the same: a deliberate exception to
// ADR 0002's rule 3, as raising the schema would hide every new Jellyfin tag
// from apps that read schema 2, while an app that rewrites the tag only
// drops it, and the tag is found by its name again.
/// The newest schema this app writes.
const tagSchemaVersion = 2;
@ -19,12 +23,49 @@ typedef TagContent = ({String name, String colorName});
/// Encodes a tag's [content] into its payload, the name as a [tidyTagName].
///
/// A tag made [fromJellyfin] is written in schema 2, any other in schema 1.
ByteData encodeTagPayload(TagContent content, {bool fromJellyfin = false}) =>
encodePayload(fromJellyfin ? 2 : 1, {
'name': tidyTagName(content.name),
'color': content.colorName,
if (fromJellyfin) 'origin': 'jellyfin',
});
ByteData encodeTagPayload(
TagContent content, {
bool fromJellyfin = false,
/// What a tag made for Jellyfin stands for.
JellyfinTagId? jellyfinId,
}) => encodePayload(fromJellyfin ? 2 : 1, {
'name': tidyTagName(content.name),
'color': content.colorName,
if (fromJellyfin) 'origin': 'jellyfin',
if (jellyfinId != null && fromJellyfin)
'jellyfin': {
'ids': jellyfinId.ids,
'part': ?jellyfinId.part,
},
});
/// What the tag in [payload] stands for, made for Jellyfin; null for a tag
/// that doesn't say, and for anything it can't read.
JellyfinTagId? tagPayloadJellyfinId(ByteData payload) {
try {
return switch (decodePayload(
payload,
newestSchema: tagSchemaVersion,
)['jellyfin']) {
{'ids': final Map<String, Object?> ids} && final Map<String, Object?> json
when ids.isNotEmpty =>
JellyfinTagId(
ids: {
for (final MapEntry(:key, :value) in ids.entries)
if (value is String && value.isNotEmpty) key: value,
},
part: switch (json['part']) {
final String part => part,
_ => null,
},
),
_ => null,
};
} on FormatException {
return null;
}
}
/// Whether the tag in [payload] was made for where words were met on
/// Jellyfin; false for anything it can't read.

View file

@ -76,12 +76,21 @@ class TagRepository(
String name, {
String colorName = defaultTagColorName,
bool fromJellyfin = false,
/// What a tag made for Jellyfin stands for: the tag standing for the
/// same is the one, whatever it is called now.
JellyfinTagId? jellyfinId,
}) async {
if (tidyTagName(name).isEmpty) {
throw ArgumentError.value(name, 'name', 'is blank');
}
final id = await _serially(
() => _idOf(name, colorName, fromJellyfin: fromJellyfin),
() => _idOf(
name,
colorName,
fromJellyfin: fromJellyfin,
jellyfinId: jellyfinId,
),
);
await _refresh();
return id;
@ -97,15 +106,18 @@ class TagRepository(
if (tidyTagName(name).isEmpty) {
throw ArgumentError.value(name, 'name', 'is blank');
}
await _serially(
() async => await _gateway.updateTagPayload(
await _serially(() async {
// What it was made for stays, as does what it stands for.
final tag = (await _read()).byId(id);
await _gateway.updateTagPayload(
id,
encodeTagPayload(
(name: name, colorName: colorName),
fromJellyfin: (await _read()).byId(id)?.fromJellyfin ?? false,
fromJellyfin: tag?.fromJellyfin ?? false,
jellyfinId: tag?.jellyfinId,
),
),
);
);
});
await _refresh();
}
@ -192,6 +204,10 @@ class TagRepository(
Map<String, Set<String>> tagNamesByCard, {
Set<String> fromJellyfin = const {},
Map<String, String> colorNames = const {},
/// What the tags made for Jellyfin stand for, by their names as given:
/// each is the tag standing for the same, whatever it is called now.
Map<String, JellyfinTagId> jellyfinIds = const {},
}) async {
final names = {for (final names in tagNamesByCard.values) ...names};
if (names.any((name) => tidyTagName(name).isEmpty)) {
@ -203,13 +219,17 @@ class TagRepository(
final ids = <String, String>{};
for (final name in names) {
ids[name] =
tagging.named(name)?.id ??
await _existingId(tagging, name, jellyfinIds[name]) ??
(made[canonicalTagName(name)] ??= await _gateway.insertTag(
encodeTagPayload((
name: name,
colorName:
colorNames[canonicalTagName(name)] ?? defaultTagColorName,
), fromJellyfin: fromJellyfin.contains(name)),
encodeTagPayload(
(
name: name,
colorName:
colorNames[canonicalTagName(name)] ?? defaultTagColorName,
),
fromJellyfin: fromJellyfin.contains(name),
jellyfinId: jellyfinIds[name],
),
));
}
await _gateway.insertCardTags([
@ -439,23 +459,56 @@ class TagRepository(
return folded;
}
/// The id of the tag named [name]: an existing one, or a new one in
/// [colorName], made [fromJellyfin] if asked; call it only in
/// [_serially].
/// The id of the tag named [name]: an existing one ([_existingId]), or
/// a new one in [colorName], made [fromJellyfin] if asked, standing for
/// [jellyfinId]; call it only in [_serially].
Future<String> _idOf(
String name,
String colorName, {
bool fromJellyfin = false,
}) async {
if ((await _read()).named(name) case final existing?) {
return existing.id;
JellyfinTagId? jellyfinId,
}) async =>
await _existingId(await _read(), name, jellyfinId) ??
await _gateway.insertTag(
encodeTagPayload(
(name: name, colorName: colorName),
fromJellyfin: fromJellyfin,
jellyfinId: jellyfinId,
),
);
/// The tag in [tagging] that [name] is, standing for [jellyfinId] if
/// made for Jellyfin: the one standing for the same, whatever it is
/// called now (decided 2026-10-16); else the one of that name, which
/// takes [jellyfinId] when it was made for Jellyfin before tags kept
/// one; null for none. Call it only in [_serially].
Future<String?> _existingId(
Tagging tagging,
String name,
JellyfinTagId? jellyfinId,
) async {
if (jellyfinId != null) {
for (final tag in tagging.all) {
if (tag.jellyfinId case final id? when id.sameAs(jellyfinId)) {
return tag.id;
}
}
}
return await _gateway.insertTag(
encodeTagPayload(
(name: name, colorName: colorName),
fromJellyfin: fromJellyfin,
),
);
final named = tagging.named(name);
if (named != null &&
jellyfinId != null &&
named.fromJellyfin &&
named.jellyfinId == null) {
await _gateway.updateTagPayload(
named.id,
encodeTagPayload(
(name: named.name, colorName: named.colorName),
fromJellyfin: true,
jellyfinId: jellyfinId,
),
);
}
return named?.id;
}
/// Blanks the tag [id], then deletes it from every card and itself.
@ -474,6 +527,7 @@ class TagRepository(
name: name,
colorName: colorName,
fromJellyfin: tagPayloadFromJellyfin(row.payload),
jellyfinId: tagPayloadJellyfinId(row.payload),
);
} on FormatException catch (error) {
log('Skipped tag ${row.id}: ${error.message}', name: 'Tags');

View file

@ -25,12 +25,40 @@ final class const Tag({
/// (`hideJellyfinTags`), as they would crowd a common word's card. They
/// still filter, narrow a study session and show where tags are chosen.
final bool fromJellyfin = false,
/// What a tag made for Jellyfin stands for, whatever it is called; null
/// for a tag made before tags kept it, and for every other tag.
final JellyfinTagId? jellyfinId,
}) with Equatable {
/// This tag as something else refers to it.
TagRef get ref => (id: id, name: name);
@override
List<Object?> get props => [id, name, colorName, fromJellyfin];
List<Object?> get props => [id, name, colorName, fromJellyfin, jellyfinId];
}
/// What a tag made for Jellyfin stands for, whatever it is called (decided
/// 2026-10-16): the ids the show, film or artist has with the metadata
/// providers Jellyfin knows (TMDB, TVDB, AniDB, MusicBrainz, …) and on the
/// Jellyfin server itself (`jellyfin:<server>`), and which part of it, if
/// any: an episode by its number (`S1E1`), a song by its own id. So a show
/// renamed (in another metadata language, on another server, or in Manage
/// tags) keeps its tag.
final class const JellyfinTagId({
/// The ids, by provider, lowercase (`tmdb`).
required final Map<String, String> ids,
/// Which part of it; null for the whole.
final String? part,
}) with Equatable {
/// Whether this and [other] stand for the same: the same part of what
/// shares an id with a provider.
bool sameAs(JellyfinTagId other) =>
part == other.part &&
ids.entries.any((entry) => other.ids[entry.key] == entry.value);
@override
List<Object?> get props => [ids, part];
}
/// A tag as something else refers to it, settings say: by its `id`, and by

View file

@ -513,9 +513,13 @@ void main() {
await adder.add('n3', [taberu]);
await adder.add('n5', [terebi]);
final tag = await adder.tagHeld('vocabulary', {
taberu.ref,
}, 'あかね噺 S1 E1');
final tag = await adder.tagHeld(
'vocabulary',
{
taberu.ref,
},
(name: 'あかね噺 S1 E1', id: null),
);
final tagging = await tags.read();
final [eat] = await cards.cardsOf('n3');

View file

@ -8,6 +8,7 @@ import 'package:halkyon_learn_flutter/src/feature/jellyfin/data/jellyfin_gateway
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';
import 'package:halkyon_learn_flutter/src/feature/tag/model/tag.dart';
import 'package:http/http.dart' as http;
import 'package:http/testing.dart';
@ -78,6 +79,10 @@ MockClient jellyfin12(List<http.Request> requests) => MockClient((
'Name': '幽霊船戦',
'Id': 'song-1',
'Type': 'Audio',
'AlbumArtists': [
{'Name': '宝鐘マリン', 'Id': 'artist-1'},
],
'ProviderIds': {'MusicBrainzTrack': 'mb-track-1'},
'AlbumArtist': '宝鐘マリン',
'Album': 'Ahoy!!',
'HasLyrics': true,
@ -98,6 +103,8 @@ MockClient jellyfin12(List<http.Request> requests) => MockClient((
'Id': 'ep-1',
'Type': 'Episode',
'SeriesName': 'Sample Journey',
'SeriesId': 'series-1',
'ProviderIds': {'Tmdb': '9001', 'Tvdb': ''},
'IndexNumber': 1,
'ParentIndexNumber': 1,
'RunTimeTicks': 520230000,
@ -183,6 +190,12 @@ MockClient jellyfin12(List<http.Request> requests) => MockClient((
utf8.encode('WEBVTT\n\n00:00:01.000 --> 00:00:05.000\n今日は\n'),
200,
),
('GET', '/Items/series-1') => json({
'Name': 'Sample Journey',
'Id': 'series-1',
'Type': 'Series',
'providerIds': {'Tmdb': '219109', 'Tvdb': '445566'},
}),
('POST', '/Sessions/Playing/Stopped') => json(null, 204),
_ => json(null, 404),
};
@ -336,6 +349,22 @@ void main() {
);
});
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'});
});
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 {
@ -673,6 +702,109 @@ void main() {
});
});
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',
),
),
));
});
});
test("a studyset's language finds Jellyfin's", () {
expect(jellyfinLanguagesOf('ja'), {'jpn'});
expect(jellyfinLanguagesOf('de-AT'), {'ger', 'deu'});

View file

@ -21,6 +21,7 @@ import 'package:halkyon_learn_flutter/src/feature/studyset/data/studyset_payload
import 'package:halkyon_learn_flutter/src/feature/studyset/data/studyset_repository.dart';
import 'package:halkyon_learn_flutter/src/feature/studyset/model/studyset_content.dart';
import 'package:halkyon_learn_flutter/src/feature/tag/data/tag_repository.dart';
import 'package:halkyon_learn_flutter/src/feature/tag/model/tag.dart';
import 'package:material_ui/material_ui.dart';
import '../../support/fake_card_gateway.dart';
@ -251,6 +252,38 @@ void main() {
expect(find.text('New'), findsOneWidget);
});
testWidgets('a show the server calls otherwise now keeps its tags: a word '
'added gets those standing for the show and the episode, whatever '
'they are called', (tester) async {
final show = await tags.create(
'旅の記録',
fromJellyfin: true,
jellyfinId: const JellyfinTagId(ids: {'tmdb': '424242'}),
);
final episode = await tags.create(
'旅の記録 1-1',
fromJellyfin: true,
jellyfinId: const JellyfinTagId(ids: {'tmdb': '424242'}, part: 'S1E1'),
);
await watch(tester);
await players.last!.play();
await playTo(tester, const Duration(seconds: 2));
await tester.tap(find.text('いました'));
await tester.pumpAndSettle();
await tester.tap(find.text('Add'));
await tester.pumpAndSettle();
final card = (await cards.cardsOf('japanese')).single;
final tagging = await tags.read();
expect(
[for (final tag in tagging.of(card.id)) tag.id],
containsAll([show, episode]),
);
expect(tagging.named('Sample Journey'), isNull);
expect(tagging.named('Sample Journey S1 E1'), isNull);
});
testWidgets("the video's words are listed, the most frequent first, "
'particles left out; one opens its first line', (tester) async {
await watch(tester);

View file

@ -1,6 +1,7 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:halkyon_learn_flutter/src/core/content/payload.dart';
import 'package:halkyon_learn_flutter/src/feature/tag/data/tag_payload.dart';
import 'package:halkyon_learn_flutter/src/feature/tag/model/tag.dart';
void main() {
group('The tag payload', () {
@ -43,6 +44,42 @@ void main() {
expect(tagPayloadFromJellyfin(own), isFalse);
});
test('a tag made for Jellyfin keeps what it stands for, still in schema '
'2 (decided 2026-10-16: older apps go on showing it, and one that '
'rewrites it only drops this); one without stands for nothing', () {
const episode = JellyfinTagId(
ids: {'tmdb': '219109', 'jellyfin:server-1': 'series-1'},
part: 'S1E1',
);
final payload = encodeTagPayload(
(name: 'あかね噺 S1 E1', colorName: 'fern'),
fromJellyfin: true,
jellyfinId: episode,
);
expect(tagSchemaVersion, 2);
expect(decodePayload(payload, newestSchema: tagSchemaVersion), {
'schemaVersion': 2,
'name': 'あかね噺 S1 E1',
'color': 'fern',
'origin': 'jellyfin',
'jellyfin': {
'ids': {'tmdb': '219109', 'jellyfin:server-1': 'series-1'},
'part': 'S1E1',
},
});
expect(tagPayloadJellyfinId(payload), episode);
expect(
tagPayloadJellyfinId(
encodeTagPayload((
name: 'あかね噺',
colorName: 'fern',
), fromJellyfin: true),
),
isNull,
);
});
test('reads a name as another device wrote it, tidied', () {
final payload = encodePayload(1, {
'name': ' Verb form',

View file

@ -145,6 +145,96 @@ void main() {
expect((await repository.read()).named('Verb'), isNull);
});
group('A tag made for Jellyfin, by what it stands for', () {
const akane = JellyfinTagId(
ids: {'tmdb': '219109', 'jellyfin:server-1': 'series-1'},
);
test('is the one a show has whatever it is called now: renamed by '
'hand, or named otherwise by the server, in another language or on '
'another server', () async {
final show = await repository.create(
'あかね噺',
fromJellyfin: true,
jellyfinId: akane,
);
await repository.update(show, name: 'Akane', colorName: 'plum');
expect(
await repository.create(
'Akane-banashi',
fromJellyfin: true,
jellyfinId: const JellyfinTagId(ids: {'tmdb': '219109'}),
),
show,
);
final ids = await repository.addTagsNamed(
{
'card-1': {'Akane-banashi', 'Akane-banashi S1 E1'},
},
fromJellyfin: {'Akane-banashi', 'Akane-banashi S1 E1'},
jellyfinIds: {
'Akane-banashi': const JellyfinTagId(
ids: {'tvdb': '445566', 'jellyfin:server-1': 'series-1'},
),
'Akane-banashi S1 E1': JellyfinTagId(ids: akane.ids, part: 'S1E1'),
},
);
expect(ids['Akane-banashi'], show);
final tagging = await repository.read();
expect(
{for (final tag in tagging.of('card-1')) tag.name},
{
'Akane',
'Akane-banashi S1 E1',
},
);
expect(tagging.byId(show)?.jellyfinId, akane);
// Another show, or another part of one, is a tag of its own.
expect(
await repository.create(
'ダンジョン飯',
fromJellyfin: true,
jellyfinId: const JellyfinTagId(ids: {'tmdb': '1'}),
),
isNot(show),
);
expect(ids['Akane-banashi S1 E1'], isNot(show));
});
test('one made before tags kept ids, found by its name, takes them, and '
'a rename then keeps them', () async {
final old = await repository.create('あかね噺', fromJellyfin: true);
expect(
await repository.create('あかね噺', fromJellyfin: true, jellyfinId: akane),
old,
);
expect((await repository.read()).byId(old)?.jellyfinId, akane);
await repository.update(old, name: 'Akane', colorName: 'plum');
expect(
await repository.create(
'Akane-banashi',
fromJellyfin: true,
jellyfinId: akane,
),
old,
);
});
test("the person's own tag of that name is taken as it is, and never "
'takes ids', () async {
final own = await repository.create('あかね噺');
expect(
await repository.create('あかね噺', fromJellyfin: true, jellyfinId: akane),
own,
);
expect((await repository.read()).byId(own)?.jellyfinId, isNull);
});
});
test('lists the tags by name, whatever their case', () async {
await repository.create('verb');
await repository.create('adjective');

View file

@ -11,6 +11,7 @@ const sampleShow = JellyfinItem(
name: 'Sample Journey',
kind: JellyfinItemKind.series,
year: 2026,
providerIds: {'tmdb': '424242'},
);
/// Its first episode.
@ -19,6 +20,7 @@ const sampleEpisode = JellyfinItem(
name: 'The Station',
kind: JellyfinItemKind.episode,
seriesName: 'Sample Journey',
seriesId: 'show-1',
seasonNumber: 1,
episodeNumber: 1,
runTime: Duration(minutes: 24),