From 439906ecbc39ddd3a62f06eb97cfe4fe7aa7ae67 Mon Sep 17 00:00:00 2001 From: exttex Date: Thu, 25 Jun 2020 14:28:56 +0200 Subject: [PATCH] Spotify playlist import --- lib/api/deezer.dart | 13 +- lib/api/spotify.dart | 127 ++++++++++++++++ lib/ui/importer_screen.dart | 282 ++++++++++++++++++++++++++++++++++++ lib/ui/library.dart | 22 ++- 4 files changed, 441 insertions(+), 3 deletions(-) create mode 100644 lib/api/spotify.dart create mode 100644 lib/ui/importer_screen.dart diff --git a/lib/api/deezer.dart b/lib/api/deezer.dart index 9f127f8..2bd3ef3 100644 --- a/lib/api/deezer.dart +++ b/lib/api/deezer.dart @@ -66,6 +66,15 @@ class DeezerAPI { return response.data; } + Future callPublicApi(String path) async { + Dio dio = Dio(); + Response response = await dio.get( + 'https://api.deezer.com/' + path, + options: Options(responseType: ResponseType.json, sendTimeout: 5000, receiveTimeout: 5000) + ); + return response.data; + } + //Authorize, bool = success Future authorize() async { try { @@ -341,7 +350,7 @@ class DeezerAPI { //Create playlist //Status 1 - private, 2 - collaborative - Future createPlaylist(String title, {String description = "", int status = 1, List trackIds = const []}) async { + Future createPlaylist(String title, {String description = "", int status = 1, List trackIds = const []}) async { Map data = await callApi('playlist.create', params: { 'title': title, 'description': description, @@ -349,7 +358,7 @@ class DeezerAPI { 'status': status }); //Return playlistId - return data['results']; + return data['results'].toString(); } } diff --git a/lib/api/spotify.dart b/lib/api/spotify.dart new file mode 100644 index 0000000..e10fcad --- /dev/null +++ b/lib/api/spotify.dart @@ -0,0 +1,127 @@ +import 'package:dio/dio.dart'; +import 'package:freezer/api/deezer.dart'; +import 'package:freezer/api/download.dart'; +import 'package:freezer/api/definitions.dart'; +import 'package:html/parser.dart'; +import 'package:html/dom.dart'; + +import 'dart:convert'; +import 'dart:async'; + + +SpotifyAPI spotify = SpotifyAPI(); + +class SpotifyAPI { + + SpotifyPlaylist importingSpotifyPlaylist; + StreamController importingStream = StreamController.broadcast(); + bool doneImporting; + + //Parse spotify URL to URI (spotify:track:1234) + String parseUrl(String url) { + Uri uri = Uri.parse(url); + if (uri.pathSegments.length > 3) return null; //Invalid URL + if (uri.pathSegments.length == 3) return 'spotify:${uri.pathSegments[1]}:${uri.pathSegments[2]}'; + if (uri.pathSegments.length == 2) return 'spotify:${uri.pathSegments[0]}:${uri.pathSegments[1]}'; + return null; + } + + //Get spotify embed url from uri + String getEmbedUrl(String uri) => 'https://embed.spotify.com/?uri=$uri'; + + //Extract JSON data form spotify embed page + Future getEmbedData(String url) async { + //Fetch + Dio dio = Dio(); + Response response = await dio.get(url); + //Parse + Document document = parse(response.data); + Element element = document.getElementById('resource'); + return jsonDecode(element.innerHtml); + } + + Future playlist(String uri) async { + //Load data + String url = getEmbedUrl(uri); + Map data = await getEmbedData(url); + //Parse + SpotifyPlaylist playlist = SpotifyPlaylist.fromJson(data); + return playlist; + } + + Future convertPlaylist(SpotifyPlaylist playlist, {bool downloadOnly = false}) async { + doneImporting = false; + importingSpotifyPlaylist = playlist; + + //Create Deezer playlist + String playlistId; + if (!downloadOnly) + playlistId = await deezerAPI.createPlaylist(playlist.name, description: playlist.description); + + //Search for tracks + for (SpotifyTrack track in playlist.tracks) { + Map deezer; + try { + //Search + deezer = await deezerAPI.callPublicApi('track/isrc:' + track.isrc); + if (deezer.containsKey('error')) throw Exception(); + String id = deezer['id'].toString(); + //Add + if (!downloadOnly) + await deezerAPI.addToPlaylist(id, playlistId); + if (downloadOnly) + await downloadManager.addOfflineTrack(Track(id: id), private: false); + track.state = TrackImportState.OK; + } catch (e) { + //On error + track.state = TrackImportState.ERROR; + } + //Add playlist id to stream, stream is for updating ui only + importingStream.add(playlistId); + importingSpotifyPlaylist = playlist; + } + doneImporting = true; + //Return DEEZER playlist id + return playlistId; + } + +} + +class SpotifyTrack { + String title; + String artists; + String isrc; + TrackImportState state = TrackImportState.NONE; + + SpotifyTrack({this.title, this.artists, this.isrc}); + + //JSON + factory SpotifyTrack.fromJson(Map json) => SpotifyTrack( + title: json['name'], + artists: json['artists'].map((j) => j['name']).toList().join(', '), + isrc: json['external_ids']['isrc'] + ); +} + +class SpotifyPlaylist { + String name; + String description; + List tracks; + String image; + + SpotifyPlaylist({this.name, this.description, this.tracks, this.image}); + + //JSON + factory SpotifyPlaylist.fromJson(Map json) => SpotifyPlaylist( + name: json['name'], + description: json['description'], + image: json['images'][0]['url'], + tracks: json['tracks']['items'].map((j) => SpotifyTrack.fromJson(j['track'])).toList() + ); +} + +enum TrackImportState { + NONE, + ERROR, + OK +} \ No newline at end of file diff --git a/lib/ui/importer_screen.dart b/lib/ui/importer_screen.dart new file mode 100644 index 0000000..e8a156f --- /dev/null +++ b/lib/ui/importer_screen.dart @@ -0,0 +1,282 @@ +import 'package:flutter/cupertino.dart'; +import 'package:flutter/material.dart'; +import 'package:freezer/api/deezer.dart'; +import 'package:freezer/api/definitions.dart'; +import 'package:freezer/api/spotify.dart'; +import 'package:freezer/ui/menu.dart'; + +class ImporterScreen extends StatefulWidget { + @override + _ImporterScreenState createState() => _ImporterScreenState(); +} + +class _ImporterScreenState extends State { + + String _url; + bool _error = false; + bool _loading = false; + SpotifyPlaylist _data; + + Future _load() async { + setState(() { + _error = false; + _loading = true; + }); + try { + String uri = spotify.parseUrl(_url); + + //Error/NonPlaylist + if (uri == null || uri.split(':')[1] != 'playlist') { + throw Exception(); + } + //Load + SpotifyPlaylist data = await spotify.playlist(uri); + setState(() => _data = data); + return; + + } catch (e) { + print(e); + setState(() { + _error = true; + _loading = false; + }); + return; + } + + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: Text('Importer'), + ), + body: ListView( + children: [ + ListTile( + title: Text('Currently supporting only Spotify, with 100 tracks limit'), + subtitle: Text('Due to API limitations'), + leading: Icon( + Icons.warning, + color: Colors.deepOrangeAccent, + ), + ), + Divider(), + Container(height: 16.0,), + Text( + 'Enter your playlist link below', + textAlign: TextAlign.center, + style: TextStyle( + fontSize: 20.0 + ), + ), + Padding( + padding: EdgeInsets.symmetric(horizontal: 16.0, vertical: 8.0), + child: Row( + children: [ + Expanded( + child: TextField( + onChanged: (String s) => _url = s, + onSubmitted: (String s) { + _url = s; + _load(); + }, + decoration: InputDecoration( + hintText: 'URL' + ), + ), + ), + IconButton( + icon: Icon(Icons.search), + onPressed: () => _load(), + ) + ], + ), + ), + Container(height: 8.0,), + + if (_data == null && _loading) + Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + CircularProgressIndicator() + ], + ), + if (_error) + ListTile( + title: Text('Error loading URL!'), + leading: Icon(Icons.error, color: Colors.red,), + ), + if (_data != null) + ImporterWidget(_data) + ], + ), + ); + } +} + +class ImporterWidget extends StatefulWidget { + + final SpotifyPlaylist playlist; + ImporterWidget(this.playlist, {Key key}): super(key: key); + + @override + _ImporterWidgetState createState() => _ImporterWidgetState(); +} + +class _ImporterWidgetState extends State { + @override + Widget build(BuildContext context) { + return Column( + children: [ + Divider(), + ListTile( + title: Text(widget.playlist.name), + subtitle: Text(widget.playlist.description), + leading: Image.network(widget.playlist.image), + ), + Row( + mainAxisAlignment: MainAxisAlignment.spaceAround, + children: [ + RaisedButton( + child: Text('Convert'), + color: Theme.of(context).primaryColor, + onPressed: () { + spotify.convertPlaylist(widget.playlist); + Navigator.of(context).pushReplacement(MaterialPageRoute( + builder: (context) => CurrentlyImportingScreen() + )); + }, + ), + RaisedButton( + child: Text('Download only'), + color: Theme.of(context).primaryColor, + onPressed: () { + spotify.convertPlaylist(widget.playlist, downloadOnly: true); + Navigator.of(context).pushReplacement(MaterialPageRoute( + builder: (context) => CurrentlyImportingScreen() + )); + }, + ), + ], + ), + ...List.generate(widget.playlist.tracks.length, (i) { + SpotifyTrack t = widget.playlist.tracks[i]; + return ListTile( + title: Text( + t.title, + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + subtitle: Text( + t.artists, + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ); + }) + ], + ); + } +} + +class CurrentlyImportingScreen extends StatelessWidget { + + Widget _stateIcon(TrackImportState s) { + switch (s) { + case TrackImportState.ERROR: + return Icon(Icons.error, color: Colors.red,); + case TrackImportState.OK: + return Icon(Icons.done, color: Colors.green); + default: + return Container(width: 0, height: 0); + } + } + + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar(title: Text('Importing...'),), + body: StreamBuilder( + stream: spotify.importingStream.stream, + builder: (context, snapshot) { + + //If not in progress + if (spotify.importingSpotifyPlaylist == null || spotify.importingSpotifyPlaylist.tracks == null || spotify.doneImporting == null) + return Center(child: CircularProgressIndicator(),); + if (spotify.doneImporting) spotify.doneImporting = null; + + //Cont OK, error, total + int ok = spotify.importingSpotifyPlaylist.tracks.where((t) => t.state == TrackImportState.OK).length; + int err = spotify.importingSpotifyPlaylist.tracks.where((t) => t.state == TrackImportState.ERROR).length; + int count = spotify.importingSpotifyPlaylist.tracks.length; + + return ListView( + children: [ + if (!(spotify.doneImporting??true)) Padding( + padding: EdgeInsets.symmetric(vertical: 8.0), + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + CircularProgressIndicator() + ], + ), + ), + Card( + child: Row( + mainAxisSize: MainAxisSize.max, + mainAxisAlignment: MainAxisAlignment.spaceAround, + children: [ + Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Icons.import_export, size: 24.0,), + Container(width: 4.0,), + Text('${ok+err}/$count', style: TextStyle(fontSize: 24.0),) + ], + ), + Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Icons.done, size: 24.0,), + Container(width: 4.0,), + Text('$ok', style: TextStyle(fontSize: 24.0),) + ], + ), + Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Icons.error, size: 24.0,), + Container(width: 4.0,), + Text('$err', style: TextStyle(fontSize: 24.0),), + ], + ), + if (snapshot.data != null) + FlatButton( + child: Text('Playlist menu'), + onPressed: () async { + Playlist p = await deezerAPI.playlist(snapshot.data); + p.library = true; + MenuSheet m = MenuSheet(context); + m.defaultPlaylistMenu(p); + }, + ) + ], + ), + ), + ...List.generate(spotify.importingSpotifyPlaylist.tracks.length, (i) { + SpotifyTrack t = spotify.importingSpotifyPlaylist.tracks[i]; + return ListTile( + title: Text(t.title), + subtitle: Text(t.artists), + leading: _stateIcon(t.state), + ); + }) + ], + ); + }, + ), + ); + } +} diff --git a/lib/ui/library.dart b/lib/ui/library.dart index 250e76f..5cbbc1f 100644 --- a/lib/ui/library.dart +++ b/lib/ui/library.dart @@ -8,11 +8,12 @@ import 'package:freezer/settings.dart'; import 'package:freezer/ui/details_screens.dart'; import 'package:freezer/ui/downloads_screen.dart'; import 'package:freezer/ui/error.dart'; +import 'package:freezer/ui/importer_screen.dart'; import 'package:freezer/ui/tiles.dart'; import 'menu.dart'; import 'settings_screen.dart'; -import 'player_bar.dart'; +import '../api/spotify.dart'; import '../api/download.dart'; class LibraryAppBar extends StatelessWidget implements PreferredSizeWidget { @@ -107,6 +108,25 @@ class LibraryScreen extends StatelessWidget { ); }, ), + Divider(), + ListTile( + title: Text('Import'), + leading: Icon(Icons.import_export), + subtitle: Text('Import playlists from Spotify'), + onTap: () { + if (spotify.doneImporting != null) { + Navigator.of(context).push( + MaterialPageRoute(builder: (context) => CurrentlyImportingScreen()) + ); + if (spotify.doneImporting) spotify.doneImporting = null; + return; + } + + Navigator.of(context).push( + MaterialPageRoute(builder: (context) => ImporterScreen()) + ); + }, + ), ExpansionTile( title: Text('Statistics'), leading: Icon(Icons.insert_chart),