put common deezer audio logic in DeezerAudio class
This commit is contained in:
parent
2862c9ec05
commit
77d6a5a51d
318
lib/api/deezer_audio.dart
Normal file
318
lib/api/deezer_audio.dart
Normal file
|
|
@ -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<Uri> 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 = <int>[
|
||||
...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 = <int>[
|
||||
...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<int> getKey(String id) {
|
||||
final secret = utf8.encode('g4el58wc0zvf9na1');
|
||||
final idmd5 =
|
||||
utf8.encode(md5.convert(utf8.encode(id)).toString().toLowerCase());
|
||||
final buffer = <int>[];
|
||||
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<int> decryptChunk(List<int> key, List<int> 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<List<int>> decryptionStream(Stream<List<int>> source,
|
||||
{required int start, required String trackId}) async* {
|
||||
var dropBytes = start % 2048;
|
||||
final deezerStart = start - dropBytes;
|
||||
int counter = deezerStart ~/ chunkSize;
|
||||
final buffer = List<int>.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<Uri?> getUrl(String trackToken, int expiration) =>
|
||||
getTrackUrl(deezerAPI, trackId, trackToken, expiration, quality: quality);
|
||||
|
||||
static Future<Uri?> 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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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<Uri> _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<Uri> _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 = <int>[
|
||||
...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 = <int>[
|
||||
...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<int> getKey(String id) {
|
||||
final secret = utf8.encode('g4el58wc0zvf9na1');
|
||||
final idmd5 =
|
||||
utf8.encode(md5.convert(utf8.encode(id)).toString().toLowerCase());
|
||||
final buffer = <int>[];
|
||||
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<int> decryptChunk(List<int> key, List<int> 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<List<int>> decryptionStream(Stream<List<int>> source,
|
||||
{required int start, required String trackId}) async* {
|
||||
var dropBytes = start % 2048;
|
||||
final deezerStart = start - dropBytes;
|
||||
int counter = deezerStart ~/ chunkSize;
|
||||
final buffer = List<int>.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<Uri?> _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<StreamAudioResponse> 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;
|
||||
|
||||
|
|
|
|||
|
|
@ -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<String> 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<String> 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<d.Artist>? 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<String> artistIds;
|
||||
late final List<String> 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<d.Artist>? artists, List<d.Track>? 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<String> albumIds;
|
||||
late final List<String> 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<String>((d.Album a) => a.id!)
|
||||
.toList(growable: false)
|
||||
..topTracksIds = artist.topTracks!
|
||||
.map<String>((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<String> 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<String>((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<d.Track>? 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<Lyric> 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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<bool> configure() {
|
||||
Future<bool> 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<bool> addOfflineTrack(Track track,
|
||||
{bool private = true, BuildContext? context, isSingleton = false}) {
|
||||
// TODO: implement addOfflineTrack
|
||||
throw UnimplementedError();
|
||||
@override
|
||||
Future<bool> 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) =>
|
||||
|
|
|
|||
|
|
@ -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) {});
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in a new issue