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

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'});