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

1446 lines
55 KiB
Ruby

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
# 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.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 >= 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 =<<SQL
music_sessions.started_at IS NULL AND
(music_sessions.created_at > 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
# 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 =<<SQL
music_sessions.started_at IS NULL AND
(music_sessions.created_at > 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 =<<SQL
(started_at IS NULL AND
(created_at <= NOW() - interval '#{UNSTARTED_INTERVAL_DAYS_PURGE} days' OR
scheduled_start <= NOW() - interval '#{UNSTARTED_INTERVAL_DAYS_PURGE} days'))
OR
(recurring_mode = '#{NO_RECURRING}' AND
scheduled_start <= NOW() - interval '#{UNSTARTED_INTERVAL_DAYS_PURGE} days')
OR
(recurring_mode = '#{RECURRING_WEEKLY}' AND
scheduled_start <= NOW() - interval '#{UNSTARTED_INTERVAL_DAYS_PURGE_RECUR} days')
SQL
self.where("lesson_session_id is NULL").where("started_at IS NULL").where(sql).find_each do |ms|
block_given? ? yield(ms) : sessions << ms
end
sessions
end
def admin_url
APP_CONFIG.admin_root_url + "/admin/music_sessions/" + id
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
def validate_timezone
if timezone
index = timezone.rindex(',')
if index
tz_identifier = timezone[(index + 1)..-1]
begin
TZInfo::Timezone.get(tz_identifier)
rescue Exception => 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