require 'iso-639' module JamRuby class MusicSession < ActiveRecord::Base include HtmlSanitize html_sanitize strict: [:name, :description] @@log = Logging.logger[MusicSession] NO_RECURRING = 'once' RECURRING_WEEKLY = 'weekly' RECURRING_MODES = [NO_RECURRING, RECURRING_WEEKLY] UNSTARTED_INTERVAL_DAYS_SKIP = '14' # days past scheduled start to skip in query UNSTARTED_INTERVAL_DAYS_PURGE = '28' # days past scheduled start to purge session UNSTARTED_INTERVAL_DAYS_PURGE_RECUR = '28' # days past scheduled start to purge recurddingsession CREATE_TYPE_START_SCHEDULED = 'start-scheduled' CREATE_TYPE_SCHEDULE_FUTURE = 'schedule-future' CREATE_TYPE_RSVP = 'rsvp' CREATE_TYPE_IMMEDIATE = 'immediately' CREATE_TYPE_QUICK_START = 'quick-start' CREATE_TYPE_LESSON = 'lesson' CREATE_TYPE_QUICK_PUBLIC = 'quick-public' attr_accessor :legal_terms, :language_description, :access_description, :scheduling_info_changed 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 belongs_to :session_controller, :class_name => 'JamRuby::User', :foreign_key => :session_controller_id, :inverse_of => :controlled_sessions belongs_to :lesson_session, :class_name => "JamRuby::LessonSession" 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" has_many :invitations, :foreign_key => "music_session_id", :inverse_of => :music_session, :class_name => "JamRuby::Invitation" 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" 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 :rsvp_requests, :class_name => "JamRuby::RsvpRequest", :foreign_key => "music_session_id", :dependent => :destroy has_many :music_notations, :class_name => "JamRuby::MusicNotation", :foreign_key => "music_session_id" has_many :jam_track_session, :class_name => "JamRuby::JamTrackSession" has_many :broadcasts, :class_name => "JamRuby::Broadcast" 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 :friends_can_join, :inclusion => {:in => [true, false]} validates :is_unstructured_rsvp, :inclusion => {:in => [true, false]} validates :legal_terms, :inclusion => {:in => [true]}, :on => :create validates :creator, :presence => true validates :timezone, presence: true, if: Proc.new { |session| session.scheduled_start } validates :scheduled_duration, presence: true, if: Proc.new { |session| session.scheduled_start } validate :creator_is_musician validate :validate_timezone before_create :generate_share_token #before_save :update_scheduled_start before_save :check_scheduling_info_changed SHARE_TOKEN_LENGTH = 8 SEPARATOR = '|' def current_broadcast Broadcast.current_broadcast(self) end def unlink_broadcast Broadcast.unlink_broadcast(self) end def create_broadcast(user, broadcast_options, google_client = GoogleClient.new) broadcast = current_broadcast if broadcast.nil? broadcast = create_youtube_broadcast(user, broadcast_options, google_client) else result = refresh_youtube_broadcast(user, broadcast, broadcast_options, google_client) # check against Youtube the real state of broadcast, to see if we need a new one? if result.nil? unlink_broadcast # user probably deleted it, or marked it complete. broadcast = create_youtube_broadcast(user, broadcast_options, google_client) end end broadcast end def create_stream(user, broadcast_options, google_client = JamRuby::GoogleClient.new) broadcast = create_broadcast(user, broadcast_options, google_client) stream = current_stream(broadcast) if stream.nil? create_youtube_stream(user, broadcast, broadcast_options, google_client) bind_broadcast(user, broadcast, google_client) else bind_broadcast(user, broadcast, google_client) end end def current_stream(broadcast) broadcast.stream_id end def refresh_stream(user, broadcast) stream_data = get_livestream(user) broadcast.stream_id = stream_data["id"] broadcast.stream_status = stream_data["status"]["streamStatus"] broadcast.stream_name = stream_data["cdn"]["ingestionInfo"]["streamName"] broadcast.stream_address = stream_data["cdn"]["ingestionInfo"]["ingestionAddress"] broadcast.stream_data = stream_data.to_json broadcast.save end def refresh_youtube_broadcast(user, broadcast, broadcast_data = nil, google_client = GoogleClient.new) if broadcast_data.nil? broadcast_data = google_client.get_broadcast(user, broadcast.broadcast_id) end if broadcast_data broadcast.update_broadcast_data(broadcast_data) broadcast.save true else # this path makes sense if the user deleted the video on the server, but we do not yet know it nil end end def get_livestream(user, google_client = GoogleClient.new) broadcast = current_broadcast if broadcast.nil? nil else stream_id = current_stream(broadcast) if stream_id.nil? nil else return google_client.get_livestream(user, stream_id) end end end def get_broadcast(user, google_client = UserManager.new.get_google_client) broadcast = current_broadcast if broadcast.nil? nil else broadcast_data = google_client.get_broadcast(user, broadcast.broadcast_id) broadcast.update_broadcast_data(broadcast_data) broadcast.save broadcast end end def set_livestream_live(user, google_client = GoogleClient.new) livestream = get_livestream(user, google_client) if livestream # if livestream["status"]["streamStatus"] == "active" transition_broadcast(user, broadcast, 'live', google_client) # end else end end # https://developers.google.com/youtube/v3/live/docs/liveStreams#resource def create_youtube_stream(user, broadcast, broadcast_options, google_client = GoogleClient.new) # https://developers.google.com/youtube/v3/live/docs/liveStreams/insert # required # snippet.title # cdn.format # cdn.ingestionType (deprecated - use resolution/framerate) stream_options = {} stream_options[:snippet] ||= {} stream_options[:snippet][:title] ||= name stream_options[:snippet][:isDefaultStream] ||= false #broadcast_options[:snippet][:scheduledEndTime] = end_time.utc.iso8601 stream_options[:cdn] ||= {} stream_options[:cdn][:frameRate] ||= '30fps' stream_options[:cdn][:resolution] ||= '360p' stream_options[:cdn][:ingestionType] ||= 'rtmp' stream_options[:contentDetails] ||= {} stream_options[:contentDetails][:isReusable] ||= false stream_options[:contentDetails][:monitorStream] ||= {} stream_options[:contentDetails][:monitorStream][:enableMonitorStream] ||= false stream_options = google_client.create_stream(user, stream_options) broadcast.stream_id = stream_options["id"] broadcast.stream_status = stream_options["status"]["streamStatus"] broadcast.stream_name = stream_options["cdn"]["ingestionInfo"]["streamName"] broadcast.stream_address = stream_options["cdn"]["ingestionInfo"]["ingestionAddress"] broadcast.stream_data = stream_options.to_json broadcast.save! broadcast end def create_youtube_broadcast(user, broadcast_options, google_client = GoogleClient.new) start_time, end_time = youtube_times broadcast_options ||= {} broadcast_options[:snippet] ||= {} broadcast_options[:snippet][:title] ||= name broadcast_options[:snippet][:description] ||= description broadcast_options[:snippet][:scheduledStartTime] = start_time.utc.iso8601 #broadcast_options[:snippet][:scheduledEndTime] = end_time.utc.iso8601 broadcast_options[:status] ||= {} broadcast_options[:status][:privacyStatus] ||= (fan_access ? 'public' : 'private') broadcast_options[:contentDetails] ||= {} # if false, this causes a 'request not authorized error' # From: https://developers.google.com/youtube/v3/live/docs/liveBroadcasts # If your channel does not have permission to disable recordings, and you attempt to insert a broadcast with the recordFromStart property set to false, the API will return a Forbidden error. #broadcast_options[:contentDetails][:recordFromStart] ||= false broadcast_data = google_client.create_broadcast(user, broadcast_options) broadcast = Broadcast.new broadcast.music_session_id = self.id broadcast.user_id = user.id broadcast.broadcast_id = broadcast_data["id"] broadcast.update_broadcast_data(broadcast_data) broadcast.save! broadcast end def bind_broadcast(user, broadcast, google_client = GoogleClient.new) bind_data = google_client.bind_broadcast(user, broadcast.broadcast_id, broadcast.stream_id) broadcast.update_broadcast_data(bind_data) broadcast.save! broadcast end # broadcastStatus one of complete, live, testing def transition_broadcast(user, broadcast, broadcastStatus, google_client = GoogleClient.new) if broadcastStatus == 'testing' && (broadcast.broadcast_status == 'testing' || broadcast.broadcast_status == 'live' || broadcast.broadcast_status == 'testStarting' || broadcast.broadcast_status == 'liveStarting') # short cut out; this in unnecessary puts "SHORT CUT OUT OF TRANSITION TESTING TESTING" return broadcast end if broadcastStatus == 'live' && broadcast.broadcast_status == 'live' # short cut out; this in unnecessary puts "SHORT CUT OUT OF TRANSITION TESTING LIVE" return broadcast end bind_data = google_client.transition_broadcast(user, broadcast.broadcast_id, broadcastStatus) broadcast.update_broadcast_data(bind_data) broadcast.save! broadcast end def youtube_times start = scheduled_start_time if start < Time.now start = Time.now end_time = start + safe_scheduled_duration return [start, end_time] else return [start, scheduled_end_time] end end def check_scheduling_info_changed @scheduling_info_changed = scheduled_start_changed? true end def update_scheduled_start # it's very important that this only run if timezone changes, or scheduled_start changes if self.scheduled_start && (self.scheduled_start_changed? || self.timezone_changed?) self.scheduled_start = MusicSession.parse_scheduled_start(self.scheduled_start, self.timezone) end 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.friends_can_join = self.friends_can_join 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 new_session.session_controller = self.session_controller new_session.school_id = self.school_id new_session.is_platform_instructor = self.is_platform_instructor new_session.use_video_conferencing_server = self.use_video_conferencing_server # copy rsvp_slots, rsvp_requests, and rsvp_requests_rsvp_slots RsvpSlot.where("music_session_id = '#{self.id}'").find_each do |slot| new_slot = RsvpSlot.new new_slot.instrument_id = slot.instrument_id new_slot.proficiency_level = slot.proficiency_level new_slot.is_unstructured_rsvp = slot.is_unstructured_rsvp 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.music_session_id = rsvp.music_session_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 # .last => new_slot above new_session.rsvp_slots.last.rsvp_requests_rsvp_slots << new_rsvp_req_slot # 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 is null OR chosen = FALSE) 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.music_session_id = rsvp.music_session_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 = nil # .last => new_slot above new_session.rsvp_slots.last.rsvp_requests_rsvp_slots << new_rsvp_req_slot end end end end # copy music_notations MusicNotation.where("music_session_id = '#{self.id}'").find_each 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 return new_session end end def is_lesson? !!lesson_session end # checks if this is a lesson and if the person indicated is a teacher or student def is_lesson_member?(user) is_lesson? && (lesson_session.teacher.id == user.id || lesson_session.student.id == user.id) end def grouped_tracks tracks = [] self.music_session_user_histories.each do |msuh| user = User.find(msuh.user_id) # see if user already exists in array t = tracks.select { |track| track.musician.id == user.id }.first if t.blank? 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 # this handles case where user is duplicated in MSUH for the same session else 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 end end tracks end # is the viewer 'friensd with the session?' # practically, to be a friend with the session, you have to be a friend with the creator # we could loosen to be friend of friends or friends with anyone in the session def friends_with_session(user) self.creator.friends?(user) 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.id) || self.approved_rsvps.include?(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.id) || self.approved_rsvps.include?(user) || (self.friends_can_join && self.friends_with_session(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 can_see? user if self.musician_access || self.fan_access true else self.creator == user || self.invited_musicians.exists?(user.id) || self.approved_rsvps.include?(user) || self.creator.friends?(user) || self.has_lesson_access?(user) end end def has_lesson_access?(user) if is_lesson? puts "HAS LESSON ACCESS" result = lesson_session.has_access?(user) puts "HAS LESSON ACCESS #{ result}" result else false end end def set_session_controller(current_user, user) # only allow update of session controller by the creator or the currently marked user should_tick = false if current_user != creator && current_user != self.session_controller return should_tick end if active_music_session if user if active_music_session.users.exists?(user) self.session_controller = user should_tick = save end else self.session_controller = nil should_tick = save end end should_tick 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, only_public = false # keep unstarted sessions around for 12 hours after scheduled_start session_not_started = "(music_sessions.scheduled_start > NOW() - '12 hour'::INTERVAL AND music_sessions.started_at IS NULL)" # keep started sessions that are not finished yet session_started_not_finished = "(music_sessions.started_at IS NOT NULL AND music_sessions.session_removed_at IS NULL)" # let session be restarted for up to 2 hours after finishing session_finished = "(music_sessions.session_removed_at > NOW() - '2 hour'::INTERVAL)" query = MusicSession.select('distinct music_sessions.*') query = query.joins( %Q{ LEFT OUTER JOIN rsvp_requests ON rsvp_requests.music_session_id = music_sessions.id AND rsvp_requests.user_id = '#{user.id}' AND rsvp_requests.chosen = TRUE LEFT OUTER JOIN invitations ON music_sessions.id = invitations.music_session_id AND invitations.receiver_id = '#{user.id}' } ) query = query.where("music_sessions.old = FALSE") query = query.where("music_sessions.canceled = FALSE") query = query.where('music_sessions.fan_access = TRUE or music_sessions.musician_access = TRUE') if only_public #query = query.where("music_sessions.user_id = '#{user.id}' OR invitations.id IS NOT NULL") query = query.where("(rsvp_requests.id IS NOT NULL) OR (invitations.id IS NOT NULL) OR (music_sessions.user_id = '#{user.id}') ") query = Search.scope_schools_together_sessions(query, user, 'music_sessions') query = query.where("music_sessions.scheduled_start IS NULL OR #{session_not_started} OR #{session_finished} OR #{session_started_not_finished}") query = query.where("music_sessions.create_type IS NULL OR (music_sessions.create_type != '#{CREATE_TYPE_QUICK_START}' AND music_sessions.create_type != '#{CREATE_TYPE_QUICK_PUBLIC}')") query = query.order("music_sessions.scheduled_start ASC") query end # if only_approved is set, then only return sessions where the current user has been chosen def self.scheduled_rsvp(user, only_approved = false) filter_approved = only_approved ? 'AND rsvp_requests_rsvp_slots.chosen = true' : '' query = MusicSession.joins( %Q{ INNER JOIN rsvp_slots ON music_sessions.id = rsvp_slots.music_session_id INNER JOIN rsvp_requests_rsvp_slots ON rsvp_requests_rsvp_slots.rsvp_slot_id = rsvp_slots.id INNER JOIN rsvp_requests ON rsvp_requests.id = rsvp_requests_rsvp_slots.rsvp_request_id } ) query = Search.scope_schools_together_sessions(query, user, 'music_sessions') query = query.where(%Q{music_sessions.old = FALSE AND music_sessions.canceled = FALSE AND (music_sessions.create_type is NULL OR (music_sessions.create_type != '#{CREATE_TYPE_QUICK_START}' AND music_sessions.create_type != '#{CREATE_TYPE_QUICK_PUBLIC}')) AND (music_sessions.scheduled_start is NULL OR music_sessions.scheduled_start > NOW() - '4 hour'::INTERVAL) AND rsvp_requests.user_id = '#{user.id}' #{filter_approved}} ).order(:scheduled_start) query end def self.create user, options band = Band.where(id: options[:band]).first if options[:band].present? 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.friends_can_join = options[:friends_can_join] || false 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.session_controller = user ms.create_type = options[:create_type] ms.is_unstructured_rsvp = options[:isUnstructuredRsvp] if options[:isUnstructuredRsvp] ms.scheduled_start = parse_scheduled_start(options[:start], options[:timezone]) if options[:start] && options[:timezone] ms.scheduled_start = options[:scheduled_start] if options[:scheduled_start] ms.school_id = user.school_id ms.is_platform_instructor = user.is_platform_instructor if options[:lesson_session] ms.lesson_session = options[:lesson_session] end ms.use_video_conferencing_server = user.use_video_conferencing_server 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 # send scheduled session notification or join session notification case ms.create_type when CREATE_TYPE_RSVP, CREATE_TYPE_SCHEDULE_FUTURE Notification.send_scheduled_session_invitation(ms, receiver) when CREATE_TYPE_LESSON if ms.lesson_session.is_active? Notification.send_jamclass_invitation_teacher(ms, receiver) end else Notification.send_session_invitation(receiver, user, ms.id) end end if options[:invitations] options[:music_notations].each do |notation| 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 # only get the active users if the session is in progress user_filter = "music_sessions_user_history.session_removed_at is null" if self.session_removed_at.nil? 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}'}) .where(user_filter) end def duration_minutes end_time = self.session_removed_at || Time.now.utc (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.id) : false) end def is_over? active_music_session.nil? end def has_mount? !active_music_session.nil? && !active_music_session.mount.nil? 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.downcase when "standard" return "session-legal-policies/standard" when "creative" return "session-legal-policies/creativecommons" when "offline" return "session-legal-policies/offline" when "jamtracks" return "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 scheduled_end_time start = scheduled_start_time duration = safe_scheduled_duration start + duration end def timezone_id MusicSession.split_timezone(timezone)[0] end def timezone_description MusicSession.split_timezone(timezone)[1] 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, u.last_jam_audio_latency, 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 distinct u.id, u.email, u.photo_url, u.first_name, u.last_name, u.online 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 running_recordings recordings.where(duration: nil) end def recordings Recording.where(music_session_id: self.id) end def self.cleanup_old_sessions old_scheduled_start = "(create_type is NOT NULL AND music_sessions.scheduled_start < NOW() - '24 hour'::INTERVAL)" old_adhoc_sessions = "(create_type IS NULL and music_sessions.created_at < NOW() - '24 hour'::INTERVAL)" MusicSession.where("#{old_scheduled_start} OR #{old_adhoc_sessions}").where(old:false).update_all(old: true) 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.to_i >= 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 = {}, include_pending=false) session_id = options[:session_id] || 'any' my_locidispid = current_user.last_jam_locidispid # 13 is an average audio gear value we use if they have not qualified any gear my_audio_latency = current_user.last_jam_audio_latency || 13 locidispid_expr = my_locidispid ? "#{my_locidispid}::bigint" : '0::bigint' # Have to pass in zero; NULL fails silently in the stored proc self.connection.execute("SELECT sms_index('#{current_user.id}'::varchar, #{locidispid_expr}, #{my_audio_latency}::integer, #{ActiveRecord::Base.connection.quote(session_id)}::varchar, #{include_pending}::boolean)").check 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.create_type IS NULL OR (music_sessions.create_type != ? AND music_sessions.create_type != ? AND music_sessions.create_type != ?)", MusicSession::CREATE_TYPE_QUICK_START, MusicSession::CREATE_TYPE_IMMEDIATE, MusicSession::CREATE_TYPE_QUICK_PUBLIC) 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', ?))", ActiveRecord::Base.connection.quote(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 else sql =< NOW() - interval '#{UNSTARTED_INTERVAL_DAYS_SKIP} days' OR music_sessions.scheduled_start > NOW() - interval '#{UNSTARTED_INTERVAL_DAYS_SKIP} days') SQL query = query.where(sql) 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.full_score, sms_users_tmp.audio_latency, sms_users_tmp.internet_score') .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 # NOTE: Unused anymore!! # # 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] = {full_score: user.full_score, audio_latency: user.audio_latency, internet_score: user.internet_score} end [music_sessions, user_scores] end def self.scheduled_index(user, options) session_id = options[:session_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.*') query = query.where("old = FALSE") query = query.where("scheduled_start IS NULL OR scheduled_start > (NOW() - (interval '15 minute'))") query = query.where("music_sessions.canceled = FALSE") query = query.where("description != 'Jam Track Session'") query = query.where("music_sessions.id NOT IN (SELECT id FROM active_music_sessions)") # one flaw in the join below is that we only consider if the creator of the session has asked you to be your friend, but you have not accepted. While this means you may always see a session by someone you haven't friended, the goal is to match up new users more quickly query = query.joins( %Q{ LEFT OUTER JOIN rsvp_requests ON rsvp_requests.music_session_id = music_sessions.id and rsvp_requests.user_id = '#{user.id}' AND rsvp_requests.chosen = true LEFT OUTER JOIN invitations ON music_sessions.id = invitations.music_session_id AND invitations.receiver_id = '#{user.id}' LEFT OUTER JOIN friendships ON music_sessions.user_id = friendships.user_id AND friendships.friend_id = '#{user.id}' LEFT OUTER JOIN friendships as friendships_2 ON music_sessions.user_id = friendships_2.friend_id AND friendships_2.user_id = '#{user.id}' } ) # keep only rsvp/invitation/friend results. Nice tailored active list now! query = query.where("open_rsvps = TRUE OR rsvp_requests.id IS NOT NULL OR invitations.id IS NOT NULL or music_sessions.user_id = '#{user.id}' OR (friendships.id IS NOT NULL AND friendships_2.id IS NOT NULL)") query = Search.scope_schools_together_sessions(query, user, 'music_sessions') # 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('music_sessions.id = ?', session_id) unless session_id.blank? query = query.where("(description_tsv @@ to_tsquery('jamenglish', ?))", ActiveRecord::Base.connection.quote(keyword) + ':*') unless keyword.blank? query = query.group("music_sessions.id, music_sessions.scheduled_start") query = query.order("music_sessions.scheduled_start DESC") 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 else sql =< NOW() - interval '#{UNSTARTED_INTERVAL_DAYS_SKIP} days' OR music_sessions.scheduled_start > NOW() - interval '#{UNSTARTED_INTERVAL_DAYS_SKIP} days') SQL query = query.where(sql) end #FROM music_sessions # WHERE old = FALSE AND (scheduled_start IS NULL OR scheduled_start > (NOW() - (interval '15 minute'))) # AND canceled = FALSE AND description != 'Jam Track Session' # AND id NOT IN (SELECT id FROM active_music_sessions); query end # returns a single session, but populates any other user info with latency scores, so that show_history.rabl can do it's business def self.session_with_scores(current_user, music_session_id, include_pending=false) MusicSession.sms_init(current_user, {session_id: music_session_id}, include_pending) music_session = MusicSession.find(music_session_id) music_session_users = MusicSession.sms_users.all user_scores = {} music_session_users.each do |user| user_scores[user.id] = {full_score: user.full_score, audio_latency: user.audio_latency, internet_score: user.internet_score} end [music_session, user_scores] end def self.upcoming_sessions end # converts the passed scheduled_start into the database timezone 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 tz_identifier = split_timezone(timezone_param)[0] begin timezone = ActiveSupport::TimeZone.new(tz_identifier) rescue Exception => e @@log.error("unable to find timezone=#{tz_identifier}, e=#{e}") puts "unable to find timezone=#{tz_identifier}, e=#{e}" end if timezone begin # first convert the time provided, and convert to the specified timezone (local_to_utc) # then, convert that to the system timezone, under the ASSUMPTION that the database is configured to use the system timezone # you can get into trouble if your dev database is not using the system timezone of the web machine result = timezone.parse(scheduled_start) rescue Exception => e @@log.error("unable to convert #{scheduled_start} to #{timezone}, e=#{e}") puts "unable to convert #{scheduled_start} to #{timezone}, e=#{e}" end end result end def scheduled_start_date if self.scheduled_start_time.blank? "" else self.scheduled_start.strftime "%a %e %B %Y" end end def scheduled_start_time if scheduled_start && scheduled_duration start_time = scheduled_start tz_identifier, tz_display = MusicSession.split_timezone(timezone) begin tz = TZInfo::Timezone.get(tz_identifier) rescue Exception => e @@log.error("unable to find timezone=#{tz_identifier}, e=#{e}") end if tz begin start_time = tz.utc_to_local(scheduled_start.utc) rescue Exception => e @@log.error("unable to convert #{scheduled_start} to #{tz}, e=#{e}") puts "unable to convert #{e}" end end start_time else "" end end # takes our stored timezone which is DISPLAY,ID and returns an array of the two values (id first, then description) def self.split_timezone(tz) result = [nil, nil] if tz index = tz.rindex(',') if index tz_display = tz[0, index] tz_identifier = tz[(index + 1)..-1] result = [tz_identifier, tz_display] end end result end def safe_scheduled_duration duration = scheduled_duration # you can put seconds into the scheduled_duration field, but once stored, it comes back out as a string if scheduled_duration.class == String duration = scheduled_duration.to_i.seconds end if duration == 0 duration = 30 * 60 end duration end def pretty_scheduled_start(with_timezone = true, shorter = false, user_tz = nil) if scheduled_start && scheduled_duration start_time = scheduled_start timezone_display = 'UTC' utc_offset_display = '00:00' tz_identifier, tz_display = MusicSession.split_timezone(timezone) short_tz = 'GMT' if user_tz tz_identifier = user_tz end begin tz = TZInfo::Timezone.get(tz_identifier) rescue Exception => e @@log.error("unable to find timezone=#{tz_identifier}, e=#{e}") end if tz begin start_time = tz.utc_to_local(scheduled_start.utc) timezone_display = tz.pretty_name utc_offset_hours = tz.current_period.utc_total_offset / (60*60) hour = sprintf '%02d', utc_offset_hours.abs minutes = sprintf '%02d', ((tz.current_period.utc_total_offset.abs % 3600) / 3600) * 60 utc_offset_display = "#{utc_offset_hours < 0 ? '-' : ' '}#{hour}:#{minutes}" short_tz = start_time.strftime("%Z") if short_tz == 'UTC' short_tz = 'GMT' end rescue Exception => e @@log.error("unable to convert #{scheduled_start} to #{tz}, e=#{e}") puts "unable to convert #{e}" end end duration = safe_scheduled_duration end_time = start_time + duration if with_timezone if shorter #"#{start_time.strftime("%a, %b %e %Y")}, #{start_time.strftime("%l:%M").strip}-#{end_time.strftime("%l:%M %p").strip} (#{short_tz}#{utc_offset_display})" "#{start_time.strftime("%a, %b %e %Y")}, #{start_time.strftime("%l:%M").strip}-#{end_time.strftime("%l:%M %p").strip} (#{timezone_display})" else "#{start_time.strftime("%A, %B %e")}, #{start_time.strftime("%l:%M").strip}-#{end_time.strftime("%l:%M %p").strip} #{timezone_display}" end else "#{start_time.strftime("%A, %B %e")} - #{end_time.strftime("%l:%M%P").strip}" end else "Date and time TBD" end end def self.purgeable_sessions sessions = [] sql =< e @@log.error("unable to find timezone=#{tz_identifier}, e=#{e}") errors.add(:timezone, ValidationMessages::MUST_BE_KNOWN_TIMEZONE) end end end end end end