music source and client for subsonic

test fixture setup for navidrome
This commit is contained in:
austinried 2025-11-01 22:24:59 +09:00
parent 9f05ebb201
commit 3408a3988e
18 changed files with 1556 additions and 8 deletions

22
compose.yaml Normal file
View File

@ -0,0 +1,22 @@
services:
library-manager:
build: ./docker/library-manager
volumes:
- deno-dir:/deno-dir
- music:/music
navidrome:
image: deluan/navidrome:latest
ports:
- "4533:4533"
restart: unless-stopped
environment:
ND_LOGLEVEL: debug
volumes:
- navidrome-data:/data
- music:/music:ro
volumes:
deno-dir:
music:
navidrome-data:

View File

@ -0,0 +1,8 @@
FROM denoland/deno:debian
ENV DENO_DIR=/deno-dir
RUN apt-get update && \
apt-get install -y unzip
COPY scripts /usr/bin

View File

@ -0,0 +1,44 @@
#!/usr/bin/env -S deno --allow-all
import * as path from "jsr:@std/path@1.1.2";
import { MUSIC_DIR } from "./util/env.ts";
import { SubsonicClient } from "./util/subsonic.ts";
await new Deno.Command("rm", { args: ["-rf", path.join(MUSIC_DIR, "*")] })
.output();
const client = new SubsonicClient(
"http://demo.subsonic.org",
"guest1",
"guest",
);
for (const id of ["197", "199", "321"]) {
const res = await client.get("download", { id });
let filename = res.headers.get("Content-Disposition")
?.split(";")[1];
filename = (filename?.includes("*=")
? decodeURIComponent(filename.split("''")[1])
: filename?.split("=")[1]) ?? `${id}.zip`;
console.log("downloading album:", filename);
const downloadPath = path.join(MUSIC_DIR, filename);
const file = await Deno.open(downloadPath, {
write: true,
create: true,
});
await res.body?.pipeTo(file.writable);
await new Deno.Command("unzip", {
args: [
downloadPath,
"-d",
path.join(MUSIC_DIR, filename.split(".")[0]),
],
}).output();
await Deno.remove(downloadPath);
}
console.log("music-download complete");

View File

@ -0,0 +1,18 @@
#!/usr/bin/env -S deno --allow-all
async function setupNavidrome() {
console.log("setting up navidrome...");
await fetch("http://navidrome:4533/auth/createAdmin", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
username: "admin",
password: "password",
}),
});
}
await setupNavidrome();
console.log("setup-servers complete");

View File

@ -0,0 +1 @@
export const MUSIC_DIR = Deno.env.get("MUSIC_DIR") ?? "/music";

View File

@ -0,0 +1,24 @@
export class SubsonicClient {
constructor(
readonly baseUrl: string,
readonly username: string,
readonly password: string,
) {}
get(method: string, params?: Record<string, string>) {
const url = new URL(`rest/${method}.view`, this.baseUrl);
url.searchParams.set("u", this.username);
url.searchParams.set("p", this.password);
url.searchParams.set("v", "1.13.0");
url.searchParams.set("c", "subtracks-test-fixture");
if (params) {
Object.entries(params).forEach(([key, value]) =>
url.searchParams.append(key, value)
);
}
return fetch(url);
}
}

77
lib/sources/models.dart Normal file
View File

@ -0,0 +1,77 @@
// ignore_for_file: annotate_overrides
import 'package:freezed_annotation/freezed_annotation.dart';
part 'models.freezed.dart';
mixin Starred {
DateTime? get starred;
}
mixin CoverArt {
String? get coverArt;
}
@freezed
abstract class SourceItem with _$SourceItem {
@With<Starred>()
const factory SourceItem.artist({
required String id,
required String name,
DateTime? starred,
}) = SourceArtist;
@With<Starred>()
@With<CoverArt>()
const factory SourceItem.album({
required String id,
String? artistId,
required String name,
String? albumArtist,
required DateTime created,
String? coverArt,
int? year,
DateTime? starred,
String? genre,
int? frequentRank,
int? recentRank,
}) = SourceAlbum;
@With<CoverArt>()
const factory SourceItem.playlist({
required String id,
required String name,
String? comment,
required DateTime created,
required DateTime changed,
String? coverArt,
String? owner,
bool? public,
}) = SourcePlaylist;
@With<Starred>()
@With<CoverArt>()
const factory SourceItem.song({
required String id,
String? albumId,
String? artistId,
required String title,
String? artist,
String? album,
Duration? duration,
int? track,
int? disc,
DateTime? starred,
String? genre,
String? coverArt,
}) = SourceSong;
}
@freezed
abstract class SourcePlaylistSong with _$SourcePlaylistSong {
const factory SourcePlaylistSong({
required String playlistId,
required String songId,
required int position,
}) = _SourcePlaylistSong;
}

View File

@ -0,0 +1,810 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
// coverage:ignore-file
// ignore_for_file: type=lint
// ignore_for_file: unused_element, deprecated_member_use, deprecated_member_use_from_same_package, use_function_type_syntax_for_parameters, unnecessary_const, avoid_init_to_null, invalid_override_different_default_values_named, prefer_expression_function_bodies, annotate_overrides, invalid_annotation_target, unnecessary_question_mark
part of 'models.dart';
// **************************************************************************
// FreezedGenerator
// **************************************************************************
// dart format off
T _$identity<T>(T value) => value;
/// @nodoc
mixin _$SourceItem {
String get id;
/// Create a copy of SourceItem
/// with the given fields replaced by the non-null parameter values.
@JsonKey(includeFromJson: false, includeToJson: false)
@pragma('vm:prefer-inline')
$SourceItemCopyWith<SourceItem> get copyWith => _$SourceItemCopyWithImpl<SourceItem>(this as SourceItem, _$identity);
@override
bool operator ==(Object other) {
return identical(this, other) || (other.runtimeType == runtimeType&&other is SourceItem&&(identical(other.id, id) || other.id == id));
}
@override
int get hashCode => Object.hash(runtimeType,id);
@override
String toString() {
return 'SourceItem(id: $id)';
}
}
/// @nodoc
abstract mixin class $SourceItemCopyWith<$Res> {
factory $SourceItemCopyWith(SourceItem value, $Res Function(SourceItem) _then) = _$SourceItemCopyWithImpl;
@useResult
$Res call({
String id
});
}
/// @nodoc
class _$SourceItemCopyWithImpl<$Res>
implements $SourceItemCopyWith<$Res> {
_$SourceItemCopyWithImpl(this._self, this._then);
final SourceItem _self;
final $Res Function(SourceItem) _then;
/// Create a copy of SourceItem
/// with the given fields replaced by the non-null parameter values.
@pragma('vm:prefer-inline') @override $Res call({Object? id = null,}) {
return _then(_self.copyWith(
id: null == id ? _self.id : id // ignore: cast_nullable_to_non_nullable
as String,
));
}
}
/// Adds pattern-matching-related methods to [SourceItem].
extension SourceItemPatterns on SourceItem {
/// A variant of `map` that fallback to returning `orElse`.
///
/// It is equivalent to doing:
/// ```dart
/// switch (sealedClass) {
/// case final Subclass value:
/// return ...;
/// case _:
/// return orElse();
/// }
/// ```
@optionalTypeArgs TResult maybeMap<TResult extends Object?>({TResult Function( SourceArtist value)? artist,TResult Function( SourceAlbum value)? album,TResult Function( SourcePlaylist value)? playlist,TResult Function( SourceSong value)? song,required TResult orElse(),}){
final _that = this;
switch (_that) {
case SourceArtist() when artist != null:
return artist(_that);case SourceAlbum() when album != null:
return album(_that);case SourcePlaylist() when playlist != null:
return playlist(_that);case SourceSong() when song != null:
return song(_that);case _:
return orElse();
}
}
/// A `switch`-like method, using callbacks.
///
/// Callbacks receives the raw object, upcasted.
/// It is equivalent to doing:
/// ```dart
/// switch (sealedClass) {
/// case final Subclass value:
/// return ...;
/// case final Subclass2 value:
/// return ...;
/// }
/// ```
@optionalTypeArgs TResult map<TResult extends Object?>({required TResult Function( SourceArtist value) artist,required TResult Function( SourceAlbum value) album,required TResult Function( SourcePlaylist value) playlist,required TResult Function( SourceSong value) song,}){
final _that = this;
switch (_that) {
case SourceArtist():
return artist(_that);case SourceAlbum():
return album(_that);case SourcePlaylist():
return playlist(_that);case SourceSong():
return song(_that);case _:
throw StateError('Unexpected subclass');
}
}
/// A variant of `map` that fallback to returning `null`.
///
/// It is equivalent to doing:
/// ```dart
/// switch (sealedClass) {
/// case final Subclass value:
/// return ...;
/// case _:
/// return null;
/// }
/// ```
@optionalTypeArgs TResult? mapOrNull<TResult extends Object?>({TResult? Function( SourceArtist value)? artist,TResult? Function( SourceAlbum value)? album,TResult? Function( SourcePlaylist value)? playlist,TResult? Function( SourceSong value)? song,}){
final _that = this;
switch (_that) {
case SourceArtist() when artist != null:
return artist(_that);case SourceAlbum() when album != null:
return album(_that);case SourcePlaylist() when playlist != null:
return playlist(_that);case SourceSong() when song != null:
return song(_that);case _:
return null;
}
}
/// A variant of `when` that fallback to an `orElse` callback.
///
/// It is equivalent to doing:
/// ```dart
/// switch (sealedClass) {
/// case Subclass(:final field):
/// return ...;
/// case _:
/// return orElse();
/// }
/// ```
@optionalTypeArgs TResult maybeWhen<TResult extends Object?>({TResult Function( String id, String name, DateTime? starred)? artist,TResult Function( String id, String? artistId, String name, String? albumArtist, DateTime created, String? coverArt, int? year, DateTime? starred, String? genre, int? frequentRank, int? recentRank)? album,TResult Function( String id, String name, String? comment, DateTime created, DateTime changed, String? coverArt, String? owner, bool? public)? playlist,TResult Function( String id, String? albumId, String? artistId, String title, String? artist, String? album, Duration? duration, int? track, int? disc, DateTime? starred, String? genre, String? coverArt)? song,required TResult orElse(),}) {final _that = this;
switch (_that) {
case SourceArtist() when artist != null:
return artist(_that.id,_that.name,_that.starred);case SourceAlbum() when album != null:
return album(_that.id,_that.artistId,_that.name,_that.albumArtist,_that.created,_that.coverArt,_that.year,_that.starred,_that.genre,_that.frequentRank,_that.recentRank);case SourcePlaylist() when playlist != null:
return playlist(_that.id,_that.name,_that.comment,_that.created,_that.changed,_that.coverArt,_that.owner,_that.public);case SourceSong() when song != null:
return song(_that.id,_that.albumId,_that.artistId,_that.title,_that.artist,_that.album,_that.duration,_that.track,_that.disc,_that.starred,_that.genre,_that.coverArt);case _:
return orElse();
}
}
/// A `switch`-like method, using callbacks.
///
/// As opposed to `map`, this offers destructuring.
/// It is equivalent to doing:
/// ```dart
/// switch (sealedClass) {
/// case Subclass(:final field):
/// return ...;
/// case Subclass2(:final field2):
/// return ...;
/// }
/// ```
@optionalTypeArgs TResult when<TResult extends Object?>({required TResult Function( String id, String name, DateTime? starred) artist,required TResult Function( String id, String? artistId, String name, String? albumArtist, DateTime created, String? coverArt, int? year, DateTime? starred, String? genre, int? frequentRank, int? recentRank) album,required TResult Function( String id, String name, String? comment, DateTime created, DateTime changed, String? coverArt, String? owner, bool? public) playlist,required TResult Function( String id, String? albumId, String? artistId, String title, String? artist, String? album, Duration? duration, int? track, int? disc, DateTime? starred, String? genre, String? coverArt) song,}) {final _that = this;
switch (_that) {
case SourceArtist():
return artist(_that.id,_that.name,_that.starred);case SourceAlbum():
return album(_that.id,_that.artistId,_that.name,_that.albumArtist,_that.created,_that.coverArt,_that.year,_that.starred,_that.genre,_that.frequentRank,_that.recentRank);case SourcePlaylist():
return playlist(_that.id,_that.name,_that.comment,_that.created,_that.changed,_that.coverArt,_that.owner,_that.public);case SourceSong():
return song(_that.id,_that.albumId,_that.artistId,_that.title,_that.artist,_that.album,_that.duration,_that.track,_that.disc,_that.starred,_that.genre,_that.coverArt);case _:
throw StateError('Unexpected subclass');
}
}
/// A variant of `when` that fallback to returning `null`
///
/// It is equivalent to doing:
/// ```dart
/// switch (sealedClass) {
/// case Subclass(:final field):
/// return ...;
/// case _:
/// return null;
/// }
/// ```
@optionalTypeArgs TResult? whenOrNull<TResult extends Object?>({TResult? Function( String id, String name, DateTime? starred)? artist,TResult? Function( String id, String? artistId, String name, String? albumArtist, DateTime created, String? coverArt, int? year, DateTime? starred, String? genre, int? frequentRank, int? recentRank)? album,TResult? Function( String id, String name, String? comment, DateTime created, DateTime changed, String? coverArt, String? owner, bool? public)? playlist,TResult? Function( String id, String? albumId, String? artistId, String title, String? artist, String? album, Duration? duration, int? track, int? disc, DateTime? starred, String? genre, String? coverArt)? song,}) {final _that = this;
switch (_that) {
case SourceArtist() when artist != null:
return artist(_that.id,_that.name,_that.starred);case SourceAlbum() when album != null:
return album(_that.id,_that.artistId,_that.name,_that.albumArtist,_that.created,_that.coverArt,_that.year,_that.starred,_that.genre,_that.frequentRank,_that.recentRank);case SourcePlaylist() when playlist != null:
return playlist(_that.id,_that.name,_that.comment,_that.created,_that.changed,_that.coverArt,_that.owner,_that.public);case SourceSong() when song != null:
return song(_that.id,_that.albumId,_that.artistId,_that.title,_that.artist,_that.album,_that.duration,_that.track,_that.disc,_that.starred,_that.genre,_that.coverArt);case _:
return null;
}
}
}
/// @nodoc
class SourceArtist with Starred implements SourceItem {
const SourceArtist({required this.id, required this.name, this.starred});
@override final String id;
final String name;
final DateTime? starred;
/// 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
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));
}
@override
int get hashCode => Object.hash(runtimeType,id,name,starred);
@override
String toString() {
return 'SourceItem.artist(id: $id, name: $name, starred: $starred)';
}
}
/// @nodoc
abstract mixin class $SourceArtistCopyWith<$Res> implements $SourceItemCopyWith<$Res> {
factory $SourceArtistCopyWith(SourceArtist value, $Res Function(SourceArtist) _then) = _$SourceArtistCopyWithImpl;
@override @useResult
$Res call({
String id, String name, DateTime? starred
});
}
/// @nodoc
class _$SourceArtistCopyWithImpl<$Res>
implements $SourceArtistCopyWith<$Res> {
_$SourceArtistCopyWithImpl(this._self, this._then);
final SourceArtist _self;
final $Res Function(SourceArtist) _then;
/// Create a copy of SourceItem
/// with the given fields replaced by the non-null parameter values.
@override @pragma('vm:prefer-inline') $Res call({Object? id = null,Object? name = null,Object? starred = freezed,}) {
return _then(SourceArtist(
id: null == id ? _self.id : id // ignore: cast_nullable_to_non_nullable
as String,name: null == name ? _self.name : name // ignore: cast_nullable_to_non_nullable
as String,starred: freezed == starred ? _self.starred : starred // ignore: cast_nullable_to_non_nullable
as DateTime?,
));
}
}
/// @nodoc
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});
@override final String id;
final String? artistId;
final String name;
final String? albumArtist;
final DateTime created;
final String? coverArt;
final int? year;
final DateTime? starred;
final String? genre;
final int? frequentRank;
final int? recentRank;
/// 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
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));
}
@override
int get hashCode => Object.hash(runtimeType,id,artistId,name,albumArtist,created,coverArt,year,starred,genre,frequentRank,recentRank);
@override
String toString() {
return 'SourceItem.album(id: $id, artistId: $artistId, name: $name, albumArtist: $albumArtist, created: $created, coverArt: $coverArt, year: $year, starred: $starred, genre: $genre, frequentRank: $frequentRank, recentRank: $recentRank)';
}
}
/// @nodoc
abstract mixin class $SourceAlbumCopyWith<$Res> implements $SourceItemCopyWith<$Res> {
factory $SourceAlbumCopyWith(SourceAlbum value, $Res Function(SourceAlbum) _then) = _$SourceAlbumCopyWithImpl;
@override @useResult
$Res call({
String id, String? artistId, String name, String? albumArtist, DateTime created, String? coverArt, int? year, DateTime? starred, String? genre, int? frequentRank, int? recentRank
});
}
/// @nodoc
class _$SourceAlbumCopyWithImpl<$Res>
implements $SourceAlbumCopyWith<$Res> {
_$SourceAlbumCopyWithImpl(this._self, this._then);
final SourceAlbum _self;
final $Res Function(SourceAlbum) _then;
/// Create a copy of SourceItem
/// with the given fields replaced by the non-null parameter values.
@override @pragma('vm:prefer-inline') $Res call({Object? id = null,Object? artistId = freezed,Object? name = null,Object? albumArtist = freezed,Object? created = null,Object? coverArt = freezed,Object? year = freezed,Object? starred = freezed,Object? genre = freezed,Object? frequentRank = freezed,Object? recentRank = freezed,}) {
return _then(SourceAlbum(
id: null == id ? _self.id : id // ignore: cast_nullable_to_non_nullable
as String,artistId: freezed == artistId ? _self.artistId : artistId // ignore: cast_nullable_to_non_nullable
as String?,name: null == name ? _self.name : name // ignore: cast_nullable_to_non_nullable
as String,albumArtist: freezed == albumArtist ? _self.albumArtist : albumArtist // ignore: cast_nullable_to_non_nullable
as String?,created: null == created ? _self.created : created // ignore: cast_nullable_to_non_nullable
as DateTime,coverArt: freezed == coverArt ? _self.coverArt : coverArt // ignore: cast_nullable_to_non_nullable
as String?,year: freezed == year ? _self.year : year // ignore: cast_nullable_to_non_nullable
as int?,starred: freezed == starred ? _self.starred : starred // ignore: cast_nullable_to_non_nullable
as DateTime?,genre: freezed == genre ? _self.genre : genre // ignore: cast_nullable_to_non_nullable
as String?,frequentRank: freezed == frequentRank ? _self.frequentRank : frequentRank // ignore: cast_nullable_to_non_nullable
as int?,recentRank: freezed == recentRank ? _self.recentRank : recentRank // ignore: cast_nullable_to_non_nullable
as int?,
));
}
}
/// @nodoc
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});
@override final String id;
final String name;
final String? comment;
final DateTime created;
final DateTime changed;
final String? coverArt;
final String? owner;
final bool? public;
/// 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
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));
}
@override
int get hashCode => Object.hash(runtimeType,id,name,comment,created,changed,coverArt,owner,public);
@override
String toString() {
return 'SourceItem.playlist(id: $id, name: $name, comment: $comment, created: $created, changed: $changed, coverArt: $coverArt, owner: $owner, public: $public)';
}
}
/// @nodoc
abstract mixin class $SourcePlaylistCopyWith<$Res> implements $SourceItemCopyWith<$Res> {
factory $SourcePlaylistCopyWith(SourcePlaylist value, $Res Function(SourcePlaylist) _then) = _$SourcePlaylistCopyWithImpl;
@override @useResult
$Res call({
String id, String name, String? comment, DateTime created, DateTime changed, String? coverArt, String? owner, bool? public
});
}
/// @nodoc
class _$SourcePlaylistCopyWithImpl<$Res>
implements $SourcePlaylistCopyWith<$Res> {
_$SourcePlaylistCopyWithImpl(this._self, this._then);
final SourcePlaylist _self;
final $Res Function(SourcePlaylist) _then;
/// Create a copy of SourceItem
/// with the given fields replaced by the non-null parameter values.
@override @pragma('vm:prefer-inline') $Res call({Object? id = null,Object? name = null,Object? comment = freezed,Object? created = null,Object? changed = null,Object? coverArt = freezed,Object? owner = freezed,Object? public = freezed,}) {
return _then(SourcePlaylist(
id: null == id ? _self.id : id // ignore: cast_nullable_to_non_nullable
as String,name: null == name ? _self.name : name // ignore: cast_nullable_to_non_nullable
as String,comment: freezed == comment ? _self.comment : comment // ignore: cast_nullable_to_non_nullable
as String?,created: null == created ? _self.created : created // ignore: cast_nullable_to_non_nullable
as DateTime,changed: null == changed ? _self.changed : changed // ignore: cast_nullable_to_non_nullable
as DateTime,coverArt: freezed == coverArt ? _self.coverArt : coverArt // ignore: cast_nullable_to_non_nullable
as String?,owner: freezed == owner ? _self.owner : owner // ignore: cast_nullable_to_non_nullable
as String?,public: freezed == public ? _self.public : public // ignore: cast_nullable_to_non_nullable
as bool?,
));
}
}
/// @nodoc
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});
@override final String id;
final String? albumId;
final String? artistId;
final String title;
final String? artist;
final String? album;
final Duration? duration;
final int? track;
final int? disc;
final DateTime? starred;
final String? genre;
final String? coverArt;
/// 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
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));
}
@override
int get hashCode => Object.hash(runtimeType,id,albumId,artistId,title,artist,album,duration,track,disc,starred,genre,coverArt);
@override
String toString() {
return 'SourceItem.song(id: $id, albumId: $albumId, artistId: $artistId, title: $title, artist: $artist, album: $album, duration: $duration, track: $track, disc: $disc, starred: $starred, genre: $genre, coverArt: $coverArt)';
}
}
/// @nodoc
abstract mixin class $SourceSongCopyWith<$Res> implements $SourceItemCopyWith<$Res> {
factory $SourceSongCopyWith(SourceSong value, $Res Function(SourceSong) _then) = _$SourceSongCopyWithImpl;
@override @useResult
$Res call({
String id, String? albumId, String? artistId, String title, String? artist, String? album, Duration? duration, int? track, int? disc, DateTime? starred, String? genre, String? coverArt
});
}
/// @nodoc
class _$SourceSongCopyWithImpl<$Res>
implements $SourceSongCopyWith<$Res> {
_$SourceSongCopyWithImpl(this._self, this._then);
final SourceSong _self;
final $Res Function(SourceSong) _then;
/// Create a copy of SourceItem
/// with the given fields replaced by the non-null parameter values.
@override @pragma('vm:prefer-inline') $Res call({Object? id = null,Object? albumId = freezed,Object? artistId = freezed,Object? title = null,Object? artist = freezed,Object? album = freezed,Object? duration = freezed,Object? track = freezed,Object? disc = freezed,Object? starred = freezed,Object? genre = freezed,Object? coverArt = freezed,}) {
return _then(SourceSong(
id: null == id ? _self.id : id // ignore: cast_nullable_to_non_nullable
as String,albumId: freezed == albumId ? _self.albumId : albumId // ignore: cast_nullable_to_non_nullable
as String?,artistId: freezed == artistId ? _self.artistId : artistId // ignore: cast_nullable_to_non_nullable
as String?,title: null == title ? _self.title : title // ignore: cast_nullable_to_non_nullable
as String,artist: freezed == artist ? _self.artist : artist // ignore: cast_nullable_to_non_nullable
as String?,album: freezed == album ? _self.album : album // ignore: cast_nullable_to_non_nullable
as String?,duration: freezed == duration ? _self.duration : duration // ignore: cast_nullable_to_non_nullable
as Duration?,track: freezed == track ? _self.track : track // ignore: cast_nullable_to_non_nullable
as int?,disc: freezed == disc ? _self.disc : disc // ignore: cast_nullable_to_non_nullable
as int?,starred: freezed == starred ? _self.starred : starred // ignore: cast_nullable_to_non_nullable
as DateTime?,genre: freezed == genre ? _self.genre : genre // ignore: cast_nullable_to_non_nullable
as String?,coverArt: freezed == coverArt ? _self.coverArt : coverArt // ignore: cast_nullable_to_non_nullable
as String?,
));
}
}
/// @nodoc
mixin _$SourcePlaylistSong {
String get playlistId; String get songId; int get position;
/// Create a copy of SourcePlaylistSong
/// with the given fields replaced by the non-null parameter values.
@JsonKey(includeFromJson: false, includeToJson: false)
@pragma('vm:prefer-inline')
$SourcePlaylistSongCopyWith<SourcePlaylistSong> get copyWith => _$SourcePlaylistSongCopyWithImpl<SourcePlaylistSong>(this as SourcePlaylistSong, _$identity);
@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));
}
@override
int get hashCode => Object.hash(runtimeType,playlistId,songId,position);
@override
String toString() {
return 'SourcePlaylistSong(playlistId: $playlistId, songId: $songId, position: $position)';
}
}
/// @nodoc
abstract mixin class $SourcePlaylistSongCopyWith<$Res> {
factory $SourcePlaylistSongCopyWith(SourcePlaylistSong value, $Res Function(SourcePlaylistSong) _then) = _$SourcePlaylistSongCopyWithImpl;
@useResult
$Res call({
String playlistId, String songId, int position
});
}
/// @nodoc
class _$SourcePlaylistSongCopyWithImpl<$Res>
implements $SourcePlaylistSongCopyWith<$Res> {
_$SourcePlaylistSongCopyWithImpl(this._self, this._then);
final SourcePlaylistSong _self;
final $Res Function(SourcePlaylistSong) _then;
/// Create a copy of SourcePlaylistSong
/// with the given fields replaced by the non-null parameter values.
@pragma('vm:prefer-inline') @override $Res call({Object? playlistId = null,Object? songId = null,Object? position = null,}) {
return _then(_self.copyWith(
playlistId: null == playlistId ? _self.playlistId : playlistId // ignore: cast_nullable_to_non_nullable
as String,songId: null == songId ? _self.songId : songId // ignore: cast_nullable_to_non_nullable
as String,position: null == position ? _self.position : position // ignore: cast_nullable_to_non_nullable
as int,
));
}
}
/// Adds pattern-matching-related methods to [SourcePlaylistSong].
extension SourcePlaylistSongPatterns on SourcePlaylistSong {
/// A variant of `map` that fallback to returning `orElse`.
///
/// It is equivalent to doing:
/// ```dart
/// switch (sealedClass) {
/// case final Subclass value:
/// return ...;
/// case _:
/// return orElse();
/// }
/// ```
@optionalTypeArgs TResult maybeMap<TResult extends Object?>(TResult Function( _SourcePlaylistSong value)? $default,{required TResult orElse(),}){
final _that = this;
switch (_that) {
case _SourcePlaylistSong() when $default != null:
return $default(_that);case _:
return orElse();
}
}
/// A `switch`-like method, using callbacks.
///
/// Callbacks receives the raw object, upcasted.
/// It is equivalent to doing:
/// ```dart
/// switch (sealedClass) {
/// case final Subclass value:
/// return ...;
/// case final Subclass2 value:
/// return ...;
/// }
/// ```
@optionalTypeArgs TResult map<TResult extends Object?>(TResult Function( _SourcePlaylistSong value) $default,){
final _that = this;
switch (_that) {
case _SourcePlaylistSong():
return $default(_that);case _:
throw StateError('Unexpected subclass');
}
}
/// A variant of `map` that fallback to returning `null`.
///
/// It is equivalent to doing:
/// ```dart
/// switch (sealedClass) {
/// case final Subclass value:
/// return ...;
/// case _:
/// return null;
/// }
/// ```
@optionalTypeArgs TResult? mapOrNull<TResult extends Object?>(TResult? Function( _SourcePlaylistSong value)? $default,){
final _that = this;
switch (_that) {
case _SourcePlaylistSong() when $default != null:
return $default(_that);case _:
return null;
}
}
/// A variant of `when` that fallback to an `orElse` callback.
///
/// It is equivalent to doing:
/// ```dart
/// switch (sealedClass) {
/// case Subclass(:final field):
/// return ...;
/// case _:
/// return orElse();
/// }
/// ```
@optionalTypeArgs TResult maybeWhen<TResult extends Object?>(TResult Function( String playlistId, String songId, int position)? $default,{required TResult orElse(),}) {final _that = this;
switch (_that) {
case _SourcePlaylistSong() when $default != null:
return $default(_that.playlistId,_that.songId,_that.position);case _:
return orElse();
}
}
/// A `switch`-like method, using callbacks.
///
/// As opposed to `map`, this offers destructuring.
/// It is equivalent to doing:
/// ```dart
/// switch (sealedClass) {
/// case Subclass(:final field):
/// return ...;
/// case Subclass2(:final field2):
/// return ...;
/// }
/// ```
@optionalTypeArgs TResult when<TResult extends Object?>(TResult Function( String playlistId, String songId, int position) $default,) {final _that = this;
switch (_that) {
case _SourcePlaylistSong():
return $default(_that.playlistId,_that.songId,_that.position);case _:
throw StateError('Unexpected subclass');
}
}
/// A variant of `when` that fallback to returning `null`
///
/// It is equivalent to doing:
/// ```dart
/// switch (sealedClass) {
/// case Subclass(:final field):
/// return ...;
/// case _:
/// return null;
/// }
/// ```
@optionalTypeArgs TResult? whenOrNull<TResult extends Object?>(TResult? Function( String playlistId, String songId, int position)? $default,) {final _that = this;
switch (_that) {
case _SourcePlaylistSong() when $default != null:
return $default(_that.playlistId,_that.songId,_that.position);case _:
return null;
}
}
}
/// @nodoc
class _SourcePlaylistSong implements SourcePlaylistSong {
const _SourcePlaylistSong({required this.playlistId, required this.songId, required this.position});
@override final String playlistId;
@override final String songId;
@override final int position;
/// Create a copy of SourcePlaylistSong
/// with the given fields replaced by the non-null parameter values.
@override @JsonKey(includeFromJson: false, includeToJson: false)
@pragma('vm:prefer-inline')
_$SourcePlaylistSongCopyWith<_SourcePlaylistSong> get copyWith => __$SourcePlaylistSongCopyWithImpl<_SourcePlaylistSong>(this, _$identity);
@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));
}
@override
int get hashCode => Object.hash(runtimeType,playlistId,songId,position);
@override
String toString() {
return 'SourcePlaylistSong(playlistId: $playlistId, songId: $songId, position: $position)';
}
}
/// @nodoc
abstract mixin class _$SourcePlaylistSongCopyWith<$Res> implements $SourcePlaylistSongCopyWith<$Res> {
factory _$SourcePlaylistSongCopyWith(_SourcePlaylistSong value, $Res Function(_SourcePlaylistSong) _then) = __$SourcePlaylistSongCopyWithImpl;
@override @useResult
$Res call({
String playlistId, String songId, int position
});
}
/// @nodoc
class __$SourcePlaylistSongCopyWithImpl<$Res>
implements _$SourcePlaylistSongCopyWith<$Res> {
__$SourcePlaylistSongCopyWithImpl(this._self, this._then);
final _SourcePlaylistSong _self;
final $Res Function(_SourcePlaylistSong) _then;
/// Create a copy of SourcePlaylistSong
/// with the given fields replaced by the non-null parameter values.
@override @pragma('vm:prefer-inline') $Res call({Object? playlistId = null,Object? songId = null,Object? position = null,}) {
return _then(_SourcePlaylistSong(
playlistId: null == playlistId ? _self.playlistId : playlistId // ignore: cast_nullable_to_non_nullable
as String,songId: null == songId ? _self.songId : songId // ignore: cast_nullable_to_non_nullable
as String,position: null == position ? _self.position : position // ignore: cast_nullable_to_non_nullable
as int,
));
}
}
// dart format on

View File

@ -0,0 +1,17 @@
import 'models.dart';
abstract class MusicSource {
Future<void> ping();
Stream<Iterable<SourceAlbum>> allAlbums();
Stream<Iterable<SourceArtist>> allArtists();
Stream<Iterable<SourcePlaylist>> allPlaylists();
Stream<Iterable<SourceSong>> allSongs();
Stream<Iterable<SourcePlaylistSong>> allPlaylistSongs();
Uri streamUri(String songId);
Uri downloadUri(String songId);
Uri coverArtUri(String coverArtId, {bool thumbnail = true});
Future<Uri?> artistArtUri(String artistId, {bool thumbnail = true});
}

View File

@ -0,0 +1,85 @@
import 'dart:convert';
import 'dart:math';
import 'package:crypto/crypto.dart';
import 'package:http/http.dart';
import 'package:xml/xml.dart';
import 'xml.dart';
class SubsonicClient {
SubsonicClient({
required this.http,
required this.address,
required this.username,
required this.password,
this.useTokenAuth = true,
});
final BaseClient http;
final Uri address;
final String username;
final String password;
final bool useTokenAuth;
Uri uri(
String method, [
Map<String, String?>? extraParams,
]) {
final pathSegments = [...address.pathSegments, 'rest', '$method.view'];
final queryParameters = {
..._params(),
...(extraParams ?? {}),
}..removeWhere((_, value) => value == null);
return Uri(
scheme: address.scheme,
host: address.host,
port: address.hasPort ? address.port : null,
pathSegments: pathSegments,
queryParameters: queryParameters,
);
}
Future<SubsonicResponse> get(
String method, [
Map<String, String?>? extraParams,
]) async {
final res = await http.get(uri(method, extraParams));
final subsonicResponse = SubsonicResponse(
XmlDocument.parse(utf8.decode(res.bodyBytes)),
);
if (subsonicResponse.status == Status.failed) {
final error = SubsonicException(subsonicResponse.xml);
throw error;
}
return subsonicResponse;
}
String _salt() {
final r = Random();
return String.fromCharCodes(
List.generate(4, (index) => r.nextInt(92) + 33),
);
}
Map<String, String> _params() {
final Map<String, String> p = {};
p['v'] = '1.13.0';
p['c'] = 'subtracks';
p['u'] = username;
if (useTokenAuth) {
p['s'] = _salt();
p['t'] = md5.convert(utf8.encode(password + p['s']!)).toString();
} else {
p['p'] = password;
}
return p;
}
}

View File

@ -0,0 +1,65 @@
import 'package:xml/xml.dart';
import '../models.dart';
SourceArtist mapArtist(XmlElement e) => SourceArtist(
id: e.getAttribute('id')!,
name: e.getAttribute('name')!,
starred: DateTime.tryParse(e.getAttribute('starred').toString()),
);
SourceAlbum mapAlbum(
XmlElement e, {
int? frequentRank,
int? recentRank,
}) => SourceAlbum(
id: e.getAttribute('id')!,
artistId: e.getAttribute('artistId'),
name: e.getAttribute('name')!,
albumArtist: e.getAttribute('artist'),
created: DateTime.parse(e.getAttribute('created')!),
coverArt: e.getAttribute('coverArt'),
year: int.tryParse(e.getAttribute('year').toString()),
starred: DateTime.tryParse(e.getAttribute('starred').toString()),
genre: e.getAttribute('genre'),
frequentRank: frequentRank,
recentRank: recentRank,
);
SourcePlaylist mapPlaylist(XmlElement e) => SourcePlaylist(
id: e.getAttribute('id')!,
name: e.getAttribute('name')!,
comment: e.getAttribute('comment'),
coverArt: e.getAttribute('coverArt'),
created: DateTime.parse(e.getAttribute('created')!),
changed: DateTime.parse(e.getAttribute('changed')!),
owner: e.getAttribute('owner'),
public: bool.tryParse(e.getAttribute('public').toString()),
);
SourceSong mapSong(XmlElement e) => SourceSong(
id: e.getAttribute('id')!,
albumId: e.getAttribute('albumId'),
artistId: e.getAttribute('artistId'),
title: e.getAttribute('title')!,
album: e.getAttribute('album'),
artist: e.getAttribute('artist'),
duration: e.getAttribute('duration') != null
? Duration(
seconds: int.parse(e.getAttribute('duration').toString()),
)
: null,
track: int.tryParse(e.getAttribute('track').toString()),
disc: int.tryParse(e.getAttribute('discNumber').toString()),
starred: DateTime.tryParse(e.getAttribute('starred').toString()),
genre: e.getAttribute('genre'),
);
SourcePlaylistSong mapPlaylistSong(
int index,
XmlElement e,
) => SourcePlaylistSong(
playlistId: e.parentElement!.getAttribute('id')!,
songId: e.getAttribute('id')!,
position: index,
);

View File

@ -0,0 +1,203 @@
import 'package:collection/collection.dart';
import 'package:pool/pool.dart';
import 'package:xml/xml.dart';
import 'package:xml/xml_events.dart';
import '../models.dart';
import '../music_source.dart';
import 'client.dart';
import 'mapping.dart';
class SubsonicSource implements MusicSource {
SubsonicSource({
required this.client,
required this.maxBitrate,
this.streamFormat,
});
final SubsonicClient client;
final int maxBitrate;
final String? streamFormat;
final _pool = Pool(10, timeout: const Duration(seconds: 60));
bool? _featureEmptyQuerySearch;
Future<bool> get supportsFastSongSync async {
if (_featureEmptyQuerySearch == null) {
final res = await client.get(
'search3',
{'query': '""', 'songCount': '1'},
);
_featureEmptyQuerySearch = res.xml.findAllElements('song').isNotEmpty;
}
return _featureEmptyQuerySearch!;
}
@override
Future<void> ping() async {
await client.get('ping');
}
@override
Stream<Iterable<SourceArtist>> allArtists() async* {
final res = await client.get('getArtists');
for (var artists in res.xml.findAllElements('artist').slices(200)) {
yield artists.map(mapArtist);
}
}
@override
Stream<Iterable<SourceAlbum>> allAlbums() async* {
final extras = await Future.wait([
_albumList(
'frequent',
).flatten().map((element) => element.getAttribute('id')!).toList(),
_albumList(
'recent',
).flatten().map((element) => element.getAttribute('id')!).toList(),
]);
final frequentlyPlayed = {
for (var i = 0; i < extras[0].length; i++) extras[0][i]: i,
};
final recentlyPlayed = {
for (var i = 0; i < extras[1].length; i++) extras[1][i]: i,
};
await for (var albums in _albumList('newest')) {
yield albums.map(
(e) => mapAlbum(
e,
frequentRank: frequentlyPlayed[e.getAttribute('id')!],
recentRank: recentlyPlayed[e.getAttribute('id')!],
),
);
}
}
@override
Stream<Iterable<SourcePlaylist>> allPlaylists() async* {
final res = await client.get('getPlaylists');
for (var playlists in res.xml.findAllElements('playlist').slices(200)) {
yield playlists.map(mapPlaylist);
}
}
@override
Stream<Iterable<SourcePlaylistSong>> allPlaylistSongs() async* {
final allPlaylists = await client.get('getPlaylists');
yield* _pool.forEach(allPlaylists.xml.findAllElements('playlist'), (
playlist,
) async {
final id = playlist.getAttribute('id')!;
final res = await client.get('getPlaylist', {'id': id});
return res.xml.findAllElements('entry').mapIndexed(mapPlaylistSong);
});
}
@override
Stream<Iterable<SourceSong>> allSongs() async* {
if (await supportsFastSongSync) {
await for (var songs in _songSearch()) {
yield songs.map(mapSong);
}
} else {
await for (var albumsList in _albumList('alphabeticalByName')) {
yield* _pool.forEach(albumsList, (album) async {
final albums = await client.get('getAlbum', {
'id': album.getAttribute('id')!,
});
return albums.xml.findAllElements('song').map(mapSong);
});
}
}
}
@override
Uri streamUri(String songId) {
return client.uri('stream', {
'id': songId,
'estimateContentLength': true.toString(),
'maxBitRate': maxBitrate.toString(),
'format': streamFormat?.toString(),
});
}
@override
Uri downloadUri(String songId) {
return client.uri('download', {'id': songId});
}
@override
Uri coverArtUri(String id, {bool thumbnail = true}) {
final opts = {'id': id};
if (thumbnail) {
opts['size'] = 256.toString();
}
return client.uri('getCoverArt', opts);
}
@override
Future<Uri?> artistArtUri(String artistId, {bool thumbnail = true}) async {
final res = await client.get('getArtistInfo2', {'id': artistId});
return Uri.tryParse(
res.xml
.getElement('artistInfo2')
?.getElement(thumbnail ? 'smallImageUrl' : 'largeImageUrl')
?.text ??
'',
);
}
Stream<Iterable<XmlElement>> _albumList(String type) async* {
const size = 500;
var offset = 0;
while (true) {
final res = await client.get('getAlbumList2', {
'type': type,
'size': size.toString(),
'offset': offset.toString(),
});
final albums = res.xml.findAllElements('album');
offset += albums.length;
yield albums;
if (albums.length < size) {
break;
}
}
}
Stream<Iterable<XmlElement>> _songSearch() async* {
const size = 500;
var offset = 0;
while (true) {
final res = await client.get('search3', {
'query': '""',
'songCount': size.toString(),
'songOffset': offset.toString(),
'artistCount': '0',
'albumCount': '0',
});
final songs = res.xml.findAllElements('song');
offset += songs.length;
yield songs;
if (songs.length < size) {
break;
}
}
}
}

View File

@ -0,0 +1,40 @@
import 'package:xml/xml.dart';
enum Status {
ok('ok'),
failed('failed');
const Status(this.value);
final String value;
}
class SubsonicResponse {
SubsonicResponse(XmlDocument xml) {
this.xml = xml.getElement('subsonic-response')!;
status = Status.values.byName(this.xml.getAttribute('status')!);
}
late Status status;
late XmlElement xml;
}
class SubsonicException implements Exception {
SubsonicException(this.xml) {
try {
final error = xml.getElement('error')!;
code = int.parse(error.getAttribute('code')!);
message = error.getAttribute('message')!;
} catch (err) {
code = -1;
message = 'Unknown error.';
}
}
final XmlElement xml;
late int code;
late String message;
@override
String toString() => 'SubsonicException [$code]: $message';
}

16
mise-tasks/servers-reset.sh Executable file
View File

@ -0,0 +1,16 @@
#!/usr/bin/env bash
set -e
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
echo "waiting for library scans..."
sleep 10
docker compose down

View File

@ -1,5 +1,6 @@
[tools]
android-sdk = "latest"
deno = "2.5.3"
flutter = "3.35"
java = "17"
yq = "latest"

View File

@ -90,7 +90,7 @@ packages:
source: hosted
version: "3.0.3"
build_runner:
dependency: transitive
dependency: "direct dev"
description:
name: build_runner
sha256: b24597fceb695969d47025c958f3837f9f0122e237c6a22cb082a5ac66c3ca30
@ -210,7 +210,7 @@ packages:
source: hosted
version: "4.11.0"
collection:
dependency: transitive
dependency: "direct main"
description:
name: collection
sha256: "2f5709ae4d3d59dd8f7cd309b4e023046b57d8a6c82130785d2b0e5868084e76"
@ -234,7 +234,7 @@ packages:
source: hosted
version: "1.15.0"
crypto:
dependency: transitive
dependency: "direct main"
description:
name: crypto
sha256: "1e445881f28f22d6140f181e07737b22f1e099a5e1ff94b0af2f9e4a463f4855"
@ -368,8 +368,16 @@ packages:
description: flutter
source: sdk
version: "0.0.0"
freezed:
dependency: "direct dev"
description:
name: freezed
sha256: "13065f10e135263a4f5a4391b79a8efc5fb8106f8dd555a9e49b750b45393d77"
url: "https://pub.dev"
source: hosted
version: "3.2.3"
freezed_annotation:
dependency: transitive
dependency: "direct main"
description:
name: freezed_annotation
sha256: "7294967ff0a6d98638e7acb774aac3af2550777accd8149c90af5b014e6d44d8"
@ -425,7 +433,7 @@ packages:
source: hosted
version: "4.3.0"
http:
dependency: transitive
dependency: "direct main"
description:
name: http
sha256: bb2ce4590bc2667c96f318d68cac1b5a7987ec819351d32b1c987239a815e007
@ -473,13 +481,21 @@ packages:
source: hosted
version: "0.7.2"
json_annotation:
dependency: transitive
dependency: "direct main"
description:
name: json_annotation
sha256: "1ce844379ca14835a50d2f019a3099f419082cfdd231cd86a142af94dd5c6bb1"
url: "https://pub.dev"
source: hosted
version: "4.9.0"
json_serializable:
dependency: "direct dev"
description:
name: json_serializable
sha256: "33a040668b31b320aafa4822b7b1e177e163fc3c1e835c6750319d4ab23aa6fe"
url: "https://pub.dev"
source: hosted
version: "6.11.1"
leak_tracker:
dependency: transitive
description:
@ -640,6 +656,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "2.3.0"
petitparser:
dependency: transitive
description:
name: petitparser
sha256: "1a97266a94f7350d30ae522c0af07890c70b8e62c71e8e3920d1db4d23c057d1"
url: "https://pub.dev"
source: hosted
version: "7.0.1"
platform:
dependency: transitive
description:
@ -657,7 +681,7 @@ packages:
source: hosted
version: "2.1.8"
pool:
dependency: transitive
dependency: "direct main"
description:
name: pool
sha256: "978783255c543aa3586a1b3c21f6e9d720eb315376a915872c61ef8b5c20177d"
@ -765,6 +789,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "3.1.0"
source_helper:
dependency: transitive
description:
name: source_helper
sha256: "6a3c6cc82073a8797f8c4dc4572146114a39652851c157db37e964d9c7038723"
url: "https://pub.dev"
source: hosted
version: "1.3.8"
source_map_stack_trace:
dependency: transitive
description:
@ -894,7 +926,7 @@ packages:
source: hosted
version: "1.2.2"
test:
dependency: transitive
dependency: "direct dev"
description:
name: test
sha256: "65e29d831719be0591f7b3b1a32a3cda258ec98c58c7b25f7b84241bc31215bb"
@ -1005,6 +1037,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "1.1.0"
xml:
dependency: "direct main"
description:
name: xml
sha256: "971043b3a0d3da28727e40ed3e0b5d18b742fa5a68665cca88e74b7876d5e025"
url: "https://pub.dev"
source: hosted
version: "6.6.1"
yaml:
dependency: transitive
description:

View File

@ -8,13 +8,20 @@ environment:
dependencies:
cached_network_image: ^3.4.1
collection: ^1.19.1
crypto: ^3.0.6
flutter:
sdk: flutter
flutter_hooks: ^0.21.3+1
freezed_annotation: ^3.1.0
go_router: ^16.2.5
hooks_riverpod: ^3.0.3
http: ^1.5.0
infinite_scroll_pagination: ^5.1.1
json_annotation: ^4.9.0
material_symbols_icons: ^4.2874.0
pool: ^1.5.2
xml: ^6.6.1
dev_dependencies:
flutter_test:
@ -22,6 +29,10 @@ dev_dependencies:
flutter_lints: ^6.0.0
custom_lint: ^0.8.0
riverpod_lint: ^3.0.3
build_runner: ^2.7.1
freezed: ^3.2.3
json_serializable: ^6.11.1
test: ^1.26.2
flutter:
uses-material-design: true

View File

@ -0,0 +1,66 @@
import 'package:collection/collection.dart';
import 'package:http/http.dart';
import 'package:subtracks/sources/subsonic/client.dart';
import 'package:subtracks/sources/subsonic/source.dart';
import 'package:test/test.dart';
class TestHttpClient extends BaseClient {
@override
Future<StreamedResponse> send(BaseRequest request) => request.send();
}
class Server {
Server({
required this.name,
required this.client,
});
final String name;
final SubsonicClient client;
}
void main() {
late SubsonicSource source;
final clients = [
Server(
name: 'navidrome',
client: SubsonicClient(
http: TestHttpClient(),
address: Uri.parse('http://localhost:4533/'),
username: 'admin',
password: 'password',
),
),
];
for (final Server(:name, :client) in clients) {
group(name, () {
setUp(() async {
source = SubsonicSource(client: client, maxBitrate: 196);
});
test('ping', () async {
await source.ping();
});
test('allAlbums', () async {
final items = (await source.allAlbums().toList()).flattened.toList();
expect(items.length, equals(3));
});
test('allArtists', () async {
final items = (await source.allArtists().toList()).flattened.toList();
expect(items.length, equals(2));
});
test('allSongs', () async {
final items = (await source.allSongs().toList()).flattened.toList();
expect(items.length, equals(20));
});
});
}
}