584 lines
19 KiB
Dart
584 lines
19 KiB
Dart
import 'dart:async';
|
|
|
|
import 'package:cached_network_image/cached_network_image.dart';
|
|
import 'package:disk_space/disk_space.dart';
|
|
import 'package:filesize/filesize.dart';
|
|
import 'package:flutter/services.dart';
|
|
import 'package:flutter_cache_manager/flutter_cache_manager.dart';
|
|
import 'package:fluttertoast/fluttertoast.dart';
|
|
import 'package:freezer/api/deezer.dart';
|
|
import 'package:freezer/api/definitions.dart';
|
|
import 'package:freezer/settings.dart';
|
|
import 'package:path_provider/path_provider.dart';
|
|
import 'package:path/path.dart' as p;
|
|
import 'package:permission_handler/permission_handler.dart';
|
|
import 'package:freezer/translations.i18n.dart';
|
|
|
|
import 'dart:io';
|
|
|
|
import 'package:sqflite/sqflite.dart';
|
|
import 'package:sqflite/sql.dart';
|
|
|
|
DownloadManager downloadManager = DownloadManager();
|
|
|
|
class DownloadManager {
|
|
|
|
//Platform channels
|
|
static MethodChannel platform = MethodChannel('f.f.freezer/native');
|
|
static EventChannel eventChannel = EventChannel('f.f.freezer/downloads');
|
|
|
|
bool running = false;
|
|
int queueSize = 0;
|
|
|
|
StreamController serviceEvents = StreamController.broadcast();
|
|
String offlinePath;
|
|
Database db;
|
|
|
|
//Start/Resume downloads
|
|
Future start() async {
|
|
await updateServiceSettings();
|
|
await platform.invokeMethod('start');
|
|
}
|
|
|
|
//Stop/Pause downloads
|
|
Future stop() async {
|
|
await platform.invokeMethod('stop');
|
|
}
|
|
|
|
Future init() async {
|
|
//Remove old DB
|
|
File oldDbFile = File(p.join((await getDatabasesPath()), 'offline.db'));
|
|
if (await oldDbFile.exists()) {
|
|
await oldDbFile.delete();
|
|
}
|
|
|
|
String dbPath = p.join((await getDatabasesPath()), 'offline2.db');
|
|
//Open db
|
|
db = await openDatabase(
|
|
dbPath,
|
|
version: 1,
|
|
onCreate: (Database db, int version) async {
|
|
Batch b = db.batch();
|
|
//Create tables, if doesn't exit
|
|
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, diskNumber INTEGER, explicit INTEGER)""");
|
|
b.execute("""CREATE TABLE Albums (
|
|
id TEXT PRIMARY KEY, title TEXT, artists TEXT, tracks TEXT, art TEXT, fans INTEGER, offline INTEGER, library INTEGER, type INTEGER, releaseDate TEXT)""");
|
|
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, radio 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();
|
|
}
|
|
);
|
|
|
|
//Create offline directory
|
|
offlinePath = p.join((await getExternalStorageDirectory()).path, 'offline/');
|
|
await Directory(offlinePath).create(recursive: true);
|
|
|
|
//Update settings
|
|
await updateServiceSettings();
|
|
|
|
//Listen to state change event
|
|
eventChannel.receiveBroadcastStream().listen((e) {
|
|
if (e['action'] == 'onStateChange') {
|
|
running = e['running'];
|
|
queueSize = e['queueSize'];
|
|
}
|
|
|
|
//Forward
|
|
serviceEvents.add(e);
|
|
});
|
|
|
|
await platform.invokeMethod('loadDownloads');
|
|
}
|
|
|
|
//Get all downloads from db
|
|
Future<List<Download>> getDownloads() async {
|
|
List raw = await platform.invokeMethod('getDownloads');
|
|
return raw.map((d) => Download.fromJson(d)).toList();
|
|
}
|
|
|
|
//Insert track and metadata to DB
|
|
Future _addTrackToDB(Batch batch, Track track, bool overwriteTrack) async {
|
|
batch.insert('Tracks', track.toSQL(off: true), conflictAlgorithm: overwriteTrack?ConflictAlgorithm.replace:ConflictAlgorithm.ignore);
|
|
batch.insert('Albums', track.album.toSQL(off: false), conflictAlgorithm: ConflictAlgorithm.ignore);
|
|
//Artists
|
|
for (Artist a in track.artists) {
|
|
batch.insert('Artists', a.toSQL(off: false), conflictAlgorithm: ConflictAlgorithm.ignore);
|
|
}
|
|
return batch;
|
|
}
|
|
|
|
Future addOfflineTrack(Track track, {private = true}) async {
|
|
//Permission
|
|
if (!private && !(await checkPermission())) return;
|
|
|
|
//Add to DB
|
|
if (private) {
|
|
Batch b = db.batch();
|
|
b = await _addTrackToDB(b, track, true);
|
|
await b.commit();
|
|
|
|
//Cache art
|
|
DefaultCacheManager().getSingleFile(track.albumArt.thumb);
|
|
DefaultCacheManager().getSingleFile(track.albumArt.full);
|
|
}
|
|
|
|
//Get path
|
|
String path = _generatePath(track, private);
|
|
await platform.invokeMethod('addDownloads', [await Download.jsonFromTrack(track, path, private: private)]);
|
|
await start();
|
|
}
|
|
|
|
Future addOfflineAlbum(Album album, {private = true}) async {
|
|
//Permission
|
|
if (!private && !(await checkPermission())) return;
|
|
|
|
//Get from API if no tracks
|
|
if (album.tracks == null || album.tracks.length == 0) {
|
|
album = await deezerAPI.album(album.id);
|
|
}
|
|
|
|
//Add to DB
|
|
if (private) {
|
|
//Cache art
|
|
DefaultCacheManager().getSingleFile(album.art.thumb);
|
|
DefaultCacheManager().getSingleFile(album.art.full);
|
|
|
|
Batch b = db.batch();
|
|
b.insert('Albums', album.toSQL(off: true), conflictAlgorithm: ConflictAlgorithm.replace);
|
|
for (Track t in album.tracks) {
|
|
b = await _addTrackToDB(b, t, false);
|
|
}
|
|
await b.commit();
|
|
}
|
|
|
|
//Create downloads
|
|
List<Map> out = [];
|
|
for (Track t in album.tracks) {
|
|
out.add(await Download.jsonFromTrack(t, _generatePath(t, private), private: private));
|
|
}
|
|
await platform.invokeMethod('addDownloads', out);
|
|
await start();
|
|
}
|
|
|
|
Future addOfflinePlaylist(Playlist playlist, {private = true}) async {
|
|
//Permission
|
|
if (!private && !(await checkPermission())) return;
|
|
|
|
//Get tracks if missing
|
|
if (playlist.tracks == null || playlist.tracks.length < playlist.trackCount) {
|
|
playlist = await deezerAPI.fullPlaylist(playlist.id);
|
|
}
|
|
|
|
//Add to DB
|
|
if (private) {
|
|
Batch b = db.batch();
|
|
b.insert('Playlists', playlist.toSQL(), conflictAlgorithm: ConflictAlgorithm.replace);
|
|
for (Track t in playlist.tracks) {
|
|
b = await _addTrackToDB(b, t, false);
|
|
//Cache art
|
|
DefaultCacheManager().getSingleFile(t.albumArt.thumb);
|
|
DefaultCacheManager().getSingleFile(t.albumArt.full);
|
|
}
|
|
await b.commit();
|
|
}
|
|
|
|
//Generate downloads
|
|
List<Map> out = [];
|
|
for (int i=0; i<playlist.tracks.length; i++) {
|
|
Track t = playlist.tracks[i];
|
|
out.add(await Download.jsonFromTrack(t, _generatePath(
|
|
t,
|
|
private,
|
|
playlistName: playlist.title,
|
|
playlistTrackNumber: i
|
|
), private: private));
|
|
}
|
|
await platform.invokeMethod('addDownloads', out);
|
|
await start();
|
|
}
|
|
|
|
//Get track and meta from offline DB
|
|
Future<Track> getOfflineTrack(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 track = Track.fromSQL(tracks[0]);
|
|
|
|
//Get album
|
|
if (album == null) {
|
|
List rawAlbums = await db.query('Albums', where: 'id == ?', whereArgs: [track.album.id]);
|
|
if (rawAlbums.length > 0)
|
|
track.album = Album.fromSQL(rawAlbums[0]);
|
|
} else {
|
|
track.album = album;
|
|
}
|
|
|
|
//Get artists
|
|
if (artists == null) {
|
|
List<Artist> newArtists = [];
|
|
for (Artist artist in track.artists) {
|
|
List rawArtist = await db.query('Artists', where: 'id == ?', whereArgs: [artist.id]);
|
|
if (rawArtist.length > 0)
|
|
newArtists.add(Artist.fromSQL(rawArtist[0]));
|
|
}
|
|
if (newArtists.length > 0)
|
|
track.artists = newArtists;
|
|
} else {
|
|
track.artists = artists;
|
|
}
|
|
return track;
|
|
}
|
|
|
|
//Get offline library tracks
|
|
Future<List<Track>> getOfflineTracks() async {
|
|
List rawTracks = await db.query('Tracks', where: 'library == 1 AND offline == 1', columns: ['id']);
|
|
List<Track> out = [];
|
|
//Load track meta individually
|
|
for (Map rawTrack in rawTracks) {
|
|
out.add(await getOfflineTrack(rawTrack['id']));
|
|
}
|
|
return out;
|
|
}
|
|
|
|
//Get all offline available tracks
|
|
Future<List<Track>> allOfflineTracks() async {
|
|
List rawTracks = await db.query('Tracks', where: 'offline == 1', columns: ['id']);
|
|
List<Track> out = [];
|
|
//Load track meta individually
|
|
for (Map rawTrack in rawTracks) {
|
|
out.add(await getOfflineTrack(rawTrack['id']));
|
|
}
|
|
return out;
|
|
}
|
|
|
|
//Get all offline albums
|
|
Future<List<Album>> getOfflineAlbums() async {
|
|
List rawAlbums = await db.query('Albums', where: 'offline == 1', columns: ['id']);
|
|
List<Album> out = [];
|
|
//Load each album
|
|
for (Map rawAlbum in rawAlbums) {
|
|
out.add(await getOfflineAlbum(rawAlbum['id']));
|
|
}
|
|
return out;
|
|
}
|
|
|
|
//Get offline album with meta
|
|
Future<Album> getOfflineAlbum(String id) async {
|
|
List rawAlbums = await db.query('Albums', where: 'id == ?', whereArgs: [id]);
|
|
if (rawAlbums.length == 0) return null;
|
|
Album album = Album.fromSQL(rawAlbums[0]);
|
|
|
|
List<Track> tracks = [];
|
|
//Load tracks
|
|
for (int i=0; i<album.tracks.length; i++) {
|
|
tracks.add(await getOfflineTrack(album.tracks[i].id, album: album));
|
|
}
|
|
album.tracks = tracks;
|
|
//Load artists
|
|
List<Artist> artists = [];
|
|
for (int i=0; i<album.artists.length; i++) {
|
|
artists.add((await getOfflineArtist(album.artists[i].id))??album.artists[i]);
|
|
}
|
|
album.artists = artists;
|
|
|
|
return album;
|
|
}
|
|
|
|
//Get offline artist METADATA, not tracks
|
|
Future<Artist> getOfflineArtist(String id) async {
|
|
List rawArtists = await db.query("Artists", where: 'id == ?', whereArgs: [id]);
|
|
if (rawArtists.length == 0) return null;
|
|
return Artist.fromSQL(rawArtists[0]);
|
|
}
|
|
|
|
//Get all offline playlists
|
|
Future<List<Playlist>> getOfflinePlaylists() async {
|
|
List rawPlaylists = await db.query('Playlists', columns: ['id']);
|
|
List<Playlist> out = [];
|
|
for (Map rawPlaylist in rawPlaylists) {
|
|
out.add(await getPlaylist(rawPlaylist['id']));
|
|
}
|
|
return out;
|
|
}
|
|
|
|
//Get offline playlist
|
|
Future<Playlist> getPlaylist(String id) async {
|
|
List rawPlaylists = await db.query('Playlists', where: 'id == ?', whereArgs: [id]);
|
|
if (rawPlaylists.length == 0) return null;
|
|
Playlist playlist = Playlist.fromSQL(rawPlaylists[0]);
|
|
//Load tracks
|
|
List<Track> tracks = [];
|
|
for (Track t in playlist.tracks) {
|
|
tracks.add(await getOfflineTrack(t.id));
|
|
}
|
|
playlist.tracks = tracks;
|
|
return playlist;
|
|
}
|
|
|
|
Future removeOfflineTracks(List<Track> tracks) async {
|
|
for (Track t in tracks) {
|
|
//Check if library
|
|
List rawTrack = await db.query('Tracks', where: 'id == ?', whereArgs: [t.id], columns: ['favorite']);
|
|
if (rawTrack.length > 0 && rawTrack[0]['favorite'] == 0) {
|
|
//Count occurrences in playlists and albums
|
|
List albums = await db.rawQuery('SELECT (id) FROM Albums WHERE tracks LIKE "%${t.id}%"');
|
|
List playlists = await db.rawQuery('SELECT (id) FROM Playlists WHERE tracks LIKE "%${t.id}%"');
|
|
if (albums.length + playlists.length == 0) {
|
|
//Safe to remove
|
|
await db.delete('Tracks', where: 'id == ?', whereArgs: [t.id]);
|
|
//Remove file
|
|
try {
|
|
File(p.join(offlinePath, t.id)).delete();
|
|
} catch (e) {
|
|
print(e);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
Future removeOfflineAlbum(String id) async {
|
|
//Get album
|
|
List rawAlbums = await db.query('Albums', where: 'id == ?', whereArgs: [id]);
|
|
if (rawAlbums.length == 0) return;
|
|
Album album = Album.fromSQL(rawAlbums[0]);
|
|
//Remove album
|
|
await db.delete('Albums', where: 'id == ?', whereArgs: [id]);
|
|
//Remove tracks
|
|
await removeOfflineTracks(album.tracks);
|
|
}
|
|
|
|
Future removeOfflinePlaylist(String id) async {
|
|
//Fetch playlist
|
|
List rawPlaylists = await db.query('Playlists', where: 'id == ?', whereArgs: [id]);
|
|
if (rawPlaylists.length == 0) return;
|
|
Playlist playlist = Playlist.fromSQL(rawPlaylists[0]);
|
|
//Remove playlist
|
|
await db.delete('Playlists', where: 'id == ?', whereArgs: [id]);
|
|
await removeOfflineTracks(playlist.tracks);
|
|
}
|
|
|
|
//Check if album, track or playlist is offline
|
|
Future<bool> checkOffline({Album album, Track track, Playlist playlist}) async {
|
|
//Track
|
|
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;
|
|
}
|
|
//Album
|
|
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;
|
|
}
|
|
//Playlist
|
|
if (playlist != 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 getOfflineTrack(trackData['id']));
|
|
}
|
|
//Albums
|
|
List albumsData = await db.rawQuery('SELECT (id) FROM Albums WHERE offline == 1 AND title like "%$query%"');
|
|
for (Map rawAlbum in albumsData) {
|
|
results.albums.add(await getOfflineAlbum(rawAlbum['id']));
|
|
}
|
|
//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;
|
|
}
|
|
|
|
//Sanitize filename
|
|
String sanitize(String input) {
|
|
RegExp sanitize = RegExp(r'[\/\\\?\%\*\:\|\"\<\>]');
|
|
return input.replaceAll(sanitize, '');
|
|
}
|
|
|
|
//Generate track download path
|
|
String _generatePath(Track track, bool private, {String playlistName, int playlistTrackNumber}) {
|
|
String path;
|
|
if (private) {
|
|
path = p.join(offlinePath, track.id);
|
|
} else {
|
|
//Download path
|
|
path = settings.downloadPath;
|
|
|
|
if (settings.playlistFolder && playlistName != null)
|
|
path = p.join(path, sanitize(playlistName));
|
|
|
|
if (settings.artistFolder)
|
|
path = p.join(path, '%artist%');
|
|
|
|
//Album folder / with disk number
|
|
if (settings.albumFolder) {
|
|
if (settings.albumDiscFolder) {
|
|
path = p.join(path, '%album%' + ' - Disk ' + (track.diskNumber??1).toString());
|
|
} else {
|
|
path = p.join(path, '%album%');
|
|
}
|
|
}
|
|
//Final path
|
|
path = p.join(path, settings.downloadFilename);
|
|
//Playlist track number variable (not accessible in service)
|
|
if (playlistTrackNumber != null) {
|
|
path = path.replaceAll('%playlistTrackNumber%', playlistTrackNumber.toString());
|
|
path = path.replaceAll('%0playlistTrackNumber%', playlistTrackNumber.toString().padLeft(2, '0'));
|
|
} else {
|
|
path = path.replaceAll('%playlistTrackNumber%', '');
|
|
path = path.replaceAll('%0playlistTrackNumber%', '');
|
|
}
|
|
}
|
|
return path;
|
|
}
|
|
|
|
//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 Playlists'))[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 in list, //TODO: Make into class in future
|
|
return ([
|
|
trackCount.toString(),
|
|
albumCount.toString(),
|
|
playlistCount.toString(),
|
|
filesize(offlineSize),
|
|
filesize((diskSpace * 1000000).floor())
|
|
]);
|
|
}
|
|
|
|
//Send settings to download service
|
|
Future updateServiceSettings() async {
|
|
await platform.invokeMethod('updateSettings', settings.getServiceSettings());
|
|
}
|
|
|
|
//Check storage permission
|
|
Future<bool> checkPermission() async {
|
|
if (await Permission.storage.request().isGranted) {
|
|
return true;
|
|
} else {
|
|
Fluttertoast.showToast(
|
|
msg: 'Storage permission denied!'.i18n,
|
|
toastLength: Toast.LENGTH_SHORT,
|
|
gravity: ToastGravity.BOTTOM
|
|
);
|
|
return false;
|
|
}
|
|
}
|
|
|
|
//Remove download from queue/finished
|
|
Future removeDownload(int id) async {
|
|
await platform.invokeMethod('removeDownload', {'id': id});
|
|
}
|
|
|
|
//Restart failed downloads
|
|
Future retryDownloads() async {
|
|
await platform.invokeMethod('retryDownloads');
|
|
}
|
|
|
|
//Delete downloads by state
|
|
Future removeDownloads(DownloadState state) async {
|
|
await platform.invokeMethod('removeDownloads', {'state': DownloadState.values.indexOf(state)});
|
|
}
|
|
|
|
}
|
|
|
|
class Download {
|
|
int id;
|
|
String path;
|
|
bool private;
|
|
String trackId;
|
|
String md5origin;
|
|
String mediaVersion;
|
|
String title;
|
|
String image;
|
|
int quality;
|
|
//Dynamic
|
|
DownloadState state;
|
|
int received;
|
|
int filesize;
|
|
|
|
Download({this.id, this.path, this.private, this.trackId, this.md5origin, this.mediaVersion,
|
|
this.title, this.image, this.state, this.received, this.filesize, this.quality});
|
|
|
|
//Get progress between 0 - 1
|
|
double get progress {
|
|
return ((received.toDouble()??0.0)/(filesize.toDouble()??1.0)).toDouble();
|
|
}
|
|
|
|
factory Download.fromJson(Map<dynamic, dynamic> data) {
|
|
return Download(
|
|
path: data['path'],
|
|
image: data['image'],
|
|
private: data['private'],
|
|
trackId: data['trackId'],
|
|
id: data['id'],
|
|
state: DownloadState.values[data['state']],
|
|
title: data['title'],
|
|
quality: data['quality']
|
|
);
|
|
}
|
|
|
|
//Change values from "update json"
|
|
void updateFromJson(Map<dynamic, dynamic> data) {
|
|
this.quality = data['quality'];
|
|
this.received = data['received']??0;
|
|
this.state = DownloadState.values[data['state']];
|
|
//Prevent null division later
|
|
this.filesize = ((data['filesize']??0) <= 0) ? 1 : (data['filesize']??1);
|
|
}
|
|
|
|
//Track to download JSON for service
|
|
static Future<Map> jsonFromTrack(Track t, String path, {private = true}) async {
|
|
//Get download info
|
|
if (t.playbackDetails == null || t.playbackDetails == []) {
|
|
t = await deezerAPI.track(t.id);
|
|
}
|
|
return {
|
|
"private": private,
|
|
"trackId": t.id,
|
|
"md5origin": t.playbackDetails[0],
|
|
"mediaVersion": t.playbackDetails[1],
|
|
"quality": private
|
|
? settings.getQualityInt(settings.offlineQuality)
|
|
: settings.getQualityInt(settings.downloadQuality),
|
|
"title": t.title,
|
|
"path": path,
|
|
"image": t.albumArt.thumb
|
|
};
|
|
}
|
|
}
|
|
|
|
//Has to be same order as in java
|
|
enum DownloadState {
|
|
NONE,
|
|
DOWNLOADING,
|
|
POST,
|
|
DONE,
|
|
DEEZER_ERROR,
|
|
ERROR
|
|
} |