├── .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 | ![squared](images/gif-reorx-squared.gif) 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 | --------------------------------------------------------------------------------