diff --git a/Dockerfile.dev b/Dockerfile.dev new file mode 100644 index 0000000..2edafe2 --- /dev/null +++ b/Dockerfile.dev @@ -0,0 +1,19 @@ +FROM node:8.7.0-alpine + +WORKDIR /app + +RUN apk add --update git + +COPY package.json package.json +COPY yarn.lock yarn.lock + +RUN yarn + +COPY . . + +RUN yarn run build + +ENV PORT 3000 +EXPOSE 3000 + +CMD ["yarn", "start"] diff --git a/Makefile b/Makefile index 11029a8..71bc38b 100644 --- a/Makefile +++ b/Makefile @@ -32,5 +32,5 @@ docker-compose-up: ## Start (and create) docker containers docker-compose up -d .PHONY: yarn -yarn: ## Migrate database schema +yarn: ## Update yarn dependencies docker-compose run --rm app yarn diff --git a/docker-compose.yml b/docker-compose.yml index f3d0bc3..4e731f0 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -15,6 +15,9 @@ services: YOUTUBE_KEY: SPOTIFY_CLIENT_ID: SPOTIFY_CLIENT_SECRET: + AWS_ACCESS_KEY_ID: + AWS_SECRET_ACCESS_KEY: + AWS_TAG: volumes: - ./:/app:cached ports: @@ -34,6 +37,9 @@ services: YOUTUBE_KEY: SPOTIFY_CLIENT_ID: SPOTIFY_CLIENT_SECRET: + AWS_ACCESS_KEY_ID: + AWS_SECRET_ACCESS_KEY: + AWS_TAG: volumes: - ./:/app:cached command: yarn run worker diff --git a/lib/lookup.js b/lib/lookup.js index fc4361b..3218851 100644 --- a/lib/lookup.js +++ b/lib/lookup.js @@ -13,7 +13,6 @@ fs.readdirSync(path.join(__dirname, 'services')).forEach(function(file) { export default function* (url) { let matchedService; for (let service of services) { - console.log(service) matchedService = service.match(url); if (matchedService) { const result = yield service.parseUrl(url); diff --git a/lib/services/amazon/index.js b/lib/services/amazon/index.js new file mode 100644 index 0000000..f42dc05 --- /dev/null +++ b/lib/services/amazon/index.js @@ -0,0 +1,128 @@ +import { parse } from 'url'; +import { inspect } from 'util'; +import amazon from 'amazon-product-api'; +import urlMatch from './url'; + +const client = amazon.createClient({ + awsId: process.env.AWS_ACCESS_KEY_ID, + awsSecret: process.env.AWS_SECRET_ACCESS_KEY, + awsTag: process.env.AWS_TAG, +}); + +export function* lookupId(id, type) { + + const results = yield client.itemLookup({ + itemId: id, + responseGroup: 'ItemAttributes,Images,ItemIds', + }); + + const result = results[0]; + + if (!result) { + return { service: 'amazon' }; + } + + if (type === 'album') { + return { + service: 'amazon', + type: 'album', + id: result.ASIN[0], + name: result.ItemAttributes[0].Title[0], + streamUrl: result.DetailPageURL[0], + purchaseUrl: result.DetailPageURL[0], + artwork: { + small: result.SmallImage[0].URL[0], + large: result.LargeImage[0].URL[0], + }, + artist: { + name: result.ItemAttributes[0].Creator[0]._, + }, + }; + } else if (type === 'track') { + return { + service: 'amazon', + type: 'track', + id: result.ASIN[0], + name: result.ItemAttributes[0].Title[0], + streamUrl: result.DetailPageURL[0], + purchaseUrl: result.DetailPageURL[0], + artwork: { + small: result.SmallImage[0].URL[0], + large: result.LargeImage[0].URL[0], + }, + album: { + name: result.ItemAttributes[0], + }, + artist: { + name: result.ItemAttributes[0].Creator[0]._, + }, + }; + } + return { service: 'amazon' }; +} + +export function* search(data, original = {}) { + + const type = data.type; + const results = yield client.itemSearch({ + author: data.artist.name, + title: data.name, + searchIndex: 'MP3Downloads', + responseGroup: 'ItemAttributes,Tracks,Images,ItemIds', + }); + + const result = results[0]; + + if (result) { + if (type === 'album') { + return { + service: 'amazon', + type, + id: result.ASIN[0], + name: result.ItemAttributes[0].Title[0], + streamUrl: result.DetailPageURL[0], + purchaseUrl: result.DetailPageURL[0], + artwork: { + small: result.SmallImage[0].URL[0], + large: result.LargeImage[0].URL[0], + }, + artist: { + name: result.ItemAttributes[0].Creator[0]._, + }, + }; + } else if (type === 'track') { + return { + service: 'amazon', + type, + id: result.ASIN[0], + name: result.ItemAttributes[0].Title[0], + streamUrl: result.DetailPageURL[0], + purchaseUrl: result.DetailPageURL[0], + artwork: { + small: result.SmallImage[0].URL[0], + large: result.LargeImage[0].URL[0], + }, + artist: { + name: result.ItemAttributes[0].Creator[0]._, + }, + album: { + name: '', + }, + }; + } + } + + return { service: 'amazon' }; +} + +export function* parseUrl(url) { + const matches = parse(url).path.match(/\/(albums|tracks)[/]+([^?]+)/); + + if (matches && matches[2]) { + return { type: matches[1].substring(0, 5), id: matches[2] }; + } + throw new Error(); +} + +export const id = 'amazon'; +export const match = urlMatch; diff --git a/lib/services/amazon/url.js b/lib/services/amazon/url.js new file mode 100644 index 0000000..e31f5f7 --- /dev/null +++ b/lib/services/amazon/url.js @@ -0,0 +1,11 @@ +import { parse } from 'url'; + +export default function match(url) { + const parsed = parse(url); + if (!parsed.host.match(/\.amazon\.com$/)) { + return false; + } + + const matches = parse(url).path.match(/\/(albums)[/]+([^/]+)/); + return (matches && !!matches[2]); +} diff --git a/lib/services/deezer/index.js b/lib/services/deezer/index.js index 6c49193..706814b 100644 --- a/lib/services/deezer/index.js +++ b/lib/services/deezer/index.js @@ -18,7 +18,7 @@ export function parseUrl(url) { } function exactMatch(needle, haystack, type, various) { - // try to find exact match + // try to find exact match return haystack.find((entry) => { if (!entry[type] || (various && (entry.artist.name !== 'Various' || entry.artist.name !== 'Various Artists'))) { return false; @@ -32,7 +32,7 @@ function exactMatch(needle, haystack, type, various) { } function looseMatch(needle, haystack, type, various) { - // try to find exact match + // try to find exact match return haystack.find((entry) => { if (!entry[type] || (various && (entry.artist.name !== 'Various' || entry.artist.name !== 'Various Artists'))) { return false; diff --git a/lib/services/itunes/index.js b/lib/services/itunes/index.js index 3529764..e72ad3e 100644 --- a/lib/services/itunes/index.js +++ b/lib/services/itunes/index.js @@ -58,8 +58,8 @@ export function* lookupId(possibleId, type, countrycode) { streamUrl: null, purchaseUrl: result.collectionViewUrl, artwork: { - small: `${result.artworkUrl100.replace('100x100', '200x200').replace('.mzstatic.com', '-ssl.mzstatic.com').replace('http://', 'https://')}`, - large: `${result.artworkUrl100.replace('100x100', '600x600').replace('.mzstatic.com', '-ssl.mzstatic.com').replace('http://', 'https://')}`, + small: `${result.artworkUrl100.replace('100x100', '200x200').replace('.mzstatic.com', '.mzstatic.com').replace('http://', 'https://')}`, + large: `${result.artworkUrl100.replace('100x100', '600x600').replace('.mzstatic.com', '.mzstatic.com').replace('http://', 'https://')}`, }, artist: { name: result.artistName, @@ -120,8 +120,8 @@ export function* search(data) { streamUrl: result.collectionViewUrl, purchaseUrl: result.collectionViewUrl, artwork: { - small: `${result.artworkUrl100.replace('100x100', '200x200').replace('.mzstatic.com', '-ssl.mzstatic.com').replace('http://', 'https://')}`, - large: `${result.artworkUrl100.replace('100x100', '600x600').replace('.mzstatic.com', '-ssl.mzstatic.com').replace('http://', 'https://')}`, + small: `${result.artworkUrl100.replace('100x100', '200x200').replace('.mzstatic.com', '.mzstatic.com').replace('http://', 'https://')}`, + large: `${result.artworkUrl100.replace('100x100', '600x600').replace('.mzstatic.com', '.mzstatic.com').replace('http://', 'https://')}`, }, artist: { name: result.artistName, diff --git a/lib/services/xbox/index.js b/lib/services/xbox/index.js deleted file mode 100644 index bcb0519..0000000 --- a/lib/services/xbox/index.js +++ /dev/null @@ -1,147 +0,0 @@ -import { parse } from 'url'; -import request from 'superagent'; -import 'superagent-bluebird-promise'; -import debuglog from 'debug'; -import urlMatch from './url'; - -const debug = debuglog('combine.fm:xbox'); - -if (!process.env.XBOX_CLIENT_ID || !process.env.XBOX_CLIENT_SECRET) { - debug('XBOX_CLIENT_ID and XBOX_CLIENT_SECRET environment variables not found, deactivating Xbox Music.'); -} - -const credentials = { - clientId: process.env.XBOX_CLIENT_ID, - clientSecret: process.env.XBOX_CLIENT_SECRET, -}; - -const apiRoot = 'https://music.xboxlive.com/1/content'; - -function* getAccessToken() { - const authUrl = 'https://login.live.com/accesstoken.srf'; - const scope = 'app.music.xboxlive.com'; - const grantType = 'client_credentials'; - - const data = { - client_id: credentials.clientId, - client_secret: credentials.clientSecret, - scope, - grant_type: grantType, - }; - const result = yield request.post(authUrl) - .timeout(10000) - .send(data) - .set('Content-type', 'application/x-www-form-urlencoded') - .promise(); - return result.body.access_token; -} - -function formatResponse(match) { - const item = { - service: 'xbox', - type: match.Album ? 'track' : 'album', - id: match.Id, - name: match.Name, - streamUrl: match.Link, - purchaseUrl: null, - artwork: { - small: `${match.ImageUrl.replace('http://', 'https://')}&w=250&h=250`, - large: `${match.ImageUrl.replace('http://', 'https://')}&w=500&h=500`, - }, - artist: { - name: match.Artists[0].Artist.Name, - }, - }; - if (match.Album) { - item.album = { name: match.Album.Name }; - } - return item; -} - -function* apiCall(path) { - const accessToken = yield getAccessToken(); - return request.get(apiRoot + path).timeout(10000).set('Authorization', `Bearer ${accessToken}`).promise(); -} - -export function* lookupId(id, type) { - const path = `/${id}/lookup`; - const apiType = `${type.charAt(0).toUpperCase() + type.substr(1)}s`; - try { - const result = yield apiCall(path); - return formatResponse(result.body[apiType].Items[0]); - } catch (e) { - if (e.status !== 404) { - debug(e.body); - } - return { service: 'xbox' }; - } -} - -export function* parseUrl(url) { - const parsed = parse(url); - const parts = parsed.path.split('/'); - const type = parts[1]; - const idMatches = parts[4].match(/bz.[\w-]+/); - const id = idMatches[0].replace('bz.', 'music.'); - if (!id) { - return false; - } - return yield lookupId(id, type); -} - -function exactMatch(item, artist, haystack) { - // try to find exact match - return haystack.find((entry) => { - if (entry.Name === item && entry.Artists[0].Artist.Name === artist) { - return entry; - } - return false; - }); -} - -function looseMatch(item, artist, haystack) { - // try to find exact match - return haystack.find((entry) => { - if (entry.Name.indexOf(item) >= 0 && entry.Artists[0].Artist.Name.indexOf(artist) >= 0) { - return entry; - } - return false; - }); -} - -export function* search(data) { - function cleanParam(str) { - return str.replace(/[:?&()[\]]+/g, ''); - } - let query; - const type = data.type; - - if (type === 'album') { - query = `${cleanParam(data.artist.name.substring(0, data.artist.name.indexOf('&')))} ${cleanParam(data.name)}`; - } else if (type === 'track') { - query = `${cleanParam(data.artist.name.substring(0, data.artist.name.indexOf('&')))} ${cleanParam(data.name)}`; - } - - const name = data.name; - const path = `/music/search?q=${encodeURIComponent(query.trim())}&filters=${type}s`; - try { - const result = yield apiCall(path); - - const apiType = `${type.charAt(0).toUpperCase() + type.substr(1)}s`; - - let match = exactMatch(name, data.artist.name, result.body[apiType].Items, type); - if (!match) { - match = looseMatch(name, data.artist.name, result.body[apiType].Items, type); - } - - if (match) { - return formatResponse(match); - } - } catch (err) { - return { service: 'xbox' }; - } - return { service: 'xbox' }; -} - -export const id = 'xbox'; -export const match = urlMatch; diff --git a/lib/services/xbox/url.js b/lib/services/xbox/url.js deleted file mode 100644 index bae28d6..0000000 --- a/lib/services/xbox/url.js +++ /dev/null @@ -1,11 +0,0 @@ -import { parse } from 'url'; - -export default function match(url) { - const parsed = parse(url); - if (!parsed.host.match(/music.microsoft.com$/)) { - return false; - } - - const parts = parsed.path.split('/'); - return (parts[1] === 'album' || parts[1] === 'track') && parts[4]; -} diff --git a/lib/share.js b/lib/share.js index 7a504e8..d975c67 100644 --- a/lib/share.js +++ b/lib/share.js @@ -56,8 +56,6 @@ export function findMatchesAsync(share) { } co(function* gen() { // eslint-disable-line no-loop-func const match = yield service.search(share); - console.log(service.id) - console.log(match) if (match.id) { models.match.create({ trackId: share.$modelOptions.name.singular == 'track' ? share.id : null, diff --git a/models/album.js b/models/album.js index 040fc49..8352760 100644 --- a/models/album.js +++ b/models/album.js @@ -3,6 +3,7 @@ export default function (sequelize, DataTypes) { id: { type: DataTypes.INTEGER, primaryKey: true, autoIncrement: true }, externalId: { type: DataTypes.STRING(50), index: true }, // eslint-disable-line new-cap service: DataTypes.ENUM( // eslint-disable-line new-cap + 'amazon', 'deezer', 'google', 'itunes', diff --git a/models/match.js b/models/match.js index 89f7017..46359bb 100644 --- a/models/match.js +++ b/models/match.js @@ -5,6 +5,7 @@ export default function (sequelize, DataTypes) { albumId: DataTypes.INTEGER, externalId: { type: DataTypes.STRING(50), index: true }, // eslint-disable-line new-cap service: DataTypes.ENUM( // eslint-disable-line new-cap + 'amazon', 'deezer', 'google', 'itunes', diff --git a/models/track.js b/models/track.js index 5d9b50c..79e3f8f 100644 --- a/models/track.js +++ b/models/track.js @@ -3,6 +3,7 @@ export default function (sequelize, DataTypes) { id: { type: DataTypes.INTEGER, primaryKey: true, autoIncrement: true }, externalId: { type: DataTypes.STRING(50), index: true }, // eslint-disable-line new-cap service: DataTypes.ENUM( // eslint-disable-line new-cap + 'amazon', 'deezer', 'google', 'itunes', diff --git a/package.json b/package.json index a86026e..6391cb6 100644 --- a/package.json +++ b/package.json @@ -18,6 +18,7 @@ "node": "^8.6.0" }, "dependencies": { + "amazon-product-api": "^0.4.4", "apple-music-jwt": "^0.1.2", "babel": "^6.1.18", "babel-cli": "^6.26.0", diff --git a/public/assets/images/amazon.png b/public/assets/images/amazon.png new file mode 100644 index 0000000..bdf648e Binary files /dev/null and b/public/assets/images/amazon.png differ diff --git a/public/src/store/index.js b/public/src/store/index.js index 812386d..3be6c93 100644 --- a/public/src/store/index.js +++ b/public/src/store/index.js @@ -16,12 +16,10 @@ const store = new Vuex.Store({ actions: { // ensure data for rendering given list type FETCH_RECENTS: ({ commit }) => fetchRecents() - .then(res => commit('SET_RECENTS', { recents: res.body.recents })), + .then(res => commit('SET_RECENTS', { recents: res.body.recents })), - FETCH_ITEM: ({ commit, state }, { service, type, id }) => fetchItem(service, type, id) - .then(item => { - return commit('SET_ITEM', { item }) - }), + FETCH_ITEM: ({ commit }, { service, type, id }) => fetchItem(service, type, id) + .then(item => commit('SET_ITEM', { item })), }, mutations: { @@ -30,7 +28,7 @@ const store = new Vuex.Store({ }, SET_ITEM: (state, { item }) => { - state.item = item.body; + state.item = item.body; // eslint-disable-line }, }, }); diff --git a/public/src/views/index.vue b/public/src/views/index.vue index e1ae1ad..f04ad1e 100644 --- a/public/src/views/index.vue +++ b/public/src/views/index.vue @@ -56,7 +56,7 @@ import search from '../components/search.vue'; export default { name: 'index-view', components: { search }, - created () { + created() { // fetch the data when the view is created and the data is // already being observed this.fetch(); @@ -68,16 +68,15 @@ export default { }; }, watch: { - '$route': 'fetch', - recents: function () { + $route: 'fetch', + recents() { if (typeof document !== 'undefined') { - const recents = this.$store.state.recents; - document.title = `Combine.fm • Share Music`; + document.title = 'Combine.fm • Share Music'; } }, }, methods: { - fetch () { + fetch() { if (!this.$store.state.recents) { fetchRecents().then((res) => { this.recents = res.body.recents; @@ -87,7 +86,7 @@ export default { } }, }, -} +};