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> 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 _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 qualitySelect(BuildContext context) { return showModalBottomSheet( 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 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 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 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 getOfflineTrack(String? id, {Album? album, List? 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 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> getOfflineTracks() async { List rawTracks = await db.query('Tracks', where: 'library == 1 AND offline == 1', columns: ['id']); List out = []; //Load track meta individually for (Map rawTrack in rawTracks as Iterable>) { out.add(await getOfflineTrack(rawTrack['id'])); } return out; } //Get all offline available tracks Future> allOfflineTracks() async { if (!isSupported) return []; List rawTracks = await db.query('Tracks', where: 'offline == 1', columns: ['id']); List out = []; //Load track meta individually for (Map rawTrack in rawTracks as Iterable>) { out.add(await getOfflineTrack(rawTrack['id'])); } return out; } //Get all offline albums Future> getOfflineAlbums() async { if (!isSupported) return []; List rawAlbums = await db.query('Albums', where: 'offline == 1', columns: ['id']); List out = []; //Load each album for (Map rawAlbum in rawAlbums as Iterable>) { out.add(await getOfflineAlbum(rawAlbum['id'])); } return out; } //Get offline album with meta Future 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 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 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 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> getOfflinePlaylists() async { if (!isSupported) return []; final rawPlaylists = await db.query('Playlists', columns: ['id']); final out = []; for (final rawPlaylist in rawPlaylists) { out.add(await getPlaylist(rawPlaylist)); } return out; } Future 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 getPlaylist(Map rawPlaylist) async { // List rawPlaylists = // await db.query('Playlists', where: 'id == ?', whereArgs: [id]); Playlist playlist = Playlist.fromSQL(rawPlaylist); //Load tracks List tracks = []; for (Track t in playlist.tracks!) { tracks.add((await getOfflineTrack(t.id))!); } playlist.tracks = tracks; return playlist; } Future removeOfflineTracks(List 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 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 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>) { 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>) { 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>) { 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> 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 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 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 getDirectory(String title) => platform.invokeMethod('getDirectory', {'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 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 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 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 }