fix audio service stop on android getTrack backend improvements get new track token when expired move shuffle button into LibraryPlaylists as FAB move favoriteButton next to track title move lyrics button on top of album art search: fix chips, and remove checkbox when selected
330 lines
9.5 KiB
Dart
330 lines
9.5 KiB
Dart
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 = <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;
|
|
}
|
|
}
|
|
|
|
static bool isTokenExpired(int trackTokenExpiration) =>
|
|
DateTime.timestamp().millisecondsSinceEpoch ~/ 1000 >
|
|
trackTokenExpiration;
|
|
|
|
Future<(Uri, String trackToken, int tokenExpiration)?> getUrl(
|
|
String trackToken, int expiration) =>
|
|
getTrackUrl(deezerAPI, trackId, trackToken, expiration, quality: quality);
|
|
|
|
static Future<(Uri, String trackToken, int tokenExpiration)?> getTrackUrl(
|
|
DeezerAPI deezerAPI,
|
|
String trackId,
|
|
String trackToken,
|
|
int expiration, {
|
|
required AudioQuality quality,
|
|
}) async {
|
|
_logger.fine(
|
|
'token expiration: $expiration/${DateTime.timestamp().millisecondsSinceEpoch ~/ 1000}');
|
|
if (isTokenExpired(expiration)) {
|
|
// get new token via pipe API
|
|
_logger.fine('token is expired, getting new token.');
|
|
final newTrack = await deezerAPI.track(trackId);
|
|
trackToken = newTrack.trackToken!;
|
|
expiration = newTrack.trackTokenExpiration!;
|
|
}
|
|
|
|
final res = await deezerAPI.getTrackUrl(
|
|
trackToken, quality.toDeezerQualityString());
|
|
if (res.error != null) {
|
|
try {
|
|
final json = jsonDecode(res.error!);
|
|
if (json['code'] == 2001) {
|
|
// token expired.
|
|
return getTrackUrl(deezerAPI, trackId, trackToken, 0,
|
|
quality: quality);
|
|
}
|
|
} catch (e) {}
|
|
_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), trackToken, expiration);
|
|
}
|
|
}
|