├── .gitignore
├── .nvmrc
├── Makefile
├── README.md
├── bower.json
├── favicon.ico
├── images
└── gif-reorx-squared.gif
├── index.jade
├── js
├── app.js
├── canvas-util.js
├── lib
│ └── canvas-filesaver.js
└── views.js
├── package.json
├── requirements.txt
├── rsync_excludes.txt
├── scss
├── _base.scss
├── _formel.scss
├── _variables.scss
├── app.scss
└── sweetalert.scss
├── squared-logo.psd
├── squared
├── __init__.py
├── app.py
├── auth.py
└── settings.py
└── todo.txt
/.gitignore:
--------------------------------------------------------------------------------
1 | bower_components/
2 | config.codekit
3 | node_modules/
4 | css/
5 | *.html
6 | .sass-cache/
7 | squared/local_settings.py
8 | .deployhost
9 |
--------------------------------------------------------------------------------
/.nvmrc:
--------------------------------------------------------------------------------
1 | 4.2.1
2 |
--------------------------------------------------------------------------------
/Makefile:
--------------------------------------------------------------------------------
1 | .PHONY: clean test
2 |
3 | host := $$(cat .deployhost)
4 |
5 | clean:
6 | rm -rf build dist *.egg-info
7 |
8 | run:
9 | PYTHONPATH=. python squared/app.py
10 |
11 | deploy:
12 | @echo "\n# Sync files"
13 | rsync -avr --exclude-from=rsync_excludes.txt . $(host):~/squared
14 | @echo "\n# Restart service"
15 | ssh $(host) "cd supervisor && supervisorctl restart squared"
16 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # Squared
2 |
3 | 
4 |
5 | _generated by [Codeology](http://codeology.braintreepayments.com/)_
6 |
--------------------------------------------------------------------------------
/bower.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "squared",
3 | "description": "",
4 | "main": "index.js",
5 | "authors": [
6 | "reorx"
7 | ],
8 | "license": "ISC",
9 | "moduleType": [
10 | "globals"
11 | ],
12 | "homepage": "",
13 | "private": true,
14 | "ignore": [
15 | "**/.*",
16 | "node_modules",
17 | "bower_components",
18 | "test",
19 | "tests"
20 | ],
21 | "dependencies": {
22 | "normalize-scss": "~3.0.3",
23 | "Sortable": "~1.4.2",
24 | "jquery": "~2.2.0",
25 | "handlebars": "~4.0.5",
26 | "sweetalert": "~1.1.3"
27 | }
28 | }
29 |
--------------------------------------------------------------------------------
/favicon.ico:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/reorx/squared/cda5e75b242e74ed56838b11718e987f92110e37/favicon.ico
--------------------------------------------------------------------------------
/images/gif-reorx-squared.gif:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/reorx/squared/cda5e75b242e74ed56838b11718e987f92110e37/images/gif-reorx-squared.gif
--------------------------------------------------------------------------------
/index.jade:
--------------------------------------------------------------------------------
1 | doctype html
2 | html
3 | head
4 | title Squared
5 | meta(charset="UTF-8")
6 | //- link(rel="stylesheet", href="/bower_components/sweetalert/dist/sweetalert.css")
7 | link(rel="stylesheet", href="/css/sweetalert.css")
8 | link(rel="stylesheet", href="/css/app.css")
9 | body
10 | #main
11 | .left-column
12 | .squared-box
13 | .placeholder._centered_hint
14 | span Drag or click to add artwork
15 | .settings-box._round_0
16 | .row
17 | label Grid:
18 | select(name="grid_number")._select
19 | option(value="9") 9X9
20 | option(value="4") 2X2
21 | .row
22 | label Size:
23 | select(name="size")._select
24 | option(value="origin") origin
25 | .row
26 | label Type:
27 | select(name="filetype")._select
28 | option(value="jpeg") jpeg
29 | option(value="png") png
30 | button.download._button Download
31 | .row.footer
32 | img.favicon(src="/favicon.ico")
33 | a(href="https://github.com/reorx/squared" target="_blank") squared
34 | span , created by 
35 | a(href="https://twitter.com/novoreorx" target="_blank") @reorx
36 | span.whatsthis (what's this?)
37 | .right-column
38 | .url-box._round_0
39 | form
40 | select._select
41 | option(value="spotify") Spotify
42 | input(type="url", name="url", required)._input
43 | .play-list._round_0
44 | .status-bar  
45 | .placeholder._centered_hint
46 | span Enter URL above to load playlist
47 | .loading._centered_hint
48 | span Loading playlist...
49 | ul
50 |
51 | script#playlist-template(type="text/x-handlebars-template").
52 | {{#playlist}}
53 |
54 |
55 |
56 |

57 |
✖
58 |
59 |
60 |
61 | {{name}}
62 | {{album.name}}
63 | {{artists.0.name}}
64 |
65 |
66 | {{/playlist}}
67 |
68 | script(src='/bower_components/jquery/dist/jquery.min.js')
69 | script(src='/bower_components/handlebars/handlebars.min.js')
70 | script(src='/bower_components/Sortable/Sortable.min.js')
71 | script(src='/bower_components/sweetalert/dist/sweetalert.min.js')
72 | script(src="/js/lib/canvas-filesaver.js")
73 | script(src='/js/canvas-util.js')
74 | script(src='/js/views.js')
75 | script(src='/js/app.js')
76 |
77 | // Google Analytics
78 | script.
79 | (function(i,s,o,g,r,a,m){i['GoogleAnalyticsObject']=r;i[r]=i[r]||function(){
80 | (i[r].q=i[r].q||[]).push(arguments)},i[r].l=1*new Date();a=s.createElement(o),
81 | m=s.getElementsByTagName(o)[0];a.async=1;a.src=g;m.parentNode.insertBefore(a,m)
82 | })(window,document,'script','//www.google-analytics.com/analytics.js','ga');
83 |
84 | ga('create', 'UA-38935957-2', 'auto');
85 | ga('send', 'pageview');
86 |
--------------------------------------------------------------------------------
/js/app.js:
--------------------------------------------------------------------------------
1 | $(function() {
2 | var views = window.views;
3 |
4 | // Initialize squared box
5 | views.initSquaredBox();
6 |
7 | // Initialize settings box
8 | views.initSettingsBox();
9 |
10 | // Initialize url-box
11 | views.initURLBox();
12 | });
13 |
--------------------------------------------------------------------------------
/js/canvas-util.js:
--------------------------------------------------------------------------------
1 | (function() {
2 | var exports;
3 |
4 |
5 | var file_type_map = {
6 | jpeg: 'image/jpeg',
7 | png: 'image/png',
8 | };
9 |
10 | var saveCanvasImage = function(canvas, size, filename, filetype) {
11 | var resized_canvas;
12 |
13 | // resize
14 | if (size === canvas.width) {
15 | console.log('no need to resize');
16 | resized_canvas = canvas;
17 | } else {
18 | if (size > (canvas.width / 2)) {
19 | console.log('resize using simple');
20 | resized_canvas = simpleDownScaleCanvas(canvas, size / canvas.width);
21 | } else {
22 | console.log('resize using algorithm');
23 | resized_canvas = downScaleCanvas(canvas, size / canvas.width);
24 | }
25 | }
26 |
27 | filename = filename + '-' + size + 'X' + size + '.' + filetype;
28 | resized_canvas.toBlob(function(blob) {
29 | saveAs(blob, filename);
30 | }, file_type_map[filetype]);
31 | };
32 |
33 | var simpleDownScaleCanvas = function(canvas, scale) {
34 | var resized_canvas = document.createElement('canvas');
35 | resized_canvas.width = scale * canvas.width;
36 | resized_canvas.height = scale * canvas.height;
37 | var rctx = resized_canvas.getContext('2d');
38 | rctx.drawImage(canvas,
39 | 0, 0, canvas.width, canvas.height,
40 | 0, 0, resized_canvas.width, resized_canvas.height);
41 | return resized_canvas;
42 | };
43 |
44 | // scales the canvas by (float) scale < 1
45 | // returns a new canvas containing the scaled image.
46 | var downScaleCanvas = function(cv, scale) {
47 | if (scale >= 1 || scale <= 0) throw ('scale must be a positive number <1 ');
48 | // NOTE stop using normalizeScale because it may cause unaccurate target width/height
49 | // scale = normaliseScale(scale);
50 | var sqScale = scale * scale; // square scale = area of a source pixel within target
51 | var sw = cv.width; // source image width
52 | var sh = cv.height; // source image height
53 | var tw = Math.floor(sw * scale); // target image width
54 | var th = Math.floor(sh * scale); // target image height
55 | console.log('target size', tw, th, scale, sw, sh);
56 | var sx = 0, sy = 0, sIndex = 0; // source x,y, index within source array
57 | var tx = 0, ty = 0, yIndex = 0, tIndex = 0; // target x,y, x,y index within target array
58 | var tX = 0, tY = 0; // rounded tx, ty
59 | var w = 0, nw = 0, wx = 0, nwx = 0, wy = 0, nwy = 0; // weight / next weight x / y
60 | // weight is weight of current source point within target.
61 | // next weight is weight of current source point within next target's point.
62 | var crossX = false; // does scaled px cross its current px right border ?
63 | var crossY = false; // does scaled px cross its current px bottom border ?
64 | var sBuffer = cv.getContext('2d').
65 | getImageData(0, 0, sw, sh).data; // source buffer 8 bit rgba
66 | var tBuffer = new Float32Array(3 * tw * th); // target buffer Float32 rgb
67 | var sR = 0, sG = 0, sB = 0; // source's current point r,g,b
68 |
69 | for (sy = 0; sy < sh; sy++) {
70 | ty = sy * scale; // y src position within target
71 | tY = 0 | ty; // rounded : target pixel's y
72 | yIndex = 3 * tY * tw; // line index within target array
73 | crossY = (tY !== (0 | ( ty + scale )));
74 | if (crossY) { // if pixel is crossing botton target pixel
75 | wy = (tY + 1 - ty); // weight of point within target pixel
76 | nwy = (ty + scale - tY - 1); // ... within y+1 target pixel
77 | }
78 | for (sx = 0; sx < sw; sx++, sIndex += 4) {
79 | tx = sx * scale; // x src position within target
80 | tX = 0 | tx; // rounded : target pixel's x
81 | tIndex = yIndex + tX * 3; // target pixel index within target array
82 | crossX = (tX !== (0 | (tx + scale)));
83 | if (crossX) { // if pixel is crossing target pixel's right
84 | wx = (tX + 1 - tx); // weight of point within target pixel
85 | nwx = (tx + scale - tX - 1); // ... within x+1 target pixel
86 | }
87 | sR = sBuffer[sIndex ]; // retrieving r,g,b for curr src px.
88 | sG = sBuffer[sIndex + 1];
89 | sB = sBuffer[sIndex + 2];
90 | if (!crossX && !crossY) { // pixel does not cross
91 | // just add components weighted by squared scale.
92 | tBuffer[tIndex ] += sR * sqScale;
93 | tBuffer[tIndex + 1] += sG * sqScale;
94 | tBuffer[tIndex + 2] += sB * sqScale;
95 | } else if (crossX && !crossY) { // cross on X only
96 | w = wx * scale;
97 | // add weighted component for current px
98 | tBuffer[tIndex ] += sR * w;
99 | tBuffer[tIndex + 1] += sG * w;
100 | tBuffer[tIndex + 2] += sB * w;
101 | // add weighted component for next (tX+1) px
102 | nw = nwx * scale;
103 | tBuffer[tIndex + 3] += sR * nw;
104 | tBuffer[tIndex + 4] += sG * nw;
105 | tBuffer[tIndex + 5] += sB * nw;
106 | } else if (!crossX && crossY) { // cross on Y only
107 | w = wy * scale;
108 | // add weighted component for current px
109 | tBuffer[tIndex ] += sR * w;
110 | tBuffer[tIndex + 1] += sG * w;
111 | tBuffer[tIndex + 2] += sB * w;
112 | // add weighted component for next (tY+1) px
113 | nw = nwy * scale;
114 | tBuffer[tIndex + 3 * tw ] += sR * nw;
115 | tBuffer[tIndex + 3 * tw + 1] += sG * nw;
116 | tBuffer[tIndex + 3 * tw + 2] += sB * nw;
117 | } else { // crosses both x and y : four target points involved
118 | // add weighted component for current px
119 | w = wx * wy;
120 | tBuffer[tIndex ] += sR * w;
121 | tBuffer[tIndex + 1] += sG * w;
122 | tBuffer[tIndex + 2] += sB * w;
123 | // for tX + 1; tY px
124 | nw = nwx * wy;
125 | tBuffer[tIndex + 3] += sR * nw;
126 | tBuffer[tIndex + 4] += sG * nw;
127 | tBuffer[tIndex + 5] += sB * nw;
128 | // for tX ; tY + 1 px
129 | nw = wx * nwy;
130 | tBuffer[tIndex + 3 * tw ] += sR * nw;
131 | tBuffer[tIndex + 3 * tw + 1] += sG * nw;
132 | tBuffer[tIndex + 3 * tw + 2] += sB * nw;
133 | // for tX + 1 ; tY +1 px
134 | nw = nwx * nwy;
135 | tBuffer[tIndex + 3 * tw + 3] += sR * nw;
136 | tBuffer[tIndex + 3 * tw + 4] += sG * nw;
137 | tBuffer[tIndex + 3 * tw + 5] += sB * nw;
138 | }
139 | } // end for sx
140 | } // end for sy
141 |
142 | // create result canvas
143 | var resCV = document.createElement('canvas');
144 | resCV.width = tw;
145 | resCV.height = th;
146 | var resCtx = resCV.getContext('2d');
147 | var imgRes = resCtx.getImageData(0, 0, tw, th);
148 | var tByteBuffer = imgRes.data;
149 | // convert float32 array into a UInt8Clamped Array
150 | var pxIndex = 0; //
151 | for (sIndex = 0, tIndex = 0; pxIndex < tw * th; sIndex += 3, tIndex += 4, pxIndex++) {
152 | tByteBuffer[tIndex] = 0 | ( tBuffer[sIndex]);
153 | tByteBuffer[tIndex + 1] = 0 | (tBuffer[sIndex + 1]);
154 | tByteBuffer[tIndex + 2] = 0 | (tBuffer[sIndex + 2]);
155 | tByteBuffer[tIndex + 3] = 255;
156 | }
157 | // writing result to canvas.
158 | resCtx.putImageData(imgRes, 0, 0);
159 | return resCV;
160 | };
161 |
162 | var log2 = function(v) {
163 | // taken from http://graphics.stanford.edu/~seander/bithacks.html
164 | var b = [0x2, 0xC, 0xF0, 0xFF00, 0xFFFF0000];
165 | var S = [1, 2, 4, 8, 16];
166 | var i = 0,
167 | r = 0;
168 |
169 | for (i = 4; i >= 0; i--) {
170 | if (v & b[i]) {
171 | v >>= S[i];
172 | r |= S[i];
173 | }
174 | }
175 | return r;
176 | };
177 |
178 | // normalize a scale <1 to avoid some rounding issue with js numbers
179 | var normaliseScale = function(s) {
180 | if (s > 1) throw ('s must be <1');
181 | s = 0 | (1 / s);
182 | var l = log2(s);
183 | var mask = 1 << l;
184 | var accuracy = 4;
185 | while (accuracy && l) {
186 | l--;
187 | mask |= 1 << l;
188 | accuracy--;
189 | }
190 | return 1 / (s & mask);
191 | };
192 |
193 |
194 | exports = {
195 | saveCanvasImage: saveCanvasImage,
196 | downScaleCanvas: downScaleCanvas,
197 | };
198 |
199 | window.canvas_util = exports;
200 | })();
201 |
--------------------------------------------------------------------------------
/js/lib/canvas-filesaver.js:
--------------------------------------------------------------------------------
1 | /* canvas-toBlob.js
2 | * A canvas.toBlob() implementation.
3 | * 2013-12-27
4 | *
5 | * By Eli Grey, http://eligrey.com and Devin Samarin, https://github.com/eboyjr
6 | * License: MIT
7 | * See https://github.com/eligrey/canvas-toBlob.js/blob/master/LICENSE.md
8 | */
9 |
10 | /*global self */
11 | /*jslint bitwise: true, regexp: true, confusion: true, es5: true, vars: true, white: true,
12 | plusplus: true */
13 |
14 | /*! @source http://purl.eligrey.com/github/canvas-toBlob.js/blob/master/canvas-toBlob.js */
15 |
16 | (function(view) {
17 | "use strict";
18 | var
19 | Uint8Array = view.Uint8Array
20 | , HTMLCanvasElement = view.HTMLCanvasElement
21 | , canvas_proto = HTMLCanvasElement && HTMLCanvasElement.prototype
22 | , is_base64_regex = /\s*;\s*base64\s*(?:;|$)/i
23 | , to_data_url = "toDataURL"
24 | , base64_ranks
25 | , decode_base64 = function(base64) {
26 | var
27 | len = base64.length
28 | , buffer = new Uint8Array(len / 4 * 3 | 0)
29 | , i = 0
30 | , outptr = 0
31 | , last = [0, 0]
32 | , state = 0
33 | , save = 0
34 | , rank
35 | , code
36 | , undef
37 | ;
38 | while (len--) {
39 | code = base64.charCodeAt(i++);
40 | rank = base64_ranks[code-43];
41 | if (rank !== 255 && rank !== undef) {
42 | last[1] = last[0];
43 | last[0] = code;
44 | save = (save << 6) | rank;
45 | state++;
46 | if (state === 4) {
47 | buffer[outptr++] = save >>> 16;
48 | if (last[1] !== 61 /* padding character */) {
49 | buffer[outptr++] = save >>> 8;
50 | }
51 | if (last[0] !== 61 /* padding character */) {
52 | buffer[outptr++] = save;
53 | }
54 | state = 0;
55 | }
56 | }
57 | }
58 | // 2/3 chance there's going to be some null bytes at the end, but that
59 | // doesn't really matter with most image formats.
60 | // If it somehow matters for you, truncate the buffer up outptr.
61 | return buffer;
62 | }
63 | ;
64 | if (Uint8Array) {
65 | base64_ranks = new Uint8Array([
66 | 62, -1, -1, -1, 63, 52, 53, 54, 55, 56, 57, 58, 59, 60, 61, -1
67 | , -1, -1, 0, -1, -1, -1, 0, 1, 2, 3, 4, 5, 6, 7, 8, 9
68 | , 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25
69 | , -1, -1, -1, -1, -1, -1, 26, 27, 28, 29, 30, 31, 32, 33, 34, 35
70 | , 36, 37, 38, 39, 40, 41, 42, 43, 44, 45, 46, 47, 48, 49, 50, 51
71 | ]);
72 | }
73 | if (HTMLCanvasElement && !canvas_proto.toBlob) {
74 | canvas_proto.toBlob = function(callback, type /*, ...args*/) {
75 | if (!type) {
76 | type = "image/png";
77 | } if (this.mozGetAsFile) {
78 | callback(this.mozGetAsFile("canvas", type));
79 | return;
80 | } if (this.msToBlob && /^\s*image\/png\s*(?:$|;)/i.test(type)) {
81 | callback(this.msToBlob());
82 | return;
83 | }
84 |
85 | var
86 | args = Array.prototype.slice.call(arguments, 1)
87 | , dataURI = this[to_data_url].apply(this, args)
88 | , header_end = dataURI.indexOf(",")
89 | , data = dataURI.substring(header_end + 1)
90 | , is_base64 = is_base64_regex.test(dataURI.substring(0, header_end))
91 | , blob
92 | ;
93 | if (Blob.fake) {
94 | // no reason to decode a data: URI that's just going to become a data URI again
95 | blob = new Blob
96 | if (is_base64) {
97 | blob.encoding = "base64";
98 | } else {
99 | blob.encoding = "URI";
100 | }
101 | blob.data = data;
102 | blob.size = data.length;
103 | } else if (Uint8Array) {
104 | if (is_base64) {
105 | blob = new Blob([decode_base64(data)], {type: type});
106 | } else {
107 | blob = new Blob([decodeURIComponent(data)], {type: type});
108 | }
109 | }
110 | callback(blob);
111 | };
112 |
113 | if (canvas_proto.toDataURLHD) {
114 | canvas_proto.toBlobHD = function() {
115 | to_data_url = "toDataURLHD";
116 | var blob = this.toBlob();
117 | to_data_url = "toDataURL";
118 | return blob;
119 | }
120 | } else {
121 | canvas_proto.toBlobHD = canvas_proto.toBlob;
122 | }
123 | }
124 | }(typeof self !== "undefined" && self || typeof window !== "undefined" && window || this.content || this));
125 | /*! @source http://purl.eligrey.com/github/FileSaver.js/blob/master/FileSaver.js */
126 | var saveAs=saveAs||function(view){"use strict";if(typeof navigator!=="undefined"&&/MSIE [1-9]\./.test(navigator.userAgent)){return}var doc=view.document,get_URL=function(){return view.URL||view.webkitURL||view},save_link=doc.createElementNS("http://www.w3.org/1999/xhtml","a"),can_use_save_link="download"in save_link,click=function(node){var event=new MouseEvent("click");node.dispatchEvent(event)},is_safari=/Version\/[\d\.]+.*Safari/.test(navigator.userAgent),webkit_req_fs=view.webkitRequestFileSystem,req_fs=view.requestFileSystem||webkit_req_fs||view.mozRequestFileSystem,throw_outside=function(ex){(view.setImmediate||view.setTimeout)(function(){throw ex},0)},force_saveable_type="application/octet-stream",fs_min_size=0,arbitrary_revoke_timeout=500,revoke=function(file){var revoker=function(){if(typeof file==="string"){get_URL().revokeObjectURL(file)}else{file.remove()}};if(view.chrome){revoker()}else{setTimeout(revoker,arbitrary_revoke_timeout)}},dispatch=function(filesaver,event_types,event){event_types=[].concat(event_types);var i=event_types.length;while(i--){var listener=filesaver["on"+event_types[i]];if(typeof listener==="function"){try{listener.call(filesaver,event||filesaver)}catch(ex){throw_outside(ex)}}}},auto_bom=function(blob){if(/^\s*(?:text\/\S*|application\/xml|\S*\/\S*\+xml)\s*;.*charset\s*=\s*utf-8/i.test(blob.type)){return new Blob(["\ufeff",blob],{type:blob.type})}return blob},FileSaver=function(blob,name,no_auto_bom){if(!no_auto_bom){blob=auto_bom(blob)}var filesaver=this,type=blob.type,blob_changed=false,object_url,target_view,dispatch_all=function(){dispatch(filesaver,"writestart progress write writeend".split(" "))},fs_error=function(){if(target_view&&is_safari&&typeof FileReader!=="undefined"){var reader=new FileReader;reader.onloadend=function(){var base64Data=reader.result;target_view.location.href="data:attachment/file"+base64Data.slice(base64Data.search(/[,;]/));filesaver.readyState=filesaver.DONE;dispatch_all()};reader.readAsDataURL(blob);filesaver.readyState=filesaver.INIT;return}if(blob_changed||!object_url){object_url=get_URL().createObjectURL(blob)}if(target_view){target_view.location.href=object_url}else{var new_tab=view.open(object_url,"_blank");if(new_tab==undefined&&is_safari){view.location.href=object_url}}filesaver.readyState=filesaver.DONE;dispatch_all();revoke(object_url)},abortable=function(func){return function(){if(filesaver.readyState!==filesaver.DONE){return func.apply(this,arguments)}}},create_if_not_found={create:true,exclusive:false},slice;filesaver.readyState=filesaver.INIT;if(!name){name="download"}if(can_use_save_link){object_url=get_URL().createObjectURL(blob);setTimeout(function(){save_link.href=object_url;save_link.download=name;click(save_link);dispatch_all();revoke(object_url);filesaver.readyState=filesaver.DONE});return}if(view.chrome&&type&&type!==force_saveable_type){slice=blob.slice||blob.webkitSlice;blob=slice.call(blob,0,blob.size,force_saveable_type);blob_changed=true}if(webkit_req_fs&&name!=="download"){name+=".download"}if(type===force_saveable_type||webkit_req_fs){target_view=view}if(!req_fs){fs_error();return}fs_min_size+=blob.size;req_fs(view.TEMPORARY,fs_min_size,abortable(function(fs){fs.root.getDirectory("saved",create_if_not_found,abortable(function(dir){var save=function(){dir.getFile(name,create_if_not_found,abortable(function(file){file.createWriter(abortable(function(writer){writer.onwriteend=function(event){target_view.location.href=file.toURL();filesaver.readyState=filesaver.DONE;dispatch(filesaver,"writeend",event);revoke(file)};writer.onerror=function(){var error=writer.error;if(error.code!==error.ABORT_ERR){fs_error()}};"writestart progress write abort".split(" ").forEach(function(event){writer["on"+event]=filesaver["on"+event]});writer.write(blob);filesaver.abort=function(){writer.abort();filesaver.readyState=filesaver.DONE};filesaver.readyState=filesaver.WRITING}),fs_error)}),fs_error)};dir.getFile(name,{create:false},abortable(function(file){file.remove();save()}),abortable(function(ex){if(ex.code===ex.NOT_FOUND_ERR){save()}else{fs_error()}}))}),fs_error)}),fs_error)},FS_proto=FileSaver.prototype,saveAs=function(blob,name,no_auto_bom){return new FileSaver(blob,name,no_auto_bom)};if(typeof navigator!=="undefined"&&navigator.msSaveOrOpenBlob){return function(blob,name,no_auto_bom){if(!no_auto_bom){blob=auto_bom(blob)}return navigator.msSaveOrOpenBlob(blob,name||"download")}}FS_proto.abort=function(){var filesaver=this;filesaver.readyState=filesaver.DONE;dispatch(filesaver,"abort")};FS_proto.readyState=FS_proto.INIT=0;FS_proto.WRITING=1;FS_proto.DONE=2;FS_proto.error=FS_proto.onwritestart=FS_proto.onprogress=FS_proto.onwrite=FS_proto.onabort=FS_proto.onerror=FS_proto.onwriteend=null;return saveAs}(typeof self!=="undefined"&&self||typeof window!=="undefined"&&window||this.content);if(typeof module!=="undefined"&&module.exports){module.exports.saveAs=saveAs}else if(typeof define!=="undefined"&&define!==null&&define.amd!=null){define([],function(){return saveAs})}
127 |
--------------------------------------------------------------------------------
/js/views.js:
--------------------------------------------------------------------------------
1 | (function() {
2 | var exports,
3 | console = window.console,
4 | current_mode,
5 | SUPPORTED_GRID_NUMBERS = [9, 4],
6 | OUTPUT_SIZES = [1200, 500, 300],
7 | SPOTIFY_IMAGE_WIDTH = 640;
8 |
9 |
10 | // Square box views
11 |
12 | var squared_box = $('.squared-box'),
13 | squared_box_dom = $('.squared-box')[0];
14 |
15 | var adjustSquaredHeight = function() {
16 | squared_box.height(squared_box.width());
17 | };
18 |
19 | var _createOption = function(value, text) {
20 | var el = $('');
21 | el.val(value);
22 | el.html(text);
23 | return el;
24 | };
25 |
26 | var setGridMode = function(number) {
27 | if (SUPPORTED_GRID_NUMBERS.indexOf(number) === -1) {
28 | alert('Not supported yet');
29 | current_mode = undefined;
30 | return;
31 | }
32 |
33 | // Remove redundant grid items
34 | squared_box.find('.grid-item').each(function(i) {
35 | if (i >= number) {
36 | $(this).remove();
37 | }
38 | });
39 |
40 | // Set class name for squared box
41 | SUPPORTED_GRID_NUMBERS.forEach(function(n) {
42 | if (n === number) {
43 | squared_box.addClass('-grids-' + n);
44 | } else {
45 | squared_box.removeClass('-grids-' + n);
46 | }
47 | });
48 |
49 | // Update size select
50 | var size_select = settings_box.find('select[name=size]'),
51 | sqrt = Math.sqrt(number),
52 | origin_size = sqrt * SPOTIFY_IMAGE_WIDTH;
53 | size_select.empty();
54 | size_select.append(
55 | _createOption(origin_size, 'origin (' + origin_size + 'X' + origin_size + ')')
56 | );
57 | OUTPUT_SIZES.forEach(function(s) {
58 | if (s <= origin_size)
59 | size_select.append(_createOption(s, s + 'X' + s));
60 | });
61 |
62 | // Update global state
63 | current_mode = {
64 | grid_number: number
65 | };
66 | };
67 |
68 | var checkGridNumber = function() {
69 | if (squared_box.find('.grid-item').length >= current_mode.grid_number) {
70 | console.log('squared box is full, max grid number reached');
71 | return false;
72 | }
73 | return true;
74 | };
75 |
76 | // XXX ensure every image is loaded
77 | var drawGridCanvas = function(grid_number, image_width, $images) {
78 | var canvas = document.createElement('canvas'),
79 | ctx = canvas.getContext('2d'),
80 | sqrt = Math.sqrt(grid_number);
81 | // adjust size
82 | canvas.width = image_width * sqrt;
83 | canvas.height = image_width * sqrt;
84 | // set background
85 | ctx.fillStyle = '#eee';
86 | ctx.fillRect(0, 0, canvas.width, canvas.height);
87 |
88 | var drawGrid = function(img, seq) {
89 | var x = (seq % sqrt) * image_width,
90 | y = Math.floor((seq / sqrt)) * image_width;
91 | ctx.drawImage(img, x, y, image_width, image_width);
92 | };
93 |
94 | // draw each image
95 | $images.each(function(i, img) {
96 | drawGrid(img, i);
97 | });
98 | return canvas;
99 | };
100 |
101 | var initSquaredBox = function() {
102 | // Adjust height on resize
103 | $(window).on('resize', function() {
104 | adjustSquaredHeight();
105 | });
106 | $(window).trigger('resize');
107 |
108 | // Make sortable
109 | Sortable.create(squared_box_dom, {
110 | group: {
111 | name: 'default',
112 | pull: false,
113 | },
114 | animation: 200,
115 | filter: '.close',
116 | onAdd: function() {
117 | squared_box.trigger('grid_changed');
118 | }
119 | });
120 |
121 | // Disable placehold dragging
122 | squared_box.find('.placeholder').on('dragstart', function(e) {
123 | e.preventDefault();
124 | });
125 |
126 | // Bind grid_change
127 | squared_box.on('grid_changed', function() {
128 | // console.log(' grid changed');
129 | var placeholder = squared_box.find('.placeholder');
130 | if (squared_box.find('.grid-item').length) {
131 | placeholder.hide();
132 | } else {
133 | placeholder.show();
134 | }
135 | });
136 |
137 | // Bind click close
138 | squared_box.on('click', '.close', function(e) {
139 | $(e.target).closest('.grid-item').remove();
140 | squared_box.trigger('grid_changed');
141 | });
142 | };
143 |
144 |
145 | // Settings box views
146 |
147 | var settings_box = $('.settings-box');
148 |
149 | var initSettingsBox = function() {
150 | settings_box.find('select[name=grid_number]').on('change', function() {
151 | var grid_number = Number(this.value);
152 | console.log('change grid number', grid_number);
153 | setGridMode(grid_number);
154 | }).trigger('change'); // Manually trigger change to set grid number for the first time
155 |
156 | settings_box.find('.download').on('click', function() {
157 | var images = squared_box.find('.grid-item > img'),
158 | // image_width = images[0].width;
159 | image_width = SPOTIFY_IMAGE_WIDTH;
160 | if (!images.length) {
161 | swal({
162 | title: 'Oops',
163 | text: 'Please select at least one image in the grid box',
164 | type: 'error',
165 | });
166 | return;
167 | }
168 |
169 | // Create grid canvas
170 | var canvas = drawGridCanvas(current_mode.grid_number, image_width, images);
171 |
172 | // Get size for save
173 | var size = settings_box.find('select[name=size]').val();
174 | if (size === 'origin') {
175 | size = canvas.width;
176 | } else {
177 | size = Number(size);
178 | }
179 |
180 | // Get type for save
181 | var filetype = settings_box.find('select[name=filetype]').val();
182 |
183 | // Save that canvas
184 | console.log('save args:', size, filetype);
185 | canvas_util.saveCanvasImage(canvas, size, 'artwork', filetype);
186 | });
187 |
188 | /*jshint multistr: true */
189 | var text = "squared is a small tool for creating grid layout images \
190 | from playlist url, it's a gift for a friend \
191 | to express my appreciation for his great music taste and recommending music to me.\n\n\
192 | Currently squared only supports making 3x3 & 2x2 grid layouts from Spotify playlist, \
193 | if further need is shown, I can extend it to support more layouts and sources. :)";
194 | settings_box.find('.whatsthis').on('click', function() {
195 | swal({
196 | title: 'Squared',
197 | text: text,
198 | imageUrl: '/favicon.ico',
199 | customClass: '_alignleft',
200 | confirmButtonText: 'I Got It',
201 | });
202 | });
203 | };
204 |
205 |
206 | // URL box views
207 |
208 | var url_box = $('.url-box');
209 |
210 | var initURLBox = function() {
211 | var url_input = url_box.find('input[name=url]');
212 |
213 | url_box.find('form').on('submit', function(e) {
214 | // console.log('submit', this.value);
215 | e.preventDefault();
216 | var url = url_input.val();
217 | if (!url)
218 | return;
219 | url_input.prop('disabled', true);
220 | renderPlaylist(url).always(function() {
221 | url_input.prop('disabled', false);
222 | });
223 | });
224 | };
225 |
226 |
227 | // Playlist views
228 |
229 | var renderPlaylist = function(url) {
230 | var $playlist = $('.play-list'),
231 | $placeholder = $playlist.find('.placeholder'),
232 | $loading = $playlist.find('.loading'),
233 | $content = $playlist.find('ul'),
234 | $statusbar = $('.status-bar');
235 |
236 | $placeholder.hide();
237 | $content.hide();
238 | $loading.show().css('display', 'flex');
239 | return fetchPlaylist(url).then(function(json) {
240 | _renderPlaylist(json, $content, $statusbar);
241 |
242 | $loading.hide();
243 | $content.show();
244 | }, function(jqxhr) {
245 | // status, responseText
246 | var message;
247 | if (jqxhr.status < 500) {
248 | message = JSON.parse(jqxhr.responseText).error.message;
249 | } else {
250 | message = jqxhr.responseText;
251 | }
252 | var text = 'Please make sure you have input a valid playlist url
' +
253 | '' + message + '
';
254 | swal({
255 | title: 'Failed to parse url',
256 | text: text,
257 | type: 'error',
258 | html: true,
259 | });
260 | console.warn('failed', arguments);
261 | });
262 | };
263 |
264 | var playlist_template = Handlebars.compile($('#playlist-template').html());
265 |
266 | var _renderPlaylist = function(json, container, statusbar) {
267 | statusbar.html('Total: ' + json.length + ' songs');
268 | var rendered = $(playlist_template({playlist: json}));
269 |
270 | container.html(rendered);
271 | // Make sortable
272 | container.find('li .grid-item-wrapper').each(function() {
273 | Sortable.create(this, {
274 | group: {
275 | name: 'default',
276 | pull: 'clone',
277 | put: false,
278 | },
279 | onMove: function (e) {
280 | if (e.to !== squared_box_dom)
281 | return;
282 | return checkGridNumber();
283 | },
284 | draggable: '.grid-item',
285 | animation: 150,
286 | });
287 | });
288 |
289 | // Bind click
290 | container.on('click', 'li .grid-item', function(e) {
291 | if (!checkGridNumber())
292 | return;
293 | var grid_item = $(e.target).closest('.grid-item');
294 | grid_item.clone().appendTo(squared_box);
295 | squared_box.trigger('grid_changed');
296 | });
297 | };
298 |
299 | var fetchPlaylist = function(url) {
300 | return $.ajax({
301 | url: '/api/get_spotify_playlist',
302 | type: 'POST',
303 | data: {
304 | playlist_url: url
305 | }
306 | });
307 | };
308 |
309 |
310 | exports = {
311 | initSquaredBox: initSquaredBox,
312 | initSettingsBox: initSettingsBox,
313 | initURLBox: initURLBox,
314 | };
315 |
316 | window.views = exports;
317 | })();
318 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "squared",
3 | "version": "1.0.0",
4 | "description": "",
5 | "main": "index.js",
6 | "scripts": {
7 | "test": "echo \"Error: no test specified\" && exit 1"
8 | },
9 | "author": "reorx",
10 | "license": "ISC",
11 | "devDependencies": {
12 | "bower": "^1.7.2"
13 | }
14 | }
15 |
--------------------------------------------------------------------------------
/requirements.txt:
--------------------------------------------------------------------------------
1 | -e git+https://github.com/reorx/torext.git#egg=torext
2 |
--------------------------------------------------------------------------------
/rsync_excludes.txt:
--------------------------------------------------------------------------------
1 | *.pyc
2 | .git
3 | node_modules
4 | scss
5 | squared/local_settings.py
6 | config.codekit
7 |
--------------------------------------------------------------------------------
/scss/_base.scss:
--------------------------------------------------------------------------------
1 | @import "../bower_components/normalize-scss/normalize";
2 | @import "variables";
3 |
--------------------------------------------------------------------------------
/scss/_formel.scss:
--------------------------------------------------------------------------------
1 | // Below is a small sets of form element styles inspired by purecss
2 |
3 | ._fe_base {
4 | display: inline-block;
5 | height: 2em;
6 | line-height: 1.5em;
7 | padding: 0 .6em;
8 | vertical-align: middle;
9 | box-sizing: border-box;
10 | border-radius: 2px;
11 | }
12 |
13 | ._button {
14 | @extend ._fe_base;
15 | text-align: center;
16 | border: 0;
17 | background: #e9e9e9;
18 | &:focus {
19 | outline: none;
20 | }
21 | &:active {
22 | box-shadow: 0 0 0 1px rgba(30,30,30,.15) inset,0 0 6px rgba(30,30,30,.2) inset;
23 | }
24 | }
25 |
26 | ._select {
27 | @extend ._fe_base;
28 | border: 0;
29 | background: #eaeaea;
30 | &:focus {
31 | outline: 0;
32 | border-color: #129FEA;
33 | }
34 | }
35 |
36 | ._input {
37 | @extend ._fe_base;
38 | padding: 0.3em 0.6em;
39 | border: 1px solid #ccc;
40 | &:focus {
41 | outline: 0;
42 | border-color: #129FEA;
43 | }
44 | }
45 |
--------------------------------------------------------------------------------
/scss/_variables.scss:
--------------------------------------------------------------------------------
1 | $main_width: 1200px;
2 | $main_padding: 20px;
3 | $main_padding_int: 20;
4 |
5 | $font_color_normal: #222;
6 | $font_color_light: #888;
7 | $font_color_large_title: #333;
8 |
9 | $border_radius: 2px;
10 | $border_color: #eaeaea;
11 |
--------------------------------------------------------------------------------
/scss/app.scss:
--------------------------------------------------------------------------------
1 | @import "_base.scss";
2 | @import "_formel.scss";
3 |
4 | html, body {
5 | height: 100%;
6 | font-size: 13px;
7 | color: $font_color_normal;
8 | }
9 |
10 | body {
11 | // DEBUG
12 | background: #eaeaea;
13 | font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif;
14 | }
15 |
16 | a, a:visited {
17 | color: #52B9FF;
18 | text-decoration: none;
19 | }
20 |
21 | // reset autofill input yellow background
22 | input:-webkit-autofill {
23 | -webkit-box-shadow: 0 0 0px 1000px white inset;
24 | }
25 |
26 | #main {
27 | width: $main_width;
28 | min-height: 100%;
29 | position: relative;
30 | margin: 0 auto;
31 | @media screen and (max-width: $main_width) {
32 | width: auto;
33 | }
34 |
35 | display: flex;
36 |
37 | .left-column {
38 | box-sizing: border-box;
39 | width: 45%;
40 | min-width: 350px;
41 | padding: $main_padding;
42 | display: flex;
43 | flex-direction: column;
44 |
45 | .squared-box {
46 | flex-shrink: 0;
47 | display: flex;
48 | flex-wrap: wrap;
49 | align-content: flex-start;
50 | background: #fff;
51 | margin-bottom: $main_padding;
52 | position: relative;
53 | min-height: 300px;
54 |
55 | &.-grids-9 {
56 | .grid-item {
57 | width: calc(100% / 3);
58 | height: calc(100% / 3);
59 | }
60 | }
61 | &.-grids-4 {
62 | .grid-item {
63 | width: calc(100% / 2);
64 | height: calc(100% / 2);
65 | }
66 | }
67 |
68 | .grid-item {
69 | // transition: all .2s ease;
70 | box-shadow: inset 0 0 8px #aaa;
71 | position: relative;
72 | .close {
73 | display: block;
74 | opacity: 0;
75 | }
76 | &:hover {
77 | .close {
78 | opacity: 0.7;
79 | }
80 | }
81 | }
82 | }
83 | .settings-box {
84 | background: #fff;
85 | flex-grow: 1;
86 | padding: $main_padding;
87 | display: flex;
88 | flex-direction: column;
89 | .row {
90 | padding-bottom: 15px;
91 | white-space: nowrap;
92 | >label {
93 | vertical-align: middle;
94 | margin-right: .5em;
95 | }
96 | >select {
97 | margin-right: 1em;
98 | }
99 | }
100 | .row.footer {
101 | border-top: 1px solid $border_color;
102 | margin-top: auto;
103 | padding-top: 15px;
104 | padding-bottom: 0;
105 | color: $font_color_light;
106 | >* {
107 | vertical-align: middle;
108 | }
109 | .favicon {
110 | width: 18px;
111 | height: 18px;
112 | margin-right: 5px;
113 | }
114 | .whatsthis {
115 | margin-left: 1em;
116 | &:hover {
117 | text-decoration: underline;
118 | cursor: pointer;
119 | }
120 | }
121 | }
122 | }
123 | }
124 |
125 | .right-column {
126 | box-sizing: border-box;
127 | width: 55%;
128 | min-width: 400px;
129 | padding: $main_padding;
130 | padding-left: 0;
131 | display: flex;
132 | flex-direction: column;
133 |
134 | .url-box {
135 | padding: $main_padding;
136 | border-bottom: 1px solid #eee;
137 | background: #fff;
138 | // align-items: center;
139 | margin-bottom: $main_padding;
140 |
141 | form {
142 | display: flex;
143 | select {
144 | margin-right: $main_padding;
145 | }
146 | input {
147 | flex-grow: 1;
148 | }
149 | }
150 | }
151 |
152 | .play-list {
153 | flex-grow: 1;
154 | background: #fff;
155 | position: relative;
156 |
157 | .status-bar {
158 | position: absolute;
159 | left: 0; right: 0; top: 0;
160 | height: 30px;
161 | box-sizing: border-box;
162 | // flex-shrink: 0;
163 | padding: 8px $main_padding;
164 | border-bottom: 1px solid $border_color;
165 | background: #fff;
166 | font-size: 12px;
167 | color: $font_color_light;
168 | }
169 | .loading {
170 | display: none;
171 | }
172 |
173 | ul {
174 | position: absolute;
175 | left: 0; right: 0; bottom: 0;
176 | top: 30px;
177 | // flex-grow: 1;
178 | display: none;
179 | margin: 0; padding: 0;
180 | list-style: none;
181 | overflow-y: auto;
182 | }
183 |
184 | li {
185 | display: flex;
186 | align-items: center;
187 | padding: 10px $main_padding;
188 | &:nth-child(even) {
189 | background: #f5f5f5;
190 | }
191 | .grid-item-wrapper {
192 | width: 80px;
193 | height: 80px;
194 | margin-right: $main_padding;
195 | border: 1px solid transparent;
196 | cursor: pointer;
197 | &:hover {
198 | border: 1px solid #4696FF;
199 | }
200 | }
201 | .song-info {
202 | >span {
203 | color: $font_color_normal;
204 | }
205 | .title {
206 | display: block;
207 | font-size: 16px;
208 | margin-bottom: 1em;
209 | }
210 | .album {
211 | display: inline-block;
212 | &:after {
213 | content: "-";
214 | display: inline-block;
215 | margin: 0 0.5em;
216 | }
217 | }
218 | .artist {
219 | display: inline-block;
220 | }
221 | &:after {
222 | content: "";
223 | display: block;
224 | clear: both;
225 | }
226 | }
227 | }
228 | }
229 | }
230 |
231 | .grid-item {
232 | >img {
233 | display: block;
234 | width: 100%;
235 | height: 100%;
236 | }
237 | .close {
238 | display: none;
239 | position: absolute;
240 | right: 5px; top: 5px;
241 | padding: 5px 8px;
242 | transition: all 0.2s ease;
243 |
244 | font-size: 18px;
245 | cursor: pointer;
246 | &:hover {
247 | color: #e52d27;
248 | }
249 | }
250 | }
251 | }
252 |
253 |
254 | // non-logic styles
255 |
256 | ._centered_hint {
257 | // Fix height 100% not working: http://stackoverflow.com/a/15389545/596206
258 | position: absolute;
259 | left: 0; right: 0; top: 0; bottom: 0;
260 |
261 | color: $font_color_light;
262 | font-size: 1.2em;
263 | display: flex;
264 | align-items: center;
265 | justify-content: center;
266 |
267 | span {
268 | display: inline-block;
269 | -webkit-user-select: none;
270 | }
271 | }
272 |
273 | ._round_0 {
274 | border-radius: 2px;
275 | }
276 |
--------------------------------------------------------------------------------
/scss/sweetalert.scss:
--------------------------------------------------------------------------------
1 | @import "variables";
2 | @import "../bower_components/sweetalert/dev/sweetalert";
3 |
4 |
5 | .sweet-alert {
6 | border-radius: $border_radius;
7 | h2 {
8 | color: $font_color_large_title;
9 | font-size: 2em;
10 | }
11 | p {
12 | font-size: 15px;
13 | line-height: 20px;
14 | color: #555;
15 | font-weight: normal;
16 | word-wrap: break-word;
17 | }
18 | &._alignleft p {
19 | text-align: left;
20 | padding: 0 1em;
21 | }
22 | pre {
23 | white-space: pre-wrap;
24 | word-wrap: break-word;
25 | text-align: left;
26 | margin: 1em 1em 0 1em;
27 | padding: .8em .8em;
28 | line-height: 18px;
29 | font-size: 13px;
30 | font-family: 'Lucida Sans Typewriter', 'Lucida Console', monaco, 'Bitstream Vera Sans Mono', monospace;
31 | background: #eee;
32 | }
33 | .sa-confirm-button-container {
34 | margin-bottom: 15px;
35 | }
36 | button {
37 | font-size: 1.2em;
38 | padding: .6em 2em;
39 | border-radius: $border_radius;
40 | }
41 |
42 | }
43 |
--------------------------------------------------------------------------------
/squared-logo.psd:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/reorx/squared/cda5e75b242e74ed56838b11718e987f92110e37/squared-logo.psd
--------------------------------------------------------------------------------
/squared/__init__.py:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env python
2 | # -*- coding: utf-8 -*-
3 |
--------------------------------------------------------------------------------
/squared/app.py:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env python
2 | # -*- coding: utf-8 -*-
3 |
4 | import os
5 | import re
6 | import time
7 | import json
8 | import logging
9 | from torext.app import TorextApp
10 | from torext.handlers import BaseHandler as _BaseHandler
11 | from tornado.httpclient import AsyncHTTPClient
12 | from tornado.web import StaticFileHandler
13 | from tornado import gen
14 | from squared.auth import SpotifyMixin
15 | import settings
16 |
17 |
18 | app = TorextApp(settings)
19 | app.update_settings({
20 | 'STATIC_PATH': None
21 | })
22 |
23 |
24 | # For local dev
25 | app.route_many([
26 | (r'/bower_components/(.*)', StaticFileHandler, {'path': os.path.join(app.root_path, '../bower_components')}),
27 | (r'/js/(.*)', StaticFileHandler, {'path': os.path.join(app.root_path, '../js')}),
28 | (r'/css/(.*)', StaticFileHandler, {'path': os.path.join(app.root_path, '../css')}),
29 | ])
30 |
31 |
32 | class HTTPRequestFailed(Exception):
33 | pass
34 |
35 |
36 | class BaseHandler(_BaseHandler):
37 | EXCEPTION_HANDLERS = {
38 | HTTPRequestFailed: '_handle_request_failed',
39 | ValueError: '_handle_param_error',
40 | }
41 |
42 | def send_json_error(self, status_code, message, ):
43 | rv = {
44 | 'error': {
45 | 'message': message,
46 | }
47 | }
48 | self.set_status(status_code)
49 | self.write_json(rv)
50 |
51 | def _handle_request_failed(self, e):
52 | self.send_json_error(403, str(e))
53 |
54 | def _handle_param_error(self, e):
55 | self.send_json_error(400, str(e))
56 |
57 |
58 | @app.route('/')
59 | class IndexHandler(BaseHandler):
60 | def get(self):
61 | index_path = os.path.join(self.app.root_path, '../index.html')
62 | self.write_file(index_path)
63 |
64 |
65 | # For local dev
66 | @app.route('/favicon.ico')
67 | class FaviconHandler(BaseHandler):
68 | def get(self):
69 | index_path = os.path.join(self.app.root_path, '../favicon.ico')
70 | self.write_file(index_path)
71 |
72 |
73 | SPOTIFY_PLAYLIST_URL_REGEX = re.compile(r'^https:\/\/\w+.spotify.com\/user\/(\w+)\/playlist\/(\w+)$')
74 |
75 |
76 | def match_user_and_id(url):
77 | rv = SPOTIFY_PLAYLIST_URL_REGEX.search(url)
78 | if not rv:
79 | return None
80 | return rv.groups()
81 |
82 |
83 | def remove_query_string(url):
84 | index = url.find('?')
85 | if index == -1:
86 | return url
87 | return url[:index]
88 |
89 |
90 | @app.route('/api/get_spotify_playlist')
91 | class SpotifyPlaylistHandler(BaseHandler):
92 | @gen.coroutine
93 | def post(self):
94 | playlist_url = self.get_argument('playlist_url')
95 | playlist_url = remove_query_string(playlist_url)
96 | url_args = match_user_and_id(playlist_url)
97 | if not url_args:
98 | raise ValueError('Could not match playlist info from given url')
99 | url = 'https://api.spotify.com/v1/users/%s/playlists/%s' % url_args
100 |
101 | token = yield get_client_token()
102 | headers = {
103 | 'Authorization': 'Bearer ' + token
104 | }
105 |
106 | resp = yield async_request(url, headers=headers)
107 |
108 | # print 'resp', resp.body
109 | data = self.json_decode(resp.body)
110 | self.write_json(format_spotify_playlist(data))
111 |
112 |
113 | def format_spotify_playlist(data):
114 | """
115 | API: https://api.spotify.com/v1/users/:username/playlists/:id
116 | """
117 | d = []
118 | for item in data['tracks']['items']:
119 | _track = item['track']
120 | track = {
121 | 'name': _track['name'],
122 | 'album': _track['album'],
123 | 'artists': _track['artists'],
124 | }
125 | d.append(track)
126 | return d
127 |
128 |
129 | client_credentails_holder = []
130 |
131 |
132 | @gen.coroutine
133 | def get_client_token(timeout=settings.REQUEST_TIMEOUT):
134 | global client_credentails_holder
135 | token = None
136 | now = int(time.time())
137 | if client_credentails_holder:
138 | token, expires_at = client_credentails_holder[0]
139 | if now >= expires_at:
140 | logging.info('token expired')
141 | token = None
142 |
143 | if not token:
144 | url = 'https://accounts.spotify.com/api/token'
145 | headers = {
146 | 'Authorization': 'Basic ' + SpotifyMixin.generate_basic_auth(
147 | settings.SPOTIFY_CLIENT_ID, settings.SPOTIFY_CLIENT_SECRET)
148 | }
149 | body = 'grant_type=client_credentials'
150 |
151 | resp = yield async_request(url, method='POST', headers=headers, body=body, max_success_code=200)
152 |
153 | access = json.loads(resp.body)
154 | token = access['access_token']
155 | expires_at = now + access['expires_in']
156 | client_credentails_holder = [(token, expires_at)]
157 |
158 | raise gen.Return(token)
159 |
160 |
161 | @gen.coroutine
162 | def async_request(url, client=None, timeout=5, max_success_code=299, **kwargs):
163 | if not client:
164 | client = AsyncHTTPClient()
165 |
166 | if 'method' not in kwargs:
167 | kwargs['method'] = 'GET'
168 |
169 | logging.info('Async request: {method} {url}'.format(url=url, **kwargs))
170 |
171 | try:
172 | resp = yield client.fetch(
173 | url,
174 | request_timeout=timeout,
175 | connect_timeout=timeout,
176 | raise_error=False,
177 | **kwargs)
178 | except Exception as e:
179 | raise HTTPRequestFailed(error=str(e))
180 |
181 | if resp.code > max_success_code:
182 | raise HTTPRequestFailed(resp.code, resp.body or str(resp.error))
183 |
184 | raise gen.Return(resp)
185 |
186 |
187 | if '__main__' == __name__:
188 | app.command_line_config()
189 | app.run()
190 |
--------------------------------------------------------------------------------
/squared/auth.py:
--------------------------------------------------------------------------------
1 | import base64
2 | import functools
3 | from tornado.auth import OAuth2Mixin, _auth_return_future, urllib_parse
4 |
5 |
6 | class AuthError(Exception):
7 | pass
8 |
9 |
10 | class SpotifyMixin(OAuth2Mixin):
11 | """
12 | https://developer.spotify.com/web-api/authorization-guide/#authorization_code_flow
13 |
14 | Usage:
15 |
16 | .. testcode::
17 |
18 | @app.route('/auth/spotify')
19 | class AuthSpotifyHandler(BaseHandler, SpotifyMixin):
20 | @gen.coroutine
21 | def get(self):
22 | code = self.get_argument('code', None)
23 | if code:
24 | access = yield self.get_authenticated_user(
25 | redirect_uri=settings.SPOTIFY_OAUTH_REDIRECT,
26 | code=code)
27 | print 'access', access
28 | self.write_json(access)
29 | else:
30 | yield self.authorize_redirect(
31 | redirect_uri=settings.SPOTIFY_OAUTH_REDIRECT + '/callback',
32 | client_id=settings.SPOTIFY_CLIENT_ID,
33 | response_type='code')
34 | """
35 | _OAUTH_AUTHORIZE_URL = 'https://accounts.spotify.com/authorize'
36 | _OAUTH_ACCESS_TOKEN_URL = 'https://accounts.spotify.com/api/token'
37 |
38 | @_auth_return_future
39 | def get_authenticated_user(self, client_id, client_secret, redirect_uri, code, callback):
40 | http = self.get_auth_http_client()
41 | body = urllib_parse.urlencode({
42 | "redirect_uri": redirect_uri,
43 | "code": code,
44 | "grant_type": "authorization_code",
45 | })
46 | headers = {
47 | 'Content-Type': 'application/x-www-form-urlencoded',
48 | 'Authorization': 'Basic ' + SpotifyMixin.generate_basic_auth(client_id, client_secret)
49 | }
50 |
51 | http.fetch(self._OAUTH_ACCESS_TOKEN_URL,
52 | functools.partial(self._on_access_token, callback),
53 | method="POST", headers=headers, body=body)
54 |
55 | def _on_access_token(self, future, response):
56 | """Callback function for the exchange to the access token."""
57 | if response.error:
58 | future.set_exception(AuthError('Google auth error: %s' % str(response)))
59 | return
60 |
61 | args = self.json_decode(response.body)
62 | future.set_result(args)
63 |
64 | @classmethod
65 | def generate_basic_auth(cls, client_id, client_secret):
66 | return base64.b64encode('{}:{}'.format(client_id, client_secret))
67 |
--------------------------------------------------------------------------------
/squared/settings.py:
--------------------------------------------------------------------------------
1 | PROJECT = 'squared'
2 |
3 | LOCALE = 'en_US'
4 |
5 | PROCESSES = 1
6 |
7 | PORT = 8000
8 |
9 | DEBUG = True
10 |
11 | LOGGERS = {
12 | '': {
13 | 'level': 'INFO',
14 | 'fmt': '%(color)s[%(fixed_levelname)s %(asctime)s %(module)s:%(lineno)d]%(end_color)s %(message)s',
15 | #'fmt': '[%(fixed_levelname)s %(asctime)s %(module)s:%(lineno)d] %(message)s',
16 | 'datefmt': '%Y-%m-%d %H:%M:%S',
17 | }
18 | }
19 |
20 | LOG_REQUEST = True
21 |
22 | LOG_RESPONSE = False
23 |
24 | TIME_ZONE = 'Asia/Shanghai'
25 |
26 | TEMPLATE_PATH = '..'
27 |
28 | # You can use jinja2 instead
29 | TEMPLATE_ENGINE = 'tornado'
30 |
31 | LOGGING_IGNORE_URLS = [
32 | ]
33 |
34 | REQUEST_TIMEOUT = 8
35 |
36 | try:
37 | from local_settings import *
38 | print 'local_settings.py involved'
39 | except ImportError:
40 | pass
41 |
--------------------------------------------------------------------------------
/todo.txt:
--------------------------------------------------------------------------------
1 | - [x] file type select
2 | - [x] 2x2 grid support
3 | - [x] google analytics
4 |
--------------------------------------------------------------------------------