freezer/lib/api/deezer.dart
2024-02-13 17:53:25 +01:00

781 lines
25 KiB
Dart

import 'dart:io';
import 'package:cookie_jar/cookie_jar.dart';
import 'package:dio/dio.dart';
import 'package:dio_cookie_manager/dio_cookie_manager.dart';
import 'package:flutter/foundation.dart';
import 'package:freezer/api/cache.dart';
import 'package:freezer/api/definitions.dart';
import 'package:freezer/api/spotify.dart';
import 'package:freezer/settings.dart';
import 'package:http/http.dart' as http;
import 'package:logging/logging.dart';
import 'cookie_jar_hive_storage.dart';
import 'dart:convert';
import 'dart:async';
final deezerAPI = DeezerAPI();
final cookieJar = PersistCookieJar(storage: HiveStorage('cookies'));
class DeezerAPI {
// from deemix: https://gitlab.com/RemixDev/deemix-js/-/blob/main/deemix/utils/deezer.js?ref_type=heads#L6
static const CLIENT_ID = "172365";
static const CLIENT_SECRET = "fb0bec7ccc063dab0417eb7b0d847f34";
static const USER_AGENT_SUFFIX =
'AppleWebKit/537.36 (KHTML, like Gecko) Chrome/118.0.0.0 Safari/537.36';
static const WINDOWS_USER_AGENT =
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) $USER_AGENT_SUFFIX";
static const MACOS_USER_AGENT =
'Mozilla/5.0 (Macintosh; Intel Mac OS X 14_0) $USER_AGENT_SUFFIX';
static const USER_AGENTS = <TargetPlatform, String>{
TargetPlatform.android: WINDOWS_USER_AGENT,
TargetPlatform.windows: WINDOWS_USER_AGENT,
TargetPlatform.iOS: MACOS_USER_AGENT,
TargetPlatform.macOS: MACOS_USER_AGENT,
TargetPlatform.linux: 'Mozilla/5.0 (X11; Linux x86_64) $USER_AGENT_SUFFIX',
};
static String get userAgent => USER_AGENTS[defaultTargetPlatform]!;
static final _logger = Logger('DeezerAPI');
DeezerAPI();
set arl(String? arl) {
if (arl == null) {
cookieJar.delete(Uri.https('www.deezer.com'));
return;
}
cookieJar.saveFromResponse(Uri.https('www.deezer.com'), [
Cookie('arl', arl)
..domain = '.deezer.com'
..httpOnly = true
..sameSite = SameSite.none
..secure = true
]);
}
String? token;
String? userId;
String? userName;
String? favoritesPlaylistId;
String? sid;
late String licenseToken;
late bool canStreamLossless;
late bool canStreamHQ;
late final dio = Dio(BaseOptions(
headers: headers,
responseType: ResponseType.json,
validateStatus: (status) => true))
..interceptors.add(CookieManager(cookieJar));
Future<bool>? _authorizing;
//Get headers
Map<String, String> get headers => {
"User-Agent": userAgent,
"Content-Language":
'${settings.deezerLanguage}-${settings.deezerCountry}',
"Cache-Control": "max-age=0",
"Accept": "*/*",
"Accept-Charset": "utf-8,ISO-8859-1;q=0.7,*;q=0.3",
"Accept-Language":
"${settings.deezerLanguage}-${settings.deezerCountry},${settings.deezerLanguage};q=0.9,en-US;q=0.8,en;q=0.7",
"Connection": "keep-alive",
};
Future<void> logout() async {
// actual logout from deezer API
await dio.get('https://www.deezer.com/logout.php');
await dio.get('https://auth.deezer.com/logout');
// delete all cookies
await cookieJar.deleteAll();
updateHeaders();
}
void updateHeaders() {
dio.options.headers = headers;
}
//Call private API
Future<Map<dynamic, dynamic>> callApi(String method,
{Map<dynamic, dynamic>? params,
String? gatewayInput,
CancelToken? cancelToken}) async {
//Post
final res = await dio.post('https://www.deezer.com/ajax/gw-light.php',
queryParameters: {
'api_version': '1.0',
'api_token': token,
'input': '3',
'method': method,
//Used for homepage
if (gatewayInput != null) 'gateway_input': gatewayInput
},
data: jsonEncode(params),
cancelToken: cancelToken);
final body = res.data;
// In case of error "Invalid CSRF token" retrieve new one and retry the same call
if (body!['error'].isNotEmpty &&
body['error'].containsKey('VALID_TOKEN_REQUIRED') &&
await rawAuthorize()) {
return callApi(method, params: params, gatewayInput: gatewayInput);
}
return body;
}
Future<Map> callPublicApi(String path) async {
final res = await dio.get('https://api.deezer.com/$path');
return res.data;
}
//Wrapper so it can be globally awaited
Future<bool> authorize() async => _authorizing ??= rawAuthorize();
// NOT WORKING ANYMORE.
// this didn't last very long now, did it?
// //Login with email FROM DEEMIX-JS
// Future<String> getArlByEmail(String email, String password) async {
// //Get MD5 of password
// final md5Password = md5.convert(utf8.encode(password)).toString();
// final hash = md5
// .convert(utf8
// .encode([CLIENT_ID, email, md5Password, CLIENT_SECRET].join('')))
// .toString();
// //Get access token
// // String url =
// // "https://tv.deezer.com/smarttv/8caf9315c1740316053348a24d25afc7/user_auth.php?login=$email&password=$md5password&device=panasonic&output=json";
// // http.Response response = await http.get(Uri.parse(url));
// // String? accessToken = jsonDecode(response.body)["access_token"];
// final res = await dio.get('https://api.deezer.com/auth/token',
// queryParameters: {
// 'app_id': CLIENT_ID,
// 'login': email,
// 'password': md5Password,
// 'hash': hash
// },
// options: Options(responseType: ResponseType.json));
// print(res.data);
// final accessToken = res.data['access_token'] as String?;
// if (accessToken == null) {
// throw Exception('login failed, access token is null');
// }
//
// print(accessToken);
//
// return getArlByAccessToken(accessToken);
// }
// FROM DEEMIX-JS
Future<String> getArlByAccessToken(String accessToken) async {
//Get SID in cookieJar
final res =
await dio.get("https://api.deezer.com/platform/generic/track/3135556",
options: Options(headers: {
'Authorization': 'Bearer $accessToken',
'User-Agent': userAgent,
}));
print(res.data);
//Get ARL
final arlRes = await dio.get("https://www.deezer.com/ajax/gw-light.php",
queryParameters: {
'method': 'user.getArl',
'input': '3',
'api_version': '1.0',
'api_token': 'null',
},
options: Options(responseType: ResponseType.json));
final arl = arlRes.data["results"];
if (arl == null) throw Exception('couldn\'t obtain ARL');
return arl;
}
//Authorize, bool = success
Future<bool> rawAuthorize({Function? onError}) async {
_logger.fine('rawAuthorize()');
try {
final data = await callApi('deezer.getUserData');
if (data['results']?['USER']?['USER_ID'] == null ||
data['results']['USER']['USER_ID'] == 0) {
return false;
} else {
token = data['results']['checkForm'];
userId = data['results']['USER']['USER_ID'].toString();
userName = data['results']['USER']['BLOG_NAME'];
favoritesPlaylistId = data['results']['USER']['LOVEDTRACKS_ID'];
canStreamHQ = data['results']['USER']['OPTIONS']['web_hq'] as bool ||
data['results']['USER']['OPTIONS']['mobile_hq'] as bool;
canStreamLossless =
data['results']['USER']['OPTIONS']['web_lossless'] as bool ||
data['results']['USER']['OPTIONS']['mobile_lossless'] as bool;
licenseToken =
data['results']['USER']['OPTIONS']['license_token'] as String;
settings.checkQuality(canStreamHQ, canStreamLossless);
cache.canStreamHQ = canStreamHQ;
cache.canStreamLossless = canStreamLossless;
if (cache.favoritesPlaylistId != favoritesPlaylistId) {
cache.favoritesPlaylistId = favoritesPlaylistId;
}
await cache.save();
return true;
}
} catch (e) {
if (onError != null) onError(e);
_logger.warning('Login Error (D): $e');
return false;
}
}
//URL/Link parser
Future<DeezerLinkResponse?> parseLink(String url) async {
Uri uri = Uri.parse(url);
//https://www.deezer.com/NOTHING_OR_COUNTRY/TYPE/ID
if (uri.host == 'www.deezer.com' || uri.host == 'deezer.com') {
if (uri.pathSegments.length < 2) return null;
DeezerLinkType? type = DeezerLinkResponse.typeFromString(
uri.pathSegments[uri.pathSegments.length - 2]);
return DeezerLinkResponse(
type: type, id: uri.pathSegments[uri.pathSegments.length - 1]);
}
//Share URL
if (uri.host == 'deezer.page.link' || uri.host == 'www.deezer.page.link') {
http.BaseRequest request = http.Request('HEAD', Uri.parse(url));
request.followRedirects = false;
http.StreamedResponse response = await request.send();
String newUrl = response.headers['location']!;
return parseLink(newUrl);
}
//Spotify
if (uri.host == 'open.spotify.com') {
if (uri.pathSegments.length < 2) return null;
String spotifyUri = 'spotify:${uri.pathSegments.sublist(0, 2).join(':')}';
try {
//Tracks
if (uri.pathSegments[0] == 'track') {
String id = await SpotifyScrapper.convertTrack(spotifyUri);
return DeezerLinkResponse(type: DeezerLinkType.TRACK, id: id);
}
//Albums
if (uri.pathSegments[0] == 'album') {
String id = await SpotifyScrapper.convertAlbum(spotifyUri);
return DeezerLinkResponse(type: DeezerLinkType.ALBUM, id: id);
}
} catch (e) {
// we don't care about errors apparently
}
}
return null;
}
//Check if Deezer available in country
static Future<bool?> chceckAvailability() async {
try {
http.Response res =
await http.get(Uri.parse('https://api.deezer.com/infos'));
return jsonDecode(res.body)["open"];
} catch (e) {
return null;
}
}
Future<GetTrackUrlResponse> getTrackUrl(
String trackToken, String format) async =>
(await getTracksUrl([trackToken], format))[0];
Future<List<GetTrackUrlResponse>> getTracksUrl(
List<String> trackTokens, String format) async {
final response = await http.post(
Uri.https('media.deezer.com', '/v1/get_url'),
body: jsonEncode({
"license_token": licenseToken,
"media": [
{
"type": "FULL",
"formats": [
{"cipher": "BF_CBC_STRIPE", "format": format}
],
}
],
"track_tokens": trackTokens,
}),
headers: headers,
);
final data = (jsonDecode(response.body) as Map)['data'] as List;
return data.map((data) {
if (data['errors'] != null) {
if (data['errors'][0]['code'] == 2002) {
return GetTrackUrlResponse(error: 'Wrong geolocation');
}
return GetTrackUrlResponse(
error: (data['errors'][0] as Map).toString());
}
if (data['media'] == null) return GetTrackUrlResponse();
return GetTrackUrlResponse(
sources: (data['media'][0]['sources'] as List)
.map<TrackUrlSource>((e) => TrackUrlSource.fromPrivateJson(
(e as Map).cast<String, dynamic>()))
.toList(growable: false));
}).toList(growable: false);
}
//Search
Future<SearchResults> search(String? query) async {
Map<dynamic, dynamic> data = await callApi('deezer.pageSearch',
params: {'nb': 128, 'query': query, 'start': 0});
return SearchResults.fromPrivateJson(data['results']);
}
Future<List<Track>> getTracks(List<String> ids) async {
final data = await callApi('song.getListData', params: {'sng_ids': ids});
return (data['results']['data'] as List)
.map<Track>((t) => Track.fromPrivateJson(t as Map))
.toList(growable: false);
}
Future<Track> track(String id) async {
return (await getTracks([id]))[0];
}
//Get album details, tracks
Future<Album> album(String? id) async {
Map<dynamic, dynamic> data = await callApi('deezer.pageAlbum', params: {
'alb_id': id,
'header': true,
'lang': settings.deezerLanguage
});
return Album.fromPrivateJson(data['results']['DATA'],
songsJson: data['results']['SONGS']);
}
//Get artist details
Future<Artist> artist(String? id) async {
Map<dynamic, dynamic> data = await callApi('deezer.pageArtist', params: {
'art_id': id,
'lang': settings.deezerLanguage,
});
return Artist.fromPrivateJson(data['results']['DATA'],
topJson: data['results']['TOP'],
albumsJson: data['results']['ALBUMS'],
highlight: data['results']['HIGHLIGHT']);
}
//Get playlist tracks at offset
Future<List<Track>?> playlistTracksPage(String? id, int start,
{int nb = 2000 /*was 50*/}) async {
Map data = await callApi('deezer.pagePlaylist', params: {
'playlist_id': id,
'lang': settings.deezerLanguage,
'nb': nb,
'tags': true,
'start': start
});
return data['results']['SONGS']['data']
.map<Track>((json) => Track.fromPrivateJson(json))
.toList();
}
//Get playlist details
Future<Playlist> playlist(String? id, {int nb = 5000}) async {
Map<dynamic, dynamic> data = await callApi('deezer.pagePlaylist', params: {
'playlist_id': id,
'lang': settings.deezerLanguage,
'nb': nb,
'tags': true,
'start': 0
});
return Playlist.fromPrivateJson(data['results']['DATA'],
songsJson: data['results']['SONGS']);
}
//Get playlist with all tracks
Future<Playlist> fullPlaylist(String? id) async {
return await playlist(id, nb: 100000);
}
//Add track to favorites
Future addFavoriteTrack(String id) async {
await callApi('favorite_song.add', params: {'SNG_ID': id});
}
//Add album to favorites/library
Future addFavoriteAlbum(String? id) async {
await callApi('album.addFavorite', params: {'ALB_ID': id});
}
//Add artist to favorites/library
Future addFavoriteArtist(String? id) async {
await callApi('artist.addFavorite', params: {'ART_ID': id});
}
Future<void> addFavoriteShow(String id) =>
callApi('show.addFavorite', params: {'SHOW_ID': id});
//Remove artist from favorites/library
Future removeArtist(String? id) async {
await callApi('artist.deleteFavorite', params: {'ART_ID': id});
}
// Mark track as disliked
Future dislikeTrack(String id) async {
await callApi('favorite_dislike.add', params: {'ID': id, 'TYPE': 'song'});
}
//Add tracks to playlist
Future addToPlaylist(String trackId, String? playlistId,
{int offset = -1}) async {
await callApi('playlist.addSongs', params: {
'offset': offset,
'playlist_id': playlistId,
'songs': [
[trackId, 0]
]
});
}
//Remove track from playlist
Future removeFromPlaylist(String trackId, String? playlistId) async {
await callApi('playlist.deleteSongs', params: {
'playlist_id': playlistId,
'songs': [
[trackId, 0]
]
});
}
//Get users playlists
Future<List<Playlist>> getPlaylists() async {
Map data = await callApi('deezer.pageProfile',
params: {'nb': 2000, 'tab': 'playlists', 'user_id': userId});
return data['results']['TAB']['playlists']['data']
.map<Playlist>((json) => Playlist.fromPrivateJson(json, library: true))
.toList();
}
//Get favorite albums
Future<List<Album>> getAlbums() async {
Map data = await callApi('deezer.pageProfile',
params: {'nb': 2000, 'tab': 'albums', 'user_id': userId});
List albumList = data['results']['TAB']['albums']['data'];
List<Album> albums = albumList
.map<Album>((json) => Album.fromPrivateJson(json, library: true))
.toList();
return albums;
}
//Remove album from library
Future<void> removeAlbum(String? id) async {
await callApi('album.deleteFavorite', params: {'ALB_ID': id});
}
//Remove track from favorites
Future<void> removeFavorite(String id) async {
await callApi('favorite_song.remove', params: {'SNG_ID': id});
}
Future<void> removeFavoriteShow(String id) =>
callApi('show.deleteFavorite', params: {'SHOW_ID': id});
//Get favorite artists
Future<List<Artist>?> getArtists() async {
Map data = await callApi('deezer.pageProfile',
params: {'nb': 40, 'tab': 'artists', 'user_id': userId});
return data['results']['TAB']['artists']['data']
.map<Artist>((json) => Artist.fromPrivateJson(json, library: true))
.toList();
}
//Get lyrics by track id
Future<Lyrics> lyrics(String? trackId, {CancelToken? cancelToken}) async {
Map data = await callApi('song.getLyrics',
params: {'sng_id': trackId}, cancelToken: cancelToken);
if (data['error'] != null && data['error'].length > 0) {
throw Exception('Deezer reported error: ${data['error']}');
}
print(data);
return Lyrics.fromPrivateJson(data['results']);
}
Future<SmartTrackList> smartTrackList(String? id) async {
Map data = await callApi('deezer.pageSmartTracklist',
params: {'smarttracklist_id': id});
return SmartTrackList.fromPrivateJson(data['results'],
songsJson: data['results']['SONGS']);
}
Future<List<Track>?> flow([String? config]) async {
Map data = await callApi('radio.getUserRadio', params: {
if (config != null) 'config_id': config,
'user_id': userId,
});
return data['results']['data']
?.map<Track>((json) => Track.fromPrivateJson(json))
.toList();
}
//Get homepage/music library from deezer
Future<HomePage> homePage() async {
List grid = [
'album',
'artist',
'channel',
'flow',
'playlist',
'radio',
'show',
'smarttracklist',
'track',
'user',
'external-link'
];
Map data = await callApi('page.get',
gatewayInput: jsonEncode({
"PAGE": "home",
"VERSION": "2.5",
"SUPPORT": {
"deeplink-list": ["deeplink"],
"list": ["episode"],
"grid-preview-one": grid,
"grid-preview-two": grid,
"slideshow": grid,
"message": ["call_onboarding"],
"grid": grid,
"horizontal-grid": grid,
"horizontal-list": ["track", "song"],
"item-highlight": ["radio"],
"large-card": ["album", "playlist", "show", "video-link"],
"long-card-horizontal-grid": grid,
"ads": [], //Nope
// ADDED BY VERSION 2.5
"small-horizontal-grid": ["flow"],
"filterable-grid": ["flow"],
},
"LANG": settings.deezerLanguage,
"OPTIONS": []
}));
return HomePage.fromPrivateJson(data['results']);
}
/// Log song listen to deezer
Future logListen(
String trackId, {
/// Amount of times the track's been seeked
int seek = 0,
/// Amount of times the track's been paused
int pause = 0,
/// 1 if the track was listened in sync, 0 otherwise
int sync = 1,
/// If the track is skipped to the next song
bool next = false,
/// If the track is skipped to the previous song
bool prev = false,
/// When the timestamp has begun as UTC int (in SECONDS)
int? timestamp,
}) async {
await callApi('log.listen', params: {
'params': {
'timestamp':
timestamp ?? (DateTime.now().millisecondsSinceEpoch) ~/ 1000,
'ts_listen': DateTime.now().millisecondsSinceEpoch ~/ 1000,
'type': 1,
'stat': {
'seek': seek, // amount of times seeked
'pause': pause, // amount of times paused
'sync': sync,
if (next) 'next': true,
if (prev) 'prev': true,
},
'media': {'id': trackId, 'type': 'song', 'format': 'MP3_128'}
}
});
}
Future<HomePage> getChannel(String? target) async {
List grid = [
'album',
'artist',
'channel',
'flow',
'playlist',
'radio',
'show',
'smarttracklist',
'track',
'user'
];
Map data = await callApi('page.get',
gatewayInput: jsonEncode({
'PAGE': target,
"VERSION": "2.5",
"SUPPORT": {
"deeplink-list": ["deeplink"],
"list": ["episode"],
"grid-preview-one": grid,
"grid-preview-two": grid,
"slideshow": grid,
"message": ["call_onboarding"],
"grid": grid,
"horizontal-grid": grid,
"item-highlight": ["radio"],
"large-card": ["album", "playlist", "show", "video-link"],
"ads": [] //Nope
},
"LANG": settings.deezerLanguage,
"OPTIONS": []
}));
return HomePage.fromPrivateJson(data['results']);
}
//Add playlist to library
Future addPlaylist(String id) async {
await callApi('playlist.addFavorite',
params: {'parent_playlist_id': int.parse(id)});
}
//Remove playlist from library
Future removePlaylist(String id) async {
await callApi('playlist.deleteFavorite',
params: {'playlist_id': int.parse(id)});
}
//Delete playlist
Future deletePlaylist(String? id) async {
await callApi('playlist.delete', params: {'playlist_id': id});
}
//Create playlist
//Status 1 - private, 2 - collaborative
Future<String> createPlaylist(String? title,
{String? description = "",
int? status = 1,
List<String> trackIds = const []}) async {
Map data = await callApi('playlist.create', params: {
'title': title,
'description': description,
'songs': trackIds
.map<List>((id) => [int.parse(id), trackIds.indexOf(id)])
.toList(),
'status': status
});
//Return playlistId
return data['results'].toString();
}
//Get part of discography
Future<List<Album>?> discographyPage(String artistId,
{int start = 0, int nb = 50}) async {
Map data = await callApi('album.getDiscography', params: {
'art_id': int.parse(artistId),
'discography_mode': 'all',
'nb': nb,
'start': start,
'nb_songs': 30
});
return data['results']['data']
.map<Album>((a) => Album.fromPrivateJson(a))
.toList();
}
Future<List<String>?> searchSuggestions(String? query,
{CancelToken? cancelToken}) async {
Map data = await callApi('search_getSuggestedQueries',
params: {'QUERY': query}, cancelToken: cancelToken);
return (data['results']['SUGGESTION'] as List?)
?.map<String>((s) => s['QUERY'] as String)
.toList();
}
//Get smart radio for artist id
Future<List<Track>?> smartRadio(String artistId) async {
Map data = await callApi('smart.getSmartRadio',
params: {'art_id': int.parse(artistId)});
return data['results']['data']
.map<Track>((t) => Track.fromPrivateJson(t))
.toList();
}
//Update playlist metadata, status = see createPlaylist
Future updatePlaylist(String id, String title, String description,
{int? status = 1}) async {
await callApi('playlist.update', params: {
'description': description,
'title': title,
'playlist_id': int.parse(id),
'status': status,
'songs': []
});
}
//Get shuffled library
Future<List<Track>?> libraryShuffle({int start = 0}) async {
Map data = await callApi('tracklist.getShuffledCollection',
params: {'nb': 50, 'start': start});
return data['results']['data']
.map<Track>((t) => Track.fromPrivateJson(t))
.toList();
}
//Get similar tracks for track with id [trackId]
Future<List<Track>> playMix(String? trackId) async {
Map data = await callApi('song.getContextualTrackMix', params: {
'sng_ids': [trackId]
});
return data['results']['data']!
.map<Track>((t) => Track.fromPrivateJson(t))
.toList();
}
Future<List<Track>> getSearchTrackMix(String trackId,
[bool? startWithInputTrack = true]) async {
Map data = await callApi('song.getSearchTrackMix', params: {
'sng_id': trackId,
if (startWithInputTrack != null)
'start_with_input_track': startWithInputTrack,
});
return data['results']['data']!
.map<Track>((t) => Track.fromPrivateJson(t))
.toList();
}
Future<List<ShowEpisode>?> allShowEpisodes(String? showId) async {
Map data = await callApi('deezer.pageShow', params: {
'country': settings.deezerCountry,
'lang': settings.deezerLanguage,
'nb': 1000,
'show_id': showId,
'start': 0,
'user_id': int.parse(deezerAPI.userId!)
});
return data['results']['EPISODES']['data']
.map<ShowEpisode>((e) => ShowEpisode.fromPrivateJson(e))
.toList();
}
}
class PipeAPI {
PipeAPI._();
Future<void> getTrackToken(String trackId) async {}
}