diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..a70afb4 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,9 @@ +// Place your settings in this file to overwrite default and user settings. +{ + "files.associations": { + "*.src": "perl" + }, + "editor.wordWrap": "off", + "files.encoding": "utf8", + "files.autoGuessEncoding": false +} diff --git a/Cover Art#KKBox.src b/Cover Art#KKBox.src index ccd66a3..92fb37e 100755 --- a/Cover Art#KKBox.src +++ b/Cover Art#KKBox.src @@ -6,65 +6,68 @@ # ################################# [Name]=KKbox -[BasedOn]=http://www.kkbox.com -[IndexUrl]=http://www.kkbox.com/tw/tc/search.php?search=album&word=%s -[AlbumUrl]=http://www.kkbox.com -[WordSeperator]=+ -[IndexFormat]=%_url%|%album%|%artist% +[BasedOn]=http://localhost:3000/ +[IndexUrl]=http://localhost:3000/api/searchAlbum?keyword=%s +[AlbumUrl]=http://localhost:3000/api/albums?id= +[WordSeparator]=%20 +[IndexFormat]=%Artist%|%Album%|%_url%|%Year% [SearchBy]=%album% +[UserAgent]=1 [Encoding]=url-utf-8 - [ParserScriptIndex]=... -# ################################################################### -# I N D E X -# ################################################################### +# ############################## +# I N D E X # +# ############################## +debug "on" "c:\\debug_kkbox1.htm" + +json "ON" -# debug "on" "d:\\debug_kkbox1.htm" +json_select_object "albums" + json_foreach "data" -findline "class=\"search-group\"" + # Artist + json_select_object "artist" + json_select "name" + sayrest + json_unselect_object + say "|" -do - findline "class=\"album\"" - findline "class=\"cover\"" + # Album + json_select "name" + sayrest + say "|" - # Url - findinline "href=\"" - sayuntil "\"" - say "|" + # Url + json_select "id" + sayrest + say "|" - # Album - findinline "title=\"" - sayuntil "\"" - say "|" + # Year + json_select "release_date" + sayuntil "\"" - # Artist - findline "playlist-sharer" - findline "" -# sayuntil "" - -# CoverURL -outputto "coverurl" -findline "" -findinline ">" -sayuntil "<" - - -# rewind -gotoline 1 - -findline "class=\"media-tag" -findline "" -sayuntil "" -saynextnumber - -findline "song-list js-song-list" - -do - # Track - outputto "track temp" - findline "data-song_idx" - findinline "data-song_idx=\"" - saynextnumber - say "|" - - outputto "_length" - findline "class=\"time\"" - findinline ">" - sayuntil "" - sayuntil " (https://yukaii.tw)", + "license": "MIT", + "devDependencies": { + "serverless": "^1.26.0", + "serverless-offline": "^3.18.0", + "serverless-plugin-typescript": "^1.1.5" + }, + "dependencies": { + "@kkbox/kkbox-js-sdk": "^1.3.0", + "bluebird": "^3.5.1", + "redis": "^2.8.0" + } +} diff --git a/api-gateway/serverless.yml b/api-gateway/serverless.yml new file mode 100644 index 0000000..0a6475c --- /dev/null +++ b/api-gateway/serverless.yml @@ -0,0 +1,35 @@ +service: kkbox-api-gateway + +plugins: + - serverless-plugin-typescript + - serverless-offline + +provider: + name: aws + runtime: nodejs6.10 + stage: ${self:custom.config.stage} + region: ${self:custom.config.region, 'us-east-1'} + environment: + KKBOX_APP_ID: ${self:custom.config.kkbox_app_id, ''} + KKBOX_APP_SECRET: ${self:custom.config.kkbox_app_secret, ''} + REDIS_URL: ${self:custom.config.redis_url, ''} + usagePlan: + quota: + limit: 5000 + offset: 2 + period: MONTH + throttle: + burstLimit: 50 + rateLimit: 30 + +functions: + index: + handler: src/handler.index + events: + - http: + path: api/{id} + method: any + cors: true + +custom: + config: ${file(./config.yml)} diff --git a/api-gateway/src/api.ts b/api-gateway/src/api.ts new file mode 100644 index 0000000..b2ab78f --- /dev/null +++ b/api-gateway/src/api.ts @@ -0,0 +1,71 @@ +import { Auth, Api } from '@kkbox/kkbox-js-sdk' +import Store from './store' + +interface IAuthData { + access_token: string; + expires_in: number; +} + +class API { + private auth : Auth + private accessToken? : string + private store : Store | null + private apiClient : Api + + async initialize() { + this.accessToken = undefined + this.auth = new Auth(process.env.KKBOX_APP_ID, process.env.KKBOX_APP_SECRET) + this.store = new Store() + + await this.getAccessToken() + this.apiClient = new Api(this.accessToken) + } + + async getAccessToken() { + const TOKEN_KEY = 'kkbox_access_token' + const EXP_KEY = 'kkbox_token_expires_in' + + if (!this.accessToken) { + let token + + try { + const expires_in = await this.store.get(EXP_KEY) + token = await this.store.get(TOKEN_KEY) + + if (new Date() > new Date(expires_in)) { + // token expiration + token = undefined + } + } catch (e) {} + + if (typeof token === 'undefined' || token === null) { + const authData = await this.generateAccessToken() + token = authData.access_token + + await this.store.set(TOKEN_KEY, authData.access_token) + await this.store.set(EXP_KEY, authData.expires_in) + } + + this.accessToken = token + } + } + + async generateAccessToken() : Promise { + const { data: { access_token, expires_in } } = await this.auth.clientCredentialsFlow.fetchAccessToken() + return { access_token, expires_in } + } + + get client () { + return this.apiClient + } + + async dispose () { + await this.store.close() + this.store = null + + this.accessToken = undefined + this.apiClient = null + } +} + +export default new API() diff --git a/api-gateway/src/handler.ts b/api-gateway/src/handler.ts new file mode 100644 index 0000000..9be7494 --- /dev/null +++ b/api-gateway/src/handler.ts @@ -0,0 +1,95 @@ +import Api from './api' +const queryString = require('query-string'); + +function stringify(obj) { + return JSON.stringify(obj).replace(/\":/g, '": ').replace(/,\"/g, ', "') +} + +async function init () { + await Api.initialize() +} + +async function searchAlbum (query, callback) { + const { keyword } = query + const { data } = await Api.client.searchFetcher + .setSearchCriteria(keyword, 'album') + .fetchSearchResult(50) + + data.albums.data = data.albums.data.map(album => { + const m = album.release_date.match(/\d+/) + album.release_date = m ? m[0] : "" + + return { + ...album, + } + }) + + callback(null, { + statusCode: 200, + body: stringify(data) + }) +} + +async function getAlbum (query, callback) { + const { id } = query + + let { data: meta } = await Api.client.albumFetcher.setAlbumID(id).fetchMetadata() + let { data: tracks } = await Api.client.albumFetcher.setAlbumID(id).fetchTracks() + + meta.image = meta.images.find(image => image.height === 500) + if (!meta.image) { meta.image = meta.images[0] } + + const m = meta.release_date.match(/\d+/) + meta.release_date = m ? m[0] : "" + + tracks.data = tracks.data.map(track => { + const date = new Date(null) + date.setSeconds(Math.round(track.duration / 1000)) + + let duration = date.toISOString().substr(11, 8) + + if (duration.split(':')[0] === '00') { + const arr = duration.split(':') + duration = `${arr[1]}:${arr[2]}` + } + + return { + ...track, + '_length': duration + } + }) + + callback(null, { + statusCode: 200, + body: stringify({ meta, tracks }) + }) +} + +export async function index(event, context, callback) { + await init() + + // TODO: check user agent and minimum script compatible version + const { headers: { 'User-Agent': userAgent } } = event + + const cb = async (...args) => { + await Api.dispose() + + callback(...args) + } + + switch(event.pathParameters.id) { + case 'searchAlbum': + return searchAlbum(event.queryStringParameters, cb) + case 'albums': + return getAlbum(event.queryStringParameters, cb) + } + + await Api.dispose() + + callback(null, { + statusCode: 500, + body: JSON.stringify({ + message: 'Internal Server Error', + }, null, 2), + }); +} diff --git a/api-gateway/src/store.ts b/api-gateway/src/store.ts new file mode 100644 index 0000000..e814d3f --- /dev/null +++ b/api-gateway/src/store.ts @@ -0,0 +1,38 @@ +import * as redis from 'redis' +import * as bluebird from 'bluebird' + +bluebird.promisifyAll(redis.RedisClient.prototype); +bluebird.promisifyAll(redis.Multi.prototype); + +class Store { + private client : any + + constructor () { + this.client = redis.createClient(process.env.REDIS_URL) + } + + set (key : string, value : any) { + return this.client.setAsync(key, JSON.stringify(value)) + } + + async get (key : string) { + const result = await this.client.getAsync(key) + return JSON.parse(result) + } + + close () { + this.client.quit() + + return new Promise(resolve => { + this.client.on('end', () => { + resolve() + }) + }) + } + + public getClient () { + return this.client + } +} + +export default Store diff --git a/server/.eslintrc.json b/server/.eslintrc.json deleted file mode 100644 index 95d3563..0000000 --- a/server/.eslintrc.json +++ /dev/null @@ -1,28 +0,0 @@ -{ - "env": { - "es6": true, - "node": true - }, - "extends": "eslint:recommended", - "parserOptions": { - "sourceType": "module" - }, - "rules": { - "indent": [ - "error", - "tab" - ], - "linebreak-style": [ - "error", - "unix" - ], - "quotes": [ - "error", - "single" - ], - "semi": [ - "error", - "always" - ] - } -} \ No newline at end of file diff --git a/server/.gitignore b/server/.gitignore deleted file mode 100644 index 3c3629e..0000000 --- a/server/.gitignore +++ /dev/null @@ -1 +0,0 @@ -node_modules diff --git a/server/KKBox.js b/server/KKBox.js deleted file mode 100644 index a9ba8c5..0000000 --- a/server/KKBox.js +++ /dev/null @@ -1,60 +0,0 @@ -var cheerio = require('cheerio'); -var request = require('request'); -var url = require('url'); - -function fullUrl(req) { - return url.format({ - protocol: req.protocol, - host: req.get('Host'), - pathname: '/album' - }); -} - -const BASE_URL = 'https://www.kkbox.com'; -const ALBUM_REGEX = /album\/(.+$)/; -const ALBUM_BASE_URL = '/tw/tc/album/'; - -module.exports = { - searchAlbum: function(req, term, page=1) { - return new Promise((resolve, reject) => { - request(`${BASE_URL}/tw/tc/search.php?search=album&word=${encodeURIComponent(term)}&cur_page=${page}`, (error, response, body) => { - if (error) { return reject(error) } - var $ = cheerio.load(body); - - var albums = $('li.album').map((i, album) => { - var $albumAnchor = $($(album).find('a')[0]); - var $cover = $($(album).find('img')[0]); - - return { - title: $albumAnchor.attr('title'), - link: `${fullUrl(req)}?path=${ALBUM_REGEX.exec($albumAnchor.attr('href'))[1]}`, - cover: $cover.attr('src') - }; - }).toArray(); - - return resolve(albums); - }); - }); - }, - - getAlbum: function(path) { - return new Promise((resolve, reject) => { - request(`${BASE_URL}${ALBUM_BASE_URL}${path}`, (error, response, body) => { - if (error) { return reject(error) } - var $ = cheerio.load(body); - - var tracks = $('ul.song-list li').map((index, track) => { - var $song_data = $(track).find('.song-data'); - - return { - index: parseInt($(track).attr('data-song_idx')), - title: $song_data.find('h3 a').text(), - artist: $song_data.find('h4 a').text() - }; - }).toArray(); - - return resolve(tracks); - }); - }); - } -}; diff --git a/server/Procfile b/server/Procfile deleted file mode 100644 index e1d4131..0000000 --- a/server/Procfile +++ /dev/null @@ -1 +0,0 @@ -web: node app.js diff --git a/server/app.js b/server/app.js deleted file mode 100644 index 74ec1db..0000000 --- a/server/app.js +++ /dev/null @@ -1,28 +0,0 @@ -var express = require('express'); -var KKBox = require('./KKBox'); -var app = express(); - -app.get('/search', function (req, res) { - if (typeof req.query.term === 'undefined') { - return res.sendStatus(404); - } - if (typeof req.query.page === 'undefined') { req.query.page = 1 } - - KKBox.searchAlbum(req, req.query.term, req.query.page).then(r => { - return res.send(r); - }); -}); - -app.get('/album', function(req, res) { - if (typeof req.query.path === 'undefined') { - return res.sendStatus(404); - } - - KKBox.getAlbum(req.query.path).then(r => { - return res.send(r); - }); -}); - -app.listen(3000, function () { - console.log('KKBox middleman started'); -}); diff --git a/server/package.json b/server/package.json deleted file mode 100644 index 0c74baf..0000000 --- a/server/package.json +++ /dev/null @@ -1,22 +0,0 @@ -{ - "name": "kkbox-middleman", - "version": "1.0.0", - "description": "", - "main": "app.js", - "scripts": { - "test": "echo \"Error: no test specified\" && exit 1" - }, - "author": "Yukai Huang", - "license": "ISC", - "dependencies": { - "cheerio": "^0.20.0", - "express": "^4.13.4", - "request": "^2.72.0" - }, - "devDependencies": { - "eslint": "^2.11.1" - }, - "engines": { - "node": "6.2.0" - } -}