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 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 :is_unstructured_rsvp, :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 new_session.scheduled_start = self.scheduled_start + 1.week 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 new_session.is_unstructured_rsvp = self.is_unstructured_rsvp new_session.legal_terms = true # 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 query = MusicSession.where("music_sessions.canceled = FALSE") query = query.where("music_sessions.user_id = '#{user.id}'") query = query.where("music_sessions.scheduled_start > NOW() - '12 hour'::INTERVAL") query = query.where("music_session_id IS NULL") query = query.order("music_sessions.scheduled_start ASC") query end def self.scheduled_rsvp user MusicSession.where(%Q{music_sessions.canceled = FALSE AND music_sessions.id in ( select distinct(rs.music_session_id) from rsvp_slots rs where rs.id in ( select rrrs.rsvp_slot_id from rsvp_requests rr inner join rsvp_requests_rsvp_slots rrrs on rr.id = rrrs.rsvp_request_id where rr.user_id = '#{user.id}' ) )} ) 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_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.open_rsvps = options[:open_rsvps] if options[:open_rsvps] ms.creator = user ms.is_unstructured_rsvp = options[:isUnstructuredRsvp] if options[:isUnstructuredRsvp] ms.scheduled_start = options[:start] ms.scheduled_start = parse_scheduled_start(ms.scheduled_start, ms.timezone) 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_cancel? user self.creator == user end def legal_policy_url # TODO: move to DB or config file or helper 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.utc.strftime "%a %e %B %Y" else "" end end def scheduled_end_time 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 "#{musician_access_description}. #{fan_access_description}." end # retrieve users that have approved RSVPs def approved_rsvps User.find_by_sql(%Q{select distinct ON(u.id) u.id, u.photo_url, u.first_name, u.last_name, json_agg(ii.id) as instrument_ids, json_agg(ii.description) as instrument_descriptions, json_agg(rs.proficiency_level) as instrument_proficiencies, json_agg(rr.id) as rsvp_request_ids 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 left 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}' AND rr.canceled != TRUE group by u.id order by u.id} ) 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 # this method excludes rsvp_slots marked as 'is_unstructured_rsvp = true' def open_slots 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}' and rs.is_unstructured_rsvp = false 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 # retrieve pending RsvpRequests def pending_rsvp_requests RsvpRequest.index(self, nil, {status: 'pending'}) 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 if timezone_offset == 0 timezone_offset = '' # no offset to specify in this case elsif timezone_offset > 0 timezone_offset = "+#{timezone_offset}" end 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 # converts the passed scheduled_start into the UTC using the specified timezone offset. # timezone comes in as TIMEZONE DISPLAY, TIMEZONE ID def self.parse_scheduled_start(scheduled_start, timezone_param) result = scheduled_start if timezone_param index = timezone_param.rindex(',') if index tz_identifier = timezone_param[(index + 1)..-1] timezone = TZInfo::Timezone.get(tz_identifier) result = timezone.local_to_utc(scheduled_start, true) end end result 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