├── index.js ├── test ├── index.js ├── Apple.js ├── Importer.js └── Spotify.js ├── .gitignore ├── src ├── drivers │ ├── index.js │ ├── Apple.js │ └── Spotify.js ├── library │ ├── Track.js │ ├── Library.js │ └── Playlist.js ├── bin │ ├── logger.js │ └── drivers.js └── Importer.js ├── bin ├── polytunes ├── polytunes-library └── polytunes-import ├── package.json └── README.md /index.js: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /test/index.js: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .env 2 | node_modules 3 | .DS_Store 4 | .npm-debug.log -------------------------------------------------------------------------------- /src/drivers/index.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | Apple: require("./Apple"), 3 | Spotify: require("./Spotify") 4 | }; -------------------------------------------------------------------------------- /src/library/Track.js: -------------------------------------------------------------------------------- 1 | class Track { 2 | constructor(name, artist, album) { 3 | this.name = name; 4 | this.artist = artist; 5 | this.album = album; 6 | } 7 | } 8 | 9 | module.exports = Track; -------------------------------------------------------------------------------- /bin/polytunes: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | const program = require("commander"); 4 | const pkg = require("../package.json"); 5 | 6 | program.version(pkg.version) 7 | .command("import", "Transfer your music library between services.") 8 | .command("library", "Inspect your library") 9 | .command("list-drivers", "List the available service drivers.") 10 | .parse(process.argv); 11 | -------------------------------------------------------------------------------- /test/Apple.js: -------------------------------------------------------------------------------- 1 | const path = require("path"); 2 | const Apple = require("../src/drivers/Apple"); 3 | 4 | const EXAMPLE_LIBRARY = path.join(__dirname, "data/Library.xml"); 5 | 6 | describe("Apple", () => { // Hrm, how much time ya got? 7 | describe("#parsePlist", function() { 8 | this.timeout(0); 9 | 10 | it("should parse a sample library", () => { 11 | return Apple.parsePlist(EXAMPLE_LIBRARY); 12 | }); 13 | }); 14 | 15 | describe("#importFromFile", function() { 16 | it("should read a library from a file", () => { 17 | return Apple.importFromFile(EXAMPLE_LIBRARY); 18 | }); 19 | }); 20 | }); -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "polytunes", 3 | "version": "0.1.0", 4 | "description": "Move your Apple Music playlist to Spotify.", 5 | "main": "index.js", 6 | "bin": { 7 | "polytunes": "./bin/polytunes" 8 | }, 9 | "dependencies": { 10 | "commander": "^2.9.0", 11 | "debug": "^2.2.0", 12 | "lodash": "^4.11.1", 13 | "plist-parser": "^0.9.2", 14 | "spotify-web-api-node": "^2.3.0" 15 | }, 16 | "devDependencies": {}, 17 | "scripts": { 18 | "test": "mocha" 19 | }, 20 | "keywords": [ 21 | "apple", 22 | "music", 23 | "spotify" 24 | ], 25 | "author": "Adrian Cooney ", 26 | "license": "MIT" 27 | } 28 | -------------------------------------------------------------------------------- /src/library/Library.js: -------------------------------------------------------------------------------- 1 | class Library { 2 | constructor() { 3 | this.playlists = {}; 4 | } 5 | 6 | addPlaylist(playlist) { 7 | return this.playlists[playlist.name] = playlist; 8 | } 9 | 10 | getPlaylist(name) { 11 | return this.playlists[name]; 12 | } 13 | 14 | getPlaylists() { 15 | return Object.keys(this.playlists).map(key => this.playlists[key]); 16 | } 17 | } 18 | 19 | class TrackNotFound extends Error { 20 | constructor(track, vendor) { 21 | super(`Track '${track.name}' by '${track.artist}' could not be found on ${vendor}.`); 22 | this.track = track; 23 | } 24 | } 25 | 26 | module.exports = Library; 27 | module.exports.TrackNotFound = TrackNotFound; -------------------------------------------------------------------------------- /src/library/Playlist.js: -------------------------------------------------------------------------------- 1 | const debug = require("debug")("library:playlist"); 2 | 3 | class Playlist { 4 | constructor(name) { 5 | this.name = name; 6 | this.tracks = []; 7 | } 8 | 9 | addTrack(track) { 10 | this.tracks.push(track); 11 | } 12 | 13 | addTracks(tracks) { 14 | tracks.forEach(this.addTrack.bind(this)); 15 | } 16 | 17 | getTracks() { 18 | return this.tracks; 19 | } 20 | } 21 | 22 | class TrackAlreadyExists extends Error { 23 | constructor(track, playlist) { 24 | super(`Track '${track.name}' by ${track.artist} already exists in playlist ${playlist.name}.`); 25 | this.track = track; 26 | this.playlist = playlist; 27 | } 28 | } 29 | 30 | module.exports = Playlist; 31 | module.exports.TrackAlreadyExists = TrackAlreadyExists; -------------------------------------------------------------------------------- /bin/polytunes-library: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | const program = require("commander"); 4 | const drivers = require("../src/bin/drivers.js"); 5 | const logger = require("../src/bin/logger.js"); 6 | 7 | program 8 | .option("-d, --driver ", "The library driver. See `list-drivers` for available drivers and configuration."); 9 | 10 | // Add the options 11 | drivers.addCommandOptions(program); 12 | 13 | program.command("playlists") 14 | .action(() => { 15 | if(!program.driver) 16 | logger.fail(new Error("Please specify the driver with -d or --driver flag. See `list-drivers` command for availabel drivers.")); 17 | 18 | drivers.getLibrary(program.driver, program).then(library => { 19 | logger.logAction("Getting %s playlists..", library.getVendorName()); 20 | return library.getPlaylists(); 21 | }).then(playlists => { 22 | playlists.forEach(playlist => { 23 | logger.log(logger.indent(playlist.toString(), 1, undefined, "-> ")); 24 | }); 25 | }).catch(logger.fail) 26 | }); 27 | 28 | program.parse(process.argv); -------------------------------------------------------------------------------- /src/bin/logger.js: -------------------------------------------------------------------------------- 1 | const debug = require("debug")("library:logger"); 2 | 3 | function logAction() { 4 | return logSymbol.apply(null, ["*"].concat(Array.prototype.slice.call(arguments))); 5 | } 6 | 7 | function logComplete() { 8 | return logSymbol.apply(null, ["+"].concat(Array.prototype.slice.call(arguments))); 9 | } 10 | 11 | function logError() { 12 | return logSymbol.apply(null, ["!"].concat(Array.prototype.slice.call(arguments))); 13 | } 14 | 15 | function logSymbol(symbol, line) { 16 | return log.apply(null, [`[${symbol}] ` + line].concat(Array.prototype.slice.call(arguments, 2))); 17 | } 18 | 19 | function log() { 20 | return console.log.apply(console, arguments); 21 | } 22 | 23 | function fail(error, code = 1) { 24 | logError(`Error: ${error.message}`); 25 | debug(error.stack); 26 | process.exit(code); 27 | } 28 | 29 | function indent(lines, count = 1, str = " ", bullet) { 30 | let pad = ""; 31 | for(var i = 0; i < count; i++) pad += str; 32 | if(bullet) pad += bullet; 33 | return pad + lines.split("\n").join("\n" + pad); 34 | } 35 | 36 | module.exports = { 37 | logAction, 38 | logComplete, 39 | log, 40 | fail, 41 | indent, 42 | logWarning: logError 43 | }; -------------------------------------------------------------------------------- /test/Importer.js: -------------------------------------------------------------------------------- 1 | const path = require("path"); 2 | 3 | const Importer = require("../src/Importer"); 4 | const SpotifyLibrary = require("../src/drivers/Spotify"); 5 | const AppleLibrary = require("../src/drivers/Apple"); 6 | 7 | const SPOTIFY_CLIENT_ID = process.env.SPOTIFY_CLIENT_ID; 8 | const SPOTIFY_CLIENT_SECRET = process.env.SPOTIFY_CLIENT_SECRET; 9 | const SPOTIFY_REFRESH_TOKEN = process.env.SPOTIFY_REFRESH_TOKEN; 10 | const EXAMPLE_APPLE_LIBRARY = path.join(__dirname, "data/Library.xml"); 11 | 12 | describe("Importer", () => { 13 | describe("#import", function() { 14 | this.timeout(0); 15 | 16 | it("should import on library to another", () => { 17 | return Promise.all([ 18 | AppleLibrary.importFromFile(EXAMPLE_APPLE_LIBRARY), 19 | SpotifyLibrary.fromCredentials({ 20 | clientId: SPOTIFY_CLIENT_ID, 21 | clientSecret: SPOTIFY_CLIENT_SECRET, 22 | refreshToken: SPOTIFY_REFRESH_TOKEN 23 | }) 24 | ]).then(([apple, spotify]) => { 25 | const importer = new Importer(apple, spotify); 26 | 27 | return importer.importPlaylist(apple.getPlaylist("House")); 28 | }) 29 | }); 30 | }); 31 | }); -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Polytunes 2 | Polytunes is a tool that allows you to liberate your music from third party services by enabling you to freely move your music from services. 3 | 4 | ### Support 5 | The following services are support by polytunes. 6 | 7 | | Service | Import From | Import To | 8 | |---------|:-------------:|:-----------:| 9 | | Apple Music | :white_check_mark: | | 10 | | Spotify | :white_check_mark: | :white_check_mark: | 11 | 12 | ### Usage 13 | Polytunes is a command line tool the runs on top of Node.js v6.0.0 and above: 14 | 15 | $ npm install -g polytunes 16 | 17 | To move you library from Apple Music to Spotify, grab a tea and run: 18 | 19 | $ polytunes --from apple --to spotify 20 | [*] Collecting libraries.. 21 | [*] Importing from your Apple library to your Spotify library. 22 | [*] Importing Apple playlist: 'Music' 23 | -> Track 'Sometimes I Feel So Deserted' by The Chemical Brothers imported to Spotify Library. 24 | -> Track 'The Hills' by The Weeknd imported to Spotify Library. 25 | -> Track 'Dip' by Danny Brown imported to Spotify Library. 26 | -! No match for track on 'Drive Me Crazy (feat. Vic Mensa)' by KAYTRANADA from Apple Library playlist on Spotify. 27 | 28 | The above command requires you to configure the *drivers* to talk to the services, see [Configuring Drivers](https://github.com/adriancooney/polytunes/wiki/Drivers). 29 | 30 | -------------------------------------------------------------------------------- /test/Spotify.js: -------------------------------------------------------------------------------- 1 | const SpotifyAPI = require("spotify-web-api-node"); 2 | const Track = require("../src/library/Track"); 3 | const Playlist = require("../src/library/Playlist"); 4 | const SpotifyLibrary = require("../src/drivers/Spotify"); 5 | 6 | const SPOTIFY_CLIENT_ID = process.env.SPOTIFY_CLIENT_ID; 7 | const SPOTIFY_CLIENT_SECRET = process.env.SPOTIFY_CLIENT_SECRET; 8 | const SPOTIFY_ACCESS_TOKEN = process.env.SPOTIFY_ACCESS_TOKEN; 9 | const SPOTIFY_REFRESH_TOKEN = process.env.SPOTIFY_REFRESH_TOKEN; 10 | 11 | var library; 12 | describe("Spotify", () => { 13 | before(() => { 14 | var api = new SpotifyAPI({ 15 | clientId: SPOTIFY_CLIENT_ID, 16 | clientSecret: SPOTIFY_CLIENT_SECRET, 17 | refreshToken: SPOTIFY_REFRESH_TOKEN 18 | }); 19 | 20 | return api.refreshAccessToken() 21 | .then(data => { 22 | api.setAccessToken(data.body.access_token); 23 | 24 | return SpotifyLibrary.fromAPI(api) 25 | }).then(lib => library = lib); 26 | }); 27 | 28 | describe("Library", () => { 29 | describe("#addTrack", () => { 30 | it("should add a track to a playlist", () => { 31 | const track = new Track("Alright", "Kendrick Lamar"); 32 | const playlist = library.playlists[Object.keys(library.playlists)[0]]; 33 | 34 | return playlist.addTrack(track); 35 | }); 36 | }); 37 | 38 | describe("#createPlaylist", () => { 39 | it("should add a track to a playlist", () => { 40 | const playlist = new Playlist("Hello world!"); 41 | 42 | return library.addPlaylist(playlist); 43 | }); 44 | }); 45 | }); 46 | 47 | describe("Playlist", () => { 48 | describe("#getTracks", () => { 49 | it("should get tracks for a playlist", () => { 50 | const playlist = library.playlists[Object.keys(library.playlists)[0]]; 51 | return playlist.getTracks(); 52 | }); 53 | }); 54 | }); 55 | }); -------------------------------------------------------------------------------- /src/bin/drivers.js: -------------------------------------------------------------------------------- 1 | const _ = require("lodash"); 2 | const drivers = require("../drivers"); 3 | 4 | const SPOTIFY_ENV = { 5 | clientId: "SPOTIFY_CLIENT_ID", 6 | clientSecret: "SPOTIFY_CLIENT_SECRET", 7 | refreshToken: "SPOTIFY_REFRESH_TOKEN" 8 | }; 9 | 10 | const APPLE_ENV = { 11 | appleLibrary: "APPLE_LIBRARY" 12 | }; 13 | 14 | function addCommandOptions(program) { 15 | program 16 | .option("--apple-library ", "The path to your apple library (override APPLE_LIBRARY env variable).") 17 | .option("--spotify-client-id ", "Specify the Spotify client ID (override SPOTIFY_CLIENT_ID).") 18 | .option("--spotify-client-secret ", "Specify the Spotify client secret (override SPOTIFY_CLIENT_SECRET).") 19 | .option("--spotify-refresh-token ", "Specify the Spotify client refresh token (override SPOTIFY_REFRESH_TOKEN)."); 20 | } 21 | 22 | function getLibrary(type, configuration) { 23 | switch(type) { 24 | case "spotify": 25 | return drivers.Spotify.fromCredentials(withEnv(SPOTIFY_ENV, configuration)); 26 | 27 | case "apple": 28 | const config = withEnv(APPLE_ENV, configuration); 29 | 30 | if(!config.appleLibrary) 31 | return Promise.reject(new Error( 32 | `Please specify path iTunes library export (xml) with the --apple-library flag.\n` + 33 | `To Export your iTunes library, go File > Library > Export Library (as XML).` 34 | )); 35 | 36 | return drivers.Apple.importFromFile(config.appleLibrary); 37 | 38 | default: 39 | return Promise.reject(new Error(`Unknown driver ${type}.`)); 40 | } 41 | } 42 | 43 | function withEnv(env, overrides) { 44 | return _.merge(pickFromEnv(env), overrides); 45 | } 46 | 47 | function pickFromEnv(vars) { 48 | return Object.keys(vars).reduce((env, key) => { 49 | env[key] = process.env[vars[key]]; 50 | return env; 51 | }, {}); 52 | } 53 | 54 | module.exports = { 55 | getLibrary, 56 | addCommandOptions 57 | }; -------------------------------------------------------------------------------- /src/drivers/Apple.js: -------------------------------------------------------------------------------- 1 | const fs = require("fs"); 2 | const PlistParser = require("plist-parser").PlistParser; 3 | const debug = require("debug")("library:apple"); 4 | 5 | const Library = require("../library/Library"); 6 | const Playlist = require("../library/Playlist"); 7 | const Track = require("../library/Track"); 8 | 9 | class AppleLibrary extends Library { 10 | getVendorName() { 11 | return "Apple"; 12 | } 13 | 14 | static importFromFile(path) { 15 | const library = new AppleLibrary(); 16 | 17 | // TODO: Stream! 18 | return AppleLibrary.parsePlist(path).then(contents => { 19 | const { Tracks, Playlists } = contents; 20 | 21 | // Grab the tracks and add to the root playlist 22 | const trackIndex = Object.keys(Tracks).reduce((index, track) => { 23 | index[track] = AppleTrack.fromItunes(Tracks[track]); 24 | return index; 25 | }, {}); 26 | 27 | // Grab the playlists 28 | Playlists.map(playlist => { 29 | const items = playlist["Playlist Items"]; 30 | 31 | if(!items || items.length === 0) 32 | return debug("Ignoring playlist: %s (empty)", playlist.Name); 33 | 34 | const applePlaylist = ApplePlaylist.fromItunes(playlist); 35 | debug("Creating playlist: %s", playlist.Name); 36 | 37 | // Find the track. 38 | items.forEach(track => { 39 | const trackId = track["Track ID"]; 40 | const selectedTrack = trackIndex[trackId]; 41 | 42 | if(selectedTrack) { 43 | debug("Adding track %d to playlist %s", trackId, playlist.Name); 44 | // Add the tracks 45 | applePlaylist.addTrack(selectedTrack); 46 | } else { 47 | debug("Unable to locate track from playlist '%s' with ID: %s", playlist.Name, trackId); 48 | } 49 | }); 50 | 51 | // Save the playlist 52 | library.addPlaylist(applePlaylist); 53 | }); 54 | 55 | // Voila, our apple library 56 | return library; 57 | }); 58 | } 59 | 60 | static parsePlist(path) { 61 | return new Promise((resolve, reject) => { 62 | fs.readFile(path, "utf8", (err, data) => { 63 | if(err) return reject(err); 64 | 65 | const parser = new PlistParser(data); 66 | 67 | // Huh, tried like 5 plist parsers and this one worked the best. 68 | resolve(parser.parse()); 69 | }) 70 | }); 71 | } 72 | 73 | getPlaylists() { 74 | return Promise.resolve(super.getPlaylists()); 75 | } 76 | } 77 | 78 | 79 | class ApplePlaylist extends Playlist { 80 | static fromItunes(data) { 81 | const playlist = new ApplePlaylist(data.Name); 82 | playlist.trackCount = data["Playlist Items"].length; 83 | 84 | if(data.Description) 85 | playlist.description = data.Description; 86 | 87 | return playlist; 88 | } 89 | 90 | toString() { 91 | return `${this.name}${ this.description ? " - " + this.description : ""} (${this.trackCount} tracks).`; 92 | } 93 | } 94 | 95 | class AppleTrack extends Track { 96 | static fromItunes(data) { 97 | // Trim all the values 98 | data = Object.keys(data).reduce((trimmed, key) => { 99 | const value = data[key]; 100 | trimmed[key] = typeof value === "string" ? value.trim() : value; 101 | return trimmed; 102 | }, {}); 103 | 104 | return new Track(data.Name, data.Artist, data.Album); 105 | } 106 | } 107 | 108 | module.exports = AppleLibrary; -------------------------------------------------------------------------------- /bin/polytunes-import: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | const program = require("commander"); 4 | const drivers = require("../src/bin/drivers"); 5 | const logger = require("../src/bin/logger"); 6 | 7 | const Importer = require("../src/Importer"); 8 | 9 | // Add the driver configuration options 10 | drivers.addCommandOptions(program); 11 | 12 | program 13 | .option("--from ", "The service to import the music from") 14 | .option("--to ", "The service to import the music to.") 15 | .usage("--from --to [playlists]") 16 | .arguments("[playlists]"); 17 | 18 | function doImport(playlistsToImport) { 19 | ["to", "from"].forEach(imp => { 20 | if(!program[imp]) 21 | logger.fail(new Error(`Please specify the service to import the music ${imp} using the --${imp} option. ` + 22 | "See `list-drivers` for a list of available services.")); 23 | }) 24 | 25 | logger.logAction("Collecting libraries.."); 26 | 27 | Promise.all([ 28 | drivers.getLibrary(program.from, program), 29 | drivers.getLibrary(program.to, program) 30 | ]).then(([fromLibrary, toLibrary]) => { 31 | const importer = new Importer(fromLibrary, toLibrary); 32 | 33 | importer.on("playlist:importing", playlist => { 34 | logger.logAction("Importing %s playlist: '%s'", fromLibrary.getVendorName(), playlist.name); 35 | }); 36 | 37 | importer.on("track:imported", (playlist, track) => { 38 | logger.log(" -> Track '%s' by %s imported to %s playlist '%s'.", track.name, track.artist, toLibrary.getVendorName(), playlist.name); 39 | }); 40 | 41 | importer.on("playlist:imported", (playlist, stats) => { 42 | logger.log(" >> %s playlist %s imported to %s (%d duplicates, %d unmatched).", fromLibrary.getVendorName(), playlist.name, toLibrary.getVendorName(), stats.duplicates, stats.unmatchedTracks.length); 43 | }); 44 | 45 | importer.on("playlist:nomatch", (playlist, track) => { 46 | logger.log(" -! No match for track on '%s' by %s from %s %s playlist on %s.", track.name, track.artist, fromLibrary.getVendorName(), playlist.name, toLibrary.getVendorName()); 47 | }); 48 | 49 | importer.on("playlist:duplicate", (playlist, track) => { 50 | logger.log(" -+ Track '%s' by %s already exists in %s %s playlist, ignoring.", track.name, track.artist, toLibrary.getVendorName(), playlist.name); 51 | }); 52 | 53 | // Check if each playlists exists 54 | if(playlistsToImport) { 55 | playlistsToImport = playlistsToImport.split(" ").map(s => s.trim()).filter(s => s); 56 | return fromLibrary.getPlaylists().then(playlists => { 57 | playlistsToImport = playlistsToImport.map(pl => { 58 | const targetPlaylist = fromLibrary.getPlaylist(pl); 59 | 60 | if(!targetPlaylist) 61 | throw new Error(`Unknown playlist ${pl} in ${fromLibrary.getVendorName()} library.`); 62 | 63 | return targetPlaylist; 64 | }); 65 | 66 | logger.logAction("Importing %d playlists from your %s library to your %s library.", playlistsToImport.length, fromLibrary.getVendorName(), toLibrary.getVendorName()) 67 | return importer.importPlaylists(playlistsToImport); 68 | }); 69 | } else { 70 | logger.logAction("Importing from your %s library to your %s library.", fromLibrary.getVendorName(), toLibrary.getVendorName()); 71 | return importer.import(); 72 | } 73 | }).then(stats => { 74 | logger.logComplete("Import complete. Imported %d of %d songs to %d %s playlists (ignored %d duplicates).", 75 | stats.totalImportedTracks, stats.totalTracks, stats.playlistCount, stats.to.getVendorName(), stats.totalDuplicates); 76 | 77 | if(stats.totalUnmatched > 0) { 78 | logger.logWarning("Some tracks could not be matched on %s.", stats.to.getVendorName()); 79 | stats.playlists.forEach(({ playlist, unmatchedTracks }) => { 80 | logger.log(" >> %s playlist %s:", stats.from.getVendorName(), playlist.name); 81 | unmatchedTracks.forEach(track => { 82 | logger.log(" -! %s track '%s' by %s.", stats.from.getVendorName(), track.name, track.artist); 83 | }); 84 | }) 85 | } 86 | }).catch(logger.fail); 87 | } 88 | 89 | program.action(doImport); 90 | program.parse(process.argv); 91 | 92 | if(program.args.length === 0) 93 | doImport(); -------------------------------------------------------------------------------- /src/Importer.js: -------------------------------------------------------------------------------- 1 | const debug = require("debug")("library:importer") 2 | const EventEmitter = require("events").EventEmitter; 3 | const Library = require("./library/Library"); 4 | const Playlist = require("./library/Playlist"); 5 | 6 | const THROTTLE = 250; 7 | 8 | class Importer extends EventEmitter { 9 | constructor(from, target) { 10 | super(); 11 | this.from = from; 12 | this.target = target; 13 | } 14 | 15 | import() { 16 | debug("Import %s library to %s.", this.from.getVendorName(), this.target.getVendorName()); 17 | return this.from.getPlaylists().then(this.importPlaylists.bind(this)); 18 | } 19 | 20 | importPlaylists(playlists) { 21 | const playlistStats = []; 22 | 23 | return playlists.reduce((acc, playlist) => { 24 | return acc.then(this.importPlaylist.bind(this, playlist)) 25 | .then(stat => playlistStats.push(stat)); 26 | }, Promise.resolve()).then(() => { 27 | const stats = playlistStats.reduce((total, stat) => { 28 | total.totalImportedTracks += stat.importedTracks; 29 | total.totalDuplicates += stat.duplicates; 30 | total.totalTracks += stat.totalTracks; 31 | total.totalUnmatched += stat.unmatchedTracks.length; 32 | 33 | return total; 34 | } , { totalImportedTracks: 0, totalDuplicates: 0, totalTracks: 0, totalUnmatched: 0 }); 35 | 36 | stats.playlists = playlistStats; 37 | stats.playlistCount = playlists.length; 38 | stats.to = this.target; 39 | stats.from = this.from; 40 | 41 | debug("Imported %d of %d songs to %d playlists.", stats.totalImportedTracks, stats.totalTracks, stats.playlistCount); 42 | 43 | stats.playlists.forEach(({ playlist, importedTracks, unmatchedTracks }) => { 44 | debug(" %s playlist (%s) has %d unmatched tracks:", playlist.name, playlist.id, unmatchedTracks.length); 45 | 46 | unmatchedTracks.forEach(track => { 47 | debug(" -> %s by %s", track.name, track.artist); 48 | }); 49 | }); 50 | 51 | return stats; 52 | }); 53 | } 54 | 55 | importPlaylist(playlist) { 56 | debug("Importing playlist '%s' to %s library (%d tracks).", playlist.name, this.target.getVendorName(), playlist.tracks.length); 57 | this.emit("playlist:importing", playlist); 58 | 59 | let targetPlaylist = this.target.getPlaylist(playlist.name); 60 | 61 | if(!targetPlaylist) { 62 | targetPlaylist = this.target.addPlaylist(playlist); 63 | } else { 64 | targetPlaylist = Promise.resolve(targetPlaylist); 65 | } 66 | 67 | return targetPlaylist.then(targetPlaylist => { 68 | // Get all the tracks for this playlist so we don't add duplicated 69 | return Promise.all([targetPlaylist, targetPlaylist.getTracks()]); 70 | }).then(([targetPlaylist]) => { 71 | const tracks = playlist.getTracks(); 72 | const stats = { 73 | playlist: targetPlaylist, 74 | importedTracks: 0, 75 | totalTracks: tracks.length, 76 | duplicates: 0, 77 | unmatchedTracks: [] 78 | }; 79 | 80 | // Loop over each track in the playlist 81 | return tracks.reduce((acc, track) => { 82 | return acc 83 | .then(() => new Promise(resolve => setTimeout(resolve, THROTTLE))) // Throttle a little 84 | .then(this.importTrack.bind(this, targetPlaylist, track)) 85 | .then(() => { 86 | // Update the matched count 87 | stats.importedTracks++; 88 | }).catch(error => { 89 | // Push an unmatched track 90 | if(error instanceof Library.TrackNotFound) { 91 | debug("No match! Track '%s' by %s not found on import, skipping.", track.name, track.artist); 92 | this.emit("playlist:nomatch", targetPlaylist, track); 93 | stats.unmatchedTracks.push(track); 94 | } else if(error instanceof Playlist.TrackAlreadyExists) { 95 | debug("Track '%s' by %s already exists in playlist %s, ignoring.", track.name, track.artist, playlist.name); 96 | this.emit("playlist:duplicate", targetPlaylist, track); 97 | stats.duplicates++; 98 | } else { 99 | return Promise.reject(error); 100 | } 101 | }); 102 | }, Promise.resolve()).then(() => { 103 | this.emit("playlist:imported", targetPlaylist, stats); 104 | 105 | return stats; 106 | }); 107 | }); 108 | } 109 | 110 | importTrack(targetPlaylist, track) { 111 | debug("Importing track '%s' to %s playlist '%s'.", track.name, this.target.getVendorName(), targetPlaylist.name); 112 | return targetPlaylist.addTrack(track).then(this.emit.bind(this, "track:imported", targetPlaylist, track)); 113 | } 114 | } 115 | 116 | module.exports = Importer; -------------------------------------------------------------------------------- /src/drivers/Spotify.js: -------------------------------------------------------------------------------- 1 | const SpotifyAPI = require("spotify-web-api-node"); 2 | const debug = require("debug")("library:spotify"); 3 | const _ = require("lodash"); 4 | 5 | const Library = require("../library/Library"); 6 | const Playlist = require("../library/Playlist"); 7 | const Track = require("../library/Track"); 8 | 9 | const VENDOR_NAME = "Spotify"; 10 | const PAGE_SIZE = 100; // Playlist track page size 11 | 12 | class SpotifyLibrary extends Library { 13 | constructor(api) { 14 | super(); 15 | this.api = api; 16 | } 17 | 18 | getVendorName() { 19 | return VENDOR_NAME; 20 | } 21 | 22 | addPlaylist(playlist, options = { public: false }) { 23 | if(playlist instanceof SpotifyPlaylist) 24 | return super.addPlaylist(playlist); 25 | 26 | debug("Creating new playlist on Spotify: %s", playlist.name); 27 | return this.api.createPlaylist(this.api.user.id, playlist.name, options).then(data => { 28 | return super.addPlaylist(SpotifyPlaylist.fromAPI(data.body, this.api)); 29 | }); 30 | } 31 | 32 | static fromCredentials(credentials) { 33 | return SpotifyLibrary.fromAPI(new SpotifyAPI(credentials)); 34 | } 35 | 36 | static fromAPI(api) { 37 | return api.refreshAccessToken().then(data => { 38 | // Ensure we have a fresh access token 39 | api.setAccessToken(data.body.access_token); 40 | 41 | return api.getMe(); 42 | }).then(data => { 43 | // Set the user details on the API object 44 | api.user = data.body; 45 | 46 | return api.getUserPlaylists(api.user.id); 47 | }).then(data => { 48 | const library = new SpotifyLibrary(api); 49 | 50 | // Get all the playlists 51 | data.body.items.forEach(playlist => { 52 | library.addPlaylist(SpotifyPlaylist.fromAPI(playlist, api)); 53 | }); 54 | 55 | return library; 56 | }) 57 | } 58 | } 59 | 60 | class SpotifyPlaylist extends Playlist { 61 | constructor(name, id, api) { 62 | super(name); 63 | this.id = id; 64 | this.api = api; 65 | } 66 | 67 | addTrack(track, force = false) { 68 | // If it's already a spotify track, don't bother 69 | // searching for the ID. 70 | if(track instanceof SpotifyTrack) { 71 | return super.addTrack(track) 72 | } 73 | 74 | // Find the track in spotify's database 75 | debug("Searching for track '%s' by %s.", track.name, track.artist); 76 | return this.api.searchTracks(`artist:${track.artist} track:${track.name}`).then(data => { 77 | const tracks = data.body.tracks.items; 78 | 79 | // Not match! 80 | if(!tracks.length) 81 | return Promise.reject(new Library.TrackNotFound(track, VENDOR_NAME)); 82 | 83 | // Pick the first result as the track. Any reason for this? Let's hope 84 | // spotify has some common sense and returns the tracks in order or 85 | // relevance. 86 | const targetTrack = SpotifyTrack.fromAPI(tracks[0]); 87 | 88 | // Add the track if it's not already in the playlist (or if force is toggled) 89 | if(force || !this.hasTrack(targetTrack)) { 90 | // We have our spotify track, now add it the user's library 91 | // It's a real shame we have to have the username to create 92 | // a spotify playlist. Like, why? 93 | debug("Adding track '%s' to playlist %s (%s)", targetTrack.name, this.name, this.id); 94 | return this.api.addTracksToPlaylist(this.api.user.id, this.id, [targetTrack.uri]).then(() => { 95 | // Add it to the playlist 96 | super.addTrack(targetTrack); 97 | }); 98 | } else { 99 | // Fail because track already exists 100 | return Promise.reject(new Playlist.TrackAlreadyExists(targetTrack, this)); 101 | } 102 | }); 103 | } 104 | 105 | getTracks() { 106 | debug("Getting tracks for %s playlist (%s).", this.name, this.id); 107 | // Get the tracks the first time and get the total 108 | return this.getTracksPaged().then(data => { 109 | const total = data.body.total; 110 | 111 | debug("Playlist %s has %d tracks (%d pages)", this.name, total, Math.ceil(total/PAGE_SIZE)); 112 | 113 | // Merge the first tracks 114 | var tracks = data.body.items.map(item => SpotifyTrack.fromAPI(item.track)); 115 | var cursor = data.body.items.length; 116 | 117 | return (function page() { 118 | if(cursor < total) { 119 | return this.getTracksPaged(PAGE_SIZE, cursor).then(data => { 120 | // Update the cursor 121 | cursor += data.body.items.length; 122 | 123 | // Push the SpotifyTracks 124 | tracks = tracks.concat(data.body.items.map(item => SpotifyTrack.fromAPI(item.track))); 125 | }).then(page.bind(this)); 126 | } else { 127 | debug("Got %d of %d tracks for %s playlist (%s).", cursor, total, this.name, this.id); 128 | 129 | // Save the tracks 130 | this.addTracks(tracks); 131 | 132 | // Debug print some 133 | tracks.slice(18).forEach(track => { 134 | debug(" -> '%s' by %s (%s)", track.name, track.artist, track.id); 135 | }); 136 | 137 | if(tracks.length > 18) 138 | debug(" -> %d more..", tracks.length - 18); 139 | 140 | // Resolve the tracks 141 | return Promise.resolve(tracks); 142 | } 143 | }.bind(this))(); 144 | }); 145 | } 146 | 147 | getTracksPaged(limit = PAGE_SIZE, offset = 0) { 148 | debug("Getting tracks from %s playlist (%s) with limit = %d, offset = %d.", this.name, this.id, limit, offset); 149 | return this.api.getPlaylistTracks(this.api.user.id, this.id, { limit, offset }); 150 | } 151 | 152 | hasTrack(track) { 153 | if(!(track instanceof SpotifyTrack)) 154 | throw new Error("SpotifyPlaylist#hasTrack can only test if SpotifyTracks are in the playlist."); 155 | 156 | debug("Testing if %s playlist (%s) has track '%s' by %s (%s)", this.name, this.id, this.tracks.length, track.name, track.artist, track.id); 157 | return this.tracks.find(tr => { 158 | return track.id === tr.id 159 | }); 160 | } 161 | 162 | static fromAPI(data, api) { 163 | // { collaborative: false, 164 | // external_urls: [Object], 165 | // href: 'https://api.spotify.com/v1/users/adriancooney/playlists/3svlOvf9y6dsHt3x6ijZ2q', 166 | // id: '3svlOvf9y6dsHt3x6ijZ2q', 167 | // images: [Object], 168 | // name: 'Groovers', 169 | // owner: [Object], 170 | // public: true, 171 | // snapshot_id: 'ukqRUSOZdf7jhhD7pT9i4Kv5jRWY03gw9JXvcJX0OVuXeryMddwOsQfwU6lbtyEz', 172 | // tracks: [Object], 173 | // type: 'playlist', 174 | // uri: 'spotify:user:adriancooney:playlist:3svlOvf9y6dsHt3x6ijZ2q' } 175 | 176 | const playlist = new SpotifyPlaylist(data.name, data.id, api); 177 | 178 | // Add the fields 179 | _.without(Object.keys(data), "tracks", "id", "name") 180 | .forEach(key => playlist[key] = data[key]); 181 | 182 | return playlist; 183 | } 184 | 185 | toString() { 186 | const flags = [this.id]; 187 | 188 | if(this.public) flags.push("public"); 189 | if(this.collaborative) flags.push("collaborative"); 190 | 191 | return `${this.name} created by ${this.owner.id}. (${flags.join(", ")})`; 192 | } 193 | } 194 | 195 | class SpotifyTrack extends Track { 196 | static fromAPI(data) { 197 | const track = new SpotifyTrack(); 198 | 199 | // Literally everything returned from the spotify API suits 200 | // the style: 201 | // { album: 202 | // { album_type: 'album', 203 | // available_markets: [Object], 204 | // external_urls: [Object], 205 | // href: 'https://api.spotify.com/v1/albums/7ycBtnsMtyVbbwTfJwRjSP', 206 | // id: '7ycBtnsMtyVbbwTfJwRjSP', 207 | // images: [Object], 208 | // name: 'To Pimp A Butterfly', 209 | // type: 'album', 210 | // uri: 'spotify:album:7ycBtnsMtyVbbwTfJwRjSP' }, 211 | // artists: 212 | // [ [ { external_urls: [Object], 213 | // href: 'https://api.spotify.com/v1/artists/2YZyLoL8N0Wb9xBt1NhZWg', 214 | // id: '2YZyLoL8N0Wb9xBt1NhZWg', 215 | // name: 'Kendrick Lamar', 216 | // type: 'artist', 217 | // uri: 'spotify:artist:2YZyLoL8N0Wb9xBt1NhZWg' } ], ], 218 | // available_markets: [ 'CA', 'MX', 'US' ], 219 | // disc_number: 1, 220 | // duration_ms: 219333, 221 | // explicit: true, 222 | // external_ids: { isrc: 'USUM71502498' }, 223 | // external_urls: { spotify: 'https://open.spotify.com/track/3iVcZ5G6tvkXZkZKlMpIUs' }, 224 | // href: 'https://api.spotify.com/v1/tracks/3iVcZ5G6tvkXZkZKlMpIUs', 225 | // id: '3iVcZ5G6tvkXZkZKlMpIUs', 226 | // name: 'Alright', 227 | // popularity: 77, 228 | // preview_url: 'https://p.scdn.co/mp3-preview/8e5d16461e73339eec796b5b7a2d72297154bafd', 229 | // track_number: 7, 230 | // type: 'track', 231 | // uri: 'spotify:track:3iVcZ5G6tvkXZkZKlMpIUs' } 232 | 233 | Object.keys(data).forEach(key => track[key] = data[key]); 234 | 235 | return track; 236 | } 237 | 238 | get artist() { 239 | return this._artist || this.artists.map(artist => artist.name).join(" & "); 240 | } 241 | 242 | set artist(value) { 243 | this._artist = value; 244 | } 245 | } 246 | 247 | module.exports = SpotifyLibrary; --------------------------------------------------------------------------------