improve album tests

This commit is contained in:
austinried 2025-11-02 18:35:13 +09:00
parent c900c9750a
commit 2df86f4faa
9 changed files with 399 additions and 37 deletions

View File

@ -13,7 +13,7 @@ const client = new SubsonicClient(
);
for (const id of ["197", "199", "321"]) {
const res = await client.get("download", { id });
const { res } = await client.get("download", { id });
let filename = res.headers.get("Content-Disposition")
?.split(";")[1];

View File

@ -1,18 +1,65 @@
#!/usr/bin/env -S deno --allow-all
import { SubsonicClient } from "./util/subsonic.ts";
import { sleep } from "./util/util.ts";
async function scrobbleTrack(
client: SubsonicClient,
album: string,
track: number,
) {
const { xml: albumsXml } = await client.get("getAlbumList2", {
type: "newest",
});
const albumId = albumsXml.querySelector(`album[name='${album}']`)?.id;
const { xml: songsXml } = await client.get("getAlbum", { id: albumId! });
const songId = songsXml.querySelector(`song[track='${track}']`)?.id;
await client.get("scrobble", {
id: songId!,
submission: "true",
});
}
async function setupTestData(client: SubsonicClient) {
await scrobbleTrack(client, "Retroconnaissance EP", 1);
await sleep(1_000);
await scrobbleTrack(client, "Retroconnaissance EP", 2);
await sleep(1_000);
await scrobbleTrack(client, "Kosmonaut", 1);
}
async function setupNavidrome() {
console.log("setting up navidrome...");
const baseUrl = "http://navidrome:4533";
const username = "admin";
const password = "password";
await fetch("http://navidrome:4533/auth/createAdmin", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
username: "admin",
password: "password",
}),
body: JSON.stringify({ username, password }),
});
const client = new SubsonicClient(baseUrl, username, password);
await setupTestData(client);
}
await setupNavidrome();
async function setupGonic() {
console.log("setting up gonic...");
const baseUrl = "http://gonic";
const username = "admin";
const password = "admin";
const client = new SubsonicClient(baseUrl, username, password);
await setupTestData(client);
}
await Promise.all([
setupNavidrome(),
setupGonic(),
]);
console.log("setup-servers complete");

View File

@ -1,3 +1,6 @@
// @deno-types="npm:@types/jsdom@27.0.0"
import { JSDOM } from "npm:jsdom@27.1.0";
export class SubsonicClient {
constructor(
readonly baseUrl: string,
@ -5,7 +8,18 @@ export class SubsonicClient {
readonly password: string,
) {}
get(method: string, params?: Record<string, string>) {
async get(
method: "download",
params?: Record<string, string>,
): Promise<{ res: Response; xml: undefined }>;
async get(
method: string,
params?: Record<string, string>,
): Promise<{ res: Response; xml: Document }>;
async get(
method: string,
params?: Record<string, string>,
): Promise<{ res: Response; xml: Document | undefined }> {
const url = new URL(`rest/${method}.view`, this.baseUrl);
url.searchParams.set("u", this.username);
@ -19,6 +33,26 @@ export class SubsonicClient {
);
}
return fetch(url);
const res = await fetch(url);
let xml: Document | undefined;
if (res.headers.get("content-type")?.includes("xml")) {
xml = new JSDOM(await res.text(), {
contentType: "text/xml",
}).window.document;
}
if (!res.ok) {
let message = `HTTP error ${res.status}`;
if (xml) {
const error = xml.querySelector("error");
const errorCode = error?.getAttribute("code");
const errorMessage = error?.getAttribute("message");
message += `\nSubsonic error${errorCode}: ${errorMessage}`;
}
throw new Error(message);
}
return { res, xml };
}
}

View File

@ -0,0 +1,3 @@
export function sleep(milliseconds: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, milliseconds));
}

View File

@ -3,6 +3,7 @@
import 'package:freezed_annotation/freezed_annotation.dart';
part 'models.freezed.dart';
part 'models.g.dart';
mixin Starred {
DateTime? get starred;
@ -67,6 +68,9 @@ abstract class SourceItem with _$SourceItem {
String? genre,
String? coverArt,
}) = SourceSong;
factory SourceItem.fromJson(Map<String, dynamic> json) =>
_$SourceItemFromJson(json);
}
@freezed
@ -76,4 +80,7 @@ abstract class SourcePlaylistSong with _$SourcePlaylistSong {
required String songId,
required int position,
}) = _SourcePlaylistSong;
factory SourcePlaylistSong.fromJson(Map<String, dynamic> json) =>
_$SourcePlaylistSongFromJson(json);
}

View File

@ -11,6 +11,38 @@ part of 'models.dart';
// dart format off
T _$identity<T>(T value) => value;
SourceItem _$SourceItemFromJson(
Map<String, dynamic> json
) {
switch (json['runtimeType']) {
case 'artist':
return SourceArtist.fromJson(
json
);
case 'album':
return SourceAlbum.fromJson(
json
);
case 'playlist':
return SourcePlaylist.fromJson(
json
);
case 'song':
return SourceSong.fromJson(
json
);
default:
throw CheckedFromJsonException(
json,
'runtimeType',
'SourceItem',
'Invalid union type "${json['runtimeType']}"!'
);
}
}
/// @nodoc
mixin _$SourceItem {
@ -21,6 +53,8 @@ mixin _$SourceItem {
@pragma('vm:prefer-inline')
$SourceItemCopyWith<SourceItem> get copyWith => _$SourceItemCopyWithImpl<SourceItem>(this as SourceItem, _$identity);
/// Serializes this SourceItem to a JSON map.
Map<String, dynamic> toJson();
@override
@ -28,7 +62,7 @@ bool operator ==(Object other) {
return identical(this, other) || (other.runtimeType == runtimeType&&other is SourceItem&&(identical(other.id, id) || other.id == id));
}
@JsonKey(includeFromJson: false, includeToJson: false)
@override
int get hashCode => Object.hash(runtimeType,id);
@ -221,11 +255,11 @@ return song(_that.id,_that.albumId,_that.artistId,_that.title,_that.artist,_that
}
/// @nodoc
@JsonSerializable()
class SourceArtist with Starred implements SourceItem {
const SourceArtist({required this.id, required this.name, this.starred, this.smallImage, this.largeImage});
const SourceArtist({required this.id, required this.name, this.starred, this.smallImage, this.largeImage, final String? $type}): $type = $type ?? 'artist';
factory SourceArtist.fromJson(Map<String, dynamic> json) => _$SourceArtistFromJson(json);
@override final String id;
final String name;
@ -233,20 +267,27 @@ class SourceArtist with Starred implements SourceItem {
final Uri? smallImage;
final Uri? largeImage;
@JsonKey(name: 'runtimeType')
final String $type;
/// Create a copy of SourceItem
/// with the given fields replaced by the non-null parameter values.
@override @JsonKey(includeFromJson: false, includeToJson: false)
@pragma('vm:prefer-inline')
$SourceArtistCopyWith<SourceArtist> get copyWith => _$SourceArtistCopyWithImpl<SourceArtist>(this, _$identity);
@override
Map<String, dynamic> toJson() {
return _$SourceArtistToJson(this, );
}
@override
bool operator ==(Object other) {
return identical(this, other) || (other.runtimeType == runtimeType&&other is SourceArtist&&(identical(other.id, id) || other.id == id)&&(identical(other.name, name) || other.name == name)&&(identical(other.starred, starred) || other.starred == starred)&&(identical(other.smallImage, smallImage) || other.smallImage == smallImage)&&(identical(other.largeImage, largeImage) || other.largeImage == largeImage));
}
@JsonKey(includeFromJson: false, includeToJson: false)
@override
int get hashCode => Object.hash(runtimeType,id,name,starred,smallImage,largeImage);
@ -295,11 +336,11 @@ as Uri?,
}
/// @nodoc
@JsonSerializable()
class SourceAlbum with Starred, CoverArt implements SourceItem {
const SourceAlbum({required this.id, this.artistId, required this.name, this.albumArtist, required this.created, this.coverArt, this.year, this.starred, this.genre, this.frequentRank, this.recentRank});
const SourceAlbum({required this.id, this.artistId, required this.name, this.albumArtist, required this.created, this.coverArt, this.year, this.starred, this.genre, this.frequentRank, this.recentRank, final String? $type}): $type = $type ?? 'album';
factory SourceAlbum.fromJson(Map<String, dynamic> json) => _$SourceAlbumFromJson(json);
@override final String id;
final String? artistId;
@ -313,20 +354,27 @@ class SourceAlbum with Starred, CoverArt implements SourceItem {
final int? frequentRank;
final int? recentRank;
@JsonKey(name: 'runtimeType')
final String $type;
/// Create a copy of SourceItem
/// with the given fields replaced by the non-null parameter values.
@override @JsonKey(includeFromJson: false, includeToJson: false)
@pragma('vm:prefer-inline')
$SourceAlbumCopyWith<SourceAlbum> get copyWith => _$SourceAlbumCopyWithImpl<SourceAlbum>(this, _$identity);
@override
Map<String, dynamic> toJson() {
return _$SourceAlbumToJson(this, );
}
@override
bool operator ==(Object other) {
return identical(this, other) || (other.runtimeType == runtimeType&&other is SourceAlbum&&(identical(other.id, id) || other.id == id)&&(identical(other.artistId, artistId) || other.artistId == artistId)&&(identical(other.name, name) || other.name == name)&&(identical(other.albumArtist, albumArtist) || other.albumArtist == albumArtist)&&(identical(other.created, created) || other.created == created)&&(identical(other.coverArt, coverArt) || other.coverArt == coverArt)&&(identical(other.year, year) || other.year == year)&&(identical(other.starred, starred) || other.starred == starred)&&(identical(other.genre, genre) || other.genre == genre)&&(identical(other.frequentRank, frequentRank) || other.frequentRank == frequentRank)&&(identical(other.recentRank, recentRank) || other.recentRank == recentRank));
}
@JsonKey(includeFromJson: false, includeToJson: false)
@override
int get hashCode => Object.hash(runtimeType,id,artistId,name,albumArtist,created,coverArt,year,starred,genre,frequentRank,recentRank);
@ -381,11 +429,11 @@ as int?,
}
/// @nodoc
@JsonSerializable()
class SourcePlaylist with CoverArt implements SourceItem {
const SourcePlaylist({required this.id, required this.name, this.comment, required this.created, required this.changed, this.coverArt, this.owner, this.public});
const SourcePlaylist({required this.id, required this.name, this.comment, required this.created, required this.changed, this.coverArt, this.owner, this.public, final String? $type}): $type = $type ?? 'playlist';
factory SourcePlaylist.fromJson(Map<String, dynamic> json) => _$SourcePlaylistFromJson(json);
@override final String id;
final String name;
@ -396,20 +444,27 @@ class SourcePlaylist with CoverArt implements SourceItem {
final String? owner;
final bool? public;
@JsonKey(name: 'runtimeType')
final String $type;
/// Create a copy of SourceItem
/// with the given fields replaced by the non-null parameter values.
@override @JsonKey(includeFromJson: false, includeToJson: false)
@pragma('vm:prefer-inline')
$SourcePlaylistCopyWith<SourcePlaylist> get copyWith => _$SourcePlaylistCopyWithImpl<SourcePlaylist>(this, _$identity);
@override
Map<String, dynamic> toJson() {
return _$SourcePlaylistToJson(this, );
}
@override
bool operator ==(Object other) {
return identical(this, other) || (other.runtimeType == runtimeType&&other is SourcePlaylist&&(identical(other.id, id) || other.id == id)&&(identical(other.name, name) || other.name == name)&&(identical(other.comment, comment) || other.comment == comment)&&(identical(other.created, created) || other.created == created)&&(identical(other.changed, changed) || other.changed == changed)&&(identical(other.coverArt, coverArt) || other.coverArt == coverArt)&&(identical(other.owner, owner) || other.owner == owner)&&(identical(other.public, public) || other.public == public));
}
@JsonKey(includeFromJson: false, includeToJson: false)
@override
int get hashCode => Object.hash(runtimeType,id,name,comment,created,changed,coverArt,owner,public);
@ -461,11 +516,11 @@ as bool?,
}
/// @nodoc
@JsonSerializable()
class SourceSong with Starred, CoverArt implements SourceItem {
const SourceSong({required this.id, this.albumId, this.artistId, required this.title, this.artist, this.album, this.duration, this.track, this.disc, this.starred, this.genre, this.coverArt});
const SourceSong({required this.id, this.albumId, this.artistId, required this.title, this.artist, this.album, this.duration, this.track, this.disc, this.starred, this.genre, this.coverArt, final String? $type}): $type = $type ?? 'song';
factory SourceSong.fromJson(Map<String, dynamic> json) => _$SourceSongFromJson(json);
@override final String id;
final String? albumId;
@ -480,20 +535,27 @@ class SourceSong with Starred, CoverArt implements SourceItem {
final String? genre;
final String? coverArt;
@JsonKey(name: 'runtimeType')
final String $type;
/// Create a copy of SourceItem
/// with the given fields replaced by the non-null parameter values.
@override @JsonKey(includeFromJson: false, includeToJson: false)
@pragma('vm:prefer-inline')
$SourceSongCopyWith<SourceSong> get copyWith => _$SourceSongCopyWithImpl<SourceSong>(this, _$identity);
@override
Map<String, dynamic> toJson() {
return _$SourceSongToJson(this, );
}
@override
bool operator ==(Object other) {
return identical(this, other) || (other.runtimeType == runtimeType&&other is SourceSong&&(identical(other.id, id) || other.id == id)&&(identical(other.albumId, albumId) || other.albumId == albumId)&&(identical(other.artistId, artistId) || other.artistId == artistId)&&(identical(other.title, title) || other.title == title)&&(identical(other.artist, artist) || other.artist == artist)&&(identical(other.album, album) || other.album == album)&&(identical(other.duration, duration) || other.duration == duration)&&(identical(other.track, track) || other.track == track)&&(identical(other.disc, disc) || other.disc == disc)&&(identical(other.starred, starred) || other.starred == starred)&&(identical(other.genre, genre) || other.genre == genre)&&(identical(other.coverArt, coverArt) || other.coverArt == coverArt));
}
@JsonKey(includeFromJson: false, includeToJson: false)
@override
int get hashCode => Object.hash(runtimeType,id,albumId,artistId,title,artist,album,duration,track,disc,starred,genre,coverArt);
@ -548,6 +610,7 @@ as String?,
}
/// @nodoc
mixin _$SourcePlaylistSong {
@ -558,6 +621,8 @@ mixin _$SourcePlaylistSong {
@pragma('vm:prefer-inline')
$SourcePlaylistSongCopyWith<SourcePlaylistSong> get copyWith => _$SourcePlaylistSongCopyWithImpl<SourcePlaylistSong>(this as SourcePlaylistSong, _$identity);
/// Serializes this SourcePlaylistSong to a JSON map.
Map<String, dynamic> toJson();
@override
@ -565,7 +630,7 @@ bool operator ==(Object other) {
return identical(this, other) || (other.runtimeType == runtimeType&&other is SourcePlaylistSong&&(identical(other.playlistId, playlistId) || other.playlistId == playlistId)&&(identical(other.songId, songId) || other.songId == songId)&&(identical(other.position, position) || other.position == position));
}
@JsonKey(includeFromJson: false, includeToJson: false)
@override
int get hashCode => Object.hash(runtimeType,playlistId,songId,position);
@ -742,11 +807,11 @@ return $default(_that.playlistId,_that.songId,_that.position);case _:
}
/// @nodoc
@JsonSerializable()
class _SourcePlaylistSong implements SourcePlaylistSong {
const _SourcePlaylistSong({required this.playlistId, required this.songId, required this.position});
factory _SourcePlaylistSong.fromJson(Map<String, dynamic> json) => _$SourcePlaylistSongFromJson(json);
@override final String playlistId;
@override final String songId;
@ -758,14 +823,17 @@ class _SourcePlaylistSong implements SourcePlaylistSong {
@pragma('vm:prefer-inline')
_$SourcePlaylistSongCopyWith<_SourcePlaylistSong> get copyWith => __$SourcePlaylistSongCopyWithImpl<_SourcePlaylistSong>(this, _$identity);
@override
Map<String, dynamic> toJson() {
return _$SourcePlaylistSongToJson(this, );
}
@override
bool operator ==(Object other) {
return identical(this, other) || (other.runtimeType == runtimeType&&other is _SourcePlaylistSong&&(identical(other.playlistId, playlistId) || other.playlistId == playlistId)&&(identical(other.songId, songId) || other.songId == songId)&&(identical(other.position, position) || other.position == position));
}
@JsonKey(includeFromJson: false, includeToJson: false)
@override
int get hashCode => Object.hash(runtimeType,playlistId,songId,position);

142
lib/sources/models.g.dart Normal file
View File

@ -0,0 +1,142 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'models.dart';
// **************************************************************************
// JsonSerializableGenerator
// **************************************************************************
SourceArtist _$SourceArtistFromJson(Map<String, dynamic> json) => SourceArtist(
id: json['id'] as String,
name: json['name'] as String,
starred: json['starred'] == null
? null
: DateTime.parse(json['starred'] as String),
smallImage: json['smallImage'] == null
? null
: Uri.parse(json['smallImage'] as String),
largeImage: json['largeImage'] == null
? null
: Uri.parse(json['largeImage'] as String),
$type: json['runtimeType'] as String?,
);
Map<String, dynamic> _$SourceArtistToJson(SourceArtist instance) =>
<String, dynamic>{
'id': instance.id,
'name': instance.name,
'starred': instance.starred?.toIso8601String(),
'smallImage': instance.smallImage?.toString(),
'largeImage': instance.largeImage?.toString(),
'runtimeType': instance.$type,
};
SourceAlbum _$SourceAlbumFromJson(Map<String, dynamic> json) => SourceAlbum(
id: json['id'] as String,
artistId: json['artistId'] as String?,
name: json['name'] as String,
albumArtist: json['albumArtist'] as String?,
created: DateTime.parse(json['created'] as String),
coverArt: json['coverArt'] as String?,
year: (json['year'] as num?)?.toInt(),
starred: json['starred'] == null
? null
: DateTime.parse(json['starred'] as String),
genre: json['genre'] as String?,
frequentRank: (json['frequentRank'] as num?)?.toInt(),
recentRank: (json['recentRank'] as num?)?.toInt(),
$type: json['runtimeType'] as String?,
);
Map<String, dynamic> _$SourceAlbumToJson(SourceAlbum instance) =>
<String, dynamic>{
'id': instance.id,
'artistId': instance.artistId,
'name': instance.name,
'albumArtist': instance.albumArtist,
'created': instance.created.toIso8601String(),
'coverArt': instance.coverArt,
'year': instance.year,
'starred': instance.starred?.toIso8601String(),
'genre': instance.genre,
'frequentRank': instance.frequentRank,
'recentRank': instance.recentRank,
'runtimeType': instance.$type,
};
SourcePlaylist _$SourcePlaylistFromJson(Map<String, dynamic> json) =>
SourcePlaylist(
id: json['id'] as String,
name: json['name'] as String,
comment: json['comment'] as String?,
created: DateTime.parse(json['created'] as String),
changed: DateTime.parse(json['changed'] as String),
coverArt: json['coverArt'] as String?,
owner: json['owner'] as String?,
public: json['public'] as bool?,
$type: json['runtimeType'] as String?,
);
Map<String, dynamic> _$SourcePlaylistToJson(SourcePlaylist instance) =>
<String, dynamic>{
'id': instance.id,
'name': instance.name,
'comment': instance.comment,
'created': instance.created.toIso8601String(),
'changed': instance.changed.toIso8601String(),
'coverArt': instance.coverArt,
'owner': instance.owner,
'public': instance.public,
'runtimeType': instance.$type,
};
SourceSong _$SourceSongFromJson(Map<String, dynamic> json) => SourceSong(
id: json['id'] as String,
albumId: json['albumId'] as String?,
artistId: json['artistId'] as String?,
title: json['title'] as String,
artist: json['artist'] as String?,
album: json['album'] as String?,
duration: json['duration'] == null
? null
: Duration(microseconds: (json['duration'] as num).toInt()),
track: (json['track'] as num?)?.toInt(),
disc: (json['disc'] as num?)?.toInt(),
starred: json['starred'] == null
? null
: DateTime.parse(json['starred'] as String),
genre: json['genre'] as String?,
coverArt: json['coverArt'] as String?,
$type: json['runtimeType'] as String?,
);
Map<String, dynamic> _$SourceSongToJson(SourceSong instance) =>
<String, dynamic>{
'id': instance.id,
'albumId': instance.albumId,
'artistId': instance.artistId,
'title': instance.title,
'artist': instance.artist,
'album': instance.album,
'duration': instance.duration?.inMicroseconds,
'track': instance.track,
'disc': instance.disc,
'starred': instance.starred?.toIso8601String(),
'genre': instance.genre,
'coverArt': instance.coverArt,
'runtimeType': instance.$type,
};
_SourcePlaylistSong _$SourcePlaylistSongFromJson(Map<String, dynamic> json) =>
_SourcePlaylistSong(
playlistId: json['playlistId'] as String,
songId: json['songId'] as String,
position: (json['position'] as num).toInt(),
);
Map<String, dynamic> _$SourcePlaylistSongToJson(_SourcePlaylistSong instance) =>
<String, dynamic>{
'playlistId': instance.playlistId,
'songId': instance.songId,
'position': instance.position,
};

View File

@ -5,12 +5,12 @@ docker compose build
docker compose down
docker volume rm $(docker compose volumes -q) || true
docker compose up -d
docker compose run --rm library-manager music-download.ts
docker compose run --rm library-manager setup-servers.ts
docker compose up -d
echo "waiting for library scans..."
sleep 10
docker compose run --rm library-manager setup-servers.ts
docker compose down

View File

@ -1,3 +1,4 @@
import 'package:collection/collection.dart';
import 'package:http/http.dart';
import 'package:subtracks/sources/subsonic/client.dart';
import 'package:subtracks/sources/subsonic/source.dart';
@ -56,6 +57,33 @@ void main() {
final items = await source.allAlbums().toList();
expect(items.length, equals(3));
final kosmo = items.firstWhere((a) => a.name == 'Kosmonaut');
expect(kosmo.id.length, greaterThan(0));
expect(kosmo.artistId?.length, greaterThan(0));
expect(kosmo.albumArtist, equals('Ugress'));
expect(kosmo.created.compareTo(DateTime.now()), lessThan(0));
expect(kosmo.coverArt?.length, greaterThan(0));
expect(kosmo.year, equals(2006));
expect(kosmo.starred, isNull);
expect(kosmo.genre, equals('Electronic'));
final retro = items.firstWhere(
(a) => a.name == 'Retroconnaissance EP',
);
final dunno = items.firstWhere(
(a) => a.name == "I Don't Know What I'm Doing",
);
expect(kosmo.recentRank, equals(0));
expect(kosmo.frequentRank, equals(1));
expect(retro.recentRank, equals(1));
expect(retro.frequentRank, equals(0));
expect(dunno.recentRank, isNull);
expect(dunno.frequentRank, isNull);
});
test('allArtists', () async {
@ -81,6 +109,39 @@ void main() {
expect(items.length, equals(0));
});
test('album-artist relation', () async {
final artists = await source.allArtists().toList();
final albums = await source.allAlbums().toList();
final artistAlbums = artists
.map(
(artist) => [
artist.name,
...albums
.where((album) => album.artistId == artist.id)
.map((album) => album.name)
.sorted(),
],
)
.sorted((a, b) => (a[0]).compareTo(b[0]));
expect(artistAlbums.length, equals(2));
expect(
artistAlbums,
equals([
[
'Brad Sucks',
"I Don't Know What I'm Doing",
],
[
'Ugress',
'Kosmonaut',
'Retroconnaissance EP',
],
]),
);
});
});
}
}