freezer/lib/api/deezer_audio_source.dart

395 lines
12 KiB
Dart

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';
import 'package:scrobblenaut/lastfm.dart';
typedef _IsolateMessage = (
Stream<List<int>> 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 Function() _getQuality;
late AudioQuality? _initialQuality;
late String _trackId;
late String _md5origin;
late String _mediaVersion;
final String trackToken;
final int trackTokenExpiration;
final StreamInfoCallback? onStreamObtained;
// some cache
AudioQuality? _currentQuality;
int? _cachedSourceLength;
String? _cachedContentType;
Uri? _downloadUrl;
DeezerAudioSource({
required AudioQuality Function() getQuality,
required String trackId,
required String md5origin,
required String mediaVersion,
required this.trackToken,
required this.trackTokenExpiration,
this.onStreamObtained,
}) {
_getQuality = getQuality;
_initialQuality = quality;
_trackId = trackId;
_md5origin = md5origin;
_mediaVersion = mediaVersion;
}
AudioQuality? get quality => _currentQuality;
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");
_currentQuality = _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 {
// only use url generation with MP3_128
_currentQuality = AudioQuality.MP3_128;
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 (_currentQuality) {
case AudioQuality.FLAC:
_currentQuality = AudioQuality.MP3_320;
break;
case AudioQuality.MP3_320:
_currentQuality = AudioQuality.MP3_128;
break;
case AudioQuality.MP3_128:
default:
_currentQuality = 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;
}
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() async {
final String actualTrackToken;
if (DateTime.now().millisecondsSinceEpoch ~/ 1000 > trackTokenExpiration) {
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, _currentQuality!.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);
}
@override
Future<StreamAudioResponse> 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<List<int>>.empty(),
contentType: _cachedContentType!);
}
}
_logger.fine("authorizing...");
if (!await deezerAPI.authorize()) {
_logger.severe("authorization failed! cannot continue!");
throw Exception("Authorization failed!");
}
// determine quality to use
final newQuality = _getQuality.call();
if (_downloadUrl != null && _currentQuality != newQuality) {
// update currentUrl to get tracks with new quality
_downloadUrl = null;
}
_currentQuality = newQuality;
if (_downloadUrl == null) {
final gottenUrl = await _getUrl();
if (gottenUrl != null) {
_downloadUrl = gottenUrl;
} else {
_logger.warning('falling back to old url generation!');
// OLD URL GENERATION
try {
_downloadUrl = await _fallbackUrl();
} on QualityException {
rethrow;
}
}
}
_logger.fine("Downloading track from ${_downloadUrl!.toString()}");
final int deezerStart = start - (start % 2048);
final req = http.Request('GET', _downloadUrl!)
..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");
}
}