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; 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 = [ ...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; } } static bool isTokenExpired(int trackTokenExpiration) => DateTime.now().millisecondsSinceEpoch ~/ 1000 > trackTokenExpiration; 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 (isTokenExpired(expiration)) { // get new token via pipe API 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); } }