feat(train): signing in to Jellyfin and marking playlists (ADR 0030, D47)
Music's decisions as D47, and its first part: the music screen from the account sheet, on Android, signs in to the person's Jellyfin with Quick Connect, else a password, and lists their playlists of music to mark for running. The token is in the keyring, the server and the marks in the person's music.json, nothing on Train's server. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
This commit is contained in:
parent
5ff9b2e053
commit
3336fe4eca
19 changed files with 2364 additions and 3 deletions
File diff suppressed because one or more lines are too long
|
|
@ -468,5 +468,31 @@
|
||||||
"recordingMapHint": "Wischen für die Karte",
|
"recordingMapHint": "Wischen für die Karte",
|
||||||
"recordingFiguresHint": "Zurückwischen für die Werte",
|
"recordingFiguresHint": "Zurückwischen für die Werte",
|
||||||
"recordingNorthUp": "Norden oben",
|
"recordingNorthUp": "Norden oben",
|
||||||
"recordingHeadingUp": "Bewegungsrichtung oben"
|
"recordingHeadingUp": "Bewegungsrichtung oben",
|
||||||
|
"musicTitle": "Musik",
|
||||||
|
"musicHint": "Jellyfin-Playlists zum Laufen",
|
||||||
|
"musicIntro": "Train spielt Musik von deinem Jellyfin-Server und wählt Titel, deren Takt zu deinen Schritten passt. Dein Konto und deine Musik bleiben auf diesem Telefon.",
|
||||||
|
"musicServer": "Jellyfin-Server",
|
||||||
|
"musicConnect": "Verbinden",
|
||||||
|
"musicBadAddress": "Gib die Adresse des Servers ein, etwa jellyfin.example.org.",
|
||||||
|
"musicUnreachable": "Der Server ist nicht erreichbar. Prüfe die Adresse und die Verbindung.",
|
||||||
|
"musicNotJellyfin": "Unter dieser Adresse ist kein Jellyfin-Server.",
|
||||||
|
"musicRefused": "Jellyfin hat abgelehnt. Prüfe Benutzername und Passwort.",
|
||||||
|
"musicCodeExpired": "Der Code ist abgelaufen.",
|
||||||
|
"musicFailed": "Jellyfin hat mit einem Fehler geantwortet. Versuch es später noch einmal.",
|
||||||
|
"musicQuickConnect": "Um dich bei {server} anzumelden, gib diesen Code in Jellyfin auf einem anderen Gerät ein, unter Einstellungen, Quick Connect.",
|
||||||
|
"musicNewCode": "Neuer Code",
|
||||||
|
"musicBack": "Zurück",
|
||||||
|
"musicUsePassword": "Mit Passwort",
|
||||||
|
"musicPasswordIntro": "Melde dich mit deinem Jellyfin-Benutzernamen und Passwort bei {server} an.",
|
||||||
|
"musicUserName": "Benutzername",
|
||||||
|
"musicPassword": "Passwort",
|
||||||
|
"musicSignIn": "Anmelden",
|
||||||
|
"musicSignedInAs": "Angemeldet als {name}",
|
||||||
|
"musicSignOut": "Abmelden",
|
||||||
|
"musicPlaylists": "Playlists zum Laufen",
|
||||||
|
"musicPlaylistsHint": "Train wählt aus den Titeln der Playlists, die du markierst, und behält sie auf diesem Telefon.",
|
||||||
|
"musicNoPlaylists": "Auf diesem Server gibt es noch keine Musik-Playlists.",
|
||||||
|
"musicRetry": "Erneut versuchen",
|
||||||
|
"musicTracks": "{count, plural, =1{1 Titel} other{{count} Titel}}"
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -588,5 +588,38 @@
|
||||||
"recordingMapHint": "Swipe for the map",
|
"recordingMapHint": "Swipe for the map",
|
||||||
"recordingFiguresHint": "Swipe back for the figures",
|
"recordingFiguresHint": "Swipe back for the figures",
|
||||||
"recordingNorthUp": "North up",
|
"recordingNorthUp": "North up",
|
||||||
"recordingHeadingUp": "Heading up"
|
"recordingHeadingUp": "Heading up",
|
||||||
|
"musicTitle": "Music",
|
||||||
|
"@musicTitle": {"description": "The music screen's title, and its tile in the account sheet."},
|
||||||
|
"musicHint": "Jellyfin playlists to run to",
|
||||||
|
"@musicHint": {"description": "Under Music in the account sheet."},
|
||||||
|
"musicIntro": "Train plays music from your Jellyfin server, choosing tracks whose beat fits your steps. Your account and music stay on this phone.",
|
||||||
|
"musicServer": "Jellyfin server",
|
||||||
|
"@musicServer": {"description": "The label of the server's address."},
|
||||||
|
"musicConnect": "Connect",
|
||||||
|
"musicBadAddress": "Enter the server's address, such as jellyfin.example.org.",
|
||||||
|
"musicUnreachable": "The server can't be reached. Check the address and the connection.",
|
||||||
|
"musicNotJellyfin": "There is no Jellyfin server at this address.",
|
||||||
|
"musicRefused": "Jellyfin refused. Check the user name and password.",
|
||||||
|
"musicCodeExpired": "The code has expired.",
|
||||||
|
"musicFailed": "Jellyfin answered with an error. Try again later.",
|
||||||
|
"musicQuickConnect": "To sign in to {server}, enter this code in Jellyfin on another device, under Settings, Quick Connect.",
|
||||||
|
"@musicQuickConnect": {"placeholders": {"server": {"type": "String"}}},
|
||||||
|
"musicNewCode": "New code",
|
||||||
|
"musicBack": "Back",
|
||||||
|
"musicUsePassword": "Use a password",
|
||||||
|
"musicPasswordIntro": "Sign in to {server} with your Jellyfin user name and password.",
|
||||||
|
"@musicPasswordIntro": {"placeholders": {"server": {"type": "String"}}},
|
||||||
|
"musicUserName": "User name",
|
||||||
|
"musicPassword": "Password",
|
||||||
|
"musicSignIn": "Sign in",
|
||||||
|
"musicSignedInAs": "Signed in as {name}",
|
||||||
|
"@musicSignedInAs": {"placeholders": {"name": {"type": "String"}}},
|
||||||
|
"musicSignOut": "Sign out",
|
||||||
|
"musicPlaylists": "Playlists for running",
|
||||||
|
"musicPlaylistsHint": "Train chooses from the tracks of the playlists you mark, and keeps them on this phone.",
|
||||||
|
"musicNoPlaylists": "There are no music playlists on this server yet.",
|
||||||
|
"musicRetry": "Try again",
|
||||||
|
"musicTracks": "{count, plural, =1{1 track} other{{count} tracks}}",
|
||||||
|
"@musicTracks": {"placeholders": {"count": {"type": "int"}}}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -2835,6 +2835,162 @@ abstract class AppLocalizations {
|
||||||
/// In en, this message translates to:
|
/// In en, this message translates to:
|
||||||
/// **'Heading up'**
|
/// **'Heading up'**
|
||||||
String get recordingHeadingUp;
|
String get recordingHeadingUp;
|
||||||
|
|
||||||
|
/// The music screen's title, and its tile in the account sheet.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Music'**
|
||||||
|
String get musicTitle;
|
||||||
|
|
||||||
|
/// Under Music in the account sheet.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Jellyfin playlists to run to'**
|
||||||
|
String get musicHint;
|
||||||
|
|
||||||
|
/// No description provided for @musicIntro.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Train plays music from your Jellyfin server, choosing tracks whose beat fits your steps. Your account and music stay on this phone.'**
|
||||||
|
String get musicIntro;
|
||||||
|
|
||||||
|
/// The label of the server's address.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Jellyfin server'**
|
||||||
|
String get musicServer;
|
||||||
|
|
||||||
|
/// No description provided for @musicConnect.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Connect'**
|
||||||
|
String get musicConnect;
|
||||||
|
|
||||||
|
/// No description provided for @musicBadAddress.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Enter the server\'s address, such as jellyfin.example.org.'**
|
||||||
|
String get musicBadAddress;
|
||||||
|
|
||||||
|
/// No description provided for @musicUnreachable.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'The server can\'t be reached. Check the address and the connection.'**
|
||||||
|
String get musicUnreachable;
|
||||||
|
|
||||||
|
/// No description provided for @musicNotJellyfin.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'There is no Jellyfin server at this address.'**
|
||||||
|
String get musicNotJellyfin;
|
||||||
|
|
||||||
|
/// No description provided for @musicRefused.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Jellyfin refused. Check the user name and password.'**
|
||||||
|
String get musicRefused;
|
||||||
|
|
||||||
|
/// No description provided for @musicCodeExpired.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'The code has expired.'**
|
||||||
|
String get musicCodeExpired;
|
||||||
|
|
||||||
|
/// No description provided for @musicFailed.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Jellyfin answered with an error. Try again later.'**
|
||||||
|
String get musicFailed;
|
||||||
|
|
||||||
|
/// No description provided for @musicQuickConnect.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'To sign in to {server}, enter this code in Jellyfin on another device, under Settings, Quick Connect.'**
|
||||||
|
String musicQuickConnect(String server);
|
||||||
|
|
||||||
|
/// No description provided for @musicNewCode.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'New code'**
|
||||||
|
String get musicNewCode;
|
||||||
|
|
||||||
|
/// No description provided for @musicBack.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Back'**
|
||||||
|
String get musicBack;
|
||||||
|
|
||||||
|
/// No description provided for @musicUsePassword.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Use a password'**
|
||||||
|
String get musicUsePassword;
|
||||||
|
|
||||||
|
/// No description provided for @musicPasswordIntro.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Sign in to {server} with your Jellyfin user name and password.'**
|
||||||
|
String musicPasswordIntro(String server);
|
||||||
|
|
||||||
|
/// No description provided for @musicUserName.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'User name'**
|
||||||
|
String get musicUserName;
|
||||||
|
|
||||||
|
/// No description provided for @musicPassword.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Password'**
|
||||||
|
String get musicPassword;
|
||||||
|
|
||||||
|
/// No description provided for @musicSignIn.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Sign in'**
|
||||||
|
String get musicSignIn;
|
||||||
|
|
||||||
|
/// No description provided for @musicSignedInAs.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Signed in as {name}'**
|
||||||
|
String musicSignedInAs(String name);
|
||||||
|
|
||||||
|
/// No description provided for @musicSignOut.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Sign out'**
|
||||||
|
String get musicSignOut;
|
||||||
|
|
||||||
|
/// No description provided for @musicPlaylists.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Playlists for running'**
|
||||||
|
String get musicPlaylists;
|
||||||
|
|
||||||
|
/// No description provided for @musicPlaylistsHint.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Train chooses from the tracks of the playlists you mark, and keeps them on this phone.'**
|
||||||
|
String get musicPlaylistsHint;
|
||||||
|
|
||||||
|
/// No description provided for @musicNoPlaylists.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'There are no music playlists on this server yet.'**
|
||||||
|
String get musicNoPlaylists;
|
||||||
|
|
||||||
|
/// No description provided for @musicRetry.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Try again'**
|
||||||
|
String get musicRetry;
|
||||||
|
|
||||||
|
/// No description provided for @musicTracks.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'{count, plural, =1{1 track} other{{count} tracks}}'**
|
||||||
|
String musicTracks(int count);
|
||||||
}
|
}
|
||||||
|
|
||||||
class _AppLocalizationsDelegate
|
class _AppLocalizationsDelegate
|
||||||
|
|
|
||||||
|
|
@ -1681,4 +1681,104 @@ class AppLocalizationsDe extends AppLocalizations {
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get recordingHeadingUp => 'Bewegungsrichtung oben';
|
String get recordingHeadingUp => 'Bewegungsrichtung oben';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get musicTitle => 'Musik';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get musicHint => 'Jellyfin-Playlists zum Laufen';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get musicIntro =>
|
||||||
|
'Train spielt Musik von deinem Jellyfin-Server und wählt Titel, deren Takt zu deinen Schritten passt. Dein Konto und deine Musik bleiben auf diesem Telefon.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get musicServer => 'Jellyfin-Server';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get musicConnect => 'Verbinden';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get musicBadAddress =>
|
||||||
|
'Gib die Adresse des Servers ein, etwa jellyfin.example.org.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get musicUnreachable =>
|
||||||
|
'Der Server ist nicht erreichbar. Prüfe die Adresse und die Verbindung.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get musicNotJellyfin =>
|
||||||
|
'Unter dieser Adresse ist kein Jellyfin-Server.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get musicRefused =>
|
||||||
|
'Jellyfin hat abgelehnt. Prüfe Benutzername und Passwort.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get musicCodeExpired => 'Der Code ist abgelaufen.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get musicFailed =>
|
||||||
|
'Jellyfin hat mit einem Fehler geantwortet. Versuch es später noch einmal.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String musicQuickConnect(String server) {
|
||||||
|
return 'Um dich bei $server anzumelden, gib diesen Code in Jellyfin auf einem anderen Gerät ein, unter Einstellungen, Quick Connect.';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get musicNewCode => 'Neuer Code';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get musicBack => 'Zurück';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get musicUsePassword => 'Mit Passwort';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String musicPasswordIntro(String server) {
|
||||||
|
return 'Melde dich mit deinem Jellyfin-Benutzernamen und Passwort bei $server an.';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get musicUserName => 'Benutzername';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get musicPassword => 'Passwort';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get musicSignIn => 'Anmelden';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String musicSignedInAs(String name) {
|
||||||
|
return 'Angemeldet als $name';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get musicSignOut => 'Abmelden';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get musicPlaylists => 'Playlists zum Laufen';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get musicPlaylistsHint =>
|
||||||
|
'Train wählt aus den Titeln der Playlists, die du markierst, und behält sie auf diesem Telefon.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get musicNoPlaylists =>
|
||||||
|
'Auf diesem Server gibt es noch keine Musik-Playlists.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get musicRetry => 'Erneut versuchen';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String musicTracks(int count) {
|
||||||
|
String _temp0 = intl.Intl.pluralLogic(
|
||||||
|
count,
|
||||||
|
locale: localeName,
|
||||||
|
other: '$count Titel',
|
||||||
|
one: '1 Titel',
|
||||||
|
);
|
||||||
|
return '$_temp0';
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1671,6 +1671,104 @@ class AppLocalizationsEn extends AppLocalizations {
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get recordingHeadingUp => 'Heading up';
|
String get recordingHeadingUp => 'Heading up';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get musicTitle => 'Music';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get musicHint => 'Jellyfin playlists to run to';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get musicIntro =>
|
||||||
|
'Train plays music from your Jellyfin server, choosing tracks whose beat fits your steps. Your account and music stay on this phone.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get musicServer => 'Jellyfin server';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get musicConnect => 'Connect';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get musicBadAddress =>
|
||||||
|
'Enter the server\'s address, such as jellyfin.example.org.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get musicUnreachable =>
|
||||||
|
'The server can\'t be reached. Check the address and the connection.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get musicNotJellyfin => 'There is no Jellyfin server at this address.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get musicRefused =>
|
||||||
|
'Jellyfin refused. Check the user name and password.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get musicCodeExpired => 'The code has expired.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get musicFailed => 'Jellyfin answered with an error. Try again later.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String musicQuickConnect(String server) {
|
||||||
|
return 'To sign in to $server, enter this code in Jellyfin on another device, under Settings, Quick Connect.';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get musicNewCode => 'New code';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get musicBack => 'Back';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get musicUsePassword => 'Use a password';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String musicPasswordIntro(String server) {
|
||||||
|
return 'Sign in to $server with your Jellyfin user name and password.';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get musicUserName => 'User name';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get musicPassword => 'Password';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get musicSignIn => 'Sign in';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String musicSignedInAs(String name) {
|
||||||
|
return 'Signed in as $name';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get musicSignOut => 'Sign out';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get musicPlaylists => 'Playlists for running';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get musicPlaylistsHint =>
|
||||||
|
'Train chooses from the tracks of the playlists you mark, and keeps them on this phone.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get musicNoPlaylists =>
|
||||||
|
'There are no music playlists on this server yet.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get musicRetry => 'Try again';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String musicTracks(int count) {
|
||||||
|
String _temp0 = intl.Intl.pluralLogic(
|
||||||
|
count,
|
||||||
|
locale: localeName,
|
||||||
|
other: '$count tracks',
|
||||||
|
one: '1 track',
|
||||||
|
);
|
||||||
|
return '$_temp0';
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The translations for English, as used in the United Kingdom (`en_GB`).
|
/// The translations for English, as used in the United Kingdom (`en_GB`).
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@ import 'package:halkyon_train_flutter/src/app/train_dependencies.dart';
|
||||||
import 'package:halkyon_train_flutter/src/app/train_frame.dart';
|
import 'package:halkyon_train_flutter/src/app/train_frame.dart';
|
||||||
import 'package:halkyon_train_flutter/src/feature/health/widget/health_screen.dart';
|
import 'package:halkyon_train_flutter/src/feature/health/widget/health_screen.dart';
|
||||||
import 'package:halkyon_train_flutter/src/feature/loop/widget/loop_screen.dart';
|
import 'package:halkyon_train_flutter/src/feature/loop/widget/loop_screen.dart';
|
||||||
|
import 'package:halkyon_train_flutter/src/feature/music/widget/music_screen.dart';
|
||||||
import 'package:halkyon_train_flutter/src/feature/profile/widget/profile_screen.dart';
|
import 'package:halkyon_train_flutter/src/feature/profile/widget/profile_screen.dart';
|
||||||
import 'package:halkyon_ui/halkyon_ui.dart';
|
import 'package:halkyon_ui/halkyon_ui.dart';
|
||||||
import 'package:lucide_icons_flutter/lucide_icons.dart';
|
import 'package:lucide_icons_flutter/lucide_icons.dart';
|
||||||
|
|
@ -70,6 +71,14 @@ class const _AccountSheet() extends StatelessWidget {
|
||||||
subtitle: Text(l10n.loopHint),
|
subtitle: Text(l10n.loopHint),
|
||||||
onTap: () => openLoopScreen(context),
|
onTap: () => openLoopScreen(context),
|
||||||
),
|
),
|
||||||
|
// Music from Jellyfin on this phone (D47).
|
||||||
|
if (PersonScope.of<TrainPerson>(context).music != null)
|
||||||
|
ListTile(
|
||||||
|
leading: const Icon(LucideIcons.music),
|
||||||
|
title: Text(l10n.musicTitle),
|
||||||
|
subtitle: Text(l10n.musicHint),
|
||||||
|
onTap: () => openMusicScreen(context),
|
||||||
|
),
|
||||||
ListTile(
|
ListTile(
|
||||||
leading: const Icon(LucideIcons.info),
|
leading: const Icon(LucideIcons.info),
|
||||||
title: Text(l10n.aboutTitle),
|
title: Text(l10n.aboutTitle),
|
||||||
|
|
|
||||||
|
|
@ -20,6 +20,8 @@ import 'package:halkyon_train_flutter/src/feature/loop/data/polar_loop.dart';
|
||||||
import 'package:halkyon_train_flutter/src/feature/map/data/map_source_repository.dart';
|
import 'package:halkyon_train_flutter/src/feature/map/data/map_source_repository.dart';
|
||||||
import 'package:halkyon_train_flutter/src/feature/map/data/offline_maps.dart';
|
import 'package:halkyon_train_flutter/src/feature/map/data/offline_maps.dart';
|
||||||
import 'package:halkyon_train_flutter/src/feature/map/widget/map_tiles.dart';
|
import 'package:halkyon_train_flutter/src/feature/map/widget/map_tiles.dart';
|
||||||
|
import 'package:halkyon_train_flutter/src/feature/music/data/music_library.dart';
|
||||||
|
import 'package:halkyon_train_flutter/src/feature/music/data/music_store.dart';
|
||||||
import 'package:halkyon_train_flutter/src/feature/plan/data/plan_repository.dart';
|
import 'package:halkyon_train_flutter/src/feature/plan/data/plan_repository.dart';
|
||||||
import 'package:halkyon_train_flutter/src/feature/plan/data/sync_plan_gateway.dart';
|
import 'package:halkyon_train_flutter/src/feature/plan/data/sync_plan_gateway.dart';
|
||||||
import 'package:halkyon_train_flutter/src/feature/profile/data/profile_repository.dart';
|
import 'package:halkyon_train_flutter/src/feature/profile/data/profile_repository.dart';
|
||||||
|
|
@ -104,6 +106,10 @@ class TrainPerson(
|
||||||
/// Their Polar Loop on this phone (D34); null where Train can't hold one
|
/// Their Polar Loop on this phone (D34); null where Train can't hold one
|
||||||
/// (iOS for now, the desktop).
|
/// (iOS for now, the desktop).
|
||||||
final PolarLoop? loop,
|
final PolarLoop? loop,
|
||||||
|
|
||||||
|
/// Their music from Jellyfin on this phone (D47); null where none plays
|
||||||
|
/// (iOS for now, the desktop).
|
||||||
|
final MusicLibrary? music,
|
||||||
final Future<void> Function()? _onDispose,
|
final Future<void> Function()? _onDispose,
|
||||||
}) implements Disposable {
|
}) implements Disposable {
|
||||||
/// Workouts deleted, to be undone for a moment (D24); dropped with the
|
/// Workouts deleted, to be undone for a moment (D24); dropped with the
|
||||||
|
|
@ -127,6 +133,7 @@ class TrainPerson(
|
||||||
await routeDeletions.dispose();
|
await routeDeletions.dispose();
|
||||||
await offlineMaps?.dispose();
|
await offlineMaps?.dispose();
|
||||||
await health?.dispose();
|
await health?.dispose();
|
||||||
|
music?.dispose();
|
||||||
await loop?.dispose();
|
await loop?.dispose();
|
||||||
await _onDispose?.call();
|
await _onDispose?.call();
|
||||||
}
|
}
|
||||||
|
|
@ -289,6 +296,7 @@ class TrainDependencies({
|
||||||
person,
|
person,
|
||||||
// One person's folder on the device at a time, as Learn keeps it.
|
// One person's folder on the device at a time, as Learn keeps it.
|
||||||
await folders.openOnly(person.authUserId),
|
await folders.openOnly(person.authUserId),
|
||||||
|
appVersion: device.appVersion,
|
||||||
accountKeys: () => accountKeys.read(person.authUserId),
|
accountKeys: () => accountKeys.read(person.authUserId),
|
||||||
onClosed: closed.complete,
|
onClosed: closed.complete,
|
||||||
);
|
);
|
||||||
|
|
@ -325,6 +333,7 @@ Future<TrainPerson> _openPersonData(
|
||||||
AppCompatibility compatibility,
|
AppCompatibility compatibility,
|
||||||
SignedIn person,
|
SignedIn person,
|
||||||
Directory folder, {
|
Directory folder, {
|
||||||
|
required String appVersion,
|
||||||
required Future<UnlockedAccountKeys?> Function() accountKeys,
|
required Future<UnlockedAccountKeys?> Function() accountKeys,
|
||||||
required void Function() onClosed,
|
required void Function() onClosed,
|
||||||
}) async {
|
}) async {
|
||||||
|
|
@ -421,6 +430,14 @@ Future<TrainPerson> _openPersonData(
|
||||||
: null,
|
: null,
|
||||||
health: health,
|
health: health,
|
||||||
loop: loop,
|
loop: loop,
|
||||||
|
// Jellyfin's account and the marked playlists, on this phone alone.
|
||||||
|
music: Platform.isAndroid
|
||||||
|
? MusicLibrary(
|
||||||
|
MusicStore(File(p.join(folder.path, 'music.json'))),
|
||||||
|
SecureJellyfinTokenStore(person.authUserId),
|
||||||
|
version: appVersion,
|
||||||
|
)
|
||||||
|
: null,
|
||||||
onDispose: () async {
|
onDispose: () async {
|
||||||
try {
|
try {
|
||||||
await workouts.dispose();
|
await workouts.dispose();
|
||||||
|
|
@ -451,6 +468,10 @@ class TrainDevice({
|
||||||
/// This build's number (after the `+` in the pubspec's version), which the
|
/// This build's number (after the `+` in the pubspec's version), which the
|
||||||
/// server knows apps by (`AppCompatibility`); null where it has none.
|
/// server knows apps by (`AppCompatibility`); null where it has none.
|
||||||
required final int? appBuild,
|
required final int? appBuild,
|
||||||
|
|
||||||
|
/// This build's version (before the `+`), which Jellyfin lists Train's
|
||||||
|
/// sign-ins by (D47).
|
||||||
|
final String appVersion = '',
|
||||||
}) {
|
}) {
|
||||||
/// Makes them, [builtIn] being the instance the build names.
|
/// Makes them, [builtIn] being the instance the build names.
|
||||||
static Future<TrainDevice> create({required Instance builtIn}) async {
|
static Future<TrainDevice> create({required Instance builtIn}) async {
|
||||||
|
|
@ -460,6 +481,7 @@ class TrainDevice({
|
||||||
support: support,
|
support: support,
|
||||||
storage: InstanceStorage(support, builtIn: builtIn),
|
storage: InstanceStorage(support, builtIn: builtIn),
|
||||||
appBuild: int.tryParse(package.buildNumber),
|
appBuild: int.tryParse(package.buildNumber),
|
||||||
|
appVersion: package.version,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,338 @@
|
||||||
|
import 'dart:async';
|
||||||
|
|
||||||
|
import 'package:clock/clock.dart';
|
||||||
|
import 'package:equatable/equatable.dart';
|
||||||
|
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||||
|
import 'package:halkyon_train_flutter/src/feature/music/data/jellyfin_client.dart';
|
||||||
|
import 'package:halkyon_train_flutter/src/feature/music/data/music_library.dart';
|
||||||
|
import 'package:halkyon_train_flutter/src/feature/music/data/music_store.dart';
|
||||||
|
|
||||||
|
/// Where signing in to Jellyfin stands (ADR 0030, D47).
|
||||||
|
enum MusicStep() {
|
||||||
|
/// Reading what the phone keeps.
|
||||||
|
opening,
|
||||||
|
|
||||||
|
/// Asking for the server's address.
|
||||||
|
address,
|
||||||
|
|
||||||
|
/// Asking the server what it is.
|
||||||
|
finding,
|
||||||
|
|
||||||
|
/// Showing a Quick Connect code until it is approved.
|
||||||
|
quickConnect,
|
||||||
|
|
||||||
|
/// Asking for the user name and password.
|
||||||
|
password,
|
||||||
|
|
||||||
|
/// Signing in.
|
||||||
|
signingIn,
|
||||||
|
|
||||||
|
/// Signed in: the playlists.
|
||||||
|
signedIn,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Why something on the music screen failed: Jellyfin's failures, and
|
||||||
|
/// Train's own.
|
||||||
|
enum MusicFailure() {
|
||||||
|
badAddress,
|
||||||
|
unreachable,
|
||||||
|
notJellyfin,
|
||||||
|
refused,
|
||||||
|
expired,
|
||||||
|
failed;
|
||||||
|
|
||||||
|
static MusicFailure of(JellyfinException error) => switch (error.failure) {
|
||||||
|
JellyfinFailure.unreachable => unreachable,
|
||||||
|
JellyfinFailure.notJellyfin => notJellyfin,
|
||||||
|
JellyfinFailure.refused => refused,
|
||||||
|
JellyfinFailure.quickConnectOff || JellyfinFailure.failed => failed,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/// What the music screen shows.
|
||||||
|
final class const MusicState({
|
||||||
|
required final MusicSettings settings,
|
||||||
|
final MusicStep step = MusicStep.opening,
|
||||||
|
|
||||||
|
/// The server being signed in to.
|
||||||
|
final JellyfinServer? server,
|
||||||
|
|
||||||
|
/// The Quick Connect code to approve.
|
||||||
|
final String? code,
|
||||||
|
|
||||||
|
/// What just failed.
|
||||||
|
final MusicFailure? failure,
|
||||||
|
|
||||||
|
/// The person's playlists of music; null until read.
|
||||||
|
final List<JellyfinPlaylist>? playlists,
|
||||||
|
}) with Equatable {
|
||||||
|
MusicState copyWith({
|
||||||
|
MusicSettings? settings,
|
||||||
|
MusicStep? step,
|
||||||
|
JellyfinServer? Function()? server,
|
||||||
|
String? Function()? code,
|
||||||
|
MusicFailure? Function()? failure,
|
||||||
|
List<JellyfinPlaylist>? Function()? playlists,
|
||||||
|
}) => MusicState(
|
||||||
|
settings: settings ?? this.settings,
|
||||||
|
step: step ?? this.step,
|
||||||
|
server: server == null ? this.server : server(),
|
||||||
|
code: code == null ? this.code : code(),
|
||||||
|
failure: failure == null ? this.failure : failure(),
|
||||||
|
playlists: playlists == null ? this.playlists : playlists(),
|
||||||
|
);
|
||||||
|
|
||||||
|
@override
|
||||||
|
List<Object?> get props => [settings, step, server, code, failure, playlists];
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The music screen: signing in to Jellyfin and marking playlists.
|
||||||
|
class MusicCubit(
|
||||||
|
final MusicLibrary _library, {
|
||||||
|
|
||||||
|
/// How often a Quick Connect code is asked about.
|
||||||
|
final Duration poll = const Duration(seconds: 3),
|
||||||
|
|
||||||
|
/// How long a code is waited for: Jellyfin forgets requests after ten
|
||||||
|
/// minutes.
|
||||||
|
final Duration expiry = const Duration(minutes: 9),
|
||||||
|
}) extends Cubit<MusicState> {
|
||||||
|
this : super(MusicState(settings: _library.settings.value));
|
||||||
|
|
||||||
|
Timer? _polling;
|
||||||
|
String? _secret;
|
||||||
|
DateTime? _since;
|
||||||
|
bool _asking = false;
|
||||||
|
|
||||||
|
Future<void> open() async {
|
||||||
|
_library.settings.addListener(_settingsChanged);
|
||||||
|
await _library.open();
|
||||||
|
if (isClosed) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (_library.settings.value.signedIn) {
|
||||||
|
emit(state.copyWith(step: MusicStep.signedIn));
|
||||||
|
await loadPlaylists();
|
||||||
|
} else {
|
||||||
|
emit(state.copyWith(step: MusicStep.address));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Finds the server at [typed], then signs in with Quick Connect where
|
||||||
|
/// it has it, else asks for the password.
|
||||||
|
Future<void> find(String typed) async {
|
||||||
|
final address = jellyfinAddress(typed);
|
||||||
|
if (address == null) {
|
||||||
|
emit(state.copyWith(failure: () => MusicFailure.badAddress));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
emit(state.copyWith(step: MusicStep.finding, failure: () => null));
|
||||||
|
try {
|
||||||
|
final server = await _library.find(address);
|
||||||
|
if (isClosed) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
emit(state.copyWith(server: () => server));
|
||||||
|
if (server.quickConnect) {
|
||||||
|
await newCode();
|
||||||
|
} else {
|
||||||
|
emit(state.copyWith(step: MusicStep.password));
|
||||||
|
}
|
||||||
|
} on JellyfinException catch (error) {
|
||||||
|
if (!isClosed) {
|
||||||
|
emit(
|
||||||
|
state.copyWith(
|
||||||
|
step: MusicStep.address,
|
||||||
|
failure: () => MusicFailure.of(error),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Asks the server for a Quick Connect code and waits for its approval;
|
||||||
|
/// a server that refuses asks for the password.
|
||||||
|
Future<void> newCode() async {
|
||||||
|
final server = state.server!;
|
||||||
|
_stopPolling();
|
||||||
|
emit(
|
||||||
|
state.copyWith(
|
||||||
|
step: MusicStep.finding,
|
||||||
|
failure: () => null,
|
||||||
|
code: () => null,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
try {
|
||||||
|
final code = await _library.jellyfin.initiateQuickConnect(
|
||||||
|
server.address,
|
||||||
|
);
|
||||||
|
if (isClosed) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
_secret = code.secret;
|
||||||
|
_since = clock.now();
|
||||||
|
emit(state.copyWith(step: MusicStep.quickConnect, code: () => code.code));
|
||||||
|
_polling = Timer.periodic(poll, (_) => unawaited(_ask()));
|
||||||
|
} on JellyfinException catch (error) {
|
||||||
|
if (!isClosed) {
|
||||||
|
emit(
|
||||||
|
error.failure == JellyfinFailure.quickConnectOff
|
||||||
|
? state.copyWith(step: MusicStep.password)
|
||||||
|
: state.copyWith(
|
||||||
|
step: MusicStep.address,
|
||||||
|
failure: () => MusicFailure.of(error),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Signs in with a password instead of the code.
|
||||||
|
void usePassword() {
|
||||||
|
_stopPolling();
|
||||||
|
emit(state.copyWith(step: MusicStep.password, failure: () => null));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Back to the address.
|
||||||
|
void back() {
|
||||||
|
_stopPolling();
|
||||||
|
emit(
|
||||||
|
state.copyWith(
|
||||||
|
step: MusicStep.address,
|
||||||
|
server: () => null,
|
||||||
|
code: () => null,
|
||||||
|
failure: () => null,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> signIn(String name, String password) async {
|
||||||
|
emit(state.copyWith(step: MusicStep.signingIn, failure: () => null));
|
||||||
|
try {
|
||||||
|
await _library.signInWithPassword(state.server!, name, password);
|
||||||
|
await _signedIn();
|
||||||
|
} on JellyfinException catch (error) {
|
||||||
|
if (!isClosed) {
|
||||||
|
emit(
|
||||||
|
state.copyWith(
|
||||||
|
step: MusicStep.password,
|
||||||
|
failure: () => MusicFailure.of(error),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Reads the person's playlists again.
|
||||||
|
Future<void> loadPlaylists() async {
|
||||||
|
emit(state.copyWith(failure: () => null));
|
||||||
|
try {
|
||||||
|
final playlists = await _library.playlists();
|
||||||
|
if (!isClosed) {
|
||||||
|
emit(state.copyWith(playlists: () => playlists));
|
||||||
|
}
|
||||||
|
} on JellyfinException catch (error) {
|
||||||
|
if (isClosed) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
emit(
|
||||||
|
_library.settings.value.signedIn
|
||||||
|
? state.copyWith(failure: () => MusicFailure.of(error))
|
||||||
|
: MusicState(
|
||||||
|
settings: _library.settings.value,
|
||||||
|
step: MusicStep.address,
|
||||||
|
failure: MusicFailure.of(error),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void mark(JellyfinPlaylist playlist, {required bool on}) =>
|
||||||
|
_library.mark(playlist, on: on);
|
||||||
|
|
||||||
|
Future<void> signOut() async {
|
||||||
|
await _library.signOut();
|
||||||
|
if (!isClosed) {
|
||||||
|
emit(
|
||||||
|
MusicState(settings: _library.settings.value, step: MusicStep.address),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _ask() async {
|
||||||
|
final server = state.server;
|
||||||
|
final secret = _secret;
|
||||||
|
if (server == null || secret == null || _asking) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (clock.now().difference(_since!) > expiry) {
|
||||||
|
_stopPolling();
|
||||||
|
emit(
|
||||||
|
state.copyWith(code: () => null, failure: () => MusicFailure.expired),
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
_asking = true;
|
||||||
|
final approved = await _library.jellyfin.quickConnectApproved(
|
||||||
|
server.address,
|
||||||
|
secret,
|
||||||
|
);
|
||||||
|
if (!approved || _secret != secret || isClosed) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
_stopPolling();
|
||||||
|
emit(state.copyWith(step: MusicStep.signingIn));
|
||||||
|
await _library.signInWithQuickConnect(server, secret);
|
||||||
|
await _signedIn();
|
||||||
|
} on JellyfinException catch (error) {
|
||||||
|
// A request the server forgot can't be approved any more; a failure to
|
||||||
|
// reach it is asked again at the next tick.
|
||||||
|
if (!isClosed && error.failure != JellyfinFailure.unreachable) {
|
||||||
|
_stopPolling();
|
||||||
|
emit(
|
||||||
|
state.copyWith(
|
||||||
|
step: MusicStep.quickConnect,
|
||||||
|
code: () => null,
|
||||||
|
failure: () => MusicFailure.of(error),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
_asking = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _signedIn() async {
|
||||||
|
if (isClosed) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
emit(
|
||||||
|
state.copyWith(
|
||||||
|
step: MusicStep.signedIn,
|
||||||
|
code: () => null,
|
||||||
|
playlists: () => null,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
await loadPlaylists();
|
||||||
|
}
|
||||||
|
|
||||||
|
void _settingsChanged() {
|
||||||
|
if (!isClosed) {
|
||||||
|
emit(state.copyWith(settings: _library.settings.value));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _stopPolling() {
|
||||||
|
_polling?.cancel();
|
||||||
|
_polling = null;
|
||||||
|
_secret = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> close() {
|
||||||
|
_stopPolling();
|
||||||
|
_library.settings.removeListener(_settingsChanged);
|
||||||
|
return super.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,303 @@
|
||||||
|
import 'dart:async';
|
||||||
|
import 'dart:convert';
|
||||||
|
import 'dart:io';
|
||||||
|
|
||||||
|
import 'package:equatable/equatable.dart';
|
||||||
|
import 'package:http/http.dart' as http;
|
||||||
|
|
||||||
|
/// Why Jellyfin couldn't be asked (ADR 0030, D47).
|
||||||
|
enum JellyfinFailure() {
|
||||||
|
/// Nothing answered at the address.
|
||||||
|
unreachable,
|
||||||
|
|
||||||
|
/// Something answered, but not Jellyfin.
|
||||||
|
notJellyfin,
|
||||||
|
|
||||||
|
/// Jellyfin refused: a wrong password, a token taken back.
|
||||||
|
refused,
|
||||||
|
|
||||||
|
/// The server has Quick Connect switched off.
|
||||||
|
quickConnectOff,
|
||||||
|
|
||||||
|
/// Jellyfin answered with an error of its own.
|
||||||
|
failed,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Jellyfin couldn't be asked, for [failure].
|
||||||
|
final class const JellyfinException(final JellyfinFailure failure)
|
||||||
|
implements Exception {
|
||||||
|
@override
|
||||||
|
String toString() => 'JellyfinException(${failure.name})';
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A Jellyfin server as it describes itself.
|
||||||
|
final class const JellyfinServer({
|
||||||
|
required final Uri address,
|
||||||
|
required final String name,
|
||||||
|
required final String id,
|
||||||
|
|
||||||
|
/// Whether it signs in with Quick Connect.
|
||||||
|
final bool quickConnect = false,
|
||||||
|
}) with Equatable {
|
||||||
|
@override
|
||||||
|
List<Object?> get props => [address, name, id, quickConnect];
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A Quick Connect request: the code the person approves, the secret
|
||||||
|
/// Train keeps.
|
||||||
|
typedef QuickConnectCode = ({String code, String secret});
|
||||||
|
|
||||||
|
/// What signing in gives: Train's own token, and whose it is.
|
||||||
|
typedef JellyfinSignIn = ({String token, String userId, String userName});
|
||||||
|
|
||||||
|
/// What asking as the person takes.
|
||||||
|
typedef JellyfinSession = ({String token, String userId});
|
||||||
|
|
||||||
|
/// A playlist of the person's on Jellyfin.
|
||||||
|
final class const JellyfinPlaylist({
|
||||||
|
required final String id,
|
||||||
|
required final String name,
|
||||||
|
|
||||||
|
/// How many items Jellyfin counts in it.
|
||||||
|
final int tracks = 0,
|
||||||
|
}) with Equatable {
|
||||||
|
@override
|
||||||
|
List<Object?> get props => [id, name, tracks];
|
||||||
|
}
|
||||||
|
|
||||||
|
/// [typed] as a Jellyfin server's address: https where it names no scheme,
|
||||||
|
/// its path kept (a server under `/jellyfin`), no trailing slash; null where
|
||||||
|
/// it names no host or another scheme than http and https.
|
||||||
|
Uri? jellyfinAddress(String typed) {
|
||||||
|
final trimmed = typed.trim();
|
||||||
|
final withScheme = trimmed.contains('://') ? trimmed : 'https://$trimmed';
|
||||||
|
final uri = Uri.tryParse(withScheme);
|
||||||
|
if (uri == null ||
|
||||||
|
uri.host.isEmpty ||
|
||||||
|
!(uri.scheme == 'https' || uri.scheme == 'http')) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return Uri(
|
||||||
|
scheme: uri.scheme,
|
||||||
|
host: uri.host,
|
||||||
|
port: uri.hasPort ? uri.port : null,
|
||||||
|
path: uri.path.replaceAll(RegExp(r'/+$'), ''),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Jellyfin's API as Train asks it (D47), over plain HTTP: as the client
|
||||||
|
/// "Halkyon Train" on the device `deviceId`, to which Jellyfin ties the
|
||||||
|
/// tokens it gives.
|
||||||
|
class JellyfinClient(
|
||||||
|
final http.Client _http, {
|
||||||
|
required final String deviceId,
|
||||||
|
required final String version,
|
||||||
|
final String device = 'Android',
|
||||||
|
}) {
|
||||||
|
static const _timeout = Duration(seconds: 15);
|
||||||
|
|
||||||
|
/// What [address] is; [JellyfinFailure.notJellyfin] where it isn't
|
||||||
|
/// Jellyfin.
|
||||||
|
Future<JellyfinServer> server(Uri address) async {
|
||||||
|
final Object? info;
|
||||||
|
try {
|
||||||
|
info = await _send('GET', address, '/System/Info/Public');
|
||||||
|
} on JellyfinException catch (error) {
|
||||||
|
if (error.failure == JellyfinFailure.unreachable) {
|
||||||
|
rethrow;
|
||||||
|
}
|
||||||
|
throw const JellyfinException(JellyfinFailure.notJellyfin);
|
||||||
|
}
|
||||||
|
if (info case {
|
||||||
|
'Id': final String id,
|
||||||
|
'ServerName': final String name,
|
||||||
|
'Version': String _,
|
||||||
|
}) {
|
||||||
|
var quickConnect = false;
|
||||||
|
try {
|
||||||
|
quickConnect =
|
||||||
|
await _send('GET', address, '/QuickConnect/Enabled') == true;
|
||||||
|
} on JellyfinException {
|
||||||
|
// A server that doesn't say has it off.
|
||||||
|
}
|
||||||
|
return JellyfinServer(
|
||||||
|
address: address,
|
||||||
|
name: name,
|
||||||
|
id: id,
|
||||||
|
quickConnect: quickConnect,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
throw const JellyfinException(JellyfinFailure.notJellyfin);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Starts a Quick Connect request (Jellyfin 10.9 and later).
|
||||||
|
Future<QuickConnectCode> initiateQuickConnect(Uri address) async {
|
||||||
|
try {
|
||||||
|
if (await _send('POST', address, '/QuickConnect/Initiate') case {
|
||||||
|
'Code': final String code,
|
||||||
|
'Secret': final String secret,
|
||||||
|
}) {
|
||||||
|
return (code: code, secret: secret);
|
||||||
|
}
|
||||||
|
} on JellyfinException catch (error) {
|
||||||
|
if (error.failure == JellyfinFailure.refused) {
|
||||||
|
throw const JellyfinException(JellyfinFailure.quickConnectOff);
|
||||||
|
}
|
||||||
|
rethrow;
|
||||||
|
}
|
||||||
|
throw const JellyfinException(JellyfinFailure.failed);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether the person approved the request [secret] belongs to.
|
||||||
|
Future<bool> quickConnectApproved(Uri address, String secret) async =>
|
||||||
|
switch (await _send(
|
||||||
|
'GET',
|
||||||
|
address,
|
||||||
|
'/QuickConnect/Connect',
|
||||||
|
query: {'Secret': secret},
|
||||||
|
)) {
|
||||||
|
{'Authenticated': true} => true,
|
||||||
|
_ => false,
|
||||||
|
};
|
||||||
|
|
||||||
|
/// Signs in with the approved request [secret] belongs to.
|
||||||
|
Future<JellyfinSignIn> authenticateWithQuickConnect(
|
||||||
|
Uri address,
|
||||||
|
String secret,
|
||||||
|
) async => _signedIn(
|
||||||
|
await _send(
|
||||||
|
'POST',
|
||||||
|
address,
|
||||||
|
'/Users/AuthenticateWithQuickConnect',
|
||||||
|
body: {'Secret': secret},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
/// Signs in with [name] and [password].
|
||||||
|
Future<JellyfinSignIn> authenticateByName(
|
||||||
|
Uri address,
|
||||||
|
String name,
|
||||||
|
String password,
|
||||||
|
) async => _signedIn(
|
||||||
|
await _send(
|
||||||
|
'POST',
|
||||||
|
address,
|
||||||
|
'/Users/AuthenticateByName',
|
||||||
|
body: {'Username': name, 'Pw': password},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
/// The person's playlists of music, by name; a playlist that names no
|
||||||
|
/// type counts as music, one of videos doesn't.
|
||||||
|
Future<List<JellyfinPlaylist>> playlists(
|
||||||
|
Uri address,
|
||||||
|
JellyfinSession session,
|
||||||
|
) async {
|
||||||
|
final answer = await _send(
|
||||||
|
'GET',
|
||||||
|
address,
|
||||||
|
'/Items',
|
||||||
|
token: session.token,
|
||||||
|
query: {
|
||||||
|
'userId': session.userId,
|
||||||
|
'includeItemTypes': 'Playlist',
|
||||||
|
'recursive': 'true',
|
||||||
|
'sortBy': 'SortName',
|
||||||
|
'fields': 'ChildCount',
|
||||||
|
},
|
||||||
|
);
|
||||||
|
return [
|
||||||
|
if (answer case {'Items': final List<Object?> items})
|
||||||
|
for (final item in items)
|
||||||
|
if (item
|
||||||
|
case {
|
||||||
|
'Id': final String id,
|
||||||
|
'Name': final String name,
|
||||||
|
}
|
||||||
|
when item['MediaType'] == null || item['MediaType'] == 'Audio')
|
||||||
|
JellyfinPlaylist(
|
||||||
|
id: id,
|
||||||
|
name: name,
|
||||||
|
tracks: item['ChildCount'] as int? ?? 0,
|
||||||
|
),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Ends [session]'s token on the server; what fails is left, as the
|
||||||
|
/// device forgets the token anyway.
|
||||||
|
Future<void> signOut(Uri address, JellyfinSession session) async {
|
||||||
|
try {
|
||||||
|
await _send('POST', address, '/Sessions/Logout', token: session.token);
|
||||||
|
} on JellyfinException {
|
||||||
|
// The server keeps it until its operator ends it.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
JellyfinSignIn _signedIn(Object? answer) {
|
||||||
|
if (answer case {
|
||||||
|
'AccessToken': final String token,
|
||||||
|
'User': {'Id': final String userId, 'Name': final String userName},
|
||||||
|
}) {
|
||||||
|
return (token: token, userId: userId, userName: userName);
|
||||||
|
}
|
||||||
|
throw const JellyfinException(JellyfinFailure.failed);
|
||||||
|
}
|
||||||
|
|
||||||
|
String _authorization(String? token) =>
|
||||||
|
'MediaBrowser Client="Halkyon Train", Device="$device", '
|
||||||
|
'DeviceId="$deviceId", Version="$version"'
|
||||||
|
'${token == null ? '' : ', Token="$token"'}';
|
||||||
|
|
||||||
|
Future<Object?> _send(
|
||||||
|
String method,
|
||||||
|
Uri address,
|
||||||
|
String path, {
|
||||||
|
String? token,
|
||||||
|
Map<String, String>? query,
|
||||||
|
Map<String, Object?>? body,
|
||||||
|
}) async {
|
||||||
|
final request =
|
||||||
|
http.Request(
|
||||||
|
method,
|
||||||
|
address.replace(
|
||||||
|
path: '${address.path}$path',
|
||||||
|
queryParameters: query,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
..headers['authorization'] = _authorization(token)
|
||||||
|
..headers['accept'] = 'application/json';
|
||||||
|
if (body != null) {
|
||||||
|
request
|
||||||
|
..headers['content-type'] = 'application/json'
|
||||||
|
..body = jsonEncode(body);
|
||||||
|
}
|
||||||
|
final http.Response response;
|
||||||
|
try {
|
||||||
|
response = await http.Response.fromStream(
|
||||||
|
await _http.send(request).timeout(_timeout),
|
||||||
|
).timeout(_timeout);
|
||||||
|
} on Object catch (error) {
|
||||||
|
if (error is http.ClientException ||
|
||||||
|
error is SocketException ||
|
||||||
|
error is TimeoutException ||
|
||||||
|
error is HandshakeException) {
|
||||||
|
throw const JellyfinException(JellyfinFailure.unreachable);
|
||||||
|
}
|
||||||
|
rethrow;
|
||||||
|
}
|
||||||
|
if (response.statusCode == 401 || response.statusCode == 403) {
|
||||||
|
throw const JellyfinException(JellyfinFailure.refused);
|
||||||
|
}
|
||||||
|
if (response.statusCode >= 300) {
|
||||||
|
throw const JellyfinException(JellyfinFailure.failed);
|
||||||
|
}
|
||||||
|
if (response.body.isEmpty) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
return jsonDecode(response.body);
|
||||||
|
} on FormatException {
|
||||||
|
throw const JellyfinException(JellyfinFailure.notJellyfin);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,129 @@
|
||||||
|
import 'package:flutter/foundation.dart';
|
||||||
|
import 'package:halkyon_train_flutter/src/feature/music/data/jellyfin_client.dart';
|
||||||
|
import 'package:halkyon_train_flutter/src/feature/music/data/music_store.dart';
|
||||||
|
import 'package:http/http.dart' as http;
|
||||||
|
|
||||||
|
/// The person's music on Jellyfin, on this phone (ADR 0030, D47): the
|
||||||
|
/// server signed in to, with Quick Connect or a password, and the
|
||||||
|
/// playlists marked for running.
|
||||||
|
class MusicLibrary(
|
||||||
|
final MusicStore _store,
|
||||||
|
final JellyfinTokenStore _tokens, {
|
||||||
|
required final String version,
|
||||||
|
final http.Client? client,
|
||||||
|
}) {
|
||||||
|
late final ValueNotifier<MusicSettings> settings = ValueNotifier(
|
||||||
|
_store.read(),
|
||||||
|
);
|
||||||
|
|
||||||
|
late final jellyfin = JellyfinClient(
|
||||||
|
client ?? http.Client(),
|
||||||
|
deviceId: settings.value.deviceId,
|
||||||
|
version: version,
|
||||||
|
);
|
||||||
|
|
||||||
|
/// Forgets the tokens of people the device no longer keeps, and who is
|
||||||
|
/// signed in where their token is gone.
|
||||||
|
Future<void> open() async {
|
||||||
|
await _tokens.forgetOthers();
|
||||||
|
if (settings.value.signedIn && await _tokens.read() == null) {
|
||||||
|
_set(settings.value.signedOut());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// What the server at [address] is.
|
||||||
|
Future<JellyfinServer> find(Uri address) => jellyfin.server(address);
|
||||||
|
|
||||||
|
/// Signs in to [server] with the Quick Connect request [secret] belongs
|
||||||
|
/// to, once approved.
|
||||||
|
Future<void> signInWithQuickConnect(
|
||||||
|
JellyfinServer server,
|
||||||
|
String secret,
|
||||||
|
) async {
|
||||||
|
await _signedIn(
|
||||||
|
server,
|
||||||
|
await jellyfin.authenticateWithQuickConnect(server.address, secret),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Signs in to [server] with [name] and [password].
|
||||||
|
Future<void> signInWithPassword(
|
||||||
|
JellyfinServer server,
|
||||||
|
String name,
|
||||||
|
String password,
|
||||||
|
) async {
|
||||||
|
await _signedIn(
|
||||||
|
server,
|
||||||
|
await jellyfin.authenticateByName(server.address, name, password),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The person's playlists of music; a token Jellyfin took back signs the
|
||||||
|
/// person out here too.
|
||||||
|
Future<List<JellyfinPlaylist>> playlists() async {
|
||||||
|
final current = settings.value;
|
||||||
|
final token = await _tokens.read();
|
||||||
|
if (!current.signedIn || token == null) {
|
||||||
|
throw const JellyfinException(JellyfinFailure.refused);
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
return await jellyfin.playlists(current.address!, (
|
||||||
|
token: token,
|
||||||
|
userId: current.userId!,
|
||||||
|
));
|
||||||
|
} on JellyfinException catch (error) {
|
||||||
|
if (error.failure == JellyfinFailure.refused) {
|
||||||
|
await _tokens.keep(null);
|
||||||
|
_set(settings.value.signedOut());
|
||||||
|
}
|
||||||
|
rethrow;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Marks [playlist] for running, or no longer.
|
||||||
|
void mark(JellyfinPlaylist playlist, {required bool on}) {
|
||||||
|
final playlists = {...settings.value.playlists};
|
||||||
|
if (on) {
|
||||||
|
playlists[playlist.id] = playlist.name;
|
||||||
|
} else {
|
||||||
|
playlists.remove(playlist.id);
|
||||||
|
}
|
||||||
|
_set(settings.value.copyWith(playlists: playlists));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Signs out: the token ended on the server where it can be, forgotten
|
||||||
|
/// here, and the marked playlists with it.
|
||||||
|
Future<void> signOut() async {
|
||||||
|
final current = settings.value;
|
||||||
|
final token = await _tokens.read();
|
||||||
|
if (current.signedIn && token != null) {
|
||||||
|
await jellyfin.signOut(current.address!, (
|
||||||
|
token: token,
|
||||||
|
userId: current.userId!,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
await _tokens.keep(null);
|
||||||
|
_set(current.signedOut());
|
||||||
|
}
|
||||||
|
|
||||||
|
void dispose() => settings.dispose();
|
||||||
|
|
||||||
|
Future<void> _signedIn(JellyfinServer server, JellyfinSignIn signedIn) async {
|
||||||
|
await _tokens.keep(signedIn.token);
|
||||||
|
_set(
|
||||||
|
MusicSettings(
|
||||||
|
deviceId: settings.value.deviceId,
|
||||||
|
address: server.address,
|
||||||
|
serverName: server.name,
|
||||||
|
serverId: server.id,
|
||||||
|
userId: signedIn.userId,
|
||||||
|
userName: signedIn.userName,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _set(MusicSettings value) {
|
||||||
|
_store.save(value);
|
||||||
|
settings.value = value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,157 @@
|
||||||
|
import 'dart:convert';
|
||||||
|
import 'dart:developer';
|
||||||
|
import 'dart:io';
|
||||||
|
import 'dart:math' show Random;
|
||||||
|
|
||||||
|
import 'package:equatable/equatable.dart';
|
||||||
|
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
|
||||||
|
|
||||||
|
/// Music from Jellyfin on this phone, for one person (ADR 0030, D47): never
|
||||||
|
/// synced, nothing of it on Train's server.
|
||||||
|
final class const MusicSettings({
|
||||||
|
/// The ID Jellyfin knows this phone's Train by, made once.
|
||||||
|
required final String deviceId,
|
||||||
|
|
||||||
|
/// The server signed in to; null before.
|
||||||
|
final Uri? address,
|
||||||
|
final String? serverName,
|
||||||
|
final String? serverId,
|
||||||
|
|
||||||
|
/// Who is signed in there; null before.
|
||||||
|
final String? userId,
|
||||||
|
final String? userName,
|
||||||
|
|
||||||
|
/// The playlists marked for running: their names by their IDs.
|
||||||
|
final Map<String, String> playlists = const {},
|
||||||
|
}) with Equatable {
|
||||||
|
/// Whether someone is signed in (their token in the keyring).
|
||||||
|
bool get signedIn => address != null && userId != null;
|
||||||
|
|
||||||
|
/// Signed out of the server: the device's ID kept, as Jellyfin lists
|
||||||
|
/// devices by it.
|
||||||
|
MusicSettings signedOut() => MusicSettings(deviceId: deviceId);
|
||||||
|
|
||||||
|
MusicSettings copyWith({Map<String, String>? playlists}) => MusicSettings(
|
||||||
|
deviceId: deviceId,
|
||||||
|
address: address,
|
||||||
|
serverName: serverName,
|
||||||
|
serverId: serverId,
|
||||||
|
userId: userId,
|
||||||
|
userName: userName,
|
||||||
|
playlists: playlists ?? this.playlists,
|
||||||
|
);
|
||||||
|
|
||||||
|
@override
|
||||||
|
List<Object?> get props => [
|
||||||
|
deviceId,
|
||||||
|
address,
|
||||||
|
serverName,
|
||||||
|
serverId,
|
||||||
|
userId,
|
||||||
|
userName,
|
||||||
|
playlists,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Keeps [MusicSettings] in a file of the person's folder (`music.json`); a
|
||||||
|
/// file it can't read is none, so the person signs in again.
|
||||||
|
class MusicStore(final File _file) {
|
||||||
|
MusicSettings read() {
|
||||||
|
if (_file.existsSync()) {
|
||||||
|
try {
|
||||||
|
if (jsonDecode(_file.readAsStringSync())
|
||||||
|
case {
|
||||||
|
'deviceId': final String deviceId,
|
||||||
|
} &&
|
||||||
|
final Map<String, Object?> json) {
|
||||||
|
return MusicSettings(
|
||||||
|
deviceId: deviceId,
|
||||||
|
address: switch (json['address']) {
|
||||||
|
final String address => Uri.tryParse(address),
|
||||||
|
_ => null,
|
||||||
|
},
|
||||||
|
serverName: json['serverName'] as String?,
|
||||||
|
serverId: json['serverId'] as String?,
|
||||||
|
userId: json['userId'] as String?,
|
||||||
|
userName: json['userName'] as String?,
|
||||||
|
playlists: {
|
||||||
|
...(json['playlists'] as Map<String, Object?>? ?? const {})
|
||||||
|
.cast<String, String>(),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} on Object catch (error) {
|
||||||
|
log('Music settings not read: ${error.runtimeType}', name: 'Music');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
final settings = MusicSettings(deviceId: _newDeviceId());
|
||||||
|
save(settings);
|
||||||
|
return settings;
|
||||||
|
}
|
||||||
|
|
||||||
|
void save(MusicSettings settings) {
|
||||||
|
_file.parent.createSync(recursive: true);
|
||||||
|
_file.writeAsStringSync(
|
||||||
|
jsonEncode({
|
||||||
|
'deviceId': settings.deviceId,
|
||||||
|
'address': settings.address?.toString(),
|
||||||
|
'serverName': settings.serverName,
|
||||||
|
'serverId': settings.serverId,
|
||||||
|
'userId': settings.userId,
|
||||||
|
'userName': settings.userName,
|
||||||
|
'playlists': settings.playlists,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
static String _newDeviceId() {
|
||||||
|
final random = Random.secure();
|
||||||
|
return [
|
||||||
|
for (var i = 0; i < 16; i++)
|
||||||
|
random.nextInt(256).toRadixString(16).padLeft(2, '0'),
|
||||||
|
].join();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Where the person's Jellyfin token is kept on this phone.
|
||||||
|
abstract interface class JellyfinTokenStore() {
|
||||||
|
Future<String?> read();
|
||||||
|
|
||||||
|
/// Keeps [token]; null forgets it.
|
||||||
|
Future<void> keep(String? token);
|
||||||
|
|
||||||
|
/// Forgets the tokens of everyone else, whose folders the device no
|
||||||
|
/// longer keeps (one person at a time, as `PersonFolders.openOnly`).
|
||||||
|
Future<void> forgetOthers();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// [JellyfinTokenStore] in the system's keyring (Android's Keystore), under
|
||||||
|
/// `train_jellyfin:` and the person's ID.
|
||||||
|
class SecureJellyfinTokenStore(
|
||||||
|
final String _person, {
|
||||||
|
final FlutterSecureStorage _storage = const FlutterSecureStorage(),
|
||||||
|
}) implements JellyfinTokenStore {
|
||||||
|
static const _prefix = 'train_jellyfin:';
|
||||||
|
|
||||||
|
String get _key => '$_prefix$_person';
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<String?> read() => _storage.read(key: _key);
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> keep(String? token) async {
|
||||||
|
await _storage.delete(key: _key);
|
||||||
|
if (token != null) {
|
||||||
|
await _storage.write(key: _key, value: token);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> forgetOthers() async {
|
||||||
|
for (final key in (await _storage.readAll()).keys) {
|
||||||
|
if (key.startsWith(_prefix) && key != _key) {
|
||||||
|
await _storage.delete(key: key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,287 @@
|
||||||
|
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||||
|
import 'package:halkyon_app/halkyon_app.dart';
|
||||||
|
import 'package:halkyon_train_flutter/l10n/app_localizations.dart';
|
||||||
|
import 'package:halkyon_train_flutter/src/app/train_dependencies.dart';
|
||||||
|
import 'package:halkyon_train_flutter/src/feature/music/bloc/music_cubit.dart';
|
||||||
|
import 'package:halkyon_train_flutter/src/feature/music/data/jellyfin_client.dart';
|
||||||
|
import 'package:halkyon_ui/halkyon_ui.dart';
|
||||||
|
import 'package:lucide_icons_flutter/lucide_icons.dart';
|
||||||
|
import 'package:material_ui/material_ui.dart';
|
||||||
|
|
||||||
|
/// Opens the music screen over [context]'s.
|
||||||
|
Future<void> openMusicScreen(BuildContext context) {
|
||||||
|
final music = PersonScope.of<TrainPerson>(context).music!;
|
||||||
|
return Navigator.of(context).push(
|
||||||
|
MaterialPageRoute<void>(
|
||||||
|
builder: (context) => BlocProvider(
|
||||||
|
create: (context) => MusicCubit(music)..open(),
|
||||||
|
child: const MusicScreen(),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Music from Jellyfin on this phone (ADR 0030, D47): signing in to the
|
||||||
|
/// person's server, with Quick Connect where it has it, else a password;
|
||||||
|
/// then their playlists, marked for running.
|
||||||
|
class const MusicScreen({super.key}) extends StatelessWidget {
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final l10n = AppLocalizations.of(context);
|
||||||
|
return BlocBuilder<MusicCubit, MusicState>(
|
||||||
|
builder: (context, state) => Scaffold(
|
||||||
|
appBar: AppBar(title: Text(l10n.musicTitle)),
|
||||||
|
body: SafeArea(
|
||||||
|
child: Center(
|
||||||
|
child: ConstrainedBox(
|
||||||
|
constraints: const BoxConstraints(
|
||||||
|
maxWidth: readableContentMaxWidth,
|
||||||
|
),
|
||||||
|
child: switch (state.step) {
|
||||||
|
MusicStep.opening ||
|
||||||
|
MusicStep.finding ||
|
||||||
|
MusicStep.signingIn => const AppLoadingIndicator(),
|
||||||
|
MusicStep.address => _Address(failure: state.failure),
|
||||||
|
MusicStep.quickConnect => _QuickConnect(state: state),
|
||||||
|
MusicStep.password => _Password(state: state),
|
||||||
|
MusicStep.signedIn => _Playlists(state: state),
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
String _failureText(AppLocalizations l10n, MusicFailure failure) =>
|
||||||
|
switch (failure) {
|
||||||
|
MusicFailure.badAddress => l10n.musicBadAddress,
|
||||||
|
MusicFailure.unreachable => l10n.musicUnreachable,
|
||||||
|
MusicFailure.notJellyfin => l10n.musicNotJellyfin,
|
||||||
|
MusicFailure.refused => l10n.musicRefused,
|
||||||
|
MusicFailure.expired => l10n.musicCodeExpired,
|
||||||
|
MusicFailure.failed => l10n.musicFailed,
|
||||||
|
};
|
||||||
|
|
||||||
|
class const _Address({final MusicFailure? failure}) extends StatefulWidget {
|
||||||
|
@override
|
||||||
|
State<_Address> createState() => _AddressState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _AddressState() extends State<_Address> {
|
||||||
|
final _address = TextEditingController();
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_address.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
void _find() => context.read<MusicCubit>().find(_address.text);
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final l10n = AppLocalizations.of(context);
|
||||||
|
final failure = widget.failure;
|
||||||
|
return ListView(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
children: [
|
||||||
|
Text(l10n.musicIntro),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
TextField(
|
||||||
|
controller: _address,
|
||||||
|
keyboardType: TextInputType.url,
|
||||||
|
autocorrect: false,
|
||||||
|
textInputAction: TextInputAction.go,
|
||||||
|
onSubmitted: (_) => _find(),
|
||||||
|
decoration: InputDecoration(
|
||||||
|
labelText: l10n.musicServer,
|
||||||
|
hintText: 'jellyfin.example.org',
|
||||||
|
errorText: failure == null ? null : _failureText(l10n, failure),
|
||||||
|
errorMaxLines: 3,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
Align(
|
||||||
|
alignment: AlignmentDirectional.centerEnd,
|
||||||
|
child: FilledButton(onPressed: _find, child: Text(l10n.musicConnect)),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class const _QuickConnect({required final MusicState state})
|
||||||
|
extends StatelessWidget {
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final l10n = AppLocalizations.of(context);
|
||||||
|
final code = state.code;
|
||||||
|
final failure = state.failure;
|
||||||
|
MusicCubit cubit() => context.read<MusicCubit>();
|
||||||
|
return ListView(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
children: [
|
||||||
|
Text(l10n.musicQuickConnect(state.server!.name)),
|
||||||
|
const SizedBox(height: 24),
|
||||||
|
if (code != null) ...[
|
||||||
|
Center(
|
||||||
|
child: SelectableText(
|
||||||
|
code,
|
||||||
|
style: Theme.of(context).textTheme.displaySmall,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
const LinearProgressIndicator(),
|
||||||
|
] else if (failure != null) ...[
|
||||||
|
Text(_failureText(l10n, failure)),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
Align(
|
||||||
|
alignment: AlignmentDirectional.centerEnd,
|
||||||
|
child: FilledButton(
|
||||||
|
onPressed: () => cubit().newCode(),
|
||||||
|
child: Text(l10n.musicNewCode),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
const SizedBox(height: 24),
|
||||||
|
OverflowBar(
|
||||||
|
alignment: MainAxisAlignment.end,
|
||||||
|
spacing: 8,
|
||||||
|
children: [
|
||||||
|
TextButton(
|
||||||
|
onPressed: () => cubit().back(),
|
||||||
|
child: Text(l10n.musicBack),
|
||||||
|
),
|
||||||
|
TextButton(
|
||||||
|
onPressed: () => cubit().usePassword(),
|
||||||
|
child: Text(l10n.musicUsePassword),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class const _Password({required final MusicState state})
|
||||||
|
extends StatefulWidget {
|
||||||
|
@override
|
||||||
|
State<_Password> createState() => _PasswordState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _PasswordState() extends State<_Password> {
|
||||||
|
final _name = TextEditingController();
|
||||||
|
final _password = TextEditingController();
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_name.dispose();
|
||||||
|
_password.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
void _signIn() =>
|
||||||
|
context.read<MusicCubit>().signIn(_name.text.trim(), _password.text);
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final l10n = AppLocalizations.of(context);
|
||||||
|
final failure = widget.state.failure;
|
||||||
|
return ListView(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
children: [
|
||||||
|
Text(l10n.musicPasswordIntro(widget.state.server!.name)),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
TextField(
|
||||||
|
controller: _name,
|
||||||
|
autocorrect: false,
|
||||||
|
textInputAction: TextInputAction.next,
|
||||||
|
autofillHints: const [AutofillHints.username],
|
||||||
|
decoration: InputDecoration(labelText: l10n.musicUserName),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
TextField(
|
||||||
|
controller: _password,
|
||||||
|
obscureText: true,
|
||||||
|
textInputAction: TextInputAction.go,
|
||||||
|
autofillHints: const [AutofillHints.password],
|
||||||
|
onSubmitted: (_) => _signIn(),
|
||||||
|
decoration: InputDecoration(
|
||||||
|
labelText: l10n.musicPassword,
|
||||||
|
errorText: failure == null ? null : _failureText(l10n, failure),
|
||||||
|
errorMaxLines: 3,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
OverflowBar(
|
||||||
|
alignment: MainAxisAlignment.end,
|
||||||
|
spacing: 8,
|
||||||
|
children: [
|
||||||
|
TextButton(
|
||||||
|
onPressed: () => context.read<MusicCubit>().back(),
|
||||||
|
child: Text(l10n.musicBack),
|
||||||
|
),
|
||||||
|
FilledButton(onPressed: _signIn, child: Text(l10n.musicSignIn)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class const _Playlists({required final MusicState state})
|
||||||
|
extends StatelessWidget {
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final l10n = AppLocalizations.of(context);
|
||||||
|
final settings = state.settings;
|
||||||
|
final playlists = state.playlists;
|
||||||
|
final failure = state.failure;
|
||||||
|
MusicCubit cubit() => context.read<MusicCubit>();
|
||||||
|
return ListView(
|
||||||
|
children: [
|
||||||
|
ListTile(
|
||||||
|
leading: const Icon(LucideIcons.server),
|
||||||
|
title: Text(settings.serverName ?? '${settings.address}'),
|
||||||
|
subtitle: Text(l10n.musicSignedInAs(settings.userName ?? '')),
|
||||||
|
trailing: TextButton(
|
||||||
|
onPressed: () => cubit().signOut(),
|
||||||
|
child: Text(l10n.musicSignOut),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const Divider(),
|
||||||
|
ListTile(
|
||||||
|
title: Text(
|
||||||
|
l10n.musicPlaylists,
|
||||||
|
style: Theme.of(context).textTheme.titleSmall,
|
||||||
|
),
|
||||||
|
subtitle: Text(l10n.musicPlaylistsHint),
|
||||||
|
),
|
||||||
|
if (failure != null)
|
||||||
|
ListTile(
|
||||||
|
title: Text(_failureText(l10n, failure)),
|
||||||
|
trailing: TextButton(
|
||||||
|
onPressed: () => cubit().loadPlaylists(),
|
||||||
|
child: Text(l10n.musicRetry),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
else if (playlists == null)
|
||||||
|
const Padding(
|
||||||
|
padding: EdgeInsets.all(16),
|
||||||
|
child: AppLoadingIndicator(),
|
||||||
|
)
|
||||||
|
else if (playlists.isEmpty)
|
||||||
|
ListTile(subtitle: Text(l10n.musicNoPlaylists)),
|
||||||
|
for (final playlist in playlists ?? const <JellyfinPlaylist>[])
|
||||||
|
CheckboxListTile(
|
||||||
|
title: Text(playlist.name),
|
||||||
|
subtitle: Text(l10n.musicTracks(playlist.tracks)),
|
||||||
|
value: settings.playlists.containsKey(playlist.id),
|
||||||
|
onChanged: (on) => cubit().mark(playlist, on: on ?? false),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -30,6 +30,9 @@ dependencies:
|
||||||
# vector tiles of the instance's source (OpenFreeMap without a setting, D22),
|
# vector tiles of the instance's source (OpenFreeMap without a setting, D22),
|
||||||
# and its coordinates.
|
# and its coordinates.
|
||||||
flutter_map: ^8.3.2
|
flutter_map: ^8.3.2
|
||||||
|
# The Jellyfin token in the keyring (ADR 0030, D47), as halkyon_app keeps
|
||||||
|
# the account's keys.
|
||||||
|
flutter_secure_storage: ^10.0.0
|
||||||
halkyon_app:
|
halkyon_app:
|
||||||
path: ../../../packages/halkyon_app
|
path: ../../../packages/halkyon_app
|
||||||
# The device's half of signing in with OpenID Connect (ADR 0023): the
|
# The device's half of signing in with OpenID Connect (ADR 0023): the
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,263 @@
|
||||||
|
import 'dart:convert';
|
||||||
|
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:halkyon_train_flutter/src/feature/music/data/jellyfin_client.dart';
|
||||||
|
import 'package:http/http.dart' as http;
|
||||||
|
import 'package:http/testing.dart';
|
||||||
|
|
||||||
|
/// Jellyfin as Train asks it (ADR 0030, D47): what the server is, signing in
|
||||||
|
/// with Quick Connect or a password, and the person's playlists.
|
||||||
|
void main() {
|
||||||
|
final address = Uri.parse('https://music.example.org/jellyfin');
|
||||||
|
|
||||||
|
/// A Jellyfin server answering [routes] by path, as JSON; every request it
|
||||||
|
/// saw in [seen].
|
||||||
|
JellyfinClient serving(
|
||||||
|
Map<String, (int, Object?)> routes, {
|
||||||
|
List<http.Request>? seen,
|
||||||
|
}) => JellyfinClient(
|
||||||
|
MockClient((request) async {
|
||||||
|
seen?.add(request);
|
||||||
|
final route = routes['${request.method} ${request.url.path}'];
|
||||||
|
if (route == null) {
|
||||||
|
return http.Response('', 404);
|
||||||
|
}
|
||||||
|
return http.Response(
|
||||||
|
jsonEncode(route.$2),
|
||||||
|
route.$1,
|
||||||
|
headers: {'content-type': 'application/json'},
|
||||||
|
);
|
||||||
|
}),
|
||||||
|
deviceId: 'device-1',
|
||||||
|
version: '0.1.0',
|
||||||
|
);
|
||||||
|
|
||||||
|
group('an address as typed', () {
|
||||||
|
test('becomes https without a scheme, keeps its path, drops a trailing '
|
||||||
|
'slash', () {
|
||||||
|
expect(
|
||||||
|
jellyfinAddress(' music.example.org/jellyfin/ '),
|
||||||
|
Uri.parse('https://music.example.org/jellyfin'),
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
jellyfinAddress('http://192.168.1.4:8096'),
|
||||||
|
Uri.parse('http://192.168.1.4:8096'),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('is none without a host or with another scheme', () {
|
||||||
|
expect(jellyfinAddress(''), isNull);
|
||||||
|
expect(jellyfinAddress('ftp://music.example.org'), isNull);
|
||||||
|
expect(jellyfinAddress('https://'), isNull);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a server says its name and whether Quick Connect is on', () async {
|
||||||
|
final seen = <http.Request>[];
|
||||||
|
final client = serving({
|
||||||
|
'GET /jellyfin/System/Info/Public': (
|
||||||
|
200,
|
||||||
|
{'ServerName': 'Home', 'Id': 'server-1', 'Version': '10.10.7'},
|
||||||
|
),
|
||||||
|
'GET /jellyfin/QuickConnect/Enabled': (200, true),
|
||||||
|
}, seen: seen);
|
||||||
|
|
||||||
|
final server = await client.server(address);
|
||||||
|
|
||||||
|
expect(server.name, 'Home');
|
||||||
|
expect(server.id, 'server-1');
|
||||||
|
expect(server.quickConnect, isTrue);
|
||||||
|
expect(
|
||||||
|
seen.first.headers['authorization'],
|
||||||
|
'MediaBrowser Client="Halkyon Train", Device="Android", '
|
||||||
|
'DeviceId="device-1", Version="0.1.0"',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("what answers without Jellyfin's basics is not Jellyfin, and what "
|
||||||
|
"doesn't answer is unreachable", () async {
|
||||||
|
await expectLater(
|
||||||
|
serving({
|
||||||
|
'GET /jellyfin/System/Info/Public': (200, {'hello': 'world'}),
|
||||||
|
}).server(address),
|
||||||
|
throwsA(
|
||||||
|
isA<JellyfinException>().having(
|
||||||
|
(e) => e.failure,
|
||||||
|
'failure',
|
||||||
|
JellyfinFailure.notJellyfin,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
await expectLater(
|
||||||
|
serving({}).server(address),
|
||||||
|
throwsA(
|
||||||
|
isA<JellyfinException>().having(
|
||||||
|
(e) => e.failure,
|
||||||
|
'failure',
|
||||||
|
JellyfinFailure.notJellyfin,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
final down = JellyfinClient(
|
||||||
|
MockClient((request) => throw http.ClientException('down')),
|
||||||
|
deviceId: 'device-1',
|
||||||
|
version: '0.1.0',
|
||||||
|
);
|
||||||
|
await expectLater(
|
||||||
|
down.server(address),
|
||||||
|
throwsA(
|
||||||
|
isA<JellyfinException>().having(
|
||||||
|
(e) => e.failure,
|
||||||
|
'failure',
|
||||||
|
JellyfinFailure.unreachable,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test(
|
||||||
|
'Quick Connect: a code to show, approved, then a token of its own',
|
||||||
|
() async {
|
||||||
|
final seen = <http.Request>[];
|
||||||
|
var approved = false;
|
||||||
|
final client = JellyfinClient(
|
||||||
|
MockClient((request) async {
|
||||||
|
seen.add(request);
|
||||||
|
final body = switch ('${request.method} ${request.url.path}') {
|
||||||
|
'POST /jellyfin/QuickConnect/Initiate' => {
|
||||||
|
'Secret': 'secret-1',
|
||||||
|
'Code': '123456',
|
||||||
|
},
|
||||||
|
'GET /jellyfin/QuickConnect/Connect' => {'Authenticated': approved},
|
||||||
|
'POST /jellyfin/Users/AuthenticateWithQuickConnect' => {
|
||||||
|
'AccessToken': 'token-1',
|
||||||
|
'User': {'Id': 'user-1', 'Name': 'Ada'},
|
||||||
|
},
|
||||||
|
_ => null,
|
||||||
|
};
|
||||||
|
return http.Response(jsonEncode(body), body == null ? 404 : 200);
|
||||||
|
}),
|
||||||
|
deviceId: 'device-1',
|
||||||
|
version: '0.1.0',
|
||||||
|
);
|
||||||
|
|
||||||
|
final code = await client.initiateQuickConnect(address);
|
||||||
|
expect(code.code, '123456');
|
||||||
|
expect(await client.quickConnectApproved(address, code.secret), isFalse);
|
||||||
|
approved = true;
|
||||||
|
expect(await client.quickConnectApproved(address, code.secret), isTrue);
|
||||||
|
final signedIn = await client.authenticateWithQuickConnect(
|
||||||
|
address,
|
||||||
|
code.secret,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(signedIn, (token: 'token-1', userId: 'user-1', userName: 'Ada'));
|
||||||
|
expect(seen[1].url.queryParameters, {'Secret': 'secret-1'});
|
||||||
|
expect(jsonDecode(seen.last.body), {'Secret': 'secret-1'});
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
test('Quick Connect switched off is said so', () async {
|
||||||
|
await expectLater(
|
||||||
|
serving({
|
||||||
|
'POST /jellyfin/QuickConnect/Initiate': (401, null),
|
||||||
|
}).initiateQuickConnect(address),
|
||||||
|
throwsA(
|
||||||
|
isA<JellyfinException>().having(
|
||||||
|
(e) => e.failure,
|
||||||
|
'failure',
|
||||||
|
JellyfinFailure.quickConnectOff,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a password signs in; a wrong one is refused', () async {
|
||||||
|
final seen = <http.Request>[];
|
||||||
|
final client = serving({
|
||||||
|
'POST /jellyfin/Users/AuthenticateByName': (
|
||||||
|
200,
|
||||||
|
{
|
||||||
|
'AccessToken': 'token-2',
|
||||||
|
'User': {'Id': 'user-2', 'Name': 'Grace'},
|
||||||
|
},
|
||||||
|
),
|
||||||
|
}, seen: seen);
|
||||||
|
expect(
|
||||||
|
await client.authenticateByName(address, 'Grace', 'pw'),
|
||||||
|
(token: 'token-2', userId: 'user-2', userName: 'Grace'),
|
||||||
|
);
|
||||||
|
expect(jsonDecode(seen.single.body), {'Username': 'Grace', 'Pw': 'pw'});
|
||||||
|
|
||||||
|
await expectLater(
|
||||||
|
serving({
|
||||||
|
'POST /jellyfin/Users/AuthenticateByName': (401, null),
|
||||||
|
}).authenticateByName(address, 'Grace', 'wrong'),
|
||||||
|
throwsA(
|
||||||
|
isA<JellyfinException>().having(
|
||||||
|
(e) => e.failure,
|
||||||
|
'failure',
|
||||||
|
JellyfinFailure.refused,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test(
|
||||||
|
"the person's playlists of music, with the token; videos' left out",
|
||||||
|
() async {
|
||||||
|
final seen = <http.Request>[];
|
||||||
|
final client = serving({
|
||||||
|
'GET /jellyfin/Items': (
|
||||||
|
200,
|
||||||
|
{
|
||||||
|
'Items': [
|
||||||
|
{
|
||||||
|
'Id': 'p1',
|
||||||
|
'Name': 'Tempo',
|
||||||
|
'MediaType': 'Audio',
|
||||||
|
'ChildCount': 42,
|
||||||
|
},
|
||||||
|
{'Id': 'p2', 'Name': 'Films', 'MediaType': 'Video'},
|
||||||
|
{'Id': 'p3', 'Name': 'Old'},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
}, seen: seen);
|
||||||
|
const session = (token: 'token-1', userId: 'user-1');
|
||||||
|
|
||||||
|
final playlists = await client.playlists(address, session);
|
||||||
|
|
||||||
|
expect(playlists, const [
|
||||||
|
JellyfinPlaylist(id: 'p1', name: 'Tempo', tracks: 42),
|
||||||
|
JellyfinPlaylist(id: 'p3', name: 'Old'),
|
||||||
|
]);
|
||||||
|
expect(seen.single.url.queryParameters, {
|
||||||
|
'userId': 'user-1',
|
||||||
|
'includeItemTypes': 'Playlist',
|
||||||
|
'recursive': 'true',
|
||||||
|
'sortBy': 'SortName',
|
||||||
|
'fields': 'ChildCount',
|
||||||
|
});
|
||||||
|
expect(
|
||||||
|
seen.single.headers['authorization'],
|
||||||
|
endsWith(', Token="token-1"'),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
test('a token Jellyfin no longer takes is refused', () async {
|
||||||
|
await expectLater(
|
||||||
|
serving({
|
||||||
|
'GET /jellyfin/Items': (401, null),
|
||||||
|
}).playlists(address, (token: 'old', userId: 'user-1')),
|
||||||
|
throwsA(
|
||||||
|
isA<JellyfinException>().having(
|
||||||
|
(e) => e.failure,
|
||||||
|
'failure',
|
||||||
|
JellyfinFailure.refused,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,126 @@
|
||||||
|
import 'dart:io';
|
||||||
|
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:halkyon_train_flutter/src/feature/music/data/jellyfin_client.dart';
|
||||||
|
import 'package:halkyon_train_flutter/src/feature/music/data/music_library.dart';
|
||||||
|
import 'package:halkyon_train_flutter/src/feature/music/data/music_store.dart';
|
||||||
|
|
||||||
|
import '../../support/fake_jellyfin.dart';
|
||||||
|
|
||||||
|
/// The person's Jellyfin on this phone (ADR 0030, D47): signed in, the
|
||||||
|
/// token in the keyring and nothing else of it in the file, the playlists
|
||||||
|
/// marked, signed out.
|
||||||
|
void main() {
|
||||||
|
late Directory folder;
|
||||||
|
late File file;
|
||||||
|
late FakeJellyfin jellyfin;
|
||||||
|
late MemoryJellyfinTokenStore tokens;
|
||||||
|
final address = Uri.parse('https://music.example.org');
|
||||||
|
|
||||||
|
setUp(() {
|
||||||
|
folder = Directory.systemTemp.createTempSync('music_');
|
||||||
|
file = File('${folder.path}/music.json');
|
||||||
|
jellyfin = FakeJellyfin();
|
||||||
|
tokens = MemoryJellyfinTokenStore();
|
||||||
|
});
|
||||||
|
|
||||||
|
tearDown(() => folder.deleteSync(recursive: true));
|
||||||
|
|
||||||
|
MusicLibrary open() => MusicLibrary(
|
||||||
|
MusicStore(file),
|
||||||
|
tokens,
|
||||||
|
version: '0.1.0',
|
||||||
|
client: jellyfin.client,
|
||||||
|
);
|
||||||
|
|
||||||
|
test('a device ID is made once and kept, and Jellyfin is told it', () async {
|
||||||
|
final first = open();
|
||||||
|
final id = first.settings.value.deviceId;
|
||||||
|
expect(id, hasLength(32));
|
||||||
|
expect(open().settings.value.deviceId, id);
|
||||||
|
|
||||||
|
await first.find(address);
|
||||||
|
expect(
|
||||||
|
jellyfin.requests.first.headers['authorization'],
|
||||||
|
contains('DeviceId="$id"'),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('signed in with a password: the token in the keyring, never in the '
|
||||||
|
'file; playlists marked and kept', () async {
|
||||||
|
final library = open();
|
||||||
|
final server = await library.find(address);
|
||||||
|
await library.signInWithPassword(server, 'Ada', 'pw');
|
||||||
|
|
||||||
|
expect(tokens.token, 'token-1');
|
||||||
|
expect(file.readAsStringSync(), isNot(contains('token-1')));
|
||||||
|
expect(library.settings.value.signedIn, isTrue);
|
||||||
|
expect(library.settings.value.userName, 'Ada');
|
||||||
|
expect(library.settings.value.serverName, 'Home');
|
||||||
|
|
||||||
|
final playlists = await library.playlists();
|
||||||
|
library.mark(playlists.first, on: true);
|
||||||
|
expect(open().settings.value.playlists, {'p1': 'Tempo'});
|
||||||
|
library.mark(playlists.first, on: false);
|
||||||
|
expect(open().settings.value.playlists, isEmpty);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a wrong password is refused, and nothing is kept', () async {
|
||||||
|
final library = open();
|
||||||
|
final server = await library.find(address);
|
||||||
|
await expectLater(
|
||||||
|
library.signInWithPassword(server, 'Ada', 'wrong'),
|
||||||
|
throwsA(isA<JellyfinException>()),
|
||||||
|
);
|
||||||
|
expect(tokens.token, isNull);
|
||||||
|
expect(library.settings.value.signedIn, isFalse);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('signing out ends the token on the server and forgets it and the '
|
||||||
|
'playlists here', () async {
|
||||||
|
final library = open();
|
||||||
|
await library.signInWithPassword(await library.find(address), 'Ada', 'pw');
|
||||||
|
library.mark(const JellyfinPlaylist(id: 'p1', name: 'Tempo'), on: true);
|
||||||
|
|
||||||
|
await library.signOut();
|
||||||
|
|
||||||
|
expect(jellyfin.valid, isEmpty);
|
||||||
|
expect(tokens.token, isNull);
|
||||||
|
expect(library.settings.value.signedIn, isFalse);
|
||||||
|
expect(library.settings.value.playlists, isEmpty);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a token Jellyfin took back signs the person out here', () async {
|
||||||
|
final library = open();
|
||||||
|
await library.signInWithPassword(await library.find(address), 'Ada', 'pw');
|
||||||
|
jellyfin.revoke();
|
||||||
|
|
||||||
|
await expectLater(library.playlists(), throwsA(isA<JellyfinException>()));
|
||||||
|
expect(tokens.token, isNull);
|
||||||
|
expect(library.settings.value.signedIn, isFalse);
|
||||||
|
});
|
||||||
|
|
||||||
|
test(
|
||||||
|
"opening forgets others' tokens, and a sign-in whose token is gone",
|
||||||
|
() async {
|
||||||
|
final library = open();
|
||||||
|
await library.signInWithPassword(
|
||||||
|
await library.find(address),
|
||||||
|
'Ada',
|
||||||
|
'pw',
|
||||||
|
);
|
||||||
|
tokens.token = null;
|
||||||
|
|
||||||
|
final again = open();
|
||||||
|
await again.open();
|
||||||
|
|
||||||
|
expect(tokens.others, isEmpty);
|
||||||
|
expect(again.settings.value.signedIn, isFalse);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
test("a file that can't be read is none", () {
|
||||||
|
file.writeAsStringSync('{not json');
|
||||||
|
expect(open().settings.value.signedIn, isFalse);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,159 @@
|
||||||
|
import 'dart:io';
|
||||||
|
|
||||||
|
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:halkyon_train_flutter/l10n/app_localizations.dart';
|
||||||
|
import 'package:halkyon_train_flutter/src/feature/music/bloc/music_cubit.dart';
|
||||||
|
import 'package:halkyon_train_flutter/src/feature/music/data/music_library.dart';
|
||||||
|
import 'package:halkyon_train_flutter/src/feature/music/data/music_store.dart';
|
||||||
|
import 'package:halkyon_train_flutter/src/feature/music/widget/music_screen.dart';
|
||||||
|
import 'package:material_ui/material_ui.dart';
|
||||||
|
|
||||||
|
import '../../support/fake_jellyfin.dart';
|
||||||
|
|
||||||
|
/// The music screen (ADR 0030, D47): the server's address, Quick Connect's
|
||||||
|
/// code until it is approved, else the password; then the playlists marked.
|
||||||
|
void main() {
|
||||||
|
late Directory folder;
|
||||||
|
late MemoryJellyfinTokenStore tokens;
|
||||||
|
|
||||||
|
setUp(() {
|
||||||
|
folder = Directory.systemTemp.createTempSync('music_');
|
||||||
|
tokens = MemoryJellyfinTokenStore();
|
||||||
|
});
|
||||||
|
|
||||||
|
tearDown(() => folder.deleteSync(recursive: true));
|
||||||
|
|
||||||
|
Future<MusicLibrary> show(WidgetTester tester, FakeJellyfin jellyfin) async {
|
||||||
|
final library = MusicLibrary(
|
||||||
|
MusicStore(File('${folder.path}/music.json')),
|
||||||
|
tokens,
|
||||||
|
version: '0.1.0',
|
||||||
|
client: jellyfin.client,
|
||||||
|
);
|
||||||
|
await tester.pumpWidget(
|
||||||
|
MaterialApp(
|
||||||
|
localizationsDelegates: AppLocalizations.localizationsDelegates,
|
||||||
|
supportedLocales: AppLocalizations.supportedLocales,
|
||||||
|
locale: const Locale('en'),
|
||||||
|
home: BlocProvider(
|
||||||
|
create: (context) => MusicCubit(
|
||||||
|
library,
|
||||||
|
poll: const Duration(milliseconds: 100),
|
||||||
|
)..open(),
|
||||||
|
child: const MusicScreen(),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
await tester.runAsync(() => Future<void>.delayed(Duration.zero));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
return library;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Lets the fake server's answers and the screen's timers run.
|
||||||
|
Future<void> settle(WidgetTester tester) async {
|
||||||
|
for (var i = 0; i < 5; i++) {
|
||||||
|
await tester.runAsync(() => Future<void>.delayed(Duration.zero));
|
||||||
|
await tester.pump(const Duration(milliseconds: 150));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
testWidgets('Quick Connect: the code shown until approved, then the '
|
||||||
|
"person's playlists, one marked", (tester) async {
|
||||||
|
final jellyfin = FakeJellyfin();
|
||||||
|
final library = await show(tester, jellyfin);
|
||||||
|
|
||||||
|
await tester.enterText(find.byType(TextField), 'music.example.org');
|
||||||
|
await tester.tap(find.text('Connect'));
|
||||||
|
await settle(tester);
|
||||||
|
expect(find.text('123456'), findsOneWidget);
|
||||||
|
expect(find.textContaining('To sign in to Home'), findsOneWidget);
|
||||||
|
|
||||||
|
jellyfin.approve();
|
||||||
|
await settle(tester);
|
||||||
|
expect(find.text('Signed in as Ada'), findsOneWidget);
|
||||||
|
expect(find.text('Tempo'), findsOneWidget);
|
||||||
|
expect(find.text('42 tracks'), findsOneWidget);
|
||||||
|
|
||||||
|
await tester.tap(find.text('Tempo'));
|
||||||
|
await tester.pump();
|
||||||
|
expect(library.settings.value.playlists, {'p1': 'Tempo'});
|
||||||
|
|
||||||
|
await tester.tap(find.text('Sign out'));
|
||||||
|
await settle(tester);
|
||||||
|
expect(find.text('Jellyfin server'), findsOneWidget);
|
||||||
|
expect(tokens.token, isNull);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('without Quick Connect, the password; a wrong one said so', (
|
||||||
|
tester,
|
||||||
|
) async {
|
||||||
|
await show(tester, FakeJellyfin(quickConnect: false));
|
||||||
|
|
||||||
|
await tester.enterText(find.byType(TextField), 'music.example.org');
|
||||||
|
await tester.tap(find.text('Connect'));
|
||||||
|
await settle(tester);
|
||||||
|
expect(find.text('User name'), findsOneWidget);
|
||||||
|
|
||||||
|
await tester.enterText(find.byType(TextField).first, 'Ada');
|
||||||
|
await tester.enterText(find.byType(TextField).last, 'wrong');
|
||||||
|
await tester.tap(find.text('Sign in'));
|
||||||
|
await settle(tester);
|
||||||
|
expect(find.textContaining('Jellyfin refused'), findsOneWidget);
|
||||||
|
|
||||||
|
await tester.enterText(find.byType(TextField).last, 'pw');
|
||||||
|
await tester.tap(find.text('Sign in'));
|
||||||
|
await settle(tester);
|
||||||
|
expect(find.text('Signed in as Ada'), findsOneWidget);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('an address that names no server is said so, and nothing is '
|
||||||
|
'asked', (tester) async {
|
||||||
|
final jellyfin = FakeJellyfin();
|
||||||
|
await show(tester, jellyfin);
|
||||||
|
|
||||||
|
await tester.enterText(find.byType(TextField), 'ftp://x');
|
||||||
|
await tester.tap(find.text('Connect'));
|
||||||
|
await tester.pump();
|
||||||
|
|
||||||
|
expect(find.textContaining("Enter the server's address"), findsOneWidget);
|
||||||
|
expect(jellyfin.requests, isEmpty);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('a code not approved in time expires, and a new one is asked '
|
||||||
|
'for', (tester) async {
|
||||||
|
final jellyfin = FakeJellyfin();
|
||||||
|
final library = MusicLibrary(
|
||||||
|
MusicStore(File('${folder.path}/music.json')),
|
||||||
|
tokens,
|
||||||
|
version: '0.1.0',
|
||||||
|
client: jellyfin.client,
|
||||||
|
);
|
||||||
|
await tester.pumpWidget(
|
||||||
|
MaterialApp(
|
||||||
|
localizationsDelegates: AppLocalizations.localizationsDelegates,
|
||||||
|
supportedLocales: AppLocalizations.supportedLocales,
|
||||||
|
locale: const Locale('en'),
|
||||||
|
home: BlocProvider(
|
||||||
|
create: (context) => MusicCubit(
|
||||||
|
library,
|
||||||
|
poll: const Duration(milliseconds: 100),
|
||||||
|
expiry: Duration.zero,
|
||||||
|
)..open(),
|
||||||
|
child: const MusicScreen(),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
await settle(tester);
|
||||||
|
|
||||||
|
await tester.enterText(find.byType(TextField), 'music.example.org');
|
||||||
|
await tester.tap(find.text('Connect'));
|
||||||
|
await settle(tester);
|
||||||
|
expect(find.text('The code has expired.'), findsOneWidget);
|
||||||
|
|
||||||
|
await tester.tap(find.text('New code'));
|
||||||
|
await tester.runAsync(() => Future<void>.delayed(Duration.zero));
|
||||||
|
await tester.pump();
|
||||||
|
expect(find.text('123456'), findsOneWidget);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,99 @@
|
||||||
|
import 'dart:convert';
|
||||||
|
|
||||||
|
import 'package:halkyon_train_flutter/src/feature/music/data/music_store.dart';
|
||||||
|
import 'package:http/http.dart' as http;
|
||||||
|
import 'package:http/testing.dart';
|
||||||
|
|
||||||
|
/// A Jellyfin server for tests (ADR 0030, D47), at
|
||||||
|
/// `https://music.example.org`: Ada signs in with the password `pw` or with
|
||||||
|
/// Quick Connect once [approve]d; her token `token-<n>` holds until
|
||||||
|
/// [revoke]d.
|
||||||
|
class FakeJellyfin({
|
||||||
|
/// Whether it signs in with Quick Connect.
|
||||||
|
final bool quickConnect = true,
|
||||||
|
}) {
|
||||||
|
final requests = <http.Request>[];
|
||||||
|
var _approved = false;
|
||||||
|
var _tokens = 0;
|
||||||
|
final _valid = <String>{};
|
||||||
|
List<Map<String, Object?>> playlists = [
|
||||||
|
{'Id': 'p1', 'Name': 'Tempo', 'MediaType': 'Audio', 'ChildCount': 42},
|
||||||
|
{'Id': 'p2', 'Name': 'Easy', 'MediaType': 'Audio', 'ChildCount': 1},
|
||||||
|
];
|
||||||
|
|
||||||
|
void approve() => _approved = true;
|
||||||
|
|
||||||
|
void revoke() => _valid.clear();
|
||||||
|
|
||||||
|
/// Tokens given and still valid.
|
||||||
|
Set<String> get valid => {..._valid};
|
||||||
|
|
||||||
|
late final http.Client client = MockClient((request) async {
|
||||||
|
requests.add(request);
|
||||||
|
final token = RegExp(
|
||||||
|
'Token="([^"]+)"',
|
||||||
|
).firstMatch(request.headers['authorization'] ?? '')?.group(1);
|
||||||
|
http.Response json(Object? body, [int status = 200]) =>
|
||||||
|
http.Response(jsonEncode(body), status);
|
||||||
|
String signIn() {
|
||||||
|
final token = 'token-${++_tokens}';
|
||||||
|
_valid.add(token);
|
||||||
|
return token;
|
||||||
|
}
|
||||||
|
|
||||||
|
switch ('${request.method} ${request.url.path}') {
|
||||||
|
case 'GET /System/Info/Public':
|
||||||
|
return json({
|
||||||
|
'ServerName': 'Home',
|
||||||
|
'Id': 'server-1',
|
||||||
|
'Version': '10.10.7',
|
||||||
|
});
|
||||||
|
case 'GET /QuickConnect/Enabled':
|
||||||
|
return json(quickConnect);
|
||||||
|
case 'POST /QuickConnect/Initiate':
|
||||||
|
return quickConnect
|
||||||
|
? json({'Code': '123456', 'Secret': 'secret-1'})
|
||||||
|
: json(null, 401);
|
||||||
|
case 'GET /QuickConnect/Connect':
|
||||||
|
return json({'Authenticated': _approved});
|
||||||
|
case 'POST /Users/AuthenticateWithQuickConnect':
|
||||||
|
return _approved
|
||||||
|
? json({
|
||||||
|
'AccessToken': signIn(),
|
||||||
|
'User': {'Id': 'user-1', 'Name': 'Ada'},
|
||||||
|
})
|
||||||
|
: json(null, 401);
|
||||||
|
case 'POST /Users/AuthenticateByName':
|
||||||
|
final body = jsonDecode(request.body) as Map<String, Object?>;
|
||||||
|
return body['Username'] == 'Ada' && body['Pw'] == 'pw'
|
||||||
|
? json({
|
||||||
|
'AccessToken': signIn(),
|
||||||
|
'User': {'Id': 'user-1', 'Name': 'Ada'},
|
||||||
|
})
|
||||||
|
: json(null, 401);
|
||||||
|
case 'GET /Items':
|
||||||
|
return _valid.contains(token)
|
||||||
|
? json({'Items': playlists})
|
||||||
|
: json(null, 401);
|
||||||
|
case 'POST /Sessions/Logout':
|
||||||
|
_valid.remove(token);
|
||||||
|
return http.Response('', 204);
|
||||||
|
}
|
||||||
|
return http.Response('', 404);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A [JellyfinTokenStore] in memory, with others' tokens in [others].
|
||||||
|
class MemoryJellyfinTokenStore() implements JellyfinTokenStore {
|
||||||
|
String? token;
|
||||||
|
final others = <String>{'someone-else'};
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<String?> read() async => token;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> keep(String? token) async => this.token = token;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> forgetOthers() async => others.clear();
|
||||||
|
}
|
||||||
|
|
@ -986,6 +986,59 @@ Strava. What was found, 27 Oct:
|
||||||
read Train's database; a role of its own for it is left for later. restic now forgets
|
read Train's database; a role of its own for it is left for later. restic now forgets
|
||||||
snapshots grouped by host alone, so that the snapshots before Train's dump joined
|
snapshots grouped by host alone, so that the snapshots before Train's dump joined
|
||||||
them aren't kept for good as a group of their own.
|
them aren't kept for good as a group of their own.
|
||||||
|
- **D47 Music from Jellyfin** (recommended, the developer's; 29 Oct; D4's how):
|
||||||
|
- **Signing in**: with Jellyfin's Quick Connect (Train shows a code, the person
|
||||||
|
approves it in another Jellyfin client, Train gets a token of its own), else with
|
||||||
|
the user name and password where the server has Quick Connect off (over the
|
||||||
|
password alone, which always passes through Train, and over an API key, which only
|
||||||
|
an administrator makes and which carries an administrator's rights).
|
||||||
|
- **Which tracks**: those of the Jellyfin playlists the person marks for running in
|
||||||
|
Train's settings (over the whole library, hours of downloads and analysis on a large
|
||||||
|
one, and over genres, only as good as the library's tags).
|
||||||
|
- **Kept on this phone only**: the token in secure storage, the server's address, the
|
||||||
|
marked playlists and each track's tempo in a file of the person's on the device,
|
||||||
|
nothing on Train's server (over sealed, synced rows, a synced model and its
|
||||||
|
migration so that a second phone skips the analysis; music plays on phones only,
|
||||||
|
D5). A second phone signs in and analyses again.
|
||||||
|
- **Offline**: the marked playlists' tracks are downloaded on unmetered networks only
|
||||||
|
and kept up with the playlists; the settings show their size and delete them (over
|
||||||
|
fetching what a run needs as it starts, which makes the start wait on the network
|
||||||
|
and a free run guess its length, and over streaming, which D4 ruled out).
|
||||||
|
- **Tempo by Train's own analysis, in Kotlin** (over aubio, GPL-3 C, which needs a
|
||||||
|
native build per ABI; TarsosDSP, GPL-3 Java, unmaintained since 2021; Essentia,
|
||||||
|
AGPL-3 C++, tens of MB and a heavy build): Android's decoder reads a minute of the
|
||||||
|
downloaded track, an onset envelope and its autocorrelation give the beats per
|
||||||
|
minute, tested on the JVM with beats of known tempo. A BPM tag the file carries is
|
||||||
|
taken first.
|
||||||
|
- **Played by Media3's ExoPlayer in the recording service** (Apache-2.0, AndroidX;
|
||||||
|
over just_audio and audio_service in Flutter, whose engine may stop with the
|
||||||
|
activity and whose player would talk to the service's cues and plan runner across
|
||||||
|
the channel): beside the cues, which duck it, and the plan runner, it plays on with
|
||||||
|
the screen off and picks the next track there.
|
||||||
|
- **Cadence followed**: the one the step detector measures, smoothed over 30 s,
|
||||||
|
without a plan and for a step aiming at heart rate or the like; for a step aiming
|
||||||
|
at a pace, the cadence the person's own past runs show at that pace (steps against
|
||||||
|
speed), the measured one until three runs show it (over the measured cadence alone,
|
||||||
|
which never pulls the person towards the step's pace, and over a cadence set by
|
||||||
|
hand). A track fits at its tempo, its half or its double, sped up or slowed down by
|
||||||
|
at most **6 %** with the pitch kept (over 10 %, where voices start to sound off, and
|
||||||
|
over none, which needs a large playlist). A metronome at the cadence plays where no
|
||||||
|
library is set (D4).
|
||||||
|
- In parts: 10a signing in to Jellyfin and marking playlists in the settings; 10b
|
||||||
|
downloading the marked tracks and working out their tempo; 10c playing them in a
|
||||||
|
recording, by cadence, and the metronome.
|
||||||
|
Built: 10a. *Music* in the account sheet, on Android: the server's address (https
|
||||||
|
where none is typed; a path kept, as for a server under `/jellyfin`), then Quick
|
||||||
|
Connect's code, asked about every 3 s and given up after 9 minutes (Jellyfin forgets
|
||||||
|
a request after ten), or *Use a password*, which a server with Quick Connect off
|
||||||
|
asks for at once; Quick Connect as Jellyfin 10.9 and later have it. Train signs in
|
||||||
|
as the client "Halkyon Train" with a device ID of its own, made once and kept, so
|
||||||
|
that the server lists it as a device of the person's. The token is in the keyring
|
||||||
|
under the person's ID, and the tokens of people the device no longer keeps are
|
||||||
|
forgotten as the library opens; a token Jellyfin takes back signs the person out
|
||||||
|
on the phone too. Signing out ends the token on the server where it answers. The
|
||||||
|
person's playlists of music are listed (a playlist naming no type counts as one,
|
||||||
|
one of videos doesn't) and marked, the marked ones by ID and name in `music.json`.
|
||||||
|
|
||||||
## Consequences
|
## Consequences
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue