import 'dart:async'; import 'dart:convert'; import 'dart:io'; import 'dart:isolate'; import 'dart:typed_data'; import 'package:encrypt/encrypt.dart'; import 'package:flutter/foundation.dart' as flutter; 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'; typedef _IsolateMessage = ( Stream> source, int start, String trackId, SendPort sendPort ); // 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; // some cache int? _cachedSourceLength; String? _cachedContentType; 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 _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 _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 = [ ...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 = await flutter.compute(getKey, trackId); await for (var bytes in source) { if (dropBytes > 0) { bytes = bytes.sublist(dropBytes); } 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); } counter++; yield bytes; } if (i < buffer.length) { buffer.removeRange(0, i); } else { buffer.clear(); } } // add remaining items in buffer if (buffer.isNotEmpty) yield buffer; } @override Future request([int? start, int? end]) async { start ??= 0; if (_cachedSourceLength != null) { if (start == _cachedSourceLength) { return StreamAudioResponse( sourceLength: _cachedSourceLength, contentLength: 0, offset: start, stream: const Stream>.empty(), contentType: _cachedContentType!); } } _logger.fine("authorizing..."); if (!await deezerAPI.authorize()) { _logger.severe("authorization failed! cannot continue!"); throw Exception("Authorization failed!"); } 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 dropBytes = start % chunkSize; _logger.finest( "deezerStart: $deezerStart (actual start: $start), end: $end, dropBytes: $dropBytes"); final stream = decryptionStream(res.stream, start: start, trackId: trackId); final cl = res.contentLength! - dropBytes; if (end == null) { _cachedSourceLength = cl + start; } return StreamAudioResponse( sourceLength: _cachedSourceLength, contentLength: cl, offset: start, stream: stream, contentType: _cachedContentType = quality == AudioQuality.FLAC ? "audio/flac" : "audio/mpeg"); } }