From 3414cfa22b327e041400bf1cb776e01097015a1c Mon Sep 17 00:00:00 2001 From: ajk Date: Thu, 11 Jul 2013 09:04:46 +0300 Subject: [PATCH 001/203] Add note to README.md about The Great Rewrite --- README.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/README.md b/README.md index 0b87477..47e8659 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,7 @@ # Beatstream +**IMPORTANT:** I am currently re-writing the front-end and the rails back-end, so the _most_ up-to-date version is here: + Beatstream is an app for streaming music from your computer to anywhere with a modern Web browser! (Beatstream currently requires Ruby to be installed on your computer) From 573226374f97e667fb5a6e7be428829782d78aa0 Mon Sep 17 00:00:00 2001 From: Antti-Jussi Kovalainen Date: Mon, 24 Feb 2014 20:27:43 +0200 Subject: [PATCH 002/203] Remove .powrc (you don't belong here) --- .powrc | 4 ---- 1 file changed, 4 deletions(-) delete mode 100644 .powrc diff --git a/.powrc b/.powrc deleted file mode 100644 index 52c4a57..0000000 --- a/.powrc +++ /dev/null @@ -1,4 +0,0 @@ -if [ -f "$rvm_path/scripts/rvm" ] && [ -f ".rvmrc" ]; then - source "$rvm_path/scripts/rvm" - source ".rvmrc" -fi From 485cefdaecbfee07a4bc5237c6b5d15f3c7d3063 Mon Sep 17 00:00:00 2001 From: Antti-Jussi Kovalainen Date: Sun, 6 Jul 2014 18:36:17 +0300 Subject: [PATCH 003/203] Update thin and trinidad --- Gemfile | 4 ++-- Gemfile.lock | 18 +++++++++--------- 2 files changed, 11 insertions(+), 11 deletions(-) diff --git a/Gemfile b/Gemfile index 3a28ad3..748711c 100644 --- a/Gemfile +++ b/Gemfile @@ -13,14 +13,14 @@ platforms :jruby do gem 'activerecord-jdbcsqlite3-adapter' # Tomcat-based server - gem 'trinidad' + gem 'trinidad', '1.4.6' end platforms :ruby, :mingw, :mswin do gem 'sqlite3' # Event-machine -based server - gem 'thin' + gem 'thin', '1.6.2' end group :assets do diff --git a/Gemfile.lock b/Gemfile.lock index b404369..9043b56 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -49,7 +49,7 @@ GEM jquery-rails (2.0.2) railties (>= 3.2.0, < 5.0) thor (~> 0.14) - jruby-rack (1.1.13.2) + jruby-rack (1.1.14) json (1.8.0) json (1.8.0-java) libv8 (3.11.8.17) @@ -114,19 +114,19 @@ GEM therubyrhino (2.0.2) therubyrhino_jar (>= 1.7.3) therubyrhino_jar (1.7.4) - thin (1.5.1) + thin (1.6.2) daemons (>= 1.0.9) - eventmachine (>= 0.12.6) + eventmachine (>= 1.0.0) rack (>= 1.0.0) thor (0.18.1) tilt (1.4.1) treetop (1.4.14) polyglot polyglot (>= 0.3.1) - trinidad (1.4.4) - jruby-rack (>= 1.1.10) - trinidad_jars (>= 1.1.0) - trinidad_jars (1.2.3) + trinidad (1.4.6) + jruby-rack (~> 1.1.13) + trinidad_jars (>= 1.3.0, < 1.5.0) + trinidad_jars (1.4.0) tzinfo (0.3.37) uglifier (2.1.1) execjs (>= 0.3.0) @@ -149,6 +149,6 @@ DEPENDENCIES sqlite3 therubyracer therubyrhino - thin - trinidad + thin (= 1.6.2) + trinidad (= 1.4.6) uglifier From b99683d349b2ccf32cf0bca239912d85135be03f Mon Sep 17 00:00:00 2001 From: Antti-Jussi Kovalainen Date: Sun, 6 Jul 2014 18:39:01 +0300 Subject: [PATCH 004/203] Update Rails to 3.2.19 --- Gemfile | 2 +- Gemfile.lock | 78 ++++++++++++++++++++++++++-------------------------- 2 files changed, 40 insertions(+), 40 deletions(-) diff --git a/Gemfile b/Gemfile index 748711c..e51f510 100644 --- a/Gemfile +++ b/Gemfile @@ -1,6 +1,6 @@ source 'https://rubygems.org' -gem 'rails', '3.2.13' +gem 'rails', '3.2.19' # ID3 info parser gem 'mp3info' diff --git a/Gemfile.lock b/Gemfile.lock index 9043b56..1aa2cc7 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -1,12 +1,12 @@ GEM remote: https://rubygems.org/ specs: - actionmailer (3.2.13) - actionpack (= 3.2.13) - mail (~> 2.5.3) - actionpack (3.2.13) - activemodel (= 3.2.13) - activesupport (= 3.2.13) + actionmailer (3.2.19) + actionpack (= 3.2.19) + mail (~> 2.5.4) + actionpack (3.2.19) + activemodel (= 3.2.19) + activesupport (= 3.2.19) builder (~> 3.0.0) erubis (~> 2.7.0) journey (~> 1.0.4) @@ -14,25 +14,25 @@ GEM rack-cache (~> 1.2) rack-test (~> 0.6.1) sprockets (~> 2.2.1) - activemodel (3.2.13) - activesupport (= 3.2.13) + activemodel (3.2.19) + activesupport (= 3.2.19) builder (~> 3.0.0) - activerecord (3.2.13) - activemodel (= 3.2.13) - activesupport (= 3.2.13) + activerecord (3.2.19) + activemodel (= 3.2.19) + activesupport (= 3.2.19) arel (~> 3.0.2) tzinfo (~> 0.3.29) activerecord-jdbc-adapter (1.2.9) activerecord-jdbcsqlite3-adapter (1.2.9) activerecord-jdbc-adapter (~> 1.2.9) jdbc-sqlite3 (~> 3.7.2) - activeresource (3.2.13) - activemodel (= 3.2.13) - activesupport (= 3.2.13) - activesupport (3.2.13) - i18n (= 0.6.1) + activeresource (3.2.19) + activemodel (= 3.2.19) + activesupport (= 3.2.19) + activesupport (3.2.19) + i18n (~> 0.6, >= 0.6.4) multi_json (~> 1.0) - arel (3.0.2) + arel (3.0.3) builder (3.0.4) daemons (1.1.9) erubis (2.7.0) @@ -43,48 +43,48 @@ GEM hike (1.2.3) hpricot (0.8.6) hpricot (0.8.6-java) - i18n (0.6.1) + i18n (0.6.9) jdbc-sqlite3 (3.7.2.1) journey (1.0.4) jquery-rails (2.0.2) railties (>= 3.2.0, < 5.0) thor (~> 0.14) jruby-rack (1.1.14) - json (1.8.0) - json (1.8.0-java) + json (1.8.1) + json (1.8.1-java) libv8 (3.11.8.17) mail (2.5.4) mime-types (~> 1.16) treetop (~> 1.4.8) - mime-types (1.23) + mime-types (1.25.1) mp3info (0.6.18) - multi_json (1.7.6) - polyglot (0.3.3) + multi_json (1.10.1) + polyglot (0.3.5) quiet_assets (1.0.2) railties (>= 3.1, < 5.0) rack (1.4.5) rack-cache (1.2) rack (>= 0.4) - rack-ssl (1.3.3) + rack-ssl (1.3.4) rack rack-test (0.6.2) rack (>= 1.0) - rails (3.2.13) - actionmailer (= 3.2.13) - actionpack (= 3.2.13) - activerecord (= 3.2.13) - activeresource (= 3.2.13) - activesupport (= 3.2.13) + rails (3.2.19) + actionmailer (= 3.2.19) + actionpack (= 3.2.19) + activerecord (= 3.2.19) + activeresource (= 3.2.19) + activesupport (= 3.2.19) bundler (~> 1.0) - railties (= 3.2.13) - railties (3.2.13) - actionpack (= 3.2.13) - activesupport (= 3.2.13) + railties (= 3.2.19) + railties (3.2.19) + actionpack (= 3.2.19) + activesupport (= 3.2.19) rack-ssl (~> 1.3.2) rake (>= 0.8.7) rdoc (~> 3.4) thor (>= 0.14.6, < 2.0) - rake (10.0.4) + rake (10.3.2) rdoc (3.12.2) json (~> 1.4) ref (1.0.5) @@ -118,16 +118,16 @@ GEM daemons (>= 1.0.9) eventmachine (>= 1.0.0) rack (>= 1.0.0) - thor (0.18.1) + thor (0.19.1) tilt (1.4.1) - treetop (1.4.14) + treetop (1.4.15) polyglot polyglot (>= 0.3.1) trinidad (1.4.6) jruby-rack (~> 1.1.13) trinidad_jars (>= 1.3.0, < 1.5.0) trinidad_jars (1.4.0) - tzinfo (0.3.37) + tzinfo (0.3.39) uglifier (2.1.1) execjs (>= 0.3.0) multi_json (~> 1.0, >= 1.0.2) @@ -142,7 +142,7 @@ DEPENDENCIES jquery-rails (= 2.0.2) mp3info quiet_assets - rails (= 3.2.13) + rails (= 3.2.19) rockstar rtaglib sass-rails From fbcbc1532f830bcb06337d044c60d53ab5f51fde Mon Sep 17 00:00:00 2001 From: Antti-Jussi Kovalainen Date: Sun, 6 Jul 2014 18:40:29 +0300 Subject: [PATCH 005/203] Remove rtaglib (unused) --- Gemfile | 1 - Gemfile.lock | 2 -- 2 files changed, 3 deletions(-) diff --git a/Gemfile b/Gemfile index e51f510..eda966b 100644 --- a/Gemfile +++ b/Gemfile @@ -4,7 +4,6 @@ gem 'rails', '3.2.19' # ID3 info parser gem 'mp3info' -gem 'rtaglib' # Last.fm gem 'rockstar' diff --git a/Gemfile.lock b/Gemfile.lock index 1aa2cc7..2ae7b99 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -95,7 +95,6 @@ GEM hpricot (>= 0.4.86) i18n rake - rtaglib (0.3.0) sass (3.2.9) sass-rails (3.2.6) railties (~> 3.2.0) @@ -144,7 +143,6 @@ DEPENDENCIES quiet_assets rails (= 3.2.19) rockstar - rtaglib sass-rails sqlite3 therubyracer From 0067e641530023b9dc0782c842d48a71db8c35fe Mon Sep 17 00:00:00 2001 From: Antti-Jussi Kovalainen Date: Sun, 6 Jul 2014 18:42:48 +0300 Subject: [PATCH 006/203] Set specific version in Gemfile for all gems --- Gemfile | 16 ++++++++-------- Gemfile.lock | 16 ++++++++-------- 2 files changed, 16 insertions(+), 16 deletions(-) diff --git a/Gemfile b/Gemfile index eda966b..99220e9 100644 --- a/Gemfile +++ b/Gemfile @@ -3,20 +3,20 @@ source 'https://rubygems.org' gem 'rails', '3.2.19' # ID3 info parser -gem 'mp3info' +gem 'mp3info', '0.6.18' # Last.fm -gem 'rockstar' +gem 'rockstar', '0.7.1' platforms :jruby do - gem 'activerecord-jdbcsqlite3-adapter' + gem 'activerecord-jdbcsqlite3-adapter', '1.2.9' # Tomcat-based server gem 'trinidad', '1.4.6' end platforms :ruby, :mingw, :mswin do - gem 'sqlite3' + gem 'sqlite3', '1.3.7' # Event-machine -based server gem 'thin', '1.6.2' @@ -26,14 +26,14 @@ group :assets do gem 'jquery-rails', '2.0.2' # Use Sass - gem 'sass-rails' + gem 'sass-rails', '3.2.6' # Minify & compact JS - gem 'uglifier' + gem 'uglifier', '2.1.1' # Execute JS in Ruby - gem 'therubyrhino', :platform => :jruby - gem 'therubyracer', :platform => :ruby + gem 'therubyrhino', '2.0.2', :platform => :jruby + gem 'therubyracer', '0.11.4', :platform => :ruby end group :development do diff --git a/Gemfile.lock b/Gemfile.lock index 2ae7b99..fa8b4cd 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -137,16 +137,16 @@ PLATFORMS x86-mingw32 DEPENDENCIES - activerecord-jdbcsqlite3-adapter + activerecord-jdbcsqlite3-adapter (= 1.2.9) jquery-rails (= 2.0.2) - mp3info + mp3info (= 0.6.18) quiet_assets rails (= 3.2.19) - rockstar - sass-rails - sqlite3 - therubyracer - therubyrhino + rockstar (= 0.7.1) + sass-rails (= 3.2.6) + sqlite3 (= 1.3.7) + therubyracer (= 0.11.4) + therubyrhino (= 2.0.2) thin (= 1.6.2) trinidad (= 1.4.6) - uglifier + uglifier (= 2.1.1) From e6b93a3a15a6527bb73c7b9071d346ce6e72d726 Mon Sep 17 00:00:00 2001 From: Antti-Jussi Kovalainen Date: Sun, 6 Jul 2014 18:46:53 +0300 Subject: [PATCH 007/203] Add changelog file --- CHANGELOG.md | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) create mode 100644 CHANGELOG.md diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..285e529 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,20 @@ +Changelog +========= + +## 1.0.1 + +### Major changes + +- Added force_ssl = false as default when running in development and test environments +- Updated Rails from 3.2.12 to 3.2.19 +- Updated thin to 1.6.2 +- Updated trinidad (for jruby) to 1.4.6 + +### Minor changes + +- Added changelog +- Refactored Song model +- Refactored SongsController +- Refactored User model +- Removed rtaglib gem (not used anywhere) +- Removed unused template `app/views/sessions/destroy.html.erb` From 96c522e01fd866b543b8449e156b994d3a64704e Mon Sep 17 00:00:00 2001 From: Antti-Jussi Kovalainen Date: Sun, 6 Jul 2014 18:52:33 +0300 Subject: [PATCH 008/203] Version 1.0.1 --- config/initializers/version.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/config/initializers/version.rb b/config/initializers/version.rb index 7258c19..32b1ffc 100644 --- a/config/initializers/version.rb +++ b/config/initializers/version.rb @@ -1,3 +1,3 @@ module BeatStream - VERSION = '1.0' + VERSION = '1.0.1' end From 0971e3d2b0f4bdcdcb6ac4be7e74c5375d05d463 Mon Sep 17 00:00:00 2001 From: Antti-Jussi Kovalainen Date: Sun, 6 Jul 2014 18:54:43 +0300 Subject: [PATCH 009/203] Rename Rails app from BeatStream => Beatstream --- Rakefile | 2 +- config.ru | 2 +- config/application.rb | 2 +- config/environment.rb | 2 +- config/environments/development.rb | 2 +- config/environments/production.rb | 2 +- config/environments/test.rb | 2 +- config/initializers/secret_token.rb | 2 +- config/initializers/session_store.rb | 4 ++-- config/initializers/version.rb | 2 +- config/routes.rb | 2 +- 11 files changed, 12 insertions(+), 12 deletions(-) diff --git a/Rakefile b/Rakefile index 0e23371..2160f25 100644 --- a/Rakefile +++ b/Rakefile @@ -6,4 +6,4 @@ require File.expand_path('../config/application', __FILE__) require 'rake/dsl_definition' -BeatStream::Application.load_tasks \ No newline at end of file +Beatstream::Application.load_tasks diff --git a/config.ru b/config.ru index b887fad..60801e6 100644 --- a/config.ru +++ b/config.ru @@ -1,4 +1,4 @@ # This file is used by Rack-based servers to start the application. require ::File.expand_path('../config/environment', __FILE__) -run BeatStream::Application +run Beatstream::Application diff --git a/config/application.rb b/config/application.rb index 78d0110..710ad7d 100644 --- a/config/application.rb +++ b/config/application.rb @@ -9,7 +9,7 @@ # Bundler.require(:default, :assets, Rails.env) end -module BeatStream +module Beatstream class Application < Rails::Application config.encoding = "utf-8" config.filter_parameters += [:password] diff --git a/config/environment.rb b/config/environment.rb index 554e19f..4fb27b2 100644 --- a/config/environment.rb +++ b/config/environment.rb @@ -2,4 +2,4 @@ require File.expand_path('../application', __FILE__) # Initialize the rails application -BeatStream::Application.initialize! +Beatstream::Application.initialize! diff --git a/config/environments/development.rb b/config/environments/development.rb index 908b1d4..d9014db 100644 --- a/config/environments/development.rb +++ b/config/environments/development.rb @@ -1,4 +1,4 @@ -BeatStream::Application.configure do +Beatstream::Application.configure do # Settings specified here will take precedence over those in config/application.rb # In the development environment your application's code is reloaded on diff --git a/config/environments/production.rb b/config/environments/production.rb index d94d57c..86a15db 100644 --- a/config/environments/production.rb +++ b/config/environments/production.rb @@ -1,4 +1,4 @@ -BeatStream::Application.configure do +Beatstream::Application.configure do config.cache_classes = true config.consider_all_requests_local = false config.action_controller.perform_caching = true diff --git a/config/environments/test.rb b/config/environments/test.rb index 38e0170..19712a0 100644 --- a/config/environments/test.rb +++ b/config/environments/test.rb @@ -1,4 +1,4 @@ -BeatStream::Application.configure do +Beatstream::Application.configure do # Settings specified here will take precedence over those in config/application.rb # The test environment is used exclusively to run your application's diff --git a/config/initializers/secret_token.rb b/config/initializers/secret_token.rb index 2122e4d..860bad7 100644 --- a/config/initializers/secret_token.rb +++ b/config/initializers/secret_token.rb @@ -4,4 +4,4 @@ # If you change this key, all old signed cookies will become invalid! # Make sure the secret is at least 30 characters and all random, # no regular words or you'll be exposed to dictionary attacks. -BeatStream::Application.config.secret_token = 'd89ad4c7f605d09858d91bcce974029ebcb9b2022bb5229fa34e83006cc8649b58bb29e3a50ef1a65ab919f1726c1e1e924cb556b3d1b92897009fffe8cfdfe4' +Beatstream::Application.config.secret_token = 'd89ad4c7f605d09858d91bcce974029ebcb9b2022bb5229fa34e83006cc8649b58bb29e3a50ef1a65ab919f1726c1e1e924cb556b3d1b92897009fffe8cfdfe4' diff --git a/config/initializers/session_store.rb b/config/initializers/session_store.rb index 5a12128..310eb8f 100644 --- a/config/initializers/session_store.rb +++ b/config/initializers/session_store.rb @@ -1,8 +1,8 @@ # Be sure to restart your server when you modify this file. -BeatStream::Application.config.session_store :cookie_store, :key => '_beat-stream_session' +Beatstream::Application.config.session_store :cookie_store, :key => '_beat-stream_session' # Use the database for sessions instead of the cookie-based default, # which shouldn't be used to store highly confidential information # (create the session table with "rails generate session_migration") -# BeatStream::Application.config.session_store :active_record_store +# Beatstream::Application.config.session_store :active_record_store diff --git a/config/initializers/version.rb b/config/initializers/version.rb index 32b1ffc..b2d6896 100644 --- a/config/initializers/version.rb +++ b/config/initializers/version.rb @@ -1,3 +1,3 @@ -module BeatStream +module Beatstream VERSION = '1.0.1' end diff --git a/config/routes.rb b/config/routes.rb index 1174ad2..da5f1a9 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -1,4 +1,4 @@ -BeatStream::Application.routes.draw do +Beatstream::Application.routes.draw do match 'settings' => 'settings#index' match 'settings/save' => 'settings#save' From a651c02bfdc865d06b3916d3daaa7258bfff6262 Mon Sep 17 00:00:00 2001 From: Antti-Jussi Kovalainen Date: Sun, 6 Jul 2014 19:07:46 +0300 Subject: [PATCH 010/203] Update changelog --- CHANGELOG.md | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 285e529..eaef796 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,17 @@ Changelog ========= +## 1.0.2 + +### Major changes + +- None + +### Minor changes + +- Renamed app from BeatStream to Beatstream + + ## 1.0.1 ### Major changes From 3fcd43482f4c1fc13f1f0ea3e2396da9e19a28ed Mon Sep 17 00:00:00 2001 From: Antti-Jussi Kovalainen Date: Sun, 6 Jul 2014 18:56:04 +0300 Subject: [PATCH 011/203] Fix indendation in controllers, models and .gitmodules --- .gitmodules | 2 +- app/controllers/application_controller.rb | 25 ++-- app/controllers/playlists_controller.rb | 12 +- app/controllers/sessions_controller.rb | 34 +++--- app/controllers/settings_controller.rb | 63 +++++----- app/controllers/songs_controller.rb | 142 +++++++++++----------- app/helpers/settings_helper.rb | 8 +- app/models/song.rb | 137 ++++++++++----------- app/models/user.rb | 2 + 9 files changed, 214 insertions(+), 211 deletions(-) diff --git a/.gitmodules b/.gitmodules index bbe3bd5..a5ca2f0 100644 --- a/.gitmodules +++ b/.gitmodules @@ -1,3 +1,3 @@ [submodule "public"] path = public -url=git://github.com/Darep/Beatstream-client.git + url=git://github.com/Darep/Beatstream-client.git diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb index 5063ed3..2489f08 100644 --- a/app/controllers/application_controller.rb +++ b/app/controllers/application_controller.rb @@ -1,17 +1,18 @@ class ApplicationController < ActionController::Base - before_filter :authorize - before_filter :redirect_to_https - protect_from_forgery + before_filter :authorize + before_filter :redirect_to_https + protect_from_forgery - protected + protected - def authorize - unless User.find_by_id(session[:user_id]) - redirect_to login_url - end - end + def authorize + unless User.find_by_id(session[:user_id]) + redirect_to login_url + end + end + + def redirect_to_https + redirect_to :protocol => "https://" unless (request.ssl? || request.local?) + end - def redirect_to_https - redirect_to :protocol => "https://" unless (request.ssl? || request.local?) - end end diff --git a/app/controllers/playlists_controller.rb b/app/controllers/playlists_controller.rb index a30688e..5d82c91 100644 --- a/app/controllers/playlists_controller.rb +++ b/app/controllers/playlists_controller.rb @@ -1,9 +1,9 @@ class PlaylistsController < ApplicationController - def index - # TODO: return a list of users' playlists - end + def index + # TODO: return a list of users' playlists + end - def new - # TODO: this - end + def new + # TODO: this + end end diff --git a/app/controllers/sessions_controller.rb b/app/controllers/sessions_controller.rb index bfe998c..7e0f3ad 100644 --- a/app/controllers/sessions_controller.rb +++ b/app/controllers/sessions_controller.rb @@ -1,23 +1,23 @@ class SessionsController < ApplicationController - skip_before_filter :authorize + skip_before_filter :authorize - def new - render :layout => 'login' - end + def new + render :layout => 'login' + end - def create - if params[:password] && user = User.authenticate(params[:username], params[:password]) - session[:username] = user.username - session[:user_id] = user.id - redirect_to root_url - else - redirect_to login_url, :alert => "Oops! Now that wasn't right, was it?" - end + def create + if params[:password] && user = User.authenticate(params[:username], params[:password]) + session[:username] = user.username + session[:user_id] = user.id + redirect_to root_url + else + redirect_to login_url, :alert => "Oops! Now that wasn't right, was it?" end + end - def destroy - session[:username] = nil - session[:user_id] = nil - redirect_to login_url - end + def destroy + session[:username] = nil + session[:user_id] = nil + redirect_to login_url + end end diff --git a/app/controllers/settings_controller.rb b/app/controllers/settings_controller.rb index 12ef696..0a0d48a 100644 --- a/app/controllers/settings_controller.rb +++ b/app/controllers/settings_controller.rb @@ -1,43 +1,42 @@ class SettingsController < ApplicationController - - def index - @user = User.find(session[:user_id]) - render :layout => false - end - - def save - @user = User.find(session[:user_id]) - if @user.update_attributes(params[:user]) - respond_to do |format| - format.html { redirect_to :action => 'index' } - format.json { render :nothing => true } - end - else - render :action => 'index', :layout => false - end + def index + @user = User.find(session[:user_id]) + render :layout => false + end + + def save + @user = User.find(session[:user_id]) + if @user.update_attributes(params[:user]) + respond_to do |format| + format.html { redirect_to :action => 'index' } + format.json { render :nothing => true } + end + else + render :action => 'index', :layout => false end + end - def lastfm_callback - token = params[:token] + def lastfm_callback + token = params[:token] - lastfm_session = Rockstar::Auth.new.session(token) + lastfm_session = Rockstar::Auth.new.session(token) - @user = User.find(session[:user_id]) - @user.update_attributes(:lastfm_session_key => lastfm_session.key, :lastfm_username => lastfm_session.username) + @user = User.find(session[:user_id]) + @user.update_attributes(:lastfm_session_key => lastfm_session.key, :lastfm_username => lastfm_session.username) - respond_to do |format| - format.html { redirect_to :action => 'index' } - format.json { render :nothing => true } - end + respond_to do |format| + format.html { redirect_to :action => 'index' } + format.json { render :nothing => true } end + end - def lastfm_disconnect - @user = User.find(session[:user_id]) - @user.update_attributes(:lastfm_session_key => nil, :lastfm_username => nil) + def lastfm_disconnect + @user = User.find(session[:user_id]) + @user.update_attributes(:lastfm_session_key => nil, :lastfm_username => nil) - respond_to do |format| - format.html { redirect_to :action => 'index' } - format.json { render :nothing => true } - end + respond_to do |format| + format.html { redirect_to :action => 'index' } + format.json { render :nothing => true } end + end end diff --git a/app/controllers/songs_controller.rb b/app/controllers/songs_controller.rb index 50fd784..a3b1536 100644 --- a/app/controllers/songs_controller.rb +++ b/app/controllers/songs_controller.rb @@ -8,101 +8,101 @@ class SongsController < ApplicationController - def index - songs_json = ''; - - if params[:refresh] - Rails.logger.info 'Forced song list refresh' - refresh(songs_json) - else - begin - f = File.open(SONGS_JSON_FILE, 'r') - Rails.logger.info 'Songs JSON modified: ' + f.mtime.to_s - songs_json = f.read - rescue Errno::ENOENT - Rails.logger.info 'Songs JSON file not found --> refreshing songs list' - refresh(songs_json) - end - end - - render :text => songs_json + def index + songs_json = ''; + + if params[:refresh] + Rails.logger.info 'Forced song list refresh' + refresh(songs_json) + else + begin + f = File.open(SONGS_JSON_FILE, 'r') + Rails.logger.info 'Songs JSON modified: ' + f.mtime.to_s + songs_json = f.read + rescue Errno::ENOENT + Rails.logger.info 'Songs JSON file not found --> refreshing songs list' + refresh(songs_json) + end end - def play - filepath = MUSIC_PATH + params[:file] + render :text => songs_json + end - response.content_type = Mime::Type.lookup_by_extension("mp3") + def play + filepath = MUSIC_PATH + params[:file] - render :text => File.open(filepath, 'rb') { |f| f.read } - #send_file filepath, :type => 'audio/mpeg' - end + response.content_type = Mime::Type.lookup_by_extension("mp3") - def now_playing - expires_now # don't cache + render :text => File.open(filepath, 'rb') { |f| f.read } + #send_file filepath, :type => 'audio/mpeg' + end - artist = params[:artist] - title = params[:title] + def now_playing + expires_now # don't cache - @user = User.find(session[:user_id]) + artist = params[:artist] + title = params[:title] - if @user != nil && @user.lastfm_session_key != nil - Rails.logger.info 'Update Now Playing to "' + artist + ' - ' + title + '" for user ' + @user.username + @user = User.find(session[:user_id]) - track = Rockstar::Track.new(artist, title) - track.updateNowPlaying(Time.now, @user.lastfm_session_key) - end + if @user != nil && @user.lastfm_session_key != nil + Rails.logger.info 'Update Now Playing to "' + artist + ' - ' + title + '" for user ' + @user.username - respond_to do |format| - format.json { render :nothing => true } - end + track = Rockstar::Track.new(artist, title) + track.updateNowPlaying(Time.now, @user.lastfm_session_key) end - def scrobble - expires_now # don't cache + respond_to do |format| + format.json { render :nothing => true } + end + end - artist = params[:artist] - title = params[:title] + def scrobble + expires_now # don't cache - @user = User.find(session[:user_id]) + artist = params[:artist] + title = params[:title] - if @user != nil && @user.lastfm_session_key != nil - Rails.logger.info 'Scrobbling track "' + artist + ' - ' + title + '" for user ' + @user.username + @user = User.find(session[:user_id]) - track = Rockstar::Track.new(artist, title) - track.scrobble(Time.now, @user.lastfm_session_key) - end + if @user != nil && @user.lastfm_session_key != nil + Rails.logger.info 'Scrobbling track "' + artist + ' - ' + title + '" for user ' + @user.username - respond_to do |format| - format.json { render :nothing => true } - end + track = Rockstar::Track.new(artist, title) + track.scrobble(Time.now, @user.lastfm_session_key) + end + + respond_to do |format| + format.json { render :nothing => true } end + end - private + private def refresh(songs_as_json) - songs = [] - - Find.find(MUSIC_PATH) do |file| - if File.directory?(file) || file !~ /.*\.mp3$/i || file =~ /^\./ - #Rails.logger.info 'Skipping file: ' + file - next - end - - begin - mp3 = Song.new(file, songs.length) - songs.push(mp3) - rescue Exception => e - Rails.logger.info e - Rails.logger.info 'Failed to load MP3: ' + file - # TODO: collect the broken mp3s into a separate array - # TODO: count the broken mp3s - end + songs = [] + + Find.find(MUSIC_PATH) do |file| + if File.directory?(file) || file !~ /.*\.mp3$/i || file =~ /^\./ + #Rails.logger.info 'Skipping file: ' + file + next + end + + begin + mp3 = Song.new(file, songs.length) + songs.push(mp3) + rescue Exception => e + Rails.logger.info e + Rails.logger.info 'Failed to load MP3: ' + file + # TODO: collect the broken mp3s into a separate array + # TODO: count the broken mp3s end + end - songs = songs.sort_by { |song| song.to_natural_sort_string } + songs = songs.sort_by { |song| song.to_natural_sort_string } - songs_as_json = songs.to_json - File.open(SONGS_JSON_FILE, 'w') { |f| f.write(songs_as_json) } + songs_as_json = songs.to_json + File.open(SONGS_JSON_FILE, 'w') { |f| f.write(songs_as_json) } end end diff --git a/app/helpers/settings_helper.rb b/app/helpers/settings_helper.rb index 94e82d4..b28b746 100644 --- a/app/helpers/settings_helper.rb +++ b/app/helpers/settings_helper.rb @@ -1,8 +1,8 @@ require 'rockstar' module SettingsHelper - def lastfm_token_url - auth = Rockstar::Auth.new - auth.token - end + def lastfm_token_url + auth = Rockstar::Auth.new + auth.token + end end diff --git a/app/models/song.rb b/app/models/song.rb index 8647808..72d1698 100644 --- a/app/models/song.rb +++ b/app/models/song.rb @@ -1,76 +1,76 @@ MUSIC_PATH = Rails.application.config.music_paths class Song - attr_reader :id, :filename, :path, :artist, :title, :album, :tracknum, :length - - def initialize(path, id) - #file = File.new(path) - #@size = file.stat.size() - - @filename = File.basename(path) - @path = path.gsub(MUSIC_PATH, '') - @id = id - - @title = @filename - @artist = '' - @album = '' - @tracknum = nil - @length = 0 - - # ID3 tag info - info = Mp3Info.open(path) - tag = info.tag() - @title = tag['title'] if (!tag['title'].nil?) - @artist = tag['artist'] if (!tag['title'].nil?) - @album = tag['album'] - @tracknum = tag['tracknum'] - @length = info.length - - @nice_title = '' - @nice_title += (@artist.to_s + ' - ') if !@artist.nil? - @nice_title += @title.to_s - - @nice_length = (Time.mktime(0)+@length).strftime("%M:%S") - - # convert outgoing strings into valid utf-8 - - @title = to_utf8(@title) - @artist = to_utf8(@artist) if !@artist.nil? - @album = to_utf8(@album) if !@album.nil? - @nice_title = to_utf8(@nice_title) + attr_reader :id, :filename, :path, :artist, :title, :album, :tracknum, :length + + def initialize(path, id) + # file = File.new(path) + # @size = file.stat.size() + + @filename = File.basename(path) + @path = path.gsub(MUSIC_PATH, '') + @id = id + + @title = @filename + @artist = '' + @album = '' + @tracknum = nil + @length = 0 + + # ID3 tag info + info = Mp3Info.open(path) + tag = info.tag() + @title = tag['title'] if (!tag['title'].nil?) + @artist = tag['artist'] if (!tag['title'].nil?) + @album = tag['album'] + @tracknum = tag['tracknum'] + @length = info.length + + @nice_title = '' + @nice_title += (@artist.to_s + ' - ') if !@artist.nil? + @nice_title += @title.to_s + + @nice_length = (Time.mktime(0)+@length).strftime("%M:%S") + + # convert outgoing strings into valid utf-8 + + @title = to_utf8(@title) + @artist = to_utf8(@artist) if !@artist.nil? + @album = to_utf8(@album) if !@album.nil? + @nice_title = to_utf8(@nice_title) + end + + def to_s + @nice_title + end + + def to_natural_sort_string + str = "" + str += artist if !artist.nil? + str += ' ' + album if !album.nil? + + # dumb ass way to achieve natural sorting + if !tracknum.nil? + track = tracknum.to_s + + if tracknum < 100 + track = "0" + track + end + if tracknum < 10 + track = "0" + track + end + + str += ' ' + track end - def to_s - @nice_title + if str.empty? + str = filename end - def to_natural_sort_string - str = "" - str += artist if !artist.nil? - str += ' ' + album if !album.nil? + str + end - # dumb ass way to achieve natural sorting - if !tracknum.nil? - track = tracknum.to_s - - if tracknum < 100 - track = "0" + track - end - if tracknum < 10 - track = "0" + track - end - - str += ' ' + track - end - - if str.empty? - str = filename - end - - str - end - - private + private # Iconv UTF-8 helper # Converts string into valid UTF-8 @@ -78,8 +78,9 @@ def to_natural_sort_string # @param [String] untrusted_string the string to convert to UTF-8 # @return [String] passed string in UTF-8 def to_utf8 untrusted_string="" - ic = Iconv.new('UTF-8//IGNORE', 'ISO-8859-15') - ic.iconv(untrusted_string) - #ic.iconv(untrusted_string + ' ')[0..-2] + ic = Iconv.new('UTF-8//IGNORE', 'ISO-8859-15') + ic.iconv(untrusted_string) + #ic.iconv(untrusted_string + ' ')[0..-2] end + end diff --git a/app/models/user.rb b/app/models/user.rb index f225cc3..428b07b 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -41,6 +41,7 @@ def password=(password) end private + def password_must_be_present errors.add(:password, "Missing password") unless hashed_password.present? end @@ -48,4 +49,5 @@ def password_must_be_present def generate_salt self.salt = self.object_id.to_s + rand.to_s end + end From 113dee67ed31fd7616ccb00c162a15f4fb4ffa52 Mon Sep 17 00:00:00 2001 From: Antti-Jussi Kovalainen Date: Sun, 6 Jul 2014 19:02:31 +0300 Subject: [PATCH 012/203] Remove unused helpers --- app/helpers/main_helper.rb | 2 -- app/helpers/sessions_helper.rb | 2 -- app/helpers/settings_helper.rb | 8 -------- app/helpers/songs_helper.rb | 2 -- app/helpers/users_helper.rb | 2 -- test/performance/browsing_test.rb | 12 ------------ test/unit/helpers/main_helper_test.rb | 4 ---- test/unit/helpers/sessions_helper_test.rb | 4 ---- test/unit/helpers/settings_helper_test.rb | 4 ---- test/unit/helpers/songs_helper_test.rb | 4 ---- test/unit/helpers/users_helper_test.rb | 4 ---- 11 files changed, 48 deletions(-) delete mode 100644 app/helpers/main_helper.rb delete mode 100644 app/helpers/sessions_helper.rb delete mode 100644 app/helpers/settings_helper.rb delete mode 100644 app/helpers/songs_helper.rb delete mode 100644 app/helpers/users_helper.rb delete mode 100644 test/performance/browsing_test.rb delete mode 100644 test/unit/helpers/main_helper_test.rb delete mode 100644 test/unit/helpers/sessions_helper_test.rb delete mode 100644 test/unit/helpers/settings_helper_test.rb delete mode 100644 test/unit/helpers/songs_helper_test.rb delete mode 100644 test/unit/helpers/users_helper_test.rb diff --git a/app/helpers/main_helper.rb b/app/helpers/main_helper.rb deleted file mode 100644 index 826effe..0000000 --- a/app/helpers/main_helper.rb +++ /dev/null @@ -1,2 +0,0 @@ -module MainHelper -end diff --git a/app/helpers/sessions_helper.rb b/app/helpers/sessions_helper.rb deleted file mode 100644 index 309f8b2..0000000 --- a/app/helpers/sessions_helper.rb +++ /dev/null @@ -1,2 +0,0 @@ -module SessionsHelper -end diff --git a/app/helpers/settings_helper.rb b/app/helpers/settings_helper.rb deleted file mode 100644 index b28b746..0000000 --- a/app/helpers/settings_helper.rb +++ /dev/null @@ -1,8 +0,0 @@ -require 'rockstar' - -module SettingsHelper - def lastfm_token_url - auth = Rockstar::Auth.new - auth.token - end -end diff --git a/app/helpers/songs_helper.rb b/app/helpers/songs_helper.rb deleted file mode 100644 index 75baad2..0000000 --- a/app/helpers/songs_helper.rb +++ /dev/null @@ -1,2 +0,0 @@ -module SongsHelper -end diff --git a/app/helpers/users_helper.rb b/app/helpers/users_helper.rb deleted file mode 100644 index 2310a24..0000000 --- a/app/helpers/users_helper.rb +++ /dev/null @@ -1,2 +0,0 @@ -module UsersHelper -end diff --git a/test/performance/browsing_test.rb b/test/performance/browsing_test.rb deleted file mode 100644 index 3fea27b..0000000 --- a/test/performance/browsing_test.rb +++ /dev/null @@ -1,12 +0,0 @@ -require 'test_helper' -require 'rails/performance_test_help' - -class BrowsingTest < ActionDispatch::PerformanceTest - # Refer to the documentation for all available options - # self.profile_options = { :runs => 5, :metrics => [:wall_time, :memory] - # :output => 'tmp/performance', :formats => [:flat] } - - def test_homepage - get '/' - end -end diff --git a/test/unit/helpers/main_helper_test.rb b/test/unit/helpers/main_helper_test.rb deleted file mode 100644 index 22da3c4..0000000 --- a/test/unit/helpers/main_helper_test.rb +++ /dev/null @@ -1,4 +0,0 @@ -require 'test_helper' - -class MainHelperTest < ActionView::TestCase -end diff --git a/test/unit/helpers/sessions_helper_test.rb b/test/unit/helpers/sessions_helper_test.rb deleted file mode 100644 index 7d44e09..0000000 --- a/test/unit/helpers/sessions_helper_test.rb +++ /dev/null @@ -1,4 +0,0 @@ -require 'test_helper' - -class SessionsHelperTest < ActionView::TestCase -end diff --git a/test/unit/helpers/settings_helper_test.rb b/test/unit/helpers/settings_helper_test.rb deleted file mode 100644 index d5c1375..0000000 --- a/test/unit/helpers/settings_helper_test.rb +++ /dev/null @@ -1,4 +0,0 @@ -require 'test_helper' - -class SettingsHelperTest < ActionView::TestCase -end diff --git a/test/unit/helpers/songs_helper_test.rb b/test/unit/helpers/songs_helper_test.rb deleted file mode 100644 index 840544d..0000000 --- a/test/unit/helpers/songs_helper_test.rb +++ /dev/null @@ -1,4 +0,0 @@ -require 'test_helper' - -class SongsHelperTest < ActionView::TestCase -end diff --git a/test/unit/helpers/users_helper_test.rb b/test/unit/helpers/users_helper_test.rb deleted file mode 100644 index 96af37a..0000000 --- a/test/unit/helpers/users_helper_test.rb +++ /dev/null @@ -1,4 +0,0 @@ -require 'test_helper' - -class UsersHelperTest < ActionView::TestCase -end From 7ea154bd122edfddb332c839c55e93ac38ef9a42 Mon Sep 17 00:00:00 2001 From: Antti-Jussi Kovalainen Date: Sun, 6 Jul 2014 19:04:08 +0300 Subject: [PATCH 013/203] Add a base controller for API controllers --- app/controllers/api_v1/api_controller.rb | 30 ++++++++++++++++++++++++ 1 file changed, 30 insertions(+) create mode 100644 app/controllers/api_v1/api_controller.rb diff --git a/app/controllers/api_v1/api_controller.rb b/app/controllers/api_v1/api_controller.rb new file mode 100644 index 0000000..57b2a04 --- /dev/null +++ b/app/controllers/api_v1/api_controller.rb @@ -0,0 +1,30 @@ +module ApiV1 + class ApiController < ApplicationController + before_filter :set_default_render_format + + rescue_from ActiveRecord::RecordNotFound do |exception| + render_404(nil, exception.message) + end + + def render_error(message, status = :unprocessable_entity) + render :status => status, :json => { :error => message } + end + + def render_errors(errors, status = :unprocessable_entity) + render :status => status, :json => { :errors => errors } + end + + def render_404(class_name = nil, message = nil) + class_name ||= params[:controller].classify.demodulize.underscore + + render :json => { + :code => 404, + :errors => [message || "Couldn't find #{class_name} with id=#{params[:id]}"] + }, :status => not_found + end + + def set_default_render_format + request.format = :json + end + end +end From 0f90dbcb0aee758e5beb322035aaff52194a0901 Mon Sep 17 00:00:00 2001 From: Antti-Jussi Kovalainen Date: Sun, 6 Jul 2014 19:07:27 +0300 Subject: [PATCH 014/203] Routes: Move login/logout routes to the bottom --- config/routes.rb | 15 +++++++-------- 1 file changed, 7 insertions(+), 8 deletions(-) diff --git a/config/routes.rb b/config/routes.rb index da5f1a9..03ed950 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -5,13 +5,6 @@ match 'settings/lastfm_callback' => 'settings#lastfm_callback' match 'settings/lastfm_disconnect' => 'settings#lastfm_disconnect' - controller :sessions do - get 'login' => :new - post 'login' => :create - delete 'logout' => :destroy - get 'logout' => :destroy - end - match 'songs' => 'songs#index' get 'songs/index' get 'songs/play' @@ -21,7 +14,13 @@ match 'playlists' => 'playlists#index' get 'playlists/new' - #match '/' => 'main#index' + controller :sessions do + get 'login' => :new + post 'login' => :create + delete 'logout' => :destroy + get 'logout' => :destroy + end + root :to => 'main#index' end From d4f36e18ae2db62a15f4718914eecf125067d542 Mon Sep 17 00:00:00 2001 From: Antti-Jussi Kovalainen Date: Sun, 6 Jul 2014 19:07:39 +0300 Subject: [PATCH 015/203] Fix users fixtures --- test/fixtures/users.yml | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/test/fixtures/users.yml b/test/fixtures/users.yml index 87a41cf..550a9a7 100644 --- a/test/fixtures/users.yml +++ b/test/fixtures/users.yml @@ -1,11 +1,9 @@ # Read about fixtures at http://api.rubyonrails.org/classes/ActiveRecord/Fixtures.html one: - name: MyString - hashed_password: MyString - salt: MyString + username: Claire + email: claire@example.com two: - name: MyString - hashed_password: MyString - salt: MyString + username: Jack + email: jack@example.com From e65a0fdd73d4adc64dfff4e7f913c6137a1dd5e9 Mon Sep 17 00:00:00 2001 From: Antti-Jussi Kovalainen Date: Sun, 6 Jul 2014 19:12:51 +0300 Subject: [PATCH 016/203] Remove unusde playlists fixture --- test/fixtures/playlists.yml | 11 ----------- 1 file changed, 11 deletions(-) delete mode 100644 test/fixtures/playlists.yml diff --git a/test/fixtures/playlists.yml b/test/fixtures/playlists.yml deleted file mode 100644 index c63aac0..0000000 --- a/test/fixtures/playlists.yml +++ /dev/null @@ -1,11 +0,0 @@ -# Read about fixtures at http://api.rubyonrails.org/classes/ActiveRecord/Fixtures.html - -# This model initially had no columns defined. If you add columns to the -# model remove the '{}' from the fixture names and add the columns immediately -# below each fixture, per the syntax in the comments below -# -one: {} -# column: value -# -two: {} -# column: value From 60c0dfdd7c4c612f3cd7c3b95eee1985982e0df5 Mon Sep 17 00:00:00 2001 From: Antti-Jussi Kovalainen Date: Sun, 6 Jul 2014 19:14:07 +0300 Subject: [PATCH 017/203] Always authorized on test env --- app/controllers/application_controller.rb | 2 ++ 1 file changed, 2 insertions(+) diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb index 2489f08..5cfb33e 100644 --- a/app/controllers/application_controller.rb +++ b/app/controllers/application_controller.rb @@ -6,6 +6,8 @@ class ApplicationController < ActionController::Base protected def authorize + return true if Rails.env.test? + unless User.find_by_id(session[:user_id]) redirect_to login_url end From 01d2ca8fc2e8e30ed4a5cc3e65b04fc0e17b719b Mon Sep 17 00:00:00 2001 From: Antti-Jussi Kovalainen Date: Sun, 6 Jul 2014 19:59:37 +0300 Subject: [PATCH 018/203] Set SONGS_JSON_FILE to a String instead of Pathname --- app/controllers/songs_controller.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/controllers/songs_controller.rb b/app/controllers/songs_controller.rb index a3b1536..24ee60e 100644 --- a/app/controllers/songs_controller.rb +++ b/app/controllers/songs_controller.rb @@ -4,7 +4,7 @@ MUSIC_PATH = Rails.application.config.music_paths -SONGS_JSON_FILE = Rails.root.join('data/songs.json') +SONGS_JSON_FILE = Rails.root.join('data/songs.json').to_s class SongsController < ApplicationController From 76c034f4199abfaf9c7aa883355a247d1e0b5e68 Mon Sep 17 00:00:00 2001 From: Antti-Jussi Kovalainen Date: Sun, 6 Jul 2014 19:59:48 +0300 Subject: [PATCH 019/203] Song: Avoid double MUSIC_PATH declarations --- app/models/song.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/models/song.rb b/app/models/song.rb index 72d1698..0e4d21e 100644 --- a/app/models/song.rb +++ b/app/models/song.rb @@ -1,4 +1,4 @@ -MUSIC_PATH = Rails.application.config.music_paths +MUSIC_PATH = Rails.application.config.music_paths unless defined?(MUSIC_PATH) class Song attr_reader :id, :filename, :path, :artist, :title, :album, :tracknum, :length From 62a38fcffda1bc875cd7fc22e6916158ab45eeab Mon Sep 17 00:00:00 2001 From: Antti-Jussi Kovalainen Date: Sun, 6 Jul 2014 20:00:07 +0300 Subject: [PATCH 020/203] Add tests for /songs and /songs/play API endpoints --- Gemfile | 4 ++ Gemfile.lock | 2 + test/api_test_helper.rb | 32 ++++++++++++++ test/fixtures/files/1sec.mp3 | Bin 0 -> 6566 bytes test/fixtures/files/30sec.mp3 | Bin 0 -> 180543 bytes test/integration/api_v1/songs_test.rb | 58 ++++++++++++++++++++++++++ 6 files changed, 96 insertions(+) create mode 100644 test/api_test_helper.rb create mode 100644 test/fixtures/files/1sec.mp3 create mode 100644 test/fixtures/files/30sec.mp3 create mode 100644 test/integration/api_v1/songs_test.rb diff --git a/Gemfile b/Gemfile index 99220e9..bcd20ec 100644 --- a/Gemfile +++ b/Gemfile @@ -39,3 +39,7 @@ end group :development do gem 'quiet_assets' end + +group :test do + gem 'fakefs', :require => 'fakefs/safe' +end diff --git a/Gemfile.lock b/Gemfile.lock index fa8b4cd..dc9ed16 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -40,6 +40,7 @@ GEM eventmachine (1.0.3-x86-mingw32) execjs (1.4.0) multi_json (~> 1.0) + fakefs (0.5.2) hike (1.2.3) hpricot (0.8.6) hpricot (0.8.6-java) @@ -138,6 +139,7 @@ PLATFORMS DEPENDENCIES activerecord-jdbcsqlite3-adapter (= 1.2.9) + fakefs jquery-rails (= 2.0.2) mp3info (= 0.6.18) quiet_assets diff --git a/test/api_test_helper.rb b/test/api_test_helper.rb new file mode 100644 index 0000000..2a09b1d --- /dev/null +++ b/test/api_test_helper.rb @@ -0,0 +1,32 @@ +# API test helpers +def get_json(url) + get url, format: :json +end + +def post_json(url, data) + post url, data.to_json, { 'CONTENT_TYPE' => 'application/json' } +end + +def put_json(url, data) + put url, data.to_json, { 'CONTENT_TYPE' => 'application/json' } +end + +def delete_json(url) + delete url, format: :json +end + +def post_multipart(url, data) + post url, data, { 'CONTENT_TYPE' => 'multipart/form-data' } +end + +def assert_put_json(url, data) + put_json url, data + res1 = json_response + get_json url + res2 = json_response + assert_equal res1, res2 +end + +def json_response + ActiveSupport::JSON.decode @response.body +end diff --git a/test/fixtures/files/1sec.mp3 b/test/fixtures/files/1sec.mp3 new file mode 100644 index 0000000000000000000000000000000000000000..08f4868def164c84a1c1ed6e4258f4a6376857d6 GIT binary patch literal 6566 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 literal 0 HcmV?d00001 diff --git a/test/fixtures/files/30sec.mp3 b/test/fixtures/files/30sec.mp3 new file mode 100644 index 0000000000000000000000000000000000000000..9c0a381a9fbf1d77e2408f10a51affc3d9f04ae8 GIT binary patch literal 180543 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ú+;fzm4$My{fX8XC*k`;-RRaCX;0HP~a7 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

  • '); var rename_field = $(''); - + rename_field.onEnter(function () { var $this = $(this), value = $this.val() || '', diff --git a/app/assets/javascripts/songlist.js b/app/assets/javascripts/songlist.js index f0a4230..ae856f0 100644 --- a/app/assets/javascripts/songlist.js +++ b/app/assets/javascripts/songlist.js @@ -1,5 +1,6 @@ //= require jquery.cookie -//= require helpers +//= require lib/utils +//= require lib/key_codes //= require dragtooltip /*! * Abstracts SlickGrid away into oblivion! From 2f4ed21d15546981118f49e59e4c093ddfecdfc9 Mon Sep 17 00:00:00 2001 From: Antti-Jussi Kovalainen Date: Mon, 16 Feb 2015 09:52:05 +0200 Subject: [PATCH 143/203] Welcome to 2015 --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 20b2c62..e5c777f 100644 --- a/README.md +++ b/README.md @@ -48,7 +48,7 @@ These are also listed on [http://www.beatstream.fi/#quirks](http://www.beatstrea ### License -Copyright (c) 2012–2014 Antti-Jussi Kovalainen +Copyright (c) 2012–2015 Antti-Jussi Kovalainen Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the From 9f772d98bce3efc2437665ab16933902ae18669f Mon Sep 17 00:00:00 2001 From: Antti-Jussi Kovalainen Date: Mon, 16 Feb 2015 09:53:50 +0200 Subject: [PATCH 144/203] Attempt to make README prettier --- README.md | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index e5c777f..69ee6eb 100644 --- a/README.md +++ b/README.md @@ -1,12 +1,16 @@ # Beatstream -**IMPORTANT:** I have been experimenting with some re-writes and I'm now slowly porting these changes from my [dev](https://github.com/Darep/Beatstream/tree/dev) branch into `master`. Stay tuned! +> **IMPORTANT:** I have been experimenting with some re-writes and I'm now slowly porting these changes from my [dev](https://github.com/Darep/Beatstream/tree/dev) branch into `master`. Stay tuned! --- -"Code quality" [![Code Climate](https://codeclimate.com/github/Darep/Beatstream/badges/gpa.svg)](https://codeclimate.com/github/Darep/Beatstream) +"Code quality" +[![Code Climate](https://codeclimate.com/github/Darep/Beatstream/badges/gpa.svg)](https://codeclimate.com/github/Darep/Beatstream) -Test status [![Build Status](https://travis-ci.org/Darep/Beatstream.svg?branch=master)](https://travis-ci.org/Darep/Beatstream) +Tests +[![Build Status](https://travis-ci.org/Darep/Beatstream.svg?branch=master)](https://travis-ci.org/Darep/Beatstream) + +--- Beatstream is an app for streaming music from your computer to anywhere with a modern Web browser! (Beatstream currently requires Ruby to be installed on your computer) From 2199d2c56c2e62b559a525ffffac4ecffc1c374a Mon Sep 17 00:00:00 2001 From: Antti-Jussi Kovalainen Date: Sat, 14 Feb 2015 12:56:13 +0200 Subject: [PATCH 145/203] Load application.js just before --- app/views/layouts/application.html.erb | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/app/views/layouts/application.html.erb b/app/views/layouts/application.html.erb index 58c8123..0397af0 100644 --- a/app/views/layouts/application.html.erb +++ b/app/views/layouts/application.html.erb @@ -11,7 +11,6 @@ <%= stylesheet_link_tag "application" %> - <%= javascript_include_tag "application" %> <%= csrf_meta_tags %> @@ -84,5 +83,7 @@
    Loading
    +<%= javascript_include_tag "application" %> + From fc79764a416b0fd5c873cb74185d2e2db9060b54 Mon Sep 17 00:00:00 2001 From: Antti-Jussi Kovalainen Date: Sat, 14 Feb 2015 13:07:19 +0200 Subject: [PATCH 146/203] Store #sidebar element in $sidebar in sidebar.js --- app/assets/javascripts/sidebar.js | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/app/assets/javascripts/sidebar.js b/app/assets/javascripts/sidebar.js index 98b7450..ccd491f 100644 --- a/app/assets/javascripts/sidebar.js +++ b/app/assets/javascripts/sidebar.js @@ -7,24 +7,26 @@ */ $(document).ready(function () { + 'use strict'; + var $sidebar = $('#sidebar'); var NEW_PLAYLIST_NAME = 'New playlist'; for (var i = 0; i < Playlists.count(); i++) { var playlist = Playlists.getAtIndex(i); var id = playlist.id; var name = playlist.name; - $('#sidebar .playlists').append('
  • ' + name + '
  • '); + $sidebar.find('.playlists').append('
  • ' + name + '
  • '); } - $('#sidebar .playlists a').click(function () { + $sidebar.find('.playlists a').click(function () { var id = $(this).attr('data-id'); Songlist.loadPlaylist(id); return false; }); - $('#sidebar .new-list').click(function () { + $sidebar.find('.new-list').click(function () { var list_item = $('
  • '); var rename_field = $(''); @@ -50,7 +52,7 @@ $(document).ready(function () { }); list_item.append(rename_field); - $('#sidebar .playlists').append(list_item); + $sidebar.find('.playlists').append(list_item); rename_field.focus(); return false; From a518cf1a420920708038099fc23ffb184c34624a Mon Sep 17 00:00:00 2001 From: Antti-Jussi Kovalainen Date: Sat, 14 Feb 2015 16:56:34 +0200 Subject: [PATCH 147/203] Fix CSS and markup dumbness in header --- app/assets/stylesheets/components/_header.scss | 17 +++++++++++------ app/views/layouts/application.html.erb | 7 +++++-- 2 files changed, 16 insertions(+), 8 deletions(-) diff --git a/app/assets/stylesheets/components/_header.scss b/app/assets/stylesheets/components/_header.scss index 14f2820..33c033e 100644 --- a/app/assets/stylesheets/components/_header.scss +++ b/app/assets/stylesheets/components/_header.scss @@ -1,4 +1,4 @@ -header { +#header { background:#333; background: -webkit-linear-gradient(top, #424242, #242424); background: -moz-linear-gradient(top, #424242, #242424); @@ -13,12 +13,17 @@ header { -webkit-box-shadow: inset 0 1px 0 #505050; -moz-box-shadow: inset 0 1px 0 #505050; box-shadow: inset 0 1px 0 #505050; +} - #logo { - font:700 30px/55px 'PT Sans', sans-serif; - float:left; - position: relative; - text-shadow:0 1px 3px #000; +#logo { + font:700 30px/55px 'PT Sans', sans-serif; + float:left; + position: relative; + text-shadow:0 1px 3px #000; + + small { + font-size: 0.615em; + margin-left: 0.25em; } } diff --git a/app/views/layouts/application.html.erb b/app/views/layouts/application.html.erb index 0397af0..e22da8c 100644 --- a/app/views/layouts/application.html.erb +++ b/app/views/layouts/application.html.erb @@ -16,8 +16,11 @@
    -
    -

    Beatstream alpha

    +
  • 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 Date: Sun, 6 Jul 2014 20:24:19 +0300 Subject: [PATCH 021/203] Add tests for /songs/now_playing and /songs/scrobble --- Gemfile | 4 +++ Gemfile.lock | 8 ++++++ test/fixtures/users.yml | 5 ++-- test/fixtures/xml/lfm_track_scrobble.xml | 13 +++++++++ .../xml/lfm_track_updateNowPlaying.xml | 10 +++++++ test/integration/api_v1/songs_test.rb | 28 +++++++++++++++++-- test/test_helper.rb | 3 ++ 7 files changed, 67 insertions(+), 4 deletions(-) create mode 100644 test/fixtures/xml/lfm_track_scrobble.xml create mode 100644 test/fixtures/xml/lfm_track_updateNowPlaying.xml diff --git a/Gemfile b/Gemfile index bcd20ec..def2c68 100644 --- a/Gemfile +++ b/Gemfile @@ -41,5 +41,9 @@ group :development do end group :test do + # Mock the filesystem gem 'fakefs', :require => 'fakefs/safe' + + # Mock web requests + gem 'webmock' end diff --git a/Gemfile.lock b/Gemfile.lock index dc9ed16..4edf34d 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -32,8 +32,11 @@ GEM activesupport (3.2.19) i18n (~> 0.6, >= 0.6.4) multi_json (~> 1.0) + addressable (2.3.6) arel (3.0.3) builder (3.0.4) + crack (0.4.2) + safe_yaml (~> 1.0.0) daemons (1.1.9) erubis (2.7.0) eventmachine (1.0.3) @@ -96,6 +99,7 @@ GEM hpricot (>= 0.4.86) i18n rake + safe_yaml (1.0.3) sass (3.2.9) sass-rails (3.2.6) railties (~> 3.2.0) @@ -131,6 +135,9 @@ GEM uglifier (2.1.1) execjs (>= 0.3.0) multi_json (~> 1.0, >= 1.0.2) + webmock (1.18.0) + addressable (>= 2.3.6) + crack (>= 0.3.2) PLATFORMS java @@ -152,3 +159,4 @@ DEPENDENCIES thin (= 1.6.2) trinidad (= 1.4.6) uglifier (= 2.1.1) + webmock diff --git a/test/fixtures/users.yml b/test/fixtures/users.yml index 550a9a7..9a8ae1c 100644 --- a/test/fixtures/users.yml +++ b/test/fixtures/users.yml @@ -1,9 +1,10 @@ # Read about fixtures at http://api.rubyonrails.org/classes/ActiveRecord/Fixtures.html -one: +claire: username: Claire email: claire@example.com -two: +jack: username: Jack email: jack@example.com + lastfm_session_key: '1234567890abcdefghijklmn' diff --git a/test/fixtures/xml/lfm_track_scrobble.xml b/test/fixtures/xml/lfm_track_scrobble.xml new file mode 100644 index 0000000..2e5537f --- /dev/null +++ b/test/fixtures/xml/lfm_track_scrobble.xml @@ -0,0 +1,13 @@ + + + + + Silence + 30 second silence + + + 1287140447 + + + + diff --git a/test/fixtures/xml/lfm_track_updateNowPlaying.xml b/test/fixtures/xml/lfm_track_updateNowPlaying.xml new file mode 100644 index 0000000..40db963 --- /dev/null +++ b/test/fixtures/xml/lfm_track_updateNowPlaying.xml @@ -0,0 +1,10 @@ + + + + Silence + 30 second silence + + + + + diff --git a/test/integration/api_v1/songs_test.rb b/test/integration/api_v1/songs_test.rb index abe8520..0bfe64a 100644 --- a/test/integration/api_v1/songs_test.rb +++ b/test/integration/api_v1/songs_test.rb @@ -5,8 +5,20 @@ class ApiV1::SongsTest < ActionDispatch::IntegrationTest setup do FakeFS.deactivate! - @one = File.open(Rails.root.join('test', 'fixtures', 'files', '1sec.mp3')).read - @thirty = File.open(Rails.root.join('test', 'fixtures', 'files', '30sec.mp3')).read + @user = users(:jack) + + fixtures_dir = Rails.root.join('test', 'fixtures') + + # Mock last.fm requests + stub_request(:post, /https?:\/\/ws\.audioscrobbler\.com\/.*method=track\.updateNowPlaying.*/). + to_return(:status => 200, :headers => {}, :body => File.new(File.join(fixtures_dir, 'xml/lfm_track_updateNowPlaying.xml'))) + + stub_request(:post, /https?:\/\/ws\.audioscrobbler\.com\/.*method=track\.scrobble.*/). + to_return(:status => 200, :headers => {}, :body => File.new(File.join(fixtures_dir, 'xml/lfm_track_scrobble.xml'))) + + # Sample MP3 files + @one = File.open(File.join(fixtures_dir, 'files', '1sec.mp3')).read + @thirty = File.open(File.join(fixtures_dir, 'files', '30sec.mp3')).read # Mock the filesystem FakeFS.activate! @@ -55,4 +67,16 @@ class ApiV1::SongsTest < ActionDispatch::IntegrationTest get_json '/songs/play?file=test_dir/1s.mp3' assert_equal @response.body, @one.force_encoding('ASCII-8BIT') end + + test 'should send now_playing info to last.fm at /songs/now_playing' do + session[:user_id] = @user.id + get '/songs/now_playing?artist=Silence&title=30%20second%20silence', { :format => 'json' }, 'rack.session' => session + assert_response :success + end + + test 'should send scrobble to last.fm at /songs/scrobble' do + session[:user_id] = @user.id + get '/songs/scrobble?artist=Silence&title=30%20second%20silence', { :format => 'json' }, 'rack.session' => session + assert_response :success + end end diff --git a/test/test_helper.rb b/test/test_helper.rb index 8bf1192..e46ab9b 100644 --- a/test/test_helper.rb +++ b/test/test_helper.rb @@ -1,6 +1,9 @@ ENV["RAILS_ENV"] = "test" require File.expand_path('../../config/environment', __FILE__) require 'rails/test_help' +require 'webmock/minitest' + +WebMock.disable_net_connect! :allow_localhost => true class ActiveSupport::TestCase # Setup all fixtures in test/fixtures/*.(yml|csv) for all tests in alphabetical order. From 0ffe3c1c8d50ee7cb757b87b92a08d171dcfc66c Mon Sep 17 00:00:00 2001 From: Antti-Jussi Kovalainen Date: Mon, 7 Jul 2014 19:40:29 +0300 Subject: [PATCH 022/203] Move SongsController under ApiV1 namespace --- app/controllers/api_v1/songs_controller.rb | 110 +++++++++++++++++++++ app/controllers/songs_controller.rb | 108 -------------------- config/routes.rb | 13 +-- 3 files changed, 117 insertions(+), 114 deletions(-) create mode 100644 app/controllers/api_v1/songs_controller.rb delete mode 100644 app/controllers/songs_controller.rb diff --git a/app/controllers/api_v1/songs_controller.rb b/app/controllers/api_v1/songs_controller.rb new file mode 100644 index 0000000..7e591ca --- /dev/null +++ b/app/controllers/api_v1/songs_controller.rb @@ -0,0 +1,110 @@ +# -*- encoding : utf-8 -*- +require 'find' +require 'logger' + +MUSIC_PATH = Rails.application.config.music_paths + +SONGS_JSON_FILE = Rails.root.join('data/songs.json').to_s + +module ApiV1 + class SongsController < ApiController + + def index + songs_json = ''; + + if params[:refresh] + Rails.logger.info 'Forced song list refresh' + refresh(songs_json) + else + begin + f = File.open(SONGS_JSON_FILE, 'r') + Rails.logger.info 'Songs JSON modified: ' + f.mtime.to_s + songs_json = f.read + rescue Errno::ENOENT + Rails.logger.info 'Songs JSON file not found --> refreshing songs list' + refresh(songs_json) + end + end + + render :text => songs_json + end + + def play + filepath = MUSIC_PATH + params[:file] + + response.content_type = Mime::Type.lookup_by_extension("mp3") + + render :text => File.open(filepath, 'rb') { |f| f.read } + #send_file filepath, :type => 'audio/mpeg' + end + + def now_playing + expires_now # don't cache + + artist = params[:artist] + title = params[:title] + + @user = User.find(session[:user_id]) + + if @user != nil && @user.lastfm_session_key != nil + Rails.logger.info 'Update Now Playing to "' + artist + ' - ' + title + '" for user ' + @user.username + + track = Rockstar::Track.new(artist, title) + track.updateNowPlaying(Time.now, @user.lastfm_session_key) + end + + respond_to do |format| + format.json { render :nothing => true } + end + end + + def scrobble + expires_now # don't cache + + artist = params[:artist] + title = params[:title] + + @user = User.find(session[:user_id]) + + if @user != nil && @user.lastfm_session_key != nil + Rails.logger.info 'Scrobbling track "' + artist + ' - ' + title + '" for user ' + @user.username + + track = Rockstar::Track.new(artist, title) + track.scrobble(Time.now, @user.lastfm_session_key) + end + + respond_to do |format| + format.json { render :nothing => true } + end + end + + private + + def refresh(songs_as_json) + songs = [] + + Find.find(MUSIC_PATH) do |file| + if File.directory?(file) || file !~ /.*\.mp3$/i || file =~ /^\./ + #Rails.logger.info 'Skipping file: ' + file + next + end + + begin + mp3 = Song.new(file, songs.length) + songs.push(mp3) + rescue Exception => e + Rails.logger.info e + Rails.logger.info 'Failed to load MP3: ' + file + # TODO: collect the broken mp3s into a separate array + # TODO: count the broken mp3s + end + end + + songs = songs.sort_by { |song| song.to_natural_sort_string } + + songs_as_json = songs.to_json + File.open(SONGS_JSON_FILE, 'w') { |f| f.write(songs_as_json) } + end + + end +end diff --git a/app/controllers/songs_controller.rb b/app/controllers/songs_controller.rb deleted file mode 100644 index 24ee60e..0000000 --- a/app/controllers/songs_controller.rb +++ /dev/null @@ -1,108 +0,0 @@ -# -*- encoding : utf-8 -*- -require 'find' -require 'logger' - -MUSIC_PATH = Rails.application.config.music_paths - -SONGS_JSON_FILE = Rails.root.join('data/songs.json').to_s - -class SongsController < ApplicationController - - def index - songs_json = ''; - - if params[:refresh] - Rails.logger.info 'Forced song list refresh' - refresh(songs_json) - else - begin - f = File.open(SONGS_JSON_FILE, 'r') - Rails.logger.info 'Songs JSON modified: ' + f.mtime.to_s - songs_json = f.read - rescue Errno::ENOENT - Rails.logger.info 'Songs JSON file not found --> refreshing songs list' - refresh(songs_json) - end - end - - render :text => songs_json - end - - def play - filepath = MUSIC_PATH + params[:file] - - response.content_type = Mime::Type.lookup_by_extension("mp3") - - render :text => File.open(filepath, 'rb') { |f| f.read } - #send_file filepath, :type => 'audio/mpeg' - end - - def now_playing - expires_now # don't cache - - artist = params[:artist] - title = params[:title] - - @user = User.find(session[:user_id]) - - if @user != nil && @user.lastfm_session_key != nil - Rails.logger.info 'Update Now Playing to "' + artist + ' - ' + title + '" for user ' + @user.username - - track = Rockstar::Track.new(artist, title) - track.updateNowPlaying(Time.now, @user.lastfm_session_key) - end - - respond_to do |format| - format.json { render :nothing => true } - end - end - - def scrobble - expires_now # don't cache - - artist = params[:artist] - title = params[:title] - - @user = User.find(session[:user_id]) - - if @user != nil && @user.lastfm_session_key != nil - Rails.logger.info 'Scrobbling track "' + artist + ' - ' + title + '" for user ' + @user.username - - track = Rockstar::Track.new(artist, title) - track.scrobble(Time.now, @user.lastfm_session_key) - end - - respond_to do |format| - format.json { render :nothing => true } - end - end - - private - - def refresh(songs_as_json) - songs = [] - - Find.find(MUSIC_PATH) do |file| - if File.directory?(file) || file !~ /.*\.mp3$/i || file =~ /^\./ - #Rails.logger.info 'Skipping file: ' + file - next - end - - begin - mp3 = Song.new(file, songs.length) - songs.push(mp3) - rescue Exception => e - Rails.logger.info e - Rails.logger.info 'Failed to load MP3: ' + file - # TODO: collect the broken mp3s into a separate array - # TODO: count the broken mp3s - end - end - - songs = songs.sort_by { |song| song.to_natural_sort_string } - - songs_as_json = songs.to_json - File.open(SONGS_JSON_FILE, 'w') { |f| f.write(songs_as_json) } - end - -end diff --git a/config/routes.rb b/config/routes.rb index 03ed950..31b1c18 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -1,16 +1,17 @@ Beatstream::Application.routes.draw do + scope :module => :api_v1, :path => '' do + get 'songs/(index)' => 'songs#index' + get 'songs/play' + get 'songs/now_playing' + get 'songs/scrobble' + end + match 'settings' => 'settings#index' match 'settings/save' => 'settings#save' match 'settings/lastfm_callback' => 'settings#lastfm_callback' match 'settings/lastfm_disconnect' => 'settings#lastfm_disconnect' - match 'songs' => 'songs#index' - get 'songs/index' - get 'songs/play' - get 'songs/now_playing' - get 'songs/scrobble' - match 'playlists' => 'playlists#index' get 'playlists/new' From ad37f0d5ad00e0c2f4101114d1e93e0d73c0b7f6 Mon Sep 17 00:00:00 2001 From: Antti-Jussi Kovalainen Date: Mon, 7 Jul 2014 19:42:47 +0300 Subject: [PATCH 023/203] Add travis.yml for Travis CI --- .travis.yml | 12 ++++++++++++ 1 file changed, 12 insertions(+) create mode 100644 .travis.yml diff --git a/.travis.yml b/.travis.yml new file mode 100644 index 0000000..d82e4e9 --- /dev/null +++ b/.travis.yml @@ -0,0 +1,12 @@ +language: ruby +rvm: + - 2.1.2 + - 2.1.1 + - 2.0.0 + - 1.9.3 + - 1.9.2 + - 1.8.7 +script: rake test +notifications: + email: + - ajk@ajk.fi From a2632e284424aeb401c2c5e5f7b0ee4c43b4bbf3 Mon Sep 17 00:00:00 2001 From: Antti-Jussi Kovalainen Date: Mon, 7 Jul 2014 19:57:58 +0300 Subject: [PATCH 024/203] Add tests for songs index creation and refresh --- test/integration/api_v1/songs_test.rb | 55 +++++++++++++++++++++++++++ 1 file changed, 55 insertions(+) diff --git a/test/integration/api_v1/songs_test.rb b/test/integration/api_v1/songs_test.rb index 0bfe64a..49fa544 100644 --- a/test/integration/api_v1/songs_test.rb +++ b/test/integration/api_v1/songs_test.rb @@ -23,6 +23,10 @@ class ApiV1::SongsTest < ActionDispatch::IntegrationTest # Mock the filesystem FakeFS.activate! + # Clean the mocks + File.delete(Rails.root.join('data/songs.json').to_s) if File.exists?(Rails.root.join('data/songs.json')) + FileUtils.rm_rf(Rails.application.config.music_paths) if Dir.exists?(Rails.application.config.music_paths) + # Mock the directories required by songs controller FileUtils.mkdir_p(Rails.application.config.music_paths) FileUtils.mkdir_p(Rails.root.join('data')) @@ -40,6 +44,8 @@ class ApiV1::SongsTest < ActionDispatch::IntegrationTest @songs_json = "[{\"filename\":\"1sec.mp3\",\"path\":\"1sec.mp3\",\"id\":0,\"title\":\"1sec silence\",\"artist\":\"Sample\",\"album\":\"Silence is golden\",\"tracknum\":1,\"length\":1.071,\"nice_title\":\"Sample - 1sec silence\",\"nice_length\":\"00:01\"},{\"filename\":\"30sec.mp3\",\"path\":\"30sec.mp3\",\"id\":1,\"title\":\"30sec silence\",\"artist\":\"Sample\",\"album\":\"Silence is golden\",\"tracknum\":30,\"length\":30.066833333333335,\"nice_title\":\"Sample - 30sec silence\",\"nice_length\":\"00:30\"}]" end +# /songs + test 'should return all songs from /songs' do get_json '/songs' assert_equal @songs_json, @response.body @@ -50,6 +56,53 @@ class ApiV1::SongsTest < ActionDispatch::IntegrationTest assert_equal @songs_json, @response.body end +# songs.json + + test 'should create all songs index if it\'s missing' do + new_song = File.open(File.join(Rails.application.config.music_paths, 'new_song.mp3'), 'wb') + new_song.write(@one) + + # Populate the songs.json file + File.delete Rails.root.join('data/songs.json') + get_json '/songs' + + # Get the new list of songs + get_json '/songs' + + match = false + json_response.each do |i| + if i['path'] == 'new_song.mp3' + match = true + return + end + end + + assert match, 'new_song not found in JSON response' + end + + test 'should refresh songs index when ?refresh is present' do + new_song = File.open(File.join(Rails.application.config.music_paths, 'new_song.mp3'), 'wb') + new_song.write(@one) + + # Trigger the refresh + get_json '/songs?refresh=true' + + # Ask for the index again + get_json '/songs' + + match = false + json_response.each do |i| + if i['path'] == 'new_song.mp3' + match = true + return + end + end + + assert match, 'new_song not found in JSON response' + end + +# /songs/play + test 'should play song from /songs/play?file=1sec.mp3' do get_json '/songs/play?file=1sec.mp3' assert_equal @response.body, @one.force_encoding('ASCII-8BIT') @@ -68,6 +121,8 @@ class ApiV1::SongsTest < ActionDispatch::IntegrationTest assert_equal @response.body, @one.force_encoding('ASCII-8BIT') end +# scrobbling + test 'should send now_playing info to last.fm at /songs/now_playing' do session[:user_id] = @user.id get '/songs/now_playing?artist=Silence&title=30%20second%20silence', { :format => 'json' }, 'rack.session' => session From d8831ad8108b56a19baaebd2b59162b8798bba07 Mon Sep 17 00:00:00 2001 From: Antti-Jussi Kovalainen Date: Mon, 7 Jul 2014 19:58:47 +0300 Subject: [PATCH 025/203] Remove "rake test" from travis.yml --- .travis.yml | 1 - 1 file changed, 1 deletion(-) diff --git a/.travis.yml b/.travis.yml index d82e4e9..d3c0dc1 100644 --- a/.travis.yml +++ b/.travis.yml @@ -6,7 +6,6 @@ rvm: - 1.9.3 - 1.9.2 - 1.8.7 -script: rake test notifications: email: - ajk@ajk.fi From 17ddf45e85b70ca389dc4331ca6a18bc7e188cf1 Mon Sep 17 00:00:00 2001 From: Antti-Jussi Kovalainen Date: Mon, 7 Jul 2014 20:13:37 +0300 Subject: [PATCH 026/203] Add "rake db:migrate" and "rake test" as scripts for Travis CI --- .travis.yml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.travis.yml b/.travis.yml index d3c0dc1..e99bf2b 100644 --- a/.travis.yml +++ b/.travis.yml @@ -6,6 +6,9 @@ rvm: - 1.9.3 - 1.9.2 - 1.8.7 +script: + - RAILS_ENV=test bundle exec rake db:migrate --trace + - RAILS_ENV=test bundle exec rake test notifications: email: - ajk@ajk.fi From 2029d3725387f2683369c6504be2ad5cb5e7f160 Mon Sep 17 00:00:00 2001 From: Antti-Jussi Kovalainen Date: Mon, 7 Jul 2014 20:26:20 +0300 Subject: [PATCH 027/203] Create config/initializers/music_paths.rb file in Travis CI --- .travis.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.travis.yml b/.travis.yml index e99bf2b..4bffa03 100644 --- a/.travis.yml +++ b/.travis.yml @@ -6,6 +6,8 @@ rvm: - 1.9.3 - 1.9.2 - 1.8.7 +before_script: + - sed "s/'\/Users\/ajk\/Music\/'/Rails.root.join('data')/" config/initializers/music_paths.rb.sample > config/initializers/music_paths.rb script: - RAILS_ENV=test bundle exec rake db:migrate --trace - RAILS_ENV=test bundle exec rake test From dedd92ced0761db2fc7c51be8151b79fb0fb92fa Mon Sep 17 00:00:00 2001 From: Antti-Jussi Kovalainen Date: Mon, 7 Jul 2014 20:26:45 +0300 Subject: [PATCH 028/203] Don't test Ruby 2.x on Travis CI, they're not supported yet --- .travis.yml | 3 --- 1 file changed, 3 deletions(-) diff --git a/.travis.yml b/.travis.yml index 4bffa03..b5c5273 100644 --- a/.travis.yml +++ b/.travis.yml @@ -1,8 +1,5 @@ language: ruby rvm: - - 2.1.2 - - 2.1.1 - - 2.0.0 - 1.9.3 - 1.9.2 - 1.8.7 From c4ae4bc43f9a62e3cbe3e5ccc125008f5ec0d311 Mon Sep 17 00:00:00 2001 From: Antti-Jussi Kovalainen Date: Mon, 7 Jul 2014 20:32:01 +0300 Subject: [PATCH 029/203] Remove controller tests (relics of the past) --- test/functional/.gitkeep | 0 test/functional/main_controller_test.rb | 7 --- test/functional/sessions_controller_test.rb | 19 -------- test/functional/settings_controller_test.rb | 14 ------ test/functional/songs_controller_test.rb | 9 ---- test/functional/users_controller_test.rb | 49 --------------------- 6 files changed, 98 deletions(-) delete mode 100644 test/functional/.gitkeep delete mode 100644 test/functional/main_controller_test.rb delete mode 100644 test/functional/sessions_controller_test.rb delete mode 100644 test/functional/settings_controller_test.rb delete mode 100644 test/functional/songs_controller_test.rb delete mode 100644 test/functional/users_controller_test.rb diff --git a/test/functional/.gitkeep b/test/functional/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/test/functional/main_controller_test.rb b/test/functional/main_controller_test.rb deleted file mode 100644 index b7ec6bf..0000000 --- a/test/functional/main_controller_test.rb +++ /dev/null @@ -1,7 +0,0 @@ -require 'test_helper' - -class MainControllerTest < ActionController::TestCase - # test "the truth" do - # assert true - # end -end diff --git a/test/functional/sessions_controller_test.rb b/test/functional/sessions_controller_test.rb deleted file mode 100644 index 7e1dbde..0000000 --- a/test/functional/sessions_controller_test.rb +++ /dev/null @@ -1,19 +0,0 @@ -require 'test_helper' - -class SessionsControllerTest < ActionController::TestCase - test "should get new" do - get :new - assert_response :success - end - - test "should get create" do - get :create - assert_response :success - end - - test "should get destroy" do - get :destroy - assert_response :success - end - -end diff --git a/test/functional/settings_controller_test.rb b/test/functional/settings_controller_test.rb deleted file mode 100644 index 6d39d54..0000000 --- a/test/functional/settings_controller_test.rb +++ /dev/null @@ -1,14 +0,0 @@ -require 'test_helper' - -class SettingsControllerTest < ActionController::TestCase - test "should get index" do - get :index - assert_response :success - end - - test "should get save" do - get :save - assert_response :success - end - -end diff --git a/test/functional/songs_controller_test.rb b/test/functional/songs_controller_test.rb deleted file mode 100644 index 5a7c083..0000000 --- a/test/functional/songs_controller_test.rb +++ /dev/null @@ -1,9 +0,0 @@ -require 'test_helper' - -class SongsControllerTest < ActionController::TestCase - test "should get index" do - get :index - assert_response :success - end - -end diff --git a/test/functional/users_controller_test.rb b/test/functional/users_controller_test.rb deleted file mode 100644 index c1bcba4..0000000 --- a/test/functional/users_controller_test.rb +++ /dev/null @@ -1,49 +0,0 @@ -require 'test_helper' - -class UsersControllerTest < ActionController::TestCase - setup do - @user = users(:one) - end - - test "should get index" do - get :index - assert_response :success - assert_not_nil assigns(:users) - end - - test "should get new" do - get :new - assert_response :success - end - - test "should create user" do - assert_difference('User.count') do - post :create, :user => @user.attributes - end - - assert_redirected_to user_path(assigns(:user)) - end - - test "should show user" do - get :show, :id => @user - assert_response :success - end - - test "should get edit" do - get :edit, :id => @user - assert_response :success - end - - test "should update user" do - put :update, :id => @user, :user => @user.attributes - assert_redirected_to user_path(assigns(:user)) - end - - test "should destroy user" do - assert_difference('User.count', -1) do - delete :destroy, :id => @user - end - - assert_redirected_to users_path - end -end From ebcfe533b507f7598189797882acb3b9e6d34851 Mon Sep 17 00:00:00 2001 From: Antti-Jussi Kovalainen Date: Mon, 7 Jul 2014 20:32:23 +0300 Subject: [PATCH 030/203] Test if simple "cp" for music_paths.rb works better in Travis CI --- .travis.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.travis.yml b/.travis.yml index b5c5273..59bb901 100644 --- a/.travis.yml +++ b/.travis.yml @@ -4,7 +4,7 @@ rvm: - 1.9.2 - 1.8.7 before_script: - - sed "s/'\/Users\/ajk\/Music\/'/Rails.root.join('data')/" config/initializers/music_paths.rb.sample > config/initializers/music_paths.rb + - cp config/initializers/music_paths.rb.sample config/initializers/music_paths.rb script: - RAILS_ENV=test bundle exec rake db:migrate --trace - RAILS_ENV=test bundle exec rake test From 3428e47b8f38ec977be1c71ef12c7cf8b2c123d1 Mon Sep 17 00:00:00 2001 From: Antti-Jussi Kovalainen Date: Mon, 7 Jul 2014 20:44:14 +0300 Subject: [PATCH 031/203] Require "webmock/test_unit" on Ruby 1.8.x --- test/test_helper.rb | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/test/test_helper.rb b/test/test_helper.rb index e46ab9b..158f6ea 100644 --- a/test/test_helper.rb +++ b/test/test_helper.rb @@ -1,7 +1,12 @@ ENV["RAILS_ENV"] = "test" require File.expand_path('../../config/environment', __FILE__) require 'rails/test_help' -require 'webmock/minitest' + +if RUBY_VERSION.starts_with? '1.8' + require 'webmock/test_unit' +else + require 'webmock/minitest' +end WebMock.disable_net_connect! :allow_localhost => true From 2b8f099d775eb2d909e7eb010f725a437d9bae46 Mon Sep 17 00:00:00 2001 From: Antti-Jussi Kovalainen Date: Mon, 7 Jul 2014 20:52:43 +0300 Subject: [PATCH 032/203] Use rocket syntax in api_test_helper.rb --- test/api_test_helper.rb | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/test/api_test_helper.rb b/test/api_test_helper.rb index 2a09b1d..5f1aa24 100644 --- a/test/api_test_helper.rb +++ b/test/api_test_helper.rb @@ -1,6 +1,6 @@ # API test helpers def get_json(url) - get url, format: :json + get url, :format => :json end def post_json(url, data) @@ -12,7 +12,7 @@ def put_json(url, data) end def delete_json(url) - delete url, format: :json + delete url, :format => :json end def post_multipart(url, data) From 7d23009dfc2a9f7b8a4c645fb6ebd719ca0c8c99 Mon Sep 17 00:00:00 2001 From: Antti-Jussi Kovalainen Date: Tue, 8 Jul 2014 14:06:39 +0300 Subject: [PATCH 033/203] Use shorter and Ruby 1.8.7 compatible .each in songs integration tests --- test/integration/api_v1/songs_test.rb | 14 ++------------ 1 file changed, 2 insertions(+), 12 deletions(-) diff --git a/test/integration/api_v1/songs_test.rb b/test/integration/api_v1/songs_test.rb index 49fa544..51023e1 100644 --- a/test/integration/api_v1/songs_test.rb +++ b/test/integration/api_v1/songs_test.rb @@ -70,12 +70,7 @@ class ApiV1::SongsTest < ActionDispatch::IntegrationTest get_json '/songs' match = false - json_response.each do |i| - if i['path'] == 'new_song.mp3' - match = true - return - end - end + json_response.each { |i| match ||= (i['path'] == 'new_song.mp3') } assert match, 'new_song not found in JSON response' end @@ -91,12 +86,7 @@ class ApiV1::SongsTest < ActionDispatch::IntegrationTest get_json '/songs' match = false - json_response.each do |i| - if i['path'] == 'new_song.mp3' - match = true - return - end - end + json_response.each { |i| match ||= (i['path'] == 'new_song.mp3') } assert match, 'new_song not found in JSON response' end From 0d0c159d3e4c397793d38dafc9a9825ac37fe471 Mon Sep 17 00:00:00 2001 From: Antti-Jussi Kovalainen Date: Tue, 8 Jul 2014 14:15:20 +0300 Subject: [PATCH 034/203] Trying a Ruby 1.8.7 compatible binary string check --- test/integration/api_v1/songs_test.rb | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/test/integration/api_v1/songs_test.rb b/test/integration/api_v1/songs_test.rb index 51023e1..1a11b4b 100644 --- a/test/integration/api_v1/songs_test.rb +++ b/test/integration/api_v1/songs_test.rb @@ -1,6 +1,8 @@ require 'test_helper' require 'api_test_helper' +require 'iconv' if RUBY_VERSION.starts_with? '1.8' + class ApiV1::SongsTest < ActionDispatch::IntegrationTest setup do FakeFS.deactivate! @@ -44,6 +46,14 @@ class ApiV1::SongsTest < ActionDispatch::IntegrationTest @songs_json = "[{\"filename\":\"1sec.mp3\",\"path\":\"1sec.mp3\",\"id\":0,\"title\":\"1sec silence\",\"artist\":\"Sample\",\"album\":\"Silence is golden\",\"tracknum\":1,\"length\":1.071,\"nice_title\":\"Sample - 1sec silence\",\"nice_length\":\"00:01\"},{\"filename\":\"30sec.mp3\",\"path\":\"30sec.mp3\",\"id\":1,\"title\":\"30sec silence\",\"artist\":\"Sample\",\"album\":\"Silence is golden\",\"tracknum\":30,\"length\":30.066833333333335,\"nice_title\":\"Sample - 30sec silence\",\"nice_length\":\"00:30\"}]" end + def in_binary(string) + if RUBY_VERSION.starts_with? '1.8' + ::Iconv.conv('UTF-8//IGNORE', 'ASCII-8BIT', string) + else + @one.force_encoding('ASCII-8BIT') + end + end + # /songs test 'should return all songs from /songs' do @@ -95,7 +105,7 @@ class ApiV1::SongsTest < ActionDispatch::IntegrationTest test 'should play song from /songs/play?file=1sec.mp3' do get_json '/songs/play?file=1sec.mp3' - assert_equal @response.body, @one.force_encoding('ASCII-8BIT') + assert_equal @response.body, in_binary(@one) end test 'should play song from /songs/play?file=test_dir/1s.mp3' do @@ -108,7 +118,7 @@ class ApiV1::SongsTest < ActionDispatch::IntegrationTest # assert get_json '/songs/play?file=test_dir/1s.mp3' - assert_equal @response.body, @one.force_encoding('ASCII-8BIT') + assert_equal @response.body, in_binary(@one) end # scrobbling From f4eafac9e663de52ebfa57f17896036e1fbd152d Mon Sep 17 00:00:00 2001 From: Antti-Jussi Kovalainen Date: Tue, 8 Jul 2014 14:15:57 +0300 Subject: [PATCH 035/203] Remove Ruby 1.8.7 from Travis CI; cannot reliably test that anymore --- .travis.yml | 1 - 1 file changed, 1 deletion(-) diff --git a/.travis.yml b/.travis.yml index 59bb901..1ad5078 100644 --- a/.travis.yml +++ b/.travis.yml @@ -2,7 +2,6 @@ language: ruby rvm: - 1.9.3 - 1.9.2 - - 1.8.7 before_script: - cp config/initializers/music_paths.rb.sample config/initializers/music_paths.rb script: From 111249649c31c4e94ca3e3fc8b48be0b4e613a04 Mon Sep 17 00:00:00 2001 From: Antti-Jussi Kovalainen Date: Tue, 8 Jul 2014 14:58:10 +0300 Subject: [PATCH 036/203] Remove Playlist and User unit test files; no tests there... --- test/unit/playlist_test.rb | 7 ------- test/unit/user_test.rb | 7 ------- 2 files changed, 14 deletions(-) delete mode 100644 test/unit/playlist_test.rb delete mode 100644 test/unit/user_test.rb diff --git a/test/unit/playlist_test.rb b/test/unit/playlist_test.rb deleted file mode 100644 index cf79b13..0000000 --- a/test/unit/playlist_test.rb +++ /dev/null @@ -1,7 +0,0 @@ -require 'test_helper' - -class PlaylistTest < ActiveSupport::TestCase - # test "the truth" do - # assert true - # end -end diff --git a/test/unit/user_test.rb b/test/unit/user_test.rb deleted file mode 100644 index 82f61e0..0000000 --- a/test/unit/user_test.rb +++ /dev/null @@ -1,7 +0,0 @@ -require 'test_helper' - -class UserTest < ActiveSupport::TestCase - # test "the truth" do - # assert true - # end -end From 0ef185aec7a96022718c558f82190a92e38eead9 Mon Sep 17 00:00:00 2001 From: Antti-Jussi Kovalainen Date: Tue, 8 Jul 2014 15:14:47 +0300 Subject: [PATCH 037/203] Move Song-related constants into models/song.rb --- app/controllers/api_v1/songs_controller.rb | 12 ++++-------- app/models/song.rb | 13 +++++++++++-- 2 files changed, 15 insertions(+), 10 deletions(-) diff --git a/app/controllers/api_v1/songs_controller.rb b/app/controllers/api_v1/songs_controller.rb index 7e591ca..5387bb1 100644 --- a/app/controllers/api_v1/songs_controller.rb +++ b/app/controllers/api_v1/songs_controller.rb @@ -2,10 +2,6 @@ require 'find' require 'logger' -MUSIC_PATH = Rails.application.config.music_paths - -SONGS_JSON_FILE = Rails.root.join('data/songs.json').to_s - module ApiV1 class SongsController < ApiController @@ -17,7 +13,7 @@ def index refresh(songs_json) else begin - f = File.open(SONGS_JSON_FILE, 'r') + f = File.open(Song.SONGS_JSON_FILE, 'r') Rails.logger.info 'Songs JSON modified: ' + f.mtime.to_s songs_json = f.read rescue Errno::ENOENT @@ -30,7 +26,7 @@ def index end def play - filepath = MUSIC_PATH + params[:file] + filepath = Song.MUSIC_PATH + params[:file] response.content_type = Mime::Type.lookup_by_extension("mp3") @@ -83,7 +79,7 @@ def scrobble def refresh(songs_as_json) songs = [] - Find.find(MUSIC_PATH) do |file| + Find.find(Song.MUSIC_PATH) do |file| if File.directory?(file) || file !~ /.*\.mp3$/i || file =~ /^\./ #Rails.logger.info 'Skipping file: ' + file next @@ -103,7 +99,7 @@ def refresh(songs_as_json) songs = songs.sort_by { |song| song.to_natural_sort_string } songs_as_json = songs.to_json - File.open(SONGS_JSON_FILE, 'w') { |f| f.write(songs_as_json) } + File.open(Song.SONGS_JSON_FILE, 'w') { |f| f.write(songs_as_json) } end end diff --git a/app/models/song.rb b/app/models/song.rb index 0e4d21e..6a10b96 100644 --- a/app/models/song.rb +++ b/app/models/song.rb @@ -1,8 +1,17 @@ -MUSIC_PATH = Rails.application.config.music_paths unless defined?(MUSIC_PATH) - class Song + MUSIC_PATH = Rails.application.config.music_paths.to_s + SONGS_JSON_FILE = Rails.root.join('data', 'songs.json').to_s + attr_reader :id, :filename, :path, :artist, :title, :album, :tracknum, :length + def self.MUSIC_PATH + MUSIC_PATH + end + + def self.SONGS_JSON_FILE + SONGS_JSON_FILE + end + def initialize(path, id) # file = File.new(path) # @size = file.stat.size() From 92135b64691386c2ca941f112a2f5e5cec6683e9 Mon Sep 17 00:00:00 2001 From: Antti-Jussi Kovalainen Date: Tue, 8 Jul 2014 15:23:55 +0300 Subject: [PATCH 038/203] Song: Set @id before @filename so it's first item in the object --- app/models/song.rb | 5 +---- test/integration/api_v1/songs_test.rb | 2 +- 2 files changed, 2 insertions(+), 5 deletions(-) diff --git a/app/models/song.rb b/app/models/song.rb index 6a10b96..992b40d 100644 --- a/app/models/song.rb +++ b/app/models/song.rb @@ -13,12 +13,9 @@ def self.SONGS_JSON_FILE end def initialize(path, id) - # file = File.new(path) - # @size = file.stat.size() - + @id = id @filename = File.basename(path) @path = path.gsub(MUSIC_PATH, '') - @id = id @title = @filename @artist = '' diff --git a/test/integration/api_v1/songs_test.rb b/test/integration/api_v1/songs_test.rb index 1a11b4b..5891de0 100644 --- a/test/integration/api_v1/songs_test.rb +++ b/test/integration/api_v1/songs_test.rb @@ -43,7 +43,7 @@ class ApiV1::SongsTest < ActionDispatch::IntegrationTest # Populate the songs.json file... maybe should do this some other way? :) get_json '/songs' - @songs_json = "[{\"filename\":\"1sec.mp3\",\"path\":\"1sec.mp3\",\"id\":0,\"title\":\"1sec silence\",\"artist\":\"Sample\",\"album\":\"Silence is golden\",\"tracknum\":1,\"length\":1.071,\"nice_title\":\"Sample - 1sec silence\",\"nice_length\":\"00:01\"},{\"filename\":\"30sec.mp3\",\"path\":\"30sec.mp3\",\"id\":1,\"title\":\"30sec silence\",\"artist\":\"Sample\",\"album\":\"Silence is golden\",\"tracknum\":30,\"length\":30.066833333333335,\"nice_title\":\"Sample - 30sec silence\",\"nice_length\":\"00:30\"}]" + @songs_json = "[{\"id\":0,\"filename\":\"1sec.mp3\",\"path\":\"1sec.mp3\",\"title\":\"1sec silence\",\"artist\":\"Sample\",\"album\":\"Silence is golden\",\"tracknum\":1,\"length\":1.071,\"nice_title\":\"Sample - 1sec silence\",\"nice_length\":\"00:01\"},{\"id\":1,\"filename\":\"30sec.mp3\",\"path\":\"30sec.mp3\",\"title\":\"30sec silence\",\"artist\":\"Sample\",\"album\":\"Silence is golden\",\"tracknum\":30,\"length\":30.066833333333335,\"nice_title\":\"Sample - 30sec silence\",\"nice_length\":\"00:30\"}]" end def in_binary(string) From 989ff29e92b22226debe1f4bac7a852ef14b4f51 Mon Sep 17 00:00:00 2001 From: Antti-Jussi Kovalainen Date: Tue, 8 Jul 2014 15:31:48 +0300 Subject: [PATCH 039/203] Move mp3 mocking into test_helper.rb and add support for unit tests --- test/integration/api_v1/songs_test.rb | 21 +-------------------- test/test_helper.rb | 23 +++++++++++++++++++++++ 2 files changed, 24 insertions(+), 20 deletions(-) diff --git a/test/integration/api_v1/songs_test.rb b/test/integration/api_v1/songs_test.rb index 5891de0..6ef32a3 100644 --- a/test/integration/api_v1/songs_test.rb +++ b/test/integration/api_v1/songs_test.rb @@ -18,28 +18,9 @@ class ApiV1::SongsTest < ActionDispatch::IntegrationTest stub_request(:post, /https?:\/\/ws\.audioscrobbler\.com\/.*method=track\.scrobble.*/). to_return(:status => 200, :headers => {}, :body => File.new(File.join(fixtures_dir, 'xml/lfm_track_scrobble.xml'))) - # Sample MP3 files - @one = File.open(File.join(fixtures_dir, 'files', '1sec.mp3')).read - @thirty = File.open(File.join(fixtures_dir, 'files', '30sec.mp3')).read - - # Mock the filesystem + mock_mp3s FakeFS.activate! - # Clean the mocks - File.delete(Rails.root.join('data/songs.json').to_s) if File.exists?(Rails.root.join('data/songs.json')) - FileUtils.rm_rf(Rails.application.config.music_paths) if Dir.exists?(Rails.application.config.music_paths) - - # Mock the directories required by songs controller - FileUtils.mkdir_p(Rails.application.config.music_paths) - FileUtils.mkdir_p(Rails.root.join('data')) - - # Create a few mock MP3 files - one_mock = File.open(File.join(Rails.application.config.music_paths, '1sec.mp3'), 'wb') - one_mock.write(@one) - - thirty_mock = File.open(File.join(Rails.application.config.music_paths, '30sec.mp3'), 'wb') - thirty_mock.write(@thirty) - # Populate the songs.json file... maybe should do this some other way? :) get_json '/songs' diff --git a/test/test_helper.rb b/test/test_helper.rb index 158f6ea..48c8495 100644 --- a/test/test_helper.rb +++ b/test/test_helper.rb @@ -19,3 +19,26 @@ class ActiveSupport::TestCase # Add more helper methods to be used by all tests here... end + +def mock_mp3s + # Sample MP3 files + @one = File.open(Rails.root.join('test', 'fixtures', 'files', '1sec.mp3')).read + @thirty = File.open(Rails.root.join('test', 'fixtures', 'files', '30sec.mp3')).read + + FakeFS do + # Clean the mocks + File.delete(Song.SONGS_JSON_FILE) if File.exists?(Song.SONGS_JSON_FILE) + FileUtils.rm_rf(Song.MUSIC_PATH) if Dir.exists?(Song.MUSIC_PATH) + + # Mock the directories required by songs controller + FileUtils.mkdir_p(Song.MUSIC_PATH) + FileUtils.mkdir_p(Rails.root.join('data')) + + # Create a few mock MP3 files + one_mock = File.open(File.join(Song.MUSIC_PATH, '1sec.mp3'), 'wb') + one_mock.write(@one) + + thirty_mock = File.open(File.join(Song.MUSIC_PATH, '30sec.mp3'), 'wb') + thirty_mock.write(@thirty) + end +end From 5877fced8d5cce18c815bb76bf8d92c7ba592b8d Mon Sep 17 00:00:00 2001 From: Antti-Jussi Kovalainen Date: Tue, 8 Jul 2014 16:05:22 +0300 Subject: [PATCH 040/203] Move refresh() from SongsController to Song --- app/controllers/api_v1/songs_controller.rb | 25 ++------------------- app/models/song.rb | 26 ++++++++++++++++++++++ 2 files changed, 28 insertions(+), 23 deletions(-) diff --git a/app/controllers/api_v1/songs_controller.rb b/app/controllers/api_v1/songs_controller.rb index 5387bb1..a4101cb 100644 --- a/app/controllers/api_v1/songs_controller.rb +++ b/app/controllers/api_v1/songs_controller.rb @@ -77,29 +77,8 @@ def scrobble private def refresh(songs_as_json) - songs = [] - - Find.find(Song.MUSIC_PATH) do |file| - if File.directory?(file) || file !~ /.*\.mp3$/i || file =~ /^\./ - #Rails.logger.info 'Skipping file: ' + file - next - end - - begin - mp3 = Song.new(file, songs.length) - songs.push(mp3) - rescue Exception => e - Rails.logger.info e - Rails.logger.info 'Failed to load MP3: ' + file - # TODO: collect the broken mp3s into a separate array - # TODO: count the broken mp3s - end - end - - songs = songs.sort_by { |song| song.to_natural_sort_string } - - songs_as_json = songs.to_json - File.open(Song.SONGS_JSON_FILE, 'w') { |f| f.write(songs_as_json) } + Song.refresh + songs_as_json = Song.all_as_json end end diff --git a/app/models/song.rb b/app/models/song.rb index 992b40d..ee4af70 100644 --- a/app/models/song.rb +++ b/app/models/song.rb @@ -4,6 +4,32 @@ class Song attr_reader :id, :filename, :path, :artist, :title, :album, :tracknum, :length + def self.refresh + songs = [] + + Find.find(MUSIC_PATH) do |file| + if File.directory?(file) || file !~ /.*\.mp3$/i || file =~ /^\./ + #Rails.logger.info 'Skipping file: ' + file + next + end + + begin + mp3 = new(file, songs.length) + songs.push(mp3) + rescue Exception => e + Rails.logger.info e + Rails.logger.info 'Failed to load MP3: ' + file + # TODO: collect the broken mp3s into a separate array + # TODO: count the broken mp3s + end + end + + songs = songs.sort_by { |song| song.to_natural_sort_string } + + songs_as_json = songs.to_json + File.open(SONGS_JSON_FILE, 'w') { |f| f.write(songs_as_json) } + end + def self.MUSIC_PATH MUSIC_PATH end From b3b7fc0126bb822ac2a5caeffaf6ff52d0c8bd7c Mon Sep 17 00:00:00 2001 From: Antti-Jussi Kovalainen Date: Tue, 8 Jul 2014 16:05:53 +0300 Subject: [PATCH 041/203] Add unit test file for Song --- test/test_helper.rb | 9 +++++++-- test/unit/song_test.rb | 13 +++++++++++++ 2 files changed, 20 insertions(+), 2 deletions(-) create mode 100644 test/unit/song_test.rb diff --git a/test/test_helper.rb b/test/test_helper.rb index 48c8495..5262a20 100644 --- a/test/test_helper.rb +++ b/test/test_helper.rb @@ -21,9 +21,12 @@ class ActiveSupport::TestCase end def mock_mp3s + was_active = FakeFS.activated? + FakeFS.deactivate! if was_active + # Sample MP3 files - @one = File.open(Rails.root.join('test', 'fixtures', 'files', '1sec.mp3')).read - @thirty = File.open(Rails.root.join('test', 'fixtures', 'files', '30sec.mp3')).read + @one = File.open(Rails.root.join('test', 'fixtures', 'files', '1sec.mp3').to_s).read + @thirty = File.open(Rails.root.join('test', 'fixtures', 'files', '30sec.mp3').to_s).read FakeFS do # Clean the mocks @@ -41,4 +44,6 @@ def mock_mp3s thirty_mock = File.open(File.join(Song.MUSIC_PATH, '30sec.mp3'), 'wb') thirty_mock.write(@thirty) end + + FakeFS.activate! if was_active end diff --git a/test/unit/song_test.rb b/test/unit/song_test.rb new file mode 100644 index 0000000..d80833f --- /dev/null +++ b/test/unit/song_test.rb @@ -0,0 +1,13 @@ +require 'test_helper' + +class SongTest < ActiveSupport::TestCase + setup do + mock_mp3s + FakeFS.activate! + end + + test 'refresh populates the Songs' do + Song.refresh + assert_not_empty Song.all_as_json + end +end From 0108c5beba4b1035ed807be35f50995d65094299 Mon Sep 17 00:00:00 2001 From: Antti-Jussi Kovalainen Date: Tue, 8 Jul 2014 16:12:29 +0300 Subject: [PATCH 042/203] Song: Add .all and .all_as_json --- app/models/song.rb | 33 ++++++++++++++++++++++++++++----- test/unit/song_test.rb | 22 ++++++++++++++++++++++ 2 files changed, 50 insertions(+), 5 deletions(-) diff --git a/app/models/song.rb b/app/models/song.rb index ee4af70..55e387b 100644 --- a/app/models/song.rb +++ b/app/models/song.rb @@ -4,6 +4,23 @@ class Song attr_reader :id, :filename, :path, :artist, :title, :album, :tracknum, :length + def self.MUSIC_PATH + MUSIC_PATH + end + + def self.SONGS_JSON_FILE + SONGS_JSON_FILE + end + + def self.all + JSON.parse(all_as_json) + end + + def self.all_as_json + json = songs_file.read + json.present? ? json : '[]' + end + def self.refresh songs = [] @@ -30,12 +47,18 @@ def self.refresh File.open(SONGS_JSON_FILE, 'w') { |f| f.write(songs_as_json) } end - def self.MUSIC_PATH - MUSIC_PATH - end + def self.songs_file + begin + file = File.open(SONGS_JSON_FILE, 'r') + Rails.logger.info "Songs JSON last modified on #{file.mtime.to_s}" + rescue Errno::ENOENT => e + # File not found + FileUtils.touch(SONGS_JSON_FILE) + file = File.open(SONGS_JSON_FILE, 'r') + Rails.logger.info "Songs JSON last modified on #{file.mtime.to_s}" + end - def self.SONGS_JSON_FILE - SONGS_JSON_FILE + return file end def initialize(path, id) diff --git a/test/unit/song_test.rb b/test/unit/song_test.rb index d80833f..b55516b 100644 --- a/test/unit/song_test.rb +++ b/test/unit/song_test.rb @@ -6,8 +6,30 @@ class SongTest < ActiveSupport::TestCase FakeFS.activate! end + def populate + Song.refresh + end + test 'refresh populates the Songs' do Song.refresh assert_not_empty Song.all_as_json end + + test 'all_as_json returns empty JSON array if Songs are not populated' do + assert_equal '[]', Song.all_as_json, 'should return empty array [] when there are no Songs loaded into the system' + end + + test 'all_as_json returns Songs as json when they are populated' do + populate + assert_match /"filename": ?"1sec\.mp3"/, Song.all_as_json + end + + test 'all returns nil when Songs are not populated' do + assert_empty Song.all + end + + test 'all returns Songs as Hashes when Songs are populated' do + populate + assert_equal '1sec.mp3', Song.all.first['filename'] + end end From 4738ea0a46f0c7d7b88adb5f7caf1bec42e42e99 Mon Sep 17 00:00:00 2001 From: Antti-Jussi Kovalainen Date: Tue, 8 Jul 2014 16:19:56 +0300 Subject: [PATCH 043/203] Song: Add .find --- app/models/song.rb | 9 +++++++++ test/unit/song_test.rb | 13 +++++++++++++ 2 files changed, 22 insertions(+) diff --git a/app/models/song.rb b/app/models/song.rb index 55e387b..cf5fc88 100644 --- a/app/models/song.rb +++ b/app/models/song.rb @@ -21,6 +21,15 @@ def self.all_as_json json.present? ? json : '[]' end + def self.find(param) + song_hash = all.find { |s| s['id'].to_i == param } + if song_hash.blank? + raise ActiveRecord::RecordNotFound, "Couldn't find Song with id=#{param}" + else + song_hash + end + end + def self.refresh songs = [] diff --git a/test/unit/song_test.rb b/test/unit/song_test.rb index b55516b..87c8c07 100644 --- a/test/unit/song_test.rb +++ b/test/unit/song_test.rb @@ -32,4 +32,17 @@ def populate populate assert_equal '1sec.mp3', Song.all.first['filename'] end + + test 'find returns Song with a specific id' do + populate + song = Song.find(0) + assert_equal '1sec.mp3', song['filename'] + end + + test 'find raises ActiveRecord::RecordNotFound for non-existing song' do + populate + assert_raise ActiveRecord::RecordNotFound do + Song.find(1337) + end + end end From 2fd26eca695f49948f11fd97c04a7154bb8329a9 Mon Sep 17 00:00:00 2001 From: Antti-Jussi Kovalainen Date: Tue, 8 Jul 2014 16:20:47 +0300 Subject: [PATCH 044/203] Arrange SongTest tests alphabetically --- test/unit/song_test.rb | 24 ++++++++++++------------ 1 file changed, 12 insertions(+), 12 deletions(-) diff --git a/test/unit/song_test.rb b/test/unit/song_test.rb index 87c8c07..28b1d08 100644 --- a/test/unit/song_test.rb +++ b/test/unit/song_test.rb @@ -10,9 +10,13 @@ def populate Song.refresh end - test 'refresh populates the Songs' do - Song.refresh - assert_not_empty Song.all_as_json + test 'all returns nil when Songs are not populated' do + assert_empty Song.all + end + + test 'all returns Songs as Hashes when Songs are populated' do + populate + assert_equal '1sec.mp3', Song.all.first['filename'] end test 'all_as_json returns empty JSON array if Songs are not populated' do @@ -24,15 +28,6 @@ def populate assert_match /"filename": ?"1sec\.mp3"/, Song.all_as_json end - test 'all returns nil when Songs are not populated' do - assert_empty Song.all - end - - test 'all returns Songs as Hashes when Songs are populated' do - populate - assert_equal '1sec.mp3', Song.all.first['filename'] - end - test 'find returns Song with a specific id' do populate song = Song.find(0) @@ -45,4 +40,9 @@ def populate Song.find(1337) end end + + test 'refresh populates the Songs' do + Song.refresh + assert_not_empty Song.all_as_json + end end From 707b7f9c5cd626d86f0e19d7d96deca1faadd183 Mon Sep 17 00:00:00 2001 From: Antti-Jussi Kovalainen Date: Tue, 8 Jul 2014 16:25:56 +0300 Subject: [PATCH 045/203] SongsController: Use Song model's .refresh and .all_as_json --- app/controllers/api_v1/songs_controller.rb | 20 +++++++------------- 1 file changed, 7 insertions(+), 13 deletions(-) diff --git a/app/controllers/api_v1/songs_controller.rb b/app/controllers/api_v1/songs_controller.rb index a4101cb..442dd5a 100644 --- a/app/controllers/api_v1/songs_controller.rb +++ b/app/controllers/api_v1/songs_controller.rb @@ -6,23 +6,17 @@ module ApiV1 class SongsController < ApiController def index - songs_json = ''; + songs_as_json = nil if params[:refresh] Rails.logger.info 'Forced song list refresh' - refresh(songs_json) + songs_as_json = refresh_and_get_all else - begin - f = File.open(Song.SONGS_JSON_FILE, 'r') - Rails.logger.info 'Songs JSON modified: ' + f.mtime.to_s - songs_json = f.read - rescue Errno::ENOENT - Rails.logger.info 'Songs JSON file not found --> refreshing songs list' - refresh(songs_json) - end + songs_as_json = Song.all_as_json + songs_as_json = refresh_and_get_all if songs_as_json == '[]' end - render :text => songs_json + render :text => songs_as_json end def play @@ -76,9 +70,9 @@ def scrobble private - def refresh(songs_as_json) + def refresh_and_get_all Song.refresh - songs_as_json = Song.all_as_json + Song.all_as_json end end From 0292a402158ecad15ab0a7c9724df63d0713969b Mon Sep 17 00:00:00 2001 From: Antti-Jussi Kovalainen Date: Tue, 8 Jul 2014 16:38:52 +0300 Subject: [PATCH 046/203] SongsController: Remove obsolete "require 'logger'" --- app/controllers/api_v1/songs_controller.rb | 1 - 1 file changed, 1 deletion(-) diff --git a/app/controllers/api_v1/songs_controller.rb b/app/controllers/api_v1/songs_controller.rb index 442dd5a..5f55f64 100644 --- a/app/controllers/api_v1/songs_controller.rb +++ b/app/controllers/api_v1/songs_controller.rb @@ -1,6 +1,5 @@ # -*- encoding : utf-8 -*- require 'find' -require 'logger' module ApiV1 class SongsController < ApiController From 2fef70243c27456fb778a4633bc213310944e3e8 Mon Sep 17 00:00:00 2001 From: Antti-Jussi Kovalainen Date: Tue, 8 Jul 2014 16:43:23 +0300 Subject: [PATCH 047/203] Deactivate FakeFS by default before every integration test --- test/integration/api_v1/songs_test.rb | 5 +---- test/test_helper.rb | 7 ++++++- 2 files changed, 7 insertions(+), 5 deletions(-) diff --git a/test/integration/api_v1/songs_test.rb b/test/integration/api_v1/songs_test.rb index 6ef32a3..23317bd 100644 --- a/test/integration/api_v1/songs_test.rb +++ b/test/integration/api_v1/songs_test.rb @@ -5,13 +5,10 @@ class ApiV1::SongsTest < ActionDispatch::IntegrationTest setup do - FakeFS.deactivate! - @user = users(:jack) - fixtures_dir = Rails.root.join('test', 'fixtures') - # Mock last.fm requests + fixtures_dir = Rails.root.join('test', 'fixtures') stub_request(:post, /https?:\/\/ws\.audioscrobbler\.com\/.*method=track\.updateNowPlaying.*/). to_return(:status => 200, :headers => {}, :body => File.new(File.join(fixtures_dir, 'xml/lfm_track_updateNowPlaying.xml'))) diff --git a/test/test_helper.rb b/test/test_helper.rb index 5262a20..cb0d00a 100644 --- a/test/test_helper.rb +++ b/test/test_helper.rb @@ -16,8 +16,13 @@ class ActiveSupport::TestCase # Note: You'll currently still have to declare fixtures explicitly in integration tests # -- they do not yet inherit this setting fixtures :all +end - # Add more helper methods to be used by all tests here... +class ActionDispatch::IntegrationTest + setup do + # Deactivate always by default + FakeFS.deactivate! + end end def mock_mp3s From b12154ddc21675a57e9c80deef3d5a3cebd222eb Mon Sep 17 00:00:00 2001 From: Antti-Jussi Kovalainen Date: Tue, 8 Jul 2014 16:43:51 +0300 Subject: [PATCH 048/203] Move scrobbling stuff into its own controller --- .../api_v1/scrobbles_controller.rb | 42 +++++++++++++++++++ app/controllers/api_v1/songs_controller.rb | 40 ------------------ config/routes.rb | 4 +- test/integration/api_v1/scrobbles_test.rb | 40 ++++++++++++++++++ test/integration/api_v1/songs_test.rb | 14 ------- 5 files changed, 84 insertions(+), 56 deletions(-) create mode 100644 app/controllers/api_v1/scrobbles_controller.rb create mode 100644 test/integration/api_v1/scrobbles_test.rb diff --git a/app/controllers/api_v1/scrobbles_controller.rb b/app/controllers/api_v1/scrobbles_controller.rb new file mode 100644 index 0000000..8172148 --- /dev/null +++ b/app/controllers/api_v1/scrobbles_controller.rb @@ -0,0 +1,42 @@ +module ApiV1 + class ScrobblesController < ApiController + before_filter :expires_now + + def now_playing + artist = params[:artist] + title = params[:title] + + @user = User.find(session[:user_id]) + + if @user != nil && @user.lastfm_session_key != nil + Rails.logger.info 'Update Now Playing to "' + artist + ' - ' + title + '" for user ' + @user.username + + track = Rockstar::Track.new(artist, title) + track.updateNowPlaying(Time.now, @user.lastfm_session_key) + end + + respond_to do |format| + format.json { render :nothing => true } + end + end + + def scrobble + artist = params[:artist] + title = params[:title] + + @user = User.find(session[:user_id]) + + if @user != nil && @user.lastfm_session_key != nil + Rails.logger.info 'Scrobbling track "' + artist + ' - ' + title + '" for user ' + @user.username + + track = Rockstar::Track.new(artist, title) + track.scrobble(Time.now, @user.lastfm_session_key) + end + + respond_to do |format| + format.json { render :nothing => true } + end + end + + end +end diff --git a/app/controllers/api_v1/songs_controller.rb b/app/controllers/api_v1/songs_controller.rb index 5f55f64..fadcb0d 100644 --- a/app/controllers/api_v1/songs_controller.rb +++ b/app/controllers/api_v1/songs_controller.rb @@ -27,46 +27,6 @@ def play #send_file filepath, :type => 'audio/mpeg' end - def now_playing - expires_now # don't cache - - artist = params[:artist] - title = params[:title] - - @user = User.find(session[:user_id]) - - if @user != nil && @user.lastfm_session_key != nil - Rails.logger.info 'Update Now Playing to "' + artist + ' - ' + title + '" for user ' + @user.username - - track = Rockstar::Track.new(artist, title) - track.updateNowPlaying(Time.now, @user.lastfm_session_key) - end - - respond_to do |format| - format.json { render :nothing => true } - end - end - - def scrobble - expires_now # don't cache - - artist = params[:artist] - title = params[:title] - - @user = User.find(session[:user_id]) - - if @user != nil && @user.lastfm_session_key != nil - Rails.logger.info 'Scrobbling track "' + artist + ' - ' + title + '" for user ' + @user.username - - track = Rockstar::Track.new(artist, title) - track.scrobble(Time.now, @user.lastfm_session_key) - end - - respond_to do |format| - format.json { render :nothing => true } - end - end - private def refresh_and_get_all diff --git a/config/routes.rb b/config/routes.rb index 31b1c18..3023b14 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -3,8 +3,8 @@ scope :module => :api_v1, :path => '' do get 'songs/(index)' => 'songs#index' get 'songs/play' - get 'songs/now_playing' - get 'songs/scrobble' + get 'songs/now_playing' => 'scrobbles#now_playing' + get 'songs/scrobble' => 'scrobbles#scrobble' end match 'settings' => 'settings#index' diff --git a/test/integration/api_v1/scrobbles_test.rb b/test/integration/api_v1/scrobbles_test.rb new file mode 100644 index 0000000..d7ff190 --- /dev/null +++ b/test/integration/api_v1/scrobbles_test.rb @@ -0,0 +1,40 @@ +require 'test_helper' +require 'api_test_helper' + +class ApiV1::ScrobblesTest < ActionDispatch::IntegrationTest + setup do + # Mock last.fm requests + fixtures_dir = Rails.root.join('test', 'fixtures') + + stub_request(:post, /https?:\/\/ws\.audioscrobbler\.com\/.*method=track\.updateNowPlaying.*/). + to_return(:status => 200, :headers => {}, :body => File.new(File.join(fixtures_dir, 'xml/lfm_track_updateNowPlaying.xml'))) + + stub_request(:post, /https?:\/\/ws\.audioscrobbler\.com\/.*method=track\.scrobble.*/). + to_return(:status => 200, :headers => {}, :body => File.new(File.join(fixtures_dir, 'xml/lfm_track_scrobble.xml'))) + + # Perform a HTTP request so the "session" object is initialized + get_json '/songs' + + # Set current user + @user = users(:jack) + session[:user_id] = @user.id + end + + def in_binary(string) + if RUBY_VERSION.starts_with? '1.8' + ::Iconv.conv('UTF-8//IGNORE', 'ASCII-8BIT', string) + else + @one.force_encoding('ASCII-8BIT') + end + end + + test 'should send now_playing info to last.fm at /songs/now_playing' do + get '/songs/now_playing?artist=Silence&title=30%20second%20silence', { :format => 'json' }, 'rack.session' => session + assert_response :success + end + + test 'should send scrobble to last.fm at /songs/scrobble' do + get '/songs/scrobble?artist=Silence&title=30%20second%20silence', { :format => 'json' }, 'rack.session' => session + assert_response :success + end +end diff --git a/test/integration/api_v1/songs_test.rb b/test/integration/api_v1/songs_test.rb index 23317bd..811a67d 100644 --- a/test/integration/api_v1/songs_test.rb +++ b/test/integration/api_v1/songs_test.rb @@ -98,18 +98,4 @@ def in_binary(string) get_json '/songs/play?file=test_dir/1s.mp3' assert_equal @response.body, in_binary(@one) end - -# scrobbling - - test 'should send now_playing info to last.fm at /songs/now_playing' do - session[:user_id] = @user.id - get '/songs/now_playing?artist=Silence&title=30%20second%20silence', { :format => 'json' }, 'rack.session' => session - assert_response :success - end - - test 'should send scrobble to last.fm at /songs/scrobble' do - session[:user_id] = @user.id - get '/songs/scrobble?artist=Silence&title=30%20second%20silence', { :format => 'json' }, 'rack.session' => session - assert_response :success - end end From a6b0d52ec56a48664169a2d48a41871e14b1e01f Mon Sep 17 00:00:00 2001 From: Antti-Jussi Kovalainen Date: Tue, 8 Jul 2014 16:44:25 +0300 Subject: [PATCH 049/203] Use Song.refresh in SongsTest to populate the Songs --- test/integration/api_v1/songs_test.rb | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/test/integration/api_v1/songs_test.rb b/test/integration/api_v1/songs_test.rb index 811a67d..3551568 100644 --- a/test/integration/api_v1/songs_test.rb +++ b/test/integration/api_v1/songs_test.rb @@ -18,8 +18,8 @@ class ApiV1::SongsTest < ActionDispatch::IntegrationTest mock_mp3s FakeFS.activate! - # Populate the songs.json file... maybe should do this some other way? :) - get_json '/songs' + # Populate the Songs + Song.refresh @songs_json = "[{\"id\":0,\"filename\":\"1sec.mp3\",\"path\":\"1sec.mp3\",\"title\":\"1sec silence\",\"artist\":\"Sample\",\"album\":\"Silence is golden\",\"tracknum\":1,\"length\":1.071,\"nice_title\":\"Sample - 1sec silence\",\"nice_length\":\"00:01\"},{\"id\":1,\"filename\":\"30sec.mp3\",\"path\":\"30sec.mp3\",\"title\":\"30sec silence\",\"artist\":\"Sample\",\"album\":\"Silence is golden\",\"tracknum\":30,\"length\":30.066833333333335,\"nice_title\":\"Sample - 30sec silence\",\"nice_length\":\"00:30\"}]" end From b96d2573ea75565853f4cce878e3dbba9c0d4a2d Mon Sep 17 00:00:00 2001 From: Antti-Jussi Kovalainen Date: Tue, 8 Jul 2014 17:04:01 +0300 Subject: [PATCH 050/203] ApiV1Controller: Fix missing : in render_404 --- app/controllers/api_v1/api_controller.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/controllers/api_v1/api_controller.rb b/app/controllers/api_v1/api_controller.rb index 57b2a04..0d679c2 100644 --- a/app/controllers/api_v1/api_controller.rb +++ b/app/controllers/api_v1/api_controller.rb @@ -20,7 +20,7 @@ def render_404(class_name = nil, message = nil) render :json => { :code => 404, :errors => [message || "Couldn't find #{class_name} with id=#{params[:id]}"] - }, :status => not_found + }, :status => :not_found end def set_default_render_format From a0007906127fcce2d6acb32cbf00547d77c353c2 Mon Sep 17 00:00:00 2001 From: Antti-Jussi Kovalainen Date: Tue, 8 Jul 2014 17:04:29 +0300 Subject: [PATCH 051/203] ApiV1Controller: Add missing "code" field in render_error and render_errors --- app/controllers/api_v1/api_controller.rb | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/controllers/api_v1/api_controller.rb b/app/controllers/api_v1/api_controller.rb index 0d679c2..cec05b9 100644 --- a/app/controllers/api_v1/api_controller.rb +++ b/app/controllers/api_v1/api_controller.rb @@ -7,11 +7,11 @@ class ApiController < ApplicationController end def render_error(message, status = :unprocessable_entity) - render :status => status, :json => { :error => message } + render :code => 422, :status => status, :json => { :error => message } end def render_errors(errors, status = :unprocessable_entity) - render :status => status, :json => { :errors => errors } + render :code => 422, :status => status, :json => { :errors => errors } end def render_404(class_name = nil, message = nil) From f820e7760777e6685673d67bc3808fffbd9ee8e0 Mon Sep 17 00:00:00 2001 From: Antti-Jussi Kovalainen Date: Tue, 8 Jul 2014 17:07:43 +0300 Subject: [PATCH 052/203] Change ScrobblesTest to actually assert the requests --- test/integration/api_v1/scrobbles_test.rb | 28 +++++++++-------------- 1 file changed, 11 insertions(+), 17 deletions(-) diff --git a/test/integration/api_v1/scrobbles_test.rb b/test/integration/api_v1/scrobbles_test.rb index d7ff190..ee49c7e 100644 --- a/test/integration/api_v1/scrobbles_test.rb +++ b/test/integration/api_v1/scrobbles_test.rb @@ -3,15 +3,6 @@ class ApiV1::ScrobblesTest < ActionDispatch::IntegrationTest setup do - # Mock last.fm requests - fixtures_dir = Rails.root.join('test', 'fixtures') - - stub_request(:post, /https?:\/\/ws\.audioscrobbler\.com\/.*method=track\.updateNowPlaying.*/). - to_return(:status => 200, :headers => {}, :body => File.new(File.join(fixtures_dir, 'xml/lfm_track_updateNowPlaying.xml'))) - - stub_request(:post, /https?:\/\/ws\.audioscrobbler\.com\/.*method=track\.scrobble.*/). - to_return(:status => 200, :headers => {}, :body => File.new(File.join(fixtures_dir, 'xml/lfm_track_scrobble.xml'))) - # Perform a HTTP request so the "session" object is initialized get_json '/songs' @@ -20,21 +11,24 @@ class ApiV1::ScrobblesTest < ActionDispatch::IntegrationTest session[:user_id] = @user.id end - def in_binary(string) - if RUBY_VERSION.starts_with? '1.8' - ::Iconv.conv('UTF-8//IGNORE', 'ASCII-8BIT', string) - else - @one.force_encoding('ASCII-8BIT') - end + def xml_file(filename) + xml_dir = Rails.root.join('test', 'fixtures', 'xml') + File.new(File.join(xml_dir, filename)) end test 'should send now_playing info to last.fm at /songs/now_playing' do + now_playing_stub = stub_request(:post, /https?:\/\/ws\.audioscrobbler\.com\/.*method=track\.updateNowPlaying.*/). + to_return(:status => 200, :headers => {}, :body => xml_file('lfm_track_updateNowPlaying.xml')) + get '/songs/now_playing?artist=Silence&title=30%20second%20silence', { :format => 'json' }, 'rack.session' => session - assert_response :success + assert_requested now_playing_stub end test 'should send scrobble to last.fm at /songs/scrobble' do + scrobble_stub = stub_request(:post, /https?:\/\/ws\.audioscrobbler\.com\/.*method=track\.scrobble.*/). + to_return(:status => 200, :headers => {}, :body => xml_file('lfm_track_scrobble.xml')) + get '/songs/scrobble?artist=Silence&title=30%20second%20silence', { :format => 'json' }, 'rack.session' => session - assert_response :success + assert_requested scrobble_stub end end From de72f3afd40af19b9d7439dcfc4e0cd8e62ccd8d Mon Sep 17 00:00:00 2001 From: Antti-Jussi Kovalainen Date: Tue, 8 Jul 2014 17:09:02 +0300 Subject: [PATCH 053/203] Test that scrobbling returns 404 if user is not found --- test/integration/api_v1/scrobbles_test.rb | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/test/integration/api_v1/scrobbles_test.rb b/test/integration/api_v1/scrobbles_test.rb index ee49c7e..663f8dc 100644 --- a/test/integration/api_v1/scrobbles_test.rb +++ b/test/integration/api_v1/scrobbles_test.rb @@ -31,4 +31,10 @@ def xml_file(filename) get '/songs/scrobble?artist=Silence&title=30%20second%20silence', { :format => 'json' }, 'rack.session' => session assert_requested scrobble_stub end + + test 'should return RecordNotFound error if user is not found on scrobble' do + session[:user_id] = 78991423413 + get '/songs/scrobble?artist=Silence&title=30%20second%20silence', { :format => 'json' }, 'rack.session' => session + assert_response :not_found + end end From 41fef78c01fac424e31f7b25e7dbef43f35cdec4 Mon Sep 17 00:00:00 2001 From: Antti-Jussi Kovalainen Date: Tue, 8 Jul 2014 17:09:44 +0300 Subject: [PATCH 054/203] ScrobblesController: Remove needless "respond_to" blocks --- app/controllers/api_v1/scrobbles_controller.rb | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/app/controllers/api_v1/scrobbles_controller.rb b/app/controllers/api_v1/scrobbles_controller.rb index 8172148..4ec3ee4 100644 --- a/app/controllers/api_v1/scrobbles_controller.rb +++ b/app/controllers/api_v1/scrobbles_controller.rb @@ -15,9 +15,7 @@ def now_playing track.updateNowPlaying(Time.now, @user.lastfm_session_key) end - respond_to do |format| - format.json { render :nothing => true } - end + render :nothing => true end def scrobble @@ -33,9 +31,7 @@ def scrobble track.scrobble(Time.now, @user.lastfm_session_key) end - respond_to do |format| - format.json { render :nothing => true } - end + render :nothing => true end end From 92fe14f8978162d637b02cc9a8987e8367f33cbd Mon Sep 17 00:00:00 2001 From: Antti-Jussi Kovalainen Date: Tue, 8 Jul 2014 17:10:53 +0300 Subject: [PATCH 055/203] ScrobblesController: Move user fetching into a private method and memoize result --- .../api_v1/scrobbles_controller.rb | 22 ++++++++++--------- 1 file changed, 12 insertions(+), 10 deletions(-) diff --git a/app/controllers/api_v1/scrobbles_controller.rb b/app/controllers/api_v1/scrobbles_controller.rb index 4ec3ee4..8bfde2e 100644 --- a/app/controllers/api_v1/scrobbles_controller.rb +++ b/app/controllers/api_v1/scrobbles_controller.rb @@ -6,13 +6,11 @@ def now_playing artist = params[:artist] title = params[:title] - @user = User.find(session[:user_id]) - - if @user != nil && @user.lastfm_session_key != nil - Rails.logger.info 'Update Now Playing to "' + artist + ' - ' + title + '" for user ' + @user.username + if user != nil && user.lastfm_session_key != nil + Rails.logger.info 'Update Now Playing to "' + artist + ' - ' + title + '" for user ' + user.username track = Rockstar::Track.new(artist, title) - track.updateNowPlaying(Time.now, @user.lastfm_session_key) + track.updateNowPlaying(Time.now, user.lastfm_session_key) end render :nothing => true @@ -22,17 +20,21 @@ def scrobble artist = params[:artist] title = params[:title] - @user = User.find(session[:user_id]) - - if @user != nil && @user.lastfm_session_key != nil - Rails.logger.info 'Scrobbling track "' + artist + ' - ' + title + '" for user ' + @user.username + if user != nil && user.lastfm_session_key != nil + Rails.logger.info 'Scrobbling track "' + artist + ' - ' + title + '" for user ' + user.username track = Rockstar::Track.new(artist, title) - track.scrobble(Time.now, @user.lastfm_session_key) + track.scrobble(Time.now, user.lastfm_session_key) end render :nothing => true end + private + + def user + @user ||= User.find(session[:user_id]) + end + end end From cfbbd0b207003ad485b886ab298292f8e75e8a2c Mon Sep 17 00:00:00 2001 From: Antti-Jussi Kovalainen Date: Tue, 8 Jul 2014 17:11:59 +0300 Subject: [PATCH 056/203] ScrobblesController: Move "track" fetching into a memoizing getter --- app/controllers/api_v1/scrobbles_controller.rb | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/app/controllers/api_v1/scrobbles_controller.rb b/app/controllers/api_v1/scrobbles_controller.rb index 8bfde2e..151a4f5 100644 --- a/app/controllers/api_v1/scrobbles_controller.rb +++ b/app/controllers/api_v1/scrobbles_controller.rb @@ -9,7 +9,6 @@ def now_playing if user != nil && user.lastfm_session_key != nil Rails.logger.info 'Update Now Playing to "' + artist + ' - ' + title + '" for user ' + user.username - track = Rockstar::Track.new(artist, title) track.updateNowPlaying(Time.now, user.lastfm_session_key) end @@ -23,7 +22,6 @@ def scrobble if user != nil && user.lastfm_session_key != nil Rails.logger.info 'Scrobbling track "' + artist + ' - ' + title + '" for user ' + user.username - track = Rockstar::Track.new(artist, title) track.scrobble(Time.now, user.lastfm_session_key) end @@ -32,6 +30,10 @@ def scrobble private + def track + @track ||= Rockstar::Track.new(params[:artist], params[:title]) + end + def user @user ||= User.find(session[:user_id]) end From 063277023245877cbfe025a0f18c25d86dd559b0 Mon Sep 17 00:00:00 2001 From: Antti-Jussi Kovalainen Date: Tue, 8 Jul 2014 18:27:27 +0300 Subject: [PATCH 057/203] Remove unused request mocks from SongsTest --- test/integration/api_v1/songs_test.rb | 8 -------- 1 file changed, 8 deletions(-) diff --git a/test/integration/api_v1/songs_test.rb b/test/integration/api_v1/songs_test.rb index 3551568..2e85156 100644 --- a/test/integration/api_v1/songs_test.rb +++ b/test/integration/api_v1/songs_test.rb @@ -7,14 +7,6 @@ class ApiV1::SongsTest < ActionDispatch::IntegrationTest setup do @user = users(:jack) - # Mock last.fm requests - fixtures_dir = Rails.root.join('test', 'fixtures') - stub_request(:post, /https?:\/\/ws\.audioscrobbler\.com\/.*method=track\.updateNowPlaying.*/). - to_return(:status => 200, :headers => {}, :body => File.new(File.join(fixtures_dir, 'xml/lfm_track_updateNowPlaying.xml'))) - - stub_request(:post, /https?:\/\/ws\.audioscrobbler\.com\/.*method=track\.scrobble.*/). - to_return(:status => 200, :headers => {}, :body => File.new(File.join(fixtures_dir, 'xml/lfm_track_scrobble.xml'))) - mock_mp3s FakeFS.activate! From 706eb9b9398cd3dd8f98cdb992e9e190fa7cc52d Mon Sep 17 00:00:00 2001 From: Antti-Jussi Kovalainen Date: Tue, 8 Jul 2014 18:27:37 +0300 Subject: [PATCH 058/203] Song: Use songs_file in refresh method --- app/models/song.rb | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/app/models/song.rb b/app/models/song.rb index cf5fc88..9fa2b9d 100644 --- a/app/models/song.rb +++ b/app/models/song.rb @@ -53,17 +53,17 @@ def self.refresh songs = songs.sort_by { |song| song.to_natural_sort_string } songs_as_json = songs.to_json - File.open(SONGS_JSON_FILE, 'w') { |f| f.write(songs_as_json) } + songs_file('w').write(songs_as_json) end - def self.songs_file + def self.songs_file(mode = 'r') begin - file = File.open(SONGS_JSON_FILE, 'r') + file = File.open(SONGS_JSON_FILE, mode) Rails.logger.info "Songs JSON last modified on #{file.mtime.to_s}" rescue Errno::ENOENT => e # File not found FileUtils.touch(SONGS_JSON_FILE) - file = File.open(SONGS_JSON_FILE, 'r') + file = File.open(SONGS_JSON_FILE, mode) Rails.logger.info "Songs JSON last modified on #{file.mtime.to_s}" end From a92565d9c5a43ba68e5b0f7112528b3225bbbaeb Mon Sep 17 00:00:00 2001 From: Antti-Jussi Kovalainen Date: Tue, 8 Jul 2014 18:42:10 +0300 Subject: [PATCH 059/203] Clean the FakeFS before every single integration test --- test/integration/api_v1/songs_test.rb | 2 -- test/test_helper.rb | 30 +++++++++++++++++++-------- 2 files changed, 21 insertions(+), 11 deletions(-) diff --git a/test/integration/api_v1/songs_test.rb b/test/integration/api_v1/songs_test.rb index 2e85156..4700c17 100644 --- a/test/integration/api_v1/songs_test.rb +++ b/test/integration/api_v1/songs_test.rb @@ -1,8 +1,6 @@ require 'test_helper' require 'api_test_helper' -require 'iconv' if RUBY_VERSION.starts_with? '1.8' - class ApiV1::SongsTest < ActionDispatch::IntegrationTest setup do @user = users(:jack) diff --git a/test/test_helper.rb b/test/test_helper.rb index cb0d00a..7f474b1 100644 --- a/test/test_helper.rb +++ b/test/test_helper.rb @@ -4,12 +4,15 @@ if RUBY_VERSION.starts_with? '1.8' require 'webmock/test_unit' + require 'iconv' else require 'webmock/minitest' end WebMock.disable_net_connect! :allow_localhost => true +# ---------------------------------------------------------------------------- + class ActiveSupport::TestCase # Setup all fixtures in test/fixtures/*.(yml|csv) for all tests in alphabetical order. # @@ -20,28 +23,37 @@ class ActiveSupport::TestCase class ActionDispatch::IntegrationTest setup do + clean_fake_fs + # Deactivate always by default FakeFS.deactivate! end end +def clean_fake_fs + # Create directories and files into the FakeFS + FakeFS do + # Clean the directories + File.delete(Song.SONGS_JSON_FILE) if File.exists?(Song.SONGS_JSON_FILE) + FileUtils.rm_rf(Song.MUSIC_PATH) if Dir.exists?(Song.MUSIC_PATH) + + # Mock the directories required by song stuff + FileUtils.mkdir_p(Song.MUSIC_PATH) + FileUtils.mkdir_p(Rails.root.join('data')) + end +end + def mock_mp3s was_active = FakeFS.activated? FakeFS.deactivate! if was_active - # Sample MP3 files + # Read the sample MP3 files into memory @one = File.open(Rails.root.join('test', 'fixtures', 'files', '1sec.mp3').to_s).read @thirty = File.open(Rails.root.join('test', 'fixtures', 'files', '30sec.mp3').to_s).read - FakeFS do - # Clean the mocks - File.delete(Song.SONGS_JSON_FILE) if File.exists?(Song.SONGS_JSON_FILE) - FileUtils.rm_rf(Song.MUSIC_PATH) if Dir.exists?(Song.MUSIC_PATH) - - # Mock the directories required by songs controller - FileUtils.mkdir_p(Song.MUSIC_PATH) - FileUtils.mkdir_p(Rails.root.join('data')) + clean_fake_fs + FakeFS do # Create a few mock MP3 files one_mock = File.open(File.join(Song.MUSIC_PATH, '1sec.mp3'), 'wb') one_mock.write(@one) From b66f8dc9e06139384f42feee0ffba4baf7cc36bb Mon Sep 17 00:00:00 2001 From: Antti-Jussi Kovalainen Date: Tue, 8 Jul 2014 19:08:50 +0300 Subject: [PATCH 060/203] ScrobblesTest: Do "get '/songs'" with FakeFS active --- test/integration/api_v1/scrobbles_test.rb | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/test/integration/api_v1/scrobbles_test.rb b/test/integration/api_v1/scrobbles_test.rb index 663f8dc..1491ca1 100644 --- a/test/integration/api_v1/scrobbles_test.rb +++ b/test/integration/api_v1/scrobbles_test.rb @@ -3,8 +3,10 @@ class ApiV1::ScrobblesTest < ActionDispatch::IntegrationTest setup do - # Perform a HTTP request so the "session" object is initialized - get_json '/songs' + FakeFS do + # Perform a HTTP request so the "session" object is initialized + get_json '/songs' + end # Set current user @user = users(:jack) From a7a1d5d2a943a7c4cd5b6d4b3ce3d849310a6899 Mon Sep 17 00:00:00 2001 From: Antti-Jussi Kovalainen Date: Tue, 8 Jul 2014 19:09:08 +0300 Subject: [PATCH 061/203] Scrobbles: Check that the user has lastfm_session_key --- app/controllers/api_v1/scrobbles_controller.rb | 5 +++++ test/integration/api_v1/scrobbles_test.rb | 7 +++++++ 2 files changed, 12 insertions(+) diff --git a/app/controllers/api_v1/scrobbles_controller.rb b/app/controllers/api_v1/scrobbles_controller.rb index 151a4f5..783c72d 100644 --- a/app/controllers/api_v1/scrobbles_controller.rb +++ b/app/controllers/api_v1/scrobbles_controller.rb @@ -1,6 +1,7 @@ module ApiV1 class ScrobblesController < ApiController before_filter :expires_now + before_filter :check_lastfm_session_key def now_playing artist = params[:artist] @@ -30,6 +31,10 @@ def scrobble private + def check_lastfm_session_key + render_error "User with id=#{params[:id]} is not connected to Last.fm" if user.lastfm_session_key.blank? + end + def track @track ||= Rockstar::Track.new(params[:artist], params[:title]) end diff --git a/test/integration/api_v1/scrobbles_test.rb b/test/integration/api_v1/scrobbles_test.rb index 1491ca1..a6071f3 100644 --- a/test/integration/api_v1/scrobbles_test.rb +++ b/test/integration/api_v1/scrobbles_test.rb @@ -39,4 +39,11 @@ def xml_file(filename) get '/songs/scrobble?artist=Silence&title=30%20second%20silence', { :format => 'json' }, 'rack.session' => session assert_response :not_found end + + test 'should return "not connected to Last.fm" error if user has not linked to last.fm' do + claire = users(:claire) + session[:user_id] = claire.id + get '/songs/scrobble?artist=Silence&title=30%20second%20silence', { :format => 'json' }, 'rack.session' => session + assert_response 422 + end end From 29b42ad431d997539551de8ea549ddaf4553ebcb Mon Sep 17 00:00:00 2001 From: Antti-Jussi Kovalainen Date: Tue, 8 Jul 2014 19:10:28 +0300 Subject: [PATCH 062/203] Scrobbles: Remove unnecessary user and user.lastfm_session_key checks in actions --- .../api_v1/scrobbles_controller.rb | 19 +++++++++---------- 1 file changed, 9 insertions(+), 10 deletions(-) diff --git a/app/controllers/api_v1/scrobbles_controller.rb b/app/controllers/api_v1/scrobbles_controller.rb index 783c72d..212e6e3 100644 --- a/app/controllers/api_v1/scrobbles_controller.rb +++ b/app/controllers/api_v1/scrobbles_controller.rb @@ -7,11 +7,8 @@ def now_playing artist = params[:artist] title = params[:title] - if user != nil && user.lastfm_session_key != nil - Rails.logger.info 'Update Now Playing to "' + artist + ' - ' + title + '" for user ' + user.username - - track.updateNowPlaying(Time.now, user.lastfm_session_key) - end + Rails.logger.info 'Update Now Playing to "' + artist + ' - ' + title + '" for user ' + user.username + track.updateNowPlaying(Time.now, user.lastfm_session_key) render :nothing => true end @@ -20,11 +17,8 @@ def scrobble artist = params[:artist] title = params[:title] - if user != nil && user.lastfm_session_key != nil - Rails.logger.info 'Scrobbling track "' + artist + ' - ' + title + '" for user ' + user.username - - track.scrobble(Time.now, user.lastfm_session_key) - end + Rails.logger.info 'Scrobbling track "' + artist + ' - ' + title + '" for user ' + user.username + track.scrobble(Time.now, user.lastfm_session_key) render :nothing => true end @@ -35,6 +29,11 @@ def check_lastfm_session_key render_error "User with id=#{params[:id]} is not connected to Last.fm" if user.lastfm_session_key.blank? end + def check_and_set_artist_and_title + @artist = params[:artist] + @title = params[:title] + end + def track @track ||= Rockstar::Track.new(params[:artist], params[:title]) end From 5ff82c3173c2847fb80b4c6abbc1bd7974fd1de1 Mon Sep 17 00:00:00 2001 From: Antti-Jussi Kovalainen Date: Tue, 8 Jul 2014 19:12:51 +0300 Subject: [PATCH 063/203] ScrobblesController: Check that artist and title parameters exist --- app/controllers/api_v1/scrobbles_controller.rb | 6 ++++++ test/integration/api_v1/scrobbles_test.rb | 10 ++++++++++ 2 files changed, 16 insertions(+) diff --git a/app/controllers/api_v1/scrobbles_controller.rb b/app/controllers/api_v1/scrobbles_controller.rb index 212e6e3..0b94616 100644 --- a/app/controllers/api_v1/scrobbles_controller.rb +++ b/app/controllers/api_v1/scrobbles_controller.rb @@ -2,6 +2,7 @@ module ApiV1 class ScrobblesController < ApiController before_filter :expires_now before_filter :check_lastfm_session_key + before_filter :check_and_set_artist_and_title def now_playing artist = params[:artist] @@ -32,6 +33,11 @@ def check_lastfm_session_key def check_and_set_artist_and_title @artist = params[:artist] @title = params[:title] + + errors = [] + errors << 'Required parameter "artist" is missing' if @artist.blank? + errors << 'Required parameter "title" is missing' if @title.blank? + render_errors errors if errors.any? end def track diff --git a/test/integration/api_v1/scrobbles_test.rb b/test/integration/api_v1/scrobbles_test.rb index a6071f3..a240d18 100644 --- a/test/integration/api_v1/scrobbles_test.rb +++ b/test/integration/api_v1/scrobbles_test.rb @@ -46,4 +46,14 @@ def xml_file(filename) get '/songs/scrobble?artist=Silence&title=30%20second%20silence', { :format => 'json' }, 'rack.session' => session assert_response 422 end + + test 'should return error if artist is missing' do + get '/songs/scrobble?title=30%20second%20silence', { :format => 'json' }, 'rack.session' => session + assert_response 422 + end + + test 'should return error if title is missing' do + get '/songs/scrobble?artist=Silence', { :format => 'json' }, 'rack.session' => session + assert_response 422 + end end From 98724b396bcae18d5fdc5bb56a21a6afbe03c2cb Mon Sep 17 00:00:00 2001 From: Antti-Jussi Kovalainen Date: Tue, 8 Jul 2014 19:14:10 +0300 Subject: [PATCH 064/203] ScrobblesController: Add artist and title getter methods and use those --- .../api_v1/scrobbles_controller.rb | 27 +++++++++---------- 1 file changed, 12 insertions(+), 15 deletions(-) diff --git a/app/controllers/api_v1/scrobbles_controller.rb b/app/controllers/api_v1/scrobbles_controller.rb index 0b94616..1399cba 100644 --- a/app/controllers/api_v1/scrobbles_controller.rb +++ b/app/controllers/api_v1/scrobbles_controller.rb @@ -2,25 +2,17 @@ module ApiV1 class ScrobblesController < ApiController before_filter :expires_now before_filter :check_lastfm_session_key - before_filter :check_and_set_artist_and_title + before_filter :check_artist_and_title def now_playing - artist = params[:artist] - title = params[:title] - Rails.logger.info 'Update Now Playing to "' + artist + ' - ' + title + '" for user ' + user.username track.updateNowPlaying(Time.now, user.lastfm_session_key) - render :nothing => true end def scrobble - artist = params[:artist] - title = params[:title] - Rails.logger.info 'Scrobbling track "' + artist + ' - ' + title + '" for user ' + user.username track.scrobble(Time.now, user.lastfm_session_key) - render :nothing => true end @@ -30,16 +22,21 @@ def check_lastfm_session_key render_error "User with id=#{params[:id]} is not connected to Last.fm" if user.lastfm_session_key.blank? end - def check_and_set_artist_and_title - @artist = params[:artist] - @title = params[:title] - + def check_artist_and_title errors = [] - errors << 'Required parameter "artist" is missing' if @artist.blank? - errors << 'Required parameter "title" is missing' if @title.blank? + errors << 'Required parameter "artist" is missing' if artist.blank? + errors << 'Required parameter "title" is missing' if title.blank? render_errors errors if errors.any? end + def artist + params[:artist] + end + + def title + params[:title] + end + def track @track ||= Rockstar::Track.new(params[:artist], params[:title]) end From 6b9a3a0a2c85db4b823761d5d57c9b46ec56dd7b Mon Sep 17 00:00:00 2001 From: Antti-Jussi Kovalainen Date: Tue, 8 Jul 2014 19:24:52 +0300 Subject: [PATCH 065/203] SessionsController: Define layout in root --- app/controllers/sessions_controller.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/controllers/sessions_controller.rb b/app/controllers/sessions_controller.rb index 7e0f3ad..d33eaa6 100644 --- a/app/controllers/sessions_controller.rb +++ b/app/controllers/sessions_controller.rb @@ -1,8 +1,8 @@ class SessionsController < ApplicationController skip_before_filter :authorize + layout 'login' def new - render :layout => 'login' end def create From c75124b9e5807719f9abd25bb620cf362e4e720a Mon Sep 17 00:00:00 2001 From: Antti-Jussi Kovalainen Date: Tue, 8 Jul 2014 19:25:29 +0300 Subject: [PATCH 066/203] MainController: Remove whitespace :-] --- app/controllers/main_controller.rb | 3 --- 1 file changed, 3 deletions(-) diff --git a/app/controllers/main_controller.rb b/app/controllers/main_controller.rb index c1fc9ca..88181c2 100644 --- a/app/controllers/main_controller.rb +++ b/app/controllers/main_controller.rb @@ -1,7 +1,4 @@ class MainController < ApplicationController - def index - # do nothing end - end From 7a5ced8cd66ac924b65ddbef8c7c28484ad8d083 Mon Sep 17 00:00:00 2001 From: Antti-Jussi Kovalainen Date: Tue, 8 Jul 2014 19:26:16 +0300 Subject: [PATCH 067/203] Move PlaylistController skeleton under ApiV1 --- app/controllers/api_v1/playlists_controller.rb | 9 +++++++++ app/controllers/playlists_controller.rb | 9 --------- 2 files changed, 9 insertions(+), 9 deletions(-) create mode 100644 app/controllers/api_v1/playlists_controller.rb delete mode 100644 app/controllers/playlists_controller.rb diff --git a/app/controllers/api_v1/playlists_controller.rb b/app/controllers/api_v1/playlists_controller.rb new file mode 100644 index 0000000..4b9cb19 --- /dev/null +++ b/app/controllers/api_v1/playlists_controller.rb @@ -0,0 +1,9 @@ +module ApiV1 + class PlaylistsController < ApiController + def index + end + + def show + end + end +end diff --git a/app/controllers/playlists_controller.rb b/app/controllers/playlists_controller.rb deleted file mode 100644 index 5d82c91..0000000 --- a/app/controllers/playlists_controller.rb +++ /dev/null @@ -1,9 +0,0 @@ -class PlaylistsController < ApplicationController - def index - # TODO: return a list of users' playlists - end - - def new - # TODO: this - end -end From 64f30297f7f3b4ff71746af3aacde23224f18225 Mon Sep 17 00:00:00 2001 From: Antti-Jussi Kovalainen Date: Tue, 8 Jul 2014 19:26:51 +0300 Subject: [PATCH 068/203] Fix whitespace in views/layouts/login.html.erb --- app/views/layouts/login.html.erb | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/app/views/layouts/login.html.erb b/app/views/layouts/login.html.erb index 27e3344..3f48714 100644 --- a/app/views/layouts/login.html.erb +++ b/app/views/layouts/login.html.erb @@ -3,7 +3,7 @@ Beatstream / Login - <%= stylesheet_link_tag "login" %> + <%= stylesheet_link_tag "login" %> <%= csrf_meta_tags %> @@ -13,9 +13,9 @@

    Beatstream

    - <%= yield %> + <%= yield %>
    - \ No newline at end of file + From fa800b5a1f7fed61e94e18aefd75619064d20dad Mon Sep 17 00:00:00 2001 From: Antti-Jussi Kovalainen Date: Tue, 8 Jul 2014 19:28:55 +0300 Subject: [PATCH 069/203] Remove testing stuff from application layout --- app/views/layouts/application.html.erb | 44 +++----------------------- 1 file changed, 5 insertions(+), 39 deletions(-) diff --git a/app/views/layouts/application.html.erb b/app/views/layouts/application.html.erb index f056bab..e3f9972 100644 --- a/app/views/layouts/application.html.erb +++ b/app/views/layouts/application.html.erb @@ -3,7 +3,7 @@ Beatstream - alpha - + @@ -21,7 +21,6 @@
    - <%= session[:username] ? session[:username] : 'You' %> @@ -36,39 +35,16 @@
    @@ -108,15 +84,5 @@
    Loading
    - - From 171b13321e6f5687f1f2929d8d03703a67dd7912 Mon Sep 17 00:00:00 2001 From: Antti-Jussi Kovalainen Date: Tue, 8 Jul 2014 19:34:25 +0300 Subject: [PATCH 070/203] Update changelog --- CHANGELOG.md | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index eaef796..564ccfd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,13 +3,23 @@ Changelog ## 1.0.2 +This is a "refactor release". Version 1.0.2 does not bring anything new, or even +fix things, but instead contains major changes to the code, which might +introduce new bugs. Because of this, I wanted to make it into a completely +independent release. -ajk + ### Major changes -- None +- Ruby 1.8.7 support maybe broken? ### Minor changes -- Renamed app from BeatStream to Beatstream +- None + +### Refactoring + +- Scrobbling related code moved into ScrobblesController +- Renamed Rails app internal name to Beatstream (from BeatStream) ## 1.0.1 From fa00d42cc49a41761b68e952d96274468cd0ee40 Mon Sep 17 00:00:00 2001 From: Antti-Jussi Kovalainen Date: Tue, 8 Jul 2014 19:34:48 +0300 Subject: [PATCH 071/203] Version 1.0.2 --- config/initializers/version.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/config/initializers/version.rb b/config/initializers/version.rb index b2d6896..61b4e76 100644 --- a/config/initializers/version.rb +++ b/config/initializers/version.rb @@ -1,3 +1,3 @@ module Beatstream - VERSION = '1.0.1' + VERSION = '1.0.2' end From fadb5d720bf873ee7c9d4f9180ba2c0b355c22db Mon Sep 17 00:00:00 2001 From: Antti-Jussi Kovalainen Date: Tue, 8 Jul 2014 19:41:47 +0300 Subject: [PATCH 072/203] Update readme --- README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 47e8659..af1c31b 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # Beatstream -**IMPORTANT:** I am currently re-writing the front-end and the rails back-end, so the _most_ up-to-date version is here: +**IMPORTANT:** I am currently re-writing the front-end and the rails back-end and slowly porting the changes from my [dev](https://github.com/Darep/Beatstream/tree/dev) branch into `master`. Stay tuned! Beatstream is an app for streaming music from your computer to anywhere with a modern Web browser! (Beatstream currently requires Ruby to be installed on your computer) @@ -30,7 +30,7 @@ Check [http://www.beatstream.fi/#quirks](http://www.beatstream.fi/#quirks) for t ### License -Copyright (c) 2012-2013 Antti-Jussi Kovalainen +Copyright (c) 2012–2014 Antti-Jussi Kovalainen Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the From c897db59bbae4250bde3a6cfde92dc050c32a8a4 Mon Sep 17 00:00:00 2001 From: Antti-Jussi Kovalainen Date: Tue, 8 Jul 2014 19:46:19 +0300 Subject: [PATCH 073/203] Update rockstar to 0.8.0 --- Gemfile | 2 +- Gemfile.lock | 10 ++++------ 2 files changed, 5 insertions(+), 7 deletions(-) diff --git a/Gemfile b/Gemfile index def2c68..ba4b6e3 100644 --- a/Gemfile +++ b/Gemfile @@ -6,7 +6,7 @@ gem 'rails', '3.2.19' gem 'mp3info', '0.6.18' # Last.fm -gem 'rockstar', '0.7.1' +gem 'rockstar', '0.8.0' platforms :jruby do gem 'activerecord-jdbcsqlite3-adapter', '1.2.9' diff --git a/Gemfile.lock b/Gemfile.lock index 4edf34d..b29ab8f 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -92,13 +92,11 @@ GEM rdoc (3.12.2) json (~> 1.4) ref (1.0.5) - rockstar (0.7.1) + rockstar (0.8.0) activesupport (>= 1.4.2) - activesupport (>= 1.4.2) - hpricot (>= 0.4.86) hpricot (>= 0.4.86) - i18n - rake + i18n (>= 0.5.0) + rake (>= 0.10.0) safe_yaml (1.0.3) sass (3.2.9) sass-rails (3.2.6) @@ -151,7 +149,7 @@ DEPENDENCIES mp3info (= 0.6.18) quiet_assets rails (= 3.2.19) - rockstar (= 0.7.1) + rockstar (= 0.8.0) sass-rails (= 3.2.6) sqlite3 (= 1.3.7) therubyracer (= 0.11.4) From 0e32aaa9141da9394bc13f274f2eaf91de963922 Mon Sep 17 00:00:00 2001 From: Antti-Jussi Kovalainen Date: Tue, 8 Jul 2014 20:08:58 +0300 Subject: [PATCH 074/203] Replace mp3info gem with ruby-mp3info --- Gemfile | 2 +- Gemfile.lock | 4 ++-- app/models/song.rb | 17 ++++++++++------- 3 files changed, 13 insertions(+), 10 deletions(-) diff --git a/Gemfile b/Gemfile index ba4b6e3..65a15a7 100644 --- a/Gemfile +++ b/Gemfile @@ -3,7 +3,7 @@ source 'https://rubygems.org' gem 'rails', '3.2.19' # ID3 info parser -gem 'mp3info', '0.6.18' +gem 'ruby-mp3info', '0.8.4', :require => 'mp3info' # Last.fm gem 'rockstar', '0.8.0' diff --git a/Gemfile.lock b/Gemfile.lock index b29ab8f..1fb9f01 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -61,7 +61,6 @@ GEM mime-types (~> 1.16) treetop (~> 1.4.8) mime-types (1.25.1) - mp3info (0.6.18) multi_json (1.10.1) polyglot (0.3.5) quiet_assets (1.0.2) @@ -97,6 +96,7 @@ GEM hpricot (>= 0.4.86) i18n (>= 0.5.0) rake (>= 0.10.0) + ruby-mp3info (0.8.4) safe_yaml (1.0.3) sass (3.2.9) sass-rails (3.2.6) @@ -146,10 +146,10 @@ DEPENDENCIES activerecord-jdbcsqlite3-adapter (= 1.2.9) fakefs jquery-rails (= 2.0.2) - mp3info (= 0.6.18) quiet_assets rails (= 3.2.19) rockstar (= 0.8.0) + ruby-mp3info (= 0.8.4) sass-rails (= 3.2.6) sqlite3 (= 1.3.7) therubyracer (= 0.11.4) diff --git a/app/models/song.rb b/app/models/song.rb index 9fa2b9d..509eac5 100644 --- a/app/models/song.rb +++ b/app/models/song.rb @@ -1,3 +1,5 @@ +require 'iconv' + class Song MUSIC_PATH = Rails.application.config.music_paths.to_s SONGS_JSON_FILE = Rails.root.join('data', 'songs.json').to_s @@ -82,13 +84,14 @@ def initialize(path, id) @length = 0 # ID3 tag info - info = Mp3Info.open(path) - tag = info.tag() - @title = tag['title'] if (!tag['title'].nil?) - @artist = tag['artist'] if (!tag['title'].nil?) - @album = tag['album'] - @tracknum = tag['tracknum'] - @length = info.length + Mp3Info.open(path) do |info| + tag = info.tag + @title = tag['title'] if (!tag['title'].nil?) + @artist = tag['artist'] if (!tag['title'].nil?) + @album = tag['album'] + @tracknum = tag['tracknum'] + @length = info.length + end @nice_title = '' @nice_title += (@artist.to_s + ' - ') if !@artist.nil? From 5ad6321194d6fb888a0ad4307099eb9c5f274758 Mon Sep 17 00:00:00 2001 From: Antti-Jussi Kovalainen Date: Tue, 8 Jul 2014 20:09:22 +0300 Subject: [PATCH 075/203] Update changelog --- CHANGELOG.md | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 564ccfd..043b428 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,18 @@ Changelog ========= +## 1.0.3 (WIP) + +### Major changes + +- None + +### Minor changes + +- Replaced mp3info gem with ruby-mp3info +- Updated rockstar gem from 0.7.1 to 0.8.0 + + ## 1.0.2 This is a "refactor release". Version 1.0.2 does not bring anything new, or even From 587656ed933e4abb3e4f500a6f922e229c500437 Mon Sep 17 00:00:00 2001 From: Antti-Jussi Kovalainen Date: Tue, 8 Jul 2014 20:10:00 +0300 Subject: [PATCH 076/203] Song: Add ID3 parsing test --- test/test_helper.rb | 10 ++++++---- test/unit/song_test.rb | 5 +++++ 2 files changed, 11 insertions(+), 4 deletions(-) diff --git a/test/test_helper.rb b/test/test_helper.rb index 7f474b1..2175e4d 100644 --- a/test/test_helper.rb +++ b/test/test_helper.rb @@ -55,11 +55,13 @@ def mock_mp3s FakeFS do # Create a few mock MP3 files - one_mock = File.open(File.join(Song.MUSIC_PATH, '1sec.mp3'), 'wb') - one_mock.write(@one) + @one_path = File.join(Song.MUSIC_PATH, '1sec.mp3').to_s + @one_mock = File.open(@one_path, 'wb') + @one_mock.write(@one) - thirty_mock = File.open(File.join(Song.MUSIC_PATH, '30sec.mp3'), 'wb') - thirty_mock.write(@thirty) + @thirty_path = File.join(Song.MUSIC_PATH, '30sec.mp3') + @thirty_mock = File.open(@thirty_path, 'wb') + @thirty_mock.write(@thirty) end FakeFS.activate! if was_active diff --git a/test/unit/song_test.rb b/test/unit/song_test.rb index 28b1d08..e60c420 100644 --- a/test/unit/song_test.rb +++ b/test/unit/song_test.rb @@ -41,6 +41,11 @@ def populate end end + test 'initialize parses ID3 info' do + song = Song.new(@one_path, 1) + assert_equal '1sec silence', song.title + end + test 'refresh populates the Songs' do Song.refresh assert_not_empty Song.all_as_json From 0cd7944ba606266f2e3c2abe351d1fdc0caf2a11 Mon Sep 17 00:00:00 2001 From: Antti-Jussi Kovalainen Date: Tue, 8 Jul 2014 20:12:42 +0300 Subject: [PATCH 077/203] Song: Add test for MP3 files without title in ID3 --- test/fixtures/files/id3less.mp3 | Bin 0 -> 2328 bytes test/test_helper.rb | 1 + test/unit/song_test.rb | 7 +++++++ 3 files changed, 8 insertions(+) create mode 100644 test/fixtures/files/id3less.mp3 diff --git a/test/fixtures/files/id3less.mp3 b/test/fixtures/files/id3less.mp3 new file mode 100644 index 0000000000000000000000000000000000000000..3b88e3266da97bb5564cde5e1100aa1eecf49d08 GIT binary patch literal 2328 zcmZ|Rdo)yQ8vyX_GTWXxlS_thWE$jtk|7aaH4P0KI*m!CD;iQNUqY$WXeQ)xn9M|o zp$>*FCbHS8>A3nE#uLD6!LD6+1ajv_?S^Z9;6qe~Tj}-v zi`_#zjl!Xq$SBrjJ_V~N!`C=LHBv`wVpbEKjCXoC7nzfi@t^W>$Y%*1Jh-OXQ z{{A8KOVh<%jns5g%}P=M@aFIoP7#7=>~t$f4Hct-k@Zit@fOE4h~Hb(k26LsQe#R& zlyK%Fbcvr3c(RhW%m)gBUhXH1iKJ<>a>l0jrOGSrS;U81x9qk%#&II?%JW}`o&$a( zd|eZ*pT>Q@$yaafwE31N?L<-mrRlS?%@6NbUdd+O5h{|Xe()1t+5-F*#7qy=!@4zh zSp4%at%|DmCV%_5MqlHI^~0O3*?dxt!ho`O(+w`x2Y3qH4$JCSy=+6ziXT2J7O!JH zBQ+#sJB-ZsJ|g};Z(^GiX6)KrOYZ{S1s>5y(EL~Yx4q3do;*xUT4YT$7O^sjW<%vB zUcHl4Pn)xv+OfU%QEP$U3AgKL5g_=9s|X`Zm7GWvUrhXq6fFp#cl+wy$cVJFXr4q&@}(|%N4|9bT&hJ|I5XN8Y{_*(5S?Gm15So{FvbOuZE&|wky zL$I4NsE4aBzq6w&S=!Q~E?(W`b#9H=s`YQ{%-x&W8bVB-ggHe{JqvsQTs%%#iTVSZ z_WJZHa;{r2U;U7)mR21nN>1E&>f5{H)u~GnW*No?J`nJyG5?5F5y+W)E`4g-MeDrp z^=E^{g^np@R|UMx2d8%h|CPa#ULJg~qgdzxd<3SrnSelWO*5-tdx(ltmTOFAT;3T8&DocFYKJW#>zN!I7bP)F-d zBp+8uj2aO*zN651SB~hX57Npx z!3D!u;BUi}Mle5Y*yhr=2qb_hC}&<+E$-+few!Zq7AGcN%(43N#MZGkz2+V8`HCa* zpq^}=Z^Vl>k+yWE|K!`##eG%&H(ft?UrnhqJQKlhP#?VaX@fKHWpED;^q*D8opTNPZcHG9wm1s>^NJBF_W#bm4X&^qMul!32?EBa;g%y1>QTUN*E8r;nxgeToH za&aTLTnmUjlXZi~OML2hXHHuS_&N+v6YW1uXc>qTRvdvidmAOJh6^=!Cxz2nuKnRc zjQ`9NynXlQA~m-W_;z^TN02`-lG_Oj3s@DKDg9|fc=;@Ct8;gb_JtYe9xOeuSpQ=5 z%gGVodtq$5E_(lPawvixxtbISJtPI^l6@HZZF|~!uDw&jy0Wlh=b(p*NgRgTYe*n_4p!OZ1cRjjvl0-tUj3 zHB`^S47r(x%JGFcOW>!GMrHg;$er!)UN*{~ds<<^@5Bjjc4s?rUfI$3$#=q1uOH=L z>3{eVW%D_OjOZZ12fb)(joCMV4x#i?;huj^9?aWTklZ~RzEQU_>BF36CS1<|`6m&E zJ_7E~?%)DllgN+q+j>{HesfrzM7A>!))A)nWn@p5lwJ4rxXYA0ih^i-> z8f|BJ{d~ieWbR;N+avq^j$yrZ6cGfU5hIk#e~vpc$@Ziu=v(sFq}j*Jo5kyL{dgHz zQNC|jZZr<$A3@|bRnhx|y>@p;s%NpvTWK2W>*LQ}3%^kb=SNGndJC*>rPi+5`jD+6 z>(3Z=+e!#RaA{(bdMGjbzTV+~Yd-(lm}EmB6g{%hLQK`JJZltQ&9)*@3_$)bN@6`Q zKb#0btEo?hV%v4Ko6@V!0#mV@mUAe<0e?<FpMoF-7xX_1v?RXV@qbN9 B1+@SG literal 0 HcmV?d00001 diff --git a/test/test_helper.rb b/test/test_helper.rb index 2175e4d..06814df 100644 --- a/test/test_helper.rb +++ b/test/test_helper.rb @@ -48,6 +48,7 @@ def mock_mp3s FakeFS.deactivate! if was_active # Read the sample MP3 files into memory + @fixtures_files_dir = Rails.root.join('test', 'fixtures', 'files') @one = File.open(Rails.root.join('test', 'fixtures', 'files', '1sec.mp3').to_s).read @thirty = File.open(Rails.root.join('test', 'fixtures', 'files', '30sec.mp3').to_s).read diff --git a/test/unit/song_test.rb b/test/unit/song_test.rb index e60c420..85880bd 100644 --- a/test/unit/song_test.rb +++ b/test/unit/song_test.rb @@ -46,6 +46,13 @@ def populate assert_equal '1sec silence', song.title end + test 'initialize uses filename if ID3 does not contain title' do + FakeFS.deactivate! + id3less = File.join(@fixtures_files_dir, 'id3less.mp3') + song = Song.new(id3less, 1) + assert_equal 'id3less.mp3', song.title + end + test 'refresh populates the Songs' do Song.refresh assert_not_empty Song.all_as_json From efc94a75a05277c9ae6ffa878463ce098ea767d2 Mon Sep 17 00:00:00 2001 From: Antti-Jussi Kovalainen Date: Wed, 9 Jul 2014 19:28:22 +0300 Subject: [PATCH 078/203] Song: Add absolute_path --- app/models/song.rb | 8 ++++++++ test/unit/song_test.rb | 5 +++++ 2 files changed, 13 insertions(+) diff --git a/app/models/song.rb b/app/models/song.rb index 509eac5..b94a0a1 100644 --- a/app/models/song.rb +++ b/app/models/song.rb @@ -14,6 +14,10 @@ def self.SONGS_JSON_FILE SONGS_JSON_FILE end + def self.absolute_path(path) + File.join(MUSIC_PATH, path) + end + def self.all JSON.parse(all_as_json) end @@ -107,6 +111,10 @@ def initialize(path, id) @nice_title = to_utf8(@nice_title) end + def absolute_path + Song.absolute_path(self.path) + end + def to_s @nice_title end diff --git a/test/unit/song_test.rb b/test/unit/song_test.rb index 85880bd..e9dc485 100644 --- a/test/unit/song_test.rb +++ b/test/unit/song_test.rb @@ -10,6 +10,11 @@ def populate Song.refresh end + test 'absolute_path' do + song = Song.new(@one_path, -1) + assert_equal File.join(Song.MUSIC_PATH, '1sec.mp3'), song.absolute_path + end + test 'all returns nil when Songs are not populated' do assert_empty Song.all end From 1f145f4d36507f8aef2993f51c2f3a749534aaff Mon Sep 17 00:00:00 2001 From: Antti-Jussi Kovalainen Date: Wed, 9 Jul 2014 20:11:36 +0300 Subject: [PATCH 079/203] Move song play logic from controller to model --- app/controllers/api_v1/songs_controller.rb | 7 ++----- app/models/song.rb | 4 ++++ 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/app/controllers/api_v1/songs_controller.rb b/app/controllers/api_v1/songs_controller.rb index fadcb0d..3936b60 100644 --- a/app/controllers/api_v1/songs_controller.rb +++ b/app/controllers/api_v1/songs_controller.rb @@ -19,12 +19,9 @@ def index end def play - filepath = Song.MUSIC_PATH + params[:file] - + song = Song.new(Song.absolute_path(params[:file]), -1) response.content_type = Mime::Type.lookup_by_extension("mp3") - - render :text => File.open(filepath, 'rb') { |f| f.read } - #send_file filepath, :type => 'audio/mpeg' + render :text => song.as_binary_stream end private diff --git a/app/models/song.rb b/app/models/song.rb index b94a0a1..12293eb 100644 --- a/app/models/song.rb +++ b/app/models/song.rb @@ -115,6 +115,10 @@ def absolute_path Song.absolute_path(self.path) end + def as_binary_stream + File.open(self.absolute_path, 'rb').read + end + def to_s @nice_title end From 979405ef4910509cb5ffd2f4f49847bebd208ff0 Mon Sep 17 00:00:00 2001 From: Antti-Jussi Kovalainen Date: Thu, 10 Jul 2014 13:00:35 +0300 Subject: [PATCH 080/203] Clean-up ApiV1::SongsController --- app/controllers/api_v1/songs_controller.rb | 3 --- 1 file changed, 3 deletions(-) diff --git a/app/controllers/api_v1/songs_controller.rb b/app/controllers/api_v1/songs_controller.rb index 3936b60..e97f858 100644 --- a/app/controllers/api_v1/songs_controller.rb +++ b/app/controllers/api_v1/songs_controller.rb @@ -1,6 +1,3 @@ -# -*- encoding : utf-8 -*- -require 'find' - module ApiV1 class SongsController < ApiController From e03bed3fb92d2b55fa33e724a50e4a3ae21dd17c Mon Sep 17 00:00:00 2001 From: Antti-Jussi Kovalainen Date: Thu, 10 Jul 2014 13:01:01 +0300 Subject: [PATCH 081/203] Require 'find' in Song --- app/models/song.rb | 1 + 1 file changed, 1 insertion(+) diff --git a/app/models/song.rb b/app/models/song.rb index 12293eb..1edc872 100644 --- a/app/models/song.rb +++ b/app/models/song.rb @@ -1,3 +1,4 @@ +require 'find' require 'iconv' class Song From 2819333f429bf87b6d4951ddd82949ea79734791 Mon Sep 17 00:00:00 2001 From: Antti-Jussi Kovalainen Date: Thu, 10 Jul 2014 13:07:42 +0300 Subject: [PATCH 082/203] Start Song ids from 1 instead of 0 --- app/models/song.rb | 2 +- test/integration/api_v1/songs_test.rb | 2 +- test/unit/song_test.rb | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/app/models/song.rb b/app/models/song.rb index 1edc872..e6cdf06 100644 --- a/app/models/song.rb +++ b/app/models/song.rb @@ -47,7 +47,7 @@ def self.refresh end begin - mp3 = new(file, songs.length) + mp3 = new(file, (songs.length + 1)) songs.push(mp3) rescue Exception => e Rails.logger.info e diff --git a/test/integration/api_v1/songs_test.rb b/test/integration/api_v1/songs_test.rb index 4700c17..4b029a5 100644 --- a/test/integration/api_v1/songs_test.rb +++ b/test/integration/api_v1/songs_test.rb @@ -11,7 +11,7 @@ class ApiV1::SongsTest < ActionDispatch::IntegrationTest # Populate the Songs Song.refresh - @songs_json = "[{\"id\":0,\"filename\":\"1sec.mp3\",\"path\":\"1sec.mp3\",\"title\":\"1sec silence\",\"artist\":\"Sample\",\"album\":\"Silence is golden\",\"tracknum\":1,\"length\":1.071,\"nice_title\":\"Sample - 1sec silence\",\"nice_length\":\"00:01\"},{\"id\":1,\"filename\":\"30sec.mp3\",\"path\":\"30sec.mp3\",\"title\":\"30sec silence\",\"artist\":\"Sample\",\"album\":\"Silence is golden\",\"tracknum\":30,\"length\":30.066833333333335,\"nice_title\":\"Sample - 30sec silence\",\"nice_length\":\"00:30\"}]" + @songs_json = "[{\"id\":1,\"filename\":\"1sec.mp3\",\"path\":\"1sec.mp3\",\"title\":\"1sec silence\",\"artist\":\"Sample\",\"album\":\"Silence is golden\",\"tracknum\":1,\"length\":1.071,\"nice_title\":\"Sample - 1sec silence\",\"nice_length\":\"00:01\"},{\"id\":2,\"filename\":\"30sec.mp3\",\"path\":\"30sec.mp3\",\"title\":\"30sec silence\",\"artist\":\"Sample\",\"album\":\"Silence is golden\",\"tracknum\":30,\"length\":30.066833333333335,\"nice_title\":\"Sample - 30sec silence\",\"nice_length\":\"00:30\"}]" end def in_binary(string) diff --git a/test/unit/song_test.rb b/test/unit/song_test.rb index e9dc485..a43eb1e 100644 --- a/test/unit/song_test.rb +++ b/test/unit/song_test.rb @@ -35,7 +35,7 @@ def populate test 'find returns Song with a specific id' do populate - song = Song.find(0) + song = Song.find(1) assert_equal '1sec.mp3', song['filename'] end From 6a6f8697dafefbb71f95dc6f2a721a981e17b0d0 Mon Sep 17 00:00:00 2001 From: Antti-Jussi Kovalainen Date: Thu, 10 Jul 2014 13:07:57 +0300 Subject: [PATCH 083/203] Song: Refactor "to_natural_sort_string" --- app/models/song.rb | 28 ++++++++-------------------- 1 file changed, 8 insertions(+), 20 deletions(-) diff --git a/app/models/song.rb b/app/models/song.rb index e6cdf06..be11a48 100644 --- a/app/models/song.rb +++ b/app/models/song.rb @@ -125,29 +125,17 @@ def to_s end def to_natural_sort_string - str = "" - str += artist if !artist.nil? - str += ' ' + album if !album.nil? + sortables = [] - # dumb ass way to achieve natural sorting - if !tracknum.nil? - track = tracknum.to_s + sortables << artist if artist.present? + sortables << album if album.present? + sortables << ("%03d" % tracknum) if tracknum.present? - if tracknum < 100 - track = "0" + track - end - if tracknum < 10 - track = "0" + track - end - - str += ' ' + track - end - - if str.empty? - str = filename + if sortables.any? + sortables.join(' ') + else + filename end - - str end private From a41b772dfc12b2ec4133c90ac761465c97be6afb Mon Sep 17 00:00:00 2001 From: Antti-Jussi Kovalainen Date: Thu, 10 Jul 2014 13:08:39 +0300 Subject: [PATCH 084/203] Song: Refactor "refresh" --- app/models/song.rb | 9 +++------ 1 file changed, 3 insertions(+), 6 deletions(-) diff --git a/app/models/song.rb b/app/models/song.rb index be11a48..cf15a22 100644 --- a/app/models/song.rb +++ b/app/models/song.rb @@ -42,18 +42,15 @@ def self.refresh Find.find(MUSIC_PATH) do |file| if File.directory?(file) || file !~ /.*\.mp3$/i || file =~ /^\./ - #Rails.logger.info 'Skipping file: ' + file next end begin - mp3 = new(file, (songs.length + 1)) - songs.push(mp3) + song = new(file, (songs.length + 1)) + songs.push(song) rescue Exception => e - Rails.logger.info e Rails.logger.info 'Failed to load MP3: ' + file - # TODO: collect the broken mp3s into a separate array - # TODO: count the broken mp3s + Rails.logger.info e end end From d87e361b323bb43faf27beecde2cacfbb7f98b5c Mon Sep 17 00:00:00 2001 From: Antti-Jussi Kovalainen Date: Thu, 10 Jul 2014 13:29:15 +0300 Subject: [PATCH 085/203] Song: Separate model initialization and mp3 file parsing --- app/controllers/api_v1/songs_controller.rb | 2 +- app/models/song.rb | 62 ++++++++++++---------- test/unit/song_test.rb | 26 ++++----- 3 files changed, 47 insertions(+), 43 deletions(-) diff --git a/app/controllers/api_v1/songs_controller.rb b/app/controllers/api_v1/songs_controller.rb index e97f858..0251fa4 100644 --- a/app/controllers/api_v1/songs_controller.rb +++ b/app/controllers/api_v1/songs_controller.rb @@ -16,7 +16,7 @@ def index end def play - song = Song.new(Song.absolute_path(params[:file]), -1) + song = Song.create_from_mp3_file(Song.absolute_path(params[:file]), -1) response.content_type = Mime::Type.lookup_by_extension("mp3") render :text => song.as_binary_stream end diff --git a/app/models/song.rb b/app/models/song.rb index cf15a22..4079735 100644 --- a/app/models/song.rb +++ b/app/models/song.rb @@ -28,6 +28,22 @@ def self.all_as_json json.present? ? json : '[]' end + def self.create_from_mp3_file(path, id) + info = Mp3Info.open(path) + tag = info.tag + + song = Song.new( + :id => id, + :filename => File.basename(path), + :path => path.gsub(MUSIC_PATH, ''), + :title => tag['title'], + :artist => tag['artist'], + :album => tag['album'], + :tracknum => tag['tracknum'], + :length => info.length + ) + end + def self.find(param) song_hash = all.find { |s| s['id'].to_i == param } if song_hash.blank? @@ -46,7 +62,7 @@ def self.refresh end begin - song = new(file, (songs.length + 1)) + song = create_from_mp3_file(file, (songs.length + 1)) songs.push(song) rescue Exception => e Rails.logger.info 'Failed to load MP3: ' + file @@ -74,35 +90,20 @@ def self.songs_file(mode = 'r') return file end - def initialize(path, id) - @id = id - @filename = File.basename(path) - @path = path.gsub(MUSIC_PATH, '') - - @title = @filename - @artist = '' - @album = '' - @tracknum = nil - @length = 0 - - # ID3 tag info - Mp3Info.open(path) do |info| - tag = info.tag - @title = tag['title'] if (!tag['title'].nil?) - @artist = tag['artist'] if (!tag['title'].nil?) - @album = tag['album'] - @tracknum = tag['tracknum'] - @length = info.length - end - - @nice_title = '' - @nice_title += (@artist.to_s + ' - ') if !@artist.nil? - @nice_title += @title.to_s - - @nice_length = (Time.mktime(0)+@length).strftime("%M:%S") + def initialize(params) + @id = params[:id] + @filename = params[:filename] || File.basename(params[:path]) + @path = params[:path] - # convert outgoing strings into valid utf-8 + @title = params[:title] || @filename + @artist = params[:artist] || '' + @album = params[:album] || '' + @tracknum = params[:tracknum] + @length = params[:length] || 0 + @nice_title = self.to_s + @nice_length = (Time.mktime(0) + @length).strftime("%M:%S") + # convert outgoing strings into "valid utf-8" @title = to_utf8(@title) @artist = to_utf8(@artist) if !@artist.nil? @album = to_utf8(@album) if !@album.nil? @@ -118,7 +119,10 @@ def as_binary_stream end def to_s - @nice_title + nice_title = [] + nice_title << @artist if @artist.present? + nice_title << @title + nice_title.join ' - ' end def to_natural_sort_string diff --git a/test/unit/song_test.rb b/test/unit/song_test.rb index a43eb1e..38b7698 100644 --- a/test/unit/song_test.rb +++ b/test/unit/song_test.rb @@ -11,7 +11,7 @@ def populate end test 'absolute_path' do - song = Song.new(@one_path, -1) + song = Song.create_from_mp3_file(@one_path, -1) assert_equal File.join(Song.MUSIC_PATH, '1sec.mp3'), song.absolute_path end @@ -33,6 +33,18 @@ def populate assert_match /"filename": ?"1sec\.mp3"/, Song.all_as_json end + test 'create_from_mp3_file parses ID3 title' do + song = Song.create_from_mp3_file(@one_path, 1) + assert_equal '1sec silence', song.title + end + + test 'create_from_mp3_file uses filename if ID3 does not contain title' do + FakeFS.deactivate! + id3less = File.join(@fixtures_files_dir, 'id3less.mp3') + song = Song.create_from_mp3_file(id3less, 1) + assert_equal 'id3less.mp3', song.title + end + test 'find returns Song with a specific id' do populate song = Song.find(1) @@ -46,18 +58,6 @@ def populate end end - test 'initialize parses ID3 info' do - song = Song.new(@one_path, 1) - assert_equal '1sec silence', song.title - end - - test 'initialize uses filename if ID3 does not contain title' do - FakeFS.deactivate! - id3less = File.join(@fixtures_files_dir, 'id3less.mp3') - song = Song.new(id3less, 1) - assert_equal 'id3less.mp3', song.title - end - test 'refresh populates the Songs' do Song.refresh assert_not_empty Song.all_as_json From d003841e1546b95d1d065868b460551a94e6a6f1 Mon Sep 17 00:00:00 2001 From: Antti-Jussi Kovalainen Date: Thu, 10 Jul 2014 13:42:03 +0300 Subject: [PATCH 086/203] Song: Use to_utf8 when reading ID3 tag, because that's where the problem is --- app/models/song.rb | 37 +++++++++++++++---------------------- 1 file changed, 15 insertions(+), 22 deletions(-) diff --git a/app/models/song.rb b/app/models/song.rb index 4079735..742531a 100644 --- a/app/models/song.rb +++ b/app/models/song.rb @@ -36,9 +36,9 @@ def self.create_from_mp3_file(path, id) :id => id, :filename => File.basename(path), :path => path.gsub(MUSIC_PATH, ''), - :title => tag['title'], - :artist => tag['artist'], - :album => tag['album'], + :title => to_utf8(tag['title']), + :artist => to_utf8(tag['artist']), + :album => to_utf8(tag['album']), :tracknum => tag['tracknum'], :length => info.length ) @@ -90,6 +90,18 @@ def self.songs_file(mode = 'r') return file end + # Iconv UTF-8 helper + # Converts string into valid UTF-8 + # + # @param [String] untrusted_string the string to convert to UTF-8 + # @return [String] passed string in UTF-8 + def self.to_utf8(untrusted_string) + return untrusted_string if untrusted_string.blank? + + ic = Iconv.new('UTF-8//IGNORE', 'ISO-8859-15') + ic.iconv(untrusted_string + ' ')[0..-2] + end + def initialize(params) @id = params[:id] @filename = params[:filename] || File.basename(params[:path]) @@ -102,12 +114,6 @@ def initialize(params) @length = params[:length] || 0 @nice_title = self.to_s @nice_length = (Time.mktime(0) + @length).strftime("%M:%S") - - # convert outgoing strings into "valid utf-8" - @title = to_utf8(@title) - @artist = to_utf8(@artist) if !@artist.nil? - @album = to_utf8(@album) if !@album.nil? - @nice_title = to_utf8(@nice_title) end def absolute_path @@ -139,17 +145,4 @@ def to_natural_sort_string end end - private - - # Iconv UTF-8 helper - # Converts string into valid UTF-8 - # - # @param [String] untrusted_string the string to convert to UTF-8 - # @return [String] passed string in UTF-8 - def to_utf8 untrusted_string="" - ic = Iconv.new('UTF-8//IGNORE', 'ISO-8859-15') - ic.iconv(untrusted_string) - #ic.iconv(untrusted_string + ' ')[0..-2] - end - end From 8f367c92a133b38f173ef55ca1c3da472b3d9a5a Mon Sep 17 00:00:00 2001 From: Antti-Jussi Kovalainen Date: Thu, 10 Jul 2014 13:44:13 +0300 Subject: [PATCH 087/203] Add MediaReader service for refreshing and reading the media files --- app/models/song.rb | 15 ++------------- app/services/media_reader.rb | 14 ++++++++++++++ test/test_helper.rb | 10 +++++----- test/unit/song_test.rb | 2 +- 4 files changed, 22 insertions(+), 19 deletions(-) create mode 100644 app/services/media_reader.rb diff --git a/app/models/song.rb b/app/models/song.rb index 742531a..353c515 100644 --- a/app/models/song.rb +++ b/app/models/song.rb @@ -2,21 +2,10 @@ require 'iconv' class Song - MUSIC_PATH = Rails.application.config.music_paths.to_s - SONGS_JSON_FILE = Rails.root.join('data', 'songs.json').to_s - attr_reader :id, :filename, :path, :artist, :title, :album, :tracknum, :length - def self.MUSIC_PATH - MUSIC_PATH - end - - def self.SONGS_JSON_FILE - SONGS_JSON_FILE - end - def self.absolute_path(path) - File.join(MUSIC_PATH, path) + File.join(MediaReader.MUSIC_PATH, path) end def self.all @@ -35,7 +24,7 @@ def self.create_from_mp3_file(path, id) song = Song.new( :id => id, :filename => File.basename(path), - :path => path.gsub(MUSIC_PATH, ''), + :path => path.gsub(MediaReader.MUSIC_PATH, ''), :title => to_utf8(tag['title']), :artist => to_utf8(tag['artist']), :album => to_utf8(tag['album']), diff --git a/app/services/media_reader.rb b/app/services/media_reader.rb new file mode 100644 index 0000000..468a984 --- /dev/null +++ b/app/services/media_reader.rb @@ -0,0 +1,14 @@ +require 'find' + +class MediaReader + MUSIC_PATH = Rails.application.config.music_paths.to_s + SONGS_JSON_FILE = Rails.root.join('data', 'songs.json').to_s + + def self.MUSIC_PATH + MUSIC_PATH + end + + def self.SONGS_JSON_FILE + SONGS_JSON_FILE + end +end diff --git a/test/test_helper.rb b/test/test_helper.rb index 06814df..f3ee009 100644 --- a/test/test_helper.rb +++ b/test/test_helper.rb @@ -34,11 +34,11 @@ def clean_fake_fs # Create directories and files into the FakeFS FakeFS do # Clean the directories - File.delete(Song.SONGS_JSON_FILE) if File.exists?(Song.SONGS_JSON_FILE) - FileUtils.rm_rf(Song.MUSIC_PATH) if Dir.exists?(Song.MUSIC_PATH) + File.delete(MediaReader.SONGS_JSON_FILE) if File.exists?(MediaReader.SONGS_JSON_FILE) + FileUtils.rm_rf(MediaReader.MUSIC_PATH) if Dir.exists?(MediaReader.MUSIC_PATH) # Mock the directories required by song stuff - FileUtils.mkdir_p(Song.MUSIC_PATH) + FileUtils.mkdir_p(MediaReader.MUSIC_PATH) FileUtils.mkdir_p(Rails.root.join('data')) end end @@ -56,11 +56,11 @@ def mock_mp3s FakeFS do # Create a few mock MP3 files - @one_path = File.join(Song.MUSIC_PATH, '1sec.mp3').to_s + @one_path = File.join(MediaReader.MUSIC_PATH, '1sec.mp3').to_s @one_mock = File.open(@one_path, 'wb') @one_mock.write(@one) - @thirty_path = File.join(Song.MUSIC_PATH, '30sec.mp3') + @thirty_path = File.join(MediaReader.MUSIC_PATH, '30sec.mp3') @thirty_mock = File.open(@thirty_path, 'wb') @thirty_mock.write(@thirty) end diff --git a/test/unit/song_test.rb b/test/unit/song_test.rb index 38b7698..5f825b8 100644 --- a/test/unit/song_test.rb +++ b/test/unit/song_test.rb @@ -12,7 +12,7 @@ def populate test 'absolute_path' do song = Song.create_from_mp3_file(@one_path, -1) - assert_equal File.join(Song.MUSIC_PATH, '1sec.mp3'), song.absolute_path + assert_equal File.join(MediaReader.MUSIC_PATH, '1sec.mp3'), song.absolute_path end test 'all returns nil when Songs are not populated' do From f560ec929b4d5c6ca41f68593553fec139bbfd85 Mon Sep 17 00:00:00 2001 From: Antti-Jussi Kovalainen Date: Thu, 10 Jul 2014 13:47:10 +0300 Subject: [PATCH 088/203] Move refresh and JSON song file reading into MediaReader --- app/models/song.rb | 38 ++------------------------------- app/services/media_reader.rb | 41 ++++++++++++++++++++++++++++++++++++ 2 files changed, 43 insertions(+), 36 deletions(-) diff --git a/app/models/song.rb b/app/models/song.rb index 353c515..e222f84 100644 --- a/app/models/song.rb +++ b/app/models/song.rb @@ -1,4 +1,3 @@ -require 'find' require 'iconv' class Song @@ -13,7 +12,7 @@ def self.all end def self.all_as_json - json = songs_file.read + json = MediaReader.all json.present? ? json : '[]' end @@ -43,40 +42,7 @@ def self.find(param) end def self.refresh - songs = [] - - Find.find(MUSIC_PATH) do |file| - if File.directory?(file) || file !~ /.*\.mp3$/i || file =~ /^\./ - next - end - - begin - song = create_from_mp3_file(file, (songs.length + 1)) - songs.push(song) - rescue Exception => e - Rails.logger.info 'Failed to load MP3: ' + file - Rails.logger.info e - end - end - - songs = songs.sort_by { |song| song.to_natural_sort_string } - - songs_as_json = songs.to_json - songs_file('w').write(songs_as_json) - end - - def self.songs_file(mode = 'r') - begin - file = File.open(SONGS_JSON_FILE, mode) - Rails.logger.info "Songs JSON last modified on #{file.mtime.to_s}" - rescue Errno::ENOENT => e - # File not found - FileUtils.touch(SONGS_JSON_FILE) - file = File.open(SONGS_JSON_FILE, mode) - Rails.logger.info "Songs JSON last modified on #{file.mtime.to_s}" - end - - return file + MediaReader.refresh end # Iconv UTF-8 helper diff --git a/app/services/media_reader.rb b/app/services/media_reader.rb index 468a984..9023abb 100644 --- a/app/services/media_reader.rb +++ b/app/services/media_reader.rb @@ -11,4 +11,45 @@ def self.MUSIC_PATH def self.SONGS_JSON_FILE SONGS_JSON_FILE end + + def self.all + songs_file.read + end + + def self.refresh + songs = [] + + Find.find(MUSIC_PATH) do |file| + if File.directory?(file) || file !~ /.*\.mp3$/i || file =~ /^\./ + next + end + + begin + song = Song.create_from_mp3_file(file, (songs.length + 1)) + songs.push(song) + rescue Exception => e + Rails.logger.info 'Failed to load MP3: ' + file + Rails.logger.info e + end + end + + songs = songs.sort_by { |song| song.to_natural_sort_string } + + songs_as_json = songs.to_json + songs_file('w').write(songs_as_json) + end + + def self.songs_file(mode = 'r') + begin + file = File.open(SONGS_JSON_FILE, mode) + Rails.logger.info "Songs JSON last modified on #{file.mtime.to_s}" + rescue Errno::ENOENT => e + # File not found + FileUtils.touch(SONGS_JSON_FILE) + file = File.open(SONGS_JSON_FILE, mode) + Rails.logger.info "Songs JSON last modified on #{file.mtime.to_s}" + end + + return file + end end From e6eb2ef0ba078dbe8cf597fbe4da810ac5c1c9f4 Mon Sep 17 00:00:00 2001 From: Antti-Jussi Kovalainen Date: Thu, 10 Jul 2014 14:39:27 +0300 Subject: [PATCH 089/203] MediaReader: Chop refresh into multiple methods --- app/services/media_reader.rb | 34 ++++++++++++++++------------------ 1 file changed, 16 insertions(+), 18 deletions(-) diff --git a/app/services/media_reader.rb b/app/services/media_reader.rb index 9023abb..6455f55 100644 --- a/app/services/media_reader.rb +++ b/app/services/media_reader.rb @@ -16,27 +16,25 @@ def self.all songs_file.read end - def self.refresh - songs = [] - - Find.find(MUSIC_PATH) do |file| - if File.directory?(file) || file !~ /.*\.mp3$/i || file =~ /^\./ - next - end - - begin - song = Song.create_from_mp3_file(file, (songs.length + 1)) - songs.push(song) - rescue Exception => e - Rails.logger.info 'Failed to load MP3: ' + file - Rails.logger.info e - end + def self.create_song(path, index) + begin + Song.create_from_mp3_file(path, index + 1) + rescue Exception => e + Rails.logger.info "Failed to load media: #{path}" + Rails.logger.info e end + end - songs = songs.sort_by { |song| song.to_natural_sort_string } + def self.files(path) + Dir.chdir(path) + return Dir['**/*.{mp3,MP3}'] + end + + def self.refresh + songs = files(MUSIC_PATH).each_with_index.map { |f,i| create_song(f, i) }. + sort_by &:to_natural_sort_string - songs_as_json = songs.to_json - songs_file('w').write(songs_as_json) + songs_file('w').write(songs.to_json) end def self.songs_file(mode = 'r') From 049b190c94ae80ae8d70174c53fba8baf030fa92 Mon Sep 17 00:00:00 2001 From: Antti-Jussi Kovalainen Date: Thu, 10 Jul 2014 14:54:40 +0300 Subject: [PATCH 090/203] Remove utf8 conversion thingie, we'll handle this stuff some otherway... --- app/models/song.rb | 20 +++----------------- 1 file changed, 3 insertions(+), 17 deletions(-) diff --git a/app/models/song.rb b/app/models/song.rb index e222f84..fa28d5d 100644 --- a/app/models/song.rb +++ b/app/models/song.rb @@ -1,5 +1,3 @@ -require 'iconv' - class Song attr_reader :id, :filename, :path, :artist, :title, :album, :tracknum, :length @@ -24,9 +22,9 @@ def self.create_from_mp3_file(path, id) :id => id, :filename => File.basename(path), :path => path.gsub(MediaReader.MUSIC_PATH, ''), - :title => to_utf8(tag['title']), - :artist => to_utf8(tag['artist']), - :album => to_utf8(tag['album']), + :title => tag['title'], + :artist => tag['artist'], + :album => tag['album'], :tracknum => tag['tracknum'], :length => info.length ) @@ -45,18 +43,6 @@ def self.refresh MediaReader.refresh end - # Iconv UTF-8 helper - # Converts string into valid UTF-8 - # - # @param [String] untrusted_string the string to convert to UTF-8 - # @return [String] passed string in UTF-8 - def self.to_utf8(untrusted_string) - return untrusted_string if untrusted_string.blank? - - ic = Iconv.new('UTF-8//IGNORE', 'ISO-8859-15') - ic.iconv(untrusted_string + ' ')[0..-2] - end - def initialize(params) @id = params[:id] @filename = params[:filename] || File.basename(params[:path]) From 3529df6a098dff7d529d35d6f19c5762bf88feed Mon Sep 17 00:00:00 2001 From: Antti-Jussi Kovalainen Date: Thu, 10 Jul 2014 14:59:57 +0300 Subject: [PATCH 091/203] Remove Song.absolute_path --- app/controllers/api_v1/songs_controller.rb | 2 +- app/models/song.rb | 11 +++++------ 2 files changed, 6 insertions(+), 7 deletions(-) diff --git a/app/controllers/api_v1/songs_controller.rb b/app/controllers/api_v1/songs_controller.rb index 0251fa4..6e53838 100644 --- a/app/controllers/api_v1/songs_controller.rb +++ b/app/controllers/api_v1/songs_controller.rb @@ -16,7 +16,7 @@ def index end def play - song = Song.create_from_mp3_file(Song.absolute_path(params[:file]), -1) + song = Song.create_from_mp3_file(params[:file], -1) response.content_type = Mime::Type.lookup_by_extension("mp3") render :text => song.as_binary_stream end diff --git a/app/models/song.rb b/app/models/song.rb index fa28d5d..c5815ef 100644 --- a/app/models/song.rb +++ b/app/models/song.rb @@ -1,10 +1,6 @@ class Song attr_reader :id, :filename, :path, :artist, :title, :album, :tracknum, :length - def self.absolute_path(path) - File.join(MediaReader.MUSIC_PATH, path) - end - def self.all JSON.parse(all_as_json) end @@ -15,6 +11,9 @@ def self.all_as_json end def self.create_from_mp3_file(path, id) + # Try to find a non-existing file by appending MUSIC_PATH + path = File.join(MediaReader.MUSIC_PATH, path) unless File.exists?(path) + info = Mp3Info.open(path) tag = info.tag @@ -58,11 +57,11 @@ def initialize(params) end def absolute_path - Song.absolute_path(self.path) + File.join(MediaReader.MUSIC_PATH, self.path) end def as_binary_stream - File.open(self.absolute_path, 'rb').read + File.open(absolute_path, 'rb').read end def to_s From d63579859a1b4c2d9f1c6d78a8f83d5ae06a965d Mon Sep 17 00:00:00 2001 From: Antti-Jussi Kovalainen Date: Thu, 10 Jul 2014 15:02:12 +0300 Subject: [PATCH 092/203] Song: Sort methods alphabetically --- app/models/song.rb | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/app/models/song.rb b/app/models/song.rb index c5815ef..57d14b0 100644 --- a/app/models/song.rb +++ b/app/models/song.rb @@ -64,13 +64,6 @@ def as_binary_stream File.open(absolute_path, 'rb').read end - def to_s - nice_title = [] - nice_title << @artist if @artist.present? - nice_title << @title - nice_title.join ' - ' - end - def to_natural_sort_string sortables = [] @@ -85,4 +78,11 @@ def to_natural_sort_string end end + def to_s + nice_title = [] + nice_title << @artist if @artist.present? + nice_title << @title + nice_title.join ' - ' + end + end From 771a0434c7f54f15b42a5d018e6f2cf46bc225c7 Mon Sep 17 00:00:00 2001 From: Antti-Jussi Kovalainen Date: Thu, 10 Jul 2014 15:02:27 +0300 Subject: [PATCH 093/203] Song: Rename create_from_mp3_file => new_from_mp3_file --- app/controllers/api_v1/songs_controller.rb | 2 +- app/models/song.rb | 2 +- app/services/media_reader.rb | 2 +- test/unit/song_test.rb | 10 +++++----- 4 files changed, 8 insertions(+), 8 deletions(-) diff --git a/app/controllers/api_v1/songs_controller.rb b/app/controllers/api_v1/songs_controller.rb index 6e53838..bec740c 100644 --- a/app/controllers/api_v1/songs_controller.rb +++ b/app/controllers/api_v1/songs_controller.rb @@ -16,7 +16,7 @@ def index end def play - song = Song.create_from_mp3_file(params[:file], -1) + song = Song.new_from_mp3_file(params[:file], -1) response.content_type = Mime::Type.lookup_by_extension("mp3") render :text => song.as_binary_stream end diff --git a/app/models/song.rb b/app/models/song.rb index 57d14b0..7412eef 100644 --- a/app/models/song.rb +++ b/app/models/song.rb @@ -10,7 +10,7 @@ def self.all_as_json json.present? ? json : '[]' end - def self.create_from_mp3_file(path, id) + def self.new_from_mp3_file(path, id) # Try to find a non-existing file by appending MUSIC_PATH path = File.join(MediaReader.MUSIC_PATH, path) unless File.exists?(path) diff --git a/app/services/media_reader.rb b/app/services/media_reader.rb index 6455f55..b3230f4 100644 --- a/app/services/media_reader.rb +++ b/app/services/media_reader.rb @@ -18,7 +18,7 @@ def self.all def self.create_song(path, index) begin - Song.create_from_mp3_file(path, index + 1) + Song.new_from_mp3_file(path, index + 1) rescue Exception => e Rails.logger.info "Failed to load media: #{path}" Rails.logger.info e diff --git a/test/unit/song_test.rb b/test/unit/song_test.rb index 5f825b8..7ed66b7 100644 --- a/test/unit/song_test.rb +++ b/test/unit/song_test.rb @@ -11,7 +11,7 @@ def populate end test 'absolute_path' do - song = Song.create_from_mp3_file(@one_path, -1) + song = Song.new_from_mp3_file(@one_path, -1) assert_equal File.join(MediaReader.MUSIC_PATH, '1sec.mp3'), song.absolute_path end @@ -33,15 +33,15 @@ def populate assert_match /"filename": ?"1sec\.mp3"/, Song.all_as_json end - test 'create_from_mp3_file parses ID3 title' do - song = Song.create_from_mp3_file(@one_path, 1) + test 'new_from_mp3_file parses ID3 title' do + song = Song.new_from_mp3_file(@one_path, 1) assert_equal '1sec silence', song.title end - test 'create_from_mp3_file uses filename if ID3 does not contain title' do + test 'new_from_mp3_file uses filename if ID3 does not contain title' do FakeFS.deactivate! id3less = File.join(@fixtures_files_dir, 'id3less.mp3') - song = Song.create_from_mp3_file(id3less, 1) + song = Song.new_from_mp3_file(id3less, 1) assert_equal 'id3less.mp3', song.title end From 3c50e2b520f8db1b34617a2d134c17c642dfe855 Mon Sep 17 00:00:00 2001 From: Antti-Jussi Kovalainen Date: Thu, 10 Jul 2014 15:04:03 +0300 Subject: [PATCH 094/203] Remove forced redirect to https when request is not local --- app/controllers/application_controller.rb | 5 ----- 1 file changed, 5 deletions(-) diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb index 5cfb33e..17bbcef 100644 --- a/app/controllers/application_controller.rb +++ b/app/controllers/application_controller.rb @@ -1,6 +1,5 @@ class ApplicationController < ActionController::Base before_filter :authorize - before_filter :redirect_to_https protect_from_forgery protected @@ -13,8 +12,4 @@ def authorize end end - def redirect_to_https - redirect_to :protocol => "https://" unless (request.ssl? || request.local?) - end - end From 2ee43f296c0bec36525b2d258a2833cb9e7678e1 Mon Sep 17 00:00:00 2001 From: Antti-Jussi Kovalainen Date: Thu, 10 Jul 2014 15:05:55 +0300 Subject: [PATCH 095/203] Remove protect_from_forgery, because its effects are unknown --- app/controllers/application_controller.rb | 1 - 1 file changed, 1 deletion(-) diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb index 17bbcef..4b16751 100644 --- a/app/controllers/application_controller.rb +++ b/app/controllers/application_controller.rb @@ -1,6 +1,5 @@ class ApplicationController < ActionController::Base before_filter :authorize - protect_from_forgery protected From bc59e313b6c9b6c81c6482f004461a05aaeac017 Mon Sep 17 00:00:00 2001 From: Antti-Jussi Kovalainen Date: Thu, 10 Jul 2014 15:06:50 +0300 Subject: [PATCH 096/203] Update changelog --- CHANGELOG.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 043b428..3932f96 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,10 +5,11 @@ Changelog ### Major changes -- None +- Removed automatic redirect to https ### Minor changes +- Removed Cross-Site Request protection token - Replaced mp3info gem with ruby-mp3info - Updated rockstar gem from 0.7.1 to 0.8.0 From d7beadf296de4ea15a9f31569127ee14b8ad227e Mon Sep 17 00:00:00 2001 From: Antti-Jussi Kovalainen Date: Thu, 10 Jul 2014 22:22:44 +0300 Subject: [PATCH 097/203] Move "user" method to ApplicationController --- app/controllers/api_v1/scrobbles_controller.rb | 4 ---- app/controllers/application_controller.rb | 8 +++++--- 2 files changed, 5 insertions(+), 7 deletions(-) diff --git a/app/controllers/api_v1/scrobbles_controller.rb b/app/controllers/api_v1/scrobbles_controller.rb index 1399cba..ea5613f 100644 --- a/app/controllers/api_v1/scrobbles_controller.rb +++ b/app/controllers/api_v1/scrobbles_controller.rb @@ -41,9 +41,5 @@ def track @track ||= Rockstar::Track.new(params[:artist], params[:title]) end - def user - @user ||= User.find(session[:user_id]) - end - end end diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb index 4b16751..66df1e2 100644 --- a/app/controllers/application_controller.rb +++ b/app/controllers/application_controller.rb @@ -6,9 +6,11 @@ class ApplicationController < ActionController::Base def authorize return true if Rails.env.test? - unless User.find_by_id(session[:user_id]) - redirect_to login_url - end + redirect_to login_url if user.blank? + end + + def user + @user ||= User.find(session[:user_id]) end end From 5b30043a49df0825adf6d4e196fb3e74a201be14 Mon Sep 17 00:00:00 2001 From: Antti-Jussi Kovalainen Date: Thu, 10 Jul 2014 22:22:53 +0300 Subject: [PATCH 098/203] DRY in SettingsController --- app/controllers/settings_controller.rb | 13 ++++++------- 1 file changed, 6 insertions(+), 7 deletions(-) diff --git a/app/controllers/settings_controller.rb b/app/controllers/settings_controller.rb index 0a0d48a..382bbe0 100644 --- a/app/controllers/settings_controller.rb +++ b/app/controllers/settings_controller.rb @@ -1,12 +1,10 @@ class SettingsController < ApplicationController def index - @user = User.find(session[:user_id]) render :layout => false end def save - @user = User.find(session[:user_id]) - if @user.update_attributes(params[:user]) + if user.update_attributes(params[:user]) respond_to do |format| format.html { redirect_to :action => 'index' } format.json { render :nothing => true } @@ -21,8 +19,10 @@ def lastfm_callback lastfm_session = Rockstar::Auth.new.session(token) - @user = User.find(session[:user_id]) - @user.update_attributes(:lastfm_session_key => lastfm_session.key, :lastfm_username => lastfm_session.username) + user.update_attributes( + :lastfm_session_key => lastfm_session.key, + :lastfm_username => lastfm_session.username + ) respond_to do |format| format.html { redirect_to :action => 'index' } @@ -31,8 +31,7 @@ def lastfm_callback end def lastfm_disconnect - @user = User.find(session[:user_id]) - @user.update_attributes(:lastfm_session_key => nil, :lastfm_username => nil) + user.update_attributes(:lastfm_session_key => nil, :lastfm_username => nil) respond_to do |format| format.html { redirect_to :action => 'index' } From 2b924510eb0d0023a1d6a2b19205f90cf74f5dfd Mon Sep 17 00:00:00 2001 From: Antti-Jussi Kovalainen Date: Thu, 10 Jul 2014 22:31:56 +0300 Subject: [PATCH 099/203] Add route name for /songs route --- config/routes.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/config/routes.rb b/config/routes.rb index 3023b14..86a970b 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -1,7 +1,7 @@ Beatstream::Application.routes.draw do scope :module => :api_v1, :path => '' do - get 'songs/(index)' => 'songs#index' + get 'songs/(index)' => 'songs#index', :as => :songs get 'songs/play' get 'songs/now_playing' => 'scrobbles#now_playing' get 'songs/scrobble' => 'scrobbles#scrobble' From fce3a9d7fdb3b97891550b3976ae3793b7de976a Mon Sep 17 00:00:00 2001 From: Antti-Jussi Kovalainen Date: Thu, 10 Jul 2014 22:33:03 +0300 Subject: [PATCH 100/203] More DRY in SettingsController --- app/controllers/settings_controller.rb | 28 +++++++------------------- 1 file changed, 7 insertions(+), 21 deletions(-) diff --git a/app/controllers/settings_controller.rb b/app/controllers/settings_controller.rb index 382bbe0..98b6138 100644 --- a/app/controllers/settings_controller.rb +++ b/app/controllers/settings_controller.rb @@ -1,41 +1,27 @@ class SettingsController < ApplicationController + layout false + def index - render :layout => false end def save - if user.update_attributes(params[:user]) - respond_to do |format| - format.html { redirect_to :action => 'index' } - format.json { render :nothing => true } - end - else - render :action => 'index', :layout => false - end + user.update_attributes(params[:user]) + redirect_to :action => 'index' end def lastfm_callback - token = params[:token] - - lastfm_session = Rockstar::Auth.new.session(token) + lastfm_session = Rockstar::Auth.new.session(params[:token]) user.update_attributes( :lastfm_session_key => lastfm_session.key, :lastfm_username => lastfm_session.username ) - respond_to do |format| - format.html { redirect_to :action => 'index' } - format.json { render :nothing => true } - end + redirect_to :action => 'index' end def lastfm_disconnect user.update_attributes(:lastfm_session_key => nil, :lastfm_username => nil) - - respond_to do |format| - format.html { redirect_to :action => 'index' } - format.json { render :nothing => true } - end + redirect_to :action => 'index' end end From 48f37a5e3c352bcbbf1d92cdeb1179dc4f1f0406 Mon Sep 17 00:00:00 2001 From: Antti-Jussi Kovalainen Date: Thu, 10 Jul 2014 22:49:05 +0300 Subject: [PATCH 101/203] Add Ruby 2.x to Travis CI --- .travis.yml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.travis.yml b/.travis.yml index 1ad5078..b31b631 100644 --- a/.travis.yml +++ b/.travis.yml @@ -1,5 +1,8 @@ language: ruby rvm: + - 2.1.2 + - 2.1.1 + - 2.0.0 - 1.9.3 - 1.9.2 before_script: From a4dd29a478aa3771599e1f1ad94387380a12dfc2 Mon Sep 17 00:00:00 2001 From: Antti-Jussi Kovalainen Date: Thu, 10 Jul 2014 22:49:12 +0300 Subject: [PATCH 102/203] Update changelog --- CHANGELOG.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3932f96..28ffda6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,8 @@ Changelog ### Major changes +- Added support for Ruby 2.x +- Removed iconv - Removed automatic redirect to https ### Minor changes From cb333f3d874f5f3b84f39ed57b711b6b17cd16f4 Mon Sep 17 00:00:00 2001 From: Antti-Jussi Kovalainen Date: Thu, 10 Jul 2014 23:13:34 +0300 Subject: [PATCH 103/203] Version 1.0.3 --- CHANGELOG.md | 2 +- config/initializers/version.rb | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 28ffda6..6d2ac5d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,7 +1,7 @@ Changelog ========= -## 1.0.3 (WIP) +## 1.0.3 ### Major changes diff --git a/config/initializers/version.rb b/config/initializers/version.rb index 61b4e76..dede55f 100644 --- a/config/initializers/version.rb +++ b/config/initializers/version.rb @@ -1,3 +1,3 @@ module Beatstream - VERSION = '1.0.2' + VERSION = '1.0.3' end From ae7e2d225d833a3c7551273816f95d6822868a06 Mon Sep 17 00:00:00 2001 From: Antti-Jussi Kovalainen Date: Sat, 16 Aug 2014 10:03:57 +0300 Subject: [PATCH 104/203] Fix crash when user is not logged in. Fixes #43 --- app/controllers/application_controller.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb index 66df1e2..641e15a 100644 --- a/app/controllers/application_controller.rb +++ b/app/controllers/application_controller.rb @@ -10,7 +10,7 @@ def authorize end def user - @user ||= User.find(session[:user_id]) + @user ||= User.find(session[:user_id]) if session[:user_id] end end From 0b3010111a6e89cfd0c705b3d6dbeb5a24d241a7 Mon Sep 17 00:00:00 2001 From: Antti-Jussi Kovalainen Date: Sat, 16 Aug 2014 10:05:49 +0300 Subject: [PATCH 105/203] Version 1.0.4 --- CHANGELOG.md | 7 +++++++ config/initializers/version.rb | 2 +- 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6d2ac5d..ac8de42 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,13 @@ Changelog ========= +## 1.0.4 + +### Bug fixes + +- Fixed a crash bug on initial login or after logging out + + ## 1.0.3 ### Major changes diff --git a/config/initializers/version.rb b/config/initializers/version.rb index dede55f..6157b1d 100644 --- a/config/initializers/version.rb +++ b/config/initializers/version.rb @@ -1,3 +1,3 @@ module Beatstream - VERSION = '1.0.3' + VERSION = '1.0.4' end From f44e8ee323b59964bd69f62a1fa55a825303557c Mon Sep 17 00:00:00 2001 From: Antti-Jussi Kovalainen Date: Thu, 4 Sep 2014 20:36:01 +0300 Subject: [PATCH 106/203] Update README.md --- README.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/README.md b/README.md index af1c31b..fe16302 100644 --- a/README.md +++ b/README.md @@ -28,6 +28,8 @@ Open Chrome and surf to http://0.0.0.0:3000 address. Log in and wait when indexi Check [http://www.beatstream.fi/#quirks](http://www.beatstream.fi/#quirks) for the most up-to-date list of issues and quirks. +**HTTPS/SSL:** When running in production environment, the app forces SSL on. To disable this, change `config.force_ssl` in `config/environments/production.rb` to false and it shouldn't redirect to https. + ### License Copyright (c) 2012–2014 Antti-Jussi Kovalainen From 88419f20c4527bcc3b90506338b8f292b0dc6d9c Mon Sep 17 00:00:00 2001 From: Antti-Jussi Kovalainen Date: Sat, 20 Sep 2014 16:46:11 +0300 Subject: [PATCH 107/203] Update README.md --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index fe16302..fba6ffa 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # Beatstream -**IMPORTANT:** I am currently re-writing the front-end and the rails back-end and slowly porting the changes from my [dev](https://github.com/Darep/Beatstream/tree/dev) branch into `master`. Stay tuned! +**IMPORTANT:** I have been experimenting with some re-writes and I'm now slowly porting these changes from my [dev](https://github.com/Darep/Beatstream/tree/dev) branch into `master`. Stay tuned! Beatstream is an app for streaming music from your computer to anywhere with a modern Web browser! (Beatstream currently requires Ruby to be installed on your computer) From 3ab7156fcc5b3f8d2365ab417776c1ba1264d4b8 Mon Sep 17 00:00:00 2001 From: Antti-Jussi Kovalainen Date: Sat, 20 Sep 2014 16:47:30 +0300 Subject: [PATCH 108/203] Add Code Climate and TravisCI badge to README --- README.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/README.md b/README.md index fba6ffa..fcb485a 100644 --- a/README.md +++ b/README.md @@ -2,6 +2,12 @@ **IMPORTANT:** I have been experimenting with some re-writes and I'm now slowly porting these changes from my [dev](https://github.com/Darep/Beatstream/tree/dev) branch into `master`. Stay tuned! +--- + +"Code quality" [![Code Climate](https://codeclimate.com/github/Darep/Beatstream/badges/gpa.svg)](https://codeclimate.com/github/Darep/Beatstream) + +Test status [![Build Status](https://travis-ci.org/Darep/Beatstream.svg?branch=master)](https://travis-ci.org/Darep/Beatstream) + Beatstream is an app for streaming music from your computer to anywhere with a modern Web browser! (Beatstream currently requires Ruby to be installed on your computer) From e1d98d567e814cf5c78df0593ac89702ceaa3f4c Mon Sep 17 00:00:00 2001 From: Antti-Jussi Kovalainen Date: Sat, 20 Sep 2014 16:48:27 +0300 Subject: [PATCH 109/203] Tweak CHANGELOG.md markup style --- CHANGELOG.md | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ac8de42..f99675f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,20 +3,20 @@ Changelog ## 1.0.4 -### Bug fixes +#### Bug fixes - Fixed a crash bug on initial login or after logging out ## 1.0.3 -### Major changes +#### Major changes - Added support for Ruby 2.x - Removed iconv - Removed automatic redirect to https -### Minor changes +#### Minor changes - Removed Cross-Site Request protection token - Replaced mp3info gem with ruby-mp3info @@ -30,15 +30,15 @@ fix things, but instead contains major changes to the code, which might introduce new bugs. Because of this, I wanted to make it into a completely independent release. -ajk -### Major changes +#### Major changes - Ruby 1.8.7 support maybe broken? -### Minor changes +#### Minor changes - None -### Refactoring +#### Refactoring - Scrobbling related code moved into ScrobblesController - Renamed Rails app internal name to Beatstream (from BeatStream) @@ -46,14 +46,14 @@ independent release. -ajk ## 1.0.1 -### Major changes +#### Major changes - Added force_ssl = false as default when running in development and test environments - Updated Rails from 3.2.12 to 3.2.19 - Updated thin to 1.6.2 - Updated trinidad (for jruby) to 1.4.6 -### Minor changes +#### Minor changes - Added changelog - Refactored Song model From d2e101b948113e382b1f22d92ff10502057ac4fa Mon Sep 17 00:00:00 2001 From: Antti-Jussi Kovalainen Date: Sat, 20 Sep 2014 16:50:49 +0300 Subject: [PATCH 110/203] Update list of known issues to README.md --- README.md | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index fcb485a..c1684a2 100644 --- a/README.md +++ b/README.md @@ -32,10 +32,20 @@ Open Chrome and surf to http://0.0.0.0:3000 address. Log in and wait when indexi ### Known issues -Check [http://www.beatstream.fi/#quirks](http://www.beatstream.fi/#quirks) for the most up-to-date list of issues and quirks. - **HTTPS/SSL:** When running in production environment, the app forces SSL on. To disable this, change `config.force_ssl` in `config/environments/production.rb` to false and it shouldn't redirect to https. +**Last.fm:** You need to manually go to https:///settings and do the authentication there. + +**Media Library:** Opens a blank page after refresh is done. Just go back, and start listening :) + +**Flash/Firefox crash:** Beatstream's Flash plugin (SoundManager2) crashes sometimes. Restart browser / Flash. + +**UTF-8 problems:** Sometimes song info looks garbled. Sometimes Beastream can't play songs with funny characters in filenames or paths. + +**Endlessly spinning loader:** You prorably have a Flash block of some kind. Allow Beatstream to use Flash. + +These are also listed on [http://www.beatstream.fi/#quirks](http://www.beatstream.fi/#quirks). + ### License Copyright (c) 2012–2014 Antti-Jussi Kovalainen From 0944fd650146530a88a0248403d29d146a649851 Mon Sep 17 00:00:00 2001 From: bublik42 Date: Wed, 15 Oct 2014 15:31:20 +0300 Subject: [PATCH 111/203] issue-30 fix --- app/assets/javascripts/songlist.js | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/app/assets/javascripts/songlist.js b/app/assets/javascripts/songlist.js index 14039b3..38b8561 100644 --- a/app/assets/javascripts/songlist.js +++ b/app/assets/javascripts/songlist.js @@ -261,6 +261,10 @@ if (shuffle) { new_row = randomToN(number_of_rows); + while(new_row == current_row && number_of_rows > 1){ + //reshuffles if same as previous and more than one song + new_row = randomToN(number_of_rows); + } } else if ((current_row + 1) < number_of_rows) { // normal operation, move to next song From c6da0715428dcf6ccd902d931d5eb794d208d4d1 Mon Sep 17 00:00:00 2001 From: bublik42 Date: Thu, 16 Oct 2014 22:20:43 +0300 Subject: [PATCH 112/203] commit-issue#37 --- Gemfile | 2 +- Gemfile.lock | 3 +++ app/assets/javascripts/application.js | 1 + app/assets/javascripts/songlist.js | 31 +++++++++++++--------- app/controllers/api_v1/songs_controller.rb | 11 ++++++-- app/models/user.rb | 1 + 6 files changed, 33 insertions(+), 16 deletions(-) diff --git a/Gemfile b/Gemfile index 65a15a7..0ea3af1 100644 --- a/Gemfile +++ b/Gemfile @@ -24,7 +24,7 @@ end group :assets do gem 'jquery-rails', '2.0.2' - + gem 'jquery-cookie-rails' # Use Sass gem 'sass-rails', '3.2.6' diff --git a/Gemfile.lock b/Gemfile.lock index 1fb9f01..8132154 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -50,6 +50,8 @@ GEM i18n (0.6.9) jdbc-sqlite3 (3.7.2.1) journey (1.0.4) + jquery-cookie-rails (1.3.1.1) + railties (>= 3.2.0, < 5.0) jquery-rails (2.0.2) railties (>= 3.2.0, < 5.0) thor (~> 0.14) @@ -145,6 +147,7 @@ PLATFORMS DEPENDENCIES activerecord-jdbcsqlite3-adapter (= 1.2.9) fakefs + jquery-cookie-rails jquery-rails (= 2.0.2) quiet_assets rails (= 3.2.19) diff --git a/app/assets/javascripts/application.js b/app/assets/javascripts/application.js index 8c04f34..10366d3 100644 --- a/app/assets/javascripts/application.js +++ b/app/assets/javascripts/application.js @@ -11,6 +11,7 @@ // GO AFTER THE REQUIRES BELOW. // //= require jquery +//= require jquery.cookie //= require jquery_ujs //= require jquery-ui-1.8.17.custom.min //= require_tree . diff --git a/app/assets/javascripts/songlist.js b/app/assets/javascripts/songlist.js index 38b8561..d85241e 100644 --- a/app/assets/javascripts/songlist.js +++ b/app/assets/javascripts/songlist.js @@ -219,6 +219,14 @@ grid.setSelectedRows([row]); grid.scrollRowIntoView(row); + + var arr = JSON.parse($.cookie('history')); + if (arr==null){ + arr = []; + } + arr.push(id); + $.cookie('history', JSON.stringify(arr)); + }; grid.playSongAtRow = function (row) { @@ -227,28 +235,25 @@ }; grid.prevSong = function () { - var number_of_rows = grid.getDataLength(); - var new_row = number_of_rows - 1; - var current_row = dataView.getRowById(grid.playingSongId); - - if (current_row === undefined) { - // current song is not in the grid, stop playing + //extracts last song from history + var arr = JSON.parse($.cookie('history')); + var new_id = arr.pop() + //we need to pop twice because last song in history is current + new_id = arr.pop() + $.cookie('history', JSON.stringify(arr)); + + if (new_id == undefined){ stop(); return; } - - if ((current_row - 1) >= 0) { - new_row = current_row - 1; - } - - grid.playSongAtRow(new_row); + grid.playSong(new_id); }; grid.nextSong = function (shuffle, repeat, manual) { var number_of_rows = grid.getDataLength(); var new_row = 0; var current_row = -1; - + if (grid.playingSongId !== null) { current_row = dataView.getRowById(grid.playingSongId); diff --git a/app/controllers/api_v1/songs_controller.rb b/app/controllers/api_v1/songs_controller.rb index bec740c..9c6e5aa 100644 --- a/app/controllers/api_v1/songs_controller.rb +++ b/app/controllers/api_v1/songs_controller.rb @@ -1,9 +1,8 @@ module ApiV1 class SongsController < ApiController - def index songs_as_json = nil - + if params[:refresh] Rails.logger.info 'Forced song list refresh' songs_as_json = refresh_and_get_all @@ -16,9 +15,17 @@ def index end def play + song = Song.new_from_mp3_file(params[:file], -1) response.content_type = Mime::Type.lookup_by_extension("mp3") render :text => song.as_binary_stream + # begin + # @history.push(song.id) + # rescue StandardError => e + # @history = Array.new + # @history.push(song.id) + # end + # cookies[:history] = JSON.generate(@history) end private diff --git a/app/models/user.rb b/app/models/user.rb index 428b07b..7394d38 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -13,6 +13,7 @@ class User < ActiveRecord::Base attr_accessor :password_confirmation attr_reader :password + validates :username, :presence => true, :uniqueness => true, :length => { :minimum => 2 } validates :email, :presence => true, :uniqueness => true, :email => true validates :password, :confirmation => true From bab84fd7cd049a65b12846c83dd29779b2a451ff Mon Sep 17 00:00:00 2001 From: bublik42 Date: Fri, 17 Oct 2014 22:55:50 +0300 Subject: [PATCH 113/203] added playing status and time to cookies, history updated --- app/assets/javascripts/main.js | 17 ++++++++++++----- app/assets/javascripts/songlist.js | 16 ++++++++++------ 2 files changed, 22 insertions(+), 11 deletions(-) diff --git a/app/assets/javascripts/main.js b/app/assets/javascripts/main.js index 9adc6c9..c560c14 100644 --- a/app/assets/javascripts/main.js +++ b/app/assets/javascripts/main.js @@ -33,7 +33,7 @@ $(document).ready(function () { soundManager.onready(function () { } } - + // ::: USER MENU ::: $(window).hashchange(function () { @@ -51,6 +51,7 @@ $(document).ready(function () { soundManager.onready(function () { lastfm.newSong(song); } }); + ; var playerTrack = $('#player-song .track'); var playPause = $('#play-pause'); @@ -70,13 +71,14 @@ $(document).ready(function () { soundManager.onready(function () { }, onPaused: function () { playPause.removeClass('playing'); + }, onSongEnd: function () { songlist.nextSong(getShuffle(), getRepeat()); }, onTimeChange: function (elaps) { elapsedTimeChanged(elaps); - + $.cookie('time', JSON.stringify(elaps)); if (!user_is_seeking) { seekbar.slider('option', 'value', elaps); } @@ -158,14 +160,18 @@ $(document).ready(function () { soundManager.onready(function () { // playback buttons playPause.click(function (e) { e.preventDefault(); - // if not playing anything, start playing the first song on the playlist if (!songlist.isPlaying()) { songlist.nextSong(getShuffle(), getRepeat()); + $.cookie('isPlaying', 'true'); return; } - BeatAudio.togglePause(); + if ($.cookie('isPlaying') == 'false'){ + $.cookie('isPlaying', 'true'); + }else{ + $.cookie('isPlaying', 'false'); + } }); nextButton.click(function (e) { @@ -235,7 +241,6 @@ $(document).ready(function () { soundManager.onready(function () { songlist.loadPlaylist('/songs/index'); - // enable buttons $('#player-buttons button').removeAttr('disabled'); @@ -250,6 +255,7 @@ $(document).ready(function () { soundManager.onready(function () { seekbar.slider('value', 0); seekbar.slider('option', 'disabled', true); playerTrack.text('None'); + $.cookie('isPlaying', 'false'); } function durationChanged(dur) { @@ -268,6 +274,7 @@ $(document).ready(function () { soundManager.onready(function () { elapsed.text((mins > 9 ? mins : '0' + mins) + ':' + (secs > 9 ? secs : '0' + secs)); lastfm.scrobble(elaps); + $.cookie('time', elaps) } }); }); diff --git a/app/assets/javascripts/songlist.js b/app/assets/javascripts/songlist.js index d85241e..a0c03a8 100644 --- a/app/assets/javascripts/songlist.js +++ b/app/assets/javascripts/songlist.js @@ -27,6 +27,8 @@ { id: 'path', name: '', field: 'path' } ]; + var seekbar = $('#seekbar-slider'); + var options = { editable: false, forceFitColumns: true, @@ -226,7 +228,7 @@ } arr.push(id); $.cookie('history', JSON.stringify(arr)); - + $.cookie('isPlaying','true'); }; grid.playSongAtRow = function (row) { @@ -235,13 +237,12 @@ }; grid.prevSong = function () { - //extracts last song from history var arr = JSON.parse($.cookie('history')); var new_id = arr.pop() - //we need to pop twice because last song in history is current - new_id = arr.pop() + if (grid.playingSongId !== null){ + new_id = arr.pop() + } $.cookie('history', JSON.stringify(arr)); - if (new_id == undefined){ stop(); return; @@ -287,11 +288,13 @@ grid.playSongAtRow(new_row); }; - // wire up model events to drive the grid dataView.onRowCountChanged.subscribe(function (e, args) { grid.updateRowCount(); grid.render(); + if ($.cookie('isPlaying') == 'true'){ + grid.prevSong(); + } }); dataView.onRowsChanged.subscribe(function (e, args) { @@ -435,4 +438,5 @@ window.Songlist = Songlist; + })(jQuery, window, document); From c80c8c3772edaa1a14f97782c5c8af0334658f2d Mon Sep 17 00:00:00 2001 From: bublik42 Date: Sun, 19 Oct 2014 23:48:56 +0300 Subject: [PATCH 114/203] issue-#22-select-all --- app/assets/javascripts/main.js | 4 +++- app/assets/javascripts/songlist.js | 17 +++++++++++++++++ 2 files changed, 20 insertions(+), 1 deletion(-) diff --git a/app/assets/javascripts/main.js b/app/assets/javascripts/main.js index c560c14..47c1353 100644 --- a/app/assets/javascripts/main.js +++ b/app/assets/javascripts/main.js @@ -10,7 +10,9 @@ //= require audio-modules/soundmanager2 var keyCode = { - ENTER: 13 + ENTER: 13, + cntrlKey: 17, + aKey: 65 }; $(document).ready(function () { soundManager.onready(function () { diff --git a/app/assets/javascripts/songlist.js b/app/assets/javascripts/songlist.js index a0c03a8..e74aa94 100644 --- a/app/assets/javascripts/songlist.js +++ b/app/assets/javascripts/songlist.js @@ -77,8 +77,25 @@ e.stopPropagation(); } + if (e.keyCode == keyCode.aKey && e.ctrlKey ){ + e.preventDefault(); + var i = 1; + var rows = []; + while (dataView.getRowById(i) !== undefined){ + rows.push(dataView.getRowById(i)); + i++; + } + grid.setSelectedRows(rows); + + } }); + // grid.onKeyUp.subscribe(function (e) { + // if (e.keyCode == keyCode.cntrlKey){ + // cntrlDown = false; + // } + // }); + grid.onSelectedRowsChanged.subscribe(function (e) { //var row = grid.getSelectedRows()[0]; }); From 2aef955b6d2e8f94f49ac627995eba81ed8b7500 Mon Sep 17 00:00:00 2001 From: Antti-Jussi Kovalainen Date: Wed, 22 Oct 2014 19:05:24 +0300 Subject: [PATCH 115/203] Clean-up after merging 'bublik42-issue#37-shuffle-back' --- app/assets/javascripts/songlist.js | 29 ++++++++++------------ app/controllers/api_v1/songs_controller.rb | 10 +------- app/models/user.rb | 1 - 3 files changed, 14 insertions(+), 26 deletions(-) diff --git a/app/assets/javascripts/songlist.js b/app/assets/javascripts/songlist.js index d85241e..05553a5 100644 --- a/app/assets/javascripts/songlist.js +++ b/app/assets/javascripts/songlist.js @@ -63,14 +63,14 @@ grid.onKeyDown.subscribe(function (e) { if (e.keyCode == keyCode.ENTER) { - + var rows = grid.getSelectedRows(); if (!rows || rows.length <= 0) { return; } var dataItem = grid.getDataItem(rows[0]); - + grid.playSong(dataItem.id); e.stopPropagation(); @@ -220,13 +220,11 @@ grid.setSelectedRows([row]); grid.scrollRowIntoView(row); - var arr = JSON.parse($.cookie('history')); - if (arr==null){ - arr = []; - } + var arr = JSON.parse($.cookie('history')) || []; arr.push(id); - $.cookie('history', JSON.stringify(arr)); - + + // save song history in a cookie + $.cookie('history', JSON.stringify(arr)); }; grid.playSongAtRow = function (row) { @@ -235,14 +233,13 @@ }; grid.prevSong = function () { - //extracts last song from history + // extract last song from history (pop twice because current song is there also) var arr = JSON.parse($.cookie('history')); - var new_id = arr.pop() - //we need to pop twice because last song in history is current - new_id = arr.pop() - $.cookie('history', JSON.stringify(arr)); + var new_id = arr.pop(); + new_id = arr.pop(); + $.cookie('history', JSON.stringify(arr)); - if (new_id == undefined){ + if (new_id == undefined) { stop(); return; } @@ -253,7 +250,7 @@ var number_of_rows = grid.getDataLength(); var new_row = 0; var current_row = -1; - + if (grid.playingSongId !== null) { current_row = dataView.getRowById(grid.playingSongId); @@ -269,7 +266,7 @@ while(new_row == current_row && number_of_rows > 1){ //reshuffles if same as previous and more than one song new_row = randomToN(number_of_rows); - } + } } else if ((current_row + 1) < number_of_rows) { // normal operation, move to next song diff --git a/app/controllers/api_v1/songs_controller.rb b/app/controllers/api_v1/songs_controller.rb index 9c6e5aa..bfd4f34 100644 --- a/app/controllers/api_v1/songs_controller.rb +++ b/app/controllers/api_v1/songs_controller.rb @@ -2,7 +2,7 @@ module ApiV1 class SongsController < ApiController def index songs_as_json = nil - + if params[:refresh] Rails.logger.info 'Forced song list refresh' songs_as_json = refresh_and_get_all @@ -15,17 +15,9 @@ def index end def play - song = Song.new_from_mp3_file(params[:file], -1) response.content_type = Mime::Type.lookup_by_extension("mp3") render :text => song.as_binary_stream - # begin - # @history.push(song.id) - # rescue StandardError => e - # @history = Array.new - # @history.push(song.id) - # end - # cookies[:history] = JSON.generate(@history) end private diff --git a/app/models/user.rb b/app/models/user.rb index 7394d38..428b07b 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -13,7 +13,6 @@ class User < ActiveRecord::Base attr_accessor :password_confirmation attr_reader :password - validates :username, :presence => true, :uniqueness => true, :length => { :minimum => 2 } validates :email, :presence => true, :uniqueness => true, :email => true validates :password, :confirmation => true From 1a4ce79981f139c7a37a7421ec02f0944a0e4be5 Mon Sep 17 00:00:00 2001 From: Antti-Jussi Kovalainen Date: Wed, 22 Oct 2014 19:16:06 +0300 Subject: [PATCH 116/203] Fix previous-button not working if history empty and shuffle off --- app/assets/javascripts/main.js | 12 ++++---- app/assets/javascripts/songlist.js | 48 ++++++++++++++++++++++-------- 2 files changed, 41 insertions(+), 19 deletions(-) diff --git a/app/assets/javascripts/main.js b/app/assets/javascripts/main.js index 9adc6c9..2a98075 100644 --- a/app/assets/javascripts/main.js +++ b/app/assets/javascripts/main.js @@ -39,7 +39,7 @@ $(document).ready(function () { soundManager.onready(function () { $(window).hashchange(function () { Routing.ResolveCurrent(); }); - + // ::: INIT songlist / GRID OMG SO BIG SECTION ::: var songlist = new Songlist({ @@ -175,12 +175,12 @@ $(document).ready(function () { soundManager.onready(function () { prevButton.click(function (e) { e.preventDefault(); - songlist.prevSong(); + songlist.prevSong(getShuffle()); }); playerTrack.dblclick(function (e) { e.preventDefault(); - + songlist.scrollNowPlayingIntoView(); }); @@ -196,7 +196,7 @@ $(document).ready(function () { soundManager.onready(function () { if (store.get(key)) { value = store.get(key); } - + if (value) { button.addClass('enabled'); } @@ -218,7 +218,7 @@ $(document).ready(function () { soundManager.onready(function () { if (key && store.get(key)) { return store.get(key); } - + return false; } @@ -242,7 +242,7 @@ $(document).ready(function () { soundManager.onready(function () { function stop() { BeatAudio.stop(); songlist.resetPlaying(); - + // TODO: hide now playing icon from slickgrid elapsedTimeChanged(0); diff --git a/app/assets/javascripts/songlist.js b/app/assets/javascripts/songlist.js index 05553a5..e98bacb 100644 --- a/app/assets/javascripts/songlist.js +++ b/app/assets/javascripts/songlist.js @@ -224,7 +224,9 @@ arr.push(id); // save song history in a cookie - $.cookie('history', JSON.stringify(arr)); + if (shuffle) { + $.cookie('history', JSON.stringify(arr)); + } }; grid.playSongAtRow = function (row) { @@ -232,18 +234,38 @@ grid.playSong(song.id); }; - grid.prevSong = function () { - // extract last song from history (pop twice because current song is there also) - var arr = JSON.parse($.cookie('history')); - var new_id = arr.pop(); - new_id = arr.pop(); - $.cookie('history', JSON.stringify(arr)); + grid.prevSong = function (shuffle) { + if (shuffle) { + var arr = JSON.parse($.cookie('history')); + var new_id; - if (new_id == undefined) { - stop(); - return; + // extract last song from history (pop twice because current song is there also) + arr.pop(); + new_id = arr.pop(); + + if (!new_id) { + return; + } + + $.cookie('history', JSON.stringify(arr)); + grid.playSong(new_id); + } else { + var number_of_rows = grid.getDataLength(); + var new_row = number_of_rows - 1; + var current_row = dataView.getRowById(grid.playingSongId); + + if (current_row === undefined) { + // current song is not in the grid, stop playing + stop(); + return; + } + + if ((current_row - 1) >= 0) { + new_row = current_row - 1; + } + + grid.playSongAtRow(new_row); } - grid.playSong(new_id); }; grid.nextSong = function (shuffle, repeat, manual) { @@ -371,8 +393,8 @@ this.grid.nextSong(shuffle, manual); }; - Songlist.prototype.prevSong = function () { - this.grid.prevSong(); + Songlist.prototype.prevSong = function (shuffle) { + this.grid.prevSong(shuffle); }; Songlist.prototype.isPlaying = function () { From f84fb34a2a53346c7175611c6a18805d83348f69 Mon Sep 17 00:00:00 2001 From: Antti-Jussi Kovalainen Date: Wed, 22 Oct 2014 19:25:52 +0300 Subject: [PATCH 117/203] Remove duplicate "time" cookie set --- app/assets/javascripts/main.js | 2 -- 1 file changed, 2 deletions(-) diff --git a/app/assets/javascripts/main.js b/app/assets/javascripts/main.js index 8d544a7..0e45c0f 100644 --- a/app/assets/javascripts/main.js +++ b/app/assets/javascripts/main.js @@ -77,8 +77,6 @@ $(document).ready(function () { soundManager.onready(function () { onTimeChange: function (elaps) { elapsedTimeChanged(elaps); - $.cookie('time', JSON.stringify(elaps)); - if (!user_is_seeking) { seekbar.slider('option', 'value', elaps); } From af15951a1c3730c4a4e4b4043f38f5bb1b8a4137 Mon Sep 17 00:00:00 2001 From: Antti-Jussi Kovalainen Date: Wed, 22 Oct 2014 19:27:50 +0300 Subject: [PATCH 118/203] Whoops --- app/assets/javascripts/main.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/assets/javascripts/main.js b/app/assets/javascripts/main.js index 0e45c0f..b70a868 100644 --- a/app/assets/javascripts/main.js +++ b/app/assets/javascripts/main.js @@ -162,13 +162,13 @@ $(document).ready(function () { soundManager.onready(function () { // if not playing anything, start playing the first song on the playlist if (!songlist.isPlaying()) { songlist.nextSong(getShuffle(), getRepeat()); - $.cookie('isPlaying', 'true'); + $.cookie('isPlaying', true); return; } BeatAudio.togglePause(); - $.cookie('isPlaying', !$.cookie('isPlaying')); + $.cookie('isPlaying', ($.cookie('isPlaying') == 'false')); }); nextButton.click(function (e) { From 9738d03948b148917bede5dba890b24b6eef53df Mon Sep 17 00:00:00 2001 From: Antti-Jussi Kovalainen Date: Wed, 22 Oct 2014 19:32:55 +0300 Subject: [PATCH 119/203] Refactor save-state feature --- app/assets/javascripts/main.js | 2 +- app/assets/javascripts/songlist.js | 9 +++++++-- 2 files changed, 8 insertions(+), 3 deletions(-) diff --git a/app/assets/javascripts/main.js b/app/assets/javascripts/main.js index b70a868..110b61b 100644 --- a/app/assets/javascripts/main.js +++ b/app/assets/javascripts/main.js @@ -272,7 +272,7 @@ $(document).ready(function () { soundManager.onready(function () { elapsed.text((mins > 9 ? mins : '0' + mins) + ':' + (secs > 9 ? secs : '0' + secs)); lastfm.scrobble(elaps); - $.cookie('time', elaps) + $.cookie('time', elaps); // TODO: use this somewhere } }); }); diff --git a/app/assets/javascripts/songlist.js b/app/assets/javascripts/songlist.js index af7061c..19a1288 100644 --- a/app/assets/javascripts/songlist.js +++ b/app/assets/javascripts/songlist.js @@ -17,6 +17,8 @@ // SlickGrid + var initDone = false; + var columns = [ { id: 'np', resizable: false, width: 22 }, { id: 'artist', name: 'Artist', field: 'artist', sortable: true }, @@ -228,7 +230,7 @@ // save song history in a cookie if (shuffle) { $.cookie('history', JSON.stringify(arr)); - $.cookie('isPlaying','true'); + $.cookie('isPlaying', true); } }; @@ -316,8 +318,11 @@ dataView.onRowCountChanged.subscribe(function (e, args) { grid.updateRowCount(); grid.render(); - if ($.cookie('isPlaying') == 'true') { + + // Start playing the last song that was playing on init + if ($.cookie('isPlaying') == 'true' && !initDone) { grid.prevSong(); + initDone = true; } }); From 8b8e1fcf506b978091f6d022373162a3f1c36eb7 Mon Sep 17 00:00:00 2001 From: Antti-Jussi Kovalainen Date: Wed, 22 Oct 2014 19:34:35 +0300 Subject: [PATCH 120/203] Fix play resuming --- app/assets/javascripts/songlist.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/assets/javascripts/songlist.js b/app/assets/javascripts/songlist.js index 19a1288..3918185 100644 --- a/app/assets/javascripts/songlist.js +++ b/app/assets/javascripts/songlist.js @@ -321,7 +321,7 @@ // Start playing the last song that was playing on init if ($.cookie('isPlaying') == 'true' && !initDone) { - grid.prevSong(); + grid.prevSong(true); // FIXME: ugh, dirty hack initDone = true; } }); From 14d62eae2a4988eae77a9129de787bb675179fe8 Mon Sep 17 00:00:00 2001 From: Antti-Jussi Kovalainen Date: Wed, 22 Oct 2014 19:41:13 +0300 Subject: [PATCH 121/203] Clean-up after merging 'bublik-42-select-all-items-ctrl-a' --- app/assets/javascripts/main.js | 3 +-- app/assets/javascripts/songlist.js | 14 ++++---------- 2 files changed, 5 insertions(+), 12 deletions(-) diff --git a/app/assets/javascripts/main.js b/app/assets/javascripts/main.js index 398bffb..24fdd0c 100644 --- a/app/assets/javascripts/main.js +++ b/app/assets/javascripts/main.js @@ -11,8 +11,7 @@ var keyCode = { ENTER: 13, - cntrlKey: 17, - aKey: 65 + A: 65 }; $(document).ready(function () { soundManager.onready(function () { diff --git a/app/assets/javascripts/songlist.js b/app/assets/javascripts/songlist.js index 3c18928..e445c41 100644 --- a/app/assets/javascripts/songlist.js +++ b/app/assets/javascripts/songlist.js @@ -79,25 +79,19 @@ e.stopPropagation(); } - if (e.keyCode == keyCode.aKey && e.ctrlKey ){ + + if (e.keyCode == keyCode.A && e.ctrlKey) { e.preventDefault(); var i = 1; var rows = []; - while (dataView.getRowById(i) !== undefined){ + while (dataView.getRowById(i) !== undefined) { rows.push(dataView.getRowById(i)); - i++; + i++; } grid.setSelectedRows(rows); - } }); - // grid.onKeyUp.subscribe(function (e) { - // if (e.keyCode == keyCode.cntrlKey){ - // cntrlDown = false; - // } - // }); - grid.onSelectedRowsChanged.subscribe(function (e) { //var row = grid.getSelectedRows()[0]; }); From 62951bd03f5e00ccfabd4aacfe27a010a2c03523 Mon Sep 17 00:00:00 2001 From: Antti-Jussi Kovalainen Date: Wed, 22 Oct 2014 19:41:33 +0300 Subject: [PATCH 122/203] Add support for cmd+a for select all on Mac --- app/assets/javascripts/songlist.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/assets/javascripts/songlist.js b/app/assets/javascripts/songlist.js index e445c41..100019b 100644 --- a/app/assets/javascripts/songlist.js +++ b/app/assets/javascripts/songlist.js @@ -80,7 +80,7 @@ e.stopPropagation(); } - if (e.keyCode == keyCode.A && e.ctrlKey) { + if (e.keyCode == keyCode.A && (e.ctrlKey || e.metaKey)) { e.preventDefault(); var i = 1; var rows = []; From 60726f863145334c52e2863d91b1ddf04b95fb3c Mon Sep 17 00:00:00 2001 From: Antti-Jussi Kovalainen Date: Sun, 21 Dec 2014 14:02:33 +0200 Subject: [PATCH 123/203] Version 1.1.0 --- CHANGELOG.md | 13 +++++++++++++ config/initializers/version.rb | 2 +- 2 files changed, 14 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f99675f..5c15d1e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,19 @@ Changelog ========= +## 1.1.0 + +#### New features + +- Continues playing where you left off when you open the app +- Use Ctrl+A or Cmd+A to select all items + +#### Bug fixes + +- Don't play the same song in a row when shuffling +- Previous button now actually plays the previous song when shuffling + + ## 1.0.4 #### Bug fixes diff --git a/config/initializers/version.rb b/config/initializers/version.rb index 6157b1d..bd45225 100644 --- a/config/initializers/version.rb +++ b/config/initializers/version.rb @@ -1,3 +1,3 @@ module Beatstream - VERSION = '1.0.4' + VERSION = '1.1.0' end From 70f83d33482b8695849e77170034dd8d19f4cb60 Mon Sep 17 00:00:00 2001 From: Antti-Jussi Kovalainen Date: Sun, 21 Dec 2014 14:04:27 +0200 Subject: [PATCH 124/203] Update README.md --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index c1684a2..20b2c62 100644 --- a/README.md +++ b/README.md @@ -34,7 +34,7 @@ Open Chrome and surf to http://0.0.0.0:3000 address. Log in and wait when indexi **HTTPS/SSL:** When running in production environment, the app forces SSL on. To disable this, change `config.force_ssl` in `config/environments/production.rb` to false and it shouldn't redirect to https. -**Last.fm:** You need to manually go to https:///settings and do the authentication there. +**Last.fm:** You need to manually go to `https://localhost:3000/settings` and do the authentication there. **Media Library:** Opens a blank page after refresh is done. Just go back, and start listening :) From c4a4005898313ecb9fd05a4b8b16783a4f84a865 Mon Sep 17 00:00:00 2001 From: Antti-Jussi Kovalainen Date: Tue, 30 Dec 2014 09:49:25 +0200 Subject: [PATCH 125/203] Add failing test for MediaReader.refresh with a broken MP3 file --- test/unit/media_reader_test.rb | 14 ++++++++++++++ 1 file changed, 14 insertions(+) create mode 100644 test/unit/media_reader_test.rb diff --git a/test/unit/media_reader_test.rb b/test/unit/media_reader_test.rb new file mode 100644 index 0000000..bd60277 --- /dev/null +++ b/test/unit/media_reader_test.rb @@ -0,0 +1,14 @@ +require 'test_helper' + +class MediaReaderTest < ActiveSupport::TestCase + setup do + mock_mp3s + FakeFS.activate! + end + + test 'refresh works with a broken mp3 file' do + broken_mp3_path = File.join(MediaReader.MUSIC_PATH, 'broken.mp3').to_s + broken_mp3 = File.open(broken_mp3_path, 'w') { |f| f.write 'test' } + MediaReader.refresh + end +end From 4fd86e1c88a78a553cb2926906ade4cba7d91e86 Mon Sep 17 00:00:00 2001 From: Antti-Jussi Kovalainen Date: Tue, 30 Dec 2014 09:49:40 +0200 Subject: [PATCH 126/203] Fix MediaReader.refresh crashing with a broken MP3 file --- app/services/media_reader.rb | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/app/services/media_reader.rb b/app/services/media_reader.rb index b3230f4..417fd20 100644 --- a/app/services/media_reader.rb +++ b/app/services/media_reader.rb @@ -31,8 +31,9 @@ def self.files(path) end def self.refresh - songs = files(MUSIC_PATH).each_with_index.map { |f,i| create_song(f, i) }. - sort_by &:to_natural_sort_string + songs = files(MUSIC_PATH).each_with_index.map {|f,i| create_song(f, i)} + .select {|s| s.is_a?(Song)} + .sort_by &:to_natural_sort_string songs_file('w').write(songs.to_json) end From 601c88e15d805cbd3cd82051a601f32d9a8ce744 Mon Sep 17 00:00:00 2001 From: Antti-Jussi Kovalainen Date: Tue, 30 Dec 2014 09:53:50 +0200 Subject: [PATCH 127/203] Version 1.1.1 --- CHANGELOG.md | 7 +++++++ config/initializers/version.rb | 2 +- 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5c15d1e..a6e9df0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,13 @@ Changelog ========= +## 1.1.1 + +#### Bug fixes + +- Fix crash on refresh if library contains a broken MP3 file + + ## 1.1.0 #### New features diff --git a/config/initializers/version.rb b/config/initializers/version.rb index bd45225..9bc1e06 100644 --- a/config/initializers/version.rb +++ b/config/initializers/version.rb @@ -1,3 +1,3 @@ module Beatstream - VERSION = '1.1.0' + VERSION = '1.1.1' end From e7dd08acf110b1e091c3afadfe5d012698240831 Mon Sep 17 00:00:00 2001 From: Antti-Jussi Kovalainen Date: Sat, 14 Feb 2015 11:48:24 +0200 Subject: [PATCH 128/203] Remove cruft from application.js --- app/assets/javascripts/application.js | 12 ------------ 1 file changed, 12 deletions(-) diff --git a/app/assets/javascripts/application.js b/app/assets/javascripts/application.js index 10366d3..7e20865 100644 --- a/app/assets/javascripts/application.js +++ b/app/assets/javascripts/application.js @@ -1,15 +1,3 @@ -// This is a manifest file that'll be compiled into application.js, which will include all the files -// listed below. -// -// Any JavaScript/Coffee file within this directory, lib/assets/javascripts, vendor/assets/javascripts, -// or vendor/assets/javascripts of plugins, if any, can be referenced here using a relative path. -// -// It's not advisable to add code directly here, but if you do, it'll appear at the bottom of the -// the compiled file. -// -// WARNING: THE FIRST BLANK LINE MARKS THE END OF WHAT'S TO BE PROCESSED, ANY BLANK LINE SHOULD -// GO AFTER THE REQUIRES BELOW. -// //= require jquery //= require jquery.cookie //= require jquery_ujs From 73ea39fbfaf9508d073ecfac762065ea93165395 Mon Sep 17 00:00:00 2001 From: Antti-Jussi Kovalainen Date: Sat, 14 Feb 2015 11:52:51 +0200 Subject: [PATCH 129/203] Require jquery.cookie in the modules that require it --- app/assets/javascripts/application.js | 1 - app/assets/javascripts/main.js | 1 + app/assets/javascripts/songlist.js | 1 + 3 files changed, 2 insertions(+), 1 deletion(-) diff --git a/app/assets/javascripts/application.js b/app/assets/javascripts/application.js index 7e20865..f85d423 100644 --- a/app/assets/javascripts/application.js +++ b/app/assets/javascripts/application.js @@ -1,5 +1,4 @@ //= require jquery -//= require jquery.cookie //= require jquery_ujs //= require jquery-ui-1.8.17.custom.min //= require_tree . diff --git a/app/assets/javascripts/main.js b/app/assets/javascripts/main.js index 24fdd0c..59556e4 100644 --- a/app/assets/javascripts/main.js +++ b/app/assets/javascripts/main.js @@ -1,3 +1,4 @@ +//= require jquery.cookie //= require helpers //= require store.min //= require jquery.ba-hashchange.min diff --git a/app/assets/javascripts/songlist.js b/app/assets/javascripts/songlist.js index 100019b..f0a4230 100644 --- a/app/assets/javascripts/songlist.js +++ b/app/assets/javascripts/songlist.js @@ -1,3 +1,4 @@ +//= require jquery.cookie //= require helpers //= require dragtooltip /*! From 2b318d474332f36c3f43c758bf0b264ee4428d87 Mon Sep 17 00:00:00 2001 From: Antti-Jussi Kovalainen Date: Sat, 14 Feb 2015 11:58:25 +0200 Subject: [PATCH 130/203] Remove jquery_ujs require (not used) --- app/assets/javascripts/application.js | 1 - 1 file changed, 1 deletion(-) diff --git a/app/assets/javascripts/application.js b/app/assets/javascripts/application.js index f85d423..8a9671c 100644 --- a/app/assets/javascripts/application.js +++ b/app/assets/javascripts/application.js @@ -1,4 +1,3 @@ //= require jquery -//= require jquery_ujs //= require jquery-ui-1.8.17.custom.min //= require_tree . From 17f9faeef6c3b38f2a42048af97afa4596431df7 Mon Sep 17 00:00:00 2001 From: Antti-Jussi Kovalainen Date: Sat, 14 Feb 2015 12:06:28 +0200 Subject: [PATCH 131/203] Clean-up login.css --- app/assets/stylesheets/login.css | 17 ++++------------- 1 file changed, 4 insertions(+), 13 deletions(-) diff --git a/app/assets/stylesheets/login.css b/app/assets/stylesheets/login.css index c1ce8ac..e7cb920 100644 --- a/app/assets/stylesheets/login.css +++ b/app/assets/stylesheets/login.css @@ -1,13 +1,4 @@ -/*! - * - */ - -/* - -*/ - - -/* RESET */ +/* 0.RESET */ html,body,div,span,applet,object,iframe,h1,h2,h3,h4,h5,h6,p,blockquote,pre,a,abbr,acronym,address,big,cite,code,del,dfn,em,img,ins,kbd,q,s,samp,small,strike,strong,sub,sup,tt,var,b,u,i,center,dl,dt,dd,ol,ul,li,fieldset,form,label,legend,table,caption,tbody,tfoot,thead,tr,th,td,article,aside,canvas,details,figcaption,figure,footer,header,hgroup,menu,nav,section,summary,time,mark,audio,video{margin:0;padding:0;} article,aside,details,figcaption,figure,footer,header,hgroup,menu,nav,section{display: block;} table{border-collapse:collapse;border-spacing:0;} @@ -35,7 +26,7 @@ input { /* 2.HEADER -*/ +----------------------------------------------------------------------------- */ h1 { height:60px; width:320px; @@ -46,8 +37,8 @@ h1 { } -/* 3.LOGIN -*/ +/* 3.LOGIN FORM +----------------------------------------------------------------------------- */ #wrap { position:absolute; top:50%; From 2e0cbc07de4a884756fe16f5cfacc5d4e85da1ed Mon Sep 17 00:00:00 2001 From: Antti-Jussi Kovalainen Date: Sat, 14 Feb 2015 12:11:28 +0200 Subject: [PATCH 132/203] Clean-up CSS file and directory structure --- app/assets/stylesheets/application.css | 20 --- app/assets/stylesheets/application.scss | 18 +++ app/assets/stylesheets/base.css.scss | 64 --------- app/assets/stylesheets/base/_base.scss | 36 +++++ .../{layout.css.scss => base/_layout.scss} | 0 app/assets/stylesheets/base/_reset.scss | 9 ++ .../stylesheets/components/_buttons.scss | 20 +++ .../_header.scss} | 0 .../stylesheets/components/_modals.scss | 75 +++++++++++ .../_player.scss} | 0 .../stylesheets/components/_preloader.scss | 26 ++++ .../_sidebar.scss} | 0 app/assets/stylesheets/components/_sm2.scss | 19 +++ .../_songlist.scss} | 0 .../stylesheets/globals/_abstractions.scss | 0 app/assets/stylesheets/globals/_mixins.scss | 5 + app/assets/stylesheets/globals/_vars.scss | 0 app/assets/stylesheets/other.css.scss | 124 ------------------ 18 files changed, 208 insertions(+), 208 deletions(-) delete mode 100644 app/assets/stylesheets/application.css create mode 100644 app/assets/stylesheets/application.scss delete mode 100644 app/assets/stylesheets/base.css.scss create mode 100644 app/assets/stylesheets/base/_base.scss rename app/assets/stylesheets/{layout.css.scss => base/_layout.scss} (100%) create mode 100644 app/assets/stylesheets/base/_reset.scss create mode 100644 app/assets/stylesheets/components/_buttons.scss rename app/assets/stylesheets/{header.css.scss => components/_header.scss} (100%) create mode 100644 app/assets/stylesheets/components/_modals.scss rename app/assets/stylesheets/{player.css.scss => components/_player.scss} (100%) create mode 100644 app/assets/stylesheets/components/_preloader.scss rename app/assets/stylesheets/{sidebar.css.scss => components/_sidebar.scss} (100%) create mode 100644 app/assets/stylesheets/components/_sm2.scss rename app/assets/stylesheets/{songlist.css.scss => components/_songlist.scss} (100%) create mode 100644 app/assets/stylesheets/globals/_abstractions.scss create mode 100644 app/assets/stylesheets/globals/_mixins.scss create mode 100644 app/assets/stylesheets/globals/_vars.scss delete mode 100644 app/assets/stylesheets/other.css.scss diff --git a/app/assets/stylesheets/application.css b/app/assets/stylesheets/application.css deleted file mode 100644 index bdafa21..0000000 --- a/app/assets/stylesheets/application.css +++ /dev/null @@ -1,20 +0,0 @@ -/* - * This is a manifest file that'll be compiled into application.css, which will include all the files - * listed below. - * - * Any CSS and SCSS file within this directory, lib/assets/stylesheets, vendor/assets/stylesheets, - * or vendor/assets/stylesheets of plugins, if any, can be referenced here using a relative path. - * - * You're free to add application-wide styles to this file and they'll appear at the top of the - * compiled file, but it's generally better to create a new file per style scope. - * - *= require_self - *= require base - *= require layout - *= require header - *= require songlist - *= require player - *= require sidebar - *= require other - *= require vendor -*/ diff --git a/app/assets/stylesheets/application.scss b/app/assets/stylesheets/application.scss new file mode 100644 index 0000000..82c6213 --- /dev/null +++ b/app/assets/stylesheets/application.scss @@ -0,0 +1,18 @@ +@import "vendor"; + +@import "globals/vars"; +@import "globals/mixins"; + +@import "base/reset"; +@import "base/base"; +@import "base/layout"; + +@import "components/buttons"; +@import "components/modals"; + +@import "components/preloader"; +@import "components/sm2"; +@import "components/header"; +@import "components/sidebar"; +@import "components/songlist"; +@import "components/player"; diff --git a/app/assets/stylesheets/base.css.scss b/app/assets/stylesheets/base.css.scss deleted file mode 100644 index 482ae40..0000000 --- a/app/assets/stylesheets/base.css.scss +++ /dev/null @@ -1,64 +0,0 @@ -/* RESET */ -html,body,div,span,applet,object,iframe,h1,h2,h3,h4,h5,h6,p,blockquote,pre,a,abbr,acronym,address,big,cite,code,del,dfn,em,img,ins,kbd,q,s,samp,small,strike,strong,sub,sup,tt,var,b,u,i,center,dl,dt,dd,ol,ul,li,fieldset,form,label,legend,table,caption,tbody,tfoot,thead,tr,th,td,article,aside,canvas,details,figcaption,figure,footer,header,hgroup,menu,nav,section,summary,time,mark,audio,video{margin:0;padding:0;} -article,aside,details,figcaption,figure,footer,header,hgroup,menu,nav,section{display: block;} -table{border-collapse:collapse;border-spacing:0;} -fieldset,img{border:0;} -address,caption,cite,dfn,th,var{font-style:normal;font-weight:normal;} -caption,th{text-align:left;} -h1,h2,h3,h4,h5,h6{font-size:100%;font-weight:normal;} -abbr,acronym{border:0;} - -html,body { - height:100%; -} - -body { - color:#333; - font-family:sans-serif; - margin:0 auto; - overflow:hidden; - -webkit-font-smoothing: antialiased; -} -h1 { - font-size:32px; - line-height:1.4em; -} -h2 { - font-size:24px; - font-weight:700; - line-height:1.4em; -} -h5 { - font-weight:700; -} -a { - color:inherit; - cursor: pointer; - text-decoration:none; -} - -.btn { - background:#595959; - background: -webkit-linear-gradient(top, #707070, #424242); - background: -moz-linear-gradient(top, #707070, #424242); - background: -o-linear-gradient(top, #707070, #424242); - background: -ms-linear-gradient(top, #707070, #424242); - background: linear-gradient(top, #707070, #424242); - color:#fff; - display:inline-block; - font-size:12px; - font-weight:700; - height:28px; - line-height:28px; - padding:0 8px; - text-align:center; - text-shadow:0 1px 0 #000; - -webkit-border-radius:3px; - -moz-border-radius:3px; - border-radius:3px; -} - -input[type="submit"] { - cursor:pointer; -} - diff --git a/app/assets/stylesheets/base/_base.scss b/app/assets/stylesheets/base/_base.scss new file mode 100644 index 0000000..fdcaa79 --- /dev/null +++ b/app/assets/stylesheets/base/_base.scss @@ -0,0 +1,36 @@ +html,body { + height:100%; +} + +body { + color:#333; + font-family:sans-serif; + margin:0 auto; + overflow:hidden; + -webkit-font-smoothing: antialiased; +} + +h1 { + font-size:32px; + line-height:1.4em; +} + +h2 { + font-size:24px; + font-weight:700; + line-height:1.4em; +} + +h5 { + font-weight:700; +} + +a { + color:inherit; + cursor: pointer; + text-decoration:none; +} + +input[type="submit"] { + cursor:pointer; +} diff --git a/app/assets/stylesheets/layout.css.scss b/app/assets/stylesheets/base/_layout.scss similarity index 100% rename from app/assets/stylesheets/layout.css.scss rename to app/assets/stylesheets/base/_layout.scss diff --git a/app/assets/stylesheets/base/_reset.scss b/app/assets/stylesheets/base/_reset.scss new file mode 100644 index 0000000..c9487f2 --- /dev/null +++ b/app/assets/stylesheets/base/_reset.scss @@ -0,0 +1,9 @@ +/* RESET */ +html,body,div,span,applet,object,iframe,h1,h2,h3,h4,h5,h6,p,blockquote,pre,a,abbr,acronym,address,big,cite,code,del,dfn,em,img,ins,kbd,q,s,samp,small,strike,strong,sub,sup,tt,var,b,u,i,center,dl,dt,dd,ol,ul,li,fieldset,form,label,legend,table,caption,tbody,tfoot,thead,tr,th,td,article,aside,canvas,details,figcaption,figure,footer,header,hgroup,menu,nav,section,summary,time,mark,audio,video{margin:0;padding:0;} +article,aside,details,figcaption,figure,footer,header,hgroup,menu,nav,section{display: block;} +table{border-collapse:collapse;border-spacing:0;} +fieldset,img{border:0;} +address,caption,cite,dfn,th,var{font-style:normal;font-weight:normal;} +caption,th{text-align:left;} +h1,h2,h3,h4,h5,h6{font-size:100%;font-weight:normal;} +abbr,acronym{border:0;} diff --git a/app/assets/stylesheets/components/_buttons.scss b/app/assets/stylesheets/components/_buttons.scss new file mode 100644 index 0000000..951052b --- /dev/null +++ b/app/assets/stylesheets/components/_buttons.scss @@ -0,0 +1,20 @@ +.btn { + background:#595959; + background: -webkit-linear-gradient(top, #707070, #424242); + background: -moz-linear-gradient(top, #707070, #424242); + background: -o-linear-gradient(top, #707070, #424242); + background: -ms-linear-gradient(top, #707070, #424242); + background: linear-gradient(top, #707070, #424242); + color:#fff; + display:inline-block; + font-size:12px; + font-weight:700; + height:28px; + line-height:28px; + padding:0 8px; + text-align:center; + text-shadow:0 1px 0 #000; + -webkit-border-radius:3px; + -moz-border-radius:3px; + border-radius:3px; +} diff --git a/app/assets/stylesheets/header.css.scss b/app/assets/stylesheets/components/_header.scss similarity index 100% rename from app/assets/stylesheets/header.css.scss rename to app/assets/stylesheets/components/_header.scss diff --git a/app/assets/stylesheets/components/_modals.scss b/app/assets/stylesheets/components/_modals.scss new file mode 100644 index 0000000..1229324 --- /dev/null +++ b/app/assets/stylesheets/components/_modals.scss @@ -0,0 +1,75 @@ +.modal { + font-size:13px; + padding:20px 30px; + width:340px; + + h2 { + margin:0 0 2px; + } + + .subheader { + color:#888; + font-size:14px; + margin:0 0 15px; + } + + fieldset { + border-top:1px solid #ddd; + margin:0 -30px; + padding:15px 30px; + } + + label { + clear:both; + display:inline-block; + float:left; + font-size:14px; + font-weight:700; + line-height:34px; + width:100px; + } + + .field { + display:inline-block; + float:left; + margin:0 0 15px; + } + + input[type="text"] { + border:1px solid #ccc; + font-size:13px; + line-height:20px; + padding:4px; + border-radius:3px; + -moz-border-radius:3px; + -webkit-border-radius:3px; + } + + .actions { + margin-left:100px; + + a { + margin-left:15px; + } + } + + .button { + background: #f5f5f5; + border: 1px solid #aaa; + color:#000; + font-size:11px; + padding:3px 6px 3px; + + border-radius:4px; + -moz-border-radius:4px; + -webkit-border-radius:4px; + + &:hover { + background:#eee; + } + + &:active { + background:#abe; + } + } +} diff --git a/app/assets/stylesheets/player.css.scss b/app/assets/stylesheets/components/_player.scss similarity index 100% rename from app/assets/stylesheets/player.css.scss rename to app/assets/stylesheets/components/_player.scss diff --git a/app/assets/stylesheets/components/_preloader.scss b/app/assets/stylesheets/components/_preloader.scss new file mode 100644 index 0000000..42a3b8f --- /dev/null +++ b/app/assets/stylesheets/components/_preloader.scss @@ -0,0 +1,26 @@ +.preloader { + background:rgba(0,0,0, 0.75); + height:100%; + position:fixed; + text-align:center; + width:100%; + z-index:998; + + * { + color:#fff; + top:50%; + left:50%; + position:absolute; + z-index:99999; + } + .loading { + margin:-8px 0 0 -8px; + } +} + +.loading { + background:url('/assets/preloader.gif') no-repeat; + height:16px; + width:16px; + @include clear_float; +} diff --git a/app/assets/stylesheets/sidebar.css.scss b/app/assets/stylesheets/components/_sidebar.scss similarity index 100% rename from app/assets/stylesheets/sidebar.css.scss rename to app/assets/stylesheets/components/_sidebar.scss diff --git a/app/assets/stylesheets/components/_sm2.scss b/app/assets/stylesheets/components/_sm2.scss new file mode 100644 index 0000000..973a784 --- /dev/null +++ b/app/assets/stylesheets/components/_sm2.scss @@ -0,0 +1,19 @@ +#sm2-container { + height:0; + width:0; + visibility:hidden; +} + +#sm2-container.swf_timedout { + background:#222; + background:rgba(0,0,0, 0.8); + height:300px; + padding:3em; + position:absolute; + left:40%; + top:40%; + text-align:center; + visibility:visible; + width:400px; + z-index:999; +} diff --git a/app/assets/stylesheets/songlist.css.scss b/app/assets/stylesheets/components/_songlist.scss similarity index 100% rename from app/assets/stylesheets/songlist.css.scss rename to app/assets/stylesheets/components/_songlist.scss diff --git a/app/assets/stylesheets/globals/_abstractions.scss b/app/assets/stylesheets/globals/_abstractions.scss new file mode 100644 index 0000000..e69de29 diff --git a/app/assets/stylesheets/globals/_mixins.scss b/app/assets/stylesheets/globals/_mixins.scss new file mode 100644 index 0000000..38e2e99 --- /dev/null +++ b/app/assets/stylesheets/globals/_mixins.scss @@ -0,0 +1,5 @@ +@mixin clear_float { + overflow:hidden; + text-indent:100%; + white-space:nowrap; +} diff --git a/app/assets/stylesheets/globals/_vars.scss b/app/assets/stylesheets/globals/_vars.scss new file mode 100644 index 0000000..e69de29 diff --git a/app/assets/stylesheets/other.css.scss b/app/assets/stylesheets/other.css.scss deleted file mode 100644 index 4c5cd51..0000000 --- a/app/assets/stylesheets/other.css.scss +++ /dev/null @@ -1,124 +0,0 @@ -/* mixins */ -@mixin clear_float { - overflow:hidden; - text-indent:100%; - white-space:nowrap; -} - -/* modals */ -.modal { - font-size:13px; - padding:20px 30px; - width:340px; - - h2 { - margin:0 0 2px; - } - .subheader { - color:#888; - font-size:14px; - margin:0 0 15px; - } - fieldset { - border-top:1px solid #ddd; - margin:0 -30px; - padding:15px 30px; - } - label { - clear:both; - display:inline-block; - float:left; - font-size:14px; - font-weight:700; - line-height:34px; - width:100px; - } - .field { - display:inline-block; - float:left; - margin:0 0 15px; - } - input[type="text"] { - border:1px solid #ccc; - font-size:13px; - line-height:20px; - padding:4px; - border-radius:3px; - -moz-border-radius:3px; - -webkit-border-radius:3px; - } - .actions { - margin-left:100px; - - a { - margin-left:15px; - } - } - .button { - background: #f5f5f5; - border: 1px solid #aaa; - color:#000; - font-size:11px; - padding:3px 6px 3px; - - border-radius:4px; - -moz-border-radius:4px; - -webkit-border-radius:4px; - - &:hover { - background:#eee; - } - - &:active { - background:#abe; - } - } -} - -/* preloader */ -.preloader { - background:rgba(0,0,0, 0.75); - height:100%; - position:fixed; - text-align:center; - width:100%; - z-index:998; - - * { - color:#fff; - top:50%; - left:50%; - position:absolute; - z-index:99999; - } - .loading { - margin:-8px 0 0 -8px; - } -} - -.loading { - background:url('/assets/preloader.gif') no-repeat; - height:16px; - width:16px; - @include clear_float; -} - -/* soundmanager 2 */ -#sm2-container { - height:0; - width:0; - visibility:hidden; -} -#sm2-container.swf_timedout { - background:#222; - background:rgba(0,0,0, 0.8); - height:300px; - padding:3em; - position:absolute; - left:40%; - top:40%; - text-align:center; - visibility:visible; - width:400px; - z-index:999; -} From dcc3816b15eaede2783527d490842e1651fcb4c8 Mon Sep 17 00:00:00 2001 From: Antti-Jussi Kovalainen Date: Sat, 14 Feb 2015 12:18:08 +0200 Subject: [PATCH 133/203] Variablize some things in SCSS --- app/assets/stylesheets/components/_buttons.scss | 13 +++++++------ app/assets/stylesheets/components/_player.scss | 13 +++++++------ app/assets/stylesheets/components/_songlist.scss | 6 +++--- app/assets/stylesheets/globals/_vars.scss | 3 +++ 4 files changed, 20 insertions(+), 15 deletions(-) diff --git a/app/assets/stylesheets/components/_buttons.scss b/app/assets/stylesheets/components/_buttons.scss index 951052b..37e0361 100644 --- a/app/assets/stylesheets/components/_buttons.scss +++ b/app/assets/stylesheets/components/_buttons.scss @@ -1,10 +1,11 @@ .btn { - background:#595959; - background: -webkit-linear-gradient(top, #707070, #424242); - background: -moz-linear-gradient(top, #707070, #424242); - background: -o-linear-gradient(top, #707070, #424242); - background: -ms-linear-gradient(top, #707070, #424242); - background: linear-gradient(top, #707070, #424242); + $gradient: top, lighten($color-gray, 10%), darken($color-gray, 10%); + background: $color-gray; + background: -webkit-linear-gradient($gradient); + background: -moz-linear-gradient($gradient); + background: -ms-linear-gradient($gradient); + background: -o-linear-gradient($gradient); + background: linear-gradient($gradient); color:#fff; display:inline-block; font-size:12px; diff --git a/app/assets/stylesheets/components/_player.scss b/app/assets/stylesheets/components/_player.scss index b0c869b..4695628 100644 --- a/app/assets/stylesheets/components/_player.scss +++ b/app/assets/stylesheets/components/_player.scss @@ -1,10 +1,11 @@ #player { - background:#222; - background: -webkit-linear-gradient(top, #383838, #383838 15%, #1f1f1f 90%, #1f1f1f); - background: -moz-linear-gradient(top, #383838, #383838 15%, #1f1f1f 90%, #1f1f1f); - background: -o-linear-gradient(top, #383838, #383838 15%, #1f1f1f 90%, #1f1f1f); - background: -ms-linear-gradient(top, #383838, #383838 15%, #1f1f1f 90%, #1f1f1f); - background: linear-gradient(top, #383838, #383838 15%, #1f1f1f 90%, #1f1f1f); + $player-gradient: top, #383838, #383838 15%, #1f1f1f 90%, #1f1f1f; + background: $color-black; + background: -webkit-linear-gradient($player-gradient); + background: -moz-linear-gradient($player-gradient); + background: -ms-linear-gradient($player-gradient); + background: -o-linear-gradient($player-gradient); + background: linear-gradient($player-gradient); border-top:1px solid #505050; bottom:0; color:#fff; diff --git a/app/assets/stylesheets/components/_songlist.scss b/app/assets/stylesheets/components/_songlist.scss index 00def45..99e5f3f 100644 --- a/app/assets/stylesheets/components/_songlist.scss +++ b/app/assets/stylesheets/components/_songlist.scss @@ -2,9 +2,9 @@ overflow: hidden; padding:15px 20px; position:relative; - + .info { - float:left; + float:left; } .meta { font-size:13px; @@ -113,7 +113,7 @@ padding-left:5px; } #slickgrid .selected { - background:#4c96e5; + background:$color-blue; color:#fff; } #slickgrid .playing { diff --git a/app/assets/stylesheets/globals/_vars.scss b/app/assets/stylesheets/globals/_vars.scss index e69de29..8ec321e 100644 --- a/app/assets/stylesheets/globals/_vars.scss +++ b/app/assets/stylesheets/globals/_vars.scss @@ -0,0 +1,3 @@ +$color-black: #222; +$color-gray: #595959; +$color-blue: #4c96e5; From b867ff78d77be41373e0bfc58467da68774484a2 Mon Sep 17 00:00:00 2001 From: Antti-Jussi Kovalainen Date: Sat, 14 Feb 2015 12:21:21 +0200 Subject: [PATCH 134/203] Change @clear_float => %clearfix --- app/assets/stylesheets/application.scss | 1 + app/assets/stylesheets/components/_preloader.scss | 2 +- app/assets/stylesheets/globals/_abstractions.scss | 5 +++++ app/assets/stylesheets/globals/_mixins.scss | 5 ----- 4 files changed, 7 insertions(+), 6 deletions(-) diff --git a/app/assets/stylesheets/application.scss b/app/assets/stylesheets/application.scss index 82c6213..0e60028 100644 --- a/app/assets/stylesheets/application.scss +++ b/app/assets/stylesheets/application.scss @@ -2,6 +2,7 @@ @import "globals/vars"; @import "globals/mixins"; +@import "globals/abstractions"; @import "base/reset"; @import "base/base"; diff --git a/app/assets/stylesheets/components/_preloader.scss b/app/assets/stylesheets/components/_preloader.scss index 42a3b8f..22442d9 100644 --- a/app/assets/stylesheets/components/_preloader.scss +++ b/app/assets/stylesheets/components/_preloader.scss @@ -19,8 +19,8 @@ } .loading { + @extend %clearfix; background:url('/assets/preloader.gif') no-repeat; height:16px; width:16px; - @include clear_float; } diff --git a/app/assets/stylesheets/globals/_abstractions.scss b/app/assets/stylesheets/globals/_abstractions.scss index e69de29..94e473e 100644 --- a/app/assets/stylesheets/globals/_abstractions.scss +++ b/app/assets/stylesheets/globals/_abstractions.scss @@ -0,0 +1,5 @@ +%clearfix { + overflow:hidden; + text-indent:100%; + white-space:nowrap; +} diff --git a/app/assets/stylesheets/globals/_mixins.scss b/app/assets/stylesheets/globals/_mixins.scss index 38e2e99..e69de29 100644 --- a/app/assets/stylesheets/globals/_mixins.scss +++ b/app/assets/stylesheets/globals/_mixins.scss @@ -1,5 +0,0 @@ -@mixin clear_float { - overflow:hidden; - text-indent:100%; - white-space:nowrap; -} From 11de0c6fdfd9a232c4fa2454fef62cde5a386bc4 Mon Sep 17 00:00:00 2001 From: Antti-Jussi Kovalainen Date: Sat, 14 Feb 2015 12:21:35 +0200 Subject: [PATCH 135/203] Add a few CSS3 mixins --- app/assets/stylesheets/globals/_mixins.scss | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/app/assets/stylesheets/globals/_mixins.scss b/app/assets/stylesheets/globals/_mixins.scss index e69de29..2bcecea 100644 --- a/app/assets/stylesheets/globals/_mixins.scss +++ b/app/assets/stylesheets/globals/_mixins.scss @@ -0,0 +1,13 @@ +@mixin box-shadow($shadow) { + -webkit-box-shadow: $shadow; + -moz-box-shadow: $shadow; + box-shadow: $shadow; +} + +@mixin linear-gradient($gradient) { + background: -webkit-linear-gradient($gradient); + background: -moz-linear-gradient($gradient); + background: -ms-linear-gradient($gradient); + background: -o-linear-gradient($gradient); + background: linear-gradient($gradient); +} From 7016bc289213991daf5851c2b0d4000e894ce875 Mon Sep 17 00:00:00 2001 From: Antti-Jussi Kovalainen Date: Sat, 14 Feb 2015 12:00:39 +0200 Subject: [PATCH 136/203] Disable text selection in player --- app/assets/stylesheets/components/_player.scss | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/app/assets/stylesheets/components/_player.scss b/app/assets/stylesheets/components/_player.scss index 4695628..fec277e 100644 --- a/app/assets/stylesheets/components/_player.scss +++ b/app/assets/stylesheets/components/_player.scss @@ -19,6 +19,14 @@ -moz-box-shadow: 0 -1px 0 #000; box-shadow: 0 -1px 0 #000; + // @extend %no-select; + -webkit-touch-callout: none; + -webkit-user-select: none; + -khtml-user-select: none; + -moz-user-select: none; + -ms-user-select: none; + user-select: none; + button { background:transparent url('/assets/icons.png') no-repeat; border:0; @@ -67,14 +75,14 @@ } #repeat { background-position:3px -189px; - + &.enabled { background-position:3px -164px } } #shuffle { background-position:-35px -189px; - + &.enabled { background-position:-35px -164px } From eefc11fcacf195ff28de7bff0da05b0cbe1c9b06 Mon Sep 17 00:00:00 2001 From: Antti-Jussi Kovalainen Date: Sat, 14 Feb 2015 12:22:47 +0200 Subject: [PATCH 137/203] Simplify selectors in _player.scss --- .../stylesheets/components/_player.scss | 109 ++++++++++-------- app/views/layouts/application.html.erb | 2 +- 2 files changed, 62 insertions(+), 49 deletions(-) diff --git a/app/assets/stylesheets/components/_player.scss b/app/assets/stylesheets/components/_player.scss index fec277e..f4489c9 100644 --- a/app/assets/stylesheets/components/_player.scss +++ b/app/assets/stylesheets/components/_player.scss @@ -1,4 +1,4 @@ -#player { +.player { $player-gradient: top, #383838, #383838 15%, #1f1f1f 90%, #1f1f1f; background: $color-black; background: -webkit-linear-gradient($player-gradient); @@ -34,58 +34,68 @@ width:40px; text-indent:-9999em; } - button[disabled="disabled"] { - /*opacity:0.2;*/ - } - #play-pause { - background-position:-68px -1px; - height:48px; - width:48px; - } - #play-pause:active { +} + +#player-buttons, #player-volume, #player-time { + float:left; +} + +#play-pause { + background-position:-68px -1px; + height:48px; + width:48px; + + &:active { background-position:-68px -88px; } - #play-pause.playing { + &.playing { background-position:-213px -1px; } - #play-pause.playing:active { + &.playing:active { background-position:-213px -88px; } - #prev { - background-position:0 -5px; - margin-right:-2px; - } - #prev:active { +} + +#prev { + background-position:0 -5px; + margin-right:-2px; + + &:active { background-position:0 -92px; } - #next { - background-position:-148px -5px; - margin-left:-2px; - } - #next:active { +} + +#next { + background-position:-148px -5px; + margin-left:-2px; + + &:active { background-position:-148px -92px; } - #repeat, #shuffle { - background:transparent url('/assets/icons.png') no-repeat; - display:inline-block; - height:24px; - margin:0 0 0 5px; - padding:0; - width:24px; - } - #repeat { - background-position:3px -189px; +} - &.enabled { - background-position:3px -164px - } +#repeat, #shuffle { + background:transparent url('/assets/icons.png') no-repeat; + display:inline-block; + height:24px; + margin:0 0 0 5px; + padding:0; + width:24px; +} + +#repeat { + background-position:3px -189px; + + &.enabled { + background-position:3px -164px } - #shuffle { - background-position:-35px -189px; +} - &.enabled { - background-position:-35px -164px - } +#shuffle { + background-position:-35px -189px; + + &.enabled { + background-position:-35px -164px } } @@ -101,16 +111,19 @@ text-indent:100%; white-space: nowrap; } + #player-volume { margin:20px 15px 0 5px; text-align:center; width:100px; } + #player-time { margin-top:18px; text-align:center; width:80px; } + #seekbar { margin:0 75px 0 365px; padding:0 15px; @@ -118,16 +131,22 @@ text-align:center; top:19px; } + #player-buttons-2 { float:right; margin:12px 10px 0 0; } +#player-song, #player-controls { + padding-left:10px; + padding-right:10px; +} #player-song { border-bottom:1px solid #000; margin-bottom:8px; - padding:5px 0 5px; + padding-top: 5px; + padding-bottom: 5px; text-shadow:0 2px 0 rgba(0,0,0, 0.5); -webkit-box-shadow:0 1px 0 #434343; -moz-box-shadow:0 1px 0 #434343; @@ -137,15 +156,9 @@ color:#999; } } -#player-song, #player-controls { - padding-left:10px; - padding-right:10px; -} + #player-controls { height:48px; vertical-align: middle; } - #player-buttons, #player-volume, #player-time { - float:left; - } diff --git a/app/views/layouts/application.html.erb b/app/views/layouts/application.html.erb index e3f9972..58c8123 100644 --- a/app/views/layouts/application.html.erb +++ b/app/views/layouts/application.html.erb @@ -55,7 +55,7 @@
    -
    +
    Current song: None
    From d2aeaba4aa4178dfb3bcb9ff57da390f691e3864 Mon Sep 17 00:00:00 2001 From: Antti-Jussi Kovalainen Date: Sat, 14 Feb 2015 12:27:28 +0200 Subject: [PATCH 138/203] Add $color-orange --- app/assets/stylesheets/globals/_vars.scss | 1 + 1 file changed, 1 insertion(+) diff --git a/app/assets/stylesheets/globals/_vars.scss b/app/assets/stylesheets/globals/_vars.scss index 8ec321e..dd3f12a 100644 --- a/app/assets/stylesheets/globals/_vars.scss +++ b/app/assets/stylesheets/globals/_vars.scss @@ -1,3 +1,4 @@ $color-black: #222; $color-gray: #595959; $color-blue: #4c96e5; +$color-orange: #f18c06; From 4ffb6fc8103211ffa556e70a0737c7a4401bfbaf Mon Sep 17 00:00:00 2001 From: Antti-Jussi Kovalainen Date: Sat, 14 Feb 2015 12:28:42 +0200 Subject: [PATCH 139/203] Add %no-select and use it to disable text-selection in #player --- app/assets/stylesheets/components/_player.scss | 9 +-------- app/assets/stylesheets/globals/_abstractions.scss | 15 ++++++++++++--- 2 files changed, 13 insertions(+), 11 deletions(-) diff --git a/app/assets/stylesheets/components/_player.scss b/app/assets/stylesheets/components/_player.scss index f4489c9..8df0c2f 100644 --- a/app/assets/stylesheets/components/_player.scss +++ b/app/assets/stylesheets/components/_player.scss @@ -1,4 +1,5 @@ .player { + @extend %no-select; $player-gradient: top, #383838, #383838 15%, #1f1f1f 90%, #1f1f1f; background: $color-black; background: -webkit-linear-gradient($player-gradient); @@ -19,14 +20,6 @@ -moz-box-shadow: 0 -1px 0 #000; box-shadow: 0 -1px 0 #000; - // @extend %no-select; - -webkit-touch-callout: none; - -webkit-user-select: none; - -khtml-user-select: none; - -moz-user-select: none; - -ms-user-select: none; - user-select: none; - button { background:transparent url('/assets/icons.png') no-repeat; border:0; diff --git a/app/assets/stylesheets/globals/_abstractions.scss b/app/assets/stylesheets/globals/_abstractions.scss index 94e473e..c52161f 100644 --- a/app/assets/stylesheets/globals/_abstractions.scss +++ b/app/assets/stylesheets/globals/_abstractions.scss @@ -1,5 +1,14 @@ %clearfix { - overflow:hidden; - text-indent:100%; - white-space:nowrap; + overflow:hidden; + text-indent:100%; + white-space:nowrap; +} + +%no-select { + -webkit-touch-callout: none; + -webkit-user-select: none; + -khtml-user-select: none; + -moz-user-select: none; + -ms-user-select: none; + user-select: none; } From 6a8cbe4c48ddf9ae6633ee02ca0c4086ae889015 Mon Sep 17 00:00:00 2001 From: Antti-Jussi Kovalainen Date: Sat, 14 Feb 2015 12:29:17 +0200 Subject: [PATCH 140/203] Fix indendation in _mixins.scss --- app/assets/stylesheets/globals/_mixins.scss | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/app/assets/stylesheets/globals/_mixins.scss b/app/assets/stylesheets/globals/_mixins.scss index 2bcecea..4322c44 100644 --- a/app/assets/stylesheets/globals/_mixins.scss +++ b/app/assets/stylesheets/globals/_mixins.scss @@ -5,9 +5,9 @@ } @mixin linear-gradient($gradient) { - background: -webkit-linear-gradient($gradient); - background: -moz-linear-gradient($gradient); - background: -ms-linear-gradient($gradient); - background: -o-linear-gradient($gradient); - background: linear-gradient($gradient); + background: -webkit-linear-gradient($gradient); + background: -moz-linear-gradient($gradient); + background: -ms-linear-gradient($gradient); + background: -o-linear-gradient($gradient); + background: linear-gradient($gradient); } From 69371f242f7464933f115337f98110718b58932e Mon Sep 17 00:00:00 2001 From: Antti-Jussi Kovalainen Date: Sat, 14 Feb 2015 12:12:38 +0200 Subject: [PATCH 141/203] Update CHANGELOG --- CHANGELOG.md | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index a6e9df0..407b636 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,17 @@ Changelog ========= +## vNext + +#### Minor changes + +- Made text in the player component (the black bar at bottom) non-selectable + +#### Refactoring + +- Sass/CSS clean-up + + ## 1.1.1 #### Bug fixes From 4f8f5da9c0873fcd3377d1c3896761ee94ffb127 Mon Sep 17 00:00:00 2001 From: Antti-Jussi Kovalainen Date: Sat, 14 Feb 2015 12:40:11 +0200 Subject: [PATCH 142/203] Re-structure file, directory and require structure of JS helpers & utils --- app/assets/javascripts/helpers.js | 69 ------------------- app/assets/javascripts/lib/jquery_onenter.js | 15 ++++ app/assets/javascripts/lib/key_codes.js | 13 ++++ .../{pretty-numbers.js => lib/utils.js} | 55 ++++++++++++--- app/assets/javascripts/main.js | 7 -- app/assets/javascripts/sidebar.js | 4 +- app/assets/javascripts/songlist.js | 3 +- 7 files changed, 76 insertions(+), 90 deletions(-) delete mode 100644 app/assets/javascripts/helpers.js create mode 100644 app/assets/javascripts/lib/jquery_onenter.js create mode 100644 app/assets/javascripts/lib/key_codes.js rename app/assets/javascripts/{pretty-numbers.js => lib/utils.js} (72%) diff --git a/app/assets/javascripts/helpers.js b/app/assets/javascripts/helpers.js deleted file mode 100644 index d90a150..0000000 --- a/app/assets/javascripts/helpers.js +++ /dev/null @@ -1,69 +0,0 @@ -/*! - * Helper functions - * - */ - -var keyCode = { - UP: 38, - DOWN: 40, - LEFT: 37, - RIGHT: 39, - BACKSPACE: 8, - TAB: 9, - DELETE: 46, - ENTER: 13, - ESCAPE: 27 -}; - - -jQuery.fn.onEnter = function(callback) { - return this.each(function () { - $(this).keyup(function (e) { - var code = (e.keyCode ? e.keyCode : e.which); - - if (code == keyCode.ENTER || code == keyCode.NUMPAD_ENTER) { - if (callback) { - callback.call(this); - } - } - }); - }); -}; - - -// function to get random number from 1 to n -function randomToN(maxVal,floatVal) -{ - var randVal = Math.random()*maxVal; - return typeof floatVal=='undefined'?Math.round(randVal):randVal.toFixed(floatVal); -} - - -function naturalsort(a, b) { - function chunkify(t) { - var tz = [], x = 0, y = -1, n = 0, i, j; - - while (i = (j = t.charAt(x++)).charCodeAt(0)) { - var m = (i == 46 || (i >=48 && i <= 57)); - if (m !== n) { - tz[++y] = ""; - n = m; - } - tz[y] += j; - } - return tz; - } - - var aa = chunkify(a.toLowerCase()); - var bb = chunkify(b.toLowerCase()); - - for (x = 0; aa[x] && bb[x]; x++) { - if (aa[x] !== bb[x]) { - var c = Number(aa[x]), d = Number(bb[x]); - if (c == aa[x] && d == bb[x]) { - return c - d; - } else return (aa[x] > bb[x]) ? 1 : -1; - } - } - return aa.length - bb.length; -} diff --git a/app/assets/javascripts/lib/jquery_onenter.js b/app/assets/javascripts/lib/jquery_onenter.js new file mode 100644 index 0000000..71c4994 --- /dev/null +++ b/app/assets/javascripts/lib/jquery_onenter.js @@ -0,0 +1,15 @@ +//= require ./key_codes + +jQuery.fn.onEnter = function(callback) { + return this.each(function () { + $(this).keyup(function (e) { + var code = (e.keyCode ? e.keyCode : e.which); + + if (code == keyCode.ENTER || code == keyCode.NUMPAD_ENTER) { + if (callback) { + callback.call(this); + } + } + }); + }); +}; diff --git a/app/assets/javascripts/lib/key_codes.js b/app/assets/javascripts/lib/key_codes.js new file mode 100644 index 0000000..7ea774e --- /dev/null +++ b/app/assets/javascripts/lib/key_codes.js @@ -0,0 +1,13 @@ +// Helper for identifying keys +window.keyCode = window.keyCode || { + UP: 38, + DOWN: 40, + LEFT: 37, + RIGHT: 39, + BACKSPACE: 8, + TAB: 9, + DELETE: 46, + ENTER: 13, + ESCAPE: 27, + A: 65 +}; diff --git a/app/assets/javascripts/pretty-numbers.js b/app/assets/javascripts/lib/utils.js similarity index 72% rename from app/assets/javascripts/pretty-numbers.js rename to app/assets/javascripts/lib/utils.js index 9ac15a8..8e71ada 100644 --- a/app/assets/javascripts/pretty-numbers.js +++ b/app/assets/javascripts/lib/utils.js @@ -1,5 +1,3 @@ -// pretty number - function pretty_number(num, opts) { var defaultOpts = { short: true, @@ -7,11 +5,11 @@ function pretty_number(num, opts) { addCommas: true, round: 2 }; - + if (typeof num != "number") { return ""; } - + function round(num, dec) { num = num * Math.pow(10, dec); @@ -32,7 +30,7 @@ function pretty_number(num, opts) { if (opts.short) { var decimal_places = Math.floor(Math.log(num) / Math.log(10)); - + var dec = [{ 'suffix': 'T', 'divisor': 12 @@ -49,12 +47,12 @@ function pretty_number(num, opts) { 'suffix': '', 'divisor': 0 }]; - + for (var i in dec) { if (decimal_places > dec[i].divisor) { num = round((num / Math.pow(10, dec[i].divisor)), 2 - (decimal_places - dec[i].divisor)); - + if (num >= 1000 && i > 0) { decimal_places -= 3; @@ -65,11 +63,11 @@ function pretty_number(num, opts) { { num += dec[i].suffix; } - + break; } } - + num = '' + num; if (opts.lowerCase) @@ -79,7 +77,7 @@ function pretty_number(num, opts) { } else if (opts.addCommas) { var decnum = ('' + (round(num, opts.round) - Math.floor(num))).substr(2); - + var tempnum = '' + Math.floor(num); num = ''; for (i = tempnum.length - 1, j = 0; i >= 0; i--, j++) @@ -90,7 +88,7 @@ function pretty_number(num, opts) { } num = tempnum[i] + num; } - + if (decnum > 0) { num = num + '.' + decnum; @@ -103,3 +101,38 @@ function pretty_number(num, opts) { function commify(num) { return pretty_number(num, {'short': false}); } + +// function to get random number from 1 to n +function randomToN(maxVal,floatVal) { + var randVal = Math.random()*maxVal; + return typeof floatVal=='undefined'?Math.round(randVal):randVal.toFixed(floatVal); +} + +function naturalsort(a, b) { + function chunkify(t) { + var tz = [], x = 0, y = -1, n = 0, i, j; + + while (i = (j = t.charAt(x++)).charCodeAt(0)) { + var m = (i == 46 || (i >=48 && i <= 57)); + if (m !== n) { + tz[++y] = ""; + n = m; + } + tz[y] += j; + } + return tz; + } + + var aa = chunkify(a.toLowerCase()); + var bb = chunkify(b.toLowerCase()); + + for (x = 0; aa[x] && bb[x]; x++) { + if (aa[x] !== bb[x]) { + var c = Number(aa[x]), d = Number(bb[x]); + if (c == aa[x] && d == bb[x]) { + return c - d; + } else return (aa[x] > bb[x]) ? 1 : -1; + } + } + return aa.length - bb.length; +} diff --git a/app/assets/javascripts/main.js b/app/assets/javascripts/main.js index 59556e4..2b44472 100644 --- a/app/assets/javascripts/main.js +++ b/app/assets/javascripts/main.js @@ -1,20 +1,13 @@ //= require jquery.cookie -//= require helpers //= require store.min //= require jquery.ba-hashchange.min //= require slick.grid //= require routing //= require lastfm //= require songlist -//= require pretty-numbers //= require audio-modules/html5audio //= require audio-modules/soundmanager2 -var keyCode = { - ENTER: 13, - A: 65 -}; - $(document).ready(function () { soundManager.onready(function () { // resize the main-area to correct height diff --git a/app/assets/javascripts/sidebar.js b/app/assets/javascripts/sidebar.js index d88c5bc..98b7450 100644 --- a/app/assets/javascripts/sidebar.js +++ b/app/assets/javascripts/sidebar.js @@ -1,4 +1,4 @@ -//= require helpers +//= require lib/jquery_onenter //= require playlists //= require songlist /*! @@ -27,7 +27,7 @@ $(document).ready(function () { $('#sidebar .new-list').click(function () { var list_item = $('

    Beatstream alpha