Spotify playlist import
This commit is contained in:
parent
2bd4646796
commit
439906ecbc
|
@ -66,6 +66,15 @@ class DeezerAPI {
|
||||||
return response.data;
|
return response.data;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<Map> 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
|
//Authorize, bool = success
|
||||||
Future<bool> authorize() async {
|
Future<bool> authorize() async {
|
||||||
try {
|
try {
|
||||||
|
@ -341,7 +350,7 @@ class DeezerAPI {
|
||||||
|
|
||||||
//Create playlist
|
//Create playlist
|
||||||
//Status 1 - private, 2 - collaborative
|
//Status 1 - private, 2 - collaborative
|
||||||
Future createPlaylist(String title, {String description = "", int status = 1, List<String> trackIds = const []}) async {
|
Future<String> createPlaylist(String title, {String description = "", int status = 1, List<String> trackIds = const []}) async {
|
||||||
Map data = await callApi('playlist.create', params: {
|
Map data = await callApi('playlist.create', params: {
|
||||||
'title': title,
|
'title': title,
|
||||||
'description': description,
|
'description': description,
|
||||||
|
@ -349,7 +358,7 @@ class DeezerAPI {
|
||||||
'status': status
|
'status': status
|
||||||
});
|
});
|
||||||
//Return playlistId
|
//Return playlistId
|
||||||
return data['results'];
|
return data['results'].toString();
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -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<Map> 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<SpotifyPlaylist> 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<SpotifyTrack> 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<SpotifyTrack>((j) => SpotifyTrack.fromJson(j['track'])).toList()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
enum TrackImportState {
|
||||||
|
NONE,
|
||||||
|
ERROR,
|
||||||
|
OK
|
||||||
|
}
|
|
@ -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<ImporterScreen> {
|
||||||
|
|
||||||
|
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: <Widget>[
|
||||||
|
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: <Widget>[
|
||||||
|
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: <Widget>[
|
||||||
|
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<ImporterWidget> {
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Column(
|
||||||
|
children: <Widget>[
|
||||||
|
Divider(),
|
||||||
|
ListTile(
|
||||||
|
title: Text(widget.playlist.name),
|
||||||
|
subtitle: Text(widget.playlist.description),
|
||||||
|
leading: Image.network(widget.playlist.image),
|
||||||
|
),
|
||||||
|
Row(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.spaceAround,
|
||||||
|
children: <Widget>[
|
||||||
|
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: <Widget>[
|
||||||
|
if (!(spotify.doneImporting??true)) Padding(
|
||||||
|
padding: EdgeInsets.symmetric(vertical: 8.0),
|
||||||
|
child: Row(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
|
children: <Widget>[
|
||||||
|
CircularProgressIndicator()
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Card(
|
||||||
|
child: Row(
|
||||||
|
mainAxisSize: MainAxisSize.max,
|
||||||
|
mainAxisAlignment: MainAxisAlignment.spaceAround,
|
||||||
|
children: <Widget>[
|
||||||
|
Row(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: <Widget>[
|
||||||
|
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: <Widget>[
|
||||||
|
Icon(Icons.done, size: 24.0,),
|
||||||
|
Container(width: 4.0,),
|
||||||
|
Text('$ok', style: TextStyle(fontSize: 24.0),)
|
||||||
|
],
|
||||||
|
),
|
||||||
|
Row(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: <Widget>[
|
||||||
|
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),
|
||||||
|
);
|
||||||
|
})
|
||||||
|
],
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
|
@ -8,11 +8,12 @@ import 'package:freezer/settings.dart';
|
||||||
import 'package:freezer/ui/details_screens.dart';
|
import 'package:freezer/ui/details_screens.dart';
|
||||||
import 'package:freezer/ui/downloads_screen.dart';
|
import 'package:freezer/ui/downloads_screen.dart';
|
||||||
import 'package:freezer/ui/error.dart';
|
import 'package:freezer/ui/error.dart';
|
||||||
|
import 'package:freezer/ui/importer_screen.dart';
|
||||||
import 'package:freezer/ui/tiles.dart';
|
import 'package:freezer/ui/tiles.dart';
|
||||||
|
|
||||||
import 'menu.dart';
|
import 'menu.dart';
|
||||||
import 'settings_screen.dart';
|
import 'settings_screen.dart';
|
||||||
import 'player_bar.dart';
|
import '../api/spotify.dart';
|
||||||
import '../api/download.dart';
|
import '../api/download.dart';
|
||||||
|
|
||||||
class LibraryAppBar extends StatelessWidget implements PreferredSizeWidget {
|
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(
|
ExpansionTile(
|
||||||
title: Text('Statistics'),
|
title: Text('Statistics'),
|
||||||
leading: Icon(Icons.insert_chart),
|
leading: Icon(Icons.insert_chart),
|
||||||
|
|
Loading…
Reference in New Issue