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:
parent
838f753bf2
commit
238a1a93ae
14 changed files with 619 additions and 77 deletions
File diff suppressed because one or more lines are too long
|
|
@ -83,38 +83,47 @@ class DictionaryAdder(
|
||||||
);
|
);
|
||||||
|
|
||||||
/// Gives every card of [studysetId] and inside it made from one of
|
/// 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
|
/// [entries] the tag [tag] made for Jellyfin (the one standing for what
|
||||||
/// episode the studyset held before it was watched too. The tag as
|
/// it stands for, whatever it is called now): the words of an episode the
|
||||||
/// something refers to it.
|
/// studyset held before it was watched too. The tag as something refers
|
||||||
|
/// to it.
|
||||||
Future<TagRef> tagHeld(
|
Future<TagRef> tagHeld(
|
||||||
String studysetId,
|
String studysetId,
|
||||||
Set<DictionaryRef> entries,
|
Set<DictionaryRef> entries,
|
||||||
String tagName,
|
({String name, JellyfinTagId? id}) tag,
|
||||||
) => _queue.run(() async {
|
) => _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({
|
await _tags.addTags({
|
||||||
for (final card in await _cards.cardsIn(
|
for (final card in await _cards.cardsIn(
|
||||||
await _studysets.subtree(studysetId),
|
await _studysets.subtree(studysetId),
|
||||||
))
|
))
|
||||||
if (entries.contains(card.content.entry)) card.id: {tagId},
|
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
|
/// Adds [words] to [studysetId], at the end of where its words go, in
|
||||||
/// their order; each card also tagged [jellyfinTagNames], tags made for
|
/// their order; each card also tagged [jellyfinTags], tags made for
|
||||||
/// Jellyfin if they are new (the show and the episode, or the artist and
|
/// Jellyfin (the show and the episode, or the artist and the song, a word
|
||||||
/// the song, a word was met in).
|
/// was met in), each the one standing for what it stands for, whatever
|
||||||
|
/// it is called now.
|
||||||
Future<DictionaryAdded> add(
|
Future<DictionaryAdded> add(
|
||||||
String studysetId,
|
String studysetId,
|
||||||
List<DictionaryItem> words, {
|
List<DictionaryItem> words, {
|
||||||
List<String> jellyfinTagNames = const [],
|
List<({String name, JellyfinTagId? id})> jellyfinTags = const [],
|
||||||
}) => _queue.run(() => _add(studysetId, words, jellyfinTagNames));
|
}) => _queue.run(() => _add(studysetId, words, jellyfinTags));
|
||||||
|
|
||||||
Future<DictionaryAdded> _add(
|
Future<DictionaryAdded> _add(
|
||||||
String studysetId,
|
String studysetId,
|
||||||
List<DictionaryItem> words,
|
List<DictionaryItem> words,
|
||||||
List<String> jellyfinTagNames,
|
List<({String name, JellyfinTagId? id})> jellyfinTags,
|
||||||
) async {
|
) async {
|
||||||
final there = await _entriesIn(studysetId);
|
final there = await _entriesIn(studysetId);
|
||||||
final fresh = [
|
final fresh = [
|
||||||
|
|
@ -137,9 +146,13 @@ class DictionaryAdder(
|
||||||
await _tags.addTagsNamed(
|
await _tags.addTagsNamed(
|
||||||
{
|
{
|
||||||
for (final (index, word) in fresh.indexed)
|
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);
|
return (added: fresh.length, skipped: words.length - fresh.length);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -281,6 +281,7 @@ class WatchCubit(
|
||||||
final JellyfinPlayback info;
|
final JellyfinPlayback info;
|
||||||
try {
|
try {
|
||||||
final item = await _jellyfin.item(itemId);
|
final item = await _jellyfin.item(itemId);
|
||||||
|
_whole = await _wholeOf(item);
|
||||||
info = await _jellyfin.playback(itemId);
|
info = await _jellyfin.playback(itemId);
|
||||||
_info = info;
|
_info = info;
|
||||||
final text = [
|
final text = [
|
||||||
|
|
@ -554,7 +555,7 @@ class WatchCubit(
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
try {
|
try {
|
||||||
await _adder.add(studysetId, [entry], jellyfinTagNames: _tagNames);
|
await _adder.add(studysetId, [entry], jellyfinTags: _tags);
|
||||||
return true;
|
return true;
|
||||||
} on Object catch (error, st) {
|
} on Object catch (error, st) {
|
||||||
log(
|
log(
|
||||||
|
|
@ -605,14 +606,47 @@ class WatchCubit(
|
||||||
/// Plays the line after the one shown.
|
/// Plays the line after the one shown.
|
||||||
Future<void> nextLine() => playLine((state.line ?? -1) + 1);
|
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.
|
/// The tags a card from this video gets.
|
||||||
List<String> get _tagNames => switch (state.item) {
|
List<JellyfinTag> get _tags => switch (_tagsOf(state.item)) {
|
||||||
final item? => switch (jellyfinTagsOf(item)) {
|
(:final whole, :final part)? => [whole, ?part],
|
||||||
(:final whole, :final part) => [whole, ?part],
|
|
||||||
},
|
|
||||||
null => const [],
|
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
|
/// 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:
|
/// 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
|
/// 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(
|
final added = await _adder.add(
|
||||||
studysetId,
|
studysetId,
|
||||||
fresh,
|
fresh,
|
||||||
jellyfinTagNames: _tagNames,
|
jellyfinTags: _tags,
|
||||||
);
|
);
|
||||||
if (!isClosed) {
|
if (!isClosed) {
|
||||||
// New cards, never studied: nothing to read again.
|
// New cards, never studied: nothing to read again.
|
||||||
|
|
@ -659,9 +693,10 @@ class WatchCubit(
|
||||||
if (_playback case final playback? when playback.state.value.playing) {
|
if (_playback case final playback? when playback.state.value.playing) {
|
||||||
await playback.pause();
|
await playback.pause();
|
||||||
}
|
}
|
||||||
|
final (:whole, :part) = _tagsOf(item)!;
|
||||||
final tag = await _adder.tagHeld(studysetId, {
|
final tag = await _adder.tagHeld(studysetId, {
|
||||||
for (final word in words) word.entry.ref,
|
for (final word in words) word.entry.ref,
|
||||||
}, jellyfinTagsOf(item).part ?? item.name);
|
}, part ?? whole);
|
||||||
return StudyFocus(tag: tag, cram: cram);
|
return StudyFocus(tag: tag, cram: cram);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -649,6 +649,20 @@ class HttpJellyfinGateway(
|
||||||
hasImage: _map(json[_key(json, 'ImageTags')]).isNotEmpty,
|
hasImage: _map(json[_key(json, 'ImageTags')]).isNotEmpty,
|
||||||
artist: _string(json, 'AlbumArtist'),
|
artist: _string(json, 'AlbumArtist'),
|
||||||
hasLyrics: json[_key(json, 'HasLyrics')] == true,
|
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,
|
||||||
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
import 'package:equatable/equatable.dart';
|
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,
|
/// A Jellyfin server as it answers before anyone signs in: where it is,
|
||||||
/// its id and name, and the version it runs.
|
/// 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
|
/// For a song, whether it has lyrics: without, there is nothing to learn
|
||||||
/// from it, and it doesn't open.
|
/// from it, and it doesn't open.
|
||||||
final bool hasLyrics = false,
|
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 {
|
}) with Equatable {
|
||||||
@override
|
@override
|
||||||
List<Object?> get props => [
|
List<Object?> get props => [
|
||||||
|
|
@ -132,6 +144,9 @@ final class const JellyfinItem({
|
||||||
hasImage,
|
hasImage,
|
||||||
artist,
|
artist,
|
||||||
hasLyrics,
|
hasLyrics,
|
||||||
|
providerIds,
|
||||||
|
seriesId,
|
||||||
|
artistId,
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -301,31 +316,75 @@ final class const JellyfinState({
|
||||||
List<Object?> get props => [deviceId, account];
|
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
|
/// 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
|
/// 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
|
/// 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
|
/// `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 "宝鐘マリン · 幽霊船戦".
|
/// Each says what it stands for (decided 2026-10-16), so a show renamed
|
||||||
({String whole, String? part}) jellyfinTagsOf(JellyfinItem item) =>
|
/// keeps its tags: the whole by the ids of [whole], the show or the artist
|
||||||
switch (item) {
|
/// asked for alone (else by its id on the server [serverId]), or a film's
|
||||||
JellyfinItem(kind: JellyfinItemKind.audio, artist: final artist?) => (
|
/// own; the part by those and the episode's number, or by its own ids.
|
||||||
whole: artist,
|
({JellyfinTag whole, JellyfinTag? part}) jellyfinTagsOf(
|
||||||
part: '$artist · ${item.name}',
|
JellyfinItem item, {
|
||||||
),
|
required String? serverId,
|
||||||
JellyfinItem(
|
JellyfinItem? whole,
|
||||||
seriesName: final series?,
|
}) {
|
||||||
seasonNumber: final season?,
|
Map<String, String> idsOf(JellyfinItem item) => {
|
||||||
episodeNumber: final episode?,
|
...item.providerIds,
|
||||||
) =>
|
if (serverId != null) 'jellyfin:$serverId': item.id,
|
||||||
(whole: series, part: '$series S$season E$episode'),
|
};
|
||||||
JellyfinItem(seriesName: final series?) => (
|
Map<String, String> wholeIds(String? id) => switch ((whole, id)) {
|
||||||
whole: series,
|
(final whole?, _) => idsOf(whole),
|
||||||
part: '$series · ${item.name}',
|
(null, final id?) when serverId != null => {'jellyfin:$serverId': id},
|
||||||
),
|
_ => const {},
|
||||||
_ => (whole: item.name, part: null),
|
};
|
||||||
};
|
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
|
/// Jellyfin's languages (ISO 639-2, both the bibliographic and the
|
||||||
/// terminological codes) for a language as studysets name it (BCP 47), so
|
/// terminological codes) for a language as studysets name it (BCP 47), so
|
||||||
|
|
|
||||||
|
|
@ -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
|
// 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
|
// 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
|
// 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.
|
/// The newest schema this app writes.
|
||||||
const tagSchemaVersion = 2;
|
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].
|
/// 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.
|
/// A tag made [fromJellyfin] is written in schema 2, any other in schema 1.
|
||||||
ByteData encodeTagPayload(TagContent content, {bool fromJellyfin = false}) =>
|
ByteData encodeTagPayload(
|
||||||
encodePayload(fromJellyfin ? 2 : 1, {
|
TagContent content, {
|
||||||
'name': tidyTagName(content.name),
|
bool fromJellyfin = false,
|
||||||
'color': content.colorName,
|
|
||||||
if (fromJellyfin) 'origin': 'jellyfin',
|
/// 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
|
/// Whether the tag in [payload] was made for where words were met on
|
||||||
/// Jellyfin; false for anything it can't read.
|
/// Jellyfin; false for anything it can't read.
|
||||||
|
|
|
||||||
|
|
@ -76,12 +76,21 @@ class TagRepository(
|
||||||
String name, {
|
String name, {
|
||||||
String colorName = defaultTagColorName,
|
String colorName = defaultTagColorName,
|
||||||
bool fromJellyfin = false,
|
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 {
|
}) async {
|
||||||
if (tidyTagName(name).isEmpty) {
|
if (tidyTagName(name).isEmpty) {
|
||||||
throw ArgumentError.value(name, 'name', 'is blank');
|
throw ArgumentError.value(name, 'name', 'is blank');
|
||||||
}
|
}
|
||||||
final id = await _serially(
|
final id = await _serially(
|
||||||
() => _idOf(name, colorName, fromJellyfin: fromJellyfin),
|
() => _idOf(
|
||||||
|
name,
|
||||||
|
colorName,
|
||||||
|
fromJellyfin: fromJellyfin,
|
||||||
|
jellyfinId: jellyfinId,
|
||||||
|
),
|
||||||
);
|
);
|
||||||
await _refresh();
|
await _refresh();
|
||||||
return id;
|
return id;
|
||||||
|
|
@ -97,15 +106,18 @@ class TagRepository(
|
||||||
if (tidyTagName(name).isEmpty) {
|
if (tidyTagName(name).isEmpty) {
|
||||||
throw ArgumentError.value(name, 'name', 'is blank');
|
throw ArgumentError.value(name, 'name', 'is blank');
|
||||||
}
|
}
|
||||||
await _serially(
|
await _serially(() async {
|
||||||
() async => await _gateway.updateTagPayload(
|
// What it was made for stays, as does what it stands for.
|
||||||
|
final tag = (await _read()).byId(id);
|
||||||
|
await _gateway.updateTagPayload(
|
||||||
id,
|
id,
|
||||||
encodeTagPayload(
|
encodeTagPayload(
|
||||||
(name: name, colorName: colorName),
|
(name: name, colorName: colorName),
|
||||||
fromJellyfin: (await _read()).byId(id)?.fromJellyfin ?? false,
|
fromJellyfin: tag?.fromJellyfin ?? false,
|
||||||
|
jellyfinId: tag?.jellyfinId,
|
||||||
),
|
),
|
||||||
),
|
);
|
||||||
);
|
});
|
||||||
await _refresh();
|
await _refresh();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -192,6 +204,10 @@ class TagRepository(
|
||||||
Map<String, Set<String>> tagNamesByCard, {
|
Map<String, Set<String>> tagNamesByCard, {
|
||||||
Set<String> fromJellyfin = const {},
|
Set<String> fromJellyfin = const {},
|
||||||
Map<String, String> colorNames = 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 {
|
}) async {
|
||||||
final names = {for (final names in tagNamesByCard.values) ...names};
|
final names = {for (final names in tagNamesByCard.values) ...names};
|
||||||
if (names.any((name) => tidyTagName(name).isEmpty)) {
|
if (names.any((name) => tidyTagName(name).isEmpty)) {
|
||||||
|
|
@ -203,13 +219,17 @@ class TagRepository(
|
||||||
final ids = <String, String>{};
|
final ids = <String, String>{};
|
||||||
for (final name in names) {
|
for (final name in names) {
|
||||||
ids[name] =
|
ids[name] =
|
||||||
tagging.named(name)?.id ??
|
await _existingId(tagging, name, jellyfinIds[name]) ??
|
||||||
(made[canonicalTagName(name)] ??= await _gateway.insertTag(
|
(made[canonicalTagName(name)] ??= await _gateway.insertTag(
|
||||||
encodeTagPayload((
|
encodeTagPayload(
|
||||||
name: name,
|
(
|
||||||
colorName:
|
name: name,
|
||||||
colorNames[canonicalTagName(name)] ?? defaultTagColorName,
|
colorName:
|
||||||
), fromJellyfin: fromJellyfin.contains(name)),
|
colorNames[canonicalTagName(name)] ?? defaultTagColorName,
|
||||||
|
),
|
||||||
|
fromJellyfin: fromJellyfin.contains(name),
|
||||||
|
jellyfinId: jellyfinIds[name],
|
||||||
|
),
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
await _gateway.insertCardTags([
|
await _gateway.insertCardTags([
|
||||||
|
|
@ -439,23 +459,56 @@ class TagRepository(
|
||||||
return folded;
|
return folded;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The id of the tag named [name]: an existing one, or a new one in
|
/// The id of the tag named [name]: an existing one ([_existingId]), or
|
||||||
/// [colorName], made [fromJellyfin] if asked; call it only in
|
/// a new one in [colorName], made [fromJellyfin] if asked, standing for
|
||||||
/// [_serially].
|
/// [jellyfinId]; call it only in [_serially].
|
||||||
Future<String> _idOf(
|
Future<String> _idOf(
|
||||||
String name,
|
String name,
|
||||||
String colorName, {
|
String colorName, {
|
||||||
bool fromJellyfin = false,
|
bool fromJellyfin = false,
|
||||||
}) async {
|
JellyfinTagId? jellyfinId,
|
||||||
if ((await _read()).named(name) case final existing?) {
|
}) async =>
|
||||||
return existing.id;
|
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(
|
final named = tagging.named(name);
|
||||||
encodeTagPayload(
|
if (named != null &&
|
||||||
(name: name, colorName: colorName),
|
jellyfinId != null &&
|
||||||
fromJellyfin: fromJellyfin,
|
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.
|
/// Blanks the tag [id], then deletes it from every card and itself.
|
||||||
|
|
@ -474,6 +527,7 @@ class TagRepository(
|
||||||
name: name,
|
name: name,
|
||||||
colorName: colorName,
|
colorName: colorName,
|
||||||
fromJellyfin: tagPayloadFromJellyfin(row.payload),
|
fromJellyfin: tagPayloadFromJellyfin(row.payload),
|
||||||
|
jellyfinId: tagPayloadJellyfinId(row.payload),
|
||||||
);
|
);
|
||||||
} on FormatException catch (error) {
|
} on FormatException catch (error) {
|
||||||
log('Skipped tag ${row.id}: ${error.message}', name: 'Tags');
|
log('Skipped tag ${row.id}: ${error.message}', name: 'Tags');
|
||||||
|
|
|
||||||
|
|
@ -25,12 +25,40 @@ final class const Tag({
|
||||||
/// (`hideJellyfinTags`), as they would crowd a common word's card. They
|
/// (`hideJellyfinTags`), as they would crowd a common word's card. They
|
||||||
/// still filter, narrow a study session and show where tags are chosen.
|
/// still filter, narrow a study session and show where tags are chosen.
|
||||||
final bool fromJellyfin = false,
|
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 {
|
}) with Equatable {
|
||||||
/// This tag as something else refers to it.
|
/// This tag as something else refers to it.
|
||||||
TagRef get ref => (id: id, name: name);
|
TagRef get ref => (id: id, name: name);
|
||||||
|
|
||||||
@override
|
@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
|
/// A tag as something else refers to it, settings say: by its `id`, and by
|
||||||
|
|
|
||||||
|
|
@ -513,9 +513,13 @@ void main() {
|
||||||
await adder.add('n3', [taberu]);
|
await adder.add('n3', [taberu]);
|
||||||
await adder.add('n5', [terebi]);
|
await adder.add('n5', [terebi]);
|
||||||
|
|
||||||
final tag = await adder.tagHeld('vocabulary', {
|
final tag = await adder.tagHeld(
|
||||||
taberu.ref,
|
'vocabulary',
|
||||||
}, 'あかね噺 S1 E1');
|
{
|
||||||
|
taberu.ref,
|
||||||
|
},
|
||||||
|
(name: 'あかね噺 S1 E1', id: null),
|
||||||
|
);
|
||||||
|
|
||||||
final tagging = await tags.read();
|
final tagging = await tags.read();
|
||||||
final [eat] = await cards.cardsOf('n3');
|
final [eat] = await cards.cardsOf('n3');
|
||||||
|
|
|
||||||
|
|
@ -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_repository.dart';
|
||||||
import 'package:halkyon_learn_flutter/src/feature/jellyfin/data/jellyfin_store.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/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/http.dart' as http;
|
||||||
import 'package:http/testing.dart';
|
import 'package:http/testing.dart';
|
||||||
|
|
||||||
|
|
@ -78,6 +79,10 @@ MockClient jellyfin12(List<http.Request> requests) => MockClient((
|
||||||
'Name': '幽霊船戦',
|
'Name': '幽霊船戦',
|
||||||
'Id': 'song-1',
|
'Id': 'song-1',
|
||||||
'Type': 'Audio',
|
'Type': 'Audio',
|
||||||
|
'AlbumArtists': [
|
||||||
|
{'Name': '宝鐘マリン', 'Id': 'artist-1'},
|
||||||
|
],
|
||||||
|
'ProviderIds': {'MusicBrainzTrack': 'mb-track-1'},
|
||||||
'AlbumArtist': '宝鐘マリン',
|
'AlbumArtist': '宝鐘マリン',
|
||||||
'Album': 'Ahoy!!',
|
'Album': 'Ahoy!!',
|
||||||
'HasLyrics': true,
|
'HasLyrics': true,
|
||||||
|
|
@ -98,6 +103,8 @@ MockClient jellyfin12(List<http.Request> requests) => MockClient((
|
||||||
'Id': 'ep-1',
|
'Id': 'ep-1',
|
||||||
'Type': 'Episode',
|
'Type': 'Episode',
|
||||||
'SeriesName': 'Sample Journey',
|
'SeriesName': 'Sample Journey',
|
||||||
|
'SeriesId': 'series-1',
|
||||||
|
'ProviderIds': {'Tmdb': '9001', 'Tvdb': ''},
|
||||||
'IndexNumber': 1,
|
'IndexNumber': 1,
|
||||||
'ParentIndexNumber': 1,
|
'ParentIndexNumber': 1,
|
||||||
'RunTimeTicks': 520230000,
|
'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'),
|
utf8.encode('WEBVTT\n\n00:00:01.000 --> 00:00:05.000\n今日は\n'),
|
||||||
200,
|
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),
|
('POST', '/Sessions/Playing/Stopped') => json(null, 204),
|
||||||
_ => json(null, 404),
|
_ => 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 '
|
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 '
|
'lyrics timed line by line, none for a song without; a song streams '
|
||||||
'as sound', () async {
|
'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", () {
|
test("a studyset's language finds Jellyfin's", () {
|
||||||
expect(jellyfinLanguagesOf('ja'), {'jpn'});
|
expect(jellyfinLanguagesOf('ja'), {'jpn'});
|
||||||
expect(jellyfinLanguagesOf('de-AT'), {'ger', 'deu'});
|
expect(jellyfinLanguagesOf('de-AT'), {'ger', 'deu'});
|
||||||
|
|
|
||||||
|
|
@ -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/data/studyset_repository.dart';
|
||||||
import 'package:halkyon_learn_flutter/src/feature/studyset/model/studyset_content.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/data/tag_repository.dart';
|
||||||
|
import 'package:halkyon_learn_flutter/src/feature/tag/model/tag.dart';
|
||||||
import 'package:material_ui/material_ui.dart';
|
import 'package:material_ui/material_ui.dart';
|
||||||
|
|
||||||
import '../../support/fake_card_gateway.dart';
|
import '../../support/fake_card_gateway.dart';
|
||||||
|
|
@ -251,6 +252,38 @@ void main() {
|
||||||
expect(find.text('New'), findsOneWidget);
|
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, "
|
testWidgets("the video's words are listed, the most frequent first, "
|
||||||
'particles left out; one opens its first line', (tester) async {
|
'particles left out; one opens its first line', (tester) async {
|
||||||
await watch(tester);
|
await watch(tester);
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
import 'package:flutter_test/flutter_test.dart';
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
import 'package:halkyon_learn_flutter/src/core/content/payload.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/data/tag_payload.dart';
|
||||||
|
import 'package:halkyon_learn_flutter/src/feature/tag/model/tag.dart';
|
||||||
|
|
||||||
void main() {
|
void main() {
|
||||||
group('The tag payload', () {
|
group('The tag payload', () {
|
||||||
|
|
@ -43,6 +44,42 @@ void main() {
|
||||||
expect(tagPayloadFromJellyfin(own), isFalse);
|
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', () {
|
test('reads a name as another device wrote it, tidied', () {
|
||||||
final payload = encodePayload(1, {
|
final payload = encodePayload(1, {
|
||||||
'name': ' Verb form',
|
'name': ' Verb form',
|
||||||
|
|
|
||||||
|
|
@ -145,6 +145,96 @@ void main() {
|
||||||
expect((await repository.read()).named('Verb'), isNull);
|
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 {
|
test('lists the tags by name, whatever their case', () async {
|
||||||
await repository.create('verb');
|
await repository.create('verb');
|
||||||
await repository.create('adjective');
|
await repository.create('adjective');
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,7 @@ const sampleShow = JellyfinItem(
|
||||||
name: 'Sample Journey',
|
name: 'Sample Journey',
|
||||||
kind: JellyfinItemKind.series,
|
kind: JellyfinItemKind.series,
|
||||||
year: 2026,
|
year: 2026,
|
||||||
|
providerIds: {'tmdb': '424242'},
|
||||||
);
|
);
|
||||||
|
|
||||||
/// Its first episode.
|
/// Its first episode.
|
||||||
|
|
@ -19,6 +20,7 @@ const sampleEpisode = JellyfinItem(
|
||||||
name: 'The Station',
|
name: 'The Station',
|
||||||
kind: JellyfinItemKind.episode,
|
kind: JellyfinItemKind.episode,
|
||||||
seriesName: 'Sample Journey',
|
seriesName: 'Sample Journey',
|
||||||
|
seriesId: 'show-1',
|
||||||
seasonNumber: 1,
|
seasonNumber: 1,
|
||||||
episodeNumber: 1,
|
episodeNumber: 1,
|
||||||
runTime: Duration(minutes: 24),
|
runTime: Duration(minutes: 24),
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue