├── .gitignore ├── .jshintrc ├── .npmignore ├── LICENSE ├── README.md ├── __tests__ └── bandcamp-test.js ├── index.js ├── lib └── bandcamp.js └── package.json /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | 5 | # Runtime data 6 | pids 7 | *.pid 8 | *.seed 9 | 10 | # Directory for instrumented libs generated by jscoverage/JSCover 11 | lib-cov 12 | 13 | # Coverage directory used by tools like istanbul 14 | coverage 15 | 16 | # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) 17 | .grunt 18 | 19 | # Compiled binary addons (http://nodejs.org/api/addons.html) 20 | build/Release 21 | 22 | # Dependency directory 23 | # Commenting this out is preferred by some people, see 24 | # https://www.npmjs.org/doc/misc/npm-faq.html#should-i-check-my-node_modules-folder-into-git- 25 | node_modules 26 | 27 | # Users Environment Variables 28 | .lock-wscript 29 | -------------------------------------------------------------------------------- /.jshintrc: -------------------------------------------------------------------------------- 1 | { 2 | "node": true, 3 | "jasmine": true, 4 | "browser": true, 5 | "esnext": true, 6 | "bitwise": true, 7 | "curly": true, 8 | "eqeqeq": true, 9 | "immed": true, 10 | "indent": 2, 11 | "latedef": true, 12 | "noarg": true, 13 | "regexp": true, 14 | "undef": true, 15 | "unused": true, 16 | "strict": true, 17 | "trailing": true, 18 | "smarttabs": true, 19 | "newcap": false, 20 | "predef": ["jest"] 21 | } -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | __tests__ -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2015 Jake Marsh 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | 23 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | node-bandcamp [![npm version](https://badge.fury.io/js/node-bandcamp.svg)](http://badge.fury.io/js/node-bandcamp) 2 | ================================================================================================================= 3 | 4 | node.js (unofficial) Bandcamp API. 5 | 6 | --- 7 | 8 | ### Getting Started 9 | 10 | 1. `npm install --save node-bandcamp` 11 | 2. `var bandcamp = require('node-bandcamp')` 12 | 13 | --- 14 | 15 | ### Searching for tracks 16 | 17 | Specific tracks can be searched for using the `trackSearch` method. This function takes the following parameters: 18 | 19 | - `query` (str): The search query for which you want results. 20 | - `limit` (defaults to 20): The maximum number of results you want returned. Defaults to `20`. 21 | 22 | The function will recursively query Bandcamp until results are exhausted or the limit is hit. 23 | 24 | ```javascript 25 | var bandcamp = require('node-bandcamp'); 26 | 27 | bandcamp.trackSearch('tibetan pop stars', 30).then(function(results){ 28 | // do something with search results 29 | }).catch(function(err) { 30 | // handle error 31 | }); 32 | ``` 33 | 34 | Returns a `promise`, which will eventually resolve as an array of search results. Results are in the format: 35 | 36 | ```json 37 | [ 38 | { 39 | "title": "title", 40 | "album": "album name", 41 | "artist": "artist name", 42 | "image": "URL to track artwork", 43 | "url": "URL to be passed to getTrack method" 44 | }, 45 | ... 46 | ] 47 | 48 | ``` 49 | 50 | --- 51 | 52 | ### Streaming a track 53 | 54 | A specific track can be streamed using the `getTrack` method. This function takes just one parameter, the Bandcamp URL string retrieved using the previously discussed `trackSearch` method. 55 | 56 | ```javascript 57 | var bandcamp = require('node-bandcamp'); 58 | 59 | bandcamp.getTrack('http://hopalong.bandcamp.com/track/tibetan-pop-stars').then(function(stream) { 60 | // pipe audio stream to res, etc. 61 | }).catch(function(err) { 62 | // handle error 63 | }); 64 | ``` 65 | 66 | Returns a `promise`, which will eventually resolve as an audio stream. 67 | 68 | --- 69 | 70 | ### Getting track details 71 | 72 | The details for a specific track (title, duration, etc.) can be retrieved using the `getDetails` method. This function takes the Bandcamp URL string for the track as a parameter, and returns a `promise` which will eventually resolve as an object in the same format as search results: 73 | 74 | ```javascript 75 | var bandcamp = require('node-bandcamp'); 76 | 77 | bandcamp.getDetails('http://hopalong.bandcamp.com/track/tibetan-pop-stars').then(function(details) { 78 | // return details to res, etc. 79 | }).catch(function(err) { 80 | // handle error 81 | }); 82 | ``` 83 | 84 | and the promise resolves with an object in the format: 85 | ```json 86 | { 87 | "title": "title", 88 | "album": "album name", 89 | "artist": "artist name", 90 | "image": "URL to track artwork", 91 | "url": "URL to be passed to getTrack method" 92 | } 93 | ``` 94 | 95 | --- 96 | 97 | ### Testing 98 | 99 | All tests for this package are within the `__tests__/` directory. If you wish to run the tests: 100 | 101 | 1. `git clone git@github.com:jakemmarsh/node-bandcamp.git` 102 | 2. `cd node-bandcamp` 103 | 3. `npm install` 104 | 4. `npm test` 105 | -------------------------------------------------------------------------------- /__tests__/bandcamp-test.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | var bandcamp = require('node-bandcamp'); 4 | var isStream = require('isstream'); 5 | 6 | jest.dontMock('../lib/bandcamp.js'); 7 | 8 | describe('Bandcamp', function() { 9 | 10 | it('should return an array of search results given a query', function(done) { 11 | bandcamp.trackSearch('tibetan pop stars', 5).then(function(results) { 12 | expect(results.constructor === Array).toBeTruthy(); 13 | done(); 14 | }).catch(function(err) { 15 | expect(err).toBeUndefined(); 16 | done(); 17 | }); 18 | }); 19 | 20 | it('should have correctly structured search results', function(done) { 21 | bandcamp.trackSearch('tibetan pop stars', 5).then(function(results) { 22 | var firstResult = results[0] || {}; 23 | 24 | expect(firstResult.title).toBeDefined(); 25 | expect(firstResult.album).toBeDefined(); 26 | expect(firstResult.artist).toBeDefined(); 27 | expect(firstResult.image).toBeDefined(); 28 | expect(firstResult.url).toBeDefined(); 29 | 30 | done(); 31 | }).catch(function(err) { 32 | expect(err).toBeUndefined(); 33 | done(); 34 | }); 35 | }); 36 | 37 | it('should return an audio stream given a track URL', function(done) { 38 | bandcamp.getTrack('http://hopalong.bandcamp.com/track/tibetan-pop-stars').then(function(stream) { 39 | expect(isStream(stream)).toBeTruthy(); 40 | done(); 41 | }).catch(function(err) { 42 | expect(err).toBeUndefined(); 43 | done(); 44 | }); 45 | }); 46 | 47 | }); -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | module.exports = require('./lib/bandcamp'); -------------------------------------------------------------------------------- /lib/bandcamp.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | var when = require('when'); 4 | var qs = require('querystring'); 5 | var request = require('request'); 6 | var cheerio = require('cheerio'); 7 | 8 | module.exports = (function() { 9 | 10 | /* 11 | * Initialize the Bandcamp API. 12 | * 13 | * @constructor 14 | */ 15 | function Bandcamp() {} 16 | 17 | /* ====================================================== */ 18 | 19 | /* 20 | * Remove leading and trailing whitespace, 21 | * remove any newlines or returns, 22 | * then remove any extra spaces. 23 | * 24 | * @param {String} text 25 | * @return {String} text 26 | */ 27 | Bandcamp.prototype.formatText = function(text) { 28 | text = text.trim(); 29 | text = text.replace(/\r?\n|\r/g, ''); 30 | text = text.replace(/ +(?= )/g, ''); 31 | 32 | return text; 33 | }; 34 | 35 | /* ====================================================== */ 36 | 37 | /* 38 | * Web scraping must be used on bandcamp.com/search due to lack of public API. 39 | * 1. iterate over all results inside .results > .result-items 40 | * 2. only process results of class `searchresult track` 41 | * - title of track is a link inside div of class `heading` 42 | * - artist and album are inside div of class `subhead`, of the format 43 | * "from by " 44 | * - track URL is a link inside div of class `itemurl` 45 | * 46 | * This process is done recursively until the limit is hit. 47 | * 48 | * @param {String} searchQuery 49 | * @param {Number} pageNumber, defaulting to null 50 | * @param {Number} limit, defaulting to 20 51 | * @return {Promise} searchResults, defaulting to an empty array 52 | */ 53 | Bandcamp.prototype.trackSearch = function(query, limit, pageNumber, searchResults) { 54 | var deferred = when.defer(); 55 | var albumArtistRegex = /from (.+?) by (.+)/i; 56 | var searchUrl = 'http://bandcamp.com/search?'; 57 | var $; 58 | var subheadText; 59 | var imageUrl; 60 | var regexResult; 61 | var trackResult; 62 | 63 | limit = limit || 20; 64 | searchResults = searchResults || []; 65 | pageNumber = pageNumber || 1; 66 | 67 | searchUrl += qs.stringify({ 68 | q: query.replace(/(%20)|( )/gi, '+'), 69 | page: pageNumber > 1 ? pageNumber : null 70 | }); 71 | 72 | // retrieve and scrape Bandcamp search results page 73 | request(searchUrl, function(err, response, body){ 74 | if ( err ) { 75 | deferred.reject(err); 76 | } else { 77 | $ = cheerio.load(body); 78 | 79 | // process each search result 80 | if( $('.searchresult.track').length ) { 81 | $('.searchresult.track').each(function(index, element) { 82 | if ( searchResults.length < limit ) { 83 | subheadText = this.formatText($(element).find('.subhead').text()); 84 | imageUrl = $(element).find('.art').children('img').first()[0].attribs.src; 85 | regexResult = albumArtistRegex.exec(subheadText); 86 | 87 | trackResult = { 88 | title: this.formatText($(element).find('.heading').text()), 89 | album: regexResult ? regexResult[1] : null, 90 | artist: regexResult ? regexResult[2] : null, 91 | image: imageUrl, 92 | url: this.formatText($(element).find('.itemurl').text()) 93 | }; 94 | 95 | searchResults.push(trackResult); 96 | } 97 | }.bind(this)); 98 | 99 | // Recurse as long as there are still results and we aren't at our result limit 100 | if ( searchResults.length < limit ) { 101 | deferred.resolve(this.trackSearch(query, limit, pageNumber + 1, searchResults)); 102 | } 103 | } 104 | 105 | // If no more results, return the results we've collected 106 | deferred.resolve(searchResults); 107 | } 108 | }.bind(this)); 109 | 110 | return deferred.promise; 111 | }; 112 | 113 | /* ====================================================== */ 114 | 115 | /* 116 | * Retrieves the raw MP3 file via scraping for the Bandcamp track URL provided, 117 | * and returns it as a stream. 118 | * 119 | * @param {String} url 120 | * @return {Stream} audio 121 | */ 122 | Bandcamp.prototype.getTrack = function(url) { 123 | var deferred = when.defer(); 124 | var trackRegex = /{"mp3-128":"(.+?)"/ig; 125 | var urlResults; 126 | 127 | request(url, function(err, response, body) { 128 | if ( err ) { 129 | deferred.reject({ message: 'Unable to retrieve the MP3 file for the specified URL.' }); 130 | } else { 131 | urlResults = trackRegex.exec(body); 132 | 133 | if ( urlResults !== null ) { 134 | deferred.resolve(request.get(urlResults[1])); 135 | } else { 136 | deferred.reject({ message: 'Unable to retrieve the MP3 file for the specified URL.' }); 137 | } 138 | } 139 | }); 140 | 141 | return deferred.promise; 142 | }; 143 | 144 | /* ====================================================== */ 145 | 146 | /* 147 | * Retrieves the track details for the Bandcamp track URL provided. 148 | * 149 | * @param {String} url 150 | * @return {Promise} trackDetails 151 | */ 152 | Bandcamp.prototype.getDetails = function(url) { 153 | var deferred = when.defer(); 154 | var $; 155 | 156 | request(url, function(err, response, body) { 157 | if ( err ) { 158 | deferred.reject({ message: 'Unable to retrieve the Bandcamp page for the specified URL.' }); 159 | } else { 160 | $ = cheerio.load(body); 161 | 162 | deferred.resolve({ 163 | title: this.formatText($('h2[itemprop=name]').text()), 164 | album: this.formatText($('span[itemprop=inAlbum]').text()), 165 | artist: this.formatText($('span[itemProp=byArtist]').text()), 166 | image: $('img[itemprop=image]').first()[0].attribs.src, 167 | url: url 168 | }); 169 | } 170 | }.bind(this)); 171 | 172 | return deferred.promise; 173 | }; 174 | 175 | /* ====================================================== */ 176 | 177 | return new Bandcamp(); 178 | 179 | })(); -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "node-bandcamp", 3 | "version": "0.0.5", 4 | "author": "Jake Marsh ", 5 | "description": "An (unofficial) node.js API for Bandcamp.", 6 | "repository": { 7 | "type": "git", 8 | "url": "https://github.com/jakemmarsh/node-bandcamp.git" 9 | }, 10 | "private": false, 11 | "license": "MIT", 12 | "keywords": [ 13 | "node", 14 | "music", 15 | "bandcamp" 16 | ], 17 | "engines": { 18 | "node": ">=0.12.x" 19 | }, 20 | "dependencies": { 21 | "cheerio": "^0.18.0", 22 | "request": "^2.53.0", 23 | "when": "^3.7.2" 24 | }, 25 | "devDependencies": { 26 | "isstream": "^0.1.2", 27 | "jest-cli": "^0.4.0" 28 | }, 29 | "jest": { 30 | "rootDir": ".", 31 | "testPathDirs": [ 32 | "" 33 | ], 34 | "testDirectoryName": "__tests__", 35 | "testFileExtensions": [ 36 | "js" 37 | ], 38 | "unmockedModulePathPatterns": ["/node_modules/when"] 39 | }, 40 | "scripts": { 41 | "test": "jest" 42 | } 43 | } 44 | --------------------------------------------------------------------------------