freezer/lib/api/deezer_audio.dart
Pato05 87c9733f51
add build script for linux
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
2024-02-19 00:49:32 +01:00

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);
}
}