319 lines
8.9 KiB
Dart
319 lines
8.9 KiB
Dart
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);
|
|
}
|
|
}
|