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 ""
-
-# Year
-outputto "Year"
-moveline -3
-findinline ">"
-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 ""
- say "|"
-
- # Title
- outputto "title temp"
- findline "class=\"song-data"
- findline ""
- sayuntil ""
- say "|"
-
- # tracks ++
- outputto "tracks"
- say "|"
-
- findline " (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"
- }
-}