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
6 changed files with 619 additions and 77 deletions
|
|
@ -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');
|
||||
|
|
|
|||
|
|
@ -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'});
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
Loading…
Reference in a new issue