freezer/lib/api/deezer.dart
Pato05 2862c9ec05
remove browser login for desktop
restore translations functionality
make scrollViews handle mouse pointers like touch, so that pull to refresh functionality is available
exit app if opening cache or settings fails (another instance running)
remove draggable_scrollbar and use builtin widget instead
fix email login
better way to manage lyrics (less updates and lookups in the lyrics List)
fix player_screen on mobile (too big -> just average :))
right click: use TapUp events instead
desktop: show context menu on triple dots button also
avoid showing connection error if the homepage is cached and available offline
i'm probably forgetting something idk
2023-10-25 00:32:28 +02:00

727 lines
24 KiB
Dart

import 'package:cookie_jar/cookie_jar.dart';
import 'package:crypto/crypto.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 'dart:convert';
import 'dart:async';
final deezerAPI = DeezerAPI();
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)]);
}
String? token;
String? userId;
String? userName;
String? favoritesPlaylistId;
String? sid;
late String licenseToken;
late bool canStreamLossless;
late bool canStreamHQ;
final cookieJar = DefaultCookieJar();
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 {
// 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}) 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));
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();
//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));
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 {
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<Track> track(String? id) async {
Map<dynamic, dynamic> data = await callApi('song.getListData', params: {
'sng_ids': [id]
});
return Track.fromPrivateJson(data['results']['data'][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 = 100}) 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});
}
//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': 100, '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': 50, '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 removeAlbum(String? id) async {
await callApi('album.deleteFavorite', params: {'ALB_ID': id});
}
//Remove track from favorites
Future removeFavorite(String id) async {
await callApi('favorite_song.remove', params: {'SNG_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) async {
Map data = await callApi('song.getLyrics', params: {'sng_id': trackId});
if (data['error'] != null && data['error'].length > 0) {
return Lyrics.error();
}
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'
];
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) async {
Map data =
await callApi('search_getSuggestedQueries', params: {'QUERY': query});
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<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();
}
}