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

752 lines
24 KiB
Dart

import 'package:freezer/main.dart';
import 'package:sqflite/sqflite.dart';
import 'package:disk_space_plus/disk_space_plus.dart';
import 'package:filesize/filesize.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.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 'dart:async';
final downloadManager = DownloadManager();
class DownloadManager {
// DownloadManager currently only supports android
static bool get isSupported => Platform.isAndroid;
//Platform channels
static MethodChannel platform = const MethodChannel('f.f.freezer/native');
static EventChannel eventChannel =
const EventChannel('f.f.freezer/downloads');
bool running = false;
int? queueSize = 0;
StreamController serviceEvents = StreamController.broadcast();
late String offlinePath;
late Database db;
//Start/Resume downloads
Future start() async {
if (!isSupported) return;
//Returns whether service is bound or not, the delay is really shitty/hacky way, until i find a real solution
await updateServiceSettings();
await platform.invokeMethod('start');
}
//Stop/Pause downloads
Future stop() async {
if (!isSupported) return;
await platform.invokeMethod('stop');
}
Future init() async {
if (!isSupported) return;
//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 {
if (!isSupported) return [];
List raw = await platform.invokeMethod('getDownloads');
return raw.map((d) => Download.fromJson(d)).toList();
}
//Insert track and metadata to DB
Future<Batch> _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;
}
//Quality selector for custom quality
Future<AudioQuality?> qualitySelect(BuildContext context) {
return showModalBottomSheet<AudioQuality>(
context: context,
builder: (context) {
return Column(
mainAxisSize: MainAxisSize.min,
children: [
Padding(
padding: const EdgeInsets.fromLTRB(0, 12, 0, 2),
child: Text(
'Quality'.i18n,
style: const TextStyle(
fontWeight: FontWeight.bold, fontSize: 20.0),
),
),
ListTile(
title: const Text('MP3 128kbps'),
onTap: () => Navigator.pop(context, AudioQuality.MP3_128),
),
ListTile(
title: const Text('MP3 320kbps'),
onTap: () => Navigator.pop(context, AudioQuality.MP3_320),
),
ListTile(
title: const Text('FLAC'),
onTap: () => Navigator.pop(context, AudioQuality.FLAC),
)
],
);
});
}
Future<bool> addOfflineTrack(Track track,
{bool private = true,
BuildContext? context,
bool isSingleton = false}) async {
if (!isSupported) return false;
//Permission
if (!private && !(await checkPermission())) return false;
//Ask for quality
AudioQuality? quality;
if (!private && settings.downloadQuality == AudioQuality.ASK) {
quality = await qualitySelect(context!);
if (quality == null) return false;
}
//Fetch track if missing meta
if (track.artists == null ||
track.artists!.isEmpty ||
track.album == null) {
track = await deezerAPI.track(track.id);
}
//Add to DB
if (private) {
Batch b = db.batch();
b = await _addTrackToDB(b, track, true);
await b.commit();
//Cache art
cacheManager.getSingleFile(track.albumArt!.thumb);
cacheManager.getSingleFile(track.albumArt!.full);
}
//Get path
String? path = _generatePath(track, private, isSingleton: isSingleton);
await platform.invokeMethod('addDownloads', [
await Download.jsonFromTrack(track, path,
private: private, quality: quality)
]);
await start();
return true;
}
Future addOfflineAlbum(Album? album,
{private = true, BuildContext? context}) async {
//Permission
if (!private && !(await checkPermission())) return;
//Ask for quality
AudioQuality? quality;
if (!private && settings.downloadQuality == AudioQuality.ASK) {
quality = await qualitySelect(context!);
if (quality == null) return false;
}
//Get from API if no tracks
if (album!.tracks == null || album.tracks!.isEmpty) {
album = await deezerAPI.album(album.id);
}
//Add to DB
if (private) {
//Cache art
cacheManager.getSingleFile(album.art!.thumb);
cacheManager.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, quality: quality));
}
await platform.invokeMethod('addDownloads', out);
await start();
}
Future addOfflinePlaylist(Playlist? playlist,
{private = true, BuildContext? context, AudioQuality? quality}) async {
if (!isSupported) return false;
//Permission
if (!private && !(await checkPermission())) return;
//Ask for quality
if (!private &&
settings.downloadQuality == AudioQuality.ASK &&
quality == null) {
quality = await qualitySelect(context!);
if (quality == null) return false;
}
//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
cacheManager.getSingleFile(t.albumArt!.thumb);
cacheManager.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,
quality: quality));
}
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.isEmpty) 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.isNotEmpty) 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.isNotEmpty) newArtists.add(Artist.fromSQL(rawArtist[0]));
}
if (newArtists.isNotEmpty) 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 as Iterable<Map<dynamic, dynamic>>) {
out.add(await getOfflineTrack(rawTrack['id']));
}
return out;
}
//Get all offline available tracks
Future<List<Track?>> allOfflineTracks() async {
if (!isSupported) return [];
List rawTracks =
await db.query('Tracks', where: 'offline == 1', columns: ['id']);
List<Track?> out = [];
//Load track meta individually
for (Map rawTrack in rawTracks as Iterable<Map<dynamic, dynamic>>) {
out.add(await getOfflineTrack(rawTrack['id']));
}
return out;
}
//Get all offline albums
Future<List<Album>> getOfflineAlbums() async {
if (!isSupported) return [];
List rawAlbums =
await db.query('Albums', where: 'offline == 1', columns: ['id']);
List<Album> out = [];
//Load each album
for (Map rawAlbum in rawAlbums as Iterable<Map<dynamic, dynamic>>) {
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.isEmpty) throw Exception();
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.isEmpty) return null;
return Artist.fromSQL(rawArtists[0]);
}
//Get all offline playlists
Future<List<Playlist>> getOfflinePlaylists() async {
if (!isSupported) return [];
final rawPlaylists = await db.query('Playlists', columns: ['id']);
final out = <Playlist>[];
for (final rawPlaylist in rawPlaylists) {
out.add(await getPlaylist(rawPlaylist));
}
return out;
}
Future<Playlist?> getPlaylistFromId(String id) async {
final rawPlaylists =
await db.query('Playlists', where: 'id == ?', whereArgs: [id]);
if (rawPlaylists.isEmpty) return null;
return await getPlaylist(rawPlaylists[0]);
}
//Get offline playlist
Future<Playlist> getPlaylist(Map<String, Object?> rawPlaylist) async {
// List rawPlaylists =
// await db.query('Playlists', where: 'id == ?', whereArgs: [id]);
Playlist playlist = Playlist.fromSQL(rawPlaylist);
//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.isNotEmpty) {
//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.isEmpty &&
playlists.isEmpty &&
rawTrack[0]['favorite'] == 0) {
//Safe to remove
await db.delete('Tracks', where: 'id == ?', whereArgs: [t.id]);
} else {
await db.update('Tracks', {'offline': 0},
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.isEmpty) 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 {
if (!isSupported) return;
//Fetch playlist
List rawPlaylists =
await db.query('Playlists', where: 'id == ?', whereArgs: [id]);
if (rawPlaylists.isEmpty) 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 {
if (!isSupported) return false;
//Track
if (track != null) {
List res = await db.query('Tracks',
where: 'id == ? AND offline == 1', whereArgs: [track.id]);
if (res.isEmpty) return false;
return true;
}
//Album
if (album != null) {
List res = await db.query('Albums',
where: 'id == ? AND offline == 1', whereArgs: [album.id]);
if (res.isEmpty) return false;
return true;
}
//Playlist
if (playlist != null) {
List res = await db
.query('Playlists', where: 'id == ?', whereArgs: [playlist.id]);
if (res.isEmpty) 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 as Iterable<Map<dynamic, dynamic>>) {
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 as Iterable<Map<dynamic, dynamic>>) {
results.albums!.add((await getOfflineAlbum(rawAlbum['id'])));
}
//Playlists
List playlists = await db.query("Playlists",
where: "title like '%' || ? || '%'", whereArgs: [query]);
for (Map playlist in playlists as Iterable<Map<dynamic, dynamic>>) {
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,
bool isSingleton = false}) {
String? path;
if (private) {
// TODO: add extension
return p.join(offlinePath, track!.id);
}
//Download path
path = settings.downloadPath;
if (settings.playlistFolder && playlistName != null) {
path = p.join(path!, sanitize(playlistName));
}
if (settings.artistFolder) path = p.join(path!, '%albumArtist%');
//Album folder / with disk number
if (settings.albumFolder) {
if (settings.albumDiscFolder) {
path = p.join(path!, '%album% - Disk ${track!.diskNumber ?? 1}');
} else {
path = p.join(path!, '%album%');
}
}
//Final path
path = p.join(path!,
isSingleton ? settings.singletonFilename : 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(*)'] as int?;
int? albumCount =
(await db.rawQuery('SELECT COUNT(*) FROM Albums WHERE offline == 1'))[0]
['COUNT(*)'] as int?;
int? playlistCount = (await db
.rawQuery('SELECT COUNT(*) FROM Playlists'))[0]['COUNT(*)'] as int?;
//Free space
double diskSpace =
await DiskSpacePlus.getFreeDiskSpace.then((value) => value ?? 0.0);
//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 {
if (!isSupported) return;
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 {
if (!isSupported) return;
await platform.invokeMethod('removeDownload', {'id': id});
}
//Restart failed downloads
Future retryDownloads() async {
if (!isSupported) return;
await platform.invokeMethod('retryDownloads');
}
//Delete downloads by state
Future removeDownloads(DownloadState state) async {
if (!isSupported) return;
await platform.invokeMethod(
'removeDownloads', {'state': DownloadState.values.indexOf(state)});
}
static Future<String?> getDirectory(String title) =>
platform.invokeMethod('getDirectory', <String, String>{'title': title});
}
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({
required this.id,
required this.path,
required this.private,
required this.trackId,
required this.title,
required this.image,
required this.state,
required this.quality,
this.md5origin,
this.mediaVersion,
this.received,
this.filesize,
});
//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) {
quality = data['quality'];
received = data['received'] ?? 0;
state = DownloadState.values[data['state']];
//Prevent null division later
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, AudioQuality? quality}) 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.offlineQuality.toDeezerQualityInt()
: (quality ?? settings.downloadQuality).toDeezerQualityInt(),
"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 }