From 3408a3988eed71d76a7a021d1c9308e25070a263 Mon Sep 17 00:00:00 2001 From: austinried <4966622+austinried@users.noreply.github.com> Date: Sat, 1 Nov 2025 22:24:59 +0900 Subject: [PATCH] music source and client for subsonic test fixture setup for navidrome --- compose.yaml | 22 + docker/library-manager/Dockerfile | 8 + .../library-manager/scripts/music-download.ts | 44 + .../library-manager/scripts/setup-servers.ts | 18 + docker/library-manager/scripts/util/env.ts | 1 + .../library-manager/scripts/util/subsonic.ts | 24 + lib/sources/models.dart | 77 ++ lib/sources/models.freezed.dart | 810 ++++++++++++++++++ lib/sources/music_source.dart | 17 + lib/sources/subsonic/client.dart | 85 ++ lib/sources/subsonic/mapping.dart | 65 ++ lib/sources/subsonic/source.dart | 203 +++++ lib/sources/subsonic/xml.dart | 40 + mise-tasks/servers-reset.sh | 16 + mise.toml | 1 + pubspec.lock | 56 +- pubspec.yaml | 11 + test/sources/subsonic_test.dart | 66 ++ 18 files changed, 1556 insertions(+), 8 deletions(-) create mode 100644 compose.yaml create mode 100644 docker/library-manager/Dockerfile create mode 100755 docker/library-manager/scripts/music-download.ts create mode 100755 docker/library-manager/scripts/setup-servers.ts create mode 100644 docker/library-manager/scripts/util/env.ts create mode 100644 docker/library-manager/scripts/util/subsonic.ts create mode 100644 lib/sources/models.dart create mode 100644 lib/sources/models.freezed.dart create mode 100644 lib/sources/music_source.dart create mode 100644 lib/sources/subsonic/client.dart create mode 100644 lib/sources/subsonic/mapping.dart create mode 100644 lib/sources/subsonic/source.dart create mode 100644 lib/sources/subsonic/xml.dart create mode 100755 mise-tasks/servers-reset.sh create mode 100644 test/sources/subsonic_test.dart diff --git a/compose.yaml b/compose.yaml new file mode 100644 index 0000000..1e05c24 --- /dev/null +++ b/compose.yaml @@ -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: diff --git a/docker/library-manager/Dockerfile b/docker/library-manager/Dockerfile new file mode 100644 index 0000000..0e4ba14 --- /dev/null +++ b/docker/library-manager/Dockerfile @@ -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 diff --git a/docker/library-manager/scripts/music-download.ts b/docker/library-manager/scripts/music-download.ts new file mode 100755 index 0000000..50dade6 --- /dev/null +++ b/docker/library-manager/scripts/music-download.ts @@ -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"); diff --git a/docker/library-manager/scripts/setup-servers.ts b/docker/library-manager/scripts/setup-servers.ts new file mode 100755 index 0000000..4a596d5 --- /dev/null +++ b/docker/library-manager/scripts/setup-servers.ts @@ -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"); diff --git a/docker/library-manager/scripts/util/env.ts b/docker/library-manager/scripts/util/env.ts new file mode 100644 index 0000000..e1412f0 --- /dev/null +++ b/docker/library-manager/scripts/util/env.ts @@ -0,0 +1 @@ +export const MUSIC_DIR = Deno.env.get("MUSIC_DIR") ?? "/music"; diff --git a/docker/library-manager/scripts/util/subsonic.ts b/docker/library-manager/scripts/util/subsonic.ts new file mode 100644 index 0000000..0d3f313 --- /dev/null +++ b/docker/library-manager/scripts/util/subsonic.ts @@ -0,0 +1,24 @@ +export class SubsonicClient { + constructor( + readonly baseUrl: string, + readonly username: string, + readonly password: string, + ) {} + + get(method: string, params?: Record) { + 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); + } +} diff --git a/lib/sources/models.dart b/lib/sources/models.dart new file mode 100644 index 0000000..7d78ee4 --- /dev/null +++ b/lib/sources/models.dart @@ -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() + const factory SourceItem.artist({ + required String id, + required String name, + DateTime? starred, + }) = SourceArtist; + + @With() + @With() + 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() + 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() + @With() + 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; +} diff --git a/lib/sources/models.freezed.dart b/lib/sources/models.freezed.dart new file mode 100644 index 0000000..7f66c3b --- /dev/null +++ b/lib/sources/models.freezed.dart @@ -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 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 get copyWith => _$SourceItemCopyWithImpl(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 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({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? 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 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({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? 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 get copyWith => _$SourceArtistCopyWithImpl(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 get copyWith => _$SourceAlbumCopyWithImpl(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 get copyWith => _$SourcePlaylistCopyWithImpl(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 get copyWith => _$SourceSongCopyWithImpl(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 get copyWith => _$SourcePlaylistSongCopyWithImpl(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 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 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? 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 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 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? 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 diff --git a/lib/sources/music_source.dart b/lib/sources/music_source.dart new file mode 100644 index 0000000..c4293e0 --- /dev/null +++ b/lib/sources/music_source.dart @@ -0,0 +1,17 @@ +import 'models.dart'; + +abstract class MusicSource { + Future ping(); + + Stream> allAlbums(); + Stream> allArtists(); + Stream> allPlaylists(); + Stream> allSongs(); + Stream> allPlaylistSongs(); + + Uri streamUri(String songId); + Uri downloadUri(String songId); + + Uri coverArtUri(String coverArtId, {bool thumbnail = true}); + Future artistArtUri(String artistId, {bool thumbnail = true}); +} diff --git a/lib/sources/subsonic/client.dart b/lib/sources/subsonic/client.dart new file mode 100644 index 0000000..3e8e911 --- /dev/null +++ b/lib/sources/subsonic/client.dart @@ -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? 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 get( + String method, [ + Map? 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 _params() { + final Map 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; + } +} diff --git a/lib/sources/subsonic/mapping.dart b/lib/sources/subsonic/mapping.dart new file mode 100644 index 0000000..ac25eb6 --- /dev/null +++ b/lib/sources/subsonic/mapping.dart @@ -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, +); diff --git a/lib/sources/subsonic/source.dart b/lib/sources/subsonic/source.dart new file mode 100644 index 0000000..f5d4b0d --- /dev/null +++ b/lib/sources/subsonic/source.dart @@ -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 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 ping() async { + await client.get('ping'); + } + + @override + Stream> allArtists() async* { + final res = await client.get('getArtists'); + + for (var artists in res.xml.findAllElements('artist').slices(200)) { + yield artists.map(mapArtist); + } + } + + @override + Stream> 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> allPlaylists() async* { + final res = await client.get('getPlaylists'); + + for (var playlists in res.xml.findAllElements('playlist').slices(200)) { + yield playlists.map(mapPlaylist); + } + } + + @override + Stream> 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> 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 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> _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> _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; + } + } + } +} diff --git a/lib/sources/subsonic/xml.dart b/lib/sources/subsonic/xml.dart new file mode 100644 index 0000000..e09eddc --- /dev/null +++ b/lib/sources/subsonic/xml.dart @@ -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'; +} diff --git a/mise-tasks/servers-reset.sh b/mise-tasks/servers-reset.sh new file mode 100755 index 0000000..e4bea48 --- /dev/null +++ b/mise-tasks/servers-reset.sh @@ -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 diff --git a/mise.toml b/mise.toml index 95e7bb5..480cd32 100644 --- a/mise.toml +++ b/mise.toml @@ -1,5 +1,6 @@ [tools] android-sdk = "latest" +deno = "2.5.3" flutter = "3.35" java = "17" yq = "latest" diff --git a/pubspec.lock b/pubspec.lock index 918b180..7ba2831 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -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: diff --git a/pubspec.yaml b/pubspec.yaml index 5345969..5581df5 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -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 diff --git a/test/sources/subsonic_test.dart b/test/sources/subsonic_test.dart new file mode 100644 index 0000000..fcc01a3 --- /dev/null +++ b/test/sources/subsonic_test.dart @@ -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 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)); + }); + }); + } +}