def _scan(self, request, stream): """ Scans the stream to a specific part of the currently playing track. """ Stream = apps.get_model("streams", "Stream") stream = Stream.objects.get(user=request.user) if not stream.now_playing.is_playing: raise Exception("Stream has to be playing") total_duration_ms = stream.now_playing.duration_ms total_duration = timedelta(milliseconds=int(total_duration_ms)) started_at_raw = self.param(request, self.PARAM_STARTED_AT) started_at = time_util.int_to_dt(int(started_at_raw)) now = time_util.now() valid_started_at = ( now > started_at and now < started_at + total_duration - timedelta(seconds=5) ) if not valid_started_at: raise Exception("Invalid scan") stream.now_playing.started_at = started_at stream.now_playing.status_at = time_util.now() stream.now_playing.save() return stream
def filter_idle(self): """ Filters to streams which are idle. """ Queue = apps.get_model("streams", "Queue") now = time_util.now() three_hours_ago = now - timedelta(hours=3) newest_queue_track_id = (Queue.objects.filter( stream_id=OuterRef("uuid"), deleted_at__isnull=True, ).order_by("-index").values_list("track_id", flat=True)[:1]) newest_queue_collection_id = (Queue.objects.filter( stream_id=OuterRef("uuid"), deleted_at__isnull=True, ).order_by("-index").values_list("collection_id", flat=True)[:1]) return (self.annotate( newest_queue_track_id=Subquery(newest_queue_track_id), ).annotate(newest_queue_collection_id=Subquery( newest_queue_collection_id), ).filter( # No recently played queues == streams that are idle. ~Exists( Queue.objects.filter(stream_id=OuterRef("uuid")).exclude( played_at__lte=three_hours_ago))).exclude( newest_queue_track_id__isnull=True, newest_queue_collection_id__isnull=True, ))
def _delete_queue(self, request, stream): """ When a user deletes (archives) something from the queue. """ Queue = apps.get_model("streams", "Queue") queue_uuid = self.param(request, "queueUuid") queue = Queue.objects.get(uuid=queue_uuid, stream=stream, user=request.user) head = Queue.objects.get_head(stream) if queue.index <= head.index: raise Exception("Cannot delete a queue that is not up next.") now = time_util.now() with transaction.atomic(): # delete queue queue.deleted_at = now queue.save() # fix offset for up next indexes children_queue_count = queue.children.count() offset = max(1, children_queue_count) relative_next_up = Queue.objects.filter(stream=stream, index__gt=queue.index, deleted_at__isnull=True) relative_next_up.update(index=F("index") - offset) # also delete children queue.children.all().update(deleted_at=now)
def _next_track(self, request, stream): Queue = apps.get_model("streams", "Queue") total_duration_ms = stream.now_playing.duration_ms if total_duration_ms: total_duration = timedelta(milliseconds=int(total_duration_ms)) is_planned = self.param(request, "isPlanned") next_head = Queue.objects.get_next(stream) if not next_head: if not stream.now_playing.is_playing and not stream.now_playing.is_paused: raise ValueError("Stream needs to be playing or paused") stream.now_playing.status_at = time_util.now() stream.now_playing.status = Queue.STATUS_ENDED_AUTO stream.now_playing.save() return stream if is_planned: playing_at = stream.now_playing.started_at + total_duration else: playing_at = time_util.now() + timedelta(milliseconds=100) with transaction.atomic(): next_head.started_at = playing_at next_head.status = Queue.STATUS_PLAYED next_head.status_at = time_util.now() next_head.save() prev_head = stream.now_playing prev_head.status = Queue.STATUS_ENDED_AUTO prev_head.status_at = time_util.now() prev_head.save() stream.now_playing = next_head stream.save() return stream
def _prev_track(self, request, stream): Queue = apps.get_model("streams", "Queue") if not stream.now_playing.track_id: raise ValueError("Nothing to play next!") if ( stream.now_playing and not stream.now_playing.is_playing and not stream.now_playing.is_paused ): playing_at = time_util.now() stream.now_playing.started_at = playing_at stream.now_playing.status = Queue.STATUS_PLAYED stream.now_playing.status_at = time_util.now() stream.now_playing.save() return stream next_head = Queue.objects.get_prev(stream) playing_at = time_util.now() + timedelta(milliseconds=100) with transaction.atomic(): next_head.started_at = playing_at next_head.status = Queue.STATUS_PLAYED next_head.status_at = time_util.now() next_head.save() prev_head = stream.now_playing prev_head.status_at = time_util.now() prev_head.status = Queue.STATUS_QUEUED_PREVIOUS prev_head.save() stream.now_playing = next_head stream.save() return stream
def _play_track(self, request, stream): """ When a user plays a paused stream. """ Queue = apps.get_model("streams", "Queue") if stream.now_playing.is_playing: raise ValueError("Cannot play a stream which is already playing") if not stream.now_playing.is_paused: raise ValueError("Cannot play a stream which is not paused") # NOTE: We have to be able to get ourselves out of a pickle. So here, # we do not restrict the play action by checking to see if the # stream has `controls_enabled`. So long as the stream is paused, # we allow the play action. # NOTE: This is a copy paste of "scan" logic found in "scan_view." total_duration_ms = stream.now_playing.duration_ms total_duration = timedelta(milliseconds=int(total_duration_ms)) timestamp_ms = self.param(request, self.PARAM_TIMESTAMP_MS) now = time_util.now() started_at = now - timedelta(milliseconds=int(timestamp_ms)) # Needs validation when scanning valid_started_at = ( now >= started_at and now < started_at + total_duration - timedelta(seconds=5)) if not valid_started_at: raise Exception("Invalid scan") stream.now_playing.started_at = started_at stream.now_playing.status_at = time_util.now() stream.now_playing.status = Queue.STATUS_PLAYED stream.now_playing.save() return stream
def generate_apple_music_token(): alg = "ES256" time_now = now() time_expired = time_now + timedelta(hours=(24 * 7)) headers = { "alg": alg, "kid": settings.APPLE_MUSIC_KEY_ID, } payload = { "iss": settings.APPLE_MUSIC_TEAM_ID, "exp": int(time_expired.strftime("%s")), "iat": int(time_now.strftime("%s")), } secret = settings.APPLE_MUSIC_AUTH_KEY_P8 return jwt.encode(payload, secret, algorithm=alg, headers=headers)
def post(self, request, **kwargs): """ Delete (archive) all TextCommentModification objects that related to a given TextComment. In practice, this is like "wiping" the comment clean. """ TextCommentModification = apps.get_model("comments", "TextCommentModification") text_comment_uuid = self.param(request, "textCommentUuid") text_comment_modification_qs = TextCommentModification.objects.filter( user=request.user, text_comment_id=text_comment_uuid) text_comment_modification_qs.update(deleted_at=time_util.now()) return self.http_react_response( "textComment/clearModifications", { "textCommentUuid": text_comment_uuid, }, )
def controls_enabled(self, end_buffer, total_duration): """ A stream's playback controls are disabled towards the end of the now playing track. This determines if the stream is able to have the controls enabled or not. """ if not self.track_id: raise Exception("Can only control a queue if it has a track.") if self.status == self.STATUS_PAUSED: return True if self.status != self.STATUS_PLAYED: return False expected_end_at = self.started_at + timedelta( milliseconds=self.track.duration_ms) controls_disabled_at = time_util.now() + timedelta( milliseconds=self.CONTROL_BUFFER_MS) return controls_disabled_at > expected_end_at
def _pause_track(self, request, stream): """ When a user pauses a playing stream. """ Queue = apps.get_model("streams", "Queue") if not stream.now_playing.is_playing: raise ValueError( "Cannot pause a stream that is not already playing") if stream.now_playing.is_paused: raise ValueError("Cannot pause a stream which is already paused") pausing_at = time_util.now() + timedelta(milliseconds=100) if not stream.now_playing.controls_enabled: raise ValueError( "Cannot pause since the track will be over by the time we try to pause" ) stream.now_playing.status = Queue.STATUS_PAUSED stream.now_playing.status_at = pausing_at stream.now_playing.save() return stream
def create_blank_queue(self, stream): """ Custom create method. TODO: this should probably only be done when the blank queue is the new head. the code should enforce that. """ Queue = apps.get_model("streams", "Queue") head = Queue.objects.get_head(stream) if head: index = head.index + 1 else: index = Queue.INITIAL_INDEX return Queue.objects.create( stream=stream, user=stream.user, index=index, is_abstract=False, duration_ms=0, status=Queue.STATUS_PLAYED, status_at=time_util.now(), )
def archive(self): self.deleted_at = time_util.now() self.save()
def _refresh_collection_apple_music_playlist_data(collection, user): CollectionListing = apps.get_model("music", "CollectionListing") Track = apps.get_model("music", "Track") Request = apps.get_model("networking", "Request") apple_music_token = generate_apple_music_token() apple_music_id = collection.external_id response = make_request( Request.TYPE_GET, f"https://api.music.apple.com/v1/catalog/us/playlists/{apple_music_id}", headers={ "Authorization": f"Bearer {apple_music_token}", }, ) response_json = response.json() items = response_json["data"][0]["relationships"]["tracks"]["data"] data = [] for item in items: data.append({ # to be saved as Track instances "format": Track.FORMAT_TRACK, "provider": Track.PROVIDER_APPLE_MUSIC, "external_id": item["id"], "name": item["attributes"]["name"], "artist_name": item["attributes"]["artistName"], "album_name": item["attributes"]["albumName"], "duration_ms": item["attributes"]["durationInMillis"], "img_url": item["attributes"]["artwork"]["url"], # not saved in Track table "_disk_number": item["attributes"]["discNumber"], "_track_number": item["attributes"]["trackNumber"], }) # first sort by disk number, then by track number data = sorted(data, key=lambda d: (d["_disk_number"], d["_track_number"])) tracks = [] track_eids = [] for track_data in data: tracks.append( Track( format=track_data["format"], provider=track_data["provider"], external_id=track_data["external_id"], name=track_data["name"], artist_name=track_data["artist_name"], album_name=track_data["album_name"], img_url=track_data["img_url"], duration_ms=track_data["duration_ms"], )) track_eids.append(track_data["external_id"]) # create Track objects if they do not already exist # TODO refresh more data points Track.objects.bulk_create(tracks, ignore_conflicts=True) track_qs = Track.objects.filter(external_id__in=track_eids) # wipe old CollectionListing objects # TODO this is not ok cl_by_tracks_qs = CollectionListing.objects.filter( track__external_id__in=track_eids) cl_by_collection_qs = CollectionListing.objects.filter( collection=collection) now = time_util.now() cl_by_tracks_qs.update(deleted_at=now) cl_by_collection_qs.update(deleted_at=now) # sort tracks by order one more time track_map = {} for track in track_qs: track_map[track.external_id] = track tracks = [] for track_eid in track_eids: tracks.append(track_map[track_eid]) # create CollectionListing objects if they do not already exist collection_listings = [] for idx in range(len(tracks)): track = tracks[idx] collection_listings.append( CollectionListing( track=track, collection=collection, number=idx, )) CollectionListing.objects.bulk_create(collection_listings)
def _refresh_collection_spotify_playlist_data(collection, user): Track = apps.get_model("music", "Track") CollectionListing = apps.get_model("music", "CollectionListing") Request = apps.get_model("networking", "Request") response = make_request( Request.TYPE_GET, f"https://api.spotify.com/v1/playlists/{collection.spotify_id}", headers={ "Authorization": f"Bearer {user.spotify_access_token}", "Content-Type": "application/json", }, ) response_json = response.json() # items are pre-sorted here items = response_json["tracks"]["items"] data = [] for item in items: artist_names = map(lambda o: o["name"], item["track"]["artists"]) data.append({ "format": Track.FORMAT_TRACK, "provider": Track.PROVIDER_SPOTIFY, "external_id": item["track"]["uri"], "name": item["track"]["name"], "artist_name": ", ".join(artist_names), "album_name": collection.name, "duration_ms": item["track"]["duration_ms"], "img_url": item["track"]["album"]["images"][0]["url"], }) tracks = [] track_eids = [] for track_data in data: tracks.append( Track( format=track_data["format"], provider=track_data["provider"], external_id=track_data["external_id"], name=track_data["name"], artist_name=track_data["artist_name"], album_name=track_data["album_name"], img_url=track_data["img_url"], duration_ms=track_data["duration_ms"], )) track_eids.append(track_data["external_id"]) # create Track objects if they do not already exist # TODO refresh more data points Track.objects.bulk_create(tracks, ignore_conflicts=True) track_qs = Track.objects.filter(external_id__in=track_eids) # wipe old CollectionListing objects to force refresh playlist # TODO this is not ok cl_by_tracks_qs = CollectionListing.objects.filter( track__external_id__in=track_eids) cl_by_collection_qs = CollectionListing.objects.filter( collection=collection) now = time_util.now() cl_by_tracks_qs.update(deleted_at=now) cl_by_collection_qs.update(deleted_at=now) # sort tracks by order one more time track_map = {} for track in track_qs: track_map[track.external_id] = track tracks = [] for track_eid in track_eids: tracks.append(track_map[track_eid]) # create CollectionListing objects if they do not already exist collection_listings = [] for idx in range(len(tracks)): track = tracks[idx] collection_listings.append( CollectionListing( track=track, collection=collection, number=idx, )) CollectionListing.objects.bulk_create(collection_listings)
def create_queue(self, stream=None, track=None, collection=None, user=None, **kwargs): """ Custom create method """ Queue = apps.get_model("streams", "Queue") last_queue = Queue.objects.last_queue(stream) index = last_queue.index + 1 queue_head = Queue.objects.get_head(stream) if queue_head.index >= index: raise ValueError("Index value is too small") # NOTE: This is a little hacky. We calculate the offset that in queue # indexes need to be bumped up by. _tracks = collection.list_tracks() if collection else [None] if not len(_tracks): raise ValueError(f"Collection has no tracks: {collection.uuid}") offset = len(_tracks) with transaction.atomic(): next_up_tracks_only = Queue.objects.filter( index__gte=index, stream=stream, deleted_at__isnull=True, ) next_up_tracks_only.update(index=F("index") + offset) queues = [] parent_queue = (Queue( stream=stream, index=(index + offset - 1), user=user, collection=collection, is_abstract=True, status=Queue.STATUS_QUEUED_INIT, status_at=time_util.now(), ) if collection else None) if parent_queue: queues.append(parent_queue) total_duration_ms = 0 for _track in _tracks: t = _track or track if not t.duration_ms: refresh_track_external_data(t, user) t.refresh_from_db() queue = Queue( stream=stream, index=index, user=user, track=(_track or track), collection=collection, is_abstract=False, parent=parent_queue, duration_ms=t.duration_ms, status=Queue.STATUS_QUEUED_INIT, status_at=time_util.now(), ) queues.append(queue) index += 1 total_duration_ms += t.duration_ms if parent_queue: parent_queue.duration_ms = total_duration_ms Queue.objects.bulk_create(queues)