jam-cloud/ruby/lib/jam_ruby/models/music_session.rb

770 lines
28 KiB
Ruby

require 'iso-639'
module JamRuby
class MusicSession < ActiveRecord::Base
@@log = Logging.logger[MusicSession]
NO_RECURRING = 'once'
RECURRING_WEEKLY = 'weekly'
RECURRING_MODES = [NO_RECURRING, RECURRING_WEEKLY]
attr_accessor :legal_terms, :language_description, :scheduled_start_time, :access_description
attr_accessor :approved_rsvps, :open_slots, :pending_invitations
attr_accessor :approved_rsvps_detail, :open_slots_detail
self.table_name = "music_sessions"
self.primary_key = 'id'
belongs_to :creator,:class_name => 'JamRuby::User', :foreign_key => :user_id, :inverse_of => :music_session_histories
belongs_to :band, :class_name => 'JamRuby::Band', :foreign_key => :band_id, :inverse_of => :music_sessions
belongs_to :active_music_session, :class_name => 'JamRuby::ActiveMusicSession', foreign_key: :music_session_id
has_many :music_session_user_histories, :class_name => "JamRuby::MusicSessionUserHistory", :foreign_key => "music_session_id", :dependent => :delete_all
has_many :comments, :class_name => "JamRuby::MusicSessionComment", :foreign_key => "music_session_id"
has_many :session_info_comments, :class_name => "JamRuby::SessionInfoComment", :foreign_key => "music_session_id"
has_many :likes, :class_name => "JamRuby::MusicSessionLiker", :foreign_key => "session_id"
has_many :plays, :class_name => "JamRuby::PlayablePlay", :as => :playable, :dependent => :destroy
has_one :share_token, :class_name => "JamRuby::ShareToken", :inverse_of => :shareable, :foreign_key => 'shareable_id'
has_one :feed, :class_name => "JamRuby::Feed", :inverse_of => :music_session, :foreign_key => 'music_session_id', :dependent => :destroy
belongs_to :genre, :class_name => "JamRuby::Genre", :inverse_of => :music_sessions, :foreign_key => 'genre_id'
has_many :join_requests, :foreign_key => "music_session_id", :inverse_of => :music_session, :class_name => "JamRuby::JoinRequest", :foreign_key => "music_session_id"
has_many :invitations, :foreign_key => "music_session_id", :inverse_of => :music_session, :class_name => "JamRuby::Invitation", :foreign_key => "music_session_id"
has_many :invited_musicians, :through => :invitations, :class_name => "JamRuby::User", :foreign_key => "receiver_id", :source => :receiver
has_many :fan_invitations, :foreign_key => "music_session_id", :inverse_of => :music_session, :class_name => "JamRuby::FanInvitation", :foreign_key => "music_session_id"
has_many :invited_fans, :through => :fan_invitations, :class_name => "JamRuby::User", :foreign_key => "receiver_id", :source => :receiver
has_many :rsvp_slots, :class_name => "JamRuby::RsvpSlot", :foreign_key => "music_session_id", :dependent => :destroy
has_many :music_notations, :class_name => "JamRuby::MusicNotation", :foreign_key => "music_session_id"
validates :genre, :presence => true
validates :description, :presence => true, :no_profanity => true
validates :fan_chat, :inclusion => {:in => [true, false]}
validates :fan_access, :inclusion => {:in => [true, false]}
validates :approval_required, :inclusion => {:in => [true, false]}
validates :musician_access, :inclusion => {:in => [true, false]}
validates :legal_terms, :inclusion => {:in => [true]}, :on => :create
validates :creator, :presence => true
validate :creator_is_musician
before_create :generate_share_token
before_create :add_to_feed
SHARE_TOKEN_LENGTH = 8
SEPARATOR = '|'
def add_to_feed
feed = Feed.new
feed.music_session = self
end
def comment_count
self.comments.size
end
# copies all relevant info for the recurring session
def copy
MusicSession.transaction do
# copy base music_session data
new_session = MusicSession.new
new_session.description = self.description
new_session.user_id = self.user_id
new_session.band_id = self.band_id
new_session.fan_access = self.fan_access
# TODO: confirm this logic
# new_session.scheduled_start = self.scheduled_start + self.scheduled_duration
new_session.scheduled_duration = self.scheduled_duration
new_session.musician_access = self.musician_access
new_session.approval_required = self.approval_required
new_session.fan_chat = self.fan_chat
new_session.genre_id = self.genre_id
new_session.legal_policy = self.legal_policy
new_session.language = self.language
new_session.name = self.name
new_session.recurring_mode = self.recurring_mode
new_session.timezone = self.timezone
new_session.open_rsvps = self.open_rsvps
# copy rsvp_slots, rsvp_requests, and rsvp_requests_rsvp_slots
RsvpSlot.find_each(:conditions => "music_session_id = '#{self.id}'") do |slot|
new_slot = RsvpSlot.new
new_slot.instrument_id = slot.instrument_id
new_slot.proficiency_level = slot.proficiency_level
new_session.rsvp_slots << new_slot
# get the request for this slot that was approved (should only be ONE)
rsvp_request_slot = RsvpRequestRsvpSlot.where("chosen = true AND rsvp_slot_id = ?", slot.id).first
unless rsvp_request_slot.nil?
rsvp = RsvpRequest.find_by_id(rsvp_request_slot.rsvp_request_id)
new_rsvp = RsvpRequest.new
new_rsvp.user_id = rsvp.user_id
new_rsvp_req_slot = RsvpRequestRsvpSlot.new
new_rsvp_req_slot.rsvp_request = new_rsvp
new_rsvp_req_slot.rsvp_slot = new_slot
new_rsvp_req_slot.chosen = true
# if this slot was not chosen, try to get any RSVPs that were 1-time cancellations and copy those
else
rejected_req_slots = RsvpRequestRsvpSlot.where("(chosen = false OR chosen is null) AND rsvp_slot_id = ?", slot.id).order("created_at ASC")
rejected_req_slots.each do |req_slot|
# get RsvpRequest corresponding to this RsvpRequestRsvpSlot
rsvp = RsvpRequest.find_by_id(req_slot.rsvp_request_id)
# if the RSVP was canceled (but all future sessions were NOT canceled), then copy this one and break
if rsvp.canceled && !rsvp.cancel_all
new_rsvp = RsvpRequest.new
new_rsvp.user_id = rsvp.user_id
new_rsvp_req_slot = RsvpRequestRsvpSlot.new
new_rsvp_req_slot.rsvp_request = new_rsvp
new_rsvp_req_slot.rsvp_slot = new_slot
new_rsvp_req_slot.chosen = true
break
end
end
end
end
# copy music_notations
MusicNotation.find_each(:conditions => "music_session_id = '#{self.id}'") do |notation|
new_notation = MusicNotation.new
new_notation.user_id = notation.user_id
new_notation.music_session = new_session
new_notation.file_url = notation.file_url
# new_notation.file_name = notation.file_name
new_notation.size = notation.size
new_session.music_notations << new_notation
end
new_session.save
# mark the next session as scheduled
self.next_session_scheduled = true
self.save
end
end
def grouped_tracks
tracks = []
self.music_session_user_histories.each do |msuh|
user = User.find(msuh.user_id)
t = Track.new
t.musician = user
t.instrument_ids = []
# this treats each track as a "user", which has 1 or more instruments in the session
unless msuh.instruments.blank?
instruments = msuh.instruments.split(SEPARATOR)
instruments.each do |instrument|
if !t.instrument_ids.include? instrument
t.instrument_ids << instrument
end
end
end
tracks << t
end
tracks
end
def can_join? user, as_musician
if as_musician
unless user.musician
return false # "a fan can not join a music session as a musician"
end
if self.musician_access
if self.approval_required
return self.invited_musicians.exists?(user)
else
return true
end
else
# the creator can always join, and the invited users can join
return self.creator == user || self.invited_musicians.exists?(user)
end
else
# it's a fan, and the only way a fan can join is if fan_access is true
self.fan_access
end
end
def self.index(current_user, user_id, band_id = nil, genre = nil)
hide_private = false
if current_user.id != user_id
hide_private = false # TODO: change to true once public flag exists
end
query = MusicSession
.joins(
%Q{
LEFT OUTER JOIN
music_sessions_user_history
ON
music_sessions.id = music_sessions_user_history.music_session_id
}
)
.where(
%Q{
music_sessions.user_id = '#{user_id}'
}
)
#query = query.where("public = false") unless !hide_private
query = query.where("music_sessions.band_id = '#{band_id}") unless band_id.nil?
query = query.where("music_sessions.genres like '%#{genre}%'") unless genre.nil?
return query
end
def self.scheduled user
current_time = Time.now
query = MusicSession.where("music_sessions.user_id = '#{user.id}'")
query = query.where("music_sessions.scheduled_start IS NOT NULL AND music_sessions.scheduled_start < '#{current_time + 12.hours}'")
query = query.where("music_sessions.scheduled_start > '#{current_time - 12.hours}'")
query = query.where("music_session_id IS NULL")
query = query.order("music_sessions.scheduled_start ASC")
return query
end
def self.create user, options
band = Band.find(options[:band]) unless options[:band].nil?
ms = MusicSession.new
ms.name = options[:name]
ms.description = options[:description]
ms.genre_id = (options[:genres].length > 0 ? options[:genres][0] : nil) if options[:genres]
ms.musician_access = options[:musician_access]
ms.approval_required = options[:approval_required]
ms.fan_access = options[:fan_access]
ms.fan_chat = options[:fan_chat]
ms.band = band
ms.legal_policy = options[:legal_policy]
ms.language = options[:language]
ms.scheduled_start = options[:start]
ms.scheduled_duration = options[:duration].to_i * 1.minutes if options[:duration]
ms.recurring_mode = options[:recurring_mode] if options[:recurring_mode]
ms.timezone = options[:timezone] if options[:timezone]
ms.legal_terms = true
ms.creator = user
ms.save
unless ms.errors.any?
ms.reload
rsvp_slot_ids = []
self_rsvp_slot_ids = []
options[:rsvp_slots].each do |rs|
rsvp = RsvpSlot.new
rsvp.instrument = Instrument.find(rs[:instrument_id])
rsvp.proficiency_level = rs[:proficiency_level]
rsvp.music_session = ms
rsvp.save
ms.rsvp_slots << rsvp
if rs[:approve] == true
self_rsvp_slot_ids.push rsvp.id
else
rsvp_slot_ids.push rsvp.id
end
end if options[:rsvp_slots]
RsvpRequest.create({session_id: ms.id, rsvp_slots: self_rsvp_slot_ids, :autoapprove => true}, user)
options[:invitations].each do |invite_id|
invitation = Invitation.new
receiver = User.find(invite_id)
invitation.sender = user
invitation.receiver = receiver
invitation.music_session = ms
invitation.save
ms.invitations << invitation
Notification.send_scheduled_session_invitation(ms, receiver)
end if options[:invitations]
options[:music_notations].each do |notation_id|
notation = MusicNotation.find(notation_id)
notation.music_session = ms
notation.save
ms.music_notations << notation
end if options[:music_notations]
ms.save
end
ms
end
def self.update user, options
music_session = MusicSession.find(options[:id])
if music_session.creator == current_user
Notification.send_scheduled_session_cancelled music_session
music_session.destroy
respond_with responder: ApiResponder, :status => 204
else
render :json => { :message => ValidationMessages::PERMISSION_VALIDATION_ERROR }, :status => 404
end
end
def unique_users
User
.joins(:music_session_user_histories)
.group("users.id")
.order("users.id")
.where(%Q{ music_sessions_user_history.music_session_id = '#{id}'})
end
# returns one user history per user, with instruments all crammed together, and with total duration
def unique_user_histories
MusicSessionUserHistory
.joins(:user)
.select("STRING_AGG(instruments, '|') AS total_instruments,
SUM(date_part('epoch', COALESCE(music_sessions_user_history.session_removed_at, music_sessions_user_history.created_at) - music_sessions_user_history.created_at)) AS total_duration,
music_sessions_user_history.user_id, music_sessions_user_history.music_session_id, users.first_name, users.last_name, users.photo_url")
.group("music_sessions_user_history.user_id, music_sessions_user_history.music_session_id, users.first_name, users.last_name, users.photo_url")
.order("music_sessions_user_history.user_id")
.where(%Q{ music_sessions_user_history.music_session_id = '#{id}'})
end
def duration_minutes
end_time = self.session_removed_at || Time.now
(end_time - self.created_at) / 60.0
end
def music_session_user_histories
@msuh ||= JamRuby::MusicSessionUserHistory
.where(:music_session_id => self.id)
.order('created_at DESC')
end
def comments
@comments ||= JamRuby::MusicSessionComment
.where(:music_session_id => self.id)
.order('created_at DESC')
end
def likes
@likes ||= JamRuby::MusicSessionLiker
.where(:music_session_id => self.music_session_id)
end
# these are 'users that are a part of this session'
# which means are currently in the music_session, or, rsvp'ed, or creator
def part_of_session? user
# XXX check RSVP'ed
user == self.creator || (active_music_session ? active_music_session.users.exists?(user) : false)
end
def is_over?
active_music_session.nil?
end
def has_mount?
active_music_session && active_music_session.mount
end
def can_delete? user
self.creator == user && self.started_at.nil?
end
def legal_policy_url
# TODO: move to DB or config file
case legal_policy
when "standard"
return "http://www.jamkazam.com/session-legal-policies/standard"
when "creative"
return "http://www.jamkazam.com/session-legal-policies/creativecommons"
when "offline"
return "http://www.jamkazam.com/session-legal-policies/offline"
when "jamtracks"
return "http://www.jamkazam.com/session-legal-policies/jamtracks"
else
return ""
end
end
def language_description
if self.language.blank?
self.language = "en"
end
iso639Details = ISO_639.find_by_code(self.language)
unless iso639Details.blank?
return iso639Details.english_name
else
return "English"
end
end
def get_timezone
tz = nil
if timezone.blank?
tz = ActiveSupport::TimeZone["Central Time (US & Canada)"]
self.timezone = tz.name + ',' + tz.tzinfo.name
else
tz = ActiveSupport::TimeZone[self.timezone.split(',')[0]]
end
tz
end
def scheduled_start_time
unless self.scheduled_start.nil?
self.scheduled_start.to_s
else
""
end
end
def timezone_description
self.get_timezone.to_s
end
def musician_access_description
if self.musician_access && self.approval_required
"Musicians may join by approval"
elsif self.musician_access && !self.approval_required
"Musicians may join at will"
elsif !self.musician_access && !self.approval_required
"Only RSVP musicians may join"
end
end
def fan_access_description
if self.fan_access && self.fan_chat
"Fans may listen, chat with the band"
elsif self.fan_access && !self.fan_chat
"Fans may listen, chat with each other"
elsif !self.fan_access && !self.fan_chat
"Fans may not listen to session"
end
end
def access_description
return "#{musician_access_description}. #{fan_access_description}."
end
# next 3 methods are used for the right sidebar on the session info page
# retrieve users that have approved RSVPs
def approved_rsvps
users = User.find_by_sql(%Q{select u.id, u.photo_url, u.first_name, u.last_name, rs.instrument_id
from rsvp_slots rs
inner join rsvp_requests_rsvp_slots rrrs on rrrs.rsvp_slot_id = rs.id
inner join rsvp_requests rr on rrrs.rsvp_request_id = rr.id
inner join users u on u.id = rr.user_id
where rrrs.chosen = true and rs.music_session_id = '#{self.id}'
order by u.id}
)
users_collapsed_instruments = []
user = User.new
# build User array with instruments collapsed
users.each_with_index do |u, index|
if index == 0 || users[index].id != users[index-1].id
user = User.new
user.id = u.id
user.photo_url = u.photo_url
user.first_name = u.first_name
user.last_name = u.last_name
user.instrument_list = [u.instrument_id]
users_collapsed_instruments << user
else
user.instrument_list << u.instrument_id
end
end
users_collapsed_instruments
end
def approved_rsvps_detail
users = User.find_by_sql(%Q{select u.id, u.photo_url, u.first_name, u.last_name, rs.instrument_id, ii.description, rs.proficiency_level
from rsvp_slots rs
inner join rsvp_requests_rsvp_slots rrrs on rrrs.rsvp_slot_id = rs.id
inner join rsvp_requests rr on rrrs.rsvp_request_id = rr.id
inner join instruments ii on ii.id = rs.instrument_id
inner join users u on u.id = rr.user_id
where rrrs.chosen = true and rs.music_session_id = '#{self.id}'
order by u.id}
)
users_collapsed_instruments = []
user = User.new
# build User array with instruments collapsed
users.each_with_index do |u, index|
if index == 0 || users[index].id != users[index-1].id
user = User.new
user.id = u.id
user.photo_url = u.photo_url
user.first_name = u.first_name
user.last_name = u.last_name
user.instrument_list = [{id: u.instrument_id, desc: u.description, level: u.proficiency_level}]
users_collapsed_instruments << user
else
user.instrument_list << {id: u.instrument_id, desc: u.description, level: u.proficiency_level}
end
end
users_collapsed_instruments
end
# get all slots for this session and perform a set difference with all chosen slots;
# this will return those that are not filled yet
def open_slots
RsvpSlot.find_by_sql(%Q{select rs.*
from rsvp_slots rs
where rs.music_session_id = '#{self.id}'
except
select distinct rs.*
from rsvp_slots rs
inner join rsvp_requests_rsvp_slots rrrs on rrrs.rsvp_slot_id = rs.id
where rs.music_session_id = '#{self.id}'
and rrrs.chosen = true
}
)
end
def open_slots_detail
RsvpSlot.find_by_sql(%Q{select rs.*, ii.description
from rsvp_slots rs
inner join instruments ii on ii.id = rs.instrument_id
where rs.music_session_id = '#{self.id}'
except
select distinct rs.*, iii.description
from rsvp_slots rs
inner join instruments iii on iii.id = rs.instrument_id
inner join rsvp_requests_rsvp_slots rrrs on rrrs.rsvp_slot_id = rs.id
where rs.music_session_id = '#{self.id}'
and rrrs.chosen = true
}
)
end
# retrieve users that have invitations but have not submitted an RSVP request for this session
def pending_invitations
User.find_by_sql(%Q{select u.id, u.email, u.photo_url, u.first_name, u.last_name
from users u
inner join invitations i on u.id = i.receiver_id
left join rsvp_requests rr on rr.user_id = i.receiver_id
where i.music_session_id = '#{self.id}'
and rr.user_id is null}
)
end
def recordings
Recording.where(music_session_id: self.id)
end
def end_history
self.update_attribute(:session_removed_at, Time.now)
# ensure all user histories are closed
music_session_user_histories.each do |music_session_user_history|
music_session_user_history.end_history
# then update any users that need their user progress updated
if music_session_user_history.duration_minutes > 15 && music_session_user_history.max_concurrent_connections >= 3
music_session_user_history.user.update_progression_field(:first_real_music_session_at)
end
end
end
def self.removed_music_session(session_id)
hist = self
.where(:id => session_id)
.limit(1)
.first
hist.end_history if hist
Notification.send_session_ended(session_id)
end
def remove_non_alpha_num(token)
token.gsub(/[^0-9A-Za-z]/, '')
end
def tag
nil unless has_attribute?(:tag)
a = read_attribute(:tag)
a.nil? ? nil : a.to_i
end
def latency
nil unless has_attribute?(:latency)
a = read_attribute(:latency)
a.nil? ? nil : a.to_i
end
# initialize the two temporary tables we use to drive sms_index and sms_users
def self.sms_init(current_user, options = {})
client_id = options[:client_id]
connection = Connection.where(user_id: current_user.id, client_id: client_id).first!
my_locidispid = connection.locidispid
# 13 is an average audio gear value we use if they have not qualified any gear
my_audio_latency = connection.last_jam_audio_latency || current_user.last_jam_audio_latency || 13
self.connection.execute("select sms_index('#{current_user.id}'::varchar, #{my_locidispid}::bigint, #{my_audio_latency}::integer)");
end
# Generate a list of music sessions (that are active) filtered by genre, language, keyword, and sorted
# (and tagged) by rsvp'd (1st), invited (2nd), and musician can join (3rd). within a group tagged the
# same, sorted by score. date seems irrelevant as these are active sessions. sms_init must be called
# first.
def self.sms_query(current_user, options = {})
client_id = options[:client_id]
genre = options[:genre]
lang = options[:lang]
keyword = options[:keyword]
offset = options[:offset]
limit = options[:limit]
day = options[:day]
timezone_offset = options[:timezone_offset]
query = MusicSession
.select('music_sessions.*')
# this is not really needed when sms_music_session_tmp is joined
# unless there is something specific we need out of active_music_sessions
# query = query.joins(
# %Q{
# INNER JOIN
# active_music_sessions
# ON
# active_music_sessions.id = music_sessions.id
# }
# )
# .select('1::integer as tag, 15::integer as latency')
# integrate sms_music_session_tmp into the processing
# then we can join sms_music_session_tmp and not join active_music_sessions
query = query.joins(
%Q{
INNER JOIN
sms_music_session_tmp
ON
sms_music_session_tmp.music_session_id = music_sessions.id
}
)
.select('sms_music_session_tmp.tag, sms_music_session_tmp.latency')
query = query.order(
%Q{
tag, latency, music_sessions.id
}
)
.group(
%Q{
tag, latency, music_sessions.id
}
)
# if not specified, default offset to 0
offset ||= 0
offset = offset.to_i
# if not specified, default limit to 20
limit ||= 20
limit = limit.to_i
query = query.offset(offset)
query = query.limit(limit)
query = query.where("music_sessions.genre_id = ?", genre) unless genre.blank?
query = query.where('music_sessions.language = ?', lang) unless lang.blank?
query = query.where("(description_tsv @@ to_tsquery('jamenglish', ?))", keyword + ':*') unless keyword.blank?
if !day.blank? && !timezone_offset.blank?
begin
day = Date.parse(day)
next_day = day + 1
timezone_offset = timezone_offset.to_i
query = query.where("scheduled_start BETWEEN TIMESTAMP WITH TIME ZONE '#{day} 00:00:00#{timezone_offset}'
AND TIMESTAMP WITH TIME ZONE '#{next_day} 00:00:00#{timezone_offset}'")
rescue Exception => e
# do nothing. bad date probably
@@log.warn("unable to parse day=#{day}, timezone_offset=#{timezone_offset}, e=#{e}")
end
end
return query
end
# returns the set of users in a music_sessions and the music_session they are in and their latency.
# sms_init must be called first.
# user.audio_latency / 2 , + other_user.audio_latency of them / 2, + network latency /2
def self.sms_users
return User.select('users.*, sms_users_tmp.music_session_id, sms_users_tmp.latency')
.joins(
%Q{
INNER JOIN
sms_users_tmp
ON
sms_users_tmp.user_id = users.id
}
)
.order('sms_users_tmp.music_session_id, sms_users_tmp.user_id')
end
# wrap me in a transaction!
# note that these queries must be actualized before the end of the transaction
# else the temporary tables created by sms_init will be gone.
def self.sms_index(current_user, params)
MusicSession.sms_init(current_user, params)
music_sessions = MusicSession.sms_query(current_user, params).all
music_session_users = MusicSession.sms_users.all
user_scores = {}
music_session_users.each do |user|
user_scores[user.id] = {latency: user.latency}
end
[music_sessions, user_scores]
end
private
def generate_share_token
token = loop do
token = SecureRandom.urlsafe_base64(SHARE_TOKEN_LENGTH, false)
token = remove_non_alpha_num(token)
token.upcase!
break token unless ShareToken.exists?(token: token)
end
self.share_token = ShareToken.new
self.share_token.token = token
self.share_token.shareable_type = "session"
end
def creator_is_musician
unless creator && creator.musician?
errors.add(:creator, ValidationMessages::MUST_BE_A_MUSICIAN)
end
end
end
end