From 77d6a5a51d2b938e0408731c8f2db2a13de97f5f Mon Sep 17 00:00:00 2001 From: Pato05 Date: Sat, 28 Oct 2023 14:42:06 +0200 Subject: [PATCH] put common deezer audio logic in DeezerAudio class --- lib/api/deezer_audio.dart | 318 ++++++++++++++++++ lib/api/deezer_audio_source.dart | 270 +-------------- lib/api/download_manager/database.dart | 284 ++++++++++++++-- .../download_manager/download_manager.dart | 72 +++- .../download_manager/download_service.dart | 6 +- lib/main.dart | 7 +- 6 files changed, 671 insertions(+), 286 deletions(-) create mode 100644 lib/api/deezer_audio.dart diff --git a/lib/api/deezer_audio.dart b/lib/api/deezer_audio.dart new file mode 100644 index 0000000..facee3a --- /dev/null +++ b/lib/api/deezer_audio.dart @@ -0,0 +1,318 @@ +import 'dart:convert'; +import 'dart:typed_data'; + +import 'package:crypto/crypto.dart'; +import 'package:dart_blowfish/dart_blowfish.dart'; +import 'package:encrypt/encrypt.dart'; +import 'package:freezer/api/deezer.dart'; +import 'package:freezer/api/definitions.dart'; +import 'package:freezer/settings.dart'; +import 'package:logging/logging.dart'; +import 'package:http/http.dart' as http; + +class DeezerAudio { + static const chunkSize = 2048; + + final DeezerAPI deezerAPI; + String md5origin; + AudioQuality quality; + String trackId; + String mediaVersion; + + DeezerAudio({ + required this.deezerAPI, + required this.md5origin, + required this.quality, + required this.trackId, + required this.mediaVersion, + }); + + static final _logger = Logger('DeezerAudio'); + + Future fallback() async { + final res = await fallbackUrl( + md5origin: md5origin, + preferredQuality: quality, + trackId: trackId, + mediaVersion: mediaVersion); + md5origin = res.md5origin; + quality = res.quality; + trackId = res.trackId; + mediaVersion = res.mediaVersion; + return res.uri; + } + + Future< + ({ + String md5origin, + String trackId, + String mediaVersion, + AudioQuality quality, + Uri uri + })> fallbackUrl({ + required String md5origin, + required AudioQuality preferredQuality, + required String trackId, + required String mediaVersion, + }) async { + _logger.finer("called _fallbackUrl()"); + + try { + final rec = await qualityFallback( + md5origin, preferredQuality, trackId, mediaVersion); + return ( + md5origin: md5origin, + trackId: trackId, + mediaVersion: mediaVersion, + quality: rec.$1, + uri: rec.$2 + ); + } on QualityException { + _logger.warning("quality fallback failed! trying trackId fallback"); + } + + Map? privateJson; + try { + // TRACK ID FALLBACK + final data = await deezerAPI + .callApi("deezer.pageTrack", params: {"sng_id": trackId}); + privateJson = data["results"]["DATA"]; + if (privateJson!.containsKey("FALLBACK")) { + String fallbackId = privateJson["FALLBACK"]["SNG_ID"]; + if (fallbackId != trackId) { + final newPrivate = + await deezerAPI.callApi("song.getListData", params: { + "sng_ids": [fallbackId] + }); + final Map trackData = newPrivate["results"]["data"][0]; + trackId = trackData["SNG_ID"]; + md5origin = trackData["MD5_ORIGIN"]; + mediaVersion = trackData["MEDIA_VERSION"]; + return await fallbackUrl( + md5origin: md5origin, + preferredQuality: preferredQuality, + trackId: trackId, + mediaVersion: mediaVersion, + ); + } + } + } catch (e, st) { + _logger.warning("ID fallback failed! Trying ISRC fallback!", e, st); + } + + try { + final data = + await deezerAPI.callPublicApi("track/isrc:${privateJson!["ISRC"]!}"); + final newId = data["id"] as int; + if (newId == int.parse(trackId)) throw Exception(); + + final newPrivate = await deezerAPI.callApi("song.getListData", params: { + "sng_ids": [newId] + }); + final trackData = newPrivate["results"]["data"][0] as Map; + trackId = trackData["SNG_ID"]; + md5origin = trackData["MD5_ORIGIN"]; + mediaVersion = trackData["MEDIA_VERSION"]; + return await fallbackUrl( + md5origin: md5origin, + preferredQuality: preferredQuality, + trackId: trackId, + mediaVersion: mediaVersion, + ); + } catch (e, st) { + _logger.severe("ISRC Fallback failed, track unavailable!", e, st); + } + + throw Exception( + "Every known method of fallback failed, track unavailable!"); + } + + Future<(AudioQuality, Uri)> qualityFallback(String md5origin, + AudioQuality currentQuality, String trackId, String mediaVersion) async { + // only use url generation with MP3_128 + currentQuality = AudioQuality.MP3_128; + final genUri = + _generateTrackUri(md5origin, currentQuality, trackId, mediaVersion); + + final req = await http.head(genUri, headers: { + 'User-Agent': DeezerAPI.userAgent, + 'Accept-Language': '*', + 'Accept': '*/*' + }); + int rc = req.statusCode; + if (rc > 400) { + _logger.warning( + "quality fallback, response code: $rc, current quality: $currentQuality"); + switch (currentQuality) { + case AudioQuality.FLAC: + currentQuality = AudioQuality.MP3_320; + break; + case AudioQuality.MP3_320: + currentQuality = AudioQuality.MP3_128; + break; + case AudioQuality.MP3_128: + default: + throw QualityException("No quality to fallback to!"); + } + + return await qualityFallback( + md5origin, currentQuality, trackId, mediaVersion); + } + + return (currentQuality, genUri); + } + + Uri _generateTrackUri(String md5origin, AudioQuality quality, String trackId, + String mediaVersion) { + int magic = 164; + // step 1 + final step1 = [ + ...md5origin.codeUnits, + magic, + ...quality.toDeezerQualityInt().toString().codeUnits, + magic, + ...trackId.codeUnits, + magic, + ...mediaVersion.codeUnits, + ]; + + // get md5 hash of step1 + final md5hex = md5.convert(step1).toString().toLowerCase(); + + // step2 + final step2 = [ + ...md5hex.codeUnits, + magic, + ...step1, + magic, + ]; + + // pad step2 with dots to get correct length + while (step2.length % 16 > 0) { + // step2.addAll(('.' * (step2.length % 16)).codeUnits); + step2.add(46); + } + + // encrypt with AES ECB + final k = Uint8List.fromList('jo6aey6haid2Teih'.codeUnits); + final encrypter = Encrypter(AES(Key(k), mode: AESMode.ecb, padding: null)); + final String step3 = encrypter + .encryptBytes(step2, iv: IV(Uint8List(8))) + .base16 + .toLowerCase(); + + final uri = + Uri.https('e-cdns-proxy-${md5origin[0]}.dzcdn.net', '/mobile/1/$step3'); + return uri; + } + + static List getKey(String id) { + final secret = utf8.encode('g4el58wc0zvf9na1'); + final idmd5 = + utf8.encode(md5.convert(utf8.encode(id)).toString().toLowerCase()); + final buffer = []; + for (int i = 0; i < 16; i++) { + final s0 = idmd5[i]; + final s1 = idmd5[i + 16]; + final s2 = secret[i]; + buffer.add(s0 ^ s1 ^ s2); + } + return buffer; + } + + static List decryptChunk(List key, List data) { + final Uint8List iv = + Uint8List.fromList(const [00, 01, 02, 03, 04, 05, 06, 07]); + final bf = Blowfish( + key: Uint8List.fromList(key), mode: Mode.cbc, padding: Padding.none) + ..setIv(iv); + Uint8List decrypted = bf.decode(data, returnType: Type.uInt8Array); + return decrypted; + } + + static Stream> decryptionStream(Stream> source, + {required int start, required String trackId}) async* { + var dropBytes = start % 2048; + final deezerStart = start - dropBytes; + int counter = deezerStart ~/ chunkSize; + final buffer = List.empty(growable: true); + final key = getKey(trackId); + + await for (var bytes in source) { + buffer.addAll(bytes); + + int i; + for (i = 0; i < buffer.length; i += chunkSize) { + if (buffer.length <= i + chunkSize) { + break; + } + + bytes = buffer.sublist(i, i + chunkSize); + + if ((counter % 3) == 0) { + bytes = decryptChunk(key, bytes); + } + + if (dropBytes > 0) { + bytes = bytes.sublist(dropBytes); + dropBytes = 0; + } + + counter++; + yield bytes; + } + + if (i < buffer.length) { + buffer.removeRange(0, i); + } else { + buffer.clear(); + } + } + + // add remaining items in buffer + + if (buffer.isNotEmpty) { + if (dropBytes > 0) { + yield buffer.sublist(dropBytes); + return; + } + + yield buffer; + } + } + + Future getUrl(String trackToken, int expiration) => + getTrackUrl(deezerAPI, trackId, trackToken, expiration, quality: quality); + + static Future getTrackUrl( + DeezerAPI deezerAPI, + String trackId, + String trackToken, + int expiration, { + required AudioQuality quality, + }) async { + final String actualTrackToken; + if (DateTime.now().millisecondsSinceEpoch ~/ 1000 > expiration) { + if (!deezerAPI.canStreamHQ && !deezerAPI.canStreamLossless) { + _logger.fine('using old url generation, token expired.'); + } + final newTrack = await deezerAPI.track(trackId); + actualTrackToken = newTrack.trackToken!; + } else { + actualTrackToken = trackToken; + } + + final res = await deezerAPI.getTrackUrl( + actualTrackToken, quality.toDeezerQualityString()); + if (res.error != null) { + _logger.warning('Error while getting track url: ${res.error!}'); + return null; + } + if (res.sources == null) { + _logger.warning('Error while getting track url: No sources!'); + return null; + } + + return Uri.parse(res.sources![0].url); + } +} diff --git a/lib/api/deezer_audio_source.dart b/lib/api/deezer_audio_source.dart index 12360f8..8106154 100644 --- a/lib/api/deezer_audio_source.dart +++ b/lib/api/deezer_audio_source.dart @@ -1,17 +1,13 @@ import 'dart:async'; -import 'dart:convert'; import 'dart:io'; import 'dart:isolate'; -import 'dart:typed_data'; -import 'package:encrypt/encrypt.dart'; import 'package:freezer/api/deezer.dart'; +import 'package:freezer/api/deezer_audio.dart'; import 'package:freezer/api/definitions.dart'; import 'package:freezer/settings.dart'; import 'package:just_audio/just_audio.dart'; -import 'package:crypto/crypto.dart'; import 'package:http/http.dart' as http; -import 'package:dart_blowfish/dart_blowfish.dart'; import 'package:logging/logging.dart'; typedef _IsolateMessage = ( @@ -27,7 +23,6 @@ class DeezerAudioSource extends StreamAudioSource { static final _logger = Logger("DeezerAudioSource"); late AudioQuality Function() _getQuality; - late AudioQuality? _initialQuality; late String _trackId; late String _md5origin; late String _mediaVersion; @@ -36,7 +31,7 @@ class DeezerAudioSource extends StreamAudioSource { final StreamInfoCallback? onStreamObtained; // some cache - AudioQuality? _currentQuality; + final DeezerAudio _deezerAudio; int? _cachedSourceLength; String? _cachedContentType; Uri? _downloadUrl; @@ -49,255 +44,24 @@ class DeezerAudioSource extends StreamAudioSource { required this.trackToken, required this.trackTokenExpiration, this.onStreamObtained, - }) { + }) : _deezerAudio = DeezerAudio( + deezerAPI: deezerAPI, + md5origin: md5origin, + quality: getQuality.call(), + trackId: trackId, + mediaVersion: mediaVersion, + ) { _getQuality = getQuality; - _initialQuality = quality; _trackId = trackId; _md5origin = md5origin; _mediaVersion = mediaVersion; } - AudioQuality? get quality => _currentQuality; + AudioQuality? get quality => _deezerAudio.quality; String get trackId => _trackId; String get md5origin => _md5origin; String get mediaVersion => _mediaVersion; - static const chunkSize = 2048; - - void _updateTrackData(Map trackData) { - _trackId = trackData["SNG_ID"]; - _md5origin = trackData["MD5_ORIGIN"]; - _mediaVersion = trackData["MEDIA_VERSION"]; - } - - Future _fallbackUrl() async { - _logger.finer("called _fallbackUrl()"); - try { - return await _qualityFallback(); - } on QualityException { - _logger.warning("quality fallback failed! trying trackId fallback"); - _currentQuality = _initialQuality; - } - - Map? privateJson; - try { - // TRACK ID FALLBACK - final data = await deezerAPI - .callApi("deezer.pageTrack", params: {"sng_id": trackId}); - privateJson = data["results"]["DATA"]; - if (privateJson!.containsKey("FALLBACK")) { - String fallbackId = privateJson["FALLBACK"]["SNG_ID"]; - if (fallbackId != trackId) { - final newPrivate = - await deezerAPI.callApi("song.getListData", params: { - "sng_ids": [fallbackId] - }); - final Map trackData = newPrivate["results"]["data"][0]; - _updateTrackData(trackData); - return await _fallbackUrl(); - } - } - } catch (e, st) { - _logger.warning("ID fallback failed! Trying ISRC fallback!", e, st); - } - - try { - final data = - await deezerAPI.callPublicApi("track/isrc:${privateJson!["ISRC"]!}"); - final newId = data["id"] as int; - if (newId == int.parse(trackId)) throw Exception(); - - final newPrivate = await deezerAPI.callApi("song.getListData", params: { - "sng_ids": [newId] - }); - final trackData = newPrivate["results"]["data"][0] as Map; - _updateTrackData(trackData); - return await _fallbackUrl(); - } catch (e, st) { - _logger.severe("ISRC Fallback failed, track unavailable!", e, st); - } - - throw Exception( - "Every known method of fallback failed, track unavailable!"); - } - - Future _qualityFallback() async { - // only use url generation with MP3_128 - _currentQuality = AudioQuality.MP3_128; - final genUri = _generateTrackUri(); - - final req = await http.head(genUri, headers: { - 'User-Agent': DeezerAPI.userAgent, - 'Accept-Language': '*', - 'Accept': '*/*' - }); - int rc = req.statusCode; - if (rc > 400) { - _logger.warning( - "quality fallback, response code: $rc, current quality: $quality"); - switch (_currentQuality) { - case AudioQuality.FLAC: - _currentQuality = AudioQuality.MP3_320; - break; - case AudioQuality.MP3_320: - _currentQuality = AudioQuality.MP3_128; - break; - case AudioQuality.MP3_128: - default: - _currentQuality = null; - throw QualityException("No quality to fallback to!"); - } - - return await _qualityFallback(); - } - - return genUri; - } - - Uri _generateTrackUri() { - int magic = 164; - // step 1 - final step1 = [ - ...md5origin.codeUnits, - magic, - ...quality!.toDeezerQualityInt().toString().codeUnits, - magic, - ...trackId.codeUnits, - magic, - ...mediaVersion.codeUnits, - ]; - - // get md5 hash of step1 - final md5hex = md5.convert(step1).toString().toLowerCase(); - - // step2 - final step2 = [ - ...md5hex.codeUnits, - magic, - ...step1, - magic, - ]; - - // pad step2 with dots to get correct length - while (step2.length % 16 > 0) { - // step2.addAll(('.' * (step2.length % 16)).codeUnits); - step2.add(46); - } - - // encrypt with AES ECB - final k = Uint8List.fromList('jo6aey6haid2Teih'.codeUnits); - final encrypter = Encrypter(AES(Key(k), mode: AESMode.ecb, padding: null)); - final String step3 = encrypter - .encryptBytes(step2, iv: IV(Uint8List(8))) - .base16 - .toLowerCase(); - - final uri = - Uri.https('e-cdns-proxy-${md5origin[0]}.dzcdn.net', '/mobile/1/$step3'); - return uri; - } - - static List getKey(String id) { - final secret = utf8.encode('g4el58wc0zvf9na1'); - final idmd5 = - utf8.encode(md5.convert(utf8.encode(id)).toString().toLowerCase()); - final buffer = []; - for (int i = 0; i < 16; i++) { - final s0 = idmd5[i]; - final s1 = idmd5[i + 16]; - final s2 = secret[i]; - buffer.add(s0 ^ s1 ^ s2); - } - return buffer; - } - - static List decryptChunk(List key, List data) { - final Uint8List iv = - Uint8List.fromList(const [00, 01, 02, 03, 04, 05, 06, 07]); - final bf = Blowfish( - key: Uint8List.fromList(key), mode: Mode.cbc, padding: Padding.none) - ..setIv(iv); - Uint8List decrypted = bf.decode(data, returnType: Type.uInt8Array); - return decrypted; - } - - static Stream> decryptionStream(Stream> source, - {required int start, required String trackId}) async* { - var dropBytes = start % 2048; - final deezerStart = start - dropBytes; - int counter = deezerStart ~/ chunkSize; - final buffer = List.empty(growable: true); - final key = getKey(trackId); - - await for (var bytes in source) { - buffer.addAll(bytes); - - int i; - for (i = 0; i < buffer.length; i += chunkSize) { - if (buffer.length <= i + chunkSize) { - break; - } - - bytes = buffer.sublist(i, i + chunkSize); - - if ((counter % 3) == 0) { - bytes = decryptChunk(key, bytes); - } - - if (dropBytes > 0) { - bytes = bytes.sublist(dropBytes); - dropBytes = 0; - } - - counter++; - yield bytes; - } - - if (i < buffer.length) { - buffer.removeRange(0, i); - } else { - buffer.clear(); - } - } - - // add remaining items in buffer - - if (buffer.isNotEmpty) { - if (dropBytes > 0) { - yield buffer.sublist(dropBytes); - return; - } - - yield buffer; - } - } - - Future _getUrl() async { - final String actualTrackToken; - if (DateTime.now().millisecondsSinceEpoch ~/ 1000 > trackTokenExpiration) { - if (!deezerAPI.canStreamHQ && !deezerAPI.canStreamLossless) { - _logger.fine('using old url generation, token expired.'); - } - final newTrack = await deezerAPI.track(trackId); - actualTrackToken = newTrack.trackToken!; - } else { - actualTrackToken = trackToken; - } - - final res = await deezerAPI.getTrackUrl( - actualTrackToken, _currentQuality!.toDeezerQualityString()); - if (res.error != null) { - _logger.warning('Error while getting track url: ${res.error!}'); - return null; - } - if (res.sources == null) { - _logger.warning('Error while getting track url: No sources!'); - return null; - } - - return Uri.parse(res.sources![0].url); - } - @override Future request([int? start, int? end]) async { start ??= 0; @@ -322,15 +86,16 @@ class DeezerAudioSource extends StreamAudioSource { // determine quality to use final newQuality = _getQuality.call(); - if (_downloadUrl != null && _currentQuality != newQuality) { + if (_downloadUrl != null && quality != newQuality) { // update currentUrl to get tracks with new quality _downloadUrl = null; } - _currentQuality = newQuality; + _deezerAudio.quality = newQuality; if (_downloadUrl == null) { - final gottenUrl = await _getUrl(); + final gottenUrl = + await _deezerAudio.getUrl(trackToken, trackTokenExpiration); if (gottenUrl != null) { _downloadUrl = gottenUrl; } else { @@ -338,7 +103,7 @@ class DeezerAudioSource extends StreamAudioSource { // OLD URL GENERATION try { - _downloadUrl = await _fallbackUrl(); + _downloadUrl = await _deezerAudio.fallback(); } on QualityException { rethrow; } @@ -370,10 +135,11 @@ class DeezerAudioSource extends StreamAudioSource { throw Exception(await res.stream.bytesToString()); } - int dropBytes = start % chunkSize; + int dropBytes = start % DeezerAudio.chunkSize; _logger.finest( "deezerStart: $deezerStart (actual start: $start), end: $end, dropBytes: $dropBytes"); - final stream = decryptionStream(res.stream, start: start, trackId: trackId); + final stream = DeezerAudio.decryptionStream(res.stream, + start: start, trackId: trackId); final cl = res.contentLength! - dropBytes; diff --git a/lib/api/download_manager/database.dart b/lib/api/download_manager/database.dart index 744361a..124c7d8 100644 --- a/lib/api/download_manager/database.dart +++ b/lib/api/download_manager/database.dart @@ -1,31 +1,265 @@ +import 'package:freezer/api/definitions.dart' as d; +import 'package:freezer/api/definitions.dart' show AlbumType; import 'package:isar/isar.dart'; +part 'database.g.dart'; + @collection class Track { - Id id = Isar.autoIncrement; - final String trackId; - final String title; - final String albumId; - final List artistIds; - //final DeezerImageDetails albumArt; - final int? trackNumber; - final bool offline; - //final Lyrics lyrics; - final bool favorite; - final int? diskNumber; - final bool explicit; + Id get isarId => int.parse(id); + late final String id; - Track({ - required this.trackId, - required this.title, - required this.albumId, - required this.artistIds, - //required this.albumArt, - required this.trackNumber, - //required this.lyrics, - required this.favorite, - required this.diskNumber, - required this.explicit, - this.offline = true, - }); + // index title for search + @Index(type: IndexType.value) + late final String title; + late final String albumId; + late final List artistIds; + late final DeezerImageDetails albumArt; + late final int? trackNumber; + late final bool offline; + late final Lyrics? lyrics; + late final bool favorite; + late final int? diskNumber; + late final bool explicit; + + Track(); + + factory Track.from(d.Track t) { + return Track() + ..id = t.id + ..title = t.title! + ..albumId = t.album!.id! + ..artistIds = t.artists!.map((e) => e.id).toList(growable: false) + ..albumArt = DeezerImageDetails.from(t.albumArt!) + ..trackNumber = t.trackNumber + ..offline = t.offline ?? false + ..lyrics = t.lyrics == null ? null : Lyrics.from(t.lyrics!) + ..favorite = t.favorite ?? false + ..diskNumber = t.diskNumber + ..explicit = t.explicit ?? false; + } + + d.Track to({d.Album? album, List? artists}) { + return d.Track( + id: id, + title: title, + album: album ?? d.Album(id: albumId), + artists: artists ?? + artistIds.map((id) => d.Artist(id: id)).toList(growable: false), + albumArt: albumArt.to(), + trackNumber: trackNumber, + offline: offline, + lyrics: lyrics?.to(), + favorite: favorite, + diskNumber: diskNumber, + explicit: explicit, + ); + } +} + +@collection +class Album { + Id get isarId => int.parse(id); + late final String id; + + @Index(type: IndexType.value) + late final String title; + late final List artistIds; + late final List trackIds; + late final DeezerImageDetails art; + late final int fansCount; + late final bool offline; + late final bool library; + @enumerated + late final AlbumType type; + late final String releaseDate; + + Album(); + + factory Album.from(d.Album album) { + return Album() + ..id = album.id! + ..title = album.title! + ..artistIds = album.artists!.map((e) => e.id).toList(growable: false) + ..trackIds = album.tracks!.map((e) => e.id).toList(growable: false) + ..art = DeezerImageDetails.from(album.art!) + ..fansCount = album.fans! + ..offline = album.offline ?? false + ..library = album.library ?? false + ..type = album.type! + ..releaseDate = album.releaseDate!; + } + + d.Album to({List? artists, List? tracks}) { + return d.Album( + id: id, + title: title, + art: art.to(), + artists: artists ?? + artistIds.map((id) => d.Artist(id: id)).toList(growable: false), + tracks: tracks ?? + trackIds.map((id) => d.Track(id: id)).toList(growable: false), + fans: fansCount, + offline: offline, + library: library, + type: type, + releaseDate: releaseDate, + ); + } +} + +@collection +class Artist { + Id get isarId => int.parse(id); + late final String id; + + @Index(type: IndexType.value) + late final String name; + late final List albumIds; + late final List topTracksIds; + late final DeezerImageDetails picture; + late final int fansCount; + late final int albumCount; + late final bool offline; + late final bool library; + late final bool radio; + + Artist(); + + factory Artist.from(d.Artist artist) { + return Artist() + ..id = artist.id + ..name = artist.name! + ..albumIds = artist.albums! + .map((d.Album a) => a.id!) + .toList(growable: false) + ..topTracksIds = artist.topTracks! + .map((d.Track t) => t.id) + .toList(growable: false) + ..picture = DeezerImageDetails.from(artist.picture!) + ..fansCount = artist.fans ?? 0 + ..albumCount = artist.albumCount ?? 0 + ..offline = artist.offline ?? false + ..library = artist.library ?? false + ..radio = artist.radio ?? false; + } +} + +@collection +class Playlist { + late final String id; + + Id get isarId => int.parse(id); + + @Index(type: IndexType.value) + late final String title; + late final List trackIds; + late final DeezerImageDetails image; + late final int durationSec; + late final String userId; + late final String userName; + late final int fansCount; + late final String description; + late final bool library; + + Playlist(); + + factory Playlist.from(d.Playlist playlist) { + return Playlist() + ..id = playlist.id + ..title = playlist.title! + ..trackIds = playlist.tracks! + .map((d.Track t) => t.id) + .toList(growable: false) + ..image = DeezerImageDetails.from(playlist.image! as d.DeezerImageDetails) + ..durationSec = playlist.duration!.inSeconds + ..userId = playlist.user!.id! + ..userName = playlist.user!.name! + ..fansCount = playlist.fans ?? 0 + ..description = playlist.description ?? '' + ..library = playlist.library ?? false; + } + + d.Playlist to({List? tracks}) { + return d.Playlist( + id: id, + title: title, + tracks: tracks ?? + trackIds.map((id) => d.Track(id: id)).toList(growable: false), + image: image.to(), + trackCount: trackIds.length, + duration: Duration(seconds: durationSec), + user: d.User(id: userId, name: userName), + fans: fansCount, + library: library, + description: description, + ); + } +} + +@embedded +class DeezerImageDetails { + late String type; + late String md5; + + DeezerImageDetails(); + + factory DeezerImageDetails.from(d.DeezerImageDetails details) { + return DeezerImageDetails() + ..type = details.type + ..md5 = details.md5; + } + + d.DeezerImageDetails to() { + return d.DeezerImageDetails(md5, type: type); + } +} + +@collection +class Lyrics { + late final String lyricsId; + late final String writers; + late final List lyrics; + late final bool sync; + + Lyrics(); + + factory Lyrics.from(d.Lyrics lyrics) { + return Lyrics() + ..lyricsId = lyrics.id ?? '' + ..writers = lyrics.writers ?? '' + ..sync = lyrics.sync + ..lyrics = lyrics.lyrics!.map(Lyric.from).toList(growable: false); + } + + d.Lyrics to() { + return d.Lyrics( + id: lyricsId, + writers: writers, + sync: sync, + lyrics: lyrics.map((e) => e.to()).toList(growable: false)); + } +} + +@embedded +class Lyric { + late final String text; + late final int? offsetMs; + late final String? lrcTimestamp; + + Lyric(); + + factory Lyric.from(d.Lyric l) { + return Lyric() + ..text = l.text! + ..offsetMs = l.offset?.inMilliseconds + ..lrcTimestamp = l.lrcTimestamp; + } + + d.Lyric to() { + return d.Lyric( + offset: offsetMs == null ? null : Duration(milliseconds: offsetMs!), + text: text, + lrcTimestamp: lrcTimestamp); + } } diff --git a/lib/api/download_manager/download_manager.dart b/lib/api/download_manager/download_manager.dart index e5024da..d006b6b 100644 --- a/lib/api/download_manager/download_manager.dart +++ b/lib/api/download_manager/download_manager.dart @@ -3,17 +3,41 @@ import 'dart:isolate'; import 'package:flutter/src/widgets/framework.dart'; import 'package:flutter_background_service/flutter_background_service.dart'; -import 'package:freezer/api/definitions.dart'; +import 'package:freezer/api/deezer.dart'; +import 'package:freezer/api/definitions.dart' as d; +import 'package:freezer/api/download_manager/database.dart'; import 'package:freezer/api/download_manager/download_service.dart'; import 'package:freezer/api/download_manager/service_interface.dart'; +import 'package:freezer/api/paths.dart'; +import 'package:freezer/main.dart'; +import 'package:freezer/settings.dart'; import 'package:freezer/translations.i18n.dart'; +<<<<<<< Updated upstream +======= +import 'package:isar/isar.dart'; +import '../download.dart' as dl; +>>>>>>> Stashed changes class DownloadManager { //implements dl.DownloadManager { + + late Isar _isar; + SendPort? _sendPort; Isolate? _isolate; - Future configure() { + Future configure() async { + _isar = await Isar.open( + [ + // collections + TrackSchema, + AlbumSchema, + ArtistSchema, + PlaylistSchema, + ], + directory: await Paths.dataDirectory(), + name: 'offline', + ); if (Platform.isAndroid || Platform.isIOS) { return FlutterBackgroundService().configure( iosConfiguration: IosConfiguration(), // fuck ios @@ -61,10 +85,46 @@ class DownloadManager { FlutterBackgroundService().invoke(method, args); } - Future addOfflineTrack(Track track, - {bool private = true, BuildContext? context, isSingleton = false}) { - // TODO: implement addOfflineTrack - throw UnimplementedError(); + @override + Future addOfflineTrack(d.Track track, + {bool private = true, BuildContext? context, isSingleton = false}) async { + //Permission + //if (!private && !(await checkPermission())) return false; + + //Ask for quality + //AudioQuality? quality; + if (!private && settings.downloadQuality == AudioQuality.ASK) { + // quality = await qualitySelect(context!); + // if (quality == null) return false; + } + if (private) { + if (track.artists == null || + track.artists!.isEmpty || + track.album == null) { + track = await deezerAPI.track(track.id); + } + + // cache album art + cacheManager.getSingleFile(track.albumArt!.thumb); + cacheManager.getSingleFile(track.albumArt!.full); + + await _isar.writeTxn(() async { + await _isar.tracks.put(Track.from(track)); + if (track.album != null) { + await _isar.albums.put(Album.from(track.album!)); + } + + if (track.artists != null) { + await _isar.artists + .putAll(track.artists!.map(Artist.from).toList(growable: false)); + } + }); + } + + // logic for downloading the track + invoke('addDownloads', {'track': track.toJson()}); + + return true; } static void _startNative(ServiceInstance service) => diff --git a/lib/api/download_manager/download_service.dart b/lib/api/download_manager/download_service.dart index d89564c..891312b 100644 --- a/lib/api/download_manager/download_service.dart +++ b/lib/api/download_manager/download_service.dart @@ -4,8 +4,10 @@ class DownloadService { static const NOTIFICATION_ID = 6969; static const NOTIFICATION_CHANNEL_ID = "freezerdownloads"; - final ServiceInterface? service; + final ServiceInterface service; DownloadService(this.service); - void run() {} + void run() { + service.on('addDownloads').listen((event) {}); + } } diff --git a/lib/main.dart b/lib/main.dart index 652608c..c46d3ca 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -109,7 +109,12 @@ void main() async { } downloadManager.init(); // photos - cacheManager = DefaultCacheManager(); + cacheManager = CacheManager(Config( + DefaultCacheManager.key, + // cache aggressively + stalePeriod: const Duration(days: 30), + maxNrOfCacheObjects: 1000, + )); // cacheManager = HiveCacheManager( // boxName: 'freezer-images', boxPath: await Paths.cacheDir()); // TODO: WA