feat(train): smaller copies of lossless tracks, transcoded by Jellyfin to Opus (ADR 0030, D52)
Smaller copies, on by default on the music screen, has the music job fetch a file above 256 kbps as Opus at 128 kbps in WebM, but for the lossy codecs (MP3, AAC, Vorbis, Opus) at any rate. Each track keeps which copy its file is; switching fetches the other copy without working out the tempo again, the file kept until the new one is whole. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
This commit is contained in:
parent
409a2519f9
commit
2a7cd927c4
23 changed files with 379 additions and 39 deletions
File diff suppressed because one or more lines are too long
|
|
@ -9,9 +9,10 @@ import java.net.URL
|
||||||
/**
|
/**
|
||||||
* Downloads the tracks the app listed (ADR 0030, D52), for the music job: each from Jellyfin
|
* Downloads the tracks the app listed (ADR 0030, D52), for the music job: each from Jellyfin
|
||||||
* into the person's music folder, through a part file renamed once whole, its tempo worked out
|
* into the person's music folder, through a part file renamed once whole, its tempo worked out
|
||||||
* where asked, and a line of JSON each appended to the folder's [FILE] (`id`, `bytes`, `tempo`,
|
* where asked, and a line of JSON each appended to the folder's [FILE] (`id`, `copy`, `bytes`,
|
||||||
* `analysed`), which the app takes in. What a line names already is skipped, as the app may not
|
* `tempo`, `analysed`), which the app takes in. A track whose copy a line names already is
|
||||||
* have taken it in yet. Never logs a track's name.
|
* skipped, as the app may not have taken it in yet. A new copy replaces the file kept only once
|
||||||
|
* it is whole. Never logs a track's name.
|
||||||
*/
|
*/
|
||||||
class MusicFetcher(
|
class MusicFetcher(
|
||||||
private val token: (String) -> String?,
|
private val token: (String) -> String?,
|
||||||
|
|
@ -29,8 +30,11 @@ class MusicFetcher(
|
||||||
val tracks: List<Track>,
|
val tracks: List<Track>,
|
||||||
)
|
)
|
||||||
|
|
||||||
/** A track to fetch: its ID, the path and query under the server's address, and whether its tempo is wanted. */
|
/**
|
||||||
class Track(val id: String, val path: String, val analyse: Boolean = true)
|
* A track to fetch: its ID, the copy wanted (`original` or `smaller`, D52, handed back in its
|
||||||
|
* line), the path and query under the server's address, and whether its tempo is wanted.
|
||||||
|
*/
|
||||||
|
class Track(val id: String, val path: String, val analyse: Boolean = true, val copy: String = "original")
|
||||||
|
|
||||||
enum class Outcome {
|
enum class Outcome {
|
||||||
/** Everything fetched, or skipped for its own error. */
|
/** Everything fetched, or skipped for its own error. */
|
||||||
|
|
@ -52,7 +56,9 @@ class MusicFetcher(
|
||||||
val done = fetchedIds(lines)
|
val done = fetchedIds(lines)
|
||||||
for (track in job.tracks) {
|
for (track in job.tracks) {
|
||||||
if (stopped()) return Outcome.STOPPED
|
if (stopped()) return Outcome.STOPPED
|
||||||
if (track.id in done || !ID.matches(track.id) || !track.path.startsWith("/Audio/")) continue
|
if ("${track.id}/${track.copy}" in done || !ID.matches(track.id) || !track.path.startsWith("/Audio/")) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
val file = File(job.folder, track.id)
|
val file = File(job.folder, track.id)
|
||||||
val part = File(job.folder, "${track.id}.part")
|
val part = File(job.folder, "${track.id}.part")
|
||||||
val connection = URL(job.address.trimEnd('/') + track.path).openConnection() as HttpURLConnection
|
val connection = URL(job.address.trimEnd('/') + track.path).openConnection() as HttpURLConnection
|
||||||
|
|
@ -78,13 +84,13 @@ class MusicFetcher(
|
||||||
}
|
}
|
||||||
val beats = if (track.analyse) tempo(file) else null
|
val beats = if (track.analyse) tempo(file) else null
|
||||||
val line =
|
val line =
|
||||||
mutableMapOf<String, Any?>("id" to track.id, "bytes" to file.length())
|
mutableMapOf<String, Any?>("id" to track.id, "copy" to track.copy, "bytes" to file.length())
|
||||||
.apply { if (track.analyse) putAll(mapOf("tempo" to beats, "analysed" to true)) }
|
.apply { if (track.analyse) putAll(mapOf("tempo" to beats, "analysed" to true)) }
|
||||||
FileOutputStream(lines, true).use { out ->
|
FileOutputStream(lines, true).use { out ->
|
||||||
out.write((JsonText.of(line) + "\n").toByteArray())
|
out.write((JsonText.of(line) + "\n").toByteArray())
|
||||||
out.fd.sync()
|
out.fd.sync()
|
||||||
}
|
}
|
||||||
done += track.id
|
done += "${track.id}/${track.copy}"
|
||||||
fetched()
|
fetched()
|
||||||
}
|
}
|
||||||
return Outcome.DONE
|
return Outcome.DONE
|
||||||
|
|
@ -96,10 +102,15 @@ class MusicFetcher(
|
||||||
|
|
||||||
private const val TIMEOUT_MS = 30_000
|
private const val TIMEOUT_MS = 30_000
|
||||||
private val ID = Regex("[A-Za-z0-9-]+")
|
private val ID = Regex("[A-Za-z0-9-]+")
|
||||||
private val LINE_ID = Regex("^\\{\"id\":\"([A-Za-z0-9-]+)\"")
|
private val LINE = Regex("^\\{\"id\":\"([A-Za-z0-9-]+)\",\"copy\":\"(\\w+)\"")
|
||||||
|
|
||||||
|
/** What the lines name, as `<id>/<copy>`. */
|
||||||
private fun fetchedIds(lines: File): MutableSet<String> =
|
private fun fetchedIds(lines: File): MutableSet<String> =
|
||||||
if (!lines.exists()) mutableSetOf()
|
if (!lines.exists()) mutableSetOf()
|
||||||
else lines.readLines().mapNotNull { LINE_ID.find(it)?.groupValues?.get(1) }.toMutableSet()
|
else
|
||||||
|
lines
|
||||||
|
.readLines()
|
||||||
|
.mapNotNull { LINE.find(it)?.groupValues?.let { (_, id, copy) -> "$id/$copy" } }
|
||||||
|
.toMutableSet()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -119,7 +119,12 @@ class MusicWorker(context: Context, parameters: WorkerParameters) : CoroutineWor
|
||||||
tracks =
|
tracks =
|
||||||
(0 until tracks.length()).map {
|
(0 until tracks.length()).map {
|
||||||
val track = tracks.getJSONObject(it)
|
val track = tracks.getJSONObject(it)
|
||||||
MusicFetcher.Track(track.getString("id"), track.getString("path"), track.optBoolean("analyse", true))
|
MusicFetcher.Track(
|
||||||
|
track.getString("id"),
|
||||||
|
track.getString("path"),
|
||||||
|
track.optBoolean("analyse", true),
|
||||||
|
track.optString("copy", "original"),
|
||||||
|
)
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -91,8 +91,8 @@ class MusicFetcherTest {
|
||||||
assertEquals("audio of t1", File(folder, "t1").readText())
|
assertEquals("audio of t1", File(folder, "t1").readText())
|
||||||
assertEquals(
|
assertEquals(
|
||||||
listOf(
|
listOf(
|
||||||
"""{"id":"t1","bytes":11,"tempo":128.0,"analysed":true}""",
|
"""{"id":"t1","copy":"original","bytes":11,"tempo":128.0,"analysed":true}""",
|
||||||
"""{"id":"t2","bytes":11,"tempo":null,"analysed":true}""",
|
"""{"id":"t2","copy":"original","bytes":11,"tempo":null,"analysed":true}""",
|
||||||
),
|
),
|
||||||
lines(),
|
lines(),
|
||||||
)
|
)
|
||||||
|
|
@ -114,7 +114,27 @@ class MusicFetcherTest {
|
||||||
fun `a track whose tempo is known is fetched without working it out`() {
|
fun `a track whose tempo is known is fetched without working it out`() {
|
||||||
fetcher().fetch(job("t1", analyse = false))
|
fetcher().fetch(job("t1", analyse = false))
|
||||||
|
|
||||||
assertEquals(listOf("""{"id":"t1","bytes":11}"""), lines())
|
assertEquals(listOf("""{"id":"t1","copy":"original","bytes":11}"""), lines())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a smaller copy replaces the file kept once whole, though a line names the original`() {
|
||||||
|
fetcher().fetch(job("t1"))
|
||||||
|
val smaller =
|
||||||
|
MusicFetcher.Job(
|
||||||
|
person = "ada",
|
||||||
|
folder = folder,
|
||||||
|
address = address,
|
||||||
|
client = "MediaBrowser",
|
||||||
|
tracks = listOf(MusicFetcher.Track("t1", "/Audio/t1/stream.webm?audioCodec=opus", false, "smaller")),
|
||||||
|
)
|
||||||
|
fetcher().fetch(smaller)
|
||||||
|
fetcher().fetch(smaller)
|
||||||
|
|
||||||
|
assertEquals("/jellyfin/Audio/t1/stream.webm?audioCodec=opus", asked.last())
|
||||||
|
assertEquals(2, asked.size)
|
||||||
|
assertEquals("""{"id":"t1","copy":"smaller","bytes":11}""", lines().last())
|
||||||
|
assertTrue(File(folder, "t1").exists())
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
|
|
|
||||||
|
|
@ -508,6 +508,8 @@
|
||||||
"musicKeptDelete": "Löschen",
|
"musicKeptDelete": "Löschen",
|
||||||
"musicKeptDeleteTitle": "Die Musik auf diesem Telefon löschen?",
|
"musicKeptDeleteTitle": "Die Musik auf diesem Telefon löschen?",
|
||||||
"musicKeptDeleteText": "Alle Playlists werden abgewählt und ihre Titel von diesem Telefon gelöscht. Auf deinem Jellyfin-Server bleiben sie.",
|
"musicKeptDeleteText": "Alle Playlists werden abgewählt und ihre Titel von diesem Telefon gelöscht. Auf deinem Jellyfin-Server bleiben sie.",
|
||||||
|
"musicSmaller": "Kleinere Kopien",
|
||||||
|
"musicSmallerHint": "Verlustfreie Dateien wie FLAC als Opus mit 128 kbit/s, die dein Server erstellt. MP3 und AAC bleiben, wie sie sind.",
|
||||||
"recordMusic": "Musik",
|
"recordMusic": "Musik",
|
||||||
"recordMusicOn": "Musik",
|
"recordMusicOn": "Musik",
|
||||||
"recordMetronome": "Metronom",
|
"recordMetronome": "Metronom",
|
||||||
|
|
|
||||||
|
|
@ -641,6 +641,8 @@
|
||||||
"musicKeptDelete": "Delete",
|
"musicKeptDelete": "Delete",
|
||||||
"musicKeptDeleteTitle": "Delete the music on this phone?",
|
"musicKeptDeleteTitle": "Delete the music on this phone?",
|
||||||
"musicKeptDeleteText": "Every playlist is unmarked, and its tracks deleted from this phone. They stay on your Jellyfin server.",
|
"musicKeptDeleteText": "Every playlist is unmarked, and its tracks deleted from this phone. They stay on your Jellyfin server.",
|
||||||
|
"musicSmaller": "Smaller copies",
|
||||||
|
"musicSmallerHint": "Lossless files such as FLAC kept as Opus at 128 kbps, which your server makes. MP3 and AAC stay as they are.",
|
||||||
"recordMusic": "Music",
|
"recordMusic": "Music",
|
||||||
"recordMusicOn": "Music",
|
"recordMusicOn": "Music",
|
||||||
"@recordMusicOn": {"description": "The segment playing the kept tracks by cadence."},
|
"@recordMusicOn": {"description": "The segment playing the kept tracks by cadence."},
|
||||||
|
|
|
||||||
|
|
@ -3070,6 +3070,18 @@ abstract class AppLocalizations {
|
||||||
/// **'Every playlist is unmarked, and its tracks deleted from this phone. They stay on your Jellyfin server.'**
|
/// **'Every playlist is unmarked, and its tracks deleted from this phone. They stay on your Jellyfin server.'**
|
||||||
String get musicKeptDeleteText;
|
String get musicKeptDeleteText;
|
||||||
|
|
||||||
|
/// No description provided for @musicSmaller.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Smaller copies'**
|
||||||
|
String get musicSmaller;
|
||||||
|
|
||||||
|
/// No description provided for @musicSmallerHint.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Lossless files such as FLAC kept as Opus at 128 kbps, which your server makes. MP3 and AAC stay as they are.'**
|
||||||
|
String get musicSmallerHint;
|
||||||
|
|
||||||
/// No description provided for @recordMusic.
|
/// No description provided for @recordMusic.
|
||||||
///
|
///
|
||||||
/// In en, this message translates to:
|
/// In en, this message translates to:
|
||||||
|
|
|
||||||
|
|
@ -1831,6 +1831,13 @@ class AppLocalizationsDe extends AppLocalizations {
|
||||||
String get musicKeptDeleteText =>
|
String get musicKeptDeleteText =>
|
||||||
'Alle Playlists werden abgewählt und ihre Titel von diesem Telefon gelöscht. Auf deinem Jellyfin-Server bleiben sie.';
|
'Alle Playlists werden abgewählt und ihre Titel von diesem Telefon gelöscht. Auf deinem Jellyfin-Server bleiben sie.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get musicSmaller => 'Kleinere Kopien';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get musicSmallerHint =>
|
||||||
|
'Verlustfreie Dateien wie FLAC als Opus mit 128 kbit/s, die dein Server erstellt. MP3 und AAC bleiben, wie sie sind.';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get recordMusic => 'Musik';
|
String get recordMusic => 'Musik';
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1819,6 +1819,13 @@ class AppLocalizationsEn extends AppLocalizations {
|
||||||
String get musicKeptDeleteText =>
|
String get musicKeptDeleteText =>
|
||||||
'Every playlist is unmarked, and its tracks deleted from this phone. They stay on your Jellyfin server.';
|
'Every playlist is unmarked, and its tracks deleted from this phone. They stay on your Jellyfin server.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get musicSmaller => 'Smaller copies';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get musicSmallerHint =>
|
||||||
|
'Lossless files such as FLAC kept as Opus at 128 kbps, which your server makes. MP3 and AAC stay as they are.';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get recordMusic => 'Music';
|
String get recordMusic => 'Music';
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -250,6 +250,9 @@ class MusicCubit(
|
||||||
void mark(JellyfinPlaylist playlist, {required bool on}) =>
|
void mark(JellyfinPlaylist playlist, {required bool on}) =>
|
||||||
_library.mark(playlist, on: on);
|
_library.mark(playlist, on: on);
|
||||||
|
|
||||||
|
/// Keeps smaller copies of large files, or the files as they are (D52).
|
||||||
|
void keepSmaller({required bool on}) => _library.keepSmaller(on: on);
|
||||||
|
|
||||||
/// Unmarks every playlist, which deletes their music on this phone.
|
/// Unmarks every playlist, which deletes their music on this phone.
|
||||||
void unmarkAll() => _library.unmarkAll();
|
void unmarkAll() => _library.unmarkAll();
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -236,7 +236,7 @@ class JellyfinClient(
|
||||||
address,
|
address,
|
||||||
'/Playlists/$playlist/Items',
|
'/Playlists/$playlist/Items',
|
||||||
token: session.token,
|
token: session.token,
|
||||||
query: {'userId': session.userId},
|
query: {'userId': session.userId, 'fields': 'MediaSources'},
|
||||||
);
|
);
|
||||||
return [
|
return [
|
||||||
if (answer case {'Items': final List<Object?> items})
|
if (answer case {'Items': final List<Object?> items})
|
||||||
|
|
@ -260,13 +260,32 @@ class JellyfinClient(
|
||||||
final int ticks => Duration(microseconds: ticks ~/ 10),
|
final int ticks => Duration(microseconds: ticks ~/ 10),
|
||||||
_ => null,
|
_ => null,
|
||||||
},
|
},
|
||||||
|
bitrate: switch (item['MediaSources']) {
|
||||||
|
[{'Bitrate': final int bitrate}, ...] => bitrate,
|
||||||
|
_ => null,
|
||||||
|
},
|
||||||
|
codec: switch (item['MediaSources']) {
|
||||||
|
[{'MediaStreams': final List<Object?> streams}, ...] =>
|
||||||
|
streams.whereType<Map<String, Object?>>().firstWhere(
|
||||||
|
(stream) => stream['Type'] == 'Audio',
|
||||||
|
orElse: () => const {},
|
||||||
|
)['Codec']
|
||||||
|
as String?,
|
||||||
|
_ => null,
|
||||||
|
},
|
||||||
),
|
),
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The path and query under the server's address the track [id] is
|
/// The path and query under the server's address the track [id] is
|
||||||
/// downloaded from, as Jellyfin keeps it (D47, D52).
|
/// downloaded from as [copy] (D47, D52): as Jellyfin keeps it, or
|
||||||
static String audioPath(String id) => '/Audio/$id/stream?static=true';
|
/// transcoded to Opus at 128 kbps in WebM.
|
||||||
|
static String audioPath(String id, MusicCopy copy) => switch (copy) {
|
||||||
|
MusicCopy.original => '/Audio/$id/stream?static=true',
|
||||||
|
MusicCopy.smaller =>
|
||||||
|
'/Audio/$id/stream.webm'
|
||||||
|
'?audioCodec=opus&audioBitRate=128000&maxAudioChannels=2',
|
||||||
|
};
|
||||||
|
|
||||||
/// The `Authorization` header Train asks with, without a token: the
|
/// The `Authorization` header Train asks with, without a token: the
|
||||||
/// music job adds the token it keeps (D52).
|
/// music job adds the token it keeps (D52).
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,7 @@ import 'package:halkyon_core/halkyon_core.dart';
|
||||||
import 'package:halkyon_train_flutter/src/feature/music/data/jellyfin_client.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_library.dart';
|
||||||
import 'package:halkyon_train_flutter/src/feature/music/data/music_platform.dart';
|
import 'package:halkyon_train_flutter/src/feature/music/data/music_platform.dart';
|
||||||
|
import 'package:halkyon_train_flutter/src/feature/music/data/music_store.dart';
|
||||||
import 'package:halkyon_train_flutter/src/feature/music/model/music_track.dart';
|
import 'package:halkyon_train_flutter/src/feature/music/model/music_track.dart';
|
||||||
import 'package:path/path.dart' as p;
|
import 'package:path/path.dart' as p;
|
||||||
|
|
||||||
|
|
@ -103,10 +104,12 @@ class MusicDownloads(
|
||||||
}
|
}
|
||||||
|
|
||||||
var _marked = <String>{};
|
var _marked = <String>{};
|
||||||
|
bool? _smaller;
|
||||||
|
|
||||||
void _marksChanged() {
|
void _marksChanged() {
|
||||||
final marked = {..._library.settings.value.playlists.keys};
|
final settings = _library.settings.value;
|
||||||
if (!setEquals(marked, _marked)) {
|
if (!setEquals({...settings.playlists.keys}, _marked) ||
|
||||||
|
settings.smaller != _smaller) {
|
||||||
unawaited(sync());
|
unawaited(sync());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -115,6 +118,7 @@ class MusicDownloads(
|
||||||
_takeIn();
|
_takeIn();
|
||||||
final settings = _library.settings.value;
|
final settings = _library.settings.value;
|
||||||
_marked = {...settings.playlists.keys};
|
_marked = {...settings.playlists.keys};
|
||||||
|
_smaller = settings.smaller;
|
||||||
// What no marked playlist holds goes first, with or without a network.
|
// What no marked playlist holds goes first, with or without a network.
|
||||||
_keepOnly((track) => track.playlists.any(_marked.contains));
|
_keepOnly((track) => track.playlists.any(_marked.contains));
|
||||||
if (!settings.signedIn || _marked.isEmpty || _closed) {
|
if (!settings.signedIn || _marked.isEmpty || _closed) {
|
||||||
|
|
@ -147,6 +151,8 @@ class MusicDownloads(
|
||||||
artist: () => track.artist,
|
artist: () => track.artist,
|
||||||
album: () => track.album,
|
album: () => track.album,
|
||||||
length: () => track.length,
|
length: () => track.length,
|
||||||
|
bitrate: () => track.bitrate,
|
||||||
|
codec: () => track.codec,
|
||||||
playlists: {
|
playlists: {
|
||||||
if (listed[track.id] case final seen?) ...seen.playlists,
|
if (listed[track.id] case final seen?) ...seen.playlists,
|
||||||
playlist,
|
playlist,
|
||||||
|
|
@ -179,14 +185,27 @@ class MusicDownloads(
|
||||||
_set(kept.value.copyWith(tracks: listed));
|
_set(kept.value.copyWith(tracks: listed));
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The missing tracks the job is to fetch, or none where no one is
|
/// What [track]'s file is to be (D52): a smaller copy where the settings
|
||||||
/// signed in or nothing is missing.
|
/// say so and its file is above [MusicSettings.smallerAbove], but for a
|
||||||
|
/// lossy codec's.
|
||||||
|
MusicCopy _wanted(MusicTrack track) =>
|
||||||
|
_library.settings.value.smaller &&
|
||||||
|
(track.bitrate ?? 0) > MusicSettings.smallerAbove &&
|
||||||
|
!MusicSettings.lossy.contains(track.codec?.toLowerCase())
|
||||||
|
? MusicCopy.smaller
|
||||||
|
: MusicCopy.original;
|
||||||
|
|
||||||
|
/// The tracks the job is to fetch: those missing, without a tempo, or
|
||||||
|
/// kept as another copy than wanted, which stays until the new one is
|
||||||
|
/// whole.
|
||||||
List<MusicJobTrack> get _missing => [
|
List<MusicJobTrack> get _missing => [
|
||||||
for (final track in kept.value.tracks.values)
|
for (final track in kept.value.tracks.values)
|
||||||
if (!track.downloaded || !track.analysed)
|
if (_wanted(track) case final copy
|
||||||
|
when !track.downloaded || !track.analysed || track.copy != copy)
|
||||||
(
|
(
|
||||||
id: track.id,
|
id: track.id,
|
||||||
path: JellyfinClient.audioPath(track.id),
|
copy: copy,
|
||||||
|
path: JellyfinClient.audioPath(track.id, copy),
|
||||||
analyse: !track.analysed,
|
analyse: !track.analysed,
|
||||||
),
|
),
|
||||||
];
|
];
|
||||||
|
|
@ -246,16 +265,20 @@ class MusicDownloads(
|
||||||
'bytes': final int bytes,
|
'bytes': final int bytes,
|
||||||
} &&
|
} &&
|
||||||
final Map<String, Object?> fetched) {
|
final Map<String, Object?> fetched) {
|
||||||
|
final copy =
|
||||||
|
MusicCopy.values.asNameMap()[fetched['copy']] ??
|
||||||
|
MusicCopy.original;
|
||||||
if (kept.value.tracks.containsKey(id)) {
|
if (kept.value.tracks.containsKey(id)) {
|
||||||
_update(
|
_update(
|
||||||
id,
|
id,
|
||||||
(track) => fetched['analysed'] == true
|
(track) => fetched['analysed'] == true
|
||||||
? track.copyWith(
|
? track.copyWith(
|
||||||
bytes: () => bytes,
|
bytes: () => bytes,
|
||||||
|
copy: copy,
|
||||||
tempo: () => (fetched['tempo'] as num?)?.toDouble(),
|
tempo: () => (fetched['tempo'] as num?)?.toDouble(),
|
||||||
analysed: true,
|
analysed: true,
|
||||||
)
|
)
|
||||||
: track.copyWith(bytes: () => bytes),
|
: track.copyWith(bytes: () => bytes, copy: copy),
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
_deleteFile(id);
|
_deleteFile(id);
|
||||||
|
|
|
||||||
|
|
@ -106,6 +106,11 @@ class MusicLibrary(
|
||||||
void playInRecording(String choice) =>
|
void playInRecording(String choice) =>
|
||||||
_set(settings.value.copyWith(inRecording: choice));
|
_set(settings.value.copyWith(inRecording: choice));
|
||||||
|
|
||||||
|
/// Keeps smaller copies of files above [MusicSettings.smallerAbove], or
|
||||||
|
/// the files as they are (D52).
|
||||||
|
void keepSmaller({required bool on}) =>
|
||||||
|
_set(settings.value.copyWith(smaller: on));
|
||||||
|
|
||||||
/// Unmarks every playlist, which deletes their music on this phone.
|
/// Unmarks every playlist, which deletes their music on this phone.
|
||||||
void unmarkAll() => _set(settings.value.copyWith(playlists: const {}));
|
void unmarkAll() => _set(settings.value.copyWith(playlists: const {}));
|
||||||
|
|
||||||
|
|
@ -132,6 +137,7 @@ class MusicLibrary(
|
||||||
MusicSettings(
|
MusicSettings(
|
||||||
deviceId: settings.value.deviceId,
|
deviceId: settings.value.deviceId,
|
||||||
inRecording: settings.value.inRecording,
|
inRecording: settings.value.inRecording,
|
||||||
|
smaller: settings.value.smaller,
|
||||||
address: server.address,
|
address: server.address,
|
||||||
serverName: server.name,
|
serverName: server.name,
|
||||||
serverId: server.id,
|
serverId: server.id,
|
||||||
|
|
|
||||||
|
|
@ -2,10 +2,17 @@ import 'dart:convert';
|
||||||
|
|
||||||
import 'package:equatable/equatable.dart';
|
import 'package:equatable/equatable.dart';
|
||||||
import 'package:flutter/services.dart';
|
import 'package:flutter/services.dart';
|
||||||
|
import 'package:halkyon_train_flutter/src/feature/music/model/music_track.dart';
|
||||||
|
|
||||||
/// A track for the music job to fetch (ADR 0030, D52): its ID, the path and
|
/// A track for the music job to fetch (ADR 0030, D52): its ID, the copy
|
||||||
/// query under the server's address, and whether its tempo is wanted.
|
/// wanted and the path and query under the server's address it comes from,
|
||||||
typedef MusicJobTrack = ({String id, String path, bool analyse});
|
/// and whether its tempo is wanted.
|
||||||
|
typedef MusicJobTrack = ({
|
||||||
|
String id,
|
||||||
|
MusicCopy copy,
|
||||||
|
String path,
|
||||||
|
bool analyse,
|
||||||
|
});
|
||||||
|
|
||||||
/// What the music job fetches (D52): the person's missing tracks from their
|
/// What the music job fetches (D52): the person's missing tracks from their
|
||||||
/// server into their music folder, signed in with the token Train keeps for
|
/// server into their music folder, signed in with the token Train keeps for
|
||||||
|
|
@ -27,7 +34,12 @@ final class const MusicJob({
|
||||||
'client': client,
|
'client': client,
|
||||||
'tracks': [
|
'tracks': [
|
||||||
for (final track in tracks)
|
for (final track in tracks)
|
||||||
{'id': track.id, 'path': track.path, 'analyse': track.analyse},
|
{
|
||||||
|
'id': track.id,
|
||||||
|
'copy': track.copy.name,
|
||||||
|
'path': track.path,
|
||||||
|
'analyse': track.analyse,
|
||||||
|
},
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -28,18 +28,34 @@ final class const MusicSettings({
|
||||||
/// What the Record sheet plays, as last chosen (D48): a `MusicChoice`'s
|
/// What the Record sheet plays, as last chosen (D48): a `MusicChoice`'s
|
||||||
/// name; null before.
|
/// name; null before.
|
||||||
final String? inRecording,
|
final String? inRecording,
|
||||||
|
|
||||||
|
/// Whether files above [smallerAbove], but for [lossy] ones, are kept as
|
||||||
|
/// smaller copies (D52).
|
||||||
|
final bool smaller = true,
|
||||||
}) with Equatable {
|
}) with Equatable {
|
||||||
|
/// The bits a second above which a smaller copy is kept of a file whose
|
||||||
|
/// codec isn't one of [lossy] (D52): FLAC, ALAC, WAV and their like.
|
||||||
|
static const smallerAbove = 256000;
|
||||||
|
|
||||||
|
/// The lossy codecs, as Jellyfin names them, whose files are kept as they
|
||||||
|
/// are at any rate: transcoding them loses their quality twice.
|
||||||
|
static const lossy = {'mp3', 'aac', 'vorbis', 'opus'};
|
||||||
|
|
||||||
/// Whether someone is signed in (their token in the keyring).
|
/// Whether someone is signed in (their token in the keyring).
|
||||||
bool get signedIn => address != null && userId != null;
|
bool get signedIn => address != null && userId != null;
|
||||||
|
|
||||||
/// Signed out of the server: the device's ID kept, as Jellyfin lists
|
/// Signed out of the server: the device's ID kept, as Jellyfin lists
|
||||||
/// devices by it.
|
/// devices by it.
|
||||||
MusicSettings signedOut() =>
|
MusicSettings signedOut() => MusicSettings(
|
||||||
MusicSettings(deviceId: deviceId, inRecording: inRecording);
|
deviceId: deviceId,
|
||||||
|
inRecording: inRecording,
|
||||||
|
smaller: smaller,
|
||||||
|
);
|
||||||
|
|
||||||
MusicSettings copyWith({
|
MusicSettings copyWith({
|
||||||
Map<String, String>? playlists,
|
Map<String, String>? playlists,
|
||||||
String? inRecording,
|
String? inRecording,
|
||||||
|
bool? smaller,
|
||||||
}) => MusicSettings(
|
}) => MusicSettings(
|
||||||
deviceId: deviceId,
|
deviceId: deviceId,
|
||||||
address: address,
|
address: address,
|
||||||
|
|
@ -49,6 +65,7 @@ final class const MusicSettings({
|
||||||
userName: userName,
|
userName: userName,
|
||||||
playlists: playlists ?? this.playlists,
|
playlists: playlists ?? this.playlists,
|
||||||
inRecording: inRecording ?? this.inRecording,
|
inRecording: inRecording ?? this.inRecording,
|
||||||
|
smaller: smaller ?? this.smaller,
|
||||||
);
|
);
|
||||||
|
|
||||||
@override
|
@override
|
||||||
|
|
@ -61,6 +78,7 @@ final class const MusicSettings({
|
||||||
userName,
|
userName,
|
||||||
playlists,
|
playlists,
|
||||||
inRecording,
|
inRecording,
|
||||||
|
smaller,
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -90,6 +108,7 @@ class MusicStore(final File _file) {
|
||||||
.cast<String, String>(),
|
.cast<String, String>(),
|
||||||
},
|
},
|
||||||
inRecording: json['inRecording'] as String?,
|
inRecording: json['inRecording'] as String?,
|
||||||
|
smaller: json['smaller'] != false,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
} on Object catch (error) {
|
} on Object catch (error) {
|
||||||
|
|
@ -113,6 +132,7 @@ class MusicStore(final File _file) {
|
||||||
'userName': settings.userName,
|
'userName': settings.userName,
|
||||||
'playlists': settings.playlists,
|
'playlists': settings.playlists,
|
||||||
'inRecording': settings.inRecording,
|
'inRecording': settings.inRecording,
|
||||||
|
'smaller': settings.smaller,
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,14 @@
|
||||||
import 'package:equatable/equatable.dart';
|
import 'package:equatable/equatable.dart';
|
||||||
|
|
||||||
|
/// What a track's file on the phone is (ADR 0030, D52).
|
||||||
|
enum MusicCopy() {
|
||||||
|
/// The file as Jellyfin keeps it.
|
||||||
|
original,
|
||||||
|
|
||||||
|
/// Transcoded by Jellyfin to Opus at 128 kbps in WebM.
|
||||||
|
smaller,
|
||||||
|
}
|
||||||
|
|
||||||
/// A track of a marked playlist (ADR 0030, D47), as Jellyfin names it and
|
/// A track of a marked playlist (ADR 0030, D47), as Jellyfin names it and
|
||||||
/// as this phone keeps it.
|
/// as this phone keeps it.
|
||||||
final class const MusicTrack({
|
final class const MusicTrack({
|
||||||
|
|
@ -11,12 +20,21 @@ final class const MusicTrack({
|
||||||
/// Its length as Jellyfin gives it.
|
/// Its length as Jellyfin gives it.
|
||||||
final Duration? length,
|
final Duration? length,
|
||||||
|
|
||||||
|
/// Its file's bits a second on the server, as Jellyfin gives them.
|
||||||
|
final int? bitrate,
|
||||||
|
|
||||||
|
/// Its audio's codec on the server, as Jellyfin names it (`flac`, `mp3`).
|
||||||
|
final String? codec,
|
||||||
|
|
||||||
/// The marked playlists holding it, by their IDs.
|
/// The marked playlists holding it, by their IDs.
|
||||||
final Set<String> playlists = const {},
|
final Set<String> playlists = const {},
|
||||||
|
|
||||||
/// Its file's size once downloaded; null before.
|
/// Its file's size once downloaded; null before.
|
||||||
final int? bytes,
|
final int? bytes,
|
||||||
|
|
||||||
|
/// What the downloaded file is (D52).
|
||||||
|
final MusicCopy copy = MusicCopy.original,
|
||||||
|
|
||||||
/// Its beats per minute; null where none was found or before it was
|
/// Its beats per minute; null where none was found or before it was
|
||||||
/// worked out ([analysed]).
|
/// worked out ([analysed]).
|
||||||
final double? tempo,
|
final double? tempo,
|
||||||
|
|
@ -31,8 +49,11 @@ final class const MusicTrack({
|
||||||
String? Function()? artist,
|
String? Function()? artist,
|
||||||
String? Function()? album,
|
String? Function()? album,
|
||||||
Duration? Function()? length,
|
Duration? Function()? length,
|
||||||
|
int? Function()? bitrate,
|
||||||
|
String? Function()? codec,
|
||||||
Set<String>? playlists,
|
Set<String>? playlists,
|
||||||
int? Function()? bytes,
|
int? Function()? bytes,
|
||||||
|
MusicCopy? copy,
|
||||||
double? Function()? tempo,
|
double? Function()? tempo,
|
||||||
bool? analysed,
|
bool? analysed,
|
||||||
}) => MusicTrack(
|
}) => MusicTrack(
|
||||||
|
|
@ -41,8 +62,11 @@ final class const MusicTrack({
|
||||||
artist: artist == null ? this.artist : artist(),
|
artist: artist == null ? this.artist : artist(),
|
||||||
album: album == null ? this.album : album(),
|
album: album == null ? this.album : album(),
|
||||||
length: length == null ? this.length : length(),
|
length: length == null ? this.length : length(),
|
||||||
|
bitrate: bitrate == null ? this.bitrate : bitrate(),
|
||||||
|
codec: codec == null ? this.codec : codec(),
|
||||||
playlists: playlists ?? this.playlists,
|
playlists: playlists ?? this.playlists,
|
||||||
bytes: bytes == null ? this.bytes : bytes(),
|
bytes: bytes == null ? this.bytes : bytes(),
|
||||||
|
copy: copy ?? this.copy,
|
||||||
tempo: tempo == null ? this.tempo : tempo(),
|
tempo: tempo == null ? this.tempo : tempo(),
|
||||||
analysed: analysed ?? this.analysed,
|
analysed: analysed ?? this.analysed,
|
||||||
);
|
);
|
||||||
|
|
@ -53,8 +77,11 @@ final class const MusicTrack({
|
||||||
'artist': artist,
|
'artist': artist,
|
||||||
'album': album,
|
'album': album,
|
||||||
'ms': length?.inMilliseconds,
|
'ms': length?.inMilliseconds,
|
||||||
|
'bitrate': bitrate,
|
||||||
|
'codec': codec,
|
||||||
'playlists': [...playlists],
|
'playlists': [...playlists],
|
||||||
'bytes': bytes,
|
'bytes': bytes,
|
||||||
|
'copy': copy.name,
|
||||||
'tempo': tempo,
|
'tempo': tempo,
|
||||||
'analysed': analysed,
|
'analysed': analysed,
|
||||||
};
|
};
|
||||||
|
|
@ -71,10 +98,14 @@ final class const MusicTrack({
|
||||||
final int ms => Duration(milliseconds: ms),
|
final int ms => Duration(milliseconds: ms),
|
||||||
_ => null,
|
_ => null,
|
||||||
},
|
},
|
||||||
|
bitrate: json['bitrate'] as int?,
|
||||||
|
codec: json['codec'] as String?,
|
||||||
playlists: {
|
playlists: {
|
||||||
...(json['playlists'] as List<Object?>? ?? const []).cast<String>(),
|
...(json['playlists'] as List<Object?>? ?? const []).cast<String>(),
|
||||||
},
|
},
|
||||||
bytes: json['bytes'] as int?,
|
bytes: json['bytes'] as int?,
|
||||||
|
// Kept before D52: as Jellyfin keeps it.
|
||||||
|
copy: MusicCopy.values.asNameMap()[json['copy']] ?? MusicCopy.original,
|
||||||
tempo: (json['tempo'] as num?)?.toDouble(),
|
tempo: (json['tempo'] as num?)?.toDouble(),
|
||||||
analysed: json['analysed'] == true,
|
analysed: json['analysed'] == true,
|
||||||
),
|
),
|
||||||
|
|
@ -88,8 +119,11 @@ final class const MusicTrack({
|
||||||
artist,
|
artist,
|
||||||
album,
|
album,
|
||||||
length,
|
length,
|
||||||
|
bitrate,
|
||||||
|
codec,
|
||||||
playlists,
|
playlists,
|
||||||
bytes,
|
bytes,
|
||||||
|
copy,
|
||||||
tempo,
|
tempo,
|
||||||
analysed,
|
analysed,
|
||||||
];
|
];
|
||||||
|
|
|
||||||
|
|
@ -284,6 +284,13 @@ class const _Playlists({
|
||||||
ListTile(subtitle: Text(l10n.musicNoPlaylists)),
|
ListTile(subtitle: Text(l10n.musicNoPlaylists)),
|
||||||
if (kept case final kept? when settings.playlists.isNotEmpty)
|
if (kept case final kept? when settings.playlists.isNotEmpty)
|
||||||
_Kept(kept: kept),
|
_Kept(kept: kept),
|
||||||
|
SwitchListTile(
|
||||||
|
secondary: const Icon(LucideIcons.fileAudio),
|
||||||
|
title: Text(l10n.musicSmaller),
|
||||||
|
subtitle: Text(l10n.musicSmallerHint),
|
||||||
|
value: settings.smaller,
|
||||||
|
onChanged: (on) => cubit().keepSmaller(on: on),
|
||||||
|
),
|
||||||
for (final playlist in playlists ?? const <JellyfinPlaylist>[])
|
for (final playlist in playlists ?? const <JellyfinPlaylist>[])
|
||||||
CheckboxListTile(
|
CheckboxListTile(
|
||||||
title: Text(playlist.name),
|
title: Text(playlist.name),
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@ import 'dart:convert';
|
||||||
|
|
||||||
import 'package:flutter_test/flutter_test.dart';
|
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/jellyfin_client.dart';
|
||||||
|
import 'package:halkyon_train_flutter/src/feature/music/model/music_track.dart';
|
||||||
import 'package:http/http.dart' as http;
|
import 'package:http/http.dart' as http;
|
||||||
import 'package:http/testing.dart';
|
import 'package:http/testing.dart';
|
||||||
|
|
||||||
|
|
@ -246,8 +247,8 @@ void main() {
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
test("a playlist's tracks in its order: their artist, album and length; "
|
test("a playlist's tracks in its order: their artist, album, length and "
|
||||||
"what isn't audio left out", () async {
|
"file's bitrate; what isn't audio left out", () async {
|
||||||
final seen = <http.Request>[];
|
final seen = <http.Request>[];
|
||||||
final client = serving({
|
final client = serving({
|
||||||
'GET /jellyfin/Playlists/p1/Items': (
|
'GET /jellyfin/Playlists/p1/Items': (
|
||||||
|
|
@ -260,6 +261,15 @@ void main() {
|
||||||
'Type': 'Audio',
|
'Type': 'Audio',
|
||||||
'Artists': ['Singer', 'Guest'],
|
'Artists': ['Singer', 'Guest'],
|
||||||
'RunTimeTicks': 2100000000,
|
'RunTimeTicks': 2100000000,
|
||||||
|
'MediaSources': [
|
||||||
|
{
|
||||||
|
'Bitrate': 912000,
|
||||||
|
'MediaStreams': [
|
||||||
|
{'Type': 'Video', 'Codec': 'mjpeg'},
|
||||||
|
{'Type': 'Audio', 'Codec': 'flac'},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
},
|
},
|
||||||
{'Id': 'v1', 'Name': 'Clip', 'Type': 'MusicVideo'},
|
{'Id': 'v1', 'Name': 'Clip', 'Type': 'MusicVideo'},
|
||||||
{
|
{
|
||||||
|
|
@ -282,9 +292,28 @@ void main() {
|
||||||
expect(tracks.map((track) => track.id), ['t1', 't2']);
|
expect(tracks.map((track) => track.id), ['t1', 't2']);
|
||||||
expect(tracks.first.artist, 'Singer');
|
expect(tracks.first.artist, 'Singer');
|
||||||
expect(tracks.first.length, const Duration(seconds: 210));
|
expect(tracks.first.length, const Duration(seconds: 210));
|
||||||
|
expect(tracks.first.bitrate, 912000);
|
||||||
|
expect(tracks.first.codec, 'flac');
|
||||||
expect(tracks.last.artist, 'Band');
|
expect(tracks.last.artist, 'Band');
|
||||||
expect(tracks.last.album, 'Album');
|
expect(tracks.last.album, 'Album');
|
||||||
expect(seen.single.url.queryParameters, {'userId': 'user-1'});
|
expect(tracks.last.bitrate, isNull);
|
||||||
|
expect(seen.single.url.queryParameters, {
|
||||||
|
'userId': 'user-1',
|
||||||
|
'fields': 'MediaSources',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a track comes as Jellyfin keeps it, or transcoded to Opus at '
|
||||||
|
'128 kbps in WebM (D52)', () {
|
||||||
|
expect(
|
||||||
|
JellyfinClient.audioPath('t1', MusicCopy.original),
|
||||||
|
'/Audio/t1/stream?static=true',
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
JellyfinClient.audioPath('t1', MusicCopy.smaller),
|
||||||
|
'/Audio/t1/stream.webm'
|
||||||
|
'?audioCodec=opus&audioBitRate=128000&maxAudioChannels=2',
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('a token Jellyfin no longer takes is refused', () async {
|
test('a token Jellyfin no longer takes is refused', () async {
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@ import 'package:halkyon_train_flutter/src/feature/music/data/jellyfin_client.dar
|
||||||
import 'package:halkyon_train_flutter/src/feature/music/data/music_downloads.dart';
|
import 'package:halkyon_train_flutter/src/feature/music/data/music_downloads.dart';
|
||||||
import 'package:halkyon_train_flutter/src/feature/music/data/music_library.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/data/music_store.dart';
|
||||||
|
import 'package:halkyon_train_flutter/src/feature/music/model/music_track.dart';
|
||||||
|
|
||||||
import '../../support/fake_jellyfin.dart';
|
import '../../support/fake_jellyfin.dart';
|
||||||
|
|
||||||
|
|
@ -249,6 +250,71 @@ void main() {
|
||||||
await again.dispose();
|
await again.dispose();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
group('smaller copies (D52)', () {
|
||||||
|
setUp(() {
|
||||||
|
jellyfin.tracks = {
|
||||||
|
'p1': [
|
||||||
|
FakeJellyfin.track('t1', 'Lossless', codec: 'flac', bitrate: 912000),
|
||||||
|
FakeJellyfin.track('t2', 'MP3'),
|
||||||
|
FakeJellyfin.track('t3', 'Small', codec: 'alac', bitrate: 256000),
|
||||||
|
],
|
||||||
|
};
|
||||||
|
library.mark(const JellyfinPlaylist(id: 'p1', name: 'Tempo'), on: true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test(
|
||||||
|
'by default, a lossless file above 256 kbps comes transcoded; a '
|
||||||
|
'lossy one at 320 kbps, and one at 256 or below, as they are',
|
||||||
|
() async {
|
||||||
|
await syncAndFetch();
|
||||||
|
|
||||||
|
final tracks = downloads.kept.value.tracks;
|
||||||
|
expect(tracks['t1']!.copy, MusicCopy.smaller);
|
||||||
|
expect(fileOf('t1').readAsStringSync(), 'opus of t1');
|
||||||
|
expect(tracks['t2']!.copy, MusicCopy.original);
|
||||||
|
expect(fileOf('t2').readAsStringSync(), 'audio of t2');
|
||||||
|
expect(tracks['t3']!.copy, MusicCopy.original);
|
||||||
|
expect(tracks['t1']!.tempo, 128);
|
||||||
|
expect(platform.job, isNull);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
test('switched off, the smaller copy is fetched again as it is, its '
|
||||||
|
'tempo kept and not worked out again; switched on, back', () async {
|
||||||
|
await syncAndFetch();
|
||||||
|
expect(platform.analysed, hasLength(3));
|
||||||
|
|
||||||
|
library.keepSmaller(on: false);
|
||||||
|
await downloads.sync();
|
||||||
|
final job = platform.job!;
|
||||||
|
expect(job.tracks.map((track) => track.id), ['t1']);
|
||||||
|
expect(job.tracks.single.copy, MusicCopy.original);
|
||||||
|
expect(job.tracks.single.analyse, isFalse);
|
||||||
|
// Kept until the new one is whole.
|
||||||
|
expect(fileOf('t1').readAsStringSync(), 'opus of t1');
|
||||||
|
|
||||||
|
await platform.run(tokens.token!);
|
||||||
|
await downloads.sync();
|
||||||
|
final t1 = downloads.kept.value.tracks['t1']!;
|
||||||
|
expect(t1.copy, MusicCopy.original);
|
||||||
|
expect(fileOf('t1').readAsStringSync(), 'audio of t1');
|
||||||
|
expect(t1.tempo, 128);
|
||||||
|
expect(platform.analysed, hasLength(3));
|
||||||
|
expect(platform.job, isNull);
|
||||||
|
|
||||||
|
library.keepSmaller(on: true);
|
||||||
|
await syncAndFetch();
|
||||||
|
expect(downloads.kept.value.tracks['t1']!.copy, MusicCopy.smaller);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a track kept before D52 counts as kept as it is', () {
|
||||||
|
expect(
|
||||||
|
MusicTrack.fromJson({'id': 't1', 'name': 'One', 'bytes': 3})!.copy,
|
||||||
|
MusicCopy.original,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
test('an ID that could name another file is never one', () {
|
test('an ID that could name another file is never one', () {
|
||||||
expect(downloads.fileOf('../music.json'), isNull);
|
expect(downloads.fileOf('../music.json'), isNull);
|
||||||
expect(downloads.fileOf('tracks.json'), isNull);
|
expect(downloads.fileOf('tracks.json'), isNull);
|
||||||
|
|
|
||||||
|
|
@ -33,6 +33,19 @@ void main() {
|
||||||
client: jellyfin.client,
|
client: jellyfin.client,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
test('smaller copies by default, the choice kept, signed out and in '
|
||||||
|
'again too (D52)', () async {
|
||||||
|
final library = open();
|
||||||
|
expect(library.settings.value.smaller, isTrue);
|
||||||
|
|
||||||
|
library.keepSmaller(on: false);
|
||||||
|
expect(open().settings.value.smaller, isFalse);
|
||||||
|
|
||||||
|
await library.signInWithPassword(await library.find(address), 'Ada', 'pw');
|
||||||
|
await library.signOut();
|
||||||
|
expect(open().settings.value.smaller, isFalse);
|
||||||
|
});
|
||||||
|
|
||||||
test('a device ID is made once and kept, and Jellyfin is told it', () async {
|
test('a device ID is made once and kept, and Jellyfin is told it', () async {
|
||||||
final first = open();
|
final first = open();
|
||||||
final id = first.settings.value.deviceId;
|
final id = first.settings.value.deviceId;
|
||||||
|
|
|
||||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue