freezer/lib/api/deezer.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

787 lines
26 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(Uri uri) async {
//https://www.deezer.com/NOTHING_OR_COUNTRY/TYPE/ID[/radio?autoplay=true]
if (uri.host == 'www.deezer.com' || uri.host == 'deezer.com') {
if (uri.pathSegments.length < 2) return null;
if (uri.pathSegments[uri.pathSegments.length - 1] == 'radio') {
return DeezerLinkResponse(
type: DeezerLinkResponse.typeFromString(
uri.pathSegments[uri.pathSegments.length - 3]),
id: uri.pathSegments[uri.pathSegments.length - 2]);
}
return DeezerLinkResponse(
type: DeezerLinkResponse.typeFromString(
uri.pathSegments[uri.pathSegments.length - 2]),
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);
request.followRedirects = false;
http.StreamedResponse response = await request.send();
String newUrl = response.headers['location']!;
return parseLink(Uri.parse(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: DeezerMediaType.track, id: id);
}
//Albums
if (uri.pathSegments[0] == 'album') {
String id = await SpotifyScrapper.convertAlbum(spotifyUri);
return DeezerLinkResponse(type: DeezerMediaType.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});
print(data['results']['TOP_RESULT']);
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.timestamp().millisecondsSinceEpoch) ~/ 1000,
'ts_listen': DateTime.timestamp().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 {}
}