331 lines
9.5 KiB
Dart
331 lines
9.5 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';
|
|
|
|
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? _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<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;
|
|
}
|
|
|
|
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 = 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<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!");
|
|
}
|
|
|
|
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");
|
|
}
|
|
}
|