freezer/lib/api/deezer_audio.dart

337 lines
9.7 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;
AudioQuality quality;
String trackId;
DeezerAudio({
required this.deezerAPI,
required this.quality,
required this.trackId,
});
static final _logger = Logger('DeezerAudio');
Future<({Uri uri, String md5origin, String mediaVersion})> fallback(
{required String md5origin, required String mediaVersion}) async {
final res = await fallbackUrl(
md5origin: md5origin,
preferredQuality: quality,
trackId: trackId,
mediaVersion: mediaVersion);
quality = res.quality;
print('PREVIOUS TRACKID: $trackId, NEW TRACKID: ${res.trackId}');
trackId = res.trackId;
return (
uri: res.uri,
md5origin: res.md5origin,
mediaVersion: res.mediaVersion
);
}
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;
}
Stream<List<int>> stream(Stream<List<int>> source, {required int start}) =>
decryptionStream(source, start: start, trackId: trackId);
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);
print('GETTING KEY FOR $trackId');
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;
}
}
static bool isTokenExpired(int trackTokenExpiration) =>
DateTime.timestamp().millisecondsSinceEpoch ~/ 1000 >
trackTokenExpiration;
Future<(Uri, String trackToken, int tokenExpiration)?> getUrl(
String trackToken, int expiration) =>
getTrackUrl(deezerAPI, trackId, trackToken, expiration, quality: quality);
static Future<(Uri, String trackToken, int tokenExpiration)?> getTrackUrl(
DeezerAPI deezerAPI,
String trackId,
String trackToken,
int expiration, {
required AudioQuality quality,
}) async {
_logger.fine(
'token expiration: $expiration/${DateTime.timestamp().millisecondsSinceEpoch ~/ 1000}');
if (isTokenExpired(expiration)) {
// get new token via pipe API
_logger.fine('token is expired, getting new token.');
final newTrack = await deezerAPI.track(trackId);
trackToken = newTrack.trackToken!;
expiration = newTrack.trackTokenExpiration!;
}
final res = await deezerAPI.getTrackUrl(
trackToken, quality.toDeezerQualityString());
if (res.error != null) {
try {
final json = res.error!;
if (json['code'] == 2001) {
// token expired.
return getTrackUrl(deezerAPI, trackId, trackToken, 0,
quality: quality);
}
throw Exception(res.error!);
} catch (e) {}
_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), trackToken, expiration);
}
}