def follow_user(user_name: str):
    """
    Follow the user ``user_name``. A user token (found on  https://listenbrainz.org/profile/ ) must
    be provided in the Authorization header!

    :reqheader Authorization: Token <user token>
    :reqheader Content-Type: *application/json*
    :statuscode 200: Successfully followed the user ``user_name``.
    :statuscode 400:
                    - Already following the user ``user_name``.
                    - Trying to follow yourself.
    :statuscode 401: invalid authorization. See error message for details.
    :resheader Content-Type: *application/json*
    """
    current_user = validate_auth_header()
    user = db_user.get_by_mb_id(user_name)

    if not user:
        raise APINotFound("User %s not found" % user_name)

    if user["musicbrainz_id"] == current_user["musicbrainz_id"]:
        raise APIBadRequest("Whoops, cannot follow yourself.")

    if db_user_relationship.is_following_user(current_user["id"], user["id"]):
        raise APIBadRequest("%s is already following user %s" % (current_user["musicbrainz_id"], user["musicbrainz_id"]))

    try:
        db_user_relationship.insert(current_user["id"], user["id"], "follow")
    except Exception as e:
        current_app.logger.error("Error while trying to insert a relationship: %s", str(e))
        raise APIInternalServerError("Something went wrong, please try again later")

    return jsonify({"status": "ok"})
def delete_feed_events(user_name):
    '''
    Delete those events from user's feed that belong to them.
    Supports deletion of recommendation and notification.
    Along with the authorization token, post the event type and event id.
    For example:

    .. code-block:: json

        {
            "event_type": "recording_recommendation",
            "id": "<integer id of the event>"
        }

    .. code-block:: json

        {
            "event_type": "notification",
            "id": "<integer id of the event>"
        }

    :param user_name: The MusicBrainz ID of the user from whose timeline events are being deleted
    :type user_name: ``str``
    :statuscode 200: Successful deletion
    :statuscode 400: Bad request, check ``response['error']`` for more details.
    :statuscode 401: Unauthorized
    :statuscode 404: User not found
    :statuscode 500: API Internal Server Error
    :resheader Content-Type: *application/json*
    '''
    user = validate_auth_header()
    if user_name != user['musicbrainz_id']:
        raise APIUnauthorized(
            "You don't have permissions to delete from this user's timeline.")

    try:
        event = ujson.loads(request.get_data())

        if event["event_type"] in [
                UserTimelineEventType.RECORDING_RECOMMENDATION.value,
                UserTimelineEventType.NOTIFICATION.value
        ]:
            try:
                event_deleted = db_user_timeline_event.delete_user_timeline_event(
                    event["id"], user["id"])
            except Exception as e:
                raise APIInternalServerError(
                    "Something went wrong. Please try again")
            if not event_deleted:
                raise APINotFound(
                    "Cannot find '%s' event with id '%s' for user '%s'" %
                    (event["event_type"], event["id"], user["id"]))
            return jsonify({"status": "ok"})

        raise APIBadRequest(
            "This event type is not supported for deletion via this method")

    except (ValueError, KeyError) as e:
        raise APIBadRequest(f"Invalid JSON: {str(e)}")
Example #3
0
def get_feedback_for_recordings_for_user(user_name):
    """
    Get feedback given by user ``user_name`` for the list of recordings supplied.

    A sample response may look like:

    .. code-block:: json

        {
            "feedback": [
                {
                    "created": 1604033691,
                    "rating": "bad_recommendation",
                    "recording_mbid": "9ffabbe4-e078-4906-80a7-3a02b537e251"
                },
                {
                    "created": 1604032934,
                    "rating": "hate",
                    "recording_mbid": "28111d2c-a80d-418f-8b77-6aba58abe3e7"
                }
            ],
            "user_name": "Vansika Pareek"
        }

    An empty response will be returned if the feedback for given recording MBID doesn't exist.

    :param mbids: comma separated list of recording_mbids for which feedback records are to be fetched.
    :type mbids: ``str``
    :statuscode 200: Yay, you have data!
    :statuscode 400: Bad request, check ``response['error']`` for more details.
    :statuscode 404: User not found.
    :resheader Content-Type: *application/json*
    """
    user = db_user.get_by_mb_id(user_name)
    if user is None:
        raise APINotFound("Cannot find user: %s" % user_name)

    mbids = request.args.get('mbids')
    if not mbids:
        raise APIBadRequest("Please provide comma separated recording 'mbids'!")

    recording_list = parse_recording_mbid_list(mbids)

    if not len(recording_list):
        raise APIBadRequest("Please provide comma separated recording mbids!")

    try:
        feedback = db_feedback.get_feedback_for_multiple_recordings_for_user(user_id=user["id"], recording_list=recording_list)
    except ValidationError as e:
        log_raise_400("Invalid JSON document submitted: %s" % str(e).replace("\n ", ":").replace("\n", " "),
                      request.args)

    recommendation_feedback = [_format_feedback(fb) for fb in feedback]

    return jsonify({
        "feedback": recommendation_feedback,
        "user_name": user_name
    })
Example #4
0
def create_user_notification_event(user_name):
    """ Post a message with a link on a user's timeline. Only approved users are allowed to perform this action.

    The request should contain the following data:

    .. code-block:: json

        {
            "metadata": {
                "message": <the message to post, required>,
            }
        }

    :param user_name: The MusicBrainz ID of the user on whose timeline the message is to be posted.
    :type user_name: ``str``
    :statuscode 200: Successful query, message has been posted!
    :statuscode 400: Bad request, check ``response['error']`` for more details.
    :statuscode 403: Forbidden, you are not an approved user.
    :statuscode 404: User not found
    :resheader Content-Type: *application/json*

    """
    creator = validate_auth_header()
    if creator["musicbrainz_id"] not in current_app.config[
            'APPROVED_PLAYLIST_BOTS']:
        raise APIForbidden(
            "Only approved users are allowed to post a message on a user's timeline."
        )

    user = db_user.get_by_mb_id(user_name)
    if user is None:
        raise APINotFound(f"Cannot find user: {user_name}")

    try:
        data = ujson.loads(request.get_data())['metadata']
    except (ValueError, KeyError) as e:
        raise APIBadRequest(f"Invalid JSON: {str(e)}")

    if "message" not in data:
        raise APIBadRequest("Invalid metadata: message is missing")

    message = _filter_description_html(data["message"])
    metadata = NotificationMetadata(creator=creator['musicbrainz_id'],
                                    message=message)

    try:
        db_user_timeline_event.create_user_notification_event(
            user['id'], metadata)
    except DatabaseException:
        raise APIInternalServerError("Something went wrong, please try again.")

    return jsonify({'status': 'ok'})
Example #5
0
def create_user_recording_recommendation_event(user_name):
    """ Make the user recommend a recording to their followers.

    The request should post the following data about the recording being recommended:

    .. code-block:: json

        {
            "metadata": {
                "artist_name": "<The name of the artist, required>",
                "track_name": "<The name of the track, required>",
                "artist_msid": "<The MessyBrainz ID of the artist, required>",
                "recording_msid": "<The MessyBrainz ID of the recording, required>",
                "release_name": "<The name of the release, optional>",
                "recording_mbid": "<The MusicBrainz ID of the recording, optional>"
            }
        }


    :param user_name: The MusicBrainz ID of the user who is recommending the recording.
    :type user_name: ``str``
    :statuscode 200: Successful query, recording has been recommended!
    :statuscode 400: Bad request, check ``response['error']`` for more details.
    :statuscode 401: Unauthorized, you do not have permissions to recommend recordings on the behalf of this user
    :statuscode 404: User not found
    :resheader Content-Type: *application/json*
    """
    user = validate_auth_header()
    if user_name != user['musicbrainz_id']:
        raise APIUnauthorized(
            "You don't have permissions to post to this user's timeline.")

    try:
        data = ujson.loads(request.get_data())
    except ValueError as e:
        raise APIBadRequest(f"Invalid JSON: {str(e)}")

    try:
        metadata = RecordingRecommendationMetadata(**data['metadata'])
    except pydantic.ValidationError as e:
        raise APIBadRequest(f"Invalid metadata: {str(e)}")

    try:
        event = db_user_timeline_event.create_user_track_recommendation_event(
            user['id'], metadata)
    except DatabaseException:
        raise APIInternalServerError("Something went wrong, please try again.")

    event_data = event.dict()
    event_data['created'] = event_data['created'].timestamp()
    event_data['event_type'] = event_data['event_type'].value
    return jsonify(event_data)
def report_abuse(user_name):
    data = request.json
    reason = None
    if data:
        reason = data.get("reason")
        if not isinstance(reason, str):
            raise APIBadRequest("Reason must be a string.")
    user_to_report = db_user.get_by_mb_id(user_name)
    if current_user.id != user_to_report["id"]:
        db_user.report_user(current_user.id, user_to_report["id"], reason)
        return jsonify(
            {"status": "%s has been reported successfully." % user_name})
    else:
        raise APIBadRequest("You cannot report yourself.")
def validate_token():
    """
    Check whether a User Token is a valid entry in the database.

    In order to query this endpoint, send a GET request with the token to check
    as the `token` argument (example: /validate-token?token=token-to-check)

    A JSON response will be returned, with one of two codes.

    :statuscode 200: The user token is valid/invalid.
    :statuscode 400: No token was sent to the endpoint.
    """
    auth_token = request.args.get('token', '')
    if not auth_token:
        raise APIBadRequest("You need to provide an Authorization token.")
    user = db_user.get_by_token(auth_token)
    if user is None:
        return jsonify({
            'code': 200,
            'message': 'Token invalid.',
            'valid': False,
        })
    else:
        return jsonify({
            'code': 200,
            'message': 'Token valid.',
            'valid': True,
            'user_name': user['musicbrainz_id'],
        })
def _is_valid_artist_type(artist_type):
    """ Check if artist type is valid.
    """
    if artist_type is None:
        raise APIBadRequest('Please provide artist type')

    return artist_type in RecommendationArtistType.__members__
Example #9
0
def follow_user(user_name: str):
    user = _get_user(user_name)

    if user.musicbrainz_id == current_user.musicbrainz_id:
        raise APIBadRequest("Whoops, cannot follow yourself.")

    if db_user_relationship.is_following_user(current_user.id, user.id):
        raise APIBadRequest(f"{current_user.musicbrainz_id} is already following user {user.musicbrainz_id}")

    try:
        db_user_relationship.insert(current_user.id, user.id, 'follow')
    except Exception:
        current_app.logger.critical("Error while trying to insert a relationship", exc_info=True)
        raise APIInternalServerError("Something went wrong, please try again later")

    return jsonify({"status": 200, "message": "Success!"})
Example #10
0
def get_recent_listens_for_user_list(user_list):
    """
    Fetch the most recent listens for a comma separated list of users. Take care to properly HTTP escape
    user names that contain commas!

    :statuscode 200: Fetched listens successfully.
    :statuscode 400: Your user list was incomplete or otherwise invalid.
    :resheader Content-Type: *application/json*
    """

    limit = _parse_int_arg("limit", 2)
    users = parse_user_list(user_list)
    if not len(users):
        raise APIBadRequest("user_list is empty or invalid.")

    db_conn = webserver.create_influx(current_app)
    listens = db_conn.fetch_recent_listens_for_users(users, limit=limit)
    listen_data = []
    for listen in listens:
        listen_data.append(listen.to_api())

    return jsonify({
        'payload': {
            'user_list': user_list,
            'count': len(listen_data),
            'listens': listen_data,
        }
    })
def _get_sitewide_stats(entity: str):
    stats_range = request.args.get("range", default="all_time")
    if not _is_valid_range(stats_range):
        raise APIBadRequest(f"Invalid range: {stats_range}")

    offset = get_non_negative_param("offset", default=0)
    count = get_non_negative_param("count", default=DEFAULT_ITEMS_PER_GET)

    stats = db_stats.get_sitewide_stats(stats_range, entity)
    if stats is None:
        raise APINoContent("")

    entity_list, total_entity_count = _process_user_entity(
        stats, offset, count)
    return jsonify({
        "payload": {
            entity: entity_list,
            "range": stats_range,
            "offset": offset,
            "count": total_entity_count,
            "from_ts": stats.from_ts,
            "to_ts": stats.to_ts,
            "last_updated": int(stats.last_updated.timestamp())
        }
    })
Example #12
0
def latest_import():
    """
    Get and update the timestamp of the newest listen submitted in previous imports to ListenBrainz.

    In order to get the timestamp for a user, make a GET request to this endpoint. The data returned will
    be JSON of the following format:

    .. code-block:: json

        {
            "musicbrainz_id": "the MusicBrainz ID of the user",
            "latest_import": "the timestamp of the newest listen submitted in previous imports. Defaults to 0"
        }

    :param user_name: the MusicBrainz ID of the user whose data is needed
    :statuscode 200: Yay, you have data!
    :resheader Content-Type: *application/json*

    In order to update the timestamp of a user, you'll have to provide a user token in the Authorization
    Header. User tokens can be found on https://listenbrainz.org/profile/ .

    The JSON that needs to be posted must contain a field named `ts` in the root with a valid unix timestamp.

    :reqheader Authorization: Token <user token>
    :reqheader Content-Type: *application/json*
    :statuscode 200: latest import timestamp updated
    :statuscode 400: invalid JSON sent, see error message for details.
    :statuscode 401: invalid authorization. See error message for details.
    """
    if request.method == 'GET':
        user_name = request.args.get('user_name', '')
        user = db_user.get_by_mb_id(user_name)
        if user is None:
            raise APINotFound(
                "Cannot find user: {user_name}".format(user_name=user_name))
        return jsonify({
            'musicbrainz_id':
            user['musicbrainz_id'],
            'latest_import':
            0 if not user['latest_import'] else int(
                user['latest_import'].strftime('%s'))
        })
    elif request.method == 'POST':
        user = validate_auth_header()

        try:
            ts = ujson.loads(request.get_data()).get('ts', 0)
        except ValueError:
            raise APIBadRequest('Invalid data sent')

        try:
            db_user.increase_latest_import(user['musicbrainz_id'], int(ts))
        except DatabaseException as e:
            current_app.logger.error(
                "Error while updating latest import: {}".format(e))
            raise APIInternalServerError(
                'Could not update latest_import, try again')

        return jsonify({'status': 'ok'})
Example #13
0
def _get_non_negative_param(param, default=None):
    """ Gets the value of a request parameter, validating that it is non-negative

    Args:
        param (str): the parameter to get
        default: the value to return if the parameter doesn't exist in the request
    """
    value = request.args.get(param, default)
    if value is not None:
        try:
            value = int(value)
        except ValueError:
            raise APIBadRequest("'{}' should be a non-negative integer".format(param))

        if value < 0:
            raise APIBadRequest("'{}' should be a non-negative integer".format(param))
    return value
Example #14
0
def _parse_int_arg(name, default=None):
    value = request.args.get(name)
    if value:
        try:
            return int(value)
        except ValueError:
            raise APIBadRequest("Invalid %s argument: %s" % (name, value))
    else:
        return default
def _validate_stats_user_params(user_name) -> Tuple[Dict, str]:
    """ Validate and return the user and common stats params """
    user = db_user.get_by_mb_id(user_name)
    if user is None:
        raise APINotFound(f"Cannot find user: {user_name}")

    stats_range = request.args.get("range", default="all_time")
    if not _is_valid_range(stats_range):
        raise APIBadRequest(f"Invalid range: {stats_range}")
    return user, stats_range
Example #16
0
def _parse_boolean_arg(name, default=None):
    value = request.args.get(name)
    if not value:
        return default

    value = value.lower()
    if value not in ["true", "false"]:
        raise APIBadRequest("Invalid %s argument: %s. Must be 'true' or 'false'" % (name, value))

    return True if value == "true" else False
Example #17
0
def parse_boolean_arg(name, default=None):
    from listenbrainz.webserver.errors import APIBadRequest
    value = request.args.get(name)
    if not value:
        return default

    value = value.lower()
    if value not in ["true", "false"]:
        raise APIBadRequest("Invalid %s argument: %s. Must be 'true' or 'false'" % (name, value))

    return True if value == "true" else False
Example #18
0
def log_raise_400(msg, data=""):
    """ Helper function for logging issues with request data and showing error page.
        Logs the message and data, raises BadRequest exception which shows 400 Bad
        Request to the user.
    """

    if isinstance(data, dict):
        data = ujson.dumps(data)

    current_app.logger.debug("BadRequest: %s\nJSON: %s" % (msg, data))
    raise APIBadRequest(msg)
Example #19
0
def parse_recording_mbid_list(mbids):
    """ Check if the passed mbids are valid UUIDs
    """
    mbid_list = []
    for mbid in mbids.split(","):
        try:
            uuid.UUID(mbid)
        except (AttributeError, ValueError):
            raise APIBadRequest("'{}' is not a valid MBID".format(mbid))
        mbid = mbid.strip()
        if not mbid:
            continue
        mbid_list.append(mbid)

    return mbid_list
Example #20
0
def insert_payload(payload, user, listen_type=LISTEN_TYPE_IMPORT):
    """ Convert the payload into augmented listens then submit them.
        Returns: augmented_listens
    """
    try:
        augmented_listens = _get_augmented_listens(payload, user, listen_type)
        _send_listens_to_queue(listen_type, augmented_listens)
    except (APIInternalServerError, APIServiceUnavailable) as e:
        raise
    except DataError:
        raise APIBadRequest("Listen submission contains invalid characters.")
    except Exception as e:
        current_app.logger.error("Error while inserting payload: %s", str(e), exc_info=True)
        raise APIInternalServerError("Something went wrong. Please try again.")
    return augmented_listens
Example #21
0
def _get_user_entity_list(
    stats: Union[UserArtistStat, UserReleaseStat, UserRecordingStat],
    stats_range: StatisticsRange,
    entity: str,
    offset: int,
    count: int,
) -> List[Union[UserArtistRecord, UserReleaseRecord, UserRecordingRecord]]:
    """ Gets a list of entity records from the stat passed based on the offset and count
    """
    if entity == 'artist':
        return getattr(stats, stats_range).artists[offset:count]
    elif entity == 'release':
        return getattr(stats, stats_range).releases[offset:count]
    elif entity == 'recording':
        return getattr(stats, stats_range).recordings[offset:count]
    raise APIBadRequest("Unknown entity: %s" % entity)
def huesound(color):
    """
    Fetch a list of releases that have cover art that has a predominant
    color that is close to the given color.

    .. code-block:: json

        {
            "payload": {
                "releases" : [
                    {
                      "artist_name": "Letherette",
                      "color": [ 250, 90, 192 ],
                      "dist": 109.973,
                      "release_mbid": "00a109da-400c-4350-9751-6e6f25e89073",
                      "caa_id": 34897349734,
                      "release_name": "EP5",
                      "recordings": "< array of listen formatted metadata >",
                      },
                    ". . ."
                ]
            }
        }

    :statuscode 200: success
    :resheader Content-Type: *application/json*
    """

    try:
        if len(color) != 6:
            raise ValueError()

        color_tuple = tuple(int(color[i:i + 2], 16) for i in (0, 2, 4))
    except ValueError:
        raise APIBadRequest("color must be a 6 digit hex color code.")

    count = _parse_int_arg("count", DEFAULT_NUMBER_OF_RELEASES)

    cache_key = HUESOUND_PAGE_CACHE_KEY % (color, count)
    results = cache.get(cache_key, decode=True)
    if not results:
        results = get_releases_for_color(*color_tuple, count)
        results = [c.to_api() for c in results]
        cache.set(cache_key, results, DEFAULT_CACHE_EXPIRE_TIME, encode=True)

    return jsonify({"payload": {"releases": results}})
Example #23
0
def validate_token():
    """
    Check whether a User Token is a valid entry in the database.

    In order to query this endpoint, send a GET request.
    A JSON response will be returned, with one of three codes.

    :statuscode 200: The user token is valid/invalid.
    :statuscode 400: No token was sent to the endpoint.
    """
    auth_token = request.args.get('token', '')
    if not auth_token:
        raise APIBadRequest("You need to provide an Authorization token.")
    user = db_user.get_by_token(auth_token)
    if user is None:
        return jsonify({'code': 200, 'message': 'Token invalid.'})
    else:
        return jsonify({'code': 200, 'message': 'Token valid.'})
Example #24
0
def get_dump_info():
    """
    Get information about ListenBrainz data dumps.
    You need to pass the `id` parameter in a GET request to get data about that particular
    dump.

    **Example response**:

    .. code-block:: json

        {
            "id": 1,
            "timestamp": "20190625-165900"
        }

    :query id: Integer specifying the ID of the dump, if not provided, the endpoint returns information about the latest data dump.
    :statuscode 200: You have data.
    :statuscode 400: You did not provide a valid dump ID. See error message for details.
    :statuscode 404: Dump with given ID does not exist.
    :resheader Content-Type: *application/json*
    """

    dump_id = request.args.get("id")
    if dump_id is None:
        try:
            dump = db_dump.get_dump_entries()[0]  # return the latest dump
        except IndexError:
            raise APINotFound("No dump entry exists.")
    else:
        try:
            dump_id = int(dump_id)
        except ValueError:
            raise APIBadRequest("The `id` parameter needs to be an integer.")
        dump = db_dump.get_dump_entry(dump_id)
        if dump is None:
            raise APINotFound("No dump exists with ID: %d" % dump_id)

    return jsonify({
        "id":
        dump["id"],
        "timestamp":
        _convert_timestamp_to_string_dump_format(dump["created"]),
    })
Example #25
0
def get_listens(user_name):
    """
    Get listens for user ``user_name``. The format for the JSON returned is defined in our :ref:`json-doc`.

    If none of the optional arguments are given, this endpoint will return the :data:`~webserver.views.api.DEFAULT_ITEMS_PER_GET` most recent listens.
    The optional ``max_ts`` and ``min_ts`` UNIX epoch timestamps control at which point in time to start returning listens. You may specify max_ts or
    min_ts, but not both in one call. Listens are always returned in descending timestamp order.

    :param max_ts: If you specify a ``max_ts`` timestamp, listens with listened_at less than (but not including) this value will be returned.
    :param min_ts: If you specify a ``min_ts`` timestamp, listens with listened_at greater than (but not including) this value will be returned.
    :param count: Optional, number of listens to return. Default: :data:`~webserver.views.api.DEFAULT_ITEMS_PER_GET` . Max: :data:`~webserver.views.api.MAX_ITEMS_PER_GET`
    :statuscode 200: Yay, you have data!
    :statuscode 404: The requested user was not found.
    :resheader Content-Type: *application/json*
    """
    db_conn = webserver.create_timescale(current_app)

    user = db_user.get_by_mb_id(user_name)
    if user is None:
        raise APINotFound("Cannot find user: %s" % user_name)

    min_ts, max_ts, count = _validate_get_endpoint_params()
    if min_ts and max_ts and min_ts >= max_ts:
        raise APIBadRequest("min_ts should be less than max_ts")

    listens, _, max_ts_per_user = db_conn.fetch_listens(user_name,
                                                        limit=count,
                                                        from_ts=min_ts,
                                                        to_ts=max_ts)
    listen_data = []
    for listen in listens:
        listen_data.append(listen.to_api())

    return jsonify({
        'payload': {
            'user_id': user_name,
            'count': len(listen_data),
            'listens': listen_data,
            'latest_listen_ts': max_ts_per_user,
        }
    })
def get_feedback_for_recordings_for_user(user_name):
    """
    Get feedback given by user ``user_name`` for the list of recordings supplied. The format for the JSON returned
    is defined in our :ref:`feedback-json-doc`.

    If the feedback for given recording MSID doesn't exist then a score 0 is returned for that recording.

    :param recordings: comma separated list of recording_msids for which feedback records are to be fetched.
    :type score: ``str``
    :statuscode 200: Yay, you have data!
    :resheader Content-Type: *application/json*
    """

    recordings = request.args.get('recordings')

    if not recordings:
        log_raise_400("'recordings' has no valid recording MSID.")

    recording_list = parse_param_list(recordings)
    if not len(recording_list):
        raise APIBadRequest("'recordings' has no valid recording MSID.")

    user = db_user.get_by_mb_id(user_name)
    if user is None:
        raise APINotFound("Cannot find user: %s" % user_name)

    try:
        feedback = db_feedback.get_feedback_for_multiple_recordings_for_user(
            user_id=user["id"], recording_list=recording_list)
    except ValidationError as e:
        # Validation errors from the Pydantic model are multi-line. While passing it as a response the new lines
        # are displayed as \n. str.replace() to tidy up the error message so that it becomes a good one line error message.
        log_raise_400(
            "Invalid JSON document submitted: %s" %
            str(e).replace("\n ", ":").replace("\n", " "), request.args)

    feedback = [_feedback_to_api(fb) for fb in feedback]

    return jsonify({
        "feedback": feedback,
    })
Example #27
0
def get_sitewide_artist():
    """
    Get sitewide top artists.


    A sample response from the endpoint may look like::

        {
            "payload": {
                "time_ranges": [
                    {
                        "time_range": "April 2020",
                        "artists": [
                            {
                                "artist_mbids": ["f4fdbb4c-e4b7-47a0-b83b-d91bbfcfa387"],
                                "artist_msid": "b4ae3356-b8a7-471a-a23a-e471a69ad454",
                                "artist_name": "Ariana Grande",
                                "listen_count": 519
                            },
                            {
                                "artist_mbids": ["f4abc0b5-3f7a-4eff-8f78-ac078dbce533"],
                                "artist_msid": "f9ee09fb-5ab4-46a2-9088-3eac0eed4920",
                                "artist_name": "Billie Eilish",
                                "listen_count": 447
                            }
                        ],
                        "from_ts": 1585699200,
                        "to_ts": 1588291199,
                    },
                    {
                        "time_range": "May 2020",
                        "artists": [
                            {
                                "artist_mbids": [],
                                "artist_msid": "2b0646af-f3f0-4a5b-b629-6c31301c1c29",
                                "artist_name": "The Weeknd",
                                "listen_count": 621
                            },
                            {
                                "artist_mbids": [],
                                "artist_msid": "9720fd77-fe48-41ba-a7a2-b4795718dd97",
                                "artist_name": "Drake",
                                "listen_count": 554
                            }
                        ],
                        "from_ts": 1588291200,
                        "to_ts": 1590969599
                    }
                ],
                "offset": 0,
                "count": 2,
                "range": "year",
                "last_updated": 1588494361,
                "from_ts": 1009823400,
                "to_ts": 1590029157
            }
        }

    .. note::
        - This endpoint is currently in beta
        - ``artist_mbids`` and ``artist_msid`` are optional fields and may not be present in all the entries
        - The example above shows the data for two days only, however we calculate the statistics for
          the current time range and the previous time range. For example for yearly statistics the data
          is calculated for the months in current as well as the past year.
        - We only calculate the top 1000 artists for each time period.

    :param count: Optional, number of artists to return for each time range,
        Default: :data:`~webserver.views.api.DEFAULT_ITEMS_PER_GET`
        Max: :data:`~webserver.views.api.MAX_ITEMS_PER_GET`
    :type count: ``int``
    :param offset: Optional, number of artists to skip from the beginning, for pagination.
        Ex. An offset of 5 means the top 5 artists will be skipped, defaults to 0
    :type offset: ``int``
    :param range: Optional, time interval for which statistics should be collected, possible values are ``week``,
        ``month``, ``year``, ``all_time``, defaults to ``all_time``
    :type range: ``str``
    :statuscode 200: Successful query, you have data!
    :statuscode 204: Statistics haven't been calculated, empty response will be returned
    :statuscode 400: Bad request, check ``response['error']`` for more details
    :resheader Content-Type: *application/json*
    """
    stats_range = request.args.get('range', default='all_time')
    if not _is_valid_range(stats_range):
        raise APIBadRequest("Invalid range: {}".format(stats_range))

    offset = get_non_negative_param('offset', default=0)
    count = get_non_negative_param('count', default=DEFAULT_ITEMS_PER_GET)

    stats = db_stats.get_sitewide_artists(stats_range)
    if stats is None or stats.data is None:
        raise APINoContent('')

    entity_data = _get_sitewide_entity_list(stats.data,
                                            entity="artists",
                                            offset=offset,
                                            count=count)
    return jsonify({
        'payload': {
            "time_ranges": entity_data,
            "range": stats_range,
            "offset": offset,
            "count": min(count, MAX_ITEMS_PER_GET),
            "from_ts": stats.data.from_ts,
            "to_ts": stats.data.to_ts,
            "last_updated": int(stats.last_updated.timestamp())
        }
    })
Example #28
0
def get_artist_map(user_name: str):
    """
    Get the artist map for user ``user_name``. The artist map shows the number of artists the user has listened to
    from different countries of the world.

    A sample response from the endpoint may look like::

        {
            "payload": {
                "from_ts": 1587945600,
                "last_updated": 1592807084,
                "artist_map": [
                    {
                        "country": "USA",
                        "artist_count": 34
                    },
                    {
                        "country": "GBR",
                        "artist_count": 69
                    },
                    {
                        "country": "IND",
                        "artist_count": 32
                    }
                ],
                "stats_range": "all_time"
                "to_ts": 1589155200,
                "user_id": "ishaanshah"
            }
        }
    .. note::
        - This endpoint is currently in beta
        - We cache the results for this query for a week to improve page load times, if you want to request fresh data you
          can use the ``force_recalculate`` flag.

    :param range: Optional, time interval for which statistics should be returned, possible values are ``week``,
        ``month``, ``year``, ``all_time``, defaults to ``all_time``
    :type range: ``str``
    :param force_recalculate: Optional, recalculate the data instead of returning the cached result.
    :type range: ``bool``
    :statuscode 200: Successful query, you have data!
    :statuscode 204: Statistics for the user haven't been calculated, empty response will be returned
    :statuscode 400: Bad request, check ``response['error']`` for more details
    :statuscode 404: User not found
    :resheader Content-Type: *application/json*

    """
    user = db_user.get_by_mb_id(user_name)
    if user is None:
        raise APINotFound("Cannot find user: {}".format(user_name))

    stats_range = request.args.get('range', default='all_time')
    if not _is_valid_range(stats_range):
        raise APIBadRequest("Invalid range: {}".format(stats_range))

    recalculate_param = request.args.get('force_recalculate', default='false')
    if recalculate_param.lower() not in ['true', 'false']:
        raise APIBadRequest(
            "Invalid value of force_recalculate: {}".format(recalculate_param))
    force_recalculate = recalculate_param.lower() == 'true'

    # Check if stats are present in DB, if not calculate them
    calculated = not force_recalculate
    stats = db_stats.get_user_artist_map(user['id'], stats_range)
    if stats is None or getattr(stats, stats_range) is None:
        calculated = False

    # Check if the stats present in DB have been calculated in the past week, if not recalculate them
    stale = False
    if calculated:
        last_updated = getattr(stats, stats_range).last_updated
        if (datetime.now() - datetime.fromtimestamp(last_updated)
            ).days >= STATS_CALCULATION_INTERVAL:
            stale = True

    if stale or not calculated:
        artist_stats = db_stats.get_user_artists(user['id'], stats_range)

        # If top artists are missing, return the stale stats if present, otherwise return 204
        if artist_stats is None or getattr(artist_stats, stats_range) is None:
            if stale:
                result = stats
            else:
                raise APINoContent('')
        else:
            # Calculate the data
            artist_msids = defaultdict(lambda: 0)
            artist_mbids = defaultdict(lambda: 0)
            top_artists = getattr(artist_stats, stats_range).artists
            for artist in top_artists:
                if artist.artist_msid is not None:
                    artist_msids[artist.artist_msid] += artist.listen_count
                else:
                    for artist_mbid in artist.artist_mbids:
                        artist_mbids[artist_mbid] += artist.listen_count

            country_code_data = _get_country_codes(artist_msids, artist_mbids)
            result = UserArtistMapStatJson(
                **{
                    stats_range: {
                        "artist_map": country_code_data,
                        "from_ts": int(
                            getattr(artist_stats, stats_range).from_ts),
                        "to_ts": int(getattr(artist_stats, stats_range).to_ts),
                        "last_updated": int(datetime.now().timestamp())
                    }
                })

            # Store in DB for future use
            try:
                db_stats.insert_user_artist_map(user['id'], result)
            except Exception as err:
                current_app.logger.error(
                    "Error while inserting artist map stats for {user}. Error: {err}. Data: {data}"
                    .format(user=user_name, err=err, data=result),
                    exc_info=True)
    else:
        result = stats

    return jsonify({
        "payload": {
            "user_id": user_name,
            "range": stats_range,
            **(getattr(result, stats_range).dict())
        }
    })
Example #29
0
def get_user_artist(user_name):
    """
    Get top artists for user ``user_name``.


    A sample response from the endpoint may look like::

        {
            "payload": {
                "artists": [
                    {
                       "artist_mbids": ["93e6118e-7fa8-49f6-9e02-699a1ebce105"],
                       "artist_msid": "d340853d-7408-4a0d-89c2-6ff13e568815",
                       "artist_name": "The Local train",
                       "listen_count": 385
                    },
                    {
                       "artist_mbids": ["ae9ed5e2-4caf-4b3d-9cb3-2ad626b91714"],
                       "artist_msid": "ba64b195-01dd-4613-9534-bb87dc44cffb",
                       "artist_name": "Lenka",
                       "listen_count": 333
                    },
                    {
                       "artist_mbids": ["cc197bad-dc9c-440d-a5b5-d52ba2e14234"],
                       "artist_msid": "6599e41e-390c-4855-a2ac-68ee798538b4",
                       "artist_name": "Coldplay",
                       "listen_count": 321
                    }
                ],
                "count": 3,
                "total_artist_count": 175,
                "range": "all_time",
                "last_updated": 1588494361,
                "user_id": "John Doe",
                "from_ts": 1009823400,
                "to_ts": 1590029157
            }
        }

    .. note::
        - This endpoint is currently in beta
        - ``artist_mbids`` and ``artist_msid`` are optional fields and may not be present in all the responses

    :param count: Optional, number of artists to return, Default: :data:`~webserver.views.api.DEFAULT_ITEMS_PER_GET`
        Max: :data:`~webserver.views.api.MAX_ITEMS_PER_GET`
    :type count: ``int``
    :param offset: Optional, number of artists to skip from the beginning, for pagination.
        Ex. An offset of 5 means the top 5 artists will be skipped, defaults to 0
    :type offset: ``int``
    :param range: Optional, time interval for which statistics should be collected, possible values are ``week``,
        ``month``, ``year``, ``all_time``, defaults to ``all_time``
    :type range: ``str``
    :statuscode 200: Successful query, you have data!
    :statuscode 204: Statistics for the user haven't been calculated, empty response will be returned
    :statuscode 400: Bad request, check ``response['error']`` for more details
    :statuscode 404: User not found
    :resheader Content-Type: *application/json*
    """
    user = db_user.get_by_mb_id(user_name)
    if user is None:
        raise APINotFound("Cannot find user: %s" % user_name)

    stats_range = request.args.get('range', default='all_time')
    if not _is_valid_range(stats_range):
        raise APIBadRequest("Invalid range: {}".format(stats_range))

    offset = get_non_negative_param('offset', default=0)
    count = get_non_negative_param('count', default=DEFAULT_ITEMS_PER_GET)

    stats = db_stats.get_user_artists(user['id'], stats_range)
    if stats is None or getattr(stats, stats_range) is None:
        raise APINoContent('')

    entity_list, total_entity_count = _process_user_entity(stats,
                                                           stats_range,
                                                           offset,
                                                           count,
                                                           entity='artist')
    from_ts = int(getattr(stats, stats_range).from_ts)
    to_ts = int(getattr(stats, stats_range).to_ts)
    last_updated = int(stats.last_updated.timestamp())

    return jsonify({
        'payload': {
            "user_id": user_name,
            "artists": entity_list,
            "count": len(entity_list),
            "total_artist_count": total_entity_count,
            "offset": offset,
            "range": stats_range,
            "from_ts": from_ts,
            "to_ts": to_ts,
            "last_updated": last_updated,
        }
    })
Example #30
0
def get_daily_activity(user_name: str):
    """
    Get the daily activity for user ``user_name``. The daily activity shows the number of listens
    submitted by the user for each hour of the day over a period of time. We assume that all listens are in UTC.

    A sample response from the endpoint may look like::

        {
            "payload": {
                "from_ts": 1587945600,
                "last_updated": 1592807084,
                "daily_activity": {
                    "Monday": [
                        {
                            "hour": 0
                            "listen_count": 26,
                        },
                        {
                            "hour": 1
                            "listen_count": 30,
                        },
                        {
                            "hour": 2
                            "listen_count": 4,
                        }...
                    ],
                    "Tuesday": [...],
                    ...
                },
                "stats_range": "all_time",
                "to_ts": 1589155200,
                "user_id": "ishaanshah"
            }
        }
    .. note::
        - This endpoint is currently in beta

    :param range: Optional, time interval for which statistics should be returned, possible values are ``week``,
        ``month``, ``year``, ``all_time``, defaults to ``all_time``
    :type range: ``str``
    :statuscode 200: Successful query, you have data!
    :statuscode 204: Statistics for the user haven't been calculated, empty response will be returned
    :statuscode 400: Bad request, check ``response['error']`` for more details
    :statuscode 404: User not found
    :resheader Content-Type: *application/json*

    """
    user = db_user.get_by_mb_id(user_name)
    if user is None:
        raise APINotFound("Cannot find user: {}".format(user_name))

    stats_range = request.args.get('range', default='all_time')
    if not _is_valid_range(stats_range):
        raise APIBadRequest("Invalid range: {}".format(stats_range))

    stats = db_stats.get_user_daily_activity(user['id'], stats_range)
    if stats is None or getattr(stats, stats_range) is None:
        raise APINoContent('')

    daily_activity_unprocessed = [
        x.dict() for x in getattr(stats, stats_range).daily_activity
    ]
    daily_activity = {
        calendar.day_name[day]: [{
            "hour": hour,
            "listen_count": 0
        } for hour in range(0, 24)]
        for day in range(0, 7)
    }

    for day, day_data in daily_activity.items():
        for hour_data in day_data:
            hour = hour_data["hour"]

            for entry in daily_activity_unprocessed:
                if entry["hour"] == hour and entry["day"] == day:
                    hour_data["listen_count"] = entry["listen_count"]
                    break
            else:
                hour_data["listen_count"] = 0

    return jsonify({
        "payload": {
            "user_id": user_name,
            "daily_activity": daily_activity,
            "from_ts": int(getattr(stats, stats_range).from_ts),
            "to_ts": int(getattr(stats, stats_range).to_ts),
            "range": stats_range,
            "last_updated": int(stats.last_updated.timestamp())
        }
    })