Skip to content

Commit

Permalink
new feature: movie recommendations
Browse files Browse the repository at this point in the history
The recommendation engine is an external app to which we talk over HTTP.
The resulting movie IDs are filtered down to:

 - movies that you haven't ignored (deleted from recommendations);
 - movies that you haven't watched yet;
 - movies that you haven't added in your to-watch list.

Recommendations are cached for 24 hours.

Mad props to @norbert for making the recommendation engine!
  • Loading branch information
mislav committed Jul 14, 2013
1 parent 5b432d7 commit 3841d30
Show file tree
Hide file tree
Showing 17 changed files with 221 additions and 59 deletions.
2 changes: 2 additions & 0 deletions Gemfile
Expand Up @@ -30,6 +30,8 @@ gem 'rails-behaviors'
gem 'omniauth-twitter'
gem 'omniauth-facebook'

gem 'fickle-ruby', :git => 'git://github.com/norbert/fickle-ruby.git', :branch => 'master'

group :extras do
gem 'nokogiri', '~> 1.4.1'
gem 'nibbler', '~> 1.3' #, :path => '/Users/mislav/Projects/nibbler'
Expand Down
16 changes: 13 additions & 3 deletions Gemfile.lock
@@ -1,3 +1,12 @@
GIT
remote: git://github.com/norbert/fickle-ruby.git
revision: 55020ed484a4b95b3e057c122ad41706b38f6305
branch: master
specs:
fickle-ruby (0.0.1)
faraday
multi_json

GEM
remote: https://rubygems.org/
specs:
Expand Down Expand Up @@ -69,7 +78,7 @@ GEM
eventmachine (0.12.10)
execjs (1.3.0)
multi_json (~> 1.0)
faraday (0.8.0.rc2)
faraday (0.8.7)
multipart-post (~> 1.1)
faraday_middleware (0.8.4)
faraday (>= 0.7.4, < 0.9)
Expand Down Expand Up @@ -103,8 +112,8 @@ GEM
daemons (>= 1.0.3)
fastthread (>= 1.0.1)
gem_plugin (>= 0.2.3)
multi_json (1.6.1)
multipart-post (1.1.4)
multi_json (1.7.7)
multipart-post (1.2.0)
never-forget (0.1.0)
activesupport
erubis
Expand Down Expand Up @@ -233,6 +242,7 @@ DEPENDENCIES
escape_utils
faraday (~> 0.8.0.rc)
faraday_middleware
fickle-ruby!
launchy
mingo (>= 0.3.0)
mongo-rails-instrumentation
Expand Down
8 changes: 8 additions & 0 deletions app/assets/javascripts/actions.coffee
Expand Up @@ -21,3 +21,11 @@ replaceActions = (element, content) ->
element.replaceWith(content)
preserve = element.find('.js-preserve')
parent.find('.actions').eq(0).prepend preserve

$(document).on 'ajaxSuccess', '.movie-recommendations .ignore', (e, xhr) ->
movie = $(this).closest('.movie')
container = movie.parent()
others = movie.siblings('.movie')
movie.remove()
if others.size is 0
container.find('.blank').removeClass('blank')
11 changes: 10 additions & 1 deletion app/assets/stylesheets/movie.scss
Expand Up @@ -86,7 +86,7 @@ ol.movies { clear:left; padding-left:0;

+ a.revert { padding:0 5px; }
}
a.revert {
a.revert, a.ignore {
color:#ccc; text-decoration:none;

&:hover { color:firebrick; }
Expand Down Expand Up @@ -255,3 +255,12 @@ ol.movies { clear:left; padding-left:0;
.actions { margin-top:7px; }
}
}

.movie-recommendations {
.movie {
padding-bottom: 1.3em;
border-bottom: 1px solid #e8e8e8;
&:last-child { border-bottom: none }
}
.blank { display: none }
}
14 changes: 12 additions & 2 deletions app/controllers/movies_controller.rb
Expand Up @@ -107,6 +107,12 @@ def remove_from_watched
current_user.watched.delete @movie
ajax_actions_or_back
end

def ignore_recommendation
recommendations = Recommendations.new(current_user)
recommendations.ignore_movie(@movie)
ajax_actions_or_back { head :ok }
end

def wikipedia
@movie.update_wikipedia_url! unless @movie.wikipedia_url.present?
Expand Down Expand Up @@ -156,8 +162,12 @@ def redirect_to_permalink?(movie)

def ajax_actions_or_back
if request.xhr?
response.content_type = Mime::HTML
render :partial => 'actions', :locals => {:movie => @movie}
if block_given?
yield
else
response.content_type = Mime::HTML
render :partial => 'actions', :locals => {:movie => @movie}
end
else
redirect_to :back
end
Expand Down
17 changes: 13 additions & 4 deletions app/controllers/users_controller.rb
Expand Up @@ -2,8 +2,8 @@

class UsersController < ApplicationController

before_filter :load_user, :only => [:show, :to_watch, :liked]
before_filter :login_required, :only => [:following]
before_filter :load_user, :only => [:show, :to_watch, :liked, :recommendations]
before_filter :login_required, :only => [:following, :recommendations]

def index
@users = User.find({}, :sort => ['_id', -1]).to_a
Expand Down Expand Up @@ -48,7 +48,8 @@ def watched_index

def show
@movies = @user.watched(max_id: params[:max_id]).page(params[:page])
ajax_pagination if stale? etag: session_cache_key(@movies)
@recommended = Recommendations.new(@user)
ajax_pagination if my_page? || stale?(etag: session_cache_key(@movies))
end

def liked
Expand All @@ -58,7 +59,8 @@ def liked

def to_watch
@movies = @user.to_watch(max_id: params[:max_id]).page(params[:page])
ajax_pagination if stale? etag: session_cache_key(@movies)
@recommended = Recommendations.new(@user)
ajax_pagination if my_page? || stale?(etag: session_cache_key(@movies))
end

def timeline
Expand All @@ -67,6 +69,13 @@ def timeline
ajax_pagination
end

def recommendations
@recommended = Recommendations.new(@user)
unless Movies.offline?
@recommended.movies.map(&:ensure_extended_info)
end
end

def following
end

Expand Down
8 changes: 8 additions & 0 deletions app/helpers/users_helper.rb
Expand Up @@ -11,6 +11,14 @@ def user_name(user)
user.name.presence || user.username
end

def user_friendly_name(user)
if user.name.present?
user.name.split(' ', 2)[0]
else
user.username
end
end

def screen_name(user)
# some usernames are numeric Facebook IDs, don't show them
(user.username =~ /\D/ or user.name.blank?) ? user.username : user.name
Expand Down
61 changes: 61 additions & 0 deletions app/models/recommendations.rb
@@ -0,0 +1,61 @@
require 'fickle'
require 'forwardable'

Recommendations = Struct.new(:user) do
extend Forwardable
def_delegators :movies, :any?, :empty?, :size

def load_movies
Movie.find(recommended_movie_ids)
end

def recommended_movie_ids
@movie_ids ||= fetch_recommendations[0].map {|item| BSON::ObjectId[item[0]] }
end

def movies
@movies ||= load_movies.reject { |movie|
ignored_ids.include?(movie.id) ||
to_watch_ids.include?(movie.id) ||
watched_ids.include?(movie.id)
}
end

def ignored_ids
user.ignored_recommendations
end

def ignore_movie(movie)
user.ignored_recommendations << movie.id
end

def watched_ids
@watched ||= user.watched(filter_association).
link_documents.map {|doc| doc['movie_id'] }
end

def to_watch_ids
@to_watch ||= user.to_watch(filter_association).
link_documents.map {|doc| doc['movie_id'] }
end

def filter_association
{ :movie_id => { '$in' => recommended_movie_ids } }
end

# response: [ [ [id, rating], [id, rating], ... ] ]
def fetch_recommendations
Rails.cache.fetch("recommendations/#{user.id}", expires_in: 1.day) do
fickle = Fickle::Client.new(fickle_url, fickle_key)
fickle.recommend([user.id.to_s])
end
end

def fickle_url
Movies::Application.config.fickle.url
end

def fickle_key
Movies::Application.config.fickle.api_key
end
end
4 changes: 3 additions & 1 deletion app/models/user.rb
Expand Up @@ -10,7 +10,9 @@ class User < Mingo

include ToWatch
include Watched


property :ignored_recommendations, :type => :set

def username=(value)
self['username'] = self.class.generate_username(value)
end
Expand Down
46 changes: 46 additions & 0 deletions app/views/movies/_full_movie.html.erb
@@ -0,0 +1,46 @@
<%= movie_poster(movie, :medium) %>

<header>
<%= render 'movies/title', :movie => movie %>
<% if movie.directors.present? %>
<p class="directors">by <strong><%= movie.directors.map { |name|
link_to(name, director_path(name))
}.to_sentence.html_safe %></strong></p>
<% end %>
<% if movie.countries.present? %>
<p class="countries">in <%= movie.countries.to_sentence %></p>
<% end %>
<%= movie_runtime(movie) %>
</header>

<% if movie.chosen_plot.present? %>
<p class="plot"><%= movie_plot movie %></p>
<% end %>
<% if elsewhere = movie_elsewhere(movie).presence %>
<p class="elsewhere">
Elsewhere:
<%= raw elsewhere.map { |label, url, title|
link_to(label, url, title: title)
}.join(separator) %>
</p>
<% end %>
<%= movie_actions(movie) %>
<% if logged_in? and (stats = current_user.friends_who_watched(movie)).present? %>
<div class="friends">
<span class=label>Social</span>
<% for rating, label in [ [true, 'liked'], [nil, 'meh'], [false, 'disliked'] ] -%>
<% people = stats.people_who_rated(rating) -%>
<% names = people.map {|p| screen_name(p) } -%>
<span class=<%= label %> rel=tooltip title="<%= label %>: <%= people.any?? names.to_sentence : 'none' %>">
<%= people.size %>
<% if rating == true %><% end %>
</span>
<% end -%>
</div>
<% end %>
47 changes: 1 addition & 46 deletions app/views/movies/show.html.erb
Expand Up @@ -13,54 +13,9 @@
<% end -%>

<article class="movie">

<%= movie_poster(@movie, :medium) %>

<header>
<%= render 'title', :movie => @movie %>
<% if @movie.directors.present? %>
<p class="directors">by <strong><%= @movie.directors.map { |name|
link_to(name, director_path(name))
}.to_sentence.html_safe %></strong></p>
<% end %>

<% if @movie.countries.present? %>
<p class="countries">in <%= @movie.countries.to_sentence %></p>
<% end %>
<%= render 'full_movie', movie: @movie %>
<%= movie_runtime(@movie) %>
</header>

<% if @movie.chosen_plot.present? %>
<p class="plot"><%= movie_plot @movie %></p>
<% end %>
<% if elsewhere = movie_elsewhere(@movie).presence %>
<p class="elsewhere">
Elsewhere:
<%= raw elsewhere.map { |label, url, title|
link_to(label, url, title: title)
}.join(separator) %>
</p>
<% end %>
<%= movie_actions(@movie) %>
<% if logged_in? and (stats = current_user.friends_who_watched(@movie)).present? %>
<div class="friends">
<span class=label>Social</span>
<% for rating, label in [ [true, 'liked'], [nil, 'meh'], [false, 'disliked'] ] -%>
<% people = stats.people_who_rated(rating) -%>
<% names = people.map {|p| screen_name(p) } -%>
<span class=<%= label %> rel=tooltip title="<%= label %>: <%= people.any?? names.to_sentence : 'none' %>">
<%= people.size %>
<% if rating == true %><% end %>
</span>
<% end -%>
</div>
<% end %>
<% if admin? %>
<aside class="admin">
<ul>
Expand Down
7 changes: 7 additions & 0 deletions app/views/users/_recommendation_notice.html.erb
@@ -0,0 +1,7 @@
<% if my_page? && @recommended && @recommended.any? %>
<p class="flash notice">
Hey <%= user_friendly_name(@user) %>, we've got
<%= link_to pluralize(@recommended.size, 'recommendation'), movie_recommendations_path(@user) %>
for you!
</p>
<% end %>
24 changes: 24 additions & 0 deletions app/views/users/recommendations.html.erb
@@ -0,0 +1,24 @@
<% body_class 'movie-show movie-recommendations' %>

<article>
<header>
<h1>Recommended for you</h1>
</header>

<% for movie in @recommended.movies %>
<article class="movie">
<%= render 'movies/full_movie', movie: movie %>

<p class=actions>
<%= link_to "× Ignore this recommendation", [:ignore_recommendation, movie], :remote => true, :method => :put, :class => 'ignore' %>
</p>
</article>
<% end %>

<p<%= @recommended.any?? ' class=blank' : '' %>>
No new recommendations as this time. Check back some other day!
<br>
See what the <%= link_to "people you follow are watching", timeline_path %>
</p>

</article>
2 changes: 2 additions & 0 deletions app/views/users/show.html.erb
Expand Up @@ -40,6 +40,8 @@
</nav>
</header>

<%= render 'recommendation_notice' %>
<%= render 'movies/paginated', :movies => @movies %>
<% elsif my_page? and (forced_blank_slate? or @user.to_watch.empty?) %>
Expand Down

1 comment on commit 3841d30

@norbert
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

+1

Please sign in to comment.