0.5.0 - Rewritten downloads, many bugfixes

This commit is contained in:
exttex 2020-10-09 20:52:45 +02:00
parent f7cbb09bc1
commit f2f6b202d1
38 changed files with 5176 additions and 1365 deletions

67
lib/api/cache.dart Normal file
View file

@ -0,0 +1,67 @@
import 'package:freezer/api/deezer.dart';
import 'package:freezer/api/definitions.dart';
import 'package:freezer/ui/details_screens.dart';
import 'package:json_annotation/json_annotation.dart';
import 'package:path_provider/path_provider.dart';
import 'package:path/path.dart' as p;
import 'dart:io';
import 'dart:convert';
part 'cache.g.dart';
Cache cache;
//Cache for miscellaneous things
@JsonSerializable()
class Cache {
//ID's of tracks that are in library
List<String> libraryTracks = [];
//Track ID of logged track, to prevent duplicates
@JsonKey(ignore: true)
String loggedTrackId;
@JsonKey(defaultValue: [])
List<Track> history = [];
//Cache playlist sort type {id: sort}
@JsonKey(defaultValue: {})
Map<String, SortType> playlistSort;
Cache({this.libraryTracks});
//Wrapper to test if track is favorite against cache
bool checkTrackFavorite(Track t) {
if (t.favorite != null && t.favorite) return true;
if (libraryTracks == null || libraryTracks.length == 0) return false;
return libraryTracks.contains(t.id);
}
//Save, load
static Future<String> getPath() async {
return p.join((await getApplicationDocumentsDirectory()).path, 'metacache.json');
}
static Future<Cache> load() async {
File file = File(await Cache.getPath());
//Doesn't exist, create new
if (!(await file.exists())) {
Cache c = Cache();
await c.save();
return c;
}
return Cache.fromJson(jsonDecode(await file.readAsString()));
}
Future save() async {
File file = File(await Cache.getPath());
file.writeAsString(jsonEncode(this.toJson()));
}
//JSON
factory Cache.fromJson(Map<String, dynamic> json) => _$CacheFromJson(json);
Map<String, dynamic> toJson() => _$CacheToJson(this);
}

69
lib/api/cache.g.dart Normal file
View file

@ -0,0 +1,69 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'cache.dart';
// **************************************************************************
// JsonSerializableGenerator
// **************************************************************************
Cache _$CacheFromJson(Map<String, dynamic> json) {
return Cache(
libraryTracks:
(json['libraryTracks'] as List)?.map((e) => e as String)?.toList(),
)
..history = (json['history'] as List)
?.map((e) =>
e == null ? null : Track.fromJson(e as Map<String, dynamic>))
?.toList() ??
[]
..playlistSort = (json['playlistSort'] as Map<String, dynamic>)?.map(
(k, e) => MapEntry(k, _$enumDecodeNullable(_$SortTypeEnumMap, e)),
) ??
{};
}
Map<String, dynamic> _$CacheToJson(Cache instance) => <String, dynamic>{
'libraryTracks': instance.libraryTracks,
'history': instance.history,
'playlistSort': instance.playlistSort
?.map((k, e) => MapEntry(k, _$SortTypeEnumMap[e])),
};
T _$enumDecode<T>(
Map<T, dynamic> enumValues,
dynamic source, {
T unknownValue,
}) {
if (source == null) {
throw ArgumentError('A value must be provided. Supported values: '
'${enumValues.values.join(', ')}');
}
final value = enumValues.entries
.singleWhere((e) => e.value == source, orElse: () => null)
?.key;
if (value == null && unknownValue == null) {
throw ArgumentError('`$source` is not one of the supported values: '
'${enumValues.values.join(', ')}');
}
return value ?? unknownValue;
}
T _$enumDecodeNullable<T>(
Map<T, dynamic> enumValues,
dynamic source, {
T unknownValue,
}) {
if (source == null) {
return null;
}
return _$enumDecode<T>(enumValues, source, unknownValue: unknownValue);
}
const _$SortTypeEnumMap = {
SortType.DEFAULT: 'DEFAULT',
SortType.REVERSE: 'REVERSE',
SortType.ALPHABETIC: 'ALPHABETIC',
SortType.ARTIST: 'ARTIST',
};

View file

@ -1,8 +1,10 @@
import 'dart:async';
import 'package:dio/adapter.dart';
import 'package:dio/dio.dart';
import 'package:cookie_jar/cookie_jar.dart';
import 'package:dio_cookie_manager/dio_cookie_manager.dart';
import 'package:freezer/api/cache.dart';
import 'dart:io';
import 'dart:convert';
@ -47,6 +49,15 @@ class DeezerAPI {
return options;
}
));
//Proxy
if (settings.proxyAddress != null && settings.proxyAddress != '' && settings.proxyAddress.length > 9) {
(dio.httpClientAdapter as DefaultHttpClientAdapter).onHttpClientCreate = (client) {
client.findProxy = (uri) => "PROXY ${settings.proxyAddress}";
client.badCertificateCallback = (X509Certificate cert, String host, int port) => true;
};
}
//Add cookies
List<Cookie> cookies = [Cookie('arl', this.arl)];
_cookieJar.saveFromResponse(Uri.parse(this.privateUrl), cookies);
@ -82,13 +93,13 @@ class DeezerAPI {
//Wrapper so it can be globally awaited
Future authorize() async {
if (_authorizing == null) {
this._authorizing = this._authorize();
this._authorizing = this.rawAuthorize();
}
return _authorizing;
}
//Authorize, bool = success
Future<bool> _authorize() async {
Future<bool> rawAuthorize({Function onError}) async {
try {
Map<dynamic, dynamic> data = await callApi('deezer.getUserData');
if (data['results']['USER']['USER_ID'] == 0) {
@ -100,7 +111,31 @@ class DeezerAPI {
this.favoritesPlaylistId = data['results']['USER']['LOVEDTRACKS_ID'];
return true;
}
} catch (e) { return false; }
} catch (e) {
if (onError != null)
onError(e);
print('Login Error (D): ' + e.toString());
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') {
Dio dio = Dio();
Response res = await dio.head(url, options: RequestOptions(
followRedirects: true
));
return parseLink('http://deezer.com' + res.realUri.toString());
}
}
//Search
@ -168,19 +203,6 @@ class DeezerAPI {
//Get playlist with all tracks
Future<Playlist> fullPlaylist(String id) async {
return await playlist(id, nb: 100000);
//OLD WORKAROUND
/*
Playlist p = await playlist(id, nb: 200);
for (int i=200; i<p.trackCount; i++) {
//Get another page of tracks
List<Track> tracks = await playlistTracksPage(id, i, nb: 200);
p.tracks.addAll(tracks);
i += 200;
continue;
}
return p;
*/
}
//Add track to favorites
@ -271,7 +293,7 @@ class DeezerAPI {
Map data = await callApi('song.getLyrics', params: {
'sng_id': trackId
});
if (data['error'] != null && data['error'].length > 0) return Lyrics().error;
if (data['error'] != null && data['error'].length > 0) return Lyrics.error();
return Lyrics.fromPrivateJson(data['results']);
}
@ -318,7 +340,15 @@ class DeezerAPI {
//Log song listen to deezer
Future logListen(String trackId) async {
await callApi('log.listen', params: {'next_media': {'media': {'id': trackId, 'type': 'song'}}});
await callApi('log.listen', params: {
'params': {
'timestamp': DateTime.now().millisecondsSinceEpoch,
'ts_listen': DateTime.now().millisecondsSinceEpoch,
'type': 1,
'stat': {'seek': 0, 'pause': 0, 'sync': 1},
'media': {'id': trackId, 'type': 'song', 'format': 'MP3_128'}
}
});
}
Future<HomePage> getChannel(String target) async {
@ -406,5 +436,16 @@ class DeezerAPI {
});
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': []
});
}
}

View file

@ -10,6 +10,7 @@ import 'package:pointycastle/block/aes_fast.dart';
import 'package:pointycastle/block/modes/ecb.dart';
import 'package:hex/hex.dart';
import 'package:path/path.dart' as p;
import 'package:freezer/translations.i18n.dart';
import 'package:crypto/crypto.dart' as crypto;
import 'dart:typed_data';
@ -30,8 +31,6 @@ class Track {
bool offline;
Lyrics lyrics;
bool favorite;
//TODO: Not in DB
int diskNumber;
bool explicit;
@ -102,6 +101,10 @@ class Track {
artists = jsonDecode(mi.extras['artists']).map<Artist>((j) => Artist.fromJson(j)).toList();
}
}
List<String> playbackDetails;
if (mi.extras['playbackDetails'] != null)
playbackDetails = jsonDecode(mi.extras['playbackDetails']).map<String>((e) => e.toString()).toList();
return Track(
title: mi.title??mi.displayTitle,
artists: artists,
@ -112,7 +115,7 @@ class Track {
thumbUrl: mi.extras['thumb']
),
duration: mi.duration,
playbackDetails: null, // So it gets updated from api
playbackDetails: playbackDetails,
lyrics: Lyrics.fromJson(jsonDecode(((mi.extras??{})['lyrics'])??"{}"))
);
}
@ -149,7 +152,9 @@ class Track {
'trackNumber': trackNumber,
'offline': off?1:0,
'lyrics': jsonEncode(lyrics.toJson()),
'favorite': (favorite??0)?1:0
'favorite': (favorite??0)?1:0,
'diskNumber': diskNumber,
'explicit': explicit?1:0
};
factory Track.fromSQL(Map<String, dynamic> data) => Track(
id: data['trackId']??data['id'], //If loading from downloads table
@ -163,7 +168,9 @@ class Track {
)),
offline: (data['offline'] == 1) ? true:false,
lyrics: Lyrics.fromJson(jsonDecode(data['lyrics'])),
favorite: (data['favorite'] == 1) ? true:false
favorite: (data['favorite'] == 1) ? true:false,
diskNumber: data['diskNumber'],
explicit: (data['explicit'] == 1) ? true:false
);
factory Track.fromJson(Map<String, dynamic> json) => _$TrackFromJson(json);
@ -186,8 +193,6 @@ class Album {
int fans;
bool offline; //If the album is offline, or just saved in db as metadata
bool library;
//TODO: Not in DB
AlbumType type;
String releaseDate;
@ -224,7 +229,9 @@ class Album {
'art': art.full,
'fans': fans,
'offline': off?1:0,
'library': (library??false)?1:0
'library': (library??false)?1:0,
'type': AlbumType.values.indexOf(type),
'releaseDate': releaseDate
};
factory Album.fromSQL(Map<String, dynamic> data) => Album(
id: data['id'],
@ -238,7 +245,9 @@ class Album {
art: ImageDetails(fullUrl: data['art']),
fans: data['fans'],
offline: (data['offline'] == 1) ? true:false,
library: (data['library'] == 1) ? true:false
library: (data['library'] == 1) ? true:false,
type: AlbumType.values[data['type']],
releaseDate: data['releaseDate']
);
factory Album.fromJson(Map<String, dynamic> json) => _$AlbumFromJson(json);
@ -256,8 +265,6 @@ class Artist {
int fans;
bool offline;
bool library;
//TODO: NOT IN DB
bool radio;
Artist({this.id, this.name, this.albums, this.albumCount, this.topTracks, this.picture, this.fans, this.offline, this.library, this.radio});
@ -296,7 +303,8 @@ class Artist {
'fans': fans,
'albumCount': this.albumCount??(this.albums??[]).length,
'offline': off?1:0,
'library': (library??false)?1:0
'library': (library??false)?1:0,
'radio': radio?1:0
};
factory Artist.fromSQL(Map<String, dynamic> data) => Artist(
id: data['id'],
@ -311,7 +319,8 @@ class Artist {
picture: ImageDetails(fullUrl: data['picture']),
fans: data['fans'],
offline: (data['offline'] == 1)?true:false,
library: (data['library'] == 1)?true:false
library: (data['library'] == 1)?true:false,
radio: (data['radio'] == 1)?true:false
);
factory Artist.fromJson(Map<String, dynamic> json) => _$ArtistFromJson(json);
@ -456,12 +465,12 @@ class Lyrics {
Lyrics({this.id, this.writers, this.lyrics});
Lyrics get error => Lyrics(
id: id,
writers: writers,
static error() => Lyrics(
id: null,
writers: null,
lyrics: [Lyric(
offset: Duration(milliseconds: 0),
text: 'Error loading lyrics!'
text: 'Lyrics unavailable, empty or failed to load!'.i18n
)]
);
@ -470,7 +479,7 @@ class Lyrics {
Lyrics l = Lyrics(
id: json['LYRICS_ID'],
writers: json['LYRICS_WRITERS'],
lyrics: json['LYRICS_SYNC_JSON'].map<Lyric>((l) => Lyric.fromPrivateJson(l)).toList()
lyrics: (json['LYRICS_SYNC_JSON']??[]).map<Lyric>((l) => Lyric.fromPrivateJson(l)).toList()
);
//Clean empty lyrics
l.lyrics.removeWhere((l) => l.offset == null);
@ -723,4 +732,28 @@ enum RepeatType {
NONE,
LIST,
TRACK
}
enum DeezerLinkType {
TRACK,
ALBUM,
ARTIST,
PLAYLIST
}
class DeezerLinkResponse {
DeezerLinkType type;
String id;
DeezerLinkResponse({this.type, this.id});
//String to DeezerLinkType
static typeFromString(String t) {
t = t.toLowerCase().trim();
if (t == 'album') return DeezerLinkType.ALBUM;
if (t == 'artist') return DeezerLinkType.ARTIST;
if (t == 'playlist') return DeezerLinkType.PLAYLIST;
if (t == 'track') return DeezerLinkType.TRACK;
return null;
}
}

804
lib/api/download-out.dart Normal file
View file

@ -0,0 +1,804 @@
import 'dart:typed_data';
import 'package:disk_space/disk_space.dart';
import 'package:ext_storage/ext_storage.dart';
import 'package:flutter/services.dart';
import 'package:path_provider/path_provider.dart';
import 'package:random_string/random_string.dart';
import 'package:sqflite/sqflite.dart';
import 'package:path/path.dart' as p;
import 'package:dio/dio.dart';
import 'package:filesize/filesize.dart';
import 'package:permission_handler/permission_handler.dart';
import 'package:flutter_local_notifications/flutter_local_notifications.dart';
import 'dart:io';
import 'dart:async';
import 'deezer.dart';
import '../settings.dart';
import 'definitions.dart';
import '../ui/cached_image.dart';
DownloadManager downloadManager = DownloadManager();
MethodChannel platformChannel = const MethodChannel('f.f.freezer/native');
class DownloadManager {
Database db;
List<Download> queue = [];
String _offlinePath;
Future _download;
FlutterLocalNotificationsPlugin flutterLocalNotificationsPlugin;
bool _cancelNotifications = true;
bool stopped = true;
Future init() async {
//Prepare DB
String dir = await getDatabasesPath();
String path = p.join(dir, 'offline.db');
db = await openDatabase(
path,
version: 1,
onCreate: (Database db, int version) async {
Batch b = db.batch();
//Create tables
b.execute(""" CREATE TABLE downloads (
id INTEGER PRIMARY KEY AUTOINCREMENT, path TEXT, url TEXT, private INTEGER, state INTEGER, trackId TEXT)""");
b.execute("""CREATE TABLE tracks (
id TEXT PRIMARY KEY, title TEXT, album TEXT, artists TEXT, duration INTEGER, albumArt TEXT, trackNumber INTEGER, offline INTEGER, lyrics TEXT, favorite INTEGER)""");
b.execute("""CREATE TABLE albums (
id TEXT PRIMARY KEY, title TEXT, artists TEXT, tracks TEXT, art TEXT, fans INTEGER, offline INTEGER, library INTEGER)""");
b.execute("""CREATE TABLE artists (
id TEXT PRIMARY KEY, name TEXT, albums TEXT, topTracks TEXT, picture TEXT, fans INTEGER, albumCount INTEGER, offline INTEGER, library INTEGER)""");
b.execute("""CREATE TABLE playlists (
id TEXT PRIMARY KEY, title TEXT, tracks TEXT, image TEXT, duration INTEGER, userId TEXT, userName TEXT, fans INTEGER, library INTEGER, description TEXT)""");
await b.commit();
}
);
//Prepare folders (/sdcard/Android/data/freezer/data/)
_offlinePath = p.join((await getExternalStorageDirectory()).path, 'offline/');
await Directory(_offlinePath).create(recursive: true);
//Notifications
await _prepareNotifications();
//Restore
List<Map> downloads = await db.rawQuery("SELECT * FROM downloads INNER JOIN tracks ON tracks.id = downloads.trackId WHERE downloads.state = 0");
downloads.forEach((download) => queue.add(Download.fromSQL(download, parseTrack: true)));
}
//Initialize flutter local notification plugin
Future _prepareNotifications() async {
flutterLocalNotificationsPlugin = FlutterLocalNotificationsPlugin();
AndroidInitializationSettings androidInitializationSettings = AndroidInitializationSettings('@drawable/ic_logo');
InitializationSettings initializationSettings = InitializationSettings(androidInitializationSettings, null);
await flutterLocalNotificationsPlugin.initialize(initializationSettings);
}
//Show download progress notification, if now/total = null, show intermediate
Future _startProgressNotification() async {
_cancelNotifications = false;
Timer.periodic(Duration(milliseconds: 500), (timer) async {
//Cancel notifications
if (_cancelNotifications) {
flutterLocalNotificationsPlugin.cancel(10);
timer.cancel();
return;
}
//Not downloading
if (this.queue.length <= 0) return;
Download d = queue[0];
//Prepare and show notification
AndroidNotificationDetails androidNotificationDetails = AndroidNotificationDetails(
'download', 'Download', 'Download',
importance: Importance.Default,
priority: Priority.Default,
showProgress: true,
maxProgress: d.total??1,
progress: d.received??1,
playSound: false,
enableVibration: false,
autoCancel: true,
//ongoing: true, //Allow dismissing
indeterminate: (d.total == null || d.total == d.received),
onlyAlertOnce: true
);
NotificationDetails notificationDetails = NotificationDetails(androidNotificationDetails, null);
await downloadManager.flutterLocalNotificationsPlugin.show(
10,
'Downloading: ${d.track.title}',
(d.state == DownloadState.POST) ? 'Post processing...' : '${filesize(d.received)} / ${filesize(d.total)} (${queue.length} in queue)',
notificationDetails
);
});
}
//Update queue, start new download
void updateQueue() async {
if (_download == null && queue.length > 0 && !stopped) {
_download = queue[0].download(
onDone: () async {
//On download finished
await db.rawUpdate('UPDATE downloads SET state = 1 WHERE trackId = ?', [queue[0].track.id]);
queue.removeAt(0);
_download = null;
//Remove notification if no more downloads
if (queue.length == 0) {
_cancelNotifications = true;
}
updateQueue();
}
).catchError((e, st) async {
if (stopped) return;
_cancelNotifications = true;
//Deezer error - track is unavailable
if (queue[0].state == DownloadState.DEEZER_ERROR) {
await db.rawUpdate('UPDATE downloads SET state = 4 WHERE trackId = ?', [queue[0].track.id]);
queue.removeAt(0);
_cancelNotifications = false;
_download = null;
updateQueue();
return;
}
//Clean
_download = null;
stopped = true;
print('Download error: $e\n$st');
queue[0].state = DownloadState.NONE;
//Shift to end
queue.add(queue[0]);
queue.removeAt(0);
//Show error
await _showError();
});
//Show download progress notifications
if (_cancelNotifications == null || _cancelNotifications) _startProgressNotification();
}
}
//Stop downloading and end my life
Future stop() async {
stopped = true;
if (_download != null) {
await queue[0].stop();
}
_download = null;
}
//Start again downloads
Future start() async {
if (_download != null) return;
stopped = false;
updateQueue();
}
//Show error notification
Future _showError() async {
AndroidNotificationDetails androidNotificationDetails = AndroidNotificationDetails(
'downloadError', 'Download Error', 'Download Error'
);
NotificationDetails notificationDetails = NotificationDetails(androidNotificationDetails, null);
flutterLocalNotificationsPlugin.show(
11, 'Error while downloading!', 'Please restart downloads in the library', notificationDetails
);
}
//Returns all offline tracks
Future<List<Track>> allOfflineTracks() async {
List data = await db.query('tracks', where: 'offline == 1');
List<Track> tracks = [];
//Load track data
for (var t in data) {
tracks.add(await getTrack(t['id']));
}
return tracks;
}
//Get all offline playlists
Future<List<Playlist>> getOfflinePlaylists() async {
List data = await db.query('playlists');
List<Playlist> playlists = [];
//Load playlists
for (var p in data) {
playlists.add(await getPlaylist(p['id']));
}
return playlists;
}
//Get playlist metadata with tracks
Future<Playlist> getPlaylist(String id) async {
if (id == null) return null;
List data = await db.query('playlists', where: 'id == ?', whereArgs: [id]);
if (data.length == 0) return null;
//Load playlist tracks
Playlist p = Playlist.fromSQL(data[0]);
for (int i=0; i<p.tracks.length; i++) {
p.tracks[i] = await getTrack(p.tracks[i].id);
}
return p;
}
//Gets favorites
Future<Playlist> getFavorites() async {
return await getPlaylist('FAVORITES');
}
Future<List<Album>> getOfflineAlbums({List albumsData}) async {
//Load albums
if (albumsData == null) {
albumsData = await db.query('albums', where: 'offline == 1');
}
List<Album> albums = albumsData.map((alb) => Album.fromSQL(alb)).toList();
for(int i=0; i<albums.length; i++) {
albums[i].library = true;
//Load tracks
for(int j=0; j<albums[i].tracks.length; j++) {
albums[i].tracks[j] = await getTrack(albums[i].tracks[j].id, album: albums[i]);
}
//Load artists
List artistsData = await db.rawQuery('SELECT * FROM artists WHERE id IN (${albumsData[i]['artists']})');
albums[i].artists = artistsData.map<Artist>((a) => Artist.fromSQL(a)).toList();
}
return albums;
}
//Get track with metadata from db
Future<Track> getTrack(String id, {Album album, List<Artist> artists}) async {
List tracks = await db.query('tracks', where: 'id == ?', whereArgs: [id]);
if (tracks.length == 0) return null;
Track t = Track.fromSQL(tracks[0]);
//Load album from DB
t.album = album ?? Album.fromSQL((await db.query('albums', where: 'id == ?', whereArgs: [t.album.id]))[0]);
if (artists != null) {
t.artists = artists;
return t;
}
//Load artists from DB
for (int i=0; i<t.artists.length; i++) {
t.artists[i] = Artist.fromSQL(
(await db.query('artists', where: 'id == ?', whereArgs: [t.artists[i].id]))[0]);
}
return t;
}
Future removeOfflineTrack(String id) async {
//Check if track present in albums
List counter = await db.rawQuery('SELECT COUNT(*) FROM albums WHERE tracks LIKE "%$id%"');
if (counter[0]['COUNT(*)'] > 0) return;
//and in playlists
counter = await db.rawQuery('SELECT COUNT(*) FROM playlists WHERE tracks LIKE "%$id%"');
if (counter[0]['COUNT(*)'] > 0) return;
//Remove file
List download = await db.query('downloads', where: 'trackId == ?', whereArgs: [id]);
await File(download[0]['path']).delete();
//Delete from db
await db.delete('tracks', where: 'id == ?', whereArgs: [id]);
await db.delete('downloads', where: 'trackId == ?', whereArgs: [id]);
}
//Delete offline album
Future removeOfflineAlbum(String id) async {
List data = await db.rawQuery('SELECT * FROM albums WHERE id == ? AND offline == 1', [id]);
if (data.length == 0) return;
Map<String, dynamic> album = Map.from(data[0]); //make writable
//Remove DB
album['offline'] = 0;
await db.update('albums', album, where: 'id == ?', whereArgs: [id]);
//Get track ids
List<String> tracks = album['tracks'].split(',');
for (String t in tracks) {
//Remove tracks
await removeOfflineTrack(t);
}
}
Future removeOfflinePlaylist(String id) async {
List data = await db.query('playlists', where: 'id == ?', whereArgs: [id]);
if (data.length == 0) return;
Playlist p = Playlist.fromSQL(data[0]);
//Remove db
await db.delete('playlists', where: 'id == ?', whereArgs: [id]);
//Remove tracks
for(Track t in p.tracks) {
await removeOfflineTrack(t.id);
}
}
//Get path to offline track
Future<String> getOfflineTrackPath(String id) async {
List<Map> tracks = await db.rawQuery('SELECT path FROM downloads WHERE state == 1 AND trackId == ?', [id]);
if (tracks.length < 1) {
return null;
}
Download d = Download.fromSQL(tracks[0]);
return d.path;
}
Future addOfflineTrack(Track track, {private = true, forceStart = true}) async {
//Paths
String path = p.join(_offlinePath, track.id);
if (track.playbackDetails == null) {
//Get track from API if download info missing
track = await deezerAPI.track(track.id);
}
if (!private) {
//Check permissions
if (!(await Permission.storage.request().isGranted)) {
return;
}
//If saving to external
//Save just extension to path, will be generated before download
path = 'mp3';
if (settings.downloadQuality == AudioQuality.FLAC) {
path = 'flac';
}
} else {
//Load lyrics for private
try {
Lyrics l = await deezerAPI.lyrics(track.id);
track.lyrics = l;
} catch (e) {}
}
Download download = Download(track: track, path: path, private: private);
//Database
Batch b = db.batch();
b.insert('downloads', download.toSQL());
b.insert('tracks', track.toSQL(off: false), conflictAlgorithm: ConflictAlgorithm.ignore);
if (private) {
//Duplicate check
List<Map> duplicate = await db.rawQuery('SELECT * FROM downloads WHERE trackId == ?', [track.id]);
if (duplicate.length != 0) return;
//Save art
//await imagesDatabase.getImage(track.albumArt.full);
imagesDatabase.saveImage(track.albumArt.full);
//Save to db
b.insert('tracks', track.toSQL(off: true), conflictAlgorithm: ConflictAlgorithm.replace);
b.insert('albums', track.album.toSQL(), conflictAlgorithm: ConflictAlgorithm.ignore);
track.artists.forEach((art) => b.insert('artists', art.toSQL(), conflictAlgorithm: ConflictAlgorithm.ignore));
}
await b.commit();
queue.add(download);
if (forceStart) start();
}
Future addOfflineAlbum(Album album, {private = true}) async {
//Get full album from API if tracks are missing
if (album.tracks == null || album.tracks.length == 0) {
album = await deezerAPI.album(album.id);
}
//Update album in database
if (private) {
await db.insert('albums', album.toSQL(off: true), conflictAlgorithm: ConflictAlgorithm.replace);
}
//Save all tracks
for (Track track in album.tracks) {
await addOfflineTrack(track, private: private, forceStart: false);
}
start();
}
//Add offline playlist, can be also used as update
Future addOfflinePlaylist(Playlist playlist, {private = true}) async {
//Load full playlist if missing tracks
if (playlist.tracks == null || playlist.tracks.length != playlist.trackCount) {
playlist = await deezerAPI.fullPlaylist(playlist.id);
}
playlist.library = true;
//To DB
if (private) {
await db.insert('playlists', playlist.toSQL(), conflictAlgorithm: ConflictAlgorithm.replace);
}
//Download all tracks
for (Track t in playlist.tracks) {
await addOfflineTrack(t, private: private, forceStart: false);
}
start();
}
Future checkOffline({Album album, Track track, Playlist playlist}) async {
//Check if album/track (TODO: Artist, playlist) is offline
if (track != null) {
List res = await db.query('tracks', where: 'id == ? AND offline == 1', whereArgs: [track.id]);
if (res.length == 0) return false;
return true;
}
if (album != null) {
List res = await db.query('albums', where: 'id == ? AND offline == 1', whereArgs: [album.id]);
if (res.length == 0) return false;
return true;
}
if (playlist != null && playlist.id != null) {
List res = await db.query('playlists', where: 'id == ?', whereArgs: [playlist.id]);
if (res.length == 0) return false;
return true;
}
return false;
}
//Offline search
Future<SearchResults> search(String query) async {
SearchResults results = SearchResults(
tracks: [],
albums: [],
artists: [],
playlists: []
);
//Tracks
List tracksData = await db.rawQuery('SELECT * FROM tracks WHERE offline == 1 AND title like "%$query%"');
for (Map trackData in tracksData) {
results.tracks.add(await getTrack(trackData['id']));
}
//Albums
List albumsData = await db.rawQuery('SELECT * FROM albums WHERE offline == 1 AND title like "%$query%"');
results.albums = await getOfflineAlbums(albumsData: albumsData);
//Artists
//TODO: offline artists
//Playlists
List playlists = await db.rawQuery('SELECT * FROM playlists WHERE title like "%$query%"');
for (Map playlist in playlists) {
results.playlists.add(await getPlaylist(playlist['id']));
}
return results;
}
Future<List<Download>> getFinishedDownloads() async {
//Fetch from db
List<Map> data = await db.rawQuery("SELECT * FROM downloads INNER JOIN tracks ON tracks.id = downloads.trackId WHERE downloads.state = 1 OR downloads.state > 3");
List<Download> downloads = data.map<Download>((d) => Download.fromSQL(d, parseTrack: true)).toList();
return downloads;
}
//Get stats for library screen
Future<List<String>> getStats() async {
//Get offline counts
int trackCount = (await db.rawQuery('SELECT COUNT(*) FROM tracks WHERE offline == 1'))[0]['COUNT(*)'];
int albumCount = (await db.rawQuery('SELECT COUNT(*) FROM albums WHERE offline == 1'))[0]['COUNT(*)'];
int playlistCount = (await db.rawQuery('SELECT COUNT(*) FROM albums WHERE offline == 1'))[0]['COUNT(*)'];
//Free space
double diskSpace = await DiskSpace.getFreeDiskSpace;
//Used space
List<FileSystemEntity> offlineStat = await Directory(_offlinePath).list().toList();
int offlineSize = 0;
for (var fs in offlineStat) {
offlineSize += (await fs.stat()).size;
}
//Return as a list, maybe refactor in future if feature stays
return ([
trackCount.toString(),
albumCount.toString(),
playlistCount.toString(),
filesize(offlineSize),
filesize((diskSpace * 1000000).floor())
]);
}
//Delete download from db
Future removeDownload(Download download) async {
await db.delete('downloads', where: 'trackId == ?', whereArgs: [download.track.id]);
queue.removeWhere((d) => d.track.id == download.track.id);
//TODO: remove files for downloaded
}
//Delete queue
Future clearQueue() async {
while (queue.length > 0) {
if (queue.length == 1) {
if (_download != null) break;
await removeDownload(queue[0]);
return;
}
await removeDownload(queue[1]);
}
}
//Remove non-private downloads
Future cleanDownloadHistory() async {
await db.delete('downloads', where: 'private == 0');
}
}
class Download {
Track track;
String path;
String url;
bool private;
DownloadState state;
String _cover;
//For canceling
IOSink _outSink;
CancelToken _cancel;
StreamSubscription _progressSub;
int received = 0;
int total = 1;
Download({this.track, this.path, this.url, this.private, this.state = DownloadState.NONE});
//Stop download
Future stop() async {
if (_cancel != null) _cancel.cancel();
//if (_outSink != null) _outSink.close();
if (_progressSub != null) _progressSub.cancel();
received = 0;
total = 1;
state = DownloadState.NONE;
}
Future download({onDone}) async {
Dio dio = Dio();
//TODO: Check for internet before downloading
Map rawTrackPublic = {};
Map rawAlbumPublic = {};
if (!this.private && !(this.path.endsWith('.mp3') || this.path.endsWith('.flac'))) {
String ext = this.path;
//Get track details
Map _rawTrackData = await deezerAPI.callApi('song.getListData', params: {'sng_ids': [track.id]});
Map rawTrack = _rawTrackData['results']['data'][0];
this.track = Track.fromPrivateJson(rawTrack);
//RAW Public API call (for genre and other tags)
try {rawTrackPublic = await deezerAPI.callPublicApi('track/${this.track.id}');} catch (e) {rawTrackPublic = {};}
try {rawAlbumPublic = await deezerAPI.callPublicApi('album/${this.track.album.id}');} catch (e) {rawAlbumPublic = {};}
//Global block check
if (rawTrackPublic['available_countries'] != null && rawTrackPublic['available_countries'].length == 0) {
this.state = DownloadState.DEEZER_ERROR;
throw Exception('Download error - not on Deezer');
}
//Get path if public
RegExp sanitize = RegExp(r'[\/\\\?\%\*\:\|\"\<\>]');
//Download path
this.path = settings.downloadPath ?? (await ExtStorage.getExternalStoragePublicDirectory(ExtStorage.DIRECTORY_MUSIC));
if (settings.artistFolder)
this.path = p.join(this.path, track.artists[0].name.replaceAll(sanitize, ''));
if (settings.albumFolder) {
String folderName = track.album.title.replaceAll(sanitize, '');
//Add disk number
if (settings.albumDiscFolder) folderName += ' - Disk ${track.diskNumber}';
this.path = p.join(this.path, folderName);
}
//Make dirs
await Directory(this.path).create(recursive: true);
//Grab cover
_cover = p.join(this.path, 'cover.jpg');
if (!settings.albumFolder) _cover = p.join(this.path, randomAlpha(12) + '_cover.jpg');
if (!await File(_cover).exists()) {
try {
await dio.download(
this.track.albumArt.full,
_cover,
);
} catch (e) {print('Error downloading cover');}
}
//Create filename
String _filename = settings.downloadFilename;
//Feats filter
String feats = '';
if (track.artists.length > 1) feats = "feat. ${track.artists.sublist(1).map((a) => a.name).join(', ')}";
//Filters
Map<String, String> vars = {
'%artists%': track.artistString.replaceAll(sanitize, ''),
'%artist%': track.artists[0].name.replaceAll(sanitize, ''),
'%title%': track.title.replaceAll(sanitize, ''),
'%album%': track.album.title.replaceAll(sanitize, ''),
'%trackNumber%': track.trackNumber.toString(),
'%0trackNumber%': track.trackNumber.toString().padLeft(2, '0'),
'%feats%': feats
};
//Replace
vars.forEach((key, value) {
_filename = _filename.replaceAll(key, value);
});
_filename += '.$ext';
this.path = p.join(this.path, _filename);
}
//Check if file exists
if (await File(this.path).exists() && !settings.overwriteDownload) {
this.state = DownloadState.DONE;
onDone();
return;
}
//Download
this.state = DownloadState.DOWNLOADING;
//Quality fallback
if (this.url == null)
await _fallback();
//Create download file
File downloadFile = File(this.path + '.ENC');
//Get start position
int start = 0;
if (await downloadFile.exists()) {
FileStat stat = await downloadFile.stat();
start = stat.size;
} else {
//Create file if doesn't exist
await downloadFile.create(recursive: true);
}
//Download
_cancel = CancelToken();
Response response;
try {
response = await dio.get(
this.url,
options: Options(
responseType: ResponseType.stream,
headers: {
'Range': 'bytes=$start-'
},
),
cancelToken: _cancel
);
} on DioError catch (e) {
//Deezer fetch error
if (e.response.statusCode == 403 || e.response.statusCode == 404) {
this.state = DownloadState.DEEZER_ERROR;
}
throw Exception('Download error - Deezer blocked.');
}
//Size
this.total = int.parse(response.headers['Content-Length'][0]) + start;
this.received = start;
//Save
_outSink = downloadFile.openWrite(mode: FileMode.append);
Stream<Uint8List> _data = response.data.stream.asBroadcastStream();
_progressSub = _data.listen((Uint8List c) {
this.received += c.length;
});
//Pipe to file
try {
await _outSink.addStream(_data);
} catch (e) {
await _outSink.close();
throw Exception('Download error');
}
await _outSink.close();
_cancel = null;
this.state = DownloadState.POST;
//Decrypt
await platformChannel.invokeMethod('decryptTrack', {'id': track.id, 'path': path});
//Tag
if (!private) {
//Tag track in native
String year;
if (rawTrackPublic['release_date'] != null && rawTrackPublic['release_date'].length >= 4)
year = rawTrackPublic['release_date'].substring(0, 4);
await platformChannel.invokeMethod('tagTrack', {
'path': path,
'title': track.title,
'album': track.album.title,
'artists': track.artistString,
'artist': track.artists[0].name,
'cover': _cover,
'trackNumber': track.trackNumber,
'diskNumber': track.diskNumber,
'genres': ((rawAlbumPublic['genres']??{})['data']??[]).map((g) => g['name']).toList(),
'year': year,
'bpm': rawTrackPublic['bpm'],
'explicit': (track.explicit??false) ? "1":"0",
'label': rawAlbumPublic['label'],
'albumTracks': rawAlbumPublic['nb_tracks'],
'date': rawTrackPublic['release_date'],
'albumArtist': (rawAlbumPublic['artist']??{})['name']
});
//Rescan android library
await platformChannel.invokeMethod('rescanLibrary', {
'path': path
});
}
//Remove encrypted
await File(path + '.ENC').delete();
if (!settings.albumFolder) await File(_cover).delete();
//Get lyrics
Lyrics lyrics;
try {
lyrics = await deezerAPI.lyrics(track.id);
} catch (e) {}
if (lyrics != null && lyrics.lyrics != null) {
//Create .LRC file
String lrcPath = p.join(p.dirname(path), p.basenameWithoutExtension(path)) + '.lrc';
File lrcFile = File(lrcPath);
String lrcData = '';
//Generate file
lrcData += '[ar:${track.artistString}]\r\n';
lrcData += '[al:${track.album.title}]\r\n';
lrcData += '[ti:${track.title}]\r\n';
for (Lyric l in lyrics.lyrics) {
if (l.lrcTimestamp != null && l.lrcTimestamp != '' && l.text != null)
lrcData += '${l.lrcTimestamp}${l.text}\r\n';
}
lrcFile.writeAsString(lrcData);
}
this.state = DownloadState.DONE;
onDone();
return;
}
Future _fallback({fallback}) async {
//Get quality
AudioQuality quality = private ? settings.offlineQuality : settings.downloadQuality;
if (fallback == AudioQuality.MP3_320) quality = AudioQuality.MP3_128;
if (fallback == AudioQuality.FLAC) {
quality = AudioQuality.MP3_320;
if (this.path.toLowerCase().endsWith('flac'))
this.path = this.path.substring(0, this.path.length - 4) + 'mp3';
}
//No more fallback
if (quality == AudioQuality.MP3_128) {
url = track.getUrl(settings.getQualityInt(quality));
return;
}
//Check
int q = settings.getQualityInt(quality);
try {
Response res = await Dio().head(track.getUrl(q));
if (res.statusCode == 200 || res.statusCode == 206) {
this.url = track.getUrl(q);
return;
}
} catch (e) {}
//Fallback
return _fallback(fallback: quality);
}
//JSON
Map<String, dynamic> toSQL() => {
'trackId': track.id,
'path': path,
'url': url,
'state': state.index,
'private': private?1:0
};
factory Download.fromSQL(Map<String, dynamic> data, {parseTrack = false}) => Download(
track: parseTrack?Track.fromSQL(data):Track(id: data['trackId']),
path: data['path'],
url: data['url'],
state: DownloadState.values[data['state']],
private: data['private'] == 1
);
}
enum DownloadState {
NONE,
DONE,
DOWNLOADING,
POST,
DEEZER_ERROR,
ERROR
}

File diff suppressed because it is too large Load diff

View file

@ -1,6 +1,9 @@
import 'dart:math';
import 'package:audio_service/audio_service.dart';
import 'package:audio_session/audio_session.dart';
import 'package:fluttertoast/fluttertoast.dart';
import 'package:freezer/api/cache.dart';
import 'package:freezer/api/deezer.dart';
import 'package:freezer/ui/android_auto.dart';
import 'package:just_audio/just_audio.dart';
@ -21,6 +24,7 @@ PlayerHelper playerHelper = PlayerHelper();
class PlayerHelper {
StreamSubscription _customEventSubscription;
StreamSubscription _mediaItemSubscription;
StreamSubscription _playbackStateStreamSubscription;
QueueSource queueSource;
LoopMode repeatType = LoopMode.off;
@ -65,9 +69,26 @@ class PlayerHelper {
//Log song (if allowed)
if (event == null) return;
if (event.processingState == AudioProcessingState.ready && event.playing) {
if (settings.logListen) deezerAPI.logListen(AudioService.currentMediaItem.id);
if (settings.logListen) {
//Check if duplicate
if (cache.loggedTrackId == AudioService.currentMediaItem.id) return;
cache.loggedTrackId = AudioService.currentMediaItem.id;
deezerAPI.logListen(AudioService.currentMediaItem.id);
}
}
});
_mediaItemSubscription = AudioService.currentMediaItemStream.listen((event) {
//Save queue
AudioService.customAction('saveQueue');
//Add to history
if (event == null) return;
if (cache.history == null) cache.history = [];
if (cache.history.length > 0 && cache.history.last.id == event.id) return;
cache.history.add(Track.fromMediaItem(event));
cache.save();
});
//Start audio_service
startService();
}
@ -79,7 +100,7 @@ class PlayerHelper {
androidEnableQueue: true,
androidStopForegroundOnPause: false,
androidNotificationOngoing: false,
androidNotificationClickStartsActivity: true,
androidNotificationClickStartsActivity: false,
androidNotificationChannelDescription: 'Freezer',
androidNotificationChannelName: 'Freezer',
androidNotificationIcon: 'drawable/ic_logo',
@ -110,6 +131,7 @@ class PlayerHelper {
Future onExit() async {
_customEventSubscription.cancel();
_playbackStateStreamSubscription.cancel();
_mediaItemSubscription.cancel();
}
//Replace queue, play specified track id
@ -256,6 +278,13 @@ class AudioPlayerTask extends BackgroundAudioTask {
});
//Update state on all clients on change
_eventSub = _player.playbackEventStream.listen((event) {
//Quality string
if (_queueIndex != -1 && _queueIndex < _queue.length) {
Map extras = mediaItem.extras;
extras['qualityString'] = event.qualityString??'';
_queue[_queueIndex] = mediaItem.copyWith(extras: extras);
}
//Update
_broadcastState();
});
_player.processingStateStream.listen((state) {
@ -296,6 +325,7 @@ class AudioPlayerTask extends BackgroundAudioTask {
//Skip in player
await _player.seek(Duration.zero, index: newIndex);
_queueIndex = newIndex;
_skipState = null;
onPlay();
}
@ -327,6 +357,40 @@ class AudioPlayerTask extends BackgroundAudioTask {
@override
Future<void> onSeekBackward(bool begin) async => _seekContinuously(begin, -1);
@override
Future<void> onSkipToNext() async {
//Shuffle
if (_player.shuffleModeEnabled??false) {
int newIndex = Random().nextInt(_queue.length)-1;
//Update state
_skipState = newIndex > _queueIndex
? AudioProcessingState.skippingToNext
: AudioProcessingState.skippingToPrevious;
_queueIndex = newIndex;
await _player.seek(Duration.zero, index: _queueIndex);
_skipState = null;
return;
}
//Update buffering state
_skipState = AudioProcessingState.skippingToNext;
_queueIndex++;
await _player.seekToNext();
_skipState = null;
await _broadcastState();
}
@override
Future<void> onSkipToPrevious() async {
if (_queueIndex == 0) return;
//Update buffering state
_skipState = AudioProcessingState.skippingToPrevious;
_queueIndex--;
await _player.seekToPrevious();
_skipState = null;
}
@override
Future<List<MediaItem>> onLoadChildren(String parentMediaId) async {
AudioServiceBackground.sendCustomEvent({
@ -417,12 +481,16 @@ class AudioPlayerTask extends BackgroundAudioTask {
this._queue = q;
AudioServiceBackground.setQueue(_queue);
//Load
_queueIndex = 0;
await _loadQueue();
await _player.seek(Duration.zero, index: 0);
//await _player.seek(Duration.zero, index: 0);
}
//Load queue to just_audio
Future _loadQueue() async {
//Don't reset queue index by starting player
int qi = _queueIndex;
List<AudioSource> sources = [];
for(int i=0; i<_queue.length; i++) {
sources.add(await _mediaItemToAudioSource(_queue[i]));
@ -432,9 +500,11 @@ class AudioPlayerTask extends BackgroundAudioTask {
//Load in just_audio
try {
await _player.load(_audioSource);
await _player.seek(Duration.zero, index: qi);
} catch (e) {
//Error loading tracks
}
_queueIndex = qi;
AudioServiceBackground.setMediaItem(mediaItem);
}
@ -523,13 +593,14 @@ class AudioPlayerTask extends BackgroundAudioTask {
//Export queue to JSON
Future _saveQueue() async {
if (_queueIndex == 0 && _queue.length == 0) return;
String path = await _getQueuePath();
File f = File(path);
//Create if doesnt exist
//Create if doesn't exist
if (! await File(path).exists()) {
f = await f.create();
}
Map data = {
'index': _queueIndex,
'queue': _queue.map<Map<String, dynamic>>((mi) => mi.toJson()).toList(),
@ -552,7 +623,7 @@ class AudioPlayerTask extends BackgroundAudioTask {
if (_queue != null) {
await AudioServiceBackground.setQueue(_queue);
await _loadQueue();
AudioServiceBackground.setMediaItem(mediaItem);
await AudioServiceBackground.setMediaItem(mediaItem);
}
}
//Send restored queue source to ui
@ -568,7 +639,6 @@ class AudioPlayerTask extends BackgroundAudioTask {
//-1 == play next
if (index == -1) index = _queueIndex + 1;
_queue.insert(index, mi);
await AudioServiceBackground.setQueue(_queue);
await _audioSource.insert(index, await _mediaItemToAudioSource(mi));

View file

@ -48,6 +48,7 @@ class SpotifyAPI {
SpotifyPlaylist playlist = SpotifyPlaylist.fromJson(data);
return playlist;
}
Future convertPlaylist(SpotifyPlaylist playlist, {bool downloadOnly = false}) async {
doneImporting = false;