freezer/lib/api/deezer_audio_source.dart

316 lines
9.2 KiB
Dart

import 'dart:async';
import 'dart:convert';
import 'dart:io';
import 'dart:typed_data';
import 'package:encrypt/encrypt.dart';
import 'package:freezer/api/deezer.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';
// Maybe better implementation of Blowfish CBC instead of random-ass, unpublished library from github?
// This class can be considered a rewrite in Dart of the Java backend (from the StreamServer.deezer() function and also from the Deezer class)
class DeezerAudioSource extends StreamAudioSource {
final _logger = Logger("DeezerAudioSource");
late AudioQuality? _quality;
late AudioQuality? _initialQuality;
late String _trackId;
late String _md5origin;
late String _mediaVersion;
final StreamInfoCallback? onStreamObtained;
DeezerAudioSource({
required AudioQuality quality,
required String trackId,
required String md5origin,
required String mediaVersion,
this.onStreamObtained,
}) {
_quality = quality;
_initialQuality = quality;
_trackId = trackId;
_md5origin = md5origin;
_mediaVersion = mediaVersion;
}
AudioQuality? get quality => _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");
_quality = _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 {
final genUri = _generateTrackUri();
final req = await http.head(genUri, headers: {
'User-Agent': deezerAPI.headers['User-Agent']!,
'Accept-Language': '*',
'Accept': '*/*'
});
int rc = req.statusCode;
if (rc > 400) {
_logger.warning(
"quality fallback, response code: $rc, current quality: $quality");
switch (_quality) {
case AudioQuality.FLAC:
_quality = AudioQuality.MP3_320;
break;
case AudioQuality.MP3_320:
_quality = AudioQuality.MP3_128;
break;
case AudioQuality.MP3_128:
default:
_quality = 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;
}
@override
Future<StreamAudioResponse> request([int? start, int? end]) async {
start ??= 0;
_logger.fine("authorizing...");
if (!await deezerAPI.authorize()) {
_logger.severe("authorization failed! cannot continue!");
throw Exception("Authorization failed!");
}
late final StreamController<List<int>> controller;
final Uri uri;
try {
uri = await _fallbackUrl();
} on QualityException {
rethrow;
}
_logger.fine("Downloading track from ${uri.toString()}");
final int deezerStart = start - (start % 2048);
final req = http.Request('GET', uri)
..headers.addAll({
'User-Agent': deezerAPI.headers['User-Agent']!,
'Accept-Language': '*',
'Accept': '*/*',
if (deezerStart > 0)
"Range": "bytes=$deezerStart-${end == null ? '' : end.toString()}"
});
final res = await req.send();
final rc = res.statusCode;
_logger
.finest("request sent! rc: $rc, content-length: ${res.contentLength}");
onStreamObtained?.call(StreamQualityInfo(
format: quality == AudioQuality.FLAC ? Format.FLAC : Format.MP3,
source: Source.stream,
size: start + res.contentLength!,
quality: quality,
));
if (rc != HttpStatus.ok && rc != HttpStatus.partialContent) {
throw Exception(await res.stream.bytesToString());
}
int counter = deezerStart ~/ chunkSize;
int dropBytes = start % chunkSize;
_logger.finest(
"deezerStart: $deezerStart (actual start: $start), end: $end, counter: $counter, dropBytes: $dropBytes");
var buffer = <int>[];
final key = getKey(trackId);
int total = 0;
final subscription = res.stream.listen(
(bytes) {
// _logger.finest(
// "got stream bytes (${bytes.length}) with buffer.length = ${buffer.length}");
total += bytes.length;
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) {
controller.add(bytes.sublist(dropBytes));
dropBytes = 0;
counter++;
continue;
}
counter++;
controller.add(bytes);
}
buffer.removeRange(0, i);
},
onDone: () {
total += buffer.length;
_logger.finest(
"onDone() called, remaining buffer ${buffer.length}, total: $total");
// add remaining items in buffer
if (buffer.isNotEmpty) controller.add(buffer);
controller.close();
},
);
subscription.pause();
controller = StreamController<List<int>>(
onListen: subscription.resume,
onPause: subscription.pause,
onResume: subscription.resume,
onCancel: subscription.cancel,
);
final cl = res.contentLength! - dropBytes;
return StreamAudioResponse(
sourceLength: cl + start,
contentLength: cl,
offset: start,
stream: controller.stream,
contentType:
quality == AudioQuality.FLAC ? "audio/flac" : "audio/mpeg");
}
}